Create a component

Build a typed vanilla-extract component from ELB's Figma: anatomy, theme contract, colour-set recipe and registration.

2026-11-07

Scaffold a component

Every component on the Euro League site — in @v4/ghost-core and the sibling @v4/ghost-content, @v4/ghost-video and @v4/ghost-basketball packages — is a rebuild of a Figma component set as a typed, vanilla-extract React component, and the Figma files it is built from are ELB's. This page takes you from an empty directory to a building skeleton; the next two pages wire the theme contract and register the component.

The authoritative conventions live in the rules files this guide links — .claude/rules/frontend/component-architecture.md is the one to have open while you work. Nothing here overrides it.

Which Figma file

ELB's design lives in five Figma files. Three of them — ELB – Web – Core, Content and Video — began as copies (forks) of the Ghost 2.0 files the template's components were first built from, and ELB's design team then changed them in place. ELB – Web – Basketball and ELB – Web – EXT are ELB's own. Build from ELB's file:

Package / family Built from
Chrome and navigation (@v4/ghost-core) ELB – Web – Core HIjezWNeRQSBGkxx8VqmjA — ELB's fork of Ghost Core
Every @v4/ghost-content component ELB – Web – Content 9iz5GeR9syO68cmPr1ppE1 — ELB's fork of Ghost – Content 2.0
@v4/ghost-video ELB – Web – Video gR9NZ422d1RuQbdgtQ8VA9 — ELB's fork of Ghost – Video 2.0
@v4/ghost-basketball ELB – Web – Basketball 8Jl1miPEIZslMda8jZ8w0C (the ECAL schedule picker: EXT Yqy0zItrtL4UptOVW54wVg)
Everything else in @v4/ghost-core Ghost – Core 2.0 67J5FrDZn5HzYzf5hJgoNA — the upstream ELB has not forked these from

The ELB forks preserve the upstream node ids, so a bridge (<kebab-name>.figma.json) naming the Ghost fileKey resolves every id perfectly while measuring the wrong design. Record the file you actually built from; node packages/token-parser/scripts/check-bridges.mjs (part of pnpm verify) fails a bridge naming a file this repo is not built from (the map is figma.files / figma.clientFiles in .claude/manifest.json). Before building against a fork, check the design is finished — /fork-status, or node packages/token-parser/scripts/design-status.mjs --file <ELB file key>. The full workflow for a component that already exists is Refactor a component.

⚡ Do it with Claude — run /generate-from-figma <url> [name] [category] --package=<package> to scaffold the whole component from a Figma node (--package=ghost-basketball for a sibling; ghost-core by default): token extraction, the theme contract, .css.ts, the component, tests, stories, the companion MDX docs, and a pixelmatch visual-comparison loop against the Figma export. This guide covers what that pipeline produces and the manual path underneath it — read it either way, because reviewing the skill's output means knowing the shape it should land in.

Run the scaffolder

The mechanical registration scaffolder creates the directory skeleton and the handful of registrations that would otherwise be easy to miss:

pnpm --filter @v4/ghost-core new-component <kebab-name>

new-component is a ghost-core script; the sibling packages have none, and /generate-from-figma --package=<package> does the same registrations there.

Names are kebab-case and flat — the atomic level (atom / molecule / organism) is a Storybook categorisation, never a path segment. new-component man-of-the-match is right; new-component organisms/man-of-the-match is not.

The script is idempotent, so it is safe to re-run. It:

  • creates packages/ghost-core/src/components/<kebab-name>/ with the figma-screenshots/ and tokens/ sub-directories,
  • inserts the barrel export into src/index.ts (kept alphabetical),
  • inserts the side-effect theme import into src/init.css.ts and appends the camel-cased name to that file's OWN_THEMES list (owner-keyed emission — a component whose theme is not in the list fails the package's check-theme-owners gate),
  • adds the bridge entry to packages/ghost-core/figma.json.

