Refactor a component

Bring a component into line with ELB's Figma fork, review it against the design, apply common refactors, or port SCSS.

2026-11-07

Refactor against ELB's fork

This is the refactor this project does most. The components came from the template built to Ghost 2.0; ELB's design team copied the Ghost 2.0 files into their own and changed the components in place. The job is to bring the code into line with ELB's file. The authoritative rule is .claude/rules/frontend/fork-refactor.md; this page is the working summary.

Every bug this workflow produces has the same shape: a wrong value that renders valid output and fails nothing. Copy-and-modify keeps the upstream answer structurally valid in the fork, so wrongness is invisible by construction. Plan the refactor around producing the missing comparison, not around reading the design more carefully.

⚡ Do it with Claude — /fork-status [--client <fileKey>] [--upstream <fileKey>] [--package <name>] runs design-status and fork-diff together and crosses them: which designs are finished, which components measure the wrong file, and which finished designs have no component at all. Run it before planning any refactor.

The files

Upstream (Ghost 2.0) ELB
Ghost – Core 2.0 67J5FrDZn5HzYzf5hJgoNA ELB – Web – Core HIjezWNeRQSBGkxx8VqmjA
Ghost – Content 2.0 RHCx7G92nx50KjNK6QshhU ELB – Web – Content 9iz5GeR9syO68cmPr1ppE1
Ghost – Video 2.0 lfZBiu2s7KiQ7jad0bg9hP ELB – Web – Video gR9NZ422d1RuQbdgtQ8VA9
— ELB – Web – Basketball 8Jl1miPEIZslMda8jZ8w0C (new UI, not a fork)
— EXT – ELB – Web – Structure Yqy0zItrtL4UptOVW54wVg (new UI, not a fork)

The map is .claude/manifest.json — figma.files (Ghost) and figma.clientFiles (ELB, with forks naming the upstream family). The package does not tell you the file: ELB designs some ghost-core components (share-tray, button-share, tag) in its Content file.

The preserved-id trap

The forks preserve the upstream node ids. A bridge ({name}.figma.json) whose fileKey still names Ghost therefore resolves every id perfectly, the extraction succeeds, coverage scores, the tests pass and the component renders — while measuring the wrong design. Four of four ghost-content components first opened were built to upstream this way, each with real on-screen differences.

  • Re-verify the fileKey, never just that the ids resolve.
  • A Ghost bridge is not automatically wrong — tag and button-share are byte-identical across the fork. Rank work by measured difference, not by which file a bridge names.
  • A cached extraction from Ghost answers every question with Ghost's answer. Never confirm or retract a design finding from a grep over a stale cache; re-measure with fork-diff.

The tools

All in packages/token-parser/scripts/. Everything but check-bridges needs FIGMA_PAT in the root .env.

Command What it answers
node packages/token-parser/scripts/check-bridges.mjs [--json] Offline, in pnpm verify: does every bridge name a file this repo is built from, and does every cached extraction agree with its bridge?
node packages/token-parser/scripts/design-status.mjs --file <elbKey> [--ready] [--unbuilt] Is the design FINISHED? Reads each spec frame's Statuses group (Department × Status variants). --ready lists only design-complete components; --unbuilt inverts the join to show finished sets with no component at all
node packages/token-parser/scripts/fork-diff.mjs --upstream <ghostKey> --client <elbKey> [--components <dir>] [--only <component>] [--gap 1200] What did the fork change? Resolves each bridge's node ids in BOTH files and diffs the Token Studio bindings, ranked by measured difference. Both keys are explicit on purpose
node packages/token-parser/scripts/design-drift.mjs [--only <component>] [--gap 3000] [--state <file>] Has ELB's design moved since we cached each extraction? Re-extracts and diffs; --state makes a long run resumable
node packages/token-parser/scripts/verify-against-figma.mjs --components=a,b [--json=<out>] Bridge and extraction integrity: the fileKey names the ELB fork, the url agrees with it, the node is not a hollow stub, an extraction with a nodes array exists
node packages/token-parser/scripts/verify-bindings.mjs --components=a,b Does the theme actually bind what the design declares — resolved through the compiled Storybook output, not grepped from source
node packages/token-parser/scripts/extract-figma-bindings.mjs --file <key> --node <id> --out <dir> --key <name> Re-extract a component's bindings from the right file; records the fileKey it walked

