Skip to content

Component lifecycle

What it is

Every component in packages/components/src/ ships four co-located files — index.tsx, a CSS Module (a stylesheet scoped to just that component), a stories file (its Storybook examples), and ComponentName.metadata.json — and moves through a lifecycle tracked on two independent axes: maturity (is it production-ready?) and implementation (where is it in the build-review-learn pipeline?). The metadata file is the machine-readable contract that the agentic tooling reads; the two-axis lifecycle is how code-derived state and human sign-off coexist without overwriting each other.

Why it’s built this way

Metadata as JSON, validated against a schema

ADR-001 records the format decision. Agentic moments like /component-scaffold and /layout-generation need structured per-component context — purpose, constraints, anti-patterns, relationships — and without a shared schema “each component would accumulate ad-hoc documentation in inconsistent shapes.” JSON won over Markdown on evidence: research by Indeed (Diana Wolosin, Into Design Systems 2025) benchmarked 8 MCP configurations across 1,056 prompts and found JSON reduced token cost by ~80% with equal or better accuracy — “JSON is unambiguous: explicit keys, explicit values, no parsing required.”

The schema was synthesized from three sources (giorris.dev’s usage/variants/antiPatterns base, hvpandya.com’s tokens list, the LLM Component Schema Standard’s generativeRules) and deliberately excludes some of what those sources include: anatomy lives in Storybook instead; aiHints/generativeRules were distributed into usage.keywords, usage.when, and composition/layout fields rather than kept as a monolithic block.

The 2026-06-17 amendment tightened it after a foundation review found 7 of 11 metadata files failed the schema: variants became a map of named axes (each axis holding { options, default, purpose }), one taxonomy was enforced (component.category ∈ atom|molecule|organism|layout, component.type ∈ interactive|display|container|input), and validation moved into CI (scripts/validate-metadata.js, ajv — the JSON-schema validator library — draft 2020-12, run in components-check.yml).

Two axes, because one field kept lying

ADR-010 starts from a concrete symptom: the Airtable Components table had a single Status field mixing production-readiness (beta/ready/deprecated) with pipeline progress (todo/in progress/in review). A component that had completed a full adversarial review still showed only ready; Accordion was beta in maturity yet its implementation was finished — one field cannot express both facts. The decision: two axes, each owned by the right process and flowing in the right direction.

Value Axis Owner Set where
beta / ready / deprecated Maturity Code component.status in metadata → pushed to Airtable’s Maturity column
in progress / in review Implementation Code (derived) sense.js derives from handoff artifacts → pushed to Implementation
done / todo Implementation Human Set directly in Airtable, pulled into .claude/component-signoff.json, never overwritten

