agentleFS
robritacca-dotcomrobritacca-dotcom/rift-ds/.claude/skills/new-component/SKILL.md

new-component

Skill9 starsChanged 7 days agoMITView on GitHub

Scaffold a new design system component with all required files and registration steps. Use when asked to add, create, or scaffold a new design system component.

8 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

4 rules. 0 are common, 1 is distinctive, and 3 are very specific to this project.

  • 0

    Common

    Most of the 7 edited versions we read keep these.

  • 1

    Distinctive

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Use this skill any time you are asked to add or create a new component to the design system — phrases like "add a [Name] component", …

    line 15
  2. Very specific:

    If the component renders as <a> when an href prop is passed, follow the Button pattern of conditional element rendering

    line 40
  3. Very specific:

    Add | 'type' (or any other attribute the component hardcodes) to the Omit list.

    line 55
  4. Very specific:

    All other spacing, padding, gap, border-radius, font sizes must use semantic tokens from tokens-light.css / tokens-typography.css

    line 145

“Common” is measured: kept by at least 6 in 10 of the 7 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

new-component

Scaffold a new design system component: the three component files plus the build-enforced registration steps.

When invoked

Use this skill any time you are asked to add or create a new component to the design system — phrases like "add a [Name] component", "create a [Name] component", "scaffold [Name]".

Not for a widget: the chat (src/chat/) is a kind of its own, with its own registry (src/chat/registry.json) and validator (scripts/validate-chat-widget.mjs), and does not go through this flow. The one way the two meet: when the chat widget starts composing a new component, that component's name joins parts in the chat registry, which the validator holds to the widget's own imports in both directions.

