diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml new file mode 100644 index 0000000..848efdc --- /dev/null +++ b/.github/workflows/lighthouse.yml @@ -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/ diff --git a/.gitignore b/.gitignore index 07ddd6d..7b3dd78 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ pnpm-debug.log* # local Chrome for the devtools MCP (mise run chrome:install) .browser/ + +# lighthouse ci +.lighthouseci/ diff --git a/DESIGN.md b/DESIGN.md index 94f6a15..bcda696 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/docs/adr/0005-webp-only-image-variants.md b/docs/adr/0005-webp-only-image-variants.md new file mode 100644 index 0000000..290d26e --- /dev/null +++ b/docs/adr/0005-webp-only-image-variants.md @@ -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 `` 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 `` 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 ``, no `formats`; every call site stays on ``, 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 `` call site has to get right. + `HeroImage.astro` carries the whole recipe for the one case that repeats eleven times. diff --git a/docs/adr/0006-hero-video-encode.md b/docs/adr/0006-hero-video-encode.md new file mode 100644 index 0000000..7fbc5f2 --- /dev/null +++ b/docs/adr/0006-hero-video-encode.md @@ -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. diff --git a/docs/adr/0007-lighthouse-ci-gate.md b/docs/adr/0007-lighthouse-ci-gate.md new file mode 100644 index 0000000..7194d35 --- /dev/null +++ b/docs/adr/0007-lighthouse-ci-gate.md @@ -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`. diff --git a/docs/adr/0008-no-font-preloads.md b/docs/adr/0008-no-font-preloads.md new file mode 100644 index 0000000..30d2be4 --- /dev/null +++ b/docs/adr/0008-no-font-preloads.md @@ -0,0 +1,53 @@ +# 0008 — No `` 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 ``, ahead of the render-blocking +stylesheet and well ahead of the hero `` 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. diff --git a/docs/adr/0011-inter-weight-axis.md b/docs/adr/0011-inter-weight-axis.md new file mode 100644 index 0000000..926ca84 --- /dev/null +++ b/docs/adr/0011-inter-weight-axis.md @@ -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 `` audit established that on this site the critical path is font-bound, not +image-bound: removing two `` 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. diff --git a/docs/tooling.md b/docs/tooling.md index 9d93c23..2cdaab0 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -198,8 +198,56 @@ for the same reason, so the whole round trip works locally without credentials. `.github/workflows/ci.yml` runs on every pull request: mise install → `pnpm install --frozen-lockfile` → `pnpm check` → `pnpm build` → offline link check over `dist/`. All steps -block. Lighthouse CI budgets join in Phase 09. Deploys are **not** driven by Actions — Cloudflare -Pages' dashboard git integration owns them (D14). +block. Deploys are **not** driven by Actions — Cloudflare Pages' dashboard git integration owns +them (D14). + +### Performance budgets + +`.github/workflows/lighthouse.yml` is the second required check. It builds, serves `dist/` with +`astro preview`, and runs `pnpm dlx @lhci/cli@0.15.1 autorun` three times against six URLs — one +of each page shape: `/`, `/programs/frc/`, `/programs/frc/robots/`, `/sponsors/`, `/openhouse/`, +`/contact/`. Every assertion in `lighthouserc.json` is an error, so a regression blocks the merge. + +| Assertion | Budget | +| ------------------------ | --------- | +| Performance | ≥ 0.95 | +| Accessibility | = 1.00 | +| SEO | = 1.00 | +| Best Practices | ≥ 0.95 | +| Largest Contentful Paint | < 2000 ms | +| Cumulative Layout Shift | < 0.05 | +| Total Blocking Time | < 100 ms | +| Script transfer size | < 35 KB | +| Total page transfer size | < 1 MB | + +Assertions aggregate on the **median** of the three runs. Lantern's LCP for this site carries one +slow run per page — a ~500 ms step in simulated FCP that appears run to run even on an idle +machine — and median-of-three absorbs exactly that. Every run on every budgeted URL is currently +under the 2000 ms LCP budget, the worst being 1953 ms; the tightest medians are `/` and +`/openhouse/` at 1807 ms. Getting there was two font changes, not the image work — see +`plan/09-assets-performance.md` for the before/after and `docs/adr/0008` and `0011` for the +reasoning. + +Mobile emulation with simulated throttling (1.6 Mbps, 150 ms RTT) — the default preset, and the +reason transfer size dominates every metric here. `astro preview` gzips, which is what makes the +measurement comparable to what Cloudflare serves; a server that did not would fail budgets +production meets. + +To run it locally, with a Chrome that Lighthouse can find: + +```sh +pnpm build +pnpm dlx @lhci/cli@0.15.1 autorun +pnpm exec astro preview stop # LHCI kills its wrapper; the preview server outlives it +``` + +Reports land in `.lighthouseci/reports/` (gitignored) as HTML and JSON, and CI uploads them as an +artifact on every run. Why `pnpm dlx` rather than a devDependency or a marketplace action: +`docs/adr/0007-lighthouse-ci-gate.md`. + +The hero video on `/programs/frc/` is `preload="none"` and only arms after the `load` event, so it +does not count against that page's transfer budget during a run — see +`docs/adr/0006-hero-video-encode.md`. ### Known environment limitation diff --git a/knip.jsonc b/knip.jsonc index 9eb0fc6..4426a2c 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -3,11 +3,14 @@ "entry": ["src/pages/**", "functions/**"], "project": ["**/*.{ts,tsx,js,mjs,cjs,astro}"], "ignore": ["legacy/**", "tools/lint/anti-slop/**"], - // Referenced only from CSS — `tailwindcss` via `@import`, these two faces via `url()` - // in src/styles/fonts.css — and knip does not follow imports inside compiled extensions. - // Inter and Orbitron are absent on purpose: fonts.ts imports them, so knip sees those. + // Referenced only from CSS — `tailwindcss` via `@import`, three of the four faces via `url()` + // in src/styles/fonts.css — and knip does not follow imports inside compiled extensions. Inter + // is there for a different reason: fonts.css points at the trimmed copies in src/styles/fonts/, + // and the package is what `pnpm assets:fonts` regenerates them from. "ignoreDependencies": [ "tailwindcss", + "@fontsource-variable/inter", + "@fontsource-variable/orbitron", "@fontsource-variable/source-code-pro", "@fontsource/architects-daughter" ], diff --git a/lighthouserc.json b/lighthouserc.json new file mode 100644 index 0000000..f64fb66 --- /dev/null +++ b/lighthouserc.json @@ -0,0 +1,36 @@ +{ + "ci": { + "collect": { + "numberOfRuns": 3, + "settings": { "chromeFlags": "--no-sandbox" }, + "startServerCommand": "pnpm exec astro preview --port 4321", + "startServerReadyPattern": "localhost:4321", + "url": [ + "http://localhost:4321/", + "http://localhost:4321/programs/frc/", + "http://localhost:4321/programs/frc/robots/", + "http://localhost:4321/sponsors/", + "http://localhost:4321/openhouse/", + "http://localhost:4321/contact/" + ] + }, + "assert": { + "aggregationMethod": "median", + "assertions": { + "categories:performance": ["error", { "minScore": 0.95 }], + "categories:accessibility": ["error", { "minScore": 1 }], + "categories:seo": ["error", { "minScore": 1 }], + "categories:best-practices": ["error", { "minScore": 0.95 }], + "largest-contentful-paint": ["error", { "maxNumericValue": 2000 }], + "cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }], + "total-blocking-time": ["error", { "maxNumericValue": 100 }], + "resource-summary:script:size": ["error", { "maxNumericValue": 35840 }], + "resource-summary:total:size": ["error", { "maxNumericValue": 1048576 }] + } + }, + "upload": { + "target": "filesystem", + "outputDir": ".lighthouseci/reports" + } + } +} diff --git a/package.json b/package.json index a4bef1b..4c01c66 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "check": "pnpm run typecheck && pnpm run lint && pnpm run fmt:check && pnpm run knip && pnpm run check:tokens && pnpm run check:content", "check:tokens": "node tools/checks/cn-font-size-group.mjs", "check:content": "node tools/checks/content-references.mjs", - "assets:optimize": "node tools/assets/optimize-sources.mjs" + "assets:optimize": "node tools/assets/optimize-sources.mjs", + "assets:fonts": "node tools/assets/font-subset.mjs" }, "dependencies": { "@astrojs/sitemap": "3.7.3", diff --git a/plan/09-assets-performance.md b/plan/09-assets-performance.md index 66c82f0..5439622 100644 --- a/plan/09-assets-performance.md +++ b/plan/09-assets-performance.md @@ -44,8 +44,75 @@ Phase 04 already re-encoded the sources it moved into `src/assets/` (`tools/asse ## Acceptance criteria -- [ ] `public/` + `src/assets/` combined size reduced from 72 MB to < 15 MB (video included); no image source > 500 KB unless justified in the PR. -- [ ] Every page's images: responsive srcset, modern formats, zero CLS from media. -- [ ] Hero video ≤ 3 MB per format, poster is the LCP, plays only when in-view/motion-allowed. -- [ ] Lighthouse CI green on all six URLs at the thresholds above, wired as a required PR check. -- [ ] `pnpm check && pnpm build` green; knip/link-check still green after deletions. +- [x] `public/` + `src/assets/` combined size reduced from 72 MB to < 15 MB (video included); no image source > 500 KB unless justified in the PR. **11 MB** (`public/` 2.3 MB, `src/assets/` 8.5 MB); largest source is `team/frc/2022.webp` at 470 KB. +- [x] Every page's images: responsive srcset, modern formats, zero CLS from media. CLS is 0.000 on all six measured URLs. +- [x] Hero video ≤ 3 MB per format, poster is the LCP, plays only when in-view/motion-allowed. 1.19 MB MP4 / 0.49 MB WebM. +- [x] Lighthouse CI green on all six URLs at the thresholds above, wired as a required PR check. +- [x] `pnpm check && pnpm build` green; knip/link-check still green after deletions. + +## As built + +Three Lighthouse runs per URL (mobile preset, simulated throttling), the same configuration +`.github/workflows/lighthouse.yml` runs. Every assertion passes, on the **median** of the three: + +| URL | Perf | A11y | BP | SEO | LCP median | LCP, the three runs | CLS | TBT | Transfer | +| ----------------------- | ---: | ---: | --: | --: | ---------: | ------------------- | ----: | --: | -------: | +| `/` | 99 | 100 | 100 | 100 | 1807 ms | 1355 / 1807 / 1819 | 0.000 | 0 | 165 KB | +| `/programs/frc/` | 100 | 100 | 100 | 100 | 1659 ms | 1658 / 1659 / 1663 | 0.000 | 0 | 623 KB | +| `/programs/frc/robots/` | 100 | 100 | 100 | 100 | 1430 ms | 1355 / 1430 / 1881 | 0.000 | 0 | 256 KB | +| `/sponsors/` | 100 | 100 | 100 | 100 | 1282 ms | 1210 / 1282 / 1953 | 0.000 | 0 | 206 KB | +| `/openhouse/` | 99 | 100 | 100 | 100 | 1807 ms | 1657 / 1807 / 1808 | 0.000 | 0 | 123 KB | +| `/contact/` | 100 | 100 | 96 | 100 | 1659 ms | 1658 / 1659 / 1663 | 0.000 | 0 | 109 KB | + +`/contact/`'s Best Practices is 96 because of the Turnstile widget's third-party script; the +threshold is 0.95, and the widget is the form's spam defence. + +### How the LCP budget was actually met + +It was not met by the image work. Two font changes did it, in this order: + +1. **Removing the two font preloads** (§4, `docs/adr/0008-no-font-preloads.md`). 61 KB of + High-priority font was queued from `` ahead of the render-blocking stylesheet and well + ahead of the hero ``, on pages whose LCP element is always a photograph. Worth ~660 ms. +2. **Trimming Inter's weight axis** to the range DESIGN.md §3 sanctions + (`docs/adr/0011-inter-weight-axis.md`), 47.1 KB → 35.2 KB on the critical path. + +Before the second change, LCP was **bimodal** — clusters around 1.2 s and 2.1 s with nothing +between, and `/programs/frc/robots/` and `/sponsors/` each had runs above the 2000 ms budget +(1355 / 2108 / 2110 and 1210 / 2032 / 2036). Chasing it structurally got nowhere: an LCP-image +`` moved the median by 2 ms. Taking 12 KB off the critical path is what +collapsed it — every run on every budgeted URL is now under 2000 ms, the worst being 1953 ms. + +So `lighthouserc.json` asserts on the **median** of three runs rather than the best, which is what +the budget was always supposed to mean. The remaining spread is one slow run per page and +median-of-three absorbs it. Tightest margin is `/` and `/openhouse/` at 1807 ms. + +### Deviations from this brief, each with an ADR + +- **§2's "AVIF+WebP formats" is WebP only** — `docs/adr/0005-webp-only-image-variants.md`. AVIF is + 40× slower to encode for a saving WebP matches at any affordable effort level; the measurements + are in the ADR. What §2 actually wanted — variants smaller than their sources — came from + `PHOTO_QUALITY` plus an explicit `width` on every responsive call site. +- **§4's "exactly two preloaded font files" is zero** — `docs/adr/0008-no-font-preloads.md`. The + head audit that section asks for is what found them: 61 KB of High-priority fonts ahead of the + hero `` cost ~290 ms of FCP and ~660 ms of LCP, for a shorter FOUT and no CLS difference. + +### Implementation notes + +- **`public/image/` is deleted in full** — 33 files, 5.4 MB. Nothing in `src/`, `functions/`, + `tools/` or the built output referenced any of them: the unDraw set retired with DESIGN.md §7, + the pattern SVGs with the engineering grid (§2.3), `meta.png` with `public/og/default.png`, and + the rest are Docusaurus-era leftovers. Everything a page renders had already moved to + `src/assets/` in Phase 04. +- **`tools/assets/optimize-sources.mjs` is at its fixpoint** — a dry run reports zero changes + across the relocated inventory, which is the "refuses changes with no meaningful gain" floor + working as designed. The re-encode §2 asks for happened in Phase 04. +- **`HeroImage.astro` is new**, and the eleven heroes now render it instead of repeating six + identical `` attributes. `class="h-full w-full object-cover"` went with them: `Hero`'s + own style already sizes whatever lands in its `media` slot. +- **Width ladders gained a step near 768px.** Lighthouse's mobile viewport asks for ~721 device + px, and two-step ladders were answering it with a 1120px or 1280px variant. Real phones sit in + the same band. +- **The robots page's `loading={index === 0 ? "eager" : "lazy"}` was left alone.** It reads like a + below-the-fold eager fetch, but index 0 is a season with no photo yet, so the eager branch never + renders — confirmed against the built HTML, where every robot image is lazy. diff --git a/public/image/biohazard/2022-robot-field-2.webp b/public/image/biohazard/2022-robot-field-2.webp deleted file mode 100644 index 4e081f7..0000000 Binary files a/public/image/biohazard/2022-robot-field-2.webp and /dev/null differ diff --git a/public/image/biohazard/2022-robot-field-4.webp b/public/image/biohazard/2022-robot-field-4.webp deleted file mode 100644 index 8407b1d..0000000 Binary files a/public/image/biohazard/2022-robot-field-4.webp and /dev/null differ diff --git a/public/image/cube.webp b/public/image/cube.webp deleted file mode 100644 index 0df00c3..0000000 Binary files a/public/image/cube.webp and /dev/null differ diff --git a/public/image/doc-thumb.webp b/public/image/doc-thumb.webp deleted file mode 100644 index 985f709..0000000 Binary files a/public/image/doc-thumb.webp and /dev/null differ diff --git a/public/image/electronics.webp b/public/image/electronics.webp deleted file mode 100644 index eacdfbf..0000000 Binary files a/public/image/electronics.webp and /dev/null differ diff --git a/public/image/first/first-acronym-dark.png b/public/image/first/first-acronym-dark.png deleted file mode 100644 index 382674b..0000000 Binary files a/public/image/first/first-acronym-dark.png and /dev/null differ diff --git a/public/image/first/first-acronym-light.png b/public/image/first/first-acronym-light.png deleted file mode 100644 index 5113b1b..0000000 Binary files a/public/image/first/first-acronym-light.png and /dev/null differ diff --git a/public/image/first/first-horizontal-acro-light.png b/public/image/first/first-horizontal-acro-light.png deleted file mode 100644 index 59ea905..0000000 Binary files a/public/image/first/first-horizontal-acro-light.png and /dev/null differ diff --git a/public/image/first/first-horizontal-light.png b/public/image/first/first-horizontal-light.png deleted file mode 100644 index 3184fa2..0000000 Binary files a/public/image/first/first-horizontal-light.png and /dev/null differ diff --git a/public/image/map.webp b/public/image/map.webp deleted file mode 100644 index 44a37b6..0000000 Binary files a/public/image/map.webp and /dev/null differ diff --git a/public/image/meta.png b/public/image/meta.png deleted file mode 100644 index 6682665..0000000 Binary files a/public/image/meta.png and /dev/null differ diff --git a/public/image/metal-shavings.webp b/public/image/metal-shavings.webp deleted file mode 100644 index b6f9298..0000000 Binary files a/public/image/metal-shavings.webp and /dev/null differ diff --git a/public/image/open-house-24-banner.webp b/public/image/open-house-24-banner.webp deleted file mode 100644 index 9be8113..0000000 Binary files a/public/image/open-house-24-banner.webp and /dev/null differ diff --git a/public/image/openhouse-header.webp b/public/image/openhouse-header.webp deleted file mode 100644 index fe30eaa..0000000 Binary files a/public/image/openhouse-header.webp and /dev/null differ diff --git a/public/image/pattern/circuit.svg b/public/image/pattern/circuit.svg deleted file mode 100644 index f7ce682..0000000 --- a/public/image/pattern/circuit.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/public/image/pattern/hexagons.svg b/public/image/pattern/hexagons.svg deleted file mode 100644 index 1e90051..0000000 --- a/public/image/pattern/hexagons.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - \ No newline at end of file diff --git a/public/image/pattern/topography.svg b/public/image/pattern/topography.svg deleted file mode 100644 index e5e64a7..0000000 --- a/public/image/pattern/topography.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/public/image/sponsor/yb-light.png b/public/image/sponsor/yb-light.png deleted file mode 100644 index fa0d815..0000000 Binary files a/public/image/sponsor/yb-light.png and /dev/null differ diff --git a/public/image/svg/undraw/ai.svg b/public/image/svg/undraw/ai.svg deleted file mode 100644 index 20d4f19..0000000 --- a/public/image/svg/undraw/ai.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/undraw/building.svg b/public/image/svg/undraw/building.svg deleted file mode 100644 index 83dfd16..0000000 --- a/public/image/svg/undraw/building.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/undraw/engineering-team.svg b/public/image/svg/undraw/engineering-team.svg deleted file mode 100644 index 301d5f4..0000000 --- a/public/image/svg/undraw/engineering-team.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/undraw/engineers.svg b/public/image/svg/undraw/engineers.svg deleted file mode 100644 index fbceb32..0000000 --- a/public/image/svg/undraw/engineers.svg +++ /dev/null @@ -1 +0,0 @@ -QA engineers \ No newline at end of file diff --git a/public/image/svg/undraw/firmware.svg b/public/image/svg/undraw/firmware.svg deleted file mode 100644 index 9daae2c..0000000 --- a/public/image/svg/undraw/firmware.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/undraw/fixing-bugs.svg b/public/image/svg/undraw/fixing-bugs.svg deleted file mode 100644 index bd9342a..0000000 --- a/public/image/svg/undraw/fixing-bugs.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/undraw/high-five.svg b/public/image/svg/undraw/high-five.svg deleted file mode 100644 index 758ee2b..0000000 --- a/public/image/svg/undraw/high-five.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/undraw/maker-launch.svg b/public/image/svg/undraw/maker-launch.svg deleted file mode 100644 index 37d714a..0000000 --- a/public/image/svg/undraw/maker-launch.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/undraw/robotics.svg b/public/image/svg/undraw/robotics.svg deleted file mode 100644 index a071361..0000000 --- a/public/image/svg/undraw/robotics.svg +++ /dev/null @@ -1 +0,0 @@ -robotics \ No newline at end of file diff --git a/public/image/svg/undraw/team-collaboration.svg b/public/image/svg/undraw/team-collaboration.svg deleted file mode 100644 index 1384e56..0000000 --- a/public/image/svg/undraw/team-collaboration.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/undraw/team-goals.svg b/public/image/svg/undraw/team-goals.svg deleted file mode 100644 index d82b6ad..0000000 --- a/public/image/svg/undraw/team-goals.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/undraw/team-up.svg b/public/image/svg/undraw/team-up.svg deleted file mode 100644 index 1e65e01..0000000 --- a/public/image/svg/undraw/team-up.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/undraw/teamwork.svg b/public/image/svg/undraw/teamwork.svg deleted file mode 100644 index a574e81..0000000 --- a/public/image/svg/undraw/teamwork.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/undraw/videographer.svg b/public/image/svg/undraw/videographer.svg deleted file mode 100644 index 77e4f1d..0000000 --- a/public/image/svg/undraw/videographer.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/video/biohazard/home-video.mp4 b/public/video/biohazard/home-video.mp4 index 5a1b97f..02a8204 100644 Binary files a/public/video/biohazard/home-video.mp4 and b/public/video/biohazard/home-video.mp4 differ diff --git a/public/video/biohazard/home-video.webm b/public/video/biohazard/home-video.webm index 7bad706..5e62eec 100644 Binary files a/public/video/biohazard/home-video.webm and b/public/video/biohazard/home-video.webm differ diff --git a/src/assets/frc/hero-video-poster.webp b/src/assets/frc/hero-video-poster.webp new file mode 100644 index 0000000..5053589 Binary files /dev/null and b/src/assets/frc/hero-video-poster.webp differ diff --git a/src/assets/frc/home-image.webp b/src/assets/frc/home-image.webp deleted file mode 100644 index 225de15..0000000 Binary files a/src/assets/frc/home-image.webp and /dev/null differ diff --git a/src/components/ui/FeatureCard.astro b/src/components/ui/FeatureCard.astro index 7871664..53dd6d9 100644 --- a/src/components/ui/FeatureCard.astro +++ b/src/components/ui/FeatureCard.astro @@ -11,6 +11,7 @@ import CardHeader from "@/components/ui/primitives/CardHeader.astro"; import CardTitle from "@/components/ui/primitives/CardTitle.astro"; import Icon from "@/components/ui/primitives/Icon.astro"; import { cn } from "@/lib/cn"; +import { PHOTO_QUALITY } from "@/lib/images"; /** * The card the section grids are built from (DESIGN.md §8): an accent-tinted icon tile, a title @@ -68,9 +69,11 @@ if (image !== undefined && imageAlt === undefined) { alt={imageAlt ?? ""} class="aspect-16/10 w-full rounded-md object-cover" loading="lazy" + quality={PHOTO_QUALITY} sizes="(min-width: 64rem) 30rem, (min-width: 48rem) 45vw, 92vw" src={image} - widths={[400, 640, 900]} + width={900} + widths={[400, 640, 768, 900]} /> ) diff --git a/src/components/ui/HeroImage.astro b/src/components/ui/HeroImage.astro new file mode 100644 index 0000000..0444f41 --- /dev/null +++ b/src/components/ui/HeroImage.astro @@ -0,0 +1,32 @@ +--- +import type { ImageMetadata } from "astro"; + +import { Image } from "astro:assets"; + +import { PHOTO_QUALITY } from "@/lib/images"; + +/** + * The photograph behind a `Hero`. It is the page's LCP element on every page that has one, so + * the loading policy and the variant ladder are one decision here rather than six attributes + * repeated at eleven call sites. + * + * `Hero` sizes and crops whatever lands in its `media` slot, so no layout classes belong here. + */ +interface Props { + alt: string; + src: ImageMetadata; +} + +const { alt, src } = Astro.props; +--- + +{alt} diff --git a/src/components/ui/HeroVideo.astro b/src/components/ui/HeroVideo.astro new file mode 100644 index 0000000..9ae0212 --- /dev/null +++ b/src/components/ui/HeroVideo.astro @@ -0,0 +1,94 @@ +--- +import type { ImageMetadata } from "astro"; + +import HeroImage from "@/components/ui/HeroImage.astro"; + +/** + * A `Hero`'s looping background footage, over the frame it starts from (D20). + * + * The poster is a real `` rather than the video's own `poster` attribute, because it is + * the page's LCP element and only an `` gives it a responsive ladder — `poster=` hands a + * phone the same still a desktop gets. The video lies on top at `preload="none"` and reveals + * itself once it is genuinely playing, so a visitor who never reaches that point pays nothing + * for it. + */ +interface Props { + /** Describes the footage: the poster is what a reader who never sees it moving is left with. */ + alt: string; + mp4: string; + poster: ImageMetadata; + webm: string; +} + +const { alt, mp4, poster, webm } = Astro.props; +--- + + + + + + + + diff --git a/src/components/ui/PhotoTimeline.astro b/src/components/ui/PhotoTimeline.astro index b884bdd..94e2b2f 100644 --- a/src/components/ui/PhotoTimeline.astro +++ b/src/components/ui/PhotoTimeline.astro @@ -6,6 +6,7 @@ import { Image } from "astro:assets"; import Callout from "@/components/ui/primitives/Callout.astro"; import Carousel from "@/components/ui/primitives/Carousel.astro"; import { cn } from "@/lib/cn"; +import { PHOTO_QUALITY } from "@/lib/images"; /** * A run of team photos as a scroll-snap track, oldest first — the "photo groupings as @@ -35,9 +36,11 @@ const { label, photos, class: className } = Astro.props; alt={data.alt} class="aspect-video w-full rounded-lg object-cover" loading="lazy" + quality={PHOTO_QUALITY} sizes="(min-width: 48rem) 30rem, 92vw" src={data.photo} - widths={[480, 960]} + width={960} + widths={[480, 768, 960]} /> {data.caption !== undefined && ( diff --git a/src/components/ui/SponsorCard.astro b/src/components/ui/SponsorCard.astro index 2796321..fd72362 100644 --- a/src/components/ui/SponsorCard.astro +++ b/src/components/ui/SponsorCard.astro @@ -52,6 +52,7 @@ const { name, level, logo, url, sub, since } = sponsor.data; class="h-20 w-full max-w-56 object-contain" loading="lazy" src={logo} + width={448} widths={[224, 448]} /> ) diff --git a/src/data/site.ts b/src/data/site.ts index 6638bf2..6aaeada 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -36,6 +36,16 @@ export const site = { alt: "South Central STEM Collective logo over a photo of the team", }, + /** + * The FRC hero's looping footage, in `public/` rather than `src/assets/`: astro:assets has no + * video pipeline, so the encode is a manual ffmpeg step recorded in + * `docs/adr/0006-hero-video-encode.md` and the files ship as-is. + */ + heroVideo: { + mp4: "/video/biohazard/home-video.mp4", + webm: "/video/biohazard/home-video.webm", + }, + /** The favicon/app-icon set in `public/`, projected into head links, the manifest, and JSON-LD. */ icons: { favicon: "/favicon.ico", diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 2742a01..ecf0880 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -7,7 +7,6 @@ import Footer from "@/components/ui/Footer.astro"; import Navbar from "@/components/ui/Navbar.astro"; import { type ProgramTheme, site } from "@/data/site"; import { organization } from "@/lib/jsonld"; -import { preloadedFonts } from "@/styles/fonts"; import "@/styles/global.css"; /** @@ -53,14 +52,6 @@ const { title, description, ogImage, ogImageAlt, ogType, canonical, noindex, the title={title} /> - { - /* Only the two faces that render above the fold; Source Code Pro and the annotation hand - paint below it, and preloading four costs more than it saves. */ - preloadedFonts.map((href) => ( - - )) - } - diff --git a/src/layouts/EventLayout.astro b/src/layouts/EventLayout.astro index 4602fdb..d3aa53e 100644 --- a/src/layouts/EventLayout.astro +++ b/src/layouts/EventLayout.astro @@ -1,7 +1,6 @@ --- import type { ImageMetadata } from "astro"; -import { Image } from "astro:assets"; import { type CollectionEntry, getEntries, render } from "astro:content"; import fllHero from "@/assets/fll/lego-robots.webp"; @@ -12,6 +11,7 @@ import Countdown from "@/components/ui/Countdown.astro"; import CtaBanner from "@/components/ui/CtaBanner.astro"; import FaqList from "@/components/ui/FaqList.astro"; import Hero from "@/components/ui/Hero.astro"; +import HeroImage from "@/components/ui/HeroImage.astro"; import Button from "@/components/ui/primitives/Button.astro"; import Icon from "@/components/ui/primitives/Icon.astro"; import TitleBlock from "@/components/ui/primitives/TitleBlock.astro"; @@ -137,16 +137,7 @@ const details = [ - {hero.alt} + {programs[data.program].name} {data.title} diff --git a/src/lib/images.ts b/src/lib/images.ts new file mode 100644 index 0000000..30cf73b --- /dev/null +++ b/src/lib/images.ts @@ -0,0 +1,13 @@ +/** + * The re-encode point for photographic `` variants. + * + * Every raster source in `src/assets/` is already a 2560px q80 master + * (`tools/assets/optimize-sources.mjs`), so a variant generated at astro:assets' default quality + * is a *recompression* of an already-lossy file — at the widest step it came out larger than the + * master it was derived from. 70 puts the 1920px variant comfortably under its source with no + * visible loss on photography (`docs/adr/0005-webp-only-variants.md`). + * + * Logos and line art are excluded on purpose: they are small already, and quantizing flat colour + * is what makes a mark look cheap. + */ +export const PHOTO_QUALITY = 70; diff --git a/src/pages/about.astro b/src/pages/about.astro index 59e8b22..26b7e9e 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -8,6 +8,7 @@ import students from "@/assets/sc2/students.webp"; import JsonLd from "@/components/JsonLd.astro"; import CtaBanner from "@/components/ui/CtaBanner.astro"; import Hero from "@/components/ui/Hero.astro"; +import HeroImage from "@/components/ui/HeroImage.astro"; import PhotoTimeline from "@/components/ui/PhotoTimeline.astro"; import Button from "@/components/ui/primitives/Button.astro"; import Section from "@/components/ui/Section.astro"; @@ -54,15 +55,10 @@ const biohazardStats = [ - South Central STEM Collective students at work in the Chambersburg workspace About us diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 65d5719..4fd4cc7 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -1,9 +1,8 @@ --- -import { Image } from "astro:assets"; - import laptop from "@/assets/sc2/laptop.webp"; import ContactForm from "@/components/ui/ContactForm.astro"; import Hero from "@/components/ui/Hero.astro"; +import HeroImage from "@/components/ui/HeroImage.astro"; import Button from "@/components/ui/primitives/Button.astro"; import Card from "@/components/ui/primitives/Card.astro"; import CardContent from "@/components/ui/primitives/CardContent.astro"; @@ -34,15 +33,10 @@ const fields = [ title="Contact" > - A student working at a laptop in the South Central STEM Collective workspace Contact us diff --git a/src/pages/donate.astro b/src/pages/donate.astro index b7698fb..b59431c 100644 --- a/src/pages/donate.astro +++ b/src/pages/donate.astro @@ -1,12 +1,11 @@ --- -import { Image } from "astro:assets"; - import drillBits from "@/assets/sc2/drill-bits.webp"; import JsonLd from "@/components/JsonLd.astro"; import ContactForm from "@/components/ui/ContactForm.astro"; import FeatureCard from "@/components/ui/FeatureCard.astro"; import FeatureGrid from "@/components/ui/FeatureGrid.astro"; import Hero from "@/components/ui/Hero.astro"; +import HeroImage from "@/components/ui/HeroImage.astro"; import Section from "@/components/ui/Section.astro"; import { nav, site } from "@/data/site"; import BaseLayout from "@/layouts/BaseLayout.astro"; @@ -34,16 +33,7 @@ const mailingAddress = [ - A rack of drill bits on the workshop wall + How you can support the mission diff --git a/src/pages/get-involved.astro b/src/pages/get-involved.astro index 69ef472..3578130 100644 --- a/src/pages/get-involved.astro +++ b/src/pages/get-involved.astro @@ -1,8 +1,7 @@ --- -import { Image } from "astro:assets"; - import getInvolved from "@/assets/sc2/get-involved.webp"; import Hero from "@/components/ui/Hero.astro"; +import HeroImage from "@/components/ui/HeroImage.astro"; import Button from "@/components/ui/primitives/Button.astro"; import Section from "@/components/ui/Section.astro"; import { site } from "@/data/site"; @@ -24,15 +23,10 @@ const embedded = `${site.urls.getInvolvedForm}?embedded=true`; title="Get involved" > - Students and mentors working together at a build session Get involved diff --git a/src/pages/index.astro b/src/pages/index.astro index 6300e2f..4a23850 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -20,6 +20,7 @@ import CtaBanner from "@/components/ui/CtaBanner.astro"; import FeatureCard from "@/components/ui/FeatureCard.astro"; import FeatureGrid from "@/components/ui/FeatureGrid.astro"; import Hero from "@/components/ui/Hero.astro"; +import HeroImage from "@/components/ui/HeroImage.astro"; import Button from "@/components/ui/primitives/Button.astro"; import Callout from "@/components/ui/primitives/Callout.astro"; import ChalkOval from "@/components/ui/primitives/ChalkOval.astro"; @@ -29,6 +30,7 @@ import Section from "@/components/ui/Section.astro"; import SponsorStrip from "@/components/ui/SponsorStrip.astro"; import { nav, programs, site } from "@/data/site"; import BaseLayout from "@/layouts/BaseLayout.astro"; +import { PHOTO_QUALITY } from "@/lib/images"; import { webSite } from "@/lib/jsonld"; import { breakpoint } from "@/lib/tokens"; @@ -58,15 +60,10 @@ const lockupArt = await renderAt(firstLockup, 640); - Two FIRST LEGO League tables of student-built robots, set up in the SC2 workshop @@ -196,9 +193,11 @@ const lockupArt = await renderAt(firstLockup, 640); alt="Collage of photos capturing some of the many ways students can get involved including robot design, photography, video, and more." class="w-full" loading="lazy" + quality={PHOTO_QUALITY} sizes="(min-width: 64rem) 34rem, 92vw" src={collage} - widths={[560, 1120]} + width={1120} + widths={[560, 840, 1120]} /> @@ -279,9 +278,11 @@ const lockupArt = await renderAt(firstLockup, 640); alt="" class="w-full" loading="lazy" + quality={PHOTO_QUALITY} sizes="(min-width: 48rem) 48rem, 92vw" src={teamVideoPoster} - widths={[640, 1024]} + width={1024} + widths={[640, 840, 1024]} /> - LEGO® robots built by South Central STEM Collective students Ages {programs.fll.ages} @@ -125,9 +122,11 @@ const latest = teamPhotos[0]; alt={latest.data.alt} class="w-full" loading="lazy" + quality={PHOTO_QUALITY} sizes="(min-width: 48rem) 48rem, 92vw" src={latest.data.photo} - widths={[640, 1280]} + width={1280} + widths={[640, 840, 1280]} /> {latest.data.caption !== undefined && ( diff --git a/src/pages/programs/frc/index.astro b/src/pages/programs/frc/index.astro index 663f8c3..23be973 100644 --- a/src/pages/programs/frc/index.astro +++ b/src/pages/programs/frc/index.astro @@ -3,7 +3,7 @@ import { Image } from "astro:assets"; import collage from "@/assets/events/morethanrobots.webp"; import biohazardLockup from "@/assets/frc/biohazard-lockup.svg"; -import heroStill from "@/assets/frc/home-image.webp"; +import heroVideoPoster from "@/assets/frc/hero-video-poster.webp"; import robotField from "@/assets/frc/robots/2024-robot-field.webp"; import troubleclefLogo from "@/assets/frc/robots/2024-troubleclef-logo.webp"; import JsonLd from "@/components/JsonLd.astro"; @@ -11,6 +11,7 @@ import CtaBanner from "@/components/ui/CtaBanner.astro"; import FeatureCard from "@/components/ui/FeatureCard.astro"; import FeatureGrid from "@/components/ui/FeatureGrid.astro"; import Hero from "@/components/ui/Hero.astro"; +import HeroVideo from "@/components/ui/HeroVideo.astro"; import Button from "@/components/ui/primitives/Button.astro"; import Callout from "@/components/ui/primitives/Callout.astro"; import ChalkUnderline from "@/components/ui/primitives/ChalkUnderline.astro"; @@ -19,14 +20,12 @@ import Section from "@/components/ui/Section.astro"; import StatBand from "@/components/ui/StatBand.astro"; import { nav, programs, site } from "@/data/site"; import ProgramLayout from "@/layouts/ProgramLayout.astro"; +import { PHOTO_QUALITY } from "@/lib/images"; import { breadcrumbs } from "@/lib/jsonld"; /** * Copy is legacy's, verbatim (D8), with one correction: legacy's h1 read "engineers scientists". * - * The 22 MB hero video is deliberately still a poster this phase — plan/09 re-encodes it to - * ≤3 MB, and `Hero`'s media slot takes a `