A whole-package fork-diff or design-drift sweep is slow and throttled; a 429 is reported as unmeasured, never as "no difference". Run per component while refactoring.

The order that works

  1. Check the design is finished. design-status --file <elbKey> --ready. On the first pass, the 34 components "measuring the wrong design" and the 11 that were design-complete had an empty intersection — refactoring a moving design means doing the expensive half twice.
  2. Check for a second page. List the file's pages and frames for the component's name. A Block - X - Concept 02 beside Block - X is invisible to every id-based tool. Ask design which page is chosen — never assume the newer one wins (the stats block was rebuilt to Concept 02, then back to Concept 1 when design confirmed it). Record the decision and who made it on the bridge.
  3. Resolve the component in both files; confirm ELB's master is not hollow. ELB masters are often 0-child stubs; where they are, extract from a real instance and record the instance id on the bridge.
  4. Diff Ghost vs ELB at each rung (fork-diff) — what changed.
  5. Diff ELB's Small vs ELB's X-Large — where each value belongs. Never apply the X-Large Ghost→ELB delta as the mobile-first base; regenerate tokens/states/_breakpoint-diff.json from the two fresh ELB extractions.
  6. Check rendered geometry against the bindings. A binding can disagree with its own render (stale plugin data); bind the render and raise a design question.
  7. Rebind the theme, then the CSS, then check each variant's recorded figmaName against the node's real name — the only check that catches swapped colour ways.
  8. Repoint the bridge, re-extract from ELB's file, regenerate the rung diff, recompute coverage — in that order, or the figure scores the old data.
  9. Restart Storybook (vanilla-extract does not re-evaluate a .css.ts when a .theme.ts changes), then measure.
  10. Fix the e2e of this component and of everything that composes it. The e2e pins the Ghost theme, so it asserts Ghost's value for the new token name.
  11. Open the real page, not just the story — see Review against Figma → Open the real page.

Not only the design diff

Half of what the first fork refactor found was not a Ghost→ELB delta at all: a prop the composing renderer never passed, a page's colour way not reaching the blocks inside its body slot (body copy at 1.06:1 on the dark formats), and a value imported from a "use client" module into a server module reading as undefined. Open the component and the surface it renders on.

Review against Figma

The design-review gate compares an existing component against its design — and on this site the design is ELB's Figma file for that family, not the Ghost 2.0 file the component was first built to. A component can be internally consistent, fully typed and green on every unit test and still be wrong, because the truth is in Figma. This page covers the exhaustive diff, the coverage recount, and the pixel score.

⚡ Do it with Claude — /review-against-figma <figma-url> [component] exhaustively diffs a component (and its composed children, recursively) against its Figma design — tokens, spacing, typography, colours, copy, structure — and applies fixes interactively. It uses the Figma REST API with plugin_data=shared, which returns every Token Studio binding on every node; an MCP-only review is guessing, because get_design_context cannot quote a token name. /sync-figma-styles is the narrower variant — it refreshes the generated token contract and fixes drift with approval.

Which file? Check before you diff

Family Truth on this site File key
Chrome, navigation, ghost-core ELB – Web – Core HIjezWNeRQSBGkxx8VqmjA
Every @v4/ghost-content component ELB – Web – Content 9iz5GeR9syO68cmPr1ppE1
@v4/ghost-video ELB – Web – Video gR9NZ422d1RuQbdgtQ8VA9
@v4/ghost-basketball (new UI) ELB – Web – Basketball 8Jl1miPEIZslMda8jZ8w0C
WSC strip, ECAL picker, page extensions EXT – ELB – Web – Structure Yqy0zItrtL4UptOVW54wVg

The map lives in .claude/manifest.json (figma.files for Ghost, figma.clientFiles for the ELB files). The three ELB forks preserve the upstream node ids, so a bridge whose fileKey still names Ghost resolves every id perfectly and measures the wrong design. Re-verify the bridge's fileKey, not merely that its ids resolve — node packages/token-parser/scripts/check-bridges.mjs does this offline and runs in pnpm verify. A component that turns out to be built to upstream is a fork refactor, with its own order of work: Refactor against ELB's fork.

What the review checks

