Troubleshooting

Field-learned failures across Storybook, the app, the test suites and the content model, each as symptom, cause and fix.

2026-11-07

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.