@v4/ghost-core
The design-system core: the theme foundation every package builds on and 148 Ghost 2.0 components.
Overview
@v4/ghost-core is the design-system core — the package every other component
package and the app itself build on. It carries two things: the theme
foundation (the colour-set model, the global contract, and the helpers that
turn a token tree into typed --ph__* CSS variables) and 148 components
rebuilt from the Ghost 2.0 Figma design system in vanilla-extract.
It came with this repo from the Publishing Hub v4 template, and its theme layer
is re-skinned to ELB's design by @v4/tokens-euro-league:
the template owns the package, so changes to it arrive through /sync-upstream
(see Take template updates). Every
component is authored the same way — a .component.tsx, structural .css.ts, a
.theme.ts wiring the token contract, .types.ts, tests and a Figma bridge — so
the package reads as one system rather than a pile of one-offs. The deep
per-component reference lives in Storybook and the
Catalogue: @v4/ghost-core; this page is
the package-level picture.
For how a single component is structured — its directory anatomy, six registration points, and the override levels — see Component system.
Entry points
The package ships deliberately separate entries so the theme layer can be
evaluated in a Node context (by the vanilla-extract compiler) without dragging
React in, and so a .tsx never has to import a stylesheet to reach a constant:
| Import | Contents |
|---|---|
@v4/ghost-core |
The 148 React components and their prop types, mock props, TEST_ID constants, plus a re-export of the theme helpers |
@v4/ghost-core/theme |
React-free. Theme emission: createTheme, createClubThemePart, createPackageTheme, clubOwnsThemeEmission / THEME_EMISSION_ENV, createScopedTheme, createScopedOverrides, clubThemeSelector, buildGlobalTheme, registerComponentTheme, componentThemeClassNames, globalTheme, assignInlineVars. The global surface's constants: buildGhostGlobalSurface, CHROME_COLOUR_WAYS, CHROME_WAY_NAMESPACES, CHROME_EXTENSION_NAMESPACES, COLOUR_SET_EXTENSION_NAMESPACES, and the clone's basketball extension (BASKETBALL_EXTENSION_LEAVES, BASKETBALL_TEAM_KEYS). Layout helpers: mediaUp / BREAKPOINTS, LAYER, stickyChromeOffset / STICKY_CHROME_OFFSET_VAR, VIEWPORT_BLEED* / viewportBleed*, typographyTokens, fullViewportScrim / MODAL_Z_INDEX. Motion: createTransition, createAnimation, MOTION_EASING, motionTokens, cardHoverMotion. Plus PH_PREFIX and the component themes sibling packages repaint from CSS (buttonCommonTheme, buttonTextTheme, cardIconTheme, countdownTheme, listCarouselTheme, filterDropdownTheme, filterDropdownTriggerTheme, listCardGridTheme) |
@v4/ghost-core/global-surface |
buildGhostGlobalSurface and the global contract's surface on its own (src/theme/global-surface.ts), including PAGE_GUTTER_CURRENT_VAR |
@v4/ghost-core/constants |
TEST_ID and the other constants, with no React and no styles |
@v4/ghost-core/motion |
The motion hooks and components the ELB motion spec's patterns are built from: useRevealOnView, useFadeOnChange, useCascade / CASCADE_MAX_ITEMS, useFlipReorder, useIndicatorSlide / usePillSlide, useCountUp, CountUp / parseFigure, useHeaderHide / HEADER_HIDE_AFTER, useTableRowMotion, useRevealChildren, RevealSections, useSettle / settleAttrs, useRevealRail, RollingDigits, useExitPhase, and the token readers (motion, readMotionMs, readMotionEasing, entrancesOff, …) |
@v4/ghost-core/classnames |
The shared classnames join helper |
@v4/ghost-core/init.css |
The bootstrap stylesheet that registers every core component theme and exports OWN_THEMES (owner-keyed, one owner for the global layer) |
@v4/ghost-core/self-emitted-themes |
SELF_EMITTED_THEMES: the themes the precompiled stylesheets emit with their components, which the app's parts leave out (createClubThemePart's selfEmitted) |
/constants, /classnames, /motion and /theme exist so that a component
.tsx, or a sibling family's code, never imports a stylesheet or a .theme.ts:
the component stylesheets are precompiled in the package build, and one that
reached a barrel would emit into the app's bootstrap scope (the precompile
fails the build when it does).
There is no separate @v4/theme package — the theme foundation lives here,
and the /theme subpath is how sibling packages and the Euro League token layer reach it.
The theme foundation
The theming axis is colour sets (base1, base2, colourSet1–colourSet6,
plus default/inverse on components that predate the rename), not an onDark
boolean. The global palette feeds each set, and a component maps the set to a
class. The key exported functions:
buildGlobalTheme(name, tokens)— turns a token tree into a typed theme object whose contract leaves arevar(--ph__{name}-*)and emits the CSS.registerComponentTheme(theme)— registers a component's theme in the module singleton thatcreateThemeandcreateClubThemePartwalk when they emit. There is no hand-maintainedthemes.tsbarrel; the registry replaces it.createPackageTheme(OWN_THEMES)— the owner-keyed call each package makes from its owninit.css.ts, so every theme is emitted exactly once.createTheme({ global, components })— emits every registered theme with a token layer's values in one call (Storybook and the e2e use it).createClubThemePart(tokens, { global?, components, selfEmitted? })— emits the named component themes with Euro League's values, leaving out theselfEmittedones (below) unless the club overrides one. This is whatapps/webcalls.
The build and the app's theme parts
build runs tsc, then scripts/precompile-styles.mjs, then
scripts/check-theme-owners.mjs. The component stylesheets are precompiled
in the package build; only init.css.ts and what it imports stay raw for the
app's vanilla-extract unit. check-theme-owners fails the build when the
OWN_THEMES list and the package's theme modules disagree in either direction.
The precompile also appends each stylesheet's sibling theme (x.css.ts beside
x.theme.ts, when a module imports the stylesheet) to that component's own
.precompiled.css, at :where(.ph__x-theme), and writes the names to
dist/self-emitted-themes.js — the /self-emitted-themes export. In the app a
component's theme then arrives only with the component, on the routes that
import it, instead of in one render-blocking file every page loads (perf,
7 Nov 2026). The build fails if a listed theme is not actually emitted.
In the app, apps/web/next.config.ts sets PH_THEME_EMISSION=club, so
createPackageTheme only registers: the component stylesheets emit their own
themes (above), and the club's parts emit the global layer, the themes with no
stylesheet of their own and any the club overrides. The club layer is emitted in
parts, each loaded by the layouts of the routes that
render it, with the global layer declared once on :root. A route that
renders a package must load its part: without it the package's components
declare no --ph__* variable and render unstyled, with nothing failing. Which
part each layout loads, and why, is on
Theme emission and performance.
The colour-set model, the token contract, and the five override levels are documented under Theming and colour sets and Override levels.
How it is consumed
- Sibling packages (
@v4/ghost-content,@v4/ghost-video,@v4/ghost-basketball) declare@v4/ghost-coreas a workspace dependency, compose its components, and bind their own tokens against the global contract via@v4/ghost-core/theme. See the sibling-packages catalogue.@v4/ghost-basketballalso extends the global surface itself:src/theme/basketball-surface.tsadds theg.components.basketball.*andg.teams.teams.<club>.*leaves. - The app imports components from
@v4/ghost-coreand emits Euro League's token layer in the parts above. - Stories live in
apps/storybook, not this package (see the Components overview).
Tokens come from @v4/tokens
Component themes never hardcode values — they bind contract paths derived from
the generated defaults in @v4/tokens. default.theme.ts
is the single file that imports the raw tokens object; every component binds
g.components.* / colour-set paths so Euro League's overrides flow through. See
Token pipeline.
Families, page measure and motion
src/families/— shared layouts, style factories and hooks that several components are built over:listbox-popup(useListboxPopup,usePopupDisclosure),nav-link,results-listandsearch-result-card. A family exposes*.tokens.ts/*.styles.tsrather than a stylesheet, so its.tsxnever imports CSS.- Page measure —
src/theme/page-measure.css.tswrites the per-band page gutter (PAGE_GUTTER_CURRENT_VAR) on:root; a band's outermost container bindsg.semantic.size.pageMeasure. The rules for the tablet and laptop rungs are in Responsive rungs (rule:.claude/rules/frontend/intermediate-rungs.md). - Motion — ELB's design-owned motion spec (v0.3, 27 Oct 2026) decides what
moves;
@v4/ghost-core/motionand the/thememotion helpers are how. Read Motion (rule:.claude/rules/frontend/motion.md; digest:.claude/reference/motion/elb-motion-spec.md) before adding any animation.
Scripts
| Script | Purpose |
|---|---|
pnpm --filter @v4/ghost-core build |
tsc, then precompile the stylesheets, then check the OWN_THEMES list |
pnpm --filter @v4/ghost-core new-component <kebab> |
The mechanical registration scaffold for a new component |
pnpm --filter @v4/ghost-core test / test:watch |
vitest (jsdom + jest-dom) |
pnpm --filter @v4/ghost-core lint / check-types |
ESLint (zero warnings) / tsc --noEmit |