All documents

Maps, and the scope control

components/map/choropleth-map.tsx — the same component the live product uses, re-pointed at the kit's own band devices and nothing else changed.

Yes: the only configuration is the colour, and here is why that works

A choropleth normally needs a palette. This one does not, because the scale is the system's band scale, and its "good" end IS the brand accent:

BandFillIn iqIn reiIn roof
green — Covered
--brand-accent
emerald
sky
orange
amber — Partial
--color-traffic-yellow
same in all three
red — Gap
--color-danger
same in all three
unknown — No data
--track-neutral
same in all three
measured, no verdict
--text-primary (ink)
same in all three

Switch data-brand and the map recolours with the rest of the app; nothing else about it moves. The three fixed colours are semantics, not brand — a gap is red in every brand, and amber never becomes "the accent of the moment".

unknown is the neutral track, never a fourth hue: we have not measured this is not a worse result than red, it is not a result. And the "measured but not judged" state is ink, deliberately — ink is on no scale, so it cannot be misread as a verdict.

What actually varies per app is the data (your polygons, your ids, your values), not the style. Two things beyond colour are worth a decision, and they are named below: the basemap host, and the camera padding your floating chrome needs.

The three moving parts

1. Tokens are resolved at runtime. MapLibre paints WebGL and cannot read a CSS custom property, so the component reads eight tokens off document.documentElement with getComputedStyle and re-reads them on a MutationObserver watching data-theme, data-brand and class, plus a prefers-color-scheme listener. This is the pattern for anything that paints outside CSS — a canvas, a chart library, a generated PDF. Fallback literals ship with the component so the first frame is never unstyled.

2. The basemap is loaded, then stripped. OpenFreeMap positron in light and dark in dark, and every symbol layer it ships is destructively removed on load. Two sets of place labels at two vintages disagreeing on one screen is worse than none, so the app draws its own from its own data. What survives is coastline, water and landcover.

positron, not a saturated style: a choropleth cannot be read against a basemap more colourful than the data. The identity is ink on cream with ONE accent, and the only saturated thing on screen should be the thing being measured.

The cost is stated rather than hidden: the page makes tile and glyph requests to a third party, and the map degrades to bare polygons if that host is unreachable. Fonts and the brand mark stay self-hosted.

3. The camera is told, not guessing. padding is the room your floating chrome leaves — the component cannot know how big your panels are. mapWorkspace.cameraPadding carries the standard sets (compact, desktop) and fit() re-frames under whatever padding is current.

The layers, in order

  1. context — outlines only, --card-border at 0.5px. Every shape of the states you touch, drawn faint behind the data. Outline, not fill: a fill means "this shape has a state", a hairline means "context". They are different kinds of thing, so they are different kinds of mark.
  2. fill — the band colour, opacity 0.72, lifting to 0.95 on the lit shape. The ink (measured-no-verdict) branch is separate and much lighter — 0.2 / 0.34 — because 0.72 of ink is a black shape.
  3. line — three weights: selected 2.5, measured 1.6, default 1.
  4. label-county / label-state — our own symbol layers with halos, thinned by MapLibre's own collision engine (text-allow-overlap: false). That is why they are symbol layers and not HTML markers: 3,000 anchors cost nothing until they are on screen.

The hover chip

It follows the cursor, it does not park in a corner: on a full-bleed canvas the corners belong to the floating chrome, and a chip pinned under a panel is a chip nobody reads. Its size is a declared constant (two sizes, plain and detailed) because the clamp that keeps it inside the canvas happens on the pointer event, before the element exists to measure.

Everything in the chip arrives formatted. The map knows about polygons and a camera; what a rate means and which reference it is a share of is the caller's business, and pushing that in here would put domain arithmetic inside a map.

Floating chrome

Anything laid over the map wears FLOAT (components/brand/devices.tsx): the popover surface, overlay elevation, 16 px radius. Legends, ledgers and control clusters are the same skin, so a map screen has one floating vocabulary.

The map route is a viewport application, not a scrolling document: it opts out by attribute ([data-map-route] in globals.css), which is why the shell does not have to know which route is rendering. See the height trap in 11-traps.md.

Three traps that will cost you a day each

  • glyphs: undefined fails style validation before the style loads. The key must be absent, not undefined. Symptom: a blank map with a mounted canvas — it looks exactly like a WebGL failure.
  • MapLibre 6's worker URL resolves to file:// under Turbopack. Ship tools/sync-maplibre-worker.mjs on predev and prebuild to copy the worker into public/maplibre/, and call setWorkerUrl("/maplibre/maplibre-gl-worker.mjs") (the component already does). Same blank-map symptom, different cause.
  • Never screenshot a map with --virtual-time-budget. It outruns MapLibre's requestAnimationFrame and the map comes out blank though the app is fine. Drive headless Chrome over CDP with a real wait.

A worked screen

examples/map-screen.tsx (MapScreen) puts it together: the map filling the viewport, a FLOAT legend over it, the ScopeSelector in the shell's header, and the camera told how much room the chrome needs.

Dependencies

maplibre-gl and react-map-gl — the only two the kit adds beyond the core, and only for this component. No map in your app? Delete components/map/ and both packages.


The scope control

components/shell/scope-selector.tsx, passed to SiteShell as headerRight.

  • It answers "what do the numbers on this screen count?" — and only a tool that HAS a scope gets one.
  • Trigger: quiet button on the input surface, current value in mono caps (it is an enum, not prose), icon in accent ink, chevrons at the end.
  • Content: every option at once, two columns, 336 px wide. A scrolling list hides the two thirds of the catalogue that make the choice meaningful.
  • Active row: ink fill, not accent fill. The accent is already spent on the icon, and eighteen accent-filled rows is a wall.
  • Footer: one link to where the options are explained. The picker is where somebody first wonders what an option means.

Fully controlled and domain-free: options, value, onChange. One icon per option, from lucide, mapped in your app — the icon is what makes eighteen mono-caps strings scannable.