Instructions

  1. Ask for the component name (PascalCase) and a one-sentence description of its purpose, if not already provided.
  2. Read these reference files before writing anything:
    • src/components/Button/Button.tsx — structural reference for a button-or-anchor component (own-props split, ref forwarding, rest spread, BEM class usage, conditional rendering)
    • src/components/Input/Input.tsx — structural reference for a form control (native onChange + onValueChange convenience callback, label/helper/error wiring)
    • src/components/Badge/Badge.css — CSS token reference (no raw hex/pixels, semantic token usage)
    • src/components/Badge/Badge.stories.tsx — stories file reference (satisfies Meta, StoryObj, autodocs)
    • website/public/schema/component.schema.json — the component contract as data. CLAUDE.md's Component Anatomy paragraph The props interface is also the published schema owns what it locks and why; node scripts/validate-json-schemas.mjs is the check. The component's own schema under website/public/schema/components/ and its page's For agents dialog derive from the props interface and need no work.
    • src/tokens/registry.json — the generated index of every semantic token by category (which CSS file owns which category is the new-token skill's step 2, backed by SEMANTIC_FILES in scripts/generate-token-registry.mjs; the registry is the one place that lists all of them)
    • design.md's Components intro, the two paragraphs Shared primitives, never redrawn and Surfaces by family — the one drawing of every small thing a component needs (the three control indicators, the chosen mark, the visually-hidden class, the close button, the exclusive set) and the surface treatment each family of component takes; src/components/Checkbox/Checkbox.tsx is the reference for exporting an indicator beside its control, and src/components/SelectionCard/SelectionCard.tsx for a host composing one
    • .claude/skills/design-qa/SKILL.md — read its "The craft checks" rubric before writing any CSS (and "The eye" for when the component lands on a page): the component faces that pass as a gate before it can register (step 4), and polish built in beats polish patched on after
  3. Create the directory src/components/ComponentName/ and write these three files. For an overlay component (anything that dismisses, traps focus, or locks scroll), first read CLAUDE.md's Component Anatomy exception and design.md's Components-intro overlay contract: import the shared behavior hooks from src/behaviors/ rather than registering your own document listeners — Dialog is the reference modal consumer, and Popover the reference light-dismiss one (useLayer with dismissOnOutsidePress and getElements, no focus scope). A component that handles Escape in its own keydown handler (a listbox closing its list) calls event.preventDefault(), which the stack respects, so a Dialog around it stays open. scripts/validate-overlay-listeners.mjs fails the build on a document key or press listener outside its sanctioned table. A panel anchored to a control (a menu, a listbox, a picker, a popover, a tooltip) is placed by src/behaviors/useAnchoredPosition.ts, never by position: absolute CSS, which a scrolling table or a sidebar clips: give it the preferred side, key its entrance on the data-side the behaviour writes, and add a story to src/stories/AnchoredPopups.stories.tsx that opens it inside a clipping box (design.md's "Popups are placed by one behaviour" owns the contract; Dropdown is the reference list, ThemePicker the reference menu). Three files is the norm, not a limit: a component may split extra modules out beside them (ShaderField keeps its hook and its shader source in sibling .ts files). If you do, read the packaging note in step 5 — a sibling .ts module does not get a deep-import subpath for free. One family-shaped exception: when the component belongs to an established shared-folder family (most of the recharts-backed charts live as single .tsx files inside src/components/Chart/, and the Inspector panel controls inside src/components/Inspector/ sharing Inspector.css; the registry entries' folder values are the authoritative membership lists), add the file to that folder instead of creating a new directory, and set the registry entry's folder field to the shared folder's name — the doc block of scripts/validate-component-registry.mjs documents the field, and the existing Chart/ entries are the pattern to copy. Two independent axes, not one: folder records where the implementation lives, and the registry's recharts flag records which barrel it ships from — FunnelChart is the precedent for a recharts-backed chart in its own folder (it composes the family's Chart.css chrome without living in Chart/). Joining the shared folder is a fit call, not a dependency test. A multi-series chart takes its default colours from getChartSeriesColors() in Chart/palette.ts — which emits var() references to the --color-chart-series-* ramp, so series colours follow a live theme switch — never a local palette array. A recharts-backed chart declares 'use client' (recharts calls client-only React APIs and ships no boundary of its own) and sizes its tick and legend text with readTokenPx('--font-caption-size', '12px') from Chart/palette.ts, never a literal fontSize. It also spreads useChartAnimation(data) from Chart/useChartAnimation.ts into every series, so it animates on mount and on new data and never on a resize (design.md's chart-set spec owns the contract, the ScatterChart timer case included). And every panel-scale chart — recharts-backed or not — wears the family's shared card chrome by importing Chart.css and composing its ds-chart wrapper/header/body classes, and takes a bare prop (chrome off: no border, padding, or fill) so it can drop straight into a Panel that supplies the surface; a new chart ships the same way (only inline-scale pieces like a sparkline or a legend tile stay chromeless).

File 1: ComponentName.tsx

  • Named export (not default)
  • BEM class naming with ds-componentname root prefix (e.g. ds-button, ds-badge)
  • Modifier classes follow ds-componentname--variant pattern
  • Imports CSS: import "./ComponentName.css"
  • If the component renders as <a> when an href prop is passed, follow the Button pattern of conditional element rendering
  • A continuous pointer gesture (a drag, a resize grip, a scrub, a move) writes the DOM per frame and commits React state once, on release: the move handler sets the style, the custom property or an SVG path's d straight on the node, and the pointer-up handler does the one setState. Per-move state re-renders everything under that state on every frame, and React counts a commit that ends with the next move already queued as a nested update, so a real drag over a re-animating sibling can hit the update-depth limit and unmount the root (the site lost a whole page to exactly that). The floating card's move in src/chat/ChatLauncher.tsx is the reference implementation (the position variables written per frame, one setFloatPos on release); its widen grip is the pure-DOM case, where the width lives on <html> and nothing needs committing. A grip a visitor can press on a served page also gets an entry in scripts/gestures.json, with the setup that reaches it, so the gesture stress check drives it in verify and CI (CLAUDE.md's Gestures row owns the registry). One exception: when the subtree the per-move update re-renders is small (a fictional GadgetKnob whose state drives only its own dial), per-move state is fine, and a comment beside the handler says so, so the next reader knows it was judged rather than missed

The component API contract — every one of these, no exceptions. This package is published to npm, so the props interface is a public contract. Getting it wrong is a breaking change later. src/components/Button/Button.tsx (button-or-anchor) and src/components/Input/Input.tsx (form control) are the reference implementations. Items 2-4 are build-enforced (scripts/validate-component-contract.mjs reads the syntax, and the props-contract story in src/stories/Contract.stories.tsx renders the component with a ref and a data-testid and fails unless both reach one element), so a slip fails the build rather than shipping; the rest are yours to get right. State a consumer can own follows the controlled/uncontrolled pair convention in CLAUDE.md's Component Anatomy (x/defaultX/onXChange), and every accessible name the component writes in English is a *Label prop with that text as its default.

  1. 'use client' on the first line — if and only if the component uses hooks, event handlers, or browser APIs. Purely presentational components must NOT have it (see src/components/Table/Table.tsx), or consumers lose the ability to render them from a React Server Component. "Uses hooks" counts what the component imports too: a shared hook file or a package that renders client-only (recharts) needs the directive on the component, and scripts/validate-component-registry.mjs fails the build when one is missing.
  2. Split the props type in two. Own props as a type, then an exported interface that merges in the native element's props:
    type ComponentNameOwnProps = { /* ...props this component owns... */ };
    
    export interface ComponentNameProps
      extends ComponentNameOwnProps,
        Omit<React.ComponentPropsWithoutRef<'div'>, keyof ComponentNameOwnProps> {}
    Add | 'type' (or any other attribute the component hardcodes) to the Omit list.
  3. Take a ref onto the primary DOM node, in one of two forms the validator accepts. A new component uses React 19's form: a named function component that destructures ref from its props and passes it to the node, with the props interface built on ComponentPropsWithRef<'div'> instead of ComponentPropsWithoutRef (the package's react peer is ^19, so every consumer has ref-as-prop; the function's name is its DevTools name, so it needs no displayName):
    export function ComponentName({ className = '', ref, ...rest }: ComponentNameProps) {
      return <div {...rest} ref={ref} className={['ds-component-name', className].filter(Boolean).join(' ')} />;
    }
    The existing components use React.forwardRef with ComponentName.displayName = 'ComponentName' after it, which stays valid. The two are identical to a consumer, so there is no migration: leave a forwardRef component alone unless you are rewriting it anyway. If the component already keeps an internal ref (focus trap, click-outside, picker trigger), merge them:
    const setRef = (node: HTMLDivElement | null) => {
      internalRef.current = node;
      if (typeof ref === 'function') ref(node); else if (ref) ref.current = node;
    };
  4. Spread {...rest} onto that same node, placed before the component's own attributes so those win; a consumer-overridable default (a landmark's aria-label) goes before the spread instead, so a passed value wins (see Accessibility below). This is what makes data-testid, aria-*, autoComplete, maxLength and form-library registration work.
  5. Event handlers keep native React signatures. onChange must be React.ChangeEventHandler, never (value: string) => void — that shape breaks react-hook-form, Formik and TanStack Form. Put the convenience callback under a name matching the value's shape, and fire both:

    Value shapeConvenience prop
    string / numberonValueChange
    booleanonCheckedChange
    arrayonValuesChange
    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      onChange?.(e);
      onValueChange?.(e.target.value);
    };
  6. Never invent a prop that shadows a native one with different semantics. Prefer variant (not priority/kind) and disabled (a real boolean, never a value inside a state enum). Figma variant properties are not code props — hover and active belong to CSS pseudo-classes, so a state prop that includes them has two sources of truth. Where a collision is unavoidable and intentional (size vs the native character-width attribute, title vs the native tooltip), say so in the prop's JSDoc.

    variant is emphasis, tone is colour (CLAUDE.md's Component Anatomy states the rule; design.md's Components intro owns the details). Never put a colour value in an emphasis union: a fictional GadgetPill that needs a red delete look takes variant?: 'primary' | 'secondary' | 'tertiary' plus tone?: 'default' | 'neutral' | 'destructive', never variant: 'destructive'. A status-bearing component keeps the five status roles on variant instead, and a data entry the component renders (a menu item, a command row) marks danger with destructive?: boolean. If the component is a rework that already shipped a mixed union, the moved value stays as a @deprecated alias that renders unchanged (item 8). scripts/validate-prop-vocabulary.mjs enforces the shape, and fails any internal caller still passing a deprecated prop or a legacy value.

  7. Discard props that would be invalid on the rendered node rather than spreading them — e.g. maxLength on a <div role="combobox">. Destructure with a _ prefix (maxLength: _maxLength) and document why in the JSDoc; the ESLint config allows ^_. A form control's name is never one of these: it reaches the form, through a hidden input when the control has no native one (design.md, "A control reaches a form through name").
  8. Deprecate, never remove. Keep the old prop working and mark it @deprecated with the replacement named — and put a description sentence before the tag, or the prop renders as a blank cell (see 9). className stays wherever it already is (usually the wrapper) — moving it is a silent visual break.
  9. A link renders through the link component, never a raw <a href> — build-enforced. A component that takes an href (its own, or an item's) adds an optional linkComponent?: LinkComponent prop and renders every link with the resolved component, so a consumer's router (Next.js's Link) navigates without a reload. In a client component, resolve it with const Link = useLinkComponent(linkComponent); from src/behaviors/LinkProvider.tsx at the top of the render, before any early return (prop, else the nearest LinkProvider, else <a>); in a server-renderable one, const Link = linkComponent ?? 'a';, since context cannot reach it, and say so in the prop's JSDoc. Button.tsx and Breadcrumb.tsx are the two patterns, and design.md's Components intro owns the contract. A link that is genuinely not navigation (an in-page jump the component scrolls itself) is a row in SANCTIONED_ANCHORS in scripts/validate-component-contract.mjs, with the reason.

    A number, a date or a time is written in the resolved locale, never the runtime's — build-enforced. The same shape as the link: an optional locale?: string prop, resolved with const locale = useLocale(localeProp); from src/behaviors/LocaleProvider.tsx in a client component, or locale = DEFAULT_LOCALE (from src/behaviors/locale.ts) in a server-renderable one, then every Intl format, collator and toLocale*String call takes that locale. A fictional GadgetMeter printing 1,204 widgets formats the number in locale and keeps "widgets" in a *Label prop: the locale formats, it never translates. A calendar reads its first weekday from weekStartOf(locale) and its names from weekdayNamesIn rather than a hand-written list. scripts/validate-locale-formatting.mjs fails the build on a call with no locale, a hand-written month or weekday name, or a server-renderable component reading the provider; design.md's Components intro owns the contract.

    A key the component answers to from outside its own focused element goes through useShortcut — build-enforced. A fictional GadgetViewer overlay whose arrows step pages binds them with useShortcut('arrowright', next, { scope: panelRef, enabled: open }) from src/behaviors/shortcuts.ts, never a document listener, so they stay quiet in text fields and behind other overlays and never take Escape from the layer stack. A shortcut the component only displays is a keys?: Shortcut prop in the shortcut grammar (mod+shift+p), drawn through Kbd's keys or formatShortcut, with a keyLabels prop for other keyboards; keep a literal display prop the component already ships. scripts/validate-overlay-listeners.mjs fails the build on a document key listener; design.md's "Keyboard shortcuts go through one listener" owns the rules.

    Lay it out from the reading direction — build-enforced. Write every side logically (margin-inline-start, inset-inline-end, border-start-start-radius, text-align: start); multiply any horizontal transform by var(--ds-inline-sign, 1) and import src/behaviors/direction.css; switch on logicalArrowKey(event.key, event.currentTarget) from src/behaviors/direction.ts for left and right; measure a drag from the inline start with isRtl; ask a popup for start/end; and give a glyph that points along the reading direction the ds-mirror-rtl class (an icon a consumer names takes mirrorClass(name)). A fictional GadgetSlider whose thumb slides by value multiplies its translate by the sign and reads ArrowLeft as an increase in Arabic. A side that genuinely belongs to the screen (a map, a chart axis, a pointer position) takes /* ds-allow(physical): <reason> */. scripts/validate-logical-properties.mjs fails the build otherwise; check the component with Storybook's Direction toolbar set to right to left.

  10. Compose the shared primitives; never redraw one — build-enforced. A component that offers a choice renders CheckboxIndicator, RadioIndicator or SwitchIndicator (exported beside Checkbox, RadioButton and ToggleSwitch) and carries the role, the state and the ds-control-host class on its own root; a list or menu row marks its selected item with ChosenMark (from the Dropdown module); text that is read but not seen takes ds-visually-hidden; a header's close is a compact tertiary CircularButton; a mutually exclusive set of pills is a SegmentedControl. A fictional GadgetPicker that wants a tick beside each row renders the checkbox indicator, never a check_box glyph or a bordered <span> of its own. scripts/validate-shared-primitives.mjs fails the build on a second drawing of any of these; design.md's Components intro ("Shared primitives, never redrawn") is the list, and a departure with a reason is a row in that script's SANCTIONED table.
  11. Give every own prop a JSDoc description — this is build-enforced. That JSDoc is the single source for Storybook's props table, the .d.ts consumers get, and the machine-readable prop API the site's /api/mcp route serves to any agent and the site chat's get_component tool repeats to visitors (website/src/data/component-api.generated.ts) — so a prop description is public copy strangers' agents and site visitors both read, and scripts/validate-component-api.mjs leak-screens it (a hit there is a leak in the npm tarball too). All three consumers see the same parse: the settings live in scripts/component-docgen.mjs, mirroring .storybook/main.ts. scripts/validate-prop-docs.mjs fails the build on a prop without a description. Two traps it catches:
/** Visual treatment */
variant?: 'primary' | 'secondary';

/**
 * Legacy alias for `variant`.      ← without this line the row renders blank:
 *                                     the parser moves the tag and everything
 * @deprecated Use `variant` instead.  after it into a separate `tags` field
 */
priority?: 'primary' | 'secondary';

And never write a prop description into the story's argTypes. Those entries override docgen, so a description there shadows the JSDoc and drifts from it. Stories set control and options only; the source owns the words.

If the component is a labelled form control, compose it inside Field (src/components/Field/Field.tsx) rather than re-implementing the scaffolding. Field owns the label and its htmlFor, the required marker, the helper/error text, the generated ids, and the aria-describedby / aria-invalid wiring — several components each rolled their own before it existed, and two of them silently diverged (Dropdown announced neither its helper text nor its error state). Pass className (your own root classes), label, helperText, error, required, disabled, size and id; render the control as children. For a set of controls with no single labelable element (a checkbox or radio group), add group, which renders the label as a <span id> for the group node to point aria-labelledby at — CheckboxGroup and RadioGroup are the live adopters. Field owns no layout, so your root class keeps its own flex/gap.

Two details worth knowing before you reach for it:

  • htmlFor only associates with labelable elements (input, select, textarea, button…). If your control is a composite built from a div — a role="combobox" trigger, say — point aria-labelledby at `${id}-label` instead, which is the id Field puts on its label.
  • Content that belongs opposite the helper text (a character counter, a unit) goes in Field's aside prop, not a hand-rolled footer.

Accessibility — what axe will not catch

The story tests run axe in 'error' mode, but axe only catches roughly a third of WCAG issues, and it passes every rule below while a screen-reader user hears the wrong thing. Write each one in from the start:

  • Everything the eye is told, the ear is told. When a visual mark is aria-hidden (a badge, a check, a status dot, an icon standing for a state), its meaning needs a text equivalent: a visually hidden span or a suffix on the owning control's label. Cover every shape the prop accepts. A badge?: number | true that announces the count but not the dot is the classic miss, and so is a step indicator whose "complete" check is hidden with nothing said in its place.
  • Name every landmark and every named-content region. A <nav>, an <aside>, a labelled list of steps, a figure: each gets an accessible name, because a page with two unnamed navs gives a screen-reader user two identical entries. Prefer aria-labelledby pointing at the component's own visible heading (ids from useId(), never hard-coded, so two instances cannot collide) and aria-describedby at its subtitle. When there is no visible heading, set a sensible default aria-label.
  • Every default label is overridable, by the consumer's own attribute. Write the default before {...rest} (<nav aria-label="Main" {...rest}>), so a passed aria-label wins with no extra prop. A string the component writes itself and the consumer cannot reach (an overflow button's "More", a close button's "Close") needs a prop of its own, named for the control (navigationLabel, overflowLabel): hard-coded English cannot be localised.
  • A shape the type accepts is never silently dropped. When one prop type feeds two renderings (inline actions and an overflow menu, say), check that every field survives both. If a sub-surface cannot honour a field (a menu row that cannot be a link, a row that draws no badge), narrow that surface's type with Omit<> so the compiler says so. A JSDoc "ignored in the menu" note still ships a trap.
  • Hidden copies leave the accessibility tree. A duplicate kept for animation (a collapsing title, a cross-fade) is aria-hidden while it is the invisible one, and a region collapsed or minimised out of view is inert so its controls leave the tab order too. Exactly one copy of any text is announced at a time.
  • Native semantics first, ARIA second, and never ARIA for coverage. Nothing counts ARIA: /foundations/accessibility dropped its ARIA census because it read a correct native element as a shortfall. A <table>, <blockquote> or heading needs nothing added, and an aria-label on a plain div or span is prohibited by ARIA 1.2. If the component is correct with no ARIA, leave it with none.

File 2: ComponentName.css

  • CSS custom properties exclusively — no hardcoded hex colours, no raw rgb()/rgba()
  • The action colour is reserved, and the build holds the line. Outside a focus outline, a component that references --color-action-primary-bg fails scripts/validate-action-colour.mjs until it is added to that script's SANCTIONED table with the role the use plays (design.md lists the roles). Decide the role first; if none fits, the use is decoration and wants a different token
  • Icons are sized by setting --icon-size on the icon element to a step from the --icon-size-* scale (src/tokens/registry.json is the list) — never font-size or raw pixel dimensions on an icon
  • Transitions/animations compose --motion-duration-* with --motion-ease-* from tokens-motion.css — never literal timings like 0.2s ease (new code must use the motion tokens from the start)
  • The same rule has a TypeScript half: a JS timer (a hover delay, an auto-dismiss, a settle timeout) takes its default from the shared constants in src/tokens/motion.ts — import the matching constant, or add a named one there deliberately, never a literal ms value in the component. Schedule timings are deliberately left alone by the reduced-motion guard; a constant that paces an animation must be guarded by its component in JS instead (design.md's Motion section records the contract and the exceptions)
  • All other spacing, padding, gap, border-radius, font sizes must use semantic tokens from tokens-light.css / tokens-typography.css
  • A control that can share a row takes the row height. A field, a button, a picker, a segmented set or a filter chip sets height: var(--control-height-default) (and --control-height-compact at size="compact") with box-sizing: border-box and horizontal padding only, so it lines up with every control beside it at both sizes; design.md's Control Height Scale owns the roster and the exceptions (a tag, a keyboard hint). Never a literal 40px or 32px
  • A z-index of 10 or more is a stacking layer: var(--layer-*) for the role (--layer-dropdown for an anchored menu, --layer-floating for a hover card, --layer-modal for a dialog; tokens-light.css lists them lowest first), or calc() off one when the surface sits exactly a step from it. Order inside the component stays a plain integer under 10; a larger local number takes ds-allow(stacking) directly above it (scripts/validate-stacking.mjs enforces it). A tooltip takes no layer of its own choosing: it calls useTopLayer from src/behaviors/useTopLayer.ts, which paints it above every layer, and the same validator fails a tooltip that does not
  • A viewport @media query switches at a width from src/tokens/breakpoints.ts (max-width: N, or min-width: N + 1 for the same step seen from above), and a JS query is built from that module's mediaBelow / mediaAbove, never a literal string. A content-driven width outside the set takes a ds-allow(breakpoint) directive directly above the query; scripts/validate-breakpoints.mjs fails the build otherwise. Prefer a container query, or measuring the component's own width, when the layout depends on where the component sits rather than on the viewport
  • If a value genuinely cannot use a token (colour-space physics, a glyph inside a control's geometry, decorative timing tuned by eye), sanction it at the site: a /* ds-allow(<category>): <reason> */ line inside a comment at the value (ds-allow-file(...) in the header for file-wide cases), plus a sentence in the component's design.md spec. The category set is the closed list in scripts/validate-css-directives.mjs (grammar is build-enforced). Never add an exception to the token-audit skill — it reads the directives; it maintains no list
  • The surface is the family's, not the component's. Before writing a fill, border, radius, hover, selected or disabled rule, find the component's family in design.md's Components intro ("Surfaces by family") and take its fill, border, radius, hover, selected and disabled tokens from there, never from memory or from this page. A fictional GadgetRow that invents a fourth radius for "a row like the others" is the drift the component-consistency-audit skill exists to find; a departure the design calls for is a sentence in the component's design.md spec, written in the same change
  • A card a chat reply carries joins the chat card family. Add its root class to the :where() list in src/behaviors/chat-card.css, import that file from the component module, and read its --ds-chat-card-* properties for the fill, border, corner, inset, edge, rhythm and one-row height instead of the tokens behind them; design.md's "The chat cards are one family" is the rule set it starts from. scripts/validate-chat-cards.mjs fails an ai component that draws a card shell of its own outside the list, and a member that restates a value the recipe owns.
  • Section comments grouping related rules (e.g. /* Base */, /* Variants */, /* States */)
  • No dark-theme overrides and no prefers-color-scheme queries — dark mode comes entirely from the semantic tokens (every token has a light and dark value). The rare sanctioned exception is recorded in the component's own design.md spec, never assumed

File 3: ComponentName.stories.tsx

  • Import: import type { Meta, StoryObj } from '@storybook/react-vite'
  • Meta uses satisfies Meta<typeof ComponentName>, and sets component: ComponentName. The props-contract story finds the component through that field and borrows its props from the meta's args or the first story with args, so a component the meta does not name, or whose stories supply its required props only through a render function, fails as uncovered. When the stories cannot carry the props as args, add a FIXTURES entry in src/stories/Contract.stories.tsx
  • title: 'Components/ComponentName'
  • tags: ['autodocs']
  • parameters: { layout: 'centered' } for compact components; 'padded' for full-width ones (cards, layouts, nav)
  • One named StoryObj export per meaningful variant or state combination
  • Story names are descriptive (e.g. Default, WithIcon, Disabled, Small)
  • A component whose contract is behavior (dismissal, focus, keyboard) gets a play function asserting it — Dialog.stories.tsx is the reference (stories run as tests, so the assertion is CI). End the play in the component's resting state so snapshots stay stable
  1. Pass design QA before registering. Invoke the design-qa skill on the new component (its "Inside new-component" section defines gate mode): render every story in both themes, magnify the joins, corners, and states, line the component up against its nearest rendered siblings, and loop on fixes until a full pass yields no defect or polish findings. One mechanical note for this pass: npm run storybook cannot cold-start here, because its prestorybook hook runs the validate-registry chain, which correctly fails on the still-unregistered folder — use a Storybook that is already running, or start the storybook-raw configuration in .claude/launch.json (port 6016; it invokes Storybook directly, bypassing the prestorybook hook, and exists for exactly this pre-registration case). Fold any open direction calls into the hand-off in step 7. Rendering correctly is necessary and nowhere near sufficient — this step is where "the buttons aren't good enough" gets caught without anyone having to say it.

    The same gate has an accessibility half, run on the rendered stories rather than the source. For every story, list what assistive tech is actually given: each landmark with its name, each interactive element with its accessible name and state (aria-current, aria-expanded), and any text inside aria-hidden or inert regions. A short script in the browser that queries nav, aside, [role], a, button and reads aria-label, aria-labelledby, innerText and closest('[inert]') produces that list. Check it against the Accessibility rules under File 1. Every state a story shows must appear in the list, and no text may appear twice. Then run the accessibility-audit skill's structural audit (its step 3, the [manual] items) on the component. Fix and re-list until both are clean. A behaviour the list proves (a badge announced, a hidden copy muted) is worth a play assertion, so that it stays proven in CI.

    The gate's last half is the API and the family: run the api-consistency skill scoped to the new component and its nearest family (its shared folder or registry category), then the component-consistency-audit skill scoped the same way. A new prop that names a size, a colour or a callback differently from its siblings, or a surface that takes a fill, radius or hover its family does not, is cheapest to fix before anything consumes it. Fix every finding before registering.

  2. Register the component in src/components/registry.json — add an object to the components array (alphabetical by name):

    { "name": "MyComponent", "label": "My component", "slug": "my-component",
      "description": "One line, ending in a full stop, under 160 characters.",
      "category": "forms", "client": true }

    The description is shipped copy (it feeds the sidebar, page metadata, and README): a verbless one-line fragment ending in a full stop, written per the register table in content-design.md. category must be one of the registry's categories — add a new one deliberately rather than inventing one per component. The label has a build-enforced length ceiling from an unexpected direction: scripts/validate-page-summaries.mjs derives a "How do I use <label>?" chip for every component's TLDR panel and holds it to the suggestion budget, so a long label fails the build there — keep labels short and the failure never appears. client must match whether the file declares 'use client'; the validator compares them and fails on a mismatch, so the registry can never document a component as server-renderable when it isn't. A component that imports recharts also sets "recharts": true — the flag records which barrel it ships from, and the doc block in src/components/registry.ts owns the field list. The sidebar nav entry, sitemap, breadcrumbs, mega-nav and the page's title and description all derive from this entry — never hand-add a nav link. This file is the single source of truth for the component count; scripts/validate-component-registry.mjs runs before every build and fails if the folder is unregistered. Confirm the registration itself with node scripts/validate-component-registry.mjs — the full npm run validate-registry chain stays red by construction until the website page and design.md section exist (scripts/validate-website-surfaces.mjs requires both for every registered component), so the full chain runs at the end of step 7, not here. Its leading generator scripts (the authoritative list is the validate-registry entry in the root package.json) regenerate every derived surface a new component touches: the README's component count/list, the package barrels (src/index.ts, or src/charts.ts if the component imports recharts — the generator routes by detecting the import and fails when the registry entry's recharts flag disagrees with what the module actually imports, so set the flag when the import is real; never hand-edit either barrel), the component prop API (website/src/data/component-api.generated.ts), the component's markdown twin (website/public/components/<slug>.md), its JSON schema and the schema index (website/public/schema/components/<slug>.json, website/public/schema/components.json; scripts/validate-json-schemas.mjs then proves the new file against the meta-schema), the shadcn registry item (website/public/r/<slug>.json, plus its registry.json index), the consumer agent skills' component catalogue (references/components.md in the library skill's folder under website/public/skill/ — AGENT_SKILLS in scripts/brand.mjs owns the folder names), and the site chat's corpus (website/src/data/site-corpus.generated.ts — it carries the component site map and every registry description), and the dependency graph's data (website/src/data/dependency-graph.generated.ts — the new component becomes a node, with its token uses and imports as edges), and the accessibility coverage figures (website/src/data/a11y-coverage.generated.ts — its story total and storied-component share move with the new component, and an overlay joins its rosters by import). Commit every file those generators rewrote in the same push as the registration — alongside it, or as a separate chore(generated): commit when the regeneration would bury the real change (both are sanctioned; the ship skill's step 2 owns the rule). CI's drift guard diffs the tree after the generators run, so a regenerated file left uncommitted fails the build. The component is then automatically part of the published rift-ds API — both the barrel and the ./components/* deep-import subpath. That wildcard maps .tsx only. A sibling .ts module in the same folder (a hook, a data table, a shader source) reaches consumers through the barrel if the .tsx re-exports it (a value re-export from a .tsx trips the react-refresh/only-export-components lint rule, so it sits under an eslint-disable for that rule with the reason written beside it, as ShaderField.tsx does; export type needs none), but has no deep-import path of its own until you add one — and a subpath has three homes, not one: SUBPATHS in scripts/package-manifest.mjs (the authoritative record of the ones that exist), the root package.json exports field it is mirrored into by hand, and the dist entry list in vite.lib.config.ts (the new-token skill's step 5 is the same recipe). scripts/validate-package-exports.mjs fails the build if any of the three disagree, so a subpath cannot reach npm with no built module behind it. Decide deliberately: a module that is genuinely internal wants no subpath, and one you document as an escape hatch needs one. scripts/validate-package-exports.mjs checks the answer either way — an unexported .ts sibling must be re-exported by the component or named in that script's INTERNAL_MODULES list with a reason. A sibling .tsx is the other way round: the ./components/* wildcard and the barrel publish it the moment it exists, so a part that is not public API (a grid two pickers share, a toggle one component owns) goes in INTERNAL_PARTS in scripts/package-manifest.mjs with its reason, which closes its deep path with a null export and keeps it out of the barrel; copy sourceExports() into the root package.json after adding one. If the component needs a new runtime dependency, stop and ask: the package's runtime deps are the react peer and the optional peers (peerDependenciesMeta in the root package.json is the list: recharts for the charts, and the chat widget's own for its subpaths), and adding one is a packaging decision.

    One exception: a docs-only helper (a component that exists purely for the website/Storybook docs — the registry's docOnlyHelpers array is the authoritative list; note the public Swatch component is not one of them) goes in the registry's docOnlyHelpers array instead of components — it gets no showcase page, no barrel export, and doesn't count. Putting it in components fails the build for a missing website page.

  3. Document it in design.md — add a short component spec section (class name, tokens used, key behaviours), following the format of the existing component sections.
  4. Hand off the website work. A component is not done until it has a showcase page: a page.tsx, a page.module.css, a layout.tsx containing exactly export const metadata = componentPageMetadata("<slug>");, and a preview entry under its slug in website/src/components/ComponentPreviews/ComponentPreviews.tsx. The preview is the only surface still hand-maintained — each entry holds a bespoke live miniature. Everything else (the index's category section, sidebar accordion, sitemap, breadcrumbs, title, description) derives from the registry entry. All of it is build-enforced by scripts/validate-website-surfaces.mjs and scripts/validate-page-titles.mjs. Ask the owner: "Should I add the website documentation page now? (invokes the component-doc-page skill)" — and whoever does that work must complete every registration above, then run npm run validate-registry: with the page and the design.md section in place, this is the first point in the flow where the whole chain can go green.

    One surface is curated and not build-enforced: the playground's Components view, whose full list is staged from the per-category section files in website/src/app/playground/sections/ and whose collage is website/src/app/playground/views/CollageView.tsx. Decide with the owner whether the new component earns a place there; nothing fails if it is left out, so the decision has to be made on purpose.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.