Design System — Component Authoring
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/frontend/references/ds-component.md |
| Description | Not specified |
Source Content
Design System — Component Authoring
Build and document React components for @dmwd-io/design-system. Absorbed from the former ds-component skill. Always pair with references/storybook.md (story setup, argTypes) and the design-principles skill (icon grid ADR-009, border ADR-008 — those live outside this skill, in design-principles).
File structure
Every component lives in src/components/ui/ (primitives) or src/components/patterns/<domain>/ (composed patterns). Three files minimum:
src/components/ui/ component-name.tsx # component + types + CVA — no test code component-name.stories.tsx # Storybook stories — required, same directory component-name.test.tsx # Vitest unit tests — when behaviour warrants itExport the component, the Props type, and the CVA helper from the .tsx file. Register the export in src/components/ui/index.ts.
Props interface
Every exported *Props interface follows this shape exactly:
import type { HTMLAttributes, ReactNode } from "react";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "@/lib/utils";
const componentNameVariants = cva( // base classes — semantic tokens only, no raw hex, no Tailwind palette numbers "inline-flex items-center rounded-lg font-body text-sm transition-control duration-fast ease-standard focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { primary: "bg-primary text-primary-foreground shadow-sm hover:bg-surface-strong", secondary: "bg-secondary text-secondary-foreground hover:bg-muted", outline: "border border-border bg-background text-foreground hover:bg-secondary", ghost: "text-foreground hover:bg-secondary", }, size: { sm: "h-8 px-3 text-xs", md: "h-9 px-3.5", lg: "h-10 px-4", }, }, defaultVariants: { variant: "primary", size: "md", }, },);
/** Props for the {@link ComponentName} component. Extends native `<button>` attributes and CVA `componentNameVariants`. */export interface ComponentNameProps extends HTMLAttributes<HTMLElement>, // or ButtonHTMLAttributes, InputHTMLAttributes, etc. VariantProps<typeof componentNameVariants> { /** Short prose sentence. Defaults to `"primary"`. */ variant?: "primary" | "secondary"; /** Icon rendered before the label. Wrap in aria-hidden="true" inside the component. */ leadingIcon?: ReactNode; /** Shows a loading spinner and disables interaction. */ loading?: boolean;}JSDoc rules (non-negotiable):
- Every prop gets a short JSDoc sentence — one line, plain English.
- Write defaults as prose:
Defaults to \”md”`.— never@default`. asChild,className,children,disabled,refare inherited from the HTML type — do not redocument them.
Component function
/** * Short description of what this component is and does. * * Use for [primary use case]. Renders as a `<button>` — interactive. * @param props - Props for the component. * @returns The rendered component. */export function ComponentName({ className, variant, size, leadingIcon, loading = false, children, ...props}: ComponentNameProps) { return ( <button className={cn(componentNameVariants({ variant, size }), className)} disabled={loading} aria-busy={loading || undefined} {...props} > {leadingIcon ? ( <span className="mt-0.5 shrink-0" aria-hidden="true">{leadingIcon}</span> ) : null} {children} </button> );}
export { componentNameVariants };Function rules:
- Always
export function— neverconst Component = () =>or arrow functions for components. - Destructure inside the function body, not in the parameter list signature.
- Use
cn()for everyclassNamemerge — never template literals or string concatenation. - Use CVA for conditional class logic — never stack
classNameternaries inline. ...propsspread goes on the root element; never spread on an inner child.
Icon layout (ADR-009 — non-negotiable)
Any component with a leading or trailing icon uses a CSS grid, never flex. Content never stacks below the icon.
// Two-column: icon | content<div className="grid grid-cols-[auto_1fr] items-start gap-3"> <ComponentIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden="true" /> <div> <p className="text-sm font-medium text-foreground">Title</p> <p className="text-xs text-muted-foreground">Supporting text</p> </div></div>
// Three-column: icon | content | action<div className="grid grid-cols-[auto_1fr_auto] items-start gap-3"> <ComponentIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden="true" /> <div>…content…</div> <Button size="sm" variant="ghost">Action</Button></div>Load the design-principles skill before building any icon-bearing component.
Token rules
| Rule | Do | Don’t |
|---|---|---|
| Colors | text-foreground, bg-card, border-border, text-primary | text-gray-500, bg-blue-100, #3b82f6 |
| Opacity modifiers | bg-muted/40 | bg-gray-100/40 |
| Radius | rounded-xl (cards) · rounded-lg (buttons/inputs) · rounded-md (badges) | raw rounded-[10px] |
| Typography | font-body, ui-type-* tokens | web fonts, font-inter, font-sans |
| Borders | one border border-border per local stack | nested border border-border on child of bordered parent |
Accessibility checklist
Every component ships these:
- Semantic HTML element —
<button>for action,<a href>for navigation,<input>for field. Never<div onClick>. -
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2on every interactive element. Neveroutline-nonewithout a ring replacement. - Decorative icons:
aria-hidden="true". Icon-only buttons:aria-labelortitleon the button. - Minimum touch target 44×44px for mobile interactions.
- State conveyed by more than color alone (pair color change with icon or text).
-
disabled:pointer-events-none disabled:opacity-50via CVA base classes. -
aria-busyon loading states.
Storybook stories (required)
Every component needs a .stories.tsx file in the same directory. Use @storybook/react-vite, createComponentDocs, and satisfies Meta<>.
import type { Meta, StoryObj } from "@storybook/react-vite";import { createComponentDocs } from "@/lib/storybook-docs";import { ComponentName } from "./component-name";
const meta = { title: "Foundations/ComponentName", // check ADR-016 for the correct root section component: ComponentName, tags: ["autodocs"], parameters: createComponentDocs({ componentName: "ComponentName", summary: "One sentence: what this component IS and its job.", whenToUse: "Concrete scenario when an engineer should reach for this.", whenNotToUse: "What to use instead and why.", accessibility: "Screen reader, keyboard, and contrast notes specific to this component.", dummyData: "Realistic example values for controls (e.g. 'Short labels: Done, In progress, Pending').", controls: "Which controls are wired and what they demonstrate.", }), args: { // sensible defaults shown in every story }, argTypes: { // see references/storybook.md for argType patterns },} satisfies Meta<typeof ComponentName>;
export default meta;type Story = StoryObj<typeof meta>;Nine UI states — cover all that apply:
| State | Story name pattern |
|---|---|
| Default | export const Default: Story = { args: {} } |
| Variant sweep | One named export per variant: Primary, Secondary, Outline |
| Size sweep | Small, Medium, Large |
| With icon | WithLeadingIcon, WithTrailingIcon |
| Disabled | export const Disabled: Story = { args: { disabled: true } } |
| Loading | export const Loading: Story = { args: { loading: true } } |
| Error / destructive | export const Destructive: Story = { args: { variant: "destructive" } } |
| Empty / no content | Story with no children / empty list |
| Success | Complete, Active, or success variant story |
Pair with references/storybook.md for full story setup rules and argType patterns.
Pre-commit gates
Always run before declaring a component done:
pnpm typecheck # tsc --noEmit — zero errors requiredpnpm vitest run --project unit # unit tests greenpnpm build-storybook # for story or visual changesscripts/lint_ds_component.sh src/components/ui/<component>.tsx # house-rule check belowscripts/lint_ds_component.sh <component-file.tsx> checks the house rules from this file: every prop has JSDoc, CVA variants are used for style branching, cn() composes classes, a sibling .stories.tsx exists, and the named-export shape is correct.
What NOT to do
- No
style={{}}for visual mechanics — all visual properties belong in CVA orcn(). - No multi-line conditional
classNameternaries — introducecva()instead. - No
@defaultin JSDoc — write defaults as prose. - No
const Component = (): JSX.Element =>— useexport function. - No raw Tailwind palette colors (
gray-500,blue-100) — semantic tokens only. - No web fonts (
font-inter,font-sans,font-serifmapping to a custom face) — system UI stack only. - No
flex-colon icon rows — CSS grid only (ADR-009). - No second
border border-borderinside a parent that already has one (ADR-008). - No
outline-nonewithout afocus-visible:ringreplacement. - No
aria-hiddenon interactive elements. </content>