@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/normalize.ts
ADDED
|
@@ -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 }
|