Theming
A Blaeu theme is a set of design tokens plus an optional basemap and CSS. It feeds
two consumers from one source: the UI chrome (as CSS custom properties, e.g.
--bl-color-accent) and the map itself (as the values inside MapLibre paint
expressions, read by plugins through map.theme.token('color')). That single source
is why the selection halo on the map is exactly the same colour as the selected row
in the attribute table — they read the same number, not two files that happen to
agree today.
A theme reaches the whole map: the basemap ground, the on-map feature layers, the edit handles, the snap indicator, the selection halo, the measurement labels, and the toolbar chrome. Switching a theme at runtime repaints all of it, live, without losing a single feature.
Switching themes
Seven themes are registered on every map, so you can switch by id with no setup:
map.theme.use('twitter-dim') // night
map.theme.use('twitter-light') // day
map.theme.list() // → every registered theme, for building a picker
map.theme.has('imagery-dark') // → true
map.theme.current.id // the active theme
map.theme.scheme // 'light' | 'dark'
use() of an unknown id throws (with the list of valid ids) rather than silently
leaving a blank map.
A preset's theme is applied, not registered: preset.theme goes through
theme.set(), which merges into the active theme without touching the registry. So
the cadastre preset's theme is what you are looking at, but it does not appear in
list() and use('cadastre') throws. If you want to switch away from a preset theme
and back again, call map.theme.register(preset.theme) yourself first.
Following the OS
map.theme.follow('auto') // track prefers-color-scheme, and flip live when the OS does
map.theme.follow('light') // pin light
map.theme.follow('dark') // pin dark
In 'auto', the map switches between the two themes named by setSchemeDefaults
(default: twitter-light / twitter-dim) whenever the OS setting changes — at
sunset, say. An explicit map.theme.use(id) afterwards takes back manual control and
sticks until you call follow('auto') again.
map.theme.setSchemeDefaults({ light: 'survey-paper', dark: 'imagery-dark' })
The built-in themes
| id | scheme | notes |
|---|---|---|
blaeu-default |
light | the neutral slate/blue chrome every map starts on, until a preset or use() changes it |
twitter-light |
light | X's default day palette |
twitter-dim |
dark | X's Dim night palette |
twitter-black |
dark | X's Lights-out — true black, for OLED |
survey-paper |
light | warm cadastral survey sheet; low-contrast so the boundary wins |
high-contrast |
light | WCAG AAA; for a laptop in direct sunlight |
imagery-dark |
dark | night theme tuned to sit over satellite / orthophoto tiles |
All seven are audited against WCAG, and the audit is generated:
docs/contrast.md lists every pair the shipped code actually paints — 20 of
them per theme — with the file and line that paints it, the floor it answers to, and the
measured ratio. packages/core/src/theme/contrast.test.ts recomputes the lot, asserts the
floors, and regenerates that document; a re-tinted token whose number does not reach the
document fails the build. It also reads the // 4.67:1 annotations back out of the theme
sources and checks those, which is how the one wrong number among them was found.
The rules come from call sites, not from token names, and that is the whole design. A
blanket 4.5:1 would call accent a defect at 3.00:1 on white — when the vivid blue is kept
deliberately, because it is a mark on a map and 3:1 is the floor for a graphic. It would
also wave through hover, which nothing in the name suggests is the background of a
control that keeps color: inherit, and which is where most of the current failures are.
The Twitter palettes correct three of X's own contrast failures while keeping the hue:
white-on-blue is exactly 3.00:1, so a filled button uses accentStrong/onAccent instead;
X's snap-yellow is 1.43:1 on white, so the light theme's snap indicator is deepened to
5.18:1; Lights-out muted text is nudged from X's 3.88:1 to clear 4.5:1. Each of the three
is a named test rather than a sentence.
Thirty-six pairs are currently below their floor, and they are published rather than
waived — pinned to the exact ratio they have, so the number cannot move unnoticed in either
direction. Twenty-nine of the thirty-six share one background, hover, which is a single
design decision rather than twenty-nine mistakes.
The three preset themes are not audited at all; see the end of
docs/contrast.md.
Tokens
ThemeTokens groups the values a theme sets. Colours:
| token | what it is |
|---|---|
accent, accentMuted |
the brand colour, and a muted form; the accent doubles as a map mark |
accentStrong, onAccent |
a filled control: a stronger accent and the label colour that sits on it |
selection, hover |
selection halo and hover highlight |
vertex, vertexActive, midpoint |
edit-handle fills |
snapIndicator |
the snap ring and its tooltip |
guide |
construction / alignment guides |
error, warning, success |
semantic status |
canvas |
the map ground — the colour a flat basemap paints, distinct from the panels |
surface, surfaceMuted |
panel / chrome backgrounds |
text, textMuted |
chrome text |
labelHalo |
the halo around on-map labels — the colour of the ground, so a dark map gets a dark halo |
border |
chrome borders |
There are also size, font, and z groups. token(group) hands plugins a whole
group of raw values — map.theme.token('size').vertexRadius is 5, ready to drop into
a MapLibre paint expression; the CSS variable --bl-size-vertex-radius carries the unit
(5px). The z group is unitless in CSS too.
Theme-following layer styles
A declarative layer can follow the theme by giving its style as a function of the
tokens instead of a fixed value. The layer manager resolves it against the live tokens
and re-resolves on every theme change:
import type { BlaeuMap } from '@blaeu/core'
declare const map: BlaeuMap
map.layers.add({
id: 'parcels',
type: 'vector',
source: 'parcels',
style: (t) => ({
line: { color: t.color.accent },
fill: { color: t.color.accent, opacity: 0.08 },
}),
})
Switch to a dark theme and the parcel line re-tints with everything else — no
subscription in the caller. A manual layer.setStyle(...) afterwards wins and detaches
the layer from the theme.
The basemap
Theme.basemap is a MapLibre style (URL or JSON). Switching a theme applies it to the
renderer at runtime. MapLibre's setStyle() tears down every source and layer, so
Blaeu re-materialises the ones it created — with their data and stacking order — after
the new style loads. The camera does not move.
The built-in themes ship a flat, offline basemap: a background layer painted the
theme's canvas colour, nothing fetched over the network. That is what makes
use('twitter-dim') turn the ground dark. An app with its own tiles registers a
variant whose basemap is its own style:
import { twitterDim } from '@blaeu/core'
import type { BlaeuMap } from '@blaeu/core'
declare const map: BlaeuMap
map.theme.register({
...twitterDim,
id: 'twitter-dim-osm',
basemap: { version: 8, sources: { osm: {/* … */} }, layers: [/* … */] },
})
In a merge/patch, basemap: null clears a previous theme's basemap; undefined
leaves it. A flat basemap has no glyphs endpoint, so on-map text layers (parcel
labels) need a basemap that provides one, or the labels render blank.
Writing your own theme
The easiest way is buildTheme, which fills in every token from the default, paints
the flat basemap from your canvas, and returns a complete, self-consistent theme:
import { buildTheme } from '@blaeu/core'
import type { BlaeuMap } from '@blaeu/core'
declare const map: BlaeuMap
const brand = buildTheme({
id: 'acme',
scheme: 'light',
color: {
canvas: '#ffffff',
accent: '#7c3aed',
accentStrong: '#5b21b6',
onAccent: '#ffffff',
selection: '#db2777',
// …only the tokens you care about; the rest inherit the default
},
})
map.theme.register(brand)
map.theme.use('acme')
Validate the pairs you set against WCAG: body text on surface ≥ 4.5:1, onAccent
on accentStrong ≥ 4.5:1, and every on-map mark (selection, snapIndicator,
accent) ≥ 3:1 on canvas. The generated audit covers the seven built-ins only, so a
theme you register in your own app is not gated by it — docs/contrast.md
is the list of pairs worth checking, and the reasoning for each.
docs/theming.md, which is the source of truth.