Troubleshooting
Most of what goes wrong in this theme goes wrong loudly, at build time, with the offending value named. That is a design choice. This page collects the messages you will actually see, plus the handful of behaviours that look like bugs and are not.
Build failures
“SITE_URL is unset or still the placeholder.” You are running a Netlify production build with https://example.com still in place. Set SITE_URL under Site configuration → Environment variables. Local builds and deploy previews are unaffected, which is why this only bites at deploy time. See Deployment.
“siteData.author.email is not a valid email address.” Exactly what it says, and the message echoes the value you set.
“siteData.socials: … needs an absolute http(s) URL.” A social link is missing its scheme (x.com/me rather than https://x.com/me), or is a mailto: or tel: URL. Only http and https pass.
A RangeError from Intl.DateTimeFormat. siteData.author.timeZone is not a valid IANA zone name. It is America/New_York, not EST or GMT-5. This throws rather than shipping a silently wrong clock in your footer.
A content-collection schema error. The message names the collection, the entry and the field. The usual causes: an unquoted year in a work entry (YAML reads 2026 as a number, the schema wants a string); an empty authors: array; an author slug with no matching folder under src/data/authors/; an unparseable pubDate; or an image path pointing at a file that does not exist.
A type error on pageCopy. Most often a headline. The work and contact heroes index their headline positionally, so replacing the two-entry array with a single string compiles but renders the first two characters. See Configuration.
Development-server oddities
These three are all the same shape — a long-running dev server holding stale state — and all three are fixed by restarting it.
A renamed or moved content entry keeps 404ing. Astro’s content layer caches entries, and renaming a folder while the server runs can leave the old entry in place and the new one missing. Restart pnpm dev. Touching the file will not clear it.
A new post or project does not appear. Same cause, same fix.
A utility class has no effect. In a long-running dev server, classes used only in files created since startup can be missing from the generated stylesheet. Restart before you go looking for a bug in your markup. If it persists after a restart, check the built CSS rather than the markup — see the tree-shaking note below.
Things that look wrong but are not
“Ignored build scripts” during install. That is allowBuilds in pnpm-workspace.yaml denying post-install compilation for @parcel/watcher, esbuild and sharp. None of them needs to compile for this project. It is the setting working.
An animate-* class appears to do nothing. Keyframes are emitted only when a utility actually appears in a scanned source file. If the class is being built from a template string — animate-${name} — Tailwind never sees it and emits no CSS. Map whole static class strings instead. To confirm, run pnpm build and grep dist for the @keyframes.
A scroll-driven animation is frozen at its start. The clipping frame around it is overflow-hidden. A hidden box is a scroll container, so view() tracks that box — which never scrolls — instead of the page. Use overflow-clip.
An element that should both arrive and rotate does neither. The CSS animation shorthand is one property, so a second animate-* on the same element wins outright. Two animations need two nested elements.
A scroll reveal renders half-finished on load. A scroll-timeline element that is already in the viewport when the page loads starts at whatever progress its position implies. Above the fold, use trigger="load" instead of the default scroll.
A staggered group all animates at once. trigger="scroll" gives each element its own timeline, and delay is inert against a position-driven animation. Use trigger="view" for a stagger below the fold.
Reveals stop working entirely after a navigation. Check that the js class is still on <html>. The view-transition router replaces the root element’s attributes with the incoming document’s, and the pre-paint script re-applies both dark and js on astro:after-swap. If you edited that script, that is where to look.
A <Reveal> wrapping something laid out with display: contents does nothing. A scroll timeline needs a real box. Use the as prop to change the tag; never class="contents".
A DistortImage frame is the wrong height in a row of cards. The frame needs min-h-0 for a caller’s aspect-* to be authoritative — without it, min-height: auto on a flex or grid item floors the frame at the image’s intrinsic height, so an aspect class can only make it taller and object-cover never crops. The shipped base class includes it; check any override.
The hero’s WebGL effect stops working on the home page. Each DistortImage holds a WebGL context, and browsers drop the oldest past roughly sixteen. The home page mounts fifteen. A visitor arriving with another WebGL tab open can push it over, and what gets dropped is the oldest context — the hero. Do not add a sixteenth frame to that page.
Contact form
“This form is not configured yet.” RESEND_API_KEY or CONTACT_TO_EMAIL is unset. This is the expected message on a fresh clone, and the check runs at request time so a missing key never breaks your build.
Mail sends without error but never arrives. You are still on the default [email protected] sender, which can only deliver to the address that owns your Resend account. Verify a domain in Resend and set CONTACT_FROM_EMAIL.
“That was quick — please take another look and resend.” The submission arrived under three seconds after the page rendered. Expected for a bot; also expected if you are testing with an autofill script.
Turnstile does nothing after you set a key. Enforcement needs both keys. Setting one logs a warning to the server console and leaves verification off — deliberately, because a half-configured challenge that blocks real visitors is worse than no challenge.
The form stops working for visitors without JavaScript. That is what enabling Turnstile does: the widget has no no-JS path. Leave the keys unset to keep the no-JS path, which the honeypot and time gate still cover.
Fields are empty after a validation error. Expected. No value is passed back to the inputs, and the router replaces the body.
A new field validates but never appears in the email. buildEmail is typed to pick only name, email and message. Add your field to the template — and escape it. See Contact Form.
The form works in pnpm dev and 404s in production. astro dev always runs a server, adapter or not. Check that export const prerender = false; is still in src/pages/contact.astro and that an adapter is mounted.
Styling
A colour does not flip in dark mode. You wrote a raw Tailwind colour. bg-violet-700 and text-zinc-300 bypass the token system entirely. Use bg-primary, text-foreground, or a paired text-base-700 dark:text-base-300.
bg-primary and bg-primary-500 look the same in one theme and different in the other. They are different tokens — one semantic and theme-flipping, one a fixed palette alias. That is working as designed; pick the semantic one unless you specifically want a fixed value.
A @theme inline token resolves to a literal instead of following the theme. The inline keyword is required. Without it Tailwind resolves the variable at build time.
A modal renders in the top-left corner instead of centred. Tailwind’s preflight resets margin: 0 on every element and clobbers the browser default dialog { margin: auto }. _overlay.css restores it — check that stylesheet is still imported by your dialog component.
An error state on a form field renders as a normal field. A consumer class merges last, so a border colour in your override beats the border-error that state="error" sets. Style shape in the override; leave colour to _field.ts.
A font-size token is ignored. If you are using --text-display-link, the syntax needs the length hint: text-(length:--text-display-link). Without it tailwind-merge reads the bare token as a colour.
Install and environment
Node version errors. The floor is 22.12.0, enforced by engines.node. It is not a soft floor — the test runner uses --experimental-strip-types, which arrived in that release.
pnpm test reports “No checks found”. Discovery found zero *.test.ts files under src/. That is a failure rather than a pass on purpose. If you deleted a subsystem and took its tests with it, that is the message you will see.
Images are not optimised locally. Expected on the Netlify adapter, which routes astro:assets through Netlify’s Image CDN and marks sharp external — in development as well as production. On another host, Astro falls back to its own image service; pnpm add sharp if your package manager does not pull it in, and check the allowBuilds setting in pnpm-workspace.yaml.
An image renders soft. Either the source is narrower than the requested width — Astro never upscales, so it emits the smaller file under a larger width attribute — or a sizes value is under-stating. Round sizes up; the failure is asymmetric. See Images & Assets.
When none of this helps
Three things narrow almost any remaining problem quickly.
Run pnpm build rather than trusting the dev server. It is the only environment that matches production.
Open /examples/ui in development and check whether the primitive misbehaves there too. If it does, the problem is in the primitive; if it does not, the problem is in how you are using it.
Read the comment above the code. This theme documents its own trade-offs inline, and a ponytail: comment marks a deliberate simplification along with the ceiling it hits and the upgrade path out of it. If something feels arbitrary, there is usually a paragraph nearby explaining why.