@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.
- package/LICENSE +201 -0
- package/NOTICE +110 -0
- package/README.md +157 -0
- package/dist/adapters/base24.d.ts +117 -0
- package/dist/adapters/base24.d.ts.map +1 -0
- package/dist/adapters/base24.js +313 -0
- package/dist/adapters/base24.js.map +1 -0
- package/dist/adapters/css.d.ts +68 -0
- package/dist/adapters/css.d.ts.map +1 -0
- package/dist/adapters/css.js +107 -0
- package/dist/adapters/css.js.map +1 -0
- package/dist/adapters/shadcn.d.ts +43 -0
- package/dist/adapters/shadcn.d.ts.map +1 -0
- package/dist/adapters/shadcn.js +89 -0
- package/dist/adapters/shadcn.js.map +1 -0
- package/dist/adapters/shiki.d.ts +60 -0
- package/dist/adapters/shiki.d.ts.map +1 -0
- package/dist/adapters/shiki.js +135 -0
- package/dist/adapters/shiki.js.map +1 -0
- package/dist/adapters/tailwind.d.ts +35 -0
- package/dist/adapters/tailwind.d.ts.map +1 -0
- package/dist/adapters/tailwind.js +58 -0
- package/dist/adapters/tailwind.js.map +1 -0
- package/dist/adapters/xterm.d.ts +64 -0
- package/dist/adapters/xterm.d.ts.map +1 -0
- package/dist/adapters/xterm.js +112 -0
- package/dist/adapters/xterm.js.map +1 -0
- package/dist/catalogue.d.ts +66 -0
- package/dist/catalogue.d.ts.map +1 -0
- package/dist/catalogue.js +110 -0
- package/dist/catalogue.js.map +1 -0
- package/dist/derive.d.ts +89 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +165 -0
- package/dist/derive.js.map +1 -0
- package/dist/generated/schemes.d.ts +16 -0
- package/dist/generated/schemes.d.ts.map +1 -0
- package/dist/generated/schemes.js +880 -0
- package/dist/generated/schemes.js.map +1 -0
- package/dist/generated/themes.d.ts +11 -0
- package/dist/generated/themes.d.ts.map +1 -0
- package/dist/generated/themes.js +1650 -0
- package/dist/generated/themes.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +58 -0
- package/dist/index.js.map +1 -0
- package/dist/normalize.d.ts +160 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +795 -0
- package/dist/normalize.js.map +1 -0
- package/dist/oklch.d.ts +141 -0
- package/dist/oklch.d.ts.map +1 -0
- package/dist/oklch.js +306 -0
- package/dist/oklch.js.map +1 -0
- package/dist/schema.d.ts +178 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +69 -0
- package/dist/schema.js.map +1 -0
- package/dist/sources.d.ts +171 -0
- package/dist/sources.d.ts.map +1 -0
- package/dist/sources.js +559 -0
- package/dist/sources.js.map +1 -0
- package/dist/validate.d.ts +121 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +255 -0
- package/dist/validate.js.map +1 -0
- package/package.json +106 -0
- package/palettes/ayu-light.json +38 -0
- package/palettes/ayu-mirage.json +38 -0
- package/palettes/ayu.json +38 -0
- package/palettes/catppuccin-frappe.json +38 -0
- package/palettes/catppuccin-latte.json +38 -0
- package/palettes/catppuccin-macchiato.json +38 -0
- package/palettes/catppuccin-mocha.json +38 -0
- package/palettes/dracula.json +38 -0
- package/palettes/everforest-dark.json +38 -0
- package/palettes/everforest-light.json +38 -0
- package/palettes/gruvbox-dark.json +38 -0
- package/palettes/gruvbox-light.json +38 -0
- package/palettes/kanagawa.json +38 -0
- package/palettes/monokai.json +38 -0
- package/palettes/nord.json +38 -0
- package/palettes/one-dark.json +38 -0
- package/palettes/rosepine-dawn.json +38 -0
- package/palettes/rosepine-moon.json +38 -0
- package/palettes/rosepine.json +38 -0
- package/palettes/solarized-dark.json +38 -0
- package/palettes/solarized-light.json +38 -0
- package/palettes/tokyonight-day.json +38 -0
- package/palettes/tokyonight-night.json +38 -0
- package/palettes/tokyonight-storm.json +38 -0
- package/palettes/vesper.json +38 -0
- package/src/adapters/base24.ts +355 -0
- package/src/adapters/css.ts +149 -0
- package/src/adapters/shadcn.ts +99 -0
- package/src/adapters/shiki.ts +168 -0
- package/src/adapters/tailwind.ts +79 -0
- package/src/adapters/xterm.ts +159 -0
- package/src/catalogue.ts +129 -0
- package/src/derive.ts +203 -0
- package/src/generated/schemes.ts +882 -0
- package/src/generated/themes.ts +1652 -0
- package/src/index.ts +146 -0
- package/src/normalize.ts +1010 -0
- package/src/oklch.ts +366 -0
- package/src/schema.ts +222 -0
- package/src/sources.ts +682 -0
- 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]
|