Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Lighthouse

on:
pull_request:

permissions:
contents: read

concurrency:
group: lighthouse-${{ github.ref }}
cancel-in-progress: true

jobs:
budgets:
name: Budgets
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Install mise tools
uses: jdx/mise-action@v4
with:
version: 2026.8.14
install: true
cache: true

- name: Locate pnpm store
run: echo "PNPM_STORE=$(pnpm store path --silent)" >>"$GITHUB_ENV"

- name: Cache pnpm store
uses: actions/cache@v6
with:
path: ${{ env.PNPM_STORE }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-

- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline

- name: Build
run: pnpm build

# lhci is a CI-only tool run through dlx rather than a devDependency: it pulls Lighthouse
# and Puppeteer, which no one needs to develop a page (docs/adr/0007-lighthouse-ci-gate.md).
- name: Lighthouse CI
run: pnpm dlx @lhci/cli@0.15.1 autorun

- name: Upload reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v5
with:
name: lighthouse-reports
path: .lighthouseci/reports/
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ pnpm-debug.log*

# local Chrome for the devtools MCP (mise run chrome:install)
.browser/

# lighthouse ci
.lighthouseci/
1 change: 1 addition & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ Fluid scale (clamp between 360px and 1440px viewports), defined as tokens:
| `small` | 0.875rem | 1.5 | Inter 400/500 |
| `label` | 0.6875–0.75rem | 1.4 | Source Code Pro 600, +0.05em tracking, uppercase (spec labels/chips) |

- **Inter ships with its weight axis trimmed to 400–700** and cannot render heavier: the axis this table does not use was 12 KB of critical-path font (`docs/adr/0011-inter-weight-axis.md`). Widening the range is a `pnpm assets:fonts` change, not just a utility class.
- Eyebrow labels: Orbitron 500, 12px, uppercase, `+0.08em` tracking, `primary` or `muted` — Orbitron's one all-caps use; SCP `label` is the other sanctioned caps.
- Prose measure: 65–75ch (`max-w-prose`).
- Implementation note: `body` names both a color (§2) and a size (this table). Tailwind resolves
Expand Down
72 changes: 72 additions & 0 deletions docs/adr/0005-webp-only-image-variants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 0005 — WebP-only image variants, at an explicit quality and width

- **Status:** accepted
- **Date:** 2026-09-01

## Context

`plan/09-assets-performance.md` §2 asks every `<Image>` call site to emit "AVIF+WebP formats".
Phase 09 is also where the sources it would apply to reached their final shape: every raster in
`src/assets/` is a WebP master, re-encoded by `tools/assets/optimize-sources.mjs` at quality 80
and capped at 2560px.

Two measurements taken against `src/assets/sc2/competition-1.webp` (2048×1365, 283 KB) decided
how those call sites are configured.

**AVIF costs 40× the encode time for a saving WebP already matches.** Sharp, encoding a 1920px
variant on the build machine:

| format | quality | effort | size | time |
| ------ | ------- | ------ | ---- | ---- |
| WebP | 80 | — | 268 KB | 0.37 s |
| WebP | 70 | — | 211 KB | 0.28 s |
| AVIF | 55 | 4 (sharp default) | 173 KB | 11.4 s |
| AVIF | 55 | 2 | 188 KB | 1.8 s |
| AVIF | 55 | 0 | 205 KB | 0.36 s |

AVIF only beats WebP at an effort level that costs eleven seconds per variant. The build emits
172 variants; at sharp's default effort that is roughly half an hour added to every build and
every CI run. At the effort levels that are affordable, AVIF and WebP land within 3% of each
other — and Astro's `<Picture>` exposes no per-format `effort` knob to tune it with.

**A variant at the default quality came out larger than its own source.** The build log before
this phase:

```
▶ /_astro/competition-1.CqVIq-Ep_xi2Ta.webp (before: 283kB, after: 291kB)
▶ /_astro/dean.zuOjR3D9_Z2ci1wI.webp (before: 168kB, after: 173kB)
▶ /_astro/award.BcUbzuVo_1o0x2m.webp (before: 54kB, after: 56kB)
```

Those are the widest steps: a re-compression of an already-lossy q80 file at q80, which adds
generation loss and bytes at the same time. Separately, Astro fills the `src` attribute — the
fallback for a client that ignores `srcset` — from the source's *intrinsic* size whenever no
`width` prop is given, so a 2560px master produced a 436 KB variant that no page ever displays.

## Decision

- **WebP only.** No `<Picture>`, no `formats`; every call site stays on `<Image>`, which keeps the
source's WebP.
- **`PHOTO_QUALITY = 70`** (`src/lib/images.ts`) on every photographic call site. Logos and line
art keep the default: they are small already, and quantizing flat colour is what makes a mark
look cheap.
- **Every responsive call site passes `width` equal to the largest entry in its `widths`.** Astro
then dedupes the fallback against that srcset entry instead of adding a full-resolution one.

Together these took the build from 183 variants to 172, the largest emitted image from 436 KB to
199 KB, and `dist/` to 9.5 MB.

## Alternatives considered

- **AVIF on hero images only.** Nine LCP images × four widths at eleven seconds each is still six
minutes per build, for images that the measurements above say WebP already matches.
- **Re-capping the masters at 1920px** so the fallback is small without a `width` prop. Solves the
fallback but throws away resolution the repository may want later, and does nothing about the
re-compression at the widest step. An explicit `width` fixes both and is reversible.

## Consequences

- Anything about the build that turns on AVIF being cheap — a faster encoder, `sharp` gaining a
usable effort/quality curve — makes this worth re-measuring. The table above is the baseline.
- `quality` and `width` are now part of what a new `<Image>` call site has to get right.
`HeroImage.astro` carries the whole recipe for the one case that repeats eleven times.
62 changes: 62 additions & 0 deletions docs/adr/0006-hero-video-encode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 0006 — The hero video is a hand-encoded pair of files in `public/`

- **Status:** accepted
- **Date:** 2026-09-01

## Context

D20 keeps the FRC hero's background footage and puts it under 3 MB. What shipped was a 44-second
1920×1080 H.264 take at 22.1 MB, beside a 14.7 MB WebM of the same thing — together, two thirds of
the 42 MB `public/` tree this phase set out to shrink.

The footage is a single continuous action-cam walk through a competition venue: soft, wide-angle,
no cuts. Nothing in it is sharp enough to reward a high resolution, and nothing in it needs to be
44 seconds long behind a heading and two buttons.

`astro:assets` has no video pipeline, so whatever the encode is, it is not something the build
performs.

## Decision

Encode by hand with ffmpeg, from the 1080p master, and commit both outputs to
`public/video/biohazard/`:

```sh
ffmpeg -ss 8.0 -t 10.5 -i home-video.mp4 -an -vf "scale=1280:720:flags=lanczos" \
-c:v libx264 -profile:v high -preset slower -crf 21 -pix_fmt yuv420p \
-g 60 -movflags +faststart home-video.mp4

ffmpeg -ss 8.0 -t 10.5 -i home-video.mp4 -an -vf "scale=1280:720:flags=lanczos" \
-c:v libvpx-vp9 -crf 30 -b:v 0 -row-mt 1 -deadline good -cpu-used 1 \
-g 60 -pix_fmt yuv420p home-video.webm
```

- **The 8.0–18.5 s window** is the one steady stretch: the view from the driver station out over
the field. Either side of it the camera whip-pans. Its first and last frames frame the same
scene, so the loop does not read as a cut.
- **720p, not 1080p.** A 1080p CRF 21 encode of this take is 2.82 MB — inside D20's budget, and
visually identical to the 1.19 MB 720p one, because the source is out of focus. Frames from both
were compared against the master before choosing.
- **VP9 rather than AV1** for the WebM. At 0.49 MB the format is not what is costing anything, and
VP9 decodes wherever WebM does. Safari falls through to the MP4 either way.
- **No audio track**, and `+faststart` so the MP4's index precedes its data.

The poster is `src/assets/frc/hero-video-poster.webp`, the encoded MP4's own first frame, so the
reveal has nothing to cross-fade.

## Alternatives considered

- **`src/assets/` with a `?url` import**, for a fingerprinted `_astro/` path and immutable caching.
`plan/09-assets-performance.md` §1 reserves `public/` for exactly this case, and a path in
`site.ts` beside `site.icons` reads the same way at the call site.
- **A boomerang (forward + reversed) cut** for a seamless loop. Doubles the file, and reversed
camera motion is obvious on a pan.

## Consequences

- Re-cutting the footage is a manual step, not `pnpm build`. This ADR is the record of what
produced the committed files.
- The masters are gone from the working tree; git history holds them.
- `HeroVideo.astro` arms playback only after `load`, and only when the visitor has neither asked
for reduced motion nor turned on Save-Data — so 1.7 MB is what an engaged desktop visitor
spends, not what the page costs to open.
53 changes: 53 additions & 0 deletions docs/adr/0007-lighthouse-ci-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 0007 — Lighthouse CI is a blocking PR gate, run through `pnpm dlx`

- **Status:** accepted
- **Date:** 2026-09-01

## Context

Objective 8 is a 10/10 mobile experience "enforced by Lighthouse CI budgets", and the plan's
budgets have been aspirational since Phase 01. Phase 09 is where they get teeth.

Two things had to be decided: how `lhci` reaches CI, and what it serves.

**How it reaches CI.** `@lhci/cli` brings Lighthouse and Puppeteer with it — a browser download
and a large tree, on every `pnpm install`, for a tool nobody runs while writing a page. The repo
also forbids new dependencies without an ADR, which is this one.

**What it serves.** Lighthouse's mobile preset throttles to 1.6 Mbps with a 150 ms RTT, so
transfer size is most of the score. Cloudflare Pages compresses; a local server that does not
would measure a site nobody is served and would fail budgets that production meets. `astro
preview` gzips (verified: `Content-Encoding: gzip` on both the document and the stylesheet), which
makes it the honest local stand-in — and it is already a `package.json` script, so the gate runs
what a developer can run.

## Decision

- `.github/workflows/lighthouse.yml`, a second required PR check beside `ci.yml`, running
`pnpm dlx @lhci/cli@0.15.1 autorun` after `pnpm build`. Exact version pin, no lockfile entry,
no cost to `pnpm install`.
- `lighthouserc.json` at the repo root: `astro preview` on :4321, three runs per URL, and the six
URLs `plan/09-assets-performance.md` §5 names — one of each page shape (home, program, gallery,
sponsors, event, form).
- Assertions are the plan's, verbatim, all `error`. The budget table lives in `docs/tooling.md`.
- Reports upload as an artifact on every run, pass or fail, so a red gate can be read without
reproducing it.

## Alternatives considered

- **`treosh/lighthouse-ci-action`.** Wraps the same CLI and would work. It is another third-party
action to trust and pin, and it hides which `lhci` command ran; a one-line `run:` step does not.
- **`@lhci/cli` as a devDependency.** Puts Lighthouse and Puppeteer in every clone. `pnpm dlx`
with an exact version gets the same reproducibility for CI, which is the only place it runs.
- **LHCI's own `staticDistDir` server.** Removes the `astro preview` process, but crawls every
HTML file in `dist/` — sixteen pages times three runs — and the six representative URLs are
what the plan budgets.

## Consequences

- `lhci` is fetched from the registry on each CI run; a registry outage fails the gate. It is a
separate workflow from `ci.yml`, so that failure does not mask a real check failure.
- Running `autorun` locally leaves the preview server up, because LHCI kills the process it
spawned and `astro preview` outlives its wrapper. `pnpm exec astro preview stop` clears it.
- The thresholds are close to the measured values on some pages (LCP especially). Loosening one
is a decision to record here, not a quiet edit to `lighthouserc.json`.
53 changes: 53 additions & 0 deletions docs/adr/0008-no-font-preloads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 0008 — No `<link rel="preload">` for fonts

- **Status:** accepted
- **Date:** 2026-09-01
- **Supersedes:** the preload wiring specified in `plan/02-design-system.md` §fonts and
`plan/05-app-shell.md` §skeleton, and the "exactly two preloaded font files" line in
`plan/09-assets-performance.md` §4.

## Context

`BaseLayout` preloaded the two above-the-fold faces — Inter variable (48.6 KB) and Orbitron
variable (12.1 KB) — from `src/styles/fonts.ts`, to shorten the flash of fallback text.

Phase 09's head audit measured what that costs. Lighthouse, mobile preset, simulated throttling
(1.6 Mbps, 150 ms RTT), one run per configuration:

| URL | | FCP | LCP | CLS | Perf |
| --- | --- | ---: | ---: | ---: | ---: |
| `/programs/frc/robots/` | with preloads | 1054 ms | 2028 ms | 0.000 | 99 |
| `/programs/frc/robots/` | without | **766 ms** | **1366 ms** | 0.000 | 100 |
| `/sponsors/` | with preloads | 1062 ms | 1958 ms | 0.000 | 99 |
| `/sponsors/` | without | **754 ms** | **1129 ms** | 0.000 | 100 |

A preload is a High-priority request issued from `<head>`, ahead of the render-blocking
stylesheet and well ahead of the hero `<img>` the browser finds later in the body. On a link that
carries 200 KB/s, putting 61 KB of fonts at the front of that queue delays first paint by ~290 ms
and the LCP image by ~660 ms. Every page on this site has a photograph as its LCP element, so the
preloads were buying a shorter FOUT with the metric the whole phase exists to protect.

`font-display: swap` means text paints in the fallback either way; the preload only changes when
the swap happens. CLS is 0.000 with and without, so the later swap costs no layout stability.

## Decision

Drop both preloads. Faces are discovered from `fonts.css` and fetched when the CSS resolves.
`src/styles/fonts.ts` existed only to supply their URLs and is deleted; `knip.jsonc` now lists
Inter and Orbitron alongside the other two faces as CSS-only dependencies.

## Alternatives considered

- **Preload Inter only.** Inter is 80% of the 61 KB, so it is most of the cost and little of the
saving.
- **`rel="preload"` with `fetchpriority="low"`.** Contradicts the point of a preload, and still
opens the connection ahead of the LCP image.
- **Cutting Inter's weight axis** with a variable-font instancer so the preload is affordable.
A new build step and a Python toolchain for a face that no longer needs preloading.

## Consequences

- Webfonts settle a few hundred milliseconds later on a cold, slow connection. Warm caches are
unaffected.
- Reintroducing a preload — for a face, or for an LCP image — is a decision to re-measure, not a
one-line addition. The table above is the baseline to beat.
66 changes: 66 additions & 0 deletions docs/adr/0011-inter-weight-axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 0011 — Inter ships with its weight axis trimmed to 400–700

- **Status:** accepted
- **Date:** 2026-09-01

## Context

Phase 09's `<head>` audit established that on this site the critical path is font-bound, not
image-bound: removing two `<link rel="preload">` tags cut LCP by ~660 ms because 61 KB of
High-priority font was queued ahead of the hero image (`docs/adr/0008-no-font-preloads.md`). What
that left is the payload itself. Three variable faces reach first paint — Inter 47.1 KB, Source
Code Pro 21.5 KB, Orbitron 11.5 KB — 80 KB between them, more than any hero image on the site.

`@fontsource-variable/inter` ships `wght 100 900`. DESIGN.md §3 sanctions **400/500/600** for
Inter, and nothing in the tree exceeds that: the only two `font-bold` call sites are Orbitron, in
the styleguide's type specimens. So more than half the axis is delta data for weights that never
render.

Measured, instancing each variable face down to the range its role actually spans:

| Face | Shipped | Trimmed | Saving | Range |
| --- | ---: | ---: | ---: | --- |
| Inter latin | 47.1 KB | **35.2 KB** | 11.9 KB | 400–700 |
| Inter latin-ext | 83.1 KB | **57.9 KB** | 25.2 KB | 400–700 |
| Source Code Pro latin | 21.5 KB | 18.3 KB | 3.2 KB | 400–600 |
| Orbitron latin | 11.5 KB | 10.8 KB | 0.7 KB | 500–700 |

## Decision

Instance **Inter only**, to `wght 400 700`, with `tools/assets/font-subset.mjs`
(`pnpm assets:fonts`). Output is committed to `src/styles/fonts/` and `fonts.css` points at it;
the `@font-face` range is declared `400 700` to match, so the browser is told what the file can
actually do.

- **400–700, not 400–600.** The extra step costs 0.6 KB and means a stray `font-bold` on body copy
renders a real weight rather than a synthetic one. Synthetic bold on body text looks worse than
0.6 KB costs.
- **latin-ext is trimmed too**, though it is never fetched — its unicode-range covers codepoints
no current copy contains. 25 KB of repository and `dist/` weight for consistency with the face
beside it, and it is the same script run.
- **Source Code Pro and Orbitron are left alone.** 3.9 KB between them is not worth narrowing what
the design is allowed to reach for; Orbitron in particular is the display voice, where a heavier
weight is a plausible future choice.
- **By hand, not at build time.** The instancing needs Python with `fonttools` and `brotli`. It
changes when DESIGN.md §3 changes, which is not per-build — the same reasoning as
`docs/adr/0010-og-cards.md`.

## Alternatives considered

- **Static instances at 400/500/600.** Smaller per file (23.7 KB at 400 alone) but three files,
~70 KB total, all three fetched because all three weights render above the fold. Worse.
- **Glyph subsetting to the characters currently on the site.** Much larger saving, and wrong: the
content is markdown that editors change, so a sponsor name or an event description with a
character outside the subset would silently render in the fallback.
- **Dropping a face entirely.** Source Code Pro is 22 KB and owns the numerals and spec labels
(DESIGN.md §3, D26); cutting it is a design decision, not a performance one. Recorded in
`plan/todo.md` as the remaining lever if the budget ever needs it.

## Consequences

- Inter physically cannot render above 700 now. DESIGN.md §3 carries a note saying so, since that
is where someone would look before reaching for a heavier weight.
- The committed woff2 files are build artifacts in the source tree. `pnpm assets:fonts`
regenerates them from the pinned package; a `@fontsource-variable/inter` bump means re-running
it, and the diff is two binaries.
- Repository and `dist/` both lose 37 KB of font, and 11.9 KB of that is off the critical path.
Loading
Loading