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