★ PLAN 7/18 — commutingcost.com pilot: Editorial Warm + motion + SEO/GEO/AEO (9 stages — READ THIS FIRST)
TL;DR
- SCOPE: two surfaces only (/ and /commute-cost/[metro]) + root artifacts. /map, /compare, /rankings, /TLCcosts, /losing-ground explicitly OUT — they inherit the component kit in Phase 3. NO backend/GPU/kernel changes; the only pipeline work is regenerating the SEO data spine.
- LOCKED DESIGN: Editorial Warm (cream #faf7f1 / ink #221d19 / Georgia serif) with per-template accent tints, each carrying an AA-safe deep variant. Motion = 8 inline-SVG transit vehicles crossing the hero, TWO per scene at different depths, over a two-depth parallax skyline, with a diagonal cream scrim that keeps copy legible. Metro pages show that metro's OWN dominant mode, derived from GTFS route_type weighted by trip count.
- AUDIT — ALREADY GOOD: both pilot pages are already true server components; the metro template already emits 3 server-rendered JSON-LD blocks (FAQPage/Dataset/BreadcrumbList); sitemap.ts, robots.ts and llms.txt already exist; maplibre is already deferred. The SSR constraint is structurally satisfied today.
- AUDIT — REAL PROBLEMS: robots.ts invites GPTBot/ClaudeBot into /admin/ and /api/ (AI-crawler groups declare allow:/ with no disallow, and robots groups match exclusively); notes[] (32 of 51 metros) + data_coverage (all 51) are generated and rendered NOWHERE (free provenance/AEO content); all 51 seo.title values are 68-87 chars so every one truncates in SERP; llms.txt is stale (still says OpenTripPlanner 2.6, US-only) and force-dynamic re-parses 442KB per bot hit; the bottom metro CTA links to a bare /route with no prefill (biggest conversion leak); homepage has no metadata export and zero JSON-LD; 20 of 51 metros have no priced transit fare and ottawa renders nearly empty.
- TOP RISK — MOTION vs CWV: SVG-internal transform animations are NOT compositor-promoted in Blink, so wheels/cranks/legs repaint on the main thread every frame while a compositor-property grep still passes. Mitigations are mandatory, not optional: a DevTools 5s idle measurement gate, animated sub-parts capped at 2 per vehicle, and SceneMotionController (IntersectionObserver pause) treated as required.
- HONESTY RULE (C8): every homepage stat must carry a verify: command and any that fails is DELETED, not shipped. Prior audits already contradict two (tolls 139 priced facilities vs 6,935 displayed; parking 1,515 garages vs 3,183). The illustrative $9,400/year line is deleted outright. Even '151,781 destinations' must be verified against Postgres — a 2026-07-20 check puts the stored figure at 133,984.
- RESOLVED: Q1 — 'metros covered' and 'guide pages' are DIFFERENT numbers. nec/midatlantic/socal are mega-regions covering multiple metros each (new-york is a metro slug with no network dir; it rides on nec, as do 5 others), so 62 was never unsupported. Copy says '60+ metros'; we ship 53 guide pages with all counts derived. Q2 — the plan's claim that .btn-primary FAILS AA was WRONG: the live #b23a1e measures 5.97:1 and PASSES; 3.2:1 belonged to the proposed #cc7a5c. No live defect, so the palette is SCOPED to the pilot and the 18 out-of-scope files are untouched. Q4 — no @commutingcost handle exists, so twitter.site is omitted rather than invented.
CommutingCost Pilot — Editorial Warm + Motion + AEO/GEO
Final implementation plan
Date: 2026-07-18
Status: APPROVED FOR EXECUTION — pending the 7 open questions in Part H (only Q1 and Q2 block a stage; the rest have safe defaults)
Scope: two surfaces only — / and /commute-cost/[metro] — plus root discovery artifacts (sitemap.xml, robots.txt, llms.txt, llms-full.txt, /methodology, OG image routes, favicon).
Out of scope: /map, /compare, /rankings, /TLCcosts, /losing-ground, /blog, /admin, /enterprise, /pricing, /widget, /find-home, /family-budget. They inherit the kit in Phase 3.
No backend, GPU, kernel, or engine changes. The only pipeline work is regenerating the SEO data spine.
Part A — Locked design decisions (do not re-litigate)
| # | Decision |
|---|---|
| L1 | Visual direction: EDITORIAL WARM. Cream #faf7f1 · paper #fffdf9 · ink #221d19 · Georgia serif display · system sans body. |
| L2 | Per-template accent tints, each with a deep variant. terracotta #cc7a5c / deep #9c4a2c (home + metro) · sage #7c8471 / #4f5c46 (rankings) · clay #b5674f / #8a4038 (drive) · ochre #c9a227 / #8a6a15 (micromobility). |
| L3 | WCAG AA is a hard requirement, not a preference. The original #cc7a5c measures 3.2:1 on white / 3.02:1 on cream and FAILS AA. All buttons, small text, and any load-bearing accent text bind to the deep variant (#9c4a2c = 6.12:1 on white, 5.73:1 on cream). |
| L4 | Motion: per-template animated background of transit vehicles crossing the hero. 8 inline-SVG vehicles (train, subway, tram w/ trolley pole + overhead wire, bus, car, bike, scooter, walk). |
| L5 | Two vehicles per scene at different depths — a foreground vehicle and a different-mode background vehicle at scale(0.6), opacity 0.34, slower. Two-depth parallax skyline. |
| L6 | A diagonal cream scrim sits between the scene and the copy so text always has calm ground. This is what fixed the legibility complaint — it is not optional decoration. |
| L7 | Rail bed only for rail modes; catenary wire only for trams. All motion is CSS transform/opacity only, inline SVG, no images, no libraries, full prefers-reduced-motion support. |
| L8 | Metro pages show that metro's own dominant mode, derived from GTFS — route_type weighted by trip count per metro. Chosen over Census journey-to-work because it is our own data and stays current. |
| L9 | Reference implementation is the source of truth for the visual system: scratchpad/editorial_motion.html, shipped in-repo at public/preview/design-directions.html and live at https://commutingcost.com/preview/design-directions.html. |
Hard constraints
- Core Web Vitals must not regress. LCP < 2.5s · CLS < 0.1 · INP < 200ms. CWV is a ranking signal.
- JSON-LD, answer content, and FAQ copy must be in server-rendered HTML. Crawlers and LLMs do not reliably execute client JS.
- Never
git add -Aon the app dir. Stage explicit paths. Never commit.env*or secrets. - Do not invent data. If a number has no source, omit the field. Never zero-fill, never render
—or$0.00as a fact.
Verified stack facts
| Fact | Value | Verified by |
|---|---|---|
| Framework | Next.js 14.2.21, App Router under src/app, React 18.3, TypeScript 5.7 |
package.json |
| Styling | Tailwind 3.4.16 (5 CSS-var bridges only) + 575-line hand-written src/app/globals.css |
tailwind.config.js, globals.css |
| Maps | maplibre-gl ^4.7.1, dynamically imported inside effects |
RouteMap.tsx:28, MetroAccessMap.tsx:56 |
| Auth / markdown | next-auth 5.0.0-beta.31, marked ^18 |
package.json |
| Deploy | Next standalone; postbuild copies content/, data/, .env.local, .next/static, public/ into .next/standalone |
package.json:8 |
| Prod host | geo3 = 172.26.1.70, reached via nested ssh through geo (-J presents the origin key and is denied) |
fleet map |
| Prod process | pm2 commutingcost-web, id 3, port 3035 — npm start is next start -p 3035, so :3035 is production, never a verification target |
package.json, service registry |
| Repo | tlcengine/commutingcost-web, branch main, HEAD ce2c3a6 |
— |
| Data spine | data/metros_seo.json — bare JSON array, 51 records today |
parsed |
| Networks on disk | 65 dirs under output/networks/, 54 with status=complete, of which 3 are corridors (nec, socal, midatlantic) |
manifest.json scan |
| Python | /home/krish/gpu-pilot/venv/bin/python has psycopg2 2.9.12 and pyarrow 19.0.1. /usr/bin/python3 has psycopg2 but no pyarrow |
direct import test |
| Engine | GPU isochrone API at geocoder:8098, 9 modes, 151,781 precomputed destinations |
project brief |
Spec correction, recorded deliberately (C-1): the design spec §7/§10 mandates
/usr/bin/python3for the data pipeline. That is wrong — it lacks pyarrow and every new script reads the network parquet files. This plan mandatesvenv/bin/python. The spec's risk register should be updated; do not "fix" the scripts back to system python.
Part B — What the audit found
Four independent audits covered routing/structure, the metro template, SEO, and the design/data layers. Honest summary:
Already good — do not break it
- Both pilot pages are true server components.
src/app/page.tsxandsrc/app/commute-cost/[metro]/page.tsxhave no"use client".SiteChromeis a client component but takeschildrenas a prop from the server layout, so page HTML is fully SSR'd. The crawler/LLM constraint is structurally satisfied today. - The metro template already emits three server-rendered JSON-LD blocks — FAQPage, Dataset, BreadcrumbList — as inline
<script type="application/ld+json">withdangerouslySetInnerHTML, and the visible FAQ is realh3/pHTML driven by the samem.seo.faqarray. Schema and copy cannot drift. - The metro route is statically generated via
generateStaticParams()withrevalidate = 86400, andgenerateMetadataalready sets title, description, canonical, OpenGraph and a Twitter card. maplibre-glJS is already deferred behindawait import()inside effects on both surfaces — the pattern to preserve.- The homepage map shell reserves height (
.cc-maps-shell { height: clamp(560px, 78vh, 820px) }with an absolutely-positioned canvas), so it is not a CLS source. sitemap.tsandrobots.tsexist as Next metadata routes, driven off the samelistMetros()loader, with an explicit AI-crawler allow-list.llms.txtalready exists as a route handler emitting per-metro facts — a working answer-surface precedent.- The deploy contract already ships
data/into.next/standalone, so new data artifacts need no deploy change. - The approved motion reference already ships in-repo at
public/preview/design-directions.html— byte-identical to the scratchpad file apart from an HTML wrapper. data/losing_ground.jsonalready proves the provenance pattern (generated,vintages,sources,as_of_date) that the metro spine lacks.
Missing or broken — the actual work
Design system
- The Editorial Warm palette does not exist. Current tokens are the Losing Ground scheme: --bg #f7f3ec, --fg #1a1714, --accent #b23a1e, --accent-mid #c98a2b, --hero-bg #15110d. Grep for any locked hex returns zero hits.
- There is no motion of any kind. Zero @keyframes, zero animation:, zero prefers-reduced-motion anywhere in src/. Only 26 hover/color transition declarations.
- The homepage hero is a dark band (--hero-bg #15110d) — the inverse of the cream direction — with two decorative radial-gradient washes that will collide with the new scene. .cc-hero is shared with /losing-ground, so it must not be mutated in place.
- The metro page has no hero at all — breadcrumb straight into <article>.
- Display font is Fraunces via a render-blocking @import to fonts.googleapis.com at globals.css:14. next/font is used nowhere.
- Tailwind carries almost no tokens — 5 CSS-var bridges and a borderColor override. No fonts, no keyframes, no animation config.
- .btn-primary binds to var(--accent) = #b23a1e, and its :hover binds to --accent-dim = #8f2c14. 18 files use .btn-primary.
- The metro page renders the CBSA eyebrow at text-xs in var(--accent) — a small-text AA failure under the locked palette.
Data spine
- No GTFS dominant-mode field exists. No route_type, dominant_mode, or mode_share anywhere in metros_seo.json or src/lib/metros.ts. Hard blocker for L8.
- No freshness field anywhere — no generated, dateModified, as_of, or vintage in metros_seo.json or any of the three *_50.json ranking files. Only file mtime.
- 51 records, not 62. Copy hardcodes "50" in three places.
- No slug↔network bridge. 6 SEO slugs have no matching network dir (new-york, los-angeles, san-francisco, san-jose, philadelphia, washington-dc); 9 network dirs have no page. The only partial bridge is public/transit/_index.json, covering 31 of 51.
- notes[] (32 of 51 metros) and data_coverage (all 51) are generated and rendered nowhere. These are ready-made provenance copy explaining exactly why each block is missing.
- Sparse coverage: 20/51 have no priced transit fare, 14 no bike-share, 7 no tolls, 5 no parking, 6 no affordability. ottawa additionally lacks cheapest_mode — it loses its two highest-value sections and renders nearly empty.
- Only affordability has a graceful-degradation fallback (:500-507); fares, bike-share, tolls and parking just vanish.
- json_ld_facts (11 structured keys) is JSON.stringify'd into a single opaque PropertyValue — machine-hostile.
- buildVariablesMeasured emits only 5 of ~15 available measures, and reads m.data while the blob reads json_ld_facts — two paths that can disagree.
- Silent truncations: only the first 8 toll facilities (21 metros have more), only the first priced bike-share system (NYC has 5).
- All 51 seo.title values are 68–87 chars — every one truncates in SERP. meta_description is only 83–133 chars, wasting snippet room.
- The generator lives outside the web repo at /home/krish/gpu-pilot/content/build_metros_seo.py with a manual copy step. No validation, no schema test, no npm task.
- Three .bak.newcities.20260712 files in data/ and src/components/SiteHeader.tsx.bak-lg-20260629 are copied into the standalone bundle by postbuild.
SEO / AEO / GEO
- Homepage has no metadata export and zero JSON-LD. No canonical for /, no page OG, no Twitter card, no Organization, no WebSite.
- No metadataBase anywhere — relative OG image paths cannot resolve and Next 14 will warn.
- No OG image route, no favicon, no icon of any kind. Metro pages declare twitter.card: "summary_large_image" and supply no image.
- No sitewide identity JSON-LD. Organization appears only nested as a creator inside Dataset nodes.
- No /methodology route. The Dataset schema asserts citable numbers with nothing to back them; Source: {aff.source} is unlinked plain text.
- No llms-full.txt.
- llms.txt combines dynamic = "force-dynamic" with revalidate = 3600 — force-dynamic wins, so a 442 KB JSON re-parse happens on every bot hit. Its content is also stale: it describes the product as OpenTripPlanner-2.6-backed and US-only.
- robots.ts bug: the seven AI-crawler groups declare allow: "/" with no disallow. robots.txt groups match exclusively, so GPTBot/ClaudeBot/PerplexityBot etc. are explicitly permitted into /admin/ and /api/ that the * group blocks.
- robots.ts bug: host: SITE_ORIGIN emits Host: https://commutingcost.com; the directive takes a bare hostname.
- Sitemap omits /losing-ground and /api-landing, and metro entries carry no lastModified (blog entries do).
- SITE_ORIGIN is declared four separate times.
- The reusable src/components/JsonLd.tsx exists but neither pilot surface uses it.
- FAQ visible-render guard is not null-safe (:535 m.seo.faq.length vs the schema guard's m.seo.faq && …). Latent crash.
- The bottom metro CTA links to a bare /route with no prefilled destination — the largest conversion leak on the template.
- MetroAccessMap's wrapper has no reserved height — a latent CLS source that a new animated hero would be blamed for.
- No tests, no lint config, no CWV harness in the repo.
Part C — Decisions resolved before any code
These are places where inputs conflicted. Each is settled here; the stages assume the decision.
C1 — Metro count: 53 pages, 54 routing regions
| Claim | Source | Reality |
|---|---|---|
| "62 metros" | project brief | not supported by anything on disk |
| "50 metros" | [metro]/page.tsx:580, commute-cost/page.tsx:12,17 |
stale copy |
| 51 records | data/metros_seo.json |
current spine |
| 54 complete networks | output/networks/*/manifest.json |
3 are corridors (nec, socal, midatlantic) |
Plan of record: ship 53 metro pages (51 existing + salt-lake-city + edmonton). Every count in code derives from metroCount() / listMetros().length — zero literals. The hero stat line becomes 53 metro guides · 9 modes · 151,781 destinations precomputed.
This is a reduction against a number the spec states five times, and against an explicitly in-scope deliverable ("regenerating
metros_seo.jsonto full 62-metro coverage"). It is therefore Open Question Q1, not a closed decision. The arithmetic supports 53; the authority to reduce the deliverable is Krishna's. Stage 1 cannot start until Q1 is answered.
C2 — Dominant mode field shape: hero_vehicle
The mode computation resolves the Amtrak exclusion, ferry fallthrough, and extended-route-type collapse before it emits anything. The app therefore consumes m.data.transit_mode_mix.hero_vehicle ∈ {subway, train, tram, bus} — a vehicle key, not a raw route type.
sceneForMetro(heroVehicle)is keyed on the four vehicle names.- Fallback is
bus, nevercar— a metro with a built transit network always has transit; a car would be a lie. - The mode-share sentence uses
dominant_mode+dominant_shareand is suppressed whenconfidence === "low".
C3 — Token naming: --accent / --accent-deep, with tint aliases
One pair of names. Defined in :root with a globally safe default, and in [data-theme='dark'], so the 18 files using .btn-primary are safe outside any [data-tint] scope. Each [data-tint="x"] block sets both --accent/--accent-deep and the aliases --tint-body/--tint-deep so motion CSS ported verbatim from the reference keeps working.
C4 — llms.txt / llms-full.txt: pre-built artifacts, served by static file readers
Kills the force-dynamic bug, removes the per-hit 442 KB re-parse, and lets the corpus be validated on geocoder before it ships. Content assertions (no OpenTripPlanner, 53 ## blocks, zero $0.00) run in the geocoder build and post-deploy against the live URL.
C5 — Freshness metadata: generated sidecar, id-keyed
data/metros_seo.meta.json is emitted by the generator, never hand-authored (a hand-authored sidecar is a lie waiting to go stale). sourcesForMetro(coverage) maps data_coverage flags → sources[].id and must be total — every coverage flag, including the new transit_mode_mix, has a matching source entry or the function is a bug.
C6 — Homepage primary CTA target: #plan
<SearchForm /> is already mounted ~200px below the hero. Sending a user to /route is a wasted navigation and a second LCP. The mid-page repeat CTA points at /route.
C7 — Copy ownership: src/lib/copy.ts owns prose, src/lib/seo.ts owns shaping
seo.ts imports from copy.ts and only shapes (metroSerpTitle ≤60, metroDescription ≤155). One string, one home.
C8 — Fabricated numbers: deleted, not shipped
- The illustrative "$9,400/year" hero line is deleted. The only sourced figure of that genre is the attributed
$12,682on the Piscataway case-study card; it stays where it is. - The six homepage
Statliterals (1,210 / 49,806 / 932,057 / 6,935 / 3,183 / 400,706) each require averify:command whose output is pasted into the PR. Prior audits already contradict two (tolls: 139 priced facilities vs 6,935 shown; parking: 1,515 garages vs 3,183). Any stat whose verify output does not match is deleted from the grid in this PR. Expect to ship four, not six. ENGINE_STATSis held to the same rule.9 modesand151,781 destinationseach get averify:command.151,781currently traces to a session log, not a live query — it is verified against Postgres or it is dropped.
C9 — Next metadata is shallow-merged. Never inherit openGraph/twitter.
Next 14 replaces nested metadata objects rather than merging them. A page-level openGraph wipes the layout's entire openGraph, silently dropping siteName, locale, and twitter.site — while looking correct in review because "the layout has them."
Every surface composes through helpers in src/lib/seo.ts:
const OG_BASE = { siteName: SITE_NAME, locale: "en_US" as const, type: "website" as const };
export const og = (o: Partial<OpenGraph>) => ({ ...OG_BASE, ...o });
export const tw = (t: Partial<Twitter>) => ({ card: "summary_large_image" as const, ...t });
scripts/seo-check.mjs asserts og:site_name is present on / and on 3 metros.
C10 — Verification never touches :3035
npm start binds 3035 and pm2 commutingcost-web owns it in production. Every pre-deploy checkpoint runs against VERIFY_PORT=3055:
PORT=3055 npx next start -p 3055 &
BASE=http://localhost:3055
:3035 is used only for post-deploy checks, and only after the health poll returns 200.
C11 — robots.txt is a crawl directive, not a security control
Stage 8 is sequenced after 7.1 for tidiness, but the real protections on the two /api/ routes are the honeypot, the timing guard, the in-process rate limit, and the closed EventName union. Do not under-invest in 8.5's validation because "robots blocks it."
C12 — Fonts: move the @import to a parallel <link>; do not delete it
Deleting globals.css:14 re-renders every out-of-scope page in Georgia, because h1,h2,h3 bind globally to --serif ("Fraunces", "Spectral", Georgia, …). That is a Phase-3 change. Plan of record: move the Google Fonts request out of the CSS @import chain into a plain <link rel="stylesheet"> in layout.tsx (removes the import-chain serialisation, keeps the typeface, keeps display=swap), and give the pilot surfaces Georgia via fontFamily.editorial. Do not ship the media="print" + string onLoad hack — React will not serialise a string event handler in an RSC <link>. Dropping Fraunces entirely is Q3.
C13 — Reference-port fidelity notes
.rider.back { transform: scale(.6) }is already restated outside the cancelled keyframe atdesign-directions.html:134. Do not drop it during the port; no new work, but T12 tests for it.- The reference has 10
@keyframes, includingrise(which Reveal's reduced-motion cancel depends on). Port all 10. - The reference's sentence conflating the two contrast ratios (
design-directions.html:214) is not ported. The brief's 6.1:1 (white-on-tint) and the reference's ≈5.6:1 (tint-on-cream) are both correct measurements of different things. Neither number appears in shipped copy. Say "AA-compliant" or say nothing. - The reference's drive scene uses car/car, which collapses the two-depth illusion and violates L5.
SCENE_DRIVE.back = 'bus'.
Part D — Implementation stages
Nine stages. Each ends in a checkpoint that must pass before the next begins. De-risking first.
STAGE 0 — Preflight & baseline (30 min)
Goal: you cannot prove CWV didn't regress without a before, and you cannot verify safely without a non-prod target.
| # | Task | Path | Detail |
|---|---|---|---|
| 0.1 | Confirm host + process identity | ~/.claude/service-registry.json |
entry commutingcost-web, id 3, port 3035, cwd commutingcost-web. Never guess a pm2 name. |
| 0.2 | Branch | geo3:/home/krish/commutingcost-web |
git checkout -b pilot/editorial-warm from main@ce2c3a6 |
| 0.3 | Establish the verification target | — | PORT=3055 npx next start -p 3055. Export VERIFY_PORT=3055, BASE=http://localhost:3055. Every Stage 4–8 checkpoint uses $BASE. |
| 0.4 | Baseline Lighthouse ×2 surfaces ×2 form factors | — | JSON → scratchpad/lh-before-{home,nyc}-{mobile,desktop}.json. Record LCP / CLS / TBT / SEO / Best-Practices. |
| 0.5 | Baseline HTML weight | — | curl -s $BASE/ \| wc -c and same for /commute-cost/new-york. The +8 KB budget is measured against these. |
| 0.6 | Back up the data spine | geocoder | cp content/metros_seo.json content/metros_seo.json.pre-pilot |
| 0.7 | Evict stale files from the ship path | data/*.bak* ×3, src/components/SiteHeader.tsx.bak-lg-20260629 |
mv to .data-archive/. postbuild currently copies these into .next/standalone. |
| 0.8 | Write the contrast checker | scratchpad/contrast.js |
Zero-dep. Asserts every deep variant vs white and vs #faf7f1; asserts #cc7a5c FAILS both; asserts --accent-deep vs --bg in dark mode ≥ 3:1. |
| 0.9 | Snapshot the live build tree outside the repo | — | cp -r .next/standalone /home/krish/cc-web-rollback/standalone-$(date +%s). Not .next/standalone.pre-pilot — next build owns .next/ and rewrites it, and a copy inside the traced output dir doubles the bundle. |
CHECKPOINT 0
- node scratchpad/contrast.js → #cc7a5c FAIL on white (3.20:1) and on cream (3.02:1); #9c4a2c PASS on both (6.12:1 / 5.73:1); all four deep variants PASS.
- curl -s -o /dev/null -w '%{http_code}' $BASE/ → 200 (verification server is up on 3055, prod on 3035 untouched).
- Baseline JSON files exist; numbers recorded in the PR description.
STAGE 1 — Data pipeline (geocoder) ⛔ HARD BLOCKER, gated on Q1
Goal: produce a 53-metro spine carrying hero_vehicle, a slug↔network bridge, and freshness stamps. Everything downstream depends on this.
Run every script with
/home/krish/gpu-pilot/venv/bin/python. System python3 has no pyarrow. Each new script opens with a hardimport pyarrowguard emitting "run this with venv/bin/python".
| # | Task | Path | Change |
|---|---|---|---|
| 1.1 | New: dominant-mode builder | gpu-pilot/content/build_dominant_mode.py |
Reads output/networks/*/transit/*.parquet. Per metro: 40 km anchor box ∩ manifest bbox → in-window stops → trips with ≥2 in-window stop-times → filter to service date 20260415 (Wed) → exclude feed mdb-11 (national Amtrak, loaded into every network; without this San Antonio reports 11% phantom rail) → canonicalise extended route_types (Montréal exo uses 127) → dominant_mode = argmax; signature_mode = argmax over non-bus drawable modes clearing 0.5% or 25 trips; hero_vehicle ∈ {subway,train,tram,bus}; confidence from trip volume. Writes content/metro_mode_mix.json keyed by SEO slug. |
| 1.2 | Modify: slug bridge + 2 new metros | gpu-pilot/content/build_metros_seo.py:31 |
Add net= to all 51 METROS entries. 16 differ from slug: new-york→nec, los-angeles→la, san-francisco→sfbay, san-jose→sfbay, washington-dc→dc, philadelphia→philly, plus 8 underscore spellings, plus riverside→riverside and baltimore→baltimore (explicitly not socal/midatlantic). Append salt-lake-city (net slc, CBSA Salt Lake City, UT — verified present in hta_index) and edmonton (net edmonton, CBSA CA-EDM, no CNT H+T, no AB tolls — correct gaps, do not backfill). Replace the stale # 50 metros docstring at :26 with a derived len(METROS). |
| 1.3 | Modify: merge mode mix + freshness | same file, rec at ~:1547 |
Adds rec["network"], rec["data"]["transit_mode_mix"], rec["generated"] (ISO), rec["vintages"] (only keys for populated blocks — omit any source you cannot date), data_coverage["transit_mode_mix"], and a notes entry when the mix is absent. Adds json_ld_facts["transit_mode_mix"] only when confidence != "low". Keep the file a bare array — src/lib/metros.ts:161 does JSON.parse(raw) as Metro[]; wrapping it breaks the metro page, sitemap and llms.txt for zero benefit. |
| 1.4 | New: meta sidecar | emitted by 1.3 → content/metros_seo.meta.json |
generated, generator_git_rev, n_metros, n_networks_built and corridor_regions_excluded computed by scanning output/networks/*/manifest.json for status=complete — never hardcoded (D-5), service_date with the label "GTFS service day used for schedule sampling" — not presented as a data vintage (D-3), engine {host, modes, precomputed_destinations}, sources[] {id, name, as_of, note?}, data_gaps computed from data_coverage. /methodology renders this; nothing on that page is hand-typed. |
| 1.5 | New: the validation gate | gpu-pilot/content/validate_metros_seo.py |
Exits non-zero on: count ≠ len(METROS); dup/bad slug; any of 13 required keys missing; seo.faq missing or malformed (closes the null-safety hole at page.tsx:535 at the data layer); meta_description > 160; any data.* present-but-{}; hero_vehicle outside the 4 values or \|Σmix−1\| > 0.005; network not a complete build or ∈ {socal,midatlantic} (nec allowed only for new-york); generated unparseable; every data_coverage flag has a matching sources[].id (D-6); every home_faq.json answer is either non-numeric or carries a source_field pointing at a real path in the spine (D-1). Warns on seo.title > 60 and prints the count — all 51 today are 68–87 chars; the validator makes that debt visible and non-growing. Prints the coverage table /methodology consumes. |
| 1.6 | New: LLM corpus builder | gpu-pilot/content/build_llms_txt.py |
Writes content/llms/llms.txt (index, ≤15 KB, one line per metro, only non-null facts) and content/llms/llms-full.txt (250–900 KB). Full file sections: per-metro expansion · all FAQ Q&A verbatim so corpus and HTML agree word for word · ## Sources · ## Coverage gaps · ## Attribution ("Cite as: CommutingCost, ## Rankings (links to the four ranking surfaces). Kills the stale OTP-2.6 / US-only prose. Never zero-fills. |
| 1.7 | New: sync script | gpu-pilot/content/sync_to_web.sh |
Runs the validator as a gate, then copies metros_seo.json, metros_seo.meta.json, metro_mode_mix.json, llms/* into <web>/data/. Sweeps data/*.bak* into .data-archive/. Prints the explicit git add line — never git add -A. |
CHECKPOINT 1 (on geocoder)
cd /home/krish/gpu-pilot
venv/bin/python content/build_dominant_mode.py && \
venv/bin/python content/build_metros_seo.py && \
venv/bin/python content/validate_metros_seo.py && \
venv/bin/python content/build_llms_txt.py && echo "STAGE 1 GREEN"
Plus:
- metro_mode_mix.json has 53 metros, no corridor slugs
- san-antonio.mix == {"bus": 1.0} (Amtrak exclusion works)
- new-york.hero_vehicle == "subway"; seattle == "tram" (ferry correctly fell through); san-jose.shared_network == true
- grep -ci opentripplanner content/llms/*.txt → 0
- grep -c '^- ' content/llms/llms.txt → 53
- grep -c '^## Attribution' content/llms/llms-full.txt → 1
- DB vocabulary check: psql -h 172.26.1.152 -p 5433 -U krish -d commute -c "SELECT route_type, route_type_name, count(*) FROM public.transit_routes GROUP BY 1,2 ORDER BY 1" — any route_type not explicitly handled by canon() must be added before this checkpoint is accepted.
- Montréal→train and Boston/Philadelphia→tram are recorded in the run log, not hand-edited (see Q7).
STAGE 2 — Shared modules in the web repo (no visual change)
Goal: land all plumbing independently verifiable, before anything user-visible moves.
| # | Task | Path | Change |
|---|---|---|---|
| 2.1 | New: single origin | src/lib/site.ts |
SITE_ORIGIN, SITE_HOST (bare, for the robots Host: directive), SITE_NAME, ORG_ID, SITE_ID, abs(). Re-export from src/lib/metros.ts:4 and src/lib/blog.ts:150 so nothing breaks. Leave the two out-of-scope page-local copies alone. |
| 2.2 | Modify: types + metroCount() |
src/lib/metros.ts |
Add network, generated, vintages to Metro; add transit_mode_mix: MetroModeMix \| null to Metro["data"]; add loadMeta(); add metroCount(). |
| 2.3 | New: freshness accessors | src/lib/dataMeta.ts |
getDataMeta() — explicit try/catch → null, so a partial data sync can never turn a previously-fine ISR page into a 500 with no deploy (M16). dataModifiedISO() (null-safe). sourcesForMetro(coverage) returns only sources whose id has a true coverage flag — this is the no-invented-data guard: a metro with no priced fares never cites the GTFS source. Must be total over all coverage flags. |
| 2.4 | New: all human-facing prose | src/lib/copy.ts |
HOME_COPY, METRO_COPY, EMAIL_COPY, FAQ_ANSWER_TEMPLATES. No JSX, no imports. No Tailwind class names (see 2.11). |
| 2.5 | New: SERP shaping + metadata helpers | src/lib/seo.ts |
metroSerpTitle(m) ≤60 (composed from m.name; falls back to the existing title only if it already fits). metroDescription(m) ≤155, built from meta_description + one existing fact, truncated on a word boundary. og() and tw() composers per C9 — every surface routes through them. Imports prose from copy.ts. |
| 2.6 | New: structured-data builders | src/lib/jsonld.ts |
organizationNode, websiteNode (with potentialAction SearchAction), softwareApplicationNode (the product entity — spec-required, the most citation-relevant homepage node), webPageNode, breadcrumbNode, faqPageNode (returns null below 2 questions — two metros have a single Q), metroDatasetNode, methodologyArticleNode, graph(). One @graph per page. Stable @ids, cross-referenced by @id. metroDatasetNode absorbs buildVariablesMeasured out of the page file and expands it from 5 to ~15 null-guarded entries, adds dateModified, isBasedOn (from sourcesForMetro), citation → /methodology, spatialCoverage.addressCountry, disambiguatingDescription from notes[], plus the transit_mode_mix PropertyValues. Keeps the raw blob but renames it raw_facts and demotes it to a supplement. Server-only — never imported by a client component (it transitively reaches node:fs via metros.ts). |
| 2.7 | New: motion config | src/lib/motion.ts |
VehicleKey, TintKey, SceneConfig, SCENE_HOME (train/bus/terra) + the four out-of-scope scenes pre-declared. sceneForMetro(heroVehicle) per C2 — keyed on 4 vehicle names, bus fallback, always tint: 'terra' (the metro tint is locked; only the vehicle varies). dominantModeLabel() returns null for unknown → clause omitted, never invented. SCENE_DRIVE.back = 'bus'. |
| 2.8 | New: sourced stats | src/lib/site-stats.ts |
Every stat carries source + verify. ENGINE_STATS = 53 metro guides / 9 modes / 151,781 destinations — each with its own verify: command per C8. DATA_STATS holds the homepage numbers pending their verify runs. |
| 2.9 | New: deep-link codec | src/lib/deeplink.ts |
buildRouteHref() / suggestionFromParams() / tripPrefsFromParams(). Pure — no window, no next/navigation; takes anything with .get(). Coords to 5 dp, label ≤120 chars, deterministic key order, range-validated, never throws. |
| 2.10 | New: beacon | src/lib/analytics.ts |
track(name, props). No-op on server and when DNT=1. sendBeacon → fetch(keepalive) fallback, never awaited. Never includes location.search (it can carry dlabel, a user-typed address). All errors swallowed. |
| 2.11 | Modify: Tailwind content glob | tailwind.config.js |
Add './src/lib/**/*.{ts,tsx}'. The current glob is src/app + src/components only, so any Tailwind class string in a new lib file would be silently purged (M12). |
CHECKPOINT 2
- npx tsc --noEmit clean · npm run build succeeds
- grep -rn '"https://commutingcost.com"' src/ | grep -v src/lib/site.ts → only the two out-of-scope page files
- node -e assertions: sceneForMetro('subway').mode === 'subway' · sceneForMetro('tram').wire === true · sceneForMetro(undefined).mode === 'bus' · SCENE_DRIVE.back !== SCENE_DRIVE.mode
- node -e assertion: sourcesForMetro({transit_mode_mix:true}).length > 0 (totality, D-6)
STAGE 3 — Token layer ⛔ 3.5 gated on Q3
Goal: one coordinated CSS/Tailwind pass. Fixes the AA defect site-wide and de-blocks the font request.
| # | Task | Path | Change |
|---|---|---|---|
| 3.1 | Editorial Warm :root tokens |
src/app/globals.css (after :48) |
--font-editorial: Georgia, 'Times New Roman', serif, --font-ui, --cream #faf7f1, --paper #fffdf9, --ink #221d19, --ink2 #463e37, --sand, --cc-line. Existing LG tokens untouched. |
| 3.2 | --accent-deep in BOTH themes ⛔ |
src/app/globals.css :root and [data-theme='dark'] (:51-74) |
:root { --accent-deep: #8f2c14; --accent-deep-hover: #7a2410; } and [data-theme='dark'] { --accent-deep: #f0906a; --accent-deep-hover: #f5a685; }. Both are required. 18 files use .btn-primary and none has a [data-tint] ancestor. Dark --bg is #15110d; a #8f2c14 button on it is a near-invisible dark-red block, and white text on it passes contrast — no checker would catch it. |
| 3.3 | Buttons — SCOPED, not global (Q2 answered: scope) | src/styles/motion.css (tint scopes only) |
⚠️ CORRECTION (C-2), measured 2026-07-20: the live .btn-primary uses var(--accent) = #b23a1e = 5.97:1 on white — it PASSES AA. The earlier "was 3.2:1, FAILED AA" claim was wrong: 3.2:1 is #cc7a5c's ratio (the proposed Editorial Warm terracotta), mistakenly attributed to the deployed color. There is no live accessibility defect. Therefore: do NOT repoint the global .btn-primary. Leave globals.css:168-178 and the 18 out-of-scope files untouched (Phase 3 owns them). Inside [data-tint] only, buttons bind to --accent-deep (#9c4a2c = 6.12:1). The binding rule that still holds absolutely: #cc7a5c is never a button background or small-text color. This also removes the dark-mode #8f2c14-on-#15110d hazard and the base==hover collision entirely, since neither global token moves. |
| 3.4 | Tint scopes (both alias pairs) | src/styles/motion.css (new) |
[data-tint="terra"] { --accent:#cc7a5c; --accent-deep:#9c4a2c; --accent-deep-hover:#83381f; --tint-body:#cc7a5c; --tint-deep:#9c4a2c; --tint-deep-rgb:156 74 44 } and the same shape for sage / clay / ochre. Plus [data-theme="dark"] [data-tint="…"] overrides that lighten each --*-deep (it is text on a dark ground there). |
| 3.5 | Font de-blocking (gated on Q3) | src/app/globals.css:14 + src/app/layout.tsx |
Per C12: move the Google Fonts request from the CSS @import into a plain <link rel="stylesheet" href="…&display=swap"> in layout.tsx. Do not delete it (that silently re-renders ~20 out-of-scope pages in Georgia). Do not ship the media="print" + string onLoad hack. Leave --serif / --sans intact. |
| 3.6 | New utility classes | src/app/globals.css |
.cc-trust / .cc-trust-link (13px ⇒ --accent-deep, load-bearing) · .cc-cta-long / .cc-cta-short responsive label swap (CSS-only, no JS, no CLS) · .cc-hp honeypot · .cc-badge-gpu. |
| 3.7 | Motion stylesheet | src/styles/motion.css |
Full port of the reference: hero shell (min-height: clamp(300px, 42vw, 392px) — CLS guard, never remove), diagonal scrim, sun, two-depth skyline pan, ground / rail bed / catenary wire, rider keyframes, SVG sub-part animations, [data-motion="paused"] block, hero typography, .cc-cta variants, .cc-eh-stats, [data-reveal], mode-bar grow, mobile scrim flip, and exactly one prefers-reduced-motion block at the end. All 10 @keyframes including rise. The reduced-motion block restates .cc-rider--back { transform: scale(.6) } outside the cancelled travelBack keyframe (present in the reference at :134 — port it, do not drop it). |
| 3.8 | Tailwind theme | tailwind.config.js |
Keep the 5 existing var bridges (26 files depend on them). Add literal Editorial Warm colors, the 4 tints with .deep, minHeight.hero, and fontFamily.editorial — named editorial, NOT display, because globals.css:90 has .font-display { … !important } which would silently beat a Tailwind .font-display and render the hero in Fraunces with no visible error. |
| 3.9 | Import order | src/app/layout.tsx |
import "./globals.css"; import "@/styles/motion.css"; |
CHECKPOINT 3
- node scratchpad/contrast.js → all deep variants PASS light and dark; both #cc7a5c rows FAIL (expected)
- grep -c "fonts.googleapis.com" src/app/globals.css → 0; grep -c "fonts.googleapis.com" src/app/layout.tsx → 1
- grep -c "prefers-reduced-motion" src/styles/motion.css → 1
- grep -c "@keyframes" src/styles/motion.css → 10
- grep -c "accent-deep-hover" src/app/globals.css → ≥ 3 (root, dark, hover rule)
- Compositor-property proof: grep -nE "animation:|transition:" src/styles/motion.css | grep -vE "transform|opacity|filter|animation-play-state|animation:\s*none" → empty
- Out-of-scope regression sweep: /rankings, /blog, /compare, /losing-ground render unchanged apart from darker buttons, in both themes
STAGE 4 — Motion + data components (new files only, no page edits)
Goal: build and verify the whole motion system in isolation, proving zero client JS before it touches a page.
| # | Task | Path | Notes |
|---|---|---|---|
| 4.1 | 8 vehicles + registry | src/components/motion/vehicles.tsx |
Server components. Port the SVG payloads from public/preview/design-directions.html; discard the JS string-builder pattern entirely — that is what makes zero-client-JS achievable. Tint via fill="var(--tint-body)" / var(--tint-deep), never string-interpolated. JSX rules: strokeWidth, className, object style, self-closed elements, identical viewBox numbers (depth math depends on them), aria-hidden + focusable="false" on every root <svg>. Vehicle({kind}) falls back rather than throwing on bad data. Cap animated sub-parts at 2 per vehicle (M6). |
| 4.2 | Two-depth skyline | src/components/motion/Skyline.tsx |
Server. far = 22 rects sage, near = 9 rects darker, both preserveAspectRatio="none". <rect>, not a background image — keeps the scene off the LCP candidate list and off the network. |
| 4.3 | Scene composer | src/components/motion/ModeScene.tsx |
Server. data-rail / data-wire attributes drive the rail bed and catenary. Durations passed as CSS custom properties (--cc-dur), never as an inline animation shorthand — an inline shorthand out-specifies the stylesheet and silently defeats the reduced-motion override. Dev-only warn if back === mode. |
| 4.4 | Island 1 — pause controller ⛔ required for CWV, not a nicety | src/components/motion/SceneMotionController.tsx |
"use client", renders null. IntersectionObserver + visibilitychange → sets data-motion="paused". Fail-open: the server never emits data-motion, so no-JS keeps animating. It can only ever reduce work. Per M6, SVG-internal transforms are not compositor-promoted in Blink, so this is the primary mitigation for continuous main-thread raster. |
| 4.5 | Island 2 — CountUp | src/components/motion/CountUp.tsx |
"use client". SSRs the final formatted value, not 0. Intl.NumberFormat('en-US') pinned — bare toLocaleString() is server-locale-dependent and causes a hydration mismatch. One-shot rAF, ~850 ms, off the LCP path. |
| 4.6 | Island 3 — Reveal | src/components/motion/Reveal.tsx |
"use client". Initial state idle ⇒ data-reveal absent from SSR HTML ⇒ content visible to crawlers and to no-JS users. This deliberately inverts the usual hide-in-CSS pattern, which would hide content from crawlers and delay LCP. Never wraps the hero. |
| 4.7 | Composition boundary | src/components/EditorialHero.tsx |
Server component; must never gain "use client". headline is a plain string + optional headlineBreak — no dangerouslySetInnerHTML on the LCP/SEO element. DOM order: scene (z1) → scrim (z2) → copy (z4) → controller. |
| 4.8 | CTA primitives | src/components/cta/{CtaGroup,TrustLine,StatLine}.tsx |
All server components. CtaGroup renders plain <a> with data-cc-event / data-cc-id / data-cc-props — no onClick, so crawlers see the hrefs, zero client bundle per CTA, INP untouched. TrustLine renders its link only when linkHref is passed (so it stays dark until /methodology exists) and carries the "Powered by GPU RAPTOR" badge (.cc-badge-gpu). |
| 4.9 | New: mode-cost bars | src/components/ModeBars.tsx |
Server component rendering data.cheapest_mode.options[] as labelled bars sized by daily_round_trip. Bar widths are inline style={{ '--w': pct }}; the grow-on-reveal animation is CSS on [data-reveal], so bars are full-width and readable with JS off and under reduced motion. Omits any option with a null cost — never a zero bar. |
| 4.10 | New: fact + cost presentation | src/components/{FactGrid,MetroCostCard}.tsx |
Server. FactGrid replaces the two divergent private Stat components (page.tsx:63-70 and [metro]/page.tsx:592-608) with one, omitting any null fact. MetroCostCard wraps the cheapest-mode callout + ModeBars + an inline source citation line. |
| 4.11 | New: semantic FAQ | src/components/FaqAccordion.tsx |
Server. <details><summary> per Q&A — answers are in the DOM and crawlable whether open or closed, keyboard-navigable natively, zero JS. First item open by default. Renders the same array that feeds faqPageNode, so schema and copy cannot drift. |
| 4.12 | New: TL;DR block | src/components/TldrBlock.tsx |
Server. A 2–4 sentence answer-first summary composed only from non-null spine facts (metroTldr(m) lives in copy.ts). Omits any clause whose fact is missing. This is the GEO citation target. |
CHECKPOINT 4
npm run build
grep -rl "cc-bobber" .next/static/chunks/ # must print NOTHING — proves zero client JS for the scene
grep -rl "cc-modebar" .next/static/chunks/ # must print NOTHING
npx tsc --noEmit
Plus the M6 measurement gate (the grep in Checkpoint 3 proves the properties are compositor-friendly; it does not prove the animations are composited):
- DevTools Performance, 5 s idle with the hero in view on /commute-cost/new-york: record main-thread raster/paint. Accept only if there is no recurring main-thread paint task ≥ 2 ms/frame. If it fails, reduce animated sub-parts to 1 per vehicle and re-measure before proceeding.
STAGE 5 — The two page rewrites ⛔ the big one
Goal: one coordinated edit per file. Both stay server components.
5A · src/app/page.tsx
| Change | Detail |
|---|---|
| Hero | Replace :10-23 (the dark .cc-hero band) with <EditorialHero scene={SCENE_HOME} sceneId="home" …/>. Leave .cc-hero in globals.css:100-166 untouched — it is shared with /losing-ground and editing it bleeds. |
| Hero content | eyebrow · h1 (HOME_COPY.h1_lead + accent span) · sub-paragraph (the "$9,400" line is deleted per C8) · CtaGroup primary → #plan, secondary → /rankings · StatLine variant="home" · TrustLine with the GPU RAPTOR badge |
| Anchor | <section id="plan" className="… scroll-mt-20"> around <SearchForm /> at :27. Native fragment nav works with JS off. |
| Metadata | New export const metadata — absolute title (so the layout template does not append the suffix), description, alternates.canonical: "/", openGraph: og({ images: ["/opengraph-image"], url: "/" }), twitter: tw({ images: ["/opengraph-image"] }). Composed through og()/tw() per C9 — never relying on layout inheritance. |
| JSON-LD | <JsonLd blocks={[graph([webPageNode(…), softwareApplicationNode(), homeFaqNode])]} /> as first child. Organization + WebSite come from the layout — do not duplicate. |
| Homepage FAQ | Hard dependency: data/home_faq.json (≥3 entries), rendered via <FaqAccordion> as visible server HTML and fed to faqPageNode. faqPageNode returns null until both exist. Schema without visible copy is a violation. Every answer is validated by 1.5 — non-numeric, or its number interpolated at build time from the spine (D-1). |
| Mid-page | New CTA card (→ /route) + <EmailCapture source="home_mid" /> between the case-study card and the stat grid. |
| Stat grid | :50-55 literals → DATA_STATS; unverifiable stats deleted (C8). |
5B · src/app/commute-cost/[metro]/page.tsx
| Change | Detail |
|---|---|
| Hero | Insert <EditorialHero> after the JSON-LD block, before <article> at :162. Uses sceneForMetro(m.data.transit_mode_mix?.hero_vehicle). Sub-copy names the metro's own mode when confidence !== "low", else omits the clause. The old eyebrow (:176) and h1 (:179-181) move into the hero and are deleted from the article — this also removes the text-xs + var(--accent) 3.02:1 AA failure. |
| TL;DR | <TldrBlock m={m} /> immediately under the hero, above the intro paragraphs. Answer-first, spine-derived, null-omitting. |
| Question H2s | Each major section is introduced by a question H2 in the metro's own words (How much does it cost to commute by transit in {name}?) followed immediately by a 40–55-word answer paragraph composed from spine facts. This is the featured-snippet surface and is distinct from the FAQ block — templates live in copy.ts, word count asserted in seo-check.mjs. Sections with no data get the question omitted, not answered with a gap. |
| Freshness stamp | A visible Data updated <Month YYYY> line rendered as <time dateTime={m.generated}> in the article, sourced from m.generated. Omitted entirely if getDataMeta() returns null. |
| Inline citations | Each data section ends with a one-line source citation (GTFS feed / GBFS / toll authority / SpotHero / CNT H+T) from sourcesForMetro(m.data_coverage), each linking to /methodology#<id>. A metro with no priced fares never cites the fare source. |
| Metadata | Rewrite :24-44 through og()/tw(): metroSerpTitle (≤60), metroDescription (≤155), canonical, self-referencing hreflang en-CA/en-US + x-default (there is no fr-CA document and none is planned — do not fabricate one), og:locale, images on both OG and Twitter → the metro OG route, modifiedTime. Add export const dynamicParams = false (closed slug set — an unknown slug currently triggers a full on-demand render before notFound()). Consider revalidate = false (M16) since the spine only changes on deploy. |
| JSON-LD | Delete the three hand-rolled <script> tags (:147-160) and the local builders (:68-129, :611-661). Render one <JsonLd blocks={[graph([webPageNode, breadcrumbNode, faqPageNode, metroDatasetNode])]} />. |
| FAQ | Replace the plain div/h3/p block with <FaqAccordion>. Fix the guard: :535 {m.seo.faq.length > 0 && → {m.seo.faq?.length ? ( — the schema guard at :69 is null-safe, this one is not. |
notes[] |
New "Data coverage & gaps" section. 32 of 51 metros carry generated explanations for missing blocks that render nowhere today. This is the honest answer for the 20 fare-less metros and exactly the provenance an LLM needs. Zero-cost AEO win. |
| Per-section fallbacks | Only affordability has one (:500-507). Add them for transit fares / bike-share / tolls / parking, fed by notes. ottawa is the worst case — no fares, no affordability, no cheapest_mode. |
| Cost presentation | Replace the raw cost table with <MetroCostCard> + <ModeBars>. |
| CTAs | Hero CtaGroup — primary = prefilled buildRouteHref({ dlat: m.anchor.lat, …, metro: m.slug }), secondary → /rankings. Replace the bottom block :509-531 (currently an unprefilled /route link, the single biggest conversion leak) with the prefilled CTA card + <EmailCapture source={"metro:" + m.slug} />. |
| Long labels | CtaSpec.labelShort + the CSS swap from 3.6. "Calculate your exact commute in Riverside-San Bernardino" overflows at 360px. |
/map CTA |
:202-207 structurally untouched — served by a different geo3 process (commutingcost-hexmap); there is no src/app/map and next.config.js has zero rewrites. Add tracking attributes only. |
| Count fix | :580 See all 50 metros → → `See all ${metroCount()} metros →` |
| Map CLS | The MetroAccessMap wrapper (:222-230) has no reserved height. Add one — with a new animated hero above it, any shift will be blamed on the motion. |
| Truncation honesty | Toll list slice(0, 8) gains a "+N more" count; the bike-share .find() gains a "N systems priced" note. Never silently drop data. |
CHECKPOINT 5 (against $BASE = :3055)
npm run build # must prerender 53 metro slugs
curl -s $BASE/ | grep -o '<h1[^>]*>' # h1 present in server HTML
curl -s $BASE/ | grep -c 'og:site_name' # 1 (C9)
curl -s $BASE/commute-cost/ottawa | grep -c 'cc-eh-scene' # 1 (fallback scene renders)
curl -s $BASE/commute-cost/dallas | grep -c 'No priced GTFS fares' # >=1 (notes rendered)
curl -s $BASE/commute-cost/new-york | grep -o 'See all [0-9]* metros' # "See all 53 metros"
curl -s $BASE/commute-cost/new-york | grep -c 'data-reveal' # 0 (Reveal idle on SSR)
curl -s $BASE/commute-cost/new-york | grep -c '<summary' # >=2 (accordion is real HTML)
curl -s $BASE/commute-cost/new-york | grep -c 'Data updated' # 1 (freshness stamp)
curl -s $BASE/commute-cost/new-york | grep -oE 'href="/route\?[^"]*"' # 2 hits, both with dlat= and metro=
STAGE 6 — Metadata root, JSON-LD graph, OG images
Goal: site-wide identity and social cards, with the OG route proven on one metro before 53 are written.
| # | Task | Path | Change |
|---|---|---|---|
| 6.1 | Root metadata | src/app/layout.tsx:5-16 |
metadataBase: new URL(SITE_ORIGIN) — must land before any OG route, or Next 14 warns and relative image paths do not resolve. Plus title template, robots with max-image-preview:large, and twitter (Q4). Do not add an icons key — 6.3 uses the file convention and both would emit duplicate <link rel="icon"> (M11). |
| 6.2 | Site-wide entity graph | src/app/layout.tsx <body> |
<JsonLd blocks={[graph([organizationNode(), websiteNode()])]} /> above <SiteChrome>. Organization uses only verifiable facts + the real public/img/certihomes-logo.png. No sameAs, no address, no contactPoint — unsourced. websiteNode carries potentialAction (see 6.7). |
| 6.3 | Favicon | src/app/icon.svg |
Next file convention. Ink on cream. Add apple-icon.png only if a real asset exists — otherwise omit the key rather than 404. |
| 6.4 | OG probe first ⛔ | src/app/opengraph-image.tsx |
Build the homepage card only, then prove two unverified assumptions before writing the metro route: (a) generateStaticParams is honored inside opengraph-image.tsx in a dynamic segment on 14.2, and (b) next/og's resvg WASM + bundled font are traced into .next/standalone. runtime = "nodejs" (we read data/ via node:fs). next/og ships inside Next 14.2 — no new dependency. |
| 6.5 | Metro OG ×53 | src/app/commute-cost/[metro]/opengraph-image.tsx |
Written only after 6.4's probe passes. generateStaticParams prerenders all 53 PNGs at build. Cream ground, 24px rule in #9c4a2c; never #cc7a5c for text. Up to 3 stat chips in priority order, each omitted when null — never a — or $0.00 chip. Footer reads Updated {YYYY-MM} (build date), not "Data as of" — CNT H+T is 2022 ACS and stamping the build month on it would overstate freshness on the most-shared artifact (D-4). Per-source vintage stays on-page. Fonts: use ImageResponse's bundled default. Georgia is a local system font and is unavailable to satori — do not write font-family: Georgia; it silently falls back and shifts metrics. |
| 6.6 | No separate twitter-image files | — | Both surfaces declare twitter.images pointing at the OG route. Separate twitter-image.tsx files would double build time for 53 byte-identical PNGs. |
| 6.7 | SearchAction target | src/app/commute-cost/page.tsx |
The spec requires WebSite.potentialAction. A SearchAction pointing at a URL that ignores the query is a false claim, so take the minimal ~10 lines: read searchParams.q and filter the metro list. No restyle of that page — it is out of scope for the visual kit. |
| 6.8 | Verification harness | scripts/seo-check.mjs |
Zero-dep Node, runs against $BASE, all assertions against raw curl HTML — no headless browser. If it passes here, it is server-rendered. Assertions: canonical on both surfaces · og:site_name on both · metadataBase resolved absolute image URLs · one @graph per page · all @id unique · every {"@id"} pointer resolves in-page or to ORG_ID/SITE_ID · FAQPage present iff faq.length ≥ 2 · isBasedOn.length equals the count of true coverage flags · SoftwareApplication present on / · potentialAction present on / · every question-H2 answer paragraph is 40–55 words · zero $0.00 / : — / : null · title ≤60 · description ≤155. Add seo:check + seo:check:ci to package.json. Keep it .mjs — tsconfig.json include is **/*.ts, so a .ts harness would be type-checked by next build and could fail the build on a test-only type error (M17). |
CHECKPOINT 6
- 6.4 probe, before writing 6.5:
bash
ls .next/server/app/opengraph-image* # prerendered body present
ls .next/standalone/node_modules/next/dist/compiled/@vercel/og/ # wasm traced
If either fails: ship one static public/og/default.png wired via metadataBase + explicit images, and defer per-metro cards to a follow-up. Do not let 53 runtime PNG renders land on geo3.
- After 6.5: ls .next/server/app/commute-cost/*/opengraph-image* → 53 bodies
- node scripts/seo-check.mjs --base=$BASE --all-metros → PASS, exit 0
- npm run build 2>&1 | grep -c metadataBase → 0 warnings
- OG PNGs return 200 image/png at 1200×630; ottawa's card renders with no empty chips
- Record the build-time delta (53 pages + 53 PNGs) — this feeds the deploy-window decision in Part F
STAGE 7 — Root artifacts
| # | Task | Path | Change |
|---|---|---|---|
| 7.1 | robots.ts — two real bugs ⛔ |
src/app/robots.ts |
Bug 1: robots.txt groups match exclusively, so the seven AI-crawler groups at :13-19 with allow:"/" and no disallow are explicitly permitted into /admin/ and /api/ that the * group blocks. Every named agent gets disallow: ["/admin/", "/api/"]. Bug 2: :22 emits Host: https://commutingcost.com; the directive takes a bare hostname → use SITE_HOST. Optionally extend the agent list (OAI-SearchBot, CCBot, Bytespider, Amazonbot, Meta-ExternalAgent) — a product call; every listed agent carries the disallow. |
| 7.2 | sitemap.ts |
src/app/sitemap.ts |
Add /methodology, /losing-ground, /api-landing. Add lastModified: new Date(m.generated) to the 53 metro routes (blog has it, metros do not). Keep /admin* and /api/* excluded. |
| 7.3 | llms.txt route |
src/app/llms.txt/route.ts |
Rewrite as a static file reader over data/llms/llms.txt per C4. Delete dynamic = "force-dynamic" (it beat revalidate and forced a 442 KB re-parse per bot hit). force-static + revalidate = 86400. |
| 7.4 | llms-full.txt route |
src/app/llms-full.txt/route.ts |
New, same pattern over data/llms/llms-full.txt. Dot-in-folder-name works — the existing llms.txt route is the proof. Ships via the existing postbuild cp -r data; no deploy change needed. |
| 7.5 | /methodology |
src/app/methodology/page.tsx |
Server, force-static. Sections: what we measure · sources & vintages, rendered from loadMeta() with stable id anchors that inline citations link to · coverage (computed, never hardcoded) · known gaps (deduped notes[]) · <time dateTime> last-updated · the GTFS service day labelled as a sampling parameter, not a vintage (D-3). JSON-LD: WebPage + TechArticle + BreadcrumbList. Linked from every metro's inline citations (where Source: {aff.source} at :488 is currently plain unlinked text), the sitemap, and SiteFooter. |
| 7.6 | Nav / footer | src/components/SiteHeader.tsx |
Add /commute-cost and /methodology to SiteFooter. The 53 metro pages currently have no top-level entry point in the shared chrome. Footer only — not the 9-item primary NAV. |
CHECKPOINT 7
curl -s $BASE/robots.txt > /tmp/robots.txt
grep -A3 'User-Agent: ClaudeBot' /tmp/robots.txt | grep -c 'Disallow: /admin/' # 1
grep -c '^Host: commutingcost.com$' /tmp/robots.txt # 1
curl -s $BASE/sitemap.xml | grep -c '<lastmod>' # >= 53 + blog + static
curl -s $BASE/sitemap.xml | grep -cE '/admin|/api/' # 0
curl -s $BASE/llms-full.txt > /tmp/llms-full.txt
grep -c '^## ' /tmp/llms-full.txt # 53 metro blocks + sections
grep -cE '\$0\.00|: —|: null' /tmp/llms-full.txt # 0
grep -c '^## Attribution' /tmp/llms-full.txt # 1
grep -A6 '^## Dallas' /tmp/llms-full.txt | grep -c 'Median transit fare' # 0 (no priced fares)
grep -A6 '^## Toronto' /tmp/llms-full.txt | grep -c 'Housing + Transportation' # 0 (CNT is US-only)
curl -o /dev/null -w '%{http_code}' $BASE/methodology # 200
STAGE 8 — Conversion plumbing
Goal: make the CTAs actually convert. Sequenced after 7.1 for tidiness, but per C11 the real protections live in the endpoints themselves.
| # | Task | Path | Change |
|---|---|---|---|
| 8.1 | /route prefill (Q5) |
src/app/route/RouteExplorer.tsx |
Import useSearchParams + the codec; seed the destination from params; one effect that fires route_prefilled and focuses the unfilled field. Add id="cc-from" / id="cc-to" to the two AddressInput usages and select by id, not by placeholder string. Do not auto-submit — the origin is empty and it would burn an API call into an error state. src/app/route/page.tsx:13 already wraps in <Suspense>, so no CSR-bailout build error. |
| 8.2 | AddressInput value sync ⛔ the actual prefill bug |
src/components/AddressInput.tsx |
:14 already lazily seeds useState(value?.label \|\| ""). The real defect is that nothing syncs q when value changes later, so a value arriving from the parent updates state but never the visible box. Add:useEffect(() => { if (value?.label) setQ(value.label); }, [value?.label]);The existing guard at :21 (q === value?.label) then correctly suppresses the geocode XHR, so "zero geocode XHR on load" holds — but only after this lands. |
| 8.3 | /route canonical |
src/app/route/page.tsx |
New metadata export with alternates.canonical: "/route". This is what makes a query-param CTA SEO-safe. No utm_ / src= / ref= on any internal link — attribution rides in the analytics payload (from_path), not the URL. |
| 8.4 | Email capture | src/components/EmailCapture.tsx |
"use client". Real <form action="/api/subscribe" method="post"> so it degrades without JS. Honeypot + timing guard. Status <p role="status" aria-live="polite"> always mounted with min-height — the CLS guard; never conditionally mount it. Success string per Q6. |
| 8.5 | Subscribe endpoint | src/app/api/subscribe/route.ts |
Accepts JSON and form-urlencoded. Honeypot/timing → silent 200. In-process rate limit (single pm2 process, so a Map suffices — say so in a comment). Appends JSONL to process.env.CC_LEADS_FILE — outside the repo and outside .next/standalone, because postbuild recopies the build tree on every deploy and a leads file inside it would be destroyed. Stores ip_hash, never a raw IP. |
| 8.6 | Event endpoint | src/app/api/e/route.ts |
POST only. Validates e against the closed EventName union (public endpoint — this is the real control, per C11), 2 KB cap, 204 No Content, no-store, same IP hashing. |
| 8.7 | Analytics mount | src/components/Analytics.tsx + src/components/SiteChrome.tsx |
One delegated click listener on document reading [data-cc-event]. No preventDefault — cmd/middle-click keeps working and sendBeacon survives navigation, so no setTimeout hack. Mounted in both SiteChrome branches so /route (which opts out of chrome at :17) still gets it. hero_view IntersectionObserver + #plan focus assist. scroll_depth is dropped — YAGNI, and it is the one candidate that would move INP. Budget: < 2 KB gzipped. No third-party script, no npm dependency — a tag manager would add a blocking third-party connection to both pilot surfaces. |
CHECKPOINT 8
- The four subscribe curls: happy → 200 + one JSONL line · honeypot filled → 200 + no line · invalid email → 400 · 11th request in a minute → 429
- POST /api/e valid → 204; {"e":"evil"} → 400
- DNT=1 → no beacon fires
- grep -cE '"ip":"[0-9]' $CC_LEADS_FILE $CC_EVENTS_FILE → 0 (no raw PII)
- grep -c 'dlabel' $CC_EVENTS_FILE → 0
- Manual: a metro deep link fills To, focuses the empty From, and produces zero geocode XHR on load
Part E — Files touched
Create (49)
| Path | Kind |
|---|---|
gpu-pilot/content/build_dominant_mode.py |
pipeline |
gpu-pilot/content/validate_metros_seo.py |
pipeline gate |
gpu-pilot/content/build_llms_txt.py |
pipeline |
gpu-pilot/content/sync_to_web.sh |
pipeline |
gpu-pilot/content/metro_mode_mix.json |
generated artifact |
gpu-pilot/content/metros_seo.meta.json |
generated artifact |
gpu-pilot/content/llms/llms.txt · llms-full.txt |
generated artifact ×2 |
<web>/data/metros_seo.meta.json · metro_mode_mix.json · llms/llms.txt · llms/llms-full.txt |
data ×4 |
<web>/data/home_faq.json |
data (validator-gated) |
<web>/src/lib/site.ts · dataMeta.ts · seo.ts · jsonld.ts · copy.ts · motion.ts · site-stats.ts · deeplink.ts · analytics.ts |
lib ×9 |
<web>/src/styles/motion.css |
css |
<web>/src/components/motion/vehicles.tsx · Skyline.tsx · ModeScene.tsx |
server ×3 |
<web>/src/components/motion/SceneMotionController.tsx · CountUp.tsx · Reveal.tsx |
client ×3 |
<web>/src/components/EditorialHero.tsx · ModeBars.tsx · FactGrid.tsx · MetroCostCard.tsx · FaqAccordion.tsx · TldrBlock.tsx |
server ×6 |
<web>/src/components/cta/CtaGroup.tsx · TrustLine.tsx · StatLine.tsx |
server ×3 |
<web>/src/components/EmailCapture.tsx · Analytics.tsx |
client ×2 |
<web>/src/app/opengraph-image.tsx |
route |
<web>/src/app/commute-cost/[metro]/opengraph-image.tsx |
route |
<web>/src/app/icon.svg |
asset |
<web>/src/app/methodology/page.tsx |
route |
<web>/src/app/llms-full.txt/route.ts |
route |
<web>/src/app/api/subscribe/route.ts · api/e/route.ts |
route ×2 |
<web>/scripts/seo-check.mjs |
harness |
scratchpad/contrast.js |
harness |
Modify (18)
| Path | What |
|---|---|
gpu-pilot/content/build_metros_seo.py |
net= bridge on 51 + 2 new metros + mode-mix merge + freshness + meta sidecar |
<web>/src/app/layout.tsx |
metadataBase, robots, site @graph, motion.css import, font <link> |
<web>/src/app/page.tsx |
hero swap, metadata, JSON-LD, FAQ accordion, CTAs, #plan, sourced stats |
<web>/src/app/commute-cost/[metro]/page.tsx |
hero, TL;DR, question-H2s, freshness stamp, inline citations, metadata, single @graph, notes, fallbacks, accordion, ModeBars, prefilled CTAs, guards, counts, map CLS |
<web>/src/app/commute-cost/page.tsx |
hardcoded "50" ×2 + the ?q= filter for SearchAction |
<web>/src/app/sitemap.ts |
lastModified on metros, 3 new routes |
<web>/src/app/robots.ts |
the two real bugs |
<web>/src/app/llms.txt/route.ts |
rewrite as static file reader |
<web>/src/app/route/page.tsx |
canonical |
<web>/src/app/route/RouteExplorer.tsx |
prefill from params |
<web>/src/components/AddressInput.tsx |
value.label → q sync effect |
<web>/src/app/globals.css |
Editorial tokens, --accent-deep ×2 themes + hover, button AA, trust/CTA/honeypot/badge classes, move @import |
<web>/src/components/SiteChrome.tsx |
Analytics mount in both branches |
<web>/src/components/SiteHeader.tsx |
footer links |
<web>/src/lib/metros.ts |
types, loadMeta, metroCount, SITE_ORIGIN re-export |
<web>/src/lib/blog.ts |
SITE_ORIGIN re-export |
<web>/tailwind.config.js |
tokens, fontFamily.editorial, src/lib content glob |
<web>/package.json |
seo:check, data:validate, cwv, verify scripts |
Move out of the ship path
data/best_commute_50.json.bak.newcities.20260712, data/best_transit_50.json.bak.newcities.20260712, data/most_walkable_50.json.bak.newcities.20260712, src/components/SiteHeader.tsx.bak-lg-20260629 → .data-archive/. postbuild currently copies these into .next/standalone.
Part F — Deploy
Web work happens on geo3 (172.26.1.70), reached with nested ssh through geo (-J presents the origin key and is denied).
F1 — Acknowledge the deploy window
postbuild runs rm -rf .next/standalone/.next/static .next/standalone/public && cp -r …. For the duration of that rm+cp, every live user's JS/CSS chunk request 404s against the still-running server. This is pre-existing, but the pilot adds 53 prerendered pages and 53 ImageResponse PNGs, materially lengthening it.
Choose one before deploying (do not pretend the in-place build is zero-downtime):
- Preferred: build in a staging checkout and swap the standalone directory atomically, then pm2 restart.
- Acceptable: pm2 stop commutingcost-web → build → pm2 start, deliberately off-peak, with the Stage-0 rollback snapshot in hand.
F2 — Steps
# 1. Ship data artifacts geocoder -> geo3 (validator runs as a gate inside the script)
bash /home/krish/gpu-pilot/content/sync_to_web.sh /path/to/checkout
# 2. Confirm ONLY expected paths are staged — NEVER git add -A
ssh krish@geo.tlcengine.com "ssh krish@172.26.1.70 'cd /home/krish/commutingcost-web && git status --porcelain'"
# 3. Snapshot the current build tree OUTSIDE the repo
ssh krish@geo.tlcengine.com "ssh krish@172.26.1.70 'mkdir -p /home/krish/cc-web-rollback && \
cp -r /home/krish/commutingcost-web/.next/standalone /home/krish/cc-web-rollback/standalone-\$(date +%s)'"
# 4. Build (node 20 required)
ssh krish@geo.tlcengine.com "ssh krish@172.26.1.70 'cd /home/krish/commutingcost-web && \
source ~/.nvm/nvm.sh && nvm use 20 && npm run build'"
# 5. Confirm the new data artifacts landed in the standalone tree
ssh krish@geo.tlcengine.com "ssh krish@172.26.1.70 'ls /home/krish/commutingcost-web/.next/standalone/data/llms/ \
/home/krish/commutingcost-web/.next/standalone/data/metros_seo.meta.json'"
# 6. Restart by REGISTRY NAME (check ~/.claude/service-registry.json first — never guess)
ssh krish@geo.tlcengine.com "ssh krish@172.26.1.70 'pm2 restart commutingcost-web'"
postbuild already copies content|data|.env.local|.next/static|public → .next/standalone, so data/llms/, data/metros_seo.meta.json, data/home_faq.json and src/app/icon.svg ship with no deploy change.
F3 — Health poll before any verification
The restart briefly 502s: nginx proxies to :3035 and the standalone server takes several seconds to bind.
for i in $(seq 1 30); do
c=$(curl -s -o /dev/null -w '%{http_code}' https://commutingcost.com/)
[ "$c" = "200" ] && { echo "up after ${i}s"; break; }; sleep 1
done
A 502 in the first ~10s is normal boot, not a failure. Only after a 200 do the post-deploy tests mean anything.
F4 — Post-deploy, in order
curl -sI https://commutingcost.com/llms-full.txt→text/plain, cache headers correctcurl -s https://commutingcost.com/robots.txt→Disallow: /admin/under every group;Host: commutingcost.comcurl -s https://commutingcost.com/sitemap.xml | grep -c '<lastmod>'≥ 53 + blog + staticnode scripts/seo-check.mjs --base=https://commutingcost.com --all-metros- T3, T5, T6, T7, T22 against the live origin
- Submit the updated sitemap in Search Console; request indexing for
/and 3 metros - Env: create
/home/krish/commutingcost-web-data/mode 700 owned bykrish; addCC_LEADS_FILE,CC_EVENTS_FILE,CC_SALTto geo3.env.local— never commit it - If any pm2 service was added or removed, regenerate
~/.claude/service-registry.jsonper the standing rule
Part G — Rollback
Three levels, fastest first.
L1 — instant revert (≤2 min). Restore the Stage-0 / F2-step-3 snapshot, which lives outside the repo:
rm -rf /home/krish/commutingcost-web/.next/standalone
cp -r /home/krish/cc-web-rollback/standalone-<ts> /home/krish/commutingcost-web/.next/standalone
pm2 restart commutingcost-web # then run the F3 health poll
L2 — git revert + rebuild (≈10 min, longer than before because of 53 OG PNGs).
git checkout main && git reset --hard ce2c3a6
cp .data-archive/* data/ # only if the moved .bak files are actually needed
npm run build && pm2 restart commutingcost-web
L3 — data-only rollback. If the pages are fine but the spine is wrong (bad mode mix, bad counts):
cp /home/krish/gpu-pilot/content/metros_seo.json.pre-pilot /home/krish/gpu-pilot/content/metros_seo.json
# re-sync, rebuild, restart.
This returns 51 metros, so metroCount() reports 51 and the two new pages 404 — acceptable and self-consistent, because every count is derived. No copy edit needed.
Partial rollback is safe by design:
- data/metros_seo.meta.json absent → getDataMeta() catches and returns null → dateModified / lastModified / the visible "Data updated" line are omitted, not zero-filled. Pages render.
- transit_mode_mix absent → sceneForMetro(undefined) → bus scene. Every metro page still renders.
- home_faq.json absent → faqPageNode returns null, no FAQ section, no schema. Valid.
- JS never runs → the scene animates, all content is visible, all JSON-LD is present. The whole motion system is CSS over server HTML.
Coupled — cannot be partially rolled back: the robots.ts fix and the two /api/ routes. If robots is reverted, revert the API routes with it.
Do not roll back by editing files on geo3 in place. Revert in git, rebuild, restart — the standalone tree is generated, not authored.
Part H — Testing & validation matrix
Run in full before deploy; starred rows again after.
| # | Check | Command / method | Pass |
|---|---|---|---|
| T1 | Build | npm run build |
53 metro pages + 53 OG PNGs prerendered; zero metadataBase warnings |
| T2 | SEO harness | node scripts/seo-check.mjs --base=$BASE --all-metros |
PASS, exit 0 |
| T3★ | Rich Results | Google Rich Results Test on /, /commute-cost/new-york, /commute-cost/ottawa, /methodology |
FAQPage + Dataset + BreadcrumbList + SoftwareApplication detected, zero errors; ottawa (no fares, no affordability, no cheapest_mode) valid with no null or zero values |
| T4 | Schema graph integrity | inside T2 | all @id unique; every {"@id"} pointer resolves; FAQPage present iff faq.length ≥ 2; isBasedOn.length == count of true coverage flags; one @graph per page |
| T5★ | Lighthouse SEO | npx lighthouse <url> --only-categories=seo, both surfaces |
100 |
| T6★ | Lighthouse Best-Practices | same run | ≥ 95 |
| T7★ | CWV with motion ON | npx lighthouse --preset=desktop and mobile-throttled, both surfaces |
LCP < 2.5s · CLS < 0.1 · TBT/INP < 200ms; diffed against the Stage-0 baseline |
| T8 | LCP element identity | Lighthouse LCP audit | the <h1> — not an image, not the map |
| T9 | Main-thread paint (M6) | DevTools Performance, 5 s idle, hero in view, both surfaces | no recurring main-thread paint task ≥ 2 ms/frame. This is the gate the compositor-property grep cannot provide. |
| T10 | HTML weight | curl -s $BASE/ \| wc -c vs baseline |
+ < 8 KB on each surface |
| T11 | Zero client JS for scene | grep -rl "cc-bobber\|cc-modebar" .next/static/chunks/ |
nothing |
| T12 | JS-off proof | curl (never runs JS): h1, TL;DR, question-H2 answers, FAQ answers, JSON-LD, stat numbers, ModeBars labels, all Reveal content |
all present in raw HTML |
| T13 | axe | axe DevTools, both surfaces, both themes | 0 serious/critical; every .btn-* and small-text accent ≥ 4.5:1; accordion keyboard-navigable |
| T14 | Reduced motion | Playwright new_context(reduced_motion="reduce") |
.cc-rider and .cc-skyline animationName === "none"; .cc-rider--back still scale(0.6); Reveal / CountUp / ModeBars instant and fully visible |
| T15 | Mobile Playwright | iPhone-13 viewport, both surfaces (use gpu-pilot/hta/pwenv) |
scrim flips to vertical; headline legible over the scene; CTAs ≥ 44px; short CTA label active < 480px; no horizontal scroll |
| T16 | Cross-browser | Chrome, Firefox, Safari (or WebKit via Playwright), both surfaces | scene renders and animates; scrim geometry correct; <details> accordion works; no layout break |
| T17 | Off-screen pause | DevTools: scroll the hero away | scene gains data-motion="paused"; no recurring paint over 5 s idle |
| T18 | Data spot-check vs DB | psql -h 172.26.1.152 -p 5433 -d commute |
transit_routes route_type vocabulary fully covered by canon(); hta_index has Salt Lake City, UT; cbd_parking_metro has the SLC row; 3 metros' on-page median fare + parking match gtfs_fares / cbd_parking |
| T19 | Mode-mix sanity | metro_mode_mix.json |
san-antonio = 100% bus (Amtrak excluded); new-york = subway; seattle = tram not ferry; Montréal=train / Boston=tram flagged in the run log, not hand-edited |
| T20 | Count coherence | grep + curl | zero literal 50 / 62 in the two surfaces; /, metro pages, llms.txt, /methodology and the sitemap all say 53 |
| T21 | Regression: the tool still works | manual | / SearchForm plans a trip end-to-end; 08854 Piscataway → 10001 NYC Penn returns a sane P&R/transit result; /route unprefilled still works; the /map link opens the geo3 hexmap; /rankings, /compare, /blog, /TLCcosts, /losing-ground render (darker buttons only), light and dark |
| T22★ | Social cards | X Card Validator + LinkedIn Post Inspector, post-deploy | image renders, not a blank frame |
| T23 | Deep-link health | curl -s $BASE/commute-cost/new-york \| grep -oE 'href="/route\?[^"]*"' |
2 hits, both with dlat= and metro=new-york; 5 random metros carry their own anchor |
| T24 | Endpoints | the curls in Checkpoint 8 | as specified; no raw PII in either JSONL |
| T25★ | GEO/AEO citation check | Post-deploy + ~1 week: ask ChatGPT, Perplexity, Claude and Google AI Overviews "how much does it cost to commute in Dallas?" and 4 other metros (5 metros total) | the site is cited or its figures appear; if not, record which competitor is cited and why — this is a measurement, not a pass/fail gate on the deploy |
Part I — Open questions for Krishna
Only genuine decisions. Q1 and Q2 block a stage; the rest have safe defaults that will be taken if no answer arrives.
| # | Question | Blocks | Recommendation |
|---|---|---|---|
| Q1 | ~~53 metro pages vs 62?~~ RESOLVED 2026-07-20 by Krishna. The plan conflated two different numbers. nec/midatlantic/socal are mega-regions covering multiple metros each (NYC, Philadelphia, Baltimore, DC, LA, Long Beach, San Jose...), proven by the spine: new-york is a metro slug with no network dir — it rides on nec, as do 5 other slugs. So metros covered != network dirs, and 62 was never unsupported. Decision: coverage copy says "60+ metros"; ship 53 guide pages (51 + salt-lake-city + edmonton), all counts derived via metroCount(). Nothing is reduced; the two numbers measure different things and both are true. |
~~Stage 1~~ closed — unblocked | Ship 53 pages, say "60+ metros". |
| Q2 | ~~Does the global .btn-primary AA fix land in Phase 1?~~ RESOLVED 2026-07-20 — the premise was false. The live button is #b23a1e = 5.97:1, PASSES AA; the 3.2:1 figure belonged to the proposed #cc7a5c, not the deployed color. There is no live defect, so there is nothing to fix globally. Decision: SCOPE the palette to the two pilot surfaces. Global --accent/.btn-primary and the 18 out-of-scope files are untouched; Phase 3 owns them. Font <link> move is decoupled and proceeds per C12/Q3. |
~~Stage 3~~ closed | Scope it. Verified by measurement, not assumption. |
| Q3 | Drop Fraunces entirely, or keep it for out-of-scope pages? Dropping it removes the Google Fonts request completely (a free LCP win everywhere) but re-renders /rankings, /blog, /compare, /losing-ground, /TLCcosts in Georgia. |
Stage 3.5 | Keep it via a parallel <link> for now; revisit in Phase 3 when those pages get the kit. Default if unanswered. |
| Q4 | ~~Does @commutingcost exist on X?~~ ANSWERED 2026-07-20 by Krishna: the handle does not exist. Omit twitter.site entirely. Do not invent or placeholder a handle. twitter.card + twitter.images still ship. |
~~Stage 6.1~~ closed | Omit the key. |
| Q5 | Is /route in scope for the prefill? /route is not a pilot surface, but the spec explicitly requires "deep-link the tool prefilled with that metro/anchor," and an unprefilled metro CTA is the biggest conversion leak on the template. Work is 3 surgical edits + a canonical + one useEffect in AddressInput — no restyle. |
Stage 8 | Yes, narrowly. Default if unanswered: yes, with no visual change to /route. |
| Q6 | Email: double opt-in or single? EMAIL_COPY.success currently promises "check your inbox to confirm" but nothing sends. |
Stage 8.4 | Ship "You're on the list." and wire confirmation later, or wire ~/bin/send-email now. Do not ship the unkept promise. Default: single opt-in, honest copy. |
| Q7 | Montréal → train and Boston/Philadelphia → tram will read as wrong to a local, though both are arithmetically correct (exo suburban rail outruns the métro by trip count; Green Line / SEPTA trolleys outrun heavy rail). |
Stage 1 (record only) | Do not hand-edit. If overridden later it must be an explicit, documented override table with a stated rationale — never a silent edit to generated data. Default: ship the GTFS answer, log the two cases. |
Appendix — Rules that must survive review
- Never
git add -A. Stage explicit paths;sync_to_web.shprints the exact line. :3035is production. All pre-deploy verification runs on:3055.- Every count derives from
metroCount()/ a manifest scan. No literal50,51,53,54, or62in shipped code or copy. - Never inherit
openGraph/twitteracross the layout/page boundary. Compose throughog()/tw(). - Omit, never zero-fill. No
$0.00, no—, nonullrendered as a fact, in HTML, JSON-LD, llms.txt, or an OG card. - Both contrast numbers (6.1:1 and ≈5.6:1) are correct measurements of different pairs. Neither appears in shipped copy. Say "AA-compliant."
--accent-deepmust exist in:rootand in[data-theme='dark']. Eighteen files depend on it outside any tint scope.- Never put an inline
animationshorthand on a motion element — it out-specifies the stylesheet and defeatsprefers-reduced-motion. jsonld.tsandmetros.tsare server-only. They reachnode:fs; a client import breaks the build in a confusing way.- Rollback snapshots live outside the repo.
next buildowns.next/.