@adea-ai/themes 0.2.0 → 0.3.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.
@@ -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.4017 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.4017 0.0285 266.52)",
89
+ "textMuted": "oklch(0.4737 0.0245 265.01)",
90
+ "textSubtle": "oklch(0.5679 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,14 @@ 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 }
179
196
  /** Which ANSI role supplies the accent, when the family's identity demands one. */
180
197
  accentSlot?: AnsiKey
181
198
  /** Explicit roles for authored themes, applied after derivation. */
@@ -666,6 +683,108 @@ function repairRole(
666
683
  return { repair: lightness, blended: false }
667
684
  }
668
685
 
686
+
687
+ /**
688
+ * The Base24 slots that carry a hue rather than a grey.
689
+ *
690
+ * `base09` and `base0F` are included even though neither donor publishes them: they
691
+ * are synthesised from red and yellow, and leaving them behind would put an
692
+ * untransposed orange next to a transposed red.
693
+ */
694
+ const CHROMATIC_SLOTS = [
695
+ 'base08',
696
+ 'base09',
697
+ 'base0A',
698
+ 'base0B',
699
+ 'base0C',
700
+ 'base0D',
701
+ 'base0E',
702
+ 'base0F',
703
+ 'base12',
704
+ 'base13',
705
+ 'base14',
706
+ 'base15',
707
+ 'base16',
708
+ 'base17',
709
+ ] as const satisfies readonly Base24Slot[]
710
+
711
+ /** The six slots whose bright siblings make a normal/bright pair. */
712
+ const NORMAL_HUE_SLOTS = ['base08', 'base0A', 'base0B', 'base0C', 'base0D', 'base0E'] as const
713
+
714
+ /**
715
+ * Moves every hue in a palette by one common lightness step, so a hue set authored
716
+ * against one canvas becomes legible on another.
717
+ *
718
+ * This exists because a composition can put a palette's hues on somebody else's
719
+ * background. GitHub's dark hues measure between 2.5:1 and 4.1:1 on a light canvas —
720
+ * fine as accents, unusable as terminal text — and repairing each hue *independently*
721
+ * to the floor destroys the thing that makes an ANSI palette an ANSI palette: the
722
+ * bright variants would converge onto their normal siblings, because a bright variant
723
+ * differs from its normal one mainly in lightness and the repair would land them all
724
+ * on the same lightness.
725
+ *
726
+ * Moving the group by one step instead transposes the whole set: every hue keeps its
727
+ * angle and chroma exactly, every relative lightness is preserved, and the palette
728
+ * stays internally consistent. The step is the smallest one that brings every *normal*
729
+ * hue to the floor, measured in the canonical form the catalogue commits so that
730
+ * rounding cannot push one back under.
731
+ */
732
+ function transposeHues(
733
+ themeId: string,
734
+ palette: Record<Base24Slot, Oklch>,
735
+ floor: number,
736
+ findings: NormalizationFinding[]
737
+ ): Partial<Record<Base24Slot, Oklch>> {
738
+ const background = palette.base00
739
+ const worst = (step: number): number =>
740
+ Math.min(
741
+ ...NORMAL_HUE_SLOTS.map((slot) =>
742
+ contrastRatio(canonical(shiftLightness(palette[slot], step)), background)
743
+ )
744
+ )
745
+
746
+ const initial = worst(0)
747
+ if (initial >= floor) return {}
748
+
749
+ // Both directions are tried, nearest first, as in `repairContrast`: a dark canvas
750
+ // needs its hues lightened and a light canvas needs them darkened, and a palette
751
+ // moved to the other kind of canvas is the whole point of the operation.
752
+ const step = 0.002
753
+ let chosen: number | undefined
754
+ for (let index = 1; index <= Math.floor(0.45 / step) && chosen === undefined; index += 1) {
755
+ for (const direction of [1, -1] as const) {
756
+ const candidate = step * index * direction
757
+ const probe = shiftLightness(palette[NORMAL_HUE_SLOTS[0]], candidate)
758
+ if (probe.l <= 0.02 || probe.l >= 0.98) continue
759
+ if (worst(candidate) >= floor) {
760
+ chosen = candidate
761
+ break
762
+ }
763
+ }
764
+ }
765
+
766
+ if (chosen === undefined) {
767
+ findings.push({
768
+ themeId,
769
+ role: 'hues',
770
+ kind: 'budget-exceeded',
771
+ message: `no single lightness step brings every hue to ${floor}:1 against the canvas`,
772
+ })
773
+ return {}
774
+ }
775
+
776
+ findings.push({
777
+ themeId,
778
+ role: 'hues',
779
+ kind: 'repaired',
780
+ 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`,
781
+ })
782
+
783
+ return Object.fromEntries(
784
+ CHROMATIC_SLOTS.map((slot) => [slot, shiftLightness(palette[slot], chosen as number)])
785
+ ) as Partial<Record<Base24Slot, Oklch>>
786
+ }
787
+
669
788
  /** The catalogued result of normalizing one source. */
670
789
  export interface NormalizedTheme {
671
790
  record: AdeaThemeRecord
@@ -699,6 +818,16 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
699
818
  })
700
819
  }
701
820
 
821
+ // The hue transpose, applied at the same stage as the corrections and for the same
822
+ // reason: the semantic roles derive from these slots, so transposing afterwards
823
+ // would leave `error` built from the untransposed red and the two disagreeing.
824
+ if (source.hueTranspose) {
825
+ const transposed = transposeHues(source.id, palette, source.hueTranspose.floor, findings)
826
+ for (const [slot, value] of Object.entries(transposed)) {
827
+ palette[slot as Base24Slot] = value
828
+ }
829
+ }
830
+
702
831
  const background = palette.base00
703
832
 
704
833
  const appearance: ThemeAppearance = ladderDirection(background) === 1 ? 'dark' : 'light'
@@ -770,7 +899,7 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
770
899
  // OKLCH rather than alpha-compositing keeps the result a real colour that CSS can
771
900
  // use without a backdrop.
772
901
  const mutedRepair = repairAcrossSurfaces(
773
- mix(palette.base05, background, 0.32),
902
+ mix(textRepair.color, background, 0.32),
774
903
  surfaces,
775
904
  CONTRAST_FLOORS.textMuted,
776
905
  0.3
@@ -779,15 +908,50 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
779
908
  colors.textMuted = muted.value
780
909
  if (muted.finding) findings.push(muted.finding)
781
910
 
782
- const subtleRepair = repairAcrossSurfaces(
911
+ /*
912
+ * The ladder is derived in order — body, secondary, tertiary — and each rung is
913
+ * held to being dimmer than the one above it.
914
+ *
915
+ * Sourcing the tertiary rung from `base03` is faithful to Base24, which defines that
916
+ * slot as comments and invisibles, and for most palettes it is exactly right: the
917
+ * value the author chose for dim text. But a few publish a `base03` that is not a dim
918
+ * grey at all — Solarized's light scheme carries its near-black `base03`, Nord Light's
919
+ * is a mid slate — and taking it on trust produced a *tertiary* role that was the
920
+ * most prominent text on the screen, at 13.9:1 against a canvas whose body text
921
+ * measures 5.5:1. So the slot is used when it cooperates and a blend replaces it when
922
+ * it does not, and either way the rung is reported.
923
+ */
924
+ const mutedContrast = contrastRatio(mutedRepair.color, background)
925
+ const fromPalette = repairAcrossSurfaces(
783
926
  palette.base03,
784
927
  surfaces,
785
928
  CONTRAST_FLOORS.textSubtle,
786
929
  0.3
787
930
  )
931
+ const paletteSubtleContrast = contrastRatio(fromPalette.color, background)
932
+
933
+ const subtleFromPalette = paletteSubtleContrast < mutedContrast
934
+ const subtleRepair =
935
+ subtleFromPalette
936
+ ? fromPalette
937
+ : repairAcrossSurfaces(
938
+ mix(textRepair.color, background, 0.55),
939
+ surfaces,
940
+ CONTRAST_FLOORS.textSubtle,
941
+ 0.3
942
+ )
943
+
788
944
  const subtle = emit(source.id, 'textSubtle', subtleRepair, CONTRAST_FLOORS.textSubtle)
789
945
  colors.textSubtle = subtle.value
790
946
  if (subtle.finding) findings.push(subtle.finding)
947
+ if (!subtleFromPalette) {
948
+ findings.push({
949
+ themeId: source.id,
950
+ role: 'textSubtle',
951
+ kind: 'substituted',
952
+ 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`,
953
+ })
954
+ }
791
955
 
