# The Astro stack, and the traps

Astro + pnpm + Tailwind CSS v4, static output, self-hosted subset fonts, brand assets in
one canonical folder.

## Scaffold

```bash
pnpm add astro @tailwindcss/vite tailwindcss
pnpm add -D @astrojs/check typescript@^6 @types/node
```

`package.json` scripts:

```json
{
  "dev": "astro dev",
  "build": "astro check && astro build",
  "preview": "astro preview",
  "images:manifest": "node scripts/collect-image-manifest.mjs",
  "brand:build": "./.venv/bin/python assets/brand/build_logo.py && ./assets/brand/generate-raster.sh assets/brand"
}
```

`astro.config.mjs`:

```js
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  site: 'https://<site>.example',
  server: { port: Number(process.env.PORT) || 4321 },
  vite: { plugins: [tailwindcss()] },
});
```

Tailwind v4 is configured **CSS-first**. Declare tokens in `src/styles/global.css` under
`@theme`; there is no `tailwind.config.js`. Token namespaces map to utilities:
`--color-clay-500` → `bg-clay-500`, `--radius-card` → `rounded-card`,
`--font-display` → `font-display`.

### The semantic alias contract — declare these on every site

Each site names its palette after its own idea (sand/clay, bone/indigo, linen/green). The
shared components in `assets/` would otherwise need hand-editing on every copy, which is a
per-site chance to introduce a silent variation. Instead, alias the palette once:

```css
@theme {
  /* the site's own palette … */
  --color-bone-50: #fbf9f5;   /* etc. */

  /* … then the aliases every copied component speaks */
  --color-paper:      var(--color-bone-50);    /* page surface        */
  --color-paper-tint: var(--color-bone-100);   /* section / card tint */
  --color-line:       var(--color-bone-200);   /* default border      */
  --color-line-soft:  var(--color-bone-300);   /* muted / dashed rule */
  --color-ink:        var(--color-ink-950);    /* primary text        */
  --color-ink-soft:   var(--color-ink-600);    /* secondary text      */
  --color-night:      var(--color-ink-900);    /* dark surface (footer) */
  --color-night-2:    var(--color-ink-800);    /* dark surface, raised  */
  --color-action:     var(--color-indigo-500); /* anchor              */
  --color-action-hi:  var(--color-indigo-600); /* anchor hover        */
  --color-action-lo:  var(--color-indigo-400); /* anchor on dark      */
  --color-spark:      var(--color-marigold-500);
  --color-spark-lo:   var(--color-marigold-300); /* spark on dark     */
}
```

Copied components use only `bg-paper`, `border-line`, `text-ink-soft`, `bg-action` and so
on. **Do not retoken the components** — add the aliases instead.

## Layout

```
assets/brand/                 canonical brand assets + build scripts (never copied)
docs/                         requirements, content model, brand, image system, manifest
public/fonts/                 subset woff2 + the font licence
public/icons/                 symlinks to just the favicon files
scripts/collect-image-manifest.mjs
src/data/<entity>.ts          the typed content model — the only source of figures
src/components/               Placeholder, Price, StatusPill, Header, Footer, cards
src/layouts/BaseLayout.astro
src/pages/
```

## Fonts

Subset and convert with fontTools rather than shipping full families:

```bash
./.venv/bin/fonttools varLib.instancer Display.ttf wdth=100 -o /tmp/d.ttf
./.venv/bin/pyftsubset /tmp/d.ttf --unicodes="U+0000-00FF,U+2000-206F,U+00B7" \
  --layout-features='*' --flavor=woff2 --output-file=public/fonts/display.woff2
```

Declare with `format("woff2-variations")` and `font-weight: 100 900`. Preload only the
faces used above the fold. Ship the font licence alongside.

### On a bilingual site, split the subsets by script

One face per weight covering both scripts makes every English visitor download the entire
second script. Cut one file per script per weight, give each `@font-face` a complementary
`unicode-range`, and preload only the current locale's pair. Six files at ~75 KB total, of
which an English page fetches ~46 KB, is a normal result.

**The currency sign is the trap: `฿` is U+0E3F, which is inside the Thai block.** A split
made by taking "Latin = the ASCII ranges, Thai = U+0E00–0E7F" therefore hands the baht sign
to the Thai file, and every price on every English page falls back to a system font — the
same failure as the missing-glyph one above, arrived at from the opposite direction. Any
currency, punctuation or symbol the *other* locale's pages also render has to be named
explicitly in the Latin range:

