@motion-proto/live-tokens 0.50.0 → 0.52.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist-plugin/adjust/index.d.cts +1 -1
  3. package/dist-plugin/adjust/index.d.ts +1 -1
  4. package/dist-plugin/{chunk-76TFDTJO.js → chunk-JIGIIE5P.js} +60 -132
  5. package/dist-plugin/{chunk-44RSTAII.js → chunk-TKZBVIW5.js} +33 -5
  6. package/dist-plugin/chunk-YR6GPXW2.js +129 -0
  7. package/dist-plugin/generateColorsAndType/index.cjs +87 -31
  8. package/dist-plugin/generateColorsAndType/index.d.cts +1 -1
  9. package/dist-plugin/generateColorsAndType/index.d.ts +1 -1
  10. package/dist-plugin/generateColorsAndType/index.js +22 -12
  11. package/dist-plugin/index.cjs +95 -55
  12. package/dist-plugin/index.js +5 -3
  13. package/dist-plugin/{themeTypes-DSwKq-bj.d.cts → themeTypes-Dky03HL9.d.cts} +18 -5
  14. package/dist-plugin/{themeTypes-DSwKq-bj.d.ts → themeTypes-Dky03HL9.d.ts} +18 -5
  15. package/dist-plugin/tokensCssMigrations/index.cjs +71 -5
  16. package/dist-plugin/tokensCssMigrations/index.d.cts +20 -9
  17. package/dist-plugin/tokensCssMigrations/index.d.ts +20 -9
  18. package/dist-plugin/tokensCssMigrations/index.js +4 -1
  19. package/package.json +4 -2
  20. package/src/editor/component-editor/NotificationEditor.svelte +6 -0
  21. package/src/editor/core/palettes/oklch.ts +3 -137
  22. package/src/editor/core/palettes/paletteDerivation.ts +79 -37
  23. package/src/editor/core/themes/generateColorsAndType.ts +18 -8
  24. package/src/editor/core/themes/themeTypes.ts +18 -6
  25. package/src/editor/docs/content/editing-tokens.md +6 -2
  26. package/src/editor/docs/content.generated.ts +1 -1
  27. package/src/editor/ui/BezierCurveEditor.svelte +90 -67
  28. package/src/editor/ui/PaletteEditor.svelte +150 -44
  29. package/src/editor/ui/Toggle.svelte +14 -2
  30. package/src/editor/ui/colors/ColorStory.svelte +12 -8
  31. package/src/editor/ui/curveEngine.ts +101 -21
  32. package/src/editor/ui/palette/OverridesPanel.svelte +19 -5
  33. package/src/editor/ui/palette/PaletteBase.svelte +17 -78
  34. package/src/editor/ui/palette/ScaleCurveEditor.svelte +109 -20
  35. package/src/editor/ui/palette/curveSummary.ts +20 -0
  36. package/src/editor/ui/palette/dockMagnify.ts +5 -13
  37. package/src/editor/ui/palette/paletteMath.ts +7 -4
  38. package/src/live-tokens/data/colors-and-type/autumn.json +528 -528
  39. package/src/live-tokens/data/colors-and-type/default.json +247 -247
  40. package/src/live-tokens/data/colors-and-type/halloween.json +532 -532
  41. package/src/live-tokens/data/colors-and-type/midnight-study.json +538 -538
  42. package/src/live-tokens/data/colors-and-type/ocean.json +521 -521
  43. package/src/live-tokens/data/colors-and-type/royal-velvet.json +532 -532
  44. package/src/live-tokens/data/colors-and-type/spring-meadow.json +504 -504
  45. package/src/live-tokens/data/colors-and-type/sunset.json +531 -531
  46. package/src/live-tokens/data/themes/autumn.json +527 -527
  47. package/src/live-tokens/data/themes/halloween.json +531 -531
  48. package/src/live-tokens/data/themes/midnight-study.json +537 -537
  49. package/src/live-tokens/data/themes/ocean.json +520 -520
  50. package/src/live-tokens/data/themes/royal-velvet.json +531 -531
  51. package/src/live-tokens/data/themes/spring-meadow.json +504 -504
  52. package/src/live-tokens/data/themes/sunset.json +531 -531
  53. package/src/live-tokens/data/tokens.generated.css +282 -282
  54. package/src/system/components/FloatingTokenTags.css +0 -1
  55. package/src/system/internal/backgroundContrast.ts +7 -0
  56. package/src/system/internal/oklch.ts +189 -0
  57. package/src/system/styles/tokens.css +283 -283
