From 5b8476ede54da869dc63bd75725b3b52ca6133dc Mon Sep 17 00:00:00 2001 From: Gab Date: Tue, 11 Aug 2026 15:07:56 -0300 Subject: [PATCH 1/2] =?UTF-8?q?docs(webkit):=20ship=20the=20webkit-lists?= =?UTF-8?q?=20skill=20=E2=80=94=20the=20index=20page=20and=20its=20filter?= =?UTF-8?q?=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every list page in a product is the same page, and each one gets rebuilt by whoever needed it: a row of Selects here, a badged funnel there, an operator builder whose operator nobody changes. The skill fixes the page skeleton (one band holding the controls, the filters and the rows they narrow), the split between search and filters, and the filter model — a field catalog plus a flat { fieldId: values[] } state where fields intersect and the values inside a field union. Routed from webkit-ui-craft between webkit-tables (the Table) and webkit-navigation (the shell). --- .../cli-templates/claude/CLAUDE.fragment.md | 2 +- .../claude/skills/webkit-lists/SKILL.md | 386 ++++++++++++++++++ .../claude/skills/webkit-ui-craft/SKILL.md | 8 +- packages/webkit/src/cli/plan.js | 1 + packages/webkit/test/cli/plan.test.mjs | 4 +- 5 files changed, 396 insertions(+), 5 deletions(-) create mode 100644 packages/webkit/cli-templates/claude/skills/webkit-lists/SKILL.md diff --git a/packages/webkit/cli-templates/claude/CLAUDE.fragment.md b/packages/webkit/cli-templates/claude/CLAUDE.fragment.md index c05e79732..02250bd30 100644 --- a/packages/webkit/cli-templates/claude/CLAUDE.fragment.md +++ b/packages/webkit/cli-templates/claude/CLAUDE.fragment.md @@ -50,7 +50,7 @@ runs axe + screenshots), and taste is a review concern. Start with `/webkit-ui-craft` (the umbrella that routes the rest). Get structure and states right before any polish: -- **Structure:** `/webkit-ux-heuristics` (right component per moment) · `/webkit-ui-states` (state surface + async behavior) · `/webkit-form` · `/webkit-tables` (data-driven `` + cell recipes) · `/webkit-navigation` +- **Structure:** `/webkit-ux-heuristics` (right component per moment) · `/webkit-ui-states` (state surface + async behavior) · `/webkit-form` · `/webkit-tables` (data-driven `
` + cell recipes) · `/webkit-lists` (the index page around it + the filter system) · `/webkit-navigation` - **Foundation:** `/webkit-baseline-ui` (tokens, hierarchy, containers, responsive widths) - **Cross-cutting quality:** `/webkit-theming-dark-mode` · `/webkit-data-viz` - **Polish:** `/webkit-motion-polish` · `/webkit-impeccable-polish` (finish + earned delight) diff --git a/packages/webkit/cli-templates/claude/skills/webkit-lists/SKILL.md b/packages/webkit/cli-templates/claude/skills/webkit-lists/SKILL.md new file mode 100644 index 000000000..ff47812d6 --- /dev/null +++ b/packages/webkit/cli-templates/claude/skills/webkit-lists/SKILL.md @@ -0,0 +1,386 @@ +--- +name: webkit-lists +description: The list page — the one shape every resource index takes on @aziontech/webkit, and the filter system that narrows it. Fixes the page skeleton (shell → controls row → filter bar → card-wrapped Table), the split between search and filters (search runs through the table's own global filter; filters pre-narrow the data outside it), and the filter model itself — a field catalog the page declares plus a flat `{ fieldId: values[] }` applied state where fields intersect and the values inside one field union. Ships the chip-bar composition (one Popover walking fields then values, one chip per field, dimmed until it narrows something) so filtering looks and behaves the same on every index in the product, instead of one Select per column on one page and an operator builder on the next. Use when building or reviewing any resource index, list, or "all X" page. The page-level companion to /webkit-tables (which owns the Table and its cell recipes). +status: active +last_updated: 2026-08-11 +scope: general +enforced_by: [webkit-prefer-over-custom, webkit-component-states, webkit-styling, ui-verify, review] +--- + +# Skill: webkit-lists + +## Purpose + +A product's list pages are the screens people spend the most time on and the ones that drift the fastest. +Each one is built by whoever needed it, so one index puts a row of Selects above the table, the next hides +the same job behind a badged funnel button, a third uses an operator builder with a `contains` dropdown +nobody changes from the default — and none of them agree on where the search field goes or how much air +sits between the controls and the rows they narrow. + +They are all the same page. This skill fixes that page: **one skeleton**, **one filter model**, and **one +rule for which columns become filters**. `/webkit-tables` owns what happens inside the `Table` — the +column model, the toolbar, the cell recipes. This skill owns everything around it: the page's structure, +the controls row, the filter bar, and the state that connects them. + +Find the components named here through the `webkit` MCP (`suggest_component` / `get_component`) or +`node_modules/@aziontech/webkit/catalog.json`. + +## How to use + +- `/webkit-lists` — build any index page in this conversation to the shape below. +- `/webkit-lists ` — review that page against the patterns; per gap report the exact line/element + (quoted), which pattern it breaks (one sentence), and the concrete fix. + +Related: `/webkit-tables` (the Table, columns, cells) · `/webkit-navigation` (the app shell the page sits +in) · `/webkit-ui-states` (the empty and loading states a filtered list must render) · +`/webkit-microcopy` (field labels, the empty-state sentence). + +## When to invoke + +- Building any "all X" / resource index / management list page. +- Adding filtering to an existing table, or being asked "how do I filter this". +- Reviewing a page whose controls row has grown a third `Select`. +- The user says the filters look different from the other pages, the table jumps to an empty page after + filtering, or the search and the filters fight each other. + +--- + +## 1. The list page, one shape + +Three nested bands, and nothing else: + +```vue + +``` + +**A first-level module index has no page heading.** The module name is already the breadcrumb crumb and +the highlighted nav row; repeating it as an `

` says the same word three times in 200px of screen. The +controls row opens the page. (A _second_-level list — a tab inside a resource — does get a heading, +because there the crumb names the resource, not the list.) + +**The controls, the filter bar and the table are ONE band**, joined by the group step +(`--layout-group-gap`), not the section step. They are not three things stacked on a page; they are one +thing — a set of rows and the two controls that narrow it. Pushing the search a full section step away +from the table it filters is the single most common version of this page done wrong. + +**The card is unpadded and the table is unbordered** (`:padded="false"` + `:border="false"`), so the card +frames the table edge to edge. A padded card inside a bordered table gives you two nested rectangles with +a stripe of dead space between them. + +**Empty and loading are the table's, not the page's.** Pass `:loading` and let the `Table` render its +skeleton; render the empty state through `EmptyState` — see `/webkit-ui-states`. A filtered-to-nothing +list needs a _different_ sentence from a genuinely empty account ("No applications match these filters" +vs "Create your first application"), and only the page knows which it is. + +--- + +## 2. Search and filters narrow different things + +They are not two flavours of the same control, and they do not live in the same place. + +| | Search | Filters | +| ------- | ---------------------------------------------------- | ------------------------------------------------- | +| Answers | "where is the one I already know the name of" | "show me the subset that shares a property" | +| Runs | inside the table, `v-model:globalFilter` | outside it, over `:data` before the table sees it | +| Covers | the free-text columns (name, id, repository, domain) | the enumerable ones (status, type, author, date) | +| Lives | in the controls row, growing to fill it | in its own row below | + +```js +const visibleRows = computed(() => applyFilters(allRows.value, filterFields, filters.value)) +// → :data="visibleRows" v-model:globalFilter="search" +``` + +**Filters narrow `:data` from outside the table** for two reasons. First, a field is often not a column at +all — "Author" is a tooltip on the Last Modified avatar, not its own column, and a filter on it could +never come from the table's own column state. Second, the table's applied-filter band only exists once a +filter exists, so the affordance that _starts_ filtering has nowhere to live inside the table. + +**The filter bar is its own row, and it wraps.** It grows every time a filter is applied — a chip gains a +value half, and on a narrow viewport the row becomes two rows. Sitting it inside the controls row makes +the search field jump width as filters come and go. It never scrolls horizontally: a filter you cannot +see is a filter you will not remove. + +--- + +## 3. The field catalog — the columns decide the fields + +The page declares what it can be narrowed by. Only the page knows which of its columns are enumerable and +how a row answers for each one, so the catalog is page data, not a component prop schema: + +```js +const filterFields = [ + { + id: 'author', // key in the applied state + label: 'Author', // chip prefix and menu row + kind: 'options', // many values, or exactly one + options: authorOptions, // [{ value, label, avatar?, icon? }] + match: (row, values) => values.includes(row.author) + }, + { + id: 'status', + label: 'Status', + kind: 'options', + options: [ + { value: 'Active', label: 'Active' }, + { value: 'Inactive', label: 'Inactive' } + ], + match: (row, values) => values.includes(row.status) + }, + { + id: 'modified', + label: 'Last Modified', + kind: 'range', // one window at a time + options: DATE_PRESETS, // 24h · 7d · 30d · 3m · Custom… + formatValue: formatDateRange, // for a value not in `options` + match: (row, values) => matchDate(row.modifiedAt, values) + } +] +``` + +**Which columns become fields:** + +- **Enumerable column → one field.** Status, type, environment, runtime, author, team. If you can list its + values, it is a field. +- **Date column → relative periods, plus `Custom…`.** People arrive asking "what changed this week", not + "what changed between the 3rd and the 9th". Ship the periods as the values and put the month grid behind + a `Custom…` row, so the common ask is one click and the rare one costs the extra step it is worth. The + periods are open-ended (`end: null`) — each means _since_, and pinning the end to `now` silently drops a + row that lands while the panel is open. +- **Free-text column → nothing.** Name, id, repository, domain are covered by the search field. One field + per text column gives you six chips nobody opens. +- **A column with two values that are never both interesting → nothing.** A field that is always set to + the same value is a control with one option. + +**`kind` says how many values a field holds, not how it looks.** `options` accumulates — three authors is +still one filter on Author. `range` replaces — two date windows at once contradict each other, so picking +a second swaps it and picking the same one again clears it. The bar reads `kind` to decide whether picking +a value keeps the panel open or returns to the field list. + +**Order the options the way the domain does, not alphabetically,** when the domain has an order: +Production · Staging · Development reads as a promotion path; Development · Production · Staging reads as +a sorted list of unrelated words. + +--- + +## 4. The applied state — one flat object + +```js +const filters = ref({}) // { status: ['Active'], modified: ['7d'] } +``` + +Keyed by field id, each holding the **array** of picked values. A missing or empty entry is not a filter. +That one rule is what the whole bar reads from: + +| Question | Answer | +| --------------------------------- | ---------------------------------------------------------- | +| Is this field narrowing anything? | its entry is non-empty | +| How many filters are applied? | the count of non-empty entries — never the count of values | +| What does the chip show? | the first pick's label, plus `+N` for the rest | +| Which chips are dimmed? | the ones whose entry is empty | + +**Fields intersect; the values inside a field union.** Author _and_ Status, but author A _or_ author B. +Every list filter implies this arrangement and almost none of them state it, so state it once in one +helper instead of re-deriving it per page: + +```js +export const applyFilters = (rows, fields, state) => + rows.filter((row) => + fields.every((field) => { + const values = state[field.id] + if (!values?.length) return true + return field.match(row, values) + }) + ) +``` + +**Count fields, not values.** Three authors is one filter on Author; reading "3" suggests three columns are +cut when only one is. + +**The chip's value half names something concrete.** "Author Bruno Germano +2" beats "3 selected": they are +the same width, and the second tells you nothing — you have to open the panel to learn a single one of the +three, which is the exact cost the whole pattern exists to remove. + +--- + +## 5. Compose the bar from webkit primitives + +There is no `@aziontech/webkit/filter-bar`. The bar is a small component you own, built from `Popover` and +`Chip`, because the field catalog and its `match` functions are your data — a package component would have +to take them as props anyway, and then you would own the same file with more indirection. + +**Anatomy:** + +``` +[⚙ Add Filter ▾] [Author · Bruno Germano +2 ×] [Status] [Last Modified] + dashed chip, filled chip, removable outlined chips — + opens the panel available, not applied +``` + +- **One anchor, not one popover per chip.** A single `Popover` whose panel walks two levels: the field list, + then that field's values. Clicking any chip opens the panel _onto that field_. One anchor means one focus + contract and one dismiss contract, instead of N popovers competing to be open. +- **The trigger is a `Chip kind="dashed"`** — the dashed border is the "add one" affordance, and it sits in + the same row as the chips it creates. A `Chip` with `clickable` works as a `Popover.Trigger` child from + the keyboard: `Enter`/`Space` dispatch a real click. +- **Applied chips are `kind="filled"` + `removable`; idle chips are `kind="outlined"`.** Every field gets a + chip, always, in catalog order — the bar shows what the page _can_ be narrowed by, not only what it is. +- **Never reorder the chips.** Moving an applied chip to the front re-inserts its DOM node, which discards + any transition that was about to run on it, and moves the target out from under the pointer that just + clicked it. +- **The remove `×` clears the field, and the chip stays.** `Chip` emits `remove` and does not unmount + itself — presence is yours. Here the chip must survive its own removal: it goes from filled back to + outlined, because the field is still available. +- **One search field serves both levels.** Typing narrows the field list at level one and the value list at + level two. +- **Keyboard: `↑`/`↓` walk the rows of the _current_ level only.** Scope the query to the level's own + container, or a level sliding out will steal focus. +- **Restore focus to the chip that opened the panel**, not to the bar. + +**Accessibility:** value rows carry `role="menuitemcheckbox"` when the field accumulates and +`role="menuitemradio"` when it replaces. The trigger's accessible name carries the applied count out loud +("Add filter, 2 applied") — a count badge that is only a coloured dot is invisible to a screen reader. + +--- + +## 6. Rewind pagination when the filters change + +Filtering `:data` from outside the table does **not** trip the table's own auto-reset, because from its +point of view the data simply changed. Filter down from page 4 and you land on an empty page 4. + +```js +watch(filters, () => { + pagination.value = { ...pagination.value, pageIndex: 0 } +}) +``` + +Rewind on anything that re-narrows the set from outside: the filters, and a tenancy/scope switch. + +**Fold the four repeated pieces into one helper** rather than re-typing them per page — the applied-state +ref, `applyFilters`, the pagination ref, and this watcher. Every index page then declares its catalog and +its columns and nothing else. + +--- + +## Hard rules + +- The controls row, the filter bar and the table are **one band** at the group step — never a section step + between the search and the rows it filters. +- A first-level module index has **no page heading**. +- `CardBox :padded="false"` + `Table :border="false"` — never a padded card around a bordered table. +- **Search runs inside the table** (`v-model:globalFilter`); **filters narrow `:data` outside it**. Never + route a membership filter through an operator builder — `is one of` on every row is a control with one + option. +- **No row of Selects** above the table, and no badged funnel button hiding them. One chip row, one anchor. +- The filter bar **wraps; it never scrolls horizontally**. +- The applied state is `{ fieldId: values[] }` — flat, arrays, empty means absent. Not a list of + `{ field, operator, value }` conditions. +- **Count fields, not values.** +- Chips render in **catalog order, always** — applied chips are never floated to the front. +- **Rewind `pageIndex` to 0** whenever the filters or the scope change. +- A free-text column does **not** get a field; the search field covers it. +- Every field needs a `match` — the catalog is what narrows the rows, not a parallel `if` chain in the page. + +## Review output + +Per gap: + +``` +✗ ApplicationsList.vue:38 + quoted:
+ rule: the controls row and the table it narrows are one band, joined at the group step + fix: gap-(--layout-group-gap) — keep the section step for the gap ABOVE the band + +✗ WorkloadsIndex.vue:71 + quoted:

`, toolbar, internal scroll, cell recipes | `/webkit-tables` | +| Structure | The index page around the table: one band, search vs filters, the chip filter bar | `/webkit-lists` | | Structure | The two console shells; one GlobalHeader; user always visible | `/webkit-navigation` | | Foundation | Deslop: components-only, tokens-only, hierarchy, rhythm, containers | `/webkit-baseline-ui` | | Quality | Both themes work with zero per-theme edits | `/webkit-theming-dark-mode` | diff --git a/packages/webkit/src/cli/plan.js b/packages/webkit/src/cli/plan.js index 83a2d9f2c..809b9177d 100644 --- a/packages/webkit/src/cli/plan.js +++ b/packages/webkit/src/cli/plan.js @@ -101,6 +101,7 @@ const CLAUDE_BUNDLE = [ 'skills/webkit-ui-states/SKILL.md', 'skills/webkit-form/SKILL.md', 'skills/webkit-tables/SKILL.md', + 'skills/webkit-lists/SKILL.md', 'skills/webkit-navigation/SKILL.md', 'skills/webkit-baseline-ui/SKILL.md', 'skills/webkit-theming-dark-mode/SKILL.md', diff --git a/packages/webkit/test/cli/plan.test.mjs b/packages/webkit/test/cli/plan.test.mjs index dd070a7a1..a7161a0ab 100644 --- a/packages/webkit/test/cli/plan.test.mjs +++ b/packages/webkit/test/cli/plan.test.mjs @@ -365,12 +365,14 @@ test('planInit copies the .claude/rules/webkit-*.md bundle', () => { '.claude/rules/webkit-testid.md', '.claude/rules/webkit-deprecation.md', '.claude/skills/webkit-usage/SKILL.md', - // UI-craft pack (14 skills: the redundancy/false-positive pass + webkit-tables). + // UI-craft pack (15 skills: the redundancy/false-positive pass + webkit-tables + + // webkit-lists, the index page around the table and its filter system). '.claude/skills/webkit-ui-craft/SKILL.md', '.claude/skills/webkit-ux-heuristics/SKILL.md', '.claude/skills/webkit-ui-states/SKILL.md', '.claude/skills/webkit-form/SKILL.md', '.claude/skills/webkit-tables/SKILL.md', + '.claude/skills/webkit-lists/SKILL.md', '.claude/skills/webkit-navigation/SKILL.md', '.claude/skills/webkit-baseline-ui/SKILL.md', '.claude/skills/webkit-theming-dark-mode/SKILL.md', From 86b8eb0d157afca6fe9967dd2ebb0fe030f8a728 Mon Sep 17 00:00:00 2001 From: Gab Date: Tue, 11 Aug 2026 17:00:19 -0300 Subject: [PATCH 2/2] docs(webkit): document the size, entrance and step transitions in the motion skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sample app's signed-out screens and filter bar carry motion the catalog cannot express — a card that follows its own content's height, a chip that grows a `0fr → 1fr` track to hold its value, a composition that assembles from opposite edges on first paint, and a routed step swap an order of magnitude smaller than that entrance. All of it was documented only in the call sites that discovered it, so the next screen rediscovers the same failures. webkit-motion-polish gains the two sanctioned layout exceptions in their exact shapes, the entrance and route/step recipes, and a glitch catalog of the fourteen ways this fails SILENTLY — `transition-[transform]` beside a `translate-x-*`, a single rAF, `` under `out-in`, a fragment root, `scrollHeight` read after the pin, a ResizeObserver watching what it sizes, permanent `overflow-hidden` shaving a focus ring. Each compiles, lints and type-checks; each renders a correct final state, which is why the DoD now asks for measured frames rather than a screenshot. add-animation gains the prior question — a keyframe is a fixed journey, so a resize whose endpoints are runtime facts is a transition recipe, not a catalog entry — so the catalog stops growing entries that are wrong on every instance but the one they were authored against. --- .claude/skills/add-animation/SKILL.md | 36 +++ .../skills/webkit-motion-polish/SKILL.md | 234 +++++++++++++++++- 2 files changed, 265 insertions(+), 5 deletions(-) diff --git a/.claude/skills/add-animation/SKILL.md b/.claude/skills/add-animation/SKILL.md index 7f8bf42a0..1a618dabb 100644 --- a/.claude/skills/add-animation/SKILL.md +++ b/.claude/skills/add-animation/SKILL.md @@ -13,6 +13,34 @@ The animation catalog is the **single source of truth**. A component may only us `cubic-bezier(…)`. (`validate-tokens` blocks all of those; `validate-spec-compliance` blocks an `animate-*` not in the catalog.) +## First: is this a catalog animation at all? + +A keyframe is a **fixed** journey — it knows both ends before it runs. Three common needs are not +that, and adding a keyframe for them produces an animation that is wrong on every instance but the +one it was authored against. Do not add a catalog entry for these; use the recipe instead +(`transition-*` + `duration-*` / `ease-*` tokens, no new `animate-*`, no `@keyframes`): + +| Need | Why it cannot be a keyframe | Recipe | +| --- | --- | --- | +| A box that resizes because its content changed (form step swap, panel level change, disclosure between two real heights) | Both heights are runtime facts; `auto` is not interpolable and must be the resting value | Measure → mutate → measure while still `auto` → pin the old value → **two** `requestAnimationFrame`s → set the new one → release to `auto` on `transitionend` **and** a fallback timer. `lib/animate-height.js` in the sample app is the working form. | +| A value that grows into place inside an element that keeps its size otherwise (a chip gaining its filter value) | The target width is the content's, unknown at author time | Single-column grid, `transition-[grid-template-columns]` from `grid-cols-[0fr]` to `grid-cols-[1fr]`; keep the clip bare and put padding on the row inside it | +| A composition assembling on first paint, its parts staggered from opposite edges | Per-instance offsets, direction, and a lead/follow delay — a keyframe would fix all three | `translate`/`opacity` on `data-[entered]:` variants, timing inline from the `duration` / `curve` tokens, flipped after two `requestAnimationFrame`s. `lib/auth-entrance.js` in the sample app is the working form. | + +`animate-slide-down` (0 → auto disclosure) is the one *catalogued* size animation and stays that +way — it does not generalise to the incremental resizes above. + +Two failure modes to check for before blaming the catalog, because both compile, lint, type-check +and animate **nothing**: + +- **`transition-[transform]` beside a `translate-x-*` or `scale-*`.** Tailwind v4 compiles those to + the standalone `translate` / `scale` properties. Name those; a `` `move-class` + names `transform,translate,scale,opacity` because its own move uses an inline `transform`. +- **A single `requestAnimationFrame` before flipping to the end state.** It can land in the frame + the browser is already painting, so the start state is never committed and the move snaps. + +The consumer-facing form of all of this — including the full glitch catalog — is the shipped +`webkit-motion-polish` skill. Keep the two in step: a recipe added here is added there. + ## Inputs - `` — kebab-case animation name (e.g. `slide-left`). Utility: `.animate-`. @@ -69,8 +97,16 @@ In the component, the motion-bearing class must pair with `motion-reduce:*` on t class string (e.g. `animate- motion-reduce:animate-none`), and the spec's `## Motion & Animations` table must list `animate-` + its reduced-motion fallback. +### 5. Verify by measuring + +Sample the animated property across `requestAnimationFrame` and assert there are interpolated frames +between the start and end values. A snap and a 150ms ease are indistinguishable by eye, and every +silent failure above renders a correct final state — a screenshot proves nothing. + ## Do not +- Do **not** add a keyframe for a journey whose endpoints are only known at runtime (see + **First: is this a catalog animation at all?**). - Do **not** add a component-local `@keyframes` or `animate-[…]` / hardcoded timing. - Do **not** edit `.claude/docs/DESIGN.md` (human mirror; updated separately). - Do **not** invent a timing outside the `duration` / `curve` / `ease` token sets. diff --git a/packages/webkit/cli-templates/claude/skills/webkit-motion-polish/SKILL.md b/packages/webkit/cli-templates/claude/skills/webkit-motion-polish/SKILL.md index a3c01424e..85e6d5136 100644 --- a/packages/webkit/cli-templates/claude/skills/webkit-motion-polish/SKILL.md +++ b/packages/webkit/cli-templates/claude/skills/webkit-motion-polish/SKILL.md @@ -2,7 +2,7 @@ name: webkit-motion-polish description: Make motion smooth using only @aziontech/theme animate tokens — animate-* utilities, duration-*/ease-*/curve tokens, compositor-props-only, ≤150ms interaction feedback, and a mandatory motion-reduce escape on every motion class. No external animation library, no inline cubic-bezier, no hardcoded ms. status: active -last_updated: 2026-07-22 +last_updated: 2026-08-11 scope: general enforced_by: [webkit-motion, webkit-accessibility, ui-verify] --- @@ -76,6 +76,212 @@ and hand the phases to the catalogued pair: Same shape with `animate-fade-in/out` for in-place content and backdrops, and `animate-slide-down` for vertical disclosure. +### Size transitions — the two sanctioned layout exceptions + +A box whose content changes (a form that swaps steps, a chip that gains a value, a panel that +drills into a sub-level) **snaps** to its new size. That snap is the glitch: it reads as one +element being replaced by a different one rather than as the same element answering. Neither case +has a catalogued `animate-*` — a keyframe cannot know the two heights — so both are transitions, +and both are allowed only in the exact shapes below. + +#### A. Height — the box follows its content + +CSS cannot interpolate to or from `height: auto`, and `auto` is what the box must be at rest: a +height pinned in JS stops responding to a resize, a late-loading font, or a validation line +appearing. So height is measured and pinned **only for the length of the move**, then released. + +Two ways to drive it. Use the **observer** when the content resizes continuously (a field appears, +a Skeleton swaps for a button): + +```js +// The measured element is INSIDE the one being sized and never gets a height of +// its own — otherwise the wrapper animating to it feeds straight back into the read. +const cardContent = ref(null) +const cardHeight = ref(0) +let observer = null + +onMounted(() => { + observer = new ResizeObserver(([entry]) => { + cardHeight.value = Math.round(entry.contentRect.height) + }) + observer.observe(cardContent.value) +}) +onBeforeUnmount(() => observer?.disconnect()) +``` + +```vue + +
+
+
+
+
+``` + +Use the **measure-mutate-measure** form when the change is discrete (a wizard step, a panel level): + +```js +const from = node.offsetHeight // still auto — this is what the user is looking at +mutate() +await nextTick() +const to = node.offsetHeight // new content, STILL auto, not yet painted +if (to === from) return // nothing to animate; height stays auto + +height.value = `${from}px` // pin the old value… +requestAnimationFrame(() => + requestAnimationFrame(() => { + height.value = `${to}px` // …let it paint, then travel: px → px interpolates + }) +) +// release to '' (auto) on transitionend, plus a timeout fallback so an interrupted +// move can never leave the height pinned +``` + +Order is load-bearing. The obvious shortcut — pin the old height first, _then_ read +`scrollHeight` — silently breaks every **shrink**: with a height pinned, `scrollHeight` returns +the greater of content and box, so a step that got shorter reports the old height and never moves. +Measure while still `auto` and growing and shrinking behave the same. + +- **`overflow-hidden` only while moving** (`data-[resizing]:overflow-hidden`). Permanently on, it + shaves the focus ring (`ring-2` + `ring-offset-2` = 4px) off any control flush with the content + edge. Permanently off, an out-of-flow leaving block hangs below the box as it shrinks. +- **Always release on `transitionend` AND a fallback timer** (longest duration + a frame or two). + An interrupted or unmounted move never fires `transitionend`, and a box stuck at a pinned height + is worse than one that snapped. +- **Re-entrancy:** land the run in flight before measuring again, or `from` is a value the previous + transition is still easing through. + +#### B. Width — grow a value into a `0fr → 1fr` grid track + +A grid track cannot be transitioned from `auto`, but it **can** from `0fr` to `1fr`. That is the +whole recipe: put the appearing content in a single-column grid, animate the track, and let the +element's own `auto` width follow. + +```vue + + + + + + +``` + +It only reads as growth if the element **keeps its DOM position**. A list that reorders when the +value is applied re-inserts the node, which discards the pending transition — see the glitch +catalog. + +### Entrance choreography (first-paint screens) + +A screen that is a composition — a form beside an illustration, a card beside a panel — assembles +from its parts instead of fading in as one flat block: each part travels in from **its own outer +edge**, one leading and the other a beat behind. + +```js +const ENTER = `${duration['slow-01']} ${curve['expressive-entrance']}` +const leadStyle = { transition: `opacity ${ENTER}, translate ${ENTER}, transform ${ENTER}` } +const followStyle = { ...leadStyle, transitionDelay: duration['fast-01'] } + +// TWO frames, not one. A single requestAnimationFrame can land in the frame the +// browser is already painting, so the from-state is never committed, there is no +// change left to interpolate, and the move snaps. +const entered = ref(false) +onMounted(() => { + requestAnimationFrame(() => requestAnimationFrame(() => (entered.value = true))) +}) +``` + +```vue +
+``` + +- **`slow-01` + `expressive-entrance`, not the interaction budget.** A card- or console-sized object + crossing real distance reads as confident at 400ms and as a twitch at 150. The ≤150ms rule governs + _feedback_; this is a scene arriving. Keep it to first paint — never replay it on a step change. +- **Stagger, don't synchronise.** One `fast-01` between lead and follow. Simultaneous arrival reads + as a slide transition; the offset reads as choreography. +- **Timing rides `style`, states ride `data-*`.** Tailwind cannot emit a per-state duration/easing + from theme tokens, so the two `transition` declarations are inline (read from `duration` / `curve` + — still never a literal) while the offsets and opacities stay in `data-[entered]:` classes. +- **Scale the offset to the element's relationship with the page edge.** A panel that _is_ the right + edge travels a short step (a longer one opens a visible strip of bare canvas beside it on the way + in); an element that runs off-page can travel further. +- **Direction carries meaning.** If two screens sit in a flow, enter each from the side it occupies + in that flow, so moving between them reads as a direction rather than as two unrelated page loads. +- **Reduced motion kills it whole**: `transition: 'none'` in the style _and_ the + `motion-reduce:translate-x-0 motion-reduce:opacity-100` landing classes, so the from-state is + never what the user is left looking at. +- **The entrance belongs to the LAYOUT, not the screen.** If three steps share a shell, put the + shell (and the entrance) on the parent route so it mounts once — the chrome holds still and only + the card swaps. Give each step its own copy and every step change replays the 400ms slide, so the + user reads three page loads where they only changed step. + +### Route and step transitions + +```vue + + + +
+ +
+
+
+``` + +- **A step swap is an order of magnitude smaller than an entrance.** Leave `fast-02`, enter + `moderate-01`: the old card is gone before the new one commits, which is what makes a step change + read as a step change rather than as a change of screen. +- **`out-in` between siblings of different heights**, so the two never fight for the same column. + But **never `out-in` inside a box that is resizing** — the field area collapses to nothing in + between and the box dips on the way. There, cross-fade with the leaving block taken out of flow + (`leave-active-class="absolute inset-x-0 top-0"`) so the incoming one owns the layout immediately + and the box has a single height to travel to. +- **Every routed step needs exactly one root element.** A fragment root cannot be animated. +- **Match the skeleton to what replaces it.** A Skeleton in the exact geometry of its target button + makes the swap a pure cross-fade with no height to travel, so nothing jumps under the cursor. + +### The glitch catalog + +Every entry here compiles, lints, and passes type-check. They fail **silently** — that is what makes +them expensive. When motion "doesn't work" and nothing is red, start here. + +| Symptom | Cause | Fix | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Element lands in the right place, no motion at all | `transition-[transform]` beside `translate-x-*` / `scale-*` — v4 compiles those to `translate` / `scale` | Name the real properties: `transition-[translate,scale,opacity]` | +| Entrance snaps to its final state | A single `requestAnimationFrame` — the from-state was never painted | Two nested `requestAnimationFrame`s | +| Routed step is blank when navigated to, correct on reload | `` as the direct child of `` — the leave completes and the entering branch is dropped | Wrap it in a stable keyed **element**; keying the component does nothing | +| `` around a component does nothing | The component's root is a fragment | Give it one root element | +| Growing animates, shrinking snaps | Height pinned _before_ `scrollHeight` was read | Measure both heights while still `auto` | +| Box stays stuck at a pinned height | `transitionend` never arrived (interrupted, unmounted) | Release on a timeout fallback too | +| Height transition jitters / never settles | `ResizeObserver` is watching the element being sized | Observe an inner element that is never given a height | +| Box dips mid-swap | `out-in` inside a height-animated container | Cross-fade with the leaving block `absolute` | +| Focus ring is clipped on flush-edge controls | Permanent `overflow-hidden` on the animated box | Clip only while moving, via `data-[resizing]:` | +| Consumer's `transition-*` class is ignored | An inline `style="transition: …"` on the same element beats every class | Express it as a utility, or accept the component owns that transition | +| Enter-from opacity has no effect | A base `opacity-*` on the same element is emitted after `opacity-0` | Recede with token colours (border/fill), and leave `opacity` to the transition | +| Reordering a list kills the item's own animation | Re-inserting a node discards any transition pending on it or inside it | A list either reorders or its items animate in place — not both on one interaction | +| A badge/dot is cut off, or the control jumps when it appears | The host clips to its own shape (`overflow-hidden`), and an in-flow marker widens the box | Overlay it on a `relative` wrapper _outside_ the control, `pointer-events-none` | +| Element fades out but never leaves | A DS component's own remove animation completed and _then_ emitted — correct for a thing going away, wrong for a thing that stays | Own the control locally when the element must survive | + ### Timing only from tokens - **Durations**: `duration-fast-01` (70ms), `duration-fast-02` (110ms), `duration-moderate-01` @@ -91,8 +297,16 @@ Same shape with `animate-fade-in/out` for in-place content and backdrops, and - **MUST** animate compositor props only: `transform` and `opacity`. - **NEVER** animate layout props (`width`, `height`, `top`, `left`, `margin`, `padding`) — they - thrash layout. (Exception: the catalogued `animate-slide-down` for disclosure height; don't roll - your own height/width transition beyond it.) + thrash layout. Three exceptions, and only three: the catalogued `animate-slide-down` for a + 0 → auto disclosure, and the two recipes in **Size transitions** below (a box that follows its + own content's height, a value that grows into a `0fr → 1fr` grid track). Anything else that + wants to resize is a `transform` in disguise — find it. +- **Name the property the utility actually sets.** Tailwind v4 compiles `translate-x-*` to the + standalone `translate` property and `scale-*` to `scale`, **not** to `transform`. So + `transition-[transform]` beside a `translate-x-*` compiles, lints, emits CSS — and animates + nothing. Write `transition-[translate,opacity]`, `transition-[scale,opacity]`. A + `` sets an inline `transform` for its own move, so its `move-class` names all + three: `transition-[transform,translate,scale,opacity]`. - **SHOULD** avoid animating paint props (`background`, `color`) except small, local UI (a single icon, a chip). For hover/active surface fills use the `::before`/`::after` ghost-layer pattern from the Interactive states catalog (listed by the webkit MCP) — fade `opacity` on the pseudo-layer, not @@ -147,7 +361,17 @@ End with: `motion clean` or `N violations`. ## Definition of Done - [ ] Only `animate-*` utilities and `duration-*` / `ease-*` / `curve` tokens are used. -- [ ] Only `transform` / `opacity` animate (plus catalogued `animate-slide-down`). -- [ ] Interaction feedback ≤ 150ms; entrance `ease-out`, exit `ease-in`. +- [ ] Only `transform` / `opacity` animate — plus catalogued `animate-slide-down` and the two + sanctioned size recipes, each in the exact shape above. +- [ ] Every `transition-[…]` names the property the utility actually sets (`translate` / `scale`, + not `transform`). +- [ ] Interaction feedback ≤ 150ms; entrance `ease-out`, exit `ease-in`. A first-paint entrance is + exempt (`slow-01` + expressive) and runs once, on mount only. +- [ ] Any animated height releases back to `auto` — on `transitionend` **and** a fallback timer — + and clips only while it moves. +- [ ] Motion was **measured, not eyeballed**: sample the animated property across + `requestAnimationFrame` and confirm interpolated frames between start and end. A snap and a + 150ms ease are indistinguishable by eye, and every entry in the glitch catalog looks fine in + a screenshot. - [ ] Every motion class has a `motion-reduce:*` escape; decorative loops are `aria-hidden` and pause off-screen. - [ ] No `@keyframes`, inline `cubic-bezier`, hardcoded ms, or external animation lib.