```python
LATIN = "U+0000-00FF,U+2000-206F,U+2212,U+2192,U+0E3F"   # ← the baht sign lives here too
THAI  = "U+0E00-0E7F,U+200B"
```

`pyftsubset` writes a range as `U+xxxx-yyyy`. Repeating the prefix (`U+0E00-U+0E7F`) is a
parse error, not a warning.

---

## Traps

Each of these has cost real time. Check them.

**`astro check` breaks on TypeScript 7.** It needs the programmatic API that TS 7 does not
yet expose. Pin `typescript@^6`.

**`process` is undefined in `astro.config.mjs`** under `astro/tsconfigs/strict` until
`@types/node` is installed. Install it or `astro check` fails on the config file.

**Never symlink a whole brand folder into `public/`.** The build dereferences it and ships
the font sources, the build scripts, and every raster you generated. Symlink only the
individual files the browser needs — normally just the favicon set. Inline the logo SVGs
into components with `?raw` instead of serving them.

**Verify the display font covers your currency and symbols.** A Latin grotesk may have no
`฿`, `₫`, `₩` or `·`. The glyph silently falls back and stops matching the digits beside
it. **Audition the whole shortlist in one pass** rather than checking a favourite and
hoping — coverage of a non-Latin currency sign is rare enough that it will often *decide*
the typeface rather than merely filter it, and discovering that after falling for one face
wastes the choosing:

```python
from fontTools.ttLib import TTFont
need = {0x0E3F: "฿", 0x00B7: "·", 0x2014: "—"}
for path in candidates:
    cmap = TTFont(path).getBestCmap()
    print(path, [s for c, s in need.items() if c not in cmap] or "ok")
```

**Then check the digits are the same width — `hmtx`, not `cmap`.** These sites publish
prices, so most of them are columns of figures, and a face with proportional-only digits
makes every column permanently ragged in a way no CSS fixes. `font-variant-numeric:
tabular-nums` only helps if the face actually has the feature.

```python
cmap, hmtx = font.getBestCmap(), font["hmtx"]
widths = {hmtx[cmap[ord(d)]][0] for d in "0123456789"}
aligned = len(widths) == 1          # or check GSUB for a 'tnum' feature
```

Absence of `tnum` does not always mean proportional — a face whose default figures are
already tabular has no need for the feature — so read the widths and treat the tag as
corroboration.

**Expect this to eliminate more candidates than glyph coverage does.** On a Thai
price-heavy site, 21 faces were auditioned: almost all passed on characters, and **13 died
on ragged figures** — Kanit, Prompt, Chakra Petch, Bai Jamjuree, K2D, Krub, Mitr, Athiti,
Kodchasan, Fahkwang, Niramit, Maitree, Pridi. Two more had the figures but lacked `²` and
`≈`. Six survived out of twenty-one, and the *numerals* were what did the cutting. On a
site made of tables they are a harder constraint than the letterforms.

When the constraint does pick the face, check whether the winner also covers the
second-script pairing named in phase 1. A family from a foundry in that script often
carries both, which turns a reserved decision into a solved one at no cost.

If the display face lacks it, set prices in the body face and say so in the brand doc.
The same gap bites again in phase 5, where the share-card script draws titles in that
display face with no browser fallback to rescue it — see
[`share-cards.md`](share-cards.md).

**`og:image` must be an absolute URL, and `site` must be set in `astro.config.mjs`.** A
root-relative `/og/home.png` looks entirely correct in the markup and resolves to nothing
at every crawler, so nothing local will ever show you the failure. Build it with
`new URL(path, Astro.site)`. The same goes for `og:url` and the canonical link.

**A committed build artifact needs a build-time check, or it will go stale silently.**
Share cards are drawn from page copy and committed, because their toolchain is not on the
deploy runner. Nothing in the deploy path would ever notice that the copy has since moved
on. Store what was baked and compare it against the built HTML on every build — this is
the general shape for any generated-then-committed asset, not just cards.

**Astro collapses whitespace between a text line and an element on the next line.**
`…charged at cost.\n<a>See the terms</a>` renders as `cost.See the terms`. Keep the space
on the same line, or use `&nbsp;`. Grep for it before shipping:

```bash
grep -rn -B1 '^\s*<a ' src/pages src/components | grep -E '[a-z,.;)]\s*$'
```

**A second project in the same session may not get a preview server.** Tooling that reads
`.claude/launch.json` usually resolves it from the session's primary directory, so starting
the preview for project B silently starts project A. Run the dev server yourself with an
explicit `PORT` and point the browser at it.

