@motion-proto/live-tokens 0.52.1 → 0.53.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 (60) hide show
  1. package/.claude/skills/live-tokens-adjust-shape-space/SKILL.md +7 -1
  2. package/CHANGELOG.md +63 -0
  3. package/bin/adjust.mjs +16 -39
  4. package/bin/generate-theme.mjs +20 -6
  5. package/dist-plugin/adjust/index.cjs +781 -21
  6. package/dist-plugin/adjust/index.d.cts +2 -1
  7. package/dist-plugin/adjust/index.d.ts +2 -1
  8. package/dist-plugin/adjust/index.js +43 -21
  9. package/dist-plugin/{chunk-6OZFXIQI.js → chunk-232GZGQU.js} +207 -12
  10. package/dist-plugin/chunk-NE6N66EE.js +753 -0
  11. package/dist-plugin/{chunk-JIGIIE5P.js → chunk-PB2JTK2H.js} +123 -0
  12. package/dist-plugin/generateColorsAndType/index.cjs +48 -32
  13. package/dist-plugin/generateColorsAndType/index.d.cts +2 -1
  14. package/dist-plugin/generateColorsAndType/index.d.ts +2 -1
  15. package/dist-plugin/generateColorsAndType/index.js +7 -773
  16. package/dist-plugin/{themeTypes-Dky03HL9.d.cts → index-DpTIRZ2H.d.cts} +49 -21
  17. package/dist-plugin/{themeTypes-Dky03HL9.d.ts → index-DpTIRZ2H.d.ts} +49 -21
  18. package/dist-plugin/index.cjs +1159 -85
  19. package/dist-plugin/index.js +102 -80
  20. package/dist-plugin/migrateData/index.cjs +956 -14
  21. package/dist-plugin/migrateData/index.js +10 -3
  22. package/package.json +6 -4
  23. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +9 -23
  24. package/src/editor/component-editor/scaffolding/LinkedBlock.svelte +8 -8
  25. package/src/editor/core/components/adjustAliases.ts +49 -22
  26. package/src/editor/core/components/aliasKinds.ts +7 -2
  27. package/src/editor/core/components/componentConfigService.ts +1 -1
  28. package/src/editor/core/store/editorStore.ts +7 -197
  29. package/src/editor/core/store/editorTypes.ts +9 -30
  30. package/src/editor/core/themes/lookSummary.ts +3 -25
  31. package/src/editor/core/themes/migrateComponentConfig.ts +205 -0
  32. package/src/editor/core/themes/migrations/2026-05-19-sectiondivider-rich-gradient.ts +1 -1
  33. package/src/editor/core/themes/migrations/2026-05-20-sectiondivider-slim-variants.ts +1 -1
  34. package/src/editor/core/themes/migrations/2026-05-22-sectiondivider-intrinsics-to-css.ts +1 -1
  35. package/src/editor/core/themes/parsers/gradient.ts +211 -0
  36. package/src/editor/core/themes/slices/gradients.ts +5 -58
  37. package/src/editor/core/themes/themeService.ts +22 -9
  38. package/src/editor/core/themes/themeTypes.ts +71 -21
  39. package/src/editor/docs/content/themes-workflow.md +9 -11
  40. package/src/editor/docs/content/where-themes-live.md +2 -2
  41. package/src/editor/docs/content.generated.ts +2 -2
  42. package/src/editor/index.ts +1 -0
  43. package/src/editor/styles/ui-editor.css +5 -2
  44. package/src/editor/ui/PaletteEditor.svelte +30 -15
  45. package/src/editor/ui/ThemePanel.svelte +24 -15
  46. package/src/editor/ui/Toggle.svelte +22 -22
  47. package/src/editor/ui/UIPillButton.svelte +1 -1
  48. package/src/editor/ui/UIReveal.svelte +47 -0
  49. package/src/editor/ui/UISquareButton.svelte +1 -1
  50. package/src/editor/ui/colors/ColorsTab.svelte +4 -5
  51. package/src/editor/ui/palette/OverridesPanel.svelte +4 -3
  52. package/src/editor/ui/palette/PaletteJumpButton.svelte +3 -3
  53. package/src/editor/ui/palette/ScaleCurveEditor.svelte +3 -2
  54. package/src/live-tokens/data/themes/autumn.json +77 -41
  55. package/src/live-tokens/data/themes/halloween.json +53 -52
  56. package/src/live-tokens/data/themes/midnight-study.json +136 -68
  57. package/src/live-tokens/data/themes/ocean.json +77 -41
  58. package/src/live-tokens/data/themes/royal-velvet.json +43 -42
  59. package/src/live-tokens/data/themes/spring-meadow.json +86 -52
  60. package/src/live-tokens/data/themes/sunset.json +101 -40
