@rathnasgala2/schemas 3.8.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.
- package/THIRD_PARTY_LICENSES.json +1 -1
- package/diagnostics/diagnostic-map.json +277 -9
- package/docs/catalogs/schema-inventory.json +6 -6
- package/examples/valid/appearance/rules.json +105 -1
- package/examples/valid/build-input/rules.json +105 -1
- package/examples/valid/problem/rules.json +35 -0
- package/examples/valid/theme-contract/rules.json +276 -2
- package/fixtures/boundary/appearance/rules.json +105 -12
- package/fixtures/boundary/build-input/rules.json +105 -12
- package/fixtures/boundary/problem/rules.json +83 -0
- package/fixtures/boundary/theme-contract/rules.json +386 -2
- package/fixtures/invalid/appearance/REQUEST_FIELD_UNKNOWN/cases.json +33 -0
- package/fixtures/invalid/appearance/SCHEMA_OBJECT_TOO_LARGE/cases.json +0 -13
- package/fixtures/invalid/appearance/SCHEMA_PATTERN_INVALID/cases.json +68 -0
- package/fixtures/invalid/appearance/SCHEMA_REQUIRED_FIELD_MISSING/cases.json +66 -0
- package/fixtures/invalid/appearance/SCHEMA_TYPE_INVALID/cases.json +99 -0
- package/fixtures/invalid/build-input/REQUEST_FIELD_UNKNOWN/cases.json +33 -0
- package/fixtures/invalid/build-input/SCHEMA_PATTERN_INVALID/cases.json +66 -0
- package/fixtures/invalid/build-input/SCHEMA_REQUIRED_FIELD_MISSING/cases.json +66 -0
- package/fixtures/invalid/build-input/SCHEMA_TYPE_INVALID/cases.json +99 -0
- package/fixtures/invalid/problem/SCHEMA_ARRAY_TOO_LONG/cases.json +13 -0
- package/fixtures/invalid/problem/SCHEMA_ENUM_INVALID/cases.json +11 -0
- package/fixtures/invalid/problem/SCHEMA_STRING_TOO_LONG/cases.json +11 -0
- package/fixtures/invalid/problem/SCHEMA_STRING_TOO_SHORT/cases.json +11 -0
- package/fixtures/invalid/problem/SCHEMA_TYPE_INVALID/cases.json +29 -0
- package/fixtures/invalid/theme-contract/REQUEST_FIELD_UNKNOWN/cases.json +33 -0
- package/fixtures/invalid/theme-contract/SCHEMA_ARRAY_TOO_LONG/cases.json +165 -0
- package/fixtures/invalid/theme-contract/SCHEMA_CONSTANT_INVALID/cases.json +11 -11
- package/fixtures/invalid/theme-contract/SCHEMA_ENUM_INVALID/cases.json +88 -0
- package/fixtures/invalid/theme-contract/SCHEMA_REQUIRED_FIELD_MISSING/cases.json +93 -0
- package/fixtures/invalid/theme-contract/SCHEMA_TYPE_INVALID/cases.json +51 -0
- package/fixtures/manifest.json +2137 -37
- package/fixtures/unknown-field/appearance/rules.json +54 -1
- package/fixtures/unknown-field/build-input/rules.json +54 -1
- package/fixtures/unknown-field/problem/rules.json +8 -0
- package/fixtures/unknown-field/theme-contract/rules.json +70 -1
- package/generated/browser/validator-core.mjs +3 -3
- package/generated/typescript/contracts/appearance.d.ts +14 -1
- package/generated/typescript/contracts/build-input.d.ts +14 -1
- package/generated/typescript/contracts/problem.d.ts +9 -0
- package/generated/typescript/contracts/theme-contract.d.ts +51 -0
- package/openapi/http-catalog.json +2 -2
- package/openapi/openapi.yaml +97 -3
- package/package.json +10 -1
- package/parity/palette/golden-vectors.json +883 -0
- package/parity/palette/stress-corpus.json +521 -0
- package/parity/palette/theme-default.fixture.json +698 -0
- package/sbom.cdx.json +23 -23
- package/schemas/appearance.schema.json +30 -3
- package/schemas/build-input.schema.json +30 -3
- package/schemas/problem.schema.json +21 -1
- package/schemas/theme-contract.schema.json +185 -1
- package/src/palette.js +919 -0
- package/types/palette.d.ts +305 -0
- 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
|
+
}
|