What's New
Release history for pitchmappr. Each version lists new features, improvements, and fixes.
All notable changes to this project are documented in this file. Format follows Keep a Changelog (opens in a new tab).
[0.9.98] — 2026-07-17
Added — three new map data layers, and a fix to the tool that checks them (2026-07-30, patch: no version bump)
- Three layers added. ZIP Code Tabulation Areas and 119th Congressional Districts join the Demographics panel, and Utility-Scale Solar PV joins Energy under Power & grid. ZIP areas carry population and housing figures, so you can shade a map by them straight away. Congressional districts are outlines only — the Census publishes the district shapes without any figures attached, so there is no "Shade by" dropdown on that one; you can still style its fill and outline like any other layer. Utility-Scale Solar PV draws the actual footprint of every large US solar farm with its capacity, technology and year built; the footprints are drawn true to size, so zoom into a project area to see them properly.
Fixed — a data layer had quietly stopped working, and the health check said it was fine
- Natural Gas Processing Plants had gone dead and been reported healthy. The layer is published by a third party, and their copy of it was withdrawn. It now points at a working copy of the same official survey data — 478 plants, right across the producing basins. If you had this layer on a map and it drew nothing, it will draw again.
- The reason it went unnoticed is now fixed too. The admin screen has a "Test all" button that checks every data source. Mapping servers answer a request for a layer that has been deleted with a perfectly normal "OK" reply that carries the error inside it — and the check was only reading the reply's status, not its contents. So a dead layer showed green. It now reads the contents as well, which is what makes a red result worth believing.
- Eleven energy layers stopped crying wolf. The Energy Transfer layers had been failing that same test permanently, for a reason that had nothing to do with them being broken. That is fixed — and as a side effect, the test can now tell you when Energy Transfer's access token has genuinely expired, which is the one thing about those layers that does need occasional attention.
Fixed — a saved Focus map could open without its mask (2026-07-30, patch: no version bump)
- Opening a Focus map right after a theme change could load it only halfway. Your locations appeared but the focus mask — the whole point of Focus mode — never drew, and nothing on screen said anything had gone wrong. Behind it: loading a project changes the map's style and restores Focus in the same breath, and for the moment the new style is still loading the map can't answer questions about itself. Focus asked anyway, hit an error, and that error quietly abandoned the rest of the load (the map's saved position and the later restore steps included). Focus now waits for the style to settle and then draws itself, so the map either loads completely or keeps trying — it can't end up half-finished. Shipped
366bfc3.
Changed — the Data Layers panel is now "Demographics"
- The rail panel that shades the map by census, metro, World Bank, Eurostat and ACS data is renamed Demographics (rail label
DEMOS, panel and section title "Demographics"). Same panel, same layers, same behaviour — only the name changed, for a clearer read of what it holds next to the separate Energy panel. The docs page keeps its address; its title and references now say Demographics.
Removed — Heat Map (for now)
- The MAP panel's Heat Map section is retired from the UI (owner call: "remove for now"). Old projects that had a heat map enabled load normally and simply don't draw one — their heat-map settings are preserved untouched in the project file, so nothing is lost if the feature returns.
Fixed — Demographics & Energy layers that didn't load, loaded partially, or loaded silently
- Partial loads are fixed. The data servers deliver rows in pages; when a server sent smaller pages than we asked for, loading stopped after the first page and — worse — that area was then remembered as "already loaded", so the missing data never arrived, even after toggling the layer off and on or reloading. Paging now follows what the server actually returns, and the browser-side cache from before the fix is discarded once so every layer refetches complete data.
- A failed patch of a big load no longer freezes the gap. Large areas load in chunks; a chunk that failed used to be swallowed by an "all loaded" stamp. Now the failed area stays pending and reloads on the next map move, and the note says some areas failed.
- Dense layers refuse less. Very dense layers (census block groups) were refused whenever the generously padded fetch area exceeded the safety ceiling — even when the actual view was fine. The exact view is now retried before refusing, so city-level block groups load.
- Nothing fails silently any more. Loading, refusals ("214,000 areas here — zoom in to load"), and failures now show as a small status message in the map's bottom strip — previously they were only visible with the panel open, which made a refused layer look broken.
- Layers restore reliably on reload. Two reload races were fixed: a layer could be filtered against a not-yet-loaded focus region and come up empty, and a draw attempted while the map style was still starting up was silently dropped. Both now retry until they land (the same guarantee pattern as the rotation lock's flatten).
- The Energy preview is honest about sampling. A big layer's eye-preview is a capped sample (pipelines previewed ~300 of ~33,000 segments and looked broken); line networks now sample 4× denser and the preview pill says "sample — Add loads the complete layer" when there's more.
Changed — the legend is now the "deck card" (your pick, option B)
- The legend's Card style matches the reference you chose: rounded card, bolder title, square colour chips for datasets (the HQ star stays a star), and the data scale draws as one joined colour bar with min / mid / max value labels instead of five range rows. The "Classed + counts" scale style keeps the detailed per-band rows (including your editable range text).
- A source line now appears at the bottom of the legend whenever a data layer feeds it — "© Esri / US Census · EIA", composed automatically from the active layers' providers. It's a per-project "Source line" toggle (on by default; a marker-only legend never shows one). This supersedes the earlier call that kept attribution off the legend.
Added — your zoom follows you in and out of Focus+
- Entering Focus+ used to open at its own fitted view, and leaving put you back wherever the tile map happened to be — your zoom and position didn't travel. Now Focus+ opens on the view you were just looking at, and leaving lands the map on the view you had in Focus+ (flat, squared up). Mercator projections only — other projections (Albers, globe, …) can't map to a tile camera, so they keep the fitted entry and the old behaviour on exit. A saved Focus+ project still opens exactly as it was saved.
Changed — Focus+ zooming is dramatically faster
- Wheel-zooming and dragging the Focus+ map re-drew the entire projected scene on every tick — 80–105 ms per frame at High/Ultra detail with a data layer on (about 10 fps, the reported slowness). Mid-gesture frames now reuse the last drawn frame (scaled exactly to the new view — ~1 ms), and the full-quality redraw happens once, a moment after the gesture ends.
[0.9.97] — 2026-07-17
Added — Map rotation lock (F225, ADR-128)
-
A padlock on the map's nav rail (between recenter and tools) controls rotation/tilt — which MapLibre had always allowed on hidden gestures (right-click-drag or Ctrl+drag rotates and tilts, two-finger drag tilts) with no UI, no reset, and no off switch, so maps got skewed by accident.
-
Locked by default (including existing projects): every rotate/tilt gesture is disabled — pinch-zoom and keyboard panning keep working. Unlocking enables them and shows exactly which gestures to use. Locking snaps the view flat — and the flatten is a guarantee (an ease for polish, backstopped by a hard set if the animation is interrupted or stalled), so the padlock is the one-click rescue from any skewed state.
-
Every reset squares the view up: recenter ⊕ and the reset flows return bearing and tilt to zero; the settings resets also re-lock.
-
Deliberate rotation now saves: the project's map view carries bearing/tilt, so an unlocked, deliberately-skewed composition survives save and reload (previously it was silently discarded).
-
Saved views remember rotation. Saving a view now captures the rotation/tilt it was composed at, and going to the view replays it. While rotation is locked a view opens square (it keeps its rotation — unlock to see it as saved, and the tool tells you so), because a locked map must never end up skewed or the padlock rescue would have nothing to rescue. Each view's row shows its rotation (e.g.
z4 · 16:9 · 30° tilt 20°), marked(locked)while suppressed. Per-view PNG/Hi-Res exports replay exactly what the view shows.
Fixed
- A rotated view's row could keep advertising its rotation after the padlock was locked (the annotation went stale until something else redrew the panel) — found by this release's verification run; the padlock now refreshes the panel immediately.
- Saved views' camera values are now validated on load; previously a project file's view data reached the map unchecked.
- A corrupt or hand-edited project file could add a broken, un-openable saved view that then stuck to the project through every save — found by this release's adversarial pass; malformed saved views are now dropped when a project loads, the same way malformed locations already were.
- The message shown when a rotated view opens square could garble a view name containing an ampersand (e.g. "Smith & Co" appeared as "Smith & Co") — now shows the name as written.
Notes
- No migrations (53 unchanged). Engine suite 502 → 524 (
tests/engine/rotate-lock.test.ts, 22 checks over the real MapMod + UI + ViewsMod via a new camera-recording harness). /verify-feature PASS ×3 — 12/12 Playwright checks against the standalone-served tool with zero console errors (the map tool's first real executor coverage). Verified live: a real Ctrl+drag rotated the map 96°/18° while unlocked and did nothing while locked; the padlock snapped a rotated map flat within 900 ms; the real Save button captured 30°/20° and go-to replayed it exactly.
[0.9.96] — 2026-07-17
Added — Base map under Focus+ (F224, ADR-127; R2, the last open QMP export win)
- "Base map under Focus+" — a first-class toggle (screen7's greyed row, now built; new "Base Map" section first under the Focus+ Additional Layers accordion) showing the raster tile base map BENEATH the projected Focus+ scene. Web Mercator only (greyed otherwise, matching QuickMap Pro), and not with AK+HI insets (an AlbersUsa composite tiles can't align under — a gate that also fixes the latent Stage-1 roads/shields gap).
- True tile alignment (the hard part). While showing,
render()keeps the MapLibre camera in lockstep with the effective d3 Mercator projection — center =proj.invert(canvas centre), zoom =log2(π·scale/256), bearing/pitch zeroed, epsilon-guarded — re-synced per render so fit changes, wheel zoom, drag pan and resizes all hold alignment. (The pre-existing Stage-1 show-through bled tiles from whatever camera preceded Focus+; nothing ever aligned them.) Proven live: 0.00 px projection↔camera deltas at 5 frame points, held through gestures. - Reversible by construction. The pre-sync camera is stashed once and restored on toggle-off / mode exit / gate break / project load;
AppState.toJSONserializes the STASH while it exists, so an autosave during Focus+ can't corrupt the savedmapView. Below-map overlay twins (focus mask, data-layer choropleth, radius rings, heatmap layers; DOM markers/popups) are hidden while showing and restored after — the canvas projects all of them itself. - The ADR-120 rules extend unchanged: transparent export stays mutually exclusive with a shown base map;
exportCompositecomposites the tile map behind; the status-strip OSM attribution reappears. Opacity rides the existing slider (0.5 wash → 1.0 crisp-over-tiles).
Notes
- No migrations (53 unchanged). Engine suite 488 → 502 (
tests/engine/focusplus-basemap.test.ts, 14 checks — zoom↔scale anchors, gate truth table, sync/stash/restore through the realrender(), hide-set, toJSON coupling, save round-trip, transparent forced off, camera-less degrade). Verified live on the standalone engine with real R2 tiles; zero console errors.
[0.9.95] — 2026-07-15
Changed — Quick Export panel rebuilt to the approved design (F223, ADR-124)
- Quick Export rebuilt to the owner's
screen7mockup. v0.9.94 grouped the panel into the right sections but reused the old checkboxes + click-to-export buttons (recorded in ADR-123 as a deliberate deviation); that read as "the old design." This ships the mockup's actual control language: a segmented PNG · Hi-Res · SVG format picker (choose, then one Export button that relabels to match) replacing the click-to-export buttons, and sliding on/off switches replacing the checkboxes (the tool's first switches — intended as the control style to roll out tool-wide). The grouping (Format · Background · Include-on-export), the separate-file Legend, and the baked scale bar (v0.9.94) are unchanged. Export output is identical — the picker fires the sameExportMod.png/svgcalls the old buttons did. - "Watermark" → "Footer". The edge-caption control (project · author · date,
ExportMod._buildWatermarkSvg) was mislabeled "Watermark"; renamed "Footer" (UI label only — theexportWatermarksetting is unchanged, so saved projects are unaffected). - Base-map-under-Focus+ removed from the export tab. It's a Focus+ display setting, not an export option, so
screen7's greyed "Base map" row is deliberately absent here (R2, when built, lives in Focus+ settings). - Toggle sub-labels removed — the toggle names are self-explanatory (owner).
Notes
- No migrations (52 unchanged). Engine suite unchanged at 488 (panel-UI only; no engine logic touched). Verified live: rendered with the production stylesheet + the real
PanelContent.quickexport(); interaction wiring exercised (format → export routing, accessible switch checkboxes); production bundle recompiles clean.
[0.9.94] — 2026-07-15
Added — Dataset marker z-order (F222)
- Dataset marker z-order — reorder which dataset's markers sit on top where they overlap, via per-row ▲▼ arrows in the MARKS Datasets roster (design-fit Option B, owner-picked from four rendered options). The roster reads front-first (top row = on top of the map); arrows disable at the ends and hide when there's only one dataset. Every draw surface honours it — the live map (per-marker z-index), PNG/Hi-Res/SVG export, the Focus+ canvas, and the D3 projection export — via one shared
AppState.locationsByZ()iterator (marker numbers stay tied to the DATA list). A new HQ dataset defaults to the top and a new non-HQ dataset below the Default; order saves with the project, and old saves open HQ-on-top / Default / creation order. ADR-122. (engine 465→484)
Added — Quick Export layout refresh + scale bar (F223)
- Quick Export block regrouped into Format · Background · Include-on-export sections (design-fit
screen7-export-options.html, owner-confirmed via a live before/after). The Legend export toggle stays a separate file (never baked onto the map — it just adds the standalone legend as a 2nd download), relabeled "Legend — separate file". The Export Views / Change Projection / JSON accordions are unchanged. (R14) - Distance scale bar baked into the export — a new "Scale bar" toggle draws a nice-round, fitted distance bar into the bottom-left of PNG/Hi-Res exports (regular & Focus tile map; deliberately omitted on the Focus+ projection, where scale varies across the frame). ADR-123. (R1; engine 484→488)
Notes
- No migrations (52 unchanged). Engine suite 465→488. Base-map-under-Focus+ (the other open export win) deferred.
[0.9.93] — 2026-07-15
Added — Focus+ export formats (transparent + true-vector SVG)
- Transparent-background PNG in Focus+ — exporting with transparent on (when the Focus+ base map isn't showing) drops the surround so everything outside your selected region is alpha, the projection analog of focus mode's country clip; the regions, data shading, radius rings and markers still paint, and crop + watermark are preserved.
- True-vector SVG for Focus+ — SVG export (removed engine-wide in v0.9.60) returns for Focus+ as real vector
<path>/<text>/<clipPath>(not a raster wrapped in<svg>): projected region fills and borders, internal boundaries, geographic + city labels with a halo, the census/energy shading clipped to the selection, radius rings, and markers as vector paths. A raster base map beneath is the one documented exception (not vectorised). - Transparent and "show base map through" are mutually exclusive in Focus+ (owner rule) — with the base map showing it is the opaque background, so the transparent option is hidden and forced off; with it off, transparent is available. Regular base maps never offer transparent; regular Focus mode is unchanged (it clips the base map to the selection, so transparent stays valid there).
- Built over the session-44 Focus+ export sink. Engine suite 446 → 465 (
tests/engine/focusplus-export.test.ts, 19 checks driving the realFocusPlusMod). /verify-feature PASS (ledger rowexp_focusplus_formats). F220, ADR-120.
Added — in-app admin Help page (/admin/help)
- A complete operator guide to the admin back office — users & the reserved-role model, orgs/departments/groups, themes, data overlays (incl. the Energy specifics: provider = operator, the Energy Transfer rotating token), sharing, geocoder keys, settings, and step-by-step how-tos. Lives inside the admin app (the docs site is for end users; the admin site is for admins). Design-fit Option A — a single page with a sticky scroll-spy topic rail (owner-picked from four rendered options). New route
app/(admin)/admin/help/; a Help entry in the admin nav. F221, ADR-121. - Fixed a first-deploy crash: the page passed icon-carrying data to a client component (an RSC "functions can't cross the server→client boundary" error) — now passes serializable data only; verified rendering live in the admin app.
Docs
- Energy tab user page linked into the docs nav + gained a "For administrators" section.
F-dataset-zorderfiled in the TODO — user-controllable dataset marker z-order (reorder which dataset's markers sit on top). Not yet built.
[0.9.92] — 2026-07-14
Removed — the old Shape Overlays renderer (F219 E4, ADR-119: the Energy program is COMPLETE)
- ShapeOverlayMod is gone — the hidden Map-panel "Shape Overlays" section, its six UI handlers, and the module pair (
ShapeOverlayMod.js+ShapeOverlayMeta.js) are removed (bundle 38 → 36 modules). Every layer it drew lives in the Energy panel, driven by the one unified engine — full data (no more 800-segment truncation), catalog-carried styling, Focus+, the Legend, labels, popups. - Old saves keep their overlays: a project saved with the old section auto-maps on load — the legacy trio to their richer replacements (
ng-pipelines/power-plants/shale-plays), quakes/flood/tribal to their own rows, the old opacity/colour (a custom colour on a coloured-by-data layer becomes the Single-colour override), "show across neighboring states" to per-layer Show beyond, and the old global label settings to each layer's labels. Idempotent, and loading never marks the project unsaved. The retired BOEM pair maps to nothing (owner sign-off).
Added — the last special fetch modes, engine-driven (migration 052)
- Earthquakes M2.5+ (30d) — a USGS live GeoJSON feed (
style.source='geojson'): the whole feed loads at once, colours and sizes by magnitude (the old threshold palette, now catalog data), and refreshes every 30 minutes while on (snapshot semantics — aged-out events drop). The ghost preview samples the feed; the Legend gains one row per magnitude band. - FEMA Flood Hazard Zones — viewport-loaded ArcGIS with per-zone colours (zone X transparent); the engine's honest count gating replaces the old misleading capped sample at continental zoom.
- Federal Tribal Lands — the 8th legacy shape row, re-homed to Geology & hazards so it isn't silently lost with the old renderer.
- Migration 052: disables the legacy trio, ports the three rows above (base URLs + style jsonb).
Fixed — the Energy panel clipped its own list (owner report)
- The Energy panel showed "Search 85 layers" but only ~21 in the Browse list, with no way to scroll to the rest. Each collapsible panel section animates open to a 1400px ceiling (
overflow:hidden), which every other panel stays under — but the Energy Browse catalog runs 85+ rows (~4,000px), so everything past ~21 rows was clipped and unreachable (the outer panel scroll can't reveal what an inner section hides). The Energy and Data Layers sections (both single, always-open) now grow to full height and scroll normally; a header click still collapses them. A?v=on the stylesheet link makes the fix reach users without a hard reload. Verified live: the section grows 1400 → 4266px, the scroll reaches the last row.
Fixed — adversarial sweep on the session-44 surfaces (owner-invoked, post-E4)
- A layer could resurrect after being turned off: a data fetch still in flight when you toggled a layer off would re-add it to the map when the response landed. Now any in-flight request goes stale the moment its layer turns off (fixes the whole loader family, not just the new feed path).
- A reopened project could show days-old earthquakes as "live": locally-cached feed data satisfied the loader completely, so nothing hit the network until the 30-minute timer. Cached data still paints instantly, but a live feed now always fetches fresh once per session — and the session flag never rides the save file.
- Long sessions no longer accumulate feed snapshots in memory (each 30-minute refresh cached its full response under a unique URL — ~50 MB/day; now pruned after use). Plus: a refresh timer is never left armed for a layer that's off.
- 27 attacks total across six categories (hostile feed bodies, refresh races, outages, empty feeds, 5k-event stress, hostile save bags, the full save/load migration lifecycle, transparent flood zones, legend XSS, junk inputs, live export abuse) — everything else survived; 21 committed as permanent regressions.
ADVERSARIAL-REPORT-2026-07-14-session44.md.
Verification
- Engine harness 407 → 446 (+18 E4, +21 adversarial regressions; 4 E1 checks re-derived to the E4 spec — quakes/flood now list; dev catalog 19 → 22). E4 checks: catalog admission, feed loader + snapshot replace + timer lifecycle + hostile feeds, quake styling, legend threshold bands, the old-save migration incl. idempotence / never-dirties / restoreFromState round-trip. Executor 19/19 on the retired-renderer bundle. The 45-item must-not-lose inventory re-verified item-by-item — nothing lost (
VERIFY-REPORT-2026-07-14-energy-catalog.md: 32 carried/built · 10 translated · 3 retired with sign-off).
[0.9.91] — 2026-07-14
Added — owner live-use batch (session 44, round 2)
- Focus+ export works. Exporting while viewing Focus+ used to download the underlying focus/base map (the capture only knew the WebGL basemap canvas). Focus+ now has its own export sink: Quick Export captures the composited Focus+ scene — regions, data layers, energy, radius rings, markers, labels, exactly as drawn — and Hi-Res renders a true 4× frame (the canvas re-renders at 4× density, not an upscale). Crop ratios and the watermark apply; base-map show-through composites correctly. This closes the long-recorded Focus+ export-sink gap.
- Energy layers ride the standalone Legend — one swatch row per active layer (a line stroke for pipelines/grid, a dot for plants/terminals, a translucent patch for basins/plays), and a categorical layer contributes one row per category ("Power Plants — Gas"). Rows rename/hide/drag like any other legend entry and export in the PNG/SVG legend file.
- "Show on secondary regions" in Focus+ — Focus+ has surrounds too (its secondary regions), so census and energy layers now offer the per-layer toggle there (census brings its full surrounding-regions styling block: muted fill, own ramp, border). Off = the layer clips to the primary selection; the Focus+ canvas clip honours it.
Changed
- Energy draws above choropleth — energy infrastructure now always paints on top of census/indicator shading, on the tile map (regular + both mask slots in focus) and on the Focus+ canvas.
- Census include-neighbours defaults OFF — focusing shades the focused region only by default; "Include neighbor states" (focus) / "Show on secondary regions" (Focus+) is the per-layer opt-in. Energy keeps its neighbours-on default (infrastructure follows the surrounds the map shows). Existing saved projects keep whatever they had.
Fixed
- Focus+ settings survive mode switches. Entering Focus+ re-applied the Focus+ theme on every entry, whose assign stomped the user's fp customizations (fills, borders, labels…) — the owner's "settings are not all saved" after Focus → Focus+. The theme now seeds only on FIRST entry (no Focus+ theme applied yet); after that the user's state is never touched.
- AK + HI insets render. The overseas-territory filter (default on) stripped Alaska and Hawaii from the US feature before the AlbersUsa composite could inset them — the insets rendered empty. Insets now feed the raw US feature (AlbersUsa itself drops non-inset territories like PR/USVI).
Verification
- Engine harness 390 → 407 (+13 session-44 checks: defaults, z-order both surfaces, Focus+ secondary-region controls + projected clip, the energy legend feed; +4 legend-render checks: energy swatch shapes in the actual SVG, rename/hide/drag, no-DataLayerMod fallback). 9 neighbour-machinery tests updated to opt in explicitly (the machinery is unchanged; only the default flipped). FocusPlusMod/ExportMod paths (theme-seed guard, insets, export sink) are outside the vm harness — owner live look is the check there.
[0.9.90] — 2026-07-14
Added — Energy special sources: Enterprise + Energy Transfer (F219 E3, ADR-119)
- 19 Enterprise Products layers from ONE shared GeoJSON (
EPD-Assets.json) fetched once and filtered by assetClass. They feed the SAME accumulating feature store as an ArcGIS layer, so they get full parity — Focus+ reprojection, IndexedDB restore, the exportable Legend, click popup, and per-layer labels. A static nationwide file, so once loaded, panning and zooming never refetch. The ghost preview samples the shared file too. - 11 Energy Transfer layers as native MapLibre vector tilesets (
style.engine='mapbox'+ tilesetId/source-layer). Drawn straight from Mapbox tiles — fast, but tiles aren't features, so (documented degrade, isolated to these 11 layers) they have no Focus+ reprojection and no labels, and the panel says so plainly. The public map token lives in the catalog row's URL; the engine health-probes the tileset on toggle-on and, if the token has rotated, notes that an admin needs to refresh it in the back office. - Both complete the 7-operator midstream set — Enterprise and Energy Transfer join the operator chips. Seeded by migration 051 — generated (extends
scripts/gen-energy-midstream-seed.mjs; 19 EPD + 11 ET, zero hand-typed URLs).
Fixed — energy focus clipping (owner report, session 44, post-051)
- Focus+ now scopes energy to the selection. The Focus+ projection is fitted to the selected region, so nationwide features — especially Enterprise's huge multi-state "Operating Basin" polygons — were projecting onto a state-shaped projection as giant misplaced shapes. Focus+ now always clips energy to the selection and drops out-of-selection features, regardless of the "Show beyond focus region" toggle (which isn't shown in Focus+). Only affects the Focus+ live canvas.
- Energy now clips to the focused region by DEFAULT in Focus and Focus+ (the owner's call — the initial "show nationwide" default was reversed). Focusing on a state shows that state's energy (plus any surrounding regions you've chosen); Show beyond focus region is the per-layer opt-out to reveal a layer nationwide. The map's Surrounding Regions control (None / Adjacent / All) now governs energy again — it was bypassed entirely by the old "show nationwide" default.
- Energy shapes keep their real boundary detail. The viewport-scaled geometry trim (right for census choropleth) was flattening shale plays and basins into boxy polygons when zoomed out (a nationwide shale fetch was ~700 vertices vs ~22k real). Energy now caps that trim small (~0.005° ≈ 500 m — a nationwide shale fetch is ~2,700 vertices, detailed) at any zoom, and the preview and load share the path so they match. The over-simplified geometry cached in the browser is invalidated (energy IndexedDB detail tag
g2) so a reload refetches at full detail.
Verification
- Engine harness 339 → 383 (+27 E3 parity/degrade, +14 adversarial, +3 Focus+ scoping; suite locks: Focus+ clips regardless of "beyond", drops out-of-selection features, doesn't over-clip a whole-world Focus+). Executor gate 6/6 (panel chrome regression-clean). No census/E1/E2 regressions.
[0.9.89] — 2026-07-14
Added — Midstream operators in the Energy panel (F219 E2, ADR-119)
- 34 midstream operator layers (Kinder Morgan · Plains All American · Phillips 66 · Williams · Targa Resources) seeded by migration 050 — generated from the curated energy catalog by
scripts/gen-energy-midstream-seed.mjs, zero hand-typed URLs. Provider carries the operator, so duplicate layer names ("Terminals") stay tellable apart. - Operator chips in the Energy panel's Browse tab — All + one chip per company; a chip scopes the list to that operator's assets, composes with search, and a stale saved operator self-heals to All. Standalone builds (no admin catalog) show no chips by design.
Fixed
- "Show beyond focus region" OFF now truly clips (
e419743): energy lines/polygons keep on any sampled vertex in scope (whole-feature centroid tests both spilled crossing pipelines and dropped corner-crossers), the layer slots below the focus mask so it cuts at the exact boundary (labels clip with it), and the Focus+ canvas applies a region clip path.
[0.9.88] — 2026-07-14
Added — The Energy Data Catalog (F219 E0–E1, ADR-119)
- A second, separate admin data catalog — Energy ("the data never mixes") rendered by the SAME unified engine as Data Layers (owner decision: extend, never fork). Migrations 048 (
style jsonbper-layer presentation defaults + RPC) and 049 (19-row V1 seed: 13 EIA Infrastructure incl. the full ~33k-segment natural-gas pipeline network · 6 Power & grid; earthquakes/flood re-homed to Geology & hazards; the BOEM rows retired — the WMS dependency is gone). - Engine extensions: line geometry; point categorical colour (power plants by fuel) + graduated size (by MW); flat/categorical/graduated-colour style read from the catalog with hostile-input clamping; per-layer Show beyond focus region (default on); Focus+ canvas rendering for all three energy geometries; standalone dev fallback.
- The Energy rail panel (
EnergyMod.js, owner-picked design E) between Data Layers and Legend: search → Active | Browse tabs; Browse rows carry an eye-preview and Add; the ghost preview draws one capped ~300-feature sample dashed/translucent with a floating PREVIEW · Add to map / Dismiss pill — one at a time, never saved with the project. Adding promotes the layer to the Active stack with geometry-aware controls (colour/colour-by + swatch mini-legend · width · size-by-value · transparency · border · labels incl. Position · About card). - Admin Energy tab un-stubbed (the "later" badge is gone; section-aware copy); the settings route passes
stylethrough the bridge.
Fixed
- Energy labels vanished on mode switches and the toggle couldn't restore them (
dae142c, owner live-look): the Focus+ canvas label pass skipped point layers entirely and computed positions polygon-only — energy point and line labels now draw (lines label at a mid-line vertex; points honour the Position control), census points stay excluded.
Verified
/verify-featurePASS onenergy_panel— engine harness (suite 302 → 339 across the session, incl. two test-caught bugs fixed pre-ship: category-casing colour miss, polygon-only centroid) + executor 6/6 (panel anatomy, 19-row Browse, live search filter, preview row-flag + map pill, Add promotes with About card).
[0.9.87] — 2026-07-14 (Data Layers — the unified, admin-managed data overlay)
Added
- Data Layers panel (F218) — a new rail panel listing admin-curated census/MSA map layers (an admin manages the catalog in the back office: add/remove/edit/test/enable). Toggle a layer on and the map shades by a numeric field with a min→max colour ramp over quantile class bands; expand it for the field picker (clean, curated field names), colours, transparency, border, a full label system (value · style · size · colour · halo), a "Surrounding regions" block (include on/off · fill opacity · own colours · border) and an About card. Works in all three map modes and feeds the exportable Legend (classed bands · +counts · gradient+axis · compact).
- The World Bank, Eurostat and Census ACS indicators joined the same list — "World data (World Bank)", "European regions (Eurostat NUTS2)" and "US counties (Census ACS)" are entries in the Data Layers panel with the identical control set, working in every mode. ACS data loads per state for the states in view (a whole state loads once; panning inside it never reloads; a continent-wide view keeps what's drawn and says "zoom in").
- Click popup — click any shaded area for a draggable detail card: the entity's name, the active metric as a headline value, every other value formatted, and a source · vintage · code footer. Closes on ×, Escape, turning the layer off, changing the field, or switching mode/panel.
- Stats in the About card — areas matched, average, highest and lowest (with names), formatted per the field; indicator entries also show source-true Source / Geography / Updated / Vintage.
Changed
- The old MAP → Data Overlay panel is retired. Its indicators live in the Data Layers panel with the full old feature set (region scope + neighbour styling + labels + popup + stats + info card). Old saves keep their overlay: a project saved with the old panel's overlay on opens with the equivalent Data Layers entry on — colours, transparency, border, neighbour styling and label settings all carried over.
- In focus modes the data clips to the focused region (+ surrounding regions when included) and fetches by the region's bounds — a focused state loads whole once and pan/zoom never refetches.
- Loaded data now STAYS loaded. Every layer keeps an accumulating data store: turning a layer off and back on, changing "Include neighbor states", panning, zooming, or switching modes re-draws instantly from data already downloaded — fetches only ever add to the store, never replace it. Big regions (past 10,000 areas) now load fully in progressive chunks instead of refusing with a "zoom in" note (the note remains only past ~60,000 areas). The store also persists in the browser's local database, so reopening a saved map repaints its data layers without re-downloading — while project files and autosaves stay tiny (they never carry geometry).
Verified
/verify-featurePASS ×3 this session (the P3 slices formal gate · the popup/stats/ACS build · P4). Engine suite 272 → 289 (popup 5 · stats/info 3 · ACS 6 · mapping/retirement 3); executor connected-chrome 12/12 on each bundle; every deploy live-confirmed by bundle hash + code markers. The 44-item must-not-lose inventory verified — nothing lost (36 items carried 1:1, 8 translated with equivalents):VERIFY-REPORT-2026-07-14-data-overlay-unification.md. EngineCFG.appVersion→ 0.9.87. No migrations (47 unchanged), no dependency or external-service changes (ADR-116/117; F218 shipped; F064/F065/F069 absorbed).
[0.9.86] — 2026-07-12 (Radius circles in Focus+ and projection exports)
Added
- Radius circles now show in Focus+ — the distance/catchment rings you draw on the base map (e.g. "50 mi around each office") now appear in Focus+ too, correctly re-projected onto its map and panning/zooming with the view. Previously they were only on the standard map and vanished when you switched to Focus+.
- Radius circles in the D3 Projection Export — the same rings now render in the projection export (and its live preview) in the export panel, drawn under your markers.
Verified
/verify-featurePASS — new engine testtests/engine/radius-projected.test.ts(10 checks on the shared projected-draw path: fill colour/opacity, border on/off + width scaling, per-ring style, dataset-rule expansion + visibility, hidden-ring skip, empty, off-projection vertices, alpha restore, guards); engine suite 153 → 163. Live real-Chrome: Focus+ renders every ring on its canvas across Mercator + Albers projections; the projection export renders them under the markers with correctly scaled borders. EngineCFG.appVersion→ 0.9.86; bundle rebuilt. No migrations, no dependency or external-service changes (ADR-115; F214 amended).- Known limitation: the Focus+ standard PNG/Hi-Res export still doesn't capture the Focus+ canvas (a separate, larger export-path task that affects all Focus+ overlays, not just rings) — use the D3 Projection Export to get a projected map with rings today.
[0.9.85] — 2026-07-12 (Marker sizing · legend layouts · label + radius refinements)
A batch of map-tool improvements.
Added
- Size markers by value (F216) — markers can scale by a number in your data (revenue, headcount, deal size). Import a CSV and map a number column to the new Size value field, then in Markers & Labels open Size by Value, turn it on, and set the min/max size. Smallest values draw small, largest draw big; locations with no value keep their normal size. Sized markers appear in your exports.
- Legend layouts — the legend Style picker now offers four looks: Card (white), Framed (navy title bar), Transparent, and Strip (a compact horizontal row). The legend now has square corners.
- Label positions — labels can now sit Above, Below, Left, or Right of the marker (was only Above/Right).
- Independent label halo — a new toggle lets you set the label halo on its own, separately from the marker halo.
- Add a dataset from the roster — a "+ New" field at the top of the Datasets list in Markers & Labels.
Changed
- Radius circles are now editable — click any ring in the list to change its colour, size, or border; a ring is selected the moment you place it. Added a Border width slider, and an Editing … [Remove] bar so you can change or delete a ring (including a "ring every location" rule) — the previous version gave no way to adjust or turn one off after placing it.
Verified
/verify-featurePASS across the batch — engine suite grew to 153 committed checks (newtests/engine/marker-sizing.test.ts+marker-size-harness.cjsdriving the real marker renderer; new legend-layout checks; radius editable-model checks) + a live real-Chrome pass on each. EngineCFG.appVersion→ 0.9.85; bundle rebuilt. No migrations, no dependency or external-service changes.- Known limitation: radius circles are still not available in Focus+ (the experimental projection mode renders on a separate canvas); planned as a follow-up.
[0.9.84] — 2026-07-12 (Radius circles · Map search)
Two map-tool additions this release: radius circles and a working map search box.
Radius circles (F214, ADR-110): draw distance rings — "50 mi around each office" — on the map from a small tool popover opened by the ⊙ button in the map's tools tray (no left-panel needed). A classic catchment / coverage visual for finance decks. Designed through a rendered four-option round; the owner picked the map-side popover.
Added
- Three ways to draw a ring — Point (click a spot on the map), Dataset (one rule rings every location in a dataset, and keeps up as you add, remove, or hide locations), or Search (type a place and ring it).
- Per-ring styling — colour (marker palette or a custom hex), fill transparency, and a border toggle; distance in miles or kilometres. A list of your rings lets you hide or delete each one.
- Rings appear in your exports automatically — they're true ground-distance circles (a 50-mile ring stays 50 miles at any latitude or zoom) drawn under your markers, so both PNG and Hi-Res exports include them with nothing extra to do.
- Map search (F215, ADR-111) — the search box on the map's control rail now works: type a place and press Enter and the map flies there. It's navigation only — it moves the view, it doesn't add a pin — and it uses the same geocoder (and rate limits) as the rest of the tool.
Verified
- Radius circles — formal
/verify-featurePASS: 126/126 engine suite (newtests/engine/radius.test.ts, 26 checks — geodesic geometry, styling, the dataset rule following the data, search, guards, save/load, and the map layer lifecycle — plus aradius-live-hook.test.tsthat drives the real map module so the add/remove re-ring path is proven as shipped) + a live real-Chrome pass (placing a point ring, ringing a dataset, colour, hide, delete). - Map search — formal
/verify-featurePASS: engine suite now 136/136 (newtests/engine/search.test.ts, 10 checks — fly-to at a clamped city zoom, navigation-only/not-dirty, no-match, rate-limit surfaced, standalone-dev message, Focus+ block, empty query, query sanitising, and the slide-out field) + a live real-Chrome pass (a real Enter in the field flew the camera to the geocoded point at zoom 9, no marker added, no console errors). - Engine
CFG.appVersion→ 0.9.84; bundle rebuilt. No migrations, no dependency or external-service changes.
[0.9.83] — 2026-07-12 (Legend — a standalone, exportable legend)
Legend (F213, ADR-109): a new Legend panel (in the map tool's left rail, between Markers and Views) builds a legend from your map and exports it as its own image file — PNG or SVG, white or transparent — to drop into your deck. It is not drawn onto the map itself, so it works the same in every map mode. Designed through two design-fit rounds (a deck-style card with White / Framed / Transparent / Compact looks) on the QuickMap Pro model.
Added
- Legend panel — turn on "Show a legend" and it builds itself from your datasets, the HQ marker, the focused region, and any active data-shading scale, using your real marker shapes. Give it a title, show per-dataset totals, and rename / hide / reorder any entry (drag the grip handle to reorder). Size it, and style it White or Transparent with an optional border and a text halo for legibility over busy backgrounds.
- Export the legend as its own file — choose PNG or SVG and click "Export legend"; transparent saves with no background so it sits over anything. The Export panel also gained "Also save the legend file", which drops the legend alongside the map whenever you export. Attribution stays on the map footer, not in the legend.
Verified
- Formal
/verify-featurePASS — 97/97 engine suite (tests/engine/legend.test.ts, 19 checks — spec ordering, the emitted SVG, the entries editor incl. drag-reorder, and save/load persistence) + a live real-Chrome pass (panel + preview render, white and transparent-halo, PNG rasterises clean, SVG is valid, and the connected panels/export still work). EngineCFG.appVersion→ 0.9.83; bundle rebuilt. No migrations, no dependency or external-service changes.
[0.9.82] — 2026-07-11 (Place a pin — click-to-place a location)
Push Pin (F212, ADR-108): a new "Place a pin" section in the DATA panel lets you add one exact location by clicking the map — a third way to add locations alongside pasting a list and importing a CSV. Designed through a design-fit round (the owner picked "dataset chips") and refined live.
Added
- Place a pin — open the DATA panel → "Place a pin" (it sits above "Add locations"). Pick a dataset from the chips at the top (Default is pre-selected; "+ New" creates one inline, with a Cancel to back out), click "Place a pin", then click the map where you want it. A draggable preview pin drops and shows the coordinates; nudge it if needed, type an optional name (on the same row as the "Add pin" button — Enter also adds), and "Add pin" drops it into the chosen dataset. The tool is ready for the next one immediately.
- HQ is just a dataset: pick or create a chip named "HQ" to draw those pins as HQ markers (there is no separate HQ checkbox). A blank name uses the coordinates as the label. Leaving the section, switching panels, or pressing Esc discards an un-added pin. Works on the Base and Focus maps (not the experimental Focus+ view).
Verified
- Formal
/verify-featurePASS on all 10 acceptance criteria — 78/78 engine suite (tests/engine/push-pin.test.ts, 14 checks) + 11/11 static-server render + regression + a live real-Chrome (WebGL) gesture pass. EngineCFG.appVersion→ 0.9.82; bundle rebuilt. No migrations, no dependency or external-service changes.
[0.9.81] — 2026-07-11 (Focus+ drag-to-pan + scroll-wheel zoom)
The second half of the v0.9.80 navigation batch (ADR-107, F211): the Focus+ projection canvas is now directly navigable, matching the feel of the base and focus maps.
Added
- Drag to pan in Focus+: hold the left button and drag — the view follows the pointer exactly, with grab/grabbing cursors. A tiny (<3px) jiggle still counts as a click, never a pan.
- Scroll-wheel zoom in Focus+, anchored at the cursor (the point under the pointer stays put — the same behaviour as the base map). One mouse notch ≈ one rail-button step; trackpads zoom smoothly; the 0.4–6× limits are shared with the buttons.
- Both are view state only — like moving the base-map camera, panning or zooming never marks the project unsaved and is never written into the saved map.
Changed
- Recenter ⊕ in Focus+ now restores the full fitted view — zoom factor and pan offset — not just the factor.
- The rail's zoom buttons keep the canvas centre fixed when the view is panned (previously irrelevant — panning didn't exist).
Fixed — the four open observations from the 2026-07-09 adversarial report (O1/O2/O3/O5)
- O1 — paste address hygiene. The paste parser now consumes every leading
{tag}/"label"token (first dataset tag wins, first label wins, extras are dropped), so a second tag like{Stores}{HQ} 1 Main Stnever leaks{HQ}into the geocoded address; a line whose address is left with no letter or digit (stray braces/quotes) is rejected instead of being sent to the geocoder; and the input sanitizer strips Unicode bidi/zero-width control characters, so a pasted right-to-left override can no longer visually scramble a Data Manager row. - O2 — no more silent line drops. Unusable non-blank paste lines are counted and surfaced ("· N line(s) ignored (no address)"); an all-unusable paste explains itself ("No usable locations — N line(s) had no address"). Blank lines stay silently skipped.
- O3 —
{HQ}merges into your existing HQ dataset. With a dataset already named "Headquarters" (or any HQ synonym), the{HQ}paste tag, CSV HQ routing, the Data Manager's HQ pick, and the legacy-flag migration all route into it instead of minting a duplicate "HQ" beside it. - O5 — import counts can't overstate. Rows rejected by validation (empty name, invalid coordinates) are now counted and toasted ("N row(s) skipped — missing name or invalid coordinates"), and the imported-count toast reports only rows actually kept.
- Verified with 9 new committed engine checks (
tests/engine/observations-o1-o5.test.ts) against the realSec/AppState/AddLocsMod; full engine suite 60/60.
Verified
- Formal
/verify-featurescoped run 12/12 PASS (five gesture checks driving real wheel/pointer events through the live canvas, plus the re-verified nav rail 5/5, recenter, and mode-switch). 13 new committed engine checks (tests/engine/focusplus-nav.test.ts); engine suite 38 → 51, green in CI. EngineCFG.appVersion0.9.81; bundle rebuilt.
[0.9.80] — 2026-07-11 (map nav rail · status strip · Focus+ zoom)
Owner-approved through three rendered design-fit rounds (pmppr-map/design-fit/map-nav-controls-G-strip.html — Option G + F's status strip + an expandable tools tray).
Added
- Navigation rail (top-right of the map, identical in all three modes): search slot (a compact placeholder field slides out — map search ships later) · zoom in/out · recenter ⊕ · a tools tray with two reserved, disabled slots (drawing tools, radius circles — "soon" tags) · the mode-aware reset ↺ (moved in from the old top-left pill; same confirmation dialogs).
- Status strip along the map's bottom edge: the coordinates under the cursor (4 decimals, falling back to the map centre), the zoom level ("Z 04" — or the projection factor like "1.3×" in Focus+), the © OpenStreetMap attribution link, and a live scale bar ("30 mi") computed the same way in every mode by inverting two points around the viewport centre.
- Focus+ can finally zoom: the rail's buttons scale the projection (×1.15 per step, clamped 0.4–6, session-only) and ⊕ returns to the fitted view — closing the navigation gap deferred on 2026-05-22. Buttons-only by design; drag-to-pan and scroll-wheel zoom are the next navigation batch.
Fixed
- Zoom buttons no longer double-fire. The old implementation overrode MapLibre's stock buttons by assigning
onclick, but the stock control's own click listener stayed attached — both could run on one click (a full +1 stock step, then +0.5 read mid-animation). The stockNavigationControlis removed entirely; the rail's buttons carry one clean eased 0.5-step handler each, and they grey out at the zoom limits.
Removed
- The floating "⊕ Z 04" zoom badge (readout → status strip; recenter → rail ⊕), the top-left "Reset …" text pill (→ rail ↺), and MapLibre's floating attribution control (→ the strip's link). No MapLibre control elements remain on the map.
[0.9.79] — 2026-07-10 (Markers & Labels rebuilt · per-dataset Halo · geocode rate limit)
Added
- Datasets roster — the Markers tab rebuilt. The tab is now two sections: Datasets and Labels. Every dataset is one aligned roster row (identity dot — a red ★ for the HQ dataset — name, and Mark/Label icon toggles under named columns); clicking the row opens its style block with Shape, Colour, Size, and Halo. Rows never wrap, and there are no expand carets — the row itself is the control.
- The Default dataset. New maps seed exactly one dataset, named Default (renameable in the Data tab). Rows without a dataset land in it, and every new dataset starts from its current Size & Halo, so a map keeps one consistent look as datasets are added.
- Lock. A checkbox on the Default dataset's block: while on (the default for new maps), every dataset's Size & Halo follow Default live — their sliders grey out with a "locked" tag — and unticking restores each dataset's own values exactly as they were.
- Per-dataset Halo. One slider per dataset (0–5) drives both the white ring around its markers (ring width = halo × 2/3) and the white glow behind its label text. Always white; 0 turns both off. Older projects convert automatically, preserving each dataset's existing ring and label treatment.
- Per-user geocoding limits on
/api/geocode(migration 042): 1,000 lookups per hour and 2,000 per day per account, checked before any paid provider call, with a clear "try again in N minutes" response. Deployments can tune both viaGEOCODE_LIMIT_PER_HOUR/GEOCODE_LIMIT_PER_DAY. The check fails open, so geocoding availability is never held hostage by the limiter itself. - Dataset rename. Every dataset's name is editable in place in the Data tab; renaming to or from an HQ name re-derives which rows count as headquarters.
Changed
- HQ is an ordinary dataset. A new HQ-named dataset defaults to a red star and is styled like any other row — the separate "HQ marker colour" pickers and "HQ location as star marker" toggle are gone, and the renderers no longer special-case HQ. Existing maps keep their current HQ appearance.
- Dataset state has one home. Marker visibility moved from the Data tab into the Datasets roster; the Data tab's Dataset Settings is lifecycle-only (create / rename / remove).
- Dataset toggles are instant. Showing/hiding a dataset's markers or labels now flips only that dataset's elements instead of rebuilding every marker and label on the map — measured at ~8ms with 1,000 locations (previously reported at minutes in long sessions). A perf tripwire logs any rebuild that ever exceeds one second.
- Geocode batch contract: a
queriesarray now always selects the batch provider (a 1-row final chunk no longer fell through to the single-lookup provider), and calls with more than 20 queries are refused with a clear error instead of silently dropping the extras.
Removed
- The White/None label background (absorbed into the Halo — 'None' ≡ halo 0), the White outline slider, the label halo colour picker (halo is always white), the "Show numbers inside markers" option, and the separate baseline marker size setting (the Default dataset's Size is the baseline now).
Fixed
- Dataset visibility and label toggles no longer rebuild all 1,000 marker graphics and destroy/recreate all 1,000 label popups per click.
[0.9.78] — 2026-07-09 (Data Surfaces v3 · adversarial hardening · Import ≠ map · design-fit C+D)
Later the same version — session 29 (map-tool source only, no version bump — ADR-095 precedent). Three arcs, all live on prod. ADR-098 / ADR-099. No migrations, no new F-IDs (F033/F204/F205/F206 amended). (The original v0.9.78 release — Data Surfaces v3, session 28 — follows below in this same entry; merged 2026-07-11 so Keep-a-Changelog has one header per version.)
Fixed — first adversarial pass on the v3 data surfaces (B002–B007)
Found by driving the real Sec/AppState/AddLocsMod modules with hostile inputs in a Node sandbox. All six fixed and re-attacked clean.
- Fixed (High) — Pending rows beyond
maxLocationswere silently discarded on every save→reload:toJSONwrote them all,Sec.parseProjecttruncated with.slice(0,1000), andPROJECT_INIT(the autosave-restore path) runsparseProject. The pending bucket is now bounded at input, surfaced with a toast, so the round-trip is lossless. (B005) - Fixed — CSV import enforced no row cap at all while paste enforced 200. Now bounded by the same total-row cap. (B002)
- Fixed — The Data-Manager empty-draft wipe-arm keyed on
_rows.length, so blanking every Address wiped the map in one click. The arm now fires on the computed commit result. (B003) - Fixed — Picking "HQ" in a row's Dataset dropdown called
getOrCreateDatasetimmediately: it created a real dataset, movedactiveDatasetId, dirtied the project and scheduled an autosave before Save, and survived Cancel. The draft now holds a'__HQ__'sentinel resolved only on commit. (B004) - Fixed — Removing a file during "Geocode locations" resurrected its rows as markers with a dangling
source, becausegeocodeAllpushed results from a pre-await snapshot. It now skips targets no longer pending. (B006) - Fixed —
removeFile/regeocodeFilehad no view-only guard, so a read-only recipient could mutate the live map. (B007)
Changed — owner-directed UX rework
- Changed — The Data panel uses the standard
--panel-w(284px) like every other panel; the 480px "wide" treatment is gone. - Changed — "Add Locations" and "Import Files" merged into one "Add locations" section.
- Changed — "+ Add a CSV" opens the OS file browser directly; the intermediate picker dialog is removed and Import CSV opens at column mapping.
- Changed — Import never touches the map. Every row lands as pending, carrying
lat/lonwhen the file supplies them; "Geocode locations" places coordinate-bearing rows with no provider call and geocodes only the rest. Pending rows may now carry coordinates and persist them across save/reload; the Data Manager flags them so Save does not promote them. The panel strip splits "N to place" from "N to geocode". - Changed — Removing a dataset that still holds locations now asks: delete its locations, or move them to a dataset you pick (or create inline).
removeDataset(id, mode, targetId). - Fixed — The Data-Manager search box lost focus after every keystroke (the whole body was re-rendered on
oninput); focus and caret are now restored. - Fixed — Pagination wrapped onto three lines (
.fseliswidth:100%, and the labels were bare text nodes in a flex row). - Fixed — The paste textarea rendered ~20px wide:
.fiisflex:1with nowidth, so a standalone<textarea>fell back to its intrinsiccols=20.
Changed — design-fit pass (9 findings, 4 rendered options; owner picked C + D)
- Added — Grouped-card pattern for data dialogs (CSS §7.6): body on
--bg, content groups on--surfacewith a mono.grp-hheading. The 284px panel deliberately keeps flat sections +.div-lbldividers (cards there would leave ~250px of usable width). - Added — Data Manager cards group by role (controls · data · actions), the data card flexing to fill; the five mutually-exclusive status chips became one segmented control; a result line reads "Showing 1 of 1,000 rows · filtered by … · clear"; "Geocode 0 changed" drops its zero.
- Added — Remove Dataset shows a consequence line naming the exact outcome (destructive tint for delete, neutral for move).
- Added — Five design tokens to §1 (
--green-lt --red-lt --red-tint --amber-fg --placeholder), replacing the only raw hexes in the stylesheet. The informational HQ note moved off the error-red tint; theAddress *label stopped being--red. - Changed — Every data dialog holds a fixed height and never resizes with its data, measured against its fullest state: Data Manager
min(860px,88vh)(it used to collapse 848→311px on a 1-row filter), Import CSV 580→600, Paste →560, Remove Dataset 296→350.
Verification: 31/31 Option-B checks · 23/23 adversarial (B002–B007) · 7/7 removeDataset, all against the real engine; plus live browser measurement of all four dialogs (0 overflow, heights stable across data states). Report: ADVERSARIAL-REPORT-2026-07-09-data-surfaces.md. Design records: pmppr-map/design-fit/data-surfaces-formatting-options.html, data-surfaces-C-plus-D.html.
The v0.9.78 release — Data Surfaces v3 (paste tags · multi-file import · Data Manager · HQ-as-dataset), session 28.
Owner-directed rebuild of the map tool's entire data-entry/management layer (ADR-096/097, F204–F207). An interim design (design-fit Option D) was built + verified earlier the same session, then superseded by this design before commit. Verified end-to-end same day (TEST_LEDGER sweep 2026-07-09: 31 features, 30 PASS / 1 env-blocked PARTIAL). No migrations.
- Added — Paste locations modal (
moPaste): one per line with per-line tags in any order —{Dataset}(auto-creates),"Label"/(Label),{HQ}; default-dataset select with inline "+ New"; Add to list (rows land pending) vs Add & geocode. (F204) - Added — Multi-file CSV import: per-file mapping dialog (Address req unless Lat+Lon; auto-detected roles; dataset rule from-column / new / existing / none; HQ-named values → HQ dataset); Import ≠ geocode (files accumulate as Pending/Partial/✓ cards;
AppState.dataFilesregistry persisted in the blob); per-file ⋮ Edit / Re-geocode / Remove (two-step arm). (F205) - Added — One "Geocode locations" button geocodes every pending row from every source together (batch 20/call + sequential single-provider fallback + moGeoOff) with a live ✓/⚠/✗/● summary strip; Data panel opens at 480px (matches the dialogs). (F205)
- Added — Data Manager (
moDataMgr, min(1280px, 96vw)) replacing the View Locations list: every row from every source — sort / search-all-columns / dataset·source·status filters; edit-in-place (address edit clears coords → "new" = the incremental-geocode dirty flag; dataset dropdown moves rows incl. into/out of HQ); bulk delete / re-geocode; "Geocode N changed"; 50/100/250 pagination; full-replace Save (empty-draft save requires a confirming second click) + armed Cancel; word-only status column. (F206) - Changed — HQ is a dataset: any dataset named HQ/Headquarters/Head Office renders its pins as HQ markers (multiple allowed);
loc.typeis derived, never set; legacy HQ-flag pins auto-migrate into an "HQ" dataset on load. HQ colour + HQ-as-star settings unchanged. (F207) - Changed —
/api/geocoderesults now includerelevance+resolvedName(single + batch), with a broad-admin "garbage-pin" guard: a state/country-type match for a specific multi-segment query that doesn't contain the query's first segment is knocked to relevance 0.5 → lands in the client "fix" band with the matched name shown. (ADR-097) - Removed — Inline Add Location form +
Search.js; moText one-per-line import +ImportMod.js; the 50-row View Locations list + inline label editor. (F028/F029/F031 → removed; F030 reworked into F205) - Fixed — PRE-EXISTING dataset-id collision:
loadJSONnever re-synced the id counter, so a dataset created after opening a saved project could mint a duplicate id (broke find-by-id)._nidis now collision-safe + the counter re-syncs on load. - Fixed —
MapMod.rebuildAllorphan-marker leak: bulk row removals (Manager save / remove-file / re-geocode-file) left ghost pins; a reconciliation pass now removes markers with no matching location. - Docs — data-panel.mdx rewritten to the four surfaces; getting-started step 3, system-constants (paste limit + HQ model), markers-labels HQ rows, capture-guide shot state corrected; FEATURE-CATALOG → F204–F207.
.env.local.examplenow documentsAPI_KEY_ENCRYPTION_KEY(missing locally = local-dev geocoding silently fails; pre-existing gap found this session).
[0.9.76] — 2026-05-21 (region-label theme controls + one-field paste import + MapLibre warning filter)
Three map-tool quick wins shipped, plus a planning-heavy session: a TODO accuracy audit, 12 new feature/bug entries, and a Quick Maps Pro reference review that corrected the non-US-region-fill data assumption. Code shipped in pmppr-map (commits 7100511, c047c5e); planning in pitchmappr (b24f9ae).
Added
- Region-label theme controls — closes the "Region-label theme tokens UI" item. Customize Focus Theme now has a "Region Labels" section controlling the state/province name labels shown in country focus: Text color, Halo color, Halo width, Neighbor text color, Size (6–36px; default 12 reproduces the prior auto-sizes and still scales with zoom), and Style (Noto Sans Regular / Medium / Italic — see Fixed; a Bold option shipped initially but was dropped same-day). The data was already wired into
FocusMod._addRegionLabelLayer; this surfaces it. Colors live-apply viaLiveStyleUI.setFocusColor→ newUI._syncRegionLabelStyle(paint); size/style viaFocusMod.refreshRegionLabels()(layer rebuild). New helpers_regionLabelSizeExpr+_regionLabelFontStackinFocusMod.js;_fcRowgained an optional default-color param so swatches show the real defaults. Font is glyph-constrained to Noto Sans (the protomaps CDN ships no other family — same constraint as the Data/Shape Overlay label "Style"); arbitrary font families remain F50.
Changed
- Paste-data import simplified to one field — F-paste-data-simplify (core). The text-import modal (
moText) dropped its second "Map labels" textarea; it is now a single field, one location per line. Each line is geocoded as-is; an optional leading "quoted" or (parenthesized) token overrides the displayed label (e.g."HQ" 1 Bryant Park→ marker labeled "HQ" at that address). RewrittenImportMod.runTextparser;index.htmlmodal collapsed to one textarea + updated help text. The HQ-flag-from-paste token is deferred (HQ is still a global singleton — see F-paste-data-simplify in TODO). appVersionbump to0.9.76inCFG.js(the brand pill reads it).
Fixed
-
MapLibre
Max vertices per segmentconsole spam suppressed — closes the F-debug-console-toggle MapLibre subset. v0.9.70'sLARGE_POLY_OPTS(tolerance: 0) keeps large focus-country fill polygons past MapLibre's 65535-vertex-per-bucket limit at low zoom; MapLibre handles the overflow internally (rendering is correct) but logged the warning repeatedly.CFG.jsnow wrapsconsole.warnwith a narrow regex filter that drops only that one benign message family — everything else passes through, and the warning still shows whenwindow.DEBUGis on. -
Region/overlay label "Style: Bold" rendered nothing — found in live verification, fixed same day (
pmppr-map9a97911, prod). The new Region Labels panel was eyeballed in a USA focus-mode session and selecting Bold made every label disappear: the protomapsbasemaps-assetsglyph CDN ships Noto Sans Regular/Medium/Italic but no Noto Sans Bold, so.../fonts/Noto%20Sans%20Bold/...pbf404s and MapLibre renders no glyphs. The identical'bold' → ['Noto Sans Bold']mapping existed in all three glyph-label Style pickers — region labels (FocusMod._regionLabelFontStack), Data Overlay (DataOverlayMod._fontStack), Shape Overlay (ShapeOverlayMod._soFontStack). Dropped the Bold option from all three Style dropdowns (now Regular / Medium / Italic) and remapped any persisted'bold'→['Noto Sans Medium'](heaviest weight the CDN serves) so projects saved earlier with Bold render in Medium instead of vanishing. True bold weight needs real Noto Sans Bold glyph PBFs — see F50.
Verified
- All five edited map-tool JS files pass
node --check. The paste parser and the warn-filter logic were unit-exercised in-browser against representative inputs (quoted/paren overrides, bare lines, blank-line skip; suppressed vs passed-through warnings) and behaved correctly. Region-label size scaling confirmed: default 12 reproduces the original interpolation stops. The live region-label panel was subsequently verified in a USA focus-mode session (2026-05-21): Size, Text/Halo/Neighbor colors, Halo width, and the Regular/Medium/Italic styles all work live. The Bold style was found broken (Noto Sans Bold glyphs 404 on the protomaps CDN → labels vanish) and removed from all three glyph-label Style pickers — see Fixed (pmppr-map9a97911). (Reaching the iframe-gated map tool needs a same-origin iframe wrapper + the preview browser pointed at the running :3000 dev server — the standalone tool's middleware bounces direct document loads to/dashboard.)
Planning (no code shipped — pitchmappr repo)
- TODO accuracy audit: marked shipped-but-unstruck items done (B14 v0.9.6, F8 v0.9.1, DO5 v0.9.0, F43/F44/F19 v0.9.47); annotated F12 + B20 as partial; recorded the B-mask1 smoke test passing 5/5 (kept open pending visual confirm); confirmed UX3 is a distinct still-open feature.
- 12 new TODO entries filed with implementation detail — session 1: F-brandmappr, F-paste-data-simplify, F-us-all-insets, B-focus-zoom1-fill, B-admin1-dual-source; session 2: B-theme-foliage-coverage, B-import-box-width, F-marker-legend, F-census-export, F-geocode-validation, F-geocode-cache, F-dataset-import-view — plus a major expansion of F-focus-shade-subregion.
- Quick Maps Pro (deckorate-v2) reference review corrected the "non-US subnational regions aren't fillable" assumption: the fillable polygons are the Natural Earth 10m admin-1 source we already derive boundary lines from. QMP's
highlightsmodel +_apply_default_polygon_style+ above-the-fills z-order embedded into F-focus-shade-subregion; FM1/FM2 re-scoped. A reference memory now points future sessions at the QMP codebase.
[0.9.75] — 2026-05-16 (B-mask-neighbor-stitch — polygon union for adjacent cutouts)
Focus-mode polish: the thin visible seam along shared borders between adjacent neighbor cutouts is closed. Single mechanical fix, vendored one new library.
Fixed
- B-mask-neighbor-stitch — visible seam at shared borders between adjacent neighbor cutouts closed. When focus mode is on with neighbor cutouts enabled (F47 "Roads/HWY shields in surrounding regions" or F49 "Show base map through neighbors"), each neighbor's outer ring was pushed as a separate inner-hole ring in the world-mask polygon. Where two cutouts shared a border (target↔neighbor or neighbor↔neighbor — e.g. Germany↔France, Florida↔Georgia, Tennessee↔Alabama), earcut tessellation produced near-degenerate triangles along the shared edge and rendered a thin visible seam line. The polygon-clipping union of all cutout geometries (target + neighbors) now happens BEFORE extracting hole rings, so adjacent cutouts merge into a single ring and the shared edge no longer exists as polygon geometry. Non-adjacent sub-polygons (offshore islands, non-touching neighbors) stay separate as expected. The legacy per-ring path is preserved as a try/catch fallback so the mask still renders if polygon-clipping ever fails to load. Implemented in
FocusMod.js:_buildMaskand applies anywhere that mask updates run (initial activation via_addLayers, runtime toggles viaupdateMaskCutout).
Added
polygon-clippinglibrary vendored topmppr-map/public/map-tool/lib/vendor/polygon-clipping.umd.js(mfogel/polygon-clipping v0.15.7, MIT, 29 KB minified UMD). Exposes globalpolygonClippingwithunion/intersection/difference/xorover Polygon and MultiPolygon coordinate arrays. This is the same Martinez-algorithm engine Turf uses internally for its boolean operations — picked over Turf because the only need today is polygon booleans, and the full Turf bundle is ~600 KB for ~150 functions of which we'd use 1. Loaded fromindex.htmlvia a non-bundled<script src="lib/vendor/...">tag immediately after the other external libraries (maplibre, pmtiles, d3) so it's available before the map-modules bundle evaluates. Future use cases for the same library (intersect/difference/xor for advanced cutouts or shape-overlay clipping) won't need any new dependencies — they can pull from the samepolygonClippingglobal.
Changed
appVersionbump to0.9.75inCFG.js+ brand pill inindex.html.
Verified
- Synthetic test: two adjacent unit squares sharing the x=10 edge unioned cleanly into one rectangle (4 corners, no internal vertex on the shared edge).
- Real data: Germany + 8 adjacent-mode neighbors (low-res Natural Earth) dropped from 75 cutout rings (largest 2238 verts = one country's outer) to 65 cutout rings (largest 4552 verts = the merged Europe-mainland cutout). The ~2× growth in the largest ring is the proof that adjacent mainlands collapsed into one shape rather than staying as separate-but-touching rings.
FocusMod.updateMaskCutout()runs without throwing on the live mask source after focus activation.- Visual confirmation in a real iframe-hosted focus scene is left to the user — the standalone preview iframe can't load the basemap PMTiles without the SaaS shell's session.
Bundle
map-modules.bundle.e74bfc3366.js(659.9 KB, 32 modules).themes.bundle.8a50be4c04.jsunchanged.polygon-clipping.umd.js(29 KB) loaded separately, not part of the modules bundle.
[0.9.74] — 2026-05-16 (B40 OAuth fix — signOut before signInWithOAuth)
The actual B40 fix. v0.9.73 shipped diagnostic logging; this release ships the fix the diagnostics pointed at.
Fixed
- B40 — Google OAuth "PKCE code verifier not found in storage" closed. Production failure mode was: user clicks "Continue with Google" → completes consent → server-side
exchangeCodeForSessionfails with the PKCE error → redirect to/login?error=oauth_failed. The v0.9.73 diagnostic logging on the error branch revealed the root cause cleanly: cookies received on the failing callback request contained the existingsb-XXX-auth-token.0+.1session-token chunks but ZEROcode-verifiercookies. Translation: the browser-sidesignInWithOAuthcall wasn't writing a fresh PKCE verifier when a prior session was already present.@supabase/ssr0.10.0 evidently skips or mishandles the PKCE setup step when stale auth-token cookies are in the jar; the OAuth flow still completes through Google and returns a code, but the server has no verifier to exchange it with. Fix: callsupabase.auth.signOut()(wrapped in try/catch) beforesignInWithOAuthinapp/login/page.tsx'shandleOAuth. Clears any stale session, forces a clean PKCE flow, the verifier cookie gets written, the exchange succeeds. Plus belt-and-suspenders: explicitauth: { flowType: 'pkce' }config oncreateBrowserClientinlib/supabase/client.ts— PKCE is already the default for@supabase/ssr, but being explicit protects against future SDK behavior shifts and makes the intent searchable. The v0.9.73 diagnostic logging stays in place — durable safety net for any future regression.
Changed
appVersionbump to0.9.74inCFG.js+ brand pill inindex.html.
Lesson recorded
The v0.9.73 work assumed OAuth was "seems to be working" after the deploy, but the symptom returned within ~36 hours. The actual fix in v0.9.74 was made possible only by the v0.9.73 diagnostic logging — without it, the root cause would have been speculation, not evidence. Worth keeping the diagnostic in place permanently. Documented in dev-docs/OAUTH-FIX-PATTERNS.md so brandmappr (or any future port-over) gets the corrected pattern from day one.
Bundle
map-modules.bundle.8bb4b21203.js(658.5 KB, 32 modules).themes.bundle.8a50be4c04.jsunchanged.
[0.9.73] — 2026-05-14 (Reset Map wipes saved views; iframe My Themes shows real focus-theme palette)
Quick-wins cluster. Two real fixes ship, both with mid-investigation discoveries that reshaped the work.
Fixed
- Reset Map now wipes saved views, with explicit user warning (B42 — reframed). Original B42 filing claimed Reset Map silently erases saved views; verification of
MapMod.resetMap()showed it actually didn't touchAppState.savedViewsat all. User clarified the actual intent: Reset Map should return everything to defaults except locations (and datasets). Three coordinated changes: (a) addedAppState.savedViews = []toMapMod.resetMap()so views are now wiped along with theme / layers / focus mode / overlays / marker styling; (b) rewrote themoResetmodal body inindex.htmlto enumerate what gets reset and explicitly state what's preserved ("Your locations and datasets will be preserved"); (c) added a dynamic#moResetViewsCountspan that the click handler populates with " This will also delete your N saved view(s)." whenAppState.savedViews.length > 0(collapses to empty when 0). Users with views know exactly how many they're about to lose before clicking the red Reset button. - Iframe My Themes panel — focus-theme thumbnails now render real palette (B30).
PanelContent.js:284in themythemespanel had the exact same color-extraction bug B27 fixed v0.9.7 on the user-side themes settings page: extraction readt.overridesonly, ignoringt.focusColors. Focus themes store their palette infocusColors(notoverrides), so focus-theme rows in the iframe panel fell back to the['#E0E4EC','#C8CFD9']gray placeholder. Fixed by extracting from BOTH sources via flat concat, then taking the first 4 hex values. Earlier TODO speculation that "v0.9.7's DB-driven theme refresh likely closed this" was half-right — the plumbing delivered the data, but the iframe-side extraction never consumedfocusColors.
Added
pmppr-map/supabase/MIGRATION-CONVENTIONS.md— written 2026-05-14, completes the OPS-supabase-grants work. One-page reference for every futureCREATE TABLE public.*migration: required GRANT boilerplate (authenticated + service_role; anon only on explicit per-table decision), the three-roles mental model in one sentence each, RLS-enable + policies-pattern, file-naming convention, worked example of a minimal new-table migration, and notes on the patterns the doc doesn't cover (sequences inherit via INSERT grants on owning table; functions need explicit GRANT EXECUTE; SECURITY DEFINER RPCs are the cross-RLS read pattern). Cross-references migrations 011, 021, and 025 as code examples. Closes OPS-supabase-grants in TODO.- Migration
026_drop_legacy_clients_rls.sql— PERF3 cleanup of legacyclients_*RLS policies + orphan column. Themapstable inherited two RLS policies —clients_see_own_projectsandclients_edit_allowed_projects— and theclientsJSONB column from migration 001's originalprojectstable (renamed tomapsin migration 008). These enforced a pre-SaaS sharing model ("client can see map where their email is in the JSONB list") that was fully replaced by theproject_shares-based sharing flow back in migration 011 and made share-aware on maps in migration 021. Migration 021's inline note at line 107 explicitly flagged this as the next PERF3 step. Verification confirmed zero code references tomaps.clientsanywhere inapp/,lib/,components/,tests/, orscripts/. The migration drops both policies, drops the GIN indexidx_projects_clients(carried from the pre-rename projects table), and drops the orphan column. Net: small per-query cost reduction (RLS no longer evaluates two legacy policies on every maps SELECT/UPDATE), one less JSONB column on every maps row, smaller security audit surface. Note: thedataJSONB blob on maps may still containdata.clientsproperties from old saved-map state — this migration only drops the TABLE COLUMNclients, not the data-blob slot of the same name. 2026-05-15 follow-up: production apply surfaced PG error 2BP01 — two dependent policies on sibling tables (snapshot_accessonproject_snapshots,edit_accessonproject_edits) also referencedmaps.clientsvia the same legacy "group_id OR clients JSONB" pattern (rewritten fromprojects.clientsby the 008 rename). Code-only grep missed them because they live in SQL, not application code. Migration 026 updated in place to (a) dropsnapshot_access(redundant —creators_manage_snapshots FOR ALLfrom migration 001 already covers SELECT via group_id), (b) dropedit_accessand replace with newusers_see_group_editspolicy that preserves group_id SELECT coverage onproject_editsminus the dead clients clause (required becausecreators_manage_editsis INSERT-only and the admin dashboard's edit-history view atapp/(admin)/admin/dashboard/page.tsx:38depends on SELECT being allowed). Production re-apply succeeded after the in-place fix. Lesson for future PERF cleanups that drop columns: code-only grep is necessary but not sufficient; also grep SQL migration files for<table>.<column>references before assuming the column is orphan. See commitf09fda3for the follow-up patch. - Migration
025_grants_backfill.sql— defensive Supabase Data API grants backfill (OPS-supabase-grants partial). Supabase product update (May/Oct 2026) changes the default behavior so new tables in thepublicschema are NOT exposed to the Data API without explicit GRANT statements. "Existing tables keep their current grants" — so this migration is a no-op on production. It exists to make fresh-clone scenarios work: any new Supabase project that runs our 25 migrations from scratch (Audit-#8's.env.testdirection, new staging projects, future production swap) would otherwise create the 27 public tables without grants, and supabase-js queries would return PostgREST42501errors. The migration uses aDOblock to iteratepg_tables WHERE schemaname = 'public'and runGRANT SELECT, INSERT, UPDATE, DELETE ON public.<table> TO authenticated+ the same toservice_roleon each.anonis deliberately omitted — per-table decision, not a default. RLS policies on each table still enforce the actual access boundaries; grants are necessary-but-not-sufficient. Functions and sequences not touched (separate grant pattern, no current breakage). Still open under OPS-supabase-grants: the migration-conventions doc that captures the GRANT + RLS + policies boilerplate as a rule for every newCREATE TABLEmigration.
Verified closed (TODO bookkeeping)
- B16 (admin theme detail map white bar behind zoom buttons) — user-confirmed closed 2026-05-14 without explicit code investigation. If a regression appears, suspect
pmppr-map/public/map-tool/preview.html(the*box-sizing reset's interaction with MapLibre canvas sizing) or the map preview container inpmppr-map/app/(admin)/admin/themes/[id]/page.tsx:597. - B14 (Focus mode missing islands — Long Island missing in NY state focus) — confirmed closed by B33's v0.9.6 ring-winding normalization.
FocusMod.jslines 173-175, 684-720, 769 contain_ringSignedArea/_rewindRing/_rewindGeometryhelpers called from both_addLayersand_buildMask. The fix is universal across all mis-wound source polygons (not just NY), and the B33 TODO entry itself predicted this would close B14. - B31 (user profile page saved themes color extraction) — confirmed closed in v0.9.7, entry just never got marked.
app/(user)/settings/themes/page.tsx:223-229reads from botht.data?.overridesANDt.data?.focusColors, inline comment at line 222 references the fix explicitly. TODO entry now reflects the real status.
Changed
appVersionbump to0.9.73inCFG.js+ brand pill inindex.html.
Bundle
map-modules.bundle.4224f695b3.js(658.5 KB, 32 modules).themes.bundle.8a50be4c04.jsunchanged.
Note on B42's reframing
The original B42 filing was based on user verbal report ("saved views are erased when you reset") without code verification. Verification showed the reverse — views weren't being wiped — and the user clarified the actual intent during the fix session. Small process reminder: verify behavior before filing wiping-state claims; the symptom direction matters.
[0.9.72] — 2026-05-11 (Console-noise cluster — DEBUG verbosity flag, shield null-warning silenced, stale TODO entries reconciled)
Small hygiene release. Two real fixes ship; two TODO entries that were already-done-but-never-marked get closed; one audit-tracking number (Audit-#10) gets routed to its proper homes.
Added
window.DEBUGverbosity flag — F-debug-console-toggle (partial). New runtime flag inpublic/map-tool/lib/map-modules/CFG.jsderived fromlocalStorage.getItem('pmppr.debug') === '1'(preferred — persists across reloads, works in iframe and standalone) OR?debug=1URL param on the standalone map-tool URL (note: query params do NOT propagate Next.js parent → iframe automatically, so localStorage is the practical path). Five diagnostic log sites in the iframe are now gated behindif(window.DEBUG): the init banner (3console.infolines ininit.js),[DataOverlays] bridge received endpointsinPostMessageBridge.js,[ExportMod] _applyFocusClip — geometries:inExportMod.js,[HiRes] offscreen canvas:inExportMod.js, andDataOverlay: loaded <abbr>inDataOverlayMod.js. Legitimateconsole.warn/console.errorcalls are NOT gated — those still surface data-load failures, geocoder fallbacks, and similar real problems. To enable from DevTools:localStorage.setItem('pmppr.debug','1')then reload; to disable:localStorage.removeItem('pmppr.debug')then reload. Still open: MapLibre-internal warnings (theMax vertices per segment is 65535...noise from v0.9.70's large-polygon work) are not gated — that would require globally wrappingconsole.warnwith a regex filter or finding a MapLibre-side option to silence those specific warnings. Different ergonomics, separate item.
Fixed
- Shield-expression null-feature warning silenced — B-shield-sprite (null-warning subset). Added a layer-level filter
['to-boolean', ['get', 'ref']]to theroads_shieldslayer inThemeMod.js(immediately after the existingshield_text → refremap).to-booleanevaluates to false for null, undefined, empty string, and 0 — features without a route number are now filtered out before the shield expression runs, so the evaluator never reacheslength(null)and MapLibre stops loggingExpected value to be of type string or array, but found nullper null-ref feature per tile. Pairs with v0.9.57'sstyleimagemissinglistener (which separately silences theImage "generic_shield-Nchar" could not be loadedwarning). Visual behavior unchanged — features that previously rendered a transparent placeholder shield now render no shield, which was the visible outcome already. Sprite-asset regeneration (the proper fix for the missinggeneric_shield-Ncharvariants) remains open under the broader B-shield-sprite entry.
Changed
appVersionbump to0.9.72inCFG.js+ brand pill inindex.html.- TODO bookkeeping reconciled. B-subtog marked closed (was actually shipped in v0.9.60 —
PanelContent.js:633already gates the neighbor cutout sub-toggles behindif(neighborsActive && fm.showBasemapRoads); inline comment at line 628 already references "B-subtog (v0.9.60)"). Audit-#9 (cloned_fromnot written on clone) marked closed (was actually shipped in v0.9.59 —app/api/maps/route.ts:44-50and:98-100already destructure and persistclonedFrom; inline comment at line 44 already references "v0.9.59 (audit-#9)"). Audit-#10 updated to reflect the post-2026-05-11 state: autofix subset is empty (npm run lint -- --fixran with zero file modifications; 238 errors + 445 warnings remain, all needing human judgment), TS error subset is closed (v0.9.59), and the 9-failed-test-files subset routes to B-rls-tests-rewrite (the architectural problem that those tests use the service-role client and don't actually validate RLS). B40 (Google OAuth double-login, SECOND REGRESSION of B25 → B38) newly filed in the TODO with diagnostic playbook leading with Vercel function logs from the B38-era[auth/callback]branch tracing before any code change.
Bundle
map-modules.bundle.0bff5902de.js(657.0 KB, 32 modules).themes.bundle.8a50be4c04.jsunchanged.
[0.9.71] — 2026-05-09 (Geocoder cascade across org/dept/group + F55(d) Stage 1 marker/label slider with live preview + Hi-Res canvas-clamping fix)
The largest single-session cluster since the v0.9.40s focus restructure. Four meaningful pieces, two pre-existing bugs closed.
Added
- Geocoder cascade — org → dept → group. Migration 024 adds
geocoder_single,geocoder_batch,nominatim_url,maptiler_api_key_encryptedcolumns toorganizationsanddepartments, with matchingset_organization_api_key/set_department_api_keyRPCs. DropsNOT NULLand'nominatim'default on the existing group columns so groups can opt to inherit./api/geocode/route.tsresolves the cascade bottom-up —group ?? dept ?? org ?? hard-coded fallback— each field independent. New sharedGeocoderConfigCardcomponent (components/admin/geocoder-config-card.tsx) replaces the ~60-line inline geocoder UI on the group page and adds the same card to org and dept admin pages. Each card shows the EFFECTIVE resolved value alongside the editable field — e.g. "Inherit (effectively MapTiler from Acme Org)" — so admins don't have to walk the chain mentally. Supporting helperlib/geocoder-cascade.tsmirrors the route resolver client-side. - F55(d) Stage 1 — Marker & label size slider with live preview. Slider 0.5×–3.0× (default 1.0×) in the Quick Export panel; multiplies marker radii and marker-label font sizes (and their above/right offsets) at export. Live preview: dragging the slider rebuilds live markers and labels in real time so the user can see what an export will look like without the export-save-open-compare-repeat cycle. Implementation:
MarkerMod._builddefaultsexportScaletoAppState.settings.exportMarkerLabelScalewhen not given a literal value, so live calls viaMapMod._makeMarkerElhonor the slider;LabelMod.applyToMarkermultiplies live popup font + offset by the same slider;oninputhandler callsMarkerMod.rebuildAll()viarequestAnimationFrameso a fast drag coalesces. Compounds with per-viewview.labelScale. Persists inAppState.settings.exportMarkerLabelScale. - Geocoding-off UX modal. New
moGeoOffmodal inindex.htmlreplaces the inline red "Geocoding (single) not enabled" status text with a proper modal that explains the situation and offers "Open CSV import" as a next step.Search.addopens the modal on substring match for "not enabled". - CSV partial-failure messaging.
ImportMod._queuetracks ageocodingDisabledflag — first timeGeo.searchthrows "not enabled", subsequent rows skip the geocode call (no more 100× redundant 403s). Rows with lat/lon already set continue importing. End-of-import surfaces a clear message —"Imported 50 of 100. 50 rows skipped — geocoding is not enabled."— and pops the modal. - Iframe-only gate for
/map-tool/*.html.middleware.tsredirects direct browser navigation (Sec-Fetch-Dest: document) to/dashboard; iframe loads pass through. Subresources bypass the middleware entirely via the matcher exclusion.
Changed
- No system defaults for geocoding. Three silent fallbacks removed: (a) the legacy
groups.geocoder_providercolumn is no longer read by the resolver — it was silently keeping'maptiler'alive even when admins set every level to "Inherit". (b) The org-level dropdown loses the "Inherit" option (org IS the top of the cascade — no level above it). (c) The env-varMAPTILER_DEFAULT_KEYis no longer consulted as a key fallback; if you pick MapTiler somewhere, you must set a key somewhere in the cascade. Per user direction: "we don't need a system default; it should be none or a specific selection." - Transparent background gated to focus mode only. The checkbox in Quick Export and Export Views now renders only when
mapMode === 'focus'.ExportModbelt-and-braces ignores the setting at export time when not in focus mode, so old project JSON saved with the setting on doesn't silently break new exports. Rationale: in regular mode the basemap is fully colored, so transparent just strips ground content with no useful payoff.
Fixed
- PNG Hi-Res from Export Views — black padding bug. Saved-view exports came out with map content in the upper-left and a large black region on the right and bottom; Quick Export Hi-Res was unaffected. Root cause: MapLibre 4.x's
maxCanvasSizeconstructor option defaults to[4096, 4096], silently clamping the offscreen canvas regardless ofpixelRatio. For viewports wider than 4096/SCALE the resultingbaseCanvaswas smaller thancssW × SCALEand the saved-view crop math read source rectangles past the canvas edge → transparent pixels. Fixed two ways: (a)maxCanvasSize: [16384, 16384]so MapLibre allocates the full requested resolution; (b) defensively deriveactualScaleX/YfrombaseCanvasdimensions and use those indrawImagesource coords, so even if MapLibre ever caps for unrelated reasons (older browser, weak GPU) the crop stays clean. PanelContent.quickexport()template-literal corruption. Backticks inside an HTML comment that lived inside a JS template literal terminated the literal early, silently corrupting the entire panel render. Bundle still built (no top-level syntax error) but runtime call failed and the iframe never finished init. Comment now uses straight quotes.- Middleware short-circuit for
/map-tool/*. Initial iframe-only gate routed map-tool loads through Supabase'supdateSession, whose session cookie write step interfered with serving static assets to iframes — map viewer hung on "Loading…". Fixed by short-circuiting/map-tool/*paths withNextResponse.next()immediately after the Sec-Fetch-Dest gate;updateSessiononly runs for SaaS routes.
Removed
groups.geocoder_providerlegacy fallback in the resolver. Column still exists in DB (not dropped — out of scope) but no longer read. Optional one-time SQL cleanup:UPDATE groups SET geocoder_provider = NULL WHERE geocoder_provider IS NOT NULL;(cosmetic, doesn't affect behavior).MAPTILER_DEFAULT_KEYenv-var fallback ingeocodeWithProvider. Env var is no longer read; leaving it set in.env.localis harmless but unused.- "Also scale basemap labels" checkbox (UI-hidden, kept in code). Companion to the size slider that scales MapLibre symbol-layer text-sizes via
_scaleBasemapLabels. User testing surfaced a regression where enabling it broke the basemap (fills/lines) in PNG Hi-Res output on regular-mode maps. Per user direction not to chase the fix (the slider+checkbox approach is itself a workaround), the checkbox was hidden from the UI. The helper code and theAppState.settings.exportScaleBasemapLabelsfield are kept for possible future reactivation. - "Inherit" option from the org-level provider dropdown. Org is the top of the cascade, so "Inherit" was nonsensical there.
Verified
User-confirmed in browser: geocoder cascade (set MapTiler at org → all groups under it use the org's key); slider live preview (drag → markers/labels resize); PNG Hi-Res from Export Views (no black padding, full resolution); transparent checkbox absent in regular mode; iframe-only gate (typing /map-tool/index.html in the address bar redirects to /dashboard).
Known noise + limitations
- FEATURE-CATALOG numbered-narrative gap. §12.43 lands directly after §12.42 (v0.9.58); v0.9.59 → v0.9.70 sections were never added to the narrative section (CHANGELOG covers them). This release does NOT close that gap; it just adds its own section.
- MapLibre
Max vertices per segment is 65535warnings continue at low zoom on large focus countries — same as v0.9.70, expected and non-fatal, tracked under F-debug-console-toggle.
Files
Code (pmppr-map)
public/map-tool/lib/map-modules/CFG.js—appVersion: '0.9.71'public/map-tool/index.html— brand pill v0.9.71;moGeoOffmodalpublic/map-tool/lib/map-modules/AppState.js— defaultspublic/map-tool/lib/map-modules/MarkerMod.js— slider live preview hookpublic/map-tool/lib/map-modules/LabelMod.js— slider live preview hookpublic/map-tool/lib/map-modules/PanelContent.js— slider UI; transparent checkbox gatingpublic/map-tool/lib/map-modules/ExportMod.js—_compositeOverlaysslider integration;_pngHiResclamping fix; transparent-mode gatepublic/map-tool/lib/map-modules/Search.js— geocode-off modalpublic/map-tool/lib/map-modules/ImportMod.js— partial-failure messagingpublic/map-tool/lib/map-modules/Geo.js— clearer standalone errorapp/api/geocode/route.ts— cascade resolverapp/(admin)/admin/{organizations,departments,groups}/[id]/page.tsx—GeocoderConfigCardwired incomponents/admin/geocoder-config-card.tsx— NEWlib/geocoder-cascade.ts— NEWmiddleware.ts— iframe-only gatesupabase/migrations/024_geocoder_cascade.sql— NEW
Docs (pitchmappr)
dev-docs/CHANGELOG.md— this entrydev-docs/HANDOFF.md— replaceddev-docs/spec/FEATURE-CATALOG.md— §7.1 / §7.2 export entries revised; §12.43 appendeddev-docs/spec/PROJECT-DATASHEET.md— version bumpdev-docs/spec/handoffs/2026-05-09-v0.9.71-cluster.md— NEW dated archivedocs/pages/export-panel.mdx— Quick Export / Export Views table updates
Migration
Migration 024 must be applied to the hosted Supabase before the new admin cards on org/dept pages render correctly (they query the new columns). Two paths:
- Dashboard: paste
pmppr-map/supabase/migrations/024_geocoder_cascade.sqlinto Supabase Dashboard → SQL Editor → run. - CLI:
cd pmppr-map && npx supabase link --project-ref lnywpgkygnvjqbdqmeql && npx supabase db push.
User confirmed migration applied during this session — production env is up to date.
[0.9.70] — 2026-05-06 (B-mask2 — focus-mask seams at low zoom on large countries fixed via geojson-vt source options)
Closes the B-mask2 family of mask seams that the user reproduced across Brazil, US (CONUS), and Canada with no settings selected. The world-mask polygon and focus-country polygon are very large for these countries (mainland Canada is ~15k vertices, US ~9k, Brazil ~7k) and span many tiles at low zoom. MapLibre's geojson-vt defaults (tolerance: 0.375, buffer: 128) Douglas-Peucker-simplify per zoom and clip per tile, which can leave sub-pixel hairline gaps where the polygon crosses tile seams — visible as horizontal seams across mid-Brazil / mid-US and white slivers between Arctic islands in Canada. Fix: new module-level constant LARGE_POLY_OPTS = { tolerance: 0, buffer: 512 } spread into the three GeoJSON sources fm-geo, fm-mask, fm-target in FocusMod.js. tolerance: 0 disables per-zoom simplification (preserves exact polygon shape across tile boundaries); buffer: 512 quadruples the default tile-edge overlap (hides any remaining sub-pixel cracks).
Verified
User-confirmed in browser: Brazil, US, Canada all clean at the previously-broken zoom levels with no settings selected.
Known noise + limitations
Console warnings — Max vertices per segment is 65535: bucket requested N at low zoom on large countries. Informational, not errors. With tolerance: 0, the world-mask polygon for Canada / Russia / etc. exceeds MapLibre's 65535-vertex-per-bucket limit (~150k tessellated for Canada). MapLibre handles the overflow by splitting into multiple buckets — rendering correctness preserved. Suppressing would require simplification, which re-introduces the seams. Filed as F-debug-console-toggle to add a user-facing verbosity switch later.
Florida + "Show base map through focus" + Include surrounding regions still shows a horizontal seam across northern Tennessee. That's a different root cause — adjacent neighbor states are added as separate cutout rings in the mask, and where two states share a border (e.g., Tennessee's south edge ≡ Alabama's north edge), the source data has them as independent rings with floating-point-epsilon-different coordinates. MapLibre's tessellator leaves a hairline sliver where the two cutouts almost-but-don't-quite overlap. Fixing this needs neighbor-polygon union (turf-style boolean operation) before pushing as cutouts — filed as B-mask-neighbor-stitch in TODO.
Files
Code (pmppr-map)
public/map-tool/lib/map-modules/CFG.js—appVersion: '0.9.70'public/map-tool/index.html— brand pill v0.9.70public/map-tool/lib/map-modules/FocusMod.js— newLARGE_POLY_OPTSconstant; spread into the three large-polygon GeoJSON source addSource calls
Docs (pitchmappr)
dev-docs/CHANGELOG.md— this entrydev-docs/HANDOFF.md— v0.9.70 section prependeddev-docs/spec/handoffs/2026-05-06-mask-seam-fix.md— new dated archive (same date as the canvas-mask refactor archive — separate file because separate bug family)dev-docs/spec/FEATURE-CATALOG.md— version header bumpeddev-docs/spec/PROJECT-DATASHEET.md— version bumpdev-docs/TODO.md— B-mask2 closed; new entry B-mask-neighbor-stitch filed; next-session block re-numbered
[0.9.67 → 0.9.69] — 2026-05-06 (B-transparent-canvas-mask — canvas-level alpha clip closes the F55(b) show-through case; sub-pixel feather added)
Closes B-transparent-canvas-mask end-to-end. Replaces the v0.9.62-66 layer-level neutralization with a canvas-level alpha clip in the transparent + focus-mode export path. The live map renders untouched (full WYSIWYG — show-through, hillshade, parks, water, neighbors all preserved exactly as on screen); after capture, the canvas is clipped to the focus polygon (always) plus any visible neighbor polygons. Pixels outside that union → alpha=0; inside → keep their rendered color. Markers / labels / watermark composite AFTER the clip per the product decision (so the corner watermark always survives). Regular-mode transparent export unchanged.
Fixed (per-step)
- v0.9.67 — canvas-mask refactor. New
_applyFocusClip(canvas, cropX, cropY, cropW, cropH, map)builds aPath2Dfrom focus + visible-neighbor polygons (projected viamap.project(), scaled to canvas pixels viacanvas.width / cropW) and applies it viaglobalCompositeOperation = 'destination-in'. Wired into_pngStandard(usesMapMod.map) and_pngHiRes(uses the offscreenoffMapinstance). Mode-detection split:transparent && mapMode === 'focus'→ canvas-mask path;transparent && mapMode !== 'focus'→ keeps the existing_makeTransparentStylelayer-level approach. Neighbor inclusion criterion:fm-neighbors-fillexists AND visibility ≠ 'none' ANDfill-opacity > 0— mirrors what's actually rendering in the live map. - v0.9.68 — robust GeoJSON source data extraction. v0.9.67 used a ternary
getData ? getData() : _datathat returned a Promise (without awaiting) on MapLibre 4.x. Result:Promise.geometry === undefinedso no geometries reached the clip path →_applyFocusClipearly-returned and exports came out fully opaque. New_readGeoJSONSource(source)helper prefers sync_data, falls back to awaitedgetData(), withquerySourceFeaturesas a final stable-public-API fallback. Plus a diagnosticconsole.log('[ExportMod] _applyFocusClip — geometries: N (focus polygon + M neighbors)')so future failures are immediately visible in DevTools. - v0.9.69 — sub-pixel edge feather. Pure pixel-cutoff
destination-inproduced visible stair-stepping along long shallow-angle polygon segments (user feedback after v0.9.68). Fix: build the alpha mask on an offscreen canvas with a small blur (ctx.filter = 'blur(...)'), thendrawImageit as the destination-in source. Blur radius scales with canvas resolution viaMath.min(scaleX, scaleY) * FEATHER_LOGICAL_PX(default 0.5) so the feather stays ~0.5 logical pixels at any DPR/Hi-Res scale — visually below the threshold of "fuzzy" while killing stair-stepping. Constant lives at the top of_applyFocusClipfor easy tuning.
Verified
User-confirmed end-to-end in browser:
- Standard case (focus + neighbors visible + admin1 ON, no show-through): focus polygon shows basemap content, dashed admin-1 inside, adjacent neighbors filled with
neighborFillColor, watermark survives, outside fully transparent. - Show-through case (focus + show-base-map-through ON): focus polygon shows full basemap (parks, roads, water, hillshade) — the v0.9.62-66 partial-fix limitation is gone.
- PNG and PNG Hi-Res both work via the same
_applyFocusCliphelper (parameterized on the map instance). - Console diagnostic confirms geometry count:
geometries: 1for focus-only modes;geometries: 6 (focus polygon + 5 neighbors)for adjacent-neighbors mode in tests.
Files
Code (pmppr-map)
public/map-tool/lib/map-modules/CFG.js—appVersion: '0.9.69'public/map-tool/index.html— brand pill v0.9.69public/map-tool/lib/map-modules/ExportMod.js— new_applyFocusClipand_readGeoJSONSourcemethods;_pngStandardand_pngHiResupdated to split mode handling and call_applyFocusClipBEFORE_compositeOverlays. The legacy_makeTransparentStyleis retained for regular-mode transparent (no focus polygon to clip to).
Docs (pitchmappr)
dev-docs/CHANGELOG.md— this entrydev-docs/HANDOFF.md— v0.9.67 → v0.9.69 section prependeddev-docs/spec/handoffs/2026-05-06-canvas-mask-refactor.md— new dated archivedev-docs/spec/FEATURE-CATALOG.md— version header bumped; Transparent background row updated to reflect the show-through case now workingdev-docs/spec/PROJECT-DATASHEET.md— version bumpdev-docs/TODO.md— B-transparent-canvas-mask moved to closed; F55(b) show-through limitation note removed; next-session priority block re-numbered
[0.9.62 → 0.9.66] — 2026-05-05 (F55(b) PNG transparent-background — partial fix; show-through case deferred to canvas-mask refactor)
Five-step iterative tightening of the PNG transparent-background export based on user testing in browser. Status: working for the standard case (focus mode, no show-base-map-through); partial for the show-through case (deferred to a follow-up canvas-mask refactor — see new TODO B-transparent-canvas-mask). Patches landed across ExportMod._makeTransparentStyle plus a synthetic fill injection for the focus polygon. Each step closed a real symptom but the architecture has a fundamental ceiling: MapLibre has no "clip tile-bounded basemap to polygon" mechanism, so making earth/landcover visible inside focus while alpha=0 outside isn't possible at the layer level — needs canvas-side alpha masking. The deferred refactor is filed; this release captures the patch series as a stable checkpoint.
Fixed (per-step)
- v0.9.62 — extended
_makeTransparentStylefrom 4 layer roles to a full earth-class denylist. Pre-fix only neutralizedbackground,waterocean-filter, focus-modeearth, andfm-mask. Every other landcover/landuse/parks/buildings layer kept its theme color, producing a "light grey checkerboard" instead of true transparency. Post-fix: regex^(earth|landcover|buildings)$|^landuse_neutralizes every protomaps land-class fill (coversearth,landcover,buildings, all 11landuse_*variants fromThemeMod._layerCategories);hillshaderaster hidden viavisibility:'none'since fill-color tricks don't apply.earthis now neutralized in BOTH regular AND focus mode (was focus-only — that was the second source of light-grey leakage in regular-mode exports). - v0.9.63 — hid neighbor layers in transparent export. Initially seemed correct (default
neighborMode:'adjacent'was painting Brazil/Chile/Uruguay/Bolivia atneighborFillColor#F2F2F2light grey, which the user reported as "transparent mode shows the surrounding countries"). Reverted in v0.9.65 after the user clarified they wanted WYSIWYG with the live view — neighbors visible in live → neighbors visible in export. Lesson: don't selectively hide layers the user has explicitly enabled. - v0.9.64 — synthetic
fm-target-fill-exportinjection. DefaulttargetFillColor:'transparent'means nofm-target-filllayer is added to the live style. With v0.9.62 makingearthtransparent, the focus polygon had nothing painting it in the export — exported as outline-only, which the user (correctly) called out as not matching the live view. Fix: when transparent + focus active + nofm-target-fill, inject a synthetic fill usingfm-targetsource withneighborFillColor(the user's "land area" reference shade). Picks up the user's neighbor color choice; falls back to#E8E8E8. - v0.9.65 — synthetic fill insertion point. v0.9.64 inserted just before
fm-target-border, which placed it ABOVEfm-admin1-borderand painted over the dashed internal admin-1 boundaries. Fix: insert just AFTERfm-mask(where the naturalfm-target-fillwould sit perFocusMod._addLayersordering), so admin1 borders render on top. Same v0.9.65 commit reverted v0.9.63's neighbor hiding. - v0.9.66 — skip synthetic fill when "Show base map through focus" is on. With show-through enabled, the synthetic solid fill obscured exactly the area the user wanted basemap content to show through. Fix:
if(isFocus && !hasTargetFill && !showThrough)— readLiveStyleUI._showBaseMapto detect mode. Caveat: this is a partial fix. With show-through ON in transparent export, the user sees water lakes + roads + labels inside focus, but NOT earth/landcover/parks/hillshade (still globally neutralized). Proper fix needs canvas-level alpha masking — see the new TODO entry B-transparent-canvas-mask.
Verified
End-to-end in user's browser at fit-to-country zoom for the standard (no show-through) case:
- Argentina focus, default settings (
targetFillColor:'transparent'), neighborMode='adjacent', showAdmin1=true: Argentina filled withneighborFillColor, dashed internal admin-1 boundaries visible inside Argentina, Chile/Bolivia/Brazil/Uruguay filled with same color + their borders, all on transparent bg. Matches user expectation. - Bahrain focus (regular mode + transparent): country shape visible on transparent bg. (User flagged the polygon's chunky outline — that's the underlying Natural Earth 10m polygon, not an export bug — see new TODO F-higher-res-coastlines.)
- Show-through ON case: confirmed by user as not yet correct. Captured here as known-limitation; refactor TODO filed.
Known limitations (deferred)
- F55(b) show-through case — needs canvas-mask refactor (see B-transparent-canvas-mask).
- F55(b) symptom (b) — "solid regions appearing partially transparent" in the original ticket. Diagnostic suspects in TODO. Not investigated this session because no clear repro was surfaced after the v0.9.62-66 layer fixes.
- Bahrain (and other small-country) coastline detail — Natural Earth 10m is the highest-res polygon currently used. Higher-detail OSM-derived polygons need a separate task — see F-higher-res-coastlines.
Files
Code (pmppr-map)
public/map-tool/lib/map-modules/CFG.js—appVersion: '0.9.66'public/map-tool/index.html— brand pill v0.9.66public/map-tool/lib/map-modules/ExportMod.js—_makeTransparentStylerewritten as described above; syntheticfm-target-fill-exportinjection added; show-through guard readsLiveStyleUI._showBaseMap
Docs (pitchmappr)
dev-docs/CHANGELOG.md— this entrydev-docs/HANDOFF.md— v0.9.62-66 section prependeddev-docs/spec/handoffs/2026-05-05-transparent-export-patches.md— new dated archivedev-docs/spec/FEATURE-CATALOG.md— Transparent background row updated; version header bumpeddev-docs/spec/PROJECT-DATASHEET.md— version bumpdev-docs/TODO.md— F55(b) marked partial; new entries B-transparent-canvas-mask (architectural refactor for show-through) and F-higher-res-coastlines (OSM-derived polygons for small countries)
[0.9.61] — 2026-05-04 (B-admin1-coverage post-ship fixes — default-OFF, density actually differs, low-zoom labels)
Three follow-up fixes for v0.9.60's region-label overlay, surfaced during the user's first hands-on browser test of the shipped feature. All three were observable bugs in the same code path that v0.9.60 added; the underlying overlay architecture didn't change. After these fixes, B-admin1-coverage is fully verified end-to-end — Brazil shows all 27 states at fit-zoom, USA shows all 51 (overlapping is expected and intended at low zoom under density "All"), France continues to work, and the State / region labels checkbox correctly defaults to off.
Fixed
- State / region labels checkbox defaulted ON visually despite
fm.showStateLabels:false._addRegionLabelLayerand_addNeighborRegionLabelLayeradded their symbol layers without an explicitvisibilityproperty, so MapLibre's default (visible) won regardless of the AppState flag. Fix: readfm.showStateLabelswhen building the layout block —'visibility': fm.showStateLabels ? 'visible' : 'none'. The checkbox now matches what's drawn on first paint. - Density
All/Major only/Minimalrendered the same number of labels at low zoom. Even withAllmode loading all 27 Brazilian features into the source,text-allow-overlap: falsemade MapLibre's collision detection the binding constraint at low zoom — only ~11 labels survived in any density. Fix: when density is'all', settext-allow-overlap: trueandtext-ignore-placement: trueso every label renders even when colliding (this is what "show me all of them" actually means).MajorandMinimalkeep collision avoidance with the labelrank filter, so the dropdown now produces visibly different output at every zoom. - Toggle ON in country focus resurrected the basemap
places_regionlayer, fighting the overlay for collision slots._toggleFocusLabelCategory('state', true)set visibility on everyplaces_region*ANDfm-region-label*layer. But_hidePlacesRegion()had hiddenplaces_region*permanently for country focus — the toggle un-hid it, and protomaps'ref-field abbreviations (AC/AM/AP/MA/CE…) collided with our overlay's full names, with the basemap winning most slots. Fix: in country focus, the state-category toggle only flips thefm-region-label*overlay;places_region*stays hidden via_hidePlacesRegion(). US-state focus still togglesplaces_region(its abbreviation override path is unchanged). - Country focus showed no state labels at the natural fit-to-country zoom for large countries.
fm-region-labelhadminzoom: 3.5andfm-neighbor-region-labelhadminzoom: 4. USA fits at ~zoom 3, Brazil at ~3.5–4 — the floor was clipping labels at the exact zoom users land on after picking a country. France (fits at ~5) worked, masking the issue during code-only verification. Fix: removedminzoomfrom both layers; extendedtext-sizeinterpolation down to zoom 2 so labels stay readable when zoomed out. The layers only exist during active country focus, so there's no risk of labels leaking into world view. _hidePlacesRegion/_restorePlacesRegionswitched from a single literal layer id to a regex sweep over^places_region.*. Defense in depth — themes that ship variants (places_region_minor, etc.) wouldn't have been caught by the v0.9.60 implementation, leaving abbreviated labels on screen. The internal save/restore state changed from a string to a per-layer-id map, so partial restores remain correct.
Files
Code (pmppr-map)
public/map-tool/lib/map-modules/CFG.js—appVersion: '0.9.61'public/map-tool/index.html— brand pill v0.9.61public/map-tool/lib/map-modules/FocusMod.js—_addRegionLabelLayer+_addNeighborRegionLabelLayerhonorfm.showStateLabelsfor initial visibility, switchtext-allow-overlap+text-ignore-placementon density'all', no longer setminzoom, extendedtext-sizeinterpolation down to zoom 2;_hidePlacesRegion+_restorePlacesRegionrewritten to sweep allplaces_region*symbol layers; module-scope_origPlacesRegionVisibilityis now a{layerId → 'visible'|'none'}map (or null)public/map-tool/lib/map-modules/UI.js—_toggleFocusLabelCategory('state', …)skipsplaces_region*in country focus and only togglesfm-region-label*; US-state focus unchanged
Docs (pitchmappr)
dev-docs/CHANGELOG.md— this entrydev-docs/HANDOFF.md— v0.9.61 handoff section prependeddev-docs/spec/handoffs/2026-05-04-region-labels-overlay.md— v0.9.61 follow-up section appended (same dated archive — same session, same date, immediately-following work)dev-docs/spec/FEATURE-CATALOG.md— version header bumped; State / region labels Default corrected from "ON" to "OFF" (was always false in AppState; v0.9.60 row inherited a stale assertion); Density description expanded with the v0.9.61 overlap-on-Allbehavior; minzoom removal noteddev-docs/spec/PROJECT-DATASHEET.md— version bumpdev-docs/TODO.md— B-admin1-coverage moved to closed/verified
Verified
End-to-end in the user's browser at fit-to-country zoom: USA shows all 51 states under density All; Brazil shows all 27 under density All; toggling the checkbox off hides the overlay; switching density between All / Major / Minimal produces visibly different label counts; France continues to render correctly.
[0.9.60] — 2026-05-04 (Comprehensive admin-1 region-label overlay; SVG export removed; B-state-labels-persist + B-subtog fixes)
Major focus-mode fix and an export-pipeline simplification. The headline change is B-admin1-coverage: country-focus state/province labels are now sourced from a custom comprehensive Natural Earth overlay instead of the protomaps places_region layer, which had highly uneven coverage per country (Brazil 13/27, Mexico 30/32, Argentina 0/24, France 0/18, Australia 6/8). Code-side complete; visual confirmation done via property-based tests in Chrome (basemap PMTiles tile rendering had an environmental issue in the test browser session that did not block the new layer's data plumbing). Two smaller fixes ride along: state labels now correctly clear when surrounding-regions mode flips to None (B-state-labels-persist), and the Roads/HWY shields neighbor sub-toggle is now hidden unless prerequisites are met (B-subtog). SVG export pathway is fully removed (F55(a)) — the user is abandoning vector export and focusing on PNG; the relabel-Hi-Res-as-300-DPI experiment was attempted and reverted (F55(c) dropped — actual delivered DPI varies due to canvas-cap clamping and the success toast already self-reports the real pixel dimensions). Higher-DPI presets (600/900) and the transparency-leak fix are deferred to a follow-up release as F55(b)/F55(d).
Added
- Comprehensive admin-1 region-label overlay (B-admin1-coverage). New
fm-region-labelandfm-neighbor-region-labelsymbol layers fed from a slim Natural Earth points GeoJSON (/map-tool/data/admin1-labels.geojson— 4589 features globally, 825 KB raw, ~250 KB gzipped). Replaces protomaps' unevenplaces_regionlayer in country-focus mode. Brazil now shows all 27 federal subdivisions including the previously-missing MT, MG, RJ, RS, SC, GO, MS, DF, PR, RO, RR, TO, PA, SE. Mexico picks up Guerrero + CDMX. Argentina, France, India go from 0 visible to full coverage. Within-polygon filter scopes labels to focus + visible neighbors via the existing_applyFocusStateFiltermachinery. Basemapplaces_regionis hidden during country focus to prevent duplicate labels; restored on exit. US-state focus is unaffected (still usesplaces_regionwith the F38b USPS abbreviation override). - Region-label density dropdown in the Focus Layers panel (only shown when country focus + State/region labels checkbox on). Three levels driven by Natural Earth's
labelrankfield (lower = more important): All (no filter), Major only (default; labelrank ≤ 6), Minimal (labelrank ≤ 3 — capital regions only). User can pick "All" for full coverage at any zoom. - Surrounding-regions label toggle. New "Include surrounding regions" checkbox under Density (only shown when neighborMode ≠ 'none'). Default OFF. When on, neighbor-country admin-1 labels (e.g. Argentine + Bolivian + Peruvian provinces during Brazil focus) render via the
fm-neighbor-region-labellayer, scoped viagetAdjacentNeighbors. - Build-time preprocessor
pmppr-map/scripts/build-admin1-labels.mjsconsumes Natural Earth'sne_10m_admin_1_states_provinces.geojson(38 MB raw, checked in topmppr-map/scripts/data/) and emits the slim point-feature output. Preservesname,iso_a2,iso_3166_2,labelrank,postal. Includes a name-truncation heuristic that prefersname_enbut falls back to localnamewhenname_enis suspiciously short (e.g. NE writesname_en="Federal"for Brazil's "Distrito Federal" — the heuristic catches this and uses the local name). Re-runnable on Natural Earth schema updates. refreshRegionLabelspublic method on FocusMod — re-builds region label layers from cached data without re-fetching. Called by the new UI handlers when density / neighbor toggles change._setRegionLabelDensityand_toggleNeighborRegionLabelsUI handlers. Both update AppState, callFocusMod.refreshRegionLabels(), and re-apply the within-polygon filter.
Fixed
- B-state-labels-persist — state/region labels for adjacent neighbors persisted after switching Surrounding Regions to None. Repro: Alabama focus → set Surrounding Regions = Adjacent → TN/MS/GA/FL labels appear → set Surrounding Regions = None → labels remained visible (should have cleared). Root cause:
UI._setNeighborMode(the chip click handler) updatedfm.neighborModeand re-ranFocusMod.activate(), butactivate()doesn't itself trigger_applyFocusLabelFilters— sibling handlers like_toggleExtendedNeighbors,_toggleNeighborCutout, and_toggleBasemapThroughNeighborsall do. Fix: added the missingUI._applyFocusLabelFilters()call to_setNeighborModeafter the activate. Verified end-to-end in Chrome: places_region filter immediately transitions from["Alabama","Florida","Georgia","Mississippi","Tennessee"]to["Alabama"], and zoom-confirmed only the AL label remains visible. - B-subtog — Roads/HWY shields "Show in surrounding regions" sub-toggle was always visible. Should be hidden unless surrounding regions are active AND the focus-area Roads toggle is on. Fix: wrapped the sub-toggle render in
if (neighborsActive && fm.showBasemapRoads)inPanelContent.focuslayers(Customize Focus Theme → Neighboring Regions section). Bundle confirmed via grep for the gate expression. Note: the v0.9.54 hillshade neighbor sub-toggle was already removed; only the Roads sub-toggle remains in this section. _toggleFocusLabelCategorynow also hides/shows the newfm-region-labelandfm-neighbor-region-labellayers when the user toggles the "State / region labels" checkbox. Previously the user had no way to turn off the new overlay (the checkbox only affected basemapplaces_region).
Removed
- F55(a) SVG export pathway. User direction (2026-05-03): "Remove the SVG export option totally — abandoning that function. Focus on PNG." Removed:
ExportMod.svg(viewId)method (~80 lines),_vectorSvgBase,_vectorSvgLabels,_hiDpiCapture,_geoToPath,_mlColor,_lineAngle(all SVG-only helpers, ~210 lines combined). PlusProjectionExportMod._renderSVG(~165 lines) andProjectionExportMod.exportSVG. Three SVG buttons removed fromPanelContent: Quick Export "SVG", per-saved-view "SVG", Projection panel "Projection SVG". OrphanedisFocusNowconst removed. Header comment inExportMod.jsrewritten — the v0.4 dual-mode-SVG-export architecture is no longer accurate._compositeOverlaysretained (also used by hi-res PNG path). v0.9.36's watermark feature noted "SVG / projection export not yet wired" — that follow-up dies with this removal.
Reverted
- F55(c) — Hi-Res PNG button relabel attempt. Briefly relabeled "PNG Hi-Res" → "PNG 300 DPI" (and matching toast / filename strings) on the rationale that 4× pixel ratio on a 96 DPI screen ≈ 384 DPI. User pushback (2026-05-04): actual delivered DPI varies because
_effectiveHiResScale()clamps toMath.min(HIRES_SCALE, _maxWebGLScale, _max2dScale)— on a wide viewport it can deliver less than 384 DPI. The "Hi-Res" label is honest and the success toast already self-reports actual pixel dimensions. Reverted all 5 strings (button labels ×2, info strip, toast text, download filename) back to "Hi-Res" /_hires_.
Files
Code (pmppr-map)
public/map-tool/lib/map-modules/CFG.js—appVersion: '0.9.60'; newgeoAdmin1Labels: '/map-tool/data/admin1-labels.geojson'public/map-tool/index.html— brand pill v0.9.60public/map-tool/lib/map-modules/FocusMod.js— new_admin1Labelsdata +_ensureAdmin1Labelsfetch; new_addRegionLabelLayer+_addNeighborRegionLabelLayersymbol-layer add functions; newrefreshRegionLabelspublic method; new_hidePlacesRegion/_restorePlacesRegionhelpers; newLYR.regionLabel+LYR.neighborRegionLabelconstants;REGION_DENSITYthreshold table; activate() now invokes the new fetch+add chain for country focus and hides basemap places_region;deactivateKeepStaterestores itpublic/map-tool/lib/map-modules/UI.js—_setNeighborModecalls_applyFocusLabelFilters(B-state-labels-persist) plusFocusMod.refreshRegionLabels;_applyFocusStateFilterregex extended to includefm-region-labelandfm-neighbor-region-label;_toggleFocusLabelCategoryextended to toggle visibility of new overlay layers when 'state' category changes; new_setRegionLabelDensityand_toggleNeighborRegionLabelshandlerspublic/map-tool/lib/map-modules/PanelContent.js— Roads neighbor sub-toggle now gated onneighborsActive && fm.showBasemapRoads(B-subtog); Focus Layers panel (focuscityrenderer) gains a Density dropdown and Include-surrounding-regions checkbox under State / region labels (country focus only); 3 SVG export buttons removed; orphanedisFocusNowconst removedpublic/map-tool/lib/map-modules/ExportMod.js— F55(a):svg(),_vectorSvgBase,_vectorSvgLabels,_hiDpiCapture,_geoToPath,_mlColor,_lineAngleremoved. Header comment rewrittenpublic/map-tool/lib/map-modules/ProjectionExportMod.js— F55(a):_renderSVGandexportSVGremovedscripts/build-admin1-labels.mjs— NEW build-time preprocessorscripts/data/ne_10m_admin_1_states_provinces.geojson— NEW 38 MB Natural Earth source (build-time only, committed to repo for reproducible builds)public/map-tool/data/admin1-labels.geojson— NEW 825 KB slim output (4589 features globally)
Docs (pitchmappr)
dev-docs/CHANGELOG.md— this entrydev-docs/HANDOFF.md— v0.9.60 handoff section prependeddev-docs/spec/handoffs/2026-05-04-region-labels-overlay.md— dated session archive (incl. full restart-prompt for picking up in a new chat)dev-docs/spec/FEATURE-CATALOG.md— Section 7 SVG rows removed; Section 3.5 State / region labels description expanded to cover the overlay + density + neighbor-labels toggle; version header bumpeddev-docs/spec/PROJECT-DATASHEET.md— version bumpdev-docs/TODO.md— F51–F55 cluster filed earlier in session; B-admin1-coverage entry expanded to reflect the global per-country audit findings; B-state-labels-persist marked complete; B-subtog marked complete; F55(a) marked shipped, F55(c) marked dropped, F55(b)+(d) still open
Verified
- TypeScript and JS syntax checks pass on all 6 modified
.jsfiles - Bundle compiles cleanly:
map-modules.bundle.<hash>.js624 KB after all changes - Property-based verification in Chrome (real authenticated session):
- Brazil focus + density 'all' →
fm-region-labelsource has 27 features (all states incl. MT, DF) - Brazil focus + density 'major' → 26 features (DF excluded — labelrank 7 > 6 threshold)
- Brazil focus + density 'minimal' → 26 features
- State / region labels checkbox correctly toggles
fm-region-labelvisibility ('none'↔'visible') places_regioncorrectly hidden in country focus (visibility: 'none')- Distrito Federal name fix verified after re-running preprocessor (was "Federal" with
name_enheuristic, now "Distrito Federal" via localname) - B-state-labels-persist: Alabama focus, Adjacent → None:
places_regionfilter immediately transitions to["Alabama"]; visual zoom confirmed only AL label visible
- Brazil focus + density 'all' →
- Bundle propagation grep confirms:
PNG Hi-Res×2 (button labels restored),PNG 300 DPI×0 (revert clean),exportSVG×0,_renderSVG×0,_vectorSvgBase×0,Projection SVG×0,B-state-labels-persist×1 (gate marker)
Known limitations / follow-ups
- F55(b) — PNG transparency leaks (deferred). Two symptoms reported: (1) transparent regions render as light grey because
_makeTransparentStyleonly zeros four layer roles (background, water-ocean, focus-mode earth + fm-mask) — landcover/landuse/parks/hillshade keep their stock theme colors; (2) solid regions appear partially transparent — likely a theme layer withfill-opacity < 1, MapLibre canvas alpha config, or_compositeOverlaysdouble-multiplying alpha. Diagnostic suspects + fix options documented in TODO.md F55 entry. - F55(d) — 600 / 900 DPI presets (deferred). New buttons alongside existing Hi-Res. Canvas-cap honesty needed (large viewport at 9.375× hits ~16k×16k browser limits — silently downgrades today). Either expose actual delivered DPI in toast or implement tiled rendering for 900.
- B-mask2 — triangular mask geometry artifacts at high zooms (Brazil + show-base-map-through). Visually confirmed reproducible 2026-05-04. Not addressed in this release; existing entry has diagnostic plan (
_buildMaskring-winding investigation). - Neighbor labels density tuning — when neighbor region labels are enabled with a country like Brazil at low zoom, the combined label count can exceed 100. The default 'major' threshold helps, but a follow-up may want per-zoom auto-density (use 'minimal' at z<5, 'major' at z5–7, 'all' at z>7) as a 4th "Auto" option. Currently deferred.
- Theme tokens for region labels — v1 ships with hardcoded styling close to protomaps default (
#555text, white halo). The data structures already supportfm.regionLabelColor/fm.regionLabelHaloColor/fm.regionLabelHaloWidth/fm.neighborRegionLabelColor(read in_addRegionLabelLayerpaint), but no UI surface yet. Adding sliders/color pickers to Customize Focus Theme is a session-2 item not yet started.
[0.9.59] — 2026-05-01 (Audit hygiene cluster — auth+write race, job number unique, test isolation, cloned_from, TS error)
Closes the remaining 5 audit findings deferred from v0.9.58 — all defense-in-depth and hygiene items, no user-facing breakage. One migration (023). All 10 audit findings now closed.
Fixed
-
#6 Auth+write
Promise.allrace in API routes.app/api/maps/[id]/route.tsPUT+DELETE andapp/api/projects/[id]/route.tsPUT ranPromise.all([getUser(), update()]), hitting the DB regardless of auth result. RLS protected mutations, but if a future migration ever broke a policy expression, unauth requests would mutate before the 401 returned. Fix: sequence —await getUser()first, return 401 if unauth, THEN run mutation. Costs ~50-100ms per write; eliminates the defense-in-depth gap. (Project DELETE was already correctly sequenced — thePromise.allthere was auth + read-only count probe, mutations come after.) -
#7 Job number race (no UNIQUE constraint). Both
app/api/projects/route.tsandapp/api/maps/route.tsPOSTs followed a scan-then-insert pattern (SELECT MAX WHERE prefix LIKE 'PM-YYYYMMDD-%'then INSERT MAX+1). Existing indexes (idx_projects_job,idx_new_projects_job) were NOT unique. Concurrent inserts in the same window could read the same MAX and both insert with the same job_number. Fix: migration 023 adds partial unique indexes (WHERE job_number IS NOT NULL— NULLs continue to be allowed, e.g. for legacy rows) onprojects.job_numberandmaps.job_number. Migration also dedup-suffixes any existing duplicates with_DUP_<id-prefix>so the constraint can be added cleanly on prod data that may have already raced. Both POST handlers now retry once on a 23505 violation when the auto-generator produced the conflict (user-supplied job numbers return 409 instead — that's a "pick a different ID" prompt, not a race retry). -
#8 Test isolation — service-role bypassed RLS in "RLS tests"; tests could hit prod.
tests/setup.tsloaded.env.localso tests hit whatever Supabase the dev pointed at — including production.tests/helpers.tsgetAdmin()usesSUPABASE_SERVICE_ROLE_KEYwhich bypasses RLS — the existingclientAsUser/queryAsUserpatterns intests/sharing/rls-policies.test.tstherefore validated SQL semantics but not policy behavior. Fix: (a)tests/setup.tsprefers.env.testover.env.localso a dedicated test-config file is found first; (b) added a non-local-Supabase URL guard that throws unlessALLOW_NON_LOCAL_TESTS=1is set, preventing accidental prod data mutations; (c) addedgetAnon()andgetUserClient(email, password)helpers totests/helpers.tsfor RLS-faithful tests (anon client + signed-in session = JWT carries user identity, so RLS sees correctauth.uid()). The existing service-role tests are untouched in v0.9.59 — converting them to use real auth sessions is a larger refactor tracked as B-rls-tests-rewrite in TODO. -
#9
cloned_fromtracking dropped on insert.view-only-client.tsx:124sentclonedFrom: mapIdin the clone payload;app/api/maps/route.tsPOST silently dropped the field. Column existed (migration 008:56). Fix:app/api/maps/route.tsnow readsclonedFromfrom body and writes tocloned_fromon insert. Restores data lineage for clone analytics + future "show me what this map diverged from" features. -
#10 TS error in
tests/sharing/rls-policies.test.ts:50.Property 'catch' does not exist on type 'PromiseLike<void>'— Supabase's PostgrestBuilder returnsPromiseLikefor some chains, which doesn't expose.catch(). Fix: wrapped the failing call intry { await ... } catch {}instead of.then().catch(). Same intent (swallow if RPC missing), type-safe form.
Migration
- 023_unique_job_numbers.sql — partial unique indexes on
projects.job_number+maps.job_number(where NOT NULL); defensive dedup of any existing duplicate job_numbers with_DUP_<id-prefix>suffix (no-op on a clean DB). Re-runnable viaIF NOT EXISTS.
Files
pmppr-map/lib/map-modules/CFG.js+ mirror —appVersion: '0.9.59'pmppr-map/public/map-tool/index.html— brand pill v0.9.59pmppr-map/app/api/maps/[id]/route.ts— sequence auth before PUT/DELETEpmppr-map/app/api/projects/[id]/route.ts— sequence auth before PUTpmppr-map/app/api/maps/route.ts— retry-on-23505 + accept clonedFrompmppr-map/app/api/projects/route.ts— retry-on-23505pmppr-map/tests/setup.ts—.env.testpreferred; non-local URL guardpmppr-map/tests/helpers.ts— addedgetAnon()+getUserClient(); deprecation note ongetAdmin()for RLS testingpmppr-map/tests/sharing/rls-policies.test.ts— try/catch instead of.then().catch()to fix TS errorpmppr-map/supabase/migrations/023_unique_job_numbers.sql— partial unique indexes + defensive dedup
Verified
- TypeScript compiles clean (was the audit-cited error; now resolved)
- Migration 023 applied to Supabase prod without errors
- Bundle rebuild succeeded (32 modules →
map-modules.bundle.11b6dcc90c.js, 637.0 KB)
New TODOs filed
- B-rls-tests-rewrite — convert existing
tests/sharing/rls-policies.test.ts(and other tests undertests/sharing/) fromgetAdmin()+clientAsUser(which uses service-role key) togetUserClientfor RLS-faithful policy validation. The new helpers are in place; converting the test bodies is a separate refactor session. - B-lint-cleanup — 673 ESLint problems remain (205
@typescript-eslint/no-explicit-anyerrors, 384 unused-vars warnings, 34 unused-expressions, plus React hook + JSX entity warnings). Mostly tech debt from rapid iteration. A dedicated cleanup session can fix the bulk via targeted refactors. NOT a CI-blocker today sincetsc --noEmitis clean.
All audit findings closed across v0.9.58 + v0.9.59
| # | Title | Closed in |
|---|---|---|
| 1 | Billing schema mismatch | v0.9.58 |
| 2 | Cross-group sharing notFound | v0.9.58 |
| 3 | Sharing scope server-enforce | v0.9.58 |
| 4 | set_group_api_key RPC missing | v0.9.58 |
| 5 | Owner-share inconsistency | v0.9.58 |
| 6 | Auth+write Promise.all race | v0.9.59 |
| 7 | Job number race | v0.9.59 |
| 8 | Test isolation | v0.9.59 |
| 9 | cloned_from dropped | v0.9.59 |
| 10 | TS error in test file | v0.9.59 |
[0.9.58] — 2026-05-01 (Audit cluster — billing schema + cross-group sharing + sharing security)
External audit of the pmppr-map codebase surfaced 10 issues. v0.9.58 closes 5 of them — the user-facing breakages and direct security gaps. The remaining 5 (defense-in-depth + hygiene) are queued for v0.9.59.
Fixed
-
#1 Billing schema mismatch — checkout/portal/billing display were broken for every user. The billing routes (
app/api/billing/create-checkout-session/route.ts,app/api/billing/create-portal-session/route.ts) queriedprofiles.organization_id— a column that doesn't exist onprofiles(the schema hasprofiles.group_id, with the org living three joins up viagroups → departments → organizations). The/billingpage atapp/(user)/billing/page.tsxseparately queriedorganizations.id = profile.group_id, conflating a group ID with an org ID. Fix: new SECURITY DEFINER helpersget_user_org_id(user_id)andget_org_seat_count(org_id)in migration 020 walk the canonical hierarchy. Updated 3 files to use them. Note: this fix repairs the existing column-lookup bug; the "Free user clicks Subscribe → personal Pro org auto-created" flow is a separate feature build (team-billing workstream, see TODO). -
#2 Cross-group share recipients couldn't open shared maps.
app/maps/[id]/page.tsxran the RLS-gated SELECT onmapsfirst, hitnotFound()when the share recipient wasn't in the project's group, and never reached theresolve_share_permissionRPC that would have granted access. Root cause: the only SELECT policies onprojectsandmapswere group-based (the legacy ones from migration 001's rename + migration 008's new projects table). Fix (Option A, share-aware RLS): migration 021 addsshared_users_see_projects/shared_users_see_mapsSELECT policies that OR inresolve_share_permission(...) IS NOT NULL, plus matching UPDATE policies foredit/owner/authorpermission. Multiple SELECT policies combine with OR in Postgres, so existing group-based access is preserved additively. -
#3 Sharing scope settings were UI-gated, not server-enforced.
/api/shares/scopesreturned which scopes the UI should display, but/api/sharesPOST acceptedscope='group','dept', or'org'without checkingis_sharing_scope_enabled. A crafted request could create shares at scopes the admin had disabled. Fix:/api/sharesPOST now callsis_sharing_scope_enabled(user_id, scope)for non-user scopes before insert; rejects with 403 if disabled. The RPC already existed in migration 011 (~line 430) — just wasn't being called. -
#4
set_group_api_keyRPC was missing. Admin group page (app/(admin)/admin/groups/[id]/page.tsx:69) called the RPC when an admin entered a MapTiler API key — the call threw "function does not exist" and the key was never saved. Fix: migration 022 adds the RPC with explicitis_admin()authorization. Currently writes plaintext togroups.maptiler_api_key_encrypted(matching the existing read path in/api/geocode/route.ts:208); proper encryption (pgcrypto + Supabase Vault) is tracked as a separate follow-up — B-api-key-encryption. -
#5 Owner-share permission was inconsistent across RPCs.
resolve_share_permission(migration 011:378+) granted owner-level access via group/dept/org-scope shares;can_manage_project_shares(migration 012:13+) only recognized direct-user owner shares. Effect: a recipient could get'owner'permission for view/edit but not for managing shares. Fix:/api/sharesPOST now rejectspermission='owner'for non-user scopes, treating co-ownership as a per-user concept. Cleaner than expandingcan_manage_project_sharesto include group-level recognition (smaller blast radius, single source of truth).
Migrations
- 020_billing_org_helpers.sql —
get_user_org_id(user_id)+get_org_seat_count(org_id)SECURITY DEFINER helpers - 021_share_aware_rls.sql — share-aware SELECT + UPDATE policies on
projects+maps - 022_set_group_api_key.sql — admin-only RPC with
is_admin()guard
Files
pmppr-map/lib/map-modules/CFG.js+ mirror —appVersion: '0.9.58'pmppr-map/public/map-tool/index.html— brand pill v0.9.58pmppr-map/app/api/billing/create-checkout-session/route.ts—get_user_org_idchain walkpmppr-map/app/api/billing/create-portal-session/route.ts—get_user_org_idchain walkpmppr-map/app/(user)/billing/page.tsx—get_user_org_id+get_org_seat_countpmppr-map/app/api/shares/route.ts— server-enforceis_sharing_scope_enabled; reject owner permission on non-user scopes
Verified
- TypeScript compiles clean (
tsc --noEmitshows no errors related to changed files) - 3 migrations applied to Supabase prod without errors
- Bundle rebuild succeeded (32 modules →
map-modules.bundle.4656964cb1.js, 637.0 KB)
New TODOs filed
- B-api-key-encryption — properly encrypt
groups.maptiler_api_key_encryptedvia pgcrypto + Supabase Vault. The column name implies encryption but currently stores plaintext; v0.9.58 fix matched existing behavior to minimize blast radius. - B-share-access-log-scope —
app/maps/[id]/page.tsxview-only branch fetches the share record byshared_with_user_id = user.idonly; group/dept/org scope share recipients miss the access-log entry. Separate from the audit's #2 finding (which was about SELECT, fixed here); this is about the access-log flow specifically.
Audit findings still open (queued for v0.9.59)
- #6 Auth+write Promise.all race in API routes
- #7 Job number race (no UNIQUE constraint)
- #8 Test isolation (service-role bypasses RLS in "RLS tests")
- #9
cloned_fromnot written on clone - #10 Lint 242 errors + TS error in
rls-policies.test.ts+ test failures
[0.9.57] — 2026-05-01 (F49 toggle ON→OFF road-color regression fixed + sprite-missing warnings silenced)
Live-debugged the open #1 item from the v0.9.56 handoff. The bug was misdiagnosed in the prior session as a layer-order race in the post-setStyle 'idle' callback; instrumented logging proved the visibility setter was firing in the right order with the right values. The actual cause was paint, not layout.
Fixed
-
F49 toggle ON → OFF leaves focus-area roads invisible until user manually re-toggles Focus Layers > Roads. The setter was setting
visibility: 'visible'correctly all along — the issue was that road line color was reverting to the focus theme's stock light grey, which blends invisibly into the gray landcover. Root cause:LiveStyleUI.toggleBaseMapdestroys__live__and_applyLiverecreates it from the source theme's flavor colors (_getLiveThemelines 49-51), wiping the user's custom road color override.fm.basemapRoadColorIS preserved onAppState, but_toggleFocusRoads(on, skipStyle=true)(the post-setStylerestore call) skipped re-applying it because the previous code path went throughLiveStyleUI.setColorwhich calls_applyLive→setStyle(recursive in this context, hence the gate). When the user re-toggled Focus Layers > Roads manually, the second call hadskipStyle=undefinedsosetColorran and the red came back — that's the "workaround" in the original bug report. Fix: apply the road color directly viasetPaintProperty('line-color', fm.basemapRoadColor)inside_toggleFocusRoadsregardless ofskipStyle. NosetStyleinvolved, so it works in both the user-driven and restore paths. Also writes the color back into__live__.overrides.major(and the_expansions.majorset) so subsequent organicsetStylerebuilds — theme switch, color picker, save/restore — preserve it instead of reverting to stock. -
Sprite-missing warnings spammed the console at z≥5 in focus mode (
Image "generic_shield-Nchar" could not be loadedplusExpected value to be of type string or array, but found null). Latent issue exposed by the F49 fix above: now that roads stay visible at the right zoom, MapLibre actually reaches the highway-shield render path and tries to loadgeneric_shield-{6,7,8,11,12,13,14}charsprite variants that aren't in our basemap sprite. Doesn't break rendering (shields just don't appear, which is correct when Highway Shields is unchecked), but the noise is constant and obscures real errors. Fix: added astyleimagemissinglistener inMapMod.initthat supplies a 1×1 transparent placeholder for any requested image. Silences the warnings; rendering unchanged. Proper fix (ship the missing sprite variants in the basemap sprite) tracked as B-shield-sprite in TODO.
Diagnostic methodology
The fix shipped after a single live-debug pass thanks to instrumented logging ([F49-DEBUG] tagged console output across LiveStyleUI.toggleBaseMap, FocusMod._addLayers, UI._toggleFocusRoads, and the post-setStyle 'idle' callback in ThemeMod.apply). The logs proved the layer-order race hypothesis from the v0.9.56 handoff was wrong (visibility was correctly set to 'visible' on every road layer), which redirected attention to paint properties. Instrumentation removed before the v0.9.57 ship.
Files
pmppr-map/lib/map-modules/CFG.js+ mirror —appVersion: '0.9.57'pmppr-map/lib/map-modules/UI.js+ mirror —_toggleFocusRoadsapplies road color viasetPaintPropertyregardless ofskipStyle; persists to__live__.overrides. Width still goes throughLiveStyleUI.setLineWidthfrom non-restore paths (line-width is often a zoom-interp expression that needs the recursive scaler in that helper).pmppr-map/lib/map-modules/MapMod.js+ mirror — newstyleimagemissinglistener registers a 1×1 transparent placeholder for any missing sprite imagepmppr-map/public/map-tool/index.html— brand pill v0.9.57
Verified
- Mirrors verified byte-identical via
diff -qfor UI.js, MapMod.js, CFG.js - Bundle rebuild succeeded (32 modules →
map-modules.bundle.6f40db8a7e.js, 637.0 KB — ~+1KB vs v0.9.56 from the new color-restore + listener code) - Local repro: Alabama focus, Roads ON, red
#e71313, toggle "Show base map through focus" ON → OFF → roads stay visible in red, no manual re-toggle needed - Console clean of
generic_shield-*warnings after surrounding-region setting changes
Still open from v0.9.55 / v0.9.56 testing
- Internal admin-1 boundaries not rendering for non-US country focus (Brazil with show-admin-1 enabled shows no Brazilian state borders). Untouched in v0.9.57.
- State / region labels for non-US country focus — verify visual rendering after v0.9.57 deploy. May be a zoom-level artifact rather than a filter bug.
- B-mask1 / B-mask2 mask geometry artifacts at certain zoom levels with show-base-map-through + neighbors visible. Untouched.
- B-shield-sprite (new) — proper fix is to ship the missing
generic_shield-Ncharsprite variants in the basemap sprite. Suppressed but not actually fixed in v0.9.57.
[0.9.56] — 2026-04-30 (Two follow-up fixes from v0.9.55 testing — fm-neighbor-cover color + non-English country/state label match)
User reported v0.9.55 issues. Two fixes ship here, plus four more open issues queued for further investigation.
Fixed
-
Surrounding regions go pure white when F47 (Roads in surrounding regions) is on. The
fm-neighbor-coveroverlay introduced in v0.9.55 painted withmaskColor(typically white). When the cutout exposed the basemap and the overlay covered it, the result was a stark white surrounding region instead of the gray neighbor fill. Changed the overlay color toneighborFillColor(default#F2F2F2) so the visual appearance matches the F47-off state — same gray neighbors, just with roads/shields rendering on top now. -
Country and state labels not showing for non-US country focus (Brazil's country label missing, etc.). The name-based filter expression matched against the protomaps
nameproperty, but protomaps tiles storenamein the local language ("Brasil" for BR, "España" for ES, etc.) while ourCOUNTRY_NAMESandFOCUS_US_STATEStables use English strings. Updated both_applyFocusCountryFilterand_applyFocusStateFilter(US-state branch) to match againstcoalesce(name:en, name)— protomaps shipsname:enfor most countries/regions, so the English fallback works regardless of locale.
Files
pmppr-map/lib/map-modules/CFG.js+ mirror —appVersion: '0.9.56'pmppr-map/lib/map-modules/UI.js+ mirror — country and state label filters usecoalesce(name:en, name)pmppr-map/lib/map-modules/FocusMod.js+ mirror —fm-neighbor-coveroverlay paints withneighborFillColor(both at add-layer time and inupdateMaskCutout)pmppr-map/public/map-tool/index.html— brand pill v0.9.56
Verified
- Mirrors verified byte-identical via
diff -qfor UI.js + FocusMod.js - Bundle rebuild succeeded (32 modules →
map-modules.bundle.900740ffb4.js634.3 KB)
Still open from v0.9.55 verification (deeper investigation needed)
- F49 toggle ON → OFF leaves focus-area roads invisible until the user toggles Focus Layers > Roads off and on again. The post-setStyle
_toggleFocusRoads(true, true)call should restore visibility but doesn't appear to be sticking. Suspected cause: layer-order resolution between FocusMod's_addLayers(which addsfm-neighbor-coverwithbeforeId=firstRoadId) and the visibility setter racing in the'idle'callback. Needs live debugging — defensive: re-call_toggleFocusRoadsafter_applyFocusLabelFiltersin the F49 toggle path, or move the visibility setter outside the'idle'callback. - Internal admin-1 boundaries not rendering for non-US country focus. Brazil with show-admin-1 reportedly shows nothing. Need to verify whether
_addAdmin1Layersis being called with the rightiso2, whether the cached admin-1 GeoJSON has features for that ISO (f.properties.a === iso2check), and whether the admin-1 layer ends up at the right position relative to the focus mask aftersetStylerebuilds. - State / region labels for non-US country focus still not appearing in some cases. v0.9.53 switched country-focus state labels to
withinpolygon filter (point geometry, works), so theoretically Brazilian provinces inside the Brazil polygon should show. May be a zoom-level issue (places_region only renders at certain zoom ranges) or a separate filter issue. Needs visual testing. - B-mask1 / B-mask2 mask geometry artifacts at certain zooms — unchanged from earlier reports.
[0.9.55] — 2026-04-30 (Revert v0.9.54 lift+filter for road LINE layers; restore cutout + add hillshade-overlay)
User testing of v0.9.54 surfaced multiple regressions caused by the new lift-roads-above-mask + within-polygon-filter mechanism:
- Roads invisible at low zoom, faintly visible at high zoom. MapLibre's
withinfilter on line geometry requires the entire feature to be inside the polygon. At low zooms, line features span across the focus boundary and fail. At high zooms, MapLibre's tile clipping produces shorter line pieces, some of which happen to fit inside the polygon and pass — that's why roads "appeared" only when zoomed in. - Road color appeared muted in show-base-map-through mode — likely a layer-ordering side effect from the lift mechanism.
- Toggling show-through ON then OFF broke focus-area roads. State management issue from the lift logic.
- Highway shields rendered in surrounding regions even when F47 was off — the previous filter scoped shields to focus + neighbors regardless of toggle state.
- Neighbor admin1 boundaries stopped rendering — collateral from road layers being lifted above fm-admin1-border layers.
Reverted the lift+filter approach for road line layers. Kept ARCH3 (the show-base-map-through split) since that's a separate independent change that works.
Reverted
_isRoadLineLayer,_liftRoadLineLayers,_applyFocusRoadFilterremoved fromUI.js._applyFocusLabelFiltersno longer calls the road lift/filter helpers._toggleNeighborCutout('roads', on)callsFocusMod.updateMaskCutoutagain (back to the cutout mechanism), then re-applies label filters so the shields filter updates.FocusMod._shouldCutoutNeighborsnow returns!!fm.showRoadsInNeighborsagain — F47 drives mask cutouts, restoring v0.9.53 behavior._addLayersandupdateMaskCutoutcutout logic changed towantExtraCutouts = cutout || (showThrough && showThroughNeighbors)— F47 OR (F49 + ARCH3 sub-toggle).
Added — fm-neighbor-cover overlay (replaces v0.9.54's intent without the lift bug)
The user's original concern was that the F47 cutout exposed hillshade in surrounding regions, not just roads. v0.9.55 solves this with a new overlay layer instead of the broken lift mechanism:
fm-neighbor-cover— a fill polygon layer usingSRC.geo(the existing neighbor-features source). Painted in the focus theme's mask color. Inserted just before the firstroads_*line layer, so it sits ABOVE hillshade / water / landcover but BELOW road lines + shields. When the user enables F47, the focus mask gets neighbor cutouts (basemap content visible there) AND this overlay covers the hillshade/water/landcover with the mask color, leaving only road lines + shields rendered on top. Result: roads visible in surrounding regions; hillshade and other basemap fills hidden.- Visibility logic (controlled in
updateMaskCutout):cover-on = cutout && !(showThrough && showThroughNeighbors). The overlay is on when F47 is the reason for the cutout. When F49 + the new "Include surrounding regions" sub-toggle is also on, the user explicitly wants full basemap visible in cuts, so the overlay is hidden.
Added — split shields filter (point geometry, within filter works fine)
Shields are point/symbol features so MapLibre's within filter actually works for them. v0.9.55 splits _applyFocusBasemapLabelFilter into two groups:
- City labels (
places_locality,places_neighbourhood) and water labels (water_label_*) — always scoped to focus + visible neighbors via the existing_buildFocusBoundaryFeature. The City & other labels checkbox in Full Base Map controls whether they render at all. - Highway shields (
roads_shields) — scope follows the F47 toggle. Default focus-only via_buildFocusOnlyFeature. When F47 is on (or F49 + Include-surrounding-regions sub-toggle is on), scope expands to focus + visible neighbors. Fixes the user's complaint that shields appeared outside the focus region even when not enabled.
Files
pmppr-map/lib/map-modules/CFG.js+ mirror —appVersion: '0.9.55'pmppr-map/lib/map-modules/UI.js+ mirror — reverted three v0.9.54 helpers; split_applyFocusBasemapLabelFilterinto city+water vs shields;_toggleNeighborCutoutrewired to cutoutpmppr-map/lib/map-modules/FocusMod.js+ mirror —_shouldCutoutNeighborsrestored; cutout logic combines F47 + F49+sub; newLYR.neighborCoverregistered; overlay layer added in_addLayers; opacity managed inupdateMaskCutoutpmppr-map/public/map-tool/index.html— brand pill v0.9.55
Verified
- Mirrors verified byte-identical via
diff -qacross UI.js + FocusMod.js - Bundle rebuild succeeded (32 modules →
map-modules.bundle.cf3c939bbb.js633.1 KB)
Trade-offs / known notes
- The hillshade-cover overlay uses the
maskColor(typically white) so it visually disappears against the focus theme's mask. If the user picks an unusual mask color, the overlay will reflect that. - The overlay covers ALL non-road basemap content in cutouts (water, landcover, hillshade, landuse). The user's original intent was "only roads in surrounding regions, not hillshade" — the overlay achieves this.
- ARCH3 (the show-base-map-through split with the new "Include surrounding regions" sub-checkbox) from v0.9.54 is preserved.
Still open
- B-mask1 / B-mask2 mask geometry artifacts at certain zooms — investigate after v0.9.55 verification.
- Neighbor admin1 boundaries not rendering — was this a v0.9.54 regression caused by the lift, or a pre-existing issue? Verify on v0.9.55.
[0.9.54] — 2026-04-30 (F47 lift+filter rework + ARCH3 split show-base-map-through into focus / surrounding-region checkboxes)
User feedback drove two related changes that both touch the focus mask machinery, so they ship together.
Changed — F47 mechanism rework: roads-in-surrounding via lift+filter, hillshade option removed
The previous F47 implementation cut holes in the focus mask for each visible neighbor polygon when the user toggled "Roads/HWY shields in surrounding regions" or "Hillshade in surrounding regions". A hole exposes ALL basemap content underneath — roads, hillshade, water, the works — so the per-feature toggles were misnamed. The user reported: with Hillshade UNCHECKED + Roads CHECKED, hillshade still appeared in the cutouts because the cutout is binary by nature. The user's direction: "Roads are most important not hillshade for surrounding regions. Can we just remove the hillshade option from the neighboring region?"
New mechanism (Option 2 from the four-option discussion): road line layers (roads_highway*, roads_major*, roads_minor*, roads_link*, roads_other, roads_tunnels_*, roads_bridges_*) and roads_shields are now LIFTED above the focus mask via moveLayer and constrained by a within polygon filter. The mask continues to cover everything else (hillshade, water, parks, landuse) in surrounding regions. When "Roads/HWY shields in surrounding regions" is on, the within polygon is focus + visible neighbors, so roads render in both. When off, the within polygon is the focus polygon only, so roads only render in the focus area — same end-user behavior as today. The mask itself never gets cut for F47, eliminating the binary side effect.
- Added
_isRoadLineLayer,_buildFocusOnlyFeature,_liftRoadLineLayers,_applyFocusRoadFilterhelpers in UI.js. Hooked into_applyFocusLabelFiltersso the lift + filter re-applies on every focus activation, theme reapply, neighbor change, F49 toggle, and F47 toggle. Idempotent:moveLayeron already-lifted layers is a no-op. - Removed
showHillshadeInNeighborsflag from AppState defaults entirely (the new mechanism doesn't expose hillshade at all). Removed the Hillshade sub-toggle from the "Show in surrounding regions" panel. Updated the note text to reflect the new behavior. - Neutralized
FocusMod._shouldCutoutNeighbors— always returns false now. F47 no longer drives mask cutouts. Only F49 (show base map through focus) does, and only conditionally per ARCH3 below. _toggleNeighborCutout('roads', on)no longer callsFocusMod.updateMaskCutout— calls the new lift + filter helpers directly.
Why this works without breaking focus-area roads: the existing Focus Layers > Roads toggle (fm.showBasemapRoads) controls visibility (on/off) of the road layers; the within filter controls where they're allowed to render. They're orthogonal. In the focus area there's no mask anyway, so lifting the road layers above the mask boundary doesn't change their visual position there — they render at the same z-position relative to other focus-area content.
Changed — F49 split into "focus" + "Include surrounding regions" sub-checkbox (ARCH3 partial from the locked plan)
User reported: "Show base map through focus shows focus and surrounding regions by default. It should only show the focus region and allow me to choose to show in surrounding regions. It worked this way previously I'm not sure when that was changed." The behavior changed in v0.9.48 (F49 fix) when the mask cutout was extended to neighbors unconditionally on show-through.
Restored old default behavior with an opt-in: "Show base map through focus" alone now cuts only the focus polygon out of the mask. New child checkbox "Include surrounding regions" appears under it (only when neighbors are active and show-through is on). Both default OFF. The user opts into extending the basemap to neighbors.
- Added
focusMode.showBasemapThroughNeighborsflag (defaultfalse) to AppState. - Added
UI._toggleBasemapThroughNeighbors(on)handler — sets the flag, rebuilds mask geometry viaFocusMod.updateMaskCutout, re-applies label/road filters via_applyFocusLabelFilters. - Updated
FocusMod._addLayersandFocusMod.updateMaskCutoutcutout logic —wantExtraCutouts = showThrough && showThroughNeighbors(wascutout || showThrough). - Updated road filter
_applyFocusRoadFilter—includeNeighbors = showInNeighbors || (showThrough && showThroughNeighbors). - Added "Include surrounding regions" sub-checkbox in PanelContent's Full Base Map section, indented under "Show base map through focus" and only shown when neighbors are active.
Files
pmppr-map/lib/map-modules/CFG.js+ mirror —appVersion: '0.9.54'pmppr-map/lib/map-modules/AppState.js+ mirror — removedshowHillshadeInNeighbors; addedshowBasemapThroughNeighborspmppr-map/lib/map-modules/UI.js+ mirror —_toggleNeighborCutoutrewired;_toggleBasemapThroughNeighborsadded;_buildFocusOnlyFeature,_isRoadLineLayer,_liftRoadLineLayers,_applyFocusRoadFilteradded;_applyFocusLabelFilterscalls thempmppr-map/lib/map-modules/FocusMod.js+ mirror —_shouldCutoutNeighborsneutralized;_addLayersandupdateMaskCutoutcutout logic gated on showThroughNeighborspmppr-map/lib/map-modules/PanelContent.js+ mirror — Hillshade sub-toggle removed; new "Include surrounding regions" sub-checkbox added under Show base map through focuspmppr-map/public/map-tool/index.html— brand pill v0.9.54
Verified
- Mirrors verified byte-identical via
diff -qacross all five modified module files. - Bundle rebuild succeeded (32 modules →
map-modules.bundle.0aaab81ff3.js633.1 KB).
Still open from earlier verifications
- B-mask1 — Mask geometry triangular polygon artifacts when neighbors visible. May be ameliorated by v0.9.54 since F47 no longer cuts holes; remaining cause would be F49 (show-through + include-neighbors) cutout polygon construction.
- B-mask2 — Mask incomplete at certain zooms. Same family.
- Brazil country label — likely viewport / centroid artifact from v0.9.52/53 user image; verify after deploy.
[0.9.53] — 2026-04-30 (Follow-on from v0.9.52 verification — water labels, country-focus state filter, ARCH4 move, cities UI restructure)
User tested v0.9.52 on prod and surfaced four issues. This release closes them. The mask-geometry items (B1/B2) and F47 hillshade selective-off remain queued for next release pending the four-option decision on F47.
Fixed
-
Water labels rendered globally — "Gulf of Mexico", "Sargasso Sea", "Caribbean Sea" (and similar
water_label_*features) appeared outside the focus polygon in Alabama-focus screenshots. Thewithinpolygon filter added in v0.9.51 covered city/locality and v0.9.52 added shields, but missed water labels. Extended_applyFocusBasemapLabelFilterlayer match pattern to include/^water_label/.test(l.id)so the samewithinconstraint scopes them to focus + neighbor polygons. -
Argentine state labels (and other non-US country focus) showed nothing — User reported Argentina focus with State/region labels enabled rendered no province labels at all. Root cause:
_buildFocusStateNames's name-based filter for country focus pulled region names from the cached Natural Earth 10m admin-1 GeoJSON (f.properties.name) and matched against the protomapsplaces_regionnameproperty. The two datasets use different display strings for many countries (e.g. "Provincia de Buenos Aires" vs "Buenos Aires", or accented vs unaccented variants), so the filter silently dropped most provinces. After the v0.9.52 reject-all-on-empty change, this manifested as zero labels rendering. Fix: country-focus state-label filter now uses thewithinpolygon filter (same pattern cities, shields, and water labels use), reusing_buildFocusBoundaryFeature. Any region label whose centroid sits inside the focus + visible-neighbor polygon stays visible — sidesteps the data-mismatch entirely. US-state focus keeps the name-based filter (FOCUS_US_STATES strings exactly match protomaps).
Changed (UX restructure — partial ARCH3/4 from the locked plan)
-
"City & other labels" relocated from Focus Layers panel back to the Full Base Map section in Customize Focus Theme (the ARCH4 move that should have shipped in v0.9.52 but I left in Focus Layers). It controls basemap city/locality labels, conceptually a basemap-visibility setting. The
withinpolygon filter still scopes it to focus + neighbors when on, so the universal rule still holds. -
"Cities in surrounding regions" toggle removed from Focus Layers; replaced with an "Include surrounding regions" sub-checkbox under "Show Local Cities". Per user direction: "just have one bucket. Local Cities. then a checkbox to include surrounding regions that is available if surrounding regions is active." Sub-checkbox renders only when the master "Show Local Cities" is on AND
neighborMode !== 'none'. Reverts the v0.9.52 (B3) independent-operation logic — surrounding cities are now coupled to the master, which matches the user's mental model._toggleSurroundingCitiesno longer triggersCityMod.deactivate; it only refreshes when the master is on._toggleCityLabelssimplified back to the pre-v0.9.52 form.
Files
pmppr-map/lib/map-modules/CFG.js+ mirror —appVersion: '0.9.53'pmppr-map/lib/map-modules/UI.js+ mirror —_applyFocusBasemapLabelFilterextended forwater_label_*;_applyFocusStateFilterfor country focus useswithinpolygon filter;_toggleSurroundingCitiesand_toggleCityLabelsreverted to coupled-master model.pmppr-map/lib/map-modules/PanelContent.js+ mirror — Focus Layers panel: removed "City & other labels" and "Cities in surrounding regions" toggles; added "Include surrounding regions" sub-checkbox under Local Cities master. Customize Focus Theme: added "City & other labels" toggle in Full Base Map section.pmppr-map/public/map-tool/index.html— brand pill v0.9.53.
Verified
- Mirrors verified byte-identical via
diff -q. - Bundle rebuild succeeded (32 modules →
map-modules.bundle.cd948810ff.js624.4 KB).
Still open from v0.9.51 / v0.9.52 verification
- B-mask1 — Mask geometry triangular polygon artifacts when "Show base map through focus" is on with surrounding regions visible.
- B-mask2 — Mask incomplete at certain zooms (Brazil with hillshade horizontal blank line).
- B-cutout-bin / F47 — Roads cutout exposes hillshade because the cutout is binary across all basemap content. STILL NEEDS the four-option decision before coding.
- B-subtog — Roads/Hillshade neighbor sub-toggles always available; should be hidden unless surrounding regions are active AND focus equivalent is on.
- Brazil's country label missing in user's image 3 of v0.9.52 verification — likely a centroid-out-of-viewport artifact rather than a real bug; will re-verify after v0.9.53 deploys.
[0.9.52] — 2026-04-30 (Bug cluster from v0.9.51 prod verification — B3, B4, B5, B6)
User completed the v0.9.51 prod-verification pass. Confirmed v0.9.42, v0.9.46, v0.9.47, v0.9.48, v0.9.49, v0.9.50 working as intended. Surfaced six bugs across the focus-mode UI; this release closes four of them. The remaining two (B1 mask geometry artifacts and B2 zoom-dependent incomplete mask) require live geometry investigation and are tracked for the next release. The architectural restructure (Focus Area / Surrounding Regions split, two independent show-basemap-through checkboxes) is also tracked for after the bug cluster lands.
Fixed
-
B5 — Highway shields rendered globally, outside the focus polygon. The
_applyFocusBasemapLabelFilterfunction added in v0.9.51 only constrainedplaces_localityandplaces_neighbourhoodsymbol layers via awithinpolygon filter;roads_shieldswas unconstrained and rendered shield icons (state route numbers, interstate markers) anywhere they appear in protomaps tiles, including well outside the focus area. Extended the layer match pattern to also coverroads_shields. Samewithinfilter, same_buildFocusBoundaryFeaturepolygon — just a wider scope. Also decoupled the filter from theshowBasemapLabelsgate: the polygon filter now applies unconditionally whenever focus is active, with per-layer visibility (city labels on/off, shields on/off) managed separately by_toggleFocusLabelCategory. The previous coupling meant toggling visibility didn't re-apply the filter, so re-enabling a layer could briefly show unfiltered features. -
B4 — Country labels missing for adjacent countries when focus is a country outside the user-selectable list.
_buildFocusCountryNameswalkedgetAdjacentNeighbors's result and looked each ISO up inFOCUS_COUNTRIES— the table that drives the focus dropdown (≈30 entries). For Brazil focus, that returnedBrazil + Argentina + Colombia + Peru(the only adjacents that happen to also be in FOCUS_COUNTRIES) and silently dropped Venezuela / Guyana / Suriname / Uruguay / Paraguay / Bolivia from the filter. NewCOUNTRY_NAMEStable inFocusData.jsis a comprehensive ISO→display-name map covering every ISO that appears as a value inADJACENCY_COUNTRIES(≈140 entries, names matching Natural Earth / protomapsnameproperty strings)._buildFocusCountryNamesnow does aCOUNTRY_NAMES[iso] || FOCUS_COUNTRIES[iso].namelookup so adjacents that aren't user-focusable still resolve. -
B3 — "Cities in surrounding regions" toggle did nothing unless "Local Cities" was also on. Three coupled issues. (1)
_toggleSurroundingCitiescalledCityMod.refresh(), which returns early when_activeIsois null._activeIsoonly gets set whenCityMod.activateruns; a user who enables surrounding cities first (without ever turning Local Cities on) never triggered activate. (2)CityMod.activatealways included the focus iso in the curated-cities filter, so even if it had run, surrounding cities would have come bundled with focus cities. (3)_toggleCityLabels(false)calledCityMod.deactivate, removing all layers — including any surrounding-region cities the user had enabled separately. Refactor:_filterCuratedtakes a newincludeFocusboolean (default true for back-compat) so the focus iso can be excluded;CityMod.activatederivesincludeFocusandextraIsosfrom the two flags independently, and skips rendering entirely when both are off. Both toggle handlers now driveCityMod.activate/deactivatebased on the OR of the two flags.CityMod.refresh()falls back toAppState.settings.focusMode.isoCodewhen_activeIsois null so neighbor-chip changes still trigger a render in the user-enables-surrounding-first flow. -
B6 — State and country labels rendered globally despite the toggle being off. Image evidence (Alabama focus, neighborMode=none, State/region labels checkbox unchecked) showed labels for Tennessee, Mississippi, Georgia, Florida, etc. — the surrounding-state names that the v0.9.49
neighborMode === 'none'gate should have excluded from_buildFocusStateNames. Two compounding causes. (1)_toggleFocusLabelCategorysetsvisibility: 'none'onplaces_region*, but the visibility setting is fragile acrosssetStylerebuilds + theme reapplies — somewhere in the activation flow it's getting re-enabled. (2)_applyFocusStateFilterand_applyFocusCountryFiltercleared the filter when the toggle was off (so global labels were ready to render the moment visibility leaked). Fix: the polygon-name filter now applies always while focus is active, decoupled from the toggle. Visibility is still managed by_toggleFocusLabelCategory; the filter is the belt-and-suspenders defense. Empty name list (e.g. country focus before admin-1 GeoJSON loads) now uses a reject-all expression rather thansetFilter(layer, null)so global labels can never leak through. Also corrected the!== falsesemantics inThemeMod.apply's post-setStyle re-apply and inPanelContent.focusLayersto=== true— the previous form treatedundefinedas ON, contradicting the AppState defaults offalse. This was the most likely cause of label visibility ever defaulting wrong on first focus activation.
Added
COUNTRY_NAMEStable (FocusData.js) — ISO 3166-1 alpha-2 → display-name lookup covering every ISO that appears as a value inADJACENCY_COUNTRIES. Names match Natural Earth / protomaps strings. Used by_buildFocusCountryNames(B4) and exposed onwindowfor any future feature that needs to resolve adjacency ISOs to human-readable names.
Changed
- Focus-mode label filters always apply when focus is active, regardless of which feature toggles are on.
_applyFocusCountryFilter,_applyFocusStateFilter, and_applyFocusBasemapLabelFilterno longer gate on the correspondingshowXxxLabelsflag. Per-layer visibility is the user-facing toggle; the polygon/name filters constrain rendering when visibility is on or if it leaks. This is also more aligned with the universal rule — the polygon constraint reflects the focus scope, not the user's toggle state.
Files
pmppr-map/lib/map-modules/CFG.js+ mirror —appVersion: '0.9.52'pmppr-map/lib/map-modules/UI.js+ mirror —_applyFocusBasemapLabelFilterextended forroads_shields(B5);_applyFocusCountryFilterand_applyFocusStateFilterdecoupled from toggle gates with reject-all fallback (B6);_buildFocusCountryNamesfalls through toCOUNTRY_NAMES(B4);_toggleSurroundingCitiesand_toggleCityLabelsdriveCityMod.activate/deactivatebased on either flag (B3).pmppr-map/lib/map-modules/FocusData.js+ mirror — newCOUNTRY_NAMEStable (B4).pmppr-map/lib/map-modules/CityMod.js+ mirror —_filterCuratedtakesincludeFocus;activatederives it fromfm.showCityLabels;refreshfalls back to AppState's iso (B3).pmppr-map/lib/map-modules/ThemeMod.js+ mirror — post-setStyle label re-apply uses=== truesemantics (B6).pmppr-map/lib/map-modules/PanelContent.js+ mirror — Focus Layers checkboxes use=== truesemantics (B6).pmppr-map/public/map-tool/index.html— brand pill v0.9.52.
Verified
- Mirrors verified byte-identical via
diff -qacross all five modified module files. - Bundle rebuild succeeded (32 modules →
map-modules.bundle.f0c2ea0ebb.js622.3 KB).
Open from same prod-verification pass — next release
- B1 — Mask geometry shows triangular/angular polygon artifacts when "Show base map through focus" is on with surrounding regions visible (image: WA/MT/OR/ID/NV focus). Likely the F49 cutout polygon construction creates self-intersecting rings or wrong winding when the mask + neighbor cutouts combine.
- B2 — Mask incomplete at certain zooms (image: Brazil with hillshade, horizontal blank line across mid-Brazil). Likely a cousin of B33's CW/CCW ring-winding issue or a tile-boundary tessellation artifact.
- B7 / F47 — Roads cutout exposes hillshade because the cutout is binary across all basemap content. Same four approach options as previously noted (sacrifice focus-area hillshade / raster clipping / single binary toggle / opaque overlay above hillshade in cut neighbors). Decision needed before coding.
- B8 — Roads/Hillshade neighbor sub-toggles always available; should be hidden unless surrounding regions are active AND the focus equivalent is on.
[0.9.51] — 2026-04-30 (Architectural correction — focus mode universal rule, basemap city label filter)
User clarified the focus-mode mental model after testing v0.9.50:
"All settings, options, features need to be shown within the focus region and surrounding regions only."
"These items (the checkboxes) for the base map are needed for the show basemap option. The basemap show through is just the base map. User can tweak the basemap colors, but everything else is controlled by the focus settings."
This corrects v0.9.44's design — I had added Country/City label checkboxes inside the Full Base Map section, gated their visibility on through-mode, and dropped the F38 filter when through was on. All wrong: the user's model is that focus mode is the always-on context, and Focus Layers is the single place per-feature visibility is controlled. "Show base map through focus" just exposes the basemap underneath; it doesn't reshape the rest of the UI.
Removed
-
Country labels and City & other labels checkboxes from the Full Base Map section in
customstylef. These duplicated functionality the Focus Layers panel already owns. The Full Base Map note text was rewritten: it now reads "When on, the focus theme's hidden basemap layers (water, roads, etc.) become visible. Use Focus Layers to control which labels and features render." -
The
!showThroughgate on Country labels in Focus Layers. The toggle is now shown whenever focus is a country, regardless of basemap-through state. Renamed from "Country labels for surrounding regions" to plain "Country labels" — the focus-region scope is now an implicit always-on rule rather than something the title needs to explain.
Changed
- F38 country/state label filter now applies unconditionally in focus mode. v0.9.44's logic that dropped the filter when
_showBaseMapwas true is reverted. Per the universal rule, country and state labels stay scoped to focus + adjacent neighbors regardless of whether the basemap is showing through.
Added
-
_applyFocusBasemapLabelFilter— basemap city/locality label filter. Country and state labels filter by NAME (small focused list); cities are too numerous to name-filter, so this uses MapLibre'swithinfilter expression. New_buildFocusBoundaryFeature()constructs a MultiPolygonFeaturefrom FocusMod's cachedSRC.target+SRC.geodata — the same source the mask + neighbor fills already use, so no extra fetches. Layers covered:places_locality,places_neighborhood/places_neighbourhood. Wired into_applyFocusLabelFiltersso it runs alongside the country/state filters and re-applies on every neighbor change, theme apply, and toggle. -
City & other labels toggle in the Focus Layers panel. Was orphaned in the deprecated Full Base Map section; now lives where it belongs. Bound to existing
fm.showBasemapLabels. Combined with the new polygon filter, this toggle shows basemap city labels strictly inside focus + surrounding regions.
Files
lib/map-modules/UI.js— F38 filter through-drop reverted; new_applyFocusBasemapLabelFilter+_buildFocusBoundaryFeature(mirrored)lib/map-modules/PanelContent.js— Focus Layers panel adds "City & other labels", drops!showThroughgate on country, renames "for surrounding regions" qualifier off; Full Base Map section drops Country/City checkboxes + note rewritten (mirrored)lib/map-modules/CFG.js— appVersion 0.9.50 → 0.9.51 (mirrored)public/map-tool/index.html— brand pill v0.9.51
No flag changes. No DB migrations. No theme schema changes.
Open / known limitations to revisit per the universal rule
The user's universal rule means every focus-mode feature should stay scoped to focus + neighbors. Items still possibly leaking outside focus + neighbors (audit needed):
-
F47 per-feature granularity. "Show in surrounding regions > Hillshade" off + Roads on still reveals hillshade in the cutout because the cutout is binary (mask hole exposes ALL basemap content). Per-feature visibility for hillshade specifically would require either sacrificing focus-area hillshade when sub-toggle is off, or rendering a clipped hillshade layer (raster clipping is non-trivial in MapLibre). Flagged for spec discussion.
-
Road labels (
roads_label_*). Currently filtered? Need to check; may show globally when basemap is through. -
Water labels, road shields, place_names other than locality/neighborhood/country/region. May need similar
withinpolygon filtering. -
Data Overlay choropleth, Shape Overlays — already scoped per existing focus integrations, but worth re-auditing against the universal rule.
[0.9.50] — 2026-04-30 (F36 — extended adjacency for surrounding regions)
Added
-
F36 — Extended adjacency for surrounding regions. New
focusMode.includeExtendedNeighborsflag (default false). When on, the visible neighbor set expands to include "secondary" adjacency — regions adjacent to the primary adjacents. For California focus, primary adjacents AZ/NV/OR are joined by secondary NM/UT/CO/ID/WA. Per user clarification: "I mean extended adjacent. So secondary adjacent. Those adjacent to the initial adjacent. It can be a simple pill to show and then user can then choose to remove what they don't want."Implementation.
getAdjacentNeighbors(iso, removed)andgetAllAdjacent(iso)inFocusData.jsnow readAppState.settings.focusMode.includeExtendedNeighborsdirectly and union secondary adjacency into the result when on. Reading from AppState centrally means every neighbor-aware feature picks up the expanded set automatically — focus mask geometry (cuts holes for all displayed neighbors), F38 country/state label filter, F47 cutout, F48 surrounding cities. No need to update each call site.UI. New
Include extended adjacencycheckbox in the Country / Region panel, shown only whenneighborMode === 'adjacent'(no point in adjacent mode is off;'all'already includes everything). Toggling re-runsFocusMod.activateso all dependent features render correctly. The chip list (getAllAdjacent-driven) shows the expanded set as additional pills; existing remove-on-click viaremovedNeighborsworks on all of them — same chip style, same UX.Deduplication. Both helpers use a
seenSet seeded with[iso, ...primary]to ensure secondary results don't include the focus or the primary set, and don't introduce duplicates. HonorsremovedNeighborsfor both primary and secondary.
Files
lib/map-modules/AppState.js—includeExtendedNeighborsdefault (mirrored)lib/map-modules/FocusData.js—getAdjacentNeighbors+getAllAdjacentextended (mirrored)lib/map-modules/UI.js— new_toggleExtendedNeighborshandler (mirrored)lib/map-modules/PanelContent.js— checkbox infocuscountryafter the chip list (mirrored)lib/map-modules/CFG.js— appVersion 0.9.49 → 0.9.50 (mirrored)public/map-tool/index.html— brand pill v0.9.50
No DB migrations. No theme schema changes.
[0.9.49] — 2026-04-30 (Two prod-test fixes — F38 filter respects neighborMode + Hillshade no longer falls inside Road Style)
Fixed
-
F38 / F38c label filter now gates on
neighborMode. With Alabama focus andneighborMode='none', the user reported seeing TN/MS/GA/FL state labels — adjacent regions that weren't selected for display. Root cause:_buildFocusStateNamesand_buildFocusCountryNamesalways calledgetAdjacentNeighbors(iso, removed)regardless of mode; the function returns the full adjacency list (only respectsremovedNeighbors, notneighborMode). Fix: both helpers now early-return the focus name only whenfm.neighborMode === 'none'. When mode is'adjacent'or'all', the existing adjacency-based name list is used. -
Hillshade checkbox no longer drops into the Road Style sub-controls. Layout was: Highway shields → Roads → [Road Style sub-controls when on] → Hillshade → [Shield Style sub-controls when on]. With Roads on, Hillshade visually fell inside the Road Style block. Fix: all three main toggles (Shields, Roads, Hillshade) are now grouped together; Road Style and Shield Style sub-controls follow at the end (Road Style first, then Shield Style). Reads as a clean checkbox list with sub-controls below.
Files
lib/map-modules/UI.js—_buildFocusCountryNamesand_buildFocusStateNamesgate adjacency onneighborMode(mirrored)lib/map-modules/PanelContent.js—focuscity(Focus Layers panel) groups main toggles before sub-controls (mirrored)lib/map-modules/CFG.js— appVersion 0.9.48 → 0.9.49 (mirrored)public/map-tool/index.html— brand pill v0.9.49
No DB migrations. No theme schema changes. No flag changes.
[0.9.48] — 2026-04-30 (F49 fix — show-base-map-through limits basemap to focus + neighbors, not the entire world)
Fixed
-
F49 reinterpreted. v0.9.46 set
fm-maskfill-opacityto 0 globally when "Show base map through focus" was on, so the basemap rendered across the entire world. User reported: "show basemap through focus is broken. It's showing the entire map and not the defined focus area or areas." Their actual intent was: basemap visible inside focus + selected surrounding regions ONLY, with the mask still framing everywhere else.Fix: mask
fill-opacitystays at 1 (always opaque); when through is on, the mask geometry is rebuilt with extra cutout holes for every visible neighbor (parallel to F47's per-feature behavior, but unconditional). Result:- Inside focus polygon → basemap visible (existing focus hole)
- Inside neighbor polygons → basemap visible (NEW cutouts via F49)
- Everywhere else → mask covers (frame)
- Neighbor fill opacity stays at 0 so basemap is actually visible inside the cuts; neighbor border keeps styling.
Both
_addLayers(initial activation) andupdateMaskCutout(live updates from toggle / chip changes) carry the same logic via a unifiedwantExtraCutouts = cutout || showThroughpredicate.
Files
lib/map-modules/FocusMod.js—_addLayersbuilds mask with extras when through is on; mask opacity reverted to fixed 1;updateMaskCutoutmatches (mirrored)lib/map-modules/CFG.js— appVersion 0.9.47 → 0.9.48 (mirrored)public/map-tool/index.html— brand pill v0.9.48
No other behavior changes. F38 filter drop (v0.9.44) stays in place — labels still render globally inside the cut areas when through is on.
[0.9.47] — 2026-04-30 (F43 — Local Cities panel renamed to "Focus Layers" + Focus Layers content moved into it)
User feedback after v0.9.46 ship: "I don't see the changes to the local cities section. It's still called local cities and not focus layers." The v0.9.44 work added a "Focus Layers" section inside customstylef (titled "Enhance with Base Map") instead of renaming the existing focuscity rail panel as the original spec intended. v0.9.47 corrects that.
Changed
-
focuscityrail panel renamed: title"Local Cities"→"Focus Layers"inSidebarConfig.js. The rail entry users click to open this panel now reads "Focus Layers". -
Focus Layers content moved from
customstylefintofocuscity. The toggles formerly stuffed into the v0.9.44 "Focus Layers" divider inside the Enhance-with-Base-Map panel are now at the top of the Focus Layers panel where users actually expect them:- State / region labels
- Country labels for surrounding regions (only when focus is a country)
- Cities in surrounding regions (only when neighbors are visible)
- Highway shields
- Roads (+ Road Style sub-controls when on)
- Hillshade
- Shield Style sub-controls when shields on
Below those, a
Local Citiessub-divider introduces the existing master toggle + city panel (source mode, city list, add/import, marker styling) — all of which is unchanged. The Focus Layers panel is now the single place to manage everything region-scoped. -
customstylef("Enhance with Base Map") panel slimmed. With Focus Layers content moved out, the panel is now: Label Style sub-controls (label size + halo), Full Base Map section (Show base map through focus toggle + Country/City labels checkboxes when on + Base Map Theme picker), and the existing Colors picker + Reset. Empty-state copy updated to match.
Files
lib/map-modules/SidebarConfig.js—focuscitytitle rename (mirrored)lib/map-modules/PanelContent.js—focuscityrenderer expanded with Focus Layers section at top + Local Cities sub-section below;customstylefrenderer cleaned of moved content (mirrored)lib/map-modules/CFG.js— appVersion 0.9.46 → 0.9.47 (mirrored)public/map-tool/index.html— brand pill v0.9.47
No behavior changes — same flags, same handlers, same logic. Pure UI relocation.
Still open
- F36 — manually add more surrounding regions (search-and-add UI for non-adjacent neighbors). Today the chip-based UI in
focuscountryonly lets users remove auto-detected adjacent regions; there's no way to add a non-adjacent region. Architectural follow-up from the v0.9.41 batch.
[0.9.46] — 2026-04-30 (F49 — "Show base map through focus" rendering parity)
Changed
-
F49 — Mask + neighbor fill go fully transparent when "Show base map through focus" is on. Completes the cluster started in v0.9.42 (toggle-off bug fix) and v0.9.44 (F38 filter drop). Today the focus mask was opaque white outside the focus polygon and neighbor regions had a colored fill on top of the mask, so even with
_layerCategoriessynced to the regular base theme and label filters dropped, the rendered map didn't actually show the basemap globally — it showed it only inside the focus hole and through neighbor borders. Fix: whenLiveStyleUI._showBaseMapis true,fm-maskfill-opacityis set to 0 andfm-neighbors-fillfill-opacityis set to 0. All borders (target border, neighbor border, admin1 border) keep their styling. Result: with through on, the basemap renders globally exactly as the same theme would in regular mode, with focus + neighbor + admin1 borders overlaid as the only focus-mode decoration.Combines cleanly with F47 (per-feature cutout):
fm-neighbors-fillopacity is now(F47-cutout || F49-through) ? 0 : 1. Toggling F47 sub-toggles off while F49 is on doesn't accidentally restore the neighbor fill.Live-update path.
FocusMod.updateMaskCutoutwas extended to also set the mask + neighbor opacity for F49.LiveStyleUI.toggleBaseMapcalls it as a fast-path before the 80ms_applyLivedebounce so the user sees instant feedback. The setStyle re-render that follows reads_showBaseMapin_addLayersand applies the same opacity, so the result is consistent.What "regular mode parity" means now: when through is on, the user sees their chosen base theme rendering exactly as in regular mode (water/roads/buildings/labels styled the same, no F38 filter, no mask, no neighbor fills) — plus the focus region's border, neighbor borders, and admin1 borders as the visual indication of focus. Picks up directly from the user's spec language: "Show base map through focus should display the themes the same way that the base map themes display them."
Files
lib/map-modules/FocusMod.js—_addLayersreadsLiveStyleUI._showBaseMapand sets mask + neighbor fill opacity accordingly;updateMaskCutoutextended to handle the F49 opacity update too (mirrored)lib/map-modules/LiveStyleUI.js—toggleBaseMapcallsFocusMod.updateMaskCutoutafter_applyLivefor instant visual feedback (mirrored)lib/map-modules/CFG.js— appVersion 0.9.45 → 0.9.46 (mirrored)public/map-tool/index.html— brand pill v0.9.46
No DB migrations. No theme schema changes.
F46 cluster — closed
| Item | Status |
|---|---|
| ✅ F46 | Focus Layers / Full Base Map split — v0.9.44 |
| ✅ F47 | Per-feature mask cutout — v0.9.43 |
| ✅ F48 | Cities in surrounding regions — v0.9.45 |
| ✅ F38c | Country focus state-label filter — v0.9.45 |
| ✅ F49 | Show-base-map-through rendering parity — v0.9.46 |
The focus-mode UX restructure cluster is complete. Only F50 (font family + style as two dropdowns; needs glyph PBF infra) and D — font consistency follow-up remain from the original discussion items.
[0.9.45] — 2026-04-30 (F48 — Cities in surrounding regions · F38c — Country focus state-label filter)
Added
-
F48 — Cities in surrounding regions toggle. New
focusMode.showSurroundingCitiesflag (default false) and a corresponding "Cities in surrounding regions" checkbox in the Focus Layers section, only shown whenneighborMode !== 'none'. When on,CityMod.activatecollects adjacent iso codes viagetAdjacentNeighbors(iso, removedNeighbors)and passes them to_filterCuratedasextraIsos. The filter accepts curated city features whose properties match the focus iso OR any of the extra isos:- US-state focus → matches sibling state names against
properties.adm1 - Country focus → matches
properties.iso2against the iso set MastershowCityLabels(set in the Local Cities panel) still gates whether ANY cities render. Toggle re-runsCityMod.refresh()immediately. Neighbor chip add/remove and Reset chip handlers also call refresh when this flag is on so the city list stays in sync as the user curates the neighbor set.
- US-state focus → matches sibling state names against
-
F38c — State-label filter for country focus.
_buildFocusStateNames()now handles country focus by extracting region names from FocusMod's cached admin-1 GeoJSON (f.properties.a= parent country ISO;f.properties.name= region display name). Filter list = focus country regions + adjacent country regions, respectingremovedNeighbors. Result is plugged into the existing F38places_region*filter pipeline. New publicFocusMod.getAdmin1Data()andFocusMod.ensureAdmin1Data(cb)accessors. When the data isn't cached yet,_applyFocusStateFiltertriggers the lazy fetch and re-applies itself once the data arrives — the existing filter state is preserved in the meantime to avoid a flash of all-regions-visible.
Files
lib/map-modules/AppState.js—showSurroundingCitiesdefault (mirrored)lib/map-modules/CityMod.js—_filterCuratedacceptsextraIsos,activatecollects neighbor isos whenshowSurroundingCitiesis on (mirrored)lib/map-modules/FocusMod.js—getAdmin1Data+ensureAdmin1Dataexposed on public API (mirrored)lib/map-modules/UI.js— new_toggleSurroundingCitieshandler,_buildFocusStateNamesextended for country focus,_applyFocusStateFiltertriggers async admin-1 load when needed (mirrored)lib/map-modules/PanelContent.js— Focus Layers "Cities in surrounding regions" toggle (mirrored)lib/map-modules/CFG.js— appVersion 0.9.44 → 0.9.45 (mirrored)public/map-tool/index.html— brand pill v0.9.45
No DB migrations. No theme schema changes.
Open in F46 cluster
- F49 — full "Show base map through focus" rendering parity. v0.9.44 shipped the filter-drop piece; remaining work is layer-ordering + neighbor handling so the basemap renders byte-identical to regular mode for the same theme.
[0.9.44] — 2026-04-30 (F46 — Focus Layers / Full Base Map section split)
Changed
-
F46 —
customstylefpanel restructured into two sections. The flat "Show Base Map Layers" list is replaced by:- Focus Layers (always available in focus mode): State / region labels, Country labels for surrounding regions (only shown when focused on a country, not a US state), Highway shields, Roads, Hillshade. Roads + Shield + Label style sub-controls follow these toggles as before.
- Full Base Map: "Show base map through focus" toggle + Base Map Theme picker as before. When the toggle is on, two new context-bound checkboxes appear here: Country labels and City & other labels. They bind to the same
showCountryLabels/showBasemapLabelsflags as the in-Focus-Layers presentations — what changes by context is the F38 filter behavior (see below). - "City & other labels" no longer appears in Focus Layers; it lives only in Full Base Map (per user spec, only meaningful when basemap is showing through).
-
F38 filter is now context-aware.
_applyFocusCountryFilterand_applyFocusStateFilterdrop the focus + neighbor filter whenLiveStyleUI._showBaseMapis true, so when "Show base map through focus" is on the basemap renders global labels (matching regular-mode behavior). When it's off, the filter is restored. Same single flag, two contexts — picking up from the user's clarification "Show base map through focus should display the themes the same way that the base map themes display them." -
LiveStyleUI.toggleBaseMapnow re-evaluates the F38 filters viaUI._applyFocusLabelFilters()after the theme reseed, so labels switch behavior immediately on toggle (no manual refresh needed).
Files
lib/map-modules/PanelContent.js—customstylefdivider rename + toggle reorder + Full Base Map section restructure (mirrored)lib/map-modules/UI.js—_applyFocusCountryFilterand_applyFocusStateFiltershort-circuit to filter=null whenLiveStyleUI._showBaseMapis true (mirrored)lib/map-modules/LiveStyleUI.js—toggleBaseMapnow callsUI._applyFocusLabelFiltersafter_applyLiveso the filter context updates live (mirrored)lib/map-modules/CFG.js— appVersion 0.9.43 → 0.9.44 (mirrored)public/map-tool/index.html— brand pill v0.9.44
No DB migrations. No theme schema changes. No AppState shape changes (existing flags showCountryLabels, showStateLabels, showBasemapLabels retain their semantics; their UI placement and filter context are what changed).
Open follow-ups in the F46 cluster
- F48 — surrounding-region cities (boolean toggle in Focus Layers; extends CityMod to render cities in neighbor isos). Not shipped in v0.9.44.
- F38c — per-country state name lookup so country focus filters places_region to focus + adjacent country regions (today country focus leaves regions unfiltered). Build at runtime from cached admin1Data.
- F49 — full "Show base map through focus" rendering parity. v0.9.44 ships the filter-drop piece (
_showBaseMapshort-circuits F38) which gets us most of the way; remaining work is layer-ordering + neighbor handling.
[0.9.43] — 2026-04-30 (F47 — per-feature mask cutout for surrounding regions)
Added
-
F47 — "Show in surrounding regions" sub-toggles for Roads/HWY shields and Hillshade. When focused on a region with surrounding regions selected (Adjacent or All neighbor mode), users can now extend basemap content visibility from just the focus polygon to the focus + every selected surrounding region. New section in Customize Focus Theme below the existing "Neighboring Regions" controls. Independent of the F37 "Match focus region style" master toggle.
Implementation. New AppState flags
focusMode.showRoadsInNeighbors+focusMode.showHillshadeInNeighbors(both default false). When either is true,FocusMod._buildMask()is called with extra cutout geometries — one hole per surrounding region polygon — so the mask becomesWORLD_RING - focusPolygon - neighbor1 - neighbor2 - .... Basemap content (roads, hillshade, water, labels — anything not explicitly hidden by the focus theme'slayerVisibility) shows through every cut hole. To make that content actually visible inside neighbor polygons, thefm-neighbors-filllayer drops tofill-opacity:0while any cutout flag is on; thefm-neighbors-borderlayer stays so the user still sees region boundaries.Live updates. New public
FocusMod.updateMaskCutout()reads cached source data fromSRC.target+SRC.geo, rebuilds the mask viasetData(), and updates neighbor-fill opacity without re-fetching GeoJSON. NewUI._toggleNeighborCutout(category, on)writes the flag, callsupdateMaskCutout, then refreshes the panel. Per-feature toggles (C1=b) so users can enable Hillshade in neighbors without enabling Roads, etc.Mask geometry.
_buildMaskwas refactored to take an optional second argextraCutoutGeometries(array of Polygon/MultiPolygon geometries). New helper_outerRingsOf(geom)extracts outer rings from either type. WORLD_RING + each cutout outer ring gets pushed (CW-rewound for GeoJSON spec compliance) into a single Polygon feature.
Files
lib/map-modules/AppState.js—showRoadsInNeighbors+showHillshadeInNeighborsdefaults (mirrored)lib/map-modules/FocusMod.js—_shouldCutoutNeighbors,_outerRingsOf,_buildMaskextension,updateMaskCutoutpublic method, neighbor fill-opacity wired to cutout state in_addLayers(mirrored)lib/map-modules/UI.js—_toggleNeighborCutouthandler (mirrored)lib/map-modules/PanelContent.js— new "Show in surrounding regions" section incustomstylef(mirrored)lib/map-modules/CFG.js— appVersion 0.9.42 → 0.9.43 (mirrored)public/map-tool/index.html— brand pill v0.9.43
No DB migrations. No theme schema changes.
Open follow-ups
- Export pipeline: F47 toggles affect the live MapLibre style. Standard PNG / hi-res PNG exports use
getStyle()cloning and should pick up the modified mask source automatically; confirm during smoke test, especially that the neighbor fill-opacity:0 carries through. Projection / D3 SVG export is a separate path (uses the FocusMod geometry directly, not the rendered mask) and may need explicit handling — flag if any export shows neighbor fills painted over basemap content.
[0.9.42] — 2026-04-29 (Real B / F49-partial fix — toggle-off now actually turns off the basemap)
Fixed
- B / F49 (real fix) — "Show base map through focus" toggle-off was leaving the basemap visible. v0.9.41's revert addressed a regression I had introduced in v0.9.40, but the user reported the bug still happened on v0.9.41 — meaning the underlying bug was older and had nothing to do with my v0.9.40 merge. Root cause:
ThemeMod.apply()deliberately skips_syncCategoriesToTheme()for__live__themes (line 734) to preserve the user's current layer toggle state across normal color/line-width edits. That's correct forLiveStyleUI.setColor()etc., but it's wrong fortoggleBaseMap's clean reseed: the new__live__was being seeded with the focus theme'slayerVisibility(basemap layers OFF), but_layerCategoriesretained the previous "basemap visible" state — and_vectorStylereads from_layerCategories, not from__live__.layerVisibility. Result: rendered map showed basemap layers from the prior state regardless of which way the user toggled. Fix:LiveStyleUI.toggleBaseMap()now manually callsThemeMod._syncCategoriesToTheme()with the source theme (focus theme for off, regular base theme for on) before_applyLive. This matches whatLiveStyleUI.reset()already does (which is why Reset Map across mode transitions has always worked).
Files
lib/map-modules/LiveStyleUI.js—toggleBaseMapnow syncs_layerCategoriesto the source theme before reseeding__live__(mirrored)lib/map-modules/CFG.js— appVersion 0.9.41 → 0.9.42 (mirrored)public/map-tool/index.html— brand pill v0.9.42
No DB migrations. No theme schema changes. Single-line behavior change with no impact on non-toggle flows.
[0.9.41] — 2026-04-29 (Hotfix release — B39 regression revert · UX polish · neighbor border slider · DO3b readability)
User-feedback hotfix after testing v0.9.40 on prod.
Fixed
- B39 regression — "Show base map through focus" was stuck on. v0.9.40's color-merge logic was copying ALL of the previous
__live__.overrides(including theme-default colors, not just user-set ones) onto the freshly-seeded__live__. So when toggling OFF, the regular base theme's colors leaked back over the focus theme's colors and the map looked visually unchanged from the ON state — making the toggle appear stuck. Reverted the merge logic; toggleBaseMap goes back to clean reseed (user customizations made through the toggle are intentionally lost). The proper "preserve only user-set keys, distinguished from theme defaults" approach requires per-key provenance tracking and is bundled with the F44 architectural rework. - UX9 layout — Apply-to-all button + per-dataset Size sliders were clipped past the panel's right edge. Two fixes: (1)
.ds-rownow usesflex-wrap:wrapso the per-dataset Size sub-row drops to a new line on narrow panels instead of getting clipped; (2) the new "Apply to all datasets" button moved to its own line below the baseline slider (was on the same.cs-rowas the slider+value, pushing it past the right edge — image showed only "Ap" + "to" visible). - DO3b — shape-overlay labels were unreadable even at the max slider. Two changes: (1) revised the zoom-interp curve from "hidden at z≤5, full at z=11" → "hidden at z≤3, full at z=8" so labels reach baseline size at common map zooms; (2) raised the size slider max from 18 → 32 px on both shape-overlay and data-overlay label sliders so users can dial larger when needed.
Added
- Neighboring Regions border-thickness slider in Customize Focus Theme. Was hardcoded at
0.5*mul; nowAppState.settings.focusMode.neighborBorderWidth(default 0.5, range 0.3–3) drivesfm-neighbors-borderline-width, with a UI slider mirroring the Focus Country "Outline width" control. NewUI._syncNeighborBorderWidth()and a corresponding entry inThemeMod._defaultFocusColorsso reset/non-focus theme switches restore the default cleanly.
Changed
- F11 Recenter button — replaced the separate top-left button with a clickable zoom badge. Per user feedback ("looks poor"), the
#zoomBadgenear the +/- zoom controls is now the Recenter trigger. Clicking it callsMapMod.recenter(). Added a⊕glyph + hover state (background + border + color transition) for click affordance. The separateaddControlbutton registered next to "Reset Map" was removed.MapMod.recenter()itself is unchanged.
Files
lib/map-modules/LiveStyleUI.js— B39 merge revert (mirrored)lib/map-modules/AppState.js—neighborBorderWidthdefault (mirrored)lib/map-modules/FocusMod.js—_addLayersreadsfm.neighborBorderWidth(mirrored)lib/map-modules/ThemeMod.js—_defaultFocusColorsaddsneighborBorderWidth(mirrored)lib/map-modules/UI.js— new_syncNeighborBorderWidth(mirrored)lib/map-modules/PanelContent.js— UX9 button-on-own-line, neighbor border slider, DO3b max raised on shape + data overlay sliders (mirrored)lib/map-modules/MapMod.js— separate Recenter button removed (mirrored)lib/map-modules/ShapeOverlayMod.js— DO3b zoom-interp curve revised (mirrored)lib/map-modules/pitchmappr.css—.ds-rownowflex-wrap:wrappublic/map-tool/index.html—#zoomBadgeclickable + hover, brand pill v0.9.41
No DB migrations. No theme schema changes.
Deferred (added to TODO with proper scope; NOT shipped in this hotfix)
- F46 — Restructure "Show Base Map Layers": Country / State / City labels split between Focus Layers (region-scoped) and Full Base Map (basemap-context). Coordinated with F43/F44.
- F47 — Selective "Match focus region style" toggles per-feature (Region / Roads-HWY / Hillshade) instead of the current all-or-nothing.
- F48 — Cities for surrounding regions (extends CityMod into FocusLayersMod).
- F49 — "Show base map through focus" should display themes the same way as base map themes (the proper B39 architectural fix — paired with F44).
- F50 — Font family + style as two-dropdown UX (B36 follow-up — needs multi-family glyph PBFs).
[0.9.40] — 2026-04-29 (B39 preserve customizations across base-map toggle · F38b state abbreviations)
Two more from the user-supplied 2026-04-29 batch — a small B39 contribution and the F38b follow-up promised in v0.9.39.
Fixed
- B39 (partial) — "Show base map through focus" no longer wipes user's color overrides. The
LiveStyleUI.toggleBaseMap()path destroys__live__and re-seeds it from a different theme source (focus theme vs regular base) — that's by design, but it was also dropping all the color and line-width customizations the user made via LiveStyleUI. Fix: snapshot__live__.overridesand__live__.lineWidthsbefore destroying, merge them back onto the freshly-seeded__live__after_applyLive, then_applyLiveonce more so the merged state paints. Layer-visibility is intentionally NOT preserved — the focus theme's hide-most-basemap-layers state is the whole point of focus mode and shouldn't bleed into the basemap view. Also added a clarifying note next to the toggle in the panel UI explaining the behavior. The deeper architectural split (separate "show base map underneath" vs "show base map through with hiding" modes that the user requested) is out of scope for this quick win — tracked as F44. - B37 follow-up. The same dynamic
fm-target-fillinsertion bug fixed in v0.9.37 was also present intoggleBaseMap's "show base map = off → restore target fill" branch — tightened thebeforeIdresolution to use the topmost-of-existing among the focus boundary layers, matching the v0.9.37 fix.
Added
- F38b — State abbreviations on US-state focus. When focus is a US state and
showStateLabelsis on,places_region*symbol layers'text-fieldis overridden with amatchexpression that maps full state names to USPS abbreviations ('California' → 'CA','Texas' → 'TX', etc.). Builds the match list fromFOCUS_US_STATES(51 entries including DC) at runtime. NewUI._origStateTextFieldscache stores the basemap's defaulttext-fieldper layer on first override so the original is restored cleanly when focus exits or the user picks a non-US-state focus. Wired through_applyFocusLabelFiltersso it runs alongside the country/state filters.
Files
lib/map-modules/LiveStyleUI.js—toggleBaseMapsnapshot + restore, plus B37 beforeId fix in the same path (mirrored)lib/map-modules/UI.js— F38b helpers_getStateAbbrMatchExpr,_applyFocusStateAbbreviations,_origStateTextFieldscache, wired into_applyFocusLabelFilters(mirrored)lib/map-modules/PanelContent.js— clarifying note next to the "Show base map through focus" toggle (mirrored)lib/map-modules/CFG.js— appVersion 0.9.39 → 0.9.40 (mirrored)public/map-tool/index.html— brand pill v0.9.40
No DB migrations. No theme schema changes. No AppState shape changes.
[0.9.39] — 2026-04-29 (F38 focus-region label filtering · UX9 marker-size baseline relocation)
Two more quick wins from the user-supplied 2026-04-29 batch.
Added
- F38 — Limit state/country basemap labels to the focus region. Parallel to the focus-locations scoping. New
UI._applyFocusLabelFilters()(covers both country and state) plus per-category builders_buildFocusCountryNames()and_buildFocusStateNames(). Filters use MapLibre's['in', ['get', 'name'], ['literal', names]]against the Protomapsplaces_country*andplaces_region*symbol layers — name match is reliable across the supported regions becauseFOCUS_COUNTRIES/FOCUS_US_STATESalready store the same display strings the tile schema renders.- Country labels — when focus is active and
showCountryLabelsis on, restrict to focus country + adjacent neighbors (or just the parent country for state focus). - State labels — when focus is active and
showStateLabelsis on AND the focus is a US state, restrict to the focus state + adjacent sibling states. Country focus leaves state labels unfiltered (no per-country state-name table yet — F38b). - Filters are wired through
_toggleFocusLabelCategory('country'|'state', on), the neighbor-chip click handler (so adding/removing chips updates label visibility live), andFocusMod.deactivateKeepState(so exiting focus restores global labels). - Note from user: state abbreviations as labels (e.g. "CA" instead of "California") is a follow-up — current behavior shows whatever protomaps tiles render at the active zoom (typically full names at low zoom, abbreviations may appear at intermediate zooms depending on tile schema).
- Country labels — when focus is active and
Changed
- UX9 — "Default Marker Size" relocated and reframed. Was at the bottom of the Markers panel, after every per-dataset row. Moved to right after the color-scheme block so users see the baseline first, then per-dataset overrides below — top-down read. Renamed divider to "Marker size — baseline for all datasets" with a clarifying note. Added an Apply to all button next to the slider that copies the baseline value into every
ds.markerScaleso existing per-dataset overrides don't silently mask a baseline change. The duplicate at the bottom of the section was removed (single source of truth).
Files
lib/map-modules/UI.js— F38 helpers:_applyFocusLabelFilters,_applyFocusCountryFilter,_applyFocusStateFilter,_buildFocusCountryNames,_buildFocusStateNames. Wired into_toggleFocusLabelCategory+ neighbor-chip delegate. (mirrored)lib/map-modules/FocusMod.js—deactivateKeepStatecallsUI._applyFocusLabelFiltersso global labels return on exit. (mirrored)lib/map-modules/PanelContent.js— UX9 marker-size baseline relocated + Apply-to-all button. (mirrored)lib/map-modules/CFG.js— appVersion 0.9.38 → 0.9.39 (mirrored)public/map-tool/index.html— brand pill v0.9.39
No DB migrations. No theme schema changes. No AppState shape changes.
[0.9.38] — 2026-04-29 (DO3b shape overlay label density · F11 Recenter button)
Two more quick wins from the queued backlog.
Changed
- DO3b — Shape overlay label density. Pipelines, power plants, earthquakes etc. carry hundreds of features each, and at low zooms the symbol-collision system alone wasn't enough to keep the screen readable.
text-sizeis now zoom-interpolated: hidden at z≤5, fades in at z=6, reaches 70% of the user-set size at z=8, and is full size at z≥11. New_zoomInterpTextSize(baseSize)helper inShapeOverlayMod.jsbuilds the MapLibre interpolate expression from the user's chosen size; both the initial-add path (applyLabels) and the live update path (updateLabelStyle) use it. Combined with the existingtext-allow-overlap: false, low-zoom shape-overlay views are now clean.
Added
- F11 — Recenter button. New top-left map control next to "Reset Map". Single fit-to-content action that picks behavior based on map mode:
- Focus mode active → fit to the focused country/state/region bounds.
- Regular mode + visible locations → fit to all markers (same effect as the existing
View allbutton in the locations panel). - Regular mode + no locations → world view.
Unlike Reset Map, this does NOT change theme/layers/settings — it only moves the camera. New
MapMod.recenter()+ newFocusMod.getActiveBounds()accessor (resolves bounds fromFOCUS_COUNTRIES/FOCUS_US_STATESlookup tables given the currently-active iso/region). Reuses the existingpm-reset-btnCSS class for visual consistency.
Files
lib/map-modules/ShapeOverlayMod.js—_zoomInterpTextSizehelper + two callsite updates (mirrored)lib/map-modules/FocusMod.js—getActiveBounds()exposed on public API (mirrored)lib/map-modules/MapMod.js—recenter()method + new map control button next to Reset Map (mirrored)lib/map-modules/CFG.js— appVersion 0.9.37 → 0.9.38 (mirrored)public/map-tool/index.html— brand pill v0.9.38
No DB migrations. No theme schema changes. No AppState shape changes.
[0.9.37] — 2026-04-29 (B37 focus admin1 boundaries · F37 match focus style for surrounding regions)
Two more quick wins from the user-supplied 2026-04-29 batch.
Fixed
- B37 — Focus map internal boundaries disappear when focus area color changes. Reproducible scenario: focus on a US state with admin1 boundaries on, change focus area color in Customize Focus Theme, admin1 lines vanish. Root cause: the default
targetFillColoris'transparent', soFocusMod._addLayers()doesn't create thefm-target-filllayer at activation time. When the user later picks a real color,LiveStyleUI.setFocusColor('targetFillColor', val)adds the layer dynamically withbeforeId='fm-target-border'. By that pointfm-admin1-borderhas already been inserted (also withbeforeId='fm-target-border'), so the new opaque fill ends up above the admin1 boundary lines in the stack and paints over them. Fix: the dynamic add now picksbeforeIdas the topmost-of-existing among[fm-neighbors-fill, fm-neighbors-border, fm-admin1-border, fm-neighbor-admin1-border, fm-target-border], so the newfm-target-filllands at the bottom of the focus stack — matching the order_addLayersuses on initial activation. Two locations inLiveStyleUI.js(the color-change path and the "show base map" disable path).
Added
- F37 — Match focus region style for surrounding regions. New checkbox in Customize Focus Theme → Neighboring Regions section. When on, neighbor fill / outline / internal-boundary colors mirror the focus region's
targetFillColor/targetBorderColor/admin1BorderColor— so picking one focus color updates both. Implementation: newAppState.settings.focusMode.matchNeighborToFocusboolean (default false). Toggling on snapshots current target* colors into neighbor* slots and pushes each throughLiveStyleUI.setFocusColor(so the live map updates via the same path as a manual color change). After that, every subsequentsetFocusColoron atarget*key auto-mirrors to the matchingneighbor*key while the toggle is on. The three neighbor color rows are hidden in the panel while on (replaced by a one-line note) to make the slaved relationship obvious. Toggling off keeps neighbor colors at their current values (a snapshot of focus colors at toggle-off time) — user can edit them independently again.
Files
lib/map-modules/AppState.js—focusMode.matchNeighborToFocusdefault added (mirrored)lib/map-modules/LiveStyleUI.js— B37beforeIdfix in two locations + newsetMatchNeighborToFocusmethod + auto-mirror branch insetFocusColor(mirrored)lib/map-modules/PanelContent.js— Match-style toggle + conditional neighbor-row hide (mirrored)lib/map-modules/CFG.js— appVersion 0.9.36 → 0.9.37 (mirrored)public/map-tool/index.html— brand pill v0.9.37
No DB migrations. No theme schema changes.
[0.9.36] — 2026-04-29 (B38 OAuth re-fix · B36 Data Overlay font label · F45 export watermark)
Three quick wins from a fresh batch of user-supplied TODO items.
Fixed
- B38 — Google OAuth required two sign-in attempts (regression of B25).
The B25 fix (2026-04-18) survived as written but stopped holding under Next 16.2.3 +
@supabase/ssr0.10. Two failure modes opened up: (1) the manualrequest.headers.get('cookie')parser used bygetAll()doesn't decode URL-encoded cookie values, so Supabase's PKCEcode-verifiercould be misread, andexchangeCodeForSessiononly succeeded on the second attempt with a fresh verifier; (2)setAll()only wrote toresponse.cookies, with no fallback if a Next 16 build ever drops cookie modifications on aNextResponse.redirect()response. Fix: rewroteapp/auth/callback/route.tsto usecookieStore.getAll()fromnext/headers(Next decodes correctly) and to write each cookie to BOTHcookieStore.set()ANDresponse.cookies.set()(belt and suspenders). Added explicitconsole.log/console.errorlines on every branch (no-code, exchange error, no-session, success) so any future regression surfaces in Vercel logs instead of failing silently. Server-side change only — needs prod smoke test on next deploy. - B36 — Data Overlay "Font" dropdown showed weights/styles, not font families.
The dropdown in the Data Overlay and Shape Overlay label sections offered Regular/Medium/Bold/Italic — those are weights, not families, and the field title said "Font" which set wrong user expectations. The TODO entry's prescribed fix (swap to font families like Arial/Helvetica) would have broken label rendering: Data/Shape Overlay labels render through MapLibre
text-fontglyph PBFs, and the protomaps glyph CDN ships only Noto Sans variants — arbitrary families need glyph PBF hosting (separate scope). Fix: renamed the field label "Font" → "Style" with a tooltip clarifying the constraint. Two locations inPanelContent.js(Data Overlay line ~1268, Shape Overlay line ~1432). True font-family support for these labels remains open as future work (would require multi-family glyph PBFs on R2 OR rendering labels as HTML popups instead of MapLibre symbols). - F45 — Export attribution watermark. New export setting
AppState.settings.exportWatermark = {enabled, position, customText}. When enabled, the PNG / Hi-Res PNG export pipeline stamps a small line of text at the chosen corner so any viewer of the image can trace its origin. Wired throughExportMod._buildWatermarkSvg(cropW, cropH)which is called from_compositeOverlays— single insertion covers both standard and hi-res paths via the existing SVG-overlay mechanism. Auto-generates from project name + author (fromUI._userInfo) + ISO date whencustomTextis empty; user can override with any string. Position dropdown: bottom-right (default) / bottom-left / top-right / top-left. Style: 10px sans-serif, dark grey fill with white halo (paint-order:stroke) so it reads on any background. UI controls added to the Export panel; default OFF so existing behavior unchanged. SVG export and projection export still use separate code paths and don't yet pick up the watermark — tracked for follow-up.
Files
app/auth/callback/route.ts— full rewrite (B38)lib/map-modules/AppState.js—exportWatermarkdefault added (mirrored to public/map-tool/lib/map-modules)lib/map-modules/ExportMod.js—_buildWatermarkSvg()+_compositeOverlaysintegration (mirrored)lib/map-modules/PanelContent.js— Data Overlay + Shape Overlay "Style" rename (B36) + Export panel watermark UI (F45) (mirrored)lib/map-modules/CFG.js— appVersion 0.9.35 → 0.9.36 (mirrored)public/map-tool/index.html— brand pill v0.9.36
No DB migrations. No theme schema changes. No server-side data model changes.
[0.9.35] — 2026-04-29 (Legacy Base Map Layers > Hillshade toggle now wired to setHillshade)
User report: clicking the "Hillshade" checkbox under the Base Map Layers > Terrain section did nothing visible — even after the v0.9.34 architecture refactor. Diagnosis: there are two hillshade checkboxes in the user-side iframe and they were decoupled.
- Customize Theme > "Show hillshade (raster-DEM relief)" — new dedicated checkbox added in v0.9.33. Calls
LiveStyleUI.setHillshade()which writes toAppState.settings.regularHillshadeEnabled(orfocusMode.hillshadeEnabled) and triggers source/layer creation. Works correctly. - Base Map Layers > Terrain > Hillshade — legacy generic-layer-toggle UI that pre-dates v0.9.30. Called
ThemeMod.toggleLayer(catIdx, lyrIdx, on)which only flippedsetLayoutProperty('hillshade', 'visibility', ...)on an existing layer. Since the hillshade source/layer don't exist by default (theme has nohillshade.enabled === trueand no user override yet), the visibility toggle had nothing to act on — silent no-op.
Fix: ThemeMod.toggleLayer and ThemeMod.toggleCategory now special-case hillshade — when the legacy layer toggle fires for hillshade, it routes through LiveStyleUI.setHillshade(on) which goes through the v0.9.34 architecture (writes to AppState slot, syncs _layerCategories.hillshade.on, re-applies the active theme). Both checkboxes now do the same thing.
Note (deferred per user direction): the duplicate UI is left in place. Customize Theme > Show hillshade and Base Map Layers > Terrain > Hillshade are now in sync but redundant. Consolidating to one (likely removing the Customize Theme one to keep Base Map Layers as the single layer-control surface) is a future cleanup task — F30 slot.
Verified end-to-end: clicking Base Map Layers > Terrain > Hillshade now sets AppState.settings.regularHillshadeEnabled = true, MapMod.map.getLayer('hillshade') returns the layer, and Object.keys(MapMod.map.getStyle().sources) includes terrain.
Files
lib/map-modules/ThemeMod.js—toggleLayerandtoggleCategoryroute hillshade throughLiveStyleUI.setHillshade(mirrored)lib/map-modules/CFG.js— appVersion 0.9.34 → 0.9.35 (mirrored)public/map-tool/index.html— brand pill v0.9.35
No DB migrations. No theme schema changes.
[0.9.34] — 2026-04-29 (Hillshade override moves from live to AppState — fixes mode-switch persistence + base-map enable)
Two user-reported bugs from v0.9.33 ship, both same root cause: hillshade override lived on the __live__ theme, which is destroyed every time ThemeMod.apply is called with a non-__live__ key (i.e. on every theme switch and every mode switch). So:
- Bug 1 — focus mode persistence: enable hillshade in focus → switch to base map → switch back to focus → checkbox still on but hillshade gone. The mode switch destroyed
__live__, taking the override with it. - Bug 2 — base map enable did nothing: similar instability with
__live__lifecycle in regular mode interacting with theme application.
Architectural refactor. Hillshade override is now stored on AppState.settings, not on __live__:
AppState.settings.regularHillshadeEnabled— regular-mode override (undefined|true|false)AppState.settings.focusMode.hillshadeEnabled— focus-mode override (same shape)
Two independent slots so each mode can be controlled separately. undefined means "use the active theme's hillshade.enabled setting"; explicit true/false is an explicit user choice that overrides the theme. AppState.settings is the project-save unit and uses the existing Object.assign restore path, so both fields survive mode switches, theme switches, page reloads, and project save/load.
LiveStyleUI changes:
setHillshade(bool)writes to the mode-appropriateAppState.settingsslot, mirrors intoThemeMod._layerCategories.hillshade.onso the visibility loop respects it, then re-applies the active theme viaThemeMod.apply(AppState.settings.theme, true). No longer touches__live__.isHillshadeEnabled()reads the mode-appropriate override; falls back to active theme'shillshade.enabledwhen override is undefined; falls back to false otherwise.
ThemeMod._vectorStyle changes:
- New
_userOverridereads fromAppState.settingsbased onmapMode. _hsEnabled = (_userOverride === true) || (_userOverride !== false && themeHs?.enabled === true)— user override wins (both directions); falls through to theme when undefined.- Source block now uses
_hsEnabled(was checkingct.hillshade.enabledonly — missed the case where override is true on a theme with no hillshade config, which is the common user-side scenario). - Layer splice block uses
_hsEnabledand synchronizes_layerCategories.hillshade.onimmediately after splicing (or before, if disabling) so the visibility loop further down doesn't override our decision.
No DB migrations. Field shapes unchanged on themes; the override lives on AppState.settings, not on themes. Existing themes with hillshade.enabled === true continue to work (and respect explicit user disable). Old saved projects load cleanly — missing override fields stay undefined, falling through to theme behavior.
Verified end-to-end in browser
- Regular mode + user toggle on → terrain source + hillshade layer appear ✓
- Switch to focus → focus override defaults to off → hillshade absent ✓ (each mode independent)
- Focus mode + toggle on → hillshade renders ✓
- Switch to base map → focus override stays
true(just not active in regular mode) ✓ - Switch back to focus →
focusHs_stillPersists: true,hsLayer: true,terrainsource present ✓ (Bug 1 closed) - Regular toggle on after page reload → hillshade renders globally ✓ (Bug 2 closed)
Files
lib/map-modules/AppState.js— addedregularHillshadeEnabled+focusMode.hillshadeEnabled(mirrored)lib/map-modules/LiveStyleUI.js—setHillshade+isHillshadeEnabledrewritten to use AppState slots (mirrored)lib/map-modules/ThemeMod.js—_vectorStylesource/layer blocks read_hsEnabledfrom AppState override (mirrored)lib/map-modules/CFG.js— appVersion 0.9.33 → 0.9.34 (mirrored)public/map-tool/index.html— brand pill v0.9.34
[0.9.33] — 2026-04-29 (User-side hillshade toggle + admin editor LOAD_THEME / toJSON / fromJSON hillshade fixes — closes F29)
Two related bug clusters resolved in one release.
Cluster 1 — User-side hillshade toggle (closes F29, originally scoped as future work)
User-reported: "On user-side base maps, when I view a theme and toggle on hillshade, it doesn't come on. On user-side focus maps, I don't see a checkbox to toggle on hillshade where is that located?"
Root cause: the user-side iframe had no hillshade enable mechanism at all. The Terrain section's "Hillshade" entry in the layer-toggle UI controlled layerVisibility.terrain_hillshade only — visibility on/off for an EXISTING hillshade layer. Since most themes don't have hillshade.enabled === true, the layer was never created in the first place, so the visibility toggle did nothing.
Built per F29 spec (toggle-only, theme-bound):
LiveStyleUI.setHillshade(enabled)— writes to__live__.hillshade.enabled, seeds full hillshade config (colors, illumination, exaggeration) from the source theme on first toggle (so the visual style follows what the theme author set up). Themes with no hillshade field get sensible defaults: black shadow, white highlight, exag 0.5, NW illumination 335°. Also writes__live__.layerVisibility.terrain_hillshadefor save consistency, and directly flipsThemeMod._layerCategories.hillshade.onbecause_syncCategoriesToThemeis intentionally skipped for__live__themes (preserves the user's other toggle states) — without the direct write, the layer would be created but immediately hidden by the visibility loop.LiveStyleUI.isHillshadeEnabled()— reads effective state (live override → active theme'shillshade.enabled→ false). Used by both panels' checkboxcheckedattribute.- Regular-mode panel (
PanelContent.customstyle): new "Hillshade" divider with one checkbox "Show hillshade (raster-DEM relief)" between the Boundary Style row and the Layer Opacity sliders. - Focus-mode panel (
PanelContent.customstylef): new "Hillshade" checkbox added to the existing "Show Base Map Layers" section right after "Roads" — matches user's requested UI placement.
Per-theme behavior: switching themes destroys __live__, so the new theme's hillshade.enabled (or default-off) takes effect. User edits don't carry across themes.
Cluster 2 — Admin editor hillshade leak across theme loads + missing serialization
Discovered while investigating Cluster 1's user-side state: the admin editor's LOAD_THEME postMessage handler never reset EditorState.hillshade, and toJSON / fromJSON didn't include hillshade at all. Three coupled bugs:
LOAD_THEMEleak: clicking the section header to enable hillshade on Theme A, then loading Theme B in the editor, leftEditorState.hillshade.enabled = true(the section header would show CHECKED while Theme B has no hillshade.enabled field). Same shape as the v0.9.16+ leaks forlayerOpacity/oneWayArrows/poiColorsetc., but the symptom was visually obvious for hillshade. Fix:LOAD_THEMEnow setsEditorState.hillshade = t.hillshade ? {...t.hillshade} : DEFAULT_HILLSHADEon every theme load. (Other v0.9.16+ leaks tracked as a separate cleanup pass.)toJSONmissing hillshade: saving a theme with hillshade enabled wouldn't actually persist the field to the DB. Fix: emithillshade: {...}only whenenabled === true(clean JSON for themes that don't use it).fromJSONmissing hillshade: legacy theme-import path didn't pick up hillshade. Fix: reset to defaults when not in the loaded object, mirroring theLOAD_THEMEfix.
Cluster 3 — Bundled theme cleanup
Per user direction "I will add it to themes I want or the user can toggle it on" — Focus MapTiler Light (the only bundled theme that shipped with hillshade.enabled: true) is now back to off-by-default like every other theme. Header comment updated to note that warm-beige earth fill suits hillshade well if you choose to enable it via the user-side toggle or admin editor.
Files
lib/map-modules/LiveStyleUI.js—setHillshade+isHillshadeEnabledmethods (mirrored)lib/map-modules/PanelContent.js— Hillshade row incustomstyle(regular) +customstylef(focus, Show Base Map Layers section) (mirrored)public/map-tool/mapprstyle.html—toJSONemits hillshade;fromJSONresets;LOAD_THEMEresets per loaded themepublic/map-tool/themes/focus-maptiler-light.js— hillshade default removedlib/map-modules/CFG.js— appVersion 0.9.32 → 0.9.33 (mirrored)public/map-tool/index.html— brand pill v0.9.33
No DB migrations. No theme schema changes. Existing themes with hillshade.enabled === true continue to work; new ones default off.
Verified end-to-end
User-side iframe loaded with Voyager (no hillshade) → user clicks "Show hillshade" → relief renders globally at z=2 (Andes, Atlas, Alps, Himalayas, Greenland, etc.). Checkbox state correctly reflects effective hillshade state across regular and focus modes.
[0.9.32] — 2026-04-29 (Hillshade z-order fix — render above landcover/landuse fills)
User-reported regression: at low zoom, hillshade was visible on Greenland but invisible everywhere else. Diagnosed as a layer-stacking bug, not a data-precision issue (which the v0.9.31 zoom-interp exaggeration was attempting to address — that fix was correct in itself but didn't solve the visible symptom because the layer was rendering UNDER opaque landcover/landuse fills).
Root cause. v0.9.30 spliced the hillshade layer at earthIdx + 1 — right after earth. But the protomaps layer roster has ~9 fill layers between earth and the first roads layer (landcover, landuse_park, landuse_park_a/b, landuse_hospital, landuse_industrial, landuse_school, landuse_zoo, landuse_aerodrome, water, etc.). Most of those are opaque and painted on top of hillshade, hiding it on every continent except Greenland (which has no landcover fill, just earth — so hillshade was visible there only).
Fix. Moved the splice point in all 3 buildStyle paths from earthIdx + 1 to the index of the first ^roads_ layer. Hillshade now renders above all terrain fills (earth, landcover, landuse_*, water) but beneath roads, buildings, boundaries, and labels — proper cartographic z-order.
Verified. Live screenshot at z=2 over Africa/Europe shows global hillshade: Andes, Atlas Mountains, Sahara escarpments, East African Rift, Alps, Carpathians, Himalayas/Pamir, Urals, Tien Shan, Rockies, Appalachians, Greenland ice sheet — all rendering with natural relief.
Surfaces touched (same fix in 3 places, kept in lockstep):
public/map-tool/lib/map-modules/ThemeMod.js_vectorStyle()—firstRoadIdxcalculationpublic/map-tool/mapprstyle.htmlbuildStyle()— same patternpublic/map-tool/preview.htmlbuildStyle()— same pattern- Mirrored to
lib/map-modules/ThemeMod.js
No DB migrations. No theme schema changes. Existing themes work unchanged. The v0.9.31 zoom-interp exaggeration is preserved (still useful for low-zoom contrast at the corrected z-order).
[0.9.31] — 2026-04-29 (Hillshade follow-up fixes — visibility at low zooms + section toggle UX)
Two bugs surfaced after the v0.9.30 ship; both addressed here. Plus a future enhancement scoped as F29 (user-side override).
Fix 1 — Hillshade not visible until z=6. At low zooms each Terrarium tile pixel covers ~150 km of terrain, so a flat exaggeration: 0.5 produces shadows too subtle to see until enough pixel detail accumulates. MapTiler/Mapterhorn reference styles solve this with zoom-interpolated exaggeration. All three buildStyle paths (mapprstyle.html, ThemeMod._vectorStyle, preview.html) now expand the user's stored exaggeration value into:
['interpolate', ['linear'], ['zoom'],
0, min(1, ex × 2.4),
4, min(1, ex × 1.6),
8, ex,
14, ex × 0.7]The user's stored value is the city-zoom (z≈8) anchor; low zooms get a 2.4× boost so relief is visible from first paint, high zooms get a 0.7× softening so shadows don't overpower at street level. The min(1, ...) clamp prevents the boosted value from exceeding MapLibre's hillshade-exaggeration ceiling. Editor copy updated to mention this behavior.
Fix 2 — Section header checkbox decoupled from feature state. Users reported the Hillshade section's master checkbox showed CHECKED on first load, but hillshade was actually OFF. Root cause: the master toggle flipped EditorState.layerVisibility.terrain_hillshade (which was unset, treated as on by the existing !== false check), but EditorState.hillshade.enabled (which actually controls source/layer creation) defaulted to false. Two states, no synchronization.
Fix: special-case the hillshade section in mapprstyle.html SidebarUI.render() and _toggleSectionMaster(). The master checkbox now derives its state from EditorState.hillshade.enabled directly. Clicking it flips both enabled and layerVisibility.terrain_hillshade together. The redundant inner "Enable hillshade" checkbox (which the user discovered as the workaround) is removed; the section body now shows a "Hillshade is off" hint when disabled, pointing back to the section header toggle.
New: F29 — User-side hillshade on/off toggle. Themes can ship hillshade pre-enabled (e.g. Focus MapTiler Light), but users can't currently disable it on a saved map without forking the theme. Scoped in TODO as a future enhancement (~1-2h): per-map override via LiveStyleUI.setHillshadeEnabled(false) on the __live__ theme, exposed in PanelContent.customstyle(). Reverse-toggle (turning ON for a theme that doesn't ship with it) deferred — would require source/layer creation in the user-side path.
No DB migrations. Field shapes unchanged from v0.9.30. Existing themes with hillshade.enabled === true work identically; the apply path just expands their stored exaggeration into a zoom-interp expression.
[0.9.30] — 2026-04-28 (Hillshade via raster-DEM with CORS proxy worker — closes F28)
Themes can now opt into a hillshade layer beneath landcover, fed by AWS Open Data Mapzen Terrarium tiles (free, no SLA) through a new Cloudflare Worker that adds CORS headers — the AWS bucket itself does not. Architecture is structured for a one-PR swap to self-hosted Mapterhorn PMTiles on R2 if/when triggers fire (see ADR-066).
Why a worker instead of self-hosting from day one: the v0.9.29 handoff assumed the upstream PMTiles file was 5–8 GB; verification against the actual current Mapterhorn URL (download.mapterhorn.com/planet.pmtiles, after Protomaps' terrain product migrated) showed it's 706 GB — ~100× the original budget. AWS Open Data uses identical Terrarium encoding so map integration is unchanged regardless of source. Worker URL https://terrain-proxy.pmppr.workers.dev/{z}/{x}/{y}.png is stable across the future backend swap; map code never changes.
New: terrain-proxy worker (pmppr-map/workers/terrain-proxy/):
wrangler.tomlwith[vars] TERRAIN_BACKEND="aws"; commented R2 binding ready for the future swapsrc/index.js— ~120 LOC: CORS preflight, path validation/{z}/{x}/{y}.png, AWS upstream fetch with edge cache (cf.cacheTtl: 604800+Cache-Control: public, max-age=604800, immutable),fetchFromR2stub for the swap, error responses, JSON status endpointREADME.md— deploy command, verification curl examples, attribution requirements, backend swap docs
Theme schema:
EditorState.hillshade = {enabled, shadowColor, highlightColor, accentColor, illuminationDirection, exaggeration}(defaults: enabled=false, shadow=#000, highlight=#FFF, accent=#000, dir=335°, exag=0.5)- New
terrain_hillshadelayerVisibilitykey — visibility on/off independent ofenabled - New
CFG.terrainTilesUrlconstant + mirror in mapprstyle.htmlTERRAIN_TILES_URL
Editor UI:
- New "Hillshade" FLAVOR_SCHEMA section (custom
type:'hillshade'render) between Background & Earth and Landcover. Enable toggle + 3 color swatches (shadow/highlight/accent) + 2 sliders (illumination dir 0–359°, exaggeration 0–1). - Section auto-collapses controls when disabled.
Apply paths (3 surfaces):
mapprstyle.html buildStyle(): conditionally addsterrainraster-dem source + splices hillshade layer afterearth._applyDiffalready triggers fullsetStyleon layer-count change, so toggling enabled live-previews correctly.ThemeMod._vectorStyle(): same source/layer logic; visibility flows through existing_layerCategoriestoggle loop via the new Terrain section entry.preview.html(admin detail page mini-preview): same; visibility honored manually since the splice happens after the visMap loop.
5-surface visibility-key lockstep (per ADR-062 discipline):
- ThemeMod
_layerCategories(newhillshadeentry under Terrain),_defaultToggles.hillshade=false,_visKeyToToggles.terrain_hillshade=['hillshade'] - mapprstyle
FLAVOR_SCHEMAtoggleKey + visMapterrain_hillshade: /^hillshade$/ - preview.html visMap entry +
DEFAULT_OFF_KEYSentry - admin detail page
DEFAULT_VISIBILITY: terrain_hillshade=false
Plumbing:
- AppState
loadJSONallow-listshillshadein both customThemes filter and PITCHMAPPR_THEMES bundle merge - PostMessageBridge
DB_THEMES_LISThandler forwardshillshadefield - Editor save path passes the field through unchanged (no field filtering at the wrapper)
Attribution: Mapzen attribution baked into the source's attribution property — MapLibre's AttributionControl surfaces it whenever hillshade is enabled. License requirement satisfied.
Cost reality:
- Today (AWS via Worker): ~$0/month at any plausible scale (Workers free tier: 100k req/day; R2 unused).
- Self-hosted alternatives in ADR-066: $0.66/mo (z0–8), $2.64/mo (z0–10), $10.60/mo (full z0–12).
See ADR-066 for full rationale, the four triggers that should prompt the self-host swap, and the URL-correction story (Protomaps → Mapterhorn migration).
[0.9.29] — 2026-04-28 (Per-zoom color interpolation for high-impact keys)
The biggest fidelity gap behind the theme-review pass: reference styles use MapLibre interpolate expressions for many colors so the rendered map smoothly transitions between hues with zoom. Pitchmappr could only store one hex per override key, so themes looked the same at z=4 and z=18.
Each of 12 high-impact keys (background, earth, water, buildings, highway, major, minor_a, minor_b, link, other, boundaries, boundaries_country) can now be either a hex string (existing) or a {low, high, lowZ, highZ} object that renders as a zoom-interpolated expression. Default zoom stops 6 and 14 if omitted.
Apply path in both mapprstyle.html buildStyle and ThemeMod._vectorStyle: pre-process splits overrides into flat (strings) + zoom (objects), the flat copy goes into the flavor passed to basemaps.layers (using .high as the fallback hex so basemaps generates valid layer paint), then post-process replaces the matching layer's paint property with the interpolate expression.
Editor UI: new "Zoom-Interpolated Colors" section between POI Per-Class Colors and the existing POIs section. Each row has a "z↕" toggle. Off shows a single hex swatch; on shows two color rows (low + high) with hex inputs and zoom-stop number inputs.
[0.9.28] — 2026-04-28 (Per-saved-view label scale)
v0.9.27 added the labelScale slider on the projectionExport global config. User wanted it per-saved-view too, with sliders in the Saved Views panel (where views are defined) AND mirrored in the Export panel for on-the-fly tweaks.
Each saved view object now carries labelScale (default 1). LabelMod.exportElements gains a 6th parameter that multiplies marker label font size. ExportMod._compositeOverlays reads view.labelScale and forwards. Affects both standard (1×) and hi-res (HIRES_SCALE×) PNG paths.
UI: sliders (0.5×–3×, step 0.05) in Saved Views panel rows AND Export Image panel rows. Both edit the same underlying view.labelScale field.
[0.9.27] — 2026-04-28 (Floor + multiplier for projection-export label sizing)
User reported marker labels became unreadable when exporting projection mode at canvas widths < 1920px. Root cause: in ProjectionExportMod.js, label sizing used sf = w / BASE_W (BASE_W = 1920) which shrank labels linearly. At 800px canvas: sf = 0.42, markerLabelSize × 0.42 ≈ 5px.
Two fixes applied together: (1) floor sf at 0.7 specifically for label sizing (geometry/halo offsets keep raw sf so projection scale and dot-to-label gap render proportionally); (2) new user-controllable AppState.settings.projectionExport.labelScale multiplier (default 1, range 0.5–3) with a slider on the projection export panel.
Combined: labelSf = max(0.7, sf) × labelScale. Used in place of *sf in every label-sizing site across both render paths (canvas preview render() and SVG exportPNG()): state labels, city labels, marker labels.
[0.9.26] — 2026-04-28 (Country boundary granularity — color, width, dash)
Country borders previously inherited everything from the general boundaries setting — same color, line-width multiplier, and dash pattern as state/regional admin boundaries. Reference styles distinguish them.
Added (all additive — country override only takes effect when set, otherwise falls back to general):
overrides.boundaries_country(color)lineWidths.bounds_country(multiplier)boundaryDashCountry(string)
UI: new "Country boundaries (override)" color row in Infrastructure section, slider in Line Widths section, plus the inline Infrastructure render now has two dash dropdowns ("Dash (both)" + "Country override" with "(inherit)" first option).
Apply: in both buildStyle and ThemeMod, post-flavor-merge injection sets line-color on boundaries_country layer when override is set; line-width scaler split with negative lookahead to avoid double-matching; both general + country dasharrays computed and applied to their respective layers in a single pass.
[0.9.25] — 2026-04-28 (Per-class POI color overrides)
The protomaps base groups 36 POI kinds into 8 color buckets via a case expression. Themes could only adjust the bucket colors; individual kinds were stuck inheriting their bucket. Now each of the 36 kinds can be overridden directly.
Schema: new EditorState.poiColors {} field — map of OSM kind → hex. New "POI Per-Class Colors" section in FLAVOR_SCHEMA, positioned just above the existing POIs (bucket) section. Custom inline render groups all 36 kinds into 6 logical sub-sections: Nature & Outdoor (8), Transport (4), Civic & Public (10), Food & Drink (4), Retail (6), Attractions (4). Each row has color swatch + hex input (placeholder "(inherit)") + × button to clear.
Apply: in both buildStyle and ThemeMod, when poiColors has entries, the pois layer's text-color is wrapped with a match expression on kind. Each overridden kind gets its explicit color; everything else falls through to the original case expression. Additive, no kind regresses.
Plumbing: poiColors added to AppState loadJSON allow-list (both customThemes filter+reconstruct and PITCHMAPPR_THEMES merge) and PostMessageBridge DB_THEMES_LIST handler.
[0.9.24] — 2026-04-28 (Custom sprite URL per theme)
The MapLibre style spec's sprite field was hardcoded to the protomaps default for the current base flavor in both buildStyle paths. Themes had no way to ship their own icon set.
Added EditorState.spriteUrl (string, optional). When set, replaces the default sprite URL directly. Empty/null = unchanged behavior. UI: text input in the Fonts section's panel with placeholder "(default for base flavor)" and help line noting URL must host both <url>.png and <url>.json per spec.
Sprite changes detected by the existing _applyDiff "sprite/glyphs URL change" branch, so live preview swaps icon sets correctly.
URL-only flavor — predefined dropdown of curated sprite sets is a follow-up.
[0.9.23] — 2026-04-28 (Per-category font overrides)
The Fonts section of the admin theme editor now exposes 5 per-category font dropdowns (Country / City / State / Road / Water) below the global font selector. Each defaults to "(inherit global)"; when set, overrides the global font on that category's matching label layers (via the same labelPatterns the existing labelStyles loop uses).
Lets a designer pick e.g. a display serif for cities, a compact sans for road names, an italic for water labels — same approach reference styles like MapTiler/Geoapify use.
Stored on labelStyles[cat].font. Travels with the rest of labelStyles through both serialization paths without new plumbing. Apply runs after the global text-font apply, so per-category overrides win on matching layers.
[0.9.22] — 2026-04-28 (Quick wins bundle: letter spacing, padding, one-way arrows, user-side dash)
Four small theme controls bundled in one release.
(a) text-letter-spacing per label category — admin only. New "Spacing" slider (-0.05 to 0.4) in Label Styles row. Stored on labelStyles[cat].letterSpacing. Applied as text-letter-spacing layout property.
(b) text-padding per label category — admin only. New "Padding" slider (0–20px, default 2 = MapLibre default). Stored on labelStyles[cat].padding. Controls label collision aggressiveness.
(c) One-way arrow color + size — admin only. Inline section under Labels. New EditorState.oneWayArrows = {color, size}. Applied as icon-color (paint) + icon-size (layout) on roads_oneway sprite layer.
(d) Boundary dash on user-side. Admin had this dropdown; mirrored to user-side customstyle as a fourth row of "Boundary style" select. New LiveStyleUI.setBoundaryDash writes ct.boundaryDash on __live__; ThemeMod's existing apply path handles rendering.
Plumbing: oneWayArrows added to AppState loadJSON allow-list and PostMessageBridge DB_THEMES_LIST.
[0.9.21] — 2026-04-28 (Theme palette classification — closes F23)
Closes F23 (organize theme dropdown by Color vs B&W) with a 7-bucket taxonomy: Color · Rich, Color · Muted, Grey · Light, Grey · Dark, High Contrast B&W, White, Custom.
Auto-classification heuristic (machine-local /tmp/classify_themes.mjs + apply_palette.mjs):
- Sample bg, earth, water, highway, major, minor_a, buildings, boundaries, landcover.forest from
data.overrides - Compute mean saturation + contrast (max-min lightness) + bg lightness
visibility === 'private'→custom- No samples (5 default base flavors) → fall back to
data.basemapping - Mean sat < 0.05: contrast > 0.55 → bw_high_contrast; bgL > 0.95 → white; bgL < 0.5 → grey_dark; else grey_light
- Mean sat ≥ 0.05: > 0.25 → color_rich; else color_muted
Applied to all 77 themes via service-role apply_palette.mjs (writes only data.palette). Distribution: Color · Rich (20), Color · Muted (27), Grey · Dark (8), Grey · Light (4), High Contrast B&W (4), White (9), Custom (5).
Three UI surfaces:
- Admin theme editor toolbar — new Palette dropdown alongside Visibility (8 options, "Uncategorized" strips field)
- User-side basemap dropdown (
PanelContent.basestyle) —<optgroup>sections per palette (with base flavors first, then alpha) /admin/themeslist page — new "Palette:" filter pill row underneath existing pills
Plumbing: palette added to AppState loadJSON (both customThemes filter+reconstruct and PITCHMAPPR_THEMES merge) and PostMessageBridge DB_THEMES_LIST. Bonus fix: DB_THEMES_LIST was silently dropping layerOpacity when forwarding — added as a sibling field.
Focus theme dropdown left alone — its existing defaults/extras grouping is more meaningful than palette layered on top.
[0.9.20] — 2026-04-28 (Halo slider value readout + Off label at 0)
The admin Label Styles halo slider had no value display next to it, unlike the Size slider beside it. Users dragging halo to 0 (which fully disables the halo via text-halo-width: 0) couldn't see they had reached it. Added a value span that shows the numeric width or "Off" when at 0.
Mirrored the same "Off" affordance to user-side PanelContent customstyle's Label halo slider readout, plus LiveStyleUI.setLabelStyle so the readout updates live as the slider drags through 0.
No behavior change — capability already existed at 0; this just makes "off" visible.
[0.9.19] — 2026-04-28 (Country label halo color override)
The basemaps protomaps flavor exposes halo color keys for every label category except country (city/state/subplace/road/address halos exist; country_label_halo does not). The places_country layer renders with text-halo-color: #e2dfda baked in — admins couldn't change it.
Schema: added country_label_halo to FLAVOR_SCHEMA Labels section, paired with label_countries toggle.
Application: since the flavor merge can't reach a key the basemaps lib doesn't know about, both buildStyle and ThemeMod.apply now inject text-halo-color directly on the places_country layer when overrides.country_label_halo is set. Halo width was already adjustable via Label Styles > Country.
[0.9.18] — 2026-04-28 (Fix Line Widths sliders silently no-op-ing on roads/boundaries)
The admin theme editor's Line Widths sliders had no visible effect on the preview, and dragging Boundaries to its minimum (0.2) still left a thin border. Both bugs traced to mapprstyle.html buildStyle().
Bug 1 — line-width scaler ignored expression arrays. The check if (typeof w === 'number') l.paint['line-width'] = w * lw[rk]; only handled plain numbers, but MapLibre styles use zoom-interpolated arrays like ["interpolate", ["linear"], ["zoom"], 12, 0.5, 18, 8]. Multiplier never applied.
Fix: replaced with a recursive scaleW() that handles plain numbers, interpolate, interpolate-hcl, and step expression shapes — same shape as ThemeMod's existing scaleVal helper used on the user-side map.
Bug 2 — boundaries slider min was 0.2. Even if scaling had worked, the slider couldn't reach 0. Lowered min:0.2 → min:0 so boundaries × 0 = 0-width line.
[0.9.17] — 2026-04-28 (Hotfix: Layer Opacity sliders apply in admin editor preview too)
v0.9.16 added Layer Opacity sliders that worked correctly on the user-side map but produced no visible change in the admin editor preview. Cause: the admin editor's preview iframe is mapprstyle.html, which builds its MapLibre style via its own buildStyle() — never calls into ThemeMod. The v0.9.16 application was added to ThemeMod only.
Fix: ported the same per-category fill-opacity override block into mapprstyle.html buildStyle(), placed right after the existing buildingOpacity application. Preview now reacts live to the sliders via the v0.9.12 _applyDiff() path.
[0.9.16] — 2026-04-28 (Layer Opacity sliders for landcover/parks/urban-green)
User noticed sampled hex values for landcover/parks/urban-green were rendering muted vs reference. Investigation via basemaps.layers() introspection: the protomaps base style applies opacity that no override can overcome — landcover fades to 0 at z≥7, landuse_park fades in only at z≥11, landuse_urban_green is always 0.7. Visible color is always a blend with earth underneath.
Solution: per-category fill-opacity override sliders on both surfaces.
Admin theme editor — new "Layer Opacity" section in FLAVOR_SCHEMA, type:'sliders' with stateKey:'layerOpacity'. _renderSliders generalized to read from EditorState[sec.stateKey] instead of hard-coded EditorState.lineWidths. EditorState gains layerOpacity field; toJSON/fromJSON/resetToBase/loadPreset handle it.
User-side map UI — new "Layer Opacity" divider in PanelContent customstyle with 3 slider rows under Line Widths. New LiveStyleUI.setLayerOpacity(cat, val) writes to __live__ theme's layerOpacity[cat] and re-applies.
ThemeMod.apply() applies ct.layerOpacity[cat] as fill-opacity to matching protomaps base layers (/^landcover$/, /^landuse_park$/, /^landuse_urban_green$/) when set. Absent values fall through to protomaps' zoom-interpolated defaults — backward compatible.
Plumbing: layerOpacity added to AppState loadJSON allow-list (both customThemes filter and PITCHMAPPR_THEMES merge).
[0.9.15] — 2026-04-28 (Fix Rules of Hooks crash on never-cached theme detail page)
Fresh import of "Dark Matter Amber" surfaced a generic "This page couldn't load" Next.js error on /admin/themes/<uuid>. Other (cached) themes loaded fine.
useDefaultThemeRefs() (added in v0.9.14) was called after the if (!theme) return ... early return in app/(admin)/admin/themes/[id]/page.tsx. For previously-viewed themes useShow returned cached data synchronously on first render so the early return never fired. A freshly-imported theme with no React Query cache hit early-return on first render (skipping the hook), then on second render the theme was defined and the hook ran — tripping React's "Rendered more hooks than during the previous render", caught by the page-level error boundary.
Fix: moved useDefaultThemeRefs() above the early return, alongside the existing useMemo. The theme?.id-guarded defaultRefs.get() lookup stayed where it was. The list page already had the hook in correct position; only the detail page was affected.
Also bundled: the staged dark-matter-yellow-roads.json theme JSON (imports as "Dark Matter Amber"), generated from the existing Geoapify source via the v0.9.11 generator.
[0.9.14] — 2026-04-28 (v0.9.13 follow-ups: focus dropdown reflection + DEFAULT badge)
Two issues surfaced during initial v0.9.13 use:
- Setting a focus-mode default applied correctly on map open, but the focus-theme dropdown on the user side stayed pointed at the literal first option ("Focus White") instead of the actually-applied theme. The regular dropdown worked because
AppState.settings.themereflects the applied theme; the focus dropdown was readingfocusMode.focusThemedirectly without falling through the resolver chain. - No visual indicator in admin views to show which themes are currently configured as defaults somewhere in the hierarchy. Disabling or deleting one without realizing it's serving as someone's default was a footgun.
Fixed — focus theme dropdown reflects resolved system default
PanelContent.js:890 focustheme() rendered the dropdown using focusMode.focusTheme || 'Focus White'. After v0.9.13 changed the AppState initial state to focusTheme: null (so the resolver could fill in), the bare || fell straight through to the literal 'Focus White' even when the system had resolved a different default.
Fixed by extending the chain to match the apply-time logic: focusMode.focusTheme || _systemDefaultFocusTheme || 'Focus White'. The dropdown now reflects what's actually rendering.
Added — DEFAULT badge + filter pill in admin themes views
New useDefaultThemeRefs hook fetches the four sources (system_settings + organizations/departments/groups default columns) and builds a Map keyed by theme UUID with { regular: string[], focus: string[] } of human-readable scope labels (System, Org: Acme, Dept: Engineering, Group: Cartography Team).
Renders a <DefaultBadge> pill on:
/admin/themesCard grid + Split list rows (yellowDEFAULT/DEFAULT · regular/DEFAULT · focuschip with hover-tooltip enumerating the levels)/admin/themes/[id]detail page header
Plus a new Defaults (n) filter pill that surfaces only themes configured as defaults somewhere — quick triage before disable/delete.
Files changed
pmppr-map/public/map-tool/lib/map-modules/PanelContent.js (focus dropdown chain) [+mirror]
pmppr-map/lib/use-default-theme-refs.ts NEW
pmppr-map/app/(admin)/admin/themes/page.tsx (DefaultBadge + Defaults filter)
pmppr-map/app/(admin)/admin/themes/[id]/page.tsx (DefaultBadge in header)
pmppr-map/public/map-tool/lib/map-modules/CFG.js (appVersion 0.9.14) [+mirror]
pmppr-map/public/map-tool/index.html (brand pill)
pitchmappr/dev-docs/CHANGELOG.md, HANDOFF.md, TODO.md (this entry + rolling state)
pitchmappr/dev-docs/spec/{PROJECT-SPEC,PROJECT-DATASHEET,TECH-SPEC,SERVER-MIGRATION-SPEC}.md (4 version headers)Verified end-to-end
- Set system default focus to "Focus Dark" via
/admin/settings/defaults - Open
/maps/new→AppState.settings._systemDefaultFocusTheme === 'Focus Dark'✓ PanelContent.focustheme()renders Focus Dark withselected✓/admin/themesshows yellowDEFAULT · regularpill on USGS Topographic andDEFAULT · FOCUSon Focus Dark ✓Defaults (2)filter pill restricts the grid to those two themes ✓
Migration
No DB schema changes — pure code.
[0.9.13] — 2026-04-28 (Admin-configurable default theme — bottom-wins hierarchy)
User asked for the system-wide default theme (applied on first map open and on user-side Reset) to be admin-configurable rather than hardcoded 'voyager'. Bottom-wins so each org/dept/group can override the global, with two independent settings (regular-mode default + focus-mode default). Especially needed for orgs with restricted theme access — they can now set a default that's actually inside their allowed theme set, removing the "open to a theme I can't see" footgun.
Added — schema + resolver (migration 019)
- 6 new nullable FK columns:
default_theme_id+default_focus_theme_idonorganizations,departments,groups. EachUUID REFERENCES themes(id) ON DELETE SET NULLso deleting a theme silently drops the reference rather than blocking. - New
system_settingstable (key/value JSONB store) for the global level. Admin-write, authenticated-read RLS. Seeded with two NULL placeholder rows (default_theme_id+default_focus_theme_id). user_default_theme(p_focus boolean) RETURNS uuid—STABLE SECURITY DEFINERresolver. Walksprofiles → groups → departments → organizationswithCOALESCE, falls through tosystem_settings. Returns NULL only when nothing is set anywhere; iframe runtime applies the bundled'voyager'/'Focus White'last-resort fallback in that case.
See ADR-065 for the design rationale + alternatives considered.
Added — admin UI
- New
/admin/settings/defaultspage — two dropdowns (regular + focus) for the global default. Direct upsert tosystem_settings. Listed in admin sidebar under Content as "Default themes". - New
<DefaultThemePanel>component mounted on org/dept/group detail pages. Two dropdowns; NULL = inherit;Inherit (X)hint resolves the parent-chain default name so admins can see what they're falling back to. - Refine
system_settingsresource registered inrefine-provider.tsx.
Added — iframe runtime + parent bridge
map-client.tsx(saved-map BRIDGE_READY) andapp/maps/new/page.tsx(new-map BRIDGE_READY) both calluser_default_theme(p_focus)for both modes during the parallel data fetch. Resolves UUIDs to theme NAMES (the iframe operates on names) against the user's accessible themes set.- New
DEFAULT_THEMESpostMessage payload{ regular, focus }. Inmap-client.tsxit's sent BEFOREPROJECT_INITso the saved-map path applies the system default on first paint. /maps/new sends empty-data PROJECT_INIT immediately and DEFAULT_THEMES arrives later — the bridge handler re-applies on receipt if no user choice was loaded. - New
PostMessageBridge.jsDEFAULT_THEMEShandler stores names onAppState.settings._systemDefaultTheme/_systemDefaultFocusThemeand triggers a late-arrival re-apply viaThemeMod.apply('custom:' + ...)when!AppState._themeIsUserChoice. /maps/newalso now sendsDB_THEMES_LISTso the iframe'scustomThemesknows about Private DB themes that may be the resolved default.
Changed — fallback chain replaces hardcoded 'voyager' / 'Focus White'
AppState.js:19initial state —theme: 'voyager'→theme: null.loadJSONline 361 chain:settings.theme || ('custom:' + settings._systemDefaultTheme) || 'voyager'.AppState.js:46initial focus state —focusTheme: 'Focus White'→focusTheme: null.AppState._themeIsUserChoiceflag — true only when the loaded blob carried an explicitsettings.theme. Bridge's late-arrival re-apply respects it so we don't override a user's saved choice with a later-resolved system default.LiveStyleUI.reset()regular + focus branches — fallback chain replaced.MapMod.resetMap()(the Reset Map button) — now appliesAppState.settings._systemDefaultThemefirst, falls back to'voyager'.UI.jsfocus-mode label resolution + restore-on-focus-exit — same fallback pattern.PanelContent.jsfocus-base-theme dropdown current value — same.
Files changed
pmppr-map/supabase/migrations/019_default_theme.sql NEW
pmppr-map/app/(admin)/admin/settings/defaults/page.tsx NEW (global defaults)
pmppr-map/components/admin/default-theme-panel.tsx NEW (per-org/dept/group)
pmppr-map/app/(admin)/admin/organizations/[id]/page.tsx (mount panel + system inherit hint)
pmppr-map/app/(admin)/admin/departments/[id]/page.tsx (mount + parent-chain resolver)
pmppr-map/app/(admin)/admin/groups/[id]/page.tsx (mount + parent-chain resolver)
pmppr-map/components/admin/refine-provider.tsx (system_settings resource)
pmppr-map/lib/nav-config.ts (Default themes sidebar entry)
pmppr-map/app/maps/[id]/map-client.tsx (RPC + DEFAULT_THEMES before PROJECT_INIT)
pmppr-map/app/maps/new/page.tsx (RPC + DB_THEMES_LIST + DEFAULT_THEMES)
pmppr-map/public/map-tool/lib/map-modules/AppState.js (theme:null, _themeIsUserChoice, fallback chain) [+mirror]
pmppr-map/public/map-tool/lib/map-modules/PostMessageBridge.js (DEFAULT_THEMES handler) [+mirror]
pmppr-map/public/map-tool/lib/map-modules/LiveStyleUI.js (5 fallback sites) [+mirror]
pmppr-map/public/map-tool/lib/map-modules/MapMod.js (Reset Map) [+mirror]
pmppr-map/public/map-tool/lib/map-modules/UI.js (focus theme + restore) [+mirror]
pmppr-map/public/map-tool/lib/map-modules/PanelContent.js (focus base theme dropdown) [+mirror]
pmppr-map/public/map-tool/lib/map-modules/CFG.js (appVersion 0.9.13) [+mirror]
pmppr-map/public/map-tool/index.html (brand pill v0.9.13)
pitchmappr/dev-docs/spec/DECISIONS-LOG.md (ADR-065)
pitchmappr/dev-docs/CHANGELOG.md (this entry)
pitchmappr/dev-docs/HANDOFF.md (rolling state)
pitchmappr/dev-docs/TODO.md (banner)
pitchmappr/dev-docs/spec/PROJECT-SPEC.md, PROJECT-DATASHEET.md,
TECH-SPEC.md, SERVER-MIGRATION-SPEC.md (4 version headers)End-to-end verification
Browser-tested on dev (production tile rendering will be visibly faster):
- Set system default to Toner Classic in
/admin/settings/defaults - Open
/maps/new - iframe
AppState.settings._systemDefaultTheme === 'Toner Classic'✓ - iframe
AppState.settings.theme === 'custom:Toner Classic'✓ - map style spec:
bg = #FFFFFF,water = #000000(Toner Classic colors) ✓
Migration application
Apply via Supabase SQL editor (per established pattern):
\i pmppr-map/supabase/migrations/019_default_theme.sqlIdempotent — ADD COLUMN IF NOT EXISTS, CREATE TABLE IF NOT EXISTS, CREATE OR REPLACE FUNCTION, ON CONFLICT DO NOTHING on the seed rows.
Known gaps (not blocking, scoped for follow-up)
- Admin UI doesn't filter the default-theme dropdown by
theme_access_mode. An admin could pick a theme their users can't access. Mitigated by theInherit (X)hint surfacing the chosen value down the chain. Future: filter by access mode. - The /maps/new fast-path opens with the bundled
'voyager'fallback for ~1 frame before the bridge re-applies the resolved system default. Imperceptible on production (RPC is fast); visible only on slow connections. Could be eliminated by gating PROJECT_INIT on the RPC completion, at the cost of slightly slower initial render for users with no system default configured.
[0.9.12] — 2026-04-28 (Theme editor live preview + Reset semantics fix)
User reported the theme editor's preview only updated in real time for boundary thickness — every other control required Save before changes appeared, blocking the manual fixup workflow for the 30 Private themes. Investigation reproduced the bug: EditorState.setColor correctly updated the style spec, but MapLibre's canvas didn't repaint. Console showed Unable to perform style diff: Style is not done loading. Rebuilding the style from scratch. — repeated setStyle() calls were interrupting each other.
Plus: the editor's Reset button reset to base flavor defaults (clearing every override) rather than reverting to the theme's last-saved DB state, which is what users expect when they want to discard in-progress edits.
Fixed — live preview repaint bug
Replaced MapPreview.refresh()'s blanket setStyle() call with a diff-and-apply path. MapPreview._applyDiff() builds the new style, walks the layer roster against the live style, and calls setPaintProperty/setLayoutProperty for each prop that changed. No layer rebuild for paint-only changes — instant repaint, no flicker, no race.
Falls back to setStyle() only when the change is structural and can't be applied surgically:
- Sprite URL change (base flavor switched — different icon set)
- Glyphs URL change (font provider switched)
- Layer count differs (Protomaps generated a different roster, e.g. some language changes)
- Unknown layer ID encountered
The 60ms debounce on rapid slider input is preserved.
mapprstyle.html — MapPreview.refresh() + new _applyDiff() method.
Fixed — editor Reset reverts to last-saved DB state
The editor wrapper at /admin/themes/editor now sends LOAD_THEME with existingTheme.data (the DB row's saved theme data, kept current via useShow's refetch-after-save) instead of RESET_THEME (which clears every override). For new themes (no editId, no DB row yet), RESET_THEME is preserved as a fallback since there's no saved state to revert to.
app/(admin)/admin/themes/editor/page.tsx — resetTheme().
Known gap — marker / shield / focus-city / focus-color settings have no live preview
These four control families (markerSettings ~10 controls, shieldSettings ~3, focusCitySettings ~12, focusColors ~4 — at mapprstyle.html:1817-2066) have nothing to live-preview against in the editor today. The preview iframe renders base map tiles only — no markers, no focus-mode mask, no shield labels — so even if those handlers called MapPreview.refresh() it would be a no-op visually. Out of scope for this release; will be addressed when the editor preview is extended to render test markers + a focus-mode toggle.
Known gap — system-wide default theme is hardcoded 'voyager'
AppState.js:19 (initial map state) and LiveStyleUI.js:219-250 (user-side Reset) both hardcode 'voyager' as the default theme. The schema has a theme_assignments.access_level = 'default' value defined-but-unused. Needs an admin-configurable default theme mechanism (new migration adding default_theme_id to orgs/depts/groups, admin UI, init resolver). Scoped for the next session as a separate ADR + migration.
Files changed
pmppr-map/public/map-tool/mapprstyle.html (refresh + _applyDiff)
pmppr-map/public/map-tool/index.html (version pill v0.9.12)
pmppr-map/public/map-tool/lib/map-modules/CFG.js (appVersion v0.9.12)
pmppr-map/lib/map-modules/CFG.js (mirror)
pmppr-map/app/(admin)/admin/themes/editor/page.tsx (resetTheme)
pitchmappr/dev-docs/CHANGELOG.md (this entry)
pitchmappr/dev-docs/HANDOFF.md (rolling state)
pitchmappr/dev-docs/TODO.md (banner)[0.9.11] — 2026-04-28 (Theme fidelity fix — landcover depth + grey roads + earth lightening across 19 imported themes)
User reviewed the v0.9.9 imported Private themes against their MapTiler/Geoapify reference renders and noted Base looked flat (no green forest depth) and roads were the wrong color (amber where MapTiler shows grey). The v0.9.9 generator had two bugs that affected most of the 19 generated themes: missing nested landcover{} extraction (so broad terrain greens weren't applied), and naive default base flavors that didn't capture warmth.
Fixed — generator bugs
landcover{}nested override now extracted from sourcestyle.json. Each theme's "Forest", "Wood", "Grass", "Scrub", "Sand", "Glacier", "Residential" / equivalent layers are walked and aggregated into a 7-key Protomaps landcover object. This is what produces broad terrain coloring (vs. flatwood_a/park_awhich only color specific landuse polygons).- All 7 landcover keys always present. Initial fix shipped only the keys with extracted source colors (e.g. Dark Matter had only 3: forest/glacier/urban_area), which caused MapLibre to throw
layers[2].paint.fill-color[2]: 'undefined' value invalidand skip rendering the entire map. Generator now backfills any missing keys with a sensible fallback (urban_area → earth → first available). - Auto-pick base flavor from source bg color. Light cream + warm tints →
light(so Protomaps' built-in warm sprites/landcover defaults inherit), very neutral very-bright →white, mid-bright neutral/cool →grayscale, dark →darkorblack. Previously every theme was hard-coded towhiteregardless of source warmth. - Lighten earth toward bg. When the source's Residential layer fill is much darker than the background, blend earth halfway toward bg so the rendered "ground" feels like the lighter cream of the reference (vs. the strict dark Residential value alone). Skipped for dark themes which look correct as-extracted.
- Road casings. For light/white/grayscale themes that have
highway/major/minorroad fills, generator now also writes the matching*_casingkeys at 22% darkness, giving roads visible definition against the lighter ground. Skipped for dark themes (their fills are already dim against dark earth).
Changed — Base theme (manual fix preserved separately)
The Base theme was hand-fixed earlier in the session before the generator was rewritten. Its DB row reflects that manual v2 (base: 'light', deeper landcover greens, grey road network with explicit casings, lightened earth). The bulk update preserves the manual Base — the generator's auto-output for base-v4.json is overwritten with content fetched from DB so file/DB stay in sync. All other 18 generated themes UPDATED from regenerated JSONs.
Result — 19 themes refreshed
✓ Base (preserved DB v2 — manual fix from earlier)
OSM Bright base=light overrides=33 landcover=7 casings=6
OSM Bright Grey base=white overrides=33 landcover=7 casings=6
OSM Bright Smooth base=light overrides=33 landcover=7 casings=6
Klokantech Basic base=light overrides=25 landcover=7 casings=5
OSM Liberty base=white overrides=23 landcover=7 casings=4
MapTiler 3D base=light overrides=22 landcover=7 casings=5
Positron base=white overrides=27 landcover=7 casings=5
Dark Matter base=black overrides=22 landcover=7 casings=0
Dark Matter Slate base=black overrides=22 landcover=7 casings=0
Dark Matter Plum base=black overrides=22 landcover=7 casings=0
Dark Matter Violet base=black overrides=22 landcover=7 casings=0
Dataviz Light base=white overrides=22 landcover=7 casings=2
Dataviz Dark base=black overrides=17 landcover=7 casings=0
OpenStreetMap base=light overrides=17 landcover=7 casings=0
Streets base=light overrides=19 landcover=7 casings=2
Streets Dark base=black overrides=20 landcover=7 casings=0
Toner Classic base=white overrides= 5 landcover=0 casings=0 (ultra-minimal, intentional)
Voyager base=light overrides=11 landcover=7 casings=0Casing count = 0 for dark themes by design (dark fills don't need outline definition). Toner Classic has 0 landcover by design (pure B&W, all landcover should match earth = white).
Verified end-to-end via Chrome MCP
- Base: cream paper + sage forest greens + grey road network at z=13 NYC ✓
- OSM Bright: classic yellow road network + cream paper + Central Park sage at z=13 ✓
- Dark Matter: dark canvas at z=13 NYC (rendering error fixed by 7-key landcover backfill) ✓
- Voyager: warm cream broad ground at z=5 with city labels visible ✓
Files changed
pmppr-map/themes-private-review/ (19 .json files updated; +384 / −135 lines)
base-v4.json (synced from DB Base v2)
+ 18 regenerated files for the other external palettes
pmppr-map/public/map-tool/index.html (version pill v0.9.11)
pmppr-map/public/map-tool/lib/map-modules/CFG.js (appVersion v0.9.11 + mirror)
pitchmappr/dev-docs/CHANGELOG.md (this entry)
pitchmappr/dev-docs/HANDOFF.md (rolling state)
pitchmappr/dev-docs/TODO.md (banner)
pitchmappr/dev-docs/spec/{PROJECT-SPEC,PROJECT-DATASHEET,TECH-SPEC,SERVER-MIGRATION-SPEC}.md (headers)DB updates (data, not code — not in git)
18 production DB rows in themes table updated with new data field via service-role client. Visibility unchanged (assigned). One-off Node script /tmp/bulk_update_themes.mjs (machine-local).
Lesson
The v0.9.9 generator missed the nested-vs-flat landuse distinction and the all-7-keys requirement. Both are documented in the pitchmappr theme reference skill but my regex extractor wasn't writing into nested object paths. v0.9.11 generator fixes both AND adds defensive backfill so any future generated theme has all 7 landcover keys regardless of source coverage.
[0.9.10] — 2026-04-27 (Theme bundle cleanup — DB-only for v0.9.9 themes)
Follow-up to v0.9.9. The 5 v0.9.9 from-scratch themes (USGS Topographic, Editorial, Vintage Atlas, Data Dashboard, Warm Dark) shipped in BOTH the bundle (as _preset: true .js files) AND the DB (as visibility='assigned' rows after the bulk-import). Per ADR-062, DB is source of truth and bundle is a cold-start fallback only — for themes that won't be edited via admin and stay in System forever, bundle redundancy is the standard pattern. But for themes in active review (Private), bundle drift creates two real footguns: (1) re-running seed-themes.mjs could re-create System rows from frozen bundle values; (2) future contributors might assume bundle is the source while DB has diverged.
User direction: keep things simple. DB is the single source of truth for these 5 themes during the review phase.
Removed — bundle entries for the 5 v0.9.9 themes
pmppr-map/public/map-tool/themes/usgs-topographic.js(deleted)pmppr-map/public/map-tool/themes/editorial.js(deleted)pmppr-map/public/map-tool/themes/vintage-atlas.js(deleted)pmppr-map/public/map-tool/themes/data-dashboard.js(deleted)pmppr-map/public/map-tool/themes/warm-dark.js(deleted)- 5 entries removed from
pmppr-map/public/map-tool/themes/manifest.json - 5
<script>tags removed frompmppr-map/public/map-tool/index.html
Preserved — DB rows for the 5 v0.9.9 themes
The 5 DB rows from the v0.9.9 bulk import remain untouched (visibility='assigned', full theme JSON in data column). The themes are still reviewable via /admin/themes/ (Private filter), still loadable in the admin theme editor, and can be downloaded as JSON from /admin/themes/[id] via "Download JSON" if needed for re-bundling later.
The 24 external-palette JSON files in pmppr-map/themes-private-review/ are unchanged.
Result
Bundle theme count: 46 → 41 (back to the v0.9.7 + v0.9.8 baseline before v0.9.9). DB theme count unchanged at 76 (the 5 themes still exist as Private DB rows; admin sees them in /admin/themes/ filtered to "Private (assignment-only)"; users don't see them until they're promoted to System).
To re-add to bundle later (if user promotes a theme to System)
- Extract the JSON from the DB row (
/admin/themes/[id]→ "Download JSON" button) - Wrap in IIFE template per
pmppr-map/themes-private-review/README.md - Drop the
.jsintopmppr-map/public/map-tool/themes/ - Append filename to
manifest.json+<script>tag inindex.html - (Optional) Run
seed-themes.mjs— for themes already promoted tovisibility='system'the upsert will refresh the DB row to match bundle (idempotent; same values either direction)
Versions
pmppr-map/public/map-tool/index.htmlbrand pill v0.9.9 → v0.9.10pmppr-map/public/map-tool/lib/map-modules/CFG.js(+ mirror)appVersionv0.9.9 → v0.9.10pitchmappr/dev-docs/spec/{PROJECT-SPEC,PROJECT-DATASHEET,TECH-SPEC,SERVER-MIGRATION-SPEC}.mdheaders v0.9.9 → v0.9.10
Files changed
pmppr-map/
public/map-tool/index.html (version pill + 5 <script> tags removed)
public/map-tool/themes/manifest.json (5 entries removed)
public/map-tool/themes/usgs-topographic.js (DELETED)
public/map-tool/themes/editorial.js (DELETED)
public/map-tool/themes/vintage-atlas.js (DELETED)
public/map-tool/themes/data-dashboard.js (DELETED)
public/map-tool/themes/warm-dark.js (DELETED)
public/map-tool/lib/map-modules/CFG.js (appVersion 0.9.9 → 0.9.10)
lib/map-modules/CFG.js (mirror)
pitchmappr/
dev-docs/CHANGELOG.md (this entry)
dev-docs/HANDOFF.md (rolling state)
dev-docs/TODO.md (banner)
dev-docs/spec/PROJECT-SPEC.md, PROJECT-DATASHEET.md,
TECH-SPEC.md, SERVER-MIGRATION-SPEC.md (version headers)[0.9.9] — 2026-04-27 (Theme expansion — 5 from-scratch designs shipped; 24 external palettes staged for admin import)
Closes F22 (Phase 3 — design new themes). Two-phase delivery:
- 5 from-scratch themes shipped as System (in bundle, available to all users): USGS Topographic, Editorial, Vintage Atlas, Data Dashboard, Warm Dark.
- 24 external palettes staged as private-review JSON files in
pmppr-map/themes-private-review/, for admin import via/admin/themes/createwithVisibility = "Private (assignment-only)". Admin reviews each, promotes kept ones to System.
Added — 5 system themes (in bundle)
- USGS Topographic (
themes/usgs-topographic.js) — cream paper background, sage greens for parks and wood, USGS-red highways, brown UPPERCASE place labels, dashed admin boundaries. Classic American outdoor map aesthetic. base=light, ~130 lines. - Editorial (
themes/editorial.js) — muted neutral basemap with bold magenta highways (#E63E80), teal water (#3AA6B0), amber major roads, charcoal road outlines. Designed so map context recedes and accent road network + data overlay pop. NYT/FT graphics-dept feel. base=white, ~140 lines. - Vintage Atlas (
themes/vintage-atlas.js) — sepia parchment background, antique aqua water, brown ink road network, olive sage parks, dashed boundaries viaboundaryDash: 'dashed'. 1920s reference-atlas style; deeper saturation than Warm Parchment. base=light, ~140 lines. - Data Dashboard (
themes/data-dashboard.js) — pure white land + barely-visible ghost grey street grid, no buildings, no POIs, only major place labels. Designed so data overlays (markers, choropleths, heat maps) are the only color the eye picks up. Cleaner than Slate Grey, Backdrop, or Flat Grey. base=white, ~150 lines incl. layerVisibility tweaks. - Warm Dark (
themes/warm-dark.js) — deep cocoa background, luminous amber/orange road network, hidden buildings/POIs. Distinct from Dark Bronze (cool brown) and Espresso (low-chroma monochrome brown) — Warm Dark is hot and orange-saturated. base=dark, ~140 lines.
All 5 verified end-to-end via Chrome MCP at localhost:3001/admin/themes/editor: LOAD_PRESET postMessage applies the theme, base flavor radio updates, sidebar layer counts match layerVisibility, map preview re-renders with new colors. Verified at z=13 over Manhattan.
Added — 24 private-review staging files
In pmppr-map/themes-private-review/:
- 5 hand-authored designs (same files as the 5 above, copied as standalone
.jsonfor re-import / archival):stencil.json,whisper.json,trailhead.json,espresso.json,amber-vein.json. - 11 Geoapify reference palettes (auto-extracted from each style's
style.json):osm-bright,osm-bright-grey,osm-bright-smooth,klokantech-basic,osm-liberty,maptiler-3d,positron,dark-matter,dark-matter-dark-grey,dark-matter-dark-purple,dark-matter-purple-roads. - 8 MapTiler reference palettes (same approach):
base-v4,dataviz-v4,dataviz-dark,openstreetmap,streets-v4,streets-dark,toner,voyager.
Each file is a minimal valid pitchmappr theme JSON: name, base, overrides{} populated only with successfully-extracted source colors. Missing keys fall through to base flavor defaults at runtime — that's why toner.json works fine with only 5 overrides (the rest are inherited from the white flavor).
Excluded from the staging set: winter-v4, positron-blue, positron-red (user cut), osm-carto (Geoapify raster-only, no vector overrides), satellite (MapTiler raster imagery), Geoapify toner (auth-locked 401).
The themes-private-review/ folder includes a README.md documenting all 24 entries plus the import flow.
Tooling — admin import flow verified end-to-end
The existing /admin/themes/create import UI (theme JSON file upload + Visibility dropdown) was tested in browser via Chrome MCP. One theme (stencil.json) was imported with Visibility = "Private (assignment-only)":
- DB row created with UUID
- Visibility correctly set to
assigned - Theme JSON preserved (overrides, name, description)
- Map preview rendered the imported theme correctly
- Layer toggles loaded from theme
layerVisibility
This confirmed the import flow works for all 24 staged JSONs without changes to the import UI.
(One gotcha: Chrome MCP's file_upload returned "Not allowed" against this React form's hidden file input. Worked around via JS DataTransfer + dispatched change event. Fine for tooling — admins use the UI directly which has no such issue.)
Versions
pmppr-map/public/map-tool/index.htmlbrand pill v0.9.8 → v0.9.9pmppr-map/public/map-tool/lib/map-modules/CFG.js(+lib/map-modules/CFG.jsmirror)appVersionv0.9.8 → v0.9.9pitchmappr/dev-docs/spec/PROJECT-SPEC.md,PROJECT-DATASHEET.md,TECH-SPEC.md,SERVER-MIGRATION-SPEC.mdheaders v0.9.8 / 2026-04-26 → v0.9.9 / 2026-04-27
Files changed
pmppr-map/
public/map-tool/index.html (version pill + 5 <script> tags)
public/map-tool/themes/manifest.json (5 entries)
public/map-tool/themes/usgs-topographic.js (NEW)
public/map-tool/themes/editorial.js (NEW)
public/map-tool/themes/vintage-atlas.js (NEW)
public/map-tool/themes/data-dashboard.js (NEW)
public/map-tool/themes/warm-dark.js (NEW)
public/map-tool/lib/map-modules/CFG.js (appVersion)
lib/map-modules/CFG.js (mirror)
themes-private-review/ (NEW DIR)
README.md, stencil.json, whisper.json, trailhead.json,
espresso.json, amber-vein.json,
osm-bright.json, osm-bright-grey.json, osm-bright-smooth.json,
klokantech-basic.json, osm-liberty.json, maptiler-3d.json,
positron.json, dark-matter.json, dark-matter-dark-grey.json,
dark-matter-dark-purple.json, dark-matter-purple-roads.json,
base-v4.json, dataviz-v4.json, dataviz-dark.json,
openstreetmap.json, streets-v4.json, streets-dark.json,
toner.json, voyager.json
pitchmappr/
dev-docs/CHANGELOG.md (this entry)
dev-docs/HANDOFF.md (rolling state)
dev-docs/TODO.md (F22 closed; banner refreshed)
dev-docs/spec/FEATURE-CATALOG.md (§12.9)
dev-docs/spec/PROJECT-SPEC.md, PROJECT-DATASHEET.md,
TECH-SPEC.md, SERVER-MIGRATION-SPEC.md (version headers)
dev-docs/spec/handoffs/2026-04-27-v099-theme-additions.md (NEW dated archive)Closed from queue
- F22 — Phase 3: Design and add new themes. Shipped 5 from-scratch (USGS Topographic, Editorial, Vintage Atlas, Data Dashboard, Warm Dark) as System; staged 19 more external-palette references plus 5 hand-authored copies as private-review JSON imports.
Still queued (no change from v0.9.8)
- F23 — Phase 4: Organize theme dropdown by Color vs B&W (1–2h). With 46 system themes now, this UX will help users.
- F25 — Saved-map theme key drift after rename (4–6h, low priority)
- B30/B31 — likely closed by v0.9.7 DB-driven refresh; needs user verification
- B35 ⚠️ — country-focus rendering regression (carried from v0.9.6, live in prod)
[0.9.8] — 2026-04-26 (mapprstyle.html consolidated as admin-iframe-only — standalone editor surface deprecated, F24 absorbed)
Changed — Theme Editor Architecture
- mapprstyle.html is now exclusively the admin theme editor's iframe payload. The standalone editor surface (separate URL, file:// support, own chrome) is deprecated. Per the v0.9.7 design, the live admin editor at
/admin/themes/editor(Next.js wrapper) provides the canonical control surface — its toolbar handles theme name, type, visibility, base flavor, description, save/delete, plus Load preset, Location, Reset over postMessage. The iframe's own chrome was duplicate/redundant and has been removed.
Removed — mapprstyle.html standalone chrome
- Entire
<nav>block (brand mark, "pitchmapprstyle" wordmark, version pill, mode toggle (Regular/Focus buttons), Load preset dropdown, Location dropdown, Import / Export .js / Reset buttons, separators). - Hidden
<input type="file" id="importInput">(only used by the now-removed Import button). - All nav-only CSS (
#nav,.brand,.brand-mark,#brandName,.bver,.nav-sep,.nav-select,.nbtn,.nbtn-primary,.mode-toggle,.mode-btn,--nav-htoken). body.embed-adminCSS overrides — no longer scoped, the embed-admin styling is now the only styling..map-inforepositioned bottom-right by default;.theme-name-rowhidden by default (the wrapper's name field is canonical).?embed=adminURL-param branch — admin mode is always-on now, the parameter is no-op'd. Wrapper still passes it for backward compatibility but the iframe ignores it.?type=regular|focusinitial-mode hint preserved.ExportImport.importFile()andExportImport._onFile()— both only callable from the removed Import button. (exportJS()retained — small, no harm.)_populatePresets()function and its 3 call sites (insetMode,fromJSON, init) — preset dropdown is gone; wrapper drives preset loading viaLOAD_PRESETpostMessage.- Static
<script src="themes/...">fallback block (24 entries, including 7 dead 404 references — slate-finance, warm-presentation, dark-boardroom, corporate-blue, monochrome-ink, corporate-light-blue, flat-design — F24 absorbed). Thethemes/manifest.jsonsynchronous loader is now the only theme-loading path; works fine in the admin iframe (always HTTP). The loader's HTTP/dedup logic was simplified (no static block to dedup against). - File:// support — was the original justification for the static script block. Admin context is always HTTP; file:// was vestigial.
Net diff
pmppr-map/public/map-tool/mapprstyle.html: 2577 → 2347 lines (−230, −9%); CSS −83 lines; HTML −37 lines; JS −88 lines (orphaned helpers + comments + the static script block); 56 sync-surface keyword hits intact.
Preserved (verified end-to-end via Chrome MCP against localhost:3001/admin/themes/editor)
FLAVOR_SCHEMA— drives sidebar rendering ANDlayerVisibilitydefaults; untouched.- All postMessage handlers —
LOAD_THEME(with v0.9.7 defaults-merge),SET_MODE,SET_NAME,LOAD_PRESET,JUMP_LOCATION,RESET_THEME,GET_THEME→THEME_DATA,MAPPRSTYLE_READY(init + every mode change, payload includes{ presets, locations, mode }). EditorState.toJSON()/fromJSON()— wrapper's DB save/load depends on this exact JSON shape.SidebarUI.render()— full panel rendering (Base Style, 12 color sections, Line Widths, Label Styles, Fonts, Language, Marker Color Scheme, Marker & Label Defaults, Highway Shield Defaults; Focus mode adds Focus Colors + Focus City Markers & Labels and removes Base Style — 20↔21 sections).ThemeMod.js,preview.html, detail page — not touched.
Verification (Chrome MCP, localhost:3001)
- 41 themes loaded from manifest.json (same as before), 30 presets in
MAPPRSTYLE_READY(mode-filtered). SET_MODE focus/regularround-trip clean (20 → 21 → 20 sidebar sections).LOAD_THEMEwithname=Probe2, base=dark, overrides={earth:#FF00FF, water:#00FFFF}and nolayerVisibility→ applies correctly AND v0.9.7 defaults-merge fires:label_states=false,pois_all=false,rlabel_oneway=falsein resulting state. Defaults-merge fix not regressed.GET_THEMEround-trips full state.RESET_THEMEworks. Zero console errors during the entire test sequence.
Files changed
pmppr-map/public/map-tool/mapprstyle.html(the strip)pmppr-map/public/map-tool/index.html(version pill v0.9.7 → v0.9.8)pitchmappr/dev-docs/spec/PROJECT-SPEC.md,PROJECT-DATASHEET.md,TECH-SPEC.md,SERVER-MIGRATION-SPEC.md(version headers v0.9.7 → v0.9.8 / 2026-04-26)pitchmappr/dev-docs/CHANGELOG.md(this entry)pitchmappr/dev-docs/HANDOFF.md(rolling state)pitchmappr/dev-docs/spec/FEATURE-CATALOG.md(section 12.8)pitchmappr/dev-docs/TODO.md(F24 closed; mapprstyle-strip logged)pitchmappr/dev-docs/spec/handoffs/2026-04-26-mapprstyle-admin-only-strip.md(dated archive)
Closed from queue
- F24 —
mapprstyle.html404 cleanup (dead<script src="themes/...">references). Absorbed by the static-fallback removal — the entire block is gone, dead refs included.
Still queued (no change from v0.9.7)
- F22 — Phase 3: Design and add new themes (4–12h)
- F23 — Phase 4: Organize theme dropdown by Color vs B&W (1–2h)
- F25 — Saved-map theme key drift after rename (4–6h, low priority)
- B30/B31 — likely closed by v0.9.7's DB-driven refresh; need user verification
- B35 ⚠️ — country-focus rendering regression (carried from v0.9.6)
[0.9.7] — 2026-04-25 (Theme System Overhaul — DB as source of truth, enable/disable + delete, per-org access mode, default base flavors, full-stack toggle sync)
Added — Database
- Migration 017
017_theme_enable.sql— addsthemes.is_enabled boolean NOT NULL DEFAULT true+idx_themes_is_enabled. Global kill-switch orthogonal to visibility/assignments. Application-layer enforcement (RLS unchanged) so admin views still see disabled themes for re-enable. - Migration 018
018_theme_access_mode.sql— wirestheme_access_modeinto themes RLS so'exclusive'actually restricts. (1) Addsgroups.theme_access_mode(didn't exist before — onlyorganizationsanddepartmentshad it). (2) Createsuser_theme_access_mode()SECURITY DEFINER STABLE returning the calling user's resolved mode with bottom-wins precedence (group → dept → org → 'open'). (3) Replacesusers_see_authorized_themesSELECT policy with mode-gated logic —'exclusive'users see onlyvisibility='assigned'themes with matchingtheme_assignmentsrow;'open'users keep the prior visibility ladder.admins_see_all_themespolicy untouched. See ADR-063. - One-off SQL seeds 5 default base flavors (Default Light, White, Grayscale, Dark, Black) into
themesasvisibility='system'withdata._isBaseFlavor: true. Idempotent viaWHERE NOT EXISTS. See ADR-064.
Added — Admin Theme Management
- Enable / Disable (slice 1) — 3-dot menu on every theme card / split-list row with Disable+Confirm flow. Detail page header gets an Enabled/Disabled chip (red when disabled). Disabled themes render at 55% opacity with a "DISABLED" badge. New "Disabled (n)" filter pill on
/admin/themes. Bulk-listed themes still findable; one click re-enables. - Delete (slice 1) — hard delete with confirm modal.
theme_assignmentscascades. System themes get a heads-up that re-runningnode scripts/seed-themes.mjswill resurrect them from the matching.jsfile. Available from list 3-dot menu, detail page header, and editor toolbar. - Org / Dept / Group theme access (slice 2) — new component
components/admin/target-themes-panel.tsxmounted on/admin/organizations/[id],/admin/departments/[id],/admin/groups/[id]. Mode dropdown (Inherit / Open / Exclusive — Inherit hidden on org since no parent), warning banner when in Exclusive mode, sectioned theme grid (Private / System / Shared) with toggle + access-level dropdown (available/default/locked) per row. Search filter. Org/dept/group settings pages are now the canonical edit surface for assignments. - Read-only assignment mirror on theme detail page —
/admin/themes/[id]AssignmentsPanel flipped from edit to display. Each row links to the assigned org/dept/group's settings page with an "edit →" affordance. Yellow banner appears when visibility ≠ Private explaining assignments aren't enforced. - Visibility vocabulary rename — UI labels updated across detail page, editor, and import flow.
assigned→ "Private (assignment-only)" (was "Assigned");private→ "Personal (creator-only)" (was "Private"). DB enum values unchanged. - Theme list section grouping — replaces "Regular Themes / Focus Themes / Shared Custom Themes" with: System · Base Flavors / Preset · Regular / Preset · Focus / Private (assignment-only) / Shared / Personal (creator-only). New filter pills: Base Flavors (n), Private (n), Shared (n), Personal (n) — all only appear when count > 0.
- Save as new… button on
/admin/themes/editorwhen editing an existing theme — forks the current state into a new row, original untouched. Lands in edit mode for the new row so subsequent tweaks save in place.
Added — User-Side Theme Filtering
THEME_ACCESS_LISTpostMessage —app/maps/[id]/map-client.tsxqueries the user's accessible theme names + resolved access mode (viauser_theme_access_mode()RPC) and ships them to the iframe on map load. Iframe filterscustomThemesagainst the allowed Set. Admin / owner roles bypass at the application layer (sendsallowed: nullandmode: 'open') so privileged users always see everything regardless of where their account sits in the hierarchy.DB_THEMES_LISTpostMessage — also ships full DB theme rows (id, name, theme_type, visibility, data). Iframe replaces bundle-origin entries (_preset+_isBaseFlavor) with DB versions on receipt, so admin renames + layer-visibility edits + color tweaks reach the user dropdown without a bundle redeploy. Bundle becomes a cold-start fallback only. See ADR-062.- Exclusive mode hides raw bases — when the user's resolved access mode is
'exclusive', the iframe also hides the 5 default base flavors (in addition to filtering customThemes against the allowed list). Without this they'd be a back-door bypass since they're loaded from the bundle, not the DB.
Added — Default Base Flavors as DB Themes (ADR-064)
- 5 new bundle files
public/map-tool/themes/default-{light,white,grayscale,dark,black}.jspush entries ontowindow.PITCHMAPPR_THEMESwith_isBaseFlavor: true+ emptyoverrides. Added tomanifest.jsonandindex.html<script>tags at the top so they sort first. Bundle theme count: 36 → 41. PanelContent.basestyle()no longer hardcodes the 5 raw<option>values. The dropdown renders entirely fromcustomThemes, base flavors first (no ★), divider, then 36 starred presets.init.jspropagates_isBaseFlavorflag from bundle into customThemes entries.AppState.loadJSONcarries_isBaseFlavorthrough saved-map state so flag survives reloads.PostMessageBridge.jsCUSTOM_THEMES_LISThandler also carries the flag through.ThemeMod._legacyAliasmap: legacytheme: 'voyager' | 'positron' | 'light' | 'dark' | 'black'values on saved maps alias to the correspondingcustom:Default …keys so older maps load + render correctly with the right dropdown selection.- Editor toolbar gets a chip-style Base Flavor toggle to mark/unmark any theme. Detail page header shows a ★ Base Flavor badge in place of the regular/focus chip when applicable.
Added — Theme Editor Wrapper Polish
?embed=adminmode inmapprstyle.html— strips the iframe's duplicate chrome (entire nav: brand, mode toggle, Import/Export, Load preset, Reset, Location). The wrapper provides every control via its toolbar. CSS hides the redundant.theme-name-rowin the sidebar so admins don't edit two name fields with conflicting save behavior.- Single-row toolbar on
/admin/themes/editor: ← Themes · Load preset… · [name] · Type · Visibility · Base Flavor · Description ✓ · Location… · Reset · Save · Save as new… · Delete. "Load preset…" lists DB themes (admin's view) — selecting navigates to?id=<id>so the editor enters edit-in-place mode. Compact inline description row (max-width 480 px, char counter) expands beneath the toolbar. - New postMessages: parent → iframe
SET_MODE,SET_NAME,LOAD_PRESET,JUMP_LOCATION,RESET_THEME. iframe → parentMAPPRSTYLE_READYpayload now includes{ presets, locations, mode }so the wrapper populates its dropdowns from the iframe's actual data. - Map-info overlay repositioned bottom-right over the map (was overlapping the sidebar's Line Widths section).
left: auto !importantto prevent stretching when both anchors are set. - Toast on save — bottom-right, auto-dismisses (2.5 s success / 5 s error). Replaces the cramped status text under the side panel.
Fixed — Theme System Sync (Detail Page ↔ Editor ↔ User Iframe ↔ Preview)
These four surfaces had drifted on layerVisibility vocabulary, default state, and key vocabulary. Brought into lockstep:
- Vocabulary alignment: detail page now writes the same fine-grained keys that mapprstyle.html writes (
label_cities,label_states,rlabel_*,wlabel_*, etc.). Friendly "Road" / "Water" toggles fan out to all relevant sub-keys. Legacy coarse keys (cityLabels,stateLabels,countryLabels,roadLabels,waterLabels) still recognized when reading saved themes; cleared when the toggle flips on so themes self-heal. - Default state map —
DEFAULT_VISIBILITY(detail page),_defaultToggles(ThemeMod),DEFAULT_OFF_KEYS(preview.html), andFLAVOR_SCHEMAdefaultOffflags (mapprstyle, applied via merge-on-LOAD_THEME) all agree on which keys default OFF:state_labels,nbhood_labels,major_rd_labels,minor_rd_labels,oneway,address, all water labels,pois,buildings,rail. - Toggle flip semantics on detail page — default-aware. Default-OFF toggle clicked to turn ON writes explicit
true(overrides default); back-off deletes the key (returns to default). Same for default-ON keys in reverse. Previously default-OFF toggles silently no-op'd because the click handler treated missing keys as ON. ThemeMod._visKeyToTogglesexpansion — added 22 new keys covering the full mapprstyle vocabulary:terrain_bg,terrain_landcover,landuse_parks/hospital/industrial/school/pedestrian/beach/airport/zoo,water_bodies/rivers/streams,struct_piers,roads_other/minor/links/major/highway/rail,bounds_country/regional. Without this, edits like "turn off Background & Earth" silently dropped at apply time.preview.htmlvisMapparallel expansion — same 22 keys +DEFAULT_OFF_KEYSlist so admin's detail-page preview reflects edits identically. Previously the simplifiedbuildStyleignoredlayerVisibilityentirely.mapprstyle.htmlLOAD_THEMEdefaults merge — was wiping its owndefaultOffinitial state when the saved theme had nolayerVisibility. Now mergesFLAVOR_SCHEMAdefaults under loaded values so missing keys correctly resolve to OFF for default-off keys (state, neighborhoods, lakes, waterways, islands, rlabel_oneway, etc.).AppState.loadJSONbundle merge — was wipingcustomThemeswith the saved snapshot, which dropped any bundle additions made after the map was last saved. Now mergesPITCHMAPPR_THEMESentries whose names aren't in the snapshot. Critical for the 5 default flavors to appear when opening older maps.- Hooks order error on
/admin/themes/[id]—useMemowas added after theif (!theme) return …early return. Moved above so hooks always run unconditionally. _buildFlavorlegacy alias bug — was routingct.basethrough_flavorMapfor custom themes;_flavorMap['light'] = 'grayscale'mistranslatedbase: 'light'into the grayscale flavor. Fixed:ct.baseis already a Protomaps flavor name and is used directly.theme_assignments.assigned_byNOT NULL —TargetThemesPanelinsert now stampscurrentUserIdfromauth.getUser()so assignments don't fail the constraint. Adds an audit trail of who assigned what.- "Imported" name fallback in iframe — removed; the
EditorState.namefallback was confusing for DB themes whosedataJSON didn't include the row name. Wrapper drives viaSET_NAMEpostMessage and the duplicate sidebar field is hidden via CSS in embed mode. - Themes skill rewritten at an internal themes reference — full pitchmappr theme schema (
overrides,markerSettingsincl. MK4markerBorderWidth,focusColors,focusCitySettings,shieldSettings,lineWidths,labelStyles, etc.) with provenance table. Drops the old "Protomaps reference only" framing.
Architecture Decisions
- ADR-062 Theme System — DB as Source of Truth.
- ADR-063 Theme Access Mode — Bottom-Wins Inheritance.
- ADR-064 Default Base Flavors as DB Themes.
Files Changed (pmppr-map repo)
New (10 files):
supabase/migrations/017_theme_enable.sqlsupabase/migrations/018_theme_access_mode.sqlcomponents/admin/target-themes-panel.tsxpublic/map-tool/themes/default-{light,white,grayscale,dark,black}.js(5 files)
Modified (22 files):
app/(admin)/admin/themes/page.tsx,[id]/page.tsx,editor/page.tsx,create/page.tsxapp/(admin)/admin/{organizations,departments,groups}/[id]/page.tsx(mount TargetThemesPanel)app/maps/[id]/map-client.tsx(THEME_ACCESS_LIST + DB_THEMES_LIST + admin bypass)public/map-tool/index.html+ 5 new<script>tagspublic/map-tool/themes/manifest.json(default flavors prepended)public/map-tool/lib/map-modules/{AppState,PanelContent,PostMessageBridge,ThemeMod,init}.js+ mirror copies inlib/map-modules/public/map-tool/mapprstyle.html(embed mode CSS, postMessage handlers, defaults merge, name fallback removal)public/map-tool/preview.html(visMap expansion, DEFAULT_OFF_KEYS, layerVisibility application)
[0.9.6] — 2026-04-19 (Bug-bash + planning sprint — 18 fixes, 3 plan docs, docs-guardian integration)
Fixed — Map Tool (followup, post-deploy)
- B33 country-focus follow-up Long Island (and Block Island, Fishers Island, Shelter Island, Plum Island, etc.) now correctly visible when focused on US country (not just NY state). Previously
_extractMainlandreturned only the SINGLE LARGEST polygon to drop overseas territories like Alaska/Hawaii — but US country geometry has 344 polygons, with Long Island stored as a SEPARATE polygon (i=160, 262 pts) from the contiguous-48 mainland (i=0, 8843 pts). The legacy logic dropped LI along with Alaska/Hawaii. Rewrote_extractMainland(geometry, mainlandBounds)inFocusMod.jsto keep ALL polygons whose bbox intersects the country'sbounds(already defined per-country inFOCUS_COUNTRIES). Now keeps mainland + LI + small US Atlantic islands while still dropping Alaska (bbox west of bounds) + Hawaii (bbox south of bounds) + Puerto Rico/USVI (bbox below bounds south). Same logic improves GB (Hebrides, Shetland), FR (Corsica), and any other country withmainland: trueflag. Legacy single-largest fallback retained for the no-bounds case.
⚠️ Known regression introduced in this release
- B35 The B33 country-focus follow-up (above) over-corrected:
_extractMainlandnow keeps 149 polygons for US country focus (was 1). The mask Polygon ends up with 149 inner rings + WORLD_RING; complex multi-ring Polygon causes MapLibre tessellation/tile-rendering artifacts at certain zooms. Visible symptoms: horizontal white line cutting across mid-southern US (Texas through Georgia), Florida appears blank/truncated at certain zooms, Delaware area gap, occasional gaps in other states. Decision 2026-04-19: leave shipped fix in place for now (LI is visible at country focus, which was the user's request); prioritize a proper fix in next cycle. Four fix options scoped in TODO B35 — recommend either (b) tighter bbox-inside-largest filter or (c) multi-feature mask. Affected commit:e7515c5(pmppr-map). Workaround: use NY state focus (or any state focus) for clean rendering; country focus has the artifacts.
Fixed — Map Tool
- B19 Data Overlay border thickness 0 now actually hides the border. JS truthy fallback (
borderWidth || 0.8) was treating 0 as falsy. Fixed inDataOverlayMod.js(3 paint blocks × 2 layer types) with!=nullguard + explicit'line-opacity': borderWidth===0?0:1. Display readouts inPanelContent.jshad the same bug — fixed. - B22 Marker scale slider min lowered 0.5 → 0.2 (per-dataset + global) in
PanelContent.js. Allows much smaller markers for dense maps. - B23 Locations no longer disappear from the map after save+reload.
MapMod.rebuildAll()only updated existing markers — early-returnif(!m)returnskipped every location afterclearAll. Now creates missing markers viaaddMarker(loc). - B24 My Projects search now matches by Project ID. Filter at
projects-client.tsx:74-82was missing thep.job_numbercheck (legacy project-level Project ID column). Added. - B25 Google OAuth no longer requires a double login.
auth/callback/route.tswas using the sharedcreateClient()whose try/catch silently swallowed cookie-write errors in Route Handlers. Rewrote to usecreateServerClientinline with cookie handlers writing directly to the redirect response. Bonus: now logs actual errors and redirects to/login?error=oauth_failedinstead of silently masking. - B26 My Projects 3-dot menu no longer appears as an empty white box. Project row
overflow-hiddenwas clipping the absolute-positioned dropdown. Made conditional:overflow-hiddenonly when project is expanded. - B27 Custom theme thumbnails now show actual theme colors instead of gray fallback. Color extraction was only reading
data.overrides; focus themes store palette indata.focusColors. Applied in user themes page + admin themes page. - B28 Admin Custom Themes section no longer shows users' private themes. Filter changed from
visibility !== 'system'tovisibility === 'shared'. Section relabeled "Shared Custom Themes." - B29 Custom themes save and view are no longer intermittent. Three independent bugs stacked: (1) settings page early-returned with "not enabled" if
resolve_settingsRPC blipped — now wraps in try/catch + assumes enabled + adds error UI + subscribes toauth.onAuthStateChange; (2) save plan-limit check was inside main try/catch — sub-query failure aborted save — now wrapped separately, falls through on failure; (3)CUSTOM_THEMES_LISTpostMessage only sent when non-empty — iframe never cleared stale state — now always sends + bridge filters out stale_account-flagged themes before merging. - B33 Long Island (and other states' islands) no longer missing from focus map. NY's polygon-6 outer ring is wound clockwise in
us_states_5m_v2.json; GeoJSON RFC 7946 requires CCW. When_buildMaskadded it as a hole inside CCW WORLD_RING, MapLibre's non-zero fill rule mishandled the LI sub-shape — basemap water layer rendered through, hiding LI's land. Added_ringSignedArea,_rewindRing,_rewindGeometryhelpers inFocusMod.js. Both_buildMaskand_addLayersnow normalize ring winding to RFC 7946 spec. Fixes the issue across all states/countries with mis-wound source data. Likely also fixes B14 (close as duplicate after verification). - MK5 (partial) Marker label halo color row now hidden when halo size = 0. Conditional
display:flex/nonetoggled live by the halo size slider'soninput— no panel refresh needed.
Fixed — Admin
- B15 Admin themes list now sorted strictly alphabetical within each group (Regular / Focus / Shared Custom). Removed
FEATURED_REGULAR/FEATURED_FOCUSconstants +sortWithFeaturedhelper. All groups uselocaleCompare.
Added — Map Tool
- MK4 Marker white-outline width control. The on/off checkbox in Markers > Additional Options is now a 0–3 slider (Off / 0.5x / 1.0x / ... / 3.0x). New
markerBorderWidthsetting onAppState.settings(default 1.0).markerBorderboolean kept in sync for backward compat. Updated inMarkerMod._shapes(3 shape renderers acceptbMult),ProjectionExportMod(canvas + SVG paths),UI.jsmode-switch stash keys,ThemeMod.jstheme apply,MapMod.jsreset. - UX19 Map Tool header decluttered + zoom relocated. Removed
nLoc,nZoom,nViewsindicators from top-left header. Added small floating zoom badge near MapLibre's NavigationControl (top:80px right:10px).MapMod.on('load')listener added so badge populates with initial zoom.UI.updateCounts()andMapMod._updateHUDmade defensively null-safe.
Security / Policy
- B32 Server-side enforcement of
custom_themes_enabled. UI gate (PanelContent.js:201) was already in place; SAVE_CUSTOM_THEME and IMPORT_CUSTOM_THEME handlers inapp/maps/[id]/map-client.tsxnow also callresolve_settingsand reject if disabled. Defense-in-depth: UI hides + server rejects. RPC failure is non-fatal (matches B29 logic). - B34 Export JSON disabled in iframe context (was bypassing sharing/permissions model — viewer could download full project blob). Import JSON gated to admin/owner/support roles in iframe (preserved for support/migration use cases). Standalone (non-iframe) context unchanged. Role read from
UI._userInfo.role.
Changed — UX
- UX16 "Map Tool" → "Mappr" rebrand across user-facing UI. Updated 5 files:
lib/nav-config.ts,components/shared/topbar.tsx,components/user/new-project-modal.tsx,app/(user)/settings/themes/page.tsx,public/map-tool/index.html. - UX17 Admin sidebar reordered. Management section now reflects org hierarchy top-down: Organizations → Departments → Groups → Users → Sharing. Data Overlays moved from Management to Content (placed between Maps and GeoJSON library).
- UX18 Redundant "Reset view" button removed from View Locations panel (populated state). Duplicated the global Reset Map. "View all" + "Clear" remain.
Diagnosed — deferred fixes
- B20 Admin Geocode Usage page shows
Nominatimbecauseapp/api/geocode/route.ts:245-246defaultsgeocoder_singleto'nominatim'when group lacks per-group config — and there's no admin UI to set per-group provider. Folded into F12 (Google geocoder + per-group admin UI). Don't change route default until admin UI ships and groups are audited. - B21 CSV import places lat/lon in wrong location — diagnosis blocked on user CSV upload.
- B30 / B31 Map Tool theme thumbnail colors + user profile theme display — pending verification after B27/B29 fixes.
Added — Planning artifacts
PLAN_export_pipeline_overhaul.md(563 lines) — F13-F18 covering Focus-aware D3 SVG/projection export, choropleth + shape overlay parity, vector PPTX research spike, Hi-Res PNG zoom/scale fix (absorbs UX10 — same architectural fix). 5 phases, 48-86 hrs total. ADR-062 outline.PLAN_data_panel_restructure.md(530 lines) — F19-F20 covering per-dataset marker/label data model, master color scheme, Markers panel restructure with per-dataset dropdown, HQ as per-row property, View Locations + Dataset Settings restructures, Local Cities & Regions enhancement. 6 phases, 48-68 hrs. Subsumes/coordinates F6, F7, F9, MK2, MS3, B22, MK4, MK5, UX18, UX1. ADR-064 outline.PLAN_role_taxonomy_normalization.md(330 lines) — SB13-SB15 normalizing 6 roles → 4 (admin/support/member/viewer; member stored, "User" displayed; owner becomes per-map relationship). DB migration sequence, code audit checklist, dropdown UX matrix, Viewer share-without-account feature. 5 phases, 17-45 hrs. ADR-063 outline.- F12 expanded to include Google Maps Geocoding API as a 3rd provider option alongside MapTiler + Nominatim, per user request. Per-org/dept/group inheritance with admin UI.
Added — Docs-guardian integration (Tier 1)
dev-docs/spec/PRODUCT-CONTEXT.mdcreated from pitchmappr starter. Status updated to v0.9.6 production SaaS.dev-docs/DOCS-CONVENTIONS.mdcaptures repo's docs schema (hyphenated spec filenames, dual-handoff convention, Nextra.mdxrequirement, two-FEATURE_CATALOG situation).dev-docs/spec/handoffs/new folder for dated per-session archives (this session's archive at2026-04-19-bug-bash-r1-r6.md).dev-docs/spec/templates/copied from skill (user-guide, feature-catalog-entry, handoff templates).scripts/audit-docs.shinstalled (executable).docs/pages/guides/new Nextra section withindex.mdxoverview +_meta.json. "Guides" entry added todocs/pages/_meta.json.- Project instructions populated — pitchmappr-specific paths, conventions, role taxonomy notes, gh-account reminder.
Infrastructure
- Mirror discipline maintained: every edit to
pmppr-map/public/map-tool/lib/map-modules/cp'd topmppr-map/lib/map-modules/. 9 modules updated this session. - Version pill bumped:
CFG.appVersion0.9.5 → 0.9.6 in both source + mirror;index.htmlbrandVer fallback updated.
[0.9.5] — 2026-04-18 (Data Overlay: popup fixes + user-selectable label content)
Fixed — feature-detail popup
- Popup anchored to
#mapArea(position: relative) instead ofdocument.body. Prior anchor measured the default top-right placement from the page body, putting the × close button behind the 46 px nav. - Drag clamped on all four sides (new
UI._clampPopupPoshelper) so the close button is always reachable. SavedpopupPosis re-clamped on every render — any off-screen coordinates from a larger prior viewport get pulled back inside. - Drag survives re-renders. Listeners are now on the outer popup element (event delegation on the header + document-level pointermove/pointerup) rather than the header. Every county click ran
innerHTML = …and destroyed the old header, taking the drag listeners with it; now the listeners live on a node that never gets replaced. - Escape key closes the popup as a safety net.
Added — user-selectable label content
- "Show labels" toggle (renamed from "Show county labels" / "Show country labels").
- Value dropdown under the toggle lets the user pick what each polygon's label displays. Scope-filtered:
- Census (US county): Name + all 11 indicators (one Census fetch pulls every variable per county).
- World Bank (country): Name + the active indicator only (WB fetches one indicator at a time).
- Eurostat (NUTS2): Name + the active indicator only (same constraint as WB).
labelContentsetting onAppState.settings.dataOverlay(default'name'). Changing it re-activates the overlay so each feature's_doLabelproperty is recomputed viadoFmt(currency, percent, population, age — matches the popup's value formatting)._addLabelLayertext-field now reads_doLabel, with fallbacks to_doName / name / NAMEfor legacy feature payloads.
[0.9.4] — 2026-04-18 (Data Overlay: feature-detail popup + Layer Info card redesign)
Changed — Data Overlay visuals
- Feature-detail popup (the card pinned to the map when you click a county / region) redesigned to the
pm-detailstructure. Header with name +×close button; shaded navy-tinted active-metric band with uppercase mono label and large value; supporting-metric table (label left / value right / tabular-nums); mono footer with source + vintage + indicator code. Matches the shared census-redesign CHANGESHEET component 4. - Sidebar Layer Info card (active overlay metadata, bottom of the Data Overlay panel) redesigned to
pm-layerinfo: uppercase mono "LAYER INFO" label, bold indicator name, description, 2×2 metadata grid (Source / Updated / Vintage / Geography). Shaded navy-tint background with inset highlight. Position unchanged — still sits where the old "Data Source" strip was. - No changes to stats pills, legend, hover, or the layer picker (CHANGESHEET components 1-3, 5 out of scope for this pass).
Added
- Popup is draggable. Header bar is the drag handle; pointer events for mouse + touch. Dragged position persists in
AppState.settings.dataOverlay.popupPosfor the session. - Popup auto-close. Hides when the user leaves the Map panel, changes the Data Overlay indicator (including to Off), or resets the map. Prior behaviour left stale popups pinned across context switches.
codefield onDO_METACensus indicators (B19013, B17001, etc.) so the popup footer reads "Census ACS 2023 · B19013". World Bank and Eurostat use existingwbCode/datasetfields.- aria-live announcement region (
#pm-detail-live) fires"Selected X. Median Household Income: $75,289."for screen readers on click.
Changed
- Supporting rows in the popup now exclude the active indicator (previously it showed twice: highlighted at the top and again in the list).
- Old
.do-popup-*and.do-info-*CSS removed; replaced with.pm-detailand.pm-layerinfoscoped rules. No new design tokens introduced.
[0.9.3] — 2026-04-18 (Iframe script bundling + PUT-latency investigation + more perf)
Performance (additional)
- Parallelized the
/projectsserver render (app/(user)/projects/page.tsx). The page used to do 4 Supabase queries serially afterauth.getUser()— profile,ownProjects,coOwnedShares,rawMaps— all keyed offuser.idwith no mutual dependency. Now fires in onePromise.all. Cuts ~3 round-trips off the My Projects page render. - Stopped embedding
dataOverlay.cachedDatainto the save blob. AppState.toJSON() used to copy the fetched Census + World Bank caches intosettings.dataOverlay.cachedDataon every save. Small today (max blob was 73 KB), but any Eurostat NUTS2 activation or large Census state load would inflate blobs 10-100×. The cache is session-local and re-fetches on demand, so embedding it in the saved project only hurt save latency and DB growth.toJSONnow stripscachedData;loadJSONalready handled absence gracefully.
Investigation — PUT /api/maps/[id] latency spikes (no code change)
- Profiled the occasional 5.6 s
PUT /api/maps/[id]tail latency against the actual DB. Findings:- Blob sizes are tiny. Across 56 maps: avg 18.6 KB, p95 40.7 KB, max 73.4 KB. Zero maps >100 KB. TOAST overhead is not the cause.
- Server-side execution is ~9 ms.
EXPLAIN (ANALYZE, BUFFERS)on the largest row (73.4 KB) shows Index Scan viaprojects_pkey(1.2 ms),set_updated_at_projectstrigger (4.5 ms), planning 2.2 ms. No seq scans, no RLS policy cost visible. - Conclusion: spikes are in transport or the dev server, not the DB. Next.js 16
next devwith Turbopack can recompile the route module mid-request and stall 1–4 s. Auto-save queue + network RTT to the Supabase region accounts for the rest of the variance. - Recommended next step (deferred): deploy current commits to Vercel and re-measure. If prod spikes are bounded at <500 ms, the problem is dev-only and doesn't warrant code changes.
- Confirmed along the way that the legacy RLS policies on
maps(renamed fromprojectsin migration 008) still includeclients_see_own_projectsandclients_edit_allowed_projectswith JSONB containment + per-row email subqueries. Not the cause of current spikes, but added to PERF3 as a cleanup candidate.
Performance
- Bundled 67 iframe scripts into 2.
public/map-tool/index.htmlpreviously loaded 32 app module<script>tags + 35 theme<script>tags individually, then an admin drop-in loader re-fetched every theme a second time to dedupe by name — roughly 70 HTTP requests per map iframe load. Newscripts/build-map-tool-bundle.mjsparses the<script src>list fromindex.html(kept as an HTML comment block, preserving load-order as source of truth), concatenates the files in order, and emits two content-hashed bundles (map-modules.bundle.<hash>.js,themes.bundle.<hash>.js) plusdist/manifest.json. Hashes are content-stable (no timestamps) so rebuilds are byte-identical when sources haven't changed — CDN / browser cache stays warm.- Dev workflow:
npm run devnow runs the bundle watcher alongsidenext devviaconcurrently.npm run buildprefixesnpm run build:map-toolso Vercel builds always regenerate beforenext build.public/map-tool/dist/is gitignored. - Drop-in admin theme loader (
themes/manifest.jsonXHR path) removed. Theme management lives in the DB + admin pages now. - Observed: 4 requests per iframe (HTML + manifest + 2 bundles) instead of ~70. Cold-load savings ~500 ms–1.5 s per map depending on connection / HTTP-2 setup cost.
- Dev workflow:
Added
scripts/build-map-tool-bundle.mjs— Node-native, zero external deps beyondconcurrentlyfor watch mode.--watchflag for dev, debounces filesystem events, rebuilds on any.jschange underlib/map-modules/orthemes/, or onindex.htmlchange.
[0.9.2] — 2026-04-18 (Project ID moves to map; request-level perf)
Changed — Project/Map data model (architectural)
- Project ID now lives on
maps.job_number, notprojects.job_number. In user terms: a project is a folder with a name + notes; each map inside it carries its own Project ID. User-supplied or auto-generated server-side asPM-YYYYMMDD-NNN(format preserved). Maps moved between projects keep their own ID.- New Map modal: the Project ID input now appears in both Existing Project and New Project modes and applies to the map, not the folder.
- Projects list: top row shows folder name + notes only — no project-level ID. Expanded rows list each map with its Project ID in the left cell (monospace). Search matches folder names + map Project IDs + map names.
- Map tool Project Details panel: the "Project" dropdown shows just the folder name (no
(job_number)suffix). The Project ID input edits the current map's ownjob_numbervia the standard save path (AppState.settings.projectJobNumber→ map blob → extracted tomaps.job_numberon PUT).
- Panel header no longer falls back to Project ID for its title.
UI._updateProjectHeader()/_renderPanel()display the project name only, so the two fields stay visually distinct. See ADR-059.
Added
MAP_METApostMessage — parent posts the column-storedjob_number+nameto the iframe onBRIDGE_READY. Bridge handler setsAppState.settings.projectJobNumber/projectNamedirectly, bypassingSec.parseProject(which rejects partial new-map payloads withoutdatasets/locationsarrays). Source props come from the server-rendered page — no extra Supabase round-trip.BRIDGE_READYretry + parent idempotency — iframe re-postsBRIDGE_READYevery 300 ms until the parent responds with anything, so the loading spinner never hangs when a full-page load races the parent'suseEffect. Parent guards against duplicate handling via a ref flag so the data-load doesn't run twice (which previously overwrote in-progress theme applies).
Performance
- Parallelized
auth.getUser()across the request hot paths. Every Supabase route used to dogetUser()→ profile/data query serially; now both run inPromise.all. RLS on the session cookie still gates actual data access, so the auth check only needs to resolve before we produce the 401 response. Touched:app/maps/[id]/page.tsx,app/api/maps/[id]/route.ts(GET/PUT/DELETE),app/api/maps/route.ts(GET/POST),app/api/projects/[id]/route.ts(GET/PUT/DELETE),app/api/projects/route.ts(GET/POST),app/api/data-overlays/settings/route.ts. Expected saving: ~100–200 ms per cold request. - Dropped redundant client-side query in
map-client.tsx. The BRIDGE_READY handler used to re-SELECT project_id, job_number, nameafter the server page had already fetched them; values now flow in as props andSET_CURRENT_PROJECT+MAP_METApost immediately on bridge handshake (above the Promise.all for the user/profile/themes batch). ~80–150 ms less time between iframe load and Project panel populating. /api/mapsPOST merged two serial profile queries (group_idanddefault_project_id) into one, and runsgenerateMapProjectId()in parallel withauth.getUser()when the client didn't supply a Project ID. See ADR-060.
Fixed
- Base Map mode no longer hides the canvas when there are no locations. Previously,
UI.updateEmptyState()hid#mapwithvisibility:hiddenand showed a "Select a map type to get started" overlay, making theme selection feel broken — picking "Dark" correctly calledThemeMod.apply('dark')and rebuilt the style, but the result wasn't visible. Now only Focus Mode without anisoCodeshows the empty-state overlay; themes preview live in Base Map mode from a zero-location project. - Map load hang from
/projectslist — postMessage race where the iframe postedBRIDGE_READYbefore the parent's ReactuseEffectregistered its listener on full page navigation. Fixed by the retry + idempotency pair above. - Stale
maps.job_numberon PUT —/api/maps/[id]PUT now skips re-readingjob_numberwhen the request only updatesdatawithout aprojectJobNumberinsettings, avoiding an unnecessary write.
Removed
- Project ID field from "Existing Project" mode as an inherited read-only display — replaced with the editable per-map Project ID input. The
jobNumberparameter is also no longer written intoprojects.job_numberfrom the "New Project" modal; folders no longer carry their own ID.
[0.9.1] — 2026-04-17 (Heat Map, Empty-State Prompts, Hot-Fix Pack)
Added
- Heat Map (F8) — new
HeatmapMod.jswraps MapLibre's nativeheatmaplayer overAppState.locations. Sidebar section "Heat Map" with Enable / Radius (6–80px) / Intensity (0.2–3) / Opacity (0.1–1). Cool→warm gradient. Works in both Base Map and Focus Map. Auto-rebuilds on marker add/remove. Persisted inAppState.settings.heatmap. - Empty-state prompts —
#emptyStateoverlay with.pm-emptystyling.UI.updateEmptyState()decides visibility:- Focus mode + no
isoCode→ "Select a country or region to get started" - Base Map mode + zero locations → "Select a map type to get started"
- Triggered on init,
setMapMode,resetMap,FocusMod.activate,updateCounts. - When visible, the underlying
#mapisvisibility:hiddenso MapLibre stops painting tiles under a cover; on dismiss, visibility restored +map.resize()called.
- Focus mode + no
- Reset Map silent reposition —
flyToswapped forjumpToso the world view doesn't trigger a tile cascade behind the empty-state card. - TODO additions — F9 (per-section reset buttons), F10 (Carto-style draw tools: radius / shape / fill+transparency).
Fixed
- Empty Untitled maps —
/maps/newno longer creates a DB row from drive-by views.hasMeaningfulContentguard on the firstPOST /api/maps: skip if no locations / views / project name / focus / overlay / heatmap. - Modal + search dropdown pollution —
/projectssearch input gotautoComplete="off". New Project modal's existing-project list now filterseq('created_by', user.id)so other users' "Default" rows don't flood the dropdown. - Auto-enable Focus Theme neighbors — checking "Include neighbor states" in Data Overlay (US state mode) now auto-flips
focusMode.neighborModefrom'none'to'adjacent'. Previously the checkbox silently did nothing because the choropleth path gates onneighborMode !== 'none'. useHighResreset bug —MapMod.resetMapwas hardcodinguseHighRes: false, forcing every post-reset focus activation to render with low-res polygons. Aligned with AppState default oftrue.- Callback-drop in
_ensureHighRes— concurrent calls (e.g. init's idle preload + a user-triggered focus activation) used to drop the second caller's callback. Replaced with a shared callback queue (matches_ensureAdmin1pattern). Same fix in_ensureLowRes. - Strict-mode ReferenceError —
_lowCbs,_highCbs,_lowLoadingwere used without declaration in_ensureLowRes/_ensureHighRes, crashing focus activation on production. Added the missingletdeclarations. - Map blank when high-res slow — earlier "wait for high-res only" path left users with a blank map if the 7.7MB fetch was slow. Reverted to dual-load: render with whichever resolution arrives first (preferring high-res), upgrade when high-res lands. Both preloaded on init.
- Reset doesn't clear popup / scope —
MapMod.resetMapnow hides#doPopupand resets all dataOverlay fields (scope,neighborCustomColors, label*).DataOverlayMod.deactivatealso hides the popup so every overlay teardown clears it. - NUTS2 click popup — added Regions branch to
getCountyInfoand the UI popup; resolves region name from boundary GeoJSON, populates all 5 Eurostat indicators from_euCache. - NUTS2 bordering scope —
_renderEurostatChoroplethnow respectsworldScope === 'bordering'by including adjacent EU/EFTA countries' regions in a separate source with the neighbor color ramp. UK→GB prefix mapping handled. - Stale-render race — all three activate paths (Census / WB / Eurostat) now check scope + indicator freshness before rendering, so rapid mode switches don't leave orphan layers from in-flight fetches.
Changed
- Empty-state copy — minimal: just the icon and a one-line title. No sub text or action buttons (per user feedback).
- Regions-mode Data Scope chips — "All Countries / Bordering" replaced with "This country only / Including neighbors" since "all 471 NUTS2 globally" isn't practical and coverage is patchy.
[0.9.0] — 2026-04-17 (Phase 5 — Eurostat NUTS2 Regional Choropleth)
Added — Regional data overlays
- 5 Eurostat NUTS2 indicators available when focused on an EU27 / EFTA / UK country: GDP per capita (PPS), Population, Unemployment rate, Life expectancy at birth, Tertiary education attainment rate (25–64).
- Scope toggle in the Data Overlay panel — switches between "Country total" (World Bank) and "Regions (NUTS2)" (Eurostat). Only appears when the focused country has NUTS2 coverage AND the
eurostat-dataendpoint is enabled for the org. - Bordering scope in regions mode — "Including neighbors" button renders adjacent EU/EFTA country regions too, using the neighbor color ramp. UK-prefixed NUTS codes are handled transparently.
- Click popup — new NUTS2 branch in
getCountyInfo; popup resolves the region name from boundary GeoJSON and shows all 5 Eurostat indicators. - ~471 NUTS2 regions loaded from nuts2json (2021 release, WGS84, 20M scale). Fetched once per session, cached in memory; also included in the project JSON cache (
_euCache).
Added — Database
- Migration
015_eurostat_endpoints.sql— addseurostat-dataandnuts2-boundariesrows to thedata_overlay_endpointscatalog. ExtendskindCHECK constraint to allow'boundary'. Admins can override URLs or disable the feature per-org via/admin/data-overlays. - Migration
016_fix_default_project_dupes.sql— consolidates existing duplicate "Default" projects per user (keeps profile's current default or oldest; reparents orphan maps; deletes empties). Adds partial unique indexidx_projects_one_default_per_user. Rewritesget_default_project()withpg_advisory_xact_lock+ "reuse existing before insert" so the race window in migration 009 is closed.
Fixed
- High-res country borders —
FocusMod.init()preloads the 10m dataset during browser idle time.FocusMod.activate()now waits for high-res before rendering a country focus — no more low-res flash. Toast appears if fetch exceeds 1.2s. - Duplicate country labels (Denmark's 400+ islands, Greece, Norway, etc.) —
_addLabelLayernow builds a dedicated point source with one feature per entity, anchored at the centroid of the largest polygon. - Stale render race — all three activate paths (Census / World Bank / Eurostat) check scope + indicator freshness before rendering. Rapid mode switches no longer leave orphan layers from in-flight fetches.
- Clean scope switch —
_setDataOverlayScoperesetsworldScopeto'all'so switching Regions→Country doesn't carry forward bordering state.
Changed
- Regions mode Data Scope chips — replaced "All Countries / Bordering" with "This country only / Including neighbors" (all-world NUTS2 at 471 features isn't practical and coverage is patchy).
EU_META/EU_CATEGORIES/EU_NUTS2_COUNTRIESadded toDataOverlayMeta.jsalongside existingDO_METAandWB_META.
[0.8.0] — 2026-04-17 (Phase 4A — Admin Endpoint Management, Org Level)
Added — Database
- Migration
014_data_overlay_settings.sql— two tables:data_overlay_endpoints— catalog of 10 known endpoints (Census ACS, World Bank WDI, Pipelines, Power Plants, Shale, BOEM Platforms/Leases, Earthquakes, FEMA Flood, Tribal Lands). Seeded from current hardcoded defaults.org_data_overlay_settings— per-org override rows. Columns:enabled,url_override,last_checked,last_status,last_http,last_latency_ms,last_error. UNIQUE(org_id, endpoint_key).- RLS: admins/owners of the org can read+write. Helper fn
_current_user_org_id()(SECURITY DEFINER). Updated_at trigger.
- Migration
014b_data_overlay_rpc.sql—get_my_data_overlay_endpoints()RPC (SECURITY DEFINER). Merges catalog + current user's org overrides. Bypasses RLS on groups/departments so non-admin users can read the resolved endpoint list without direct table access.
Added — Admin page
/admin/data-overlays— list of all 10 endpoints grouped by kind (Data / Shape). Per-row: enable/disable toggle, editable URL (save on blur / Enter), per-row "Test" button (server-side health check with 8s timeout), status badge (OK/error/timeout + latency), reset-to-default. Top-right "Test All".- API routes:
GET/PUT/DELETE /api/admin/data-overlays(admin-only),POST /api/admin/data-overlays/test(server-side GET with AbortController, persistslast_status/last_http/last_latency_ms),GET /api/data-overlays/settings(all authenticated users, usesget_my_data_overlay_endpointsRPC).
Added — Map-tool wiring
- PostMessage
DATA_OVERLAY_ENDPOINTS— parent (map-client.tsx+maps/new/page.tsx) fetches/api/data-overlays/settingsafter BRIDGE_READY and posts the payload to the iframe. Bridge stores it onwindow.__doEndpoints, refreshes the panel, and auto-deactivates overlays whoseenabled === false. - ShapeOverlayMod —
_resolveUrl(key, meta.url)+_isEnabledByOrg(key)helpers. Four fetch sites (GeoJSON, WMS, viewport-query base URL, refresh timer) now route through_resolveUrl.isEnabledByOrgexposed on public API. - DataOverlayMod —
_censusBaseUrl()+_wbBaseUrl(code)substitute{year}and{code}placeholders in admin-editable URLs; three fetch sites swapped. - PanelContent — Shape Overlays panel filters out overlays where
ShapeOverlayMod.isEnabledByOrg(key) === false.
Added — Admin nav
- "Data Overlays" entry in ADMIN_NAV (between Sharing and Maps).
Design notes
- Scope: org-level only in 4A. Dept/group tri-state overrides are scoped in 4C.
- Encryption: API keys are 4B scope; 4A deals with public endpoint URLs only.
- Auto-check cron: 4C scope. 4A has a manual "Test All" button only.
[0.7.7] — 2026-04-17 (Data Overlay Polish & Phase 3 Follow-ups)
Added
- DO3a — "Show across neighboring states" toggle (Option B bbox clip). New
showAcrossNeighborsflag inAppState.shapeOverlays.ShapeOverlayMod._getClipBbox()unions focused state + adjacent neighbors' bounds fromFOCUS_US_STATES;_filterFeaturesToBbox()filters features client-side by first-coord test;refreshAll()rebuilds active overlays on toggle. When on, layers render abovefm-mask/fm-neighbors-fillinstead of being clipped under the mask. Toggle only shown for US-state focus. - Shape Overlays in Base Map mode — section no longer gated behind focus.
SidebarConfigentry droppedmode:'focus'; PanelContent only shows a warning when focused on a non-US country.hydro(Major Waterways WMS) overlay removed — it wasn't respecting focus clipping. - DO6 — Custom Min/Max color picker for data overlay. Color-pair pickers at top of "Focus State Style" section; reset-to-default button appears when custom ramp active.
customColorswas already wired throughdoLerp(); added UI + handlers. - DO7 — Separate color ramp for neighbor choropleth. Added
neighborCustomColorstoAppState.dataOverlay.enrichFeatures()/enrichWBFeatures()takeisNeighborflag and pick the matching ramp. Pickers at top of "Neighboring States Style" section (renamed from "Neighbor States"). Reset-to-focus-colors button. - DO9 — Data values in project JSON export.
DataOverlayMod.getCachedData()/restoreCachedData()public API.AppState.toJSON()attaches_censusCache+_wbCacheundersettings.dataOverlay.cachedData;loadJSON()restores them and deletes the blob from live settings. Only included when caches are non-empty. - Shape overlay label positioning — 9 compass/corner/center positions (
top,bottom,left,right,top-left/right,bottom-left/right,center)._labelPlacement()maps to MapLibretext-anchor+text-offset. Line overlays (pipelines) keepsymbol-placement:'line'regardless. - Local Cities label positioning — same 9 positions added to
CityMod. Legacy"above"preserved as alias for"top". - Floating map legend — data overlay legend (gradient bar, min/max, stats) moved out of panel to bottom-right of
#mapArea._updateLegendOverlay()called from state + WB render completions and_removeLayers. Styled.do-legend-overlaywith white/blurred backdrop, rounded 8px, subtle shadow, tabular numerals.
Verified (no code change)
- DO3c — Shape overlays in hi-res PNG export.
ExportMod._pngHiRes()clonesMapMod.map.getStyle(), which already includesso-*layers + inline GeoJSON source data. MapLibre rehydrates them on the offscreen map. - DO8 — Choropleth in hi-res PNG export. Same
getStyle()clone mechanism carries Data Overlay layers. User-confirmed.
Changed
- "Neighbor States" → "Neighboring States Style" — consistent section-title convention with "Focus State Style".
- Data Overlay panel reorder — Min/Max color pickers now appear directly below each section title, before opacity/border controls.
Removed
hydro(Major Waterways) overlay — WMS tile layer couldn't be clipped to focus region.
[0.7.6] — 2026-04-17 (Shape Overlays — Phase 3)
Added — Shape Overlays
- 9 infrastructure/geographic overlays for US focus mode: Natural Gas Pipelines (EIA), Power Plants 50MW+ (EIA, sized by MW, colored by fuel), Shale & Tight Oil Plays (EIA), GOM Drilling Platforms (BOEM, active only), GOM OCS Lease Areas (BOEM WMS), Earthquakes M2.5+ 30d (USGS, 30min auto-refresh), FEMA Flood Hazard Zones (viewport-bounded query), Federal Tribal Lands (Census TIGERweb AIANNHA), Major Waterways (USGS National Map WMS).
- Focus-region clipping — shape overlays render BELOW
fm-maskso they're automatically clipped to the focused state's geometry via the mask hole. No per-layer filtering needed; one unified approach works for points, lines, polygons, and WMS tiles. - Per-overlay controls — toggle on/off, opacity slider (0.1–1.0), color picker (single-color overlays only), inline legend, click popup with feature properties.
- Loading pulse animation — prominent blue pulsing badge appears during API fetches (Census neighbor fetches, World Bank indicator fetches, shape overlay data loads). Active overlay rows also pulse while loading.
- Click popups — MapLibre popups with feature properties (plant name/fuel/MW, earthquake magnitude/location, flood zone/subtype, pipeline operator, etc.).
- Viewport-bounded queries — FEMA flood zones re-fetch on
moveend/zoomendusing current map bounds to keep the result set manageable. - Auto-refresh — USGS earthquakes refresh every 30 minutes via
setInterval+source.setData().
Added — Unified Label System
- Shape overlay labels — single labels section in Shape Overlays panel applies to ALL active overlays. Font (Regular/Medium/Bold/Italic), size (8–18px), color, halo toggle, halo color, halo width.
- Data overlay label enhancements — font dropdown, halo toggle, halo color, halo width added to existing Labels section in Data Overlay panel.
- Labels automatically added when a new overlay is toggled on while labels are enabled.
- Pipeline labels use
symbol-placement: 'line'; everything else usespoint.
Added — Infrastructure
ShapeOverlayMeta.js— 9 overlay definitions: URLs, colors, legends, geometry types, refresh configs.SO_META,SO_LEGENDS,SO_REFRESH,SO_KEYS_ORDER.ShapeOverlayMod.js— fetch/render/toggle/refresh logic. Handles GeoJSON (polygon/line/point), WMS rasters, viewport queries. Public API:init,toggleOverlay,setOpacity,setColor,isActive,isLoading,deactivateAll,restoreFromState,fixLayerOrder,applyLabels,updateLabelStyle._beforeId()helper — returns'fm-mask'for focus-region clipping; overlay layers insert before the mask.fixLayerOrder()— moves so-* layers below mask; keeps focus borders and city layers on top of everything.restoreFromState()— called fromThemeModidle callback aftermap.setStyle()rebuilds; re-readsAppState.settings.shapeOverlaysand re-adds enabled overlays.- AppState additions —
shapeOverlays:{}(per-overlay state),shapeOverlayLabels:{}(global label config), new data overlay label fields (labelFont,labelHalo,labelHaloColor,labelHaloWidth).
Fixed
- Country→state focus transition —
DataOverlayMod.activate()now checks whether the current indicator exists in the target mode's metadata (DO_METAfor states,WB_METAfor countries); if not, clears the indicator, removes layers, and refreshes the panel. - "Show base map through focus" toggle bug — shape overlays disappeared after this toggle because
map.setStyle()destroyed all custom layers.ShapeOverlayMod.restoreFromState()now called fromThemeMod's idle callback restores them. - Slider value overflow — overlay panel sliders showed values outside the sidebar boundaries. Fixed with
max-width:100%,min-width:0,flex-shrink:0, and explicitwidth:54pxlabels. - Loading pulse visibility — upgraded pulse animation with background color transitions (0.14 → 0.28 alpha) and expanding box-shadow ring. Border + spinner icon for clarity.
- Neighbor state loading feedback —
fetchNeighborDatanow sets_loading=trueduring the batch fetch so the pulse badge appears (previously only showed on initial state fetch).
Design Decisions
- Shape overlays use MapLibre-native layer types (fill, line, circle, symbol, raster) instead of a library like Leaflet. This allows direct integration with the existing focus mask and z-order management.
- Color-by-property uses MapLibre
match/caseexpressions (e.g.['match', ['downcase', ['get', 'PrimSource']], 'gas', '#ffa657', ...]) for power plant fuel types, earthquake magnitudes, and flood zone categories. - Size-by-property uses
interpolatelinear expressions for power plant MW and earthquake magnitude. - Emoji icons removed from labels for cleaner UI (per user request).
Infrastructure
- Commit:
edabcde(pmppr-map repo).
[0.7.5] — 2026-04-16 (World Bank Data Overlay — Phase 2)
Added — World Bank Country Overlay
- 9 World Bank WDI indicators — GDP per Capita (USD + PPP), GNI per Capita, GDP Growth Rate, Total Population, Life Expectancy, Urban Population %, Electricity Access %, Maternal Mortality. Each with its own color ramp.
- World scope selector — "All Countries" (default, world choropleth) or "Bordering" (focused country + adjacent neighbors only). Replaces the US-only "Include neighbor states" toggle for country focus.
- Dual-mode panel — Data Overlay section automatically shows Census indicators for US states, World Bank indicators for countries. Labels adapt (counties/countries, neighbor states/countries).
- Country click popup — click any country to see all 9 WB indicator values.
- ISO2-keyed data — World Bank response parsed using
country.id(ISO2) directly, matching GeoJSONproperties.isowithout ISO3 conversion. ~240 countries matched. - High-res country GeoJSON —
_ensureCountryGeo()always fetches 10m resolution data for data overlay rendering.
Added — Infrastructure
WB_META— 9 indicator definitions with WB API codes, color ramps, descriptions, categories.WB_CATEGORIES— Economic Output, Population & Society, Infrastructure.ISO2_TO_ISO3/ISO3_TO_ISO2— ~100 country mappings for future use._activateWorldBank()— fetches fromapi.worldbank.org/v2/..., caches per indicator._buildCountryChoropleth()— enriches country features with choropleth colors, supports "all" and "bordering" scopes._ensureCountryGeo()— loads high-res country GeoJSON from R2.worldScopesetting added toAppState.settings.dataOverlay(default:'all').FocusMod.getCountryData()— exposes_geoHigh || _geoLowfor data overlay use.
Fixed
- Border color not updating —
updateStyle()now handles both Census and World Bank re-renders. - Neighbor border visibility —
fm-neighbors-bordermoved above choropleth in_fixLayerOrder(). - WB aggregate regions filtered — regions like "Africa Eastern and Southern" excluded from data.
Infrastructure
- Commit:
91eddfc(pmppr-map repo).
[0.7.4] — 2026-04-16 (Census Data Overlay — Phase 1)
Added — Data Overlay
- Census ACS data overlay — new "Data Overlay" collapsible section in focus mode panel. When a US state is focused, select a demographic indicator to render a county-level choropleth from Census ACS 5-Year data (2023 vintage).
- 11 indicators — Median Household Income, Per Capita Income, Poverty Rate, Unemployment Rate, Bachelor's+ Rate, Total Population, Median Age, Median Home Value, Median Gross Rent, Homeownership Rate, Vacancy Rate. Each with its own color ramp.
- County click popup — click any county to see all indicator values in a floating panel.
- Stats summary — county count, average, highest/lowest county names displayed inline.
- Include neighbor states — toggle to extend choropleth into adjacent states with separate fill opacity, border color, and border width controls.
- Neighbor Census data — fetched in batches of 3 with 15-second timeouts per request. Loading spinner overlay during fetch.
- Style controls — fill opacity slider, border color picker + hex input, border width slider. All match existing
.cs-row/.cs-label/.cs-swatchpatterns. - Data info strip — shows indicator description, source (Census Bureau), dataset (ACS 5-Year), vintage (2023), geography (County).
- Census sentinel filtering —
-666666666values (Census suppressed data marker) properly filtered out. - Loading overlay — centered spinner with "Loading Census data..." text on map area during API fetch.
Added — Infrastructure
- DataOverlayMeta.js — indicator definitions (
DO_META), FIPS lookup (DO_STATES,DO_ABBR_TO_FIPS), Census variable codes (DO_CENSUS_VARS), color interpolation (doLerp), value formatting (doFmt), county name normalization (doNormalizeCounty). - DataOverlayMod.js — IIFE module: Census API fetch with AbortController timeout,
_parseCensusparser,_buildChoroplethwith['get','_doColor']paint expression (avoids MapLibre duplicate-key errors from multi-part county polygons),_fixLayerOrderz-order management,fetchNeighborDatabatch fetcher. fixLayerOrder()— public method called by FocusMod after high-res data rebuilds to maintain correct z-order (choropleth abovefm-neighbors-fill, belowfm-target-border, city layers on top).dataOverlaysettings — added toAppState.settingswith indicator, fillOpacity, borderColor, borderWidth, includeNeighbors, neighborFillOpacity, neighborBorderColor, neighborBorderWidth, customColors.
Changed
- SidebarConfig.js — added
'dataoverlay'section to map panel focus mode. - FocusMod.js —
getCountyData()getter exposed; data overlay activation after county data loads;fixLayerOrdercalled after high-res rebuild. - MapMod.js —
resetMap()now clears data overlay state and callsDataOverlayMod.deactivate(). Reset defaultsneighborMode:'none'. - index.html —
<script>tags for DataOverlayMeta.js and DataOverlayMod.js added after CityMod.js.
Fixed
- City layers hidden by data overlay —
_fixLayerOrdernow restoresfm-city-dotsandfm-city-labelsto top of layer stack after reordering.
Infrastructure
- Commits:
ce6bf5a(pmppr-map repo).
[0.7.3] — 2026-04-16 (Selective Neighbor Management)
Added — Focus Mode
- Three-mode neighbor selector — replaced binary "Show surrounding boundaries" checkbox with chip-based None / Adjacent / All selector in the Focus Theme panel. Adjacent mode (new default) shows only geographically neighboring regions; All restores the previous behavior of showing every country/state; None hides all neighbors.
- Adjacency maps — ported from PitchmapprLite.
ADJACENCY_US_STATEScovers all 51 US states (including DC),ADJACENCY_COUNTRIEScovers 60+ countries. Both defined inFocusData.jsalongside existing focus country/state definitions. - Neighbor chips UI — in Adjacent mode, interactive chips display each adjacent region by name (full country name for world focus, 2-letter abbreviation for US states). Click × to hide a neighbor from the map, + to restore, Reset to restore all.
- Fast neighbor updates —
FocusMod.updateNeighborSource()uses MapLibresource.setData()for in-place GeoJSON updates when toggling individual chips, avoiding full layer rebuild. - Neighbor county boundaries for US states — "Show internal surrounding boundaries" now loads county-level data for adjacent US states (previously only showed duplicate state-level outlines from admin-1 data). New
_addNeighborCountyLayers()function filters county GeoJSON to neighbor state abbreviations.
Changed — Focus Mode
- Default
neighborMode: 'adjacent'— new projects default to Adjacent mode withshowNeighbors: true. Previous default wasshowNeighbors: false(no neighbors). showNeighborsis now a derived property — kept in sync withneighborMode(truewhen mode !== 'none') to avoid refactoring 14+ existing references.- Neighbor admin-1 filtering —
_addNeighborAdmin1Layers()now filters admin-1 features to only adjacent countries when in Adjacent mode (country focus). US state focus routes to county data instead.
Added — State & Migration
neighborModeandremovedNeighborsadded toAppState.settings.focusModedefaults.- Project migration —
Sec.parseProject()migrates old projects:showNeighbors: true→neighborMode: 'all',showNeighbors: false→neighborMode: 'none'. Ensures backward compatibility.
Infrastructure
- CSS —
.nbr-chips/.nbr-chipstyles for neighbor chip UI. - Commit:
6471ce6(pmppr-map repo).
[0.7.2] — 2026-04-12 (Help & Reference Docs Site)
Added — Documentation Site
- Nextra docs site — full help & reference site at
docs.pitchmappr.com, 17 content pages covering all application tabs (Project, Map Regular, Map Focus, Data, Markers & Labels, Views, Export), theme system (Theme System, Theme Editor, Built-in Themes, Color Schemes), technical reference (Data Models, System Constants), and help (Tips & Shortcuts, Troubleshooting, Glossary, What's New). - Quick Start walkthrough — interactive 10-step carousel on the index page guiding new users through the core workflow.
- SVG diagrams — 5 brand-aware React components (ExportWorkflowDiagram, LayoutDiagram, MapPanelDiagram, RailDiagram, QuickStart) adapting to light/dark mode via
useBrandColorshook. - Feature catalog —
FEATURE_CATALOG.md(134KB) comprehensive feature inventory used as source material for page generation. - Generator scripts —
scripts/generate-pages.js(page scaffolding from catalog),scripts/sync-changelog.js(sync changelog to docs),scripts/generate-brand-assets.js(OG images, favicons).
Added — Documentation Infrastructure
- Nextra 2 + Next.js 14 — static site generator with MDX support, built-in search, dark mode, TOC sidebar.
- Vercel deployment — separate Vercel project at
docs.pitchmappr.com, root directory set todocs/. - PostCSS isolation —
postcss.config.cjswith empty plugins prevents parent repo's@tailwindcss/postcssfrom being inherited during Vercel build. - CVE override —
next-mdx-remoteoverridden to v6.0.0 via npm overrides to satisfy Vercel's CVE-2026-0969 security check.
Changed — Documentation Styling
- BrandMappr formatting match — globals.css rewritten to match BrandMappr docs layout conventions: 28px H1, 20px H2, 14px body text with 28px line-height, 12px border-radius code blocks, navy table headers with rounded corners, 8px border-radius callouts, light blue sidebar active state with font-weight 600.
- Brand preservation — all pitchmappr branding retained: navy
#1A3E72palette, DM Sans + IBM Plex Mono fonts, compass logo, version badge. - Dark mode — full dark theme with adapted brand colors, sidebar active state, callout colors, code block tints.
Fixed
- SVG diagram build errors — 3 components (
ExportWorkflowDiagram.jsx,MapPanelDiagram.jsx,LayoutDiagram.jsx) referenced bare variables (blueLt,surface, etc.) instead of destructuredc.blueLt. Added proper destructuring fromuseBrandColors()hook.
Infrastructure
- Workspace launch config — added
docs-devconfiguration to the workspace launch config for local preview at port 3333. - Commits:
48abb3c,a130548,995c50e,51fd5bd(pitchmappr repo).
[0.7.1] — 2026-04-11 (Sharing System, Project ID, Admin Plan)
Added — Sharing System (Phases A–G)
- Migration 011 — 5 tables (
project_shares,project_share_access_log,share_notifications,org_sharing_settings,dept_sharing_overrides), RLS policies, 2 helper functions (resolve_share_permission,is_sharing_scope_enabled), 3 triggers (notify on share/update, convert pending invites on signup),email_notificationscolumn on profiles. - Migration 012 — Fix infinite recursion in
project_sharesRLS policies. Extracted co-ownership check intocan_manage_project_shares()SECURITY DEFINER function. - Migration 013 — Add
adminplan tier for system org (unlimited everything). - Share Dialog (
components/user/share-dialog.tsx) — email input + permission picker (view/clone/edit/owner), current access list with inline permission changes and revoke, scope selector (person/group/dept/org), author badge + owner badge. - Sharing API —
app/api/shares/CRUD routes (GET/POST/PUT/DELETE), scopes endpoint, access logging endpoint. - Admin Sharing Settings (
app/(admin)/admin/sharing/) — org-level toggle switches for group/dept/org sharing scopes, department override table with tri-state toggles (Inherit/On/Off). - Sharing Hub (
app/(user)/sharing/) — two-tab page (Shared with You / Shared by You), sortable columns, unread notification dots, last accessed/opened columns, three-dot menu with "Manage sharing..." action. - Sidebar badge — unread share notification count on "Sharing" nav item.
- View Only mode (
app/maps/[id]/view-only-client.tsx) — minimal layout for view/clone permissions: top bar with project info + permission badge, collapsible saved views sidebar, map iframe in read-only mode, clone button with success toast. - Co-owner workflow — author shown at top of share dialog with crown badge, co-owners get distinct orange "Owner" badge, "Make Owner" permission only available to original author, co-owned projects appear in My Projects list.
- Email notifications — 3 React Email templates (
share-invite.tsx,share-permission-changed.tsx,share-revoked.tsx),lib/email/share-notifications.tshelper, fire-and-forget from API routes, respectsemail_notificationsprofile preference. - Notification preferences — "Sharing updates" toggle added to Settings → Notifications page, registered in email provider preference map.
Added — Testing Infrastructure
- Vitest integration test framework with
tests/setup.tsandtests/helpers.ts(test user/project/share creation + cleanup via Supabase admin client). - 49 functional tests — share CRUD (8), permission resolution (10), RLS policies (9), notification triggers (5), admin settings + scope checks (11), access logging (4), pending invite resolution (3).
- 29 adversarial tests — SQL injection, XSS, constraint violations (owner flag, single target, no target), FK violations, cascade behavior, permission escalation attempts, cross-org isolation, concurrent duplicate prevention, notification spam prevention.
- 78 total tests across 9 test files, all passing.
Added — Admin Plan & Role
adminplan tier — unlimited seats, exports, storage, custom themes. No Stripe billing.- Admin role chip — red badge in admin users list, filter chip, role dropdown option on user detail page.
Changed — "Job Number" → "Project ID"
- Renamed all UI labels across 7+ files: new project modal, projects page, sharing hub, view-only mode, admin projects list, admin project detail, admin maps list, preferences page.
- Auto-generated Project ID — format
PM-YYYYMMDD-NNN(sequential per day) when not provided. - Same
job_numberdatabase column, just relabeled.
Changed — Map Tool Project Details Panel
- "Job Number" → "Project ID" with monospace font.
- "Owner" field removed entirely.
- "Creator" → "Author" — now read-only, auto-filled from logged-in user's display name.
- Changes applied to both
lib/map-modules/PanelContent.jsandpublic/map-tool/lib/map-modules/PanelContent.js.
Changed — Nav & Pages
- "Deliveries" → "Sharing" in sidebar nav with Share2 icon.
- Old
/deliveriespage deleted, replaced by/sharinghub. - "Sharing" added to admin nav under Management section.
Changed — My Projects Page
- Search fixed — now searches project name, project ID, AND map names within projects. Previously only searched project-level fields.
- Pagination fixed —
sorted.slice()now applied. Previously rendered all results ignoring page state. - Unassigned maps auto-assigned to default project on page load. "Unassigned Maps" section removed.
- Share button visible — labeled "Share" button (blue pill) on each project row, plus three-dot menu with Share/Rename/Delete actions.
- Shared indicator — people icon + count on shared projects, clickable popover showing recipients and permissions.
Fixed
- Infinite recursion in RLS —
project_sharesINSERT/SELECT/UPDATE policies caused "infinite recursion detected" error. Fixed withcan_manage_project_shares()SECURITY DEFINER function (migration 012). - Theme saving blocked on Free plan —
custom_themes: 0prevented all saves. Admin/owner roles now bypass the limit check. - TypeScript build error —
projectIdstate variable shadowed local variable innew-project-modal.tsx. Renamed tocustomPid. - "Open" button on received shares — navigated to
/projectsinstead of the actual map. Now routes to/maps/{id}for View Only/Clone mode. - Sharing hub test isolation — admin settings tests leaked state between runs. Fixed with explicit state reset before each scope test.
- postMessage origin mismatch — Vercel Live toolbar wraps the page, changing
event.originfromhttps://map.pitchmappr.comtohttps://vercel.live. Parent handler now checksevent.source === iframe.contentWindowas alternative to origin check. AllpostMessageresponses switched fromwindow.location.originto'*'(matching iframe pattern). - "Flavor not found" crash — applying a saved custom theme crashed because
ct.basestored the theme key (e.g.'voyager') not the Protomaps flavor name ('light')._buildFlavornow maps through_flavorMapwith try/catch fallback. - Account themes overwritten by loadJSON —
PROJECT_INITreplacedcustomThemeswith project data, losing DB-loaded account themes. Fixed with_accountThemesstash that re-merges after everyloadJSON()call.
Known Issues
- B18: Custom theme save/load intermittent — theme saving still fails intermittently. Partial fixes applied (origin, flavor, stash) but root cause may be a race condition between
PROJECT_INITandCUSTOM_THEMES_LISTmessage ordering. Tracked in TODO.md.
Adversarial Test Findings
- Supabase auth.admin.deleteUser() does NOT cascade to profiles or shares. Orphaned data survives. Documented as known Supabase limitation.
- Empty string email passes
single_share_targetconstraint (non-null but meaningless). No DB-level email format validation.
[0.7.0] — 2026-04-10 (Projects & Maps, Roles, Custom Themes)
Added — Custom Themes
- Save custom themes from map tool — users save current theme (base map or focus) via postMessage bridge to Supabase. Appears in My Themes section.
- My Themes panel in map tool — new "My Themes" section in Map panel, visible in both regular and focus modes. Save, import JSON, export, delete.
- Export/import on user themes page —
/settings/themesenhanced with card layout, export JSON, import JSON, delete with confirmation dialog. - Focus theme capture — saving in focus mode captures all focus colors (mask, border, fill, neighbor, water, border widths, dash style).
- Plan tier limits — free=0, pro=10, team/enterprise=unlimited custom themes. Enforced on save via plan check.
- Admin feature toggle —
custom_themes_enabledsetting in org/dept/group settings cascade.
Added — Settings Cascade
- Settings schema (
lib/settings-schema.ts) — 11 cascadable settings with keys, types, defaults, categories (Features + Limits). - Settings cascade component (
components/admin/settings-cascade.tsx) — reusable side-by-side comparison table (parent value vs current value) with override, lock, and reset controls. - Applied to admin pages — org, dept, and group detail pages now show full settings cascade instead of just override counts.
Added — Role Hierarchy (Phase 0)
- 6 roles — admin (system), owner (org management), support (scoped user management), creator, viewer, client.
- admin/owner restricted to system org — enforced by
enforce_system_roles()trigger. - Support role scoping —
support_scopecolumn (group/dept/org) withis_support_for()RLS function. - 5 default orgs seeded — Pitchmappr Team (system, enterprise), Public-Free, Public-Pro, Enterprise-Stripe, Enterprise-Invoice. Each with default dept/group.
is_admin()— updated to accept both admin+owner (backward-compatible with all 15+ RLS policies).is_system_admin()— admin-only for sensitive ops (billing, org deletion).- Admin sidebar filtering — nav items filtered by role. Owner sees Management+Content, Admin sees everything including Billing/System.
- Billing APIs admin-only — subscriptions/invoices routes restricted to admin role.
- New user signup — auto-assigned to Public-Free org default group.
Added — Projects & Maps Restructuring (Phase 1)
projectstable renamed tomaps— each row is an individual map.- New
projectstable — parent grouping container (name, job_number, description, group_id, created_by). owner_idrenamed toauthor_id— removes "owner" terminology from maps.map_authorsjunction table — multi-author support per map.cloned_fromcolumn — for future clone tracking ({job_number}_c1numbering).- Default project per user — every user gets a "Default" project on account creation.
get_default_project()RPC auto-recreates if deleted. - Maps always assigned to a project — API resolves default project if none specified.
- My Projects page (
/projects) — expandable project folders with nested maps, search by name/job number, pagination, "Add Map" button, rename/delete project modals, move-to-project dropdown on maps. - New Map modal — toggle between "Existing Project" and "New Project" tabs. Pre-selects default project.
- Map tool project panel — dropdown of all user's projects, change project via postMessage.
- Route restructuring — map editor at
/maps/[id], new map at/maps/new, API at/api/maps/. - Admin pages — separate Projects (groups) and Maps (individual) pages.
- Unassigned Maps section — shows maps with no project, each with "Move to..." dropdown.
Added — UI Updates (Phase 2)
- Brand marks updated — square-dark-24 for user sidebar + map tool, square-light-24 for admin sidebar, square-on-dark-32 for forgot-password. All using new design system from
MARK-USAGE-GUIDE.md. - User avatar in map tool — replaced ACCOUNT rail button with avatar + name + chevron dropdown in top nav bar.
- Dropdown menu — identical across topbar and map tool: name, email, role badge, My Projects, Map Tool, Settings, Billing, Sign out. All links use
target="_top"to break out of iframe. - Avatar color matching — name-hash color algorithm shared between topbar and map tool (blue, teal, amber, purple, rose, emerald).
- SVG favicon —
public/favicon.svg(circle-dark-32),public/mark.svg(circle-dark-64).
Changed
- Sidebar label — "My Maps" renamed to "My Projects".
- Map Tool link — sidebar and topbar point to
/maps/new. - All post-login redirects —
/maps→/projects(login page, auth callback, reset password, middleware, root page). /adminpage — redirects to/admin/dashboardfor direct admin portal access.- Map tool init performance — consolidated 5 sequential Supabase queries into 1
getUser+ 3 parallel queries viaPromise.all.PROJECT_INITsent ~3x faster. - Team page role logic —
isAdminincludes admin, owner, support. Role dropdown shows role-appropriate options.
Fixed
- Theme RLS INSERT — added
WITH CHECKclause tousers_manage_own_themespolicy. - UI.js syntax error — user menu methods placed outside object literal, preventing map tool from loading.
- Project delete — added missing DELETE RLS policy on projects table.
- Stale URLs — fixed all remaining
/projects/new→/maps/new,/maps→/projectsreferences. - Map tool iframe links — added
target="_top"to all dropdown links so navigation breaks out of iframe. - Maps not saving to projects —
/maps/newpage now passesprojectIdin first-save POST request. - Large data column — removed
dataJSONB from My Projects maps query (was causing slow/stale responses).
Database Migrations
006_fix_theme_rls_insert.sql— WITH CHECK for theme inserts007_roles_and_orgs.sql— roles, orgs, tiers, triggers, helper functions008_projects_and_maps.sql— rename projects→maps, new projects table, map_authors, cloned_from009_default_project_per_user.sql— default_project_id on profiles, get_default_project() RPC010_fix_project_delete_policy.sql— DELETE policy on projects table
[0.6.7] — 2026-04-09 (Admin UX + Theme Management)
Added — Theme Management
- Theme preview system — lightweight
preview.html(MapLibre + PMTiles only, ~80 lines) for admin theme previews. No full map tool loaded. - Theme list redesign — two view modes: Card Grid (gradient swatches) and Split List (left list + right live map preview). Search, filter pills, view toggle.
- Theme detail redesign — three-panel layout: settings panel (palette, labels, layers) + map preview + collapsible JSON editor with validation.
- Theme sorting — featured-first order matching map tool dropdown, then alphabetical. Grouped by Regular/Focus/Custom sections.
- Theme editor —
/admin/themes/editorembeds mapprstyle.html with postMessage bridge for create/edit themes. - Theme import/export — upload JSON to create theme, download JSON from detail page.
- mapprstyle.html restored — recovered 2,413-line theme editor, added postMessage bridge.
- 36 system themes seeded —
scripts/seed-themes.mjspopulates themes table.
Added — Admin UX
- Favicons — 6 icon files + manifest.webmanifest + metadata in layout.tsx.
- Pagination — added to themes list, theme assignments, admin projects, user maps page.
- Sortable columns — SortableHeader component on 7 admin pages + user maps sort dropdown.
- Create organizations —
/admin/organizations/createwith name/slug/domain/plan/seats form. - Standardized layer toggles — all themes show same 5 label + 15 layer toggles on detail page.
Added — User Portal
- Enhanced projects page — table layout with Name, Job #, Theme + swatches, Creator, Created, Updated, Status. Delete with confirmation dialog.
- Map Tool sidebar link — opens
/projects/new(creates project on first auto-save). - Account button in map tool sidebar rail — navigates to user portal.
Fixed — Build & Navigation
- Lazy-init SDK clients — Stripe, Resend, Supabase admin use proxy/lazy patterns to avoid build-time crashes.
- Post-login redirect — respects
?redirect=parameter from middleware. - Avatar dropdown — context-aware links for admin vs user portal.
- Middleware optimization — skips Supabase for API/public/bypass routes. No admin DB query.
- Server components —
/mapsand/projects/[id]fetch data on server, eliminating duplicate queries.
Fixed — Performance
- Eliminated redundant DB queries — middleware, layout, and page queries consolidated.
- Loading spinner — shows while map tool iframe initializes.
[0.6.6] — 2026-04-09 (Phase 3 + SaaS Backend)
Added — Geocoding Proxy (Phase 3)
- Nominatim + MapTiler split geocoder — per-group provider configuration: single geocode uses MapTiler (fast, relevance-scored), batch geocode uses Nominatim (free, rate-limited). Groups store
nominatim_urlfor future self-hosted Nominatim instances - Server-side geocoding proxy — API routes proxy geocoding requests, hiding API keys from the client. Replaces client-side key exposure from v0.6.5
Added — SaaS Admin Portal (26 pages)
- Refine.dev v5 admin panel — full CRUD admin interface at
/admin/*powered by Refine.dev v5 with Supabase data provider. React 19 compatible, uses browser Supabase client with admin RLS (not service role) - Admin pages — organizations, departments, groups, users/profiles, projects, themes, geocode logs, usage analytics, system settings (26 pages total)
- Admin middleware — role-based access check ensures only users with admin role can access
/admin/*routes
Added — User Portal (12 pages)
- User shell — sidebar + topbar layout with avatar, notification badge, and navigation.
/mapsas default landing page (replaces standalone/projects) - User pages — maps list, map detail, account settings, team management, billing, usage dashboard (12 pages total)
- Shell components — Sidebar, Topbar, UserAvatar, StatusBadge reusable components
Added — Stripe Billing Integration
- 4 subscription tiers — Free, Starter, Professional, Enterprise with per-tier feature gates
- Stripe checkout —
/api/stripe/checkoutcreates Checkout Sessions for tier upgrades - Stripe webhooks —
/api/stripe/webhookshandlescheckout.session.completed,invoice.paid,customer.subscription.updated/deletedevents - Stripe customer portal —
/api/stripe/portalredirects to Stripe-hosted billing management - Billing UI — plan comparison cards, current plan indicator, upgrade/downgrade flow
Added — Email System
- Dual provider — Resend (primary, transactional) + SendGrid (bulk/marketing) with automatic failover
- 11 email templates — welcome, email confirmation, password reset, invitation, team join, billing receipt, usage alert, project shared, trial expiring, account deactivated, admin notification
- React Email components —
@react-email/componentsfor type-safe, preview-able email templates rendered via@react-email/render
Added — Cron Routes
- Expire invitations —
/api/cron/expire-invitationsmarks stale team invitations as expired - Clean Stripe events —
/api/cron/clean-stripe-eventspurges processed webhook events older than 30 days - Aggregate usage —
/api/cron/aggregate-usagerolls up daily geocode/export counts into monthly summaries
Added — Auth Pages
- Forgot password —
/forgot-passwordsends password reset email via Supabase Auth - Reset password —
/reset-passwordhandles token validation and new password submission
Added — SQL Migrations
- 003_enable_geocoder — enables geocoder extensions and group-level provider config columns
- 004_saas_foundation — subscriptions, invoices, usage_logs, invitations, email_logs tables with RLS
- 005_geocoder_split — adds
nominatim_url,geocoder_single,geocoder_batchcolumns to groups table
Added — Dependencies (pmppr-map)
@refinedev/core,@refinedev/supabase— admin panel frameworkstripe,@stripe/stripe-js— payment processingresend,@sendgrid/mail— email delivery@react-email/components,@react-email/render— email templateszod— runtime schema validationdate-fns— date formatting utilitiesrecharts— usage analytics charts
Fixed
- Route group conflicts — resolved Next.js
(admin)and(user)route group conflicts with overlapping layouts - user_roles references — fixed references to deprecated
user_rolestable, replaced withprofiles.rolecolumn - Refine v5 API adaptations — updated deprecated Refine v4 hooks and components to v5 equivalents (useTable, useForm, resource definitions)
Changed
- Default landing page —
/mapsreplaces/projectsas the authenticated user's default route. Standalone/projectspage removed - Route group architecture — Next.js routes reorganized into
(admin)and(user)route groups. Admin pages use/admin/URL prefix via subfolder - Enhanced middleware — added admin role check for
/admin/*, cron authorization via bearer token, API route bypass for webhook endpoints - 4 new ADRs — ADR-029 (split geocoder), ADR-030 (Refine.dev v5), ADR-031 (route group architecture), ADR-032 (/maps default landing)
[0.6.6] — 2026-04-09
Added — Server Migration (Phase 2: Auth + Persistence)
- Working login page — Supabase Auth with email/password sign-up/sign-in, Google OAuth, Apple OAuth button (ready for provider setup)
- Email confirmation flow — detects when Supabase requires email verification, shows branded "Check your email" screen instead of freezing
- OAuth callback route —
/auth/callbackhandles code exchange after Google/Apple redirect - Custom auth domain —
auth.pitchmappr.comvia Supabase Pro custom domain (replaces random Supabase URL in OAuth consent screens) - Project CRUD API —
GET/POST /api/projects(list, create) +GET/PUT/DELETE /api/projects/[id](read, update, soft-delete). Extractsname/job_numberfrom project data for top-level indexing - Project list page —
/projectswith branded nav bar, project cards (name, job number, status badge, relative timestamp), "New Project" button - Project detail page —
/projects/[id]with slim top bar (back link) + full-height iframe - PostMessage bridge —
PostMessageBridge.js(27th JS module) enables iframe↔parent communication. Promise-basedsend()with request IDs, 15s timeout, origin validation - Server-side project save —
ProjectMod.save()sends data via postMessage to parent, which calls PUT API. Clears dirty flag and shows toast on success - Auto-save debounce —
AppState._touch()triggers 2s debounced save when running in iframe._savingflag prevents overlapping saves - File export/import preserved —
ProjectMod.exportJSON()downloads .json file,ProjectMod.importJSON()reads file + triggers server save - Bootstrap migration —
002_bootstrap_group.sqlseeds default org/dept/group, updates profile trigger withgroup_id - Brand styling — all pages (login, confirmation, projects, detail) use pitchmappr tokens: navy blues (#1A3E72, #2558A8), DM Sans, IBM Plex Mono, mark/wordmark logo
- AUTH-OAUTH-SETUP.md — comprehensive setup guide for Google OAuth, Apple Sign-In, custom domain, DNS, and troubleshooting
- 2 new ADRs — ADR-027 (postMessage bridge), ADR-028 (custom auth domain)
Fixed
- Profile trigger search_path —
handle_new_user()updated withSET search_path = publicand explicitpublic.profilesreference to fix "relation profiles does not exist" error - Login freeze on sign-up — page no longer freezes when email confirmation is required; shows confirmation screen instead
Changed
- Root redirect —
app/page.tsxchanged from iframe toredirect('/projects') - Middleware — authenticated users on
/or/loginredirect to/projects;/auth/*routes are public - PanelContent exportjson — removed "no auto-save" warning, buttons renamed to "Export JSON" / "Import JSON", auto-save status note shown when in iframe
- 6-doc spec framework — added AUTH-OAUTH-SETUP.md as 6th spec document (was 5+1 migration spec)
[0.6.6] — 2026-04-08
Added — Server Migration (Phase 0 + Phase 1)
- Next.js 16 foundation — App Router + TypeScript + Tailwind v4 + shadcn/ui (button, input, card, table)
- Supabase integration — 11-table schema (organizations, departments, groups, profiles, projects, project_snapshots, project_edits, geojson_files, geocode_log, themes, theme_assignments) with RLS policies, indexes, and auto-create profiles trigger
- Auth middleware — Supabase SSR session management, protected routes redirect to /login
- Module extraction — index.html monolith (~7,100 lines) extracted into 26 separate JS files + CSS in
lib/map-modules/. Global namespace pattern (window.X = X) preserves all cross-module references and inline onclick handlers - Two-repo architecture —
pmppr(pitchmappr.com, public website) +pmppr-map(map.pitchmappr.com, Next.js + Supabase map tool app) - iframe integration — map tool served at clean root URL via iframe in Next.js, no
index.htmlvisible in URL - SERVER-MIGRATION-SPEC.md — consolidated migration plan (supersedes 6 earlier .docx/.md files), 11-phase roadmap (~122 hrs), Org→Dept→Group hierarchy
- 3 new architectural decisions — ADR-024 (two-repo), ADR-025 (global namespace extraction), ADR-026 (iframe integration)
Fixed
- CFG.appVersion — corrected from '0.5' to '0.6.6' (also geocoderAgent)
Changed
- Repo cleanup — untracked 60+ non-deployable files from git (data/, demos/, marketing/, .wrangler/, planning docs, tool caches)
- 6-document spec framework — added SERVER-MIGRATION-SPEC.md as 6th spec doc
Added — MapTiler Multi-Pass Geocoder
- MapTiler as default geocoder — replaced Nominatim with MapTiler multi-pass system ported from geocodr.html. 6-pass fallback: full query → POI pass → number-word normalization ("One Bryant Park" → "1 Bryant Park") → drop leading segment (removes business name) → last 2 segments → append country bias
- Relevance threshold — rejects low-confidence results (default 0.5, admin-configurable). Uses
autocomplete=falsefor better scoring. - Country bias auto-detection — when focus mode is active, focused country ISO code automatically biases geocoding results
- Admin-configurable geocoding settings —
AppState.settings.geocodingwithrelevanceFloor,usePoi, andcountryBias(saved per-project, future UI-ready) - API key validation — throws clear error if MapTiler key not set
Removed
- Nominatim geocoder —
Geo._nom()provider removed entirely. Rate limit reduced from 1100ms to 150ms (MapTiler allows ~6 req/sec)
Changed — Documentation
- 5-document spec framework — restructured
dev-docs/spec/from 3 files to 5: PROJECT-SPEC.md, TECH-SPEC.md, FEATURE-CATALOG.md, PROJECT-DATASHEET.md, DECISIONS-LOG.md - TECH-SPEC.md — new document: every technology choice with rationale, CDN dependencies with versions, external services, CFG reference, geodata inventory
- DECISIONS-LOG.md — new document: 23 architecture decisions with context, alternatives, and consequences
- All spec docs — updated to v0.6.5→v0.6.6 with changelogs, cross-references, and current metrics
[0.6.5] — 2026-04-07
Added — UX Quick Wins
- Delete dataset confirmation modal (UX4) — removing a dataset now shows a confirmation dialog ("Are you sure? Locations will be moved to another dataset") with Cancel/Remove buttons, matching the existing
moClearmodal pattern - Delete view confirmation modal (UX5) — removing a saved view now shows a confirmation dialog before deletion
- Business fonts for labels (UX2) — label font dropdown expanded from 3 to 7 options: Default (IBM Plex Mono), Arial, Calibri, Helvetica Neue, Georgia, Times New Roman, Garamond. Font mapping updated in LabelMod, SVG export, and projection export
- New export crop ratios (F4) — added 3:2 (Photo / Film) and 2:1 (Ultra-wide / Banner) to CROP_RATIOS, inserted between existing presets
- Larger font sizes (UX12) — marker label size range expanded 8–18 → 6–36px, city label 7–16 → 6–36px, halo max 3 → 5, base map label multiplier max 2x → 3x (both regular and focus modes)
- Water color in focus mode (FM9) — new "Water" section with color picker in Customize Focus Theme panel, between Mask and Focus Country. Live updates basemap water layer, applied on focus activation
- Copilot integration plan (I9) — detailed implementation plan for AI assistant with bridge actions, CF Worker proxy, per-theme toggle. Saved as
dev-docs/PLAN_copilot_integration.md
Fixed
- Focus custom colors lost in PNG export — hi-res PNG export now re-applies all focus colors (mask, water, fill, borders, neighbors) to the offscreen map via setPaintProperty after style clone
- Focus colors lost on mode switch — stash/restore when toggling regular↔focus now includes targetFillColor, waterColor, targetBorderWidth, admin1BorderWidth, admin1Dashed (previously reset to defaults)
Changed
- I6 verified complete — confirmed
ne_10m_countries_v1.json(7.7MB) andne_10m_admin1_v3.json(11.1MB) already live on R2 with CFG URLs pointing to R2
[0.6.4] — 2026-04-05
Added — New Theme Presets (8 themes)
- ArcGIS Light — cream land (#F3EFE9), muted blue water (#AAD3DF), soft green parks (#D1E5C9), warm rose roads (#EA9EAD). Esri-inspired cartographic style
- Sage — muted olive-green earth (#E4E8DE), nature-forward. Good for real estate, agriculture, ESG, land use
- Slate — true neutral grey (#F0F0F0) with visible features. No warm or cool tint
- Sandstone — desert tan earth (#EEE2D0), rust-brown boundaries (#987050), teal-blue water. Middle East, Southwest US aesthetic
- Newsprint — cream paper (#F0EBE2), dark ink features. Roads fade into paper, boundaries and labels carry the map
- Dark Bronze — warm chocolate earth (#201C14), bronze boundaries (#5A4830), gold/amber labels. Fills the "dark warm" gap
- Lavender — soft violet-grey earth (#EAE6EE), periwinkle water, sage parks. Modern SaaS/tech/healthcare aesthetic
- Blueprint — high-contrast light (#F2F2F4), dark boundaries (#505058), visible road hierarchy. Designed for print output
Changed — Theme Consolidation (4 merges)
- Corporate Blue + Corporate Light Blue merged into Frost — all three occupied the same cool blue-grey slot (earth values within 6 RGB units). Frost kept as survivor with best name and most complete properties
- Warm Presentation merged into Warm Parchment — Warm Parchment has fully specified property set; Warm Presentation was a sparse subset
- Dark Boardroom merged into Midnight Blue — both deep navy dark themes; Midnight Blue has richer Bloomberg-terminal aesthetic and complete properties
- All 4 deleted themes archived in
themes/archive/*.js.bak
Changed — Theme Reorganization
- Grey theme families aligned — standardized fill and border colors across grey theme groups:
- Flat Grey group (earth #F4F4F4, borders #C2C2C2): Monochrome Ink renamed to "Flat Grey", Flat Design renamed to "Flat Grey (blue water)" (green tints removed), Focus Grey Light renamed to "Focus Flat Grey"
- Slate Grey group (earth #E8E8E8, borders #C2C2C2): Slate Grey updated, Slate Finance renamed to "Slate Grey (blue water)", Focus Slate Finance renamed to "Focus Slate Grey (blue water)"
- Focus Flat Grey neighbor colors — neighborFillColor #FAFAFA, neighborBorderColor #E6E6E6
- Focus theme dropdown reordered — themes with similar names grouped together, updated ordering arrays
- "Neighboring Countries" renamed — label in Customize Focus Theme changed to "Neighboring Regions"
- Theme originals archived — all renamed themes backed up in
themes/archive/
Fixed
- Focus theme color reset on switch — switching focus themes now resets all focusColors to defaults before applying new theme colors; previously, customizations (e.g. targetFillColor) would persist across theme switches
- Focus fill color picker showing white — Customize Focus Theme panel now shows the theme's actual earth color when targetFillColor is transparent, instead of always displaying white
- Live customization base theme bug — "Customize Theme" in regular mode now correctly uses the current theme as base instead of stale
_preFocusThemevalue; previously, customizing a custom theme would revert to voyager colors - Live customization reset — "Reset to defaults" button now correctly returns to the selected custom theme instead of falling back to voyager
__live__theme base flavor resolution — custom themes now correctly resolve theirbaseproperty for the live style system
[0.6.3] — 2026-04-04
Added — Theme System Alignment
- All 7 mapprstyle properties now consumed —
font,glyphsUrl,language,buildingOpacity,boundaryDash,labelScale,poiIconsapplied by_vectorStyle(). Previously exported by mapprstyle but silently ignored - Per-type lineWidths consumed —
_vectorStyle()supports{highways, major, minor, service, other, boundaries}with{roads, boundaries}shorthand fallback. Per-type values from 20+ theme files were previously silently ignored - Per-category textSizeMultiplier in mapprstyle — Label Styles section has a "Size" slider (0.5x–2.0x) per label category (Country, City, State, Road, Water)
- Black built-in theme — "Black — high contrast" added as 5th option in Base Map Theme dropdown, using Protomaps
blackflavor
Added — Highway Shields
- Highway shields on by default —
roads_shieldslayer defaults to visible at z7+ on highways/major roads - Shield text→ref remap — PMTiles uses
reffield instead ofshield_text;_vectorStyle()remaps filter, text-field, and icon-image expressions automatically - Shield controls in Customize Theme (regular mode) — text color, text size (0.5–2x), icon size (0.5–2x) in Highway Shields subsection
- Shield settings in theme system —
shieldSettingsproperty in theme files ({textColor, textSize, iconSize}), consumed by_vectorStyle(), full mapprstyle UI with "Highway Shield Defaults" section
Added — Focus Mode Enhancements
- "Enhance with Base Map" section — merged old "Base Map Labels" and "Base Map Options" into single unified section
- Roads toggle in focus mode — show/hide basemap road lines with color picker and width slider (0.2–3x), uses LiveStyleUI for proper expression-based width handling
- Shields toggle in focus mode — independent highway shields toggle with text color and size controls
- Focus base map theme dropdown — now includes Black theme option
Fixed
- Removed
_mapLW()data loss — theme loading pipeline no longer averages per-type lineWidths into a singleroadsvalue 'black'base flavor accepted —loadJSONvalidator now includes'black'in valid bases- Nested overrides preserved —
poisandlandcovernested color objects no longer stripped on JSON load/save - LiveStyleUI per-type lineWidths display — Roads slider shows computed average when per-type theme is active
- Focus mode road width — uses LiveStyleUI.setLineWidth (not direct setPaintProperty) for proper expression handling
Changed
- Focus mode sidebar — "Base Map Labels" + "Base Map Options" merged into "Enhance with Base Map" with layer toggles at top, full base map controls below
- SKILL docs — documented coarse/fine-grained layerVisibility keys, both lineWidths formats, textSizeMultiplier, shieldSettings, all advanced properties
- SPEC.md — theme data model expanded with per-type lineWidths, nested overrides, labelStyles, shieldSettings, and all advanced properties
[0.6.2] — 2026-04-03
Changed — Panel Reorganization
- GitHub repo migrated —
tensmedia1/pmtesting→teamtools2026-coder/pmppr. Git history rewritten to new author. Vercel deployment atpmppr.vercel.app - Data tab — consolidated 5 sections to 3: Add Location, Import Locations (renamed, absorbs Location List as "View Locations"), Dataset Settings (combines Datasets + Dataset Visibility)
- Markers & Labels tab — consolidated 4 sections to 2: Markers (absorbs Marker Defaults — color scheme, default size, Additional Options), Labels (absorbs Label Defaults — font, position, size, color, halo)
- Views tab — Export Sizes merged into Save View section as inline reference. Explanation text moved to top of Save View
- Export tab — new Quick Export section (current-view PNG/Hi-Res/SVG), Export Images renamed to Export Views, Export JSON renamed to JSON (with Import button added), Projection Export renamed to Change Projection
- Header — removed JSON/Load buttons (now in Export > JSON section)
- Export status div — hidden when empty to eliminate spacing gap, shown via
UI.sl()when content is set
[0.6.1] — 2026-04-02
Added
- High-resolution country outlines — NE 10m country data (
ne_10m_countries_v1.json, 241 countries, 7.7MB) replaces 50m data. Singapore, Bahrain, Hong Kong etc. now have proper curved coastlines instead of angular lines - High-resolution admin-1 boundaries — NE 10m admin-1 data (
ne_10m_admin1_v3.json, 240 countries, 11.1MB) with 562K coordinates (2.3× more detail than v2) - Dual internal boundary toggles — Focus Theme section split into "Internal Boundaries" and "Surrounding Boundaries" groups:
- "Show low zoom boundaries" — GeoJSON overlay, visible at Z1–8
- "Show high zoom boundaries" — basemap tile boundaries, high detail at all zoom levels
- Both toggles fully independent
- Surrounding Boundaries section — "Show surrounding boundaries" + "Show internal surrounding boundaries" always visible, second disabled until first is checked
- Focus country outline width — slider in Customize Focus Theme (0.5–4px), controls
fm-target-borderlayer - Internal boundary width — slider (0.3–3px) in Customize Focus Theme
- Internal boundary style — Solid/Dashed dropdown in Customize Focus Theme
- Theme editor border settings — mapprstyle.html now exports
targetBorderWidth,admin1BorderWidth,admin1Dashedin focus theme JSON - Auto-named views — Saving views without a name auto-generates "View 1", "View 2", etc. Placeholder updates to next available name
- Export filenames — priority changed to Job Number → Project Name → "pitchmappr"
Fixed
- No default tab on load — page loads with all panels closed instead of Data tab open
- No default country in focus mode — dropdown shows "Select a country…" placeholder instead of defaulting to US
- B10: Internal boundary resolution — replaced single low-res GeoJSON layer with dual approach: GeoJSON for overview zoom + basemap tiles for detail zoom
- B11: Bahrain boundaries — Bahrain (BH) now found in 10m data with proper boundaries
- High-res always on —
useHighResdefaults totrue, checkbox hidden, 10m data loads eagerly on focus activation - View names not unique — auto-increment prevents duplicate names on rapid saves
Changed
- Focus Layer Colors → Customize Focus Theme — renamed section
- Focus Theme section restructured with labeled groups: Internal Boundaries, Surrounding Boundaries
- High resolution boundaries checkbox removed — always-on, no user toggle needed
CFG.geoDataHighResnow points tone_10m_countries_v1.json(10m, was 50m)CFG.geoAdmin1now points tone_10m_admin1_v3.json(high-res, was v2)_defaultFocusColorsincludestargetBorderWidth,admin1BorderWidth,admin1Dashed- Base Map Options Line Widths restored (were briefly moved to Focus Layer Settings)
Data Files (local, pending R2 upload)
data/ne_10m_countries_v1.json— 241 countries, 7.7MB (replaces 50m for high-res)data/ne_10m_admin1_v3.json— 240 countries, 11.1MB (replaces v2)tools/prep-admin1-highres.py— script to regenerate admin-1 data from NE shapefiles
[0.6.0] — 2026-04-02
Added
- Panel reorganization — 8 tabs reduced to 6. Layers and Settings tabs eliminated, sections redistributed
- New rail order: Project, Map, Data, Markers & Labels, Views, Export
- Map Layers and Customize Style moved into Map tab (regular mode)
- Focus Layers, City Markers, Customize Style moved into Map tab (focus mode)
- Export Sizes moved into Export tab
- Marker Defaults split into Marker Defaults + Label Defaults
- Map tab restructure — mode toggle renamed "Base Map" / "Focus Map"
- Base Map mode: "Base Map Theme", "Base Map Layers", "Customize Theme"
- Focus mode: Country/Region (selectors only), Focus Theme (+ boundary toggles), Focus Layer Colors, Local Cities (master toggle), Base Map Labels (+ label style controls), Base Map Options
- Independent focus basemap — Focus mode has its own basemap theme selector in Base Map Options, stored in
focusMode.focusBaseTheme. Does not affect Base Map mode selection - Local Cities section — promoted from sub-panel, "Show Local Cities" master toggle controls visibility
- Base Map Labels section — label toggles + Label Style (size, halo) controls
- Clear confirmation modal — Location List "Clear" button now shows confirmation dialog
Fixed
- Basemap Style not reflecting current theme —
ThemeMod.apply()now refreshes panel (skips__live__to preserve color pickers) - Reset to defaults restoring wrong theme — reads
_baseThemeKeyfrom__live__before removing it - Reset leaving "Show base map" checked —
reset()now sets_showBaseMap=falseand restores target fill layer - Focus theme switch not clearing basemap —
applyFocusTheme()clears_showBaseMap,__live__, and debounce timer - Color picker closing immediately —
UI.refreshPanel('map')skipped for__live__incremental updates - resetView() resetting entire map — now only flies to world view (zoom 2), preserves theme/focus/settings
- Water label misleading — renamed to "Lakes / Rivers" in color keys
Changed
- Removed Reset Map button from focus mode panel sidebar
- Focus color pickers (Mask, Country, Neighbors) moved from Customize Style to Focus Layer Colors section
- Boundary toggles moved from Country/Region to Focus Theme section
[0.5.15] — 2026-04-02
Added
- Project tab — new top-level tab (first in icon rail) with editable project metadata
- Project Details fields: Project Name, Job Number, Owner (optional), Creator, Description (multi-line textarea, optional)
- Project Name or Job Number required —
.note-style hint below Job Number field - Panel header: project name (or job number as fallback) replaces panel title across all tabs. Falls back to default tab title when neither is set
- File Info section: Created date, Last saved, Last edited by, Schema version, Locations/Datasets/Views counts
- History section: collapsible placeholder for future change tracking
- Focus badge: passive "Focus" indicator in Map panel header when focus mode is active
- Project metadata in AppState:
projectName,projectJobNumber,projectOwner,projectCreator,projectDescription,lastEditedBy,projectCreated— all serialized in JSON save/load - Export filenames now use project name (falls back to app name)
- Removed redundant "Project name" input from Export Images panel (now managed in Project tab)
Changed
SIDEBAR_CONFIGgainsprojectpanel withfileinfoandhistorysectionsPANEL_METAgainsprojectentry with document iconProjectMod.save()setsprojectCreatedon first save, trackslastEditedByfrom Creator/Owner- Page title updates to project name when set
[0.5.14] — 2026-04-02
Added
- Transparent background export for all PNG types — "Transparent background" checkbox now works for Standard PNG, Hi-Res PNG, and focus mode exports
_makeTransparentStyle()sets background layer torgba(0,0,0,0), filters ocean features from water layer (kind!='ocean'), makes earth transparent in focus mode, and makes focus mask transparent- Standard PNG temporarily swaps live map style, captures, then safely restores via
finallyblock - Hi-Res PNG offscreen container sets
background:transparentto prevent CSS bleed-through - Lakes, rivers, and streams retain their colors — only ocean becomes transparent
- In focus mode, other countries are hidden (earth layer transparent), focused country rendered via
fm-target-fill
- Light focus fill warning — toast warning when exporting transparent + focus mode with a very light fill color (avg RGB > 200) that may be hard to see
- SVG transparent export (partial) — background rect
fill="none", earth + landcover skipped, ocean features filtered from water layer. Known issue: basemap layers (roads, parks) still render outside focused country (tracked as B9)
Fixed
- B4: Accordion closes on add location — removed auto-expand of Location List section after adding a location, which was closing the Add Location accordion due to exclusive accordion behavior
- B5:
__live__theme persists after Reset Map — addedclearTimeout(LiveStyleUI._debounceTimer)before filtering out__live__inresetMap(), preventing the debounced_applyLive()from re-adding it - FM7-R2: City data served from R2 —
CFG.geoCitiesswitched from localdata/path to Cloudflare R2 URL - SVG export: stale focus layer IDs — fixed skipIds set from old
focus-*names to actualfm-*layer IDs.fm-target-fillandfm-target-bordernow render in SVG; overlay layers (mask, neighbors, admin borders) correctly skipped
Known Issues
- B9: SVG transparent export artifacts — basemap layers (roads, parks, boundaries) render outside the focused country in transparent SVG export. Needs clip-path from focus geometry or alternative approach. Occasional line artifacts from tile-boundary seams
[0.5.13] — 2026-03-31
Added
- F3: High-res PNG export — 4× vector-re-rendered PNG export for print-quality output
- Offscreen MapLibre instance at
pixelRatio:4re-renders the base map from vector tile geometry at 4× resolution (not upscaling). Produces genuinely sharper coastlines, roads, borders, and map labels - GPU and 2D canvas limit detection — probes
MAX_TEXTURE_SIZEand 2D canvas allocation at first export, auto-downgrades scale if hardware can't support 4× - Robust tile-load waiting —
areTilesLoaded()polling with exponential backoff on the offscreen instance, plus post-idlejumpToto force view sync with the live map - Transparent background support — when "Transparent background" is checked, the offscreen style's background layer is set to
rgba(0,0,0,0)before render - Works in both regular and focus mode — focus mask, neighbor, border, and CityMod layers are all included via
getStyle()cloning - Download via
toBlob()instead oftoDataURL()for better memory efficiency on large canvases - Status message shows output dimensions and file size (e.g. "4400×2476px · 3.2MB")
- Re-entry guard prevents double-click triggering concurrent exports
- Offscreen MapLibre instance at
- Export panel: side-by-side PNG / PNG Hi-Res buttons — each saved view card and the current-view section now show both Standard and Hi-Res export buttons. Info line shows expected hi-res dimensions
Fixed
- Blurry marker/label overlays on Retina displays —
_compositeOverlays()SVG overlay now setswidth/heightto the output canvas pixel dimensions while keepingviewBoxat CSS pixel coordinates. The browser rasterises the SVG at the full output resolution instead of at CSS pixel size then stretching. Fixes both standard exports on Retina (existing bug) and hi-res exports
[0.5.12] — 2026-03-31
Added
- FM7: Focus city labels & markers system — dedicated city system for focus mode, independent of basemap labels
- CityMod module — new module managing curated + custom city data, MapLibre circle + symbol layers
- Curated city data — 3,885 cities across 225 countries from Natural Earth 10m populated places, filtered to top-30 per country and top-15 per US state. Served as GeoJSON from R2 (
ne_10m_cities_v1.json, 0.6MB) - City markers — MapLibre circle layer. Default: transparent fill with grey (#999) outline, 2px radius. Customizable: size, fill color, outline color, outline on/off
- City labels — MapLibre symbol layer (Noto Sans). Default: 9px grey (#888) text with 1px white halo. Customizable: size, color, halo size/color, position (above/right)
- Per-city control — scrollable checkbox list in Focus Layers panel. Toggle individual cities on/off without full rebuild (uses MapLibre filter expressions)
- Auto-overlap detection — flags cities within ~0.05 degrees of user markers with orange indicator
- City source mode — Default (curated list) / Custom Only (user-added) / Both (merged)
- Custom city add — text input with geocoding (Nominatim or MapTiler) in Focus Layers panel
- CSV city import — import cities from CSV with name,lat,lon columns (or name-only for geocoding)
- Style controls — Customize Style-matching UI with cs-row/cs-slider/cs-swatch pattern: marker size/fill/outline, label size/color/halo/position
- Export integration — city markers render in PNG export (native MapLibre layers), SVG projection export (
<circle>+<text>elements), and canvas projection export - Project serialization — customCities, disabledCities, and all city style settings persist in project JSON (additive, no schema bump)
- MapTiler geocoding provider — new
Geo._maptiler()method. CFG.geocoder supports'maptiler'option alongside existing nominatim/opencage/custom - Data prep script —
tools/prep-cities.jsNode.js script generates filtered city GeoJSON from Natural Earth source data - MapStyler: focus city marker/label defaults — new "Focus City Markers & Labels" section in mapprstyle.html (focus themes only). Controls for marker size/fill/outline and label size/color/halo/position. Exported as
focusCitySettingsin theme JS. ThemeMod.apply() applies settings to focusMode when loading focus themes
Fixed
- Basemap label toggle hiding city layers —
_toggleFocusLabelCategory('basemap')and_toggleFocusLabels()now excludefm-prefixed layers, preventing focus city labels from being hidden when basemap labels are toggled off - Per-city toggle removing all labels —
toggleCity()now uses_applyFilter()with MapLibre filter expressions instead of fullrefresh()rebuild, preventing race condition with label visibility - GeoJSON source tiling race condition —
_addLayers()reuses existing source viasetData()when possible, avoiding tiling failures when FocusMod rebuilds style simultaneously. Explicit visibility enforcement after layer creation
[0.5.11] — 2026-03-30
Fixed
- B6: Counties missing in focus single-state view —
addFocusedAdmin1()now loads county data (not admin-1) for US states. The toggle handler was calling the wrong data path - B7: High-res boundaries re-enables itself — added
useHighResuser preference to AppState. Checkbox now reflects user choice (not just whether data is loaded). AddedunloadHighRes()method with proper uncheck handler. Proactive high-res load no longer auto-upgrades without user opt-in - B8: Theme change resets zoom/view —
restoreFromState({skipZoom:true})in ThemeMod.apply idle handler. Also fixed "Show surrounding boundaries" toggles and proactive high-res callback to preserve map position - Focus mode: surrounding boundaries for US states — now shows neighboring US states (not foreign countries) when "Show surrounding boundaries" is checked. Uses
_usStatesDataas neighbor source with state-level ISO filtering - Focus mode: admin-1 boundaries too faint — increased line-width from 0.8 to 1.0 and tightened dash pattern from [4,2] to [3,1.5] for better visibility
- MK3: Transparent label background in exports — SVG export now adds subtle white halo (1.5px, 85% opacity) when label background is "none" and no explicit halo is set, matching the CSS
text-shadowbehavior
Added
- Focus country fill color — new
targetFillColorsetting with color picker in Layers > Customize Style > Focus Country > Fill. Newfm-target-filllayer between mask and border. Default white (#FFFFFF) - MK1: Theme-defined marker defaults —
ThemeMod.apply()now appliesmarkerSettingsfrom regular custom themes (markerScale, border, label styling) - FM6: Decoupled Boundaries color — Boundaries color/width in Customize Style no longer syncs to focus border colors. They are now independent
- F5: Tile load guarantee —
_settle()now usesareTilesLoaded()polling with exponential backoff (250ms initial, 1.5x growth, 2s cap, 15s max) instead of fixed 4s timeout - Lakes layer (PE1) — Natural Earth 10m lakes (1,355 features) rendered in ProjectionExportMod on both canvas and SVG paths.
GeoDataCache.lakes()fetches real lake GeoJSON - MapStyler: color scheme dropdown (MS1) — theme editor can define default marker color palette (standard/pitchbook/slate/slateGreyLight/warm)
- MapStyler: marker & label defaults (MS2) — theme editor section with enable checkbox, marker scale/border/numbers, label position/background/size/color/halo controls
- MapStyler: targetFillColor in focus themes — Focus Colors > Focus Country > Fill color picker
Changed
- Geodata: US counties upgraded — Census 20m (27K pts) → Census 5m (225K pts, 8.4x more detail). County and state boundaries now from same Census source, eliminating misalignment
- Geodata: US states upgraded — Natural Earth 10m → Census 5m (matches county data)
- Geodata: Admin-1 boundaries upgraded — sparse line fragments → polygon-derived boundaries from Natural Earth 10m admin-1 polygons. Canada improved 9.4x (1,857 → 17,475 pts). 240 countries covered. File size reduced from 7.1MB to 4.6MB
- Geodata: All data served from Cloudflare R2 — admin-1, US states, US counties, and lakes all moved from local
data/topmtiles-proxy.pmppr.workers.dev(resolves I1)
[0.5.10] — 2026-03-30
Fixed
- PNG export now includes markers and labels — previously
ExportMod.png()only captured the MapLibre WebGL canvas, which excluded DOM-based markers (maplibregl.Marker) and labels (maplibregl.Popup). New_compositeOverlays()helper builds an SVG overlay fromMarkerMod.exportEl()+LabelMod.exportElements(), renders to an offscreen Image, and composites onto the map canvas viadrawImage(). Works for both regular and focus mode exports, with and without crop - Version flash on load — hardcoded
v0.3bin the HTML header#brandVercaused a brief flash of the wrong version before JS init updated it tov0.5fromCFG.appVersion. Now matches the current version in the initial HTML
Changed
- SVG export restricted to focus mode only — SVG export is now only available when the map is in focus mode. SVG buttons are hidden in the Export panel UI for regular-mode views and for the "current view" when in regular mode. Runtime guard in
ExportMod.svg()rejects with error message if called outside focus mode - Saved views store mapMode —
AppState.addView()now recordsmapMode('regular' or 'focus') on each saved view. Export panel shows "Regular" or "Focus" tag on each view card. SVG button appears only on focus-mode views, even when currently in regular mode - Export panel view cards — each view card now displays its mode (Regular/Focus) alongside zoom and crop info
[0.5.9] — 2026-03-29
Added
- d3-geo projection export system — parallel export pipeline using d3-geo projections with transparent backgrounds, completely independent of MapLibre. Opt-in via "Enable projection export" checkbox in Export panel
- 9 projection types — Albers USA (composite), Albers Equal Area, Conic Equal Area, Lambert Conformal Conic, Plate Carrée, Natural Earth, Web Mercator, Equal Earth, Orthographic (Globe)
- 5 region presets — CONUS, North America, Europe, Asia Pacific, World. Each auto-configures projection type, center, and scale
- Live preview canvas — 280×158 preview in the Export panel with checkerboard transparency background, updates in real time as settings change
- Auto-fit to markers — computes optimal projection center and scale to frame all visible marker locations with 8% padding
- Projection colors — 4 color pickers (Land, Borders, States, Water) + border width slider + toggles for state borders, country borders, state abbreviations
- Projection PNG export — renders to offscreen canvas at selected output size (up to 4K) with transparent background
- Projection SVG export — vector output with PPT bridge compatible
<g id="markers">structure, halo labels, background rects - Export routing — when projection export is enabled, existing PNG/SVG buttons automatically route to projection export. Dedicated "Projection PNG" / "Projection SVG" buttons also available
- GeoDataCache module — shared lazy-loading cache for Natural Earth / world-atlas GeoJSON (countries-110m, US states-10m). Prevents duplicate fetches when used alongside FocusMod
- Output size options — 1920×1080 (16:9), 2560×1440 (QHD), 3840×2160 (4K), 1280×960 (4:3), 1920×1440 (4:3 HD)
- CFG.geoLakes — config key for future lakes GeoJSON layer (placeholder URL)
- Project JSON persistence —
projectionExportsettings deep-merged on load, fully serialized on save
Changed
- Export panel — "Projection Export" section added at top (collapsed by default). Existing "Export Images" and "Export JSON" sections unchanged
[0.5.8] — 2026-03-29
Added
- Per-dataset marker size — each dataset gets a Size slider (0.5x–2.0x) in the Markers section, with Reset button to return to global default
- Default marker size — global size slider in Markers > Defaults, applies to all markers unless overridden per dataset
- Marker label styling — new controls in Markers > Defaults > Label Style: label size (8–18px), text color picker, halo size (0–3), halo color picker. All apply to both live map and SVG export
- Label position option — Above/Right chip buttons in Label Style. "Right" positions labels to the right of markers (matching finance map conventions); "Above" is the classic centered-above position
- Focus mode marker defaults — entering focus mode auto-applies: smaller markers (0.7x), no white outline, right-side labels, red text (#CC0000), white halo (1.0), no label background. All stashed/restored independently per mode
- Marker settings stash/restore — Regular and Focus modes maintain separate marker configurations (scale, border, numbers, label size/color/halo/position, per-dataset label backgrounds and sizes). Switching modes preserves each mode's settings
- Theme
markerSettingsinfrastructure — theme files can definemarkerSettings: { markerScale, markerBorder, markerLabelSize, markerLabelColor, ... }. Applied when switching focus themes. Prepared for mapprstyle.html editor integration - Datasets section in Data tab — "Add dataset" input and dataset list with Remove buttons moved from Markers > Defaults to Data tab for better discoverability
Changed
- Color scheme moved to Markers tab — dropdown + swatches moved from Settings > Color Scheme to Markers > Defaults > Marker Color Scheme. Settings tab now only contains Export Sizes
- Focus mode colorScheme fallback — defaults to
slateGreyLightwhen focus theme has nocolorScheme(was undefined/inherited). Regular custom themes default tostandard - Regular themes apply colorScheme — custom regular themes with
colorSchemeproperty now auto-set the marker palette when selected - Marker drop-shadow reduced — from
(0 2px 4px)to(0 1px 2px)for less visual center shift at low zoom - Marker defaults section renamed — from "Settings" to "Defaults"
Fixed
- Marker subpixel drift at low zoom — marker
randsizenow rounded to integers, eliminating fractional-pixel positioning that caused markers to appear offset from their coordinates when zoomed out - Missing focus theme script tags — 8 of 11 focus themes had no
<script>tags and only loaded via manifest.json on HTTP, notfile://. All 11 now have script tags - Focus White blue outline — default
targetBorderColorwas#94A3B8(slate blue), changed to#AAAAAA(neutral grey) matching Focus White theme - Focus mode label color — focus defaults incorrectly set marker labels to red (
#CC0000). Now uses dark (#1B2333) same as regular mode. Theme files can override viamarkerSettingsif needed
[0.5.7] — 2026-03-29
Added
- Reset Map confirmation modal — Reset Map button (both map control and sidebar) now shows a confirmation dialog before resetting. Explains what will be reset and confirms locations won't be deleted
- Slate Grey Light color scheme — new muted blue-grey marker palette with 8 tones, lighter than the existing Slate scheme
- Color scheme per focus theme — each focus theme specifies a preferred marker color scheme (
colorSchemeproperty). Focus White/Clean Minimal/MapTiler Light use Slate Grey Light; all grey/dark/slate themes use Slate - Independent mode customizations — Regular and Focus modes maintain separate
__live__theme customizations (colors, line widths, label styles, layer visibility, Show Base Map). Switching modes stashes/restores each mode's settings independently - Full JSON export of all settings — export now deep-clones settings including stashed
__live__themes for both modes,_showBaseMap, focus colors, and color scheme. Import restores everything so another user gets the exact same view
Changed
- Color scheme auto-switches on mode change — entering focus mode applies the focus theme's color scheme; returning to regular restores the previous scheme. Reset Map restores Standard
- Default
activeThemecorrected — was'voyager'(invalid key, fell back to Standard silently), now'standard'explicitly
Fixed
- Focus color customizations persist across mode switches — custom maskColor, targetBorderColor, neighborFillColor etc. no longer reset to preset defaults when switching regular→focus→regular
__live__theme survives mode switch — customizations in either mode are deep-cloned before switching and restored after, including line widths and label styles- Color picker scroll dismissal — panel scroll events (wheel/touchmove) blocked while native color picker is open, preventing Chrome from closing the picker on scroll
[0.5.6] — 2026-03-29
Added
- Focus theme support in mapprstyle.html — theme editor now supports creating and editing focus mode themes with Regular/Focus mode toggle, Focus Colors section (Mask, Focus Country, Neighboring Countries), filtered preset dropdown, and proper export/import with
_focusThemeflag andfocusColors
[0.5.5] — 2026-03-29
Added
- 8 new focus themes — Focus Grey Light, Focus Grey Dark, Focus Slate Grey, Focus Slate Finance, Focus Clean Minimal, Focus Finance, Focus MapTiler Light, Focus MapTiler Dark. Each derived from existing regular themes using their earth/water/boundary colors
- Focus Colors in Customize Style — 6 individual color controls organized into 3 sections: Mask (color), Focus Country (outline, internal), Neighboring Countries (fill, outline, internal). All update live and persist in JSON export/import
- Show internal surrounding boundaries toggle — nested under "Show surrounding boundaries", shows state/province lines for neighboring countries only (not the focused country). Uses dedicated GeoJSON layer filtered by country
- Visible lakes in focus themes — all white/grey focus themes now have water set slightly different from earth so Great Lakes etc. are subtly visible by default
- Focus theme creation skill (
docs/SKILL_focus_theme_creation.md) — comprehensive guide for creating focus mode themes - Updated regular theme creation skill (
docs/SKILL_theme_creation.md) — added visual editor reference, dropdown sorting, focus theme pairing
Changed
- Renamed "Focus Grey" to "Focus Grey Moderate" for clarity in the expanded theme list
- Focus Dark converted from blue-tinted to pure neutral dark grey throughout
- Focus White borders lightened (#AAAAAA outline, #BBBBBB internal — was #333333)
- Focus Grey Moderate country outline changed to #787878 (was #444444)
- Theme dropdown sorting — Focus themes: defaults first, separator line, themed variants below. Regular themes: 4 built-in defaults, separator, custom presets alphabetically sorted
- Focus White & Focus Grey Light neighbor fill changed to #FAFAFA, neighbor border to #CCCCCC
Fixed
- Toggling internal boundaries no longer removes neighbor admin-1 layer —
_toggleAdmin1uses directaddFocusedAdmin1instead ofrestoreFromStatewhich destroyed all layers - Neighbor admin-1 layers persist across theme switches — fixed missing
fmvariable declaration in_activateWithGeo; merged focused and neighbor admin-1 into single data fetch callback _ensureAdmin1callback queue — callbacks no longer dropped when concurrent loads requested
[0.5.4] — 2026-03-29
Added
- US county boundaries — when drilling into a US state in focus mode, internal boundaries show county lines from Census Bureau 20m data (
data/us_counties_v1.json, ~1MB simplified with mapshaper) - Granular focus label toggles — replaced single "Show labels" with three independent toggles: Country labels, State/region labels, City & other labels. Preferences persist in AppState.
- "Show base map through focus" toggle in Customize Style — lets users overlay the regular basemap through the focus filter for a terrain+focus hybrid view
- High-res borders on by default — 50m boundaries load proactively on focus activation, not just at zoom 7+
- Alaska/Hawaii support — region buttons now show the full US geometry (skip mainland extraction when regionKey is set)
Fixed
- Double boundaries in focus mode — basemap
boundaries_countrylayer hidden in focus mode (focus hasfm-target-border);boundaries(regional/state) layer visibility follows showAdmin1 toggle - Incomplete internal boundaries — switched from Natural Earth admin-1 GeoJSON (incomplete coverage) to basemap vector tile
boundarieslayer for state/province borders - Customize Style reverting to regular map —
_getLiveTheme()now seeds from current focus theme in focus mode, copying ALL flavor overrides (not just 8 keys) - Boundary width not scaling — focus border layers now use
_borderMul()to read width multiplier from__live__theme when created - Internal boundaries toggle not persisting — added
showAdmin1flag to AppState, guarded in_activateWithGeo()so toggle-off survives style changes - restoreFromState re-zoom (B1) —
skipZoomoption preventsfitBounds()from overriding saved mapView - LiveStyleUI debounce leak (B5) —
reset()clears pending debounce timer
Changed
- Focus mode defaults — all toggles off except high-res borders on first entry; US selected by default with no neighbors
- Reset Map fully clears all focus settings (isoCode, regionKey, all toggles, focusTheme,
__live__customizations, colors) and returns to world view with voyager theme - Clear Focus button replaced with Reset Map button (same function as top-bar Reset Map)
- UI text cleanup — removed 5 redundant labels: Place name, Map style, Country, Focus style, View name
- Map Layers panel syncs with actual map layer visibility before rendering (no stale toggle states)
[0.5.3] — 2026-03-26
Added
- Customize Style section in Layers tab — live editing of the active base map's visual properties without creating custom themes. Includes:
- 8 color pickers with swatch + hex input (Land, Water, Parks, Roads, Buildings, Borders, Labels, Label Halo)
- Line width multipliers for Roads and Boundaries (0.2x–3x range sliders)
- Per-category label size and halo width controls (Country/City/State/Road/Water)
- "Reset to default" button — resets to the active base theme's original values (regular or focus mode, depending on current mode)
- Creates a transient
__live__custom theme stored inAppState.settings.customThemes
- Focus border customization — boundary color/width sliders in Customize Style now also update
fm-target-border,fm-admin1-border, andfm-neighbors-borderlayers viasetPaintProperty ThemeMod._syncFocusPanelToggles()— refreshes Focus Layers and Focus Country panel checkboxes after any Map Layers toggle change
Fixed
- Zoom reverting on customize edit —
ThemeMod.apply()now saves center/zoom beforesetStyle()and restores withjumpTo()immediately after. View no longer resets on every style change. - Map colors changing with non-voyager themes —
_getLiveTheme()now uses_preFocusTheme(the stashed regular theme) when in focus mode, so the__live__theme seeds from the correct base flavor instead of the focus theme. - Road width reversed — fixed scale expression generation. Width multipliers now correctly increase road width with higher values.
- Layer toggle sync — toggling labels/boundaries in Map Layers now updates the "Show labels" / "Show internal boundaries" checkboxes in Focus Layers and Focus Country panels.
- Focus mode black border underneath — boundary color changes now update both basemap boundaries AND focus-specific border layers, and persist to
AppState.settings.focusModesorestoreFromStateuses the right colors. - Focus boundary width not changing — boundary width slider now multiplies the hardcoded focus border widths (1.5/0.8/0.5) by the slider value.
- Label toggles turning on borders — fixed unintended side effects when editing layer properties in focus mode.
- Scale expressions for line widths — replaced broken
['*', base, multiplier]with correct['interpolate', ['linear'], ['zoom'], ...]zoom-dependent scale expressions that preserve the original zoom breakpoints.
Changed
ThemeMod.apply()preserves map view (center + zoom) across style rebuildsLiveStyleUI._getLiveTheme()seeds from base map theme, not current theme (handles focus mode correctly)LiveStyleUI.resetToDefault()is mode-aware: resets to focus theme defaults when in focus mode, base theme defaults in regular mode
[0.5.2] — 2026-03-26
Changed
- Sidebar restructured to 7 tabs — DATA, MAP, MARKS, LAYERS, VIEWS, EXPORT, CONFIG. Each tab has a focused purpose instead of overloaded panels.
- Dataset Visibility moved from Layers to DATA tab for easier access alongside location management.
- Map tab slimmed — Regular mode: Base Style only. Focus mode: Country/Region + Focus Theme only.
- Config tab slimmed — Only Color Scheme + Export Sizes. Custom Map Theme builder removed.
- Data tab — "Set as HQ" checkbox moved below Map Label field (before dataset dropdown).
- Focus country quick toggles restored — Show surrounding boundaries, Show internal boundaries, Show labels appear inline when a country is active.
Added
- MARKS tab (new) — Markers section (shape/color per dataset + HQ color), Labels section (show/hide + bg style per dataset), Settings section (HQ/border/numbers toggles, label font, dataset management).
- LAYERS tab (new) — Map Layers (12 collapsible categories, visible in both modes), Focus Layers (greyed out with note when not in focus mode, active when focus country selected).
PanelContent.markerdefaults()— combines marker toggles, label font, dataset add/remove (previously split across sfont/slabels/smarkers).PanelContent.dsvisibility()— dataset show/hide withUI.renderLocListIfOpen()call.PanelContent.maplayers()— layer category toggles (extracted from old layers section).PanelContent.focuslayers()— focus mode quick toggles with disabled state when not in focus.docs/TESTING-PLAN.md— 80+ test cases covering all tabs, marker positioning, mode switching, JSON persistence.
Removed
PanelContent.sfont()— consolidated intomarkerdefaultsPanelContent.slabels()— consolidated intomarkerdefaultsPanelContent.smarkers()— consolidated intomarkerdefaultsPanelContent.scustomtheme()— custom theme builder UI removed from Config- Old
PanelContent.layers()— split intodsvisibility+maplayers - Duplicate "Map base style" dropdown from Config tab
- Mode toggle from Layers panel — only on Map panel now
Fixed
- Dataset visibility toggle — added
UI.renderLocListIfOpen()to onchange handler so location list updates when toggling - Marker position bug — verified resolved by previous session's
_buildMask()fix for MultiPolygon countries
[0.5.1] — 2026-03-26
Changed
- Map Mode toggle — persistent "Regular Map" / "Focus Map" segmented control at top of Map panel. Completely separates the two map experiences — no theme bleeding between modes.
- Focus themes rewritten — Focus White/Grey/Dark now use uniform fills (pure white, grey, or charcoal for ALL layers: earth, water, landuse, roads, landcover). Only boundaries and labels visible. No tinted colors.
- Focus theme isolation —
focusMode.focusThemestored independently fromsettings.theme. Switching modes stashes/restores the regular base style. Country selection persists across mode switches. - SIDEBAR_CONFIG mode filtering — sections have
modeproperty ('regular','focus', or omitted for both)._renderPanel()filters byAppState.settings.mapMode.
Added
AppState.settings.mapMode—'regular'or'focus', controls which panel sections appearAppState.settings.focusMode.focusTheme— independent focus theme selection (default: 'Focus White')UI.setMapMode(mode)— handles theme stashing/restoring, focus layer management on mode switchUI.applyFocusTheme(name)— applies a focus theme without touching regular theme stateFocusMod.deactivateKeepState()— removes map layers but preserves country/region selection for re-entry_focusTheme: trueflag on Focus theme files for reliable filtering from customThemes- PanelContent 'focustheme' — Focus theme dropdown (White/Grey/Dark)
- PanelContent 'focuscountry' — Country/region picker with toggles (relocated from old 'focusmode')
- Reset Map button — top-left map control, resets to Regular mode + voyager + world view
- Transparent background toggle — in Export panel, SVG export uses
fill="none"on background rect - Backward compatibility — project load infers
mapModefromfocusMode.activeif absent
Removed
- Old
focusmodepanel section (replaced byfocustheme+focuscountry) - Focus Region dropdown from Layers section (country selection now in focuscountry)
- Auto-theme-switch logic from
FocusMod.activate()(mode toggle owns theme switching) - Theme layerVisibility override pass from
_vectorStyle()(toggles are sole authority)
[0.5] — 2026-03-22 (updated 2026-03-23)
Phase Goal
Cloudflare R2 tile hosting with full z15 world basemap (130.9GB). Protomaps vector themes with 24 preset styles. Standalone theme editor (mapprstyle.html) exposing all 80+ flavor properties. Focus Mode for country isolation with dedicated themes.
Infrastructure
- GitHub repo:
https://github.com/teamtools2026-coder/pmppr.git(migrated fromtensmedia1/pmtesting) - Cloudflare R2 bucket:
pitchmappr-tiles(created) - Vercel:
https://pmppr.vercel.app/
Added
- Protomaps official basemap themes — replaced 12 hand-built layer definitions with
@protomaps/basemapsv5 (~80 professionally styled layers). Road casings, building fills, park patterns, POI icons, water gradients. CDN loaded, no build step. - Granular layer toggle system — replaced 12 flat layer group checkboxes with 12 collapsible categories containing ~35 individual layer toggles. Each category has a master checkbox (with indeterminate state for mixed), chevron expand/collapse with CSS animation, and per-layer toggles. Categories: Terrain, Landuse (8 types: Parks, Hospital, Industrial, School, Zoo, Airport, Pedestrian, Beach), Water (Bodies/Rivers/Streams), Roads (Highways/Major/Minor/Links/Other/Railways), Tunnels, Bridges, Structures (Buildings/Piers), Boundaries (Country/Regional), Place Labels (Country/City/State/Neighborhood), Road Labels (Major/Minor/Shields/One-way/Addresses), Water Labels (Oceans/Lakes/Waterways/Islands), POIs.
- Custom Theme Builder — create named custom themes with granular control over base map colors, line widths, label visibility, and label styling. Saved as presets alongside built-in themes.
- 8 color pickers: Land, Water, Roads, Borders, Buildings, Parks, Label text, Label halo
- Line width multipliers for Roads and Boundaries (0.2x–3x)
- Per-type label visibility (Country/City/State/Road/Water)
- Per-type label styling: text transform (Mixed case/UPPERCASE/Normal), halo width (0–3px)
- Live preview toggle for real-time map updates while editing
- Edit and delete saved custom themes
- Custom themes shown with ★ prefix in all theme dropdowns
- 24 built-in theme presets — Slate Grey, Clean Minimal, Clean Minimal Warm, Clean Minimal Dark, Slate Finance, Warm Presentation, Dark Boardroom, Corporate Blue, Monochrome Ink, White Paper, Warm Parchment, Finance (No Labels), MapTiler Light, MapTiler White, MapTiler Toner, MapTiler Dark, Midnight Blue, Frost, Soft Atlas, Corporate Light Blue, Flat Design, Focus White, Focus Grey, Focus Dark. Each sets all 87 flavor properties for a complete look.
- 3 gap-filling themes (2026-03-23) — designed to complete the warm/cool and light/dark matrix:
- Midnight Blue — base
dark, Bloomberg terminal aesthetic. Deep navy earth (#101828), blue-black water, slate-blue roads, ice-blue labels. Cool-toned dark (vs grey MapTiler Dark and charcoal Dark Boardroom). - Frost — base
white, Stadia Alidade Smooth inspired. Cool blue-grey earth (#E4E8EC), steel blue water (#A0BCDA), slate labels. The only cool-toned light theme. - Soft Atlas — base
light, National Geographic / premium atlas style. Warm parchment earth (#E8E0D4), richer green parks, medium blue water, brown labels. More saturated than finance themes — for investor decks about real assets.
- Midnight Blue — base
- 2 additional themes (2026-03-23):
- Corporate Light Blue — base
white, soft blue-tinted earth (#EAEEF4), powder blue water (#B4CCE4), white roads, slate-blue labels. Light blue companion to Corporate Blue. - Flat Design — base
white, Apple/Stripe/Vercel tech aesthetic. Flat #F2F2F2 earth, desaturated pastels, muted grey water (#D0DCE8), razor-thin roads (0.4-0.7x), hidden minor labels/neighborhoods/rails/tunnels/POIs, Noto Sans Medium font, 0.95x label scale. Ultra-clean modern.
- Corporate Light Blue — base
- 3 Focus Mode themes (2026-03-23) — optimized for country isolation with prominent state/regional boundaries:
- Focus White — base
white, pure white earth (#FAFAFA), #888 boundaries at 1.8x width, barely-visible landuse, clean labels, no shields/tunnels/POIs. Clean canvas for country isolation. - Focus Grey — base
white, light warm grey earth (#E8E8E4), #777 boundaries at 2.0x width, visible roads/parks/cities, subtle landuse differentiation. More detail than Focus White. - Focus Dark — base
dark, deep charcoal earth (#20232A), #5C6070 boundaries at 1.8x width, silver city labels, dark blue water. Country shape pops against dark background. Presentation-optimized.
- Focus White — base
- 4 MapTiler-inspired themes (2026-03-23) — cloned from MapTiler Cloud styles using the MapTiler fork (a local worktree) as visual reference, with colors sampled from the MapTiler style JSONs:
- MapTiler Light (streets-v2 clone) — base
light, warm beige earth, muted gold highways, cream major roads, bright blue water, green parks, road shields + labels, POIs enabled - MapTiler White (basic-v2 clone) — base
white, near-white earth with warm tint, uniform white roads, saturated blue water, subtle forests, no POIs - MapTiler Toner (toner-v2 clone) — base
white, high-contrast black & white, black water + road casings + buildings, white road fills, graphic poster style - MapTiler Dark (dataviz-dark clone) — base
dark, pure greyscale (no color), dark grey earth, very dark water, medium grey labels with black halos, minimal layers
- MapTiler Light (streets-v2 clone) — base
- CustomThemeUI module — editor state management, color shorthand expansion (e.g. setting "Roads" color sets 6 flavor keys), live preview via temporary
__preview__theme ThemeMod._getVectorPaints()— dynamic SVG export colors for both built-in and custom themes. Replaces broken static_vectorPaintslookup.ThemeMod._buildFlavor()— clones Protomaps base flavor and merges custom overrides forbasemaps.layers()generation- Per-view dataset visibility UI — each saved view now shows dataset toggle checkboxes in the Saved Views panel. Overrides persist in
overrides.datasetVisibilityand are respected by both PNG and SVG export. - "Export current view SVG" button alongside existing PNG button in Export panel
customThemesadded toAppState.settingswith validated save/load in project JSON- Accordion sidebar sections — only one section open at a time per panel. Clicking a section header closes all siblings.
- Marker numbers off by default — new
markerNumberssetting (default false). Toggle in Settings > Marker Defaults: "Show numbers inside markers" - Dataset visibility above Map Layers — moved to top of Layers panel, separate from base map layer toggles
- Default 2 datasets —
initDefaults()now creates Dataset_1 (blue) and Dataset_2 (red) - Color picker: theme swatches + hex input — replaced native
<input type="color">with theme palette swatches (selected = blue border) + hex text input. Applies to both dataset colors and HQ color. - HQ option on manual add — "Set as HQ location" checkbox in the Add Location form
- Export project name — project name input in Export panel. Filenames:
projectname_YYYYMMDD_HHMM.png/svg - Geolocation default view — map centers on user's country via
navigator.geolocationon load (fallback: world view).MapMod._homeCenter/_homeZoomstore the home position. - Reset View / View All buttons — "View all" fits all visible locations, "Reset view" returns to geolocation home position. Both in Location List.
AppState.datasetExists(id)— existence check method added to prevent silent fallback todatasets[0]- Focus region (country/continent isolation) — dropdown in Layers panel with 11 countries + 6 continents + World. Flies to predefined bounding boxes via
MapMod.focusRegion(key). - FocusMod — Country isolation mode — full country focus using Natural Earth GeoJSON data. Features:
- Country selector dropdown in Map panel with all countries
- Inverted polygon mask hides everything outside the target country
- Basemap visible inside the country (roads, labels, cities, parks)
- Optional neighbor country silhouettes (grey shapes for context)
- US-specific sub-regions: CONUS, Alaska, Hawaii quick-select buttons
- US state selector dropdown for individual state focus
- Dual-resolution Natural Earth data: 110m (low-res, instant) + 50m (high-res, on-demand)
- "High resolution boundaries" toggle loads 50m data (~2.3MB)
- Layers inserted before basemap labels using
beforeIdfor correct z-ordering - State persists in
AppState.settings.focusMode(active, isoCode, regionKey, showNeighbors) restoreFromState()re-applies on project load and theme change- 3 dedicated Focus themes (Focus White, Focus Grey, Focus Dark) optimized for isolation
- Data files in
/focus_mode/: ne_110m_slim.json (254KB), ne_50m_slim.json (2.3MB)
Fixed
- Marker drift on pan/zoom — added
anchor:'center'tomaplibregl.Markerconstructor. Markers now stay pinned to their geographic coordinates. - Focus Mode survives theme change —
ThemeMod.apply()now listens for MapLibreidleevent aftersetStyle()and callsFocusMod.restoreFromState()to re-add mask/neighbor/border layers. Previously, changing the base style destroyed all FocusMod layers. - Marker panel scroll reset — shape button clicks now update SVG fill/stroke inline without re-rendering the panel. Scroll position preserved.
- SVG export colors —
_vectorSvgBase()now uses dynamic_getVectorPaints()instead of undefined_vectorPaintsstatic lookup. Earth, water, boundary, park, road colors now match the active theme in SVG exports. LabelMod.exportElements()now accepts optionalisDsVisiblefilter for per-view visibility- SVG export tile seam fix — background rect now uses computed earth fill color (not map background color) so tile-boundary gaps are invisible. Added
<clipPath>to constrain all paths to viewport. Increased coordinate precision to.toFixed(2). - SVG basemap labels —
_vectorSvgLabels()renders street names, place names, and POI labels from symbol layers as<text>elements with halo support, line-placement rotation, and dedup. New Layer 2 in SVG output.
Known Issues — SVG Export (v0.5)
Status: Alpha quality. The pure-vector SVG approach has fundamental limitations due to tile-based geometry extraction. A hybrid approach (raster base + vector overlays) is the recommended path forward.
Root Cause Analysis
MapLibre renders vector tiles onto a WebGL canvas with seamless tile stitching, anti-aliasing, and GPU blending. Our SVG export uses queryRenderedFeatures() to extract raw tile-clipped geometry and re-project it as <path> elements. This reverse-engineering approach cannot fully replicate MapLibre's rendering because:
- Tile clipping: Each tile contains geometry clipped to its boundary. MapLibre hides the seams; SVG cannot.
- Label placement: MapLibre's collision detection engine (priority-based, curved text, icon pairing) cannot be replicated from feature queries alone.
- Data-driven styling: Per-feature paint values (road width by class, etc.) are evaluated by the GPU;
queryRenderedFeatures()only exposes layer-level paint.
Issue Table
| # | Issue | Severity | Details |
|---|---|---|---|
| 1 | Tile boundary seams | High | At z12+, earth/water/landcover polygons from adjacent tiles don't align perfectly. Sub-pixel gaps (0.001–0.01px) let the background color through, creating visible "quarters" or grid lines. Mitigated in v0.5.1 by using earth color as background + <clipPath> + higher coordinate precision (.toFixed(2)), but water/landcover seams persist. This is fundamental to tile-based vector data and cannot be fully fixed without polygon merging. |
| 2 | Inconsistent tile loading | High | _settle() uses idle event + 4s timeout fallback. But idle can fire between tile batches — the map briefly idles while waiting for the next R2 fetch. Cold-cache R2 requests for 130GB PMTiles at z15 can take 10-30s. Export silently omits layers whose tiles haven't arrived. No retry, no progress indicator. |
| 3 | Symbol layer text only | Medium | Basemap labels export text position/color/halo but NOT icons (POI markers, shields, one-way arrows). Symbol layer icons require sprite sheet extraction — not implemented. |
| 4 | Font mismatch | Medium | SVG <text> uses font-family from MapLibre layout (e.g., "Noto Sans Medium") but the SVG viewer may not have that font installed. Fallback to sans-serif. No embedded font support. |
| 5 | Label collision | Medium | MapLibre handles label collision avoidance in the renderer, but SVG text placement uses raw feature positions — overlapping labels are not de-conflicted. Dedup by proximity (15px grid) helps but doesn't match MapLibre's algorithm. |
| 6 | Focus Mode mask | Untested | FocusMod layers (focus-mask, focus-neighbor-fill, etc.) are in the skip list — focus mask does not export to SVG. Country isolation not reflected in export. |
| 7 | Per-feature paint variation | Low | Paint values read from features[0].layer.paint. Layers with data-driven styling (e.g., road width by road class) use uniform width for all features. |
| 8 | No line caps/joins at tile boundaries | Low | Line features split across tiles render as separate paths. At tile boundaries, stroked lines may show gaps or missing joins. |
| 9 | Large file size at high zoom | Info | z15 Manhattan exports ~400-500KB SVG with 32 layer groups and thousands of paths. Complex scenes could exceed 1MB. |
Recommended Solution: Hybrid SVG Export
Instead of pure-vector SVG, use a hybrid approach: high-DPI raster <image> for the basemap + vector <path>/<text> for markers and labels. This:
- Eliminates tile seams (raster is pixel-perfect from WebGL canvas)
- Preserves all basemap labels, icons, and collision avoidance (rendered by MapLibre)
- Keeps markers/labels as editable vector elements for Illustrator/PowerPoint workflows
- Supports 4x DPR (5512×3100px for 16:9) for print-quality output
- Trades infinite scalability for visual accuracy — acceptable for the finance/presentation use case
A standalone demo exists at demo/test-hybrid-svg.html testing this approach with configurable DPR (2x/3x/4x), crop ratios, and export formats (hybrid SVG, high-res PNG, pure-vector SVG).
Changed
- ThemeMod rewritten —
_vectorStyle()now uses_buildFlavor()+basemaps.layers()from Protomaps official theme library. Supports custom theme keys (custom:ThemeName) alongside built-in keys._layerDefsreplaced by_layerCategoriesfor granular toggle UI. - ThemeMod.apply() — handles
custom:prefixed keys, syncs boththemeSelectandthemeSelectSettingsdropdowns - Theme select labels updated: Light (was Voyager), White (was Positron), Grayscale (was Light), Dark
- New dependency:
@protomaps/basemaps@5via CDN - Renamed
pitchmappr_v04.html→index.htmlfor Vercel deployment compatibility loadJSON()settings whitelist expanded to includecustomThemes,markerNumbers,projectNamewith per-theme validation- UI._renderPanel() — accordion logic: first
closedDefault:falsesection opens, rest closed - UI.toggleSection() — closes all sibling sections when one is opened
- Location List buttons — "Clear all" / "Fit all" replaced with "View all" / "Reset view" / "Clear"
Infrastructure
- GitHub repo initialized:
https://github.com/tensmedia1/pitchm.git(now athttps://github.com/teamtools2026-coder/pmppr.git) - Cloudflare R2 bucket
pitchmappr-tilescreated - Cloudflare Worker
pmtiles-proxydeployed athttps://pmtiles-proxy.pmppr.workers.dev/ - R2 API token configured in rclone
- PMTiles z10 extract (3.66GB, global, maxzoom 10) — uploaded to R2 (superseded by z15)
- PMTiles z15 full world (130.91GB,
basemap_full.pmtiles) — uploaded to R2 and live. Street-level roads, buildings, landuse detail.
Removed
- Master "Show map labels" checkbox — replaced by individual label type toggles
New: mapprstyle.html — Standalone Theme Editor
mapprstyle.html— single-file HTML theme editor. Same CDN stack (MapLibre + PMTiles + Protomaps). Opens directly in Chrome.- 17 sidebar sections covering all 87 flavor properties + advanced controls:
- Color sections (13): Background & Earth, Landcover (7), Landuse (17), Water, Structures, Road Fills (7), Road Casings (7), Tunnels (10), Bridges (10), Infrastructure, Labels (14), POIs (8)
- Advanced controls (4): Line Widths (6 per-type sliders), Label Styles (5), Fonts, Language
- Inline layer toggles — every color section has per-row visibility checkboxes. Toggle a layer on/off right next to its color picker. Section headers have master on/off toggles with indeterminate state. Badge shows on/total count (e.g., "5/6").
- Font selection — 11 fonts across 2 CDNs: Protomaps (Noto Sans Regular/Medium/Italic), UNDP GeoHub (Open Sans Regular/Bold/Light/Semi Bold, Roboto Regular/Medium/Bold/Light). Glyph CDN switcher auto-updates available fonts.
- Language selector — 41 languages (ar, de, en, es, fr, ja, ko, zh-Hans, etc.). Labels update live on the map preview.
- Per-road-type width sliders — separate width multipliers for Highways, Major, Minor, Service, Paths/Other (0-3x each), plus Boundaries.
- Building opacity slider — 0-1 range in Structures section.
- Boundary dash pattern — Solid, Dashed, Dotted, Dash-dot options in Infrastructure section.
- Label font size multiplier — 0.5x-2.0x scale slider in Label Styles section.
- POI icon toggle — show/hide POI icons independently of POI text labels.
- Sprite set indicator — shows active sprite sheet (auto-selected from base flavor) in Base Style section.
- Live map preview — real-time updates on every change (60ms debounce). Same tile source as pitchmappr.
- Data-driven UI — all sections rendered from
FLAVOR_SCHEMAarray. No hand-coded HTML per property. - Export as
.js— generates self-registering IIFE files that pitchmappr loads viawindow.PITCHMAPPR_THEMES. Includes all new properties (font, language, opacity, widths, dash, label scale, POI icons). - Import — load
.jsor.jsontheme files. - 16 presets — all pitchmappr presets available in dropdown.
- Location jumper — NYC, Manhattan z16, London, Paris, Tokyo, Dubai, Alps, World.
- pitchmappr integration —
index.htmlcheckswindow.PITCHMAPPR_THEMESon init, merges external themes into custom themes list.
Theme Architecture: /themes/ Folder
- 24 preset themes extracted to individual
.jsfiles in/themes/folder — single source of truth shared by bothindex.htmlandmapprstyle.html. themes/manifest.json— lists all 24 theme filenames. Used by HTTP manifest loader for dynamic discovery of additional themes beyond the built-in presets.- Dual loading strategy — static
<script>tags (works onfile://protocol) + manifest loader on HTTP (picks up additional themes). Deduplicates by name. _preset: trueflag — built-in presets marked so UI can distinguish them from user-created themes.- layerVisibility compatibility shim —
index.htmlmaps granular keys (rlabel_major,struct_buildings, etc.) to coarse keys (roadLabels,buildings, etc.) forThemeModcompatibility. - Adding a new theme — drop
.jsfile in/themes/, add filename tomanifest.json. Both apps load it automatically on HTTP. Forfile://, also add a<script>tag. - TODO v1.0: Replace manifest + script tags with server-side
GET /api/themesendpoint for fully dynamic discovery.
Documentation
- Protomaps Custom Theme Reference —
docs/protomaps-custom-themes.md— complete reference for all 87 flavor properties (72 flat + 8 POI + 7 landcover), 73 layers, zoom-dependent behaviors, and 16 preset theme specifications. Updated with font, language, and advanced control documentation. - Theme Creation Skill —
docs/SKILL_theme_creation.md— detailed step-by-step skill for creating fully polished map theme presets using all available Protomaps options. - mapprstyle Spec —
docs/MAPPRSTYLE_SPEC.md— architecture, UI layout, export format, and pitchmappr integration for the standalone theme editor. Updated with 17 sections, manifest loading, and all new features. - SPEC.md updated to v0.5 — reflects R2 hosting, z15 basemap, 21 preset themes (including 4 MapTiler-inspired),
/themes/folder architecture, mapprstyle.html, Protomaps themes, custom theme builder, granular layer toggles. - MapTiler fork — parallel branch
funny-blackwith MapTiler Cloud vector tiles for side-by-side quality comparison. Worktree at a local worktree
Planned (remaining)
-
Test SVG export resolution with z15 tile detail (Manhattan z16)— tested, alpha quality. 32 layer groups export but with tile seams, missing labels, font mismatches. See Known Issues table above. -
Compare MapTiler fork quality vs Protomaps+z15— 4 MapTiler-inspired themes created from side-by-side comparison -
Focus Mode implementation— FocusMod with Natural Earth data, country/state isolation, 3 dedicated themes -
SVG export: tile seam mitigation— background=earth color, clipPath, .toFixed(2) precision. Helps but doesn't fully solve (fundamental tile geometry issue). - Hybrid SVG export — integrate raster base (4x DPR canvas capture) + vector marker/label overlays into ExportMod. Demo prototype at
demo/test-hybrid-svg.html. This is the recommended path forward for v1.0. - High-res PNG export — add 3x/4x DPR option to ExportMod by temporarily inflating map container, re-rendering, capturing, restoring. Quick win for print-quality output.
- SVG export: tile load guarantee — replace
_settle()timeout withmap.areTilesLoaded()polling + exponential backoff. Show "Waiting for tiles..." progress indicator. - SVG export: Focus Mode support — include focus mask/neighbor layers in SVG output
- Focus Mode: improve low-res shapes (110m data looks coarse), neighbor country labels
- Clean up stale multipart upload entry in Cloudflare R2 dashboard
- Host Calibri PBF glyphs on R2 for custom font support (use MapLibre font-maker to generate from TTF)
- v1.0: Replace manifest.json + script tags with server-side API endpoint for dynamic theme discovery
- First git push to remote
[0.4] — 2026-03-21
Added
- MapLibre GL JS replaces Leaflet 1.9.4 as the map rendering engine
- PMTiles support via configurable
CFG.pmtilesUrl— set URL for vector tiles, leave empty for CartoDB raster fallback - Dual-mode ThemeMod: vector style paint properties (PMTiles) or raster tile URLs (CartoDB), both via MapLibre
- True vector SVG export — when PMTiles configured, SVG base map is rendered as
<path>elements viaqueryRenderedFeatures(), infinitely scalable with no raster degradation - Comprehensive layer toggles — Land, Water, Parks/Landuse, Major roads, Minor roads, Country borders, State/Province borders, Water labels, Country labels, City labels. Each toggle controls both live map and SVG export visibility.
- Map label toggle (vector mode only) via
ThemeMod.toggleLabels() preserveDrawingBuffer: trueon MapLibre Map for lossless canvas exportmapLabelssetting in AppState for label visibility persistence- High-DPI raster fallback for SVG export when PMTiles not configured
Changed
- Breaking: CDN dependencies — Leaflet + html2canvas removed, MapLibre GL JS 4.7.1 + PMTiles 3.2.0 added
- Export system uses
map.getCanvas()instead of html2canvas — lossless at device pixel ratio, no scale:3 hack _applyCrop()usesdevicePixelRatioinstead of hardcodedSCALE=3- Markers are now
maplibregl.Markerwith custom DOM elements (wasL.markerwithL.divIcon) - Labels use
maplibregl.Popupon hover (was LeafletbindTooltip) MarkerMod.makeIcon()removed — replaced byMapMod._makeMarkerEl()- Marker visibility uses
display:nonetoggle (wasaddLayer/removeLayer) - Theme select reduced from 6 options to 4 (voyager, positron, light, dark) plus labels checkbox
- Old theme keys (
light_all,dark_nolbls, etc.) auto-normalized for backward compatibility with saved projects - All coordinate conversions (
[lat,lng]↔[lng,lat]) handled at MapMod boundary only map.invalidateSize()→map.resize()map.setView()→map.jumpTo()map.latLngToContainerPoint()→map.project()
Removed
- Leaflet 1.9.4 dependency
- html2canvas dependency
- Runtime tooltip CSS injection IIFE (styles now in main
<style>block) MarkerMod.makeIcon()(Leaflet-specific)MapMod.tileproperty (tile layer management now handled by MapLibre style system)
Known Issues
- Markers drift on pan/zoom —
maplibregl.Markerelements don't stay anchored to their geographic coordinates. Likely a CSS anchor/offset issue inMapMod._makeMarkerEl(). Fix pending.
[0.3c] — 2026-03-21
Added
- Crop export math in
ExportMod._cropRect()andExportMod._applyCrop()— PNG and SVG exports now respect the saved view's crop ratio - Crop-space coordinate translation for markers and labels in export (markers and labels outside the crop window are excluded)
Changed
LabelMod.exportElements()now acceptscropX/cropYoffset parameters for crop-space alignment- SVG export uses cropped canvas as raster base instead of full map capture
- File size grew from ~96 KB to ~109 KB
Fixed
- SVG and PNG exports now match the crop overlay preview (previously exported the full viewport regardless of ratio)
[0.3b] — 2026-03 (mid)
Added
- Option B rail layout: 48 px icon rail + 284 px slide panel (flex child, not absolute)
- Config-driven sidebar via
SIDEBAR_CONFIG— adding a section requires only two changes (config entry +PanelContentfunction) PANEL_META— panel titles and SVG icons for rail buttons- Named color themes: Standard, Pitchbook, Slate/Minimal, Warm with swatch preview in Settings
- Live crop frame overlay on map when saving a view (
ViewsMod.showCropOverlay) - HUD display: zoom level + coordinates at bottom of map
map.invalidateSize()fires 210 ms after panel open/close to prevent grey tile strip- Unsaved changes badge (amber pulse in nav bar)
beforeunloadwarning when state is dirtyOverlayModstub (legend/compass removed, call sites preserved)
Changed
- Marker shapes reduced to circle, star, square (pin and diamond removed)
- Project schema bumped to v3
- Full panel restructure — all content now rendered through
PanelContentfunctions
Removed
- Compass rose overlay
- Legend overlay
- Pin and diamond marker shapes
[0.3] — 2026-03 (early)
Added
- Inline label editing in Location List — click label text to edit, Enter to commit, Escape to revert
- CSV import preview table — 5-row preview with column mapping, skip/valid counts, time estimate
- Per-dataset label controls: show/hide toggle + background style (white / none)
- Per-dataset marker controls: shape selector (circle/star/square), color picker, HQ color
- Marker border toggle (
AppState.settings.markerBorder) — white SVG stroke on/off hqAsStarsetting — HQ markers override shape to star- Number labels on markers (1-based index centered on shape)
- Label font selection: Default (IBM Plex Mono), Arial, Calibri
- Theme color palette restricts dataset color picker options
Changed
- Label system: per-dataset control replaces global toggle
- Marker system: per-dataset shape/color replaces global defaults
- Import flow: CSV column auto-detection improved (regex patterns for name/label/lat/lon/dataset)
[0.2] — 2026-03 (early)
Added
- Core application: single-file HTML with Leaflet 1.9.4 + CartoDB tiles
- Nominatim geocoding with session cache and 1100 ms rate enforcement
- Dataset system: named groups with color, shape, HQ color, visibility
- Location management: add (geocode), remove, bulk import (text + CSV)
- CSV import with column auto-detection and dataset column support
- SVG export: raster base +
<g id="markers">vector layer +<g id="labels"> - PNG export: html2canvas at 3x scale with retina tile detection
- Project JSON save/load with schema validation (v2 initially)
- Saved views with zoom/center persistence
- Security module (
Sec): XSS helpers, input sanitization, CSV/JSON parsing - White-label support: all brand values in
APP_CONFIG(nowCFG) - Content Security Policy meta tag
[0.1 / POC] — 2026-02
Added
- Initial proof of concept
- Basic Leaflet map with CartoDB Voyager tiles
- Single location search via Nominatim
- Simple marker rendering
- Design explorations: Cartographer, Meridian, Atlas, Traverse themes
- Sidebar concept prototypes
Archive
Previous design iterations preserved in archive/simple_Map_designs/:
design1_cartographer.html— serif fonts, parchment aestheticdesign2_meridian.html— alternative themedesign3_atlas.html— alternative themedesign4_traverse.html— alternative themesidebar_concepts.html— sidebar UI explorationpitchmappr_poc.html— early POC (~81 KB)pitchmappr_v02.html— v0.2 (~83 KB)pitchmappr_v03.html— v0.3 (~97 KB)