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 + + + diff --git a/src/pages/index.astro b/src/pages/index.astro index 6c66a36..46cfeff 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,152 +1,411 @@ --- import BaseLayout from '../layouts/BaseLayout.astro'; import EventBar from '../components/EventBar.astro'; -import BuilderNightTrack from '../components/BuilderNightTrack.astro'; -import { getCommunityData } from '../lib/community'; -import { community } from '../config'; +import Section from '../components/Section.astro'; +import StatLine from '../components/StatLine.astro'; +import { getCommunityData, formatEventDate } from '../lib/community'; +import { community, links } from '../config'; import { getCollection } from 'astro:content'; const { nextEvent, nightsHeld, thingsShipped } = await getCommunityData(); /** - * The design calls for one full-bleed photo of a Builder Night — warm, faces. - * There is no stand-in for that: the banner artwork is brand illustration, not a - * room full of people, and cropping it full-bleed would read as a placeholder. - * So the section renders only when a real photo exists in the gallery - * collection, and is absent rather than wrong until one is added. + * The design's five-photo mosaic sits under the headline "Every photo is a real + * Thursday" — a claim only the photos themselves can honour. In the design they + * are empty placeholders, and the gallery collection is currently + * empty, so the whole section renders only once real photos exist. Shipping the + * claim over placeholder boxes is the failure mode of #98, at larger scale. */ -const photo = (await getCollection('gallery')).sort( - (a, b) => b.data.date.getTime() - a.data.date.getTime(), -)[0]; +const photos = (await getCollection('gallery')) + .sort((a, b) => b.data.date.getTime() - a.data.date.getTime()) + .slice(0, 5); -const stats = [ - { value: community.memberCount, label: 'people, not users' }, - { value: nightsHeld, label: nightsHeld === 1 ? 'night held' : 'nights held' }, +/** + * Responsive sources for the mosaic. + * + * The grid renders each tile at roughly 194px wide on a phone and 425px on a + * desktop, but the files are 1200px — so without this a phone downloaded 674KB + * to draw thumbnails, about thirty times the pixels it can show. + * + * The ladder is fixed rather than derived, so every width in the srcset is a + * constant this file can state truthfully. Every gallery image is normalised to + * a 1200px canonical width with `-400` and `-800` siblings beside it; + * e2e/gallery-images.spec.ts fails if any sibling is missing, so the convention + * cannot rot silently into 404s. + * + * Not `astro:assets`, which would do all of this automatically: its image + * service needs `sharp`, and adding a native binary dependency to a repo built + * for first-time contributors is a bigger decision than this markup. + */ +const LADDER = [400, 800] as const; +const CANONICAL_WIDTH = 1200; + +function srcset(image: string): string { + const base = image.replace(/\.webp$/, ''); + return [...LADDER.map((w) => `${base}-${w}.webp ${w}w`), `${image} ${CANONICAL_WIDTH}w`].join( + ', ', + ); +} + +/* Two columns below `lg`, three above — matching the grid this feeds. */ +const PHOTO_SIZES = '(min-width: 1024px) 33vw, 50vw'; + +/** + * The design showed three repo cards, one of which (`p-l-otHole`) does not exist + * in the org. These are the real ones. Issue counts are deliberately not shown: + * the design hardcoded them and they were already wrong (it claimed 7 and 3 for + * repos that have 11 and 4), and a number frozen at build time is the same bug + * as #94. The link goes to the live filtered list instead, which is never stale. + */ +const repos = [ + { + name: 'This website', + blurb: 'The page you are on. Open source, built at Collab Labs, reviewed by whoever shows up.', + stack: ['Astro', 'Tailwind', 'Alpine.js'], + href: `${links.github.website}/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22`, + }, { - value: thingsShipped, - label: thingsShipped === 1 ? 'thing shipped, so far' : 'things shipped, so far', + name: 'Community Handbook', + blurb: + 'Our philosophy, values, and governance in plain markdown. Disagree with a line? Open a PR.', + stack: ['Markdown', 'GitHub'], + href: `${links.github.org}/community-handbook/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22`, + }, + { + name: 'djVisualizer', + blurb: + 'Real-time audio visualizer and effects processor for DJs, built with the Web Audio API.', + stack: ['JavaScript', 'Web Audio'], + href: `${links.github.org}/djVisualizer/issues`, + }, +]; + +/** + * "Vision Lab" and its "Indy Hall" venue in the design have no source anywhere in + * this repo or the org. Builder Nights and Collab Labs are real and documented; + * workshops are referenced on /join and in the blog. The third card is that, + * rather than a night we do not run. Counts on the first card come from live + * data; the others carry no number, because there is no source for one. + */ +const tracks = [ + { + tag: 'Builder Night', + tagClass: 'bg-brand-pink text-white', + title: 'Bring a thing. Leave with it further along.', + body: 'Every other Thursday. Two hours of heads-down work in a room of people doing the same, plus whoever wants to pair.', + meta: `${nightsHeld} ${nightsHeld === 1 ? 'night' : 'nights'} held`, + }, + { + tag: 'Collab Lab', + tagClass: 'bg-brand-purple text-white', + title: 'Ship something with three strangers.', + body: 'Designers and developers on one repo for an afternoon. This website was built here, in public, by people who met that day.', + meta: `${thingsShipped} ${thingsShipped === 1 ? 'thing' : 'things'} shipped`, + }, + { + tag: 'Workshops', + tagClass: 'bg-brand-yellow text-brand-dark', + title: 'Learn the thing you keep meaning to learn.', + body: 'Hands-on sessions and career support, free and open to every skill level. No prerequisite beyond showing up.', + meta: 'Free · all levels', }, ]; --- - - + + { + /* No
here: BaseLayout already wraps the page slot in +
, and nesting a second one reports two main + landmarks. Same reason this page renders no header or footer of its own, + even though the source design carried both. */ + }
-
-

- - Come as you are. - - - - Leave with a flock. - - -

-

- A tech community in Philly for the people who were never handed a way in. Free, in person, - and open before you feel ready. -

-
- - -
-

- One night. Three hours. Something exists that didn't. -

- -
+
+
+ { + /* Ordinals, not source order: 0, 1, [beat], 3, 3. + The place arrives, then you, then a pause longer than any other on + the page, then the tagline and both buttons together. Everywhere + else on this site a group staggers; this is the one that lands in + unison, because the headline above it is about not being alone and + a single-file entrance would be arguing the opposite. */ + } +
+ Philadelphia · creativity × technology +
+

+ You're not the only one in the room. +

+

+ Designers, artists, engineers, and folks three weeks into a tutorial. One Philly community + — free, no application, no one checking credentials at the door. +

+ +
+
+ { - photo && ( -
- {photo.data.alt} + nextEvent && ( +
+
+ {/* + The design anchored this bar with four overlapping member avatars and + a "+422" bubble. We have no member headshots and no consent to put + faces on the homepage, and three placeholder circles under a social + -proof claim is the failure mode of #98 — so the bubble carries the + claim on its own. brand-dark on brand-purple is 5.63:1. + + The number is the same hand-maintained one StatLine prints twice + below; it comes from config so all three can never disagree. + */} +
+ {community.memberCount} members +
+
+
+ Next night · {formatEventDate(nextEvent.start)} +
+
+ {nextEvent.title} + {nextEvent.venue && ` · ${nextEvent.venue}`} +
+
+ + RSVP + +
) } - -
-
-

- Free, forever, for us. -

-

- Builder Nights, workshops, and career labs. You show up, you pair with someone, you leave - having made a thing. No application, no fee, no prerequisite. -

- +
+
+
+
+ What actually happens +
+

+ Show up before you feel ready. +

+

+ Three kinds of nights. All free, all in Philly, all fine to attend alone. +

+
+
+ { + tracks.map((track) => ( +
+ {track.tag} +

+ {track.title} +

+

{track.body}

+
+ {track.meta} +
+
+ )) + } +
+
+
+ + +
+ { + photos.length > 0 && ( +
+ {photos.map((photo, i) => ( +
+ {photo.data.alt} +
+ ))} +
+ ) + } +
+
+ + +
+
+
+
+ Open source +
+

+ We learn by shipping. +

+

+ Every repo keeps issues sized for a first pull request. Design work counts as a + contribution here. +

+
+
+ { + repos.map((repo) => ( +
+

{repo.name}

+

{repo.blurb}

+
+ {repo.stack.map((tech) => ( + {tech} + ))} +
+ + Good first issues → + +
+ )) + } +
-
-

+ + +
+
+ The PhilaCon Valley pigeon, perched on a keyboard in front of the Philadelphia skyline +

- Paid work that pays for it. -

-

- Design, apps, and software for Philly nonprofits and community orgs. The fee funds the - free side — and the people who build it came up through it. + A pigeon flies alone. A flock goes much further. +

+

+ You don't have to earn your place. Come to one night and see — nobody is going to ask for + your credentials at the door.

- Start a project → + Find your first night → +
-
- - -
- { - stats.map((stat) => ( -
-
- {stat.value} -
-
- {stat.label} -
-
- )) - } -
+
- + { + /* Clears the fixed bottom bar so the footer's last line is never trapped + under it. Carries the same expiry as the bar. */ + } { nextEvent && (