@rathnasgala2/schemas 3.9.0 → 3.10.0

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.
Files changed (45) hide show
  1. package/THIRD_PARTY_LICENSES.json +1 -1
  2. package/diagnostics/diagnostic-map.json +255 -9
  3. package/docs/catalogs/schema-inventory.json +5 -5
  4. package/examples/valid/appearance/rules.json +105 -1
  5. package/examples/valid/build-input/rules.json +105 -1
  6. package/examples/valid/theme-contract/rules.json +276 -2
  7. package/fixtures/boundary/appearance/rules.json +105 -12
  8. package/fixtures/boundary/build-input/rules.json +105 -12
  9. package/fixtures/boundary/theme-contract/rules.json +386 -2
  10. package/fixtures/invalid/appearance/REQUEST_FIELD_UNKNOWN/cases.json +33 -0
  11. package/fixtures/invalid/appearance/SCHEMA_OBJECT_TOO_LARGE/cases.json +0 -13
  12. package/fixtures/invalid/appearance/SCHEMA_PATTERN_INVALID/cases.json +68 -0
  13. package/fixtures/invalid/appearance/SCHEMA_REQUIRED_FIELD_MISSING/cases.json +66 -0
  14. package/fixtures/invalid/appearance/SCHEMA_TYPE_INVALID/cases.json +99 -0
  15. package/fixtures/invalid/build-input/REQUEST_FIELD_UNKNOWN/cases.json +33 -0
  16. package/fixtures/invalid/build-input/SCHEMA_PATTERN_INVALID/cases.json +66 -0
  17. package/fixtures/invalid/build-input/SCHEMA_REQUIRED_FIELD_MISSING/cases.json +66 -0
  18. package/fixtures/invalid/build-input/SCHEMA_TYPE_INVALID/cases.json +99 -0
  19. package/fixtures/invalid/theme-contract/REQUEST_FIELD_UNKNOWN/cases.json +33 -0
  20. package/fixtures/invalid/theme-contract/SCHEMA_ARRAY_TOO_LONG/cases.json +165 -0
  21. package/fixtures/invalid/theme-contract/SCHEMA_CONSTANT_INVALID/cases.json +11 -11
  22. package/fixtures/invalid/theme-contract/SCHEMA_ENUM_INVALID/cases.json +88 -0
  23. package/fixtures/invalid/theme-contract/SCHEMA_REQUIRED_FIELD_MISSING/cases.json +93 -0
  24. package/fixtures/invalid/theme-contract/SCHEMA_TYPE_INVALID/cases.json +51 -0
  25. package/fixtures/manifest.json +1955 -35
  26. package/fixtures/unknown-field/appearance/rules.json +54 -1
  27. package/fixtures/unknown-field/build-input/rules.json +54 -1
  28. package/fixtures/unknown-field/theme-contract/rules.json +70 -1
  29. package/generated/browser/validator-core.mjs +3 -3
  30. package/generated/typescript/contracts/appearance.d.ts +14 -1
  31. package/generated/typescript/contracts/build-input.d.ts +14 -1
  32. package/generated/typescript/contracts/theme-contract.d.ts +51 -0
  33. package/openapi/http-catalog.json +2 -2
  34. package/openapi/openapi.yaml +56 -2
  35. package/package.json +10 -1
  36. package/parity/palette/golden-vectors.json +883 -0
  37. package/parity/palette/stress-corpus.json +521 -0
  38. package/parity/palette/theme-default.fixture.json +698 -0
  39. package/sbom.cdx.json +23 -23
  40. package/schemas/appearance.schema.json +30 -3
  41. package/schemas/build-input.schema.json +30 -3
  42. package/schemas/theme-contract.schema.json +185 -1
  43. package/src/palette.js +919 -0
  44. package/types/palette.d.ts +305 -0
  45. package/fixtures/invalid/build-input/SCHEMA_OBJECT_TOO_LARGE/cases.json +0 -15