**`scroll-behavior: smooth` fights screenshot verification** and contradicts a restrained
motion policy. Leave it off.

**One placeholder id, one ratio.** See the placeholder protocol; the manifest collector
enforces it.

**`sharp` must be installed, or image optimization silently does nothing.** Astro's
image pipeline degrades to copying the original untouched and emits only a build-time
`WARN` — `astro check` still reports 0 errors and the build still prints Complete. A
19-image site shipped 42 MB of unprocessed PNG this way while looking green. Never accept
the exit code as proof: assert that derivatives exist.

```bash
pnpm add sharp
ls dist/_astro/*.webp | wc -l     # must be > 0
```

**"Could not find Sharp" in `astro dev` can mean sharp is installed and fine.** It is a
native module, and under pnpm's isolated `node_modules` Vite's SSR pipeline tries to
process it; the load throws, and Astro's image service reports *every* failure with the
same not-installed message. The signature is unmistakable once you know it: `astro dev`
returns 500 for every single image while `pnpm build` emits the full set of derivatives.
Believe the build. Mark it external:

```js
vite: { plugins: [tailwindcss()], ssr: { external: ['sharp'] } },
```

Do not resolve this by reinstalling, changing package manager, or hoisting — those all
address the message rather than the cause, and the message is wrong.

**Astro copies imported source images into `dist/_astro` even when nothing references
them.** With `format="webp"` every original is emitted alongside its derivatives and
never linked — 41.9 MB of orphans in one build. Prune as part of `pnpm build`:

```json
"build": "astro check && astro build && node scripts/prune-unreferenced-assets.mjs"
```

A file is safe to delete only when its exact filename appears in none of the built HTML,
CSS or JS — including the CSS inside `_astro`, which can reference fonts and images.

**`form.name` is not the field named "name".** `HTMLFormElement` has its own `name` IDL
property, so `form.name` returns the form's name attribute (usually `""`) and shadows the
control. `form.name.value.trim()` throws at runtime while every other `form.<field>` lookup
works fine, which makes it look like an unrelated bug. Look fields up explicitly:

```js
const nameInput = document.getElementById('name');   // not form.name
```

Other shadowed names to avoid as field names: `action`, `method`, `target`, `elements`,
`length`, `id`, `submit`, `reset`.

**`sizes` does not inherit usefully.** A component-level default (`50vw`) quietly makes
every 3-up card and every thumbnail download a candidate several times wider than it
renders. Pass a placement-specific `sizes` at each call site; the same image id legitimately
gets different values on a card and on a detail page. Audit it in the built HTML, not the
source.

---

## Progressive enhancement patterns

**A mode switch with no JavaScript.** Radio inputs plus `:has()` — the panels toggle in
pure CSS, and JS only enhances:

```css
#modes:has(#mode-a:checked) .panel-b,
#modes:has(#mode-b:checked) .panel-a { display: none; }
```

**A demo enquiry form that respects privacy.** Never let it submit: a form with no
`action` posts to the current URL and, on GET, writes personal data into the query
string. Use `type="button"` plus a JS handler that shows a demo confirmation, and a
`<noscript>` block offering the real alternative (phone, messaging app, email).

**A cost summary that is honest without JS.** Render the summary from the same default
the selects use, so the static numbers always describe what is on screen. JS then
recalculates on input.

## Verification, in order

1. `pnpm build` — `astro check` clean.
2. Narrow viewport: confirm `document.documentElement.scrollWidth === innerWidth`; expect
   deliberate overflow only inside `overflow-x-auto` containers. **Sweep every route at
   320, 360 and 390 in one script** — see below. Checking one page by eye at one width is
   how two shipped sites each kept an overflow nobody saw.
3. Exercise interactive logic **by script**, not by eye — drive the inputs, read the
   computed outputs back, and check the arithmetic against the content model.
4. **Measure, don't judge.** Touch targets, contrast and candidate widths are numbers.
   A 38px chip looks fine and fails the 44px rule; read `getBoundingClientRect()`.
5. Count `<script>` tags per built page against the stated JS budget.
6. Confirm the footer attribution renders on every page.
7. `pnpm images:manifest` produces a complete manifest.

### The overflow sweep

Load every route into a resizable iframe and name the offenders. Eyeballing the home page
at one width misses the rest of the site, and a 10px overflow is invisible while still
making the whole page slide under the thumb.

