@adea-ai/themes 0.3.1 → 0.4.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 (69) hide show
  1. package/package.json +1 -1
  2. package/src/accents.ts +249 -0
  3. package/src/generated/themes.ts +17 -17
  4. package/src/index.ts +15 -0
  5. package/src/sources.ts +13 -3
  6. package/dist/adapters/base24.d.ts +0 -117
  7. package/dist/adapters/base24.d.ts.map +0 -1
  8. package/dist/adapters/base24.js +0 -313
  9. package/dist/adapters/base24.js.map +0 -1
  10. package/dist/adapters/css.d.ts +0 -68
  11. package/dist/adapters/css.d.ts.map +0 -1
  12. package/dist/adapters/css.js +0 -107
  13. package/dist/adapters/css.js.map +0 -1
  14. package/dist/adapters/shadcn.d.ts +0 -51
  15. package/dist/adapters/shadcn.d.ts.map +0 -1
  16. package/dist/adapters/shadcn.js +0 -120
  17. package/dist/adapters/shadcn.js.map +0 -1
  18. package/dist/adapters/shiki.d.ts +0 -60
  19. package/dist/adapters/shiki.d.ts.map +0 -1
  20. package/dist/adapters/shiki.js +0 -139
  21. package/dist/adapters/shiki.js.map +0 -1
  22. package/dist/adapters/tailwind.d.ts +0 -35
  23. package/dist/adapters/tailwind.d.ts.map +0 -1
  24. package/dist/adapters/tailwind.js +0 -58
  25. package/dist/adapters/tailwind.js.map +0 -1
  26. package/dist/adapters/xterm.d.ts +0 -64
  27. package/dist/adapters/xterm.d.ts.map +0 -1
  28. package/dist/adapters/xterm.js +0 -112
  29. package/dist/adapters/xterm.js.map +0 -1
  30. package/dist/catalogue.d.ts +0 -66
  31. package/dist/catalogue.d.ts.map +0 -1
  32. package/dist/catalogue.js +0 -110
  33. package/dist/catalogue.js.map +0 -1
  34. package/dist/derive.d.ts +0 -89
  35. package/dist/derive.d.ts.map +0 -1
  36. package/dist/derive.js +0 -172
  37. package/dist/derive.js.map +0 -1
  38. package/dist/generated/schemes.d.ts +0 -16
  39. package/dist/generated/schemes.d.ts.map +0 -1
  40. package/dist/generated/schemes.js +0 -880
  41. package/dist/generated/schemes.js.map +0 -1
  42. package/dist/generated/themes.d.ts +0 -11
  43. package/dist/generated/themes.d.ts.map +0 -1
  44. package/dist/generated/themes.js +0 -1658
  45. package/dist/generated/themes.js.map +0 -1
  46. package/dist/index.d.ts +0 -67
  47. package/dist/index.d.ts.map +0 -1
  48. package/dist/index.js +0 -58
  49. package/dist/index.js.map +0 -1
  50. package/dist/normalize.d.ts +0 -192
  51. package/dist/normalize.d.ts.map +0 -1
  52. package/dist/normalize.js +0 -958
  53. package/dist/normalize.js.map +0 -1
  54. package/dist/oklch.d.ts +0 -141
  55. package/dist/oklch.d.ts.map +0 -1
  56. package/dist/oklch.js +0 -311
  57. package/dist/oklch.js.map +0 -1
  58. package/dist/schema.d.ts +0 -178
  59. package/dist/schema.d.ts.map +0 -1
  60. package/dist/schema.js +0 -69
  61. package/dist/schema.js.map +0 -1
  62. package/dist/sources.d.ts +0 -296
  63. package/dist/sources.d.ts.map +0 -1
  64. package/dist/sources.js +0 -624
  65. package/dist/sources.js.map +0 -1
  66. package/dist/validate.d.ts +0 -121
  67. package/dist/validate.d.ts.map +0 -1
  68. package/dist/validate.js +0 -255
  69. package/dist/validate.js.map +0 -1
