Troubleshooting
Field-learned failures across Storybook, the app, the test suites and the content model, each as symptom, cause and fix.
Storybook and components
The failures here share a nasty property: one broken module takes the whole
Storybook down, not just the story you were editing. A blank sidebar is almost
never the story you last touched — it is a compile failure somewhere in its
import graph, and the dev server caches that failure until you restart it. Read
the dev-server log for Internal server error before you suspect anything else.
Every story in Storybook goes blank after adding a component
Symptom — You add a new component, and the entire Storybook renders blank —
every story, not just the new one. Story modules still return 200; the browser
console shows Failed to fetch dynamically imported module.
Cause — A registration point was written before the file it points at
exists. The two registries — packages/ghost-core/src/index.ts (the barrel
export) and packages/ghost-core/src/init.css.ts (the side-effect theme import)
— are part of the vanilla-extract compile and the Vite import graph. One
unresolvable specifier fails the whole compile, so every story dies at once. The
classic miss is pointing at a bare directory that has no index.ts yet.
Fix — Register last. Order of work per component: write the
component/theme/css files, build and type-check them, then add the two
registration lines, then restart Storybook. If you must break off mid-component,
comment the registration lines out with a RESTORE marker rather than leaving a
dangling import. Preflight check: every relative specifier in the two registries
must resolve to a real file — a directory without index.ts does not count.
Storybook blanks after a .css.ts edit, with no obvious bad import
Symptom — Same blank-Storybook signature, but you only edited a .css.ts
file — no registration change. The dev-server log carries
Cannot access 'x' before initialization.
Cause — A temporal dead zone (TDZ) error in one .css.ts. Inserting a
globalStyle(${x} …) above the const x = style(…) it references throws
at compile time, and — exactly like the register-last failure — one failing
.css.ts fails the whole package's vanilla-extract compile, so every story
importing the package dies. The story module itself still curls 200; the error
is in its dependency graph.
Fix — Place any cross-element rule after the last declaration it
references. When Storybook blanks after a css edit, grep the dev log for
Internal server error before suspecting the Vite graph.
A component renders with stale CSS after you edited a shared module
Symptom — You edit a shared non-CSS module (a family style factory, a tokens
helper) that a .css.ts imports, and Storybook keeps serving the old CSS. Your
change appears to have done nothing.
Cause — vanilla-extract does not invalidate a .css.ts when a plain
.ts dependency of it changes. The .css.ts is never re-evaluated, so the
running dev server holds stale styles.
Fix — Restart Storybook after editing anything under src/families/** or a
shared theme helper, before running visual-diff, story tests or e2e — otherwise
you will chase phantom failures. The same warning applies to the token pipeline:
a src/ fix in @v4/token-parser changes nothing until you rebuild its dist/,
rebuild @v4/tokens, clear the Vite cache and restart.
A component renders completely unstyled
Symptom — One component renders with no styling at all, while everything else is fine. Often only on certain colour sets, or only in a sibling package's stories.
Cause — Two candidates. (1) An unknown variant value: vanilla-extract
recipes silently drop a variant value they don't recognise and render the element
unstyled. The Storybook colour-set toolbar forces arbitrary sets onto every
story, so a component that supports only a subset of the ramp hits this
constantly. (2) A missing init.css.ts side-effect import: a component's
theme must register inside init.css.ts's vanilla-extract compilation unit, or
its --ph__* variables are never emitted — the registry cannot see across
compile units.
Fix — For (1), resolve any unsupported colour-set value to the component's
default set before calling the recipe (the Badge resolveVariant pattern). For
(2), add the one alphabetical side-effect import line to init.css.ts and, in a
sibling package, add the component's camelName to that package's OWN_THEMES
list — new-component appends it for you, and a hand-scaffolded component that
forgets it fails the package build (that failure is the guard working). A private
_internal/* layout is not caught by the sibling packages'
scripts/check-dist.mjs (ghost-core's owner-list guard is
scripts/check-theme-owners.mjs), so its side-effect import must be added by
hand; the symptom is silent — every consumer keeps its classes and declares zero
--ph__<layout>-* variables.
A component is styled in Storybook but unstyled in the app
Symptom — The story is correct; on the site the same component renders with
no colours or spacing, and its element declares no --ph__* variables.
Cause — In apps/web the club theme is the one emitter
(PH_THEME_EMISSION=club in apps/web/next.config.ts; packages only register),
and it is emitted in parts that load with the routes that render them:
apps/web/src/theme/tokens.css.ts (every page: ghost-core + ghost-content),
tokens-video.css.ts and tokens-basketball.css.ts, imported by the segment
layouts that need them (app/[locale]/video/layout.tsx, the data-area layouts
such as games/ and teams/, news/[...path]/layout.tsx, …). A route that
renders a package without loading its part gets nothing — and nothing fails. The
other cause: a stylesheet, a .theme.ts, or something they import reached a
package barrel or a family's .tsx, and the package's precompile step
(packages/ghost-core/scripts/precompile-styles.mjs) failed or emitted into the
wrong scope.
Fix — Import the package's theme part in that route's layout. Check it in the
browser: every ph__*-theme class the page uses must have declarations in that
page's stylesheets. For the second cause, import from @v4/ghost-core/constants,
/classnames or /theme (or the family's *.tokens.ts) instead of the barrel.
Rules: .claude/rules/frontend/theme-variant-patterns.md, "The app emits its club
theme in PARTS".
A change to ghost-core or the model does not show in apps/web
Symptom — You edited a component, a theme or a @v4/model transform; the
story shows it, the running site does not — even after a refresh.
Cause — The app consumes workspace packages through their built dist
(@v4/ghost-core exports ./dist/index.js; @v4/model's subpaths point into
dist/). pnpm dev does not rebuild them, and building under a live next dev
poisons its cache.
Fix — Rebuild the package (pnpm --filter @v4/ghost-core build, or
@v4/model), stop next dev, rm -rf apps/web/.next, and start it again.
Storybook shows different colours from the tests
Symptom — A colour measured in a test, or a contrast result, does not match what you see in the Storybook UI; or a contrast result moved after a palette change in one suite and not the other.
Cause — The Storybook UI renders the Euro League club theme by default.
The Playwright e2e pins Ghost: its storageState sets localStorage["ph-club-theme"] = "ghost" before the page loads, so it asserts the Ghost 2.0 contract. The vitest story suite sets the same flag in .storybook/vitest.setup.ts but still renders the Euro League theme — the preview reads the flag when it loads, before the setup runs (measured 29 Sep 2026: a story resolving g.primitive.color.primary["1"] rendered ELB's #FA5500, not Ghost's #1976DC). So an ELB palette change moves story-suite colour and contrast results, and the e2e's do not. The app renders the Euro League theme.
Fix — Compare like with like: add &globals=clubTheme:euro-league to a story
URL to see the club values, or clear the stored flag. A contrast result that
depends on the ELB palette is a question for the app (CONTRAST=1 node scripts/page-audit.mjs), not for the Ghost-pinned suites.
Tests and CI
Most test failures in this repo are environmental, not real — a stale or busy server produces failures that read exactly like a broken component. The single most useful reflex is to split real from environmental by timing: a genuine assertion fails fast (about a second — these specs compare computed values with no retry loop), an environmental one sits on the full expect timeout (15s, set at the config root). Confirm any suspicious red on a quiet tree — no concurrent agents, nothing else serving on the port — before you believe it.
The e2e webServer serves a static Storybook build (pnpm turbo run build --filter=@v4/storybook && pnpm --filter @v4/storybook serve-static), so the
cold-compile and worker-starvation failures this page used to lead with no longer
happen on the default path. They come back the moment reuseExistingServer
adopts a Storybook dev server you left on :6006 — which is the first thing to
check.
e2e "element(s) not found" across many specs
Symptom — A batch of failures, all toBeVisible() on a "does the story
exist" gate, all at the full 15s timeout; the victims move between runs.
Cause — Rarely the component. reuseExistingServer: true adopted something
already on the port: a Storybook dev server (on-demand Vite compiles; the
alphabetically-first spec, accordion-block-expandable-section, meets the
coldest compile), a dev server with a stale module graph, or another checkout's
Storybook entirely (next entry). A wrong story id has the same signature and does
not move — multi-word exports kebab-case (--with-token-overrides); the page
console carries NoStoryMatchError, and GET /index.json lists the real ids.
Fix — Stop the dev server and let the e2e build and serve the static Storybook. If you must run against a dev server, drop to one or two workers. Diagnose by timing: one spec, single-worker — a real failure fails fast.
e2e runs against another checkout's Storybook
Symptom — Stories that exist in this repo are "not found", or components render a design you already changed.
Cause — Playwright's reuseExistingServer checks only that something answers
on the port. A template-repo Storybook left on :6006 once served a whole batch's
e2e from the wrong tree.
Fix — Check whose server it is, and if it is not this repo, run on a free port rather than killing it:
lsof -p $(lsof -t -iTCP:6006 -sTCP:LISTEN) | grep cwd
E2E_PORT=6016 pnpm --filter @v4/e2e test -- <spec>
The whole Storybook vitest browser suite wedges
Symptom — The full @v4/storybook vitest browser suite hangs and never
finishes, yet chunked or per-file runs pass fine.
Cause — A running :6006 Storybook dev server can deadlock the full-suite
run, and Turbo runs storybook#test and e2e#test concurrently.
Fix — Stop the Storybook dev server, then run the suite (its script retries
once: vitest run || vitest run). Do not diagnose a wedge by counting
processes — Playwright and Vitest run fixed worker pools, so the count looks
identical whether the suite is grinding or dead. Measure work instead:
ps aux | grep -E "vitest|playwright" | grep -v grep | awk '{s+=$3} END {print "total CPU%:", s}'
Hundreds of percent means it is busy. Near-0% with processes still alive is the real wedge signature.
An interaction e2e fails on a story with a play function
Symptom — A disclosure spec clicks the trigger and aria-expanded stays
"false" through the full timeout — at both rungs, in isolation, single-worker,
on a quiet tree. Several components sharing a hook fail the same way.
Cause — The story's play function runs on mount and drives the same
control, so the e2e is racing it: the e2e's click opens the panel and a later
play click shuts it. A pre-check of aria-expanded="false" cannot tell before
from after.
Fix — Point the spec at a play-less story (Interactive: args, no
play). What settles it is the story suite: if the same play function passes in
Vitest browser mode, the component works.
page.evaluate returns null/false for everything, as if the DOM were empty
Symptom — A spec navigates and then reaches straight into page.evaluate with
document.querySelector; every parentage or structure check comes back false
or null, reading like a broken DOM tree. The same test passes when run alone
with -g.
Cause — page.evaluate() does not auto-wait (unlike expect(locator) and
locator.evaluate()). Storybook mounts the story after page.goto resolves, so
on a slow render the evaluate reads an empty document.
Fix — Await one expect(locator).toBeVisible() on the subtree you are about
to inspect before any page.evaluate, or scope the evaluate to a locator. A
related engine divergence: pair every "all images ready" wait with a
"there is at least one image" gate — [].every(…) is true, so an empty-document
wait resolves instantly (Firefox resolves goto early enough to hit that vacuous
window, which produced a card measured "372px outside its container" that was
purely a missing wait).
not.toBeDisabled() fails on a button that is clearly enabled
Symptom — await expect(el).not.toBeDisabled() fails against a button that
takes focus and fires its handler perfectly.
Cause — The house disabled pattern uses aria-disabled="true" (not the HTML
disabled attribute) to keep the control keyboard-discoverable. Playwright's
toBeDisabled() / not.toBeDisabled() treat aria-disabled="true" as disabled,
so they are blind to exactly the distinction the pattern exists to express.
Fix — Assert the pieces directly instead:
await expect(el).toHaveAttribute("aria-disabled", "true");
expect(await el.evaluate((n) => (n as HTMLButtonElement).disabled)).toBe(false);
await tabTo(page, el);
await expect(el).toBeFocused();
A story override at a specific breakpoint has no effect in e2e
Symptom — A spec passes a tokens override through Storybook URL args at a
breakpoint and the override never lands, so the test passes or fails for the
wrong reason.
Cause — Storybook URL args do not reach two levels into an object.
&args=colourSet:inverse works; &args=tokens.group.leaf:24px silently does
not apply.
Fix — Put any override a spec needs into a real story
(DesktopOnlyTokenOverride), never a URL arg. It is better documentation anyway
— it makes the breakpoint-shadowing case visible in the sidebar instead of hidden
in a URL. And remember the paired discipline: e2e specs assert resolved token
values, so editing a component's theme — or a story's tokens demo value —
silently reddens its spec. Grep apps/e2e/tests/{package}/<component>.e2e.ts for
the old literal in the same change.
A contrast result moved after a palette change
Symptom — After an ELB palette change, a contrast finding appears or disappears in one place but not another.
Cause — Only the e2e pins the Ghost theme. The story suite sets the same flag, but its static import of the preview reads it first, so it renders the Euro League theme like the Storybook UI and the app — and moves with an ELB palette change while the e2e does not.
Fix — Measure ELB contrast where ELB renders: CONTRAST=1 node scripts/page-audit.mjs <path> against the running app, or a story URL with
&globals=clubTheme:euro-league.
next dev runs out of memory on the Game Centre
Symptom — The dev server crashes with a heap out-of-memory error when you open a game page.
Cause — The Game Centre's module graph is too large for next dev.
Fix — Run a local production build: pnpm --filter @v4/web build (its script
already sets NODE_OPTIONS=--max-old-space-size=12288), then
pnpm --filter @v4/web start -- -p 3155, with the Competition Engine reachable at
COMPETITION_ENGINE_BASE_URL.
The deploy build fails while dev and CI pass
Symptom — pnpm dev and CI are green; the Flightcontrol build fails during
prerendering.
Cause — A URL hook (usePathname, useSearchParams) read outside a
<Suspense> boundary. next dev and the CI checks tolerate it; next build
does not. The dev deploys failed every build from 2 Oct 2026 until #129 moved the
pathname read behind Suspense.
Fix — Wrap the component that reads the URL in <Suspense>, and run
pnpm --filter @v4/web build locally before pushing a change that adds one.
Model and Contentful
The model's source of truth is packages/model, never the Contentful web app,
and its CI drift gate compares a live environment's introspected schema against
the vendored schema.graphql. That gate is sensitive by design — which means it
also reddens for reasons that are not model changes at all. These are the ones
that have actually caught people out.
The drift gate reddens right after bootstrapping a new environment
Symptom — CI drift goes red within the hour of standing up a new environment or club, with a schema diff you did not author — often around 51 lines.
Cause — The runner's own ledger bootstrap. Creating the modelMigrationState
type (the per-environment migration ledger) legitimately grows the environment's
schema, and the drift gate catches everything that touches the model — including
the runner's own housekeeping.
Fix — After any ledger bootstrap in a new environment class, resync the
vendored schema (pnpm --filter @v4/model drift locally, or let the sync-schema
step regenerate it). This is the gate working, not a fault — the diff is real, it
just came from the runner rather than from a migration.
Introspection returns an empty schema (0 types)
Symptom — The drift or PR gate introspects a schema with zero content types and diffs it against a full model, so everything looks deleted. No error is raised.
Cause — API keys are environment-scoped, and a key that cannot see the target environment introspects an empty schema rather than erroring. A freshly created environment is in no API key's scope until it is added.
Fix — Check the key's environments list before trusting a "0 types" result.
The PR gate handles this itself — it scopes the pre-production delivery key (the
one scoped to develop and not master, found by its scope rather than its name)
to its ephemeral environment before introspection and unscopes it on cleanup. Also note the GraphQL
API takes delivery tokens, not CMA; sync-schema derives an
environment-scoped delivery key from the CMA api_keys endpoint so CI carries one
secret (CONTENTFUL_CMA_TOKEN).
Clone-vs-source drift flakes on descriptions with zero model change
Symptom — A clone-vs-source schema diff reddens purely on type/field descriptions (docstrings) or on directive definitions, with no actual model change.
Cause — A long-lived environment can serve a stale GraphQL schema.
Contentful regenerates a fresh clone's GraphQL schema from the content types'
current descriptions, while the source environment keeps serving cached
docstrings — so the two disagree on prose that nobody changed. Directive
definitions (@enumMapper, @fieldResolver, …) exist on develop's backend but
not on a fresh clone's, and diff the same way.
Fix — This is already handled in the vendored SDL and must stay handled:
sync-schema vendors a deterministic schema — lexicographicSortSchema, all
descriptions stripped via an AST visit, and all directive definitions dropped.
Type shape is the contract; never re-add docstrings or directive definitions
to the vendored schema.graphql to "fix" a diff. If a real diff hides behind
description noise, resync and re-diff rather than hand-editing the generated file
(which the drift gate forbids anyway).
The drift gate goes red on every other PR
Symptom — PRs that touch nothing in the model fail the drift gate, all with the same schema diff.
Cause — A migration was applied to the shared develop environment before
its PR merged. develop now serves a schema the vendored schema.graphql on
main does not have, so every other branch drifts.
Fix — Merge the PR that carries the migration and its regenerated schema;
never regenerate the vendored schema on an unrelated branch to make the gate pass.
To prove a new field's schema before merge, migrate a throwaway clone of
develop instead of develop itself.
Codegen fails with "Fragment cannot be spread here" after a migration
Symptom — After a migration edited a reference field, generate fails in one
of the app packages with Fragment cannot be spread here.
Cause — A reference field's linkContentType is its GraphQL union. A type
removed from the allow-list leaves the union, and any fragment spreading it is
now invalid — it is a schema change, not just a validation.
Fix — In the same PR: fix the fragments, re-vendor schema.graphql from an
environment the migration has run on (sync-schema), regenerate @v4/model,
apps/web, @v4/content-app and @v4/video-app, and make sure the model PR gate
is green before merging — it is not a required check.
videoCollection changes meaning between environments
Symptom — A query using videoCollection returns entries of one type on
develop and another on a fresh clone, or the drift gate flips on that field
with no model change.
Cause — A content type whose id is another type's id plus Collection
(videoCollection beside video) claims the same Query field as the other
type's auto-generated collection, and Contentful hands it to whichever type was
published last.
Fix — Already handled: sync-schema drops the ambiguous field name from the
vendored schema. Never query it; list Video entries through a linking collection
or the video platform.
A field-removal audit misses a field that is still queried
Symptom — A search for a field id finds no fragment using it, the field is removed, and codegen breaks.
Cause — GraphQL field names are not always the field ids: Contentful
re-cases them. The field id isContentTargetedAtUnder18s is
isContentTargetedAtUnder18S in schema.graphql.
Fix — Match case-insensitively, include filter arguments as well as selections, and prove the removal with codegen against a migrated clone.
lint-migrations or sync-upstream stops on a numbering collision
Symptom — A template sync stops before opening a PR because two migrations share a number in a domain directory.
Cause — One of this project's migrations was written into a domain directory
(migrations/content/, …), which holds the template's sequence. The next
template migration of the same number collides.
Fix — This project's migrations go in migrations/club/. Move an already-applied one
and declare the move in packages/model/migrations/renamed.json, so the runner
reads the old ledger entry as the new file rather than running it again — the
file records five such moves.
Site copy differs from the design, with nothing wrong in the code
Symptom — A label on the site does not match the default copy in
packages/model/src/dictionary/keys.ts.
Cause — Resolution is requested locale → the default dictionary locale
(en-US in this space) → the keys.ts default. A label seeded or edited in
Contentful's en-US beats the code default.
Fix — Change the en-US label in Contentful (editors own labels), or confirm
with the editors that the difference is intended. See
Dictionary keys.