It deliberately does not write the component, theme, CSS or test files — that generative work is yours (or /generate-from-figma's). Its closing line says as much: "Scaffolded … — now run /generate-from-figma for the real work."

A note on register-LAST. The scaffolder writes the barrel export and the init.css.ts import before the files they point at exist, which is exactly the situation that blanks Storybook (one unresolvable specifier fails the whole vanilla-extract compile). That is fine when you go straight on to write the files — but if you scaffold and stop, comment the two registration lines out with a RESTORE marker, or write the files first. See Register and test for the full rule.

The directory anatomy

One directory per component. Every file has a single responsibility, and the kebab-name prefix is fixed:

packages/ghost-core/src/components/<kebab-name>/
├── index.ts                       # barrel export (component + mock + theme + types)
├── <kebab-name>.component.tsx      # the React 19 function component
├── <kebab-name>.css.ts             # vanilla-extract structural styles (recipe/style/globalStyle)
├── <kebab-name>.theme.ts           # the token contract (buildGlobalTheme + registerComponentTheme)
├── <kebab-name>.types.ts           # props interface, state unions, colour-set constants
├── <kebab-name>.test.tsx           # vitest + Testing Library unit tests (never .spec)
├── <kebab-name>.mock.ts            # mock props (plain data; .tsx only if it needs JSX)
├── <kebab-name>.figma.json         # the Figma node map (design-to-code bridge) — names ELB's file
├── figma-screenshots/              # reference PNGs exported from Figma (optional)
└── tokens/                         # extracted Token Studio bindings + _manifest.json (optional)
File Owns Never contains
.theme.ts Colours, spacing, radii, opacity, dimensions, motion — anything overridable Structural layout, selectors
.css.ts display, flex, position, overflow, pseudo-states, media queries Literal colour/spacing values (reference t.*)
.component.tsx Markup, ARIA, event handlers, assignInlineTokens Style logic beyond the inline-token pass
.types.ts Exported props, tokens?: DeepPartial<…>, colourSet?, state unions any (ESLint no-explicit-any is an error)

What each generated file looks like

For a display atom the files are small. Here is the shape the scaffold grows into — index.ts first, because it is pure boilerplate and it fixes the public surface:

// index.ts — the barrel. Stories and tests import from here, never a deep path.
export { default as Badge } from "./badge.component";
export * from "./badge.mock";
export * from "./badge.theme";
export * from "./badge.types";

The types file is where you declare the API. Note three house patterns: the colourSet prop stays the full ColourSet union even when the component only supports a subset (it degrades at the boundary — see Theme and styles); a supported-set constant is exported as PH_<NAME>_COLOUR_SETS; and every exported member carries JSDoc so the Storybook autodocs props table is meaningful.

// badge.types.ts
import type { HTMLAttributes, ReactNode } from "react";
import type { ColourSet, DeepPartial } from "../../types";
import type { BadgeTokens } from "./badge.theme";

/** The colour sets Badge supports — a subset of the full ramp. The prop stays
 * the full `ColourSet` union; unsupported values degrade to `"default"` at the
 * boundary (recipes silently drop unknown variant values → unstyled render). */
export const PH_BADGE_COLOUR_SETS = ["default", "inverse"] as const satisfies readonly ColourSet[];
export type BadgeColourSet = (typeof PH_BADGE_COLOUR_SETS)[number];

export interface BadgeProps {
  /** Label rendered inside the pill (uppercased via the `textCase.uppercase` token). */
  text: string;
  /** Optional leading slot — opaque decorative content, typically an icon from `@v4/tokens/icons`. */
  leadingElement?: ReactNode;
  /** Optional trailing slot — opaque decorative content such as a club logo. */
  trailingElement?: ReactNode;
  /** Colour set. Badge supports `default` and `inverse`; any other value degrades to `default`. */
  colourSet?: ColourSet;
  /** Per-instance token overrides, applied as inline CSS custom properties. */
  tokens?: DeepPartial<BadgeTokens>;
  /** Extra class name(s) appended to the root element. */
  className?: string;
  /** Overrides the root `data-testid`; child ids derive from it as BEM children (`{testId}__*`). */
  testId?: string;
  /** Native attributes spread onto the root element. */
  nativeProps?: HTMLAttributes<HTMLDivElement>;
}

The component file is markup plus attributes — no style logic beyond assignInlineTokens for the tokens prop. It always stamps data-component="<PascalName>" and a data-testid, always composes the two theme class names alongside the recipe class, and derives child test ids BEM-style:

// badge.component.tsx (abridged)
import type { ReactNode } from "react";
import { TEST_ID } from "../../constants";
import { globalTheme } from "../../theme/default.theme";
import { classnames } from "../../utils/classnames";
import * as styles from "./badge.css";
import { badgeTheme } from "./badge.theme";
import { PH_BADGE_COLOUR_SETS, type BadgeColourSet, type BadgeProps } from "./badge.types";

const resolveColourSet = (colourSet: BadgeProps["colourSet"]): BadgeColourSet =>
  PH_BADGE_COLOUR_SETS.includes(colourSet as BadgeColourSet)
    ? (colourSet as BadgeColourSet)
    : "default";

export default function Badge({
  text,
  colourSet = "default",
  tokens,
  className,
  testId = TEST_ID.BADGE.COMPONENT,
  nativeProps = {},
}: BadgeProps): ReactNode {
  return (
    <div
      {...nativeProps}
      className={classnames(
        globalTheme.themeClassName, // ph__global-theme
        badgeTheme.themeClassName, // ph__badge-theme (defensive: works outside a provider)
        styles.badge({ colourSet: resolveColourSet(colourSet) }),
        className,
      )}
      style={badgeTheme.assignInlineTokens(tokens)}
      data-component="Badge"
      data-testid={testId}
    >
      <span className={styles.text}>{text}</span>
    </div>
  );
}

The mock is plain data — slot props that need JSX are layered on in the Storybook mock, not here — and uses sports sample content and placehold.co images:

// badge.mock.ts
import type { BadgeProps } from "./badge.types";

export const badgeMockProps: BadgeProps = {
  text: "Man of the Match",
  colourSet: "default",
};

Composition fidelity

If the Figma design composes other components, the React component imports and composes the real ones — never a re-implementation, never inline HTML that imitates a library component. If a composed child does not exist in code yet, build it as its own standalone component first, then import it. A missing child is a build gap, not a licence for placeholder markup. A ReactNode slot is legitimate only for genuinely consumer-owned artwork (crests, sponsor logos, background imagery) — and where a slot stands in for a real component, the story mock must pass the real component.

Next

Theme and styles

A component is split down the middle: overridable values live in .theme.ts as a typed token contract, and structural styles live in .css.ts referencing that contract. This split is what lets ELB's token package re-skin the whole library with one createTheme() call, and it is enforced by the rules — never put a colour or a spacing literal in a .css.ts, and never put display: flex in a .theme.ts.

The theme contract (.theme.ts)

The theme file is one registerComponentTheme(buildGlobalTheme(...)) call. buildGlobalTheme('<camelName>', values) walks the value object, emits a CSS custom property per leaf (prefixed --ph__<kebab-name>-*, kebab-casing every path segment), and returns a themeContract of var(--…) references that the .css.ts consumes. registerComponentTheme adds it to the registry that createTheme() walks.

There are two ways to source the values.

Generated contract (the target). When the component's Token Studio namespace exists in the Ghost 2.0 tokens.json, the theme file could be a single wiring statement over the generated per-component contract. @v4/tokens exports ./components/*, but no component theme imports one yet — every theme today is hand-authored against the global contract (below) — so treat this as the intended shape:

import { registerComponentTheme, buildGlobalTheme } from "@v4/ghost-core/theme";
import type { MapTokensContract } from "@v4/ghost-core/theme";
import { buttonTokens } from "@v4/tokens/components/button"; // generated — never hand-edit

export const buttonTheme = registerComponentTheme(buildGlobalTheme("button", buttonTokens));
export type ButtonTokens = MapTokensContract<typeof buttonTheme.themeContract>;

Hand-authored contract (the fallback). Components whose namespace has not landed yet author the value object by hand against the global defaults contract, and migrate to the generated import — swapping one line — when design catches up. g is the global contract; reference its paths, never literals:

import { globalTheme } from "../../theme/default.theme";
import { buildGlobalTheme } from "../../theme/helpers/build-global-theme";
import { registerComponentTheme } from "../../theme/registry";
import type { MapTokensContract } from "../../types";

const g = globalTheme.themeContract;

export const badgeTheme = registerComponentTheme(
  buildGlobalTheme("badge", {
    // Structural tokens (shared across colour sets)
    paddingInline: g.semantic.spacer["4"], // S.Spacer.4
    paddingBlock: g.semantic.spacer["0"], // S.Spacer.0 — a None-valued binding, still wired
    gap: g.semantic.spacer["4"],
    borderRadius: g.semantic.borderRadius.medium,
    borderWidth: g.semantic.borderWidth.none,
    boxShadow: g.semantic.boxShadow.none,
    opacity: g.semantic.opacity.none, // S.Opacity.None (= 1, fully opaque)
    // TODO: Token Studio: Figma frame height is a FIXED 24px with no token (design question).
    height: "24px",

    // Nested element tokens (a composite typography token, expanded)
    text: {
      fontFamily: g.primitive.ltr.labels.small.labelXS.fontFamily,
      fontWeight: g.primitive.ltr.labels.small.labelXS.fontWeight,
      fontSize: g.primitive.ltr.labels.small.labelXS.fontSize,
      lineHeight: g.primitive.ltr.labels.small.labelXS.lineHeight,
      letterSpacing: g.primitive.ltr.labels.small.labelXS.letterSpacing,
      textTransform: g.primitive.textCase.uppercase,
    },

    // Per-colour-set colours
    colourSets: {
      default: {
        backgroundColor: g.semantic.color.background.inverse.main,
        textColor: g.semantic.color.text.inverse.main,
        elementColor: g.semantic.color.fill.inverse.main,
      },
      inverse: {
        backgroundColor: g.semantic.color.background.default.main,
        textColor: g.semantic.color.text.default.main,
        elementColor: g.semantic.color.fill.default.main,
      },
    },
  }),
);

export type BadgeTokens = MapTokensContract<typeof badgeTheme.themeContract>;

Four rules govern the values:

  1. 100% binding coverage. Every Token Studio binding the component's Figma nodes carry (see tokens/_manifest.json) must be referenced — including None-valued ones (paddingBlock: Spacer.0, borderWidth: None, boxShadow: None), because a Euro League override of that semantic must have somewhere to flow. A fixed value with no Figma binding is hardcoded with a // TODO: Token Studio: marker and raised as a design question.
  2. Reference contract paths, never literals. Use g.semantic.spacer["4"], not '8px'. Use a template literal for composite values.
  3. Never import { tokens } from "@v4/tokens" for the Component/* namespaces. They live in the global contract under g.components.* (g.components.core.*, g.components.content.*, g.components.video.*, g.components.basketball.*, and the team colours g.teams.teams.<club>.* from packages/ghost-core/src/theme/basketball-surface.ts). A raw import freezes the generated literal into the contract at build time, beyond every Euro League override. The only theming file that imports raw tokens is packages/ghost-core/src/theme/default.theme.ts.
  4. Let the value object infer its type. Never annotate the buildGlobalTheme argument with a hand-written interface — it lacks the implicit index signature TokenTree needs and silently collapses the whole contract to any.

The colour-set theming model

There is no onDark prop. The theming axis is colour sets: default, inverse, base1, base2, and colourSet1–colourSet6. A dark surface is a colour set (base2 or a dark brand set), not a boolean. Each set specifies a full surface — typography, links, backgrounds, borders, fills, buttons — as typed contract paths (g.colourSets.base1.background.main).

Polarity is per-component — do not assume it. Badge's default set uses the inverse semantic surface (a black pill on light UI); its inverse set uses the default surface. Always read the component's own extracted bindings; polarity is whatever the manifest says.

Because recipes silently drop unknown variant values (rendering the component unstyled), any component that supports a subset of the ramp must resolve unsupported sets to its default before calling the recipe — Storybook's colour-set toolbar forces arbitrary sets onto every story, so this path is exercised constantly. That is the resolveColourSet helper shown on the scaffold page.

Structural styles (.css.ts)

The stylesheet reads the contract via const t = <name>Theme.themeContract and puts only structure and selectors in place. Use style() for single elements, recipe() for variant axes, and globalStyle() for parent-child selectors and pseudo-states.

import { createVar, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { badgeTheme } from "./badge.theme";

const t = badgeTheme.themeContract;

// globalStyle() cannot reference a recipe variant class, so a per-set colour
// flows to child elements through a CSS var set in the variant body.
const elementColor = createVar();

export const badge = recipe({
  base: {
    boxSizing: "border-box",
    display: "inline-flex",
    alignItems: "center", // Figma counterAxisAlignItems: CENTER
    justifyContent: "center", // Figma primaryAxisAlignItems: CENTER
    width: "fit-content", // Figma horizontal sizing: HUG
    height: t.height,
    gap: t.gap,
    paddingInline: t.paddingInline,
    paddingBlock: t.paddingBlock,
    borderRadius: t.borderRadius,
    borderStyle: "solid",
    borderWidth: t.borderWidth,
    boxShadow: t.boxShadow,
    opacity: t.opacity,
  },
  // Single variant axis → colours live directly in the variant bodies.
  variants: {
    colourSet: {
      default: {
        backgroundColor: t.colourSets.default.backgroundColor,
        color: t.colourSets.default.textColor,
        vars: { [elementColor]: t.colourSets.default.elementColor },
      },
      inverse: {
        backgroundColor: t.colourSets.inverse.backgroundColor,
        color: t.colourSets.inverse.textColor,
        vars: { [elementColor]: t.colourSets.inverse.elementColor },
      },
    },
  },
  defaultVariants: { colourSet: "default" },
});

export const text = style({
  fontFamily: t.text.fontFamily,
  fontSize: t.text.fontSize,
  lineHeight: t.text.lineHeight,
  letterSpacing: t.text.letterSpacing,
  textTransform: t.text.textTransform,
});

One axis vs many: recipe bodies vs compoundVariants

  • One variant axis (Badge has only colourSet) → put the colours directly in each variant body, as above.
  • Two axes that intersect (the colourSet × state matrix) → use compoundVariants, one entry per crossing, referencing contract paths:
compoundVariants: [
  {
    variants: { colourSet: "base1", state: "enabled" },
    style: {
      backgroundColor: t.colourSets.base1.enabled.backgroundColor,
      color: t.colourSets.base1.enabled.textColor,
    },
  },
  // … one entry per colourSet × state
],

Recipe variant keys must match the TypeScript unions exactly. Interactive pseudo-selectors (:hover:not(:active), :focus-visible, :active) attach only to the enabled state.

The conventions that bite silently

These are worth internalising before your first component — each has shipped a visibly-wrong build that no gate caught:

  • Logical properties from day one. paddingInline, insetInlineStart, borderStartStartRadius — physical left/right are not allowed. Directional glyphs (a "next" chevron) don't mirror with layout, so opt them into the directionalIcon helper — never a blanket svg { transform: scaleX(-1) }.
  • Motion follows the ELB motion spec. What moves, and with which values, is design's: every animation names its spec pattern in a comment (// P-CASCADE — …), takes its durations and easings from the motion.* tokens (never a literal), renders its final state at once under reduced motion, and ships a unit test and an e2e for both sides (.claude/rules/frontend/motion.md; Motion). The mechanics go through createTransition() / createAnimation() from @v4/ghost-core/theme, which carry the prefers-reduced-motion guard. If your rule also declares an "@media" key, merge it — a second "@media" in the same object literal replaces the guard wholesale.
  • Mobile-first @media. Base styles come from Figma Breakpoint=S; [mediaUp('lg')] overrides come from Breakpoint=XL. Never a container query for a layout breakpoint. Every breakpoint-scoped theme leaf (gapDesktop, *.xl.*) must be consumed under a mediaUp() block, or the desktop layout silently renders the mobile value.
  • The rungs the design does not draw. Most sets author only Small and X-Large. A band's outermost container binds g.semantic.size.pageMeasure (never the XL container width), and a family that lays out columns or rails declares its tablet (md) step from the M grid (.claude/rules/frontend/intermediate-rungs.md; Responsive rungs).
  • Transparent Figma INSIDE strokes → an inset box-shadow ring, never a real border (a border grows a hug-height element by 2px). A visible ring keeps a real border with a stated reason.
  • Map auto-layout axes, not just spacing. A layoutMode: VERTICAL frame needs flexDirection: column; primaryAxisAlignItems → justifyContent, counterAxisAlignItems → alignItems, itemSpacing → gap. A display: block element honours none of them, so a gap override on it is dead.

The full patterns are in .claude/rules/frontend/vanilla-extract-conventions.md and theme-variant-patterns.md.

Verify coverage

Coverage is computed, never hand-written. Recount it from the extraction files:

pnpm --filter @v4/token-parser exec v4-tokens coverage \
  -c packages/ghost-core/src/components -n <kebab-name>

The basis is node × property pairs with INSTANCE subtrees AND hidden nodes excluded (coverageBasis: "node-property-pairs@v2"); the block reports what it dropped (hiddenNodePairsExcluded, hiddenSubtrees). A component built over a shared _internal/* layout declares coverage.delegatedThemes in its manifest, or every delegated binding reads as a gap. A figure is only as good as the file the extraction came from: one cached from Ghost scores the component against upstream. Read the emitted block rather than trusting a prose figure: gaps lists uncovered node × property pairs; cssConsumption.unread names theme leaves no CSS rule reads (an emitted var nobody reads means a Euro League override of it silently no-ops — consume it, or record it in the manifest as referencedNotConsumed with a reason). A coverage number is never self-certifying, and it is unreliable in both directions — read the theme.

Next

Register and test — get the component into the six registration points and its three test suites.

Register and test

A component is not done when it renders. It exists in six places that must stay in sync, and it ships three test suites plus three companion MDX docs. This page is the checklist and the order to do it in.

The six registration points

# Point File
1 Package barrel export packages/ghost-core/src/index.ts
2 registerComponentTheme(…) the component's .theme.ts
3 Side-effect theme import + OWN_THEMES entry packages/ghost-core/src/init.css.ts
4 Figma bridge entry packages/ghost-core/figma.json
5 Stories + three companion MDX docs apps/storybook
6 Token contract generated @v4/tokens/components/<name>, or a code-only marker

Points 1, 3 and 4 are inserted for you by pnpm --filter @v4/ghost-core new-component. Point 2 is the registerComponentTheme call you already wrote in the .theme.ts. Points 5 and 6 are the work below.

There is no hand-maintained themes.ts barrel — the registry replaces it — and no separate components/index.ts barrel; src/index.ts is the one export list. Supporting convention (not one of the six): a TEST_ID entry in packages/ghost-core/src/constants.ts.

⚡ Do it with Claude — /validate-registrations audits every component against all six points and optionally auto-fixes. Run it before you open a PR; it is the cheapest way to catch a missing init.css.ts import or a stale figma.json id.

Register LAST

Points 1 and 3 both import specifiers that must resolve to real files. Write them before the .component.tsx / .theme.ts exist and one unresolvable import fails the entire vanilla-extract compile and the Vite import graph — so every story in Storybook goes blank, not just the new component's, and the dev server caches the failure until it is restarted.

The order of work per component is therefore:

  1. Write the component / theme / css / types files.
  2. pnpm --filter @v4/ghost-core build && pnpm --filter @v4/ghost-core check-types.
  3. Then confirm the two registration lines (the scaffolder wrote them; verify they resolve).
  4. Restart Storybook.

If you must interrupt a component mid-build, comment the registration lines out with a RESTORE marker rather than leaving a dangling import. A bare directory without an index.ts does not count as a resolvable specifier — that is the classic miss.

Test IDs

Add the component's ids to the central constants file, then wire data-testid attributes. Root id is the kebab name; children are root__child (BEM-style):

// packages/ghost-core/src/constants.ts
export const TEST_ID = {
  BADGE: testIds("badge", {
    TEXT: "text",
    LEADING_ELEMENT: "leading-element",
    TRAILING_ELEMENT: "trailing-element",
  }),
};

Children derive from the actual testId prop so a consumer that renames the root renames its descendants too:

data-testid={testId === TEST_ID.BADGE.COMPONENT ? TEST_ID.BADGE.TEXT : `${testId}__text`}

⚡ Do it with Claude — /add-test-ids adds the TEST_ID constants and wires the data-testid attributes for you. /component-accessibility runs a static WCAG A/AA review (semantics, ARIA, keyboard/focus, contrast structure) with safe auto-fixes.

Stories

Stories live in the Storybook app, never the component package: apps/storybook/stories/<package>/<category>/<kebab-name>/, titled <Package>/<Category>/<Name> (Ghost-Core/…, Ghost-Content/…, Ghost-Video/…, Ghost-Basketball/…). The story id follows the title (ghost-core-<category>-<name>--<story>), so a retitle is a breaking change to every e2e spec and MDX that addresses the story by id — move them in the same commit.

Each component ships, minimum:

  • Playground — controls-driven default with argTypes.
  • AllColourSets — every supported set, each on its representative background, including one dir="rtl" row.
  • All<Axis> for every variant axis (states, sizes, levels).
  • One story per slot/optional-content permutation (LeadingIconOnly, BothSlots, TextOnly, …).
  • WithTokenOverrides — exercising the per-instance tokens prop visibly.
  • A Responsive story if the Figma set had a Breakpoint axis.
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Badge } from "@v4/ghost-core";
import { badgeMock } from "./badge.mock";

const meta = {
  title: "Ghost-Core/Atoms/Badge",
  component: Badge,
  parameters: { layout: "padded", a11y: { test: "error" } },
  tags: ["autodocs", "pilot"],
} satisfies Meta<typeof Badge>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Playground: Story = {
  args: { ...badgeMock },
  argTypes: {
    colourSet: { control: "select", options: ["default", "inverse"] },
  },
};

export const AllColourSets: Story = {
  render: () => (
    <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      {(["default", "inverse"] as const).map((cs) => (
        <Badge key={cs} {...badgeMock} colourSet={cs} />
      ))}
    </div>
  ),
};

The Storybook mock (badge.mock.tsx, alongside the story) spreads the package's plain-data mock and layers JSX slot props over it — real generated icons from @v4/tokens/icons, never ad-hoc inline SVG, and placehold.co for images. Never build inline HTML that imitates a library component.

Three companion MDX docs sit beside the stories, auto-discovered by the stories/**/*.mdx glob: <name>.figma.mdx (design link, node map, token bindings), <name>.accessibility.mdx (semantics, ARIA, contrast notes) and <name>.theme.mdx (the token contract table and the five override levels). The /generate-from-figma pipeline generates the first two; regenerate rather than hand-edit.

pnpm --filter @v4/storybook dev     # Storybook on :6006
pnpm --filter @v4/storybook test    # vitest browser-mode story tests (a11y + play)

Stop the dev server before a full story-suite run — a running :6006 can deadlock it.

Unit tests

<kebab-name>.test.tsx beside the component; vitest + Testing Library + jest-dom, in jsdom. Import from the barrel (./index), use TEST_ID constants, and cover the required cases: renders with default props, each key content prop, optional props, className, data-component, tokens overrides (assert the --ph__<name>-* custom property lands on the root), interactive onClick enabled/disabled, and a non-default colourSet without error.

import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { TEST_ID } from "../../constants";
import { badgeMockProps } from "./badge.mock";
import { Badge } from "./index";

describe("Badge", () => {
  it("renders with default props", () => {
    render(<Badge {...badgeMockProps} />);
    expect(screen.getByTestId(TEST_ID.BADGE.COMPONENT)).toBeInTheDocument();
  });

  it("sets the data-component attribute", () => {
    render(<Badge {...badgeMockProps} />);
    expect(screen.getByTestId(TEST_ID.BADGE.COMPONENT)).toHaveAttribute("data-component", "Badge");
  });
});
pnpm --filter @v4/ghost-core test
pnpm --filter @v4/ghost-core exec vitest run src/components/<kebab-name>/

Unit tests prove DOM, props and attributes — they do not prove computed token values resolve, because jsdom has no layout engine. That is e2e's job.

Component e2e (Playwright)

Every component ships apps/e2e/tests/<package>/<kebab-name>.e2e.ts — specs are foldered by owning package (ghost-core, ghost-content, ghost-video, ghost-basketball, basketball-app, and shared for cross-package specs) — asserting resolved token values against manifest values, per-instance override resolution, interaction states, slot dimensions, RTL, and the breakpoint projects — only what needs a real browser. The runner boots or reuses a Storybook on :6006.

pnpm --filter @v4/e2e test                # every project
pnpm --filter @v4/e2e test:chromium       # desktop (1280) + ghost-s (360)
pnpm --filter @v4/e2e test:cross-browser  # webkit + firefox (risk subset)
pnpm --filter @v4/e2e test:full-matrix    # E2E_FULL_MATRIX=1: every spec on every engine

There are six projects: desktop (Chromium 1280) and ghost-s (Chromium 360) run every spec; webkit and firefox (1280) run the cross-browser risk subset listed in apps/e2e/playwright.config.ts; tablet (834) and laptop (1180) run only the specs whose expectations derive from the viewport (INTERMEDIATE_RUNG_SPECS).

Reuse adopts any server on :6006 — including a Storybook left running from another checkout, which then serves the whole run from the wrong tree. Check the listening process's working directory, or run on a free port: E2E_PORT=6016 pnpm --filter @v4/e2e test -- <spec>.

Two harness rules save hours: reach focus with tabTo(page, locator) (WebKit honours "Full Keyboard Access", so a bare Tab reaches text fields only), and ask about the viewport with isDesktopProject() — never branch on test.info().project.name, because a project-name check silently asserts the 360px Small-set values against a 1280px render on every new desktop-class project. e2e specs assert token values, so changing a theme token means changing its e2e assertion — grep the spec for the old value in the same change.

Contrast figures for the accessibility MDX come from the tool, never by hand (axe is silent wherever a background-image is painted — it returns incomplete, never a pass or a violation):

node apps/storybook/scripts/contrast-table.mjs --story <id>   # or --component <name>

The local gate before a PR

pnpm verify runs the cheap CI gates in order — format:check, lint (zero warnings), check-types, the package unit suites (the same filters CI uses: everything except e2e and the Storybook browser suite), check-bridges.mjs (every Figma bridge names a file this repo is built from), pnpm compose -- --check (composition drift), the app codegen, and a git diff --exit-code over the generated trees. The suites were missing from verify until 29 Sep 2026, and it printed "all gates green" while a package suite was red. Beyond verify, the nightly workflow owns the full-matrix e2e run and the story suite.

  • .claude/rules/frontend/component-architecture.md — files, registrations, coverage.
  • .claude/rules/documentation/storybook-conventions.md — the story matrix, MDX, play functions, maturity tags.
  • .claude/rules/testing/test-conventions.md — unit and e2e conventions, the harness rules.