The diff walks the full Figma subtree and, per node, compares against the implementation:

  • Token bindings — every sharedPluginData.tokens entry against the .theme.ts contract path that should carry it.
  • Spacing — per-side padding and itemSpacing against the .css.ts.
  • Typography — the composite type token (family, weight, size, line-height, letter-spacing, case) against the theme's text.* group.
  • Colours — per-colour-set fills, borders and text against the colourSets contract group, read per variant (the declared variant beats the plugin data cached on inner nodes — Figma does not refresh cached data on a variant swap).
  • Copy and structure — the rendered characters and the node tree against the markup.

Coverage: recount, never trust the number

Binding coverage is computed from the extraction files. Recount it rather than reading the manifest's prose figure, which has been wrong in both directions:

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

Read the emitted block, not the headline percentage:

  • gaps — uncovered node × property pairs. Before remediating from this list, read the theme: a theme that binds a whole namespace at once (default: pg.default, or a way(g.colourSets.colourSet1) helper) covers leaves the scan credits through wholesaleNamespacePaths(), but a helper annotated only with a {Set} placeholder credits nothing — annotate the call site with the real token names it produces.
  • cssConsumption.unread — theme leaves no .css.ts 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.
  • rollUps — nodes credited through an ancestor's theme group. A roll-up onto (root) on a real element is the signature of an unstyled element.
  • delegatedThemes — for a component built over a shared _internal/* layout, declare the layout's theme path in the manifest or every delegated binding reads as a gap.

Two more limits on the figure:

  • It is only as good as the file it was measured against. Coverage scores the theme against the component's CACHED extraction; if that came from Ghost, 100% means "matches Ghost". After repointing a bridge, re-extract from the ELB file (packages/token-parser/scripts/extract-figma-bindings.mjs --file <key> --node <id> --out <dir> --key <name>, which records the fileKey it walked) before reading any number.
  • Hidden nodes are out of the basis (node-property-pairs@v2). A fork hides the upstream parts it does not ship, and Figma keeps their bindings; the block reports what was dropped as hiddenNodePairsExcluded and hiddenSubtrees — check those really are authoring the design does not show. An extraction older than the flag counts hidden nodes, so re-extract before trusting it.

The banked lesson, demonstrated repeatedly: a coverage number is never self-certifying. Fix the tool or read the theme; do not reason about which components look suspicious.

Visual score

Score the rendered story against the committed Figma reference PNG (run from the repo root). Compare like-for-like content — the tool notes size mismatches:

pnpm --filter @v4/storybook visual-diff -- --story <story-id> --ref <reference.png>

The pixelmatch percentage is a signal, not a gate — a cropped component can score well while a wrapper paints a bar beside it (the width-cap trap), and text over consumer imagery cannot be certified by sampling. Use it to catch the gross regressions, and lean on the token diff for the exact ones.

Contrast

axe is silent wherever a background-image is painted — it returns incomplete, never a pass or a violation — so a green a11y gate proves nothing about text over a gradient or image. Measure those pairs with the tool, which composites and applies the right WCAG threshold:

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

For text over consumer imagery it reports the design's guaranteed floor (the scrim over the brightest possible content), not a sample of the placehold.co fixture — a number that varies with the mock is not evidence about the component.

Open the real page

A story renders the component in isolation; the page composes it inside a column, a slot and a colour way the story never shows. Three defects on this site shipped green because only the story was checked (a gallery clipped by a narrower column, an embed restyled by the rich-text well, body copy invisible on a dark article format). Open the page in the running app at several widths, and sweep it with the browser-only checks in Testing → Browser sweeps (scripts/page-audit.mjs, scripts/clip-sweep.mjs).

Bridge rot

The {name}.figma.json node ids rot silently — a bridge that resolves is not necessarily pointing at the right node (a Size=Small master instead of the set, or an id the API now returns null for). Re-resolve and re-record ids whenever you review a component, and treat a null API response as a stale bridge rather than a missing component. A Name#id slot label is a component-property id, not a node id — it was never a node.

Recording the outcome

Where a difference is a provable no-op (a HUG frame where flex-start, center and stretch are identical; a byte-identical variant), record the reasoning in a comment rather than churning a fix — and where the Figma side is the artefact (an instance never resized off the Small variant), log it as a design question. The test is always "does anything render differently?", never "is the binding odd?".

Common refactors

Three refactors come up constantly once a component is live: pulling shared styles out of a family, adding a colour set, and adding a responsive mode — plus two that are governed by their own rules, rungs and motion. Each has a silent failure mode banked in the rules — this page is the safe path through them.

Extracting shared styles into a family

When several components share a colour cascade or a structural pattern, the shared piece moves into packages/ghost-core/src/families/** (a plain .ts factory) or a shared _internal/* layout (a private component with its own theme). Two hazards follow:

vanilla-extract does not invalidate a .css.ts when a plain .ts dependency changes. After editing anything under src/families/** or a shared theme helper, the running Storybook dev server keeps serving stale CSS — the .css.ts is never re-evaluated. Restart Storybook before running visual-diff, story tests or e2e, or you will chase phantom failures.

A shared _internal/* layout needs its delegation declared. When a public component is built over a private layout, the layout's theme holds most of the contract and the component binds only the leaves it owns. The coverage CLI reads the component's own .theme.ts and follows its relative imports — but the component's theme does not import the layout (the component module does), so the walk never reaches it and every delegated binding reads as a gap. Declare it in the manifest:

// tokens/_manifest.json
"coverage": {
  "delegatedThemes": ["../_internal/carousel-block/carousel-block.theme.ts"]
}

Do not re-bind a delegated leaf in the component's own theme to move the number — that duplicates the contract and destroys the single source of truth. Also add the layout's side-effect theme import to the package's init.css.ts by hand, and its theme name to that file's OWN_THEMES list. The sibling packages' scripts/check-dist.mjs (ghost-content, ghost-video, ghost-basketball) does not look for _internal/* themes, so a forgotten import leaves every consumer silently un-styled; in ghost-core the owner-list guard is packages/ghost-core/scripts/check-theme-owners.mjs.

Adding a colour set

A component that supports default | inverse and needs to gain, say, colourSet1:

  1. Add the set to the supported constant in .types.ts (PH_<NAME>_COLOUR_SETS). The colourSet prop stays the full ColourSet union — only the supported list grows.
  2. Add its colours to the theme contract under colourSets.colourSet1, read from that set's own extracted bindings. Polarity is per-component and does not carry over — read the manifest, do not copy the default mapping.
  3. Add the recipe variant body (single axis) or the compoundVariants entries (colourSet × state) referencing the new contract paths.
  4. Add the set to AllColourSets in the story (with its representative background) and to any e2e that iterates the ramp.

The boundary guard already handles the degrade path — the shared resolveVariant helper (packages/ghost-core/src/utils/variants.ts), called with the component's PH_<NAME>_COLOUR_SETS list, will now pass colourSet1 through instead of degrading it. If the new set uses a helper that maps a whole namespace, annotate the call site with the token names it produces so coverage credits it:

// CS.ColorSet-1.Background.Main / CS.ColorSet-1.Text.Main / CS.ColorSet-1.Text.Supporting
colourSet1: way(g.colourSets.colourSet1),

When a molecule composes a themed child, resolve the colour way once at the boundary and pass the resolved value down — do not forward the parent's colourSet unchanged to several children, because they degrade independently and rarely support the same subset (a colourSet5 parent forwarding to a default | inverse child renders a degraded default child beside a colourSet5 parent). Guard it with a unit test that diffs the whole composed class set against the parent's default way — a per-child assertion cannot see the drift, because each child is individually correct.

Adding a responsive mode

The trap here is a mediaUp('lg') rule that encodes a composition-level decision. A responsive flip authored for one consumer breaks every other consumer of the primitive (the nav list's horizontal no-wrap flip belonged to the site header's X-Large layout and gave the mega menu a clipped strip). The house answer is a declared mode, not a hidden media rule:

  • Express the flip as an explicit prop the consumer declares — the lgOrientation / lgRootWidth / lgSize precedent — defaulted to preserve today's behaviour.
  • The default is an "inherit" member that emits no lg rule at all, so every standalone consumer keeps byte-identical CSS.
  • The composing parent declares the step once (<LinkListItem size="small" lgSize="default" />), exported as a PH_*_ITEM_SIZE constant and Omitted from the child's props so the Figma set owns it, not the consumer.

Mechanically: add an lg{Axis} recipe variant with empty bodies and put the media rules in compoundVariants keyed on the crossings where the value actually changes (compoundVariants emit after the base variant classes, so the media rule reliably wins, and the no-op crossings emit nothing).

Two guards are mandatory, because this class of bug ships silently:

  • A unit test cross-checking the declared ruling against the extracted Figma variant JSON.
  • An e2e measuring the resolved values on the element at both rungs with page.setViewportSize — a story rendered at one width cannot observe a wrong breakpoint.

Rejected alternatives: a container query (banned for viewport-driven layout breakpoints) and a dual-render (doubles every row in the DOM and accessibility tree, and a hidden branch has already defeated a focus trap and leaked a scroll lock).

Adding a tablet or laptop step

Most designs author only Small (360) and X-Large (1440), which leaves two bands — 768–1023 and 1024–1439 — that no frame describes. Any new mediaUp rule follows .claude/rules/frontend/intermediate-rungs.md: a band's outermost container binds the page measure (once, never on a descendant), a component that lays out columns or rails gets an md step for its family, a new leaf that steps at md carries a .md sub-key, and every md rule is asserted at 768 and 834. node scripts/rung-census.mjs counts how many components step at each rung. The full walk-through is the Responsive rungs guide.

Changing motion

Any animation or transition you add, change or remove goes through .claude/rules/frontend/motion.md: it names its ELB motion-spec pattern id in a comment, takes durations and easings from the motion tokens (never a literal), renders the final state at once under reduced motion, and is tested on both sides. See the Motion guide.

Repainting a composed child from the parent's state

When a parent's :hover must repaint a composed child (a card hover darkening the button inside it), the per-instance tokens prop cannot express it — a prop is fixed at render and cannot follow a :hover. The house mechanism is typed CSS: assignVars over the child's contract subtree, targeting the child element with a selector that out-specifies its theme class:

import { assignVars } from "@vanilla-extract/css";
import { buttonCommonTheme } from "@v4/ghost-core/theme";

globalStyle(`${card}:hover [data-component="ButtonCommon"]`, {
  vars: assignVars(buttonCommonTheme.themeContract.colourSets.default.primary.containerBackground, {
    enabled: t.cta.background.hover,
    hover: t.cta.background.hover,
    active: t.cta.background.active,
    disabled: t.cta.background.disabled,
  }),
});

assignVars over a contract subtree (never a hardcoded var-name string) is checked against the child's real contract, so a renamed path fails the build instead of silently no-opping, and it forces you to supply every state in the subtree. The child theme must be exported from the React-free @v4/ghost-core/theme entry to be nameable from a .css.ts. Guard it with a test — a wrong var name emits valid CSS that never matches anything.

After any refactor

Because e2e specs assert resolved token values, editing a theme silently breaks the component's spec — the suite goes red in a file you did not open. Grep apps/e2e/tests/{package}/<component>.e2e.ts (folders ghost-core, ghost-content, ghost-video, ghost-basketball, basketball-app, shared) for the old value or token name in the same change, and run the specs of every component that COMPOSES the one you changed. The e2e pins the Ghost theme, so it asserts Ghost's value for the new token name. Re-verify on a quiet tree (no concurrent agents, and no dev server left on :6006 for the e2e to adopt) before treating a failure as a regression.

From CSS modules to vanilla-extract

The component packages in this repo contain no SCSS. Occasionally a component is ported from an older Stadion codebase styled with *.module.scss + *.tokens.scss (base-ui). Its SCSS is behavioural reference, not source — the visual truth is ELB's Figma, but the SCSS tells you the layout mechanics, edge cases and responsive behaviour that a static design cannot. This page is the conversion.

This is the rare refactor here. Most work on this site is a component brought into line with ELB's Figma fork — start at Refactor against ELB's fork.

⚡ Do it with Claude — /convert-css-modules-to-vanilla <component-name-or-path> is the workhorse for this. It reads the source *.module.scss / *.tokens.scss / *.component.tsx / *.types.ts, produces the .theme.ts contract, converts the stylesheet to .css.ts, re-points the props to the shared @v4/ghost-core types, wires the registrations, and ports the tests and story. This guide covers the pattern it applies so you can review its output — and drive it by hand where a component needs it.

The order of conversion

Work outside-in, contract first:

  1. Read the source. {name}.module.scss, {name}.tokens.scss (if present), {name}.component.tsx, {name}.types.ts. The tokens.scss file is the map of overridable values; the module.scss is the structure.
  2. Write {name}.theme.ts. Hand-author the token object from the tokens.scss variables — every var(--p-*) / var(--s-*) / var(--cs-*) becomes a g.* contract path (.claude/reference/tokens/token-reference.md is the translation table; css-variables-reference.md in the skill directory holds the raw legacy values), and $component-* SCSS variables become nested contract keys. Token Studio's component namespaces are generated per Figma file and group (packages/tokens/src/__generated__/components/{content,core,football,gamification,video}/<group>.ts, e.g. @v4/tokens/components/core/form), not per component — and a component theme binds them through the global contract (g.components.core.*, g.components.content.*, …), never by importing those modules raw: a raw import freezes the literal beyond every Euro League override.
  3. Write {name}.types.ts. Port the props, re-point BasicComponentProps to the shared @v4/ghost-core types, and add tokens?: DeepPartial<{Name}Tokens> plus colourSet?: ColourSet wherever the SCSS had colour-set modifier classes (.onDark, .inverse, …). There is no onDark prop here — a dark surface is a colour set.
  4. Convert {name}.module.scss → {name}.css.ts against the theme contract.
  5. Update the component to consume the vanilla-extract styles (styles.*, theme.themeClassName, assignInlineTokens).
  6. Align the barrel and register. Standard index.ts, TEST_ID in constants.ts, package export in src/index.ts — then run /validate-registrations {name}.
  7. Port the tests and story.

Selector translation

vanilla-extract has hard requirements that SCSS does not enforce. Every pseudo-selector must carry an & or a class reference; there are no bare combinators; parent-child relationships become template literals with class references.

/* SCSS module */
.button {
  display: flex;
  padding: var(--s-spacer-4);
  color: var(--cs-base1-text-main);

  &:hover {
    color: var(--cs-base1-text-emphasis);
  }

  .contents {
    gap: var(--s-spacer-3);
  }
}

