diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index d2cdde6..d7fe39b 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -10,8 +10,30 @@ permissions: contents: read jobs: - lighthouse: + # The 6 pages are split across two shards that run on separate runners. + # Lighthouse measures timing under CPU/network throttling, so its numbers are + # only accurate when a single Lighthouse instance has the machine to itself; + # never run the URLs concurrently on one runner. Sharding across isolated + # runner VMs keeps each measurement contention-free while roughly halving + # wall-clock time. Assertions/settings stay in lighthouserc.json; each shard + # overrides only the URL list via repeated --collect.url flags. + lighthouse-shard: + name: Lighthouse shard ${{ matrix.name }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: A + urls: >- + --collect.url=http://localhost:8299/index.html + --collect.url=http://localhost:8299/work.html + --collect.url=http://localhost:8299/ideas.html + - name: B + urls: >- + --collect.url=http://localhost:8299/speak.html + --collect.url=http://localhost:8299/about.html + --collect.url=http://localhost:8299/contact.html steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -26,4 +48,21 @@ jobs: - name: Build site (Astro -> dist/) run: npm run build - name: Run Lighthouse CI (asserts performance/SEO/a11y budgets) - run: npx lhci autorun + run: npx lhci autorun ${{ matrix.urls }} + + # Single required-status-friendly gate: stays green only if every shard + # passed. Keep this job named "lighthouse" so branch protection that requires + # a "lighthouse" check keeps working after the split. + lighthouse: + name: lighthouse + needs: lighthouse-shard + if: always() + runs-on: ubuntu-latest + steps: + - name: Require all Lighthouse shards to pass + run: | + if [ "${{ needs.lighthouse-shard.result }}" != "success" ]; then + echo "One or more Lighthouse shards failed." + exit 1 + fi + echo "All Lighthouse shards passed." diff --git a/CLAUDE.md b/CLAUDE.md index ac1c4cf..8a05b73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,8 +28,13 @@ else, end to end, including getting it live. Follow this loop for every change, without being asked for each step: -1. **Work on a branch.** Never push to `main` directly: it is protected and rejects - direct pushes. Changes reach it only through a pull request. +1. **Work on a branch, always cut from the latest `main`.** Never push to `main` + directly: it is protected and rejects direct pushes. Changes reach it only through a + pull request. Keeping current with `main` is your job, never the owner's to ask + about: `git fetch origin main` before you start, and before you merge, rebase your + branch onto the latest `main` (`git rebase origin/main`) and resolve any conflicts + yourself so the PR always merges cleanly. If `main` moves while a PR is open, rebase + again rather than letting it go stale. 2. **Validate thoroughly before showing it.** `npm run build`, then `python3 tools/validate_site.py dist` and `python3 tools/seo_check.py dist`; both must pass clean. Re-run until green. For anything visible, look at the built page, @@ -80,9 +85,11 @@ This depends on two things staying set up (see [docs/hosted-admin.md](docs/hoste - **The design system and behavior are unchanged and self-contained.** CSS in `public/assets/css/style.css`; behavior in `public/assets/js/main.js` (mobile nav, scroll-reveal, gallery lightbox, contact-form FormSubmit AJAX handler, footer - year). No external fonts, scripts, or CDNs — - it must keep working offline and under a strict CSP. Pages stay legible with - JavaScript disabled. Everything in `public/` ships verbatim into `dist/`. + year). No external fonts, scripts, or CDNs, with **one sanctioned exception: + Google Analytics** (see "Analytics" below). Everything else must keep working + offline and CSP-safe. Pages stay legible with JavaScript disabled, and they + render fine even when the GA request is blocked. Everything in `public/` ships + verbatim into `dist/`. - **Keep links relative.** Every internal link and asset reference is relative (`work`, `assets/css/style.css`, `favicon.svg`) so the site works under both the GitHub Pages subpath and the apex domain. `base` stays `/`; do not switch to @@ -170,6 +177,45 @@ consistent. Editable per page: `seo.title`, `seo.description`, `seo.ogTitle`, Everything is keyed to `https://quentinfears.com` as the single source of truth. +## Analytics + +The site uses **Google Analytics 4** (`gtag.js`, stream `Quentin Fears`, +measurement ID `G-1WEVVZN8TV`). This is the **one deliberate exception** to the +no-external-scripts rule: an owner-approved third-party tag, wired so it stays +contained. + +- **Single source of truth:** `GA_MEASUREMENT_ID` in + [src/lib/content.ts](src/lib/content.ts). To change the ID, update it there + *and* `GA_MEASUREMENT_ID` in [tools/seo_check.py](tools/seo_check.py). +- **Emitted by [BaseLayout.astro](src/layouts/BaseLayout.astro), not per page,** + so every page (current and future) gets the tag automatically. Do not paste + the snippet into individual pages. +- **Production only.** The tag is gated behind `import.meta.env.PROD`, so + `npm run build` (and `build:admin`) include it but `npm run dev` does not, and + local editing never sends hits to the live property. +- **CI enforces it.** `tools/seo_check.py` fails the build if any page ships + without both halves of the snippet (the `gtag/js?id=…` loader and the + `gtag('config', …)` call). New pages/layouts must keep analytics wired; do not + remove or gut the tag. This is the intended behaviour even though it loads a + CDN script, so do not "fix" it back out to restore the offline guarantee. +- **Lazy-loaded on purpose.** The `gtag.js` library is injected only after the + page paints (`load` → `requestIdleCallback`), so it stays off the main thread + during render and the strict `total-blocking-time ≤ 200ms` gate in + [lighthouserc.json](lighthouserc.json) still passes. The `gtag('config', …)` call + queues in `dataLayer` immediately and fires when the library arrives, so no + pageview is lost. Do not "simplify" this back to an eager ` + {gaEnabled && }