| name | new-component |
| description | Add a new component to Construct's design system (atom, molecule, or organism) following every house convention — placement, props contract, token styling, demo page, registration, docs. Use whenever building a component from a roadmap/design issue or from scratch. |
New Construct Component
The ritual for adding a component so it lands indistinguishable from the ones already here. Work through the sections in order; the Verify section gates the finish.
1. Placement
1-atom/ — renders one thing, composes nothing of ours (Text, Pic, Code).
2-molecule/ — composes atoms, owns a small interaction (BaseInput, Chip).
3-organism/ — multi-molecule machinery (Overlay, Accordion).
- Root of
app/components/ — behavioral/choreography components with no visual substance of their own (Reveal, P5).
- No namespace prefixes — all dirs register with
pathPrefix: false.
2. Props contract (match the Base molecules)
- Controlled component:
modelValue + emit("update:modelValue"). The component never mutates its own open/value state directly — one code path, driven by the prop (see Overlay.vue for the reference pattern).
withDefaults(defineProps<Props>()), TypeScript interface, no runtime validators.
- Size variants where sensible:
size?: "small" | "medium" | "large" mapping to t-micro-1 / t-caption-2 / t-body-2 classes. These names are component variants, not spacing units — the unit scale is numeric (--unit-8, pb-16).
- Ids:
useId() per instance, never label-derived (const uid = useId() — see BaseSelect for why: label-derived ids collide the moment two instances share a label).
- Labels: the component owns its own
<label :for> pairing, class input-label t-caption-2 pb-8 (all four Base molecules use exactly this).
- Form controls must speak the FormField contract: accept
invalid?: boolean and describedBy?: string, bind them as :aria-invalid="invalid || undefined" and :aria-describedby="describedBy" on the real control element, not the wrapper div (attrs fallthrough hits the wrapper — bind explicitly). Add is-invalid class for the --status-error border.
- Icon props type from the single source:
import type { IconName } from "~/components/1-atom/Iconography.vue" — never hand-copy the union.
3. Styling rules
- Tokens only. Colors from
foundations/color.scss custom properties; spacing from the numeric unit scale (var(--unit-8), utility classes pb-8/mt-32); radii from --radii-*. Zero hex values in component styles.
- Both themes, automatically. If you only use tokens, themes come free. If you need a new token (e.g. a status color), add it at
:root (dark) AND mirror it in the theme-light mixin at the mirrored scale position (gray-50↔950, violet-300↔700, red-300↔700) — verify ≥4.5:1 contrast on both --background-primary values.
- Focus rings gate on input modality:
[data-input="keyboard"] &:focus-visible (mouse clicks match :focus-visible on text inputs too — spec heuristic).
touch-action: manipulation comes free from reset.scss on interactive elements; don't undo it.
- Motion: every tween branches on
deviceStore.userMotionReduced — gsap.set (snap) instead of gsap.to. No exceptions; this is the house rule that also makes hidden-tab browser verification possible.
- Never add CSS-emitting code to
_global.scss. Shared form styles live in atoms/inputs.scss / atoms/buttons.scss, imported per-SFC.
4. Demo page (app/pages/demo/<rung>/<name>.vue)
Structure every existing page follows — copy the skeleton from a recent one (breakpoint.vue, theme.vue):
DemoPageHeader with the component name.
- Overview
DemoPageBody: what it is, the one-sentence design rationale, a Code usage snippet.
- Live example(s): real working instances in an
.example-box. Cover every variant and state (default / with label / disabled / error via FormField for form controls).
- API Reference
DemoPageBody: a Code block listing every prop, emit, and slot with types, plus a /* Contracts worth knowing */ comment for the non-obvious rules.
Gotchas:
Code blocks: no theme prop (they adapt to light/dark automatically; a theme="github-dark" locks it). No backticks inside :code template literals — they terminate the attribute expression and break the page compile; use quotes in snippet code.
- Page boilerplate:
useSeoMeta(pageSEO({ title: "<Name> Documentation" })) + definePageMeta({ pageTransition: pageTransitionDefault() }), both imported explicitly (not auto-imported).
5. Registration
app/assets/scripts/navigation.ts — add the demo page entry (feeds both SectionNav and the header menu).
scripts/smoke.mjs — add the demo route to the routes array. Every shipped feature's demo page is smoke-covered.
6. Docs
CLAUDE.md: add the component to its rung's list in "Component hierarchy". If it introduces a contract others must follow (like FormField's slot props), give it a short ### section.
README.md: add to the relevant list if it's scaffolding future forks should know about.
7. Verify
npm run build && npm run smoke
Then verify in the browser (see the verify-browser skill): every interactive state, keyboard operation, both themes, and reduced-motion behavior. A component isn't done because it compiles — it's done when its demo page proves every claim its API reference makes.