The Implementation axis holds exactly four broad stages (the only values that ever reach Airtable — issue #64, ADR-010 amendment), and the derivation rules are machine-checkable. in progress = code exists but the review pipeline hasn’t begun; a substate field in .claude/component-pipeline.json distinguishes unreviewed (no loop artifacts at all — a lone .snapshot.json is just a context cache; this replaces the old established label) from scaffold-underway (a .run.json open with no render checkpoint yet). in review = generation is complete and renderable, so the review checklist has begun — the component stays here, as committable work-in-progress, until human sign-off.

The checklist has four items, rendered per component in STATUS_QUO.md and derived at render time by sense.js (no stored checklist state): (1) the automated gate — lint, typecheck, build, metadata, a11y scripts as one pass/fail item; (2) visual review — a human y/n/other(comments) answer recorded as visualReview: { status, comments, at } in .claude/component-review-state.json by /add-component or /layout-generation right after the render checkpoint; (3) code review — labelled “Code and behavioural a11y review — adversarial subagent” for Tier-2 interactive components (ADR-008), plain “Code review” otherwise; (4) learnings back-fill via /extract-learnings. Items a path doesn’t require render as explicit n/a — reason lines, never silently omitted.

Which review route a component took is recorded in a reviewPath field: full (/review-component — a fresh adversarial reviewer subagent plus the learnings loop) or standard (/code-review run inside the working session, no subagent and no separate learnings step, so item 4 is n/a). Human done/todo values win over the derived stage, and the push never overwrites them — the “don’t downgrade done” guard, covered in Governance. Because the two axes never share a column, a pushed value and a human value can never collide: Accordion reads Maturity: beta + Implementation: done simultaneously.

The three-question test before any new component

ADR-009 is the anti-proliferation rule, applied in order:

  1. Same semantic role (same role, interaction model, visual family; differs only in a containable presentational detail) → extend the existing component with a prop or variant. Example: trailingIcon on Button — the low-weight link-styled variant (ghost, now transparent — ADR-024) already carried the action semantics; icon placement is presentational.
  2. Different semantic role despite similar shape (different chrome at rest, different icon axis, different state model, different ARIA role or keyboard contract) → new component. Example: ButtonArrow (navigation — bordered, disabled state, left/right) vs the Accordion disclosure trigger (borderless at rest, open/collapsed, up/down). Per the ADR: “they share a circular shape but nothing else load-bearing.”
  3. Single parent, no other consumer in the fixed set → molecule-internal styled element in the parent’s CSS Module, not extracted to src/components/. Example: the Accordion expand trigger — internal to Accordion “the same way <summary> is internal to <details>.” The signal: you cannot name a second consumer without inventing a hypothetical.

Visual similarity alone is never a reason to create a new component or to merge two that differ in role.

Parts are not new components

ADR-023 adds one more outcome to the test above. Sometimes a requirement passes question 1 (same role, so extend the existing component), yet covering it with props would need several props that are only valid in certain combinations. CardVertical was the trigger: a favorite toggle and an overflow menu that must never appear together, plus consumer-specific content such as an instructor row. In that case the component gains parts instead of more props: named subcomponents reachable as CardVertical.Media, CardVertical.Body and so on.

The existing prop API stays as a preset, rebuilt from the parts, so nothing breaks. Parts never enter the fixed component set and have no folder of their own. They are declared only in the parent’s metadata (composition.parts), and the gate checks them like any other metadata field. “Make the common configurable, make the uncommon composable”: the preset covers the common case, and the parts cover the rest.

ADR-006 is the same don’t-over-build spirit applied a level up. The Homepage needed a horizontal card carousel; the options were a full Carousel component, a hook + documented pattern, or nothing. Prior art split cleanly: Polaris, Atlassian, Radix, and Chakra ship no carousel component (primitives + pattern); Carbon, Ant, and Bootstrap ship one but are full product frameworks that accept the maintenance cost; shadcn/ui wraps Embla. The decision: useCarousel(itemCount, visibleCount) encapsulates only the error-prone shared logic (offset tracking, canPrev/canNext, prev/next/reset); layout and markup stay in consuming code, with the Layout/Examples/Carousel story as the canonical reference. “Nothing” was rejected because developers would diverge on disabled-state logic and produce off-by-one bugs. The revisit trigger is explicit: 3+ distinct layouts or raised a11y requirements.

How it works, concretely

The metadata schema (packages/components/component.schema.json) requires four top-level properties: component, usage, variants, composition (originally named relationships — see the ADR-001 2026-07-23 amendment). states, tokens and accessibility moved to each component’s <Name>.spec.json, its component spec, on 2026-10-11 (ADR-001 amendments, ADR-027). Metadata that still carries them fails the schema. The token list is now whatever the spec’s styles reference. Scripts that need metadata and spec combined in one object read it through readComponentMetadata in scripts/lib.js. What each section holds, why, and who consumes it is the subject of 10 — Machine-readable metadata; this page keeps only the lifecycle-relevant shape. composition may also carry an optional parts list for components with subcomponents (ADR-023); scripts/validate-metadata.js cross-checks each part’s references against real components and sibling parts. A variants block modelled as named axes looks like:

"variants": {
"variant": { "options": ["primary", "secondary", "ghost"], "default": "primary", "purpose": "visual emphasis of the action" },
"size": { "options": ["sm", "md", "lg"], "default": "md", "purpose": "control height and label size" }
}

The fixed component scope grows only by explicit phase decision (see AGENTS.md “Components”): the Phase 4–5 core (Box, Stack, Inline, Text, Heading, Icon, Button, TextField, Select, Checkbox, Card), then 5b (Avatar, AppHeader, Breadcrumb, Divider, ProgressBar, CardHorizontal), 5c (CardVertical, Chip, VideoFrame, ButtonArrow, ScrollArea), and 5d (Accordion, Badge, the Button ghost variant — renamed transparent in ADR-024 — the useSlider hook). New components enter through the verified /add-component loop or the /component-scaffold moment — see Agentic moments.

After any scaffold or edit, the deterministic gate must pass with no manual fixes: npm run metadata:validate && npm run typecheck && npm run build && npm run spec:validate && npm run a11y:coverage && npm run a11y:test, plus a render check in both themes in Storybook. components-check.yml runs the same gate on every PR.

Diagram

flowchart TB
    subgraph M[Maturity axis — code-owned]
        direction LR
        beta --> ready --> deprecated
    end
    subgraph I[Implementation axis]
        direction LR
        subgraph derived[derived by sense.js]
            inprogress["in progress<br/>(unreviewed / scaffold-underway)"]
            inreview["in review<br/>(checklist: gate → visual → code review → learnings)"]
            inprogress --> inreview
        end
        subgraph human[human-set in Airtable]
            todo
            done
        end
    end
    meta[component.status<br/>in metadata] --> M
    artifacts[.review.json / .run.json / visualReview<br/>handoff artifacts + review state] --> derived
    M -->|pushed| A[(Airtable<br/>Maturity column)]
    derived -->|pushed, skips cell<br/>if human value present| B[(Airtable<br/>Implementation column)]
    human -->|pulled, wins over derived| B