package/src/palette.js ADDED
@@ -0,0 +1,919 @@
1
+ /**
2
+ * Publication palette: the shared "follow and shift" algorithm.
3
+ *
4
+ * An author picks up to three colours (Accent, Background, Text), each as an
5
+ * explicit light and dark pair. A theme declares, in an optional `palette`
6
+ * block of its theme.json, which of those three seeds each colour inside
7
+ * each of 40 palette-drivable tokens follows. This module derives the final
8
+ * value of every token from the theme and the author's picks, and checks the
9
+ * result against the contrast pairs. The template (at render), theme-tooling
10
+ * (at theme release) and the App (while picking) all import it, so the three
11
+ * can never disagree.
12
+ *
13
+ * Pure browser-safe ESM: no Node built-ins, no I/O, no dependencies. All
14
+ * colour arithmetic is in OKLab/OKLCH with the standard sRGB transfer
15
+ * function; every result is rounded to 8 bits and written as lowercase hex.
16
+ *
17
+ * The 40 drivable keys, their value types and the per-type colour counts live
18
+ * here and nowhere else; the JSON Schema generators and the semantic
19
+ * validator import them from this module.
20
+ *
21
+ * @module
22
+ */
23
+
24
+ /** The `palette.profile` value a theme must carry. */
25
+ export const PALETTE_PROFILE = 'gala-palette-follow-v1';
26
+
27
+ /** The three roles an author may pick, in canonical order. */
28
+ export const PALETTE_ROLES = /** @type {readonly PaletteRole[]} */ (
29
+ Object.freeze(['accent', 'canvas', 'text'])
30
+ );
31
+
32
+ /** The two colour modes, in canonical order. */
33
+ export const PALETTE_MODES = /** @type {readonly PaletteMode[]} */ (
34
+ Object.freeze(['light', 'dark'])
35
+ );
36
+
37
+ /** The seed token each role is read from, and the token that carries it. */
38
+ export const PALETTE_SEED_KEYS = Object.freeze({
39
+ accent: 'color-accent',
40
+ canvas: 'color-canvas',
41
+ text: 'color-text',
42
+ });
43
+
44
+ /**
45
+ * Every palette-drivable token key and its value type, in theme catalog
46
+ * order. A theme binds any subset; every other key is always theme-owned.
47
+ *
48
+ * @type {Readonly<Record<string, 'color' | 'paint' | 'border' | 'shadow'>>}
49
+ */
50
+ export const PALETTE_KEY_TYPES = Object.freeze({
51
+ 'border-button': 'border',
52
+ 'border-card': 'border',
53
+ 'border-chip': 'border',
54
+ 'border-media-divider': 'border',
55
+ 'border-quote': 'border',
56
+ 'border-row-divider': 'border',
57
+ 'border-section-rule': 'border',
58
+ 'color-accent': 'color',
59
+ 'color-accent-2': 'color',
60
+ 'color-border': 'color',
61
+ 'color-btn-panel': 'color',
62
+ 'color-btn-panel-text': 'color',
63
+ 'color-btn-text': 'color',
64
+ 'color-canvas': 'color',
65
+ 'color-chip-text': 'color',
66
+ 'color-focus': 'color',
67
+ 'color-footer': 'color',
68
+ 'color-header': 'color',
69
+ 'color-icon-accent': 'color',
70
+ 'color-link': 'color',
71
+ 'color-link-underline': 'color',
72
+ 'color-link-underline-hover': 'color',
73
+ 'color-link-visited': 'color',
74
+ 'color-on-accent': 'color',
75
+ 'color-panel-muted': 'color',
76
+ 'color-panel-text': 'color',
77
+ 'color-selection': 'color',
78
+ 'color-surface': 'color',
79
+ 'color-surface-raised': 'color',
80
+ 'color-text': 'color',
81
+ 'color-text-faint': 'color',
82
+ 'color-text-muted': 'color',
83
+ 'color-toc-active': 'color',
84
+ 'color-toc-active-text': 'color',
85
+ 'paint-button': 'paint',
86
+ 'paint-chip': 'paint',
87
+ 'paint-page-decor': 'paint',
88
+ 'paint-panel': 'paint',
89
+ 'shadow-avatar-ring': 'shadow',
90
+ 'shadow-button': 'shadow',
91
+ });
92
+
93
+ /** The 40 palette-drivable token keys, in theme catalog order. */
94
+ export const PALETTE_DRIVABLE_KEYS = Object.freeze(
95
+ Object.keys(PALETTE_KEY_TYPES),
96
+ );
97
+
98
+ /**
99
+ * The most colours one value of each drivable type can hold under the token
100
+ * grammar: one colour, one border colour, three shadow layers, and three
101
+ * gradients of five stops. A value's exact count is `paletteColourCount`.
102
+ */
103
+ export const PALETTE_MAX_COLOURS_BY_TYPE = Object.freeze({
104
+ color: 1,
105
+ border: 1,
106
+ shadow: 3,
107
+ paint: 15,
108
+ });
109
+
110
+ /**
111
+ * The contrast pairs every palette must clear on its final values, per
112
+ * mode: the 17 theme-tooling pairs plus the focus ring and the three status
113
+ * colours on the canvas. A pair flagged `skipWhenNotPlainColor` is skipped
114
+ * (never silently passed) when its background is `none`, translucent,
115
+ * transparent or an unreadable gradient.
116
+ */
117
+ export const PALETTE_PAIRS = Object.freeze(
118
+ [
119
+ ['color-text', 'color-canvas', 4.5],
120
+ ['color-text', 'color-surface', 4.5],
121
+ ['color-text-muted', 'color-canvas', 4.5],
122
+ ['color-text-muted', 'color-surface', 4.5],
123
+ ['color-link', 'color-canvas', 4.5],
124
+ ['color-chip-text', 'paint-chip', 4.5, true],
125
+ ['color-btn-text', 'paint-button', 4.5, true],
126
+ ['color-btn-panel-text', 'color-btn-panel', 4.5],
127
+ ['color-panel-text', 'paint-panel', 4.5, true],
128
+ ['color-code-text', 'color-code-canvas', 4.5],
129
+ ['color-syntax-comment', 'color-code-canvas', 4.5],
130
+ ['color-syntax-function', 'color-code-canvas', 4.5],
131
+ ['color-syntax-keyword', 'color-code-canvas', 4.5],
132
+ ['color-syntax-number', 'color-code-canvas', 4.5],
133
+ ['color-syntax-string', 'color-code-canvas', 4.5],
134
+ ['color-on-accent', 'color-accent', 4.5],
135
+ ['color-toc-active-text', 'color-toc-active', 4.5, true],
136
+ ['color-focus', 'color-canvas', 3],
137
+ ['color-danger', 'color-canvas', 4.5],
138
+ ['color-success', 'color-canvas', 4.5],
139
+ ['color-warning', 'color-canvas', 4.5],
140
+ ].map(([foreground, background, minimum, skip]) =>
141
+ Object.freeze({
142
+ label: `${foreground} on ${background}`,
143
+ foreground: /** @type {string} */ (foreground),
144
+ background: /** @type {string} */ (background),
145
+ minimum: /** @type {number} */ (minimum),
146
+ skipWhenNotPlainColor: skip === true,
147
+ }),
148
+ ),
149
+ );
150
+
151
+ /** Limits of the Background role (OKLab lightness). */
152
+ export const PALETTE_CANVAS_BAND = Object.freeze({
153
+ lightMin: 0.9,
154
+ darkMax: 0.3,
155
+ });
156
+
157
+ /** The margin above a pair's minimum that the readability clamp aims for. */
158
+ export const PALETTE_CLAMP_MARGIN = 0.1;
159
+
160
+ /** Chroma below which a seed counts as neutral (no usable hue). */
161
+ const NEUTRAL_CHROMA = 0.02;
162
+
163
+ const HEX_COLOUR = /#[0-9a-f]{6}(?:[0-9a-f]{2})?/gu;
164
+ const PICK_HEX = /^#[0-9a-f]{6}$/u;
165
+
166
+ /**
167
+ * @typedef {'accent' | 'canvas' | 'text'} PaletteRole
168
+ * @typedef {'light' | 'dark'} PaletteMode
169
+ * @typedef {{ light: string, dark: string }} PalettePair
170
+ * @typedef {Partial<Record<PaletteRole, PalettePair>>} PaletteTokens
171
+ * @typedef {{ key: string, type: string, light: string, dark: string }} PaletteThemeToken
172
+ * @typedef {{
173
+ * key: string,
174
+ * light: (PaletteRole | null)[],
175
+ * dark: (PaletteRole | null)[],
176
+ * }} PaletteFollow
177
+ * @typedef {{ profile: string, follow: PaletteFollow[] }} ThemePalette
178
+ * @typedef {{ palette?: ThemePalette, tokens: PaletteThemeToken[] }} PaletteTheme
179
+ * @typedef {{
180
+ * key: string,
181
+ * mode: PaletteMode,
182
+ * from: string,
183
+ * to: string,
184
+ * pair: string,
185
+ * }} PaletteAdjustment
186
+ * @typedef {{
187
+ * profile: string,
188
+ * seeds: Record<PaletteMode, Record<PaletteRole, string>>,
189
+ * bound: string[],
190
+ * tokens: Record<string, PalettePair>,
191
+ * adjusted: PaletteAdjustment[],
192
+ * }} DerivedPalette
193
+ * @typedef {{
194
+ * label: string,
195
+ * foreground: string,
196
+ * background: string,
197
+ * minimum: number,
198
+ * status: 'pass' | 'fail' | 'skipped',
199
+ * ratio: number | null,
200
+ * reason?: string,
201
+ * worstStop?: string,
202
+ * }} PairResult
203
+ * @typedef {{
204
+ * code: 'APPEARANCE_PALETTE_CONTRAST',
205
+ * pair: string,
206
+ * mode: PaletteMode,
207
+ * ratio: number | null,
208
+ * minimum: number,
209
+ * foregroundKey: string,
210
+ * backgroundKey: string,
211
+ * reason?: string,
212
+ * }} PaletteFailure
213
+ * @typedef {{
214
+ * mode: PaletteMode,
215
+ * ok: boolean,
216
+ * results: PairResult[],
217
+ * failures: PaletteFailure[],
218
+ * }} PaletteCheck
219
+ * @typedef {{
220
+ * code: 'APPEARANCE_PALETTE_BAND' | 'APPEARANCE_PALETTE_TEXT_CONTRAST',
221
+ * mode: PaletteMode,
222
+ * role: PaletteRole,
223
+ * value: number,
224
+ * limit: number,
225
+ * }} PickedFailure
226
+ */
227
+
228
+ /**
229
+ * Error raised for a malformed theme palette block or malformed picked
230
+ * colours. `code` is one of `THEME_PALETTE_MISSING`, `THEME_PALETTE_INVALID`
231
+ * or `APPEARANCE_PALETTE_INVALID`.
232
+ */
233
+ export class PaletteError extends Error {
234
+ /**
235
+ * @param {string} code stable machine-readable code
236
+ * @param {string} detail human-readable detail
237
+ */
238
+ constructor(code, detail) {
239
+ super(`${code}: ${detail}`);
240
+ this.name = 'PaletteError';
241
+ /** @type {string} */
242
+ this.code = code;
243
+ /** @type {string} */
244
+ this.detail = detail;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * The number of colour literals (`#rrggbb` or `#rrggbbaa`) in a token
250
+ * value. `transparent` and `none` are not colours. A theme's `follow` list
251
+ * for that value, in that mode, has exactly this length.
252
+ *
253
+ * @param {string} value token value
254
+ * @returns {number} colour count
255
+ */
256
+ export function paletteColourCount(value) {
257
+ return (value.match(HEX_COLOUR) ?? []).length;
258
+ }
259
+
260
+ /* ---------------------------------------------------------------------- */
261
+ /* Colour arithmetic */
262
+ /* ---------------------------------------------------------------------- */
263
+
264
+ /**
265
+ * @param {string} hex `#rrggbb` or `#rrggbbaa`
266
+ * @returns {[number, number, number]} 8-bit channels
267
+ */
268
+ function channelsOf(hex) {
269
+ return [
270
+ Number.parseInt(hex.slice(1, 3), 16),
271
+ Number.parseInt(hex.slice(3, 5), 16),
272
+ Number.parseInt(hex.slice(5, 7), 16),
273
+ ];
274
+ }
275
+
276
+ /**
277
+ * @param {number} c sRGB channel in [0, 1]
278
+ * @returns {number} linear light
279
+ */
280
+ function toLinear(c) {
281
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
282
+ }
283
+
284
+ /**
285
+ * @param {number} x linear light
286
+ * @returns {number} sRGB channel
287
+ */
288
+ function fromLinear(x) {
289
+ return x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055;
290
+ }
291
+
292
+ /**
293
+ * @param {string} hex `#rrggbb[aa]`
294
+ * @returns {{ L: number, C: number, h: number }} OKLCH, hue in degrees
295
+ */
296
+ function toOklch(hex) {
297
+ const [r8, g8, b8] = channelsOf(hex);
298
+ const r = toLinear(r8 / 255);
299
+ const g = toLinear(g8 / 255);
300
+ const b = toLinear(b8 / 255);
301
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
302
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
303
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
304
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
305
+ const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
306
+ const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
307
+ const C = Math.hypot(a, bb);
308
+ const h = ((Math.atan2(bb, a) * 180) / Math.PI + 360) % 360;
309
+ return { L, C, h };
310
+ }
311
+
312
+ /**
313
+ * @param {number} L OKLab lightness
314
+ * @param {number} C chroma
315
+ * @param {number} h hue in degrees
316
+ * @returns {[number, number, number]} linear sRGB (may be out of gamut)
317
+ */
318
+ function oklchToLinear(L, C, h) {
319
+ const a = C * Math.cos((h * Math.PI) / 180);
320
+ const b = C * Math.sin((h * Math.PI) / 180);
321
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
322
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
323
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
324
+ return [
325
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
326
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
327
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
328
+ ];
329
+ }
330
+
331
+ /**
332
+ * @param {[number, number, number]} linear linear sRGB
333
+ * @returns {boolean} whether every channel is inside the sRGB gamut
334
+ */
335
+ function inGamut(linear) {
336
+ return linear.every((x) => x >= -1e-6 && x <= 1 + 1e-6);
337
+ }
338
+
339
+ /**
340
+ * @param {number} value channel in [0, 1]
341
+ * @returns {string} two lowercase hex digits
342
+ */
343
+ function byteHex(value) {
344
+ const byte = Math.min(255, Math.max(0, Math.round(value * 255)));
345
+ return byte.toString(16).padStart(2, '0');
346
+ }
347
+
348
+ /**
349
+ * Convert OKLCH to `#rrggbb`, reducing chroma until the colour is inside the
350
+ * sRGB gamut, then rounding to 8 bits.
351
+ *
352
+ * @param {number} L OKLab lightness
353
+ * @param {number} C chroma
354
+ * @param {number} h hue in degrees
355
+ * @returns {string} lowercase `#rrggbb`
356
+ */
357
+ function fromOklch(L, C, h) {
358
+ const lightness = Math.min(1, Math.max(0, L));
359
+ let chroma = Math.max(0, C);
360
+ if (!inGamut(oklchToLinear(lightness, chroma, h))) {
361
+ let low = 0;
362
+ let high = chroma;
363
+ for (let step = 0; step < 32; step += 1) {
364
+ const middle = (low + high) / 2;
365
+ if (inGamut(oklchToLinear(lightness, middle, h))) low = middle;
366
+ else high = middle;
367
+ }
368
+ chroma = low;
369
+ }
370
+ const [r, g, b] = oklchToLinear(lightness, chroma, h);
371
+ return `#${byteHex(fromLinear(Math.max(0, r)))}${byteHex(fromLinear(Math.max(0, g)))}${byteHex(fromLinear(Math.max(0, b)))}`;
372
+ }
373
+
374
+ /**
375
+ * WCAG relative luminance of an 8-bit `#rrggbb` colour.
376
+ *
377
+ * @param {string} hex colour
378
+ * @returns {number} luminance in [0, 1]
379
+ */
380
+ function luminance(hex) {
381
+ const [r8, g8, b8] = channelsOf(hex);
382
+ const channel = (/** @type {number} */ c8) => {
383
+ const c = c8 / 255;
384
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
385
+ };
386
+ return 0.2126 * channel(r8) + 0.7152 * channel(g8) + 0.0722 * channel(b8);
387
+ }
388
+
389
+ /**
390
+ * @param {string} a `#rrggbb`
391
+ * @param {string} b `#rrggbb`
392
+ * @returns {number} WCAG contrast ratio, at least 1
393
+ */
394
+ function contrastRatio(a, b) {
395
+ const x = luminance(a);
396
+ const y = luminance(b);
397
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
398
+ }
399
+
400
+ /**
401
+ * Piecewise-linear lightness map fixing 0 and 1 and sending `from` to `to`.
402
+ *
403
+ * @param {number} lightness lightness to map
404
+ * @param {number} from theme seed lightness
405
+ * @param {number} to author seed lightness
406
+ * @returns {number} mapped lightness
407
+ */
408
+ function mapLightness(lightness, from, to) {
409
+ if (from <= 1e-9) return to + lightness * (1 - to);
410
+ if (from >= 1 - 1e-9) return (lightness * to) / from;
411
+ return lightness <= from
412
+ ? (lightness * to) / from
413
+ : to + ((lightness - from) * (1 - to)) / (1 - from);
414
+ }
415
+
416
+ /**
417
+ * Move one colour by the perceptual change that turns the theme's seed into
418
+ * the author's seed, keeping the colour's alpha.
419
+ *
420
+ * @param {string} colour `#rrggbb[aa]` literal from the theme
421
+ * @param {string} themeSeed the theme's own seed (`#rrggbb[aa]`)
422
+ * @param {string} authorSeed the author's seed (`#rrggbb`)
423
+ * @returns {string} shifted `#rrggbb[aa]`
424
+ */
425
+ function shiftColour(colour, themeSeed, authorSeed) {
426
+ if (colour.slice(0, 7) === themeSeed.slice(0, 7)) {
427
+ return authorSeed.slice(0, 7) + colour.slice(7);
428
+ }
429
+ const from = toOklch(themeSeed);
430
+ const to = toOklch(authorSeed);
431
+ const source = toOklch(colour);
432
+ const neutral = from.C < NEUTRAL_CHROMA;
433
+ const hue = neutral ? to.h : (source.h + (to.h - from.h) + 720) % 360;
434
+ const chroma = neutral ? to.C : source.C * (to.C / from.C);
435
+ const lightness = mapLightness(source.L, from.L, to.L);
436
+ return fromOklch(lightness, chroma, hue) + colour.slice(7);
437
+ }
438
+
439
+ /**
440
+ * Replace the colours of one token value, one role entry per colour.
441
+ *
442
+ * @param {string} value the theme's literal value
443
+ * @param {(PaletteRole | null)[]} follow one entry per colour
444
+ * @param {Record<PaletteRole, string>} themeSeeds the theme's seeds
445
+ * @param {Record<PaletteRole, string>} seeds the effective seeds
446
+ * @returns {string} the derived value
447
+ */
448
+ function deriveValue(value, follow, themeSeeds, seeds) {
449
+ let index = 0;
450
+ return value.replace(HEX_COLOUR, (colour) => {
451
+ const role = follow[index];
452
+ index += 1;
453
+ if (role === null || role === undefined) return colour;
454
+ const themeSeed = themeSeeds[role];
455
+ const seed = seeds[role];
456
+ if (themeSeed === seed) return colour;
457
+ return shiftColour(colour, themeSeed, seed);
458
+ });
459
+ }
460
+
461
+ /* ---------------------------------------------------------------------- */
462
+ /* Validation */
463
+ /* ---------------------------------------------------------------------- */
464
+
465
+ /**
466
+ * @param {unknown} value candidate
467
+ * @returns {value is PaletteRole} whether it names a palette role
468
+ */
469
+ function isRole(value) {
470
+ return (
471
+ typeof value === 'string' && PALETTE_ROLES.some((role) => role === value)
472
+ );
473
+ }
474
+
475
+ /**
476
+ * @param {unknown} value candidate
477
+ * @returns {value is Record<string, unknown>} whether it is a plain object
478
+ */
479
+ function isRecord(value) {
480
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
481
+ }
482
+
483
+ /**
484
+ * Validate a theme's `palette` block against its own tokens: the profile,
485
+ * that every key is palette-drivable and appears at most once, that each
486
+ * list length equals the number of colours in the value of that mode, and
487
+ * that every entry is `accent`, `canvas`, `text` or `null`.
488
+ *
489
+ * @param {PaletteTheme} theme theme.json
490
+ * @returns {ThemePalette} the validated block
491
+ * @throws {PaletteError} `THEME_PALETTE_MISSING` or `THEME_PALETTE_INVALID`
492
+ */
493
+ export function validateThemePalette(theme) {
494
+ const palette = /** @type {unknown} */ (theme.palette);
495
+ if (palette === undefined) {
496
+ throw new PaletteError('THEME_PALETTE_MISSING', 'theme has no palette');
497
+ }
498
+ const invalid = (/** @type {string} */ detail) =>
499
+ new PaletteError('THEME_PALETTE_INVALID', detail);
500
+ if (!isRecord(palette) || !Array.isArray(palette.follow)) {
501
+ throw invalid('palette must be {profile, follow[]}');
502
+ }
503
+ if (
504
+ Object.keys(palette).length !== 2 ||
505
+ palette.profile !== PALETTE_PROFILE
506
+ ) {
507
+ throw invalid(`palette.profile must be ${PALETTE_PROFILE}`);
508
+ }
509
+ const seen = new Set();
510
+ for (const entry of /** @type {unknown[]} */ (palette.follow)) {
511
+ if (!isRecord(entry) || Object.keys(entry).length !== 3) {
512
+ throw invalid('follow entry must be {key, light, dark}');
513
+ }
514
+ const key = entry.key;
515
+ if (
516
+ typeof key !== 'string' ||
517
+ !Object.hasOwn(PALETTE_KEY_TYPES, key) ||
518
+ seen.has(key)
519
+ ) {
520
+ throw invalid(`follow key ${String(key)} is not drivable or repeated`);
521
+ }
522
+ seen.add(key);
523
+ const token = theme.tokens.find((row) => row.key === key);
524
+ if (!token) throw invalid(`theme has no token ${key}`);
525
+ for (const mode of PALETTE_MODES) {
526
+ const list = entry[mode];
527
+ if (
528
+ !Array.isArray(list) ||
529
+ list.length !== paletteColourCount(token[mode]) ||
530
+ !list.every((role) => role === null || isRole(role))
531
+ ) {
532
+ throw invalid(`${key} ${mode} follow list does not match its value`);
533
+ }
534
+ }
535
+ }
536
+ return /** @type {ThemePalette} */ (palette);
537
+ }
538
+
539
+ /**
540
+ * Validate the author's picked colours (`appearance.tokens`).
541
+ *
542
+ * @param {unknown} tokens candidate
543
+ * @returns {PaletteTokens} the same value, typed
544
+ * @throws {PaletteError} `APPEARANCE_PALETTE_INVALID`
545
+ */
546
+ export function validatePaletteTokens(tokens) {
547
+ const invalid = (/** @type {string} */ detail) =>
548
+ new PaletteError('APPEARANCE_PALETTE_INVALID', detail);
549
+ if (!isRecord(tokens)) throw invalid('tokens must be an object');
550
+ for (const [role, pair] of Object.entries(tokens)) {
551
+ if (!isRole(role)) throw invalid(`unknown role ${role}`);
552
+ if (
553
+ !isRecord(pair) ||
554
+ Object.keys(pair).length !== 2 ||
555
+ typeof pair.light !== 'string' ||
556
+ typeof pair.dark !== 'string' ||
557
+ !PICK_HEX.test(pair.light) ||
558
+ !PICK_HEX.test(pair.dark)
559
+ ) {
560
+ throw invalid(`${role} must be {light, dark} lowercase #rrggbb`);
561
+ }
562
+ }
563
+ return /** @type {PaletteTokens} */ (tokens);
564
+ }
565
+
566
+ /* ---------------------------------------------------------------------- */
567
+ /* Pair evaluation */
568
+ /* ---------------------------------------------------------------------- */
569
+
570
+ /**
571
+ * @param {string} value token value
572
+ * @returns {string | undefined} why it is not a plain opaque colour
573
+ */
574
+ function whyNotPlain(value) {
575
+ if (/^#[0-9a-f]{6}(?:ff)?$/u.test(value)) return undefined;
576
+ if (/^#[0-9a-f]{6}00$/u.test(value)) return 'fully transparent';
577
+ if (/^#[0-9a-f]{8}$/u.test(value)) return 'translucent';
578
+ if (value === 'none') return 'none';
579
+ return 'a gradient';
580
+ }
581
+
582
+ /**
583
+ * Evaluate one pair on final values.
584
+ *
585
+ * @param {{ label: string, minimum: number, skipWhenNotPlainColor: boolean }} pair the pair
586
+ * @param {string} foreground foreground token value
587
+ * @param {string} background background token value
588
+ * @returns {Omit<PairResult, 'label' | 'foreground' | 'background' | 'minimum'>} outcome
589
+ */
590
+ function evaluatePair(pair, foreground, background) {
591
+ let unmeasurable = whyNotPlain(background) ?? whyNotPlain(foreground);
592
+ if (unmeasurable === 'a gradient' && whyNotPlain(foreground) === undefined) {
593
+ const stops = background.match(HEX_COLOUR) ?? [];
594
+ const readable =
595
+ /^(?:repeating-)?(?:linear|radial)-gradient\(/u.test(background) &&
596
+ !background.includes('transparent') &&
597
+ stops.length >= 2 &&
598
+ stops.every((stop) => stop.length === 7 || stop.endsWith('ff'));
599
+ if (readable) {
600
+ const fg = foreground.slice(0, 7);
601
+ let worst = { ratio: Number.POSITIVE_INFINITY, stop: '' };
602
+ for (const stop of stops) {
603
+ const ratio = contrastRatio(fg, stop.slice(0, 7));
604
+ if (ratio < worst.ratio) worst = { ratio, stop: stop.slice(0, 7) };
605
+ }
606
+ return {
607
+ status: worst.ratio >= pair.minimum ? 'pass' : 'fail',
608
+ ratio: worst.ratio,
609
+ worstStop: worst.stop,
610
+ };
611
+ }
612
+ unmeasurable = 'an unreadable gradient';
613
+ }
614
+ if (unmeasurable !== undefined) {
615
+ return pair.skipWhenNotPlainColor
616
+ ? { status: 'skipped', ratio: null, reason: unmeasurable }
617
+ : { status: 'fail', ratio: null, reason: unmeasurable };
618
+ }
619
+ const ratio = contrastRatio(foreground.slice(0, 7), background.slice(0, 7));
620
+ return { status: ratio >= pair.minimum ? 'pass' : 'fail', ratio };
621
+ }
622
+
623
+ /* ---------------------------------------------------------------------- */
624
+ /* Derivation */
625
+ /* ---------------------------------------------------------------------- */
626
+
627
+ /**
628
+ * Move a bound foreground's lightness by the smallest step that lifts every
629
+ * one of its pairs to the minimum plus the clamp margin. Hue and chroma are
630
+ * kept. Returns the original value when no lightness can reach the target.
631
+ *
632
+ * @param {string} colour `#rrggbb` foreground
633
+ * @param {string[]} backgrounds the opaque backgrounds (plain or stop) it sits on
634
+ * @param {number} target ratio to reach against every background
635
+ * @returns {string} the adjusted colour, or `colour` itself
636
+ */
637
+ function clampForeground(colour, backgrounds, target) {
638
+ const start = toOklch(colour);
639
+ const meanLuminance =
640
+ backgrounds.reduce((sum, bg) => sum + luminance(bg), 0) /
641
+ backgrounds.length;
642
+ const preferred = luminance(colour) >= meanLuminance ? 1 : -1;
643
+ const passes = (/** @type {string} */ candidate) =>
644
+ backgrounds.every((bg) => contrastRatio(candidate, bg) >= target);
645
+ for (let step = 1; step <= 400; step += 1) {
646
+ for (const direction of [preferred, -preferred]) {
647
+ const lightness = start.L + direction * step * 0.0025;
648
+ if (lightness < 0 || lightness > 1) continue;
649
+ const candidate = fromOklch(lightness, start.C, start.h);
650
+ if (passes(candidate)) return candidate;
651
+ }
652
+ }
653
+ return colour;
654
+ }
655
+
656
+ /**
657
+ * Derive every token's final value from a theme and the author's picks.
658
+ *
659
+ * Each colour bound to a seed whose effective value differs from the theme's
660
+ * own is moved in OKLCH (hue rotated by the hue difference, chroma scaled by
661
+ * the chroma ratio or set to the author's when the theme's seed is neutral,
662
+ * lightness through a piecewise-linear map), gamut-mapped by reducing chroma
663
+ * and rounded to 8 bits. A seed unchanged keeps the theme's literal exactly,
664
+ * and a colour equal to the theme's seed becomes the author's seed exactly.
665
+ * Where a contrast pair's foreground is bound (and not itself a seed), its
666
+ * lightness is then moved by the smallest step reaching the minimum plus
667
+ * 0.1; a failing pair with a theme-owned or seed foreground is not adjusted
668
+ * and is reported by `checkPalette`.
669
+ *
670
+ * @param {PaletteTheme} theme theme.json (needs `tokens` and `palette`)
671
+ * @param {PaletteTokens} tokens the author's `appearance.tokens` (`{}` = theme colours)
672
+ * @returns {DerivedPalette} the derived palette
673
+ * @throws {PaletteError} on a malformed theme palette or malformed picks
674
+ */
675
+ export function derivePalette(theme, tokens) {
676
+ const palette = validateThemePalette(theme);
677
+ const picks = validatePaletteTokens(tokens);
678
+ const themeSeeds =
679
+ /** @type {Record<PaletteMode, Record<PaletteRole, string>>} */ ({
680
+ light: {},
681
+ dark: {},
682
+ });
683
+ for (const role of /** @type {PaletteRole[]} */ (PALETTE_ROLES)) {
684
+ const seedToken = theme.tokens.find(
685
+ (row) => row.key === PALETTE_SEED_KEYS[role],
686
+ );
687
+ if (!seedToken) {
688
+ throw new PaletteError(
689
+ 'THEME_PALETTE_INVALID',
690
+ `theme has no ${PALETTE_SEED_KEYS[role]}`,
691
+ );
692
+ }
693
+ themeSeeds.light[role] = seedToken.light;
694
+ themeSeeds.dark[role] = seedToken.dark;
695
+ }
696
+ const seeds =
697
+ /** @type {Record<PaletteMode, Record<PaletteRole, string>>} */ ({
698
+ light: { ...themeSeeds.light },
699
+ dark: { ...themeSeeds.dark },
700
+ });
701
+ for (const role of /** @type {PaletteRole[]} */ (PALETTE_ROLES)) {
702
+ const pick = picks[role];
703
+ if (pick) {
704
+ seeds.light[role] = pick.light;
705
+ seeds.dark[role] = pick.dark;
706
+ }
707
+ }
708
+
709
+ /** @type {Record<string, PalettePair>} */
710
+ const values = {};
711
+ for (const row of theme.tokens)
712
+ values[row.key] = { light: row.light, dark: row.dark };
713
+ const bound = [];
714
+ for (const entry of palette.follow) {
715
+ bound.push(entry.key);
716
+ const row = theme.tokens.find((token) => token.key === entry.key);
717
+ if (!row) continue;
718
+ values[entry.key] = {
719
+ light: deriveValue(row.light, entry.light, themeSeeds.light, seeds.light),
720
+ dark: deriveValue(row.dark, entry.dark, themeSeeds.dark, seeds.dark),
721
+ };
722
+ }
723
+
724
+ /** @type {PaletteAdjustment[]} */
725
+ const adjusted = [];
726
+ /** @type {Set<string>} */
727
+ const seedKeys = new Set(Object.values(PALETTE_SEED_KEYS));
728
+ for (const mode of PALETTE_MODES) {
729
+ const foregrounds = [
730
+ ...new Set(PALETTE_PAIRS.map((pair) => pair.foreground)),
731
+ ];
732
+ for (const key of foregrounds) {
733
+ if (!bound.includes(key) || seedKeys.has(key)) continue;
734
+ const current = values[key]?.[mode];
735
+ if (current === undefined || whyNotPlain(current) !== undefined) continue;
736
+ const backgrounds = [];
737
+ let failing = false;
738
+ let failingPair = '';
739
+ for (const pair of PALETTE_PAIRS.filter(
740
+ (candidate) => candidate.foreground === key,
741
+ )) {
742
+ const background = values[pair.background]?.[mode];
743
+ if (background === undefined) continue;
744
+ const outcome = evaluatePair(pair, current, background);
745
+ if (outcome.status === 'skipped' || outcome.ratio === null) continue;
746
+ const stops =
747
+ whyNotPlain(background) === 'a gradient'
748
+ ? (background.match(HEX_COLOUR) ?? []).map((stop) =>
749
+ stop.slice(0, 7),
750
+ )
751
+ : [background.slice(0, 7)];
752
+ backgrounds.push(...stops);
753
+ if (outcome.status === 'fail' && !failing) {
754
+ failing = true;
755
+ failingPair = pair.label;
756
+ }
757
+ }
758
+ if (!failing) continue;
759
+ const minimum = Math.max(
760
+ ...PALETTE_PAIRS.filter((pair) => pair.foreground === key).map(
761
+ (pair) => pair.minimum,
762
+ ),
763
+ );
764
+ const next = clampForeground(
765
+ current.slice(0, 7),
766
+ backgrounds,
767
+ minimum + PALETTE_CLAMP_MARGIN,
768
+ );
769
+ if (next !== current.slice(0, 7)) {
770
+ const pairValue = /** @type {PalettePair} */ (values[key]);
771
+ pairValue[mode] = next;
772
+ adjusted.push({
773
+ key,
774
+ mode,
775
+ from: current,
776
+ to: next,
777
+ pair: failingPair,
778
+ });
779
+ }
780
+ }
781
+ }
782
+
783
+ return {
784
+ profile: PALETTE_PROFILE,
785
+ seeds,
786
+ bound,
787
+ tokens: values,
788
+ adjusted,
789
+ };
790
+ }
791
+
792
+ /**
793
+ * Check every palette pair on the derived final values, in one mode.
794
+ *
795
+ * @param {DerivedPalette} derived result of `derivePalette`
796
+ * @param {PaletteMode} mode `light` or `dark`
797
+ * @returns {PaletteCheck} per-pair results and the failures (`APPEARANCE_PALETTE_CONTRAST`)
798
+ */
799
+ export function checkPalette(derived, mode) {
800
+ /** @type {PairResult[]} */
801
+ const results = [];
802
+ /** @type {PaletteFailure[]} */
803
+ const failures = [];
804
+ for (const pair of PALETTE_PAIRS) {
805
+ const foreground = derived.tokens[pair.foreground]?.[mode];
806
+ const background = derived.tokens[pair.background]?.[mode];
807
+ const base = {
808
+ label: pair.label,
809
+ foreground: pair.foreground,
810
+ background: pair.background,
811
+ minimum: pair.minimum,
812
+ };
813
+ if (foreground === undefined || background === undefined) {
814
+ const result = {
815
+ ...base,
816
+ status: /** @type {const} */ ('fail'),
817
+ ratio: null,
818
+ reason: 'token missing',
819
+ };
820
+ results.push(result);
821
+ } else {
822
+ results.push({ ...base, ...evaluatePair(pair, foreground, background) });
823
+ }
824
+ }
825
+ for (const result of results) {
826
+ if (result.status === 'fail') {
827
+ failures.push({
828
+ code: 'APPEARANCE_PALETTE_CONTRAST',
829
+ pair: result.label,
830
+ mode,
831
+ ratio: result.ratio,
832
+ minimum: result.minimum,
833
+ foregroundKey: result.foreground,
834
+ backgroundKey: result.background,
835
+ ...(result.reason === undefined ? {} : { reason: result.reason }),
836
+ });
837
+ }
838
+ }
839
+ return { mode, ok: failures.length === 0, results, failures };
840
+ }
841
+
842
+ /**
843
+ * Check the author's picks on their own, without a theme: Background
844
+ * lightness inside the band (OKLab L at least 0.90 in light, at most 0.30 in
845
+ * dark) and Text at least 4.5:1 on Background in each mode where both are
846
+ * set. The author's colours are never adjusted.
847
+ *
848
+ * @param {PaletteTokens} tokens the author's `appearance.tokens`
849
+ * @returns {{ ok: boolean, failures: PickedFailure[] }} outcome
850
+ */
851
+ export function checkPickedColours(tokens) {
852
+ const picks = validatePaletteTokens(tokens);
853
+ /** @type {PickedFailure[]} */
854
+ const failures = [];
855
+ for (const mode of PALETTE_MODES) {
856
+ const canvas = picks.canvas?.[mode];
857
+ if (canvas !== undefined) {
858
+ const lightness = toOklch(canvas).L;
859
+ const limit =
860
+ mode === 'light'
861
+ ? PALETTE_CANVAS_BAND.lightMin
862
+ : PALETTE_CANVAS_BAND.darkMax;
863
+ if (mode === 'light' ? lightness < limit : lightness > limit) {
864
+ failures.push({
865
+ code: 'APPEARANCE_PALETTE_BAND',
866
+ mode,
867
+ role: 'canvas',
868
+ value: lightness,
869
+ limit,
870
+ });
871
+ }
872
+ }
873
+ const text = picks.text?.[mode];
874
+ if (canvas !== undefined && text !== undefined) {
875
+ const ratio = contrastRatio(text, canvas);
876
+ if (ratio < 4.5) {
877
+ failures.push({
878
+ code: 'APPEARANCE_PALETTE_TEXT_CONTRAST',
879
+ mode,
880
+ role: 'text',
881
+ value: ratio,
882
+ limit: 4.5,
883
+ });
884
+ }
885
+ }
886
+ }
887
+ return { ok: failures.length === 0, failures };
888
+ }
889
+
890
+ /**
891
+ * Suggest the dark value for a light pick: the pick's hue and chroma at the
892
+ * OKLab lightness of the theme's own dark seed for that role, gamut-mapped.
893
+ *
894
+ * @param {PaletteTheme} theme theme.json (needs `tokens`)
895
+ * @param {PaletteRole} role the role being picked
896
+ * @param {string} lightHex the author's light pick, lowercase `#rrggbb`
897
+ * @returns {string} suggested dark `#rrggbb`
898
+ * @throws {PaletteError} `APPEARANCE_PALETTE_INVALID` for a malformed pick or role
899
+ */
900
+ export function suggestDarkSeed(theme, role, lightHex) {
901
+ if (!isRole(role) || !PICK_HEX.test(lightHex)) {
902
+ throw new PaletteError(
903
+ 'APPEARANCE_PALETTE_INVALID',
904
+ 'role or colour malformed',
905
+ );
906
+ }
907
+ const seedToken = theme.tokens.find(
908
+ (row) => row.key === PALETTE_SEED_KEYS[role],
909
+ );
910
+ if (!seedToken) {
911
+ throw new PaletteError(
912
+ 'THEME_PALETTE_INVALID',
913
+ `theme has no ${PALETTE_SEED_KEYS[role]}`,
914
+ );
915
+ }
916
+ const pick = toOklch(lightHex);
917
+ const target = toOklch(seedToken.dark);
918
+ return fromOklch(target.L, pick.C, pick.h);
919
+ }