@@ -52,6 +52,8 @@ interface TokensCssMigration {
52
52
  */
53
53
  /** Names declared as custom properties (`--x: …;`) anywhere in the source. */
54
54
  declare function collectDefinedTokens(css: string): Set<string>;
55
+ /** Declared name → value, last declaration winning (as the cascade would). */
56
+ declare function collectTokenValues(css: string): Map<string, string>;
55
57
  /** Names referenced via `var(--x)` anywhere in the source. */
56
58
  declare function collectReferencedTokens(css: string): Set<string>;
57
59
  interface ScaleEntry {
@@ -123,16 +125,25 @@ interface ContractViolation {
123
125
  id: string;
124
126
  /** Token names the migration removed or renamed away despite declaring `additive`. */
125
127
  removed: string[];
128
+ /** Token names whose value the migration rewrote despite declaring `additive`. */
129
+ changed: string[];
126
130
  }
127
131
  /**
128
- * Guardrail for the token-as-API contract: an `additive` migration must never
129
- * remove or rename a token. We verify behaviorally rather than trusting the
130
- * label — apply each additive migration to the package's own canonical
131
- * `tokens.css` (which defines the full current vocabulary) and flag any token
132
- * that disappears. A rename surfaces as the removal of its old name, so it is
133
- * caught too. This catches the dangerous direction: a breaking change shipped as
134
- * a backward-compatible one. (Over-labeling a no-op as `breaking` is harmless
135
- * and not checked.)
132
+ * Guardrail for the token-as-API contract: an `additive` migration must only
133
+ * ever insert new names — it must never remove or rename a token, and never
134
+ * rewrite the value of one a consumer already references. We verify
135
+ * behaviorally rather than trusting the label: apply each additive migration to
136
+ * the package's own canonical `tokens.css` (which defines the full current
137
+ * vocabulary) and flag any token that disappears or changes value. A rename
138
+ * surfaces as the removal of its old name, so it is caught too.
139
+ *
140
+ * The value half is what makes a value-rewriting migration (hex → `oklch()`)
141
+ * impossible to mislabel: it renames nothing, so a name-only guard would wave
142
+ * it through as additive and the dev plugin would auto-apply a breaking change
143
+ * to a consumer's vendored file.
144
+ *
145
+ * This catches the dangerous direction only. (Over-labeling a no-op as
146
+ * `breaking` is harmless and not checked.)
136
147
  */
137
148
  declare function findContractViolations(canonicalCss: string): ContractViolation[];
138
149
  type SemverBump = 'major' | 'minor' | 'patch' | 'none';
@@ -183,4 +194,4 @@ interface ValidateInput {
183
194
  */
184
195
  declare function validateTokensCss(input: ValidateInput): MissingToken[];
185
196
 
186
- export { type BreakingGateResult, type ComponentSource, type ContractViolation, type MissingToken, type RunResult, type SemverBump, TOKENS_CSS_MIGRATIONS, type TokensCssMigration, type ValidateInput, collectDefinedTokens, collectReferencedTokens, enforceBreakingRequiresMajor, ensureScale, findContractViolations, removeToken, removeTokensMatching, renameToken, runAdditiveTokensCssMigrations, runTokensCssMigrations, semverBumpType, validateTokensCss };
197
+ export { type BreakingGateResult, type ComponentSource, type ContractViolation, type MissingToken, type RunResult, type SemverBump, TOKENS_CSS_MIGRATIONS, type TokensCssMigration, type ValidateInput, collectDefinedTokens, collectReferencedTokens, collectTokenValues, enforceBreakingRequiresMajor, ensureScale, findContractViolations, removeToken, removeTokensMatching, renameToken, runAdditiveTokensCssMigrations, runTokensCssMigrations, semverBumpType, validateTokensCss };
@@ -2,6 +2,7 @@ import {
2
2
  TOKENS_CSS_MIGRATIONS,
3
3
  collectDefinedTokens,
4
4
  collectReferencedTokens,
5
+ collectTokenValues,
5
6
  enforceBreakingRequiresMajor,
6
7
  ensureScale,
7
8
  findContractViolations,
@@ -12,7 +13,8 @@ import {
12
13
  runTokensCssMigrations,
13
14
  semverBumpType,
14
15
  validateTokensCss
15
- } from "../chunk-44RSTAII.js";
16
+ } from "../chunk-TKZBVIW5.js";
17
+ import "../chunk-YR6GPXW2.js";
16
18
  import {
17
19
  readLiveTokensConfig
18
20
  } from "../chunk-D3ZVKOR4.js";
@@ -20,6 +22,7 @@ export {
20
22
  TOKENS_CSS_MIGRATIONS,
21
23
  collectDefinedTokens,
22
24
  collectReferencedTokens,
25
+ collectTokenValues,
23
26
  enforceBreakingRequiresMajor,
24
27
  ensureScale,
25
28
  findContractViolations,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.50.0",
3
+ "version": "0.52.1",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -110,7 +110,9 @@
110
110
  "check": "svelte-check --tsconfig ./tsconfig.json",
111
111
  "test": "vitest run",
112
112
  "test:watch": "vitest",
113
- "test:e2e": "playwright test",
113
+ "test:e2e": "npm run test:e2e:contract && npm run test:e2e:stateful",
114
+ "test:e2e:contract": "playwright test tests/e2e/component-render-contract.spec.ts tests/e2e/component-alias-contract.spec.ts --workers=4",
115
+ "test:e2e:stateful": "playwright test tests/e2e/live-editing.spec.ts tests/e2e/theme-workflow.spec.ts --workers=1",
114
116
  "test:e2e:components": "playwright test tests/e2e/component-render-contract.spec.ts",
115
117
  "test:e2e:ui": "playwright test --ui",
116
118
  "prepare:e2e": "node scripts/prepare-playwright-data.mjs",
@@ -87,12 +87,14 @@
87
87
  let dismissible = $state(false);
88
88
  let rightOption: ButtonVariantOption = $state('none');
89
89
  let leftOption: ButtonVariantOption = $state('none');
90
+ let headerAction = $state(false);
90
91
  let actions = $derived(((): NotificationActions => {
91
92
  const a: NotificationActions = {};
92
93
  const right = toVariant(rightOption);
93
94
  const left = toVariant(leftOption);
94
95
  if (right) a.right = { label: 'Confirm', variant: right, onClick: () => {} };
95
96
  if (left) a.left = { label: 'Cancel', variant: left, onClick: () => {} };
97
+ if (headerAction) a.header = { label: 'Action', onClick: () => {} };
96
98
  return a;
97
99
  })());
98
100
  </script>
@@ -112,6 +114,10 @@
112
114
  <input type="checkbox" bind:checked={dismissible} />
113
115
  <span>Dismissible</span>
114
116
  </label>
117
+ <label class="toolbar-check">
118
+ <input type="checkbox" bind:checked={headerAction} />
119
+ <span>Header button</span>
120
+ </label>
115
121
  <label class="toolbar-field">
116
122
  <span>Right button</span>
117
123
  <select class="canvas-toolbar-select" bind:value={rightOption}>
@@ -1,137 +1,3 @@
1
- // OKLCH color space conversions — hand-rolled, no dependencies
2
- // Pipeline: sRGB hex → linear RGB → OKLab (via M1×M2 + cube root) → OKLCH (polar)
3
-
4
- export interface Oklch {
5
- l: number; // 0..1
6
- c: number; // 0..~0.4
7
- h: number; // 0..360
8
- }
9
-
10
- // --- sRGB gamma ---
11
-
12
- function srgbToLinear(c: number): number {
13
- return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
14
- }
15
-
16
- function linearToSrgb(c: number): number {
17
- return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
18
- }
19
-
20
- // --- Hex ↔ linear RGB ---
21
-
22
- function hexToLinearRgb(hex: string): [number, number, number] {
23
- const r = parseInt(hex.slice(1, 3), 16) / 255;
24
- const g = parseInt(hex.slice(3, 5), 16) / 255;
25
- const b = parseInt(hex.slice(5, 7), 16) / 255;
26
- return [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)];
27
- }
28
-
29
- function linearRgbToHex(r: number, g: number, b: number): string {
30
- const toHex = (c: number) => {
31
- const v = Math.round(Math.max(0, Math.min(1, linearToSrgb(c))) * 255);
32
- return v.toString(16).padStart(2, '0');
33
- };
34
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
35
- }
36
-
37
- // --- Linear RGB ↔ OKLab ---
38
- // Uses the two-matrix approach from Björn Ottosson's blog
39
-
40
- function linearRgbToOklab(r: number, g: number, b: number): [number, number, number] {
41
- const l_ = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
42
- const m_ = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
43
- const s_ = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
44
-
45
- const l = Math.cbrt(l_);
46
- const m = Math.cbrt(m_);
47
- const s = Math.cbrt(s_);
48
-
49
- return [
50
- 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
51
- 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
52
- 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
53
- ];
54
- }
55
-
56
- function oklabToLinearRgb(L: number, a: number, b: number): [number, number, number] {
57
- const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
58
- const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
59
- const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
60
-
61
- const l = l_ * l_ * l_;
62
- const m = m_ * m_ * m_;
63
- const s = s_ * s_ * s_;
64
-
65
- return [
66
- +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
67
- -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
68
- -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
69
- ];
70
- }
71
-
72
- // --- OKLab ↔ OKLCH ---
73
-
74
- function oklabToOklch(L: number, a: number, b: number): Oklch {
75
- const c = Math.sqrt(a * a + b * b);
76
- let h = (Math.atan2(b, a) * 180) / Math.PI;
77
- if (h < 0) h += 360;
78
- return { l: L, c, h };
79
- }
80
-
81
- function oklchToOklab(l: number, c: number, h: number): [number, number, number] {
82
- const hRad = (h * Math.PI) / 180;
83
- return [l, c * Math.cos(hRad), c * Math.sin(hRad)];
84
- }
85
-
86
- // --- Public API ---
87
-
88
- export function hexToOklch(hex: string): Oklch {
89
- const [r, g, b] = hexToLinearRgb(hex);
90
- const [L, a, bVal] = linearRgbToOklab(r, g, b);
91
- return oklabToOklch(L, a, bVal);
92
- }
93
-
94
- export function oklchToHex(l: number, c: number, h: number): string {
95
- const [L, a, b] = oklchToOklab(l, c, h);
96
- const [r, g, bVal] = oklabToLinearRgb(L, a, b);
97
- return linearRgbToHex(r, g, bVal);
98
- }
99
-
100
- function isInGamut(r: number, g: number, b: number): boolean {
101
- const eps = 0.0001;
102
- return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;
103
- }
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
-
113
- export function gamutClamp(l: number, c: number, h: number): Oklch {
114
- // Clamp lightness
115
- if (l <= 0) return { l: 0, c: 0, h };
116
- if (l >= 1) return { l: 1, c: 0, h };
117
-
118
- // Binary search: reduce chroma until in sRGB gamut
119
- const [L, a, b] = oklchToOklab(l, c, h);
120
- const [r, g, bVal] = oklabToLinearRgb(L, a, b);
121
-
122
- if (isInGamut(r, g, bVal)) return { l, c, h };
123
-
124
- let lo = 0;
125
- let hi = c;
126
- for (let i = 0; i < 20; i++) {
127
- const mid = (lo + hi) / 2;
128
- const [La, aa, ba] = oklchToOklab(l, mid, h);
129
- const [rr, gg, bb] = oklabToLinearRgb(La, aa, ba);
130
- if (isInGamut(rr, gg, bb)) {
131
- lo = mid;
132
- } else {
133
- hi = mid;
134
- }
135
- }
136
- return { l, c: lo, h };
137
- }
1
+ // The math lives in the system layer because `src/system` ships to consumers
2
+ // and cannot import from `src/editor`; `backgroundContrast.ts` needs it too.
3
+ export * from '../../../system/internal/oklch';
@@ -13,8 +13,8 @@
13
13
  * derives the same CSS vars from the persisted config.
14
14
  */
15
15
 
16
- import { hexToOklch, oklchToHexClamped, gamutClamp, type Oklch } from './oklch';
17
- import { type CurveAnchor, sampleCurve, makeAnchor, tangentAnchor } from '../../ui/curveEngine';
16
+ import { cssColorToOklch, oklchToCssClamped, gamutClamp, type Oklch } from './oklch';
17
+ import { type CurveAnchor, sampleCurve, makeAnchor, tangentAnchor, resmoothAutoCurve } from '../../ui/curveEngine';
18
18
  import type { PaletteConfig } from '../themes/themeTypes';
19
19
 
20
20
  export interface PaletteSpec {
@@ -108,8 +108,18 @@ export const SCALES: readonly Scale[] = [
108
108
  export const BW_GUARD_MIN_L = 0.17;
109
109
  export const BW_GUARD_MAX_L = 0.93;
110
110
 
111
- export const DEFAULT_PALETTE_LIGHTNESS = (): CurveAnchor[] => [makeAnchor(0, 95, 5), makeAnchor(100, 8, 5)];
112
- export const DEFAULT_PALETTE_SATURATION = (): CurveAnchor[] => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)];
111
+ /** Defaults ship auto-shaped, so a curve nobody has touched is the one the editor
112
+ * draws with Auto smooth on. Handle lengths come from the points, which is why
113
+ * `makeAnchor`'s tangent argument is left off throughout. */
114
+ const autoCurve = (...anchors: CurveAnchor[]): CurveAnchor[] => resmoothAutoCurve(anchors);
115
+
116
+ export const DEFAULT_PALETTE_LIGHTNESS = (): CurveAnchor[] => autoCurve(makeAnchor(0, 95), makeAnchor(100, 8));
117
+ export const DEFAULT_PALETTE_SATURATION = (): CurveAnchor[] => autoCurve(makeAnchor(0, 100), makeAnchor(100, 100));
118
+ export const DEFAULT_PALETTE_HUE = (): CurveAnchor[] => autoCurve(makeAnchor(0, 0), makeAnchor(100, 0));
119
+
120
+ /** Hue is cyclic, so derivation wraps rather than clamps. The trig accepts
121
+ * any angle; `Oklch.h` is documented 0..360 and readouts print it. */
122
+ const wrapHue = (h: number): number => ((h % 360) + 360) % 360;
113
123
 
114
124
  export type SchemeDirection = 'light' | 'dark';
115
125
 
@@ -133,31 +143,31 @@ export function scaleCurveDefaults(scheme: SchemeDirection = 'dark'): ScaleCurve
133
143
  if (scheme === 'light') {
134
144
  return {
135
145
  Surfaces: {
136
- lightness: () => [makeAnchor(0, 98, 5), makeAnchor(100, 82, 5)],
137
- saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
146
+ lightness: () => autoCurve(makeAnchor(0, 98), makeAnchor(100, 82)),
147
+ saturation: () => autoCurve(makeAnchor(0, 100), makeAnchor(100, 100)),
138
148
  },
139
149
  Borders: {
140
- lightness: () => [makeAnchor(0, 92, 5), makeAnchor(100, 45, 5)],
141
- saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
150
+ lightness: () => autoCurve(makeAnchor(0, 92), makeAnchor(100, 45)),
151
+ saturation: () => autoCurve(makeAnchor(0, 100), makeAnchor(100, 100)),
142
152
  },
143
153
  Text: {
144
- lightness: () => [makeAnchor(0, 30, 30), makeAnchor(100, 120, 30)],
145
- saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 15, 30)],
154
+ lightness: () => autoCurve(makeAnchor(0, 30), makeAnchor(100, 120)),
155
+ saturation: () => autoCurve(makeAnchor(0, 100), makeAnchor(100, 15)),
146
156
  },
147
157
  };
148
158
  }
149
159
  return {
150
160
  Surfaces: {
151
- lightness: () => [makeAnchor(0, 15, 5), makeAnchor(100, 47, 5)],
152
- saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
161
+ lightness: () => autoCurve(makeAnchor(0, 15), makeAnchor(100, 47)),
162
+ saturation: () => autoCurve(makeAnchor(0, 100), makeAnchor(100, 100)),
153
163
  },
154
164
  Borders: {
155
- lightness: () => [makeAnchor(0, 25, 5), makeAnchor(100, 80, 5)],
156
- saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
165
+ lightness: () => autoCurve(makeAnchor(0, 25), makeAnchor(100, 80)),
166
+ saturation: () => autoCurve(makeAnchor(0, 100), makeAnchor(100, 100)),
157
167
  },
158
168
  Text: {
159
- lightness: () => [makeAnchor(0, 120, 30), makeAnchor(100, 55, 30)],
160
- saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 15, 30)],
169
+ lightness: () => autoCurve(makeAnchor(0, 120), makeAnchor(100, 55)),
170
+ saturation: () => autoCurve(makeAnchor(0, 100), makeAnchor(100, 15)),
161
171
  },