792
956
  /**
793
957
  * The status roles, each with fallback candidates.
@@ -803,6 +967,39 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
803
967
  * The order is not "brightest first" — on a light canvas the bright variants are
804
968
  * worse, so the declared ANSI colour is always tried before its bright sibling.
805
969
  */
970
+ /*
971
+ * Body text gets enough headroom above the secondary rung to stay the stronger of the
972
+ * two.
973
+ *
974
+ * The secondary rung is repaired against the *raised* surfaces, which is the binding
975
+ * constraint, so on a palette whose foreground is itself only a little above the floor
976
+ * the repair can push secondary text past the body text — Tokyo Night Day came out
977
+ * with 5.6:1 for a caption and 5.5:1 for the paragraph, which collapses the two roles
978
+ * into one. The fix is to give the body text room rather than to weaken the secondary
979
+ * floor, and it is a small move: the foreground is already above its own floor, so this
980
+ * only ever fires on a palette that was already tight.
981
+ */
982
+ const bodyContrast = contrastRatio(textRepair.color, background)
983
+ const mutedAgainstCanvas = contrastRatio(mutedRepair.color, background)
984
+ if (mutedAgainstCanvas + MINIMUM_LADDER_GAP > bodyContrast) {
985
+ const lifted = repairAcrossSurfaces(
986
+ textRepair.color,
987
+ surfaces,
988
+ mutedAgainstCanvas + MINIMUM_LADDER_GAP,
989
+ REPAIR_BUDGET.text
990
+ )
991
+ if (lifted.satisfied) {
992
+ colors.text = formatOklch(lifted.color)
993
+ colors.foreground = colors.text
994
+ findings.push({
995
+ themeId: source.id,
996
+ role: 'text',
997
+ kind: 'repaired',
998
+ 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)`,
999
+ })
1000
+ }
1001
+ }
1002
+
806
1003
  const statusCandidates = {
807
1004
  error: [palette.base08, palette.base12],
808
1005
  success: [palette.base0B, palette.base14],
@@ -913,13 +1110,25 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
913
1110
  colors.accentForeground = formatOklch(accentForeground.value)
914
1111
  findings.push(...accentForeground.findings)
915
1112
 
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)
1113
+ /*
1114
+ * The selection is the palette's own where the source declares one, and the floor
1115
+ * applies to it either way.
1116
+ *
1117
+ * A composition can carry its structure donor's selection explicitly, and taking that
1118
+ * on trust is how Adea Light shipped a selection measuring 1.11:1 against its own
1119
+ * canvas — Nord's is deliberately subtle, and subtle is not the same as invisible. An
1120
+ * invisible selection is a functional defect rather than a matter of taste: the user
1121
+ * cannot see what they have selected. So the declared value is used as given and still
1122
+ * measured, and moving it is reported.
1123
+ *
1124
+ * The cursor is deliberately *not* treated this way. It has no floor — a cursor is an
1125
+ * affordance rather than text, and several palettes in the catalogue give it a colour
1126
+ * of its own — so the adapters derive a legible glyph for it instead
1127
+ * (see `adapters/xterm.ts`).
1128
+ */
1129
+ const declaredSelection = source.selection ? parseColor(source.selection) : undefined
921
1130
  const selectionRepair = repairContrast(
922
- selectionSource,
1131
+ declaredSelection ?? (palette.base02 as Oklch),
923
1132
  background,
924
1133
  CONTRAST_FLOORS.selection,
925
1134
  0.5
@@ -952,9 +1161,9 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
952
1161
  cursor: source.cursor
953
1162
  ? authoredColor(source.id, 'cursor', source.cursor)
954
1163
  : formatOklch(palette.base05),
955
- selection: source.selection
956
- ? authoredColor(source.id, 'selection', source.selection)
957
- : selection.value,
1164
+ // The repaired value, not the declared one: `selectionRepair` above started from
1165
+ // whatever the source declared.
1166
+ selection: selection.value,
958
1167
  family: source.family,
959
1168
  familyLabel: source.familyLabel,
960
1169
  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,