@adea-ai/themes 0.2.0 → 0.3.1
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/NOTICE +37 -21
- package/README.md +27 -11
- package/dist/generated/schemes.js +25 -25
- package/dist/generated/schemes.js.map +1 -1
- package/dist/generated/themes.d.ts.map +1 -1
- package/dist/generated/themes.js +58 -54
- package/dist/generated/themes.js.map +1 -1
- package/dist/normalize.d.ts +25 -0
- package/dist/normalize.d.ts.map +1 -1
- package/dist/normalize.js +180 -13
- package/dist/normalize.js.map +1 -1
- package/dist/oklch.d.ts +11 -11
- package/dist/oklch.d.ts.map +1 -1
- package/dist/oklch.js +21 -16
- package/dist/oklch.js.map +1 -1
- package/dist/sources.d.ts +52 -8
- package/dist/sources.d.ts.map +1 -1
- package/dist/sources.js +105 -57
- package/dist/sources.js.map +1 -1
- package/package.json +1 -1
- package/palettes/adea-light.json +56 -0
- package/src/generated/schemes.ts +25 -25
- package/src/generated/themes.ts +58 -54
- package/src/normalize.ts +238 -13
- package/src/oklch.ts +22 -17
- package/src/sources.ts +128 -60
package/src/generated/themes.ts
CHANGED
|
@@ -77,57 +77,61 @@ export const generatedThemes: readonly AdeaThemeRecord[] = Object.freeze([
|
|
|
77
77
|
"name": "Adea Light",
|
|
78
78
|
"appearance": "light",
|
|
79
79
|
"colors": {
|
|
80
|
-
"background": "oklch(
|
|
81
|
-
"foreground": "oklch(0.
|
|
82
|
-
"surface": "oklch(
|
|
83
|
-
"surfaceElevated": "oklch(
|
|
84
|
-
"surfaceHover": "oklch(0.
|
|
85
|
-
"surfaceActive": "oklch(0.
|
|
86
|
-
"border": "oklch(0.
|
|
87
|
-
"borderMuted": "oklch(0.
|
|
88
|
-
"text": "oklch(0.
|
|
89
|
-
"textMuted": "oklch(0.
|
|
90
|
-
"textSubtle": "oklch(0.
|
|
91
|
-
"accent": "oklch(0.
|
|
92
|
-
"accentForeground": "oklch(0.
|
|
93
|
-
"success": "oklch(0.
|
|
94
|
-
"warning": "oklch(0.
|
|
95
|
-
"error": "oklch(0.
|
|
96
|
-
"info": "oklch(0.
|
|
80
|
+
"background": "oklch(0.933 0.016 261.79)",
|
|
81
|
+
"foreground": "oklch(0.3697 0.0285 266.52)",
|
|
82
|
+
"surface": "oklch(0.898 0.0108 261.79)",
|
|
83
|
+
"surfaceElevated": "oklch(0.868 0.0151 261.79)",
|
|
84
|
+
"surfaceHover": "oklch(0.833 0.0209 261.79)",
|
|
85
|
+
"surfaceActive": "oklch(0.798 0.0245 261.79)",
|
|
86
|
+
"border": "oklch(0.783 0.0144 261.79)",
|
|
87
|
+
"borderMuted": "oklch(0.838 0.0096 261.79)",
|
|
88
|
+
"text": "oklch(0.3697 0.0285 266.52)",
|
|
89
|
+
"textMuted": "oklch(0.474 0.0245 265.01)",
|
|
90
|
+
"textSubtle": "oklch(0.5695 0.0216 263.92)",
|
|
91
|
+
"accent": "oklch(0.4713 0.1518 253.31)",
|
|
92
|
+
"accentForeground": "oklch(0.9513 0.0074 260.73)",
|
|
93
|
+
"success": "oklch(0.4511 0.1809 145.62)",
|
|
94
|
+
"warning": "oklch(0.4756 0.1401 79.91)",
|
|
95
|
+
"error": "oklch(0.4905 0.1626 25.78)",
|
|
96
|
+
"info": "oklch(0.5115 0.1155 202.14)"
|
|
97
97
|
},
|
|
98
98
|
"ansi": {
|
|
99
|
-
"black": "oklch(0.
|
|
100
|
-
"red": "oklch(0.
|
|
101
|
-
"green": "oklch(0.
|
|
102
|
-
"yellow": "oklch(0.
|
|
103
|
-
"blue": "oklch(0.
|
|
104
|
-
"magenta": "oklch(0.
|
|
105
|
-
"cyan": "oklch(0.
|
|
106
|
-
"white": "oklch(0.
|
|
107
|
-
"brightBlack": "oklch(0.
|
|
108
|
-
"brightRed": "oklch(0.
|
|
109
|
-
"brightGreen": "oklch(0.
|
|
110
|
-
"brightYellow": "oklch(0.
|
|
111
|
-
"brightBlue": "oklch(0.
|
|
112
|
-
"brightMagenta": "oklch(0.
|
|
113
|
-
"brightCyan": "oklch(0.
|
|
114
|
-
"brightWhite": "oklch(0.
|
|
115
|
-
},
|
|
116
|
-
"cursor": "oklch(0.
|
|
117
|
-
"selection": "oklch(0.
|
|
99
|
+
"black": "oklch(0.3792 0.029 266.47)",
|
|
100
|
+
"red": "oklch(0.4905 0.1626 25.78)",
|
|
101
|
+
"green": "oklch(0.4511 0.1809 145.62)",
|
|
102
|
+
"yellow": "oklch(0.4756 0.1401 79.91)",
|
|
103
|
+
"blue": "oklch(0.4713 0.1518 253.31)",
|
|
104
|
+
"magenta": "oklch(0.4883 0.1668 301.69)",
|
|
105
|
+
"cyan": "oklch(0.5115 0.1155 202.14)",
|
|
106
|
+
"white": "oklch(0.7399 0.0172 262.74)",
|
|
107
|
+
"brightBlack": "oklch(0.4523 0.0352 264.13)",
|
|
108
|
+
"brightRed": "oklch(0.5574 0.1134 25.81)",
|
|
109
|
+
"brightGreen": "oklch(0.5277 0.188 145.48)",
|
|
110
|
+
"brightYellow": "oklch(0.5464 0.1386 85.24)",
|
|
111
|
+
"brightBlue": "oklch(0.5417 0.1153 246.66)",
|
|
112
|
+
"brightMagenta": "oklch(0.5565 0.1275 305.86)",
|
|
113
|
+
"brightCyan": "oklch(0.5596 0.1107 201.78)",
|
|
114
|
+
"brightWhite": "oklch(0.9513 0.0074 260.73)"
|
|
115
|
+
},
|
|
116
|
+
"cursor": "oklch(0.7337 0.0627 217.58)",
|
|
117
|
+
"selection": "oklch(0.8733 0.0164 262.75)",
|
|
118
118
|
"family": "adea",
|
|
119
119
|
"familyLabel": "Adea",
|
|
120
120
|
"label": "Light",
|
|
121
|
-
"description": "The default light theme.
|
|
121
|
+
"description": "The default light theme. Nord Light's cool canvas, with the same hues as Adea Dark.",
|
|
122
122
|
"provenance": {
|
|
123
123
|
"project": "Adea",
|
|
124
124
|
"url": "https://github.com/adea-ai/themes",
|
|
125
|
-
"license": "Apache-2.0"
|
|
125
|
+
"license": "Apache-2.0",
|
|
126
|
+
"bootstrappedFrom": [
|
|
127
|
+
"Nord Light (iTerm2-Color-Schemes)",
|
|
128
|
+
"GitHub Dark Default (iTerm2-Color-Schemes)"
|
|
129
|
+
]
|
|
126
130
|
},
|
|
127
131
|
"tags": [
|
|
128
132
|
"light",
|
|
129
|
-
"
|
|
130
|
-
"
|
|
133
|
+
"default",
|
|
134
|
+
"cool"
|
|
131
135
|
]
|
|
132
136
|
},
|
|
133
137
|
{
|
|
@@ -630,7 +634,7 @@ export const generatedThemes: readonly AdeaThemeRecord[] = Object.freeze([
|
|
|
630
634
|
"borderMuted": "oklch(0.3749 0.0074 232.92)",
|
|
631
635
|
"text": "oklch(0.8299 0.0405 86.05)",
|
|
632
636
|
"textMuted": "oklch(0.7099 0.0315 133.05)",
|
|
633
|
-
"textSubtle": "oklch(0.
|
|
637
|
+
"textSubtle": "oklch(0.6054 0.025 166.83)",
|
|
634
638
|
"accent": "oklch(0.7732 0.0906 125.78)",
|
|
635
639
|
"accentForeground": "oklch(0.2799 0.0123 232.92)",
|
|
636
640
|
"success": "oklch(0.7732 0.0906 125.78)",
|
|
@@ -683,15 +687,15 @@ export const generatedThemes: readonly AdeaThemeRecord[] = Object.freeze([
|
|
|
683
687
|
"appearance": "light",
|
|
684
688
|
"colors": {
|
|
685
689
|
"background": "oklch(0.9371 0.0307 98.87)",
|
|
686
|
-
"foreground": "oklch(0.
|
|
690
|
+
"foreground": "oklch(0.4114 0.0212 232.87)",
|
|
687
691
|
"surface": "oklch(0.9021 0.0201 98.87)",
|
|
688
692
|
"surfaceElevated": "oklch(0.8721 0.0282 98.87)",
|
|
689
693
|
"surfaceHover": "oklch(0.8371 0.0394 98.87)",
|
|
690
694
|
"surfaceActive": "oklch(0.8021 0.0462 98.87)",
|
|
691
695
|
"border": "oklch(0.7871 0.0276 98.87)",
|
|
692
696
|
"borderMuted": "oklch(0.8421 0.0184 98.87)",
|
|
693
|
-
"text": "oklch(0.
|
|
694
|
-
"textMuted": "oklch(0.
|
|
697
|
+
"text": "oklch(0.4114 0.0212 232.87)",
|
|
698
|
+
"textMuted": "oklch(0.4731 0.0242 189.99)",
|
|
695
699
|
"textSubtle": "oklch(0.5707 0.0252 133.52)",
|
|
696
700
|
"accent": "oklch(0.518 0.0915 125.92)",
|
|
697
701
|
"accentForeground": "oklch(0.9879 0.0163 91.55)",
|
|
@@ -1295,15 +1299,15 @@ export const generatedThemes: readonly AdeaThemeRecord[] = Object.freeze([
|
|
|
1295
1299
|
"appearance": "dark",
|
|
1296
1300
|
"colors": {
|
|
1297
1301
|
"background": "oklch(0.2673 0.0486 219.82)",
|
|
1298
|
-
"foreground": "oklch(0.
|
|
1302
|
+
"foreground": "oklch(0.7857 0.0197 205.26)",
|
|
1299
1303
|
"surface": "oklch(0.3023 0.0588 219.82)",
|
|
1300
1304
|
"surfaceElevated": "oklch(0.3323 0.06 219.82)",
|
|
1301
1305
|
"surfaceHover": "oklch(0.3673 0.06 219.82)",
|
|
1302
1306
|
"surfaceActive": "oklch(0.4023 0.06 219.82)",
|
|
1303
1307
|
"border": "oklch(0.4173 0.0438 219.82)",
|
|
1304
1308
|
"borderMuted": "oklch(0.3623 0.0292 219.82)",
|
|
1305
|
-
"text": "oklch(0.
|
|
1306
|
-
"textMuted": "oklch(0.
|
|
1309
|
+
"text": "oklch(0.7857 0.0197 205.26)",
|
|
1310
|
+
"textMuted": "oklch(0.7007 0.029 209.92)",
|
|
1307
1311
|
"textSubtle": "oklch(0.5954 0.051 216.2)",
|
|
1308
1312
|
"accent": "oklch(0.6437 0.1019 187.38)",
|
|
1309
1313
|
"accentForeground": "oklch(0.2673 0.0486 219.82)",
|
|
@@ -1356,16 +1360,16 @@ export const generatedThemes: readonly AdeaThemeRecord[] = Object.freeze([
|
|
|
1356
1360
|
"appearance": "light",
|
|
1357
1361
|
"colors": {
|
|
1358
1362
|
"background": "oklch(0.9735 0.0261 90.1)",
|
|
1359
|
-
"foreground": "oklch(0.
|
|
1363
|
+
"foreground": "oklch(0.4382 0.0285 221.9)",
|
|
1360
1364
|
"surface": "oklch(0.9385 0.0125 90.1)",
|
|
1361
1365
|
"surfaceElevated": "oklch(0.9085 0.0187 90.1)",
|
|
1362
1366
|
"surfaceHover": "oklch(0.8735 0.0273 90.1)",
|
|
1363
1367
|
"surfaceActive": "oklch(0.8385 0.0332 90.1)",
|
|
1364
1368
|
"border": "oklch(0.8235 0.0234 90.1)",
|
|
1365
1369
|
"borderMuted": "oklch(0.8785 0.0156 90.1)",
|
|
1366
|
-
"text": "oklch(0.
|
|
1367
|
-
"textMuted": "oklch(0.
|
|
1368
|
-
"textSubtle": "oklch(0.
|
|
1370
|
+
"text": "oklch(0.4382 0.0285 221.9)",
|
|
1371
|
+
"textMuted": "oklch(0.4996 0.0277 179.72)",
|
|
1372
|
+
"textSubtle": "oklch(0.5965 0.0272 149.41)",
|
|
1369
1373
|
"accent": "oklch(0.5764 0.2025 355.89)",
|
|
1370
1374
|
"accentForeground": "oklch(0.9735 0.0261 90.1)",
|
|
1371
1375
|
"success": "oklch(0.5424 0.1508 118.6)",
|
|
@@ -1417,15 +1421,15 @@ export const generatedThemes: readonly AdeaThemeRecord[] = Object.freeze([
|
|
|
1417
1421
|
"appearance": "light",
|
|
1418
1422
|
"colors": {
|
|
1419
1423
|
"background": "oklch(0.9135 0.0068 277.16)",
|
|
1420
|
-
"foreground": "oklch(0.
|
|
1424
|
+
"foreground": "oklch(0.4021 0.1563 264.1)",
|
|
1421
1425
|
"surface": "oklch(0.8785 0.0053 277.16)",
|
|
1422
1426
|
"surfaceElevated": "oklch(0.8485 0.0072 277.16)",
|
|
1423
1427
|
"surfaceHover": "oklch(0.8135 0.0098 277.16)",
|
|
1424
1428
|
"surfaceActive": "oklch(0.7785 0.0109 277.16)",
|
|
1425
1429
|
"border": "oklch(0.7635 0.0062 277.16)",
|
|
1426
1430
|
"borderMuted": "oklch(0.8185 0.0041 277.16)",
|
|
1427
|
-
"text": "oklch(0.
|
|
1428
|
-
"textMuted": "oklch(0.
|
|
1431
|
+
"text": "oklch(0.4021 0.1563 264.1)",
|
|
1432
|
+
"textMuted": "oklch(0.4633 0.1085 268.28)",
|
|
1429
1433
|
"textSubtle": "oklch(0.5556 0.0451 278.01)",
|
|
1430
1434
|
"accent": "oklch(0.5001 0.1031 230.51)",
|
|
1431
1435
|
"accentForeground": "oklch(0.9135 0.0068 277.16)",
|
package/src/normalize.ts
CHANGED
|
@@ -150,6 +150,15 @@ const REPAIR_BUDGET = Object.freeze({
|
|
|
150
150
|
/** The accent preference order, best first. */
|
|
151
151
|
const ACCENT_PREFERENCE = ['blue', 'magenta', 'cyan', 'green'] as const satisfies readonly AnsiKey[]
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* How much stronger body text must be than the secondary rung.
|
|
155
|
+
*
|
|
156
|
+
* Small on purpose. The requirement is that the two are *distinguishable*, not that
|
|
157
|
+
* there is a wide gulf — 0.4 of a contrast ratio is a visible difference without
|
|
158
|
+
* making secondary text unnecessarily faint.
|
|
159
|
+
*/
|
|
160
|
+
const MINIMUM_LADDER_GAP = 0.4
|
|
161
|
+
|
|
153
162
|
/** The smallest chroma a colour needs before it reads as "a colour" rather than grey. */
|
|
154
163
|
const MINIMUM_ACCENT_CHROMA = 0.035
|
|
155
164
|
|
|
@@ -176,6 +185,29 @@ export interface ThemeSourceSpec {
|
|
|
176
185
|
* on the entry that makes it.
|
|
177
186
|
*/
|
|
178
187
|
palette?: Partial<Record<Base24Slot, string>>
|
|
188
|
+
/**
|
|
189
|
+
* Transpose the palette's hues onto the canvas.
|
|
190
|
+
*
|
|
191
|
+
* Set when a composition borrows one palette's hues for another palette's canvas.
|
|
192
|
+
* See {@link transposeHues} for why the move is common to every hue rather than
|
|
193
|
+
* applied per hue.
|
|
194
|
+
*/
|
|
195
|
+
hueTranspose?: { floor: number }
|
|
196
|
+
/**
|
|
197
|
+
* The floor for body text, overriding the catalogue's.
|
|
198
|
+
*
|
|
199
|
+
* The catalogue holds every palette to WCAG AA on every surface, which is what lets
|
|
200
|
+
* it admit other people's work. The *defaults* are not other people's work: this
|
|
201
|
+
* system holds its own themes to AAA — 7:1 — because they are what a user sees when
|
|
202
|
+
* they have not chosen anything, and `packages/ui/tests/tokens.test.ts` asserts it.
|
|
203
|
+
*
|
|
204
|
+
* It has to be declared here rather than left to the consumer's test, because the
|
|
205
|
+
* binding pair is text on a *raised* surface: Adea Light's foreground is 7.5:1 on its
|
|
206
|
+
* canvas and 6.2:1 on a popover, since the light ladder descends away from the canvas.
|
|
207
|
+
* Raising the floor rather than lowering the assertion is the point — the theme is
|
|
208
|
+
* repaired until the claim is true, and the claim stays.
|
|
209
|
+
*/
|
|
210
|
+
textFloor?: number
|
|
179
211
|
/** Which ANSI role supplies the accent, when the family's identity demands one. */
|
|
180
212
|
accentSlot?: AnsiKey
|
|
181
213
|
/** Explicit roles for authored themes, applied after derivation. */
|
|
@@ -666,6 +698,108 @@ function repairRole(
|
|
|
666
698
|
return { repair: lightness, blended: false }
|
|
667
699
|
}
|
|
668
700
|
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The Base24 slots that carry a hue rather than a grey.
|
|
704
|
+
*
|
|
705
|
+
* `base09` and `base0F` are included even though neither donor publishes them: they
|
|
706
|
+
* are synthesised from red and yellow, and leaving them behind would put an
|
|
707
|
+
* untransposed orange next to a transposed red.
|
|
708
|
+
*/
|
|
709
|
+
const CHROMATIC_SLOTS = [
|
|
710
|
+
'base08',
|
|
711
|
+
'base09',
|
|
712
|
+
'base0A',
|
|
713
|
+
'base0B',
|
|
714
|
+
'base0C',
|
|
715
|
+
'base0D',
|
|
716
|
+
'base0E',
|
|
717
|
+
'base0F',
|
|
718
|
+
'base12',
|
|
719
|
+
'base13',
|
|
720
|
+
'base14',
|
|
721
|
+
'base15',
|
|
722
|
+
'base16',
|
|
723
|
+
'base17',
|
|
724
|
+
] as const satisfies readonly Base24Slot[]
|
|
725
|
+
|
|
726
|
+
/** The six slots whose bright siblings make a normal/bright pair. */
|
|
727
|
+
const NORMAL_HUE_SLOTS = ['base08', 'base0A', 'base0B', 'base0C', 'base0D', 'base0E'] as const
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Moves every hue in a palette by one common lightness step, so a hue set authored
|
|
731
|
+
* against one canvas becomes legible on another.
|
|
732
|
+
*
|
|
733
|
+
* This exists because a composition can put a palette's hues on somebody else's
|
|
734
|
+
* background. GitHub's dark hues measure between 2.5:1 and 4.1:1 on a light canvas —
|
|
735
|
+
* fine as accents, unusable as terminal text — and repairing each hue *independently*
|
|
736
|
+
* to the floor destroys the thing that makes an ANSI palette an ANSI palette: the
|
|
737
|
+
* bright variants would converge onto their normal siblings, because a bright variant
|
|
738
|
+
* differs from its normal one mainly in lightness and the repair would land them all
|
|
739
|
+
* on the same lightness.
|
|
740
|
+
*
|
|
741
|
+
* Moving the group by one step instead transposes the whole set: every hue keeps its
|
|
742
|
+
* angle and chroma exactly, every relative lightness is preserved, and the palette
|
|
743
|
+
* stays internally consistent. The step is the smallest one that brings every *normal*
|
|
744
|
+
* hue to the floor, measured in the canonical form the catalogue commits so that
|
|
745
|
+
* rounding cannot push one back under.
|
|
746
|
+
*/
|
|
747
|
+
function transposeHues(
|
|
748
|
+
themeId: string,
|
|
749
|
+
palette: Record<Base24Slot, Oklch>,
|
|
750
|
+
floor: number,
|
|
751
|
+
findings: NormalizationFinding[]
|
|
752
|
+
): Partial<Record<Base24Slot, Oklch>> {
|
|
753
|
+
const background = palette.base00
|
|
754
|
+
const worst = (step: number): number =>
|
|
755
|
+
Math.min(
|
|
756
|
+
...NORMAL_HUE_SLOTS.map((slot) =>
|
|
757
|
+
contrastRatio(canonical(shiftLightness(palette[slot], step)), background)
|
|
758
|
+
)
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
const initial = worst(0)
|
|
762
|
+
if (initial >= floor) return {}
|
|
763
|
+
|
|
764
|
+
// Both directions are tried, nearest first, as in `repairContrast`: a dark canvas
|
|
765
|
+
// needs its hues lightened and a light canvas needs them darkened, and a palette
|
|
766
|
+
// moved to the other kind of canvas is the whole point of the operation.
|
|
767
|
+
const step = 0.002
|
|
768
|
+
let chosen: number | undefined
|
|
769
|
+
for (let index = 1; index <= Math.floor(0.45 / step) && chosen === undefined; index += 1) {
|
|
770
|
+
for (const direction of [1, -1] as const) {
|
|
771
|
+
const candidate = step * index * direction
|
|
772
|
+
const probe = shiftLightness(palette[NORMAL_HUE_SLOTS[0]], candidate)
|
|
773
|
+
if (probe.l <= 0.02 || probe.l >= 0.98) continue
|
|
774
|
+
if (worst(candidate) >= floor) {
|
|
775
|
+
chosen = candidate
|
|
776
|
+
break
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (chosen === undefined) {
|
|
782
|
+
findings.push({
|
|
783
|
+
themeId,
|
|
784
|
+
role: 'hues',
|
|
785
|
+
kind: 'budget-exceeded',
|
|
786
|
+
message: `no single lightness step brings every hue to ${floor}:1 against the canvas`,
|
|
787
|
+
})
|
|
788
|
+
return {}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
findings.push({
|
|
792
|
+
themeId,
|
|
793
|
+
role: 'hues',
|
|
794
|
+
kind: 'repaired',
|
|
795
|
+
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`,
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
return Object.fromEntries(
|
|
799
|
+
CHROMATIC_SLOTS.map((slot) => [slot, shiftLightness(palette[slot], chosen as number)])
|
|
800
|
+
) as Partial<Record<Base24Slot, Oklch>>
|
|
801
|
+
}
|
|
802
|
+
|
|
669
803
|
/** The catalogued result of normalizing one source. */
|
|
670
804
|
export interface NormalizedTheme {
|
|
671
805
|
record: AdeaThemeRecord
|
|
@@ -699,6 +833,16 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
|
|
|
699
833
|
})
|
|
700
834
|
}
|
|
701
835
|
|
|
836
|
+
// The hue transpose, applied at the same stage as the corrections and for the same
|
|
837
|
+
// reason: the semantic roles derive from these slots, so transposing afterwards
|
|
838
|
+
// would leave `error` built from the untransposed red and the two disagreeing.
|
|
839
|
+
if (source.hueTranspose) {
|
|
840
|
+
const transposed = transposeHues(source.id, palette, source.hueTranspose.floor, findings)
|
|
841
|
+
for (const [slot, value] of Object.entries(transposed)) {
|
|
842
|
+
palette[slot as Base24Slot] = value
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
702
846
|
const background = palette.base00
|
|
703
847
|
|
|
704
848
|
const appearance: ThemeAppearance = ladderDirection(background) === 1 ? 'dark' : 'light'
|
|
@@ -755,13 +899,14 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
|
|
|
755
899
|
|
|
756
900
|
// Body text first: several palettes publish a foreground that fails on their own
|
|
757
901
|
// background, and every other role's floors are measured against the canvas.
|
|
902
|
+
const textFloor = source.textFloor ?? CONTRAST_FLOORS.text
|
|
758
903
|
const textRepair = repairAcrossSurfaces(
|
|
759
904
|
palette.base05,
|
|
760
905
|
surfaces,
|
|
761
|
-
|
|
906
|
+
textFloor,
|
|
762
907
|
REPAIR_BUDGET.text
|
|
763
908
|
)
|
|
764
|
-
const text = emit(source.id, 'text', textRepair,
|
|
909
|
+
const text = emit(source.id, 'text', textRepair, textFloor)
|
|
765
910
|
colors.text = text.value
|
|
766
911
|
colors.foreground = text.value
|
|
767
912
|
if (text.finding) findings.push(text.finding)
|
|
@@ -770,7 +915,7 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
|
|
|
770
915
|
// OKLCH rather than alpha-compositing keeps the result a real colour that CSS can
|
|
771
916
|
// use without a backdrop.
|
|
772
917
|
const mutedRepair = repairAcrossSurfaces(
|
|
773
|
-
mix(
|
|
918
|
+
mix(textRepair.color, background, 0.32),
|
|
774
919
|
surfaces,
|
|
775
920
|
CONTRAST_FLOORS.textMuted,
|
|
776
921
|
0.3
|
|
@@ -779,15 +924,50 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
|
|
|
779
924
|
colors.textMuted = muted.value
|
|
780
925
|
if (muted.finding) findings.push(muted.finding)
|
|
781
926
|
|
|
782
|
-
|
|
927
|
+
/*
|
|
928
|
+
* The ladder is derived in order — body, secondary, tertiary — and each rung is
|
|
929
|
+
* held to being dimmer than the one above it.
|
|
930
|
+
*
|
|
931
|
+
* Sourcing the tertiary rung from `base03` is faithful to Base24, which defines that
|
|
932
|
+
* slot as comments and invisibles, and for most palettes it is exactly right: the
|
|
933
|
+
* value the author chose for dim text. But a few publish a `base03` that is not a dim
|
|
934
|
+
* grey at all — Solarized's light scheme carries its near-black `base03`, Nord Light's
|
|
935
|
+
* is a mid slate — and taking it on trust produced a *tertiary* role that was the
|
|
936
|
+
* most prominent text on the screen, at 13.9:1 against a canvas whose body text
|
|
937
|
+
* measures 5.5:1. So the slot is used when it cooperates and a blend replaces it when
|
|
938
|
+
* it does not, and either way the rung is reported.
|
|
939
|
+
*/
|
|
940
|
+
const mutedContrast = contrastRatio(mutedRepair.color, background)
|
|
941
|
+
const fromPalette = repairAcrossSurfaces(
|
|
783
942
|
palette.base03,
|
|
784
943
|
surfaces,
|
|
785
944
|
CONTRAST_FLOORS.textSubtle,
|
|
786
945
|
0.3
|
|
787
946
|
)
|
|
947
|
+
const paletteSubtleContrast = contrastRatio(fromPalette.color, background)
|
|
948
|
+
|
|
949
|
+
const subtleFromPalette = paletteSubtleContrast < mutedContrast
|
|
950
|
+
const subtleRepair =
|
|
951
|
+
subtleFromPalette
|
|
952
|
+
? fromPalette
|
|
953
|
+
: repairAcrossSurfaces(
|
|
954
|
+
mix(textRepair.color, background, 0.55),
|
|
955
|
+
surfaces,
|
|
956
|
+
CONTRAST_FLOORS.textSubtle,
|
|
957
|
+
0.3
|
|
958
|
+
)
|
|
959
|
+
|
|
788
960
|
const subtle = emit(source.id, 'textSubtle', subtleRepair, CONTRAST_FLOORS.textSubtle)
|
|
789
961
|
colors.textSubtle = subtle.value
|
|
790
962
|
if (subtle.finding) findings.push(subtle.finding)
|
|
963
|
+
if (!subtleFromPalette) {
|
|
964
|
+
findings.push({
|
|
965
|
+
themeId: source.id,
|
|
966
|
+
role: 'textSubtle',
|
|
967
|
+
kind: 'substituted',
|
|
968
|
+
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`,
|
|
969
|
+
})
|
|
970
|
+
}
|
|
791
971
|
|
|
792
972
|
/**
|
|
793
973
|
* The status roles, each with fallback candidates.
|
|
@@ -803,6 +983,39 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
|
|
|
803
983
|
* The order is not "brightest first" — on a light canvas the bright variants are
|
|
804
984
|
* worse, so the declared ANSI colour is always tried before its bright sibling.
|
|
805
985
|
*/
|
|
986
|
+
/*
|
|
987
|
+
* Body text gets enough headroom above the secondary rung to stay the stronger of the
|
|
988
|
+
* two.
|
|
989
|
+
*
|
|
990
|
+
* The secondary rung is repaired against the *raised* surfaces, which is the binding
|
|
991
|
+
* constraint, so on a palette whose foreground is itself only a little above the floor
|
|
992
|
+
* the repair can push secondary text past the body text — Tokyo Night Day came out
|
|
993
|
+
* with 5.6:1 for a caption and 5.5:1 for the paragraph, which collapses the two roles
|
|
994
|
+
* into one. The fix is to give the body text room rather than to weaken the secondary
|
|
995
|
+
* floor, and it is a small move: the foreground is already above its own floor, so this
|
|
996
|
+
* only ever fires on a palette that was already tight.
|
|
997
|
+
*/
|
|
998
|
+
const bodyContrast = contrastRatio(textRepair.color, background)
|
|
999
|
+
const mutedAgainstCanvas = contrastRatio(mutedRepair.color, background)
|
|
1000
|
+
if (mutedAgainstCanvas + MINIMUM_LADDER_GAP > bodyContrast) {
|
|
1001
|
+
const lifted = repairAcrossSurfaces(
|
|
1002
|
+
textRepair.color,
|
|
1003
|
+
surfaces,
|
|
1004
|
+
mutedAgainstCanvas + MINIMUM_LADDER_GAP,
|
|
1005
|
+
REPAIR_BUDGET.text
|
|
1006
|
+
)
|
|
1007
|
+
if (lifted.satisfied) {
|
|
1008
|
+
colors.text = formatOklch(lifted.color)
|
|
1009
|
+
colors.foreground = colors.text
|
|
1010
|
+
findings.push({
|
|
1011
|
+
themeId: source.id,
|
|
1012
|
+
role: 'text',
|
|
1013
|
+
kind: 'repaired',
|
|
1014
|
+
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)`,
|
|
1015
|
+
})
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
806
1019
|
const statusCandidates = {
|
|
807
1020
|
error: [palette.base08, palette.base12],
|
|
808
1021
|
success: [palette.base0B, palette.base14],
|
|
@@ -913,13 +1126,25 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
|
|
|
913
1126
|
colors.accentForeground = formatOklch(accentForeground.value)
|
|
914
1127
|
findings.push(...accentForeground.findings)
|
|
915
1128
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1129
|
+
/*
|
|
1130
|
+
* The selection is the palette's own where the source declares one, and the floor
|
|
1131
|
+
* applies to it either way.
|
|
1132
|
+
*
|
|
1133
|
+
* A composition can carry its structure donor's selection explicitly, and taking that
|
|
1134
|
+
* on trust is how Adea Light shipped a selection measuring 1.11:1 against its own
|
|
1135
|
+
* canvas — Nord's is deliberately subtle, and subtle is not the same as invisible. An
|
|
1136
|
+
* invisible selection is a functional defect rather than a matter of taste: the user
|
|
1137
|
+
* cannot see what they have selected. So the declared value is used as given and still
|
|
1138
|
+
* measured, and moving it is reported.
|
|
1139
|
+
*
|
|
1140
|
+
* The cursor is deliberately *not* treated this way. It has no floor — a cursor is an
|
|
1141
|
+
* affordance rather than text, and several palettes in the catalogue give it a colour
|
|
1142
|
+
* of its own — so the adapters derive a legible glyph for it instead
|
|
1143
|
+
* (see `adapters/xterm.ts`).
|
|
1144
|
+
*/
|
|
1145
|
+
const declaredSelection = source.selection ? parseColor(source.selection) : undefined
|
|
921
1146
|
const selectionRepair = repairContrast(
|
|
922
|
-
|
|
1147
|
+
declaredSelection ?? (palette.base02 as Oklch),
|
|
923
1148
|
background,
|
|
924
1149
|
CONTRAST_FLOORS.selection,
|
|
925
1150
|
0.5
|
|
@@ -952,9 +1177,9 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
|
|
|
952
1177
|
cursor: source.cursor
|
|
953
1178
|
? authoredColor(source.id, 'cursor', source.cursor)
|
|
954
1179
|
: formatOklch(palette.base05),
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1180
|
+
// The repaired value, not the declared one: `selectionRepair` above started from
|
|
1181
|
+
// whatever the source declared.
|
|
1182
|
+
selection: selection.value,
|
|
958
1183
|
family: source.family,
|
|
959
1184
|
familyLabel: source.familyLabel,
|
|
960
1185
|
label: source.label,
|
package/src/oklch.ts
CHANGED
|
@@ -199,20 +199,6 @@ export function formatOklch(color: Oklch): string {
|
|
|
199
199
|
return `oklch(${l} ${c} ${h})`
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
/**
|
|
203
|
-
* The colour as it will actually be written.
|
|
204
|
-
*
|
|
205
|
-
* Repairs must be measured against this rather than against their own working
|
|
206
|
-
* value. {@link formatOklch} rounds lightness to four decimals, and rounding can
|
|
207
|
-
* move a pairing from 4.5001:1 to 4.4998:1 — so a repair that converges on the
|
|
208
|
-
* exact floor and is then rounded ships a value that fails the floor it was
|
|
209
|
-
* computed to satisfy. Measuring the canonical form closes that gap by making the
|
|
210
|
-
* thing measured and the thing committed the same thing.
|
|
211
|
-
*/
|
|
212
|
-
export function canonical(color: Oklch): Oklch {
|
|
213
|
-
return parseColor(formatOklch(color)) ?? color
|
|
214
|
-
}
|
|
215
|
-
|
|
216
202
|
/**
|
|
217
203
|
* Relative luminance of an sRGB colour, per WCAG 2.1.
|
|
218
204
|
*
|
|
@@ -244,6 +230,20 @@ export function contrastDirection(foreground: Oklch, background: Oklch): 1 | -1
|
|
|
244
230
|
return foreground.l >= background.l ? 1 : -1
|
|
245
231
|
}
|
|
246
232
|
|
|
233
|
+
/**
|
|
234
|
+
* The colour as it will actually be written.
|
|
235
|
+
*
|
|
236
|
+
* Repairs must be measured against this rather than against their own working
|
|
237
|
+
* value. {@link formatOklch} rounds lightness to four decimals, and rounding can
|
|
238
|
+
* move a pairing from 4.5001:1 to 4.4998:1 — so a repair that converges on the
|
|
239
|
+
* exact floor and is then rounded ships a value that fails the floor it was
|
|
240
|
+
* computed to satisfy. Measuring the canonical form closes that gap by making the
|
|
241
|
+
* thing measured and the thing committed the same thing.
|
|
242
|
+
*/
|
|
243
|
+
export function canonical(color: Oklch): Oklch {
|
|
244
|
+
return parseColor(formatOklch(color)) ?? color
|
|
245
|
+
}
|
|
246
|
+
|
|
247
247
|
export type ContrastRepair = {
|
|
248
248
|
color: Oklch
|
|
249
249
|
ratio: number
|
|
@@ -274,7 +274,12 @@ export function repairContrast(
|
|
|
274
274
|
minimum: number,
|
|
275
275
|
budget = 0.22
|
|
276
276
|
): ContrastRepair {
|
|
277
|
-
|
|
277
|
+
// Measured in the canonical form, for the reason {@link canonical} gives: the caller
|
|
278
|
+
// commits `formatOklch`'s rounded output, and a repair that converges on the exact
|
|
279
|
+
// floor of an unrounded value ships a value that fails it. The lower a floor is, the
|
|
280
|
+
// more this matters, and the scale starts at 1.2:1 — so a selection colour's whole
|
|
281
|
+
// range is a few rounding steps wide.
|
|
282
|
+
const initial = contrastRatio(canonical(foreground), background)
|
|
278
283
|
if (initial >= minimum) {
|
|
279
284
|
return { color: foreground, ratio: initial, delta: 0, satisfied: true }
|
|
280
285
|
}
|
|
@@ -292,10 +297,10 @@ export function repairContrast(
|
|
|
292
297
|
for (const direction of [preferred, -preferred] as const) {
|
|
293
298
|
const candidate: Oklch = { ...foreground, l: foreground.l + direction * step * index }
|
|
294
299
|
if (candidate.l <= 0 || candidate.l >= 1) continue
|
|
295
|
-
const ratio = contrastRatio(candidate, background)
|
|
300
|
+
const ratio = contrastRatio(canonical(candidate), background)
|
|
296
301
|
if (ratio >= minimum) {
|
|
297
302
|
return {
|
|
298
|
-
color: candidate,
|
|
303
|
+
color: canonical(candidate),
|
|
299
304
|
ratio,
|
|
300
305
|
delta: Number((step * index).toFixed(4)),
|
|
301
306
|
satisfied: true,
|