Troubleshooting
Grouped by what you are looking at when it happens.
The build fails
“link(s) in the built HTML point at a path this build did not produce”. You linked somewhere that does not exist. The message names the dead path, how many pages draw it, and one example page. Three fixes, in order of preference: build the page, retarget the link, or drop the row. Note the ordering when you add a route — add the route before the navData row, or this fires.
It can also fire on a href a helper rewrote rather than one you wrote. The known case is a trailing slash: getLocalizedRoute normalizing /rss.xml into /rss.xml/, which 404s under trailingSlash: "always". If the dead path looks like something you declared correctly, compare the emitted href with the declared one.
A content-schema error naming an entry and a field. Zod rejected frontmatter. The common ones are a missing heroImage (required in both blog and customers), a metrics array that is not exactly three entries, and description or standfirst left out — they are two different required strings doing two different jobs.
Role "…" — location "…" names none of careersData.offices. A careers advert’s location string contains no declared office city. Either add the office to careersData.offices or fix the string. This throws rather than degrading because a JobPosting with no jobLocation is not worth emitting.
department "…" is not in careersData.departments or the equivalent for an integration category. An entry names an id that does not exist. It throws rather than dropping, because a silently dropped role leaves a table of eight under a heading that still says nine.
TeamSection: no image imported for "…". A config entry has no matching static import in that component’s image registry. Add the import to the registry map; the error names both the key and the component.
A pnpm test failure in theme-contrast.test.mjs. You changed a colour token and a pair no longer clears WCAG AA. The output names the pair, the theme it fails in and the ratio. Move a stop rather than lowering the threshold — the shipped design itself had a white-on-teal pair at 2.4:1, which is exactly the class of mistake this catches.
ERR_MODULE_NOT_FOUND from a check file. The module under test imports something Node cannot resolve — a @js/* alias, an astro:* module, or a relative path without its extension. Checks run under bare Node with type stripping. Use import type (not import { type … }, which keeps the statement) for types, and relative specifiers with extensions for values. See Commands & Testing.
Something looks wrong in the browser
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 — the class is on the element and no rule exists. Restart the dev server before you go looking for a bug in your markup. The tell is that pnpm build renders it correctly.
Every image renders as alt text, and /_image/ 500s. sharp has stopped resolving in the running dev process. Restart it. Again, pnpm build is unaffected, which is the tell.
A renamed content entry keeps 404ing in dev. Astro’s content layer caches entries, and moving or renaming a folder while the server runs can leave the old entry in place and the new one missing. Restart; touching the file will not clear it.
Text is invisible inside the footer, or inside a pinned-dark card. class="dark" re-points token variables, but color is an inherited computed value — anything inside without an explicit colour class keeps light-mode ink. Add a token class (text-foreground, text-card-foreground). NotchedCard’s tone="ink" carries text-foreground for exactly this reason.
A raw Tailwind colour looks right and then breaks in dark mode. bg-teal-500 and text-gray-300 bypass both the theming and the dark mode. Use semantic tokens: bg-primary, text-muted-foreground, border-border.
A short page shows the olive canvas below the dark footer. Something removed the sticky-footer column — BaseLayout’s flex min-h-[100lvh] flex-col on <body> plus grow on <main>. Without it the body stretches but nothing pushes the footer down. Every shipped page has a full-height hero, which hides the bug rather than fixing it.
The header overlaps the first band. The header is fixed and out of flow; <main> carries pt-18 md:pt-22 to clear it. A full-bleed hero that wants the bar overlaid cancels it with a negative margin — check you have not removed the padding instead.
A card’s data-slot styling hook does nothing. The primitive emitted the attribute twice, and an HTML parser keeps the first. Primitives that accept a caller’s data-slot must destructure it rather than leave it in ...rest, because the spread lands after the literal attribute. Section does this correctly and is the reference.
The motion is wrong
A scroll-driven animation is frozen at its first frame, but the element renders. Almost always one of two causes.
An ancestor has overflow-hidden, which makes it a scroll container and kills the timeline. Use overflow-clip — that is why Section’s variant is clip and why MaskedImage has no clip at all.
Or the element is position: fixed and uses timeline-scroll. The bare scroll() form resolves to the nearest scroll container in the containing-block chain, and a fixed element’s containing block is the viewport, so there is no such ancestor. Use timeline-scroll-root.
A parallax leaves content offset, or a fade ends invisible, in Firefox. Scroll timelines are unsupported there, so the declaration is dropped and the animation plays once, time-based. Entrances survive that because they end at identity; scroll-only shapes do not. Add the animation to the @supports not (animation-timeline: view()) guard at the bottom of motion/index.css, which makes it inert instead.
Reduced motion still shows a staggered drip. The global guard zeroes durations and delays for exactly this reason — a zero duration on a staggered set still leaves every item waiting out its delay. If you are seeing a drip anyway, the delay is being applied somewhere the guard does not reach, or the animation is scroll-driven. A scroll-driven animation is progressed by position, not time, so the guard cannot stop it: the element must also carry motion-reduce:animate-none.
A reveal blinks — the card paints, then snaps to transparent and fades in. That is the failure data-armed exists to prevent, and it means the controller released an element that was never armed. Check the element carries data-play-on-sight and data-at-rest and that _reveal.ts is wired through onReady.
Content is invisible with JavaScript off. Something is suppressing the animation’s 0% keyframe rather than its name. The whole design of [data-at-rest] { animation-name: none } is that the element renders its finished state when no script runs. If you write a new reveal, suppress the name, never the opacity.
An interactive primitive stops working after the first navigation. It bound only on load. Everything scripted must go through onReady in _client.ts, which re-wires on astro:after-swap. If the element also survives the swap under transition:persist, pass the AbortSignal to every listener or it will collect a second copy each time — a persisted theme toggle would toggle twice per click.
The contact form
The form renders but the submit is disabled, with a note under it. CONTACT_WIRED in src/components/Contact/_form.ts is false. That is the deliberate unwired state. Flipping it alone is not enough — you also need an adapter, src/actions/index.ts mounting the action, and prerender = false on the route. All four ship done in this theme, so if you are seeing this, one of them was reversed.
“This form is not available right now. Please email us directly.” RESEND_API_KEY or CONTACT_TO_EMAIL is unset. The server log carries the operator’s version of the message naming both. Set them as secrets on the host (wrangler secret put), not in a file.
“Your message could not be sent.” on every submission, with a 403 from Resend. You have not verified a sending domain, so the sandbox sender only delivers to the Resend account owner’s own address. Either set CONTACT_TO_EMAIL to that address, or verify a domain at resend.com/domains and set CONTACT_FROM_EMAIL to an address on it.
A submission is rejected as spam by a real person. The time gate is three seconds from render to arrival, judged server clock to server clock. A page served from a cache would break that; /contact/ is on-demand precisely so its timestamp is fresh.
Submissions vanish with the page still looking fine. This is the state the single switch exists to make unreachable: a form that posts to a route with no action re-renders empty and drops the message. If you have wired things by hand, check that the POST target, the wrapper element and the submit’s type are all gated together.
Deployment
Canonical URLs, the sitemap and the feed all point at the wrong domain. site in astro.config.mjs is still the demo deployment. It feeds seven things and none of them looks broken in review.
The sitemap is missing /contact/. It emits no file, so @astrojs/sitemap cannot enumerate it. It is named by hand through customPages, fed from ON_DEMAND_ROUTES. If you removed the route’s prerender = false, remove it from that array too.
Wrangler complains that main does not exist. Do not add main or assets to wrangler.jsonc. The Cloudflare Vite plugin reads that file at the start of the build and validates main, so naming the build’s own output there fails before the build can create it. The adapter merges your config with the paths it produced and writes dist/server/wrangler.json, which is what pnpm deploy:cf deploys.
A relative path resolves to nothing while inspecting the build. astro build deletes and recreates dist/, so a shell sitting inside it from an earlier cd is left on a deleted inode. Use absolute paths when inspecting build output.
Still stuck
Three places carry more detail than this page can:
AGENTS.md(symlinked asCLAUDE.md) — the house rules and the gotcha list, binding on any change..claude/rules/— five rule files: TypeScript, Tailwind, Astro, motion and SEO. The last two are the ones to read before touching an animation or the<head>.wiki/— a maintained knowledge base with a page per subsystem and the design decisions behind them. Start atwiki/index.md.
Most of the theme’s non-obvious behaviour is also documented at the top of the file that implements it. If something surprises you, read that file’s header before assuming it is a bug — in this codebase the header usually explains why.