Troubleshooting
Grouped by what you are looking at when it happens.
The build fails
SITE_URL is unset or still the placeholder.
You are running a production deploy on https://example.com. Set SITE_URL in your host’s environment variables. This is intentional — it makes it impossible to ship canonical URLs and a sitemap pointing at the placeholder. Local builds and deploy previews are unaffected. See Deployment.
A content entry fails schema validation. The error names the entry and the field. The usual causes, in order of frequency:
- A
bodystring in aworkentry contains a colon followed by a space, so YAML parses it as a mapping. Use a folded block scalar:- >-on its own line, then the text indented below. See Content Collections. - A required field is missing.
heroImageandcategoriesare required on a blog post, andclient,title,description,order,cardImage,heroImageandnarrativeare all required on a case study. categories: []— the schema is.min(1), not.optional().
Could not resolve image or a missing asset.
Frontmatter image paths are relative to the entry file, which from src/data/blog/<slug>/index.md means three levels up: ../../../assets/images/….jpg. Count the segments.
An author reference does not resolve.
authors: ["amara-osei"] must match a folder name under src/data/authors/. The reference fails at build rather than rendering an empty byline, which is the point.
astro check errors on an icon name.
IconName is a union over the real registry. If a name does not type-check, it is not in the set — open /examples/ui and search the Icons panel, or check iconNames directly. Do not add it to icons.ts; that file is auto-generated and rewritten wholesale. Add it to custom.ts.
astro check errors after renaming a service.
That is the type system working. ServiceSlug is derived from the services array in navData.json.ts, so ServiceTabs fails at its panel map rather than rendering a rail with a missing panel. Update both.
Node version errors on install.
The floor is 22.13.0, enforced by engines.node. Older releases fail on the test runner even if the install appears to succeed.
Something looks wrong in the browser
A component is un-themed — white on white, or the wrong grey.
Almost always a raw Tailwind colour where a token belongs. Search the component for orange-, stone-, gray-, zinc-, slate-, bg-white and text-black. Replace with a semantic token: bg-primary, text-foreground, bg-muted, border-border. See Colors.
A base-* utility does not change in dark mode.
It is not supposed to. base-* is a Layer 1 palette alias, and Layer 1 does not flip — it is warm in both themes. Use the semantic tokens (bg-muted, text-muted-foreground, border-border) for anything that must follow the theme’s neutral hue.
A new token you added does not flip.
You need all three layers. Declare it in :root and .dark in global.css, then bridge it in @theme inline in tailwind-theme.css. inline is required — without it the token resolves to a literal at build time and stops being a runtime variable.
Light mode looks broken on one page.
Check whether that page mounts a ThemeToggle. A page without one is dark-only, so light-mode bugs on it are invisible until someone mounts one. The 404 illustration shipped with a stock raw-hex palette for exactly this reason — a light-mode-white figure on a near-black page, unnoticed because the 404 has no toggle.
A flash of the wrong theme on load.
The pre-paint script in BaseHead has been moved or bundled. It must stay is:inline and in <head>. Moving it to a bundled <script> reintroduces the flash.
An in-page anchor lands under the header.
scroll-padding-top: 5rem on html clears the fixed bar. If you changed the header’s height or its top-6 offset, update that value too.
The header parks with a sliver still on screen when it retracts.
Its translate is -100% - 1.5rem — its own height plus the gap it floats in. That 1.5rem must track top-6. They are the same number written twice, because a translate percentage resolves against the element’s own box rather than the offset.
Motion does not play
A scroll-driven reveal never fires. Three things to check in order:
- Is it inside an
overflow-hiddenbox? A hidden box is a scroll container, soview()tracks that frame — which never scrolls — instead of the page, and the animation freezes. Useoverflow-clip. - Is it above the fold? A scroll-timeline element already in view on load renders mid-progress. Use a plain time-based
animate-*for above-the-fold content. - Is the wrapper
display: contents? That breaks the timeline.<Reveal>must render a real box.
A parallax or fade-through is inert in Firefox.
Deliberate. Scroll timelines are unsupported there, and a @supports guard in motion/index.css makes the scroll-only shapes inert rather than letting them play once and strand content off-screen or invisible. Add any new scroll-only keyframe to that guard’s list.
Motion still plays with “reduce motion” on.
The global guard zeroes time durations, so it cannot stop a scroll-driven animation. Any element you drive with timeline-* must also carry motion-reduce:animate-none.
Reduced motion leaves text permanently hidden.
The guard is losing the cascade. An armed reveal rule is more specific, so the guard needs !important. This was a live defect in one sidecar until it was measured in-browser.
A sidecar stylesheet’s motion ignores reduced motion entirely.
An unlayered sidecar outranks the layered global guard. Every sidecar must restate its own @media (prefers-reduced-motion: reduce) block.
An animate-* class does nothing.
Either the class name was interpolated — animate-${x} is invisible to the compiler and produces no CSS — or the keyframe was tree-shaken. Confirm with pnpm build and grep dist for the @keyframes.
A carousel’s arrows are dead, or a reveal did not arm.
Two primitives that both ship a script are sharing a root element. _client.ts wires an element at most once across the whole library, so whichever registers second finds it already wired and returns — with no error anywhere. Give each script its own box; nesting is fine. Reusing a sibling’s tv() config is fine.
Interactive primitives stop working after navigation
A dropdown or dialog stops responding after a view transition.
Those four use delegated controllers bound once at document level and should survive. If something else broke, check that its script uses onReady from _client.ts rather than a bare DOMContentLoaded listener.
A primitive fires twice per interaction.
Almost certainly transition:persist on an element that survives a swap. _client.ts handles this correctly — cleanups run on astro:before-swap and the element is wired again from scratch — but only if your wire passes { signal } to every addEventListener. A listener bound without the signal is never released.
A small script broke after a build but works in dev.
Check vite.build.assetsInlineLimit is still 0. Inlined short scripts break under <ClientRouter />.
Development annoyances
A renamed content entry keeps 404ing.
Astro’s content layer caches entries, and moving or renaming a folder while the dev server runs leaves the old entry in place. Restart pnpm dev; touching the file will not clear it.
A utility class has no effect in dev. In a long-running dev server, classes used only in newly created files can be missing from the generated stylesheet. Restart before you go looking for a bug in your markup.
Install prints a notice about ignored build scripts.
That is allowBuilds in pnpm-workspace.yaml denying post-install builds for @parcel/watcher, esbuild and sharp. Working as designed.
pnpm outdated always shows TypeScript behind.
Held at 6.x on purpose. @astrojs/check accepts ^5 || ^6 and typescript-eslint accepts <6.1.0, so bumping breaks pnpm check and pnpm lint together.
pnpm test fails with “No checks found”.
Discovery found zero *.test.ts files under src/. That is a failure rather than a pass by design. You have probably deleted or renamed one — the convention is exact.
A *.test.ts fails with ERR_MODULE_NOT_FOUND.
The module it imports is not dependency-free. Checks run under bare Node with type stripping, which resolves neither the path aliases nor astro:content. Extract the logic into a module that imports nothing.
Things that are working as intended
Eight header and footer links 404.
/careers/, /process/ and six /services/<slug>/ pages. navData.json.ts ships the design’s full IA so the chrome looks complete. Build them or delete the rows — step 6 in Deployment.
The contact form does nothing when submitted.
It has no action and no handler. The theme is fully static and assumes no mail provider. Wiring a destination is a required launch step — see Contact Form.
The CSS bundle is larger than expected.
The dev catalog’s demo classes are in it. Tailwind scans Sections/UiCatalog/ even though it builds no pages. Deleting it and src/pages/examples/ takes the shared stylesheet from 103,249 to 84,802 bytes. Do not try to fix it with @source not — that directive is not build-mode conditional and silently breaks the catalog in dev too.
/examples/ui is missing from the production build.
Correct. getStaticPaths returns [] when import.meta.env.PROD.
A case-study page has no JSON-LD of its own.
Deliberate. A case study here has no author and no publication date, so an Article node would ship with two effectively-required fields empty or invented. The site-level Organization and WebSite graph still covers the page.
getBreadcrumbSchema is exported but unused.
Also deliberate. It requires a visible breadcrumb nav — emitting the structured data without one is markup and schema disagreeing. Build the nav first.
When the numbers in the docs disagree with the code
Re-derive rather than trusting a paragraph, including one here. The icon count in this theme was wrong in three separate places at once — a README said 504, a generated file’s header said 497, and a note claimed a batch was never committed when it was. The truth came from Object.keys(ICON_REGISTRY).length, which is 572, and the dev catalog had been rendering that number correctly the whole time.
Counts move when sections land. Measure, do not quote.