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