@motion-proto/live-tokens 0.47.0 → 0.48.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/.claude/skills/live-tokens-adjust-shape-space/SKILL.md +68 -0
- package/.claude/skills/live-tokens-create-component/SKILL.md +2 -2
- package/.claude/skills/live-tokens-generate-theme/SKILL.md +152 -0
- package/CHANGELOG.md +292 -0
- package/README.md +37 -26
- package/bin/adjust.mjs +254 -0
- package/bin/cli.mjs +94 -7
- package/bin/generate-theme.mjs +251 -0
- package/bin/migrate.mjs +95 -6
- package/dist-plugin/adjust/index.cjs +259 -0
- package/dist-plugin/adjust/index.d.cts +45 -0
- package/dist-plugin/adjust/index.d.ts +45 -0
- package/dist-plugin/adjust/index.js +176 -0
- package/dist-plugin/{chunk-H4TRUINI.js → chunk-44RSTAII.js} +0 -48
- package/dist-plugin/chunk-6OZFXIQI.js +316 -0
- package/dist-plugin/chunk-76TFDTJO.js +556 -0
- package/dist-plugin/chunk-D3ZVKOR4.js +52 -0
- package/dist-plugin/dataPaths-DBN0RPuT.d.cts +54 -0
- package/dist-plugin/dataPaths-DBN0RPuT.d.ts +54 -0
- package/dist-plugin/generateColorsAndType/index.cjs +1781 -0
- package/dist-plugin/generateColorsAndType/index.d.cts +81 -0
- package/dist-plugin/generateColorsAndType/index.d.ts +81 -0
- package/dist-plugin/generateColorsAndType/index.js +1210 -0
- package/dist-plugin/index.cjs +901 -533
- package/dist-plugin/index.d.cts +3 -2
- package/dist-plugin/index.d.ts +3 -2
- package/dist-plugin/index.js +658 -1082
- package/dist-plugin/migrateData/index.cjs +728 -0
- package/dist-plugin/migrateData/index.d.cts +60 -0
- package/dist-plugin/migrateData/index.d.ts +60 -0
- package/dist-plugin/migrateData/index.js +348 -0
- package/dist-plugin/themeTypes-DMHZOnUn.d.cts +211 -0
- package/dist-plugin/themeTypes-DMHZOnUn.d.ts +211 -0
- package/dist-plugin/tokensCssMigrations/index.cjs +2 -1
- package/dist-plugin/tokensCssMigrations/index.d.cts +3 -15
- package/dist-plugin/tokensCssMigrations/index.d.ts +3 -15
- package/dist-plugin/tokensCssMigrations/index.js +4 -2
- package/package.json +18 -4
- package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +183 -158
- package/src/editor/component-editor/scaffolding/ComponentFileMenu.svelte +0 -4
- package/src/editor/component-editor/scaffolding/SaveAsDialog.svelte +4 -4
- package/src/editor/component-editor/scaffolding/TokenLayout.svelte +2 -59
- package/src/editor/component-editor/scaffolding/VariantGroup.svelte +1 -1
- package/src/editor/core/components/adjustAliases.ts +180 -0
- package/src/editor/core/components/aliasKinds.ts +73 -0
- package/src/editor/core/components/componentConfigService.ts +26 -38
- package/src/editor/core/flashStatus.ts +1 -1
- package/src/editor/core/fonts/fontMigration.ts +13 -13
- package/src/editor/core/fonts/fontPairing.ts +35 -0
- package/src/editor/core/palettes/paletteDerivation.ts +128 -16
- package/src/editor/core/preview/lookPreview.ts +141 -0
- package/src/editor/core/productionPulse.ts +34 -21
- package/src/editor/core/storage/files/versionedFileResourceClient.ts +18 -45
- package/src/editor/core/store/editorConfigStore.ts +2 -2
- package/src/editor/core/store/editorPersistence.ts +0 -1
- package/src/editor/core/store/editorStore.ts +114 -51
- package/src/editor/core/store/editorTypes.ts +0 -1
- package/src/editor/core/store/gradientSource.ts +2 -2
- package/src/editor/core/themes/colorsAndTypeService.ts +95 -0
- package/src/editor/core/themes/generateColorsAndType.ts +433 -0
- package/src/editor/core/themes/loadRows.ts +64 -0
- package/src/editor/core/themes/lookSummary.ts +75 -0
- package/src/editor/core/themes/migrations/2026-04-24-legacy-keys-and-bg-to-canvas.ts +3 -3
- package/src/editor/core/themes/migrations/2026-05-13-primary-to-brand.ts +2 -2
- package/src/editor/core/themes/migrations/2026-05-26-drop-overlay-extra-stops.ts +3 -3
- package/src/editor/core/themes/migrations/2026-08-13-drop-legacy-shape-space-keys.ts +38 -0
- package/src/editor/core/themes/migrations/2026-08-15-line-height-scale-rename.ts +57 -0
- package/src/editor/core/themes/migrations/index.ts +19 -11
- package/src/editor/core/themes/slices/components.ts +4 -4
- package/src/editor/core/themes/slices/fonts.ts +1 -1
- package/src/editor/core/themes/slices/gradients.ts +22 -0
- package/src/editor/core/themes/slices/palettes.ts +2 -2
- package/src/editor/core/themes/themeInit.ts +21 -29
- package/src/editor/core/themes/themeService.ts +218 -78
- package/src/editor/core/themes/themeTypes.ts +94 -51
- package/src/editor/docs/Docs.svelte +1 -0
- package/src/editor/docs/chapters.ts +1 -0
- package/src/editor/docs/content/01-overview.md +1 -1
- package/src/editor/docs/content/editing-tokens.md +6 -6
- package/src/editor/docs/content/getting-started.md +9 -7
- package/src/editor/docs/content/themes-workflow.md +74 -34
- package/src/editor/docs/content/where-themes-live.md +61 -0
- package/src/editor/docs/content.generated.ts +5 -4
- package/src/editor/index.ts +27 -27
- package/src/editor/pages/ComponentEditorPage.svelte +2 -2
- package/src/editor/pages/EditorShell.svelte +4 -30
- package/src/editor/ui/BezierCurveEditor.svelte +2 -2
- package/src/editor/ui/ColorEditPanel.svelte +1 -1
- package/src/editor/ui/FileLoadList.svelte +67 -7
- package/src/editor/ui/GradientEditor.svelte +3 -3
- package/src/editor/ui/PaletteEditor.svelte +26 -12
- package/src/editor/ui/ThemePanel.svelte +1067 -0
- package/src/editor/ui/UIDialog.svelte +11 -9
- package/src/editor/ui/UIPillButton.svelte +7 -2
- package/src/editor/ui/colors/ColorWheel.svelte +34 -1
- package/src/editor/ui/curveEngine.ts +48 -0
- package/src/editor/ui/index.ts +4 -2
- package/src/editor/ui/palette/PaletteBase.svelte +75 -51
- package/src/editor/ui/palette/PaletteJumpButton.svelte +3 -8
- package/src/live-tokens/data/colors-and-type/autumn.json +2490 -0
- package/src/live-tokens/data/{themes → colors-and-type}/default.json +816 -293
- package/src/live-tokens/data/colors-and-type/halloween.json +2529 -0
- package/src/live-tokens/data/colors-and-type/midnight-study.json +2536 -0
- package/src/live-tokens/data/colors-and-type/ocean.json +2489 -0
- package/src/live-tokens/data/colors-and-type/royal-velvet.json +2520 -0
- package/src/live-tokens/data/colors-and-type/spring-meadow.json +2476 -0
- package/src/live-tokens/data/colors-and-type/sunset.json +2528 -0
- package/src/live-tokens/data/themes/autumn.json +3918 -0
- package/src/live-tokens/data/themes/halloween.json +3992 -0
- package/src/live-tokens/data/themes/midnight-study.json +3932 -0
- package/src/live-tokens/data/themes/ocean.json +3917 -0
- package/src/live-tokens/data/themes/royal-velvet.json +3983 -0
- package/src/live-tokens/data/themes/spring-meadow.json +3904 -0
- package/src/live-tokens/data/themes/sunset.json +3931 -0
- package/src/live-tokens/data/tokens.generated.css +106 -117
- package/src/system/styles/CONVENTIONS.md +1 -1
- package/src/system/styles/fonts.css +1 -1
- package/template/README.md +7 -5
- package/template/_gitignore +0 -6
- package/src/editor/core/components/componentPersist.ts +0 -62
- package/src/editor/core/manifests/manifestService.ts +0 -172
- package/src/editor/ui/ManifestFileManager.svelte +0 -446
- package/src/editor/ui/ThemeFileManager.svelte +0 -785
- package/src/live-tokens/data/manifests/default.json +0 -35
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brief → generator: a full ColorsAndType from 10 OKLCH seeds + a scheme, with
|
|
3
|
+
* AA floors enforced on derived text tokens. Seed *selection* lives in the
|
|
4
|
+
* live-tokens-generate-theme skill; the CLI reaches this via
|
|
5
|
+
* `dist-plugin/generateColorsAndType`, so keep this module Node-safe (no DOM).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { hexToOklch, type Oklch } from '../palettes/oklch';
|
|
9
|
+
import {
|
|
10
|
+
PALETTE_SPECS,
|
|
11
|
+
PALETTE_STEPS,
|
|
12
|
+
SCALES,
|
|
13
|
+
type SchemeDirection,
|
|
14
|
+
type Step,
|
|
15
|
+
palettesToVars,
|
|
16
|
+
scaleStepToX,
|
|
17
|
+
scaleToCssVar,
|
|
18
|
+
setCurveAnchor,
|
|
19
|
+
} from '../palettes/paletteDerivation';
|
|
20
|
+
import { formatGradientValue, makeDefaultGradients } from './slices/gradients';
|
|
21
|
+
import { AA_BODY, AA_LARGE, contrastRatio, findLForContrast } from '../palettes/contrast';
|
|
22
|
+
import { type HarmonyAxis, type HarmonyMode } from '../palettes/colorHarmony';
|
|
23
|
+
import { defaultPaletteConfig } from '../../ui/palette/paletteMath';
|
|
24
|
+
import { sanitizeFileName } from '../storage/files/versionedFileResourceClient';
|
|
25
|
+
import { CURRENT_COLORS_AND_TYPE_SCHEMA_VERSION } from './migrations/index';
|
|
26
|
+
import type { FontSource, FontStack, GradientDiskToken, PaletteConfig, ColorsAndType } from './themeTypes';
|
|
27
|
+
|
|
28
|
+
export interface ColorsAndTypeBrief {
|
|
29
|
+
name: string;
|
|
30
|
+
scheme: SchemeDirection;
|
|
31
|
+
/** One seed per palette label (all 10 required); hex string or numeric OKLCH. */
|
|
32
|
+
seeds: Record<string, Oklch | string>;
|
|
33
|
+
/** Advisory record of the harmony reasoning; seeds stay ground truth. */
|
|
34
|
+
harmony?: { mode: HarmonyMode };
|
|
35
|
+
/** Page-background sky. Off by default; the skill turns it on only when the
|
|
36
|
+
* mood brief evokes atmosphere. The engine derives the stops itself, on the
|
|
37
|
+
* scheme's safe side of the Canvas anchor. */
|
|
38
|
+
canvasGradient?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Non-color content carried forward from the currently active colors and type
|
|
42
|
+
* so gradients, shadows, component aliases, and fonts survive regeneration. */
|
|
43
|
+
export interface CarryForward {
|
|
44
|
+
cssVariables?: Record<string, string>;
|
|
45
|
+
fontSources?: FontSource[];
|
|
46
|
+
fontStacks?: FontStack[];
|
|
47
|
+
gradients?: GradientDiskToken[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ContrastCheck {
|
|
51
|
+
textVar: string;
|
|
52
|
+
floor: number;
|
|
53
|
+
/** Worst (lowest-ratio) surface this text was checked against. */
|
|
54
|
+
against: string;
|
|
55
|
+
ratio: number;
|
|
56
|
+
pass: boolean;
|
|
57
|
+
corrected: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface GenerateColorsAndTypeReport {
|
|
61
|
+
scheme: SchemeDirection;
|
|
62
|
+
checks: ContrastCheck[];
|
|
63
|
+
failures: string[];
|
|
64
|
+
/** 'recipes' when the engine replaced absent/stock swatch gradients with the
|
|
65
|
+
* family-relative recipes; 'carried' when user-tuned ones rode through. */
|
|
66
|
+
gradients: 'recipes' | 'carried';
|
|
67
|
+
/** Present when the brief opted into the page-background sky: either
|
|
68
|
+
* 'on, <sky> → <anchor>' or a 'skipped — …' explanation when the Canvas
|
|
69
|
+
* anchors at the ramp edge and leaves no room. */
|
|
70
|
+
canvasGradient?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface GenerateColorsAndTypeResult {
|
|
74
|
+
colorsAndType: ColorsAndType;
|
|
75
|
+
slug: string;
|
|
76
|
+
report: GenerateColorsAndTypeReport;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const HEX_RE = /^#[0-9a-f]{6}$/i;
|
|
80
|
+
const HARMONY_MODES: readonly string[] = [
|
|
81
|
+
'complementary', 'split-complementary', 'triadic', 'tetradic',
|
|
82
|
+
'compound', 'square', 'analogous', 'monochromatic', 'custom',
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const TEXT_SCALE = SCALES.find((s) => s.title === 'Text')!;
|
|
86
|
+
const SURFACE_SCALE = SCALES.find((s) => s.title === 'Surfaces')!;
|
|
87
|
+
|
|
88
|
+
/** [step, floor, solve target]. The floor is the pass criterion; the target is
|
|
89
|
+
* what the solver aims for when a correction fires, so corrected text lands
|
|
90
|
+
* comfortably readable instead of at the knife edge (dark-theme body text at
|
|
91
|
+
* exactly 4.5:1 reads muddy — Material puts primary text far above AA). When
|
|
92
|
+
* the target is unreachable the solver returns its best effort, judged
|
|
93
|
+
* against the floor. */
|
|
94
|
+
const NEUTRAL_TEXT_FLOORS: ReadonlyArray<readonly [string, number, number]> = [
|
|
95
|
+
['primary', AA_BODY, 7],
|
|
96
|
+
['secondary', AA_BODY, 5.5],
|
|
97
|
+
['tertiary', AA_LARGE, 3.5],
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const FUNCTIONAL_TEXT_TARGET = 3.5;
|
|
101
|
+
|
|
102
|
+
/** Functional text families checked at AA_LARGE against the neutral band.
|
|
103
|
+
* Neutral gets the three-step check above; Canvas's `--text-canvas` is not a
|
|
104
|
+
* text-on-surface role (Canvas is the page-background family). */
|
|
105
|
+
const FUNCTIONAL_TEXT_LABELS = PALETTE_SPECS
|
|
106
|
+
.filter((s) => s.label !== 'Neutral' && s.label !== 'Canvas')
|
|
107
|
+
.map((s) => s.label);
|
|
108
|
+
|
|
109
|
+
const norm = (h: number): number => (h >= 0 && h < 360 ? h : ((h % 360) + 360) % 360);
|
|
110
|
+
|
|
111
|
+
function parseSeed(label: string, raw: Oklch | string, problems: string[]): Oklch | null {
|
|
112
|
+
if (typeof raw === 'string') {
|
|
113
|
+
if (!HEX_RE.test(raw.trim())) {
|
|
114
|
+
problems.push(`${label}: "${raw}" is not a #rrggbb hex color`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return hexToOklch(raw.trim());
|
|
118
|
+
}
|
|
119
|
+
const { l, c, h } = raw ?? ({} as Oklch);
|
|
120
|
+
if (typeof l !== 'number' || l <= 0 || l >= 1) problems.push(`${label}: l must be in (0, 1), got ${l}`);
|
|
121
|
+
if (typeof c !== 'number' || c < 0 || c > 0.4) problems.push(`${label}: c must be in [0, 0.4], got ${c}`);
|
|
122
|
+
if (typeof h !== 'number' || !Number.isFinite(h)) problems.push(`${label}: h must be a finite number, got ${h}`);
|
|
123
|
+
if (problems.length > 0) return null;
|
|
124
|
+
return { l, c, h: norm(h) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function validateBrief(brief: ColorsAndTypeBrief): { seeds: Record<string, Oklch>; slug: string } {
|
|
128
|
+
const problems: string[] = [];
|
|
129
|
+
const name = typeof brief.name === 'string' ? brief.name.trim() : '';
|
|
130
|
+
if (!name) problems.push('name: required');
|
|
131
|
+
const slug = sanitizeFileName(name || 'unnamed');
|
|
132
|
+
if (slug === 'default') problems.push('name: "default" is the protected package theme; pick another name');
|
|
133
|
+
if (brief.scheme !== 'light' && brief.scheme !== 'dark') {
|
|
134
|
+
problems.push(`scheme: must be "light" or "dark", got ${JSON.stringify(brief.scheme)}`);
|
|
135
|
+
}
|
|
136
|
+
if (brief.harmony && !HARMONY_MODES.includes(brief.harmony.mode)) {
|
|
137
|
+
problems.push(`harmony.mode: unknown mode ${JSON.stringify(brief.harmony.mode)}`);
|
|
138
|
+
}
|
|
139
|
+
if (brief.canvasGradient !== undefined && typeof brief.canvasGradient !== 'boolean') {
|
|
140
|
+
problems.push(`canvasGradient: must be a boolean, got ${JSON.stringify(brief.canvasGradient)}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const seeds: Record<string, Oklch> = {};
|
|
144
|
+
const given = brief.seeds ?? {};
|
|
145
|
+
for (const spec of PALETTE_SPECS) {
|
|
146
|
+
if (!(spec.label in given)) {
|
|
147
|
+
problems.push(`seeds.${spec.label}: missing`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const parsed = parseSeed(spec.label, given[spec.label], problems);
|
|
151
|
+
if (parsed) seeds[spec.label] = parsed;
|
|
152
|
+
}
|
|
153
|
+
const known = new Set<string>(PALETTE_SPECS.map((s) => s.label));
|
|
154
|
+
for (const label of Object.keys(given)) {
|
|
155
|
+
if (!known.has(label)) problems.push(`seeds.${label}: unknown palette (expected ${[...known].join(', ')})`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (problems.length > 0) {
|
|
159
|
+
throw new Error(`Invalid theme brief:\n ${problems.join('\n ')}`);
|
|
160
|
+
}
|
|
161
|
+
return { seeds, slug };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface CheckDef {
|
|
165
|
+
paletteLabel: string;
|
|
166
|
+
step: Step;
|
|
167
|
+
textVar: string;
|
|
168
|
+
floor: number;
|
|
169
|
+
target: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function checkDefs(): CheckDef[] {
|
|
173
|
+
const defs: CheckDef[] = [];
|
|
174
|
+
for (const [stepName, floor, target] of NEUTRAL_TEXT_FLOORS) {
|
|
175
|
+
defs.push({
|
|
176
|
+
paletteLabel: 'Neutral',
|
|
177
|
+
step: TEXT_SCALE.steps.find((s) => s.name === stepName)!,
|
|
178
|
+
textVar: scaleToCssVar('Text', stepName, 'neutral')!,
|
|
179
|
+
floor,
|
|
180
|
+
target,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const primary = TEXT_SCALE.steps.find((s) => s.name === 'primary')!;
|
|
184
|
+
for (const label of FUNCTIONAL_TEXT_LABELS) {
|
|
185
|
+
const ns = PALETTE_SPECS.find((s) => s.label === label)!.cssNamespace;
|
|
186
|
+
defs.push({
|
|
187
|
+
paletteLabel: label,
|
|
188
|
+
step: primary,
|
|
189
|
+
textVar: scaleToCssVar('Text', 'primary', ns)!,
|
|
190
|
+
floor: AA_LARGE,
|
|
191
|
+
target: FUNCTIONAL_TEXT_TARGET,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return defs;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function surfaceVars(): string[] {
|
|
198
|
+
return [
|
|
199
|
+
...SURFACE_SCALE.steps.map((s) => scaleToCssVar('Surfaces', s.name, 'neutral')!),
|
|
200
|
+
'--page-bg',
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function worstSurface(
|
|
205
|
+
textHex: string,
|
|
206
|
+
vars: Record<string, string>,
|
|
207
|
+
): { against: string; ratio: number } {
|
|
208
|
+
let against = '';
|
|
209
|
+
let ratio = Infinity;
|
|
210
|
+
for (const surfVar of surfaceVars()) {
|
|
211
|
+
const surfHex = vars[surfVar];
|
|
212
|
+
if (!surfHex || !HEX_RE.test(surfHex)) continue;
|
|
213
|
+
const r = contrastRatio(textHex, surfHex);
|
|
214
|
+
if (r < ratio) {
|
|
215
|
+
ratio = r;
|
|
216
|
+
against = surfVar;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { against, ratio };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Pin the palette's Text curves so `step` derives to the solved color: the
|
|
223
|
+
* lightness anchor is the solved L as a multiplier of seed L, the saturation
|
|
224
|
+
* anchor the solved chroma as a multiplier of seed C (the solver may have
|
|
225
|
+
* decayed chroma to reach the ratio, so lightness alone can undershoot). */
|
|
226
|
+
function pinTextStep(cfg: PaletteConfig, step: Step, solved: { l: number; c: number }): void {
|
|
227
|
+
const x = scaleStepToX(step, TEXT_SCALE);
|
|
228
|
+
const seed = cfg.baseColor;
|
|
229
|
+
const yL = Math.max(0, Math.min(200, (solved.l / seed.l) * 100));
|
|
230
|
+
cfg.scaleCurves.Text.lightness = setCurveAnchor(cfg.scaleCurves.Text.lightness, x, yL).curve;
|
|
231
|
+
if (seed.c > 0) {
|
|
232
|
+
const yC = Math.max(0, Math.min(200, (solved.c / seed.c) * 100));
|
|
233
|
+
cfg.scaleCurves.Text.saturation = setCurveAnchor(cfg.scaleCurves.Text.saturation, x, yC).curve;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const MAX_CORRECTION_ROUNDS = 3;
|
|
238
|
+
|
|
239
|
+
function runContrastGate(
|
|
240
|
+
configs: Record<string, PaletteConfig>,
|
|
241
|
+
scheme: SchemeDirection,
|
|
242
|
+
): Pick<GenerateColorsAndTypeReport, 'scheme' | 'checks' | 'failures'> {
|
|
243
|
+
const defs = checkDefs();
|
|
244
|
+
const direction = scheme === 'light' ? 'darker' : 'lighter';
|
|
245
|
+
const corrected = new Set<string>();
|
|
246
|
+
|
|
247
|
+
for (let round = 0; round < MAX_CORRECTION_ROUNDS; round++) {
|
|
248
|
+
const vars = palettesToVars(configs);
|
|
249
|
+
let anyFixed = false;
|
|
250
|
+
for (const def of defs) {
|
|
251
|
+
const textHex = vars[def.textVar];
|
|
252
|
+
if (!textHex) continue;
|
|
253
|
+
const worst = worstSurface(textHex, vars);
|
|
254
|
+
if (worst.ratio >= def.floor) continue;
|
|
255
|
+
const cfg = configs[def.paletteLabel];
|
|
256
|
+
const seed = cfg.baseColor;
|
|
257
|
+
const solved = findLForContrast({
|
|
258
|
+
against: vars[worst.against],
|
|
259
|
+
ratio: def.target,
|
|
260
|
+
direction,
|
|
261
|
+
c: hexToOklch(textHex).c,
|
|
262
|
+
h: seed.h,
|
|
263
|
+
lMax: Math.min(1, 2 * seed.l),
|
|
264
|
+
});
|
|
265
|
+
pinTextStep(cfg, def.step, solved);
|
|
266
|
+
corrected.add(def.textVar);
|
|
267
|
+
anyFixed = true;
|
|
268
|
+
}
|
|
269
|
+
if (!anyFixed) break;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const finalVars = palettesToVars(configs);
|
|
273
|
+
const checks: ContrastCheck[] = defs.map((def) => {
|
|
274
|
+
const worst = worstSurface(finalVars[def.textVar] ?? '#000000', finalVars);
|
|
275
|
+
return {
|
|
276
|
+
textVar: def.textVar,
|
|
277
|
+
floor: def.floor,
|
|
278
|
+
against: worst.against,
|
|
279
|
+
ratio: worst.ratio,
|
|
280
|
+
pass: worst.ratio >= def.floor,
|
|
281
|
+
corrected: corrected.has(def.textVar),
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const failures = checks
|
|
286
|
+
.filter((c) => !c.pass)
|
|
287
|
+
.map((c) => {
|
|
288
|
+
const label = defs.find((d) => d.textVar === c.textVar)!.paletteLabel;
|
|
289
|
+
const hint = scheme === 'dark'
|
|
290
|
+
? `raise the ${label} seed lightness (derived text tops out at 2× seed L)`
|
|
291
|
+
: `raise the ${label} seed lightness or reduce its chroma`;
|
|
292
|
+
return `${c.textVar} reaches ${c.ratio.toFixed(2)}:1 vs ${c.against} (floor ${c.floor}:1) — ${hint}`;
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return { scheme, checks, failures };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const ADJACENT_HUE_MAX = 120;
|
|
299
|
+
|
|
300
|
+
const hueDist = (a: number, b: number): number => {
|
|
301
|
+
const d = Math.abs(norm(a) - norm(b));
|
|
302
|
+
return Math.min(d, 360 - d);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const linear = (variable: string, angle: number, from: string, to: string): GradientDiskToken => ({
|
|
306
|
+
variable,
|
|
307
|
+
type: 'linear',
|
|
308
|
+
angle,
|
|
309
|
+
stops: [{ position: 0, color: from }, { position: 100, color: to }],
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/** The four swatch recipes, family-relative so they suit any seed set: a
|
|
313
|
+
* within-family brand sweep, two cross-family pairs that fall back to
|
|
314
|
+
* within-family when the hues sit more than 120° apart (an srgb gradient
|
|
315
|
+
* between distant hues passes through gray), and a canvas sweep on the
|
|
316
|
+
* scheme's rich half of the ramp. Stops are var() refs, so later palette
|
|
317
|
+
* edits flow through. */
|
|
318
|
+
function recipeGradients(seeds: Record<string, Oklch>, scheme: SchemeDirection): GradientDiskToken[] {
|
|
319
|
+
return [
|
|
320
|
+
linear('--gradient-1', 90, '--color-brand-400', '--color-brand-700'),
|
|
321
|
+
hueDist(seeds.Special.h, seeds.Brand.h) <= ADJACENT_HUE_MAX
|
|
322
|
+
? linear('--gradient-2', 135, '--color-special-500', '--color-brand-500')
|
|
323
|
+
: linear('--gradient-2', 135, '--color-special-400', '--color-special-700'),
|
|
324
|
+
hueDist(seeds.Brand.h, seeds.Accent.h) <= ADJACENT_HUE_MAX
|
|
325
|
+
? linear('--gradient-3', 90, '--color-brand-500', '--color-accent-500')
|
|
326
|
+
: linear('--gradient-3', 90, '--color-accent-400', '--color-accent-700'),
|
|
327
|
+
scheme === 'dark'
|
|
328
|
+
? linear('--gradient-4', 180, '--color-canvas-600', '--color-canvas-900')
|
|
329
|
+
: linear('--gradient-4', 180, '--color-canvas-200', '--color-canvas-500'),
|
|
330
|
+
];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Absent or still the stock defaults → the engine may replace them; anything
|
|
334
|
+
* else is user-tuned and rides through untouched. */
|
|
335
|
+
function isStockGradients(gradients: GradientDiskToken[] | undefined): boolean {
|
|
336
|
+
return !gradients || gradients.length === 0
|
|
337
|
+
|| JSON.stringify(gradients) === JSON.stringify(makeDefaultGradients());
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Turn on the page-background sky: two ramp steps from the Canvas anchor on
|
|
341
|
+
* the scheme's safe side (darker for dark, lighter for light), ending at the
|
|
342
|
+
* anchor. The anchor step IS the solid `--page-bg` the contrast gate checked,
|
|
343
|
+
* and every other stop sits further from text lightness, so the gradient can
|
|
344
|
+
* only raise contrast — call this after the gate has run.
|
|
345
|
+
*
|
|
346
|
+
* A canvas anchored at the ramp's edge (near-white in light, near-black in
|
|
347
|
+
* dark) has no room on the safe side; the sky is skipped with a report line
|
|
348
|
+
* saying so, since a flat "gradient" would silently do nothing. */
|
|
349
|
+
function applyCanvasGradient(canvas: PaletteConfig, scheme: SchemeDirection): string {
|
|
350
|
+
const labels = PALETTE_STEPS.map((s) => s.label);
|
|
351
|
+
const anchor = canvas.anchorPlacement!.step;
|
|
352
|
+
const sky = scheme === 'dark'
|
|
353
|
+
? Math.min(anchor + 2, labels.length - 1)
|
|
354
|
+
: Math.max(anchor - 2, 0);
|
|
355
|
+
if (sky === anchor) {
|
|
356
|
+
return `skipped — the Canvas seed anchors at the ramp edge (${labels[anchor]}), leaving no room for a sky; commit the canvas further from ${scheme === 'dark' ? 'black' : 'white'}`;
|
|
357
|
+
}
|
|
358
|
+
canvas.emptyMode = 'gradient';
|
|
359
|
+
canvas.gradientStyle = 'linear';
|
|
360
|
+
canvas.gradientAngle = 180;
|
|
361
|
+
canvas.gradientReverse = false;
|
|
362
|
+
canvas.gradientStops = [
|
|
363
|
+
{ position: 0, paletteLabel: labels[sky] },
|
|
364
|
+
{ position: 100, paletteLabel: labels[anchor] },
|
|
365
|
+
];
|
|
366
|
+
canvas.gradientSize = 'window';
|
|
367
|
+
return `on, ${labels[sky]} → ${labels[anchor]}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function buildColorsAndTypeFromSeeds(
|
|
371
|
+
brief: ColorsAndTypeBrief,
|
|
372
|
+
carry: CarryForward = {},
|
|
373
|
+
now: string = new Date().toISOString(),
|
|
374
|
+
): GenerateColorsAndTypeResult {
|
|
375
|
+
const { seeds, slug } = validateBrief(brief);
|
|
376
|
+
|
|
377
|
+
const configs: Record<string, PaletteConfig> = {};
|
|
378
|
+
for (const spec of PALETTE_SPECS) {
|
|
379
|
+
configs[spec.label] = defaultPaletteConfig({
|
|
380
|
+
baseColor: seeds[spec.label],
|
|
381
|
+
neutral: spec.neutral,
|
|
382
|
+
scheme: brief.scheme,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const report = runContrastGate(configs, brief.scheme);
|
|
387
|
+
const canvasGradient = brief.canvasGradient
|
|
388
|
+
? applyCanvasGradient(configs.Canvas, brief.scheme)
|
|
389
|
+
: undefined;
|
|
390
|
+
|
|
391
|
+
const gradients = isStockGradients(carry.gradients)
|
|
392
|
+
? recipeGradients(seeds, brief.scheme)
|
|
393
|
+
: carry.gradients!;
|
|
394
|
+
|
|
395
|
+
const harmonyAxes: HarmonyAxis[] = [
|
|
396
|
+
{ hue: norm(seeds.Brand.h), family: 'Brand' },
|
|
397
|
+
{ hue: norm(seeds.Accent.h), family: 'Accent' },
|
|
398
|
+
{ hue: norm(seeds.Canvas.h), family: 'Canvas' },
|
|
399
|
+
{ hue: norm(seeds.Brand.h + 270), family: null },
|
|
400
|
+
];
|
|
401
|
+
|
|
402
|
+
// The catch-all bag carries only tokens no typed slice owns (the server's
|
|
403
|
+
// normalizeColorsAndType invariant); strip anything this derivation emits.
|
|
404
|
+
const owned = new Set(Object.keys(palettesToVars(configs)));
|
|
405
|
+
const cssVariables = Object.fromEntries(
|
|
406
|
+
Object.entries(carry.cssVariables ?? {}).filter(([k]) => !owned.has(k)),
|
|
407
|
+
);
|
|
408
|
+
// Rendered projections of the structured gradients, kept for production CSS.
|
|
409
|
+
for (const t of gradients) cssVariables[t.variable] = formatGradientValue(t);
|
|
410
|
+
|
|
411
|
+
const colorsAndType: ColorsAndType = {
|
|
412
|
+
name: brief.name.trim(),
|
|
413
|
+
createdAt: now,
|
|
414
|
+
updatedAt: now,
|
|
415
|
+
editorConfigs: configs,
|
|
416
|
+
cssVariables,
|
|
417
|
+
fontSources: carry.fontSources ?? [],
|
|
418
|
+
fontStacks: carry.fontStacks ?? [],
|
|
419
|
+
gradients,
|
|
420
|
+
harmonyAxes,
|
|
421
|
+
schemaVersion: CURRENT_COLORS_AND_TYPE_SCHEMA_VERSION,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
colorsAndType,
|
|
426
|
+
slug,
|
|
427
|
+
report: {
|
|
428
|
+
...report,
|
|
429
|
+
gradients: gradients === carry.gradients ? 'carried' : 'recipes',
|
|
430
|
+
...(canvasGradient ? { canvasGradient } : {}),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ThemeMeta, ColorsAndTypeMeta } from './themeTypes';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one Load list. A theme is the whole look; a colors and type file is a
|
|
5
|
+
* preset holding that half of one. Both belong in the same window, told apart
|
|
6
|
+
* by a badge rather than by living in separate managers.
|
|
7
|
+
*
|
|
8
|
+
* The shipped presets are themes, so their colors-and-type files stay out: they
|
|
9
|
+
* are package-owned (`isPackage`) until the user's own copy shadows them, and
|
|
10
|
+
* that copy is exactly the file the list must keep reachable.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type LoadRowKind = 'look' | 'layer';
|
|
14
|
+
|
|
15
|
+
export interface LoadRow {
|
|
16
|
+
/** `<kind>:<slug>`. The two kinds are separate resources with separate name
|
|
17
|
+
* spaces, so a row needs an id that says which one it came from. */
|
|
18
|
+
fileName: string;
|
|
19
|
+
slug: string;
|
|
20
|
+
kind: LoadRowKind;
|
|
21
|
+
name: string;
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
isProtected: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const loadRowId = (kind: LoadRowKind, slug: string): string => `${kind}:${slug}`;
|
|
27
|
+
|
|
28
|
+
export function buildLoadRows(looks: ThemeMeta[], layers: ColorsAndTypeMeta[]): LoadRow[] {
|
|
29
|
+
const lookRows: LoadRow[] = looks
|
|
30
|
+
.map((f): LoadRow => ({
|
|
31
|
+
fileName: loadRowId('look', f.fileName),
|
|
32
|
+
slug: f.fileName,
|
|
33
|
+
kind: 'look',
|
|
34
|
+
name: f.name,
|
|
35
|
+
updatedAt: f.updatedAt,
|
|
36
|
+
isProtected: f.isProtected,
|
|
37
|
+
}))
|
|
38
|
+
.sort((a, b) => Number(b.isProtected) - Number(a.isProtected));
|
|
39
|
+
const layerRows: LoadRow[] = layers
|
|
40
|
+
.filter((f) => !f.isPackage)
|
|
41
|
+
.map((f) => ({
|
|
42
|
+
fileName: loadRowId('layer', f.fileName),
|
|
43
|
+
slug: f.fileName,
|
|
44
|
+
kind: 'layer',
|
|
45
|
+
name: f.name,
|
|
46
|
+
updatedAt: f.updatedAt,
|
|
47
|
+
isProtected: false,
|
|
48
|
+
}));
|
|
49
|
+
return [...lookRows, ...layerRows];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether picking this row loads colors and type alone. A layer file holds
|
|
54
|
+
* nothing else, so it ignores the toggle; a look honors it.
|
|
55
|
+
*/
|
|
56
|
+
export function isColorsOnly(row: LoadRow | null, colorsOnly: boolean): boolean {
|
|
57
|
+
if (row?.kind === 'layer') return true;
|
|
58
|
+
return colorsOnly;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The toggle reads as on and locked while a layer row is picked. */
|
|
62
|
+
export function colorsOnlyIsForced(row: LoadRow | null): boolean {
|
|
63
|
+
return row?.kind === 'layer';
|
|
64
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ComponentSummary } from '../components/componentConfigService';
|
|
2
|
+
|
|
3
|
+
export interface LookProductionInput {
|
|
4
|
+
/** Slug of the theme the editor has open. */
|
|
5
|
+
openTheme: string;
|
|
6
|
+
/** Slug of the published theme, or null while the read has not landed. */
|
|
7
|
+
productionTheme: string | null;
|
|
8
|
+
/** Live look moved past what the published theme holds: unsaved edits, or a
|
|
9
|
+
* save since the last Adopt. */
|
|
10
|
+
unpublished: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LookProductionState {
|
|
14
|
+
/** True when production is known to ship the look on screen. */
|
|
15
|
+
inProduction: boolean;
|
|
16
|
+
/** True while the production theme is unread: neither claim can be made. */
|
|
17
|
+
unknown: boolean;
|
|
18
|
+
/** True when production ships a theme other than the open one. */
|
|
19
|
+
themeOff: boolean;
|
|
20
|
+
/** True when the open theme has moved since it was published. */
|
|
21
|
+
unpublished: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether production is running the look on screen. Production is one saved
|
|
26
|
+
* theme, so the first half is an identity check against the open one; the
|
|
27
|
+
* second half is the live look sitting ahead of what was published, which
|
|
28
|
+
* `unpublished` carries.
|
|
29
|
+
*
|
|
30
|
+
* A null production read is not an answer, so it is neither state: `unknown`
|
|
31
|
+
* says so and `inProduction` stays false. Callers render that as its own
|
|
32
|
+
* neutral state, which keeps a mount from flashing the alarm without letting a
|
|
33
|
+
* read that never lands read as shipped forever.
|
|
34
|
+
*/
|
|
35
|
+
export function lookProductionState({
|
|
36
|
+
openTheme,
|
|
37
|
+
productionTheme,
|
|
38
|
+
unpublished,
|
|
39
|
+
}: LookProductionInput): LookProductionState {
|
|
40
|
+
const unknown = productionTheme === null;
|
|
41
|
+
const themeOff = !unknown && productionTheme !== openTheme;
|
|
42
|
+
return {
|
|
43
|
+
unknown,
|
|
44
|
+
themeOff,
|
|
45
|
+
unpublished,
|
|
46
|
+
inProduction: !unknown && !themeOff && !unpublished,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How many components run something other than what the open theme carries.
|
|
52
|
+
*
|
|
53
|
+
* Two facts are free on the client: whether the theme embeds a config for a
|
|
54
|
+
* component, and which layer the component's live config resolves from. A
|
|
55
|
+
* component the theme carries but which runs the shipped default is off it, and
|
|
56
|
+
* so is a component running a buffer the theme carries nothing for. A buffer
|
|
57
|
+
* over a config the theme does carry says nothing either way — that is the
|
|
58
|
+
* state an apply leaves — so it stays uncounted.
|
|
59
|
+
*
|
|
60
|
+
* The Default theme breaks that rule: it is the one theme that embeds every
|
|
61
|
+
* component's DEFAULT config by value (full set, not delta), so carrying an
|
|
62
|
+
* entry says nothing. For it, off-theme is simply "buffered".
|
|
63
|
+
*/
|
|
64
|
+
export function countComponentsOffLook(
|
|
65
|
+
components: ComponentSummary[],
|
|
66
|
+
lookConfigs: Record<string, unknown> | null | undefined,
|
|
67
|
+
lookIsDefault = false,
|
|
68
|
+
): number {
|
|
69
|
+
const carried = lookConfigs ?? {};
|
|
70
|
+
return components.filter((c) => {
|
|
71
|
+
if (lookIsDefault) return c.source === 'working';
|
|
72
|
+
const inLook = Object.prototype.hasOwnProperty.call(carried, c.name);
|
|
73
|
+
return inLook ? c.source === 'default' : c.source === 'working';
|
|
74
|
+
}).length;
|
|
75
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Migration } from './index';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* ColorsAndType migration (2026-04-24): rename legacy CSS-var keys to their
|
|
5
5
|
* current names, drop a few orphaned tokens, and convert the entire
|
|
6
6
|
* `-bg`/`-bg-*` family to the `-canvas` namespace.
|
|
7
7
|
*
|
|
@@ -58,11 +58,11 @@ function migrateLegacyKeys(rawVars: Record<string, string>): void {
|
|
|
58
58
|
migrateBgToCanvas(rawVars);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
export const
|
|
61
|
+
export const colorsAndTypeMigration_2026_04_24_legacyKeysAndBgToCanvas: Migration = {
|
|
62
62
|
id: '2026-04-24-legacy-keys-and-bg-to-canvas',
|
|
63
63
|
fromVersion: 0,
|
|
64
64
|
toVersion: 1,
|
|
65
|
-
appliesTo: '
|
|
65
|
+
appliesTo: 'colors-and-type',
|
|
66
66
|
apply(rawVars) {
|
|
67
67
|
const out = { ...rawVars };
|
|
68
68
|
migrateLegacyKeys(out);
|
|
@@ -39,11 +39,11 @@ function renameKeysAndValues(rawVars: Record<string, string>): Record<string, st
|
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export const
|
|
42
|
+
export const colorsAndTypeMigration_2026_05_13_primaryToBrand: Migration = {
|
|
43
43
|
id: '2026-05-13-primary-to-brand-theme',
|
|
44
44
|
fromVersion: 1,
|
|
45
45
|
toVersion: 2,
|
|
46
|
-
appliesTo: '
|
|
46
|
+
appliesTo: 'colors-and-type',
|
|
47
47
|
apply: renameKeys,
|
|
48
48
|
};
|
|
49
49
|
|
|
@@ -3,7 +3,7 @@ import type { Migration } from './index';
|
|
|
3
3
|
/**
|
|
4
4
|
* Overlay scale trim (2026-05-26): `--overlay-lowest`, `--overlay-lower`,
|
|
5
5
|
* `--overlay-higher`, and `--overlay-highest` were retired. The kept stops
|
|
6
|
-
* are `--overlay-low`, `--overlay`, and `--overlay-high`.
|
|
6
|
+
* are `--overlay-low`, `--overlay`, and `--overlay-high`. ColorsAndType files
|
|
7
7
|
* stripped of the dropped keys; component configs whose aliases referenced
|
|
8
8
|
* a dropped stop rebind to the nearest survivor.
|
|
9
9
|
*/
|
|
@@ -14,11 +14,11 @@ const DROPPED_TO_KEPT: Record<string, string> = {
|
|
|
14
14
|
'--overlay-highest': '--overlay-high',
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
export const
|
|
17
|
+
export const colorsAndTypeMigration_2026_05_26_dropOverlayExtraStops: Migration = {
|
|
18
18
|
id: '2026-05-26-drop-overlay-extra-stops-theme',
|
|
19
19
|
fromVersion: 2,
|
|
20
20
|
toVersion: 3,
|
|
21
|
-
appliesTo: '
|
|
21
|
+
appliesTo: 'colors-and-type',
|
|
22
22
|
apply(rawVars) {
|
|
23
23
|
const out: Record<string, string> = {};
|
|
24
24
|
for (const [key, value] of Object.entries(rawVars)) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Migration } from './index';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Legacy shape/space keys (2026-08-13): eleven radius/padding entries that
|
|
5
|
+
* predate the per-component config system and outlived the restructures that
|
|
6
|
+
* renamed their components' tokens (Badge's `trait` variant, Dialog's
|
|
7
|
+
* variant×state axes, SectionDivider's flat padding). Nothing reads them —
|
|
8
|
+
* shape and space now live only in `component-configs/<id>/*.json` — so they
|
|
9
|
+
* are a second, dead home for state the adjust engine writes elsewhere.
|
|
10
|
+
*/
|
|
11
|
+
const DROPPED = new Set([
|
|
12
|
+
'--badge-trait-radius',
|
|
13
|
+
'--badge-trait-padding',
|
|
14
|
+
'--sectiondivider-padding',
|
|
15
|
+
'--dialog-primary-default-radius',
|
|
16
|
+
'--dialog-primary-default-padding',
|
|
17
|
+
'--dialog-primary-hover-radius',
|
|
18
|
+
'--dialog-primary-hover-padding',
|
|
19
|
+
'--dialog-secondary-default-radius',
|
|
20
|
+
'--dialog-secondary-default-padding',
|
|
21
|
+
'--dialog-secondary-hover-radius',
|
|
22
|
+
'--dialog-secondary-hover-padding',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const colorsAndTypeMigration_2026_08_13_dropLegacyShapeSpaceKeys: Migration = {
|
|
26
|
+
id: '2026-08-13-drop-legacy-shape-space-keys',
|
|
27
|
+
fromVersion: 3,
|
|
28
|
+
toVersion: 4,
|
|
29
|
+
appliesTo: 'colors-and-type',
|
|
30
|
+
apply(rawVars) {
|
|
31
|
+
const out: Record<string, string> = {};
|
|
32
|
+
for (const [key, value] of Object.entries(rawVars)) {
|
|
33
|
+
if (DROPPED.has(key)) continue;
|
|
34
|
+
out[key] = value;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
},
|
|
38
|
+
};
|