@@ -7,7 +7,10 @@ import {
7
7
  normalizeTheme,
8
8
  planLegacyRenames,
9
9
  versionedFileResourceServer
10
- } from "../chunk-6OZFXIQI.js";
10
+ } from "../chunk-232GZGQU.js";
11
+ import {
12
+ CURRENT_COMPONENT_SCHEMA_VERSION
13
+ } from "../chunk-NE6N66EE.js";
11
14
  import {
12
15
  readLiveTokensConfig,
13
16
  resolveDataDirs
@@ -151,6 +154,7 @@ function migrateData(opts) {
151
154
  const resolvers = {
152
155
  readColorsAndType: (name) => colorsRes.readJson(name),
153
156
  readComponentConfig: (comp, name) => compRes(comp).readJson(name),
157
+ listComponentNames: () => comps,
154
158
  normalizeColorsAndType: (colorsAndType) => colorsAndType
155
159
  };
156
160
  const upgradedThemes = [];
@@ -230,7 +234,6 @@ function migrateData(opts) {
230
234
  for (const comp of comps) {
231
235
  const pointed = prodConfigs.get(comp);
232
236
  if (!isObject(pointed)) continue;
233
- if (sameContent(pointed, defaultConfig(comp))) continue;
234
237
  componentConfigs[comp] = stripMarkers(pointed);
235
238
  }
236
239
  recoveredTheme = {
@@ -239,7 +242,11 @@ function migrateData(opts) {
239
242
  updatedAt: now,
240
243
  schemaVersion: THEME_SCHEMA_VERSION,
241
244
  colorsAndType: isObject(prodColors) ? stripMarkers(prodColors) : null,
242
- componentConfigs
245
+ componentConfigs,
246
+ // The configs above are what boot just wrote (`prodConfigs`, read off
247
+ // the pointed files), at the current component schema — not 0, which
248
+ // would misdescribe them as unmigrated.
249
+ componentSchemaVersion: CURRENT_COMPONENT_SCHEMA_VERSION
243
250
  };
244
251
  const slug = nextAvailableName((n) => themesRes.existingPath(n) !== null, RECOVERED_SLUG);
245
252
  recoveredThemePath = themesRes.filePath(slug);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.52.1",
3
+ "version": "0.53.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -111,7 +111,7 @@
111
111
  "test": "vitest run",
112
112
  "test:watch": "vitest",
113
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",
114
+ "test:e2e:contract": "playwright test tests/e2e/component-render-contract.spec.ts tests/e2e/component-alias-contract.spec.ts --workers=${PLAYWRIGHT_WORKERS:-4}",
115
115
  "test:e2e:stateful": "playwright test tests/e2e/live-editing.spec.ts tests/e2e/theme-workflow.spec.ts --workers=1",
116
116
  "test:e2e:components": "playwright test tests/e2e/component-render-contract.spec.ts",
117
117
  "test:e2e:ui": "playwright test --ui",
@@ -120,6 +120,7 @@
120
120
  "build:lib": "npm run build:plugin",
121
121
  "deploy:local": "bash scripts/deploy-local.sh",
122
122
  "check:no-style-imports": "node scripts/check-no-style-imports.mjs",
123
+ "check:no-tooling-imports": "node scripts/check-no-tooling-imports.mjs",
123
124
  "check:slot-prose": "node scripts/check-slot-prose.mjs",
124
125
  "check:overlay-portal": "node scripts/check-overlay-portal.mjs",
125
126
  "check:editor-font-isolation": "node scripts/check-editor-font-isolation.mjs",
@@ -127,13 +128,14 @@
127
128
  "check:production-is-default": "node scripts/check-production-is-default.mjs",
128
129
  "check:docs-content": "node scripts/sync-docs.mjs --check",
129
130
  "check:token-contract": "node scripts/check-token-contract.mjs",
131
+ "check:preset-themes": "node scripts/check-preset-themes.mjs",
130
132
  "sync:component-defaults": "node scripts/sync-component-defaults.mjs --write",
131
133
  "sync:docs": "node scripts/sync-docs.mjs --write",
132
- "generate:preset-themes": "node scripts/generate-preset-themes.mjs",
134
+ "seed:preset-theme": "node scripts/seed-preset-theme.mjs",
133
135
  "collapse:theme": "node scripts/collapse-theme-to-default.mjs",
134
136
  "check:smoke-install": "bash scripts/smoke-install.sh",
135
137
  "check:smoke-create": "bash scripts/smoke-create.sh",
136
- "prepublishOnly": "npm run check:no-style-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:token-contract && npm run check:smoke-install && npm run check:smoke-create"
138
+ "prepublishOnly": "npm run check:no-style-imports && npm run check:no-tooling-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:token-contract && npm run check:preset-themes && npm run check:smoke-install && npm run check:smoke-create"
137
139
  },
138
140
  "peerDependencies": {
139
141
  "@sveltejs/vite-plugin-svelte": "^7.0",
@@ -5,10 +5,7 @@
5
5
  <script lang="ts">
6
6
  import { onMount, onDestroy } from 'svelte';
7
7
  import UIInfoPopover from '../../ui/UIInfoPopover.svelte';
8
- import type {
9
- ComponentConfigMeta,
10
- LiveSource,
11
- } from '../../core/themes/themeTypes';
8
+ import type { ComponentConfigMeta } from '../../core/themes/themeTypes';
12
9
  import { componentSourceFile } from './componentSources';
13
10
  import {
14
11
  getActiveComponentConfig,
@@ -73,7 +70,6 @@
73
70
  // Saved presets. Nothing live points at one: the component runs its working
74
71
  // buffer, and the theme it belongs to carries the copy that ships.
75
72
  let files: ComponentConfigMeta[] = $state([]);
76
- let liveSource: LiveSource = $state('default');
77
73
  let openTheme: { fileName: string; name: string } = $state({
78
74
  fileName: 'default',
79
75
  name: 'Motion Proto',
@@ -94,18 +90,13 @@
94
90
  let compDirty = $derived($componentDirty[component] ?? false);
95
91
  let openIsProtected = $derived(openTheme.fileName === 'default');
96
92
  // Both rows name a document, because that is what a config belongs to now:
97
- // the component runs the open theme's config (or the shipped default), and
98
- // production ships the published theme's.
99
- let editorDoc = $derived(liveSource === 'default' ? 'default' : openTheme.fileName);
100
- let productionDoc = $derived(
101
- $productionTheme
102
- ? ($productionTheme.componentConfigs[component] ? $productionTheme._fileName ?? 'default' : 'default')
103
- : null,
104
- );
105
- let editorName = $derived(liveSource === 'default' ? 'Default' : openTheme.name);
106
- let productionName = $derived(
107
- productionDoc === null ? '—' : productionDoc === 'default' ? 'Default' : $productionTheme?.name ?? '—',
108
- );
93
+ // every theme carries every component by value, so the component always
94
+ // runs the open theme's config, and production always runs the published
95
+ // theme's.
96
+ let editorDoc = $derived(openTheme.fileName);
97
+ let productionDoc = $derived($productionTheme?._fileName ?? null);
98
+ let editorName = $derived(openTheme.name);
99
+ let productionName = $derived(productionDoc === null ? '—' : $productionTheme?.name ?? '—');
109
100
  // Saving writes the buffer, which the bake has not seen: the document is
110
101
  // still the published one, so only the shared signal can say the config on
111
102
  // screen is not what production runs.
@@ -123,11 +114,8 @@
123
114
  if (data) files = data.files;
124
115
  }
125
116
 
126
- /** Which layer the component's live config comes from, and the theme it
127
- * belongs to. Both ride on the live read door's markers. */
117
+ /** The theme the component belongs to, by the open document's own markers. */
128
118
  async function refreshLive() {
129
- const cfg = await getActiveComponentConfig(component);
130
- if (cfg) liveSource = cfg._source ?? 'default';
131
119
  try {
132
120
  const theme = await getActiveTheme();
133
121
  if (theme) openTheme = { fileName: theme._fileName ?? 'default', name: theme.name };
@@ -186,7 +174,6 @@
186
174
  component,
187
175
  componentConfigFromState($editorState, component, openTheme.name),
188
176
  );
189
- liveSource = 'working';
190
177
  markComponentSaved(component);
191
178
  bumpComponentActiveRevision();
192
179
  }
@@ -233,7 +220,6 @@
233
220
  const { _fileName, _source, ...cfg } = await loadComponentConfig(component, file.fileName);
234
221
  await writeWorkingComponentConfig(component, { ...cfg, component });
235
222
  loadComponentActive(component, cfg.aliases, cfg.config, cfg.schemaVersion ?? 0);
236
- liveSource = 'working';
237
223
  bumpComponentActiveRevision();
238
224
  } catch {
239
225
  // intentional: see comment above
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
- import { fade, slide } from 'svelte/transition';
2
+ import { fade } from 'svelte/transition';
3
3
  import TokenLayout from './TokenLayout.svelte';
4
+ import UIReveal from '../../ui/UIReveal.svelte';
4
5
  import LinkageChart from './LinkageChart.svelte';
5
6
  import { getEditorContext } from './editorContext';
6
7
  import type { LinkedBlockResult, LinkedGroup } from './linkedBlock';
@@ -11,7 +12,6 @@
11
12
  const reduceMotion = typeof window !== 'undefined'
12
13
  && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
13
14
  const fadeDur = reduceMotion ? 0 : 140;
14
- const slideDur = reduceMotion ? 0 : 200;
15
15
 
16
16
  interface Props {
17
17
  component: string;
@@ -139,8 +139,8 @@
139
139
  {/if}
140
140
  </span>
141
141
  </button>
142
- {#if sectionExpanded}
143
- <div class="linked-grid" transition:slide|local={{ duration: slideDur }}>
142
+ <UIReveal open={sectionExpanded}>
143
+ <div class="linked-grid">
144
144
  {#each cards as card (card.row.variable)}
145
145
  {@const cardExpanded = expandedCards[card.row.variable] === true}
146
146
  {@const cardHovered = hoveredVar === card.row.variable}
@@ -176,8 +176,8 @@
176
176
  >({card.unlinkedText})</span>
177
177
  {/if}
178
178
  </button>
179
- {#if cardExpanded}
180
- <div class="chart-wrap" transition:slide|local={{ duration: slideDur }}>
179
+ <UIReveal open={cardExpanded}>
180
+ <div class="chart-wrap">
181
181
  <LinkageChart
182
182
  contexts={card.contexts}
183
183
  broken={card.brokenContexts}
@@ -187,11 +187,11 @@
187
187
  onselect={handleChartSelect}
188
188
  />
189
189
  </div>
190
- {/if}
190
+ </UIReveal>
191
191
  </article>
192
192
  {/each}
193
193
  </div>
194
- {/if}
194
+ </UIReveal>
195
195
  </section>
196
196
  {/if}
197
197
 
@@ -1,4 +1,4 @@
1
- import { matchesKind } from './aliasKinds';
1
+ import { matchesKind, stripSide } from './aliasKinds';
2
2
  import type { AliasDiskValue, ComponentConfig } from '../themes/themeTypes';
3
3
 
4
4
  export type AdjustKind = 'radius' | 'padding' | 'gap' | 'border-width';
@@ -52,12 +52,21 @@ const RADIUS_RUNGS = [
52
52
  /** The editor picker's 12-step subset (`UIPaddingSelector`), not the full
53
53
  `--space-*` scale: every value this engine writes must stay re-editable
54
54
  by hand in the picker. */
55
- const SPACE_RUNGS = [
55
+ const SPACE_SETTABLE = [
56
56
  '--space-0', '--space-2', '--space-4', '--space-6', '--space-8', '--space-10',
57
57
  '--space-12', '--space-16', '--space-20', '--space-24', '--space-32', '--space-48',
58
58
  ];
59
59
 
60
- const SPACE_FAMILY = [...SPACE_RUNGS, '--space-40', '--space-64', '--space-96', '--space-128'];
60
+ const SPACE_RUNGS = SPACE_SETTABLE;
61
+
62
+ /** Content insets stop at `--space-4`. Below it the text sits against its own
63
+ edge, which is a deliberate choice rather than somewhere a relative
64
+ "tighter" should deposit you. Outer space is exempt: a 2px gap between an
65
+ icon and its label, or a 2px margin under a bar, is ordinary design. Both
66
+ rungs stay pickable by hand and settable by name. */
67
+ const INSET_RUNGS = SPACE_SETTABLE.slice(SPACE_SETTABLE.indexOf('--space-4'));
68
+
69
+ const SPACE_FAMILY = [...SPACE_SETTABLE, '--space-40', '--space-64', '--space-96', '--space-128'];
61
70
 
62
71
  const BORDER_WIDTH_RUNGS = [
63
72
  '--border-width-0', '--border-width-1', '--border-width-2', '--border-width-3',
@@ -66,48 +75,66 @@ const BORDER_WIDTH_RUNGS = [
66
75
  '--border-width-24',
67
76
  ];
68
77
 
69
- const LADDERS: Record<AdjustKind, { rungs: string[]; family: string[] }> = {
70
- radius: { rungs: RADIUS_RUNGS, family: [...RADIUS_RUNGS, RADIUS_FULL] },
71
- padding: { rungs: SPACE_RUNGS, family: SPACE_FAMILY },
72
- gap: { rungs: SPACE_RUNGS, family: SPACE_FAMILY },
73
- 'border-width': { rungs: BORDER_WIDTH_RUNGS, family: BORDER_WIDTH_RUNGS },
78
+ const LADDERS: Record<AdjustKind, { rungs: string[]; settable: string[]; family: string[] }> = {
79
+ radius: {
80
+ rungs: RADIUS_RUNGS,
81
+ settable: [...RADIUS_RUNGS, RADIUS_FULL],
82
+ family: [...RADIUS_RUNGS, RADIUS_FULL],
83
+ },
84
+ padding: { rungs: SPACE_RUNGS, settable: SPACE_SETTABLE, family: SPACE_FAMILY },
85
+ gap: { rungs: SPACE_RUNGS, settable: SPACE_SETTABLE, family: SPACE_FAMILY },
86
+ 'border-width': {
87
+ rungs: BORDER_WIDTH_RUNGS,
88
+ settable: BORDER_WIDTH_RUNGS,
89
+ family: BORDER_WIDTH_RUNGS,
90
+ },
74
91
  };
75
92
 
76
93
  const TOKEN_NAME = /^--[a-z0-9-]+$/;
77
94
 
78
95
  type Outcome = { from: string; to: string } | { skip: SkipReason };
79
96
 
80
- function rungsFor(op: AdjustOp): string[] {
81
- return op.kind === 'radius' && op.full ? [...RADIUS_RUNGS, RADIUS_FULL] : LADDERS[op.kind].rungs;
97
+ /** `-margin` shares the padding kind, so the inset floor keys off the variable
98
+ rather than the kind. */
99
+ function rungsFor(op: AdjustOp, variable: string): string[] {
100
+ if (op.kind === 'radius') return op.full ? [...RADIUS_RUNGS, RADIUS_FULL] : RADIUS_RUNGS;
101
+ if (op.kind === 'padding' && stripSide(variable).endsWith('-padding')) return INSET_RUNGS;
102
+ return LADDERS[op.kind].rungs;
82
103
  }
83
104
 
84
105
  function spaceStep(token: string): number {
85
106
  return Number(token.slice('--space-'.length));
86
107
  }
87
108
 
88
- /** `<=` sends an exact tie (`--space-40`, midway between 32 and 48) upward. */
89
- function nearestSpaceRung(token: string, rungs: string[]): number {
109
+ /** An off-rung value has no step to count from, so landing on the first rung
110
+ the shift points at is itself that shift's opening step. Snapping to the
111
+ nearest rung instead would spend two visible steps on a one-step request.
112
+ Returns -1 when the ladder holds nothing in that direction. */
113
+ function snapRung(token: string, rungs: string[], direction: number): number {
90
114
  const step = spaceStep(token);
91
- let best = 0;
92
- for (let i = 1; i < rungs.length; i++) {
93
- if (Math.abs(spaceStep(rungs[i]) - step) <= Math.abs(spaceStep(rungs[best]) - step)) best = i;
115
+ if (direction > 0) return rungs.findIndex((rung) => spaceStep(rung) > step);
116
+ for (let index = rungs.length - 1; index >= 0; index--) {
117
+ if (spaceStep(rungs[index]) < step) return index;
94
118
  }
95
- return best;
119
+ return -1;
96
120
  }
97
121
 
98
- function resolve(op: AdjustOp, value: AliasDiskValue): Outcome {
122
+ function resolve(op: AdjustOp, variable: string, value: AliasDiskValue): Outcome {
99
123
  if (typeof value !== 'string' || !TOKEN_NAME.test(value)) return { skip: 'raw-value' };
100
124
  if (!LADDERS[op.kind].family.includes(value)) return { skip: 'off-ladder' };
101
125
  if (op.set !== undefined) return { from: value, to: op.set };
102
126
 
103
- const rungs = rungsFor(op);
127
+ const rungs = rungsFor(op, variable);
104
128
  let index = rungs.indexOf(value);
129
+ let shift = op.shift!;
105
130
  if (index < 0) {
106
131
  if (value === RADIUS_FULL) return { skip: 'pill-preserved' };
107
- index = nearestSpaceRung(value, rungs);
132
+ index = snapRung(value, rungs, shift);
133
+ if (index < 0) return { skip: 'clamped' };
134
+ shift -= Math.sign(shift);
108
135
  }
109
136
 
110
- const wanted = index + op.shift!;
137
+ const wanted = index + shift;
111
138
  const landed = Math.min(Math.max(wanted, 0), rungs.length - 1);
112
139
  if (landed !== wanted && rungs[landed] === value) return { skip: 'clamped' };
113
140
  return { from: value, to: rungs[landed] };
@@ -123,7 +150,7 @@ function validateOp(op: AdjustOp, configs: Record<string, ComponentConfig>): voi
123
150
  const hasSet = op.set !== undefined;
124
151
  const hasShift = op.shift !== undefined;
125
152
  if (hasSet === hasShift) throw new Error(`A ${op.kind} op needs exactly one of "set" or "shift"`);
126
- const settable = op.kind === 'radius' ? ladder.family : ladder.rungs;
153
+ const settable = ladder.settable;
127
154
  if (hasSet && !settable.includes(op.set!)) {
128
155
  throw new Error(`"${op.set}" is not on the ${op.kind} ladder`);
129
156
  }
@@ -161,7 +188,7 @@ export function adjustAliases(
161
188
 
162
189
  for (const [variable, value] of Object.entries(config.aliases)) {
163
190
  if (!matchesKind(variable, op.kind)) continue;
164
- const outcome = resolve(op, value);
191
+ const outcome = resolve(op, variable, value);
165
192
  if ('skip' in outcome) {
166
193
  reportFor(component).skips.push({ variable, value, reason: outcome.skip });
167
194
  continue;
@@ -64,10 +64,15 @@ const MATCHER_BY_KIND = Object.fromEntries(
64
64
 
65
65
  const SIDE_SUFFIXES = ['-top', '-right', '-bottom', '-left'];
66
66
 
67
+ /** Drops a per-side suffix so callers can test the parent name. */
68
+ export function stripSide(variable: string): string {
69
+ const side = SIDE_SUFFIXES.find((s) => variable.endsWith(s));
70
+ return side ? variable.slice(0, -side.length) : variable;
71
+ }
72
+
67
73
  /** Side-aware kind test. Per-side paddings (`--card-default-body-padding-top`)
68
74
  are written by UIPaddingSelector and never declared as editor tokens, so
69
75
  `rawKind` never meets one; the CLI does, and they belong with their parent. */
70
76
  export function matchesKind(variable: string, kind: TokenKind): boolean {
71
- const side = SIDE_SUFFIXES.find((s) => variable.endsWith(s));
72
- return MATCHER_BY_KIND[kind](side ? variable.slice(0, -side.length) : variable);
77
+ return MATCHER_BY_KIND[kind](stripSide(variable));
73
78
  }
@@ -58,7 +58,7 @@ export const deleteComponentConfig = (component: string, fileName: string): Prom
58
58
  resourceFor(component).remove(fileName);
59
59
 
60
60
  /** The config the component is running: the buffer, else the open theme's
61
- * copy, else the component default. `_source` says which. */
61
+ * copy (every theme carries every component by value). `_source` says which. */
62
62
  export const getActiveComponentConfig = (component: string): Promise<ComponentConfig | null> =>
63
63
  resourceFor(component).getActive();
64
64
 
@@ -28,6 +28,7 @@ import {
28
28
  CURRENT_COMPONENT_SCHEMA_VERSION,
29
29
  runMigrations,
30
30
  } from '../themes/migrations';
31
+ import { migrateComponentConfig } from '../themes/migrateComponentConfig';
31
32
  import { renamePrimaryPaletteKey } from '../themes/migrations/2026-05-13-primary-to-brand';
32
33
  import {
33
34
  renameBackgroundPaletteKey,
@@ -220,202 +221,11 @@ export {
220
221
  // `fromVersion >= file.schemaVersion`, then `splitAliasesAndConfig` routes
221
222
  // literal-valued knobs (per `KNOWN_COMPONENT_CONFIG_KEYS`) into the config
222
223
  // bucket and wraps the remainder as `CssVarRef` discriminated unions.
223
-
224
- /**
225
- * Migrate a disk-shape alias bag through the registered migration runner.
226
- *
227
- * The runner is string-typed by contract (most migrations only do
228
- * key/value string rewrites). Object-valued entries — currently just
229
- * inline `{ kind: 'gradient', value: ... }` payloads — pass through
230
- * untouched: we split the bag into string/object subsets, run the runner
231
- * on the string subset, then merge.
232
- *
233
- * For sectiondivider's legacy 7-flat-token gradients we run a synthesis
234
- * step BEFORE the migration runner that would strip those tokens. The
235
- * synthesized gradient object lands in the object subset and survives
236
- * the stripping pass.
237
- */
238
- function migrateComponentAliases(
239
- component: string,
240
- aliasesIn: Record<string, AliasDiskValue>,
241
- rawConfig: Record<string, unknown> | undefined,
242
- fileVersion: number,
243
- ): { aliases: Record<string, AliasDiskValue>; config: Record<string, unknown> } {
244
- const stringPart: Record<string, string> = {};
245
- const objectPart: Record<string, AliasDiskValue> = {};
246
- for (const [k, v] of Object.entries(aliasesIn)) {
247
- if (typeof v === 'string') stringPart[k] = v;
248
- else objectPart[k] = v;
249
- }
250
- // String-valued config entries join the migration input so a migration can
251
- // rename or relocate them. After migration the bag flows through
252
- // `splitAliasesAndConfig` which routes KNOWN keys back to config and the
253
- // rest to aliases — this is what lets a migration *move* a key from the
254
- // config bucket to the aliases bucket by also removing it from KNOWN.
255
- // Config wins over an aliases-bucket collision on the same key: the legacy
256
- // single-bucket → split-bucket move preserved "explicit config field beats
257
- // legacy alias-bucketed value", and that contract still holds.
258
- // Non-string config values (rare; reserved for future structured payloads)
259
- // pass through untouched.
260
- const configOut: Record<string, unknown> = {};
261
- for (const [k, v] of Object.entries(rawConfig ?? {})) {
262
- if (typeof v === 'string') stringPart[k] = v;
263
- else configOut[k] = v;
264
- }
265
- if (component === 'sectiondivider') {
266
- synthesizeSectionDividerGradients(stringPart, objectPart);
267
- }
268
- const migratedString = runMigrations('component-config', fileVersion, stringPart, { component });
269
- const migratedObject = component === 'sectiondivider'
270
- ? renameSectionDividerObjectSlots(objectPart)
271
- : objectPart;
272
- return {
273
- aliases: { ...migratedString, ...migratedObject },
274
- config: configOut,
275
- };
276
- }
277
-
278
- /**
279
- * v8→v9 companion to `componentMigration_2026_05_20_sectiondividerSlimVariants`:
280
- * the structured background payload is reshaped from six color families →
281
- * three size variants (lg/md/sm). Canvas's gradient is taken as the canonical
282
- * seed and fanned across all three new variants; other families' gradients
283
- * are dropped. Idempotent — keys already at the final shape pass through.
284
- *
285
- * Sources accepted (in priority order, first-found wins for canvas's slot):
286
- * --sectiondivider-canvas-background (current/in-progress shape)
287
- * --sectiondivider-canvas-gradient (v7-era shape)
288
- * --sectiondivider-color-canvas-background (intermediate shape that landed
289
- * briefly during the color-prop pivot)
290
- *
291
- * The migration runner only sees string keys, so this object-shape rename
292
- * has to live alongside the runner call.
293
- */
294
- const FAMILIES = ['canvas', 'neutral', 'alternate', 'primary', 'accent', 'special'] as const;
295
- const SIZE_VARIANTS = ['lg', 'md', 'sm'] as const;
296
- function renameSectionDividerObjectSlots(
297
- objectPart: Record<string, AliasDiskValue>,
298
- ): Record<string, AliasDiskValue> {
299
- // Find canvas's gradient under any historical key shape.
300
- const canvasSourceKeys = [
301
- '--sectiondivider-canvas-background',
302
- '--sectiondivider-canvas-gradient',
303
- '--sectiondivider-color-canvas-background',
304
- ];
305
- let canvasGradient: AliasDiskValue | undefined;
306
- for (const key of canvasSourceKeys) {
307
- if (objectPart[key] !== undefined) {
308
- canvasGradient = objectPart[key];
309
- break;
310
- }
311
- }
312
-
313
- // If we found a canvas gradient (any era), fan it into the three new
314
- // per-variant background slots and drop every other family's gradient.
315
- // If no canvas gradient is found, pass through non-family keys verbatim
316
- // so non-migration data (theme tokens, other components) isn't touched.
317
- const out: Record<string, AliasDiskValue> = {};
318
- for (const [key, value] of Object.entries(objectPart)) {
319
- // Skip any sectiondivider per-family gradient; the canvas fan-out emits
320
- // the canonical set below.
321
- let isFamilyGradient = false;
322
- for (const f of FAMILIES) {
323
- if (
324
- key === `--sectiondivider-${f}-background`
325
- || key === `--sectiondivider-${f}-gradient`
326
- || key === `--sectiondivider-color-${f}-background`
327
- ) {
328
- isFamilyGradient = true;
329
- break;
330
- }
331
- }
332
- // Pass through anything that's already at the final lg/md/sm shape so
333
- // re-running the migration is idempotent.
334
- if (isFamilyGradient) continue;
335
- out[key] = value;
336
- }
337
- if (canvasGradient !== undefined) {
338
- for (const v of SIZE_VARIANTS) {
339
- out[`--sectiondivider-${v}-background`] = canvasGradient;
340
- }
341
- }
342
- return out;
343
- }
344
-
345
- /** SectionDivider variants that ship gradient aliases. */
346
- const SECTIONDIVIDER_VARIANTS = ['canvas', 'neutral', 'alternate', 'primary', 'accent', 'special'] as const;
347
-
348
- /**
349
- * Collapse legacy 7-flat-token gradients (`{angle, stop-{1,2,3}-{color,position}}`)
350
- * for each sectiondivider variant into one structured gradient alias at
351
- * `--sectiondivider-{v}-gradient`. The flat tokens stay in `stringPart`
352
- * for the migration runner to strip; the structured payload joins
353
- * `objectPart` so the merge produces the new shape.
354
- *
355
- * Skips variants that already declare a gradient alias (object form) so
356
- * round-tripping doesn't overwrite user edits.
357
- */
358
- function synthesizeSectionDividerGradients(
359
- stringPart: Record<string, string>,
360
- objectPart: Record<string, AliasDiskValue>,
361
- ): void {
362
- const GRADIENT_ANGLE_TOKENS: Record<string, number> = {
363
- '--gradient-angle-horizontal': 90,
364
- '--gradient-angle-vertical': 180,
365
- '--gradient-angle-diagonal': 135,
366
- '--gradient-angle-counter-diagonal': 45,
367
- };
368
- const GRADIENT_STOP_POSITIONS: Record<string, number> = {
369
- '--gradient-stop-start': 0,
370
- '--gradient-stop-mid': 50,
371
- '--gradient-stop-end': 100,
372
- };
373
- const parseAngle = (raw: string | undefined): number => {
374
- if (!raw) return 135;
375
- if (raw.startsWith('--')) return GRADIENT_ANGLE_TOKENS[raw] ?? 135;
376
- const m = raw.match(/^(-?\d+(?:\.\d+)?)\s*deg$/i);
377
- return m ? parseFloat(m[1]) : 135;
378
- };
379
- const parsePosition = (raw: string | undefined, fallback: number): number => {
380
- if (!raw) return fallback;
381
- if (raw.startsWith('--')) return GRADIENT_STOP_POSITIONS[raw] ?? fallback;
382
- const m = raw.match(/^(-?\d+(?:\.\d+)?)\s*%$/);
383
- return m ? parseFloat(m[1]) : fallback;
384
- };
385
- const parseStopColor = (raw: string | undefined): { color: string; opacity?: number } | null => {
386
- if (!raw) return null;
387
- if (raw.startsWith('--')) return { color: raw };
388
- const mix = raw.match(/^color-mix\(\s*in\s+srgb\s*,\s*var\((--[a-z0-9-]+)\)\s+([\d.]+)%\s*,\s*transparent\s*\)$/i);
389
- if (mix) return { color: mix[1], opacity: parseFloat(mix[2]) };
390
- return null;
391
- };
392
- for (const v of SECTIONDIVIDER_VARIANTS) {
393
- const slot = `--sectiondivider-${v}-gradient`;
394
- if (slot in objectPart) continue;
395
- const angleKey = `${slot}-angle`;
396
- const has1 = stringPart[`${slot}-stop-1-color`] !== undefined;
397
- const has2 = stringPart[`${slot}-stop-2-color`] !== undefined;
398
- const has3 = stringPart[`${slot}-stop-3-color`] !== undefined;
399
- if (!has1 && !has2 && !has3) continue;
400
- const stops: { position: number; color: string; opacity?: number }[] = [];
401
- const positionDefaults = [0, 50, 100];
402
- for (let i = 1; i <= 3; i++) {
403
- const c = parseStopColor(stringPart[`${slot}-stop-${i}-color`]);
404
- if (!c) continue;
405
- const pos = parsePosition(stringPart[`${slot}-stop-${i}-position`], positionDefaults[i - 1]);
406
- stops.push({ position: pos, color: c.color, ...(c.opacity !== undefined ? { opacity: c.opacity } : {}) });
407
- }
408
- if (stops.length < 2) continue;
409
- objectPart[slot] = {
410
- kind: 'gradient',
411
- value: {
412
- type: 'linear',
413
- angle: parseAngle(stringPart[angleKey]),
414
- stops,
415
- },
416
- };
417
- }
418
- }
224
+ //
225
+ // The migration itself — `migrateComponentConfig`, plus its sectiondivider
226
+ // gradient helpers — lives in `../themes/migrateComponentConfig` so the
227
+ // server (`normalizeTheme`) can run the same pass over an embedded theme
228
+ // config instead of reading it raw.
419
229
 
420
230
  /**
421
231
  * Disk-shape → in-memory split. Routes legacy single-bucket aliases that
@@ -459,7 +269,7 @@ export function toComponentSlice(
459
269
  config?: Record<string, unknown>,
460
270
  schemaVersion: number = 0,
461
271
  ): { aliases: Record<string, CssVarRef>; config: Record<string, unknown> } {
462
- const migrated = migrateComponentAliases(component, aliases, config, schemaVersion);
272
+ const migrated = migrateComponentConfig(component, aliases, config, schemaVersion);
463
273
  return splitAliasesAndConfig(migrated.aliases, migrated.config);
464
274
  }
465
275
 
@@ -1,4 +1,5 @@
1
1
  import type { PaletteConfig, FontSource, FontStack } from '../themes/themeTypes';
2
+ import type { GradientStop, GradientValue } from '../themes/parsers/gradient';
2
3
  import type { HarmonyAxis } from '../palettes/colorHarmony';
3
4
 
4
5
  export interface ShadowGlobals {
@@ -46,20 +47,9 @@ export interface ColumnsState {
46
47
  * round-trip when the user toggles back to a real gradient. */
47
48
  export type GradientType = 'linear' | 'radial' | 'solid' | 'none';
48
49
 
49
- export interface GradientTokenStop {
50
- /** 0–100 percentage along the gradient axis. */
51
- position: number;
52
- /** CSS variable name the stop resolves through (e.g. '--color-brand-500'). */
53
- color: string;
54
- /** When `false`, this stop is an explicit off-palette override: the stop
55
- * picker opens to any color (not family-filtered), and the stop is skipped
56
- * by family-swap rewrites so its color survives a variant family change.
57
- * Defaults to true (follow family) on read; absence is the same as `true`.
58
- * Only meaningful for gradients rendered inside a family-aware context. */
59
- monochrome?: boolean;
60
- /** 0–100 alpha applied to the stop's color. Defaults to 100 (fully opaque). */
61
- opacity?: number;
62
- }
50
+ /** One stop of a gradient. Defined by `themes/parsers/gradient`, which owns
51
+ * the shape so the CSS form and the persisted form cannot drift apart. */
52
+ export type GradientTokenStop = GradientStop;
63
53
 
64
54
  export interface GradientToken {
65
55
  /** Output CSS variable, e.g. '--gradient-1'. */
@@ -83,22 +73,11 @@ export interface GradientToken {
83
73
  }
84
74
 
85
75
  /** Structured gradient payload carried inline on a component alias.
86
- * Mirrors GradientToken minus `variable` (the alias key itself is the
87
- * binding). Used when a component owns a per-instance gradient that
88
- * doesn't share the theme-level `--gradient-N` library. */
89
- export interface GradientAliasValue {
90
- type: GradientType;
91
- angle: number;
92
- /** See GradientToken.radius. */
93
- radius?: number;
94
- /** See GradientToken.centerX. */
95
- centerX?: number;
96
- /** See GradientToken.aspectX. */
97
- aspectX?: number;
98
- /** See GradientToken.aspectY. */
99
- aspectY?: number;
100
- stops: GradientTokenStop[];
101
- }
76
+ * `GradientToken` minus `variable` (the alias key itself is the binding).
77
+ * Used when a component owns a per-instance gradient that doesn't share the
78
+ * theme-level `--gradient-N` library. Defined by `themes/parsers/gradient`,
79
+ * the module that serializes and parses this shape. */
80
+ export type GradientAliasValue = GradientValue;
102
81
 
103
82
  export type CssVarRef =
104
83
  /** An alias to a design token. `opacity` is an optional integer percent set