@v4/ghost-core

The design-system core: the theme foundation every package builds on and 148 Ghost 2.0 components.

2026-11-07

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 are var(--ph__{name}-*) and emits the CSS.
  • registerComponentTheme(theme) — registers a component's theme in the module singleton that createTheme and createClubThemePart walk when they emit. There is no hand-maintained themes.ts barrel; the registry replaces it.
  • createPackageTheme(OWN_THEMES) — the owner-keyed call each package makes from its own init.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 the selfEmitted ones (below) unless the club overrides one. This is what apps/web calls.

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-core as 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-basketball also extends the global surface itself: src/theme/basketball-surface.ts adds the g.components.basketball.* and g.teams.teams.<club>.* leaves.
  • The app imports components from @v4/ghost-core and 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-list and search-result-card. A family exposes *.tokens.ts / *.styles.ts rather than a stylesheet, so its .tsx never imports CSS.
  • Page measure — src/theme/page-measure.css.ts writes the per-band page gutter (PAGE_GUTTER_CURRENT_VAR) on :root; a band's outermost container binds g.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/motion and the /theme motion 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