@motion-proto/live-tokens 0.40.0 → 0.41.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/CHANGELOG.md +115 -0
- package/dist-plugin/{chunk-D77VD4Z6.js → chunk-H4TRUINI.js} +102 -13
- package/dist-plugin/index.cjs +288 -67
- package/dist-plugin/index.js +171 -55
- package/dist-plugin/tokensCssMigrations/index.cjs +102 -13
- package/dist-plugin/tokensCssMigrations/index.js +1 -1
- package/package.json +3 -2
- package/src/app/site.css +97 -20
- package/src/editor/core/palettes/colorHarmony.ts +228 -0
- package/src/editor/core/palettes/contrast.ts +128 -0
- package/src/editor/core/palettes/oklch.ts +8 -0
- package/src/editor/core/palettes/paletteDerivation.ts +267 -72
- package/src/editor/core/palettes/recommendText.ts +216 -0
- package/src/editor/core/palettes/solveTextContrast.ts +251 -0
- package/src/editor/core/store/editorCore.ts +2 -1
- package/src/editor/core/store/editorPersistence.ts +18 -1
- package/src/editor/core/store/editorStore.ts +20 -1
- package/src/editor/core/store/editorTypes.ts +4 -0
- package/src/editor/core/store/editorViewStore.ts +7 -3
- package/src/editor/core/themes/migrations/2026-06-05-palette-unification.ts +11 -9
- package/src/editor/core/themes/migrations/2026-07-21-palette-oklch-basis.ts +51 -0
- package/src/editor/core/themes/migrations/2026-07-24-base-anchor-placement.ts +37 -0
- package/src/editor/core/themes/migrations/2026-07-25-background-spot-to-base.ts +40 -0
- package/src/editor/core/themes/themeTypes.ts +23 -3
- package/src/editor/docs/CodeBlock.svelte +1 -1
- package/src/editor/docs/Docs.svelte +30 -13
- package/src/editor/pages/EditorShell.svelte +31 -3
- package/src/editor/ui/ColorEditPanel.svelte +22 -16
- package/src/editor/ui/EditorViewSwitcher.svelte +43 -9
- package/src/editor/ui/FontStackEditor.svelte +0 -1
- package/src/editor/ui/PaletteEditor.svelte +117 -155
- package/src/editor/ui/ProjectFontsSection.svelte +0 -3
- package/src/editor/ui/TextStylesSection.svelte +173 -0
- package/src/editor/ui/UILineHeightSelector.svelte +6 -5
- package/src/editor/ui/UIPaletteSelector.svelte +6 -1
- package/src/editor/ui/UITextTransformSelector.svelte +106 -0
- package/src/editor/ui/VariablesTab.svelte +23 -10
- package/src/editor/ui/colors/ColorReadouts.svelte +123 -0
- package/src/editor/ui/colors/ColorStory.svelte +375 -0
- package/src/editor/ui/colors/ColorWheel.svelte +740 -0
- package/src/editor/ui/colors/ColorsTab.svelte +508 -0
- package/src/editor/ui/colors/HarmonyAxesList.svelte +320 -0
- package/src/editor/ui/colors/LightnessBar.svelte +261 -0
- package/src/editor/ui/colors/PaletteStepStrip.svelte +142 -0
- package/src/editor/ui/colors/colorWheelMath.ts +9 -0
- package/src/editor/ui/colors/harmony-icons/analogous.svg +13 -0
- package/src/editor/ui/colors/harmony-icons/complementary.svg +10 -0
- package/src/editor/ui/colors/harmony-icons/compound.svg +15 -0
- package/src/editor/ui/colors/harmony-icons/custom.svg +10 -0
- package/src/editor/ui/colors/harmony-icons/monochromatic.svg +11 -0
- package/src/editor/ui/colors/harmony-icons/shades.svg +9 -0
- package/src/editor/ui/colors/harmony-icons/split-complementary.svg +13 -0
- package/src/editor/ui/colors/harmony-icons/square.svg +13 -0
- package/src/editor/ui/colors/harmony-icons/tetradic.svg +15 -0
- package/src/editor/ui/colors/harmony-icons/triadic.svg +13 -0
- package/src/editor/ui/colors/harmonyModeIcons.ts +23 -0
- package/src/editor/ui/colors/paletteBaseColor.ts +214 -0
- package/src/editor/ui/index.ts +0 -1
- package/src/editor/ui/palette/OverridesPanel.svelte +6 -3
- package/src/editor/ui/palette/PaletteBase.svelte +16 -8
- package/src/editor/ui/palette/dockMagnify.ts +22 -0
- package/src/editor/ui/palette/paletteEditorState.ts +5 -4
- package/src/editor/ui/palette/paletteMath.ts +93 -181
- package/src/editor/ui/sections/textStyles.ts +82 -0
- package/src/editor/ui/sections/tokenScales.ts +3 -3
- package/src/live-tokens/data/themes/default.json +3 -3
- package/src/live-tokens/data/tokens.generated.css +96 -95
- package/src/system/components/Badge.svelte +11 -11
- package/src/system/components/Button.svelte +7 -7
- package/src/system/components/Callout.svelte +8 -8
- package/src/system/components/Card.svelte +2 -2
- package/src/system/components/CodeSnippet.svelte +1 -1
- package/src/system/components/CollapsibleSection.svelte +7 -7
- package/src/system/components/CornerBadge.svelte +1 -1
- package/src/system/components/Dialog.svelte +2 -2
- package/src/system/components/FloatingTokenTags.css +6 -5
- package/src/system/components/Input.svelte +6 -6
- package/src/system/components/MenuSelect.svelte +4 -4
- package/src/system/components/Notification.svelte +8 -8
- package/src/system/components/ProgressBar.svelte +2 -2
- package/src/system/components/RadioButton.svelte +4 -4
- package/src/system/components/SectionDivider.svelte +6 -6
- package/src/system/components/SegmentedControl.svelte +5 -5
- package/src/system/components/SideNavigation.svelte +12 -12
- package/src/system/components/TabBar.svelte +4 -4
- package/src/system/components/Table.svelte +2 -2
- package/src/system/components/Tooltip.svelte +1 -1
- package/src/system/styles/tokens.css +59 -6
- package/src/editor/ui/TextTab.svelte +0 -203
package/src/app/site.css
CHANGED
|
@@ -10,58 +10,67 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
h1 {
|
|
13
|
-
font-family: var(--font-
|
|
14
|
-
font-size: var(--font-size
|
|
15
|
-
font-weight: var(--font-weight
|
|
13
|
+
font-family: var(--heading-xl-font-family);
|
|
14
|
+
font-size: var(--heading-xl-font-size);
|
|
15
|
+
font-weight: var(--heading-xl-font-weight);
|
|
16
|
+
line-height: var(--heading-xl-line-height);
|
|
17
|
+
letter-spacing: var(--heading-xl-letter-spacing);
|
|
16
18
|
color: var(--text-primary);
|
|
17
19
|
margin: 0 0 var(--space-12);
|
|
18
|
-
line-height: var(--line-height-sm);
|
|
19
20
|
overflow-wrap: break-word;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
h2 {
|
|
23
|
-
font-family: var(--font-
|
|
24
|
-
font-size: var(--font-size
|
|
25
|
-
font-weight: var(--font-weight
|
|
24
|
+
font-family: var(--heading-lg-font-family);
|
|
25
|
+
font-size: var(--heading-lg-font-size);
|
|
26
|
+
font-weight: var(--heading-lg-font-weight);
|
|
27
|
+
line-height: var(--heading-lg-line-height);
|
|
28
|
+
letter-spacing: var(--heading-lg-letter-spacing);
|
|
26
29
|
color: var(--text-primary);
|
|
27
|
-
letter-spacing: var(--letter-spacing-normal);
|
|
28
30
|
margin: var(--space-32) 0 var(--space-12);
|
|
29
|
-
line-height: var(--line-height-sm);
|
|
30
31
|
overflow-wrap: break-word;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
h3 {
|
|
34
|
-
font-family: var(--font-
|
|
35
|
-
font-size: var(--font-size
|
|
36
|
-
font-weight: var(--font-weight
|
|
35
|
+
font-family: var(--heading-md-font-family);
|
|
36
|
+
font-size: var(--heading-md-font-size);
|
|
37
|
+
font-weight: var(--heading-md-font-weight);
|
|
38
|
+
line-height: var(--heading-md-line-height);
|
|
39
|
+
letter-spacing: var(--heading-md-letter-spacing);
|
|
37
40
|
color: var(--text-primary);
|
|
38
41
|
margin: var(--space-24) 0 var(--space-8);
|
|
39
|
-
line-height: var(--line-height-sm);
|
|
40
42
|
overflow-wrap: break-word;
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
h4 {
|
|
46
|
+
font-family: var(--heading-sm-font-family);
|
|
47
|
+
font-size: var(--heading-sm-font-size);
|
|
48
|
+
font-weight: var(--heading-sm-font-weight);
|
|
49
|
+
line-height: var(--heading-sm-line-height);
|
|
50
|
+
letter-spacing: var(--heading-sm-letter-spacing);
|
|
51
|
+
color: var(--text-primary);
|
|
52
|
+
margin: var(--space-16) 0 var(--space-4);
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
@media (max-width: 768px) {
|
|
44
56
|
h1 {
|
|
45
|
-
line-height: 1.1;
|
|
46
57
|
margin-bottom: var(--space-8);
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
h2 {
|
|
50
|
-
line-height: 1.15;
|
|
51
61
|
margin-top: var(--space-24);
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
h3 {
|
|
55
|
-
line-height: 1.2;
|
|
56
65
|
margin-top: var(--space-20);
|
|
57
66
|
}
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
p {
|
|
61
|
-
font-family: var(--font-
|
|
62
|
-
font-size: var(--font-size
|
|
70
|
+
font-family: var(--body-md-font-family);
|
|
71
|
+
font-size: var(--body-md-font-size);
|
|
63
72
|
color: var(--text-secondary);
|
|
64
|
-
line-height: var(--line-height
|
|
73
|
+
line-height: var(--body-md-line-height);
|
|
65
74
|
margin: 0 0 14px;
|
|
66
75
|
}
|
|
67
76
|
|
|
@@ -69,6 +78,17 @@ p:last-child {
|
|
|
69
78
|
margin-bottom: 0;
|
|
70
79
|
}
|
|
71
80
|
|
|
81
|
+
code {
|
|
82
|
+
font-family: var(--code-font-family);
|
|
83
|
+
font-size: var(--code-font-size);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
pre {
|
|
87
|
+
font-family: var(--code-font-family);
|
|
88
|
+
font-size: var(--code-font-size);
|
|
89
|
+
line-height: var(--code-line-height);
|
|
90
|
+
}
|
|
91
|
+
|
|
72
92
|
a {
|
|
73
93
|
color: var(--text-brand);
|
|
74
94
|
text-decoration: none;
|
|
@@ -94,7 +114,7 @@ ul li {
|
|
|
94
114
|
font-family: var(--font-serif);
|
|
95
115
|
font-size: var(--font-size-md);
|
|
96
116
|
color: var(--text-secondary);
|
|
97
|
-
line-height:
|
|
117
|
+
line-height: var(--line-height-relaxed);
|
|
98
118
|
margin-bottom: var(--space-4);
|
|
99
119
|
}
|
|
100
120
|
|
|
@@ -136,3 +156,60 @@ blockquote {
|
|
|
136
156
|
color: var(--text-secondary);
|
|
137
157
|
font-style: italic;
|
|
138
158
|
}
|
|
159
|
+
|
|
160
|
+
.heading-xl {
|
|
161
|
+
font-family: var(--heading-xl-font-family);
|
|
162
|
+
font-size: var(--heading-xl-font-size);
|
|
163
|
+
font-weight: var(--heading-xl-font-weight);
|
|
164
|
+
line-height: var(--heading-xl-line-height);
|
|
165
|
+
letter-spacing: var(--heading-xl-letter-spacing);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.heading-lg {
|
|
169
|
+
font-family: var(--heading-lg-font-family);
|
|
170
|
+
font-size: var(--heading-lg-font-size);
|
|
171
|
+
font-weight: var(--heading-lg-font-weight);
|
|
172
|
+
line-height: var(--heading-lg-line-height);
|
|
173
|
+
letter-spacing: var(--heading-lg-letter-spacing);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.heading-md {
|
|
177
|
+
font-family: var(--heading-md-font-family);
|
|
178
|
+
font-size: var(--heading-md-font-size);
|
|
179
|
+
font-weight: var(--heading-md-font-weight);
|
|
180
|
+
line-height: var(--heading-md-line-height);
|
|
181
|
+
letter-spacing: var(--heading-md-letter-spacing);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.heading-sm {
|
|
185
|
+
font-family: var(--heading-sm-font-family);
|
|
186
|
+
font-size: var(--heading-sm-font-size);
|
|
187
|
+
font-weight: var(--heading-sm-font-weight);
|
|
188
|
+
line-height: var(--heading-sm-line-height);
|
|
189
|
+
letter-spacing: var(--heading-sm-letter-spacing);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.body-md {
|
|
193
|
+
font-family: var(--body-md-font-family);
|
|
194
|
+
font-size: var(--body-md-font-size);
|
|
195
|
+
font-weight: var(--body-md-font-weight);
|
|
196
|
+
line-height: var(--body-md-line-height);
|
|
197
|
+
letter-spacing: var(--body-md-letter-spacing);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.body-sm {
|
|
201
|
+
font-family: var(--body-sm-font-family);
|
|
202
|
+
font-size: var(--body-sm-font-size);
|
|
203
|
+
font-weight: var(--body-sm-font-weight);
|
|
204
|
+
line-height: var(--body-sm-line-height);
|
|
205
|
+
letter-spacing: var(--body-sm-letter-spacing);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.eyebrow {
|
|
209
|
+
font-family: var(--eyebrow-font-family);
|
|
210
|
+
font-size: var(--eyebrow-font-size);
|
|
211
|
+
font-weight: var(--eyebrow-font-weight);
|
|
212
|
+
line-height: var(--eyebrow-line-height);
|
|
213
|
+
letter-spacing: var(--eyebrow-letter-spacing);
|
|
214
|
+
text-transform: var(--eyebrow-text-transform);
|
|
215
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Harmony rotates HUE ONLY (Global invariant 6). Each palette keeps its own
|
|
2
|
+
// OKLCH chroma and lightness; only a family's hue moves onto a geometric
|
|
3
|
+
// relationship with the anchor. The anchor is axis 0's stored hue: harmony lives
|
|
4
|
+
// on four fixed axes (Anchor/Secondary/Tertiary/Quaternary), each owning a hue
|
|
5
|
+
// whether or not a color family is bound to it. No chroma clamp or cap lives
|
|
6
|
+
// here; a saturated background is a legitimate choice, not something to correct.
|
|
7
|
+
|
|
8
|
+
import type { Oklch } from './oklch';
|
|
9
|
+
import type { PaletteConfig } from '../themes/themeTypes';
|
|
10
|
+
import { PALETTE_SPECS } from './paletteDerivation';
|
|
11
|
+
|
|
12
|
+
export type HarmonyMode =
|
|
13
|
+
| 'complementary'
|
|
14
|
+
| 'split-complementary'
|
|
15
|
+
| 'triadic'
|
|
16
|
+
| 'tetradic'
|
|
17
|
+
| 'compound'
|
|
18
|
+
| 'square'
|
|
19
|
+
| 'analogous'
|
|
20
|
+
| 'monochromatic'
|
|
21
|
+
| 'custom';
|
|
22
|
+
|
|
23
|
+
/** Families the user may order/include on the harmony axes (dev-declared pool). */
|
|
24
|
+
export const HARMONY_ELIGIBLE: readonly string[] = ['Brand', 'Accent', 'Background', 'Special', 'Neutral', 'Alternate'];
|
|
25
|
+
|
|
26
|
+
export const AXIS_COUNT = 4;
|
|
27
|
+
export const AXIS_ROLES = ['Anchor', 'Secondary', 'Tertiary', 'Quaternary'] as const;
|
|
28
|
+
|
|
29
|
+
export interface HarmonyAxis {
|
|
30
|
+
/** Hue in [0, 360). Always present, bound or not. */
|
|
31
|
+
hue: number;
|
|
32
|
+
/** Bound family label (member of HARMONY_ELIGIBLE), or null when the axis is empty. */
|
|
33
|
+
family: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Slot 0 is the anchor (Brand), then Accent, Background — the pre-axes default.
|
|
37
|
+
const LEGACY_DEFAULT_ORDER: readonly string[] = ['Brand', 'Accent', 'Background'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Coerce an untrusted legacy `harmonyOrder` (theme JSON) into a valid order:
|
|
41
|
+
* keep only eligible entries, first occurrence of each. A non-array, empty, or
|
|
42
|
+
* fully-invalid input falls back to the default order. Private to
|
|
43
|
+
* `axesFromLegacyOrder`, the sole migration entry point.
|
|
44
|
+
*/
|
|
45
|
+
function sanitizeLegacyOrder(input: unknown): string[] {
|
|
46
|
+
if (!Array.isArray(input)) return [...LEGACY_DEFAULT_ORDER];
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
const out: string[] = [];
|
|
49
|
+
for (const entry of input) {
|
|
50
|
+
if (typeof entry !== 'string') continue;
|
|
51
|
+
if (!HARMONY_ELIGIBLE.includes(entry)) continue;
|
|
52
|
+
if (seen.has(entry)) continue;
|
|
53
|
+
seen.add(entry);
|
|
54
|
+
out.push(entry);
|
|
55
|
+
}
|
|
56
|
+
return out.length > 0 ? out : [...LEGACY_DEFAULT_ORDER];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// In-range hues pass through bit-exact: the mod-360 round trip perturbs the
|
|
60
|
+
// last mantissa bits, which would make a preserved hue compare unequal.
|
|
61
|
+
const norm = (h: number): number => (h >= 0 && h < 360 ? h : ((h % 360) + 360) % 360);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Slot hues for `mode` from the anchor hue, priority-ordered: slot 1 is the
|
|
65
|
+
* primary harmonic partner. Returns the first `slotCount` slots (1–4). With the
|
|
66
|
+
* default order, dealing slots 0–2 reproduces today's per-family output.
|
|
67
|
+
*/
|
|
68
|
+
export function harmonyHues(mode: HarmonyMode, anchorHue: number, slotCount: number): number[] {
|
|
69
|
+
const a = norm(anchorHue);
|
|
70
|
+
const slots = ((): number[] => {
|
|
71
|
+
switch (mode) {
|
|
72
|
+
case 'complementary': return [a, norm(a + 180), a, norm(a + 180)];
|
|
73
|
+
case 'split-complementary': return [a, norm(a + 210), norm(a + 150), a];
|
|
74
|
+
case 'triadic': return [a, norm(a + 240), norm(a + 120), a];
|
|
75
|
+
case 'tetradic': return [a, norm(a + 180), norm(a + 60), norm(a + 240)];
|
|
76
|
+
case 'compound': return [a, norm(a + 180), norm(a + 30), norm(a + 210)];
|
|
77
|
+
case 'square': return [a, norm(a + 180), norm(a + 90), norm(a + 270)];
|
|
78
|
+
case 'analogous': return [a, norm(a + 30), norm(a - 30), a];
|
|
79
|
+
case 'monochromatic': return [a, a, a, a];
|
|
80
|
+
case 'custom': return [a, a, a, a];
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
return slots.slice(0, slotCount);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function reHue(config: PaletteConfig, hue: number): Oklch {
|
|
87
|
+
const { l, c } = config.baseColor;
|
|
88
|
+
return { l, c, h: norm(hue) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Re-hue Neutral and Alternate to `anchorHue`, own chroma + lightness kept. */
|
|
92
|
+
export function tintNeutralsFromAnchor(
|
|
93
|
+
palettes: Record<string, PaletteConfig>,
|
|
94
|
+
anchorHue: number,
|
|
95
|
+
): Record<string, Oklch> {
|
|
96
|
+
const out: Record<string, Oklch> = {};
|
|
97
|
+
for (const label of ['Neutral', 'Alternate']) {
|
|
98
|
+
const config = palettes[label];
|
|
99
|
+
if (config) out[label] = reHue(config, anchorHue);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Callers only pass HARMONY_ELIGIBLE families, all of which have a spec; a miss
|
|
105
|
+
// is a programming error, so surface it rather than seed a silent hue 0.
|
|
106
|
+
const specInitialHue = (label: string): number =>
|
|
107
|
+
PALETTE_SPECS.find((s) => s.label === label)!.initialColor.h;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Seed the four axes for a fresh editor: the default trio bound (hues read from
|
|
111
|
+
* `PALETTE_SPECS`, never hardcoded) and Quaternary unbound at anchor + 270 (the
|
|
112
|
+
* square slot-3 offset). Fresh array each call — it seeds mutable state.
|
|
113
|
+
*/
|
|
114
|
+
export function defaultHarmonyAxes(): HarmonyAxis[] {
|
|
115
|
+
const anchorHue = specInitialHue('Brand');
|
|
116
|
+
return [
|
|
117
|
+
{ hue: anchorHue, family: 'Brand' },
|
|
118
|
+
{ hue: specInitialHue('Accent'), family: 'Accent' },
|
|
119
|
+
{ hue: specInitialHue('Background'), family: 'Background' },
|
|
120
|
+
{ hue: norm(anchorHue + 270), family: null },
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Per-axis availability in `mode`: an axis participates only when its slot is a
|
|
126
|
+
* distinct position (complementary → 2 axes, triadic → 3, square → 4). Two
|
|
127
|
+
* carve-outs keep every axis active: monochromatic, whose repeats are the point
|
|
128
|
+
* (all axes collapse onto the anchor), and custom, which imposes no geometry.
|
|
129
|
+
*/
|
|
130
|
+
export function modeActiveAxes(mode: HarmonyMode): boolean[] {
|
|
131
|
+
if (mode === 'custom' || mode === 'monochromatic') return Array(AXIS_COUNT).fill(true);
|
|
132
|
+
const slots = harmonyHues(mode, 0, AXIS_COUNT);
|
|
133
|
+
return slots.map((h, i) => slots.slice(0, i).every((prev) => prev !== h));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Re-deal every axis's hue from `mode`'s geometry, anchored on axis 0's hue.
|
|
138
|
+
* Bindings are preserved; `'custom'` imposes no constraint and returns a copy.
|
|
139
|
+
* An axis inactive in `mode` (its slot would be a repeat) is left alone — the
|
|
140
|
+
* UI disables it while unbound.
|
|
141
|
+
*/
|
|
142
|
+
export function applyHarmonyToAxes(mode: HarmonyMode, axes: HarmonyAxis[]): HarmonyAxis[] {
|
|
143
|
+
if (mode === 'custom') return axes.map((a) => ({ ...a }));
|
|
144
|
+
const hues = harmonyHues(mode, axes[0].hue, AXIS_COUNT);
|
|
145
|
+
const active = modeActiveAxes(mode);
|
|
146
|
+
return axes.map((a, i) => (active[i] ? { hue: hues[i], family: a.family } : { ...a }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* New baseColors for the families bound to axes, each re-hued to its axis hue
|
|
151
|
+
* with its own chroma + lightness preserved. Unbound axes and missing configs
|
|
152
|
+
* contribute nothing.
|
|
153
|
+
*/
|
|
154
|
+
export function boundColorPatch(
|
|
155
|
+
axes: HarmonyAxis[],
|
|
156
|
+
palettes: Record<string, PaletteConfig>,
|
|
157
|
+
): Record<string, Oklch> {
|
|
158
|
+
const out: Record<string, Oklch> = {};
|
|
159
|
+
for (const axis of axes) {
|
|
160
|
+
if (axis.family === null) continue;
|
|
161
|
+
const config = palettes[axis.family];
|
|
162
|
+
if (config) out[axis.family] = reHue(config, axis.hue);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Coerce untrusted `harmonyAxes` (theme JSON) into exactly `AXIS_COUNT` axes:
|
|
169
|
+
* truncate extras, pad missing indexes from the defaults, drop ineligible or
|
|
170
|
+
* duplicate families to `null`, and replace non-finite hues with the default
|
|
171
|
+
* axis hue. The loaded color is ground truth, so every bound axis whose palette
|
|
172
|
+
* exists finally snaps its hue to that palette's `baseColor.h`.
|
|
173
|
+
*/
|
|
174
|
+
export function sanitizeHarmonyAxes(
|
|
175
|
+
input: unknown,
|
|
176
|
+
palettes: Record<string, PaletteConfig>,
|
|
177
|
+
): HarmonyAxis[] {
|
|
178
|
+
const defaults = defaultHarmonyAxes();
|
|
179
|
+
const source = Array.isArray(input) ? input : defaults;
|
|
180
|
+
const used = new Set<string>();
|
|
181
|
+
const axes: HarmonyAxis[] = [];
|
|
182
|
+
for (let i = 0; i < AXIS_COUNT; i++) {
|
|
183
|
+
const entry = (source[i] ?? defaults[i]) as Partial<HarmonyAxis>;
|
|
184
|
+
const rawFamily = entry.family;
|
|
185
|
+
let family: string | null = null;
|
|
186
|
+
if (typeof rawFamily === 'string' && HARMONY_ELIGIBLE.includes(rawFamily) && !used.has(rawFamily)) {
|
|
187
|
+
family = rawFamily;
|
|
188
|
+
used.add(rawFamily);
|
|
189
|
+
}
|
|
190
|
+
const rawHue = entry.hue;
|
|
191
|
+
const hue = typeof rawHue === 'number' && Number.isFinite(rawHue) ? norm(rawHue) : defaults[i].hue;
|
|
192
|
+
axes.push({ hue, family });
|
|
193
|
+
}
|
|
194
|
+
for (const axis of axes) {
|
|
195
|
+
if (axis.family !== null && palettes[axis.family]) {
|
|
196
|
+
axis.hue = norm(palettes[axis.family].baseColor.h);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return axes;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Migrate a legacy `harmonyOrder` into axes: `order[i]` binds to axis `i` (via
|
|
204
|
+
* the same eligibility/dedup rules as the old model), seeded at its palette's
|
|
205
|
+
* `baseColor.h` (or the family's spec hue when no palette loaded). Unfilled axes
|
|
206
|
+
* stay unbound at the default hues, Quaternary offset from the migrated anchor.
|
|
207
|
+
*/
|
|
208
|
+
export function axesFromLegacyOrder(
|
|
209
|
+
order: unknown,
|
|
210
|
+
palettes: Record<string, PaletteConfig>,
|
|
211
|
+
): HarmonyAxis[] {
|
|
212
|
+
const legacy = sanitizeLegacyOrder(order);
|
|
213
|
+
const defaults = defaultHarmonyAxes();
|
|
214
|
+
const hueOf = (family: string): number =>
|
|
215
|
+
norm(palettes[family] ? palettes[family].baseColor.h : specInitialHue(family));
|
|
216
|
+
const axes: HarmonyAxis[] = [];
|
|
217
|
+
for (let i = 0; i < AXIS_COUNT; i++) {
|
|
218
|
+
const family = legacy[i] ?? null;
|
|
219
|
+
if (family) {
|
|
220
|
+
axes.push({ hue: hueOf(family), family });
|
|
221
|
+
} else if (i === AXIS_COUNT - 1) {
|
|
222
|
+
axes.push({ hue: norm(axes[0].hue + 270), family: null });
|
|
223
|
+
} else {
|
|
224
|
+
axes.push({ hue: defaults[i].hue, family: null });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return axes;
|
|
228
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// WCAG 2.1 contrast math + an OKLCH-lightness solver.
|
|
2
|
+
//
|
|
3
|
+
// The solver is the engine behind the "Derive accessible text" action: given a
|
|
4
|
+
// surface hex and a target ratio, it finds the lightness (at fixed hue) whose
|
|
5
|
+
// gamut-clamped color clears the ratio. Contrast is monotonic in L only on one
|
|
6
|
+
// side of the surface's own lightness, so `direction` selects the branch.
|
|
7
|
+
|
|
8
|
+
import { hexToOklch, oklchToHex, gamutClamp } from './oklch';
|
|
9
|
+
|
|
10
|
+
export const AA_BODY = 4.5;
|
|
11
|
+
export const AA_LARGE = 3.0;
|
|
12
|
+
|
|
13
|
+
function linearizeChannel(v8: number): number {
|
|
14
|
+
const s = v8 / 255;
|
|
15
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function relativeLuminance(hex: string): number {
|
|
19
|
+
const r = linearizeChannel(parseInt(hex.slice(1, 3), 16));
|
|
20
|
+
const g = linearizeChannel(parseInt(hex.slice(3, 5), 16));
|
|
21
|
+
const b = linearizeChannel(parseInt(hex.slice(5, 7), 16));
|
|
22
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function contrastRatio(a: string, b: string): number {
|
|
26
|
+
const la = relativeLuminance(a);
|
|
27
|
+
const lb = relativeLuminance(b);
|
|
28
|
+
const hi = Math.max(la, lb);
|
|
29
|
+
const lo = Math.min(la, lb);
|
|
30
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FindLOptions {
|
|
34
|
+
against: string;
|
|
35
|
+
ratio: number;
|
|
36
|
+
direction: 'lighter' | 'darker';
|
|
37
|
+
c: number;
|
|
38
|
+
h: number;
|
|
39
|
+
margin?: number;
|
|
40
|
+
/** Reachable-L window. The Text derivation multiplies seed L by a curve
|
|
41
|
+
* factor clamped to [0,2], so a family can only reach L ∈ [0, min(1,2·seedL)].
|
|
42
|
+
* The solver passes that ceiling as `lMax` so the returned L is realisable. */
|
|
43
|
+
lMin?: number;
|
|
44
|
+
lMax?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FindLResult {
|
|
48
|
+
l: number;
|
|
49
|
+
c: number;
|
|
50
|
+
hex: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Binary-search OKLCH lightness for a color that clears `ratio` against
|
|
55
|
+
* `against`, staying on the `direction` side of the surface (the monotonic
|
|
56
|
+
* branch). If the ratio is unreachable at chroma `c` within the window, decay
|
|
57
|
+
* chroma toward 0 and retry — achromatic extremes reach the full 1..21 range.
|
|
58
|
+
* The result is re-verified with `contrastRatio` after the hex round-trip and
|
|
59
|
+
* nudged one step if 8-bit quantization ate the margin (Global invariant 8).
|
|
60
|
+
*/
|
|
61
|
+
export function findLForContrast(opts: FindLOptions): FindLResult {
|
|
62
|
+
const { against, ratio, direction, c, h } = opts;
|
|
63
|
+
const margin = opts.margin ?? 0.05;
|
|
64
|
+
const lMin = opts.lMin ?? 0;
|
|
65
|
+
const lMax = opts.lMax ?? 1;
|
|
66
|
+
const target = ratio + margin;
|
|
67
|
+
const againstL = hexToOklch(against).l;
|
|
68
|
+
|
|
69
|
+
const evalAt = (l: number, cc: number) => {
|
|
70
|
+
const g = gamutClamp(l, cc, h);
|
|
71
|
+
const hex = oklchToHex(g.l, g.c, g.h);
|
|
72
|
+
return { hex, chroma: g.c, ratio: contrastRatio(hex, against) };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Returns the boundary solution at a fixed chroma, or null if `target` is
|
|
76
|
+
// unreachable within the window at that chroma. `extreme` is the window end
|
|
77
|
+
// with the most contrast (top L for lighter, bottom L for darker).
|
|
78
|
+
const solveAtChroma = (cc: number): { l: number; hex: string; chroma: number } | null => {
|
|
79
|
+
const lighter = direction === 'lighter';
|
|
80
|
+
const lo0 = lighter ? Math.max(lMin, againstL) : lMin;
|
|
81
|
+
const hi0 = lighter ? lMax : Math.min(lMax, againstL);
|
|
82
|
+
if (lo0 >= hi0) return null; // no room to move to the requested side within the window
|
|
83
|
+
const extreme = lighter ? hi0 : lo0;
|
|
84
|
+
if (evalAt(extreme, cc).ratio < target) return null;
|
|
85
|
+
let lo = lo0;
|
|
86
|
+
let hi = hi0;
|
|
87
|
+
for (let i = 0; i < 24; i++) {
|
|
88
|
+
const mid = (lo + hi) / 2;
|
|
89
|
+
const meets = evalAt(mid, cc).ratio >= target;
|
|
90
|
+
// Contrast rises toward `extreme`; keep the half that still meets target.
|
|
91
|
+
if (lighter ? meets : !meets) hi = mid;
|
|
92
|
+
else lo = mid;
|
|
93
|
+
}
|
|
94
|
+
const at = lighter ? hi : lo;
|
|
95
|
+
const e = evalAt(at, cc);
|
|
96
|
+
return { l: at, hex: e.hex, chroma: e.chroma };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
let sol = solveAtChroma(c);
|
|
100
|
+
if (!sol) {
|
|
101
|
+
if (!solveAtChroma(0)) {
|
|
102
|
+
// Even achromatic can't reach the target within the window — return the
|
|
103
|
+
// extreme (best effort); the caller reports it as unmet.
|
|
104
|
+
const extremeL = direction === 'lighter' ? lMax : lMin;
|
|
105
|
+
const e = evalAt(extremeL, 0);
|
|
106
|
+
return { l: extremeL, c: 0, hex: e.hex };
|
|
107
|
+
}
|
|
108
|
+
let lo = 0;
|
|
109
|
+
let hi = c;
|
|
110
|
+
for (let i = 0; i < 24; i++) {
|
|
111
|
+
const mid = (lo + hi) / 2;
|
|
112
|
+
if (solveAtChroma(mid)) lo = mid;
|
|
113
|
+
else hi = mid;
|
|
114
|
+
}
|
|
115
|
+
sol = solveAtChroma(lo)!;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let { l, hex, chroma } = sol;
|
|
119
|
+
const step = direction === 'lighter' ? 0.004 : -0.004;
|
|
120
|
+
for (let k = 0; k < 12 && contrastRatio(hex, against) < ratio; k++) {
|
|
121
|
+
l = Math.min(lMax, Math.max(lMin, l + step));
|
|
122
|
+
const g = gamutClamp(l, chroma, h);
|
|
123
|
+
l = g.l;
|
|
124
|
+
chroma = g.c;
|
|
125
|
+
hex = oklchToHex(g.l, g.c, g.h);
|
|
126
|
+
}
|
|
127
|
+
return { l, c: chroma, hex };
|
|
128
|
+
}
|
|
@@ -102,6 +102,14 @@ function isInGamut(r: number, g: number, b: number): boolean {
|
|
|
102
102
|
return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/** The clamped sRGB projection: reduce chroma into gamut, then serialize to hex.
|
|
106
|
+
* This is the correct projection for unclamped stored intent — naive
|
|
107
|
+
* `oklchToHex` per-channel clips (shifting hue), this preserves hue and L. */
|
|
108
|
+
export function oklchToHexClamped(l: number, c: number, h: number): string {
|
|
109
|
+
const g = gamutClamp(l, c, h);
|
|
110
|
+
return oklchToHex(g.l, g.c, g.h);
|
|
111
|
+
}
|
|
112
|
+
|
|
105
113
|
export function gamutClamp(l: number, c: number, h: number): Oklch {
|
|
106
114
|
// Clamp lightness
|
|
107
115
|
if (l <= 0) return { l: 0, c: 0, h };
|