diff --git a/.claude/agent-memory/debugger/MEMORY.md b/.claude/agent-memory/debugger/MEMORY.md
index ab768f2..ace14cd 100644
--- a/.claude/agent-memory/debugger/MEMORY.md
+++ b/.claude/agent-memory/debugger/MEMORY.md
@@ -1 +1,2 @@
- [Windows ISR case collision](windows-isr-case-collision.md) — local next start case-folds cache keys; verify redirect/dynamicParams with next dev or a Vercel preview, not a Windows production build.
+- [Local runtime console noise](local-runtime-console-noise.md) — Vercel analytics 404s, WebGL GPU-stall warnings, and Leaflet CSS preload warning are known non-defect noise on local next start/dev.
diff --git a/.claude/agent-memory/debugger/local-runtime-console-noise.md b/.claude/agent-memory/debugger/local-runtime-console-noise.md
new file mode 100644
index 0000000..bebb00a
--- /dev/null
+++ b/.claude/agent-memory/debugger/local-runtime-console-noise.md
@@ -0,0 +1,46 @@
+---
+name: local-runtime-console-noise
+description: three recurring non-defect console/network noise sources when driving this app locally with a real browser (Playwright) — don't re-diagnose them as bugs
+metadata:
+ type: project
+---
+
+Proven 2026-09-06 during a full runtime console sweep of
+`feat/game-region-path-segment` (all routes, dev + local `next start` prod,
+report: `plans/reports/debugger-260906-1848-runtime-console-sweep.md`).
+
+1. **`/_vercel/insights/script.js` and `/_vercel/speed-insights/script.js` →
+ 404, prod (`next start`) only, every route.** `@vercel/analytics` /
+ `@vercel/speed-insights` (`src/app/layout.js`) fetch these from Vercel's
+ edge platform; a local `next start` has no such endpoint. Dev mode skips
+ the fetch entirely (analytics runs in its own debug mode). Not present on
+ the real Vercel deployment — an artifact of testing prod build outside
+ Vercel's infra, not a code defect.
+
+2. **`[.WebGL-0x...] GPU stall due to ReadPixels` driver warning on every
+ `/game/*` route** (panorama viewer's WebGL canvas), both dev and prod.
+ Chromium/ANGLE driver performance advisory tied to this sandbox's
+ virtual/software GPU doing a canvas readback. Not JS, not app code.
+
+3. **"preloaded but not used" CSS warning on `/`, prod only** for chunk hash
+ matching Leaflet's CSS (shared by `/game/[region]` and `/debug/coverage`,
+ confirmed via `.next/server/app/*/page_client-reference-manifest.js`).
+ Next's production `` prefetch preloads the target route's CSS ahead
+ of a click; the browser's own "unused preload" heuristic fires if the
+ visitor hasn't navigated within a few seconds. Inherent to prod-only Link
+ prefetch, not a bug.
+
+**Why this matters:** these three showed up repeatedly across every route in
+the full sweep and would otherwise look like a wall of new regressions.
+[[windows-isr-case-collision]] covers a separate local-build-only pitfall
+(ISR cache case-folding); this file covers browser-console noise instead.
+
+**How to apply:** when a future runtime sweep on this project reports one of
+these three signatures, don't re-investigate from scratch — confirm it still
+matches this pattern (same env, same route shape) and move on. Also: when
+testing a route with a live map (Leaflet/OSM/Geoapify tiles) or continuous
+canvas rendering, `page.goto(..., { waitUntil: 'networkidle' })` can spuriously
+time out or produce `requestfailed`/`ERR_ABORTED` noise purely from the test
+harness's own wait-condition or page-close timing — re-verify with
+`waitUntil: 'load'` plus a fixed settle delay before treating it as a real
+hang.
diff --git a/docs/game-flow.md b/docs/game-flow.md
index cd4befd..74feee3 100644
--- a/docs/game-flow.md
+++ b/docs/game-flow.md
@@ -35,6 +35,14 @@ conversion, and are the only place the URL casing convention lives.
- An uppercase URL plays rather than redirecting. There is deliberately no
canonical-casing redirect: a redirect on a prerendered route gets cached as
that route's response, and nothing in the app generates an uppercase link
+- Re-casing is the only difference `regionFromSlug` accepts. A spelling that
+ merely uppercases into a code (`hn-badınh`, dotless i) is a 404: it is a
+ different URL, so it would be its own cache entry and its own analytics row.
+ Percent-encoding is the exception it cannot reach — the router decodes the
+ segment first, so `/game/%74phcm` plays; seeing the raw form would mean
+ giving up static rendering on all 85 pages
+- Each region page carries its own title and description (`Ba Dinh —
+ VNGeoGuessr`), so the tab, the history entry and a bookmark name the region
- `/game` with no region plays the whole country
- `?region=` and `?location=` still work; they redirect to `/game/{slug}` and
are kept for links and bookmarks already in the wild
diff --git a/docs/project-structure.md b/docs/project-structure.md
index cb4dd7a..a7320fb 100644
--- a/docs/project-structure.md
+++ b/docs/project-structure.md
@@ -30,12 +30,17 @@ Next.js 16 App Router structure:
#### Game Pages
- `not-found.js` - The app-wide 404 for any unmatched path
- `components/NotFoundPanel.js` - Shared body of both 404s
+- `components/InlineScript.js` - The root layout's pre-paint theme script.
+ Executable on the server, inert (`text/plain`) when React renders it on the
+ client, where a script cannot run anyway — which is what stops React's
+ "Encountered a script tag" console error on the region 404
- `game/[region]/page.js` - The game screen for one region (`/game/tphcm`).
Server Component: validates the slug, prerenders one page per region,
404s an unknown one
- `game/[region]/not-found.js` - The 404 for an unknown region code. A client
component only so it can re-apply the theme: a thrown `notFound()` is served
- from Next's error shell, which carries no pre-paint theme script
+ from Next's error shell, so React renders the root layout on the client,
+ where its inline theme script cannot execute
- `game/page.js` - Redirects the legacy `?region=` / `?location=` links to
`/game/{slug}`; a region-less `/game` goes to the country round
- `credits/page.js` - Data sources, licenses, and open-source credits
@@ -155,10 +160,15 @@ either the in-memory fake or a real Redis. `fake-neon.js`, `mock-neon.js` and
Neon SDK boundary, loaded with small synthetic rows.
`tests/e2e/` holds the Playwright smoke specs (`*.spec.js`, so vitest never
-collects them): the homepage picker, the username modal, and one full round,
-all against browser-level stubs in `tests/e2e/helpers.js` with a fixture
-panorama in `tests/e2e/fixtures/`. Deeper UI behavior (`RegionSelect.js`, the
-coverage page, real panoramas) remains manual testing only.
+collects them): the homepage picker, the username modal, one full round, and
+`routing.spec.js` — the URL contract, which is most of the suite (region paths,
+legacy `?region=` redirects, both 404s, and the layout's inline theme script).
+All run against browser-level stubs in `tests/e2e/helpers.js` with a fixture
+panorama in `tests/e2e/fixtures/`. `global-setup.js` compiles the game routes
+once before the workers start; without it a cold `next dev` makes every test
+that navigates to `/game/*` time out together. Deeper UI behavior
+(`RegionSelect.js`, the coverage page, real panoramas) remains manual testing
+only.
## Documentation (`/docs/`)
- `project-overview.md` - Project overview, administrative basis, coverage note
diff --git a/plans/260906-1122-game-region-path-segment/phase-01-dynamic-route.md b/plans/260906-1122-game-region-path-segment/phase-01-dynamic-route.md
index 5b109cc..195ec73 100644
--- a/plans/260906-1122-game-region-path-segment/phase-01-dynamic-route.md
+++ b/plans/260906-1122-game-region-path-segment/phase-01-dynamic-route.md
@@ -115,7 +115,7 @@ stay as the belt-and-braces it was meant to be.
4. Verify manually: `npm run dev`, then `/game/TPHCM`, `/game/VN`,
`/game/HN-BADINH`, `/game/hn` (expect redirect to `/game/HN`),
- `/game/NOTAREGION` (expect 404), and `/game/DN-HOANGSA` or another real
+ `/game/NOTAREGION` (expect 404), and `/game/tphcm-cuchi` or another real
unplayable code (expect the coverage error panel, not a 404).
5. Run `npm test`, `npm run lint`, `npm run build`. Confirm the build output
@@ -136,7 +136,7 @@ stay as the belt-and-braces it was meant to be.
- [x] `/game/TPHCM` plays a TPHCM round; `/game/VN` plays a country round
- [x] `/game/NOTAREGION` → 404
-- [x] `/game/DN-HOANGSA` → coverage error panel, not 404
+- [x] `/game/tphcm-cuchi` → coverage error panel, not 404
- [x] `/game/hn` → redirect to `/game/HN`
- [x] `next build` output shows the region pages prerendered
- [x] No `useSearchParams` anywhere under `src/app/game/`
diff --git a/plans/260906-1122-game-region-path-segment/plan.md b/plans/260906-1122-game-region-path-segment/plan.md
index 7d1c527..22f67df 100644
--- a/plans/260906-1122-game-region-path-segment/plan.md
+++ b/plans/260906-1122-game-region-path-segment/plan.md
@@ -171,15 +171,15 @@ Phase 3 verifies both.
- [x] `/game/tphcm`, `/game/vn` and `/game/hn-badinh` all play normally
- [x] `/game?region=TPHCM` and `/game?location=TPHCM` both redirect to `/game/tphcm`
-- [x] `/game` redirects to `/game/VN`
+- [x] `/game` redirects to `/game/vn`
- [x] `/game/TPHCM` (any casing) serves 200 directly — no canonical-casing redirect
- [x] `/game/NOTAREGION` returns 404
-- [x] `/game/DN-HOANGSA` (real but unplayable) renders the existing coverage
+- [x] `/game/tphcm-cuchi` (real but unplayable) renders the existing coverage
error panel, **not** a 404
-- [x] `RegionPicker` emits `/game/{code}` hrefs; no `?region=` remains in `src/`
+- [x] `RegionPicker` emits `/game/{slug}` hrefs (lowercase); no `?region=` remains in `src/`
outside API-route call sites
- [x] No `useSearchParams` in the game route
-- [x] `npm test` green, `npm run lint` 0 errors, `npm run build` clean,
+- [x] `npm test` green, `npm run lint` 0 errors, `npm run build:check` clean,
`npm run test:e2e` green
- [x] Docs updated to the new URL shape
diff --git a/plans/reports/code-reviewer-260906-1659-region-path-diff-review.md b/plans/reports/code-reviewer-260906-1659-region-path-diff-review.md
new file mode 100644
index 0000000..46526f9
--- /dev/null
+++ b/plans/reports/code-reviewer-260906-1659-region-path-diff-review.md
@@ -0,0 +1,185 @@
+---
+title: "Code review: feat/game-region-path-segment vs main (post-6d7cb30)"
+plan: plans/260906-1122-game-region-path-segment/plan.md
+branch: feat/game-region-path-segment
+reviewed: 2026-09-06
+status: DONE_WITH_CONCERNS
+---
+
+# Code review: region path segment, current tree
+
+Second-pass review. Everything the 1237 review raised (H1 unencoded redirect
+target, M1 stale JSDoc, M2 missing `key`, M3 no `not-found`) is fixed in the
+committed tree — verified line by line, not taken on trust. This report covers
+only what is still true, plus the surface introduced by `6d7cb30`.
+
+## Scope
+
+- `src/`: `game/page.js`, `game/[region]/page.js`, `game/[region]/not-found.js`,
+ `not-found.js`, `components/NotFoundPanel.js`, `components/GameClient.js`,
+ `components/RegionPicker.js`, `lib/regions.js`
+- `tests/`: `regions.test.js`, `e2e/{routing,game,home,username}.spec.js`
+- Ran: `npm test` (19 files, 268 tests, green), `npm run lint` (0 errors,
+ 19 warnings, none in changed files), `npm run build:check` (clean;
+ 101 static pages, `/game/[region]` = 85 SSG, `/game` = f dynamic)
+
+## Verdict
+
+No critical or high findings. Two medium items worth a decision before this is
+called done; both are consequences of the migration rather than defects in it.
+
+## Medium
+
+### M1 — `regionFromSlug` normalises where it should validate (CONFIRMED)
+
+`src/lib/regions.js:68-71`
+
+```js
+const code = String(slug).toUpperCase();
+return isRegion(code) ? code : null;
+```
+
+The doc comment above it (`:59-64`) frames the accepted input set as "casing".
+It is not. `String.prototype.toUpperCase` is full Unicode case mapping, so the
+accepted set is *every string whose uppercase equals a region code*. Verified
+against the real tree, not reasoned about:
+
+```
+regionFromSlug('hn-badınh') -> 'HN-BADINH' // U+0131 dotless i
+```
+
+`long s` (U+017F) maps to `S` the same way, so `hn-ontay` also resolves;
+5 codes contain `S` and 5+ contain `I`.
+
+Failure scenario: `GET /game/hn-bad%C4%B1nh` returns **200** and plays a real
+Ba Dinh round. On Vercel that URL is not in `generateStaticParams`, so
+`dynamicParams: true` renders it on demand and caches the result as its own ISR
+entry (the debugger report measured `s-maxage=31536000` for this route class),
+and Web Analytics records it as its own path row. The entire stated purpose of
+this branch is one analytics row per region; a homoglyph or mixed-case link
+silently splits it, and the URL space that does this is unbounded, not the
+"one stray row" the comment budgets for.
+
+No security impact — the value handed to `GameClient` is always the canonical
+uppercase code, `isRegion` gates it, and nothing downstream trusts the slug.
+This is a caching/attribution finding.
+
+One-line fix that keeps the stated intent (`/game/TPHCM` must still play):
+
+```js
+export function regionFromSlug(slug) {
+ const raw = String(slug);
+ const code = raw.toUpperCase();
+ // Reject anything that is not an ASCII re-casing of the slug: a dotless-i
+ // spelling uppercases to a real code but is a different URL, and would be
+ // cached and counted as one.
+ return isRegion(code) && regionSlug(code) === raw.toLowerCase() ? code : null;
+}
+```
+
+`'TPHCM'.toLowerCase() === regionSlug('TPHCM')` → still 200.
+The dotless-i spelling lowercases to itself, not to `hn-badinh` → 404. Covered
+by extending the existing casing test in `tests/regions.test.js:47-56` with one
+line.
+
+### M2 — 85 new indexable pages, one shared `` (CONFIRMED)
+
+`src/app/game/[region]/page.js:13-15`, `src/app/layout.js:19-22`
+
+Build output confirms 85 SSG pages under `/game/[region]`. None exports
+`metadata` or `generateMetadata`, so every one inherits the root
+`title: "VNGeoGuessr"` / `description: "GeoGuessr for VietNam"`. There is no
+`robots.txt` and no `sitemap`.
+
+Failure scenario: before this branch there was exactly one `/game` URL and the
+region lived in a query string crawlers ignore. Now a crawler reaches 85 thin
+JS-shell pages with byte-identical titles and descriptions — a duplicate-title
+cluster. The same gap is visible to the user: browser tab, history entry and
+bookmark for `/game/hn-badinh` all read "VNGeoGuessr", so the one thing the new
+URL shape newly expresses is the one thing the page never says.
+
+Fix is a `generateMetadata` beside `generateStaticParams`. **Guard it** — this
+is the trap: `getRegion()` (`src/lib/regions.js:23-31`) *throws* on an unknown
+code, so a naive `generateMetadata` would turn the honest 404 at
+`/game/notaregion` into a 500 before `notFound()` is ever reached. Resolve with
+`regionFromSlug` first and return the bare root metadata when it is `null`.
+
+Non-blocking, and arguably a follow-up rather than this branch's job — but the
+branch is what created the surface.
+
+## Low
+
+- **L1** — `src/app/game/[region]/not-found.js:22-24` applies the theme once on
+ mount and never subscribes. `watchSystemTheme` exists in `src/lib/theme.js:81`
+ and `ThemeToggle` uses it. A `system`-theme visitor who flips OS appearance
+ while sitting on this page keeps the old palette until they navigate. Rare
+ page, rare gesture; noted for completeness only.
+- **L2** — `tests/e2e/home.spec.js:38-58` and `tests/e2e/username.spec.js`
+ repair drift caused by `6251ea5` (DebugFooter split into link + button) and
+ the landing-prompt change, both already on `main`. The repairs are correct and
+ test real behaviour — verified `DebugFooter.js:72,86` against the new
+ assertions — but they are unrelated to this feature and widen the diff a
+ `git bisect` on region routing would have to step over.
+- **L3** — `tests/e2e/routing.spec.js:115,120` imports `../../src/lib/regions.js`
+ twice in one test. Cosmetic.
+- **L4** — `NotFoundPanel` was created against the ui-ux review's explicit
+ recommendation not to abstract two eight-line panels. The component is
+ justified in its own header comment and has a real domain anchor, so this is a
+ defensible reversal, not scope creep. Recording the disagreement, not
+ reopening it.
+- **L5** — `plans/260906-1122-game-region-path-segment/plan.md:174,179` still say
+ `/game/VN` and `/game/{code}`; the shipped convention is lowercase. Plan text
+ only, and plan mutation is not mine to make. For the lead.
+
+## Explicit checks requested
+
+| Check | Result |
+|---|---|
+| Region slug validation | `[region]/page.js:31-33` resolves through `regionFromSlug` and `notFound()`s before rendering. `GameClient.js:97` keeps `isRegion` as a belt-and-braces guard, correctly described as such. **Pass**, with M1. |
+| Case handling | `regionSlug` = `toLowerCase`, `regionFromSlug` = `toUpperCase`. Round trip asserted for all 85 codes (`tests/regions.test.js:38-44`) and slug charset asserted `^[a-z0-9-]+$` (`:28-36`). Both green. Uppercase URLs serve 200 without redirect, by design. **Pass**, with M1. |
+| 404 on unknown region | `notFound()` thrown in the render path, not returned, not wrapped in `try/catch` — matches `node_modules/next/dist/docs/01-app/03-api-reference/04-functions/not-found.md`. `[region]/not-found.js` is the closest boundary. E2E asserts status 404 and the visible way out. **Pass.** |
+| Legacy `?region=` redirect | `game/page.js:36-51`. `firstValue` handles `string / string[] / undefined / ''` — the full documented search-param shape. `region` beats `location`, matching `main`'s `GameClient.js:171`. Destination is `encodeURIComponent`'d, so CRLF and `../` both end at an honest 404 (E2E `routing.spec.js:89-99`). Prototype-named keys (`?constructor=`, `?toString=`) fall through `firstValue`'s `typeof` check harmlessly — checked. **Pass.** |
+| Server/client boundary | `[region]/page.js` imports exactly `next/navigation`, `GameClient`, `lib/regions`. No path to `pano-index`, `pano-db`, `pano-history`, `data/panos`, `data/boundaries`. The `use client` import-walk in `tests/regions.test.js:263-325` now covers the two new client files (`[region]/not-found.js` and, transitively, `NotFoundPanel`). **Pass.** |
+| Resolved district / coordinates | The page passes exactly one prop, `region={code}` — a code the URL already contains. Prerendered HTML is identical for every visitor and contains no panorama data. `GameClient.js:16-19` still sources the revealed path from `/api/guess`, not client-side. **Pass.** |
+| Windows vs Vercel case-sensitivity | The local `next start` ISR case-folding quirk is a known accepted local-verification hazard; not reported. Nothing in the current tree redirects from `[region]`, so the corruption path the debugger reproduced has no trigger left. |
+| Concurrency | `key={code}` on `GameClient` (`[region]/page.js:41`) forces a remount on region change, closing the prop-vs-closure desync class outright. Epoch/watchdog/prefetch machinery is byte-identical to `main` apart from the identifier swap. **Pass.** |
+| Error boundaries | `redirect()` and `notFound()` both signal by throwing and neither is caught. `applyTheme` in an effect cannot throw on a client (`window` guaranteed). **Pass.** |
+| Backwards compatibility | Every legacy URL shape still resolves; no API route, schema or exported contract touched. `regionSlug`/`regionFromSlug` are additive. **Pass.** |
+| Auth / authz / N+1 | Not applicable — anonymous public routes, no database access on either page. |
+| `Button asChild` in a Server Component | `NotFoundPanel` is a Server Component importing `@/components/ui/button`. Same pattern already shipped in `src/app/debug/layout.js:20-31`, and `build:check` prerenders `/_not-found` clean. **Pass.** |
+
+## Accepted trade-offs, deliberately not flagged
+
+- Windows `next start` case-folds ISR cache keys; local-only, already in the
+ debugger's agent memory.
+- Debug routes stay in production.
+- `/game` changes from static to `f` dynamic — one function invocation per
+ legacy hit, `no-store`. Inherent to reading a query string server-side. (A bot
+ can spend invocations with random `?region=` values, but so can it against any
+ `/api/*` route already shipped.)
+- Region-less `/game` now means the country round, not TPHCM.
+- No canonical-casing redirect from `/game/[region]`; a redirect on an ISR route
+ gets cached as that route's response.
+- A real region with no imagery renders the API's coverage message instead of
+ 404ing.
+- Legacy redirect is 307, not `permanentRedirect`'s 308 — conservative, keeps
+ the region-less default changeable without stranding browser caches.
+- The region 404 flashes light before hydration; documented in the file itself.
+- Legacy absolute-ladder scores stay mixed on the boards.
+
+## Plan status
+
+`plan.md` is `status: completed` and all 12 acceptance boxes are checked. I
+verified each against the tree and the build; all hold, with the two text
+staleness nits in L5. No follow-up phase is required for the plan itself —
+M1 and M2 are new findings, not unfinished plan work.
+
+## Unresolved questions
+
+1. Is M1 worth the one-line guard, or is the homoglyph URL space acceptable
+ under the same "one stray row" reasoning already accepted for casing? The
+ difference from the accepted case is that this set is unbounded and each
+ member is a year-long ISR entry.
+2. Does M2 belong to this branch or to the analytics plan
+ (`plans/260906-1057-game-analytics-events/`)? Per-region titles and
+ per-region analytics rows are the same product intent.
diff --git a/plans/reports/code-reviewer-260906-1751-applied-fixes-verification.md b/plans/reports/code-reviewer-260906-1751-applied-fixes-verification.md
new file mode 100644
index 0000000..4ec71b2
--- /dev/null
+++ b/plans/reports/code-reviewer-260906-1751-applied-fixes-verification.md
@@ -0,0 +1,181 @@
+---
+title: "Applied fixes verification: feat/game-region-path-segment (uncommitted)"
+plan: plans/260906-1122-game-region-path-segment/plan.md
+branch: feat/game-region-path-segment
+date: 2026-09-06
+status: DONE_WITH_CONCERNS
+sources:
+ - plans/reports/fix-260906-1717-review-findings-resolved.md
+ - plans/reports/code-reviewer-260906-1659-region-path-diff-review.md
+---
+
+# Applied fixes verification
+
+Adversarial review of the uncommitted working-tree changes (9 files) on top of
+the 3 committed branch commits. All 5 claims hold. One new Medium finding, two
+Lows, one residual the new comments overstate.
+
+## Gates actually run
+
+| Gate | Result |
+|---|---|
+| `npm test` | 19 files, **268 passed**, 5.3s |
+| `npm run lint` | **0 errors, 19 warnings**; none in a changed file (all pre-existing `react-hooks/*`) |
+| `npm run build` | clean, Next 16.3.3 Turbopack, 101 static pages, `/game/[region]` still SSG x85 |
+| `npx playwright test tests/e2e/routing.spec.js --workers=1` | **14/14 passed**, 17.6s |
+
+Two measurement caveats, neither a defect in the diff:
+
+- First `npm run lint` crashed with `ENOENT: no such file or directory, scandir
+ 'test-results'` — eslint walked a Playwright artifact dir a concurrent run was
+ writing. `.gitignore` has `/test-results/`; `eslint.config.mjs` `ignores` does
+ not. Clean on re-run.
+- The full e2e spec at default worker count timed out on the 8 game-page tests
+ (60s each); all pass in 15s with `--workers=1`. Coincided with a peer agent
+ editing `not-found.js` mid-run (Turbopack dev recompile). Not the diff.
+
+No dev server left running; ports 3000-3019 confirmed free at exit.
+
+## Claim-by-claim
+
+### 1. `regionFromSlug` round-trip guard — VERIFIED
+
+`src/lib/regions.js:75-80`.
+
+- All 85 codes match `/^[A-Z0-9-]+$/` (checked against the real tree), so for
+ any pure-ASCII input the guard is a tautology: **0 wrong rejections** over
+ 1955 generated re-casings (all-lower, all-upper, alternating, 20 random per
+ code).
+- Brute-forced every code point `U+0080..U+10FFFF` for a residual bypass — a
+ char whose `toUpperCase()` is ASCII `[A-Z0-9-]` **and** whose `toLowerCase()`
+ equals that uppercase lowercased. **Zero hits.** Covers dotless i (U+0131),
+ long s (U+017F), Kelvin sign (U+212A), Angstrom (U+212B), capital sharp s
+ (U+1E9E) and the `fi`-class ligatures (multi-char expansions compared as whole
+ strings, not per char).
+- `toUpperCase`/`toLowerCase` are locale-independent by spec
+ (`toLocaleUpperCase` is the locale-sensitive pair), so a `tr-TR` server locale
+ cannot reintroduce the İ/ı problem. Turkish dotted İ (U+0130) and `i`+U+0307
+ both 404 live.
+- Live statuses (dev :3011): `/game/TPHCM` 200, `/game/TpHcM` 200,
+ `/game/hn-badinh` 200, `/game/hn-bad%C4%B1nh` 404, `/game/hn-%C5%BFontay` 404,
+ `/game/hn-bad%C4%B0nh` 404, `/game/hn-badi%CC%87nh` 404, `/game/%E2%84%AAhn` 404.
+
+**Residual — the comment overstates (Medium-low).** Percent-encoded ASCII is
+still accepted and is neither normalised nor redirected: `GET /game/%74phcm`
+returns **200**, final URL unchanged. That is a distinct URL string with its own
+cache entry and its own analytics row — precisely the argument used to justify
+the guard. So `src/lib/regions.js:66` ("Re-casing is the ONLY accepted
+difference") and the matching `docs/game-flow.md` bullet are false as written.
+Narrow the wording to *case*, or close the family.
+
+### 2. `generateMetadata` — VERIFIED
+
+`src/app/game/[region]/page.js:40-56`.
+
+- Guard holds. `/game/notaregion` returns **404, not 500**, in dev (:3011) and
+ in production `next start` (:3012); the title falls back to the root
+ `VNGeoGuessr`, which is the `return {}` branch executing. `notFound()` still
+ injects ``.
+- API usage matches Next 16.3.3 docs (`generate-metadata.md`, `not-found.md`):
+ `params` awaited as a Promise, no `metadata` export in the same segment,
+ server component (no `"use client"`), `{}` inherits the parent.
+- `regionPath(code).slice(1).reverse().join(', ')` correct at all three levels,
+ read out of the prerendered HTML:
+ - `game/vn.html` → `Vietnam — VNGeoGuessr` / "…in Vietnam" (`|| name` fallback fires)
+ - `game/tphcm.html` → `Ho Chi Minh — VNGeoGuessr` / "…in Ho Chi Minh"
+ - `game/hn-badinh.html` → `Ba Dinh — VNGeoGuessr` / "…in Ba Dinh, Ha Noi"
+- No new data reach: `regionFromSlug` + `getRegion` + `regionPath` are sync tree
+ lookups, no fetch, no N+1, build still prerenders all 85.
+- The comment at `page.js:47` said "Outermost first" (the opposite of the
+ produced order) when I started; a peer corrected it to "Narrowest first"
+ mid-review. Resolved in the tree.
+
+**Informational.** An unplayable region now advertises a round it cannot serve:
+`/game/dn-hoangsa` gets "Guess where you are in Hoang Sa, Da Nang, from street
+view." while the page renders the no-coverage panel.
+
+### 3. Panel contrast + 404 label — VERIFIED
+
+`NotFoundPanel.js:31` is `text-foreground text-sm`; "Go to VNGeoGuessr" is
+present in the production `/nosuchpath` HTML with `href="/"`. The 4.05:1 /
+3.09:1 figures in the comment are carried from the ui-ux report and were not
+re-measured here.
+
+### 4. `watchSystemTheme` subscription — VERIFIED, one doc caveat
+
+`src/app/game/[region]/not-found.js:26-30`. `reapply` is declared inside the
+effect and calls `getStoredTheme()` fresh on every invocation, so no stale
+closure; the unsubscribe is returned from the effect; `applyTheme` is idempotent
+(`classList.toggle(_, bool)` plus a direct `colorScheme` assign), so the
+mount-time call plus a later OS flip cannot double-apply.
+
+**Low.** It diverges from `ThemeToggle.js:36-38`, which subscribes **only** when
+the choice is `'system'`. This one subscribes unconditionally and leans on
+`applyTheme(getStoredTheme())` being a no-op for an explicit light/dark choice —
+true, but it makes `src/lib/theme.js:76` ("The callback fires only while
+following the system") false for the second caller. That line described
+ThemeToggle's gating, not the function. Fix the JSDoc or gate the subscription.
+
+### 5. Tests — VERIFIED, and proven load-bearing
+
+Not phantom tests. During my run a peer agent mutated both `actionHref` values to
+`/broken`; both 404 e2e tests then failed at `routing.spec.js:147` — the new
+`toHaveAttribute('href', '/')` assertions. The mutation has since been reverted.
+The two unit lines and the percent-encoded homoglyph e2e case all exercise the
+guard and fail without it.
+
+## New finding (Medium): the region-404 "renders inside the root layout" claim is not what the assertion proves
+
+Measured server responses, dev **and** `next start`:
+
+| Request | shell | `Made by` | `No such region` | `` |
+|---|---|---|---|---|
+| `GET /game/notaregion` | `` | **0** | **0** | `VNGeoGuessr` |
+| `GET /nosuchpath` | root layout | 1 | — | — |
+
+The region 404's server response carries neither the root layout nor the panel —
+18.7 KB of error shell. The heading, the exit link and the footer all appear only
+after hydration, which is why Playwright's `getByText('Made by')` passes.
+Consequences:
+
+1. `tests/e2e/routing.spec.js:149-152` — "this is the assertion that catches a
+ collapsed first paint" — is wrong. The assertion runs against the hydrated
+ DOM and cannot see first paint. Finding P3 ("region 404 centring unverified")
+ remains unverified for first paint.
+2. `fix-260906-1717` section P3, "the region 404 does render inside the root
+ layout, so the panel centres", is true post-hydration only.
+3. `not-found.js:19` calls the cost "a light flash before hydration". The real
+ first paint is an **empty** error shell — no heading, no way out, no footer.
+ Same root cause as the `Encountered a script tag while rendering React
+ component … never executed` warning in the Playwright log: the root layout's
+ pre-paint theme `