package/dist/normalize.js DELETED
@@ -1,958 +0,0 @@
1
- /**
2
- * The normalizer: Base24 in, Adea theme out.
3
- *
4
- * This module is the reason the package exists. The palettes in `palettes/` are
5
- * other people's, and they arrive describing a *terminal*: a background, a
6
- * foreground, a comment grey, and sixteen hues. A UI needs different words — a
7
- * surface ladder, borders, an accent, four status roles that are legible as text.
8
- * Translating between the two is the work Adea owns, and it is done here, once,
9
- * for every theme in the catalogue, rather than by hand in each consumer.
10
- *
11
- * ## The transformations, and why each is not obvious
12
- *
13
- * **The surface ladder is built, not read.** Base24 has exactly one background
14
- * slot, so there is no upstream value to copy for "a card on the canvas". The four
15
- * rungs are derived by adding fixed increments to the background's OKLCH
16
- * lightness, keeping its hue and a damped share of its chroma — which is what
17
- * makes Catppuccin's ladder mauve-tinted and Nord's blue-tinted without either
18
- * being told to be.
19
- *
20
- * **The direction of that ladder flips with the appearance.** On a dark theme a
21
- * raised surface is *lighter*; on a light theme it is *darker*. Getting this
22
- * wrong yields a light theme whose dialogs are white-on-white, so
23
- * {@link ladderDirection} is measured from the background rather than assumed from
24
- * the declared variant.
25
- *
26
- * **Light Base24 schemes invert their own ramp.** In a dark scheme `base03` is a
27
- * dark grey and `base05` is near-white; in a light scheme `base03` is the lightest
28
- * grey and `base05` is near-black. A fixed assignment of slots to ANSI roles
29
- * therefore produces an inverted 16-colour ramp on half the catalogue. The ramp
30
- * roles are assigned by *measured contrast* and by appearance instead, in
31
- * {@link buildAnsiRamp}.
32
- *
33
- * **A selection colour can erase its own text.** Base24's `base02` is whatever the
34
- * palette's author set, and several set it to a near-white — fine as a terminal
35
- * selection, fatal as an app selection under near-white body text. The theme keeps
36
- * the palette's colour and the *adapters* derive a matching foreground
37
- * (see `adapters/xterm.ts`); the catalogue additionally checks that the selection
38
- * is perceptible against the background at all.
39
- *
40
- * **Status and accent colours are repaired, within a budget.** A palette chosen for
41
- * an editor is not automatically legible as UI text. Every status role is checked
42
- * against the background and its lightness moved by the least amount that clears
43
- * the floor — and if that would move it more than the budget allows, the theme is
44
- * reported as failing rather than silently shipping a colour that is no longer the
45
- * one the palette's author chose.
46
- */
47
- import { parseBase24Palette } from './adapters/base24';
48
- import { canonical, contrastRatio, contrastDirection, deltaEok, formatOklch, mix, parseColor, shiftLightness, } from './oklch';
49
- import { repairContrast } from './oklch';
50
- /**
51
- * The contrast floors.
52
- *
53
- * These are the catalogue's policy, and they are the reason an external palette
54
- * can be admitted at all: nothing enters the catalogue on taste. `text` sits at
55
- * the WCAG AA body-text floor; `border` is measured on the non-text scale, where
56
- * the requirement is perceptibility rather than legibility.
57
- */
58
- export const CONTRAST_FLOORS = Object.freeze({
59
- text: 4.5,
60
- textMuted: 4.5,
61
- /** Large or non-essential text, per WCAG 1.4.3. */
62
- textSubtle: 3,
63
- accent: 4.5,
64
- /**
65
- * The accent on a raised surface, where it is a fill rather than a label.
66
- *
67
- * The same two-tier reasoning as `statusRaised`: a button or a selected tab is a
68
- * user-interface component, which WCAG holds to 3:1, and the pairing that
69
- * actually carries text — the accent's own label — is measured separately at 4.5
70
- * against the accent itself. Holding the accent to 4.5 on every surface instead
71
- * made Everforest Light's every hue collapse into its body text, because that
72
- * palette's colours are all around 2:1 on its own canvas.
73
- */
74
- accentRaised: 3,
75
- accentForeground: 4.5,
76
- /**
77
- * Status colours drawn on the canvas, where they are read as small text.
78
- */
79
- status: 4.5,
80
- /**
81
- * Status colours drawn on a raised surface, where they are an indicator.
82
- *
83
- * The two-tier policy is deliberate and it is not a relaxation for convenience.
84
- * A status role on a card is nearly always a dot, an icon or a chip's own fill —
85
- * a graphical object, which WCAG holds to 3:1 — and it is that way because the
86
- * alternative does not exist: Ayu Light and Everforest Light are muted palettes
87
- * built for a near-white canvas and neither contains a yellow that is legible as
88
- * small text on one. Forcing 4.5:1 there required blending the warning colour
89
- * 94% of the way to the body text, which produces a warning that is the same
90
- * colour as the text beside it and is therefore not a warning at all.
91
- *
92
- * A component that renders small status *text* on a raised surface should put it
93
- * on the role's `-subtle` fill, which is a tint of the canvas and is measured at
94
- * the body floor.
95
- */
96
- statusRaised: 3,
97
- /** A divider is decorative; it must be seen, not read. */
98
- border: 1.15,
99
- borderMuted: 1.08,
100
- /** A raised surface must be distinguishable from the canvas it sits on. */
101
- surface: 1.03,
102
- /** A selection must be visible under the text it selects. */
103
- selection: 1.2,
104
- });
105
- /** How far the normalizer may move a colour's lightness before it gives up. */
106
- /**
107
- * How much a repair changed the palette's colour.
108
- *
109
- * Lightness movement and blending are not equally costly. Moving lightness keeps
110
- * the hue and most of the chroma and is what a designer does by hand; blending
111
- * toward the foreground gives up chroma, and past roughly a third of the way the
112
- * result stops reading as the colour it came from. The weights encode that, and the
113
- * score is only ever used to compare two ways of repairing the *same role*, so its
114
- * absolute value means nothing.
115
- */
116
- function distortionOf(attempt) {
117
- return attempt.blended ? attempt.repair.delta * 3 : attempt.repair.delta;
118
- }
119
- const REPAIR_BUDGET = Object.freeze({
120
- /** Body text: some palettes publish a foreground that fails on their own canvas. */
121
- text: 0.2,
122
- status: 0.24,
123
- accent: 0.24,
124
- });
125
- /** The accent preference order, best first. */
126
- const ACCENT_PREFERENCE = ['blue', 'magenta', 'cyan', 'green'];
127
- /**
128
- * How much stronger body text must be than the secondary rung.
129
- *
130
- * Small on purpose. The requirement is that the two are *distinguishable*, not that
131
- * there is a wide gulf — 0.4 of a contrast ratio is a visible difference without
132
- * making secondary text unnecessarily faint.
133
- */
134
- const MINIMUM_LADDER_GAP = 0.4;
135
- /** The smallest chroma a colour needs before it reads as "a colour" rather than grey. */
136
- const MINIMUM_ACCENT_CHROMA = 0.035;
137
- const SURFACE_STEPS = Object.freeze({
138
- surface: 0.035,
139
- surfaceElevated: 0.065,
140
- surfaceHover: 0.1,
141
- surfaceActive: 0.135,
142
- });
143
- /**
144
- * Which way a raised surface moves from the canvas.
145
- *
146
- * Measured from the background's lightness rather than taken from the declared
147
- * variant. The declaration is authored by hand and has been wrong; the luminance
148
- * of the background is not.
149
- */
150
- function ladderDirection(background) {
151
- return background.l <= 0.5 ? 1 : -1;
152
- }
153
- /** True when the declared variant and the measured canvas agree. */
154
- export function appearanceMatchesCanvas(appearance, background) {
155
- return (background.l <= 0.5 ? 'dark' : 'light') === appearance;
156
- }
157
- /**
158
- * Builds one rung of the surface ladder.
159
- *
160
- * Chroma is scaled up slightly rather than held constant: the steps are small
161
- * enough that a hue carried at its original chroma reads as a grey smudge, while
162
- * the same hue with a little more chroma reads as "the tint this theme is made
163
- * of". The scale is modest because a surface is still a surface — at the hover
164
- * rung a large scale turns a panel into a coloured button.
165
- */
166
- function surfaceRung(background, step, direction) {
167
- const raised = shiftLightness(background, step * direction);
168
- const chromaScale = 1 + Math.min(0.6, step * 6);
169
- return { ...raised, c: Math.min(0.06, raised.c * chromaScale) };
170
- }
171
- /**
172
- * Assigns the greyscale ANSI roles.
173
- *
174
- * Base24 does not name `black`, `white` or `brightBlack` — the background and
175
- * foreground slots do double duty — so the mapping is Adea's, and two things about
176
- * it are not obvious.
177
- *
178
- * **It is appearance-aware, because the ANSI greyscale ramp means opposite things
179
- * on the two kinds of theme.** On a dark terminal ANSI 0 is a dark neutral used for
180
- * text on light fills and ANSI 8 is the dim comment grey; on a light terminal ANSI
181
- * 0 *is* the body text. A single fixed rule gets one of the two wrong.
182
- *
183
- * **`white` and `brightWhite` are read from different slots per appearance.** On a
184
- * dark theme they are the two strongest foregrounds, because that is where a dark
185
- * palette keeps its light colours. On a light theme the foreground ramp is
186
- * *entirely dark* — Gruvbox Light's `base05`, `base06` and `base07` are all
187
- * `#3c3836` — so reading "white" off it yields a near-black, and the light colours
188
- * have to come from the two slots the palette puts *beyond* its canvas, `base10`
189
- * and `base11`. Where a palette leaves those darker than its canvas, the foreground
190
- * ramp is used instead.
191
- *
192
- * Candidates are paired with `base01` and de-duplicated first, because light
193
- * schemes routinely set `base05`, `base06` and `base07` to the same value — without
194
- * de-duplication the ramp collapses to three members and the roles collide.
195
- */
196
- function buildAnsiRamp(palette, background, appearance) {
197
- // Which slots are *foregrounds* depends on the appearance, because Base24's
198
- // background slots do double duty. `base01` is the "lighter background" — a
199
- // status-bar fill — so on a dark scheme it is a usable mid grey and on a light
200
- // scheme it is a second, barely-darker canvas. `base06` and `base07` are the
201
- // "light foreground" and "light background", and light schemes routinely set
202
- // `base07` to the *lightest* background, which ranks as the dimmest member of the
203
- // ramp: including it gave Everforest Light a `brightBlack` of `#fffbef`, a
204
- // comment colour 1.16:1 against its own canvas.
205
- //
206
- // So a light theme's greyscale foregrounds are exactly the three slots Base24
207
- // defines as foreground-ish, and a dark theme's include the two extra slots it has
208
- // to work with.
209
- const candidates = appearance === 'dark'
210
- ? [palette.base01, palette.base03, palette.base04, palette.base05, palette.base06, palette.base07]
211
- : [palette.base03, palette.base04, palette.base05];
212
- const distinct = [];
213
- for (const candidate of candidates) {
214
- if (distinct.every((kept) => deltaEok(kept, candidate) > 0.02))
215
- distinct.push(candidate);
216
- }
217
- // Strongest first: the ramp is ranked by measured contrast, not by slot number,
218
- // because a slot's contrast depends on which appearance the palette is.
219
- distinct.sort((a, b) => contrastRatio(b, background) - contrastRatio(a, background));
220
- const strongest = distinct[0] ?? palette.base05;
221
- const dimmest = distinct[distinct.length - 1] ?? palette.base03;
222
- const secondStrongest = distinct[1] ?? strongest;
223
- const secondDimmest = distinct[distinct.length - 2] ?? dimmest;
224
- if (appearance === 'dark') {
225
- return {
226
- brightWhite: strongest,
227
- white: secondStrongest,
228
- black: dimmest,
229
- brightBlack: secondDimmest,
230
- };
231
- }
232
- // The two slots a light scheme places beyond its canvas. `base11` is one step
233
- // further than `base10`, which is what makes it the brighter of the pair.
234
- //
235
- // A canvas that is already at the top of the range — Adea's own light theme is
236
- // pure white — has nothing beyond it, so the fallback is the *brightest* end of
237
- // the foreground ramp rather than the darkest. Falling back to the dimmest would
238
- // hand a light theme an ANSI white darker than its own text.
239
- const beyondCanvas = [palette.base10, palette.base11].filter((candidate) => candidate.l > background.l);
240
- return {
241
- brightWhite: beyondCanvas[1] ?? beyondCanvas[0] ?? strongest,
242
- white: beyondCanvas[0] ?? secondStrongest,
243
- black: strongest,
244
- brightBlack: dimmest,
245
- };
246
- }
247
- /** Formats a role, pairing the value with the finding it produced if any. */
248
- function emit(themeId, role, repair, floor, blended = false) {
249
- if (repair.satisfied && repair.delta === 0)
250
- return { value: formatOklch(repair.color) };
251
- if (repair.satisfied) {
252
- return {
253
- value: formatOklch(repair.color),
254
- finding: {
255
- themeId,
256
- role,
257
- kind: 'repaired',
258
- message: blended
259
- ? `blended ${(repair.delta * 100).toFixed(0)}% into the foreground to reach ${floor}:1 (now ${repair.ratio.toFixed(2)}:1)`
260
- : `lightness moved ${repair.delta.toFixed(3)} to reach ${floor}:1 (now ${repair.ratio.toFixed(2)}:1)`,
261
- },
262
- };
263
- }
264
- return {
265
- value: formatOklch(repair.color),
266
- finding: {
267
- themeId,
268
- role,
269
- kind: 'budget-exceeded',
270
- message: `still ${repair.ratio.toFixed(2)}:1 against the canvas after the maximum lightness move; needs ${floor}:1`,
271
- },
272
- };
273
- }
274
- /**
275
- * Chooses the accent.
276
- *
277
- * The accent is the one colour a UI uses for "this is interactive", so it is
278
- * chosen by measurement rather than by convention: candidates are tried in a fixed
279
- * preference order (blue first, because a blue accent is what most of these
280
- * palettes were designed around) and the first that clears the text floor within
281
- * budget, while still being *a* colour rather than a grey, wins. A family whose
282
- * identity lies elsewhere — Everforest is green, Rosé Pine is iris — declares its
283
- * slot explicitly, and that declaration is honoured before any preference.
284
- */
285
- function chooseAccent(themeId, ansi, surfaces, preferred, anchor) {
286
- const findings = [];
287
- const background = surfaces[0];
288
- const order = preferred ? [preferred, ...ACCENT_PREFERENCE] : ACCENT_PREFERENCE;
289
- let fallback;
290
- const satisfying = [];
291
- for (const role of order) {
292
- const candidate = ansi[role];
293
- if (!candidate || candidate.c < MINIMUM_ACCENT_CHROMA)
294
- continue;
295
- // Two tiers, as for the status roles: the link floor on the canvas, then the
296
- // component floor on a raised surface, applied in sequence.
297
- const onCanvas = repairRole(candidate, [background], anchor, CONTRAST_FLOORS.accent, REPAIR_BUDGET.accent);
298
- if (!onCanvas.repair.satisfied) {
299
- if (!fallback || onCanvas.repair.ratio > fallback.repair.ratio) {
300
- fallback = { role, repair: onCanvas.repair };
301
- }
302
- continue;
303
- }
304
- const onRaised = repairRole(onCanvas.repair.color, surfaces.slice(1), anchor, CONTRAST_FLOORS.accentRaised, REPAIR_BUDGET.accent);
305
- const attempt = {
306
- repair: {
307
- color: onRaised.repair.color,
308
- ratio: onRaised.repair.ratio,
309
- delta: onCanvas.repair.delta + onRaised.repair.delta,
310
- satisfied: onRaised.repair.satisfied,
311
- },
312
- blended: onCanvas.blended || onRaised.blended,
313
- };
314
- if (attempt.repair.satisfied) {
315
- satisfying.push({ role, repair: attempt.repair, distortion: distortionOf(attempt), blended: attempt.blended });
316
- continue;
317
- }
318
- // Track the best attempt so a theme that cannot satisfy the floor still gets
319
- // its most legible candidate rather than an arbitrary one.
320
- if (!fallback || attempt.repair.ratio > fallback.repair.ratio) {
321
- fallback = { role, repair: attempt.repair };
322
- }
323
- }
324
- if (satisfying.length > 0) {
325
- // A declared accent is honoured whenever it can be reached without giving the
326
- // colour up. Lightness movement *is* what a palette author does to make an
327
- // accent usable — Rosé Pine's iris needs a 0.11 step on its light variant and is
328
- // still unmistakably iris — so a lightness repair never costs a family its
329
- // identity. Blending does: past about a third of the way the result stops being
330
- // the colour it came from, and a green repaired into slate is no longer
331
- // Everforest's accent.
332
- //
333
- // So the declared slot wins unless it had to be blended; if it did — or if none
334
- // was declared — the least-distorted candidate wins, with the preference order
335
- // breaking ties.
336
- const cheapest = satisfying.reduce((best, entry) => entry.distortion < best.distortion ? entry : best);
337
- const declared = preferred ? satisfying.find((entry) => entry.role === preferred) : undefined;
338
- const chosen = declared && !declared.blended ? declared : cheapest;
339
- if (chosen.role !== preferred && preferred) {
340
- findings.push({
341
- themeId,
342
- role: 'accent',
343
- kind: 'substituted',
344
- message: declared
345
- ? `the declared accent slot ${preferred} could only clear the floor by blending ${(declared.repair.delta * 100).toFixed(0)}% into the foreground; used ${chosen.role} at ${(chosen.repair.delta * 100).toFixed(0)}% instead`
346
- : `the declared accent slot ${preferred} could not clear the floor; used ${chosen.role} instead`,
347
- });
348
- }
349
- if (chosen.repair.delta > 0.01) {
350
- findings.push({
351
- themeId,
352
- role: 'accent',
353
- kind: 'repaired',
354
- message: chosen.blended
355
- ? `blended ${(chosen.repair.delta * 100).toFixed(0)}% into the foreground from the palette's ${chosen.role}`
356
- : `lightness moved ${chosen.repair.delta.toFixed(3)} from the palette's ${chosen.role}`,
357
- });
358
- }
359
- return { role: chosen.role, repair: chosen.repair, findings };
360
- }
361
- const chosen = fallback ?? {
362
- role: 'blue',
363
- repair: repairContrast(background, background, 1),
364
- };
365
- findings.push({
366
- themeId,
367
- role: 'accent',
368
- kind: 'budget-exceeded',
369
- message: `no accent candidate cleared ${CONTRAST_FLOORS.accent}:1; the best was ${chosen.role} at ${chosen.repair.ratio.toFixed(2)}:1`,
370
- });
371
- return { role: chosen.role, repair: chosen.repair, findings };
372
- }
373
- /**
374
- * Picks the text colour drawn on top of the accent.
375
- *
376
- * Not derived from the appearance, which is the tempting shortcut and is wrong:
377
- * Catppuccin Mocha's blue accent is light enough that black is the legible label,
378
- * while Nord's is dark enough that white is. The pick is whichever of the
379
- * palette's own two extremes measures better, repaired if neither clears the floor.
380
- */
381
- function chooseAccentForeground(themeId, accent, palette) {
382
- const findings = [];
383
- const candidates = [palette.base00, palette.base07, palette.base05, palette.base06];
384
- let best = candidates[0];
385
- let bestRatio = contrastRatio(best, accent);
386
- for (const candidate of candidates.slice(1)) {
387
- const ratio = contrastRatio(candidate, accent);
388
- if (ratio > bestRatio) {
389
- best = candidate;
390
- bestRatio = ratio;
391
- }
392
- }
393
- const repair = repairContrast(best, accent, CONTRAST_FLOORS.accentForeground, 0.6);
394
- if (!repair.satisfied) {
395
- findings.push({
396
- themeId,
397
- role: 'accentForeground',
398
- kind: 'budget-exceeded',
399
- message: `only ${repair.ratio.toFixed(2)}:1 against the accent; needs ${CONTRAST_FLOORS.accentForeground}:1`,
400
- });
401
- }
402
- return { value: repair.color, findings };
403
- }
404
- /**
405
- * Moves a colour's lightness until it clears a floor against **every** surface it
406
- * can be drawn on.
407
- *
408
- * This is the difference between a theme that is legible on its canvas and a theme
409
- * that is legible in the application. `repairContrast` measures against one
410
- * background, which is right for a terminal and wrong for a UI: a status colour is
411
- * as likely to appear inside a card or a popover as on the canvas, and those rungs
412
- * are deliberately moved away from the canvas, so a colour tuned to exactly 4.5:1
413
- * on the background lands at roughly 4.0:1 on a card.
414
- *
415
- * The surfaces of one theme all sit on the same side of the canvas, so the surface
416
- * with the *lowest* current ratio is the furthest from the colour, and moving away
417
- * from it moves away from all of them. The worst surface is recomputed on every
418
- * step rather than chosen once, because a colour can cross between them.
419
- */
420
- function repairAcrossSurfaces(color, surfaces, minimum, budget) {
421
- // Measured in the canonical form the catalogue commits, so a repair cannot
422
- // converge on a value that rounding then pushes back below the floor.
423
- const worst = (candidate) => {
424
- const rounded = canonical(candidate);
425
- let result = { surface: surfaces[0], ratio: Number.POSITIVE_INFINITY };
426
- for (const surface of surfaces) {
427
- const ratio = contrastRatio(rounded, surface);
428
- if (ratio < result.ratio)
429
- result = { surface, ratio };
430
- }
431
- return result;
432
- };
433
- const initial = worst(color);
434
- if (initial.ratio >= minimum) {
435
- return { color, ratio: initial.ratio, delta: 0, satisfied: true };
436
- }
437
- const direction = contrastDirection(color, initial.surface);
438
- const step = 0.002;
439
- const maximumSteps = Math.floor(budget / step);
440
- for (let index = 1; index <= maximumSteps; index += 1) {
441
- const candidate = { ...color, l: color.l + direction * step * index };
442
- if (candidate.l <= 0 || candidate.l >= 1)
443
- break;
444
- const measured = worst(candidate);
445
- if (measured.ratio >= minimum) {
446
- return {
447
- color: candidate,
448
- ratio: measured.ratio,
449
- delta: Number((step * index).toFixed(4)),
450
- satisfied: true,
451
- };
452
- }
453
- }
454
- return { color, ratio: initial.ratio, delta: 0, satisfied: false };
455
- }
456
- /**
457
- * Repairs a colour that cannot reach the floor by lightness alone, by blending it
458
- * toward the palette's own foreground.
459
- *
460
- * Everforest Light is why this exists. Its entire palette is muted pastels chosen
461
- * for a cream canvas, and its brightest accents measure 1.6:1 against that canvas —
462
- * no lightness move fixes that within a budget that leaves the colour recognisable,
463
- * because the colour is already as dark as its own hue allows. Darkening it further
464
- * produces olive.
465
- *
466
- * Blending toward the palette's foreground instead is what the palette's own author
467
- * does when they need a colour to be read rather than seen: it keeps the hue, gives
468
- * up chroma, and converges on the foreground's contrast as the blend approaches 1.
469
- * The result is a muted, deeper version of the accent — which is what Everforest's
470
- * light themes actually look like.
471
- *
472
- * The blend is bound by the foreground, so it always terminates; and when even a
473
- * full blend cannot clear the floor, the theme is reported rather than shipped with
474
- * an invented colour.
475
- */
476
- function repairByBlending(color, surfaces, anchor, minimum) {
477
- const worstRatio = (candidate) => Math.min(...surfaces.map((surface) => contrastRatio(canonical(candidate), surface)));
478
- let low = 0;
479
- let high = 1;
480
- if (worstRatio(anchor) < minimum) {
481
- return { color, ratio: worstRatio(color), delta: 0, satisfied: false };
482
- }
483
- // The smallest blend that works: 20 iterations resolve the amount far below the
484
- // precision the catalogue is committed at.
485
- for (let iteration = 0; iteration < 20; iteration += 1) {
486
- const mid = (low + high) / 2;
487
- if (worstRatio(mix(color, anchor, mid)) >= minimum)
488
- high = mid;
489
- else
490
- low = mid;
491
- }
492
- const blended = canonical(mix(color, anchor, high));
493
- return {
494
- color: blended,
495
- ratio: worstRatio(blended),
496
- delta: Number(high.toFixed(4)),
497
- satisfied: true,
498
- };
499
- }
500
- /**
501
- * The full repair chain for a foreground role.
502
- *
503
- * Lightness first, because that is the cheapest change and usually enough; blending
504
- * second, because some palettes cannot be repaired any other way. Both are reported
505
- * so a reviewer can see which themes needed which.
506
- */
507
- function repairRole(color, surfaces, anchor, minimum, budget) {
508
- const lightness = repairAcrossSurfaces(color, surfaces, minimum, budget);
509
- if (lightness.satisfied)
510
- return { repair: lightness, blended: false };
511
- const blend = repairByBlending(color, surfaces, anchor, minimum);
512
- if (blend.satisfied)
513
- return { repair: blend, blended: true };
514
- return { repair: lightness, blended: false };
515
- }
516
- /**
517
- * The Base24 slots that carry a hue rather than a grey.
518
- *
519
- * `base09` and `base0F` are included even though neither donor publishes them: they
520
- * are synthesised from red and yellow, and leaving them behind would put an
521
- * untransposed orange next to a transposed red.
522
- */
523
- const CHROMATIC_SLOTS = [
524
- 'base08',
525
- 'base09',
526
- 'base0A',
527
- 'base0B',
528
- 'base0C',
529
- 'base0D',
530
- 'base0E',
531
- 'base0F',
532
- 'base12',
533
- 'base13',
534
- 'base14',
535
- 'base15',
536
- 'base16',
537
- 'base17',
538
- ];
539
- /** The six slots whose bright siblings make a normal/bright pair. */
540
- const NORMAL_HUE_SLOTS = ['base08', 'base0A', 'base0B', 'base0C', 'base0D', 'base0E'];
541
- /**
542
- * Moves every hue in a palette by one common lightness step, so a hue set authored
543
- * against one canvas becomes legible on another.
544
- *
545
- * This exists because a composition can put a palette's hues on somebody else's
546
- * background. GitHub's dark hues measure between 2.5:1 and 4.1:1 on a light canvas —
547
- * fine as accents, unusable as terminal text — and repairing each hue *independently*
548
- * to the floor destroys the thing that makes an ANSI palette an ANSI palette: the
549
- * bright variants would converge onto their normal siblings, because a bright variant
550
- * differs from its normal one mainly in lightness and the repair would land them all
551
- * on the same lightness.
552
- *
553
- * Moving the group by one step instead transposes the whole set: every hue keeps its
554
- * angle and chroma exactly, every relative lightness is preserved, and the palette
555
- * stays internally consistent. The step is the smallest one that brings every *normal*
556
- * hue to the floor, measured in the canonical form the catalogue commits so that
557
- * rounding cannot push one back under.
558
- */
559
- function transposeHues(themeId, palette, floor, findings) {
560
- const background = palette.base00;
561
- const worst = (step) => Math.min(...NORMAL_HUE_SLOTS.map((slot) => contrastRatio(canonical(shiftLightness(palette[slot], step)), background)));
562
- const initial = worst(0);
563
- if (initial >= floor)
564
- return {};
565
- // Both directions are tried, nearest first, as in `repairContrast`: a dark canvas
566
- // needs its hues lightened and a light canvas needs them darkened, and a palette
567
- // moved to the other kind of canvas is the whole point of the operation.
568
- const step = 0.002;
569
- let chosen;
570
- for (let index = 1; index <= Math.floor(0.45 / step) && chosen === undefined; index += 1) {
571
- for (const direction of [1, -1]) {
572
- const candidate = step * index * direction;
573
- const probe = shiftLightness(palette[NORMAL_HUE_SLOTS[0]], candidate);
574
- if (probe.l <= 0.02 || probe.l >= 0.98)
575
- continue;
576
- if (worst(candidate) >= floor) {
577
- chosen = candidate;
578
- break;
579
- }
580
- }
581
- }
582
- if (chosen === undefined) {
583
- findings.push({
584
- themeId,
585
- role: 'hues',
586
- kind: 'budget-exceeded',
587
- message: `no single lightness step brings every hue to ${floor}:1 against the canvas`,
588
- });
589
- return {};
590
- }
591
- findings.push({
592
- themeId,
593
- role: 'hues',
594
- kind: 'repaired',
595
- message: `transposed all ${CHROMATIC_SLOTS.length} hue slots by ${chosen.toFixed(3)} in lightness to reach ${floor}:1 on the borrowed canvas; hue and chroma unchanged`,
596
- });
597
- return Object.fromEntries(CHROMATIC_SLOTS.map((slot) => [slot, shiftLightness(palette[slot], chosen)]));
598
- }
599
- /**
600
- * Normalizes one source into a catalogue entry.
601
- *
602
- * Pure: the same inputs always produce the same theme and the same findings, which
603
- * is what lets the committed catalogue be regenerated and diffed in CI.
604
- */
605
- export function normalizeTheme(source) {
606
- const findings = [];
607
- const vendored = parseBase24Palette(source.scheme.palette);
608
- // Corrections are applied to the palette before anything derives from it, so the
609
- // surface ladder, the accent and every floor are computed from the value that
610
- // will actually ship. Correcting the output instead would leave the ladder built
611
- // around a background that is not the background.
612
- const palette = { ...vendored };
613
- for (const [slot, value] of Object.entries(source.palette ?? {})) {
614
- const parsed = parseColor(value);
615
- if (!parsed)
616
- throw new Error(`${source.id}: palette correction for ${slot} is not a colour`);
617
- palette[slot] = parsed;
618
- findings.push({
619
- themeId: source.id,
620
- role: slot,
621
- kind: 'substituted',
622
- message: `replaced the vendored ${slot} with the upstream project's own value`,
623
- });
624
- }
625
- // The hue transpose, applied at the same stage as the corrections and for the same
626
- // reason: the semantic roles derive from these slots, so transposing afterwards
627
- // would leave `error` built from the untransposed red and the two disagreeing.
628
- if (source.hueTranspose) {
629
- const transposed = transposeHues(source.id, palette, source.hueTranspose.floor, findings);
630
- for (const [slot, value] of Object.entries(transposed)) {
631
- palette[slot] = value;
632
- }
633
- }
634
- const background = palette.base00;
635
- const appearance = ladderDirection(background) === 1 ? 'dark' : 'light';
636
- if (appearance !== source.appearance) {
637
- findings.push({
638
- themeId: source.id,
639
- role: 'appearance',
640
- kind: 'substituted',
641
- message: `declared ${source.appearance} but the canvas measures ${appearance}; used the measurement`,
642
- });
643
- }
644
- const direction = ladderDirection(background);
645
- const surface = surfaceRung(background, SURFACE_STEPS.surface, direction);
646
- const surfaceElevated = surfaceRung(background, SURFACE_STEPS.surfaceElevated, direction);
647
- const surfaceHover = surfaceRung(background, SURFACE_STEPS.surfaceHover, direction);
648
- const surfaceActive = surfaceRung(background, SURFACE_STEPS.surfaceActive, direction);
649
- /**
650
- * Every surface a foreground role can be drawn on.
651
- *
652
- * The interaction rungs are excluded: text is not placed on a hover fill without
653
- * the component also choosing a foreground for it, and holding every role to the
654
- * furthest rung would compress the whole palette for a case that does not occur.
655
- */
656
- const surfaces = [background, surface, surfaceElevated];
657
- const colors = {
658
- background: formatOklch(background),
659
- foreground: formatOklch(palette.base05),
660
- surface: formatOklch(surface),
661
- surfaceElevated: formatOklch(surfaceElevated),
662
- surfaceHover: formatOklch(surfaceHover),
663
- surfaceActive: formatOklch(surfaceActive),
664
- border: formatOklch({
665
- ...surfaceRung(background, 0.15, direction),
666
- c: Math.min(0.05, background.c * 0.9),
667
- }),
668
- borderMuted: formatOklch({
669
- ...surfaceRung(background, 0.095, direction),
670
- c: Math.min(0.04, background.c * 0.6),
671
- }),
672
- text: formatOklch(palette.base05),
673
- textMuted: '',
674
- textSubtle: '',
675
- accent: '',
676
- accentForeground: '',
677
- success: '',
678
- warning: '',
679
- error: '',
680
- info: '',
681
- };
682
- // Body text first: several palettes publish a foreground that fails on their own
683
- // background, and every other role's floors are measured against the canvas.
684
- const textFloor = source.textFloor ?? CONTRAST_FLOORS.text;
685
- const textRepair = repairAcrossSurfaces(palette.base05, surfaces, textFloor, REPAIR_BUDGET.text);
686
- const text = emit(source.id, 'text', textRepair, textFloor);
687
- colors.text = text.value;
688
- colors.foreground = text.value;
689
- if (text.finding)
690
- findings.push(text.finding);
691
- // Muted and subtle text are blends toward the canvas, then measured. Blending in
692
- // OKLCH rather than alpha-compositing keeps the result a real colour that CSS can
693
- // use without a backdrop.
694
- const mutedRepair = repairAcrossSurfaces(mix(textRepair.color, background, 0.32), surfaces, CONTRAST_FLOORS.textMuted, 0.3);
695
- const muted = emit(source.id, 'textMuted', mutedRepair, CONTRAST_FLOORS.textMuted);
696
- colors.textMuted = muted.value;
697
- if (muted.finding)
698
- findings.push(muted.finding);
699
- /*
700
- * The ladder is derived in order — body, secondary, tertiary — and each rung is
701
- * held to being dimmer than the one above it.
702
- *
703
- * Sourcing the tertiary rung from `base03` is faithful to Base24, which defines that
704
- * slot as comments and invisibles, and for most palettes it is exactly right: the
705
- * value the author chose for dim text. But a few publish a `base03` that is not a dim
706
- * grey at all — Solarized's light scheme carries its near-black `base03`, Nord Light's
707
- * is a mid slate — and taking it on trust produced a *tertiary* role that was the
708
- * most prominent text on the screen, at 13.9:1 against a canvas whose body text
709
- * measures 5.5:1. So the slot is used when it cooperates and a blend replaces it when
710
- * it does not, and either way the rung is reported.
711
- */
712
- const mutedContrast = contrastRatio(mutedRepair.color, background);
713
- const fromPalette = repairAcrossSurfaces(palette.base03, surfaces, CONTRAST_FLOORS.textSubtle, 0.3);
714
- const paletteSubtleContrast = contrastRatio(fromPalette.color, background);
715
- const subtleFromPalette = paletteSubtleContrast < mutedContrast;
716
- const subtleRepair = subtleFromPalette
717
- ? fromPalette
718
- : repairAcrossSurfaces(mix(textRepair.color, background, 0.55), surfaces, CONTRAST_FLOORS.textSubtle, 0.3);
719
- const subtle = emit(source.id, 'textSubtle', subtleRepair, CONTRAST_FLOORS.textSubtle);
720
- colors.textSubtle = subtle.value;
721
- if (subtle.finding)
722
- findings.push(subtle.finding);
723
- if (!subtleFromPalette) {
724
- findings.push({
725
- themeId: source.id,
726
- role: 'textSubtle',
727
- kind: 'substituted',
728
- message: `base03 measures ${paletteSubtleContrast.toFixed(1)}:1, no dimmer than textMuted's ${mutedContrast.toFixed(1)}:1, so the tertiary rung is a blend rather than the palette's comment colour`,
729
- });
730
- }
731
- /**
732
- * The status roles, each with fallback candidates.
733
- *
734
- * A palette's yellow is chosen to be *a* yellow, not to be legible as text — Ayu
735
- * Light's `#ffcc66` measures 1.9:1 on its own near-white canvas, and no amount of
736
- * lightness repair fixes that without turning it into olive, which is no longer
737
- * Ayu. So each status role has candidates and takes the first that clears the
738
- * floor: warnings fall back to the palette's orange slot, which is Base24's
739
- * constants colour and in practice the warning colour this palette already uses;
740
- * reds and greens fall back to their bright variants.
741
- *
742
- * The order is not "brightest first" — on a light canvas the bright variants are
743
- * worse, so the declared ANSI colour is always tried before its bright sibling.
744
- */
745
- /*
746
- * Body text gets enough headroom above the secondary rung to stay the stronger of the
747
- * two.
748
- *
749
- * The secondary rung is repaired against the *raised* surfaces, which is the binding
750
- * constraint, so on a palette whose foreground is itself only a little above the floor
751
- * the repair can push secondary text past the body text — Tokyo Night Day came out
752
- * with 5.6:1 for a caption and 5.5:1 for the paragraph, which collapses the two roles
753
- * into one. The fix is to give the body text room rather than to weaken the secondary
754
- * floor, and it is a small move: the foreground is already above its own floor, so this
755
- * only ever fires on a palette that was already tight.
756
- */
757
- const bodyContrast = contrastRatio(textRepair.color, background);
758
- const mutedAgainstCanvas = contrastRatio(mutedRepair.color, background);
759
- if (mutedAgainstCanvas + MINIMUM_LADDER_GAP > bodyContrast) {
760
- const lifted = repairAcrossSurfaces(textRepair.color, surfaces, mutedAgainstCanvas + MINIMUM_LADDER_GAP, REPAIR_BUDGET.text);
761
- if (lifted.satisfied) {
762
- colors.text = formatOklch(lifted.color);
763
- colors.foreground = colors.text;
764
- findings.push({
765
- themeId: source.id,
766
- role: 'text',
767
- kind: 'repaired',
768
- message: `lightness moved a further ${lifted.delta.toFixed(3)} so body text (${lifted.ratio.toFixed(1)}:1) stays clear of the secondary rung (${mutedAgainstCanvas.toFixed(1)}:1)`,
769
- });
770
- }
771
- }
772
- const statusCandidates = {
773
- error: [palette.base08, palette.base12],
774
- success: [palette.base0B, palette.base14],
775
- warning: [palette.base0A, palette.base09],
776
- info: [palette.base0C, palette.base0D],
777
- };
778
- for (const [role, candidates] of Object.entries(statusCandidates)) {
779
- const key = role;
780
- let chosen;
781
- for (const candidate of candidates) {
782
- // Two tiers, applied in sequence: the body floor on the canvas, then the
783
- // indicator floor on the raised surfaces. Both push the colour the same way,
784
- // so applying the second to the result of the first cannot undo it.
785
- //
786
- // The blend anchor is the *repaired* text colour rather than the palette's
787
- // raw foreground: when a palette's own body text fails the floor on a raised
788
- // surface, blending toward the raw value inherits that failure and the blend
789
- // has no valid endpoint.
790
- const onCanvas = repairRole(candidate, [background], textRepair.color, CONTRAST_FLOORS.status, REPAIR_BUDGET.status);
791
- if (!onCanvas.repair.satisfied) {
792
- if (!chosen || onCanvas.repair.ratio > chosen.repair.ratio)
793
- chosen = onCanvas;
794
- continue;
795
- }
796
- const onRaised = repairRole(onCanvas.repair.color, surfaces.slice(1), textRepair.color, CONTRAST_FLOORS.statusRaised, REPAIR_BUDGET.status);
797
- const combined = {
798
- repair: {
799
- color: onRaised.repair.color,
800
- ratio: onRaised.repair.ratio,
801
- delta: onCanvas.repair.delta + onRaised.repair.delta,
802
- satisfied: onRaised.repair.satisfied,
803
- },
804
- blended: onCanvas.blended || onRaised.blended,
805
- };
806
- if (combined.repair.satisfied) {
807
- chosen = combined;
808
- break;
809
- }
810
- if (!chosen || combined.repair.ratio > chosen.repair.ratio)
811
- chosen = combined;
812
- }
813
- const result = emit(source.id, key, chosen.repair, CONTRAST_FLOORS.statusRaised, chosen.blended);
814
- colors[key] = result.value;
815
- if (result.finding)
816
- findings.push(result.finding);
817
- if (chosen.blended) {
818
- findings.push({
819
- themeId: source.id,
820
- role: key,
821
- kind: 'repaired',
822
- message: `blended ${(chosen.repair.delta * 100).toFixed(0)}% toward the theme's own foreground; lightness alone could not reach the floors`,
823
- });
824
- }
825
- }
826
- const ramp = buildAnsiRamp(palette, background, appearance);
827
- const ansi = {
828
- black: formatOklch(ramp.black),
829
- red: formatOklch(palette.base08),
830
- green: formatOklch(palette.base0B),
831
- yellow: formatOklch(palette.base0A),
832
- blue: formatOklch(palette.base0D),
833
- magenta: formatOklch(palette.base0E),
834
- cyan: formatOklch(palette.base0C),
835
- white: formatOklch(ramp.white),
836
- brightBlack: formatOklch(ramp.brightBlack),
837
- brightRed: formatOklch(palette.base12),
838
- brightGreen: formatOklch(palette.base14),
839
- brightYellow: formatOklch(palette.base13),
840
- brightBlue: formatOklch(palette.base16),
841
- brightMagenta: formatOklch(palette.base17),
842
- brightCyan: formatOklch(palette.base15),
843
- brightWhite: formatOklch(ramp.brightWhite),
844
- };
845
- const ansiColors = Object.fromEntries(Object.entries(ansi).map(([key]) => [key, palette[ansiToSlot(key, palette, ramp)]]));
846
- const accent = chooseAccent(source.id, ansiColors, surfaces, source.accentSlot, textRepair.color);
847
- findings.push(...accent.findings);
848
- colors.accent = formatOklch(accent.repair.color);
849
- if (accent.repair.delta > 0) {
850
- findings.push({
851
- themeId: source.id,
852
- role: 'accent',
853
- kind: 'repaired',
854
- message: `lightness moved ${accent.repair.delta.toFixed(3)} to reach ${CONTRAST_FLOORS.accent}:1 against the canvas`,
855
- });
856
- }
857
- const accentForeground = chooseAccentForeground(source.id, accent.repair.color, palette);
858
- colors.accentForeground = formatOklch(accentForeground.value);
859
- findings.push(...accentForeground.findings);
860
- /*
861
- * The selection is the palette's own where the source declares one, and the floor
862
- * applies to it either way.
863
- *
864
- * A composition can carry its structure donor's selection explicitly, and taking that
865
- * on trust is how Adea Light shipped a selection measuring 1.11:1 against its own
866
- * canvas — Nord's is deliberately subtle, and subtle is not the same as invisible. An
867
- * invisible selection is a functional defect rather than a matter of taste: the user
868
- * cannot see what they have selected. So the declared value is used as given and still
869
- * measured, and moving it is reported.
870
- *
871
- * The cursor is deliberately *not* treated this way. It has no floor — a cursor is an
872
- * affordance rather than text, and several palettes in the catalogue give it a colour
873
- * of its own — so the adapters derive a legible glyph for it instead
874
- * (see `adapters/xterm.ts`).
875
- */
876
- const declaredSelection = source.selection ? parseColor(source.selection) : undefined;
877
- const selectionRepair = repairContrast(declaredSelection ?? palette.base02, background, CONTRAST_FLOORS.selection, 0.5);
878
- const selection = emit(source.id, 'selection', selectionRepair, CONTRAST_FLOORS.selection);
879
- if (selection.finding)
880
- findings.push(selection.finding);
881
- // Authored overrides, applied last so they win over derivation. They go through
882
- // the same conversion as every derived value: the catalogue's contract is that a
883
- // role is an `oklch()` string, and an override written as hex in the source list
884
- // must not be the one place that contract leaks.
885
- if (source.colors) {
886
- for (const [role, value] of Object.entries(source.colors)) {
887
- colors[role] = authoredColor(source.id, role, value);
888
- }
889
- }
890
- if (source.ansi) {
891
- for (const [role, value] of Object.entries(source.ansi)) {
892
- ansi[role] = authoredColor(source.id, role, value);
893
- }
894
- }
895
- return {
896
- record: {
897
- id: source.id,
898
- name: source.name,
899
- appearance,
900
- colors,
901
- ansi,
902
- cursor: source.cursor
903
- ? authoredColor(source.id, 'cursor', source.cursor)
904
- : formatOklch(palette.base05),
905
- // The repaired value, not the declared one: `selectionRepair` above started from
906
- // whatever the source declared.
907
- selection: selection.value,
908
- family: source.family,
909
- familyLabel: source.familyLabel,
910
- label: source.label,
911
- description: source.description,
912
- provenance: source.provenance ?? { project: '', url: '', license: '' },
913
- tags: source.tags,
914
- },
915
- findings,
916
- };
917
- }
918
- /** Parses an authored value and re-serialises it in the catalogue's notation. */
919
- function authoredColor(themeId, role, value) {
920
- const parsed = parseColor(value);
921
- if (!parsed)
922
- throw new Error(`${themeId}: ${role} is not a colour: ${value}`);
923
- return formatOklch(parsed);
924
- }
925
- /** Maps an ANSI role back to the Base24 slot it was read from. */
926
- function ansiToSlot(role, palette, ramp) {
927
- const direct = {
928
- red: 'base08',
929
- green: 'base0B',
930
- yellow: 'base0A',
931
- blue: 'base0D',
932
- magenta: 'base0E',
933
- cyan: 'base0C',
934
- brightRed: 'base12',
935
- brightYellow: 'base13',
936
- brightGreen: 'base14',
937
- brightCyan: 'base15',
938
- brightBlue: 'base16',
939
- brightMagenta: 'base17',
940
- };
941
- const slot = direct[role];
942
- if (slot)
943
- return slot;
944
- const rampSlots = {
945
- black: ramp.black,
946
- white: ramp.white,
947
- brightBlack: ramp.brightBlack,
948
- brightWhite: ramp.brightWhite,
949
- };
950
- const target = rampSlots[role];
951
- if (!target)
952
- return 'base05';
953
- const match = Object.keys(palette).find((candidate) => deltaEok(palette[candidate], target) < 1e-9);
954
- return match ?? 'base05';
955
- }
956
- /** Re-exported so the normalizer's callers do not need a second import. */
957
- export { contrastDirection };
958
- //# sourceMappingURL=normalize.js.map