162
172
  };
163
173
  }
@@ -191,10 +201,10 @@ export type DerivedValue =
191
201
 
192
202
  /** The single color → CSS-string projection, and the single clamp point for
193
203
  * derived color: unclamped stored intent is chroma-reduced into sRGB gamut,
194
- * then serialized (hex this wave; `oklch()` after Part B). Idempotent on the
195
- * already-in-gamut per-step derivation output. `raw` passes through untouched. */
204
+ * then serialized as `oklch()`. Idempotent on the already-in-gamut per-step
205
+ * derivation output. `raw` passes through untouched. */
196
206
  export function serializeDerivedValue(value: DerivedValue): string {
197
- return value.kind === 'raw' ? value.css : oklchToHexClamped(value.l, value.c, value.h);
207
+ return value.kind === 'raw' ? value.css : oklchToCssClamped(value.l, value.c, value.h);
198
208
  }
199
209
 
200
210
  export function computePaletteOklch(
@@ -203,12 +213,18 @@ export function computePaletteOklch(
203
213
  lightnessCurve: CurveAnchor[],
204
214
  saturationCurve: CurveAnchor[],
205
215
  curveOffset: Record<string, number>,
216
+ hueCurve?: CurveAnchor[],
206
217
  ): Oklch {
207
- const { c: baseC, h } = base;
218
+ const { c: baseC } = base;
208
219
  const xPos = stepIndexToX(index);
209
220
  const targetL = Math.max(0, Math.min(100, sampleCurve(lightnessCurve, xPos) + (curveOffset.lightness ?? 0))) / 100;
210
221
  const satMul = Math.max(0, Math.min(2, (sampleCurve(saturationCurve, xPos) + (curveOffset.saturation ?? 0)) / 100));
211
222
  const targetC = baseC * satMul;
223
+ // Zero delta skips the wrap rather than folding through it: `%360` round-trips
224
+ // introduce a one-ULP drift for most floats already in range, which would break
225
+ // byte-identity (Global invariant 1) for every palette with no hue curve.
226
+ const hueDelta = (hueCurve ? sampleCurve(hueCurve, xPos) : 0) + (curveOffset.hue ?? 0);
227
+ const h = hueDelta === 0 ? base.h : wrapHue(base.h + hueDelta);
212
228
  return gamutClamp(targetL, targetC, h);
213
229
  }
214
230
 
@@ -348,9 +364,10 @@ export function liftCurveAnchor(
348
364
  }
349
365
 
350
366
  /**
351
- * Place the base color in the ramp: pin a lightness anchor (base L) and a
352
- * saturation anchor (multiplier 100) at the step whose lightness is nearest
353
- * the base L, so the picked color renders verbatim at that step. Placement
367
+ * Place the base color in the ramp: pin a lightness anchor (base L), a
368
+ * saturation anchor (multiplier 100), and, when a hue curve exists, a hue
369
+ * anchor (delta 0), all at the step whose lightness is nearest the base L,
370
+ * so the picked color renders verbatim at that step. Placement
354
371
  * is measured against the curve with the previous placement lifted out —
355
372
  * the pinned anchor equals base L at its own x by construction, so measuring
356
373
  * against it would be circular and the anchor could never move. Runs inside
@@ -362,6 +379,9 @@ export function syncBaseAnchor(cfg: PaletteConfig): void {
362
379
  const fresh = prev === undefined;
363
380
  let lCurve = cfg.lightnessCurve;
364
381
  let sCurve = cfg.saturationCurve;
382
+ // Hue is pinned only once a curve exists (RJC 5): an absent curve is flat
383
+ // zero already, so there is nothing to lift or re-place.
384
+ let hCurve = cfg.hueCurve;
365
385
  if (prev) {
366
386
  // Re-placing an existing anchor (a hue move, typically) must preserve
367
387
  // whatever is currently on the curve, edited or not — never restore the
@@ -370,6 +390,7 @@ export function syncBaseAnchor(cfg: PaletteConfig): void {
370
390
  const prevX = stepIndexToX(prev.step);
371
391
  lCurve = liftCurveAnchor(lCurve, prevX, prev.displacedL);
372
392
  sCurve = liftCurveAnchor(sCurve, prevX, prev.displacedS);
393
+ if (hCurve) hCurve = liftCurveAnchor(hCurve, prevX, prev.displacedH);
373
394
  }
374
395
  const step = nearestPaletteStep(lCurve, cfg.baseColor.l);
375
396
  const x = stepIndexToX(step);
@@ -377,6 +398,8 @@ export function syncBaseAnchor(cfg: PaletteConfig): void {
377
398
  const s = setCurveAnchor(sCurve, x, 100, fresh);
378
399
  cfg.lightnessCurve = l.curve;
379
400
  cfg.saturationCurve = s.curve;
401
+ const h = hCurve ? setCurveAnchor(hCurve, x, 0, fresh) : undefined;
402
+ if (h) cfg.hueCurve = h.curve;
380
403
  // priorEndpoints is captured once, on the curve's first-ever placement,
381
404
  // and carried forward untouched thereafter so a later clear can still
382
405
  // restore the true original regardless of how many times the anchor moved.
@@ -384,18 +407,27 @@ export function syncBaseAnchor(cfg: PaletteConfig): void {
384
407
  step,
385
408
  displacedL: l.displacedY,
386
409
  displacedS: s.displacedY,
410
+ displacedH: h?.displacedY,
387
411
  priorLightnessEndpoints: prev?.priorLightnessEndpoints ?? l.priorEndpoints,
388
412
  priorSaturationEndpoints: prev?.priorSaturationEndpoints ?? s.priorEndpoints,
413
+ // No else-branch reading `prev?.priorHueEndpoints`: that could only fire
414
+ // if a placed hue curve later went absent, which RJC 7 (reset never
415
+ // deletes) never lets happen.
416
+ priorHueEndpoints: h ? (prev?.priorHueEndpoints ?? h.priorEndpoints) : undefined,
389
417
  };
390
418
  }
391
419
 
392
- /** Toggle-off: lift the placement out of both curves and drop the flag. */
420
+ /** Toggle-off: lift the placement out of every curve it touched (hue only
421
+ * if present) and drop the flag. */
393
422
  export function clearBaseAnchor(cfg: PaletteConfig): void {
394
423
  const prev = cfg.anchorPlacement;
395
424
  if (prev) {
396
425
  const x = stepIndexToX(prev.step);
397
426
  cfg.lightnessCurve = liftCurveAnchor(cfg.lightnessCurve, x, prev.displacedL, prev.priorLightnessEndpoints);
398
427
  cfg.saturationCurve = liftCurveAnchor(cfg.saturationCurve, x, prev.displacedS, prev.priorSaturationEndpoints);
428
+ if (cfg.hueCurve) {
429
+ cfg.hueCurve = liftCurveAnchor(cfg.hueCurve, x, prev.displacedH, prev.priorHueEndpoints);
430
+ }
399
431
  cfg.anchorPlacement = undefined;
400
432
  }
401
433
  cfg.anchorToBase = false;
@@ -405,7 +437,7 @@ export function computeDerivedOklch(
405
437
  step: Step,
406
438
  base: Oklch,
407
439
  scaleTitle: string,
408
- scaleCurves: Record<string, { lightness: CurveAnchor[]; saturation: CurveAnchor[] }>,
440
+ scaleCurves: Record<string, { lightness: CurveAnchor[]; saturation: CurveAnchor[]; hue?: CurveAnchor[] }>,
409
441
  curveOffset: Record<string, number>,
410
442
  ): Oklch {
411
443
  const scale = SCALES.find((s) => s.title === scaleTitle)!;
@@ -413,8 +445,10 @@ export function computeDerivedOklch(
413
445
  const defs = defaultScaleCurves[scaleTitle];
414
446
  const lCurve = scaleCurves[scaleTitle]?.lightness ?? defs.lightness();
415
447
  const sCurve = scaleCurves[scaleTitle]?.saturation ?? defs.saturation();
448
+ const hCurve = scaleCurves[scaleTitle]?.hue;
416
449
  const lOff = curveOffset[`${scaleTitle}-lightness`] ?? 0;
417
450
  const sOff = curveOffset[`${scaleTitle}-saturation`] ?? 0;
451
+ const hOff = curveOffset[`${scaleTitle}-hue`] ?? 0;
418
452
  const { l: baseL, c: baseC, h: baseH } = base;
419
453
  let targetL: number;
420
454
  if (scale.isText) {
@@ -425,7 +459,10 @@ export function computeDerivedOklch(
425
459
  }
426
460
  const satMul = Math.max(0, Math.min(2, (sampleCurve(sCurve, xPos) + sOff) / 100));
427
461
  const targetC = baseC * satMul;
428
- return gamutClamp(targetL, targetC, baseH);
462
+ // See computePaletteOklch: zero delta skips the wrap to preserve byte-identity.
463
+ const hueDelta = (hCurve ? sampleCurve(hCurve, xPos) : 0) + hOff;
464
+ const targetH = hueDelta === 0 ? baseH : wrapHue(baseH + hueDelta);
465
+ return gamutClamp(targetL, targetC, targetH);
429
466
  }
430
467
 
431
468
  export function scaleToCssVar(scaleTitle: string, stepName: string, cssNamespace: string | null): string | null {
@@ -461,7 +498,7 @@ export function derivePaletteValues(spec: PaletteSpec, config: PaletteConfig | u
461
498
  // Overrides are numeric OKLCH intent; pass straight through as color-kind.
462
499
  const value: DerivedValue = (k in overrides)
463
500
  ? { kind: 'color', ...overrides[k] }
464
- : { kind: 'color', ...computePaletteOklch(index, baseColor, lightnessCurve, saturationCurve, curveOffset) };
501
+ : { kind: 'color', ...computePaletteOklch(index, baseColor, lightnessCurve, saturationCurve, curveOffset, config.hueCurve) };
465
502
  out[`--color-${spec.cssNamespace}-${ps.label}`] = value;
466
503
  });
467
504
 
@@ -512,8 +549,8 @@ export function derivePaletteValues(spec: PaletteSpec, config: PaletteConfig | u
512
549
  const stopsCss = sortedStops
513
550
  .map((s) => {
514
551
  const stopValue = out[`--color-${spec.cssNamespace}-${s.paletteLabel}`];
515
- const hex = stopValue ? serializeDerivedValue(stopValue) : '#000000';
516
- return `${hex} ${s.position}%`;
552
+ const css = stopValue ? serializeDerivedValue(stopValue) : 'oklch(0 0 0)';
553
+ return `${css} ${s.position}%`;
517
554
  })
518
555
  .join(', ');
519
556
  let gradient: string;
@@ -554,8 +591,6 @@ export function palettesToVars(palettes: Record<string, PaletteConfig>): Record<
554
591
  return out;
555
592
  }
556
593
 
557
- const HEX_RE = /^#[0-9a-f]{6}$/i;
558
-
559
594
  /**
560
595
  * Reconcile palette typed state against the catch-all `cssVariables` bag and
561
596
  * report the set of variable names the typed slice now owns. Two operations:
@@ -596,11 +631,13 @@ export function reconcilePalettesFromCssVars(
596
631
  if (current === undefined) continue;
597
632
 
598
633
  if (current._imported === true) {
599
- const anchorHex = cssVars[`--color-${spec.cssNamespace}-500`];
600
- if (anchorHex && HEX_RE.test(anchorHex.trim())) {
601
- // Input boundary: the imported cssVariables anchor is a hex string;
602
- // parse it to the OKLCH basis exactly once, here.
603
- next[spec.label] = { ...current, baseColor: hexToOklch(anchorHex.trim()), _imported: false };
634
+ const anchor = cssVars[`--color-${spec.cssNamespace}-500`];
635
+ const anchorOklch = anchor ? cssColorToOklch(anchor) : null;
636
+ if (anchorOklch) {
637
+ // Input boundary: the imported cssVariables anchor is a CSS string in
638
+ // either serialization this system has shipped (hex before the OKLCH
639
+ // flip, `oklch()` after); parse it to the basis exactly once, here.
640
+ next[spec.label] = { ...current, baseColor: anchorOklch, _imported: false };
604
641
  syncBaseAnchor(next[spec.label]);
605
642
  snapped.add(spec.label);
606
643
  } else {
@@ -611,7 +648,12 @@ export function reconcilePalettesFromCssVars(
611
648
  }
612
649
  }
613
650
 
614
- for (const k of Object.keys(derivePaletteVars(spec, next[spec.label]))) {
651
+ // Key names only: which variables this palette owns is a structural
652
+ // question, so read the IR rather than serializing values that are
653
+ // discarded. Callers reach here with pre-migration configs whose
654
+ // `baseColor` is still a hex string; serializing those derived NaNs used
655
+ // to produce junk strings nobody read.
656
+ for (const k of Object.keys(derivePaletteValues(spec, next[spec.label]))) {
615
657
  consumed.add(k);
616
658
  }
617
659
  }
@@ -5,7 +5,7 @@
5
5
  * `dist-plugin/generateColorsAndType`, so keep this module Node-safe (no DOM).
6
6
  */
7
7
 
8
- import { hexToOklch, type Oklch } from '../palettes/oklch';
8
+ import { hexToOklch, cssColorToOklch, oklchToHexClamped, type Oklch } from '../palettes/oklch';
9
9
  import {
10
10
  PALETTE_SPECS,
11
11
  PALETTE_STEPS,
@@ -201,6 +201,15 @@ function surfaceVars(): string[] {
201
201
  ];
202
202
  }
203
203
 
204
+ /** Derived vars serialize as `oklch()` while the WCAG contrast API is
205
+ * sRGB-terminated (decision 7), so project back to hex at this boundary.
206
+ * Gradient `--page-bg` parses to null and is skipped, as it was when these
207
+ * call sites read hex directly. */
208
+ function varToHex(value: string | undefined): string | null {
209
+ const parsed = value ? cssColorToOklch(value) : null;
210
+ return parsed ? oklchToHexClamped(parsed.l, parsed.c, parsed.h) : null;
211
+ }
212
+
204
213
  function worstSurface(
205
214
  textHex: string,
206
215
  vars: Record<string, string>,
@@ -208,8 +217,8 @@ function worstSurface(
208
217
  let against = '';
209
218
  let ratio = Infinity;
210
219
  for (const surfVar of surfaceVars()) {
211
- const surfHex = vars[surfVar];
212
- if (!surfHex || !HEX_RE.test(surfHex)) continue;
220
+ const surfHex = varToHex(vars[surfVar]);
221
+ if (!surfHex) continue;
213
222
  const r = contrastRatio(textHex, surfHex);
214
223
  if (r < ratio) {
215
224
  ratio = r;
@@ -248,17 +257,18 @@ function runContrastGate(
248
257
  const vars = palettesToVars(configs);
249
258
  let anyFixed = false;
250
259
  for (const def of defs) {
251
- const textHex = vars[def.textVar];
252
- if (!textHex) continue;
260
+ const text = cssColorToOklch(vars[def.textVar] ?? '');
261
+ if (!text) continue;
262
+ const textHex = oklchToHexClamped(text.l, text.c, text.h);
253
263
  const worst = worstSurface(textHex, vars);
254
264
  if (worst.ratio >= def.floor) continue;
255
265
  const cfg = configs[def.paletteLabel];
256
266
  const seed = cfg.baseColor;
257
267
  const solved = findLForContrast({
258
- against: vars[worst.against],
268
+ against: varToHex(vars[worst.against]) ?? '#000000',
259
269
  ratio: def.target,
260
270
  direction,
261
- c: hexToOklch(textHex).c,
271
+ c: text.c,
262
272
  h: seed.h,
263
273
  lMax: Math.min(1, 2 * seed.l),
264
274
  });
@@ -271,7 +281,7 @@ function runContrastGate(
271
281
 
272
282
  const finalVars = palettesToVars(configs);
273
283
  const checks: ContrastCheck[] = defs.map((def) => {
274
- const worst = worstSurface(finalVars[def.textVar] ?? '#000000', finalVars);
284
+ const worst = worstSurface(varToHex(finalVars[def.textVar]) ?? '#000000', finalVars);
275
285
  return {
276
286
  textVar: def.textVar,
277
287
  floor: def.floor,
@@ -16,8 +16,17 @@ export interface PaletteConfig {
16
16
  baseColor: Oklch;
17
17
  lightnessCurve: CurveAnchor[];
18
18
  saturationCurve: CurveAnchor[];
19
- scaleCurves: Record<string, { lightness: CurveAnchor[]; saturation: CurveAnchor[] }>;
19
+ /** Signed hue offset in degrees per step, applied on top of the base hue.
20
+ * Absent means flat zero: the palette holds one hue, which is what every
21
+ * theme saved before this field did. */
22
+ hueCurve?: CurveAnchor[];
23
+ scaleCurves: Record<string, { lightness: CurveAnchor[]; saturation: CurveAnchor[]; hue?: CurveAnchor[] }>;
20
24
  curveOffset: Record<string, number>;
25
+ /** Per curve key: are the tangents derived from the points, or held by hand?
26
+ * Only an explicit switch-off is recorded. A missing key means the curve's own
27
+ * shape answers (`isAutoSmoothCurve`), which is what every theme saved before
28
+ * the flag existed relies on. */
29
+ curveAutoSmooth?: Record<string, boolean>;
21
30
  overrides: Record<string, Oklch>;
22
31
  snappedScales: string[];
23
32
  emptyMode?: 'solid' | 'gradient';
@@ -30,11 +39,12 @@ export interface PaletteConfig {
30
39
  /**
31
40
  * Where the base color sits in the ramp while `anchorToBase` is on: the
32
41
  * PALETTE_STEPS index whose curve anchors are pinned to the base color
33
- * (lightness = base L, saturation = multiplier 100). `displacedL` /
34
- * `displacedS` remember the y of a pre-existing anchor (endpoint or
35
- * user-authored) the placement overwrote at that x, so moving the
36
- * placement or toggling off restores it instead of deleting it.
37
- * `priorLightnessEndpoints` / `priorSaturationEndpoints` remember the two
42
+ * (lightness = base L, saturation = multiplier 100, hue delta = 0 when a
43
+ * hue curve exists). `displacedL` / `displacedS` / `displacedH` remember
44
+ * the y of a pre-existing anchor (endpoint or user-authored) the placement
45
+ * overwrote at that x, so moving the placement or toggling off restores it
46
+ * instead of deleting it. `priorLightnessEndpoints` /
47
+ * `priorSaturationEndpoints` / `priorHueEndpoints` remember the two
38
48
  * endpoints exactly as they stood before the curve's first-ever placement
39
49
  * (a smoothed reshape, not algebraically invertible the way a later
40
50
  * scaled-handle re-placement is), so a clear can still restore the true
@@ -44,8 +54,10 @@ export interface PaletteConfig {
44
54
  step: number;
45
55
  displacedL?: number;
46
56
  displacedS?: number;
57
+ displacedH?: number;
47
58
  priorLightnessEndpoints?: [CurveAnchor, CurveAnchor];
48
59
  priorSaturationEndpoints?: [CurveAnchor, CurveAnchor];
60
+ priorHueEndpoints?: [CurveAnchor, CurveAnchor];
49
61
  };
50
62
  /**
51
63
  * Set to true by importers when they overlay `cssVariables[--color-{ns}-*]`