Document the z-index tokens and the isolate-not-out-bid rule for third-party
ladders, the footer and action-bar height tokens, how the game screen's
vertical budget and safe areas constrain floating chrome, which Leaflet chrome
each phone map state shows, and why percentage heights collapse below the
sticky-footer column.
The free plan asks for all three credits for non-osm-carto styles -- Geoapify,
OpenMapTiles for the tile schema, OpenStreetMap for the data -- and the page
carried only two. It matters more now that the credit is suppressed on the
collapsed minimap thumbnail.
The two-column grid held half the width empty until a point was picked, and
reflowing the map on selection moved the very dot the user had just clicked.
From lg the inspector now floats over the map's right edge; a phone has
neither the width to float into nor the height to split, so there it takes the
surface and its close button brings the map back.
The map keeps a 16rem floor below lg: the page chrome takes 225px, so a purely
flexed surface collapsed to 98px on a landscape phone. Escape closes the
inspector unless a select owns the keypress, a ResizeObserver re-measures the
map when it reappears, and the tile attribution moves to the corner the
inspector does not cover.
The how-to-play hint was a free-floating overlay centred on the whole game
box, which put it over the guess map search field on desktop and over the
Mapillary attribution -- required visible by their Terms of Use -- on phones.
It now rides PanoramaViewer's topBarSlot as a flex sibling of that credit, so
neither collision is representable.
Replace the ad-hoc z-index values and the two forced 9999 rules with one
ladder of tokens in globals.css. Leaflet and Photo Sphere Viewer ladders are
contained by isolate on the panes that host them rather than out-bid, which
lets the dialog scrim finally cover the action bar and the minimap.
Also: pad the header and content box with the left/right safe-area insets that
viewportFit cover requires, size the collapsed minimap against the viewport so
a landscape phone shrinks it instead of pushing it into the panorama chrome,
hide Leaflet's credit and zoom buttons on that thumbnail and lift the zoom
control clear of the wrapped credit when it expands, move the desktop
"Click to place your guess" badge off the tile credit, raise the hint dismiss
target to 44px, and move the sticky-footer column off body so Radix portals
are not flex items.
Render the credit and build stamp as an in-flow footer row in a
sticky-footer body column instead of a fixed overlay, so it can no
longer sit on top of the panorama, mini-map, or action bar. Page roots
now fill the column with flex-1, and the elements that used to reserve
space for the overlay drop their clearance offsets.
Open the username modal on first visit when localStorage holds no name,
instead of waiting for the first Play click. Dismissing the prompt with
no saved name falls back to a generated Player-xxxxxx, so every exit
path leaves a leaderboard name behind.
Centralize color token system in globals.css, update theme.js with semantic naming, replace icon library references with Lucide, rename vn-surface class, and update all component references.
Defer username prompt to first Play click, make username editable in header chip, generate random Player-xxxxxx name on skip or deep-link, update tests accordingly.
Geoapify free plan requires three linked credits for osm-bright styles
(Geoapify, OpenMapTiles, OSM contributors); the OSM fallback credit now
links to openstreetmap.org/copyright per OSM guidelines.
Adds licensing research reports (Mapillary ToU verification, tile-provider comparison) and a complete plan for the Geoapify tile-provider migration with two execution phases.
Introduces map-tiles.js module to centralize tile provider logic, supporting Geoapify (paid, high quality) with API key fallback to OSM public server. Updates LeafletMap, ResultMap, and CoverageMap to use the new abstraction, improves maintainability and consistency across the codebase.
Adds an overlay to acknowledge Mapillary imagery copyright and a dedicated /credits page displaying all data sources and providers with proper attribution.
Add improved hover/active feedback to debug button, safe-area offset for
better mobile positioning, descriptive title, and z-index adjustment for
proper stacking context.
Extract shared app bar (Home, DebugNav, ThemeToggle) to debug/layout.js,
consolidate tools as cards in debug/page.js, move bbox/Mapillary tester
to debug/bbox/page.js, apply shared shell to debug/coverage/page.js with
RegionSelect title row, and update project-structure docs to reflect new
debug section organization.
Expose NEXT_PUBLIC_COMMIT_SHA via next.config.mjs (resolves full commit
SHA from VERCEL_GIT_COMMIT_SHA or git), render copyable footer stamp in
DebugFooter component (bottom-center, underlined, a11y-tuned), and add
e2e test for footer visibility and clipboard interaction.
Document 260831-1906 plan phases, execution journal, and code review reports
covering scoring ladder, game state refactor, home region continuity, and
leaderboard UI fixes. Multiple review passes verified test coverage and
component contracts.
Leaderboard distance colors now computed against each board's own region's
scoring ladder, matching how boards are credited. 2km distance shows green
on country board (2 points) and red on district board (0 points). Remove
dead cdnjs config from ResultMap. Update project-structure.md.
Track last played region in localStorage and show \"Continue in [region]\"
button on home page. Add scoring table derived from SCORE_BANDS with caption
explaining region-relative ladder. New last-region.js utility module.
Separate initialLoading/roundLoading/submitting concerns to prevent premature
panorama unmount. Add roundKey viewer remount trigger and round-epoch watchdog
to prevent stale fetches from replacing the current round. Next-round prefetch
in result dialog. Replace alert() with inline error+retry. Last-region write
on successful load.
Implement region-scaled scoring: base ladder (0-5 points) adjusts thresholds
proportionally to the picked region's bbox diagonal. Leaderboards credited
separately using their own region's ladder, preventing country-round guesses
from earning inflated district-board points. Score and per-level points
returned in gameResult.bands and gameResult.levels.
- replace the pre-redesign gradient, white/10 glassmorphism and text-white
with vn-gradient-bg, bg-card surfaces and foreground/muted tokens, so the
page follows the light/dark theme like every other screen
- document the styling conventions and the deliberate raw-color exceptions
(score bands, podium, panorama surround) in docs/development.md
- 9 chromium specs: region picker, username modal, one full round to the
reveal and next-round reset
- every /api/* call, the panorama image, and OSM tiles stubbed via
page.route, so the lane runs offline with no services or env
- webServer starts or reuses next dev (never reuses in CI)
- panoramas + pano_provinces tables replace 28MB bundled JSON; pano-index.js
now draws via cached COUNT + ORDER BY id OFFSET with rejection sampling,
composite (province,id)/(district,id) indexes support the skip
- scripts/seed-pano-db.mjs validates pipeline artifacts (the old real-data
vitest invariants, extracted to scripts/lib/pano-artifacts.mjs), stages
into panoramas_next, verifies, renames into place in one transaction,
keeps panoramas_old as backup; --province reseeds in place; --check
validates only
- pipeline writes gitignored data-build/panos/; pano barrel removed
- tests run against PGlite mocked in at the @neondatabase/serverless
boundary, mirroring the fake-upstash pattern; fixtures replace real data
- infrastructure errors rethrow instead of reading as missing coverage;
session ids via crypto.randomUUID (uuid package dropped)
Adds offline region-tree matching with diacritic folding and Vietnamese
aliases (quận/q7 for District N), plus Photon geocoder for streets and
places bounded to the played region. Selection pans/zooms only; guess
placement remains click-only on the map. LeafletMap now supports zoomPosition
and proper teardown. Includes client-safety tests to prevent server-data
imports in this module.
Move result display, map panels, and leaderboard into dedicated components,
reducing GameClient complexity. Removes unused tabs component and cleans
up unused parameters.
Reconcile every doc with the shipped code. All six described a flat
five-city model, and features.md, tech-stack.md, game-flow.md and
project-structure.md still documented the dart-throw over /images?bbox=
that the prebuilt panorama indexes replaced.
project-overview.md gains a Coverage note that classifies absent coverage
into its three causes -- not yet added, no street imagery, missing from
the boundary -- because a note that only says "partial" teaches
maintainers to ignore real gaps. Cu Chi is named as the one instance of
the third, which is the only one that is a defect.
A context hook had been denying access to src/app/debug/coverage/page.js,
so this also lands the two items earlier phases recorded as
undeliverable: the api/debug/city-coverage -> region-coverage rename, and
the page's migration from a flat city list to RegionSelect. With its last
caller gone, game.js drops CITIES, cities, cityNames, cityCenters and
cityBboxes; getCityIndex, indexedCities and fetchCityPanorama take names
that match what they now take.
Selecting a district with no boundary returned a 400 and left the
previous region's panorama count and outline on screen beside the error,
so Ho Chi Minh's 184,938 read as Cu Chi's. The error path now clears
everything derived from the previous region, and a null boundary removes
the outline rather than skipping the redraw.
Two plan-time claims did not survive contact with the code and the docs
follow the code: VN scores like any other node rather than being a
zero-scoring exploration mode, and the fan-out credits two levels when a
panorama falls outside every district outline.
The home page is a province accordion. The province row plays that province
and only the chevron expands it, so picking Ha Noi stays one click while its
thirty districts stay reachable. Districts with no coverage are listed and
disabled rather than hidden, with the reason shown -- absent coverage is
something the tree knows about, and a district that silently vanishes is more
confusing than one that says why.
The leaderboard modal browses by level and then by region. It used to fetch
every board when it opened, which was twelve requests for five cities and
would have been a hundred and thirty-four for sixty-seven regions; it now
fetches only the board on screen, caches within a session, and clears on open
so a player who just scored does not see a stale total. A failed fetch says so
instead of rendering as an empty board, which would have read as wiped
leaderboards during an outage.
A round shows one rank row per level it credited, and reveals where the
panorama actually was -- the interesting part when the player chose a province
or the whole country. A submission that did not record now says that plainly.
It previously rendered as a confident nine-hundred-and-ninety-nine-kilometre
miss, indistinguishable from a real one.
Removing the old fixed global/city rank state left its setters behind as free
identifiers, which would have thrown on every Next Round and Skip. Lint, the
build and all two hundred and sixty tests passed over it, because the preset
enables neither no-undef nor no-unused-vars and this project has no type
checker. no-undef is now an error, verified by reintroducing the fault.
The accordion and select come from Radix. Both were absent from the component
library, and hand-rolling an accessible accordion and a grouped listbox would
have shipped broken keyboard support.
The debug coverage page is unchanged: it cannot be read in this environment,
so it still lists cities and still calls the route by its old name. The city
lookups in lib/game.js remain live for that reason.
A round now carries two regions. The one the player picked is public and comes
back in every response; the one the panorama actually sits in is a secret, and
it is what the leaderboard fans out from. Revealing the second before the
guess would collapse a country-wide round to a single district, so it joins
the exact coordinates on the never-serialized list -- including in the session
lookup handler, which echoes fields back to whoever holds the session id, and
that is the player.
Session consumption is now a claim rather than a courtesy. Reading a session
and then deleting it is not a guard: ten concurrent submits all read it alive,
all delete it, and all score. DEL is atomic and returns how many keys it
removed, so exactly one caller sees a 1 -- the route scores only if it won
that. Consuming before the writes also closes the sequential case, where a
failure partway through the fan-out would otherwise leave the session alive
for half an hour and let a retry re-credit every level that already succeeded.
A guess lost to a mid-write failure is the accepted cost.
Region parsing lives in one place. Four routes accept a region, and four
slightly different ideas about casing and defaulting is how a typo becomes a
leaderboard key nobody reads. Unknown and uncovered codes are rejected with a
400 that names the region, rather than served as an empty board that looks
exactly like a region nobody has played yet.
Sessions created before this change still score, at province level, since they
carry no district. They expire within half an hour, so the fallback can go a
release from now.
The debug coverage route serves any region with an outline rather than only
the five provinces. Its directory keeps the city-coverage name for now: the
page that calls it cannot be read in this environment, and renaming the route
without updating its caller would break it.
A guess is credited to the district its panorama sits in, then to that
district's province, then to Vietnam. Each level keeps its own board, so a
player can top District 7 without touching the national table.
Every existing point survives untouched. The country maps to the
leaderboard:vietnam and distance:vietnam keys that already exist rather than
to a new leaderboard:city:vn, so the national board keeps accumulating instead
of restarting. The ':city:' segment in the key namespace is now a misnomer --
it holds district and province codes alike -- but renaming it to ':region:'
would strand every key holding a player's history, so it stays, with a comment
recording why. Da Lat and Duc Hoa keep their bare codes for the same reason.
Ha Noi, Da Nang and Ho Chi Minh keep their totals on the province: those
points predate districts and cannot be attributed to one, so their boards stay
continuous while district boards start at zero.
Levels are written in parallel. They are independent keys and none reads
another's state, so serialising them would add two round trips of latency to
every guess for nothing.
Region codes and the leaderboard limit are validated inside the library rather
than only in the routes. The key builder lowercases whatever it is handed
straight into a Redis key, and the routes are not its only callers -- the
migration script and anything added later bypass them entirely.
The adapter gains scanKeys, which the migration needs to enumerate what it is
about to touch. It applies the key prefix to the pattern and strips it from
the results, because a caller that scanned 'leaderboard:*' directly would
match nothing at all: every physical key carries the prefix, and an empty
result is indistinguishable from an empty database. An empty KEY_PREFIX is no
longer accepted, since enumeration with no prefix would reach every other
project sharing the database.
The migration seeds Lam Dong and Long An from Da Lat and Duc Hoa, whose
history they inherit. It runs after the deploy, not before: migrating first
leaves a window where a Da Lat guess credits the town and the country but not
the province. It copies absolute scores so a second run converges rather than
doubling, and empties the destination first so a player trimmed out of the
source cannot survive in the copy with a stale score. An empty source is
refused before that delete -- otherwise the destination is wiped and nothing
written back, which reads as a clean no-op. Afterwards it confirms each
destination matches its source and that nothing else went backwards, accepting
that boards grow while the app serves traffic. Its backup can be restored
through the same script.
The migration logic lives in scripts/lib/ so its guards are reachable from a
test rather than only by running it against a live database.
Every panorama now carries the district it falls in, so a guess can be
attributed to a leaf rather than only to a province. The assignment runs
against the indexes already on disk: districts are a property of a panorama,
not a separate dataset, and re-fetching per district would multiply a
~2,800-tile build against Mapillary's 50,000/day cap for bytes we already
hold.
The district is stored as an integer offset into a per-province districts
array rather than as a code string, which costs about five bytes an entry
instead of twelve. Across 424,617 entries that is the difference between the
2.85 MB the data actually grew and something closer to five.
Coverage is judged on distinct places, not on raw counts. The index is thinned
at 33m, so a count overstates how many different places a district offers by
roughly thirty times -- a district can hold hundreds of panoramas and still be
one street seen from many angles. Playability therefore needs both a panorama
floor, which exists because the Mapillary lookup retries three times with a
different candidate, and a floor on distinct ~1.1km cells. Sixty-four of the
sixty-seven regions clear it. The three that do not are one of each kind the
coverage note describes: Cu Chi has no boundary, while Cam Le and Hoa Vang
have no street imagery at all.
A point that falls in a sliver between two simplified outlines is placed in
the nearest district by distance to the outline itself. Ranking by bounding-box
centre instead was measured putting points up to six kilometres inside the
wrong district, because a compact district's centre can beat a sprawling
neighbour whose edge is metres away. The stranded tally is recorded per
province and the build refuses to write above two percent, since that is the
signal that the leaf simplification tolerance has opened gaps along shared
borders. It currently sits at 0.05 percent, worst case forty-six metres.
fetchCityPanorama now reports the district of the attempt that succeeded.
Each retry draws a fresh candidate, potentially from a different district, so
carrying the first one forward would credit the wrong place. An exhausted pool
returns a failure rather than escaping as a 500.
Panorama arrays handed out at runtime are frozen. They are process-global and
cached for the life of the server, and an in-place sort on the shared district
array had already once repointed every panorama at the wrong district.
Nothing is wired to scoring yet: the resolved district is computed and
returned but not stored on the session. That lands with the leaderboard
fan-out.