Refactor a component
Bring a component into line with ELB's Figma fork, review it against the design, apply common refactors, or port SCSS.
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>]runsdesign-statusandfork-difftogether 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 —
tagandbutton-shareare 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
- 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. - Check for a second page. List the file's pages and frames for the
component's name. A
Block - X - Concept 02besideBlock - Xis 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. - 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.
- Diff Ghost vs ELB at each rung (
fork-diff) — what changed. - 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.jsonfrom the two fresh ELB extractions. - 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.
- Rebind the theme, then the CSS, then check each variant's recorded
figmaNameagainst the node's real name — the only check that catches swapped colour ways. - 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.
- Restart Storybook (vanilla-extract does not re-evaluate a
.css.tswhen a.theme.tschanges), then measure. - 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.
- 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 withplugin_data=shared, which returns every Token Studio binding on every node; an MCP-only review is guessing, becauseget_design_contextcannot quote a token name./sync-figma-stylesis 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.tokensentry against the.theme.tscontract path that should carry it. - Spacing — per-side padding and
itemSpacingagainst 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
colourSetscontract 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 away(g.colourSets.colourSet1)helper) covers leaves the scan credits throughwholesaleNamespacePaths(), 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.tsrule reads. An emitted var nobody reads means a Euro League override of it silently no-ops. Consume it, or record it in the manifest asreferencedNotConsumedwith 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 thefileKeyit 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 ashiddenNodePairsExcludedandhiddenSubtrees— 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:
- Add the set to the supported constant in
.types.ts(PH_<NAME>_COLOUR_SETS). ThecolourSetprop stays the fullColourSetunion — only the supported list grows. - 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 thedefaultmapping. - Add the recipe variant body (single axis) or the
compoundVariantsentries (colourSet × state) referencing the new contract paths. - Add the set to
AllColourSetsin 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/lgSizeprecedent — defaulted to preserve today's behaviour. - The default is an
"inherit"member that emits nolgrule at all, so every standalone consumer keeps byte-identical CSS. - The composing parent declares the step once (
<LinkListItem size="small" lgSize="default" />), exported as aPH_*_ITEM_SIZEconstant andOmitted 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.tscontract, converts the stylesheet to.css.ts, re-points the props to the shared@v4/ghost-coretypes, 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:
- Read the source.
{name}.module.scss,{name}.tokens.scss(if present),{name}.component.tsx,{name}.types.ts. Thetokens.scssfile is the map of overridable values; themodule.scssis the structure. - Write
{name}.theme.ts. Hand-author the token object from thetokens.scssvariables — everyvar(--p-*)/var(--s-*)/var(--cs-*)becomes ag.*contract path (.claude/reference/tokens/token-reference.mdis the translation table;css-variables-reference.mdin 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. - Write
{name}.types.ts. Port the props, re-pointBasicComponentPropsto the shared@v4/ghost-coretypes, and addtokens?: DeepPartial<{Name}Tokens>pluscolourSet?: ColourSetwherever the SCSS had colour-set modifier classes (.onDark,.inverse, …). There is noonDarkprop here — a dark surface is a colour set. - Convert
{name}.module.scss→{name}.css.tsagainst the theme contract. - Update the component to consume the vanilla-extract styles (
styles.*,theme.themeClassName,assignInlineTokens). - Align the barrel and register. Standard
index.ts,TEST_IDinconstants.ts, package export insrc/index.ts— then run/validate-registrations {name}. - 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 negativegapis invalid CSS and is dropped silently — an authored-4pxoverlap ships as no overlap. Route it through amargin(or a dedicated overlap token). display: blockon 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 latergapoverride is dead.- Physical properties. Translate
margin-left→marginInlineStartetc. as you go; RTL is cheaper to get right now than to retrofit. - Raw
transitionstrings. Replace withcreateTransition()/createAnimation()so the reduced-motion guard comes for free. - INSIDE strokes. A transparent Figma INSIDE stroke ported as a real CSS
bordergrows a hug-height element by 2px — paint it as an insetbox-shadowring 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.