@unmap/themes 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ import { Flavor } from '@protomaps/basemaps';
2
+
3
+ /**
4
+ * Named colour ramps: the single source of colour truth for unmap cartography.
5
+ *
6
+ * Moved here from apps/gateway/src/palette.ts (which now re-exports these) so themes, the
7
+ * gateway, and the builder all draw from one vocabulary. Flavors and theme recipes reference
8
+ * these by name and never inline a hex literal.
9
+ *
10
+ * Ramps run light to dark by ascending key.
11
+ */
12
+ /**
13
+ * Ember. Pre-rebrand brand accent; survives only as the `street` motorway orange, which is
14
+ * cartographic convention (orange motorways) rather than brand. Never the interface accent —
15
+ * that is magenta, which stays out of every basemap.
16
+ */
17
+ declare const ember: {
18
+ readonly 200: "#ffe9c9";
19
+ readonly 300: "#ffc08c";
20
+ readonly 400: "#f7a35c";
21
+ readonly 500: "#f97316";
22
+ readonly 600: "#e07b2e";
23
+ readonly 700: "#b45309";
24
+ };
25
+ /** Cool neutrals, hue 212. The spine of every Process Ink flavour. */
26
+ declare const ink: {
27
+ readonly 25: "#F1F8FA";
28
+ readonly 50: "#EAF2F3";
29
+ readonly 100: "#E2E9EB";
30
+ readonly 200: "#D6DEE0";
31
+ readonly 300: "#C9D1D3";
32
+ readonly 400: "#B7C0C2";
33
+ readonly 500: "#9DA7A9";
34
+ readonly 600: "#7E888B";
35
+ readonly 700: "#5C6769";
36
+ readonly 800: "#3D494B";
37
+ readonly 850: "#2B3639";
38
+ readonly 900: "#1A2628";
39
+ readonly 950: "#0E191C";
40
+ readonly 1000: "#030E10";
41
+ };
42
+ /** Water, hue 200. Cooler and more chromatic than the land it meets. */
43
+ declare const wave: {
44
+ readonly 100: "#C7F1F3";
45
+ readonly 200: "#B3E2E4";
46
+ readonly 300: "#9CCFD2";
47
+ readonly 400: "#7FB8BB";
48
+ readonly 500: "#5F9FA2";
49
+ readonly 600: "#368286";
50
+ readonly 700: "#1C6265";
51
+ readonly 800: "#024347";
52
+ readonly 900: "#002A2C";
53
+ readonly 950: "#001C1E";
54
+ readonly 980: "#001012";
55
+ };
56
+ /** Vegetation, hue 152: park, wood, scrub. */
57
+ declare const verdant: {
58
+ readonly 100: "#D3F1DA";
59
+ readonly 200: "#C3E3CA";
60
+ readonly 300: "#AFD2B7";
61
+ readonly 400: "#96BC9F";
62
+ readonly 500: "#76A281";
63
+ readonly 600: "#50825E";
64
+ readonly 700: "#366042";
65
+ readonly 800: "#1E4128";
66
+ readonly 900: "#0B2815";
67
+ };
68
+ /** Warm landcover, hue 82: sand, beach, farmland. */
69
+ declare const sandstone: {
70
+ readonly 100: "#F5E8D0";
71
+ readonly 200: "#E7D9BF";
72
+ readonly 300: "#D6C7AC";
73
+ readonly 400: "#BFAF92";
74
+ readonly 500: "#A19070";
75
+ };
76
+ /**
77
+ * Pure neutrals, zero chroma. The `muted` pair only.
78
+ *
79
+ * `ink` carries a hue-212 cool cast, which is right for every other flavour and wrong for this
80
+ * one: `muted` exists to vanish under a customer's data, and a basemap with *any* hue biases the
81
+ * colours laid on top of it. So muted gets its own ramp where R=G=B exactly.
82
+ *
83
+ * Calibrated against Mapbox Light and Protomaps' `WHITE`, whose land/water/building relationships
84
+ * this flavour follows: land a light grey, roads *brighter* than the land rather than darker,
85
+ * buildings a single step down, and water the only tone with any real weight.
86
+ *
87
+ * Their label colours are deliberately NOT copied. Protomaps `WHITE` puts `state_label` #b3b3b3
88
+ * on #ffffff, roughly 2:1, which fails the 4.5:1 floor `cartography.test.ts` enforces. The
89
+ * structure is worth borrowing; the label contrast is not.
90
+ */
91
+ declare const graphite: {
92
+ readonly 25: "#FAFAFA";
93
+ readonly 50: "#F2F2F2";
94
+ readonly 100: "#E9E9E9";
95
+ readonly 200: "#DEDEDE";
96
+ readonly 300: "#D1D1D1";
97
+ readonly 400: "#BEBEBE";
98
+ readonly 500: "#A4A4A4";
99
+ readonly 600: "#868686";
100
+ readonly 700: "#656565";
101
+ readonly 800: "#464646";
102
+ readonly 900: "#232323";
103
+ readonly 1000: "#0B0B0B";
104
+ };
105
+
106
+ /**
107
+ * The unmap cartography authoring surface, moved here from apps/gateway/src/flavors.ts so the
108
+ * gateway, the theme system, and the unmap.dev builder share one definition.
109
+ *
110
+ * A `Recipe` is what a theme can override: every colour a Flavor controls, expressed at the
111
+ * level we author at. Expanding through `makeFlavor` keeps the consistency guarantees a raw
112
+ * Flavor cannot: a casing is always derived beside its fill, and the tunnel and bridge families
113
+ * cannot drift out of step with the surface roads they mirror.
114
+ */
115
+ /**
116
+ * Appended, never inserted. This array's order drives the style picker in the unmap.dev builder,
117
+ * so inserting a base mid-array silently reorders a control people have already learned.
118
+ */
119
+ declare const THEME_BASES: readonly ["base", "muted", "relief", "street", "outdoor"];
120
+ type ThemeBase = (typeof THEME_BASES)[number];
121
+ declare const MODES: readonly ["light", "dark"];
122
+ type ThemeMode = (typeof MODES)[number];
123
+ interface RoadRamp {
124
+ /** Unclassified / track. The bottom of the hierarchy. */
125
+ other: string;
126
+ otherCasing: string;
127
+ /** Residential and service. */
128
+ minor: string;
129
+ minorCasing: string;
130
+ service: string;
131
+ serviceCasing: string;
132
+ /** Primary / secondary. */
133
+ major: string;
134
+ majorCasing: string;
135
+ /** Motorway. */
136
+ highway: string;
137
+ highwayCasing: string;
138
+ /** Slip roads. */
139
+ link: string;
140
+ linkCasing: string;
141
+ }
142
+ interface LabelRamp {
143
+ city: string;
144
+ cityHalo: string;
145
+ state: string;
146
+ stateHalo: string;
147
+ country: string;
148
+ subplace: string;
149
+ subplaceHalo: string;
150
+ address: string;
151
+ addressHalo: string;
152
+ roadMinor: string;
153
+ roadMinorHalo: string;
154
+ roadMajor: string;
155
+ roadMajorHalo: string;
156
+ ocean: string;
157
+ }
158
+ interface Recipe {
159
+ background: string;
160
+ earth: string;
161
+ water: string;
162
+ /** Park, wood and scrub each take two tones; `b` is the slightly deeper of the pair. */
163
+ parkA: string;
164
+ parkB: string;
165
+ woodA: string;
166
+ woodB: string;
167
+ scrubA: string;
168
+ scrubB: string;
169
+ sand: string;
170
+ beach: string;
171
+ glacier: string;
172
+ /** Institutional land: hospital, school, industrial, military, zoo, aerodrome. */
173
+ institutional: string;
174
+ pedestrian: string;
175
+ aerodrome: string;
176
+ runway: string;
177
+ pier: string;
178
+ buildings: string;
179
+ railway: string;
180
+ boundaries: string;
181
+ roads: RoadRamp;
182
+ labels: LabelRamp;
183
+ /**
184
+ * Tunnels read one step recessive against the surface road, bridges one step assertive.
185
+ * Expressed as the colours to substitute rather than a computed shift, so a style can opt
186
+ * out of the convention where it does not suit the palette.
187
+ */
188
+ tunnelFill: string;
189
+ tunnelCasing: string;
190
+ bridgeCasing: string;
191
+ pois: Flavor["pois"];
192
+ landcover: Flavor["landcover"];
193
+ }
194
+ /**
195
+ * Expand a recipe into the full Flavor. Every one of the interface's required slots is written
196
+ * here exactly once, so a missing slot is a compile error rather than a silently black layer.
197
+ */
198
+ declare function makeFlavor(r: Recipe): Flavor;
199
+
200
+ /**
201
+ * The five ramp families (plus ember) a recipe draws from. `buildRecipes` is parameterized on
202
+ * this so the theme generator can re-emit the same recipes from *generated* ramps — same
203
+ * lightness ladders, a customer's hues — while the stock `RECIPES` stay byte-identical to the
204
+ * hand-tuned originals (they are `buildRecipes(STOCK_RAMPS)`).
205
+ */
206
+ interface RampSet {
207
+ ink: typeof ink;
208
+ wave: typeof wave;
209
+ verdant: typeof verdant;
210
+ sandstone: typeof sandstone;
211
+ graphite: typeof graphite;
212
+ ember: typeof ember;
213
+ }
214
+ declare const STOCK_RAMPS: RampSet;
215
+ /** Build the eight recipes from a ramp set. Stock ramps in → the shipped cartography out. */
216
+ declare function buildRecipes(r: RampSet): Record<ThemeBase, Record<ThemeMode, Recipe>>;
217
+ declare const RECIPES: Record<ThemeBase, Record<ThemeMode, Recipe>>;
218
+
219
+ /**
220
+ * The unmap theme document: one theme, both modes, data only.
221
+ *
222
+ * A theme is colours, bounded numbers and one enum — never URLs, never expressions, never
223
+ * arbitrary style JSON. That is a security boundary, not a modelling choice: theme documents
224
+ * arrive on public query strings and are composed into style JSON the gateway signs its own
225
+ * asset URLs into. `validateTheme` is the only door in.
226
+ *
227
+ * `base` names the structural chassis (one of the four shipped styles); each half carries only
228
+ * sparse overrides on that chassis, so presets are near-empty documents and codes stay short.
229
+ */
230
+ type DeepPartial<T> = {
231
+ [K in keyof T]?: NonNullable<T[K]> extends object ? DeepPartial<NonNullable<T[K]>> : T[K];
232
+ };
233
+ type BasemapOverrides = DeepPartial<Recipe>;
234
+ interface MarkerTheme {
235
+ /** Pin colour, #RRGGBB or #RRGGBBAA. */
236
+ color?: string;
237
+ /** Pin scale, 0.5–2. */
238
+ scale?: number;
239
+ }
240
+ interface RouteTheme {
241
+ /** Main stroke colour. */
242
+ color?: string;
243
+ /** Main stroke width in px, 1–12. */
244
+ width?: number;
245
+ /** Main stroke opacity, 0–1. */
246
+ opacity?: number;
247
+ /** Under-stroke (casing) colour. */
248
+ casingColor?: string;
249
+ /** Casing width in px, 0–24. 0 disables the casing. */
250
+ casingWidth?: number;
251
+ /** Casing opacity, 0–1. */
252
+ casingOpacity?: number;
253
+ }
254
+ interface ThemeHalf {
255
+ basemap?: BasemapOverrides;
256
+ marker?: MarkerTheme;
257
+ route?: RouteTheme;
258
+ }
259
+ /**
260
+ * The simple-mode seed colours a generated theme was built from. Carried on the document so a
261
+ * shared code reopens the builder in simple mode with the seeds intact; the gateway validates
262
+ * them like any other field and otherwise ignores them — composition reads only the halves.
263
+ */
264
+ interface ThemeSeeds {
265
+ /** Neutral cast: land, buildings, roads, labels. */
266
+ land: string;
267
+ water: string;
268
+ /** Vegetation: parks, wood, scrub, forest landcover. */
269
+ nature: string;
270
+ /** Marker + route accent. Defaults to the interface magenta. */
271
+ accent?: string;
272
+ }
273
+ interface ThemeDoc {
274
+ v: 1;
275
+ /** Display name, ≤ 40 chars. */
276
+ name?: string;
277
+ base: ThemeBase;
278
+ seeds?: ThemeSeeds;
279
+ light?: ThemeHalf;
280
+ dark?: ThemeHalf;
281
+ }
282
+
283
+ /**
284
+ * Validation failure. `detail` lists every bad path in one throw — collect-then-throw, so a
285
+ * builder user fixing a hand-edited document sees all of it at once rather than one field per
286
+ * round trip (the same reporting philosophy as the cartography contrast tests).
287
+ */
288
+ declare class ThemeError extends Error {
289
+ readonly detail: string[];
290
+ constructor(message: string, detail: string[]);
291
+ }
292
+ /**
293
+ * Validate an untrusted value as a ThemeDoc. Returns the (unchanged) document on success;
294
+ * throws a `ThemeError` naming every invalid path otherwise. Unknown fields anywhere are
295
+ * errors — a theme is data only, and the reject-unknown posture is what keeps it that way.
296
+ */
297
+ declare function validateTheme(input: unknown): ThemeDoc;
298
+
299
+ /**
300
+ * The compact theme code: `ut1.` + base64url(deflate(minified JSON)).
301
+ *
302
+ * Web-standard streams only (CompressionStream/DecompressionStream, Blob, btoa/atob) so the
303
+ * same module runs in Workers, browsers and Node 18+. `deflate` (zlib) rather than
304
+ * `deflate-raw`, which Node 20 — the repo floor — does not implement; the wrapper costs six
305
+ * bytes and buys a checksum.
306
+ *
307
+ * `ut1` is the wire version. Bump it only for a breaking change, and keep decoding old
308
+ * prefixes forever: codes get hardcoded into customers' pages.
309
+ */
310
+ declare const THEME_CODE_PREFIX = "ut1.";
311
+ declare const MAX_CODE_LENGTH = 4096;
312
+ /** Validate and encode a theme document to its compact `ut1.` code. Throws `ThemeError`. */
313
+ declare function encodeTheme(doc: ThemeDoc): Promise<string>;
314
+ /** Decode and validate a compact `ut1.` code. Every failure lands as `ThemeError`. */
315
+ declare function decodeTheme(code: string): Promise<ThemeDoc>;
316
+
317
+ /**
318
+ * Platform drawing defaults for the two client-side overlays a theme styles. These are exactly
319
+ * the convention the unmap.dev hero has always drawn: a magenta pin, and a route as a solid
320
+ * line over a wide translucent casing so it stays legible on light and dark cartography alike.
321
+ */
322
+ declare const DEFAULT_MARKER: Required<MarkerTheme>;
323
+ declare const DEFAULT_ROUTE: Required<RouteTheme>;
324
+ interface ResolvedTheme {
325
+ /** `<base>-<mode>`, the chassis name — what selects sprites and terrain behaviour. */
326
+ flavorName: `${ThemeBase}-${ThemeMode}`;
327
+ flavor: Flavor;
328
+ marker: Required<MarkerTheme>;
329
+ route: Required<RouteTheme>;
330
+ }
331
+ /**
332
+ * Merge sparse overrides onto a base recipe, walking the base's own shape. Composing from the
333
+ * base's keys (never spreading the override object) means an unexpected key cannot ride along
334
+ * even if a caller skipped validation — defence in depth behind `validateTheme`.
335
+ */
336
+ declare function mergeRecipe(base: Recipe, overrides?: BasemapOverrides): Recipe;
337
+ /**
338
+ * Resolve one half of a theme into everything a renderer needs: the expanded Flavor, the
339
+ * chassis name, and marker/route styles with defaults filled in.
340
+ */
341
+ declare function resolveTheme(doc: ThemeDoc, mode: ThemeMode): ResolvedTheme;
342
+
343
+ /** WCAG contrast ratio between two hex colours (1–21). */
344
+ declare function contrast(a: string, b: string): number;
345
+ /** Every enforced pair with its floor — the generator's contrast-clamp iterates these. */
346
+ declare const LABEL_PAIRS: Array<[keyof Flavor, keyof Flavor, number]>;
347
+ interface ContrastWarning {
348
+ mode: ThemeMode;
349
+ /** The Flavor label slot that breaches, e.g. "city_label". */
350
+ label: string;
351
+ /** The Flavor slot it was checked against, e.g. "city_label_halo". */
352
+ background: string;
353
+ ratio: number;
354
+ /** 4.5 for primary labels, 3 for secondary. */
355
+ floor: number;
356
+ }
357
+ /** Every label-contrast breach in the theme, across both modes. Empty means clean. */
358
+ declare function contrastWarnings(doc: ThemeDoc): ContrastWarning[];
359
+
360
+ /**
361
+ * The shipped styles as starting-point themes: zero overrides, so each is guaranteed identical
362
+ * to its `flavor=` twin (asserted in the gateway theme tests). The builder opens on these.
363
+ */
364
+ declare const PRESET_THEMES: Record<ThemeBase, ThemeDoc>;
365
+
366
+ /**
367
+ * Minimal sRGB ↔ OKLCH conversion (Björn Ottosson's OKLab constants), plus a chroma-reducing
368
+ * gamut map. Zero dependencies — the generator is the only consumer and needs exactly this.
369
+ *
370
+ * OKLCH is the working space for the same reason the hand-built ramps in `palette.ts` were
371
+ * generated in it: perceived lightness is carried almost entirely by L, so a generator can swap
372
+ * hue and chroma while *preserving a ramp's lightness ladder* — which is where the label
373
+ * contrast the cartography tests enforce actually lives.
374
+ */
375
+ interface Oklch {
376
+ /** Perceptual lightness, 0–1. */
377
+ l: number;
378
+ /** Chroma, 0 upward (~0.37 max for sRGB). */
379
+ c: number;
380
+ /** Hue in degrees, 0–360. */
381
+ h: number;
382
+ }
383
+ /** Parse #RRGGBB (or #RRGGBBAA — alpha ignored) into OKLCH. */
384
+ declare function hexToOklch(hex: string): Oklch;
385
+ /**
386
+ * OKLCH → #RRGGBB. Out-of-gamut colours are mapped by *reducing chroma* at constant L and h
387
+ * (bisection), never by clipping channels — clipping shifts perceived lightness, and the whole
388
+ * generator contract is that L is preserved.
389
+ */
390
+ declare function oklchToHex(color: Oklch): string;
391
+
392
+ /** The seeds that reproduce (approximately) the stock cartography. Simple mode opens on these. */
393
+ declare const DEFAULT_SEEDS: Required<ThemeSeeds>;
394
+ /**
395
+ * Generate a full theme from seed colours. Deterministic; the result always clears the label
396
+ * floors in both modes (enforced, and asserted by the hue-sweep test).
397
+ *
398
+ * Family mapping: `land` recolours the neutral spine (and `muted`'s graphite, at a whisper of
399
+ * chroma — that flavour exists to vanish under data), `water` the wave ramp, `nature` the
400
+ * verdant ramp. Warm landcover (sandstone), POI accents, white road fills and the street
401
+ * motorway orange stay stock: they are tuned relationships, not identity. `accent` styles the
402
+ * marker and route overlays only — it never enters the basemap, same rule as the brand magenta.
403
+ */
404
+ declare function generateTheme(base: ThemeBase, seeds: ThemeSeeds): ThemeDoc;
405
+
406
+ /**
407
+ * Curated seed palettes for the builder's simple mode — the hand-picked starting points, named
408
+ * for Canadian landscapes. Each is just a seed trio + accent; `generateTheme` does the rest,
409
+ * and its contrast guarantee holds for these like any other seeds (asserted in tests).
410
+ */
411
+ interface SeedPalette {
412
+ /** Display name, lowercase like the flavour names. Not translated — these are names. */
413
+ name: string;
414
+ seeds: Required<ThemeSeeds>;
415
+ }
416
+ declare const SEED_PALETTES: SeedPalette[];
417
+ /**
418
+ * A random but *harmonic* seed trio — the shuffle button. Hues are related (water lands
419
+ * cool-of-complementary, nature analogous-of-triadic, the accent opposes the land) and chroma
420
+ * stays inside a tasteful band, so shuffles read as designed rather than random; the
421
+ * generator's contrast guarantee makes every one of them legible regardless.
422
+ *
423
+ * `rng` is injectable (returns [0,1)) so tests can drive it deterministically.
424
+ */
425
+ declare function shuffleSeeds(rng?: () => number): Required<ThemeSeeds>;
426
+
427
+ export { type BasemapOverrides, type ContrastWarning, DEFAULT_MARKER, DEFAULT_ROUTE, DEFAULT_SEEDS, type DeepPartial, LABEL_PAIRS, type LabelRamp, MAX_CODE_LENGTH, MODES, type MarkerTheme, type Oklch, PRESET_THEMES, RECIPES, type RampSet, type Recipe, type ResolvedTheme, type RoadRamp, type RouteTheme, SEED_PALETTES, STOCK_RAMPS, type SeedPalette, THEME_BASES, THEME_CODE_PREFIX, type ThemeBase, type ThemeDoc, ThemeError, type ThemeHalf, type ThemeMode, type ThemeSeeds, buildRecipes, contrast, contrastWarnings, decodeTheme, ember, encodeTheme, generateTheme, graphite, hexToOklch, ink, makeFlavor, mergeRecipe, oklchToHex, resolveTheme, sandstone, shuffleSeeds, validateTheme, verdant, wave };