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/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',