Add a domain
Build a domain of this site's own, as basketball was, or buy one the template already has with /add-feature.
What a domain is
A domain is a slice of the site with its own UI, pages and content model. The Euro League
site composes three beyond the baseline: content (articles, listings, pages), video
(the video section) and basketball (the competition data areas — teams, players, coaches,
games, standings and stats). content and video came from the Publishing Hub template this repo
was generated from; basketball is this project's own — the template has never heard of it (see
A club-owned domain).
How domains, tiers and the ownership map work is explained on Domains & bolt-ons; this guide is the steps. In short, a domain is not a single package — it is a coordinated set of things that must agree:
- A component package (
@v4/ghost-<domain>) — the domain's UI, built exactly like@v4/ghost-core. Here:@v4/ghost-content,@v4/ghost-video,@v4/ghost-basketball. - A route package (
@v4/<domain>-app) — per-route factories and aroutes.json, if the domain contributes pages. Here:@v4/content-app,@v4/video-app,@v4/basketball-app. - Model ownership — the content types it owns, declared in
packages/model/domains.json. Every migration this project writes goes inmigrations/club/(see Change the content model → Migrations in this repo). - Composition membership — its presence in
packages/model/composition.json, written bypnpm composefromapps/web/club.config.ts.
The tiers on this site
domains.json classifies every domain by tier
(the tiers):
| Tier | Domains in this repo's domains.json |
|---|---|
baseline |
core, content |
implied |
sport-common (pulled in only by football's requires, so not composed here) |
bolt-on |
composed: video, basketball (this project's own); declared but not bought: football, gamification, retail, hospitality, ads, app |
quarantine |
quarantine |
club.config.ts lists domains: ["content", "video", "basketball"]. Naming the baseline content
is harmless: the resolver (packages/model/scripts/composition.mjs) accepts a baseline name and
adds the baselines regardless. basketball requires nothing — sport-common's shapes are
football's, not this sport's.
The domains this site has not bought stay DECLARED with emptied template blocks, so their type
ownership is still known (the migration lint and the baseline replay need it) while none of their
code is on disk. Buying one later is Add a template domain.
The ownership map and the composition
packages/model/domains.json maps every content type to exactly one owning domain; its declaration
order is the composition order
(The ownership map). A domain's migrations create
or delete only the types it owns, and a cross-domain edit needs a // cross-domain: marker and
must stay expand-only. You edit it in
step 2 of building a domain.
packages/model/composition.json records which bolt-on domains the app composes — content,
video and basketball for web. You never hand-edit it: pnpm compose writes it from
club.config.ts (composition).
A domain can ship a minimal Contentful surface
A domain does not have to own many content types, or many fragments. video's data reads come from
the Stadion video platform through @v4/media; its packages/model/src/video/ holds what the
platform has no endpoint for — two fragments (video-landing-settings.graphql,
video-category-card.graphql) and one transform (transforms/video-category-card.ts), exported as
@v4/model/video. basketball owns no content types at all; its src/basketball/ holds eight
fragments and two transforms over this project's sports types, exported as @v4/model/basketball.
Next
A club-owned domain — what basketball is made of and
the rules that follow; then Build a domain, step by step.
A club-owned domain
basketball is this project's own domain. The template has no basketball code, no basketball types
and no basketball entry in its domains.json; Euro League declared it here (19 Oct 2026) for the
competition data areas of the site map — teams, players, coaches, games, standings and stats. It is
the model for any further domain this site needs that will never come from upstream.
What it is made of
| Part | Where |
|---|---|
| Declaration | packages/model/domains.json → "basketball": { "tier": "bolt-on", "note": "CLUB-OWNED …" }, after video in declaration order |
| Component package | packages/ghost-basketball — built from ELB – Web – Basketball (8Jl1miPEIZslMda8jZ8w0C) with /generate-from-figma <url> --package=ghost-basketball |
| Global surface | packages/ghost-core/src/theme/basketball-surface.ts — g.components.basketball.* and the team colours g.teams.teams.<club>.*, which Ghost does not have |
| Route package | packages/basketball-app — routes.json + factories, registered in DOMAIN_PACKAGES (compose-club.ts) |
| Data integration | packages/competition-engine (@v4/competition-engine) — the only importer of the Competition Engine contract |
| Model surface | packages/model/src/basketball/ — eight fragments and two transforms over this project's sports types, exported as @v4/model/basketball |
| Content types | None of its own. The sports types it reads come from this project's migrations: club/0030-video-platform-sports-types.cjs, club/0038-basketball-types.cjs and later club/ files |
| App theme part | apps/web/src/theme/tokens-basketball.css.ts, imported by the teams, players, coaches, games, standings and stats layouts under apps/web/src/app/[locale]/ |
| Stories and e2e | apps/storybook/stories/ghost-basketball/, apps/e2e/tests/ghost-basketball/ and apps/e2e/tests/basketball-app/ |
Its domains.json template block lists only packages/competition-engine and
packages/model/src/basketball, with no stories. A template block is a prune list the template's
scaffolder reads; nothing here reads this one, so it is a record of what the domain owns rather
than something to keep complete.
Rules that follow from being this project's own
- Migrations go in
migrations/club/, like every migration this project writes. There is nomigrations/basketball/. Becausebasketballowns no types in the ownership map, a migration that touches a sports type the map assigns elsewhere (team,season,competitionandfixtureare still mapped tofootball) is a club migration over a type this project created for itself. - Edits to template-owned files stay small.
basketball-surface.tsexists so the change toglobal-surface.ts— a file/sync-upstreampatches — is two lines; thebasketballentry incompose-club.ts'sDOMAIN_PACKAGESis one. Each is a 3-way merge the next template update has to carry, so keep them to the minimum the generator and the surface need (Take template updates). /sync-upstreamnever offers it anything. The sync diffs the template's shared paths plus thetemplate.packagesof the domains in the TEMPLATE'sdomains.jsonthat exist here; the template has no basketball entry, sopackages/basketball-app,packages/ghost-basketballandpackages/competition-engineare never in the diff. They are this project's to maintain.- Bridges name ELB's file.
check-bridges.mjsreads the file map in.claude/manifest.json; ghost-basketball's bridges must name8Jl1miPEIZslMda8jZ8w0C(or ELB – Web – EXT,Yqy0zItrtL4UptOVW54wVg, for the three ECAL schedule-picker components).
Build a domain
Building a new domain for this site end-to-end means creating its component package, wiring it into
the theme layer, declaring its model ownership, and — if it contributes pages — adding a route
package. This page is that flow, with basketball as the worked example; substitute your
<domain>. A domain the template already has is not built this way — restore it with
/add-feature.
The short version, in order:
/new-package ghost-<domain>, then add the domain's surface module besideglobal-surface.tsif it binds a namespace Ghost lacks.- Declare the domain in
domains.jsonwith anotethat says it is this project's own and what it covers. - Any content types it needs:
migrations/club/NNNN-<name>.cjs. - The route package and its
DOMAIN_PACKAGESentry;pnpm compose. - The app theme part and the segment layouts that import it,
transpilePackages, thenpnpm verify.
Each step in detail follows.
1. The component package
⚡ Do it with Claude —
/new-package <kebab-name>(e.g./new-package ghost-basketball) bootstraps a sibling@v4/ghost-*component package: the scaffold, the theme-layer wiring, the Storybook group, the e2e setup, and every trap already banked from ghost-content, ghost-video and ghost-basketball. Run it rather than copying a sibling by hand — the traps below are exactly what it exists to get right.
The package copies the newest sibling's shape (packages/ghost-basketball):
package.json (@v4/<name>, workspace:* deps, the house scripts —
build / dev / test / test:watch / lint / check-types), tsconfig.json +
tsconfig.build.json from @v4/typescript-config, eslint.config.js from
@v4/eslint-config, vitest.config.ts + vitest.setup.ts (jsdom + jest-dom),
src/index.ts, and src/init.css.ts. Its build is
tsc -p tsconfig.build.json && node scripts/check-dist.mjs;
ghost-content and ghost-video also run
../ghost-core/scripts/precompile-styles.mjs between the two, which precompiles
the component stylesheets for the app.
Three package-level traps make this more than a copy:
- Owner-keyed theme emission. The theme registry is a module singleton
shared across vanilla-extract compile units, so this package's
init.css.tsmust callcreatePackageTheme(OWN_THEMES)with an explicit list of the theme names it owns — not a barecreateTheme(), which re-emits the whole ghost-core layer. Each new component adds its name toOWN_THEMES; a name in the list that never registered throws at compile time, andcheck-dist.mjsfails the build when the list and the package's theme modules disagree in either direction. Never reintroduce an emit-once guard keyed on a global "already emitted" set — it keys on evaluation order, which is not guaranteed, and it silently left an entire package unstyled once. - Component namespaces bind through the global contract. Bind
g.components.<domain>.*. The global surface is built in ONE place,buildGhostGlobalSurface(packages/ghost-core/src/theme/global-surface.ts), andpackages/tokens-euro-league/src/index.tscalls the same helper, so a new namespace added there reaches Euro League's overrides with no mirror to maintain. A namespace Ghost does not have goes in its own module beside it: basketball's ispackages/ghost-core/src/theme/basketball-surface.ts(g.components.basketball.*and the team coloursg.teams.teams.<club>.*), hooked intoglobal-surface.tswith a two-line edit so the template-owned file barely changes. Neverimport { tokens } from "@v4/tokens"in a component theme (it freezes generated literals beyond every Euro League override). - Register LAST. Write the component files before the
src/index.tsbarrel export and theinit.css.tsside-effect import that point at them — one unresolvable specifier blanks every story in Storybook and the dev server caches the failure until restarted.
Stories get a new top-level group {Package}/{Category}/{Name}
(Ghost-Basketball/Molecules/WidgetTable) under
apps/storybook/stories/<package>/ — the directory keeps the ghost- prefix
(stories/ghost-video, stories/ghost-basketball). e2e specs go in
apps/e2e/tests/<package>/. Prove the compile before writing a second component:
pnpm --filter @v4/ghost-basketball build
pnpm --filter @v4/ghost-basketball check-types
pnpm --filter @v4/ghost-basketball test
# then boot Storybook FRESH (kill the old process — it caches vanilla-extract failures)
# and verify one component renders with real --ph__* values.
2. Register the domain in the model
Add the domain to packages/model/domains.json — its tier, a note saying it is
this project's own, its template block (a record of the paths the domain owns),
and every content type it owns in the types map. Declaration order here is the canonical composition order, so place
the domain where it should compose relative to the others:
// packages/model/domains.json → "domains"
"<domain>": {
"tier": "bolt-on",
"template": {
"packages": ["packages/ghost-<domain>", "packages/<domain>-app", "packages/model/src/<domain>"],
"stories": ["apps/storybook/stories/ghost-<domain>"],
"e2e": ["apps/e2e/tests/ghost-<domain>"],
"storyPrefix": "ghost-<domain>",
"workspaceDeps": ["@v4/ghost-<domain>", "@v4/<domain>-app"],
"claude": [".claude/reference/figma/ghost-<domain>-plan.md"]
}
}
// packages/model/domains.json → "types"
"<domainTypeA>": "<domain>",
"<domainTypeB>": "<domain>"
New migrations go in packages/model/migrations/club/, never a domain
directory: the domain directories hold the template's numbering, and a file of
ours there collides with the template's next migration of the same number. basketball has no migrations/basketball/; its sports types are
club/0030-video-platform-sports-types.cjs and club/0038-basketball-types.cjs.
See Change the content model → Migrations.
3. The route package (if the domain has pages)
To contribute routes, the domain additionally needs a *-app package with
factories and a routes.json, following packages/basketball-app (or packages/video-app). Then register the package with the
generator:
// packages/template-config/scripts/compose-club.ts
const DOMAIN_PACKAGES: Record<string, { name: string; dir: string }> = {
content: { name: "@v4/content-app", dir: "packages/content-app" },
video: { name: "@v4/video-app", dir: "packages/video-app" },
football: { name: "@v4/football-app", dir: "packages/football-app" },
// club-owned (Euro League): the competition data areas of the site map
basketball: { name: "@v4/basketball-app", dir: "packages/basketball-app" },
};
The dependencies a route can declare are the keys of DEP_WIRING in the same
file: client, deliveryClient, buildClient, locale, media, sportsData
and competitionEngine. If the routes need one the generator does not know
about yet, extend DEP_WIRING (and add the app lib/ module it imports from) —
never hand-edit a generated shim. The full route flow is in
Add a page or route; /new-route
scaffolds one.
4. Wire the app
The app needs three hand-written insertions the generator does not make:
transpilePackagesinapps/web/next.config.ts— every raw-source workspace package the app imports.- The package's theme part. The app emits the Euro League theme in parts
(
apps/web/src/theme/):tokens.css.tsloads on every page with ghost-core and ghost-content; each further package gets its owntokens-<package>.css.tscallingcreateClubThemePartover that package'sOWN_THEMES(tokens-video.css.ts,tokens-basketball.css.ts). - The segment layouts that load it. Every route that renders the package
imports its part from its layout —
apps/web/src/app/[locale]/teams/layout.tsximports bothtokens-basketball.cssandtokens-video.css. A route that renders a package without its part gets components that declare no--ph__*variable and render unstyled, with nothing failing.
5. Compose and verify
Add the domain to apps/web/club.config.ts domains, then:
pnpm compose # rewrites composition.json, the route shims and the generated lib files
pnpm compose -- --check
pnpm verify # includes the compose drift gate and codegen drift
6. Currency (same commit)
Because .claude/ and the docs rot silently, the same commit that adds a package
updates:
- the layout table row in
CLAUDE.md, apps/docs/content/ai-tooling/skills-and-agents/if any skill/agent/rule changed,.claude/reference/figma/inventory for the package's Figma file, and the file map in.claude/manifest.json(figma.files/figma.clientFiles) socheck-bridges.mjsaccepts the package's bridges,turbo.jsononly if the package introduces new env vars.
Related
.claude/rules/frontend/theme-variant-patterns.md— owner-keyed emission and the app's theme parts..claude/rules/model/contentful-model.md— domain ownership and the migration lint.- What a domain is — the concepts behind this flow.
Add a template domain
If Euro League buys a domain the template already has — football, gamification, retail,
hospitality, ads or app — do not build it: restore it from the template with /add-feature
(pnpm add-feature -- --domain=<domain> [--ref=main] [--remote=template]).
⚡ Do it with Claude —
/add-featureruns the script and walks the follow-up checklist it prints.
It needs a clean working tree and the template git remote /sync-upstream uses
(git remote add template <publishing-hub template repo url>, then git fetch template). It reads
the TEMPLATE's domains.json at that ref as the source of truth for what the domain owns, and
(packages/template-config/scripts/add-feature.ts) restores the domain's packages, stories, e2e
folders, .claude references, its migrations under packages/model/migrations/<domain>/ and any
missing apps/web/src/lib file that references its packages; restores its domains.json template
block and adds it to composition.json; adds its workspace deps to apps/web/package.json and
transpilePackages; regenerates the composed model baselines; then runs pnpm install and
pnpm compose.
The restored migrations are the template's own sequence for that domain, so they stay in
migrations/<domain>/; only migrations this project writes afterwards go in migrations/club/.
Then follow up by hand:
- Apply the domain's content types to the space — on a throwaway environment first, then
model-apply-develop.ymlapplies them on merge (Migrations in this repo). - The app wiring it cannot insert — a feature section in
club.config.ts, and the package's theme PART:apps/web/src/theme/tokens-<package>.css.tsover the package'sOWN_THEMES, imported from every segment layout that renders the package (Build a domain → Wire the app). The script's own hint names aninit.cssimport intokens.css.ts, which predates the parts model; follow the parts model instead. - Verify:
pnpm --filter @v4/model test && pnpm --filter @v4/web check-types, thenpnpm verify.
After it lands, /sync-upstream offers the domain's template changes like those of content and
video (Take template updates).