@adea-ai/themes 0.1.2 → 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.
package/src/normalize.ts CHANGED
@@ -51,6 +51,7 @@ import type {
51
51
  AdeaThemeRecord,
52
52
  AnsiKey,
53
53
  ThemeAppearance,
54
+ ThemeProvenance,
54
55
  } from './schema'
55
56
  import type { Base24Scheme, Base24Slot } from './adapters/base24'
56
57
  import { parseBase24Palette } from './adapters/base24'
@@ -149,6 +150,15 @@ const REPAIR_BUDGET = Object.freeze({
149
150
  /** The accent preference order, best first. */
150
151
  const ACCENT_PREFERENCE = ['blue', 'magenta', 'cyan', 'green'] as const satisfies readonly AnsiKey[]
151
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
+
152
162
  /** The smallest chroma a colour needs before it reads as "a colour" rather than grey. */
153
163
  const MINIMUM_ACCENT_CHROMA = 0.035
154
164
 
@@ -175,6 +185,14 @@ export interface ThemeSourceSpec {
175
185
  * on the entry that makes it.
176
186
  */
177
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 }
178
196
  /** Which ANSI role supplies the accent, when the family's identity demands one. */
179
197
  accentSlot?: AnsiKey
180
198
  /** Explicit roles for authored themes, applied after derivation. */
@@ -182,6 +200,13 @@ export interface ThemeSourceSpec {
182
200
  ansi?: Partial<AdeaAnsi>
183
201
  cursor?: string
184
202
  selection?: string
203
+ /**
204
+ * A provenance that overrides the family's.
205
+ *
206
+ * For a theme that is not simply a member of a family — Adea's composed dark theme
207
+ * belongs to the `adea` family but has two upstream parents to name.
208
+ */
209
+ provenance?: ThemeProvenance
185
210
  }
186
211
 
187
212
  /** Something the normalizer did that a reviewer should know about. */
@@ -658,6 +683,108 @@ function repairRole(
658
683
  return { repair: lightness, blended: false }
659
684
  }
660
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
+
661
788
  /** The catalogued result of normalizing one source. */
662
789
  export interface NormalizedTheme {
663
790
  record: AdeaThemeRecord
@@ -691,6 +818,16 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
691
818
  })
692
819
  }
693
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
+
694
831
  const background = palette.base00
695
832
 
696
833
  const appearance: ThemeAppearance = ladderDirection(background) === 1 ? 'dark' : 'light'
@@ -762,7 +899,7 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
762
899
  // OKLCH rather than alpha-compositing keeps the result a real colour that CSS can
763
900
  // use without a backdrop.
764
901
  const mutedRepair = repairAcrossSurfaces(
765
- mix(palette.base05, background, 0.32),
902
+ mix(textRepair.color, background, 0.32),
766
903
  surfaces,
767
904
  CONTRAST_FLOORS.textMuted,
768
905
  0.3
@@ -771,15 +908,50 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
771
908
  colors.textMuted = muted.value
772
909
  if (muted.finding) findings.push(muted.finding)
773
910
 
774
- 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(
775
926
  palette.base03,
776
927
  surfaces,
777
928
  CONTRAST_FLOORS.textSubtle,
778
929
  0.3
779
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
+
780
944
  const subtle = emit(source.id, 'textSubtle', subtleRepair, CONTRAST_FLOORS.textSubtle)
781
945
  colors.textSubtle = subtle.value
782
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
+ }
783
955
 
784
956
  /**
785
957
  * The status roles, each with fallback candidates.
@@ -795,6 +967,39 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
795
967
  * The order is not "brightest first" — on a light canvas the bright variants are
796
968
  * worse, so the declared ANSI colour is always tried before its bright sibling.
797
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
+
798
1003
  const statusCandidates = {
799
1004
  error: [palette.base08, palette.base12],
800
1005
  success: [palette.base0B, palette.base14],
@@ -905,13 +1110,25 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
905
1110
  colors.accentForeground = formatOklch(accentForeground.value)
906
1111
  findings.push(...accentForeground.findings)
907
1112
 
908
- // The selection is the palette's own, repaired only if it is invisible against
909
- // the canvas. Adapters handle making text legible on top of it.
910
- const selectionSource = source.selection
911
- ? (palette.base02 as Oklch)
912
- : (palette.base02 as Oklch)
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
913
1130
  const selectionRepair = repairContrast(
914
- selectionSource,
1131
+ declaredSelection ?? (palette.base02 as Oklch),
915
1132
  background,
916
1133
  CONTRAST_FLOORS.selection,
917
1134
  0.5
@@ -944,18 +1161,14 @@ export function normalizeTheme(source: ThemeSourceSpec): NormalizedTheme {
944
1161
  cursor: source.cursor
945
1162
  ? authoredColor(source.id, 'cursor', source.cursor)
946
1163
  : formatOklch(palette.base05),
947
- selection: source.selection
948
- ? authoredColor(source.id, 'selection', source.selection)
949
- : selection.value,
1164
+ // The repaired value, not the declared one: `selectionRepair` above started from
1165
+ // whatever the source declared.
1166
+ selection: selection.value,
950
1167
  family: source.family,
951
1168
  familyLabel: source.familyLabel,
952
1169
  label: source.label,
953
1170
  description: source.description,
954
- provenance: {
955
- project: '',
956
- url: '',
957
- license: '',
958
- },
1171
+ provenance: source.provenance ?? { project: '', url: '', license: '' },
959
1172
  tags: source.tags,
960
1173
  },
961
1174
  findings,
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,