Add a domain

Build a domain of this site's own, as basketball was, or buy one the template already has with /add-feature.

2026-11-07

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 a routes.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 in migrations/club/ (see Change the content model → Migrations in this repo).
  • Composition membership — its presence in packages/model/composition.json, written by pnpm compose from apps/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 no migrations/basketball/. Because basketball owns no types in the ownership map, a migration that touches a sports type the map assigns elsewhere (team, season, competition and fixture are still mapped to football) is a club migration over a type this project created for itself.
  • Edits to template-owned files stay small. basketball-surface.ts exists so the change to global-surface.ts — a file /sync-upstream patches — is two lines; the basketball entry in compose-club.ts's DOMAIN_PACKAGES is 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-upstream never offers it anything. The sync diffs the template's shared paths plus the template.packages of the domains in the TEMPLATE's domains.json that exist here; the template has no basketball entry, so packages/basketball-app, packages/ghost-basketball and packages/competition-engine are never in the diff. They are this project's to maintain.
  • Bridges name ELB's file. check-bridges.mjs reads the file map in .claude/manifest.json; ghost-basketball's bridges must name 8Jl1miPEIZslMda8jZ8w0C (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:

  1. /new-package ghost-<domain>, then add the domain's surface module beside global-surface.ts if it binds a namespace Ghost lacks.
  2. Declare the domain in domains.json with a note that says it is this project's own and what it covers.
  3. Any content types it needs: migrations/club/NNNN-<name>.cjs.
  4. The route package and its DOMAIN_PACKAGES entry; pnpm compose.
  5. The app theme part and the segment layouts that import it, transpilePackages, then pnpm 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.ts must call createPackageTheme(OWN_THEMES) with an explicit list of the theme names it owns — not a bare createTheme(), which re-emits the whole ghost-core layer. Each new component adds its name to OWN_THEMES; a name in the list that never registered throws at compile time, and check-dist.mjs fails 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), and packages/tokens-euro-league/src/index.ts calls 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 is packages/ghost-core/src/theme/basketball-surface.ts (g.components.basketball.* and the team colours g.teams.teams.<club>.*), hooked into global-surface.ts with a two-line edit so the template-owned file barely changes. Never import { 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.ts barrel export and the init.css.ts side-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:

  • transpilePackages in apps/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.ts loads on every page with ghost-core and ghost-content; each further package gets its own tokens-<package>.css.ts calling createClubThemePart over that package's OWN_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.tsx imports both tokens-basketball.css and tokens-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) so check-bridges.mjs accepts the package's bridges,
  • turbo.json only if the package introduces new env vars.
  • .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-feature runs 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.yml applies 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.ts over the package's OWN_THEMES, imported from every segment layout that renders the package (Build a domain → Wire the app). The script's own hint names an init.css import in tokens.css.ts, which predates the parts model; follow the parts model instead.
  • Verify: pnpm --filter @v4/model test && pnpm --filter @v4/web check-types, then pnpm verify.

After it lands, /sync-upstream offers the domain's template changes like those of content and video (Take template updates).