@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.
@@ -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(1 0 0)",
81
- "foreground": "oklch(0.2645 0 0)",
82
- "surface": "oklch(1 0 0)",
83
- "surfaceElevated": "oklch(1 0 0)",
84
- "surfaceHover": "oklch(0.9761 0 0)",
85
- "surfaceActive": "oklch(0.9551 0 0)",
86
- "border": "oklch(0.9401 0 0)",
87
- "borderMuted": "oklch(0.9612 0 0)",
88
- "text": "oklch(0.2645 0 0)",
89
- "textMuted": "oklch(0.5417 0 0)",
90
- "textSubtle": "oklch(0.5658 0 0)",
91
- "accent": "oklch(0.325 0 0)",
92
- "accentForeground": "oklch(0.9911 0 0)",
93
- "success": "oklch(0.5244 0.1401 148.04)",
94
- "warning": "oklch(0.5538 0.1207 66.44)",
95
- "error": "oklch(0.5551 0.1765 30.46)",
96
- "info": "oklch(0.5198 0.0936 223.13)"
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.2373 0.0113 254.06)",
100
- "red": "oklch(0.5054 0.1905 27.52)",
101
- "green": "oklch(0.4606 0.1227 148.77)",
102
- "yellow": "oklch(0.5091 0.0985 68.76)",
103
- "blue": "oklch(0.4949 0.1992 260.6)",
104
- "magenta": "oklch(0.4773 0.185 347.85)",
105
- "cyan": "oklch(0.5198 0.0936 223.13)",
106
- "white": "oklch(0.4849 0.0196 251.02)",
107
- "brightBlack": "oklch(0.4849 0.0196 251.02)",
108
- "brightRed": "oklch(0.5864 0.1586 23.34)",
109
- "brightGreen": "oklch(0.6127 0.1559 150.57)",
110
- "brightYellow": "oklch(0.6026 0.1091 71.79)",
111
- "brightBlue": "oklch(0.6231 0.188 259.81)",
112
- "brightMagenta": "oklch(0.5889 0.1701 345.97)",
113
- "brightCyan": "oklch(0.6089 0.1109 221.72)",
114
- "brightWhite": "oklch(0.2785 0.0132 253.04)"
115
- },
116
- "cursor": "oklch(0.2785 0.0132 253.04)",
117
- "selection": "oklch(0.8363 0.081 271.45)",
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. A neutral ladder with a monochrome accent.",
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
- "neutral",
130
- "default"
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.7447 0.0252 133.52)",
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.4754 0.0212 232.87)",
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.4754 0.0212 232.87)",
694
- "textMuted": "oklch(0.4743 0.0242 189.99)",
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.7017 0.0197 205.26)",
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.7017 0.0197 205.26)",
1306
- "textMuted": "oklch(0.7001 0.029 209.92)",
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.5002 0.0285 221.9)",
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.5002 0.0285 221.9)",
1367
- "textMuted": "oklch(0.4999 0.0277 179.72)",
1368
- "textSubtle": "oklch(0.2673 0.0486 219.82)",
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.4661 0.1563 264.1)",
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.4661 0.1563 264.1)",
1428
- "textMuted": "oklch(0.4626 0.1085 268.28)",
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
- CONTRAST_FLOORS.text,
906
+ textFloor,
762
907
  REPAIR_BUDGET.text
763
908
  )
764
- const text = emit(source.id, 'text', textRepair, CONTRAST_FLOORS.text)
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(palette.base05, background, 0.32),
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
- const subtleRepair = repairAcrossSurfaces(
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
- // The selection is the palette's own, repaired only if it is invisible against
917
- // the canvas. Adapters handle making text legible on top of it.
918
- const selectionSource = source.selection
919
- ? (palette.base02 as Oklch)
920
- : (palette.base02 as Oklch)
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
- selectionSource,
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
- selection: source.selection
956
- ? authoredColor(source.id, 'selection', source.selection)
957
- : selection.value,
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
- const initial = contrastRatio(foreground, background)
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,