@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.
- package/CHANGELOG.md +45 -0
- package/dist-plugin/adjust/index.d.cts +1 -1
- package/dist-plugin/adjust/index.d.ts +1 -1
- package/dist-plugin/{chunk-76TFDTJO.js → chunk-JIGIIE5P.js} +60 -132
- package/dist-plugin/{chunk-44RSTAII.js → chunk-TKZBVIW5.js} +33 -5
- package/dist-plugin/chunk-YR6GPXW2.js +129 -0
- package/dist-plugin/generateColorsAndType/index.cjs +87 -31
- package/dist-plugin/generateColorsAndType/index.d.cts +1 -1
- package/dist-plugin/generateColorsAndType/index.d.ts +1 -1
- package/dist-plugin/generateColorsAndType/index.js +22 -12
- package/dist-plugin/index.cjs +95 -55
- package/dist-plugin/index.js +5 -3
- package/dist-plugin/{themeTypes-DSwKq-bj.d.cts → themeTypes-Dky03HL9.d.cts} +18 -5
- package/dist-plugin/{themeTypes-DSwKq-bj.d.ts → themeTypes-Dky03HL9.d.ts} +18 -5
- package/dist-plugin/tokensCssMigrations/index.cjs +71 -5
- package/dist-plugin/tokensCssMigrations/index.d.cts +20 -9
- package/dist-plugin/tokensCssMigrations/index.d.ts +20 -9
- package/dist-plugin/tokensCssMigrations/index.js +4 -1
- package/package.json +4 -2
- package/src/editor/component-editor/NotificationEditor.svelte +6 -0
- package/src/editor/core/palettes/oklch.ts +3 -137
- package/src/editor/core/palettes/paletteDerivation.ts +79 -37
- package/src/editor/core/themes/generateColorsAndType.ts +18 -8
- package/src/editor/core/themes/themeTypes.ts +18 -6
- package/src/editor/docs/content/editing-tokens.md +6 -2
- package/src/editor/docs/content.generated.ts +1 -1
- package/src/editor/ui/BezierCurveEditor.svelte +90 -67
- package/src/editor/ui/PaletteEditor.svelte +150 -44
- package/src/editor/ui/Toggle.svelte +14 -2
- package/src/editor/ui/colors/ColorStory.svelte +12 -8
- package/src/editor/ui/curveEngine.ts +101 -21
- package/src/editor/ui/palette/OverridesPanel.svelte +19 -5
- package/src/editor/ui/palette/PaletteBase.svelte +17 -78
- package/src/editor/ui/palette/ScaleCurveEditor.svelte +109 -20
- package/src/editor/ui/palette/curveSummary.ts +20 -0
- package/src/editor/ui/palette/dockMagnify.ts +5 -13
- package/src/editor/ui/palette/paletteMath.ts +7 -4
- package/src/live-tokens/data/colors-and-type/autumn.json +528 -528
- package/src/live-tokens/data/colors-and-type/default.json +247 -247
- package/src/live-tokens/data/colors-and-type/halloween.json +532 -532
- package/src/live-tokens/data/colors-and-type/midnight-study.json +538 -538
- package/src/live-tokens/data/colors-and-type/ocean.json +521 -521
- package/src/live-tokens/data/colors-and-type/royal-velvet.json +532 -532
- package/src/live-tokens/data/colors-and-type/spring-meadow.json +504 -504
- package/src/live-tokens/data/colors-and-type/sunset.json +531 -531
- package/src/live-tokens/data/themes/autumn.json +527 -527
- package/src/live-tokens/data/themes/halloween.json +531 -531
- package/src/live-tokens/data/themes/midnight-study.json +537 -537
- package/src/live-tokens/data/themes/ocean.json +520 -520
- package/src/live-tokens/data/themes/royal-velvet.json +531 -531
- package/src/live-tokens/data/themes/spring-meadow.json +504 -504
- package/src/live-tokens/data/themes/sunset.json +531 -531
- package/src/live-tokens/data/tokens.generated.css +282 -282
- package/src/system/components/FloatingTokenTags.css +0 -1
- package/src/system/internal/backgroundContrast.ts +7 -0
- package/src/system/internal/oklch.ts +189 -0
- 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
|
|
129
|
-
* remove or rename a token
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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-
|
|
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.
|
|
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": "
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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 {
|
|
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
|
-
|
|
112
|
-
|
|
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: () =>
|
|
137
|
-
saturation: () =>
|
|
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: () =>
|
|
141
|
-
saturation: () =>
|
|
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: () =>
|
|
145
|
-
saturation: () =>
|
|
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: () =>
|
|
152
|
-
saturation: () =>
|
|
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: () =>
|
|
156
|
-
saturation: () =>
|
|
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: () =>
|
|
160
|
-
saturation: () =>
|
|
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
|
|
195
|
-
*
|
|
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 :
|
|
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
|
|
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)
|
|
352
|
-
* saturation anchor (multiplier 100)
|
|
353
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
516
|
-
return `${
|
|
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
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
//
|
|
603
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
252
|
-
if (!
|
|
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:
|
|
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
|
-
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* placement or toggling off restores it
|
|
37
|
-
* `priorLightnessEndpoints` /
|
|
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}-*]`
|