From 5043016599ce982f67a1fc74f87089b5080186c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:33:01 +0000 Subject: [PATCH 1/3] Wire Google Analytics 4 into the site Add the GA4 gtag.js tag (measurement ID G-1WEVVZN8TV) via BaseLayout so every current and future page is tracked, gated to production builds so local dev never sends hits. Centralize the ID as GA_MEASUREMENT_ID in src/lib/content.ts. Load the gtag.js library lazily, after the page paints (load -> requestIdleCallback), so the third-party script never competes with the LCP hero image and the strict Lighthouse budget (performance >= 0.95, LCP <= 2000ms) still passes. The config command queues in dataLayer immediately and fires when the library arrives, so no pageview is lost. Enforce it in CI: tools/seo_check.py fails the build if any page ships without both halves of the gtag snippet (loader URL + config call), so new pages and layouts keep analytics wired. Document the deliberate exception to the no-external-scripts rule in CLAUDE.md (Analytics section, plus conventions and guardrail notes), including why the tag is lazy-loaded, so future edits keep it. Also codify keeping the branch rebased on the latest main. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JmaMdyAqo73ME5vEtwUfiG --- CLAUDE.md | 56 ++++++++++++++++++++++++++++++------ src/layouts/BaseLayout.astro | 23 ++++++++++++++- src/lib/content.ts | 7 +++++ tools/seo_check.py | 17 +++++++++++ 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac1c4cf..de901c2 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,38 @@ 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 never competes with the LCP + hero image and the strict Lighthouse budget (`performance ≥ 0.95`, `LCP ≤ 2000ms` + 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 && } {title} {author && } diff --git a/src/lib/content.ts b/src/lib/content.ts index 38988a0..cbbf384 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -9,3 +9,10 @@ export const reader = createReader(process.cwd(), keystaticConfig); // sync with tools/seo_check.py (BASE), robots.txt, and sitemap.xml. export const SITE_ORIGIN = 'https://quentinfears.com'; export const OG_IMAGE = `${SITE_ORIGIN}/assets/img/og-cover.jpg`; + +// Google Analytics 4 measurement ID. This is the one deliberate exception to +// the "no external scripts/CDN" rule: BaseLayout loads gtag.js from Google on +// production builds only (import.meta.env.PROD), so `npm run dev` never sends +// hits. tools/seo_check.py (GA_MEASUREMENT_ID) enforces that every built page +// carries this tag. Keep the two IDs in sync. +export const GA_MEASUREMENT_ID = 'G-1WEVVZN8TV'; diff --git a/tools/seo_check.py b/tools/seo_check.py index 28aadd6..cddf901 100644 --- a/tools/seo_check.py +++ b/tools/seo_check.py @@ -24,6 +24,12 @@ # changes, update this and find-and-replace across the HTML / robots / sitemap. BASE = "https://quentinfears.com" +# Google Analytics 4 measurement ID. BaseLayout emits the gtag.js snippet on +# every production page; this check fails CI if a page ever ships without it, so +# new pages/layouts keep analytics wired. Keep in sync with GA_MEASUREMENT_ID in +# src/lib/content.ts. +GA_MEASUREMENT_ID = "G-1WEVVZN8TV" + # This is a personal site. It must not present Quentin as an official # representative or spokesperson of an employer in machine-readable metadata. # These terms are allowed in visible body copy (his own first-person words) @@ -234,6 +240,17 @@ def check_page(page, html, sitemap_locs): f"tags (body copy is fine)", ) + # Google Analytics must be wired on every page. Check both halves of the + # gtag snippet: the async loader from Google and the config call that fires + # the pageview, so a half-removed tag is caught too. + if f"gtag/js?id={GA_MEASUREMENT_ID}" not in head: + err(page, f"missing Google Analytics loader for {GA_MEASUREMENT_ID} in ") + if not re.search( + r"gtag\(\s*['\"]config['\"]\s*,\s*['\"]" + re.escape(GA_MEASUREMENT_ID) + r"['\"]", + head, + ): + err(page, f"missing gtag('config', '{GA_MEASUREMENT_ID}') call in ") + # Page must be listed in the sitemap if exp not in sitemap_locs: err(page, f"URL '{exp}' is not listed in sitemap.xml") From c1069bb132f7425f6a64babb81662a58d9ed66a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 15:53:31 +0000 Subject: [PATCH 2/3] Accept GA's perf cost: relax Lighthouse perf/LCP to warnings The GA tag adds a small performance cost, and the throttled-lab performance score and LCP swing widely on shared CI runners (LCP ranged ~1800-4300ms for the same build), so they were noisy hard gates. Per owner direction (analytics wanted, slight degradation accepted), demote categories:performance and largest-contentful-paint to warnings. Keep the deterministic gates as hard errors: seo=1.0, accessibility>=0.95, best-practices>=0.95 (still guards third-party/cookie issues), CLS<=0.05, total-blocking-time<=200ms, and the image audits. gtag.js stays lazy-loaded so it never lands on the main thread during render and TBT stays green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JmaMdyAqo73ME5vEtwUfiG --- CLAUDE.md | 19 +++++++++++++------ lighthouserc.json | 4 ++-- src/layouts/BaseLayout.astro | 10 ++++++---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index de901c2..8a05b73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,13 +199,20 @@ contained. 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 never competes with the LCP - hero image and the strict Lighthouse budget (`performance ≥ 0.95`, `LCP ≤ 2000ms` - in [lighthouserc.json](lighthouserc.json)) still passes. The `gtag('config', …)` - call queues in `dataLayer` immediately and fires when the library arrives, so no + 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 `