```js
const f = document.createElement('iframe');
f.style.cssText = 'position:fixed;left:-9999px;top:0;border:0;height:900px;';
document.body.appendChild(f);
for (const r of ROUTES) for (const w of [320, 360, 390]) {
  f.style.width = w + 'px'; f.src = r;
  await new Promise(res => { f.onload = res; }); await new Promise(res => setTimeout(res, 250));
  const d = f.contentDocument.documentElement, vw = d.clientWidth;
  if (d.scrollWidth > vw) console.log(r, w, d.scrollWidth - vw,
    [...f.contentDocument.querySelectorAll('*')]
      .map(el => [el, el.getBoundingClientRect()])
      .filter(([, rc]) => rc.width > 0 && rc.right > vw + 0.5)
      .map(([el, rc]) => el.tagName + '@' + Math.round(rc.right)).slice(0, 3));
}
```

360 is the stated bar, but include 320 — it costs one more loop iteration and catches the
marginal fits that are one copy edit away from breaking at 360 too.

**`shrink-0` on content of unknown length is the usual culprit.** It is right for a fixed
badge or a short figure and wrong for anything the content model can lengthen: a brand
lockup with a tagline, a definition value that is a sentence rather than a number. Both
overflows found in this family were exactly this. When the flex child holds copy, let it
shrink and wrap, and put `min-w-0` on its sibling.

**A wide table is a real constraint, not a bug to style away.** Its min-content width is
set by the longest unbreakable word in the widest column. Measure that first — tightening
cell padding below `sm` often buys the 60–80px needed — and wrap it in `overflow-x-auto`
regardless, so the *table* scrolls and the page never does.

**…but `overflow-x-auto` does nothing when the wide thing sits inside a grid or flex
item.** Those items default to `min-width: auto`, meaning they refuse to shrink below their
content's min-content width. So the table's `min-w-[32rem]` sizes the *track*, the track
sizes the page, and the page is what ends up scrolling — while the scroller you added sits
there measuring a box that was never constrained. Wrapping it more tightly cannot help,
because the thing being widened is outside the wrapper. Put `min-w-0` on the grid or flex
child:

```html
<div class="grid lg:grid-cols-[1fr_minmax(0,24rem)]">
  <div class="min-w-0">          <!-- without this the scroller below is inert -->
    <div class="overflow-x-auto"><table class="min-w-[32rem]">…</table></div>
```

`minmax(0, …)` does the same job for a track declared with an explicit size. The tell is an
overflow that is identical at every viewport width — a page that overflows by the same 528px
at 320, 360 and 390 is not a responsive bug, it is a min-content floor.

### When the browser surface is unreliable

An embedded preview pane can collapse or background itself. The tell is
`innerWidth === 0` or `document.visibilityState === 'hidden'` — and when it happens,
**lazy images never enter the viewport, so "images did not load" is a false alarm**, and
any width-dependent check is meaningless.

Check `innerWidth` and `visibilityState` before trusting any visual measurement. Fall back
to things that do not need a viewport: `curl` the routes, grep the built HTML, hit the dev
image endpoint directly and assert on the content type. Then **say plainly which checks
were static and which were visual** — a static pass is not a visual pass, and claiming
otherwise is the failure mode here.

Dispatching a synthetic `change` on a form does not reach handlers that compare
`e.target === someInput`; real interaction bubbles from the input. Dispatch on the input.

### Verify the apparatus before you trust the measurement

The expensive failures in this phase are not missed defects — they are **defects reported
that were never there**, because the harness did not reproduce the condition it claimed to
set. Each one costs a round of investigation into working code, and the correction is
public.

Two that have actually happened, both from the same mistake:

- **`document.documentElement.style.width = '360px'` does not narrow the viewport.** Media
  queries, percentage widths and `clientWidth` all still resolve against the real window, so
  what gets compared is a 1280px layout against a hardcoded 360. Every wide element is
  reported as an overflow. The iframe sweep above is the method that works, and it works
  precisely because the iframe *is* a viewport.
- **The sweep's own iframe is at `left:-9999px`, so nothing in it ever intersects the
  viewport and no lazy image loads.** It is the right tool for geometry and the wrong tool
  for asset loading; run image checks in a real, visible window.

The rule generalises past both: **read back the quantity you believe you just set, in the
same call that reports the result.** Print `clientWidth` beside every overflow figure, print
`naturalWidth` beside every broken-image claim. A harness that cannot show the condition it
established has not measured anything, and the output looks exactly like a real finding.
