Troubleshooting
Most of these are failures the theme deliberately makes loud. If a build stops with a message naming a file, that is usually the design working.
The build throws on SITE_URL
SITE_URL is unset or still the placeholder.
A production deploy refuses to build on https://example.com, because that value feeds canonical, OG, JSON-LD, the sitemap, robots.txt and llms.txt at once, and none of them looks broken in review.
Set SITE_URL in your host’s environment. If your host is not Netlify or Vercel, the gate cannot see its own production signal, so set DEPLOY_ENV=production there too — and nowhere else, or your preview deploys will start failing on the placeholder as well.
Local builds and deploy previews are unaffected by design.
A check fails saying the description describes a starter template
siteData.test.ts is doing its job. The brand description in src/config/siteData.json.ts ships in the JSON-LD on every page and opens llms.txt, so it is how crawlers learn what the practice is. Write the real one.
pnpm test fails on the dead-link list
navData.test.ts asserts that the links the chrome draws which do not resolve are exactly the PLANNED_ROUTES list in src/config/navData.json.ts.
Two ways to hit it. You added a link to a route that does not exist — add it to the list or fix the href. Or you built one of the planned routes and did not strike it off — remove it from the list in the same commit.
That is the check keeping the promise that nothing else on the site 404s.
The booking form returns 500 in production but works in dev
This is the failure the endpoint was rewritten to prevent, so first check the obvious cause: the server log names the missing variable. RESEND_API_KEY, BOOKING_FROM and BOOKING_TO all have to be set in the host’s environment, and BOOKING_FROM must be on a domain verified in Resend — unverified senders are rejected.
If all three are set and it still fails, check that the code reads them through astro:env/server and not import.meta.env. Vite substitutes import.meta.env.RESEND_API_KEY at build time, which on a build machine without the secret is undefined; the “is it configured?” guard then folds to always-true and everything after it is dead-code-eliminated. The compiled chunk becomes four lines that return 500 for ever, while astro dev works perfectly.
The declaration in astro.config.mjs — access: "secret" — is what makes the value a runtime lookup.
Three identical bookings from one click
Fixed in the shipped code, but if you have edited _booking.ts, check the busy latch still disables event.submitter rather than the wizard’s Next button. On the last step the Next button is hidden and is not the control anyone presses, so latching it lets three impatient clicks send three POSTs.
“An invalid form control is not focusable”
Native validation refuses to report on a control it cannot focus, so a required field inside a hidden panel blocks the submit with no message anywhere.
The theme’s answer is that step panels are <fieldset>s and are disabled as well as hidden — panel.hidden = panel.disabled = !on — with every panel re-enabled before the payload is read. If you add a step or a field, keep it inside the fieldset.
The build fails with “Cannot apply unknown utility class”
Layer order. The declared order is @layer theme, base, components, utilities, and @apply cannot reach a class in a later layer. A rule in components cannot @apply .primary-focus, which lives in utilities.
Spell the utilities out instead, which is what .article-prose a does.
A focus ring draws nothing
If you have added focus:outline-hidden next to focus-visible:outline-2, that is the cause, and tokens.test.ts should have caught it.
In Tailwind v4, outline-hidden compiles to --tw-outline-style: none and outline-2 compiles to outline-style: var(--tw-outline-style) — so the :focus rule sets the variable the :focus-visible rule reads, and the ring resolves to none on every element wearing the class. It looks correct in the source.
:focus-visible is already the keyboard-only selector; suppressing :focus on top of it was never needed.
A form field has no visible border
Check --input in global.css. It is the edge of a field, not its fill, and it must be var(--border). Pointed at --background it draws a white border on a white card — every input, select, textarea, checkbox, radio and the Switch’s off-state track, all invisible at once, and easy to miss because a select still reads as a control without its edge.
A .dark block fails the build
tokens.test.ts rejects one on purpose. The site is light-only because the design system defines a single palette, and a .dark block with no script to set the class is dead CSS shipped to every visitor.
Re-adding dark mode means restoring all four pieces together — the @variant dark declaration, the .dark block, the pre-paint script in BaseHead and a toggle primitive — from real designed values. Git history has the old shape.
An icon renders as an empty square
The name is not in the merged registry. IconName should have caught it at astro check, so if it is rendering empty at runtime the likely cause is a custom glyph whose markup is wrong rather than a missing name.
Confirm the registry itself:
node --experimental-strip-types -e "import('./src/components/svg/icons/registry.ts').then(m => console.log(m.iconNames.length))"
Two failures here are invisible at runtime — a dropped custom glyph and a merge resolving the wrong way round both render an empty <svg>. registry.test.ts covers the merge direction.
Also check you added the glyph to custom.ts and not to icons.ts, which is generated and overwritten wholesale.
A tv() config eats a colour class
You imported tv from tailwind-variants instead of from @js/tv.
The stock instance does not recognise text-body and text-meta as font sizes, and because text-* is also the colour prefix it files them under text-color — so the size is ignored and the colour token is dropped. Import from @js/tv.
A scroll animation plays once and leaves content offset
Native scroll timelines are Chromium and Safari. In Firefox the animation-timeline declaration is dropped and the animation runs once, time-based.
For entrances that is fine — they end at identity, so content ends visible. For scroll-only shapes it is not, which is why parallax-*, ken-burns and fade-through are made inert under @supports not (animation-timeline: view()). If you added a scroll-only animation, add it to that list.
An animation ignores reduced motion
If it is scroll-driven, the global guard cannot help: that guard zeroes time durations, and a scroll-driven animation is progressed by scroll position. It needs motion-reduce:animate-none explicitly, which is why Reveal carries one.
If it is a sidecar stylesheet of your own, check it does not declare its own @layer — that lands it in the wrong place relative to the guard.
An interactive primitive stops working after a navigation
It works on first load and is inert after a client-side navigation, which means its script is not going through onReady in src/components/ui/_client.ts. That helper wires on load and on astro:after-swap.
This is invisible in development if you always hard-refresh.
Scripts stop firing under view transitions
vite.build.assetsInlineLimit: 0 in astro.config.mjs stops short scripts being inlined, because inlined scripts break under <ClientRouter />. If you have changed that setting, change it back.
The deploy prompts to provision a KV namespace
session: false has been removed from astro.config.mjs. Left unset, the Cloudflare adapter wires Astro’s session store to a KV binding and asks wrangler to provision the namespace mid-deploy — stateful infrastructure and an interactive prompt for a feature nothing here uses.
wrangler deploy cannot find the config
Point it at the generated one, not the one you edit:
npx wrangler deploy -c dist/server/wrangler.json
The adapter reads wrangler.jsonc at build time and writes the real config with the emitted paths filled in.
A blog post does not appear
Check draft. getPosts() drops drafts, and every consumer reads that one helper, so a draft is absent from the index, the related list, the feed and llms.txt consistently.
Check the filename is not _-prefixed — the glob pattern ignores those.
If the build failed instead, the message names the entry and the legal values: author must be a slug in src/config/doctors/, category one of the five in blogData.json.ts, and heroImage is required.
The stylesheet is much bigger than expected
The dev-only UI catalog. It builds no pages in production, but Tailwind still scans its markup, so its demo classes ship in the stylesheet every page loads.
Deleting src/components/Sections/UiCatalog/ and src/pages/examples/ takes the shared CSS from 76,413 to 56,695 bytes and drops 70 unused @keyframes.
pnpm test reports finding no checks
That is a failure, not a pass, and the runner exits non-zero on purpose — the point of discovery is that it cannot quietly stop finding the checks it is meant to run. Something has moved the src/ directory or the naming convention.
pnpm wiki:lint fails after a refactor
A path:line citation in wiki/ no longer points at the symbol the prose names. Update the citation, or the prose if the behaviour changed.
If you have deleted the wiki/ directory, remove the command from your chain.