Create a component
Build a typed vanilla-extract component from ELB's Figma: anatomy, theme contract, colour-set recipe and registration.
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-basketballfor 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 thefigma-screenshots/andtokens/sub-directories, - inserts the barrel export into
src/index.ts(kept alphabetical), - inserts the side-effect theme import into
src/init.css.tsand appends the camel-cased name to that file'sOWN_THEMESlist (owner-keyed emission — a component whose theme is not in the list fails the package'scheck-theme-ownersgate), - 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.tsimport 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 aRESTOREmarker, 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 — the
.theme.tscontract, colour sets, and the.css.tsrecipe. - Register and test — the six registration points, stories, unit tests and e2e.
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:
- 100% binding coverage. Every Token Studio binding the component's Figma
nodes carry (see
tokens/_manifest.json) must be referenced — includingNone-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. - Reference contract paths, never literals. Use
g.semantic.spacer["4"], not'8px'. Use a template literal for composite values. - Never
import { tokens } from "@v4/tokens"for theComponent/*namespaces. They live in the global contract underg.components.*(g.components.core.*,g.components.content.*,g.components.video.*,g.components.basketball.*, and the team coloursg.teams.teams.<club>.*frompackages/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 rawtokensispackages/ghost-core/src/theme/default.theme.ts. - Let the value object infer its type. Never annotate the
buildGlobalThemeargument with a hand-writteninterface— it lacks the implicit index signatureTokenTreeneeds and silently collapses the whole contract toany.
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 thedirectionalIconhelper — never a blanketsvg { 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 themotion.*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 throughcreateTransition()/createAnimation()from@v4/ghost-core/theme, which carry theprefers-reduced-motionguard. 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 FigmaBreakpoint=S;[mediaUp('lg')]overrides come fromBreakpoint=XL. Never a container query for a layout breakpoint. Every breakpoint-scoped theme leaf (gapDesktop,*.xl.*) must be consumed under amediaUp()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-shadowring, never a realborder(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: VERTICALframe needsflexDirection: column;primaryAxisAlignItems→justifyContent,counterAxisAlignItems→alignItems,itemSpacing→gap. Adisplay: blockelement honours none of them, so agapoverride 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-registrationsaudits 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 missinginit.css.tsimport or a stalefigma.jsonid.
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:
- Write the component / theme / css / types files.
pnpm --filter @v4/ghost-core build && pnpm --filter @v4/ghost-core check-types.- Then confirm the two registration lines (the scaffolder wrote them; verify they resolve).
- 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-idsadds theTEST_IDconstants and wires thedata-testidattributes for you./component-accessibilityruns 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 withargTypes.AllColourSets— every supported set, each on its representative background, including onedir="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-instancetokensprop visibly.- A
Responsivestory 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.
Related rules
.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.