@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
package/src/oklch.ts ADDED
@@ -0,0 +1,366 @@
1
+ /**
2
+ * The colour core.
3
+ *
4
+ * OKLCH is the canonical representation in this package, which means every value
5
+ * that enters the catalogue is converted to it and every value that leaves is
6
+ * derived from it. The reason is not fashion: OKLCH's lightness axis is
7
+ * perceptually uniform, so a surface ladder built by adding `0.035` to L produces
8
+ * rungs that *look* evenly spaced across every hue, and a contrast repair that
9
+ * moves L by a fixed amount changes the perceived brightness by a predictable
10
+ * amount. Neither holds in sRGB or HSL, where the same numeric step is a large
11
+ * change in yellow and an invisible one in blue.
12
+ *
13
+ * Two conversion directions are needed and they are not symmetric:
14
+ *
15
+ * - **In**, from the hex values upstream palettes publish. Lossless.
16
+ * - **Out**, to hex, for consumers that cannot express OKLCH — xterm.js reads
17
+ * hex, and older Shiki engines resolve hex. This direction can land outside
18
+ * sRGB, so it gamut-maps rather than clipping.
19
+ *
20
+ * Clipping a channel is what turns a carefully chosen out-of-gamut purple into a
21
+ * flat, over-bright magenta. The gamut mapping here reduces chroma at constant
22
+ * lightness and hue instead, which is the standard CSS Color 4 approach and keeps
23
+ * the colour recognisably the one that was asked for.
24
+ */
25
+
26
+ /** A colour in OKLCH. `l` and `c` are unbounded here; use {@link inGamut} to test. */
27
+ export type Oklch = {
28
+ /** Perceptual lightness, nominally 0–1. */
29
+ l: number
30
+ /** Chroma, nominally 0–0.4. */
31
+ c: number
32
+ /** Hue angle in degrees, 0–360. Meaningless when `c` is 0. */
33
+ h: number
34
+ }
35
+
36
+ const SRGB_TO_LINEAR = (channel: number): number =>
37
+ channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
38
+
39
+ const LINEAR_TO_SRGB = (channel: number): number =>
40
+ channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055
41
+
42
+ /** One sRGB channel as a two-digit hex pair. */
43
+ function hexChannel(value: number): string {
44
+ return Math.round(Math.min(1, Math.max(0, LINEAR_TO_SRGB(value))) * 255)
45
+ .toString(16)
46
+ .padStart(2, '0')
47
+ }
48
+
49
+ type LinearRgb = { r: number; g: number; b: number }
50
+
51
+ /** OKLab, the rectangular form OKLCH is the polar form of. */
52
+ type Oklab = { l: number; a: number; b: number }
53
+
54
+ function linearRgbToOklab({ r, g, b }: LinearRgb): Oklab {
55
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
56
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
57
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
58
+ return {
59
+ l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
60
+ a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
61
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
62
+ }
63
+ }
64
+
65
+ function oklabToLinearRgb({ l, a, b }: Oklab): LinearRgb {
66
+ const lp = l + 0.3963377774 * a + 0.2158037573 * b
67
+ const mp = l - 0.1055613458 * a - 0.0638541728 * b
68
+ const sp = l - 0.0894841775 * a - 1.291485548 * b
69
+ const lc = lp ** 3
70
+ const mc = mp ** 3
71
+ const sc = sp ** 3
72
+ return {
73
+ r: 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
74
+ g: -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
75
+ b: -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc,
76
+ }
77
+ }
78
+
79
+ export const oklchToOklab = ({ l, c, h }: Oklch): Oklab => {
80
+ const radians = (h * Math.PI) / 180
81
+ return { l, a: c * Math.cos(radians), b: c * Math.sin(radians) }
82
+ }
83
+
84
+ export const oklabToOklch = ({ l, a, b }: Oklab): Oklch => {
85
+ const c = Math.hypot(a, b)
86
+ // Hue is undefined for a neutral; report 0 rather than NaN so that a grey
87
+ // survives a round trip and can still be interpolated.
88
+ const h = c < 1e-6 ? 0 : ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360
89
+ return { l, c, h }
90
+ }
91
+
92
+ /** True when every linear channel sits inside the sRGB cube. */
93
+ export function inGamut(color: Oklch, tolerance = 1e-4): boolean {
94
+ const { r, g, b } = oklabToLinearRgb(oklchToOklab(color))
95
+ const within = (channel: number): boolean =>
96
+ channel >= -tolerance && channel <= 1 + tolerance
97
+ return within(r) && within(g) && within(b)
98
+ }
99
+
100
+ /**
101
+ * Brings a colour into sRGB by reducing chroma at constant lightness and hue.
102
+ *
103
+ * Reduction is a bisection because the sRGB boundary along a constant-hue line is
104
+ * not a linear function of chroma — a fixed number of decrements either stops
105
+ * short of the boundary (leaving a clipped colour) or overshoots it (desaturating
106
+ * far more than necessary). 24 iterations resolve chroma to well under one
107
+ * 8-bit-steps' worth of difference.
108
+ */
109
+ export function gamutMap(color: Oklch): Oklch {
110
+ const lightness = Math.min(1, Math.max(0, color.l))
111
+ const saturated = { ...color, l: lightness }
112
+ if (inGamut(saturated)) return saturated
113
+
114
+ let low = 0
115
+ let high = saturated.c
116
+ for (let iteration = 0; iteration < 24; iteration += 1) {
117
+ const mid = (low + high) / 2
118
+ if (inGamut({ ...saturated, c: mid })) low = mid
119
+ else high = mid
120
+ }
121
+ return { ...saturated, c: low }
122
+ }
123
+
124
+ const HEX_PATTERN = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i
125
+
126
+ /** Parses `#rgb` and `#rrggbb`. Returns undefined rather than throwing. */
127
+ export function hexToOklch(hex: string): Oklch | undefined {
128
+ const match = HEX_PATTERN.exec(hex.trim())
129
+ if (!match) return undefined
130
+ const digits = match[1] ?? ''
131
+ const expanded =
132
+ digits.length === 3
133
+ ? digits
134
+ .split('')
135
+ .map((digit) => digit + digit)
136
+ .join('')
137
+ : digits
138
+
139
+ const [r, g, b] = [0, 2, 4].map((offset) =>
140
+ SRGB_TO_LINEAR(Number.parseInt(expanded.slice(offset, offset + 2), 16) / 255)
141
+ ) as [number, number, number]
142
+
143
+ return oklabToOklch(linearRgbToOklab({ r, g, b }))
144
+ }
145
+
146
+ /** Serialises to `#rrggbb` after gamut mapping. Always lower case. */
147
+ export function oklchToHex(color: Oklch): string {
148
+ const mapped = gamutMap(color)
149
+ const { r, g, b } = oklabToLinearRgb(oklchToOklab(mapped))
150
+ return `#${hexChannel(r)}${hexChannel(g)}${hexChannel(b)}`
151
+ }
152
+
153
+ /** Reads one component, allowing a percentage scaled to the channel's own maximum. */
154
+ function readComponent(raw: string | undefined, scale: number): number {
155
+ const text = raw ?? '0'
156
+ const value = Number.parseFloat(text)
157
+ if (Number.isNaN(value)) return Number.NaN
158
+ return text.endsWith('%') ? (value / 100) * scale : value
159
+ }
160
+
161
+ const OKLCH_PATTERN =
162
+ /^oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+)(?:deg)?\s*(?:\/\s*[\d.]+%?\s*)?\)$/i
163
+
164
+ /**
165
+ * Parses an `oklch()` string.
166
+ *
167
+ * Alpha is accepted and discarded: every role in the catalogue is opaque, and a
168
+ * translucent value would silently break the contrast floors, which are defined
169
+ * for opaque colours.
170
+ */
171
+ export function parseOklch(input: string): Oklch | undefined {
172
+ const match = OKLCH_PATTERN.exec(input.trim())
173
+ if (!match) return undefined
174
+ const l = readComponent(match[1], 1)
175
+ const c = readComponent(match[2], 0.4)
176
+ const h = Number.parseFloat(match[3] ?? '0')
177
+ if ([l, c, h].some(Number.isNaN)) return undefined
178
+ return { l, c, h }
179
+ }
180
+
181
+ /** Accepts either notation, so adapters and fixtures can use whichever reads better. */
182
+ export function parseColor(input: string): Oklch | undefined {
183
+ const text = input.trim()
184
+ return text.toLowerCase().startsWith('oklch') ? parseOklch(text) : hexToOklch(text)
185
+ }
186
+
187
+ /**
188
+ * Serialises to the canonical `oklch(L C H)` form, rounded to the precision the
189
+ * catalogue is committed at.
190
+ *
191
+ * The rounding is part of the on-disk contract, not cosmetic: generated files are
192
+ * diffed in CI to prove the committed catalogue matches its sources, and a full
193
+ * float would make that diff unstable across platforms.
194
+ */
195
+ export function formatOklch(color: Oklch): string {
196
+ const l = Number(color.l.toFixed(4))
197
+ const c = Number(color.c.toFixed(4))
198
+ const h = color.c < 1e-6 ? 0 : Number(color.h.toFixed(2))
199
+ return `oklch(${l} ${c} ${h})`
200
+ }
201
+
202
+ /**
203
+ * The colour as it will actually be written.
204
+ *
205
+ * Repairs must be measured against this rather than against their own working
206
+ * value. {@link formatOklch} rounds lightness to four decimals, and rounding can
207
+ * move a pairing from 4.5001:1 to 4.4998:1 — so a repair that converges on the
208
+ * exact floor and is then rounded ships a value that fails the floor it was
209
+ * computed to satisfy. Measuring the canonical form closes that gap by making the
210
+ * thing measured and the thing committed the same thing.
211
+ */
212
+ export function canonical(color: Oklch): Oklch {
213
+ return parseColor(formatOklch(color)) ?? color
214
+ }
215
+
216
+ /**
217
+ * Relative luminance of an sRGB colour, per WCAG 2.1.
218
+ *
219
+ * Computed from linear-light channels, which is why this goes through OKLab
220
+ * rather than being read off `L`: OKLCH lightness is perceptual and WCAG's is
221
+ * not, and the two disagree most in the saturated blues and yellows where the
222
+ * contrast floors actually bite.
223
+ */
224
+ export function relativeLuminance(color: Oklch): number {
225
+ const { r, g, b } = oklabToLinearRgb(oklchToOklab(gamutMap(color)))
226
+ return 0.2126 * unit(r) + 0.7152 * unit(g) + 0.0722 * unit(b)
227
+ }
228
+
229
+ /** The WCAG 2.1 contrast ratio, 1–21. Order-independent. */
230
+ export function contrastRatio(a: Oklch, b: Oklch): number {
231
+ const la = relativeLuminance(a)
232
+ const lb = relativeLuminance(b)
233
+ const [lighter, darker] = la > lb ? [la, lb] : [lb, la]
234
+ return (lighter + 0.05) / (darker + 0.05)
235
+ }
236
+
237
+ /** Clamps a linear channel into 0–1. */
238
+ function unit(value: number): number {
239
+ return Math.min(1, Math.max(0, value))
240
+ }
241
+
242
+ /** The direction in which a colour's lightness must move to gain contrast. */
243
+ export function contrastDirection(foreground: Oklch, background: Oklch): 1 | -1 {
244
+ return foreground.l >= background.l ? 1 : -1
245
+ }
246
+
247
+ export type ContrastRepair = {
248
+ color: Oklch
249
+ ratio: number
250
+ /** How far L moved, 0 when the input already cleared the floor. */
251
+ delta: number
252
+ /** False when the floor could not be reached within `budget`. */
253
+ satisfied: boolean
254
+ }
255
+
256
+ /**
257
+ * Moves a colour's lightness until it clears a contrast floor, by the smallest
258
+ * step that works.
259
+ *
260
+ * A theme's status colours come from its author's palette, and a palette chosen
261
+ * for a terminal is not automatically legible as a UI accent: Solarized's yellow
262
+ * is the colour Solarized is famous for, and it is also nearly invisible on
263
+ * Solarized's own light background. The catalogue's answer is not to discard the
264
+ * colour or to accept the failure, but to keep the hue and chroma the author
265
+ * chose and move only the lightness, by the least amount that clears the floor.
266
+ *
267
+ * `budget` bounds that movement. Past it the colour is no longer the one the
268
+ * palette specifies, so the repair reports `satisfied: false` and the validator
269
+ * fails the theme instead of shipping a palette that quietly is not Solarized.
270
+ */
271
+ export function repairContrast(
272
+ foreground: Oklch,
273
+ background: Oklch,
274
+ minimum: number,
275
+ budget = 0.22
276
+ ): ContrastRepair {
277
+ const initial = contrastRatio(foreground, background)
278
+ if (initial >= minimum) {
279
+ return { color: foreground, ratio: initial, delta: 0, satisfied: true }
280
+ }
281
+
282
+ // Both directions are tried, nearest first. Moving away from the background's
283
+ // lightness is the usual answer, but when the colour is already near an end of the
284
+ // range there is no headroom in that direction — Solarized Light's cursor is a
285
+ // mid grey on a near-white canvas, and *lightening* it has 0.03 to work with while
286
+ // darkening it has the whole range.
287
+ const preferred = contrastDirection(foreground, background)
288
+ const step = 0.002
289
+ const maximumSteps = Math.floor(budget / step)
290
+
291
+ for (let index = 1; index <= maximumSteps; index += 1) {
292
+ for (const direction of [preferred, -preferred] as const) {
293
+ const candidate: Oklch = { ...foreground, l: foreground.l + direction * step * index }
294
+ if (candidate.l <= 0 || candidate.l >= 1) continue
295
+ const ratio = contrastRatio(candidate, background)
296
+ if (ratio >= minimum) {
297
+ return {
298
+ color: candidate,
299
+ ratio,
300
+ delta: Number((step * index).toFixed(4)),
301
+ satisfied: true,
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ return {
308
+ color: foreground,
309
+ ratio: initial,
310
+ delta: 0,
311
+ satisfied: false,
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Interpolates two colours in OKLCH.
317
+ *
318
+ * Hue takes the shorter arc, so a blend from magenta (330°) to orange (30°) goes
319
+ * through red rather than the long way round through green. When either side is
320
+ * neutral its hue is meaningless, so the other side's hue is carried across
321
+ * instead of being interpolated toward an arbitrary zero.
322
+ */
323
+ export function mix(a: Oklch, b: Oklch, amount: number): Oklch {
324
+ const t = Math.min(1, Math.max(0, amount))
325
+ const neutral = 1e-6
326
+ const aChromatic = a.c > neutral
327
+ const bChromatic = b.c > neutral
328
+
329
+ let hue: number
330
+ if (aChromatic && bChromatic) {
331
+ const delta = ((b.h - a.h + 540) % 360) - 180
332
+ hue = (a.h + delta * t + 360) % 360
333
+ } else if (aChromatic) {
334
+ hue = a.h
335
+ } else {
336
+ hue = b.h
337
+ }
338
+
339
+ return {
340
+ l: a.l + (b.l - a.l) * t,
341
+ c: a.c + (b.c - a.c) * t,
342
+ h: hue,
343
+ }
344
+ }
345
+
346
+ /** Shifts lightness by `delta`, keeping chroma and hue. Chroma is damped at the extremes. */
347
+ export function shiftLightness(color: Oklch, delta: number): Oklch {
348
+ const l = Math.min(1, Math.max(0, color.l + delta))
349
+ // Chroma that was representable at the original lightness may not be at the new
350
+ // one — every hue's maximum chroma collapses toward the black and white ends.
351
+ // Damping before the gamut map keeps the hue from drifting as it is reduced.
352
+ const headroom = Math.min(1, 4 * Math.min(l, 1 - l) + 0.15)
353
+ return { l, c: color.c * headroom, h: color.h }
354
+ }
355
+
356
+ /** Scales chroma, clamped to the maximum the hue can hold. */
357
+ export function scaleChroma(color: Oklch, factor: number): Oklch {
358
+ return { ...color, c: Math.max(0, Math.min(0.4, color.c * factor)) }
359
+ }
360
+
361
+ /** The perceptual distance between two colours, for deduplication and assertions. */
362
+ export function deltaEok(a: Oklch, b: Oklch): number {
363
+ const first = oklchToOklab(a)
364
+ const second = oklchToOklab(b)
365
+ return Math.hypot(first.l - second.l, first.a - second.a, first.b - second.b)
366
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The canonical schema.
3
+ *
4
+ * This file is the contract. Everything else in the package — the normalizer, the
5
+ * Base24 bridge, the terminal and editor adapters — exists to move values into or
6
+ * out of the shapes declared here, and no adapter is allowed to invent a role that
7
+ * is not in this file.
8
+ *
9
+ * ## Why this and not Base24
10
+ *
11
+ * Base24 is an excellent interchange format and a poor application schema. Its
12
+ * sixteen `base00`–`base0F` slots describe an editor: there is a slot for the
13
+ * background, a slot for comments, and a slot for the colour of a deprecated API,
14
+ * but nothing that means "the surface one step above the card" or "the text a
15
+ * caption uses". A UI built by reading Base24 directly ends up with component
16
+ * authors choosing between `base01` and `base02` by eye, which is how a design
17
+ * system acquires four nearly-identical greys that no one dares consolidate.
18
+ *
19
+ * So the roles below are the ones a component actually asks for — a surface, a
20
+ * border, a muted text, an accent — and Base24 is a bridge at the edge. See
21
+ * `adapters/base24.ts` for both directions.
22
+ *
23
+ * ## Why OKLCH strings and not numbers
24
+ *
25
+ * Every colour here is an `oklch(L C H)` string rather than a parsed object. It
26
+ * keeps the catalogue readable and diffable in review, it can be handed straight
27
+ * to CSS with no serialisation step, and it cannot be mutated by a consumer that
28
+ * receives it. Call {@link "../oklch".parseColor} when the numbers are needed.
29
+ */
30
+
31
+ /** Whether a theme is a light or a dark theme. */
32
+ export type ThemeAppearance = 'dark' | 'light'
33
+
34
+ /**
35
+ * The seventeen colours that describe a surface.
36
+ *
37
+ * The set is deliberately small and deliberately semantic. There is no `grey300`
38
+ * and no `blue500`: a component that needs "a slightly raised panel" asks for
39
+ * `surfaceElevated` and gets whichever value the active theme considers one step
40
+ * up, at every theme in the catalogue, without the component knowing.
41
+ */
42
+ export interface AdeaThemeColors {
43
+ /** The canvas. Everything that is not a surface sits on this. */
44
+ background: string
45
+ /** Default body text on `background`. Clears 4.5:1 against it in every theme. */
46
+ foreground: string
47
+ /** The first rung above the canvas: cards, panels, sidebars. */
48
+ surface: string
49
+ /** The second rung: popovers, dialogs, menus floating above a card. */
50
+ surfaceElevated: string
51
+ /** A surface under the pointer. */
52
+ surfaceHover: string
53
+ /** A surface under an active press or a selected row. */
54
+ surfaceActive: string
55
+ /** The default divider and input outline. */
56
+ border: string
57
+ /** A divider that should recede: table rules, separators inside a card. */
58
+ borderMuted: string
59
+ /** Text at full strength. Identical to `foreground` unless a theme overrides it. */
60
+ text: string
61
+ /** Secondary text: descriptions, captions, table cell metadata. Clears 4.5:1. */
62
+ textMuted: string
63
+ /**
64
+ * Tertiary text: timestamps, counts, placeholder copy. Clears 3:1, which is the
65
+ * WCAG floor for large text and for non-essential text — the floor is lower
66
+ * here on purpose, because at 4.5:1 there is no longer any visual difference
67
+ * between this role and `textMuted` and the role stops earning its place.
68
+ */
69
+ textSubtle: string
70
+ /** The one interactive colour: primary buttons, selected tabs, focus rings. */
71
+ accent: string
72
+ /** Text and icons drawn on top of `accent`. Clears 4.5:1 against it. */
73
+ accentForeground: string
74
+ /** Positive status. Text-legible against `background`, unlike most palettes' green. */
75
+ success: string
76
+ /** Cautionary status. Text-legible against `background`. */
77
+ warning: string
78
+ /** Destructive status and destructive text. Text-legible against `background`. */
79
+ error: string
80
+ /** Neutral notice. Text-legible against `background`. */
81
+ info: string
82
+ }
83
+
84
+ /**
85
+ * The sixteen ANSI colours.
86
+ *
87
+ * Carried in the canonical schema rather than derived on demand because they are
88
+ * what a shell, a diff and a build log are coloured with, and a terminal embedded
89
+ * in the app must agree with the terminal the user has open beside it. When a
90
+ * theme is imported from a palette that publishes ANSI values, these are those
91
+ * values, not a reconstruction.
92
+ */
93
+ export interface AdeaAnsi {
94
+ black: string
95
+ red: string
96
+ green: string
97
+ yellow: string
98
+ blue: string
99
+ magenta: string
100
+ cyan: string
101
+ white: string
102
+ brightBlack: string
103
+ brightRed: string
104
+ brightGreen: string
105
+ brightYellow: string
106
+ brightBlue: string
107
+ brightMagenta: string
108
+ brightCyan: string
109
+ brightWhite: string
110
+ }
111
+
112
+ /**
113
+ * A theme.
114
+ *
115
+ * This is the published contract: the three identity fields, the seventeen surface
116
+ * roles, the sixteen ANSI colours, and the two state colours a selection needs.
117
+ */
118
+ export interface AdeaTheme {
119
+ /** Stable id. The `data-theme` value and what a preference stores. */
120
+ id: string
121
+ /** Human-readable name, e.g. `"Catppuccin Mocha"`. */
122
+ name: string
123
+ appearance: ThemeAppearance
124
+ colors: AdeaThemeColors
125
+ ansi: AdeaAnsi
126
+ /** The text cursor. Usually the foreground, but a palette may disagree. */
127
+ cursor: string
128
+ /** The selection background. */
129
+ selection: string
130
+ }
131
+
132
+ /** Where a palette came from and the terms it is used under. */
133
+ export interface ThemeProvenance {
134
+ /** The project or person the palette belongs to. */
135
+ project: string
136
+ /** The canonical upstream URL. */
137
+ url: string
138
+ /** An SPDX identifier. Every palette in this catalogue is permissive. */
139
+ license: string
140
+ /**
141
+ * The commit the vendored values were taken from, when they were taken from a
142
+ * vendored artefact rather than from the project directly. This is what makes
143
+ * the palette data auditable: the numbers in `palettes/` can be re-derived from
144
+ * a named revision rather than trusted.
145
+ */
146
+ revision?: string
147
+ /** The upstream artefacts this palette's ANSI half was bootstrapped from. */
148
+ bootstrappedFrom?: readonly string[]
149
+ }
150
+
151
+ /**
152
+ * A catalogue entry: the theme, plus what a picker and an audit need.
153
+ *
154
+ * Kept as a separate type rather than folded into {@link AdeaTheme} so that a
155
+ * consumer receiving a theme can depend on exactly the documented contract and
156
+ * nothing else, while the catalogue still carries the provenance that lets a
157
+ * licence audit answer "where did this colour come from".
158
+ */
159
+ export interface AdeaThemeRecord extends AdeaTheme {
160
+ /** Groups the variants that came from one project, e.g. `catppuccin`. */
161
+ family: string
162
+ /** How the family is written in a picker, e.g. `Catppuccin`. */
163
+ familyLabel: string
164
+ /** Short display name within the family, e.g. `Mocha`. */
165
+ label: string
166
+ /** One line, shown in a picker. */
167
+ description: string
168
+ provenance: ThemeProvenance
169
+ /** Search and filter terms, e.g. `['dark', 'muted', 'popular']`. */
170
+ tags: readonly string[]
171
+ }
172
+
173
+ /** A family of themes: one project, one or more variants. */
174
+ export interface ThemeFamily {
175
+ id: string
176
+ label: string
177
+ themes: readonly AdeaThemeRecord[]
178
+ }
179
+
180
+ /** The keys of {@link AdeaThemeColors}, for iteration and validation. */
181
+ export const THEME_COLOR_KEYS = [
182
+ 'background',
183
+ 'foreground',
184
+ 'surface',
185
+ 'surfaceElevated',
186
+ 'surfaceHover',
187
+ 'surfaceActive',
188
+ 'border',
189
+ 'borderMuted',
190
+ 'text',
191
+ 'textMuted',
192
+ 'textSubtle',
193
+ 'accent',
194
+ 'accentForeground',
195
+ 'success',
196
+ 'warning',
197
+ 'error',
198
+ 'info',
199
+ ] as const satisfies readonly (keyof AdeaThemeColors)[]
200
+
201
+ /** The keys of {@link AdeaAnsi}. */
202
+ export const ANSI_KEYS = [
203
+ 'black',
204
+ 'red',
205
+ 'green',
206
+ 'yellow',
207
+ 'blue',
208
+ 'magenta',
209
+ 'cyan',
210
+ 'white',
211
+ 'brightBlack',
212
+ 'brightRed',
213
+ 'brightGreen',
214
+ 'brightYellow',
215
+ 'brightBlue',
216
+ 'brightMagenta',
217
+ 'brightCyan',
218
+ 'brightWhite',
219
+ ] as const satisfies readonly (keyof AdeaAnsi)[]
220
+
221
+ export type ThemeColorKey = (typeof THEME_COLOR_KEYS)[number]
222
+ export type AnsiKey = (typeof ANSI_KEYS)[number]