@adea-ai/themes 0.1.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 (109) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +110 -0
  3. package/README.md +157 -0
  4. package/dist/adapters/base24.d.ts +117 -0
  5. package/dist/adapters/base24.d.ts.map +1 -0
  6. package/dist/adapters/base24.js +313 -0
  7. package/dist/adapters/base24.js.map +1 -0
  8. package/dist/adapters/css.d.ts +68 -0
  9. package/dist/adapters/css.d.ts.map +1 -0
  10. package/dist/adapters/css.js +107 -0
  11. package/dist/adapters/css.js.map +1 -0
  12. package/dist/adapters/shadcn.d.ts +43 -0
  13. package/dist/adapters/shadcn.d.ts.map +1 -0
  14. package/dist/adapters/shadcn.js +89 -0
  15. package/dist/adapters/shadcn.js.map +1 -0
  16. package/dist/adapters/shiki.d.ts +60 -0
  17. package/dist/adapters/shiki.d.ts.map +1 -0
  18. package/dist/adapters/shiki.js +135 -0
  19. package/dist/adapters/shiki.js.map +1 -0
  20. package/dist/adapters/tailwind.d.ts +35 -0
  21. package/dist/adapters/tailwind.d.ts.map +1 -0
  22. package/dist/adapters/tailwind.js +58 -0
  23. package/dist/adapters/tailwind.js.map +1 -0
  24. package/dist/adapters/xterm.d.ts +64 -0
  25. package/dist/adapters/xterm.d.ts.map +1 -0
  26. package/dist/adapters/xterm.js +112 -0
  27. package/dist/adapters/xterm.js.map +1 -0
  28. package/dist/catalogue.d.ts +66 -0
  29. package/dist/catalogue.d.ts.map +1 -0
  30. package/dist/catalogue.js +110 -0
  31. package/dist/catalogue.js.map +1 -0
  32. package/dist/derive.d.ts +89 -0
  33. package/dist/derive.d.ts.map +1 -0
  34. package/dist/derive.js +165 -0
  35. package/dist/derive.js.map +1 -0
  36. package/dist/generated/schemes.d.ts +16 -0
  37. package/dist/generated/schemes.d.ts.map +1 -0
  38. package/dist/generated/schemes.js +880 -0
  39. package/dist/generated/schemes.js.map +1 -0
  40. package/dist/generated/themes.d.ts +11 -0
  41. package/dist/generated/themes.d.ts.map +1 -0
  42. package/dist/generated/themes.js +1650 -0
  43. package/dist/generated/themes.js.map +1 -0
  44. package/dist/index.d.ts +67 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +58 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/normalize.d.ts +160 -0
  49. package/dist/normalize.d.ts.map +1 -0
  50. package/dist/normalize.js +795 -0
  51. package/dist/normalize.js.map +1 -0
  52. package/dist/oklch.d.ts +141 -0
  53. package/dist/oklch.d.ts.map +1 -0
  54. package/dist/oklch.js +306 -0
  55. package/dist/oklch.js.map +1 -0
  56. package/dist/schema.d.ts +178 -0
  57. package/dist/schema.d.ts.map +1 -0
  58. package/dist/schema.js +69 -0
  59. package/dist/schema.js.map +1 -0
  60. package/dist/sources.d.ts +171 -0
  61. package/dist/sources.d.ts.map +1 -0
  62. package/dist/sources.js +559 -0
  63. package/dist/sources.js.map +1 -0
  64. package/dist/validate.d.ts +121 -0
  65. package/dist/validate.d.ts.map +1 -0
  66. package/dist/validate.js +255 -0
  67. package/dist/validate.js.map +1 -0
  68. package/package.json +106 -0
  69. package/palettes/ayu-light.json +38 -0
  70. package/palettes/ayu-mirage.json +38 -0
  71. package/palettes/ayu.json +38 -0
  72. package/palettes/catppuccin-frappe.json +38 -0
  73. package/palettes/catppuccin-latte.json +38 -0
  74. package/palettes/catppuccin-macchiato.json +38 -0
  75. package/palettes/catppuccin-mocha.json +38 -0
  76. package/palettes/dracula.json +38 -0
  77. package/palettes/everforest-dark.json +38 -0
  78. package/palettes/everforest-light.json +38 -0
  79. package/palettes/gruvbox-dark.json +38 -0
  80. package/palettes/gruvbox-light.json +38 -0
  81. package/palettes/kanagawa.json +38 -0
  82. package/palettes/monokai.json +38 -0
  83. package/palettes/nord.json +38 -0
  84. package/palettes/one-dark.json +38 -0
  85. package/palettes/rosepine-dawn.json +38 -0
  86. package/palettes/rosepine-moon.json +38 -0
  87. package/palettes/rosepine.json +38 -0
  88. package/palettes/solarized-dark.json +38 -0
  89. package/palettes/solarized-light.json +38 -0
  90. package/palettes/tokyonight-day.json +38 -0
  91. package/palettes/tokyonight-night.json +38 -0
  92. package/palettes/tokyonight-storm.json +38 -0
  93. package/palettes/vesper.json +38 -0
  94. package/src/adapters/base24.ts +355 -0
  95. package/src/adapters/css.ts +149 -0
  96. package/src/adapters/shadcn.ts +99 -0
  97. package/src/adapters/shiki.ts +168 -0
  98. package/src/adapters/tailwind.ts +79 -0
  99. package/src/adapters/xterm.ts +159 -0
  100. package/src/catalogue.ts +129 -0
  101. package/src/derive.ts +203 -0
  102. package/src/generated/schemes.ts +882 -0
  103. package/src/generated/themes.ts +1652 -0
  104. package/src/index.ts +146 -0
  105. package/src/normalize.ts +1010 -0
  106. package/src/oklch.ts +366 -0
  107. package/src/schema.ts +222 -0
  108. package/src/sources.ts +682 -0
  109. package/src/validate.ts +325 -0
