@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/NOTICE +40 -1
- package/README.md +34 -2
- package/dist/generated/schemes.js +49 -49
- package/dist/generated/schemes.js.map +1 -1
- package/dist/generated/themes.d.ts.map +1 -1
- package/dist/generated/themes.js +99 -91
- package/dist/generated/themes.js.map +1 -1
- package/dist/normalize.d.ts +18 -1
- package/dist/normalize.d.ts.map +1 -1
- package/dist/normalize.js +178 -16
- 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 +126 -3
- package/dist/sources.d.ts.map +1 -1
- package/dist/sources.js +165 -105
- package/dist/sources.js.map +1 -1
- package/package.json +1 -1
- package/palettes/adea-dark.json +56 -0
- package/palettes/adea-light.json +56 -0
- package/src/generated/schemes.ts +49 -49
- package/src/generated/themes.ts +99 -91
- package/src/normalize.ts +229 -16
- package/src/oklch.ts +22 -17
- package/src/sources.ts +265 -108
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(
|
|
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
|
-
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
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,
|