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:
| Band | Fill | In iq | In rei | In 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
context— outlines only,--card-borderat 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.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.line— three weights: selected 2.5, measured 1.6, default 1.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: undefinedfails 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. Shiptools/sync-maplibre-worker.mjsonpredevandprebuildto copy the worker intopublic/maplibre/, and callsetWorkerUrl("/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'srequestAnimationFrameand 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:
quietbutton 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.