@@ -0,0 +1,795 @@
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
+ /** The smallest chroma a colour needs before it reads as "a colour" rather than grey. */
128
+ const MINIMUM_ACCENT_CHROMA = 0.035;
129
+ const SURFACE_STEPS = Object.freeze({
130
+ surface: 0.035,
131
+ surfaceElevated: 0.065,
132
+ surfaceHover: 0.1,
133
+ surfaceActive: 0.135,
134
+ });
135
+ /**
136
+ * Which way a raised surface moves from the canvas.
137
+ *
138
+ * Measured from the background's lightness rather than taken from the declared
139
+ * variant. The declaration is authored by hand and has been wrong; the luminance
140
+ * of the background is not.
141
+ */
142
+ function ladderDirection(background) {
143
+ return background.l <= 0.5 ? 1 : -1;
144
+ }
145
+ /** True when the declared variant and the measured canvas agree. */
146
+ export function appearanceMatchesCanvas(appearance, background) {
147
+ return (background.l <= 0.5 ? 'dark' : 'light') === appearance;
148
+ }
149
+ /**
150
+ * Builds one rung of the surface ladder.
151
+ *
152
+ * Chroma is scaled up slightly rather than held constant: the steps are small
153
+ * enough that a hue carried at its original chroma reads as a grey smudge, while
154
+ * the same hue with a little more chroma reads as "the tint this theme is made
155
+ * of". The scale is modest because a surface is still a surface — at the hover
156
+ * rung a large scale turns a panel into a coloured button.
157
+ */
158
+ function surfaceRung(background, step, direction) {
159
+ const raised = shiftLightness(background, step * direction);
160
+ const chromaScale = 1 + Math.min(0.6, step * 6);
161
+ return { ...raised, c: Math.min(0.06, raised.c * chromaScale) };
162
+ }
163
+ /**
164
+ * Assigns the greyscale ANSI roles.
165
+ *
166
+ * Base24 does not name `black`, `white` or `brightBlack` — the background and
167
+ * foreground slots do double duty — so the mapping is Adea's, and two things about
168
+ * it are not obvious.
169
+ *
170
+ * **It is appearance-aware, because the ANSI greyscale ramp means opposite things
171
+ * on the two kinds of theme.** On a dark terminal ANSI 0 is a dark neutral used for
172
+ * text on light fills and ANSI 8 is the dim comment grey; on a light terminal ANSI
173
+ * 0 *is* the body text. A single fixed rule gets one of the two wrong.
174
+ *
175
+ * **`white` and `brightWhite` are read from different slots per appearance.** On a
176
+ * dark theme they are the two strongest foregrounds, because that is where a dark
177
+ * palette keeps its light colours. On a light theme the foreground ramp is
178
+ * *entirely dark* — Gruvbox Light's `base05`, `base06` and `base07` are all
179
+ * `#3c3836` — so reading "white" off it yields a near-black, and the light colours
180
+ * have to come from the two slots the palette puts *beyond* its canvas, `base10`
181
+ * and `base11`. Where a palette leaves those darker than its canvas, the foreground
182
+ * ramp is used instead.
183
+ *
184
+ * Candidates are paired with `base01` and de-duplicated first, because light
185
+ * schemes routinely set `base05`, `base06` and `base07` to the same value — without
186
+ * de-duplication the ramp collapses to three members and the roles collide.
187
+ */
188
+ function buildAnsiRamp(palette, background, appearance) {
189
+ // Which slots are *foregrounds* depends on the appearance, because Base24's
190
+ // background slots do double duty. `base01` is the "lighter background" — a
191
+ // status-bar fill — so on a dark scheme it is a usable mid grey and on a light
192
+ // scheme it is a second, barely-darker canvas. `base06` and `base07` are the
193
+ // "light foreground" and "light background", and light schemes routinely set
194
+ // `base07` to the *lightest* background, which ranks as the dimmest member of the
195
+ // ramp: including it gave Everforest Light a `brightBlack` of `#fffbef`, a
196
+ // comment colour 1.16:1 against its own canvas.
197
+ //
198
+ // So a light theme's greyscale foregrounds are exactly the three slots Base24
199
+ // defines as foreground-ish, and a dark theme's include the two extra slots it has
200
+ // to work with.
201
+ const candidates = appearance === 'dark'
202
+ ? [palette.base01, palette.base03, palette.base04, palette.base05, palette.base06, palette.base07]
203
+ : [palette.base03, palette.base04, palette.base05];
204
+ const distinct = [];
205
+ for (const candidate of candidates) {
206
+ if (distinct.every((kept) => deltaEok(kept, candidate) > 0.02))
207
+ distinct.push(candidate);
208
+ }
209
+ // Strongest first: the ramp is ranked by measured contrast, not by slot number,
210
+ // because a slot's contrast depends on which appearance the palette is.
211
+ distinct.sort((a, b) => contrastRatio(b, background) - contrastRatio(a, background));
212
+ const strongest = distinct[0] ?? palette.base05;
213
+ const dimmest = distinct[distinct.length - 1] ?? palette.base03;
214
+ const secondStrongest = distinct[1] ?? strongest;
215
+ const secondDimmest = distinct[distinct.length - 2] ?? dimmest;
216
+ if (appearance === 'dark') {
217
+ return {
218
+ brightWhite: strongest,
219
+ white: secondStrongest,
220
+ black: dimmest,
221
+ brightBlack: secondDimmest,
222
+ };
223
+ }
224
+ // The two slots a light scheme places beyond its canvas. `base11` is one step
225
+ // further than `base10`, which is what makes it the brighter of the pair.
226
+ //
227
+ // A canvas that is already at the top of the range — Adea's own light theme is
228
+ // pure white — has nothing beyond it, so the fallback is the *brightest* end of
229
+ // the foreground ramp rather than the darkest. Falling back to the dimmest would
230
+ // hand a light theme an ANSI white darker than its own text.
231
+ const beyondCanvas = [palette.base10, palette.base11].filter((candidate) => candidate.l > background.l);
232
+ return {
233
+ brightWhite: beyondCanvas[1] ?? beyondCanvas[0] ?? strongest,
234
+ white: beyondCanvas[0] ?? secondStrongest,
235
+ black: strongest,
236
+ brightBlack: dimmest,
237
+ };
238
+ }
239
+ /** Formats a role, pairing the value with the finding it produced if any. */
240
+ function emit(themeId, role, repair, floor, blended = false) {
241
+ if (repair.satisfied && repair.delta === 0)
242
+ return { value: formatOklch(repair.color) };
243
+ if (repair.satisfied) {
244
+ return {
245
+ value: formatOklch(repair.color),
246
+ finding: {
247
+ themeId,
248
+ role,
249
+ kind: 'repaired',
250
+ message: blended
251
+ ? `blended ${(repair.delta * 100).toFixed(0)}% into the foreground to reach ${floor}:1 (now ${repair.ratio.toFixed(2)}:1)`
252
+ : `lightness moved ${repair.delta.toFixed(3)} to reach ${floor}:1 (now ${repair.ratio.toFixed(2)}:1)`,
253
+ },
254
+ };
255
+ }
256
+ return {
257
+ value: formatOklch(repair.color),
258
+ finding: {
259
+ themeId,
260
+ role,
261
+ kind: 'budget-exceeded',
262
+ message: `still ${repair.ratio.toFixed(2)}:1 against the canvas after the maximum lightness move; needs ${floor}:1`,
263
+ },
264
+ };
265
+ }
266
+ /**
267
+ * Chooses the accent.
268
+ *
269
+ * The accent is the one colour a UI uses for "this is interactive", so it is
270
+ * chosen by measurement rather than by convention: candidates are tried in a fixed
271
+ * preference order (blue first, because a blue accent is what most of these
272
+ * palettes were designed around) and the first that clears the text floor within
273
+ * budget, while still being *a* colour rather than a grey, wins. A family whose
274
+ * identity lies elsewhere — Everforest is green, Rosé Pine is iris — declares its
275
+ * slot explicitly, and that declaration is honoured before any preference.
276
+ */
277
+ function chooseAccent(themeId, ansi, surfaces, preferred, anchor) {
278
+ const findings = [];
279
+ const background = surfaces[0];
280
+ const order = preferred ? [preferred, ...ACCENT_PREFERENCE] : ACCENT_PREFERENCE;
281
+ let fallback;
282
+ const satisfying = [];
283
+ for (const role of order) {
284
+ const candidate = ansi[role];
285
+ if (!candidate || candidate.c < MINIMUM_ACCENT_CHROMA)
286
+ continue;
287
+ // Two tiers, as for the status roles: the link floor on the canvas, then the
288
+ // component floor on a raised surface, applied in sequence.
289
+ const onCanvas = repairRole(candidate, [background], anchor, CONTRAST_FLOORS.accent, REPAIR_BUDGET.accent);
290
+ if (!onCanvas.repair.satisfied) {
291
+ if (!fallback || onCanvas.repair.ratio > fallback.repair.ratio) {
292
+ fallback = { role, repair: onCanvas.repair };
293
+ }
294
+ continue;
295
+ }
296
+ const onRaised = repairRole(onCanvas.repair.color, surfaces.slice(1), anchor, CONTRAST_FLOORS.accentRaised, REPAIR_BUDGET.accent);
297
+ const attempt = {
298
+ repair: {
299
+ color: onRaised.repair.color,
300
+ ratio: onRaised.repair.ratio,
301
+ delta: onCanvas.repair.delta + onRaised.repair.delta,
302
+ satisfied: onRaised.repair.satisfied,
303
+ },
304
+ blended: onCanvas.blended || onRaised.blended,
305
+ };
306
+ if (attempt.repair.satisfied) {
307
+ satisfying.push({ role, repair: attempt.repair, distortion: distortionOf(attempt), blended: attempt.blended });
308
+ continue;
309
+ }
310
+ // Track the best attempt so a theme that cannot satisfy the floor still gets
311
+ // its most legible candidate rather than an arbitrary one.
312
+ if (!fallback || attempt.repair.ratio > fallback.repair.ratio) {
313
+ fallback = { role, repair: attempt.repair };
314
+ }
315
+ }
316
+ if (satisfying.length > 0) {
317
+ // A declared accent is honoured whenever it can be reached without giving the
318
+ // colour up. Lightness movement *is* what a palette author does to make an
319
+ // accent usable — Rosé Pine's iris needs a 0.11 step on its light variant and is
320
+ // still unmistakably iris — so a lightness repair never costs a family its
321
+ // identity. Blending does: past about a third of the way the result stops being
322
+ // the colour it came from, and a green repaired into slate is no longer
323
+ // Everforest's accent.
324
+ //
325
+ // So the declared slot wins unless it had to be blended; if it did — or if none
326
+ // was declared — the least-distorted candidate wins, with the preference order
327
+ // breaking ties.
328
+ const cheapest = satisfying.reduce((best, entry) => entry.distortion < best.distortion ? entry : best);
329
+ const declared = preferred ? satisfying.find((entry) => entry.role === preferred) : undefined;
330
+ const chosen = declared && !declared.blended ? declared : cheapest;
331
+ if (chosen.role !== preferred && preferred) {
332
+ findings.push({
333
+ themeId,
334
+ role: 'accent',
335
+ kind: 'substituted',
336
+ message: declared
337
+ ? `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`
338
+ : `the declared accent slot ${preferred} could not clear the floor; used ${chosen.role} instead`,
339
+ });
340
+ }
341
+ if (chosen.repair.delta > 0.01) {
342
+ findings.push({
343
+ themeId,
344
+ role: 'accent',
345
+ kind: 'repaired',
346
+ message: chosen.blended
347
+ ? `blended ${(chosen.repair.delta * 100).toFixed(0)}% into the foreground from the palette's ${chosen.role}`
348
+ : `lightness moved ${chosen.repair.delta.toFixed(3)} from the palette's ${chosen.role}`,
349
+ });
350
+ }
351
+ return { role: chosen.role, repair: chosen.repair, findings };
352
+ }
353
+ const chosen = fallback ?? {
354
+ role: 'blue',
355
+ repair: repairContrast(background, background, 1),
356
+ };
357
+ findings.push({
358
+ themeId,
359
+ role: 'accent',
360
+ kind: 'budget-exceeded',
361
+ message: `no accent candidate cleared ${CONTRAST_FLOORS.accent}:1; the best was ${chosen.role} at ${chosen.repair.ratio.toFixed(2)}:1`,
362
+ });
363
+ return { role: chosen.role, repair: chosen.repair, findings };
364
+ }
365
+ /**
366
+ * Picks the text colour drawn on top of the accent.
367
+ *
368
+ * Not derived from the appearance, which is the tempting shortcut and is wrong:
369
+ * Catppuccin Mocha's blue accent is light enough that black is the legible label,
370
+ * while Nord's is dark enough that white is. The pick is whichever of the
371
+ * palette's own two extremes measures better, repaired if neither clears the floor.
372
+ */
373
+ function chooseAccentForeground(themeId, accent, palette) {
374
+ const findings = [];
375
+ const candidates = [palette.base00, palette.base07, palette.base05, palette.base06];
376
+ let best = candidates[0];
377
+ let bestRatio = contrastRatio(best, accent);
378
+ for (const candidate of candidates.slice(1)) {
379
+ const ratio = contrastRatio(candidate, accent);
380
+ if (ratio > bestRatio) {
381
+ best = candidate;
382
+ bestRatio = ratio;
383
+ }
384
+ }
385
+ const repair = repairContrast(best, accent, CONTRAST_FLOORS.accentForeground, 0.6);
386
+ if (!repair.satisfied) {
387
+ findings.push({
388
+ themeId,
389
+ role: 'accentForeground',
390
+ kind: 'budget-exceeded',
391
+ message: `only ${repair.ratio.toFixed(2)}:1 against the accent; needs ${CONTRAST_FLOORS.accentForeground}:1`,
392
+ });
393
+ }
394
+ return { value: repair.color, findings };
395
+ }
396
+ /**
397
+ * Moves a colour's lightness until it clears a floor against **every** surface it
398
+ * can be drawn on.
399
+ *
400
+ * This is the difference between a theme that is legible on its canvas and a theme
401
+ * that is legible in the application. `repairContrast` measures against one
402
+ * background, which is right for a terminal and wrong for a UI: a status colour is
403
+ * as likely to appear inside a card or a popover as on the canvas, and those rungs
404
+ * are deliberately moved away from the canvas, so a colour tuned to exactly 4.5:1
405
+ * on the background lands at roughly 4.0:1 on a card.
406
+ *
407
+ * The surfaces of one theme all sit on the same side of the canvas, so the surface
408
+ * with the *lowest* current ratio is the furthest from the colour, and moving away
409
+ * from it moves away from all of them. The worst surface is recomputed on every
410
+ * step rather than chosen once, because a colour can cross between them.
411
+ */
412
+ function repairAcrossSurfaces(color, surfaces, minimum, budget) {
413
+ // Measured in the canonical form the catalogue commits, so a repair cannot
414
+ // converge on a value that rounding then pushes back below the floor.
415
+ const worst = (candidate) => {
416
+ const rounded = canonical(candidate);
417
+ let result = { surface: surfaces[0], ratio: Number.POSITIVE_INFINITY };
418
+ for (const surface of surfaces) {
419
+ const ratio = contrastRatio(rounded, surface);
420
+ if (ratio < result.ratio)
421
+ result = { surface, ratio };
422
+ }
423
+ return result;
424
+ };
425
+ const initial = worst(color);
426
+ if (initial.ratio >= minimum) {
427
+ return { color, ratio: initial.ratio, delta: 0, satisfied: true };
428
+ }
429
+ const direction = contrastDirection(color, initial.surface);
430
+ const step = 0.002;
431
+ const maximumSteps = Math.floor(budget / step);
432
+ for (let index = 1; index <= maximumSteps; index += 1) {
433
+ const candidate = { ...color, l: color.l + direction * step * index };
434
+ if (candidate.l <= 0 || candidate.l >= 1)
435
+ break;
436
+ const measured = worst(candidate);
437
+ if (measured.ratio >= minimum) {
438
+ return {
439
+ color: candidate,
440
+ ratio: measured.ratio,
441
+ delta: Number((step * index).toFixed(4)),
442
+ satisfied: true,
443
+ };
444
+ }
445
+ }
446
+ return { color, ratio: initial.ratio, delta: 0, satisfied: false };
447
+ }
448
+ /**
449
+ * Repairs a colour that cannot reach the floor by lightness alone, by blending it
450
+ * toward the palette's own foreground.
451
+ *
452
+ * Everforest Light is why this exists. Its entire palette is muted pastels chosen
453
+ * for a cream canvas, and its brightest accents measure 1.6:1 against that canvas —
454
+ * no lightness move fixes that within a budget that leaves the colour recognisable,
455
+ * because the colour is already as dark as its own hue allows. Darkening it further
456
+ * produces olive.
457
+ *
458
+ * Blending toward the palette's foreground instead is what the palette's own author
459
+ * does when they need a colour to be read rather than seen: it keeps the hue, gives
460
+ * up chroma, and converges on the foreground's contrast as the blend approaches 1.
461
+ * The result is a muted, deeper version of the accent — which is what Everforest's
462
+ * light themes actually look like.
463
+ *
464
+ * The blend is bound by the foreground, so it always terminates; and when even a
465
+ * full blend cannot clear the floor, the theme is reported rather than shipped with
466
+ * an invented colour.
467
+ */
468
+ function repairByBlending(color, surfaces, anchor, minimum) {
469
+ const worstRatio = (candidate) => Math.min(...surfaces.map((surface) => contrastRatio(canonical(candidate), surface)));
470
+ let low = 0;
471
+ let high = 1;
472
+ if (worstRatio(anchor) < minimum) {
473
+ return { color, ratio: worstRatio(color), delta: 0, satisfied: false };
474
+ }
475
+ // The smallest blend that works: 20 iterations resolve the amount far below the
476
+ // precision the catalogue is committed at.
477
+ for (let iteration = 0; iteration < 20; iteration += 1) {
478
+ const mid = (low + high) / 2;
479
+ if (worstRatio(mix(color, anchor, mid)) >= minimum)
480
+ high = mid;
481
+ else
482
+ low = mid;
483
+ }
484
+ const blended = canonical(mix(color, anchor, high));
485
+ return {
486
+ color: blended,
487
+ ratio: worstRatio(blended),
488
+ delta: Number(high.toFixed(4)),
489
+ satisfied: true,
490
+ };
491
+ }
492
+ /**
493
+ * The full repair chain for a foreground role.
494
+ *
495
+ * Lightness first, because that is the cheapest change and usually enough; blending
496
+ * second, because some palettes cannot be repaired any other way. Both are reported
497
+ * so a reviewer can see which themes needed which.
498
+ */
499
+ function repairRole(color, surfaces, anchor, minimum, budget) {
500
+ const lightness = repairAcrossSurfaces(color, surfaces, minimum, budget);
501
+ if (lightness.satisfied)
502
+ return { repair: lightness, blended: false };
503
+ const blend = repairByBlending(color, surfaces, anchor, minimum);
504
+ if (blend.satisfied)
505
+ return { repair: blend, blended: true };
506
+ return { repair: lightness, blended: false };
507
+ }
508
+ /**
509
+ * Normalizes one source into a catalogue entry.
510
+ *
511
+ * Pure: the same inputs always produce the same theme and the same findings, which
512
+ * is what lets the committed catalogue be regenerated and diffed in CI.
513
+ */
514
+ export function normalizeTheme(source) {
515
+ const findings = [];
516
+ const vendored = parseBase24Palette(source.scheme.palette);
517
+ // Corrections are applied to the palette before anything derives from it, so the
518
+ // surface ladder, the accent and every floor are computed from the value that
519
+ // will actually ship. Correcting the output instead would leave the ladder built
520
+ // around a background that is not the background.
521
+ const palette = { ...vendored };
522
+ for (const [slot, value] of Object.entries(source.palette ?? {})) {
523
+ const parsed = parseColor(value);
524
+ if (!parsed)
525
+ throw new Error(`${source.id}: palette correction for ${slot} is not a colour`);
526
+ palette[slot] = parsed;
527
+ findings.push({
528
+ themeId: source.id,
529
+ role: slot,
530
+ kind: 'substituted',
531
+ message: `replaced the vendored ${slot} with the upstream project's own value`,
532
+ });
533
+ }
534
+ const background = palette.base00;
535
+ const appearance = ladderDirection(background) === 1 ? 'dark' : 'light';
536
+ if (appearance !== source.appearance) {
537
+ findings.push({
538
+ themeId: source.id,
539
+ role: 'appearance',
540
+ kind: 'substituted',
541
+ message: `declared ${source.appearance} but the canvas measures ${appearance}; used the measurement`,
542
+ });
543
+ }
544
+ const direction = ladderDirection(background);
545
+ const surface = surfaceRung(background, SURFACE_STEPS.surface, direction);
546
+ const surfaceElevated = surfaceRung(background, SURFACE_STEPS.surfaceElevated, direction);
547
+ const surfaceHover = surfaceRung(background, SURFACE_STEPS.surfaceHover, direction);
548
+ const surfaceActive = surfaceRung(background, SURFACE_STEPS.surfaceActive, direction);
549
+ /**
550
+ * Every surface a foreground role can be drawn on.
551
+ *
552
+ * The interaction rungs are excluded: text is not placed on a hover fill without
553
+ * the component also choosing a foreground for it, and holding every role to the
554
+ * furthest rung would compress the whole palette for a case that does not occur.
555
+ */
556
+ const surfaces = [background, surface, surfaceElevated];
557
+ const colors = {
558
+ background: formatOklch(background),
559
+ foreground: formatOklch(palette.base05),
560
+ surface: formatOklch(surface),
561
+ surfaceElevated: formatOklch(surfaceElevated),
562
+ surfaceHover: formatOklch(surfaceHover),
563
+ surfaceActive: formatOklch(surfaceActive),
564
+ border: formatOklch({
565
+ ...surfaceRung(background, 0.15, direction),
566
+ c: Math.min(0.05, background.c * 0.9),
567
+ }),
568
+ borderMuted: formatOklch({
569
+ ...surfaceRung(background, 0.095, direction),
570
+ c: Math.min(0.04, background.c * 0.6),
571
+ }),
572
+ text: formatOklch(palette.base05),
573
+ textMuted: '',
574
+ textSubtle: '',
575
+ accent: '',
576
+ accentForeground: '',
577
+ success: '',
578
+ warning: '',
579
+ error: '',
580
+ info: '',
581
+ };
582
+ // Body text first: several palettes publish a foreground that fails on their own
583
+ // background, and every other role's floors are measured against the canvas.
584
+ const textRepair = repairAcrossSurfaces(palette.base05, surfaces, CONTRAST_FLOORS.text, REPAIR_BUDGET.text);
585
+ const text = emit(source.id, 'text', textRepair, CONTRAST_FLOORS.text);
586
+ colors.text = text.value;
587
+ colors.foreground = text.value;
588
+ if (text.finding)
589
+ findings.push(text.finding);
590
+ // Muted and subtle text are blends toward the canvas, then measured. Blending in
591
+ // OKLCH rather than alpha-compositing keeps the result a real colour that CSS can
592
+ // use without a backdrop.
593
+ const mutedRepair = repairAcrossSurfaces(mix(palette.base05, background, 0.32), surfaces, CONTRAST_FLOORS.textMuted, 0.3);
594
+ const muted = emit(source.id, 'textMuted', mutedRepair, CONTRAST_FLOORS.textMuted);
595
+ colors.textMuted = muted.value;
596
+ if (muted.finding)
597
+ findings.push(muted.finding);
598
+ const subtleRepair = repairAcrossSurfaces(palette.base03, surfaces, CONTRAST_FLOORS.textSubtle, 0.3);
599
+ const subtle = emit(source.id, 'textSubtle', subtleRepair, CONTRAST_FLOORS.textSubtle);
600
+ colors.textSubtle = subtle.value;
601
+ if (subtle.finding)
602
+ findings.push(subtle.finding);
603
+ /**
604
+ * The status roles, each with fallback candidates.
605
+ *
606
+ * A palette's yellow is chosen to be *a* yellow, not to be legible as text — Ayu
607
+ * Light's `#ffcc66` measures 1.9:1 on its own near-white canvas, and no amount of
608
+ * lightness repair fixes that without turning it into olive, which is no longer
609
+ * Ayu. So each status role has candidates and takes the first that clears the
610
+ * floor: warnings fall back to the palette's orange slot, which is Base24's
611
+ * constants colour and in practice the warning colour this palette already uses;
612
+ * reds and greens fall back to their bright variants.
613
+ *
614
+ * The order is not "brightest first" — on a light canvas the bright variants are
615
+ * worse, so the declared ANSI colour is always tried before its bright sibling.
616
+ */
617
+ const statusCandidates = {
618
+ error: [palette.base08, palette.base12],
619
+ success: [palette.base0B, palette.base14],
620
+ warning: [palette.base0A, palette.base09],
621
+ info: [palette.base0C, palette.base0D],
622
+ };
623
+ for (const [role, candidates] of Object.entries(statusCandidates)) {
624
+ const key = role;
625
+ let chosen;
626
+ for (const candidate of candidates) {
627
+ // Two tiers, applied in sequence: the body floor on the canvas, then the
628
+ // indicator floor on the raised surfaces. Both push the colour the same way,
629
+ // so applying the second to the result of the first cannot undo it.
630
+ //
631
+ // The blend anchor is the *repaired* text colour rather than the palette's
632
+ // raw foreground: when a palette's own body text fails the floor on a raised
633
+ // surface, blending toward the raw value inherits that failure and the blend
634
+ // has no valid endpoint.
635
+ const onCanvas = repairRole(candidate, [background], textRepair.color, CONTRAST_FLOORS.status, REPAIR_BUDGET.status);
636
+ if (!onCanvas.repair.satisfied) {
637
+ if (!chosen || onCanvas.repair.ratio > chosen.repair.ratio)
638
+ chosen = onCanvas;
639
+ continue;
640
+ }
641
+ const onRaised = repairRole(onCanvas.repair.color, surfaces.slice(1), textRepair.color, CONTRAST_FLOORS.statusRaised, REPAIR_BUDGET.status);
642
+ const combined = {
643
+ repair: {
644
+ color: onRaised.repair.color,
645
+ ratio: onRaised.repair.ratio,
646
+ delta: onCanvas.repair.delta + onRaised.repair.delta,
647
+ satisfied: onRaised.repair.satisfied,
648
+ },
649
+ blended: onCanvas.blended || onRaised.blended,
650
+ };
651
+ if (combined.repair.satisfied) {
652
+ chosen = combined;
653
+ break;
654
+ }
655
+ if (!chosen || combined.repair.ratio > chosen.repair.ratio)
656
+ chosen = combined;
657
+ }
658
+ const result = emit(source.id, key, chosen.repair, CONTRAST_FLOORS.statusRaised, chosen.blended);
659
+ colors[key] = result.value;
660
+ if (result.finding)
661
+ findings.push(result.finding);
662
+ if (chosen.blended) {
663
+ findings.push({
664
+ themeId: source.id,
665
+ role: key,
666
+ kind: 'repaired',
667
+ message: `blended ${(chosen.repair.delta * 100).toFixed(0)}% toward the theme's own foreground; lightness alone could not reach the floors`,
668
+ });
669
+ }
670
+ }
671
+ const ramp = buildAnsiRamp(palette, background, appearance);
672
+ const ansi = {
673
+ black: formatOklch(ramp.black),
674
+ red: formatOklch(palette.base08),
675
+ green: formatOklch(palette.base0B),
676
+ yellow: formatOklch(palette.base0A),
677
+ blue: formatOklch(palette.base0D),
678
+ magenta: formatOklch(palette.base0E),
679
+ cyan: formatOklch(palette.base0C),
680
+ white: formatOklch(ramp.white),
681
+ brightBlack: formatOklch(ramp.brightBlack),
682
+ brightRed: formatOklch(palette.base12),
683
+ brightGreen: formatOklch(palette.base14),
684
+ brightYellow: formatOklch(palette.base13),
685
+ brightBlue: formatOklch(palette.base16),
686
+ brightMagenta: formatOklch(palette.base17),
687
+ brightCyan: formatOklch(palette.base15),
688
+ brightWhite: formatOklch(ramp.brightWhite),
689
+ };
690
+ const ansiColors = Object.fromEntries(Object.entries(ansi).map(([key]) => [key, palette[ansiToSlot(key, palette, ramp)]]));
691
+ const accent = chooseAccent(source.id, ansiColors, surfaces, source.accentSlot, textRepair.color);
692
+ findings.push(...accent.findings);
693
+ colors.accent = formatOklch(accent.repair.color);
694
+ if (accent.repair.delta > 0) {
695
+ findings.push({
696
+ themeId: source.id,
697
+ role: 'accent',
698
+ kind: 'repaired',
699
+ message: `lightness moved ${accent.repair.delta.toFixed(3)} to reach ${CONTRAST_FLOORS.accent}:1 against the canvas`,
700
+ });
701
+ }
702
+ const accentForeground = chooseAccentForeground(source.id, accent.repair.color, palette);
703
+ colors.accentForeground = formatOklch(accentForeground.value);
704
+ findings.push(...accentForeground.findings);
705
+ // The selection is the palette's own, repaired only if it is invisible against
706
+ // the canvas. Adapters handle making text legible on top of it.
707
+ const selectionSource = source.selection
708
+ ? palette.base02
709
+ : palette.base02;
710
+ const selectionRepair = repairContrast(selectionSource, background, CONTRAST_FLOORS.selection, 0.5);
711
+ const selection = emit(source.id, 'selection', selectionRepair, CONTRAST_FLOORS.selection);
712
+ if (selection.finding)
713
+ findings.push(selection.finding);
714
+ // Authored overrides, applied last so they win over derivation. They go through
715
+ // the same conversion as every derived value: the catalogue's contract is that a
716
+ // role is an `oklch()` string, and an override written as hex in the source list
717
+ // must not be the one place that contract leaks.
718
+ if (source.colors) {
719
+ for (const [role, value] of Object.entries(source.colors)) {
720
+ colors[role] = authoredColor(source.id, role, value);
721
+ }
722
+ }
723
+ if (source.ansi) {
724
+ for (const [role, value] of Object.entries(source.ansi)) {
725
+ ansi[role] = authoredColor(source.id, role, value);
726
+ }
727
+ }
728
+ return {
729
+ record: {
730
+ id: source.id,
731
+ name: source.name,
732
+ appearance,
733
+ colors,
734
+ ansi,
735
+ cursor: source.cursor
736
+ ? authoredColor(source.id, 'cursor', source.cursor)
737
+ : formatOklch(palette.base05),
738
+ selection: source.selection
739
+ ? authoredColor(source.id, 'selection', source.selection)
740
+ : selection.value,
741
+ family: source.family,
742
+ familyLabel: source.familyLabel,
743
+ label: source.label,
744
+ description: source.description,
745
+ provenance: {
746
+ project: '',
747
+ url: '',
748
+ license: '',
749
+ },
750
+ tags: source.tags,
751
+ },
752
+ findings,
753
+ };
754
+ }
755
+ /** Parses an authored value and re-serialises it in the catalogue's notation. */
756
+ function authoredColor(themeId, role, value) {
757
+ const parsed = parseColor(value);
758
+ if (!parsed)
759
+ throw new Error(`${themeId}: ${role} is not a colour: ${value}`);
760
+ return formatOklch(parsed);
761
+ }
762
+ /** Maps an ANSI role back to the Base24 slot it was read from. */
763
+ function ansiToSlot(role, palette, ramp) {
764
+ const direct = {
765
+ red: 'base08',
766
+ green: 'base0B',
767
+ yellow: 'base0A',
768
+ blue: 'base0D',
769
+ magenta: 'base0E',
770
+ cyan: 'base0C',
771
+ brightRed: 'base12',
772
+ brightYellow: 'base13',
773
+ brightGreen: 'base14',
774
+ brightCyan: 'base15',
775
+ brightBlue: 'base16',
776
+ brightMagenta: 'base17',
777
+ };
778
+ const slot = direct[role];
779
+ if (slot)
780
+ return slot;
781
+ const rampSlots = {
782
+ black: ramp.black,
783
+ white: ramp.white,
784
+ brightBlack: ramp.brightBlack,
785
+ brightWhite: ramp.brightWhite,
786
+ };
787
+ const target = rampSlots[role];
788
+ if (!target)
789
+ return 'base05';
790
+ const match = Object.keys(palette).find((candidate) => deltaEok(palette[candidate], target) < 1e-9);
791
+ return match ?? 'base05';
792
+ }
793
+ /** Re-exported so the normalizer's callers do not need a second import. */
794
+ export { contrastDirection };
795
+ //# sourceMappingURL=normalize.js.map