Skill: Design Component
Produce a complete component specification matching the project format.
Steps
- Read
.claude/rules/components.md
→ "Component Quality Bar" (the 8-state table) and "Atomic Design"; the always-on 8-state table is in → Non-Negotiables.
- Check if it already exists: , , , , , , , . Match the existing spec format.
- Pull the ARIA pattern from
accessibility/aria-patterns.md
and contrast/target rules from accessibility/wcag-checklist.md
.
- Map every value to tokens () — sizes via , states via .
- Apply visual judgment from (states, focus, no slop).
- Optional fast start:
python3 scripts/scaffold_component.py "<Name>"
to emit a stub, then fill it in.
Output
Spec with: anatomy diagram, variants table, sizes table, all 8 applicable states, token mapping, accessibility (role/keyboard/SR), and a note to render via
frameworks/adapter-protocol.md
.
Accuracy — verify every state, don't assume (mandatory when code is produced)
A component is only "correct" when
every variant × state renders right — not just the resting default. Build a
states harness: render the component in each applicable state (default, hover, focus, active, disabled, loading
, error
, selected
/
) × each variant in one HTML file (see
examples/component-states/button.html
). Then RUN the gates and report their real output (CLAUDE.md → Verification Protocol):
node scripts/verify_states.mjs <harness> [--dark]
— contrast of every element in default/hover/focus
node scripts/axe_audit.mjs <harness> [--dark]
— ARIA/role/name/label correctness
node scripts/measure_render.mjs <harness> [--dark]
— every text element AA
- overlays/modals also:
node scripts/verify_focustrap.mjs <harness> --open=<trigger>
Every state must pass in light AND dark before the component is "done". Never claim a state is correct without a gate proving it.
Gates prove contrast/a11y — they do NOT prove pixels. RENDER AND LOOK.
The contrast/axe gates pass while the UI is still visibly broken: a checkbox that doesn't toggle, a dash sitting at the bottom of its box, a checkmark and an indeterminate dash with mismatched stroke weight, a control that's too heavy. You must screenshot the harness and inspect it before claiming done — for every state, and after interaction. Playwright + system Chrome:
js
const b = await chromium.launch({channel:'chrome'});
const p = await b.newPage({deviceScaleFactor:4});
await p.goto('file://'+abs); await p.addStyleTag({content:'*{transition:none!important}'});
await p.mouse.move(2000,2000); // park pointer OFF the component
await p.locator('.stack').first().screenshot({path:'/tmp/x.png'});
Read the PNG. Then look for, specifically:
- Functional: click each interactive element and assert the state actually changed (
await loc.click(); expect(await loc.isChecked())
). A custom control whose overlay box covers the real will not toggle unless the box has (or an enclosing forwards the click).
- Geometry: glyphs centered, not stacked/offset. If a box holds an sibling plus a , the pseudo lands in row 2 → use on the hidden sibling, or one container child.
- Stroke consistency: a checkmark and its indeterminate dash must use the same rendering method (one , two toggled by state — same ), never an svg check vs a CSS rect (they read as different weights).
- Transition artifact: screenshot WITHOUT disabling transitions and a just-clicked control looks half-faded mid-animation — that is not a bug. Always disable transitions and park the pointer before judging a state.
Consistency across files is non-negotiable. The same component (e.g. checkbox) must use byte-identical CSS + markup in every harness/page. A checkbox that looks thin in
and heavy (native
) in
is a bug. Factor one pattern, reuse it verbatim.
Verified custom checkbox/radio pattern (thin, token-driven, gated + eyeballed)
Real
underneath (keeps native a11y + keyboard); a drawn
overlay with
; check + dash as two
in one
toggled by
/
; 1.5px
,
radius,
glyph,
round caps. Reference:
examples/component-states/form-controls.html
and
(select-all uses
). Native
renders too heavy — do not use it when the house look is "thin".
Responsive — every component, no sideways scroll (gated)
Build mobile-first; a fixed-px width that can't shrink is a bug. Run
node scripts/verify_responsive.mjs <file|dir>
— it loads each harness at 280/320/414px and fails on any horizontal overflow. The four recurring causes and their fixes:
- fixed →
inline-size:100%;max-inline-size:Npx
(cap, don't pin).
- / default 40px inline-start padding (a reset does NOT clear padding) → on every list. This also silently mis-aligns a list's edge vs a sibling block (looks like unequal widths) — same fix.
- non-wrapping flex rows (breadcrumb, stepper, tabs) → , or for tabs + .
- min larger than viewport →
minmax(min(Npx,100%),1fr)
.
Motion — tokenized, real easing, animate the thing that moves
Timing/easing are tokens (
--duration-fast|normal|slow
,
--ease-out|in|in-out|emphasized
in the theme;
=
). Never hardcode ms/curves. A component that toggles open/closed must animate its
height, not just rotate a chevron — collapse via
/
alone reads as "rigid, no transition". Smooth-height pattern (no JS measuring): wrap content in an inner that clips overflow, animate the grid track:
css
.panel{display:grid;grid-template-rows:0fr;transition:grid-template-rows var(--duration-normal) var(--ease-emphasized)}
.panel.open{grid-template-rows:1fr}
.panel > .inner{overflow:hidden;min-block-size:0}
Keep a11y: expand = remove
then add
next frame; collapse = remove
, set
on
. Reference: accordion in
examples/component-states/overlays.html
. Always honor
@media(prefers-reduced-motion:reduce){…transition:none}
.
Layout — fill the space, don't ship AI-empty filler
- , never for card grids. keeps empty phantom tracks so 3 cards cluster left with a void on the right; collapses empties so cards stretch to fill the row. Always
repeat(auto-fit,minmax(min(Npx,100%),1fr))
.
- Equal-height panels in a row: on the grid, AND make the shorter panel's body fill —
.panel{display:flex;flex-direction:column}
+ the inner region . A child sized with (e.g. chart bars) needs a definite-height ancestor (a box or explicit height), or the % resolves to 0 and the element collapses. Wrap the bar in a and give the bar of that.
- A main region that's 80% whitespace reads as machine-generated. Fill a dashboard with real, plausible content (stats row + activity list + a chart), not one lonely widget. Intentional density is the difference between "designed" and "AI slop".
- Trailing gap in a toolbar/header: a flex item with capped by stops growing and leaves empty space after the last item. Push the right-hand cluster with on its first element.
- Mobile nav must not overlap. Putting the sidebar and main in the same grid area makes an opened sidebar paint over content. On mobile switch the shell to so opening the sidebar pushes main down. Reference:
examples/component-states/app-shell.html
.
Icons — real lucide, referenced by name (never hand-draw paths)
Hand-approximated SVG path data renders as broken glyphs (a help "?" became a dot; settings became a hamburger). Use
verbatim lucide paths, referenced by name via an injected
sprite —
examples/component-states/icons.js
defines each icon once and
<svg class="ico" aria-hidden="true"><use href="#i-NAME"/></svg>
uses it. No per-use path duplication, no network, offline + gate-safe. Add a new icon to
once; never paste raw paths into markup. (Inline lucide is acceptable only if the path is copied verbatim from lucide.)
.ico{stroke:currentColor;fill:none;stroke-width:2}
— color via
.
Graphical / icon-only controls (3:1, theme-stable)
A no-text control (carousel dot, kebab, icon button) is held to
3:1 (WCAG 1.4.11), not 4.5 —
applies this automatically when an element has no direct text node. Two traps it catches:
- An empty keeps the UA (≈black) regardless of theme → set its to the actual indicator color and drive the visual via (e.g. dot is a
::before{background:currentColor}
), so the gate measures the real thing.
- Theme-flipping tokens (, ) invert between light/dark; white text or a teal indicator on them passes in one mode and fails the other. Use dark-aware values (override in ) or stable tokens (, which adapts) for avatars, active dots, and selected states.