becomes

// button.css.ts
import { style, globalStyle } from "@vanilla-extract/css";
import { buttonTheme } from "./button.theme";

const t = buttonTheme.themeContract;

export const button = style({
  display: "flex",
  padding: t.padding, // was var(--s-spacer-4) → contract path in the theme
  color: t.colourSets.base1.enabled.textColor,
});

export const contents = style({
  gap: t.contents.gap,
});

// pseudo-states and parent→child cascades go through globalStyle(),
// anchored on a class reference — never a bare `:hover`
globalStyle(`${button}:hover`, {
  color: t.colourSets.base1.hover.textColor,
});

Colour-set modifiers become a variant axis

An SCSS component that toggled .onDark / .inverse with a boolean or a modifier class becomes a colourSet prop mapped to a recipe variant. The class-per-state SCSS pattern collapses into recipe() variant bodies (single axis) or compoundVariants (colourSet × state) — see Create a component → Theme and styles for the full recipe pattern. Remember the boundary guard: resolve any unsupported colour set to the component's default before calling the recipe, because recipes silently drop unknown variant values and render the component unstyled.

Translation traps

  • Negative itemSpacing / negative margins. A negative gap is invalid CSS and is dropped silently — an authored -4px overlap ships as no overlap. Route it through a margin (or a dedicated overlap token).
  • display: block on an auto-layout frame. The most common conversion defect. Port all four auto-layout facts — the mode, both axis alignments and the spacing — or a later gap override is dead.
  • Physical properties. Translate margin-left → marginInlineStart etc. as you go; RTL is cheaper to get right now than to retrofit.
  • Raw transition strings. Replace with createTransition() / createAnimation() so the reduced-motion guard comes for free.
  • INSIDE strokes. A transparent Figma INSIDE stroke ported as a real CSS border grows a hug-height element by 2px — paint it as an inset box-shadow ring instead.

After the convert

Run the local gate and the registration audit:

pnpm --filter @v4/ghost-core check-types
pnpm --filter @v4/ghost-core lint
pnpm --filter @v4/ghost-core exec vitest run src/components/<kebab-name>/

For a component in a sibling package, swap the filter (--filter @v4/ghost-content, @v4/ghost-video or @v4/ghost-basketball) — each has the same check-types / lint / test scripts.

Then diff the result against ELB's design — Review against Figma — because the conversion is faithful to the old rendering, and the design may have moved.