@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.
Files changed (124) hide show
  1. package/.claude/skills/live-tokens-adjust-shape-space/SKILL.md +68 -0
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +2 -2
  3. package/.claude/skills/live-tokens-generate-theme/SKILL.md +152 -0
  4. package/CHANGELOG.md +292 -0
  5. package/README.md +37 -26
  6. package/bin/adjust.mjs +254 -0
  7. package/bin/cli.mjs +94 -7
  8. package/bin/generate-theme.mjs +251 -0
  9. package/bin/migrate.mjs +95 -6
  10. package/dist-plugin/adjust/index.cjs +259 -0
  11. package/dist-plugin/adjust/index.d.cts +45 -0
  12. package/dist-plugin/adjust/index.d.ts +45 -0
  13. package/dist-plugin/adjust/index.js +176 -0
  14. package/dist-plugin/{chunk-H4TRUINI.js → chunk-44RSTAII.js} +0 -48
  15. package/dist-plugin/chunk-6OZFXIQI.js +316 -0
  16. package/dist-plugin/chunk-76TFDTJO.js +556 -0
  17. package/dist-plugin/chunk-D3ZVKOR4.js +52 -0
  18. package/dist-plugin/dataPaths-DBN0RPuT.d.cts +54 -0
  19. package/dist-plugin/dataPaths-DBN0RPuT.d.ts +54 -0
  20. package/dist-plugin/generateColorsAndType/index.cjs +1781 -0
  21. package/dist-plugin/generateColorsAndType/index.d.cts +81 -0
  22. package/dist-plugin/generateColorsAndType/index.d.ts +81 -0
  23. package/dist-plugin/generateColorsAndType/index.js +1210 -0
  24. package/dist-plugin/index.cjs +901 -533
  25. package/dist-plugin/index.d.cts +3 -2
  26. package/dist-plugin/index.d.ts +3 -2
  27. package/dist-plugin/index.js +658 -1082
  28. package/dist-plugin/migrateData/index.cjs +728 -0
  29. package/dist-plugin/migrateData/index.d.cts +60 -0
  30. package/dist-plugin/migrateData/index.d.ts +60 -0
  31. package/dist-plugin/migrateData/index.js +348 -0
  32. package/dist-plugin/themeTypes-DMHZOnUn.d.cts +211 -0
  33. package/dist-plugin/themeTypes-DMHZOnUn.d.ts +211 -0
  34. package/dist-plugin/tokensCssMigrations/index.cjs +2 -1
  35. package/dist-plugin/tokensCssMigrations/index.d.cts +3 -15
  36. package/dist-plugin/tokensCssMigrations/index.d.ts +3 -15
  37. package/dist-plugin/tokensCssMigrations/index.js +4 -2
  38. package/package.json +18 -4
  39. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +183 -158
  40. package/src/editor/component-editor/scaffolding/ComponentFileMenu.svelte +0 -4
  41. package/src/editor/component-editor/scaffolding/SaveAsDialog.svelte +4 -4
  42. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +2 -59
  43. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +1 -1
  44. package/src/editor/core/components/adjustAliases.ts +180 -0
  45. package/src/editor/core/components/aliasKinds.ts +73 -0
  46. package/src/editor/core/components/componentConfigService.ts +26 -38
  47. package/src/editor/core/flashStatus.ts +1 -1
  48. package/src/editor/core/fonts/fontMigration.ts +13 -13
  49. package/src/editor/core/fonts/fontPairing.ts +35 -0
  50. package/src/editor/core/palettes/paletteDerivation.ts +128 -16
  51. package/src/editor/core/preview/lookPreview.ts +141 -0
  52. package/src/editor/core/productionPulse.ts +34 -21
  53. package/src/editor/core/storage/files/versionedFileResourceClient.ts +18 -45
  54. package/src/editor/core/store/editorConfigStore.ts +2 -2
  55. package/src/editor/core/store/editorPersistence.ts +0 -1
  56. package/src/editor/core/store/editorStore.ts +114 -51
  57. package/src/editor/core/store/editorTypes.ts +0 -1
  58. package/src/editor/core/store/gradientSource.ts +2 -2
  59. package/src/editor/core/themes/colorsAndTypeService.ts +95 -0
  60. package/src/editor/core/themes/generateColorsAndType.ts +433 -0
  61. package/src/editor/core/themes/loadRows.ts +64 -0
  62. package/src/editor/core/themes/lookSummary.ts +75 -0
  63. package/src/editor/core/themes/migrations/2026-04-24-legacy-keys-and-bg-to-canvas.ts +3 -3
  64. package/src/editor/core/themes/migrations/2026-05-13-primary-to-brand.ts +2 -2
  65. package/src/editor/core/themes/migrations/2026-05-26-drop-overlay-extra-stops.ts +3 -3
  66. package/src/editor/core/themes/migrations/2026-08-13-drop-legacy-shape-space-keys.ts +38 -0
  67. package/src/editor/core/themes/migrations/2026-08-15-line-height-scale-rename.ts +57 -0
  68. package/src/editor/core/themes/migrations/index.ts +19 -11
  69. package/src/editor/core/themes/slices/components.ts +4 -4
  70. package/src/editor/core/themes/slices/fonts.ts +1 -1
  71. package/src/editor/core/themes/slices/gradients.ts +22 -0
  72. package/src/editor/core/themes/slices/palettes.ts +2 -2
  73. package/src/editor/core/themes/themeInit.ts +21 -29
  74. package/src/editor/core/themes/themeService.ts +218 -78
  75. package/src/editor/core/themes/themeTypes.ts +94 -51
  76. package/src/editor/docs/Docs.svelte +1 -0
  77. package/src/editor/docs/chapters.ts +1 -0
  78. package/src/editor/docs/content/01-overview.md +1 -1
  79. package/src/editor/docs/content/editing-tokens.md +6 -6
  80. package/src/editor/docs/content/getting-started.md +9 -7
  81. package/src/editor/docs/content/themes-workflow.md +74 -34
  82. package/src/editor/docs/content/where-themes-live.md +61 -0
  83. package/src/editor/docs/content.generated.ts +5 -4
  84. package/src/editor/index.ts +27 -27
  85. package/src/editor/pages/ComponentEditorPage.svelte +2 -2
  86. package/src/editor/pages/EditorShell.svelte +4 -30
  87. package/src/editor/ui/BezierCurveEditor.svelte +2 -2
  88. package/src/editor/ui/ColorEditPanel.svelte +1 -1
  89. package/src/editor/ui/FileLoadList.svelte +67 -7
  90. package/src/editor/ui/GradientEditor.svelte +3 -3
  91. package/src/editor/ui/PaletteEditor.svelte +26 -12
  92. package/src/editor/ui/ThemePanel.svelte +1067 -0
  93. package/src/editor/ui/UIDialog.svelte +11 -9
  94. package/src/editor/ui/UIPillButton.svelte +7 -2
  95. package/src/editor/ui/colors/ColorWheel.svelte +34 -1
  96. package/src/editor/ui/curveEngine.ts +48 -0
  97. package/src/editor/ui/index.ts +4 -2
  98. package/src/editor/ui/palette/PaletteBase.svelte +75 -51
  99. package/src/editor/ui/palette/PaletteJumpButton.svelte +3 -8
  100. package/src/live-tokens/data/colors-and-type/autumn.json +2490 -0
  101. package/src/live-tokens/data/{themes → colors-and-type}/default.json +816 -293
  102. package/src/live-tokens/data/colors-and-type/halloween.json +2529 -0
  103. package/src/live-tokens/data/colors-and-type/midnight-study.json +2536 -0
  104. package/src/live-tokens/data/colors-and-type/ocean.json +2489 -0
  105. package/src/live-tokens/data/colors-and-type/royal-velvet.json +2520 -0
  106. package/src/live-tokens/data/colors-and-type/spring-meadow.json +2476 -0
  107. package/src/live-tokens/data/colors-and-type/sunset.json +2528 -0
  108. package/src/live-tokens/data/themes/autumn.json +3918 -0
  109. package/src/live-tokens/data/themes/halloween.json +3992 -0
  110. package/src/live-tokens/data/themes/midnight-study.json +3932 -0
  111. package/src/live-tokens/data/themes/ocean.json +3917 -0
  112. package/src/live-tokens/data/themes/royal-velvet.json +3983 -0
  113. package/src/live-tokens/data/themes/spring-meadow.json +3904 -0
  114. package/src/live-tokens/data/themes/sunset.json +3931 -0
  115. package/src/live-tokens/data/tokens.generated.css +106 -117
  116. package/src/system/styles/CONVENTIONS.md +1 -1
  117. package/src/system/styles/fonts.css +1 -1
  118. package/template/README.md +7 -5
  119. package/template/_gitignore +0 -6
  120. package/src/editor/core/components/componentPersist.ts +0 -62
  121. package/src/editor/core/manifests/manifestService.ts +0 -172
  122. package/src/editor/ui/ManifestFileManager.svelte +0 -446
  123. package/src/editor/ui/ThemeFileManager.svelte +0 -785
  124. 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
- * Theme migration (2026-04-24): rename legacy CSS-var keys to their
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 themeMigration_2026_04_24_legacyKeysAndBgToCanvas: Migration = {
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: 'theme',
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 themeMigration_2026_05_13_primaryToBrand: Migration = {
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: 'theme',
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`. Theme files
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 themeMigration_2026_05_26_dropOverlayExtraStops: Migration = {
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: 'theme',
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
+ };