diff --git a/.gitignore b/.gitignore
index 789addf..1a0e30b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -159,3 +159,10 @@ playwright/.cache/
# `astro check` (and therefore `npm run build`) runs first, so nothing needs it
# committed — tracked, it just produced a dirty tree on every dev server start.
.astro/
+
+# Superpowers brainstorm scratch (visual companion)
+.superpowers/
+
+# macOS Finder metadata. Browsing public/ in Finder drops these into the
+# deploy directory, where they would be served as real files.
+.DS_Store
diff --git a/docs/adding-content.md b/docs/adding-content.md
index 28fc786..dd59286 100644
--- a/docs/adding-content.md
+++ b/docs/adding-content.md
@@ -115,6 +115,58 @@ The card on `/blog` will link directly to your content. PhilaCon Valley amplifie
Same as above but without `externalUrl` and `platform`. Write the full content in the Markdown body and it will be hosted on the site.
+## Adding Event Photos
+
+The homepage's "The room" section renders a five-photo mosaic under the headline
+"Every photo is a real Thursday." Until there are photos, the section renders no
+mosaic at all and the headline reads "Come see the room." instead — the claim and
+the evidence ship together or not at all.
+
+Two steps. Put the image file in `public/images/gallery/`:
+
+```
+public/images/gallery/patch-002-pairing.webp
+```
+
+Then add one JSON file per photo in `src/content/gallery/`:
+
+```json
+// src/content/gallery/patch-002-pairing.json
+{
+ "image": "/images/gallery/patch-002-pairing.webp",
+ "alt": "Two people pairing on a laptop at a long table",
+ "event": "PATCH 002: Tap In NFC Lab",
+ "date": "2026-07-30"
+}
+```
+
+### Field reference
+
+| Field | Required | Notes |
+| --------- | -------- | ------------------------------------------------------------------------------- |
+| `image` | Yes | Site-absolute path into `public/`, starting with `/images/`. Not a file import. |
+| `alt` | Yes | What is happening in the photo, for screen readers. Not "event photo". |
+| `event` | Yes | Which night it was taken at. |
+| `date` | Yes | `YYYY-MM-DD`. The five most recent photos are the ones the homepage shows. |
+| `caption` | No | Optional visible caption. |
+
+The homepage takes the five newest by `date`, so adding a sixth retires the
+oldest rather than growing the grid. YAML (`.yaml` / `.yml`) works too if you
+prefer it — the collection accepts both.
+
+**The first photo gets a double-height cell.** The mosaic's leftmost tile spans
+two rows, so whichever entry sorts first should be a **portrait** photo. A
+landscape shot there gets cropped to a tall slot, which cuts people off at both
+edges — a wide group photo is the worst thing to put in it. Give the group shot
+one of the four wide cells instead.
+
+Sorting is newest `date` first; entries sharing a date fall back to filename
+order. So among photos from the same night, `patch-001-pairing.json` comes
+before `patch-001-together.json`.
+
+**Before you add a photo:** make sure the people in it are okay with being on a
+public homepage. There is no way to un-publish a face someone finds later.
+
## What About GitHub Repos?
The `/projects` page automatically displays all repositories from the [philaconvalley GitHub organization](https://github.com/philaconvalley). If your project is in the org, it shows up with no file needed.
diff --git a/docs/perf-baseline.md b/docs/perf-baseline.md
new file mode 100644
index 0000000..f2f6883
--- /dev/null
+++ b/docs/perf-baseline.md
@@ -0,0 +1,45 @@
+# Performance baseline
+
+Captured on `waskar/site-redesign` at commit `de52793`, immediately
+before the motion system landed. Spec §5.5 gates the redesign on staying within 3 performance
+points of these numbers.
+
+Command: `npx --yes @lhci/cli@0.15.x autorun --config=.lighthouserc.json --collect.numberOfRuns=3` (resolved to `@lhci/cli@0.15.1`)
+Machine: local dev (numbers are not comparable to CI — re-baseline there if CI is ever the gate).
+
+| URL | Performance | Accessibility | Best practices | SEO |
+| ------------ | ----------- | ------------- | -------------- | ----- |
+| `/` | `79` | `95` | `93` | `100` |
+| `/about/` | `84` | `95` | `93` | `100` |
+| `/events/` | `92` | `96` | `75` | `100` |
+| `/projects/` | `81` | `93` | `96` | `100` |
+
+## Read the performance column as a median, not a measurement
+
+The table records the median of three runs. The runs were not tight. `/about/` scored
+`0.77 / 0.84 / 0.95` — an 18-point spread on one machine, in one sitting, against one build.
+The other pages were sampled the same way and there is no reason to think they are steadier.
+
+That matters because spec §5.5's gate is ±3 points, and the noise here is six times the gate.
+Three local runs cannot resolve a 3-point change: a page that genuinely regressed by 3 points
+will often score _higher_ than this table, and an unchanged page will often score lower. Taken
+at face value, `84` sends the next implementer chasing a regression that does not exist, or
+waving through one that does.
+
+So evaluate the gate like this:
+
+1. **CI is the enforcement surface.** If the numbers here are ever used to block a change, they
+ must first be re-taken in CI, where the machine is at least consistent between runs. The
+ figures above are local and indicative — useful for spotting a page that halved, not for
+ adjudicating three points.
+2. **Locally, compare medians of at least 5 runs, taken back-to-back in one sitting**, with the
+ same command and nothing else running. Compare median against median, never a single run
+ against this table.
+3. **Record the spread, not just the median**, whenever this file is updated. A median with no
+ spread beside it reads as precision the measurement does not have.
+4. **Treat a sub-5-point local move as no signal at all.** Investigate the trace (LCP, TBT, the
+ actual bytes shipped) rather than the score, because those are stable where the composite
+ score is not.
+
+If any page later drops more than 3 performance points against this table, measured that way,
+spec §5.5 applies: depth drops to two planes site-wide before anything else is cut.
diff --git a/docs/plans/2026-08-01-homepage-scroll-choreography.md b/docs/plans/2026-08-01-homepage-scroll-choreography.md
new file mode 100644
index 0000000..5160fdb
--- /dev/null
+++ b/docs/plans/2026-08-01-homepage-scroll-choreography.md
@@ -0,0 +1,344 @@
+# Homepage Scroll Choreography Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Make GSAP earn the 44 KB it already costs, by giving it work only a scroll runtime can do — a scrubbed skyline parallax and one pinned, orchestrated moment — instead of nine interchangeable fade-ups.
+
+**Architecture:** The homepage motion moves out of an inline `
+```
+
+Astro bundles the import, so this stays covered by `script-src 'self'` and adds no inline hash.
+
+**Step 2: Add the two refresh hooks**
+
+ScrollTrigger caches element positions at creation. Two things move them afterwards on this page:
+
+```ts
+// public/js/event-freshness.js removes the whole next-night band once the event
+// has started, which shortens the document under every trigger below it.
+// ScrollTrigger's own refresh on `load` covers that, but webfonts settle later
+// and change every text block's height, so ask for one more.
+document.fonts?.ready.then(() => ScrollTrigger.refresh());
+```
+
+**Step 3: Verify nothing changed**
+
+```bash
+npm run build && npm run csp:hash && npm run test:e2e
+```
+
+Expected: build clean; **CSP hashes in sync** (unchanged count); 15 passed / 9 skipped.
+
+**Step 4: Commit**
+
+```bash
+git add src/scripts/homepage-motion.ts src/pages/index.astro
+git commit -m "ref(home): Move homepage motion into a module and refresh after fonts settle"
+```
+
+---
+
+## Task 4: Scrub the skyline parallax
+
+The signature effect. The band's background is a mirror-seamless repeating tile, so shifting `background-position-x` moves the city sideways against the hero with no edge to expose — the seam is what makes this cheap.
+
+**Files:**
+
+- Modify: `src/components/SkylineBand.astro` (add a hook attribute)
+- Modify: `src/scripts/homepage-motion.ts`
+
+**Step 1: Write the failing test**
+
+Add to `e2e/motion-preferences.spec.ts`:
+
+```ts
+test('the skyline parallax is scrubbed by scroll position, and reverses', async ({ page }) => {
+ await page.goto('/');
+ const posAt = async (y: number) => {
+ await page.evaluate((v) => window.scrollTo(0, v), y);
+ await page.waitForTimeout(400);
+ return page.$eval('[data-pcv-parallax]', (el) => getComputedStyle(el).backgroundPositionX);
+ };
+ const top = await posAt(0);
+ const mid = await posAt(600);
+ expect(mid).not.toBe(top);
+ expect(await posAt(0)).toBe(top); // reversible — the half IntersectionObserver cannot do
+});
+```
+
+**Step 2: Run it and watch it fail**
+
+Expected: FAIL — no element matches `[data-pcv-parallax]`.
+
+**Step 3: Implement**
+
+Add `data-pcv-parallax` to the band wrapper in `SkylineBand.astro`. In the motion module:
+
+```ts
+gsap.to('[data-pcv-parallax]', {
+ backgroundPositionX: '-260px',
+ ease: 'none',
+ scrollTrigger: {
+ trigger: '[data-pcv-parallax]',
+ start: 'top bottom',
+ end: 'bottom top',
+ scrub: 0.6,
+ },
+});
+```
+
+Guard the whole thing behind the existing `reduced` check.
+
+**Step 4: Verify, including reduced motion**
+
+```bash
+npx playwright test e2e/motion-preferences.spec.ts
+```
+
+Expected: all pass, including the existing reduced-motion cases.
+
+**Step 5: Commit**
+
+```bash
+git add src/components/SkylineBand.astro src/scripts/homepage-motion.ts e2e/motion-preferences.spec.ts
+git commit -m "feat(home): Scrub the skyline sideways against the hero on scroll"
+```
+
+---
+
+## Task 5: Pin the room, run the counters during the pin
+
+The orchestrated moment. `#nights` holds still while the stat card assembles and its three numbers count up. This is the one place the page spends its boldness.
+
+**Files:**
+
+- Modify: `src/pages/index.astro` (`#nights` needs a min-height under the pin)
+- Modify: `src/scripts/homepage-motion.ts`
+
+**Step 1: Write the failing test**
+
+Create `e2e/pinned-room.spec.ts` asserting the section's `top` stays fixed across three scroll positions inside the pin range, and that the counters end on the real values from `data-count-to`.
+
+**Step 2: Run it and watch it fail**
+
+Expected: FAIL — the section top moves with the scroll.
+
+**Step 3: Implement**
+
+Pin `#nights` with `start: 'top 62px'` — matching the sticky header offset the anchor scroll already uses, so the section's own heading is never cropped. Give it `min-height` under the pin only, or the section below stays in frame and the pin reads as a stuck page. Drive the counters from a timeline bound to the same trigger, writing through `Math.round`.
+
+**Step 4: Check the pin against the real layout**
+
+Pinning breaks if an ancestor has `overflow: hidden` or a `transform`. Verify:
+
+```bash
+grep -n "overflow\|transform" src/layouts/BaseLayout.astro
+```
+
+Expected: nothing on the `` wrapper. If there is, the pin needs `pinType: 'transform'`.
+
+**Step 5: Verify**
+
+```bash
+npx playwright test e2e/pinned-room.spec.ts && npm run test:e2e
+```
+
+**Step 6: Commit**
+
+```bash
+git add src/pages/index.astro src/scripts/homepage-motion.ts e2e/pinned-room.spec.ts
+git commit -m "feat(home): Pin the room section while its numbers count up"
+```
+
+---
+
+## Task 6: Close the reveal gap
+
+`#nights` gets a reveal on its `` but nothing on the photo mosaic beneath it (`index.astro:211`). Once the gallery collection has entries, the heading will animate while five photos pop in under it. Invisible today only because the collection is empty.
+
+**Files:**
+
+- Modify: `src/scripts/homepage-motion.ts`
+
+Add the mosaic's children to the reveal selector with the same stagger the cards use. Verify by adding a temporary entry to `src/content/gallery/`, checking the reveal, then removing it.
+
+**Commit:** `fix(home): Reveal the photo mosaic instead of popping it in under an animated heading`
+
+---
+
+## Task 7: Full verification pass
+
+**Step 1: Everything green**
+
+```bash
+npm run lint
+npm run build
+npm run csp:hash
+npx prettier --check .
+npm run test:e2e
+```
+
+**Step 2: Confirm the payload did not grow**
+
+```bash
+ls -l dist/_astro/*.js | awk '{printf "%-46s %6d KB\n", $9, $5/1024}'
+```
+
+Expected: the homepage bundle stays at ~114 KB raw / 44 KB gzip. If it grew, a plugin was added — ScrollToPlugin and ScrollTrigger are the only two this page should import.
+
+**Step 3: Reduced motion, by hand**
+
+macOS System Settings → Accessibility → Display → Reduce motion. Reload. Expected: no parallax, no pin, no counter tick — numbers render at their final values, and every section is legible without scrolling past it.
+
+---
+
+## Out of scope, tracked elsewhere
+
+- `JetBrains Mono` loads on all 12 pages for one page's use — follow-up issue.
+- `src/content/gallery` is empty, so the build prints a collection warning twice — issue #6.
+- Whether the redesign covers about/events/join/contact — open question for Diego and Saige.
diff --git a/docs/superpowers/plans/2026-08-01-motion-system-foundation.md b/docs/superpowers/plans/2026-08-01-motion-system-foundation.md
new file mode 100644
index 0000000..2f13847
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-01-motion-system-foundation.md
@@ -0,0 +1,915 @@
+# Motion System Foundation Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Capture a performance baseline, then replace `src/scripts/homepage-motion.ts` with a
+reusable, markup-declared motion system and a `Section.astro` component — with zero visible change
+to the site.
+
+**Architecture:** Motion becomes two modules. `src/scripts/motion/primitives.ts` exports the
+individual moves as plain functions. `src/scripts/motion/index.ts` scans the DOM for `data-pcv-*`
+attributes and wires those moves up inside a single `gsap.matchMedia()` block, so reduced-motion is
+one code path rather than a JS branch plus a parallel CSS block that can drift. Page authors never
+import GSAP again — they add an attribute.
+
+**Tech Stack:** Astro 7, TypeScript, GSAP 3 (ScrollTrigger + ScrollToPlugin, bundled from
+`node_modules`), Tailwind 3, Playwright.
+
+## Global Constraints
+
+Copied from `docs/superpowers/specs/2026-08-01-website-redesign-design.md`. Every task's
+requirements implicitly include this section.
+
+- **This slice is invisible.** When it merges, the site looks and behaves exactly as it does today.
+ No new animation, no layout change, no color change. The proof is that the existing e2e suite
+ passes unmodified.
+- **Only three moves ship here:** `arrive`, `hold`, and the count-up. `depth` and `seam` are visible
+ by definition and land in slice 4 (Home), where a test can observe them. Do not write them now.
+- **No scrubbed animation may ever own text content** (spec §5.4). Counters stay one-shot tweens over
+ markup that already contains the true value.
+- **The header never animates.**
+- **No animation may delay anything actionable.** CTAs are clickable on frame one.
+- **GSAP is imported from `node_modules`, never a CDN.** The CSP is `script-src 'self'` and is not
+ being loosened (spec §9).
+- **`HEADER_OFFSET` is `62`** — the sticky header height. Anchor scrolling and pinning both clear it.
+- **Reduced motion** is handled by a single `gsap.matchMedia()` path.
+- **Node ≥ 22.12.0**, npm (not pnpm). Lint/format run automatically on commit via husky + lint-staged.
+
+---
+
+### Task 1: Capture the Lighthouse performance baseline
+
+Spec §5.5 gates the motion system on costing no more than 3 Lighthouse performance points. That
+sentence is meaningless without a number recorded before any of this work lands.
+
+**Files:**
+
+- Create: `docs/perf-baseline.md`
+
+**Interfaces:**
+
+- Consumes: nothing.
+- Produces: `docs/perf-baseline.md` containing a performance score per URL, referenced by every later
+ slice's performance check.
+
+- [ ] **Step 1: Confirm you are on the redesign branch and the tree is clean**
+
+```bash
+git branch --show-current # expect: waskar/site-redesign
+git status --short # expect: no output
+```
+
+- [ ] **Step 2: Build the site**
+
+```bash
+npm run build
+```
+
+Expected: `astro check` reports 0 errors, then `dist/` is written.
+
+- [ ] **Step 3: Run Lighthouse against the built output**
+
+`.lighthouserc.json` already points at `staticDistDir: "dist"` and the four URLs that matter. CI runs
+this through `treosh/lighthouse-ci-action`; locally, `@lhci/cli` is not a dependency, so invoke it
+with `npx` rather than adding one.
+
+```bash
+npx --yes @lhci/cli@0.15.x autorun --config=.lighthouserc.json --collect.numberOfRuns=3
+```
+
+Expected: three runs per URL, then an assertion summary. Assertion failures are fine here — you are
+recording reality, not enforcing it. Note the **median performance score** for each of the four URLs.
+
+- [ ] **Step 4: Record the numbers**
+
+Create `docs/perf-baseline.md`. Replace every `<…>` with the actual observed value — this file is
+worthless if it contains guesses.
+
+```markdown
+# Performance baseline
+
+Captured on `waskar/site-redesign` at commit ``, immediately
+before the motion system landed. Spec §5.5 gates the redesign on staying within 3 performance
+points of these numbers.
+
+Command: `npx @lhci/cli@0.15.x autorun --config=.lighthouserc.json --collect.numberOfRuns=3`
+Machine: local dev (numbers are not comparable to CI — re-baseline there if CI is ever the gate).
+
+| URL | Performance | Accessibility | Best practices | SEO |
+| ------------ | ----------- | ------------- | -------------- | ----- |
+| `/` | `` | `` | `` | `` |
+| `/about/` | `` | `` | `` | `` |
+| `/events/` | `` | `` | `` | `` |
+| `/projects/` | `` | `` | `` | `` |
+
+If any page later drops more than 3 performance points against this table, spec §5.5 applies:
+depth drops to two planes site-wide before anything else is cut.
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add docs/perf-baseline.md
+git commit -m "docs(perf): Record the Lighthouse baseline before the motion system"
+```
+
+---
+
+### Task 2: Extract the motion primitives and the attribute scanner
+
+Move today's behavior into the new modules unchanged. The existing e2e suite is the test: it was
+written against the current behavior and must pass without edits.
+
+**Files:**
+
+- Create: `src/scripts/motion/primitives.ts`
+- Create: `src/scripts/motion/index.ts`
+- Modify: `src/pages/index.astro` (attributes + import swap)
+- Delete: `src/scripts/homepage-motion.ts`
+- Test: `e2e/scroll-choreography.spec.ts`, `e2e/motion-preferences.spec.ts` (existing, unmodified)
+
+**Interfaces:**
+
+- Consumes: `HEADER_OFFSET` is defined here for the first time.
+- Produces:
+ - `HEADER_OFFSET: number`
+ - `staggerDelay(index: number, base?: number): number`
+ - `groups(selector: string): HTMLElement[][]`
+ - `arrive(elements: HTMLElement[], opts?: { y?: number }): void`
+ - `hold(section: HTMLElement, distance?: number): void`
+ - `countUp(elements: HTMLElement[]): void`
+ - `smoothAnchors(reduced: boolean): void`
+
+- [ ] **Step 1: Run the existing suite and confirm it is green before you touch anything**
+
+```bash
+npm run test:e2e -- scroll-choreography motion-preferences
+```
+
+Expected: PASS. If it is red before you start, stop and fix that first — you cannot prove an
+invisible refactor against a broken baseline.
+
+- [ ] **Step 2: Write `src/scripts/motion/primitives.ts`**
+
+```ts
+/**
+ * The motion vocabulary. Every animation on this site is one of these moves.
+ *
+ * GSAP is imported from node_modules rather than a CDN — the CSP allows
+ * `script-src 'self'` only, and re-adding a CDN origin would undo #109.
+ *
+ * These functions are deliberately dumb: they take elements and animate them.
+ * All DOM querying, all media-query gating, and all decisions about *what*
+ * animates live in ./index.ts. That split is what lets a page author declare
+ * motion with an attribute and never import this file.
+ */
+import { gsap } from 'gsap';
+import { ScrollTrigger } from 'gsap/ScrollTrigger';
+import { ScrollToPlugin } from 'gsap/ScrollToPlugin';
+
+gsap.registerPlugin(ScrollTrigger, ScrollToPlugin);
+
+/** Height of the sticky header. Anchor scrolling and pinning both clear it. */
+export const HEADER_OFFSET = 62;
+
+/**
+ * Humanised stagger (spec §5.2).
+ *
+ * A perfectly even stagger is the sound of a machine. People entering a room
+ * do not arrive on a metronome — one walks in, pauses, two arrive together,
+ * someone hangs back. These offsets add that irregularity.
+ *
+ * The table is fixed rather than random on purpose. Random would be
+ * untestable, and it would also differ between two renders of the same page,
+ * which is a flicker nobody asked for. Eight values is enough that no group on
+ * this site repeats the pattern visibly.
+ */
+const JITTER = [0, 0.037, 0.019, 0.051, 0.008, 0.043, 0.026, 0.061];
+
+export function staggerDelay(index: number, base = 0.09): number {
+ return Number((index * base + JITTER[index % JITTER.length]).toFixed(3));
+}
+
+/**
+ * Collect matching elements, grouped by the section they live in.
+ *
+ * Grouping matters: stagger restarts at zero in every section, so the fourth
+ * card on the page does not wait for the first three in a section it is not
+ * part of. Elements outside any section are one group.
+ */
+export function groups(selector: string): HTMLElement[][] {
+ const bySection = new Map();
+ document.querySelectorAll(selector).forEach((el) => {
+ const key = el.closest('[data-pcv-section]') ?? document.body;
+ const list = bySection.get(key) ?? [];
+ list.push(el);
+ bySection.set(key, list);
+ });
+ return [...bySection.values()];
+}
+
+/**
+ * ARRIVE — entrances.
+ *
+ * Nothing on this site fades in; things travel in from off-stage with weight.
+ * The computed delay is written back to the element as `data-pcv-delay` so the
+ * stagger is observable from a test and legible in devtools. It is written
+ * before the tween is created, so it is present even if the trigger never fires.
+ */
+export function arrive(elements: HTMLElement[], opts: { y?: number } = {}): void {
+ elements.forEach((el, i) => {
+ const delay = staggerDelay(i);
+ el.dataset.pcvDelay = String(delay);
+ gsap.from(el, {
+ y: opts.y ?? 34,
+ opacity: 0,
+ duration: 0.7,
+ ease: 'power3.out',
+ delay,
+ scrollTrigger: { trigger: el, start: 'top 88%' },
+ });
+ });
+}
+
+/**
+ * HOLD — the page stops and makes you look. At most one per page (spec §5.1).
+ *
+ * Pinning is the thing no IntersectionObserver can do: an observer fires when
+ * an element crosses a threshold, once, and cannot hold a section in place
+ * against distance scrolled. If every call to this function is ever deleted,
+ * delete GSAP with it.
+ *
+ * The layout properties are applied here rather than in markup on purpose.
+ * A pinned section must fill the screen or the pin reads as a page that has
+ * stopped responding — but that is a consequence of pinning, not of the
+ * design, so it must exist in exactly the case that pins and nowhere else.
+ */
+export function hold(section: HTMLElement, distance = 420): void {
+ section.style.minHeight = `calc(100vh - ${HEADER_OFFSET}px)`;
+ section.style.display = 'flex';
+ section.style.flexDirection = 'column';
+ section.style.justifyContent = 'center';
+
+ ScrollTrigger.create({
+ trigger: section,
+ start: `top ${HEADER_OFFSET}px`,
+ end: `+=${distance}`,
+ pin: true,
+ pinSpacing: true,
+ invalidateOnRefresh: true,
+ });
+}
+
+/**
+ * Count-up. Deliberately NOT a primitive — it is a text behaviour governed by
+ * the hard rule in spec §5.4, and it is listed separately so nobody mistakes
+ * it for a fourth move they may reach for freely.
+ *
+ * These are one-shot tweens, and that is a correctness requirement rather than
+ * taste. A scrubbed counter *owns* the text: whatever progress the scrub is
+ * stranded at becomes what the page claims. Jump past the section in a single
+ * frame — restored scroll position, the End key, an anchor link — and a scrub
+ * renders once at progress 0 and stops, leaving "0 members · 0 nights held"
+ * on screen permanently. That is a number on the page that is not true.
+ *
+ * A one-shot cannot fail that way: its only writer always runs to the end, and
+ * if it never fires at all the markup's own value — already the real one — is
+ * left untouched. It also stops the numbers counting *down* on scroll-up,
+ * which reads as members leaving.
+ */
+export function countUp(elements: HTMLElement[]): void {
+ elements.forEach((el, i) => {
+ const to = Number(el.dataset.countTo ?? el.textContent ?? 0);
+ if (!Number.isFinite(to)) return;
+ const counter = { value: 0 };
+ gsap.to(counter, {
+ value: to,
+ duration: 0.9,
+ ease: 'power2.out',
+ delay: i * 0.12,
+ onUpdate: () => {
+ el.textContent = String(Math.round(counter.value));
+ },
+ onComplete: () => {
+ el.textContent = String(to);
+ },
+ });
+ });
+}
+
+/**
+ * Eased scroll for in-page links. Not one of the four moves — it is navigation,
+ * and it must work whether or not motion is allowed, so it is wired outside the
+ * matchMedia block and takes the preference as an argument.
+ */
+export function smoothAnchors(reduced: boolean): void {
+ document.querySelectorAll('a[href^="#"]').forEach((a) => {
+ a.addEventListener('click', (e) => {
+ const sel = a.getAttribute('href');
+ if (!sel || sel === '#') return;
+ const el = document.querySelector(sel);
+ if (!el) return;
+ e.preventDefault();
+ const y = el.getBoundingClientRect().top + window.scrollY - HEADER_OFFSET;
+ if (reduced) {
+ window.scrollTo(0, y);
+ return;
+ }
+ gsap.to(window, {
+ duration: 1.05,
+ ease: 'power3.inOut',
+ scrollTo: { y, autoKill: false },
+ overwrite: 'auto',
+ });
+ });
+ });
+}
+
+export { gsap, ScrollTrigger };
+```
+
+- [ ] **Step 3: Write `src/scripts/motion/index.ts`**
+
+```ts
+/**
+ * The scanner. This is the only file that decides *what* animates.
+ *
+ * Pages declare motion in markup — `data-pcv-arrive`, `data-pcv-hold` — and
+ * this wires it up. A contributor who has never opened a GSAP doc gets motion
+ * that matches the rest of the site exactly, because there is only one
+ * implementation of each move.
+ *
+ * Everything gated on motion preference lives inside a single
+ * `gsap.matchMedia()` block. A JS branch plus a parallel CSS block is two
+ * sources of truth that drift; matchMedia also reverts its tweens on cleanup,
+ * which for a `from()` tween restores the element's real, visible state.
+ */
+import {
+ HEADER_OFFSET,
+ ScrollTrigger,
+ arrive,
+ countUp,
+ groups,
+ gsap,
+ hold,
+ smoothAnchors,
+} from './primitives';
+
+smoothAnchors(window.matchMedia('(prefers-reduced-motion: reduce)').matches);
+
+const media = gsap.matchMedia();
+
+media.add('(prefers-reduced-motion: no-preference)', () => {
+ groups('[data-pcv-arrive]').forEach((group) => arrive(group));
+ countUpInHolds();
+});
+
+media.add('(prefers-reduced-motion: no-preference) and (min-width: 1024px)', () => {
+ document.querySelectorAll('[data-pcv-hold]').forEach((section) => hold(section));
+});
+
+/**
+ * Counters fire when their section is reached, once. Scoped to hold sections
+ * because that is the only place the page is standing still long enough for a
+ * count to be read rather than glimpsed.
+ */
+function countUpInHolds(): void {
+ document.querySelectorAll('[data-pcv-hold]').forEach((section) => {
+ const counters = [...section.querySelectorAll('[data-count-to]')];
+ if (!counters.length) return;
+ ScrollTrigger.create({
+ trigger: section,
+ start: `top ${HEADER_OFFSET}px`,
+ once: true,
+ onEnter: () => countUp(counters),
+ });
+ });
+}
+
+/**
+ * ScrollTrigger caches every trigger's position when it is created. Two things
+ * move those positions afterwards:
+ *
+ * 1. public/js/event-freshness.js removes the entire next-night band once the
+ * event has started, shortening the document above every trigger below it.
+ * 2. Webfonts swap in and change the height of every block of text.
+ *
+ * ScrollTrigger refreshes itself on `load`, which covers the first. Fonts can
+ * settle after that, so ask for one more once they have.
+ */
+document.fonts?.ready.then(() => ScrollTrigger.refresh());
+```
+
+- [ ] **Step 4: Add the attributes to `src/pages/index.astro`**
+
+These reproduce exactly what `homepage-motion.ts` selected by CSS selector today.
+
+- On the `#what` and `#build` section elements, and on `#nights`, add `data-pcv-section`.
+- On the `` inside `#what`, `#build`, and `#nights`, add `data-pcv-arrive`.
+- On the `.pcv-card` divs in `#what` and `#build`, add `data-pcv-arrive`.
+- On each photo div in `#nights`, add `data-pcv-arrive`.
+- On the `#nights` section element, add `data-pcv-hold`.
+
+Example, on the tracks grid in `#what`:
+
+```astro
+{
+ tracks.map((track) => (
+
+ …unchanged…
+
+ ))
+}
+```
+
+- [ ] **Step 5: Swap the import and delete the old module**
+
+In `src/pages/index.astro`, change the bottom script block:
+
+```astro
+
+```
+
+Then:
+
+```bash
+git rm src/scripts/homepage-motion.ts
+```
+
+- [ ] **Step 6: Typecheck and build**
+
+```bash
+npm run build
+```
+
+Expected: `astro check` reports 0 errors. If it reports an unused export or a missing type, fix it
+now — do not proceed with a red typecheck.
+
+- [ ] **Step 7: Run the existing suite unmodified**
+
+```bash
+npm run test:e2e -- scroll-choreography motion-preferences
+```
+
+Expected: PASS, with no edits to either spec file. This is the whole proof of the task: the pin
+still holds, the counters still land on their real values, reduced motion still pins nothing, and
+the hero entrance CSS is untouched.
+
+If `scroll-choreography` fails on the pin, the most likely cause is the `min-width: 1024px`
+condition — Playwright's default Desktop Chrome viewport is 1280 wide, so it should pass; check that
+you used `min-width: 1024px` and not `min-width: 1280px`.
+
+- [ ] **Step 8: Run the whole suite, to catch anything the refactor touched by accident**
+
+```bash
+npm run test:e2e
+```
+
+Expected: PASS.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A src/scripts src/pages/index.astro
+git commit -m "ref(motion): Move the homepage choreography into a declarative motion system
+
+Pages now declare motion with data-pcv-* attributes instead of the script
+reaching in by CSS selector, so a contributor can add motion without opening
+a GSAP doc and without inventing a second way to do it.
+
+Behaviour is unchanged. The existing scroll-choreography and motion-preferences
+specs pass unmodified, which is the point: an invisible refactor proved by a
+suite written before it."
+```
+
+---
+
+### Task 3: Prove the humanised stagger
+
+Task 2 introduced `staggerDelay` but nothing asserts it. This adds the test that makes the
+irregularity a guaranteed property rather than an accident someone can flatten later.
+
+**Files:**
+
+- Create: `e2e/motion-system.spec.ts`
+
+**Interfaces:**
+
+- Consumes: `data-pcv-delay` written by `arrive()` in Task 2.
+- Produces: nothing later tasks depend on.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { test, expect } from '@playwright/test';
+
+/**
+ * The stagger is humanised (spec §5.2): evenly-spaced entrances read as a
+ * machine, so each element's delay carries a small fixed offset. This asserts
+ * the property that makes it human — the gaps between consecutive delays are
+ * not all identical — and asserts it is deterministic, because a random
+ * version would flicker between renders and could not be tested at all.
+ */
+test.describe('humanised stagger', () => {
+ test('arriving elements in a group get unequal, deterministic delays', async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'no-preference' });
+ await page.goto('/');
+
+ // Scoped to the cards, not to every arriving element: the section's
+ // also arrives and is index 0 of the same group, so an unscoped locator
+ // matches four elements, not three.
+ const cards = page.locator('#what .pcv-card[data-pcv-delay]');
+ await expect(cards).toHaveCount(3);
+
+ const delays = await cards.evaluateAll((els) =>
+ els.map((el) => Number((el as HTMLElement).dataset.pcvDelay)),
+ );
+
+ // Strictly increasing — later elements never arrive before earlier ones.
+ expect(delays).toEqual([...delays].sort((a, b) => a - b));
+ expect(new Set(delays).size).toBe(delays.length);
+
+ // And the gaps are not uniform, which is the entire point.
+ const gaps = delays.slice(1).map((d, i) => Number((d - delays[i]).toFixed(3)));
+ expect(new Set(gaps).size).toBeGreaterThan(1);
+ });
+
+ test('delays are identical across reloads', async ({ page }) => {
+ await page.goto('/');
+ const read = () =>
+ page
+ .locator('#what [data-pcv-arrive][data-pcv-delay]')
+ .evaluateAll((els) => els.map((el) => (el as HTMLElement).dataset.pcvDelay));
+
+ const first = await read();
+ await page.reload();
+ const second = await read();
+
+ expect(second).toEqual(first);
+ });
+
+ test('stagger restarts per section rather than running across the page', async ({ page }) => {
+ await page.goto('/');
+ const firstOf = (sel: string) =>
+ page
+ .locator(`${sel} [data-pcv-arrive][data-pcv-delay]`)
+ .first()
+ .getAttribute('data-pcv-delay');
+
+ expect(await firstOf('#what')).toBe(await firstOf('#build'));
+ });
+});
+```
+
+- [ ] **Step 2: Run it and watch it fail for the right reason**
+
+```bash
+npm run test:e2e -- motion-system
+```
+
+Expected: if Task 2 is complete this **passes**. If it fails with `toHaveCount(3)` receiving 0, the
+`data-pcv-arrive` attributes from Task 2 Step 4 are missing — fix the markup, not the test. If the
+uniform-gaps assertion fails, `JITTER` was dropped or flattened to zeros.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add e2e/motion-system.spec.ts
+git commit -m "test(motion): Assert the stagger is uneven, deterministic and per-section"
+```
+
+---
+
+### Task 4: Introduce `Section.astro`
+
+The colour rhythm in spec §4 is a rule, and a rule written in a doc gets ignored in four months. A
+component that only accepts five bands cannot be ignored. This task creates it and adopts it on the
+homepage **without changing any colour** — the band is recorded, not yet rendered. Slice 3 is the
+single file that turns bands into backgrounds.
+
+**Files:**
+
+- Create: `src/components/Section.astro`
+- Modify: `src/pages/index.astro`
+- Test: `e2e/motion-system.spec.ts` (extend)
+
+**Interfaces:**
+
+- Consumes: nothing from Task 3.
+- Produces: `Section.astro` with props
+ `{ band: 'door' | 'air' | 'room' | 'work' | 'invitation'; hold?: boolean }` plus a rest spread of
+ `HTMLAttributes<'section'>`, rendering `` plus `data-pcv-hold`
+ when `hold` is true. The prop is `band`, not `role`, so it cannot shadow the ARIA attribute.
+
+- [ ] **Step 1: Write `src/components/Section.astro`**
+
+```astro
+---
+/**
+ * A band of the page.
+ *
+ * Every page runs the same sequence — open air, the door, inside the room,
+ * the invitation — and colour marks position in that story rather than which
+ * page you are on. That rule lives here rather than in a doc because a
+ * component with five allowed bands cannot be quietly ignored, and a
+ * convention can.
+ *
+ * The prop is called `band`, not `role`. It is a story position, not an ARIA
+ * role, and it is deliberately not forwarded to the DOM: "air" is not a valid
+ * ARIA role and would be a real accessibility defect if it leaked. It is
+ * exposed as `data-pcv-section`, which the motion scanner also uses to scope
+ * stagger groups. Everything else spreads through to the ``, so
+ * callers can pass `aria-labelledby` or a genuine `role`.
+ *
+ * This component renders no background of its own yet. Adopting it and
+ * restyling the site are two different changes, and doing them together would
+ * mean a refactor nobody could review. Slice 3 replaces the `class`
+ * pass-through with a band-driven background, in this file only.
+ */
+import type { HTMLAttributes } from 'astro/types';
+
+type Band = 'door' | 'air' | 'room' | 'work' | 'invitation';
+
+type Props = HTMLAttributes<'section'> & {
+ band: Band;
+ hold?: boolean;
+};
+
+const { band, hold = false, class: className, ...rest } = Astro.props;
+---
+
+
+```
+
+- [ ] **Step 2: Adopt it on the homepage, changing no classes**
+
+In `src/pages/index.astro`, replace the raw `` elements with ``, moving the
+existing class list across verbatim and dropping the `data-pcv-section` / `data-pcv-hold` attributes
+you added by hand in Task 2 Step 4 (the component supplies them now).
+
+Add the import at the top of the frontmatter:
+
+```astro
+import Section from '../components/Section.astro';
+```
+
+The five conversions, with classes copied exactly as they are today:
+
+```astro
+
+```
+
+```astro
+
+```
+
+```astro
+
+```
+
+```astro
+
+```
+
+```astro
+
+```
+
+Leave the conditional next-night `` as a raw element. It carries `data-pcv-event-start` and
+is removed wholesale by `public/js/event-freshness.js`; wrapping it buys nothing and adds a prop
+pass-through for an attribute only it uses.
+
+- [ ] **Step 3: Extend the test**
+
+Append to `e2e/motion-system.spec.ts`:
+
+```ts
+test.describe('section bands', () => {
+ test('the homepage bands declare their story position', async ({ page }) => {
+ await page.goto('/');
+ const bands = await page
+ .locator('[data-pcv-section]')
+ .evaluateAll((els) => els.map((el) => el.getAttribute('data-pcv-section')));
+
+ expect(bands).toEqual(['door', 'air', 'room', 'work', 'invitation']);
+ });
+
+ test('the story position never leaks into ARIA', async ({ page }) => {
+ await page.goto('/');
+ // None of the five band names is a valid ARIA role, and no section on this
+ // page needs one, so `band` reaching the DOM as `role` is a genuine
+ // accessibility defect. Assert no section carries an ARIA role at all.
+ await expect(page.locator('section[role]')).toHaveCount(0);
+ });
+
+ test('exactly one section holds, per spec §5.1', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('[data-pcv-hold]')).toHaveCount(1);
+ });
+});
+```
+
+- [ ] **Step 4: Build and run the full suite**
+
+```bash
+npm run build && npm run test:e2e
+```
+
+Expected: PASS, including `landmarks.spec.ts` — `BaseLayout` already supplies the single ``,
+and `Section` renders ``, so the landmark count is unchanged.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/Section.astro src/pages/index.astro e2e/motion-system.spec.ts
+git commit -m "feat(sections): Add Section.astro and adopt it on the homepage
+
+Colour is about to stop meaning which page you are on and start meaning where
+you are in the story. Putting that rule in a component rather than a doc is
+what stops it decaying: five bands, no others.
+
+No colour changes here. The band is recorded, not yet rendered, so this stays
+reviewable and slice 3 is a single-file change."
+```
+
+---
+
+### Task 5: Guard the site against its own JavaScript
+
+Direction A means depth is earned by motion — so the day GSAP fails to load, the site must go flat
+rather than go blank. Nothing currently asserts that. This is cheap now and expensive to retrofit
+after `depth` and `seam` land in slice 4.
+
+**Files:**
+
+- Create: `e2e/no-js.spec.ts`
+
+**Interfaces:**
+
+- Consumes: nothing.
+- Produces: nothing.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { test, expect } from '@playwright/test';
+
+/**
+ * The redesign earns its depth from motion, which means the site's structure
+ * must never *depend* on the motion running. If a bundle fails to load, a CSP
+ * change blocks it, or a browser is simply slow, the page has to degrade to a
+ * flat, complete, readable document — not a blank one.
+ *
+ * `arrive()` animates *from* opacity 0, so the elements' resting state is
+ * visible and this holds today. It is asserted here so that a later change to
+ * a `to()` tween, or a CSS class that pre-hides elements, fails loudly.
+ */
+test.describe('with JavaScript disabled', () => {
+ test.use({ javaScriptEnabled: false });
+
+ test('every homepage band renders and is readable', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page.locator('[data-pcv-section]')).toHaveCount(5);
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+
+ for (const el of await page.locator('#what .pcv-card').all()) {
+ await expect(el).toBeVisible();
+ await expect(el).toHaveCSS('opacity', '1');
+ }
+ });
+
+ test('the stat numbers are already true before any script runs', async ({ page }) => {
+ await page.goto('/');
+ const counters = page.locator('#nights [data-count-to]');
+ await expect(counters).toHaveCount(3);
+ for (const el of await counters.all()) {
+ expect((await el.textContent())?.trim()).toBe(await el.getAttribute('data-count-to'));
+ }
+ });
+
+ test('the primary call to action is present and clickable', async ({ page }) => {
+ await page.goto('/');
+ const cta = page.locator('#top a').first();
+ await expect(cta).toBeVisible();
+ await expect(cta).toHaveAttribute('href', /.+/);
+ });
+});
+```
+
+- [ ] **Step 2: Run it**
+
+```bash
+npm run test:e2e -- no-js
+```
+
+Expected: PASS. If the card-opacity assertion fails, something is hiding elements in CSS before JS
+runs — that is a real bug this test exists to catch, and the fix is in the CSS, not the test.
+
+- [ ] **Step 3: Run everything one final time**
+
+```bash
+npm run lint && npm run build && npm run test:e2e && npm run test:csp
+```
+
+Expected: all PASS. `test:csp` matters here: GSAP is bundled by Astro under `script-src 'self'`, and
+this slice must not have introduced an inline script or a new origin.
+
+- [ ] **Step 4: Re-run Lighthouse and compare against the baseline**
+
+```bash
+npx --yes @lhci/cli@0.15.x autorun --config=.lighthouserc.json --collect.numberOfRuns=3
+```
+
+Compare each URL's median performance score against `docs/perf-baseline.md`. This slice changed no
+markup weight and added no library, so expect it to be within noise. If any page is more than 3
+points down, stop and investigate before slice 3 — spec §5.5 applies, and finding it here is far
+cheaper than finding it after five pages are rebuilt.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add e2e/no-js.spec.ts
+git commit -m "test(no-js): Assert the site degrades flat rather than blank
+
+Depth is earned by motion in this design, so the failure mode of a missing
+bundle has to be a flat page, never an empty one. Cheap to assert now, and
+expensive to retrofit once depth and seams land."
+```
+
+---
+
+## What this slice deliberately does not do
+
+- **No `depth`, no `seam`.** Both are visible by definition, and slice 2 is invisible. They land in
+ slice 4 on Home, where a test can observe them. Writing them now would mean shipping untested code
+ and calling it done.
+- **No colour changes.** `Section` records its band and renders the existing classes. Slice 3 turns
+ bands into backgrounds in one file.
+- **No page merges, no redirects, no deletions.** Slices 5–9.
+- **No contributor pipeline and no photography.** Slice 6 and an external dependency respectively.
+
+## Open question blocking slice 3
+
+Spec §4 states the `door` band is `brand-yellow` (`#FDC873`) on every page. The homepage hero is
+currently `bg-brand-sky` (`#54B5FC`), and `docs/design-system.md` — which claims Home is yellow — is
+stale relative to the code. Since the homepage is the agreed north star, the spec and the code
+disagree about the single most-repeated colour on the site.
+
+This does not block slices 1 or 2, which change no colours. It must be resolved before slice 3.
+
+---
+
+## Carried forward to slice 4
+
+Deferred observations from the final whole-branch review and its fix wave. None block this
+slice; all of them are cheapest to resolve when slice 4 (Home) wires the photo mosaic.
+
+1. **`arrive()`'s `shared` option has no caller and no test.** It was added so a grid row can
+ share one trigger instead of firing twelve. It is unverified until slice 4 uses it — wire the
+ photo mosaic to it and test it there, or delete it if the mosaic does not need it.
+2. **The stagger cap can invert order past index 3.** `staggerDelay` caps the linear term at
+ `Math.min(index, 3)`, so items 4+ are separated only by jitter and can land a few hundredths
+ out of sequence. That is the intended "they arrive together" reading, and it is unreachable
+ today (no group exceeds four elements). It is stated in the fix report but not in
+ `primitives.ts` — add a sentence there when a group first exceeds four elements.
+3. **`countUp()`'s interrupt path is untested.** `onInterrupt` writes the true value if the
+ motion-preference context reverts mid-tween. Sound by inspection, unguarded against
+ regression. A test would need to flip `prefers-reduced-motion` while a tween is running.
+4. **`e2e/mobile-motion.spec.ts` keys observed counter values by `data-count-to`.** If two
+ counters in `#nights` ever share a value the map collapses and the test fails for an
+ unrelated reason. Safe today (425 / 13 / 6); fragile if the numbers converge.
+5. **`e2e/mobile-motion.spec.ts` reads `minHeight` but never asserts on it.** Harmless, mildly
+ misleading — assert it or drop it.
+
+## Resolved: the door's colour
+
+**Decided 2026-08-02 — the `door` role is `brand-yellow` (`#FDC873`). Spec §4 stands as
+written; the homepage hero changes from `bg-brand-sky` to yellow in slice 3.**
+
+The conflict was real: spec §4 said yellow, `docs/design-system.md` said yellow, and the
+homepage shipped `bg-brand-sky` (`#54B5FC`). Since the homepage is the agreed north star,
+the code's disagreement with both documents had to be settled rather than assumed.
+
+It resolves to yellow for two reasons. The blue was painted as the sky behind an illustrated
+skyline that was deleted in `f1ba32a` — it is set dressing for a set already struck, and
+`tailwind.config.mjs` says as much in its own comment on the token. And it is the only cold
+colour in an otherwise entirely warm printed palette; the door is the invitation, so it
+should feel warm.
+
+Consequences for slice 3:
+
+- `Section.astro` maps `band="door"` to `bg-brand-yellow`, and `src/pages/index.astro` drops
+ its hand-written `bg-brand-sky`.
+- `brand-sky` becomes unreferenced. Remove the token from `tailwind.config.mjs` in the same
+ slice rather than leaving a colour nothing uses.
+- Re-check contrast: the hero runs dark text (`#1A1A1A`) on the new background, and
+ `e2e/contrast.spec.ts` covers this. It must pass unmodified.
diff --git a/docs/superpowers/specs/2026-08-01-website-redesign-design.md b/docs/superpowers/specs/2026-08-01-website-redesign-design.md
new file mode 100644
index 0000000..40b65ff
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-01-website-redesign-design.md
@@ -0,0 +1,354 @@
+# Website Redesign — Design Spec
+
+**Date:** 2026-08-01
+**Branch:** `waskar/site-redesign`
+**Status:** Approved for planning
+
+---
+
+## 1. The problem
+
+The homepage has a clear voice: "You're not the only one in the room." Yellow, loud, a
+pigeon, a claim about belonging. The other ten pages have not caught up. They share the
+homepage's components but not its argument — each one opens with a different hero color
+drawn from a table in `docs/design-system.md`, then repeats the same three-card grid.
+
+That table is the symptom. Color currently encodes _which page you are on_, which is an
+admission that the pages would otherwise be indistinguishable. Nothing else about a page
+tells a visitor where they are or why they should care.
+
+Underneath the visual problem is a structural one: eleven pages serving one audience with
+one job, several of which are duplicates of each other (`/join` and `/contact` both say
+"reach us"; `/projects` and `/resources` both say "here is what we build"; `/support` is a
+donate button wearing a page).
+
+And underneath that is the honest one. The homepage headline reads "Every photo is a real
+Thursday." The `gallery` content collection is empty. The code degrades correctly — it
+swaps the headline and hides the grid — but the result is a website selling belonging on
+which no human being appears.
+
+## 2. Decisions
+
+These were settled during brainstorming and are not open questions.
+
+| Decision | Choice |
+| --------------- | ------------------------------------------------------------------------------------------ |
+| Starting point | The homepage's voice is correct and settled. Everything else moves to it. |
+| Audience | One: the person in Philadelphia who might show up but thinks they're not ready. |
+| Page count | Eleven down to five. |
+| Design idea | **The Room** — the site is a space you move through — backed by real data as proof. |
+| Visual language | **Direction A, "Flat but staged."** The material never changes. Depth is earned by motion. |
+| Motion | GSAP everywhere, authored nowhere. Four primitives, declared in markup. |
+| Human presence | Real event photography + live GitHub contributors. |
+
+### 2.1 Why direction A and not atmospheric depth
+
+The obvious way to make a site feel like a space is gradients, soft shadows, blur and
+translucency. It was rejected. The flat printed look — hard 2px ink borders, solid offset
+shadows, no gradients — is the one visual asset PhilaCon Valley has that is recognizable
+from across a room, and atmospheric depth is available to everyone. The material stays
+exactly as it is. Depth comes from movement and overlap.
+
+The consequence is deliberate: **kill the JavaScript and the site goes flat.** That is the
+proof that the motion is structural rather than decorative. One exception is borrowed from
+the "layered paper" direction — overlapping section seams — so the page still reads as
+layered with JavaScript off or motion disabled.
+
+## 3. Information architecture
+
+Five pages. Existing URLs are preserved wherever possible; breaking inbound links to prove
+a point is vanity.
+
+| URL | Room | Absorbs | Its one job |
+| ----------- | ------------- | ------------ | ---------------------------------------------- |
+| `/` | Home | — | Make a stranger feel they are already inside |
+| `/events` | The Nights | — | Turn "someday" into a specific Thursday |
+| `/projects` | What We Build | `/resources` | Show real work, hand over a first issue |
+| `/about` | Who We Are | `/support` | Earn trust, then ask for money — in that order |
+| `/join` | Come In | `/contact` | One door, one form, no maze |
+
+**Redirects** (permanent, in `vercel.json`): `/resources → /projects`,
+`/support → /about`, `/contact → /join`.
+
+**Detail routes are untouched.** `/projects/[slug]` and `/resources/[slug]` continue to
+exist and render their content collections. Only the index pages merge. `/resources/*`
+detail URLs must keep working after `/resources` itself redirects — the redirect is exact-
+path, not a prefix.
+
+**Blog stays unrouted** in `src/pages/_blog/`. Three posts with no fourth scheduled is
+worse than no blog. Its content is still used — see §6.4.
+
+**404** stays, restyled to the new system.
+
+## 4. Color system
+
+The per-page hero color table in `docs/design-system.md` is deleted. Color stops meaning
+_which page_ and starts meaning _where you are in the story_. Every page runs the same
+sequence: **open air → the door → inside the room → the invitation.**
+
+| Role | Token | Hex | Meaning |
+| ------------ | -------------- | --------- | ------------------------------------------ |
+| `door` | `brand-yellow` | `#FDC873` | Every page's hero. Every page. |
+| `air` | `brand-cream` | `#FFEED0` | Browsing, no pressure |
+| `room` | `brand-dark` | `#1A1A1A` | The held moment. Proof, photos, numbers. |
+| `work` | `brand-purple` | `#B383C3` | Making, building, shipping |
+| `invitation` | `brand-pink` | `#FF66A8` | Where someone is asked to act |
+| — | `brand-coral` | `#EF657F` | Labels and links only. Never a background. |
+
+Someone arriving at `/about` from a search result gets the same front door as someone
+arriving at `/`. This is also what makes the choreography legible: a section sliding over
+another section means the same thing every time it happens.
+
+## 5. Motion system
+
+### 5.1 The four primitives
+
+Every animation on the site is one of these four. If a proposed animation is not one of
+these four, it does not ship.
+
+1. **Arrive** — entrances. Nothing fades in; elements travel 28–40px from off-stage with
+ `power3.out` and slight overshoot, staggered within a group.
+2. **Depth** — scroll-linked parallax across three planes: far `0.4×`, mid `0.7×`,
+ near `1×`. Two planes on viewports below 768px. This is where the space comes from.
+3. **Hold** — a pin that stops the page and makes the visitor look. **At most one per
+ page.** `/join` deliberately has none.
+4. **Seam** — the next section scrubs up over the current one with a rounded top edge.
+ Its static end state is the layered layout, so it survives with JavaScript off.
+
+### 5.2 Humanized stagger
+
+Perfect intervals read as a machine. Within an `arrive` group, each element's delay carries
+a small offset derived deterministically from its index, so timings are slightly irregular
+and no two groups are identical. Deterministic, not random — random would be untestable and
+would flicker between renders. The effect is not consciously noticed; it is the difference
+between a page that animates and a page that is populated.
+
+### 5.3 Declared in markup, not in scripts
+
+Contributors never write GSAP:
+
+```astro
+
+ Show up before you feel ready.
+ …
+
+```
+
+`band` here is a prop on the `Section.astro` component naming the section's position in the
+story. It is deliberately not called `role`: that would shadow the real ARIA attribute, and
+none of the five values is a valid ARIA role. The component renders a real `` and does
+not forward this prop to the DOM.
+
+One module scans for `data-pcv-*` attributes and wires the timelines. Someone who has never
+opened a GSAP doc gets motion that matches the rest of the site exactly.
+
+### 5.4 Hard rules
+
+- **No scrubbed animation ever owns text content.** This is site law, promoted from the
+ existing comment in `homepage-motion.ts`: a scrub stranded at progress 0 leaves whatever
+ it was mid-writing on the page permanently. Counters and any other text remain one-shot
+ tweens whose only writer always runs to completion, over markup that already contains the
+ true value.
+- **The header never animates.**
+- **Nothing loops forever** except the pigeon's bob.
+- **No animation delays anything actionable.** Every CTA is clickable on frame one,
+ mid-entrance.
+- **Reduced motion** collapses all four primitives to their end state through a single
+ `gsap.matchMedia()` path — not a parallel CSS block that can drift out of sync.
+
+### 5.5 Performance gate
+
+If the motion system costs more than 3 Lighthouse performance points on any page relative
+to the pre-redesign baseline, depth drops to two planes site-wide before anything else is
+cut. Baseline is captured before slice 1 begins.
+
+## 6. Human presence
+
+The design's load-bearing wall. Six mechanisms, in order of how real they are.
+
+### 6.1 Photographs of real nights (external dependency)
+
+Non-negotiable and not buildable in code. Someone shoots the next 2–3 events; target 8–12
+usable frames of people in a room. Requires a stated photo-consent practice — a line on the
+Luma RSVP and a way to opt out at the door. This is a policy decision owned by the team,
+not a component.
+
+`PhotoGrid.astro` is reworked to degrade honestly: renders nothing at 0 photos, a real
+layout at 5, the full mosaic at 12+.
+
+### 6.2 Live GitHub contributors
+
+The contributor list _is_ the community, it is already public, and it needs no consent
+process. Avatars, handles and merged-PR counts appear on repo cards and in a "built by
+these people" block.
+
+### 6.3 Specifics, never testimonials
+
+No quote on this site says a variant of "PhilaCon Valley changed my life." Where member
+voice appears, it is a specific: what someone brought and what they left with. Out of scope
+for this phase — the team opted not to collect these before launch. The design must not
+depend on them.
+
+### 6.4 One person's voice
+
+"We" is nobody. `src/content/blog/waskar-between-commits.md` and
+`waskar-plot-holes-potholes.md` already contain first-person writing. One honest paragraph
+is pulled from existing content, attributed by name with a link, and placed on `/about`.
+No new writing is required from the founder. If it reads badly in review, it is cut — the
+page must stand without it.
+
+### 6.5 Never fake a human
+
+Site law. If the real thing does not exist yet, show nothing. No stock photography, no
+illustrated teams, no placeholder avatars. This extends the pattern the homepage already
+follows for the empty gallery.
+
+### 6.6 Alt text is a description of people
+
+Photo alt text describes who is in the room and what they are doing, not "event photo."
+Accessibility and the argument of the site are the same work here.
+
+## 7. Page designs
+
+Every page runs door → air → room → work → invitation. Content differs; the heartbeat does
+not.
+
+### 7.1 Home `/`
+
+Door (yellow, headline + two CTAs, depth planes behind) → live next-night band (dark) →
+what actually happens, three tracks (cream) → **HOLD: the room** — photos, and the existing
+count-up numbers (dark) → we learn by shipping, repos + contributors (purple) → the flock
+(pink).
+
+The existing hold is kept as-is in behavior. It is the best thing on the current site and
+the seed of the whole design.
+
+### 7.2 The Nights `/events`
+
+Door: "Pick a Thursday." → **HOLD: what a night actually looks like** — a scrubbed 7:00 →
+9:30 timeline (dark) → the three kinds of nights (cream) → what got shipped last time
+(purple) → RSVP (pink).
+
+Rationale for the hold: the visitor's loudest unspoken fear is not "will this be useful"
+but "what will I have to do when I walk in, and will I look stupid." Answering it literally,
+minute by minute, removes the actual barrier.
+
+### 7.3 What We Build `/projects`
+
+Door: "Your first pull request is already waiting." → the repos with live good-first-issue
+links and contributor faces (purple) → **HOLD: what happens to your PR** — the review path,
+shown slowly (dark) → guides and resources, absorbed from `/resources` (cream) → claim an
+issue (pink).
+
+Rationale for the hold: a first-time contributor does not fear writing the code. They fear
+being judged in public, permanently, with their name attached.
+
+### 7.4 Who We Are `/about`
+
+Door: "A pigeon flies alone." → why this exists, founder paragraph per §6.4 (cream) →
+**HOLD: the numbers, and what they cost** (dark) → the people doing it (purple) → keep the
+lights on, donate, absorbed from `/support` (pink).
+
+The receipts section is what a funder or board candidate needs. They are not the primary
+audience and get no separate wing — one section is made honest enough that they do not need
+one.
+
+### 7.5 Come In `/join`
+
+Door: "There is no application." → three ways in (cream) → the form (pink).
+
+Three bands, **no hold.** This is the moment a person is finally about to act; you do not
+stop someone walking through your front door to show them a slideshow. It also demonstrates
+that the rule is "at most one hold," not "every page gets a toy."
+
+The merged form replaces the current 347-line `/join` and 331-line `/contact` with a single
+`StepForm` presenting one question at a time (Alpine.js, already a dependency). Same data
+collected; it stops feeling like paperwork.
+
+## 8. Components and files
+
+**New**
+
+- `src/scripts/motion/primitives.ts` — the four moves, humanized stagger, `matchMedia`.
+- `src/scripts/motion/index.ts` — attribute scanner; replaces `src/scripts/homepage-motion.ts`.
+- `src/components/Section.astro` — owns the color band and the seam. Takes
+ `band="door" | "air" | "room" | "work" | "invitation"`. A component cannot be ignored the
+ way a documented convention can.
+- `src/components/Contributors.astro`
+- `src/components/StepForm.astro`
+
+**Modified**
+
+- `src/components/PhotoGrid.astro` — 0/5/12 degradation.
+- `src/pages/{index,events,projects/index,about,join}.astro` — rebuilt on `Section`.
+- `src/pages/404.astro` — restyled.
+- `scripts/refresh-community-data.mjs` — also snapshots contributors and downloads avatars.
+- `src/data/community-snapshot.json` — gains a `contributors` array.
+- `vercel.json` — three redirects.
+- `docs/design-system.md` — the page-color table is replaced by the story-position table.
+
+**Deleted**
+
+- `src/pages/{resources/index,support,contact}.astro`
+- `src/scripts/homepage-motion.ts`
+
+**Untouched:** `src/pages/{projects,resources}/[slug].astro`, `src/pages/_blog/*`,
+`rss.xml.ts`, `content.config.ts` schemas.
+
+## 9. Data and security
+
+**Contributors are fetched by `scripts/refresh-community-data.mjs`, not at build time.**
+Same pattern already used for events. A GitHub rate limit must never be able to fail a
+deploy; the committed snapshot is the source of truth at build time.
+
+**Avatars are downloaded into `public/` by that script, not hotlinked.** Hotlinking would
+require adding `avatars.githubusercontent.com` to `img-src`. The CSP was deliberately
+tightened in #109 and is not being loosened for profile pictures. GSAP is already bundled
+from `node_modules` and stays that way — `script-src 'self'` is unchanged.
+
+## 10. Testing
+
+Playwright is already configured. Added coverage:
+
+- **No-JS pass** — seams, layout and all content present and readable with JavaScript
+ disabled.
+- **Reduced-motion pass** — all four primitives render at their end state; nothing hidden.
+- **Text integrity** — jump instantly past every hold (End key, restored scroll, anchor) and
+ assert no counter or heading is left mid-animation showing a false value.
+- **Redirects** — the three permanent redirects resolve, and `/resources/[slug]` detail
+ pages still render.
+- **Lighthouse budget** — performance gate per §5.5.
+
+## 11. Build order
+
+Each slice is independently mergeable. If work stops halfway, the result is a working site
+rather than a construction site.
+
+1. Baseline Lighthouse capture.
+2. Motion system + `Section.astro` as an invisible refactor — homepage behaves exactly as it
+ does today.
+3. Color system + `docs/design-system.md` rewrite.
+4. Home.
+5. The Nights.
+6. What We Build (+ contributors pipeline).
+7. Who We Are.
+8. Come In (+ `StepForm`).
+9. Redirects, 404, deletions, docs.
+
+## 12. Risks
+
+| Risk | Severity | Mitigation |
+| ------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| No photos exist on launch day | **High** — most likely to occur | The design ships as a choreographed empty room, worse than today. `PhotoGrid` degrades to nothing, but the `/` and `/about` holds lose their subject. Slices 4 and 7 do not merge until photos exist. |
+| Motion cost on low-end Android | Medium | Two depth planes below 768px; Lighthouse gate at §5.5. |
+| Scope — five pages is not a weekend | Medium | The slice order in §11 is the insurance. |
+| Merged forms lose a field someone relied on | Low | Field-by-field diff of `/join` and `/contact` before deleting either. |
+
+## 13. Explicitly out of scope
+
+- Any separate treatment for sponsors, funders, board candidates or agency clients. One
+ audience was chosen deliberately.
+- Routing or redesigning the blog.
+- Member testimonials or collected member specifics (§6.3).
+- A new logo, new typefaces, or changes to the color values themselves. Only their _meaning_
+ changes.
diff --git a/e2e/builder-night-track.spec.ts b/e2e/builder-night-track.spec.ts
deleted file mode 100644
index 5536243..0000000
--- a/e2e/builder-night-track.spec.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { test, expect } from '@playwright/test';
-
-/**
- * The Builder Night timeline is absolutely positioned and deliberately stays
- * horizontal at every width — stacked, the left-to-right travel that IS the
- * argument becomes a bulleted list of times. The cost of that choice is that
- * nothing in the layout stops two boxes from occupying the same pixels: the
- * opaque outcome chip is later in DOM order than the captions, so when it
- * overlaps one it silently paints over the text rather than pushing it aside.
- *
- * That shipped once — at every width up to ~430px the chip covered "6:30pm ·
- * you arrive alone", so an iPhone-sized visitor read "6:30PM · YOU ARRI". These
- * widths are the real device sizes it broke on, plus the boundary just past it.
- *
- * Locators are by text, not by position class, so the test states the contract
- * ("these two captions stay readable") rather than the current pixel values.
- *
- * Runs under reduced motion, which the stylesheet honours by freezing every
- * pcv-loop-* transform. That is not a shortcut around a flaky assertion: it is
- * the resting geometry, the state the layout is actually designed at, and the
- * only one a boundingBox mid-keyframe would not read differently on every run.
- * The transient overlap while pcvChip rises in from translateY(12px) is handled
- * structurally instead, by the captions' z-10.
- */
-
-const PHONE_WIDTHS = [320, 360, 375, 390, 414, 430];
-
-const CAPTIONS = ['6:30pm · you arrive alone', '7:00pm · two others pull up a chair'];
-
-interface Box {
- x: number;
- y: number;
- width: number;
- height: number;
-}
-
-function overlap(a: Box, b: Box) {
- return {
- x: Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)),
- y: Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)),
- };
-}
-
-test.describe('Builder Night timeline', () => {
- for (const width of PHONE_WIDTHS) {
- test(`outcome chip never covers a caption at ${width}px`, async ({ page }) => {
- await page.setViewportSize({ width, height: 900 });
- await page.emulateMedia({ reducedMotion: 'reduce' });
- await page.goto('/');
-
- const chip = page.locator('.pcv-loop-chip');
- await expect(chip).toHaveCount(1);
- const chipBox = await chip.boundingBox();
- expect(chipBox).not.toBeNull();
-
- for (const caption of CAPTIONS) {
- // Scoped to the decorative track: the sr-only summary narrates the same
- // times in prose, and matching that instead would assert nothing.
- const locator = page.locator('[aria-hidden="true"]').getByText(caption, { exact: true });
- await expect(locator, `caption "${caption}" is missing`).toHaveCount(1);
-
- const box = await locator.boundingBox();
- expect(box, `caption "${caption}" has no box`).not.toBeNull();
-
- const { x, y } = overlap(chipBox!, box!);
- expect(
- x > 0 && y > 0,
- `the outcome chip overlaps "${caption}" by ${x}x${y}px at ${width}px and paints over it`,
- ).toBe(false);
- }
- });
- }
-});
diff --git a/e2e/gallery-images.spec.ts b/e2e/gallery-images.spec.ts
new file mode 100644
index 0000000..66b76fc
--- /dev/null
+++ b/e2e/gallery-images.spec.ts
@@ -0,0 +1,60 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * The mosaic's srcset is built by convention: `foo.webp` is the 1200px
+ * canonical, and `foo-400.webp` / `foo-800.webp` sit beside it. A convention
+ * that nothing checks is a convention that eventually ships 404s — a
+ * contributor adds a photo, copies one file instead of three, and the only
+ * symptom is a phone silently falling back to the full-size image, or no image
+ * at all. So every URL the browser could choose is fetched here.
+ *
+ * This also pins the reason the ladder exists: the tiles render far smaller
+ * than the files, and without responsive sources a phone paid 674KB for them.
+ */
+test.describe('gallery responsive sources', () => {
+ test('every srcset candidate resolves', async ({ page, request }) => {
+ await page.goto('/');
+
+ const imgs = page.locator('#nights img');
+ const count = await imgs.count();
+ test.skip(count === 0, 'no gallery photos in this build — nothing to check');
+
+ const urls = new Set();
+ for (let i = 0; i < count; i++) {
+ const src = await imgs.nth(i).getAttribute('src');
+ const set = await imgs.nth(i).getAttribute('srcset');
+ expect(set, `photo ${i} should carry a srcset`).toBeTruthy();
+ if (src) urls.add(src);
+ for (const candidate of set!.split(',')) {
+ const url = candidate.trim().split(/\s+/)[0];
+ expect(url, 'srcset entry should have a URL').toBeTruthy();
+ urls.add(url);
+ }
+ }
+
+ // Three ladder rungs per photo; anything less means the srcset lost one.
+ expect(urls.size, 'expected 3 URLs per photo').toBe(count * 3);
+
+ for (const url of urls) {
+ const res = await request.get(url);
+ expect(res.status(), `${url} should be served, not 404`).toBe(200);
+ }
+ });
+
+ test('a phone is offered a source far smaller than the canonical file', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/');
+
+ const img = page.locator('#nights img').first();
+ test.skip((await page.locator('#nights img').count()) === 0, 'no gallery photos in this build');
+
+ await img.scrollIntoViewIfNeeded();
+ // currentSrc is the candidate the browser actually chose for this viewport.
+ await expect.poll(() => img.evaluate((el: HTMLImageElement) => el.currentSrc)).not.toBe('');
+
+ const chosen = await img.evaluate((el: HTMLImageElement) => el.currentSrc);
+ expect(chosen, `a 390px viewport should not pull the 1200px canonical (got ${chosen})`).toMatch(
+ /-(400|800)\.webp$/,
+ );
+ });
+});
diff --git a/e2e/mobile-motion.spec.ts b/e2e/mobile-motion.spec.ts
new file mode 100644
index 0000000..d9c81d1
--- /dev/null
+++ b/e2e/mobile-motion.spec.ts
@@ -0,0 +1,119 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * What the motion system must NOT do on a phone.
+ *
+ * Two moves are gated at `min-width: 1024px` — the pin, and the count-up that
+ * only makes sense while the pin is holding the section still. Every other spec
+ * runs at Desktop Chrome's 1280px, which is above that gate, so nothing there
+ * can tell whether the gate exists at all. This file runs under the
+ * `mobile-chrome` project (see playwright.config.ts) for exactly that reason.
+ *
+ * The failure this guards against is not cosmetic: a count-up on a band that
+ * slides past at scroll speed is not read, it is a flicker of numbers that are
+ * briefly false, and if the tween is interrupted on the way past they stay
+ * false.
+ */
+
+const NIGHTS = '#nights';
+
+/**
+ * Scroll the room band past the top of the viewport, in steps.
+ *
+ * `scrollIntoViewIfNeeded` is not enough: it stops as soon as the section is
+ * merely visible, which can leave its top edge below the counter trigger's
+ * `top 62px` start and so never fire the count-up at all — making an assertion
+ * that "no count-up ran" pass for the wrong reason. Stepping right through the
+ * band, and then on to the bottom of the document, gives the trigger every
+ * chance to fire. If a count-up still never writes text, it is because the
+ * media gate stopped it.
+ */
+async function scrollPastNights(page: import('@playwright/test').Page) {
+ const top = await page.$eval(NIGHTS, (el) => el.getBoundingClientRect().top + window.scrollY);
+ for (let y = Math.max(0, top - 400); y <= top + 600; y += 100) {
+ await page.evaluate((v) => window.scrollTo(0, v), y);
+ await page.waitForTimeout(80);
+ }
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await page.waitForTimeout(1600);
+}
+
+test.describe('below the 1024px gate', () => {
+ test('the viewport really is under the gate', async ({ page }) => {
+ await page.goto('/');
+ // Guards the guard: if the project's device ever changes to a wide one,
+ // every assertion below would pass vacuously.
+ expect(await page.evaluate(() => window.innerWidth)).toBeLessThan(1024);
+ });
+
+ test('the stat numbers are their true values and never counted up', async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'no-preference' });
+
+ // Sample the counters continuously from first paint. A count-up that ran
+ // and finished would leave the final value in place, so reading only at the
+ // end cannot distinguish "never animated" from "animated and landed" — the
+ // exact reason this regression was invisible. Any value other than the true
+ // one, at any point, means a tween wrote text.
+ await page.addInitScript(() => {
+ const seen: Record> = {};
+ (window as unknown as { __pcvSeen: typeof seen }).__pcvSeen = seen;
+ const sample = () => {
+ document.querySelectorAll('#nights [data-count-to]').forEach((el) => {
+ const key = el.dataset.countTo ?? '';
+ (seen[key] ??= new Set()).add((el.textContent ?? '').trim());
+ });
+ requestAnimationFrame(sample);
+ };
+ requestAnimationFrame(sample);
+ });
+
+ await page.goto('/');
+ await scrollPastNights(page);
+
+ const counters = page.locator('#nights [data-count-to]');
+ await expect(counters).toHaveCount(3);
+
+ // Sets do not survive serialisation, so flatten them in page context.
+ const observed = await page.evaluate(() => {
+ const raw = (window as unknown as { __pcvSeen: Record> }).__pcvSeen;
+ return Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, [...v]]));
+ });
+ expect(Object.keys(observed)).toHaveLength(3);
+
+ for (const [countTo, values] of Object.entries(observed)) {
+ expect(values, `#nights counter ${countTo} showed values other than its true one`).toEqual([
+ countTo,
+ ]);
+ }
+ });
+
+ test('nothing pins — the room band scrolls away like any other', async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'no-preference' });
+ await page.goto('/');
+
+ const from = await page.$eval(NIGHTS, (el) => el.getBoundingClientRect().top + window.scrollY);
+ const tops: number[] = [];
+ for (let y = from - 200; y <= from + 900; y += 100) {
+ await page.evaluate((v) => window.scrollTo(0, v), y);
+ await page.waitForTimeout(120);
+ tops.push(await page.$eval(NIGHTS, (el) => Math.round(el.getBoundingClientRect().top)));
+ }
+
+ // Longest run of samples that barely moved. A pin would hold it for many.
+ let best = 0;
+ let run = 1;
+ for (let i = 1; i < tops.length; i++) {
+ run = Math.abs(tops[i] - tops[i - 1]) <= 2 ? run + 1 : 1;
+ best = Math.max(best, run);
+ }
+ expect(best, 'nothing should hold the room band in place on a phone').toBeLessThan(4);
+
+ // And the pin's layout styling must not be applied either: the band is as
+ // tall as its content needs, not stretched to the viewport.
+ const stretched = await page.$eval(NIGHTS, (el) => {
+ const s = getComputedStyle(el);
+ return { display: s.display, minHeight: s.minHeight };
+ });
+ expect(stretched.display).not.toBe('flex');
+ });
+});
diff --git a/e2e/motion-preferences.spec.ts b/e2e/motion-preferences.spec.ts
index 22ca85c..317ee3c 100644
--- a/e2e/motion-preferences.spec.ts
+++ b/e2e/motion-preferences.spec.ts
@@ -11,31 +11,29 @@ async function setReducedTransparency(page: Page, reduce: boolean) {
test.describe('prefers-reduced-motion', () => {
test('hero entrance animations run normally with no preference', async ({ page }) => {
+ // The hero used to animate in CSS (`animation-name: pcvRise`) on its own
+ // 0.9s clock while the rest of the page arrived on GSAP's. It is now on the
+ // one motion system, so the proof moved with it: `data-pcv-delay` is
+ // written by arrive() and only exists when the system actually wired this
+ // element up. Asserting a real value is a stronger check than asserting a
+ // keyframe name — a stray CSS class could satisfy the old assertion.
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto('/');
const tagline = page.locator('[data-testid="hero-tagline"]');
- await expect(tagline).toHaveCSS('animation-name', 'pcvRise');
+ await expect(tagline).toHaveAttribute('data-pcv-delay', /^\d/);
+ await expect(tagline).toBeVisible();
});
- test('the looping Builder Night track keeps its spine when motion is reduced', async ({
- page,
- }) => {
- // The track draws itself by animating width from 0. Killing the animation
- // without restoring the width would leave a zero-width line and the dots
- // would sit on nothing — so assert the restored width, not just the absence
- // of animation.
+ test('scroll-revealed cards are visible when motion is reduced', async ({ page }) => {
+ // GSAP reveals these by animating from opacity 0. If the reduced-motion
+ // branch ever stops skipping that, the cards stay invisible forever rather
+ // than merely un-animated — so assert they are actually painted.
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/');
- const track = page.locator('.pcv-loop-track');
- await expect(track).toHaveCSS('animation-name', 'none');
-
- const [trackWidth, parentWidth] = await track.evaluate((el) => [
- el.getBoundingClientRect().width,
- (el.parentElement as HTMLElement).getBoundingClientRect().width,
- ]);
- expect(trackWidth).toBeGreaterThan(0);
- expect(Math.abs(trackWidth - parentWidth)).toBeLessThan(2);
+ const card = page.locator('#what .pcv-card').first();
+ await expect(card).toBeVisible();
+ await expect(card).toHaveCSS('opacity', '1');
});
test('hero entrance animations and scroll-smooth are disabled when reduced', async ({ page }) => {
@@ -44,8 +42,15 @@ test.describe('prefers-reduced-motion', () => {
const html = page.locator('html');
await expect(html).toHaveCSS('scroll-behavior', 'auto');
+ // arrive() lives inside the no-preference matchMedia context, so under
+ // `reduce` it never runs and never stamps a delay. Absence of the attribute
+ // is therefore proof the entrance was skipped entirely — and the element
+ // must still be fully painted, which is the failure mode that actually
+ // hurts: an entrance that is skipped by hiding rather than by not running.
const tagline = page.locator('[data-testid="hero-tagline"]');
- await expect(tagline).toHaveCSS('animation-name', 'none');
+ await expect(tagline).not.toHaveAttribute('data-pcv-delay', /.*/);
+ await expect(tagline).toBeVisible();
+ await expect(tagline).toHaveCSS('opacity', '1');
await expect(tagline).toHaveCSS('transform', 'none');
});
});
diff --git a/e2e/motion-system.spec.ts b/e2e/motion-system.spec.ts
new file mode 100644
index 0000000..8367172
--- /dev/null
+++ b/e2e/motion-system.spec.ts
@@ -0,0 +1,131 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * The stagger is humanised (spec §5.2): evenly-spaced entrances read as a
+ * machine, so each element's delay carries a small fixed offset. This asserts
+ * the property that makes it human — the gaps between consecutive delays are
+ * not all identical — and asserts it is deterministic, because a random
+ * version would flicker between renders and could not be tested at all.
+ */
+test.describe('humanised stagger', () => {
+ test('arriving elements in a group get unequal, deterministic delays', async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'no-preference' });
+ await page.goto('/');
+
+ // Scoped to the cards, not to every arriving element: the section's
+ // also arrives and is index 0 of the same group, so an unscoped locator
+ // matches four elements, not three.
+ const cards = page.locator('#what .pcv-card[data-pcv-delay]');
+ await expect(cards).toHaveCount(3);
+
+ const delays = await cards.evaluateAll((els) =>
+ els.map((el) => Number((el as HTMLElement).dataset.pcvDelay)),
+ );
+
+ // Strictly increasing — later elements never arrive before earlier ones.
+ expect(delays).toEqual([...delays].sort((a, b) => a - b));
+ expect(new Set(delays).size).toBe(delays.length);
+
+ // And the gaps are not uniform, which is the entire point.
+ const gaps = delays.slice(1).map((d, i) => Number((d - delays[i]).toFixed(3)));
+ expect(new Set(gaps).size).toBeGreaterThan(1);
+ });
+
+ test('delays are identical across reloads', async ({ page }) => {
+ await page.goto('/');
+ const read = () =>
+ page
+ .locator('#what [data-pcv-arrive][data-pcv-delay]')
+ .evaluateAll((els) => els.map((el) => (el as HTMLElement).dataset.pcvDelay));
+
+ const first = await read();
+ await page.reload();
+ const second = await read();
+
+ expect(second).toEqual(first);
+ });
+
+ test('stagger restarts per section rather than running across the page', async ({ page }) => {
+ await page.goto('/');
+ const firstOf = (sel: string) =>
+ page
+ .locator(`${sel} [data-pcv-arrive][data-pcv-delay]`)
+ .first()
+ .getAttribute('data-pcv-delay');
+
+ expect(await firstOf('#what')).toBe(await firstOf('#build'));
+ });
+});
+
+test.describe('section bands', () => {
+ test('the homepage bands declare their story position', async ({ page }) => {
+ await page.goto('/');
+ const bands = await page
+ .locator('[data-pcv-section]')
+ .evaluateAll((els) => els.map((el) => el.getAttribute('data-pcv-section')));
+
+ expect(bands).toEqual(['door', 'air', 'room', 'work', 'invitation']);
+ });
+
+ test('the story position never leaks into ARIA', async ({ page }) => {
+ await page.goto('/');
+ // None of the five band names — door, air, room, work, invitation — is a
+ // valid ARIA role, so `band` reaching the DOM as `role` would be a genuine
+ // accessibility defect. Asserted as "no section on this page carries an ARIA
+ // role at all" rather than as a list of the band names: `Section` now
+ // spreads arbitrary attributes through, and none of these sections needs
+ // one, so any `role` here is either the leak or a mistake.
+ await expect(page.locator('section[role]')).toHaveCount(0);
+ });
+
+ test('exactly one section holds, per spec §5.1', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('[data-pcv-hold]')).toHaveCount(1);
+ });
+});
+
+/**
+ * The hero is the one place the stagger system is deliberately broken.
+ *
+ * Its headline is "You're not the only one in the room." An evenly-staggered
+ * entrance under that sentence is four elements arriving alone, one at a time —
+ * the motion arguing against the copy. So the hero declares ordinals instead of
+ * relying on source order: 0, 1, [3 skipped], 3, 3. The place, then you, then a
+ * held beat, then the tagline and both buttons together.
+ *
+ * Both halves are asserted because both are easy to erase without noticing. Drop
+ * the shared ordinal and the unison silently becomes a stagger; renumber to
+ * close the gap and the beat disappears.
+ */
+test.describe('hero sequence', () => {
+ const delays = (page: import('@playwright/test').Page) =>
+ page
+ .locator('#top [data-pcv-arrive][data-pcv-delay]')
+ .evaluateAll((els) => els.map((el) => Number((el as HTMLElement).dataset.pcvDelay)));
+
+ test('the tagline and the buttons arrive in unison, not in sequence', async ({ page }) => {
+ await page.goto('/');
+ const [, , tagline, ctas] = await delays(page);
+ expect(tagline).toBe(ctas);
+ });
+
+ test('a beat separates the headline from everything that follows', async ({ page }) => {
+ await page.goto('/');
+ const [eyebrow, headline, tagline] = await delays(page);
+
+ const firstGap = headline - eyebrow;
+ const beat = tagline - headline;
+
+ // The pause after the headline is the longest on the page — that is the
+ // loneliness the sentence is about, and it has to be felt as a hold rather
+ // than read as one more even step.
+ expect(beat).toBeGreaterThan(firstGap * 1.4);
+ });
+
+ test('the hero runs on the motion system, not on a CSS keyframe', async ({ page }) => {
+ await page.goto('/');
+ // Four elements wired by arrive(), and no survivor of the old CSS entrance.
+ await expect(page.locator('#top [data-pcv-arrive][data-pcv-delay]')).toHaveCount(4);
+ await expect(page.locator('#top .pcv-enter-rise')).toHaveCount(0);
+ });
+});
diff --git a/e2e/no-js.spec.ts b/e2e/no-js.spec.ts
new file mode 100644
index 0000000..8cd8480
--- /dev/null
+++ b/e2e/no-js.spec.ts
@@ -0,0 +1,49 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * The redesign earns its depth from motion, which means the site's structure
+ * must never *depend* on the motion running. If a bundle fails to load, a CSP
+ * change blocks it, or a browser is simply slow, the page has to degrade to a
+ * flat, complete, readable document — not a blank one.
+ *
+ * What this file proves is narrow and worth stating exactly: with scripts
+ * disabled, the document's own resting state is already complete and readable —
+ * no stylesheet pre-hides content in anticipation of a script arriving to
+ * reveal it, and the stat numbers in the markup are already the true ones.
+ *
+ * It cannot say anything about how the tweens are written. With JavaScript off,
+ * neither a `from()` nor a `to()` tween runs, so both pass here identically. The
+ * guard against a `to()` tween stranding elements at opacity 0 is the
+ * scripts-enabled suite, not this one.
+ */
+test.describe('with JavaScript disabled', () => {
+ test.use({ javaScriptEnabled: false });
+
+ test('every homepage band renders and is readable', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page.locator('[data-pcv-section]')).toHaveCount(5);
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+
+ for (const el of await page.locator('#what .pcv-card').all()) {
+ await expect(el).toBeVisible();
+ await expect(el).toHaveCSS('opacity', '1');
+ }
+ });
+
+ test('the stat numbers are already true before any script runs', async ({ page }) => {
+ await page.goto('/');
+ const counters = page.locator('#nights [data-count-to]');
+ await expect(counters).toHaveCount(3);
+ for (const el of await counters.all()) {
+ expect((await el.textContent())?.trim()).toBe(await el.getAttribute('data-count-to'));
+ }
+ });
+
+ test('the primary call to action is present and clickable', async ({ page }) => {
+ await page.goto('/');
+ const cta = page.locator('#top a').first();
+ await expect(cta).toBeVisible();
+ await expect(cta).toHaveAttribute('href', /.+/);
+ });
+});
diff --git a/e2e/scroll-choreography.spec.ts b/e2e/scroll-choreography.spec.ts
new file mode 100644
index 0000000..f21571c
--- /dev/null
+++ b/e2e/scroll-choreography.spec.ts
@@ -0,0 +1,86 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * The effect that justifies carrying GSAP at all.
+ *
+ * Pinning is the thing IntersectionObserver cannot do: an observer fires when
+ * an element *crosses* a threshold, once, and cannot hold a section in place
+ * against the distance scrolled. If this file is deleted, the library should be
+ * deleted with it.
+ */
+
+async function scrollTo(page: import('@playwright/test').Page, y: number, settle = 120) {
+ await page.evaluate((v) => window.scrollTo(0, v), y);
+ await page.waitForTimeout(settle);
+}
+
+/**
+ * Sample the section's viewport position across a scroll sweep. Pinning is
+ * asserted from what the visitor can see — the section stops moving while the
+ * page keeps scrolling — rather than by reaching into ScrollTrigger's internals,
+ * which an ES module does not expose on window anyway.
+ */
+async function sweep(page: import('@playwright/test').Page) {
+ const from = await page.$eval('#nights', (el) => el.getBoundingClientRect().top + window.scrollY);
+ const samples: { y: number; top: number }[] = [];
+ for (let y = from - 200; y <= from + 1200; y += 100) {
+ await scrollTo(page, y, 120);
+ samples.push({
+ y,
+ top: await page.$eval('#nights', (el) => Math.round(el.getBoundingClientRect().top)),
+ });
+ }
+ return samples;
+}
+
+/** Longest run of consecutive samples whose top stayed within 2px. */
+function longestHold(samples: { top: number }[]) {
+ let best = 0;
+ let run = 1;
+ for (let i = 1; i < samples.length; i++) {
+ run = Math.abs(samples[i].top - samples[i - 1].top) <= 2 ? run + 1 : 1;
+ best = Math.max(best, run);
+ }
+ return best;
+}
+
+test.describe('pinned room section', () => {
+ test('holds still while the page keeps scrolling, then releases', async ({ page }) => {
+ await page.goto('/');
+ const samples = await sweep(page);
+
+ expect(
+ longestHold(samples),
+ 'section should stay put across several scroll steps',
+ ).toBeGreaterThanOrEqual(4);
+
+ // And it is a pin, not a permanently stuck element: it moves again after.
+ const first = samples[0].top;
+ const last = samples[samples.length - 1].top;
+ expect(last).toBeLessThan(first);
+ });
+
+ test('counters land on the real values, not wherever the tween stopped', async ({ page }) => {
+ await page.goto('/');
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await page.waitForTimeout(1600);
+
+ const counters = page.locator('#nights [data-count-to]');
+ await expect(counters).toHaveCount(3);
+ for (const el of await counters.all()) {
+ expect((await el.textContent())?.trim()).toBe(await el.getAttribute('data-count-to'));
+ }
+ });
+
+ test('nothing is pinned and numbers are final when motion is reduced', async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.goto('/');
+
+ const samples = await sweep(page);
+ expect(longestHold(samples), 'nothing should hold the section in place').toBeLessThan(4);
+
+ for (const el of await page.locator('#nights [data-count-to]').all()) {
+ expect((await el.textContent())?.trim()).toBe(await el.getAttribute('data-count-to'));
+ }
+ });
+});
diff --git a/e2e/scroll-progress.spec.ts b/e2e/scroll-progress.spec.ts
new file mode 100644
index 0000000..fafa08e
--- /dev/null
+++ b/e2e/scroll-progress.spec.ts
@@ -0,0 +1,76 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * The rule under the header reports scroll position on every page.
+ *
+ * It is asserted on the *computed transform* rather than an inline style string,
+ * so it still passes if the implementation moves to a scroll-driven CSS
+ * animation or anything else that ends up as a real scaleX on the element.
+ *
+ * Deliberately not gated on prefers-reduced-motion: the rule reports where the
+ * visitor already is rather than animating on its own clock, so it must keep
+ * working when motion is off. motion-preferences.spec.ts covers what does stop.
+ */
+function scaleX(transform: string): number {
+ if (transform === 'none') return 1;
+ const parts = transform.match(/matrix\(([^)]+)\)/);
+ if (!parts) throw new Error(`unexpected transform "${transform}"`);
+ return Number(parts[1].split(',')[0]);
+}
+
+async function ruleScale(page: import('@playwright/test').Page): Promise {
+ const transform = await page
+ .locator('[data-pcv-progress]')
+ .evaluate((el) => getComputedStyle(el).transform);
+ return scaleX(transform);
+}
+
+test.describe('header scroll progress rule', () => {
+ test('starts empty and fills as the homepage is scrolled', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('[data-pcv-progress]')).toBeAttached();
+
+ expect(await ruleScale(page), 'rule should start empty at the top of the page').toBeLessThan(
+ 0.02,
+ );
+
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
+ // The rule updates in a rAF callback, so wait for the value rather than a timeout.
+ await expect
+ .poll(() => ruleScale(page), { message: 'rule should be full at the bottom' })
+ .toBeGreaterThan(0.98);
+
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await expect
+ .poll(() => ruleScale(page), { message: 'rule should reverse back to empty' })
+ .toBeLessThan(0.02);
+ });
+
+ test('reports zero, not full, on a page too short to scroll', async ({ page }) => {
+ /*
+ * The viewport is made taller than the document rather than trusting some
+ * page to be short: every route scrolls at a normal desktop height, so
+ * picking one and skipping when it does not left this case uncovered.
+ */
+ await page.setViewportSize({ width: 1280, height: 4000 });
+ await page.goto('/contact');
+ await expect
+ .poll(() => page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight), {
+ message: 'viewport should be taller than the document for this assertion to mean anything',
+ })
+ .toBeLessThanOrEqual(0);
+
+ /*
+ * The failure this pins: `scrollY / 0` is NaN and `Math.min(1, NaN)` is NaN,
+ * which lands in the transform as an invalid value. An earlier shape of this
+ * guard read `scrollable > 0 ? … : 1` and drew a full rule on a page the
+ * visitor had not moved through at all.
+ */
+ expect(await ruleScale(page)).toBeLessThan(0.02);
+ });
+
+ test('is present on a page that is not the homepage', async ({ page }) => {
+ await page.goto('/about');
+ await expect(page.locator('[data-pcv-progress]')).toBeAttached();
+ });
+});
diff --git a/e2e/stat-line.spec.ts b/e2e/stat-line.spec.ts
new file mode 100644
index 0000000..d6eed63
--- /dev/null
+++ b/e2e/stat-line.spec.ts
@@ -0,0 +1,44 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * The stat line ("425 members · 13 nights held · 6 things shipped") appears
+ * twice on the homepage. It used to be one interpolated string, which made the
+ * two copies impossible to disagree — but also made its numbers impossible to
+ * animate individually, since there were no elements to animate.
+ *
+ * These tests hold both properties at once: the copies still cannot drift, and
+ * each number is now separately addressable.
+ */
+test.describe('stat line', () => {
+ test('both copies render the same numbers', async ({ page }) => {
+ await page.goto('/');
+ const lines = page.locator('[data-testid="stat-line"]');
+ await expect(lines).toHaveCount(2);
+
+ const [first, second] = await lines.allTextContents();
+ expect(first.replace(/\s+/g, ' ').trim()).toBe(second.replace(/\s+/g, ' ').trim());
+ });
+
+ test('each number is individually addressable for animation', async ({ page }) => {
+ await page.goto('/');
+ const counts = page.locator('[data-testid="stat-line"]').first().locator('[data-count-to]');
+ await expect(counts).toHaveCount(3);
+
+ for (const el of await counts.all()) {
+ const target = Number(await el.getAttribute('data-count-to'));
+ expect(Number.isFinite(target)).toBe(true);
+ expect(target).toBeGreaterThanOrEqual(0);
+ // The rendered text must already be the final value: with no JavaScript,
+ // or before any counter runs, the claim on the page has to be true.
+ expect((await el.textContent())?.trim()).toBe(String(target));
+ }
+ });
+
+ test('the line still reads as a sentence, not a list of bare numbers', async ({ page }) => {
+ await page.goto('/');
+ const text = (await page.locator('[data-testid="stat-line"]').first().textContent()) ?? '';
+ expect(text.replace(/\s+/g, ' ').trim()).toMatch(
+ /^\d+ members · \d+ nights? held · \d+ things? shipped$/,
+ );
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index b29a7db..8040b7e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@vercel/analytics": "^2.0.1",
"alpinejs": "^3.15.12",
"astro": "^7.1.1",
+ "gsap": "^3.15.0",
"tailwindcss": "^3.4.19",
"typescript": "^5.6.2"
},
@@ -4422,6 +4423,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/gsap": {
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
+ "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
+ "license": "Standard 'no charge' license: https://gsap.com/standard-license."
+ },
"node_modules/h3": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz",
diff --git a/package.json b/package.json
index e8f1170..da7e387 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"@vercel/analytics": "^2.0.1",
"alpinejs": "^3.15.12",
"astro": "^7.1.1",
+ "gsap": "^3.15.0",
"tailwindcss": "^3.4.19",
"typescript": "^5.6.2"
},
diff --git a/playwright.config.ts b/playwright.config.ts
index ab11b0b..fdef95a 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -3,7 +3,10 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
// csp.spec.ts needs the vercel.json headers, which `astro preview` does not
- // serve — it runs under playwright.csp.config.ts instead.
+ // serve — it runs under playwright.csp.config.ts instead. Repeated on every
+ // project below, not stated once here: a project's own testIgnore *replaces*
+ // this one rather than adding to it, so a project that sets it must carry the
+ // exclusion too or csp.spec.ts silently rejoins the run and fails.
testIgnore: 'csp.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
@@ -17,6 +20,22 @@ export default defineConfig({
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
+ // mobile-*.spec.ts asserts what must NOT happen below 1024px. At this
+ // project's 1280px it would assert the opposite of the truth.
+ testIgnore: ['csp.spec.ts', 'mobile-*.spec.ts'],
+ },
+ {
+ // Everything else runs only at Desktop Chrome's 1280px, which is how a
+ // count-up leaking onto phones went unnoticed. The pin and the counters
+ // are gated at min-width 1024px, so that gate needs a viewport under it.
+ //
+ // Scoped rather than a second run of the whole suite: several specs
+ // (scroll-choreography above all) encode desktop-only intent and assert
+ // the pin holds, which below 1024px is correctly false. Widening them to
+ // pass at both sizes would delete the assertion that matters.
+ name: 'mobile-chrome',
+ use: { ...devices['Pixel 5'] },
+ testMatch: 'mobile-*.spec.ts',
},
],
webServer: {
diff --git a/public/images/gallery/README.md b/public/images/gallery/README.md
new file mode 100644
index 0000000..85bb509
--- /dev/null
+++ b/public/images/gallery/README.md
@@ -0,0 +1,32 @@
+# Event photos
+
+Image files for the homepage's "The room" mosaic go here.
+
+Dropping a file in this folder does nothing on its own. Each photo also needs an
+entry in `src/content/gallery/` naming the event, the date, and its alt text —
+that entry is what puts it on the page. Full instructions and the field table:
+[`docs/adding-content.md`](../../../docs/adding-content.md#adding-event-photos).
+
+```
+public/images/gallery/patch-002-pairing.webp <- the file
+src/content/gallery/patch-002-pairing.json <- what makes it appear
+```
+
+## Before you add one
+
+The mosaic renders under the headline **"Every photo is a real Thursday."** Two
+things follow from that, and neither is a formality:
+
+- **It has to be one of our nights.** A photo from a conference we attended, a
+ partner's production shoot, or a stock library makes the page assert something
+ untrue in the one section built to prove the sentence above it. If the mosaic
+ is empty the homepage says "Come see the room." instead and nothing is
+ claimed — that is the correct state until real photos exist, not a gap to
+ fill.
+- **The people in it have to be okay with it.** This is a public homepage. There
+ is no way to un-publish a face someone finds later.
+
+## Format
+
+WebP or JPEG, roughly 1600px on the long edge. The grid crops to fill, so put
+the subject near the centre. Keep files under ~300KB — these load on phones.
diff --git a/public/images/gallery/builder-night-presenting-400.webp b/public/images/gallery/builder-night-presenting-400.webp
new file mode 100644
index 0000000..96afb5b
Binary files /dev/null and b/public/images/gallery/builder-night-presenting-400.webp differ
diff --git a/public/images/gallery/builder-night-presenting-800.webp b/public/images/gallery/builder-night-presenting-800.webp
new file mode 100644
index 0000000..89c732c
Binary files /dev/null and b/public/images/gallery/builder-night-presenting-800.webp differ
diff --git a/public/images/gallery/builder-night-presenting.webp b/public/images/gallery/builder-night-presenting.webp
new file mode 100644
index 0000000..7049197
Binary files /dev/null and b/public/images/gallery/builder-night-presenting.webp differ
diff --git a/public/images/gallery/collab-lab-presenting-400.webp b/public/images/gallery/collab-lab-presenting-400.webp
new file mode 100644
index 0000000..f3201b4
Binary files /dev/null and b/public/images/gallery/collab-lab-presenting-400.webp differ
diff --git a/public/images/gallery/collab-lab-presenting-800.webp b/public/images/gallery/collab-lab-presenting-800.webp
new file mode 100644
index 0000000..e06c48f
Binary files /dev/null and b/public/images/gallery/collab-lab-presenting-800.webp differ
diff --git a/public/images/gallery/collab-lab-presenting.webp b/public/images/gallery/collab-lab-presenting.webp
new file mode 100644
index 0000000..4a5c1ce
Binary files /dev/null and b/public/images/gallery/collab-lab-presenting.webp differ
diff --git a/public/images/gallery/kickback-game-night-group-400.webp b/public/images/gallery/kickback-game-night-group-400.webp
new file mode 100644
index 0000000..96c6de3
Binary files /dev/null and b/public/images/gallery/kickback-game-night-group-400.webp differ
diff --git a/public/images/gallery/kickback-game-night-group-800.webp b/public/images/gallery/kickback-game-night-group-800.webp
new file mode 100644
index 0000000..3b1965a
Binary files /dev/null and b/public/images/gallery/kickback-game-night-group-800.webp differ
diff --git a/public/images/gallery/kickback-game-night-group.webp b/public/images/gallery/kickback-game-night-group.webp
new file mode 100644
index 0000000..70a7fac
Binary files /dev/null and b/public/images/gallery/kickback-game-night-group.webp differ
diff --git a/public/images/gallery/patch-001-pairing-400.webp b/public/images/gallery/patch-001-pairing-400.webp
new file mode 100644
index 0000000..28cd29c
Binary files /dev/null and b/public/images/gallery/patch-001-pairing-400.webp differ
diff --git a/public/images/gallery/patch-001-pairing-800.webp b/public/images/gallery/patch-001-pairing-800.webp
new file mode 100644
index 0000000..2b544fb
Binary files /dev/null and b/public/images/gallery/patch-001-pairing-800.webp differ
diff --git a/public/images/gallery/patch-001-pairing.webp b/public/images/gallery/patch-001-pairing.webp
new file mode 100644
index 0000000..a687e52
Binary files /dev/null and b/public/images/gallery/patch-001-pairing.webp differ
diff --git a/public/images/gallery/patch-001-tables-400.webp b/public/images/gallery/patch-001-tables-400.webp
new file mode 100644
index 0000000..a93cb36
Binary files /dev/null and b/public/images/gallery/patch-001-tables-400.webp differ
diff --git a/public/images/gallery/patch-001-tables-800.webp b/public/images/gallery/patch-001-tables-800.webp
new file mode 100644
index 0000000..eb18f17
Binary files /dev/null and b/public/images/gallery/patch-001-tables-800.webp differ
diff --git a/public/images/gallery/patch-001-tables.webp b/public/images/gallery/patch-001-tables.webp
new file mode 100644
index 0000000..eb35fa0
Binary files /dev/null and b/public/images/gallery/patch-001-tables.webp differ
diff --git a/public/images/gallery/patch-001-together-400.webp b/public/images/gallery/patch-001-together-400.webp
new file mode 100644
index 0000000..d2769e5
Binary files /dev/null and b/public/images/gallery/patch-001-together-400.webp differ
diff --git a/public/images/gallery/patch-001-together-800.webp b/public/images/gallery/patch-001-together-800.webp
new file mode 100644
index 0000000..94b9363
Binary files /dev/null and b/public/images/gallery/patch-001-together-800.webp differ
diff --git a/public/images/gallery/patch-001-together.webp b/public/images/gallery/patch-001-together.webp
new file mode 100644
index 0000000..de75bb5
Binary files /dev/null and b/public/images/gallery/patch-001-together.webp differ
diff --git a/public/js/scroll-progress.js b/public/js/scroll-progress.js
new file mode 100644
index 0000000..5a1ffab
--- /dev/null
+++ b/public/js/scroll-progress.js
@@ -0,0 +1,55 @@
+/**
+ * The rule under the header, reporting how far down the document you are.
+ *
+ * It is a position indicator, not decoration, so it is deliberately NOT gated on
+ * `prefers-reduced-motion`: it reports the scroll the visitor is already
+ * performing rather than animating on a clock of its own. The fill carries no
+ * CSS transition for the same reason — a transition would make it lag the thing
+ * it is reporting.
+ *
+ * WHY THIS LIVES IN public/ AS PLAIN JS, loaded with `is:inline`:
+ *
+ * The same reason as event-freshness.js, and it was learned here the hard way.
+ * As `src/scripts/scroll-progress.ts` imported from a component
+
+
+