@motion-proto/live-tokens 0.40.1 → 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 +105 -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 +5 -4
- 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
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* derives the same CSS vars from the persisted config.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { hexToOklch,
|
|
16
|
+
import { hexToOklch, oklchToHexClamped, gamutClamp, type Oklch } from './oklch';
|
|
17
17
|
import { type CurveAnchor, sampleCurve, makeAnchor } from '../../ui/curveEngine';
|
|
18
18
|
import type { PaletteConfig } from '../themes/themeTypes';
|
|
19
19
|
|
|
@@ -21,40 +21,54 @@ export interface PaletteSpec {
|
|
|
21
21
|
label: string;
|
|
22
22
|
cssNamespace: string;
|
|
23
23
|
emptySelector?: boolean;
|
|
24
|
-
|
|
24
|
+
/** Seed OKLCH intent used only when a config carries no `baseColor` (never
|
|
25
|
+
* for a loaded theme). Numeric so it composes with the OKLCH basis. */
|
|
26
|
+
initialColor: Oklch;
|
|
25
27
|
/** Seed-default role only: neutrals start with a calm low-chroma base and
|
|
26
28
|
* the neutral lightness ramp. The derivation path is identical for all. */
|
|
27
29
|
neutral?: boolean;
|
|
30
|
+
/** Editor-only heading override (Alternate reads "Alternate (neutral)"). */
|
|
31
|
+
displayLabel?: string | null;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
/**
|
|
31
35
|
* Single source of truth for which palettes exist and how their CSS namespaces
|
|
32
36
|
* map. Was previously hardcoded inside `VariablesTab.svelte`; centralising it
|
|
33
|
-
* here lets the store seed boot-time vars without depending on the UI tree
|
|
37
|
+
* here lets the store seed boot-time vars without depending on the UI tree, and
|
|
38
|
+
* drives the editor's palette-editor order + (Waves 4/5) the Colors swatch row.
|
|
34
39
|
*/
|
|
35
40
|
export const PALETTE_SPECS: readonly PaletteSpec[] = [
|
|
36
|
-
{ label: '
|
|
37
|
-
{ label: '
|
|
38
|
-
{ label: 'Background', cssNamespace: 'canvas', emptySelector: true, initialColor:
|
|
39
|
-
{ label: '
|
|
40
|
-
{ label: '
|
|
41
|
-
{ label: 'Special', cssNamespace: 'special', initialColor:
|
|
42
|
-
{ label: 'Info', cssNamespace: 'info', initialColor:
|
|
43
|
-
{ label: 'Success', cssNamespace: 'success', initialColor:
|
|
44
|
-
{ label: 'Warning', cssNamespace: 'warning', initialColor:
|
|
45
|
-
{ label: 'Danger', cssNamespace: 'danger', initialColor:
|
|
41
|
+
{ label: 'Brand', cssNamespace: 'brand', initialColor: { l: 0.5573, c: 0.1841, h: 25.49 } },
|
|
42
|
+
{ label: 'Accent', cssNamespace: 'accent', initialColor: { l: 0.7674, c: 0.164, h: 70.44 } },
|
|
43
|
+
{ label: 'Background', cssNamespace: 'canvas', emptySelector: true, initialColor: { l: 0.2284, c: 0.0384, h: 282.93 } },
|
|
44
|
+
{ label: 'Neutral', cssNamespace: 'neutral', neutral: true, initialColor: { l: 0.5679, c: 0.0134, h: 240.07 } },
|
|
45
|
+
{ label: 'Alternate', cssNamespace: 'alternate', neutral: true, displayLabel: 'Alternate (neutral)', initialColor: { l: 0.5873, c: 0.0087, h: 48.57 } },
|
|
46
|
+
{ label: 'Special', cssNamespace: 'special', initialColor: { l: 0.6056, c: 0.2189, h: 292.72 } },
|
|
47
|
+
{ label: 'Info', cssNamespace: 'info', initialColor: { l: 0.5871, c: 0.1855, h: 259.56 } },
|
|
48
|
+
{ label: 'Success', cssNamespace: 'success', initialColor: { l: 0.7198, c: 0.1918, h: 149.52 } },
|
|
49
|
+
{ label: 'Warning', cssNamespace: 'warning', initialColor: { l: 0.6704, c: 0.1716, h: 48.75 } },
|
|
50
|
+
{ label: 'Danger', cssNamespace: 'danger', initialColor: { l: 0.6103, c: 0.2165, h: 18.1 } },
|
|
46
51
|
] as const;
|
|
47
52
|
|
|
48
|
-
const PALETTE_STEPS = [
|
|
53
|
+
export const PALETTE_STEPS = [
|
|
49
54
|
{ label: '100' }, { label: '200' }, { label: '300' }, { label: '400' },
|
|
50
55
|
{ label: '500' }, { label: '600' }, { label: '700' }, { label: '800' },
|
|
51
56
|
{ label: '850' }, { label: '900' }, { label: '950' },
|
|
52
57
|
];
|
|
53
58
|
|
|
54
|
-
interface
|
|
55
|
-
|
|
59
|
+
export interface Step {
|
|
60
|
+
name: string;
|
|
61
|
+
position: number;
|
|
62
|
+
lightness?: number;
|
|
63
|
+
saturation?: number;
|
|
64
|
+
}
|
|
65
|
+
export interface Scale {
|
|
66
|
+
title: string;
|
|
67
|
+
isText: boolean;
|
|
68
|
+
steps: Step[];
|
|
69
|
+
}
|
|
56
70
|
|
|
57
|
-
const SCALES: readonly Scale[] = [
|
|
71
|
+
export const SCALES: readonly Scale[] = [
|
|
58
72
|
{
|
|
59
73
|
title: 'Surfaces', isText: false,
|
|
60
74
|
steps: [
|
|
@@ -89,63 +103,208 @@ const SCALES: readonly Scale[] = [
|
|
|
89
103
|
},
|
|
90
104
|
];
|
|
91
105
|
|
|
106
|
+
/** Guard band for derived near-black/near-white text: values stay between
|
|
107
|
+
* ≈ #111 and ≈ #e8e8e8 unless pure black/white is opted into. The AA floor
|
|
108
|
+
* beats the guard (see `recommendText.ts`). */
|
|
109
|
+
export const BW_GUARD_MIN_L = 0.17;
|
|
110
|
+
export const BW_GUARD_MAX_L = 0.93;
|
|
111
|
+
|
|
92
112
|
export const DEFAULT_PALETTE_LIGHTNESS = (): CurveAnchor[] => [makeAnchor(0, 95, 5), makeAnchor(100, 8, 5)];
|
|
93
113
|
export const DEFAULT_PALETTE_SATURATION = (): CurveAnchor[] => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)];
|
|
94
114
|
|
|
95
|
-
export
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
115
|
+
export type SchemeDirection = 'light' | 'dark';
|
|
116
|
+
|
|
117
|
+
export type ScaleCurveDefaults = Record<
|
|
118
|
+
string,
|
|
119
|
+
{ lightness: () => CurveAnchor[]; saturation: () => CurveAnchor[] }
|
|
120
|
+
>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Scheme-parameterized seed anchors for the Surfaces/Borders/Text scales.
|
|
124
|
+
* `'dark'` is the historical default (dark surfaces, light text); `'light'`
|
|
125
|
+
* inverts the lightness bands (light surfaces, dark text). Saturation curves
|
|
126
|
+
* are scheme-independent. Derivation math is untouched — only these seed
|
|
127
|
+
* defaults differ, so saved themes (which carry explicit `scaleCurves`) render
|
|
128
|
+
* identically regardless of scheme.
|
|
129
|
+
*
|
|
130
|
+
* Light-scheme anchors are proposed values, tunable during QA. The dark anchors
|
|
131
|
+
* are frozen: existing themes depend on them via the `defaultScaleCurves` alias.
|
|
132
|
+
*/
|
|
133
|
+
export function scaleCurveDefaults(scheme: SchemeDirection = 'dark'): ScaleCurveDefaults {
|
|
134
|
+
if (scheme === 'light') {
|
|
135
|
+
return {
|
|
136
|
+
Surfaces: {
|
|
137
|
+
lightness: () => [makeAnchor(0, 98, 5), makeAnchor(100, 82, 5)],
|
|
138
|
+
saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
|
|
139
|
+
},
|
|
140
|
+
Borders: {
|
|
141
|
+
lightness: () => [makeAnchor(0, 92, 5), makeAnchor(100, 45, 5)],
|
|
142
|
+
saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
|
|
143
|
+
},
|
|
144
|
+
Text: {
|
|
145
|
+
lightness: () => [makeAnchor(0, 30, 30), makeAnchor(100, 120, 30)],
|
|
146
|
+
saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 15, 30)],
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
Surfaces: {
|
|
152
|
+
lightness: () => [makeAnchor(0, 15, 5), makeAnchor(100, 47, 5)],
|
|
153
|
+
saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
|
|
154
|
+
},
|
|
155
|
+
Borders: {
|
|
156
|
+
lightness: () => [makeAnchor(0, 25, 5), makeAnchor(100, 80, 5)],
|
|
157
|
+
saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
|
|
158
|
+
},
|
|
159
|
+
Text: {
|
|
160
|
+
lightness: () => [makeAnchor(0, 120, 30), makeAnchor(100, 55, 30)],
|
|
161
|
+
saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 15, 30)],
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const defaultScaleCurves: ScaleCurveDefaults = scaleCurveDefaults('dark');
|
|
109
167
|
|
|
110
|
-
function paletteStepKey(label: string): string { return `Palette-${label}`; }
|
|
111
|
-
function stepKey(scaleTitle: string, stepName: string): string { return `${scaleTitle}-${stepName}`; }
|
|
168
|
+
export function paletteStepKey(label: string): string { return `Palette-${label}`; }
|
|
169
|
+
export function stepKey(scaleTitle: string, stepName: string): string { return `${scaleTitle}-${stepName}`; }
|
|
112
170
|
|
|
113
|
-
function stepIndexToX(index: number
|
|
114
|
-
return
|
|
171
|
+
export function stepIndexToX(index: number): number {
|
|
172
|
+
return (index / (PALETTE_STEPS.length - 1)) * 100;
|
|
115
173
|
}
|
|
116
174
|
|
|
117
|
-
function
|
|
175
|
+
export function scaleStepToX(step: Step, scale: Scale): number {
|
|
176
|
+
const idx = scale.steps.indexOf(step);
|
|
177
|
+
return scale.steps.length > 1 ? (idx / (scale.steps.length - 1)) * 100 : 50;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Typed intermediate representation for a derived CSS-variable value. The
|
|
182
|
+
* derived-vars map is heterogeneous — colors, but also gradient strings
|
|
183
|
+
* (`--page-bg`) and keywords (`--page-bg-attachment`) — so derivation and
|
|
184
|
+
* serialization are split: derivation yields this IR (`palettesToValues`),
|
|
185
|
+
* `serializeDerivedValue` renders it to a CSS string. `color` carries numeric
|
|
186
|
+
* OKLCH intent (gamut-clamped at derivation, the projection-only clamp point);
|
|
187
|
+
* `raw` carries a verbatim CSS string.
|
|
188
|
+
*/
|
|
189
|
+
export type DerivedValue =
|
|
190
|
+
| { kind: 'color'; l: number; c: number; h: number }
|
|
191
|
+
| { kind: 'raw'; css: string };
|
|
192
|
+
|
|
193
|
+
/** The single color → CSS-string projection, and the single clamp point for
|
|
194
|
+
* derived color: unclamped stored intent is chroma-reduced into sRGB gamut,
|
|
195
|
+
* then serialized (hex this wave; `oklch()` after Part B). Idempotent on the
|
|
196
|
+
* already-in-gamut per-step derivation output. `raw` passes through untouched. */
|
|
197
|
+
export function serializeDerivedValue(value: DerivedValue): string {
|
|
198
|
+
return value.kind === 'raw' ? value.css : oklchToHexClamped(value.l, value.c, value.h);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function computePaletteOklch(
|
|
118
202
|
index: number,
|
|
119
|
-
base:
|
|
203
|
+
base: Oklch,
|
|
120
204
|
lightnessCurve: CurveAnchor[],
|
|
121
205
|
saturationCurve: CurveAnchor[],
|
|
122
206
|
curveOffset: Record<string, number>,
|
|
123
|
-
):
|
|
124
|
-
const { c: baseC, h } =
|
|
125
|
-
const xPos = stepIndexToX(index
|
|
207
|
+
): Oklch {
|
|
208
|
+
const { c: baseC, h } = base;
|
|
209
|
+
const xPos = stepIndexToX(index);
|
|
126
210
|
const targetL = Math.max(0, Math.min(100, sampleCurve(lightnessCurve, xPos) + (curveOffset.lightness ?? 0))) / 100;
|
|
127
211
|
const satMul = Math.max(0, Math.min(2, (sampleCurve(saturationCurve, xPos) + (curveOffset.saturation ?? 0)) / 100));
|
|
128
212
|
const targetC = baseC * satMul;
|
|
129
|
-
|
|
130
|
-
|
|
213
|
+
return gamutClamp(targetL, targetC, h);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The palette step whose curve lightness is nearest the picked color's L
|
|
217
|
+
* (both in curve space, 0–100; `curveOffset` shifts anchor and steps alike,
|
|
218
|
+
* so it cancels out of the comparison). */
|
|
219
|
+
export function nearestPaletteStep(lightnessCurve: CurveAnchor[], baseL: number): number {
|
|
220
|
+
let best = 0;
|
|
221
|
+
let bestD = Infinity;
|
|
222
|
+
for (let i = 0; i < PALETTE_STEPS.length; i++) {
|
|
223
|
+
const d = Math.abs(sampleCurve(lightnessCurve, stepIndexToX(i)) - baseL * 100);
|
|
224
|
+
if (d < bestD) { bestD = d; best = i; }
|
|
225
|
+
}
|
|
226
|
+
return best;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Insert an anchor at x, or overwrite one already there (endpoints included).
|
|
230
|
+
* Returns the y it displaced so the caller can restore it later. */
|
|
231
|
+
export function setCurveAnchor(curve: CurveAnchor[], x: number, y: number): { curve: CurveAnchor[]; displacedY?: number } {
|
|
232
|
+
const existing = curve.findIndex((a) => Math.abs(a.x - x) < 0.5);
|
|
233
|
+
if (existing >= 0) {
|
|
234
|
+
const displacedY = curve[existing].y;
|
|
235
|
+
return { curve: curve.map((a, i) => (i === existing ? { ...a, x, y } : a)), displacedY };
|
|
236
|
+
}
|
|
237
|
+
let insertAt = curve.findIndex((a) => a.x > x);
|
|
238
|
+
if (insertAt < 0) insertAt = curve.length;
|
|
239
|
+
return { curve: [...curve.slice(0, insertAt), makeAnchor(x, y, 15), ...curve.slice(insertAt)] };
|
|
131
240
|
}
|
|
132
241
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
242
|
+
/** Undo a placement at x: restore the displaced y when one was recorded,
|
|
243
|
+
* remove the anchor when the placement created it. Endpoints are never
|
|
244
|
+
* removed — a curve needs its boundaries. */
|
|
245
|
+
export function liftCurveAnchor(curve: CurveAnchor[], x: number, displacedY?: number): CurveAnchor[] {
|
|
246
|
+
const idx = curve.findIndex((a) => Math.abs(a.x - x) < 0.5);
|
|
247
|
+
if (idx < 0) return curve;
|
|
248
|
+
if (displacedY !== undefined) return curve.map((a, i) => (i === idx ? { ...a, y: displacedY } : a));
|
|
249
|
+
if (idx === 0 || idx === curve.length - 1) return curve;
|
|
250
|
+
return curve.filter((_, i) => i !== idx);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Place the base color in the ramp: pin a lightness anchor (base L) and a
|
|
255
|
+
* saturation anchor (multiplier 100) at the step whose lightness is nearest
|
|
256
|
+
* the base L, so the picked color renders verbatim at that step. Placement
|
|
257
|
+
* is measured against the curve with the previous placement lifted out —
|
|
258
|
+
* the pinned anchor equals base L at its own x by construction, so measuring
|
|
259
|
+
* against it would be circular and the anchor could never move. Runs inside
|
|
260
|
+
* the same store mutation as the base-color write; mutates `cfg` in place.
|
|
261
|
+
*/
|
|
262
|
+
export function syncBaseAnchor(cfg: PaletteConfig): void {
|
|
263
|
+
if (cfg.anchorToBase === false) return;
|
|
264
|
+
const prev = cfg.anchorPlacement;
|
|
265
|
+
let lCurve = cfg.lightnessCurve;
|
|
266
|
+
let sCurve = cfg.saturationCurve;
|
|
267
|
+
if (prev) {
|
|
268
|
+
const prevX = stepIndexToX(prev.step);
|
|
269
|
+
lCurve = liftCurveAnchor(lCurve, prevX, prev.displacedL);
|
|
270
|
+
sCurve = liftCurveAnchor(sCurve, prevX, prev.displacedS);
|
|
271
|
+
}
|
|
272
|
+
const step = nearestPaletteStep(lCurve, cfg.baseColor.l);
|
|
273
|
+
const x = stepIndexToX(step);
|
|
274
|
+
const l = setCurveAnchor(lCurve, x, cfg.baseColor.l * 100);
|
|
275
|
+
const s = setCurveAnchor(sCurve, x, 100);
|
|
276
|
+
cfg.lightnessCurve = l.curve;
|
|
277
|
+
cfg.saturationCurve = s.curve;
|
|
278
|
+
cfg.anchorPlacement = { step, displacedL: l.displacedY, displacedS: s.displacedY };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Toggle-off: lift the placement out of both curves and drop the flag. */
|
|
282
|
+
export function clearBaseAnchor(cfg: PaletteConfig): void {
|
|
283
|
+
const prev = cfg.anchorPlacement;
|
|
284
|
+
if (prev) {
|
|
285
|
+
const x = stepIndexToX(prev.step);
|
|
286
|
+
cfg.lightnessCurve = liftCurveAnchor(cfg.lightnessCurve, x, prev.displacedL);
|
|
287
|
+
cfg.saturationCurve = liftCurveAnchor(cfg.saturationCurve, x, prev.displacedS);
|
|
288
|
+
cfg.anchorPlacement = undefined;
|
|
289
|
+
}
|
|
290
|
+
cfg.anchorToBase = false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function computeDerivedOklch(
|
|
294
|
+
step: Step,
|
|
295
|
+
base: Oklch,
|
|
136
296
|
scaleTitle: string,
|
|
137
297
|
scaleCurves: Record<string, { lightness: CurveAnchor[]; saturation: CurveAnchor[] }>,
|
|
138
298
|
curveOffset: Record<string, number>,
|
|
139
|
-
):
|
|
299
|
+
): Oklch {
|
|
140
300
|
const scale = SCALES.find((s) => s.title === scaleTitle)!;
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const defs = defaultScaleCurves[scaleTitle as keyof typeof defaultScaleCurves];
|
|
301
|
+
const xPos = scaleStepToX(step, scale);
|
|
302
|
+
const defs = defaultScaleCurves[scaleTitle];
|
|
144
303
|
const lCurve = scaleCurves[scaleTitle]?.lightness ?? defs.lightness();
|
|
145
304
|
const sCurve = scaleCurves[scaleTitle]?.saturation ?? defs.saturation();
|
|
146
305
|
const lOff = curveOffset[`${scaleTitle}-lightness`] ?? 0;
|
|
147
306
|
const sOff = curveOffset[`${scaleTitle}-saturation`] ?? 0;
|
|
148
|
-
const { l: baseL, c: baseC, h: baseH } =
|
|
307
|
+
const { l: baseL, c: baseC, h: baseH } = base;
|
|
149
308
|
let targetL: number;
|
|
150
309
|
if (scale.isText) {
|
|
151
310
|
const lMul = Math.max(0, Math.min(2, (sampleCurve(lCurve, xPos) + lOff) / 100));
|
|
@@ -155,8 +314,7 @@ function computeDerivedColor(
|
|
|
155
314
|
}
|
|
156
315
|
const satMul = Math.max(0, Math.min(2, (sampleCurve(sCurve, xPos) + sOff) / 100));
|
|
157
316
|
const targetC = baseC * satMul;
|
|
158
|
-
|
|
159
|
-
return oklchToHex(clamped.l, clamped.c, clamped.h);
|
|
317
|
+
return gamutClamp(targetL, targetC, baseH);
|
|
160
318
|
}
|
|
161
319
|
|
|
162
320
|
export function scaleToCssVar(scaleTitle: string, stepName: string, cssNamespace: string | null): string | null {
|
|
@@ -176,8 +334,8 @@ export function scaleToCssVar(scaleTitle: string, stepName: string, cssNamespace
|
|
|
176
334
|
return null;
|
|
177
335
|
}
|
|
178
336
|
|
|
179
|
-
export function
|
|
180
|
-
const out: Record<string,
|
|
337
|
+
export function derivePaletteValues(spec: PaletteSpec, config: PaletteConfig | undefined): Record<string, DerivedValue> {
|
|
338
|
+
const out: Record<string, DerivedValue> = {};
|
|
181
339
|
if (!config) return out;
|
|
182
340
|
|
|
183
341
|
const baseColor = config.baseColor ?? spec.initialColor;
|
|
@@ -189,25 +347,36 @@ export function derivePaletteVars(spec: PaletteSpec, config: PaletteConfig | und
|
|
|
189
347
|
|
|
190
348
|
PALETTE_STEPS.forEach((ps, index) => {
|
|
191
349
|
const k = paletteStepKey(ps.label);
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
350
|
+
// Overrides are numeric OKLCH intent; pass straight through as color-kind.
|
|
351
|
+
const value: DerivedValue = (k in overrides)
|
|
352
|
+
? { kind: 'color', ...overrides[k] }
|
|
353
|
+
: { kind: 'color', ...computePaletteOklch(index, baseColor, lightnessCurve, saturationCurve, curveOffset) };
|
|
354
|
+
out[`--color-${spec.cssNamespace}-${ps.label}`] = value;
|
|
195
355
|
});
|
|
196
356
|
|
|
197
357
|
for (const scale of SCALES) {
|
|
198
358
|
for (const step of scale.steps) {
|
|
199
359
|
const k = stepKey(scale.title, step.name);
|
|
200
|
-
const
|
|
201
|
-
? overrides[k]
|
|
202
|
-
:
|
|
360
|
+
const value: DerivedValue = (k in overrides)
|
|
361
|
+
? { kind: 'color', ...overrides[k] }
|
|
362
|
+
: { kind: 'color', ...computeDerivedOklch(step, baseColor, scale.title, scaleCurves, curveOffset) };
|
|
203
363
|
const varName = scaleToCssVar(scale.title, step.name, spec.cssNamespace);
|
|
204
|
-
if (varName) out[varName] =
|
|
364
|
+
if (varName) out[varName] = value;
|
|
205
365
|
}
|
|
206
366
|
}
|
|
207
367
|
|
|
368
|
+
// `--text-inverted` is the derived flip of primary — mirrored L, same
|
|
369
|
+
// hue/chroma — pulled inside the guard band so a near-white primary yields
|
|
370
|
+
// ≈ #111 rather than pure black. Only Neutral emits `--text-primary`, so
|
|
371
|
+
// its presence gates this to the Neutral palette.
|
|
372
|
+
const textPrimary = out['--text-primary'];
|
|
373
|
+
if (textPrimary?.kind === 'color') {
|
|
374
|
+
const mirroredL = Math.max(BW_GUARD_MIN_L, Math.min(BW_GUARD_MAX_L, 1 - textPrimary.l));
|
|
375
|
+
out['--text-inverted'] = { kind: 'color', ...gamutClamp(mirroredL, textPrimary.c, textPrimary.h) };
|
|
376
|
+
}
|
|
377
|
+
|
|
208
378
|
if (spec.emptySelector) {
|
|
209
379
|
const emptyMode = config.emptyMode ?? 'solid';
|
|
210
|
-
const emptyStep = config.emptyStep ?? '850';
|
|
211
380
|
const gradientStyle = config.gradientStyle ?? 'linear';
|
|
212
381
|
const gradientAngle = config.gradientAngle ?? 180;
|
|
213
382
|
const gradientReverse = config.gradientReverse ?? false;
|
|
@@ -218,15 +387,23 @@ export function derivePaletteVars(spec: PaletteSpec, config: PaletteConfig | und
|
|
|
218
387
|
];
|
|
219
388
|
|
|
220
389
|
if (emptyMode === 'solid') {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
390
|
+
// The solid page background is the base color itself, not a selected
|
|
391
|
+
// step: the base anchor already renders it verbatim in the ramp, so a
|
|
392
|
+
// separate spot selection could only disagree with it.
|
|
393
|
+
out['--page-bg'] = { kind: 'color', ...baseColor };
|
|
394
|
+
out['--page-bg-attachment'] = { kind: 'raw', css: 'scroll' };
|
|
224
395
|
} else {
|
|
396
|
+
// `--page-bg` is a raw kind composed from the already-serialized step
|
|
397
|
+
// colors — the gradient string is a projection, not a basis value.
|
|
225
398
|
const sortedStops = [...gradientStops].sort((a, b) =>
|
|
226
399
|
gradientReverse ? b.position - a.position : a.position - b.position,
|
|
227
400
|
);
|
|
228
401
|
const stopsCss = sortedStops
|
|
229
|
-
.map((s) =>
|
|
402
|
+
.map((s) => {
|
|
403
|
+
const stopValue = out[`--color-${spec.cssNamespace}-${s.paletteLabel}`];
|
|
404
|
+
const hex = stopValue ? serializeDerivedValue(stopValue) : '#000000';
|
|
405
|
+
return `${hex} ${s.position}%`;
|
|
406
|
+
})
|
|
230
407
|
.join(', ');
|
|
231
408
|
let gradient: string;
|
|
232
409
|
switch (gradientStyle) {
|
|
@@ -234,18 +411,34 @@ export function derivePaletteVars(spec: PaletteSpec, config: PaletteConfig | und
|
|
|
234
411
|
case 'conic': gradient = `conic-gradient(from ${gradientAngle}deg, ${stopsCss})`; break;
|
|
235
412
|
default: gradient = `linear-gradient(${gradientAngle}deg, ${stopsCss})`;
|
|
236
413
|
}
|
|
237
|
-
out['--page-bg'] = gradient;
|
|
238
|
-
out['--page-bg-attachment'] = gradientSize === 'window' ? 'fixed' : 'scroll';
|
|
414
|
+
out['--page-bg'] = { kind: 'raw', css: gradient };
|
|
415
|
+
out['--page-bg-attachment'] = { kind: 'raw', css: gradientSize === 'window' ? 'fixed' : 'scroll' };
|
|
239
416
|
}
|
|
240
417
|
}
|
|
241
418
|
|
|
242
419
|
return out;
|
|
243
420
|
}
|
|
244
421
|
|
|
245
|
-
export function
|
|
422
|
+
export function derivePaletteVars(spec: PaletteSpec, config: PaletteConfig | undefined): Record<string, string> {
|
|
246
423
|
const out: Record<string, string> = {};
|
|
424
|
+
for (const [k, v] of Object.entries(derivePaletteValues(spec, config))) {
|
|
425
|
+
out[k] = serializeDerivedValue(v);
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function palettesToValues(palettes: Record<string, PaletteConfig>): Record<string, DerivedValue> {
|
|
431
|
+
const out: Record<string, DerivedValue> = {};
|
|
247
432
|
for (const spec of PALETTE_SPECS) {
|
|
248
|
-
Object.assign(out,
|
|
433
|
+
Object.assign(out, derivePaletteValues(spec, palettes[spec.label]));
|
|
434
|
+
}
|
|
435
|
+
return out;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function palettesToVars(palettes: Record<string, PaletteConfig>): Record<string, string> {
|
|
439
|
+
const out: Record<string, string> = {};
|
|
440
|
+
for (const [k, v] of Object.entries(palettesToValues(palettes))) {
|
|
441
|
+
out[k] = serializeDerivedValue(v);
|
|
249
442
|
}
|
|
250
443
|
return out;
|
|
251
444
|
}
|
|
@@ -294,8 +487,10 @@ export function reconcilePalettesFromCssVars(
|
|
|
294
487
|
if (current._imported === true) {
|
|
295
488
|
const anchorHex = cssVars[`--color-${spec.cssNamespace}-500`];
|
|
296
489
|
if (anchorHex && HEX_RE.test(anchorHex.trim())) {
|
|
297
|
-
|
|
298
|
-
|
|
490
|
+
// Input boundary: the imported cssVariables anchor is a hex string;
|
|
491
|
+
// parse it to the OKLCH basis exactly once, here.
|
|
492
|
+
next[spec.label] = { ...current, baseColor: hexToOklch(anchorHex.trim()), _imported: false };
|
|
493
|
+
syncBaseAnchor(next[spec.label]);
|
|
299
494
|
snapped.add(spec.label);
|
|
300
495
|
} else {
|
|
301
496
|
// No anchor in cssVariables to snap to — flag has nothing to do; clear
|