@motion-proto/live-tokens 0.7.1 → 0.9.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 (96) hide show
  1. package/.claude/skills/live-tokens-add-component/SKILL.md +488 -0
  2. package/README.md +34 -0
  3. package/dist-plugin/index.cjs +707 -90
  4. package/dist-plugin/index.d.cts +1 -0
  5. package/dist-plugin/index.d.ts +1 -0
  6. package/dist-plugin/index.js +707 -90
  7. package/package.json +6 -2
  8. package/src/app/site.css +1 -1
  9. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +34 -27
  10. package/src/editor/component-editor/DialogEditor.svelte +4 -4
  11. package/src/editor/component-editor/NotificationEditor.svelte +3 -1
  12. package/src/editor/component-editor/SectionDividerEditor.svelte +439 -112
  13. package/src/editor/component-editor/StandardButtonsEditor.svelte +13 -1
  14. package/src/editor/component-editor/editors.d.ts +10 -0
  15. package/src/editor/component-editor/index.ts +16 -1
  16. package/src/editor/component-editor/registry.ts +103 -26
  17. package/src/editor/component-editor/scaffolding/AngleDial.svelte +52 -13
  18. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +10 -11
  19. package/src/editor/component-editor/scaffolding/ComponentsTab.svelte +2 -2
  20. package/src/editor/component-editor/scaffolding/LinkedBlock.svelte +0 -1
  21. package/src/editor/component-editor/scaffolding/RadialShapePad.svelte +483 -0
  22. package/src/editor/component-editor/scaffolding/ShadowBackdrop.svelte +15 -2
  23. package/src/editor/component-editor/scaffolding/StateBlock.svelte +103 -15
  24. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +9 -6
  25. package/src/editor/component-editor/scaffolding/TypeEditor.svelte +13 -1
  26. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +239 -25
  27. package/src/editor/component-editor/scaffolding/buildTypeGroupTokens.ts +1 -0
  28. package/src/editor/component-editor/scaffolding/componentSources.ts +3 -3
  29. package/src/editor/component-editor/scaffolding/defaultSections.ts +15 -10
  30. package/src/editor/component-editor/scaffolding/types.ts +11 -0
  31. package/src/editor/core/components/componentConfigKeys.ts +22 -3
  32. package/src/editor/core/components/componentConfigService.ts +2 -2
  33. package/src/editor/core/components/componentPersist.ts +7 -5
  34. package/src/editor/core/manifests/manifestService.ts +58 -3
  35. package/src/editor/core/palettes/familySwap.ts +99 -0
  36. package/src/editor/core/palettes/paletteDerivation.ts +69 -0
  37. package/src/editor/core/palettes/tokenRegistry.ts +4 -1
  38. package/src/editor/core/store/editorStore.ts +206 -12
  39. package/src/editor/core/store/editorTypes.ts +55 -12
  40. package/src/editor/core/store/gradientSource.ts +192 -0
  41. package/src/editor/core/themes/migrations/2026-05-19-collapsiblesection-drop-frame-surface.ts +28 -0
  42. package/src/editor/core/themes/migrations/2026-05-19-sectiondivider-rich-gradient.ts +35 -0
  43. package/src/editor/core/themes/migrations/2026-05-20-sectiondivider-slim-variants.ts +82 -0
  44. package/src/editor/core/themes/migrations/2026-05-21-sectiondivider-spacing-to-padding.ts +24 -0
  45. package/src/editor/core/themes/migrations/2026-05-22-sectiondivider-intrinsics-to-css.ts +81 -0
  46. package/src/editor/core/themes/migrations/index.ts +10 -0
  47. package/src/editor/core/themes/slices/components.ts +27 -4
  48. package/src/editor/core/themes/slices/gradients.ts +88 -13
  49. package/src/editor/core/themes/themeInit.ts +2 -2
  50. package/src/editor/core/themes/themeTypes.ts +56 -1
  51. package/src/editor/index.ts +10 -1
  52. package/src/editor/overlay/ColumnsOverlay.svelte +0 -1
  53. package/src/editor/overlay/LiveEditorOverlay.svelte +1 -4
  54. package/src/editor/pages/ComponentEditorPage.svelte +53 -3
  55. package/src/editor/pages/EditorShell.svelte +53 -3
  56. package/src/editor/styles/ui-editor.css +1 -0
  57. package/src/editor/styles/ui-form-controls.css +19 -20
  58. package/src/editor/ui/BezierCurveEditor.svelte +114 -63
  59. package/src/editor/ui/EditorViewSwitcher.svelte +0 -1
  60. package/src/editor/ui/FileLoadList.svelte +22 -5
  61. package/src/editor/ui/FontStackEditor.svelte +214 -76
  62. package/src/editor/ui/GradientEditor.svelte +435 -215
  63. package/src/editor/ui/GradientStopPicker.svelte +11 -3
  64. package/src/editor/ui/ManifestFileManager.svelte +71 -4
  65. package/src/editor/ui/PaletteEditor.svelte +52 -79
  66. package/src/editor/ui/ProjectFontsSection.svelte +328 -293
  67. package/src/editor/ui/ThemeFileManager.svelte +0 -4
  68. package/src/editor/ui/UIFontFamilySelector.svelte +0 -1
  69. package/src/editor/ui/UIFontSizeSelector.svelte +3 -0
  70. package/src/editor/ui/UIInfoPopover.svelte +0 -1
  71. package/src/editor/ui/UILetterSpacingSelector.svelte +65 -0
  72. package/src/editor/ui/UIPaletteSelector.svelte +31 -4
  73. package/src/editor/ui/UIPillButton.svelte +33 -3
  74. package/src/editor/ui/UISegmentedControl.svelte +114 -0
  75. package/src/editor/ui/UITokenSelector.svelte +4 -1
  76. package/src/editor/ui/VariablesTab.svelte +41 -35
  77. package/src/editor/ui/palette/OverridesPanel.svelte +14 -37
  78. package/src/editor/ui/palette/PaletteBase.svelte +3 -3
  79. package/src/editor/ui/sections/ColumnsSection.svelte +1 -2
  80. package/src/editor/ui/sections/GradientsSection.svelte +1 -1
  81. package/src/editor/ui/sections/OverlaysSection.svelte +1 -1
  82. package/src/editor/ui/sections/ShadowsSection.svelte +1 -1
  83. package/src/system/components/Button.svelte +2 -2
  84. package/src/system/components/Card.svelte +29 -1
  85. package/src/system/components/CollapsibleSection.svelte +25 -2
  86. package/src/system/components/Dialog.svelte +24 -4
  87. package/src/system/components/FloatingTokenTags.css +43 -24
  88. package/src/system/components/FloatingTokenTags.svelte +88 -137
  89. package/src/system/components/Notification.svelte +8 -1
  90. package/src/system/components/SectionDivider.svelte +532 -381
  91. package/src/system/styles/CONVENTIONS.md +1 -1
  92. package/src/system/styles/fonts.css +3 -16
  93. package/src/system/styles/tokens.css +356 -1199
  94. package/src/system/styles/tokens.generated.css +544 -0
  95. package/src/editor/component-editor/scaffolding/DividerEditor.svelte +0 -94
  96. package/src/editor/component-editor/scaffolding/GradientCard.svelte +0 -296
@@ -30,6 +30,10 @@
30
30
  stateActions?: Snippet<[string]>;
31
31
  previewActions?: Snippet;
32
32
  compositeControls?: Snippet<[string]>;
33
+ /** Each child should be a `.property-row`. Rendered above the token grid
34
+ so per-variant display knobs (alignment, hairline, etc.) lead the list
35
+ before the typography and token rows. */
36
+ extraPropertyRowsTop?: Snippet<[string]>;
33
37
  /** Each child should be a `.property-row` to match the token grid above. */
34
38
  extraPropertyRows?: Snippet<[string]>;
35
39
  /** Extra sections appended below the Background controls inside the canvas
@@ -37,6 +41,13 @@
37
41
  Background. Lets per-instance display knobs (anchor, alignment, etc.)
38
42
  live with the canvas rather than in a separate config block above. */
39
43
  canvasToolbarExtras?: Snippet;
44
+ /** Per-element Show toggles, forwarded to StateBlock. Keyed by element name. */
45
+ elementToggles?: Record<string, { checked: boolean; label?: string; onchange: (checked: boolean) => void }>;
46
+ /** Explicit order for element-grouped sections, forwarded to StateBlock. */
47
+ elementOrder?: string[];
48
+ /** Per-element extras snippet, forwarded to StateBlock. Receives the
49
+ element name and renders between the section heading and its content. */
50
+ elementExtras?: Snippet<[string]>;
40
51
  /** Skip the default centered, padded stage when the editor brings its own backdrop. */
41
52
  unboxedPreview?: boolean;
42
53
  backdropPadding?: string;
@@ -58,8 +69,12 @@
58
69
  stateActions,
59
70
  previewActions,
60
71
  compositeControls,
72
+ extraPropertyRowsTop,
61
73
  extraPropertyRows,
62
74
  canvasToolbarExtras,
75
+ elementToggles,
76
+ elementOrder,
77
+ elementExtras,
63
78
  unboxedPreview = false,
64
79
  backdropPadding,
65
80
  backdropModes,
@@ -78,7 +93,7 @@
78
93
 
79
94
  let activeTab: string = $state('');
80
95
 
81
- const TYPE_PROPS = ['colorVariable', 'familyVariable', 'sizeVariable', 'weightVariable', 'lineHeightVariable', 'outlineWidthVariable', 'outlineColorVariable'] as const;
96
+ const TYPE_PROPS = ['colorVariable', 'familyVariable', 'sizeVariable', 'weightVariable', 'lineHeightVariable', 'letterSpacingVariable', 'outlineWidthVariable', 'outlineColorVariable'] as const;
82
97
  // Carry per-side derived vars so split padding fully transfers; no-op when absent.
83
98
  const PADDING_SIDES = ['top', 'right', 'bottom', 'left'] as const;
84
99
 
@@ -124,6 +139,25 @@
124
139
  skips these in the typeGroups copy loop. */
125
140
  const COLOR_TYPE_PROPS = new Set(['colorVariable', 'outlineColorVariable']);
126
141
 
142
+ /** True iff `colorRef` is a CSS-var token whose slug contains `family`
143
+ as a hyphen-delimited segment (e.g. `--surface-canvas-low` ∋ `canvas`). */
144
+ function stopMatchesFamily(colorRef: string, family: string): boolean {
145
+ if (!colorRef.startsWith('--')) return false;
146
+ return colorRef.slice(2).split('-').includes(family);
147
+ }
148
+
149
+ /** Replace the first `from` segment in a CSS-var token slug with `to`.
150
+ Used to swap stop colors across variant families on gradient copy
151
+ (e.g. `--surface-success-high` + (success → accent) → `--surface-accent-high`). */
152
+ function swapFamilyInToken(colorRef: string, from: string, to: string): string {
153
+ if (!colorRef.startsWith('--')) return colorRef;
154
+ const parts = colorRef.slice(2).split('-');
155
+ const idx = parts.indexOf(from);
156
+ if (idx < 0) return colorRef;
157
+ parts[idx] = to;
158
+ return '--' + parts.join('-');
159
+ }
160
+
127
161
  function pickCopySource(toState: string, fromVariant: string, fromState: string) {
128
162
  const preserveColorFamily = $preserveColorFamilyStore;
129
163
  if (!component || !states) return;
@@ -139,11 +173,15 @@
139
173
  const slice = s.components[component!] ?? (s.components[component!] = { activeFile: 'default', aliases: {}, config: {} });
140
174
  const dstVarsTouched: string[] = [];
141
175
  /** Resolve a variable's effective value as a CSS string: the override if
142
- set, otherwise its declared default. Returns null if neither exists. */
176
+ set, otherwise its declared default. Returns null if neither exists.
177
+ Gradient refs short-circuit to null — the copy path for gradients
178
+ uses `applyGradient`, not the alpha-extract pipeline. */
143
179
  const effectiveValue = (varName: string): string | null => {
144
180
  const ref = slice.aliases[varName];
145
- if (ref) return ref.kind === 'token' ? `var(${ref.name})` : ref.value;
146
- return getDeclaredValue(varName);
181
+ if (!ref) return getDeclaredValue(varName);
182
+ if (ref.kind === 'token') return `var(${ref.name})`;
183
+ if (ref.kind === 'literal') return ref.value;
184
+ return null;
147
185
  };
148
186
 
149
187
  const apply = (srcVar: string, dstVar: string) => {
@@ -179,10 +217,49 @@
179
217
  };
180
218
  dstVarsTouched.push(dstVar);
181
219
  };
220
+ /** Copy a structured gradient ref. Stops carry source's positions +
221
+ opacities verbatim; out-of-family stop colors carry verbatim too.
222
+ With preserveColorFamily on, in-family stop colors swap to the
223
+ destination family — see plan §5 for the worked example. */
224
+ const applyGradient = (
225
+ srcVar: string,
226
+ dstVar: string,
227
+ srcFamily: string | undefined,
228
+ dstFamily: string | undefined,
229
+ ) => {
230
+ const srcRef = slice.aliases[srcVar];
231
+ if (!srcRef || srcRef.kind !== 'gradient') {
232
+ // Source has no override — clearing dst returns it to its own CSS
233
+ // default (which is dst's family by design), preserving the
234
+ // "destination keeps its family palette" invariant.
235
+ delete slice.aliases[dstVar];
236
+ dstVarsTouched.push(dstVar);
237
+ return;
238
+ }
239
+ const swapFamilies = preserveColorFamily
240
+ && !!srcFamily && !!dstFamily
241
+ && srcFamily !== dstFamily;
242
+ const newStops = srcRef.value.stops.map((s) => {
243
+ if (swapFamilies && stopMatchesFamily(s.color, srcFamily!)) {
244
+ return { ...s, color: swapFamilyInToken(s.color, srcFamily!, dstFamily!) };
245
+ }
246
+ return { ...s };
247
+ });
248
+ slice.aliases[dstVar] = {
249
+ kind: 'gradient',
250
+ value: { type: srcRef.value.type, angle: srcRef.value.angle, stops: newStops },
251
+ };
252
+ dstVarsTouched.push(dstVar);
253
+ };
254
+
182
255
  const minLen = Math.min(srcTokens.length, dstTokens.length);
183
256
  for (let i = 0; i < minLen; i++) {
184
257
  const srcVar = srcTokens[i].variable;
185
258
  const dstVar = dstTokens[i].variable;
259
+ if (srcTokens[i].kind === 'gradient' || dstTokens[i].kind === 'gradient') {
260
+ applyGradient(srcVar, dstVar, srcTokens[i].family, dstTokens[i].family);
261
+ continue;
262
+ }
186
263
  if (preserveColorFamily && isColorToken(srcTokens[i])) {
187
264
  applyColorPreserve(srcVar, dstVar);
188
265
  continue;
@@ -231,7 +308,45 @@
231
308
  });
232
309
 
233
310
  let stateNames = $derived(states ? Object.keys(states) : []);
311
+ /** Key-name convention: when ANY state name contains " / ", the strip switches
312
+ to two-tier rendering — top row is parts (unique left-hand sides), bottom
313
+ row is the active part's states (right-hand sides). Parts with no slash
314
+ have no sub-states and skip the bottom row when active. */
315
+ const PART_SEP = ' / ';
316
+ let isHierarchical = $derived(stateNames.some((n) => n.includes(PART_SEP)));
317
+ /** Ordered list of unique parts, preserving the order they first appear in `states`. */
318
+ let parts = $derived.by(() => {
319
+ const seen: string[] = [];
320
+ for (const n of stateNames) {
321
+ const part = n.includes(PART_SEP) ? n.split(PART_SEP)[0] : n;
322
+ if (!seen.includes(part)) seen.push(part);
323
+ }
324
+ return seen;
325
+ });
326
+ /** Sub-states of the currently active part (only meaningful in hierarchical mode). */
327
+ let activePart = $derived(activeTab.includes(PART_SEP) ? activeTab.split(PART_SEP)[0] : activeTab);
328
+ let activeSubState = $derived(activeTab.includes(PART_SEP) ? activeTab.split(PART_SEP)[1] : '');
329
+ let partSubStates = $derived(
330
+ isHierarchical
331
+ ? stateNames.filter((n) => n.startsWith(activePart + PART_SEP)).map((n) => n.split(PART_SEP)[1])
332
+ : [],
333
+ );
234
334
  let tabsStripVisible = $derived(stateNames.length >= 2);
335
+ let subStripVisible = $derived(isHierarchical && partSubStates.length >= 2);
336
+
337
+ /** Switch parts. If the new part has sub-states, jump to its first one; otherwise
338
+ activate the part itself. Keeps activeTab as a single canonical key so the
339
+ downstream property lookup, copy-from menus, and focusedStateStore stay simple. */
340
+ function selectPart(part: string) {
341
+ const firstSub = stateNames.find((n) => n.startsWith(part + PART_SEP));
342
+ activeTab = firstSub ?? part;
343
+ focusedStateStore.set(activeTab);
344
+ }
345
+ function selectSubState(sub: string) {
346
+ activeTab = `${activePart}${PART_SEP}${sub}`;
347
+ focusedStateStore.set(activeTab);
348
+ }
349
+
235
350
  $effect(() => {
236
351
  if (stateNames.length > 0 && !stateNames.includes(activeTab)) {
237
352
  activeTab = stateNames[0];
@@ -297,30 +412,62 @@
297
412
  {@render children?.({ activeState: activeTab })}
298
413
  </ShadowBackdrop>
299
414
  {/if}
300
- </div>
301
415
 
302
- {#if tabsStripVisible}
303
- <div class="tabs-states-block">
304
- <span class="editor-subsection-title">{selectorLabel}</span>
305
- <div class="tabs-selectors">
306
- <div class="state-tabs" role="tablist">
307
- {#each stateNames as s}
308
- <button
309
- type="button"
310
- class="state-tab-btn"
311
- class:active={activeTab === s}
312
- role="tab"
313
- aria-selected={activeTab === s}
314
- onclick={() => { activeTab = s; focusedStateStore.set(s); }}
315
- >{s}</button>
316
- {/each}
416
+ {#if tabsStripVisible}
417
+ <div class="tabs-states-block">
418
+ <span class="editor-subsection-title">{selectorLabel}</span>
419
+ <div class="tabs-selectors">
420
+ {#if isHierarchical}
421
+ <div class="state-tabs" role="tablist">
422
+ {#each parts as p}
423
+ <button
424
+ type="button"
425
+ class="state-tab-btn"
426
+ class:active={activePart === p}
427
+ role="tab"
428
+ aria-selected={activePart === p}
429
+ onclick={() => selectPart(p)}
430
+ >{p}</button>
431
+ {/each}
432
+ </div>
433
+ {:else}
434
+ <div class="state-tabs" role="tablist">
435
+ {#each stateNames as s}
436
+ <button
437
+ type="button"
438
+ class="state-tab-btn"
439
+ class:active={activeTab === s}
440
+ role="tab"
441
+ aria-selected={activeTab === s}
442
+ onclick={() => { activeTab = s; focusedStateStore.set(s); }}
443
+ >{s}</button>
444
+ {/each}
445
+ </div>
446
+ {/if}
447
+ {#if activeTab}
448
+ {@render stateActions?.(activeTab)}
449
+ {/if}
317
450
  </div>
318
- {#if activeTab}
319
- {@render stateActions?.(activeTab)}
451
+ {#if subStripVisible}
452
+ <div class="tabs-selectors substrip">
453
+ <span class="editor-subsection-title state-eyebrow">State</span>
454
+ <div class="state-tabs" role="tablist">
455
+ {#each partSubStates as s}
456
+ <button
457
+ type="button"
458
+ class="state-tab-btn"
459
+ class:active={activeSubState === s}
460
+ role="tab"
461
+ aria-selected={activeSubState === s}
462
+ onclick={() => selectSubState(s)}
463
+ >{s}</button>
464
+ {/each}
465
+ </div>
466
+ </div>
320
467
  {/if}
321
468
  </div>
322
- </div>
323
- {/if}
469
+ {/if}
470
+ </div>
324
471
 
325
472
  {#if activeTab && states[activeTab]}
326
473
  {@const stateName = activeTab}
@@ -339,12 +486,20 @@
339
486
  />
340
487
  {/if}
341
488
  </div>
489
+ {#if extraPropertyRowsTop}
490
+ <div class="extra-property-rows extra-property-rows--top">
491
+ {@render extraPropertyRowsTop(stateName)}
492
+ </div>
493
+ {/if}
342
494
  <StateBlock
343
495
  tokens={states[stateName]}
344
496
  typeGroups={typeGroups[stateName] ?? []}
345
497
  {component}
346
498
  {linkedOrder}
347
499
  {columns}
500
+ {elementToggles}
501
+ {elementOrder}
502
+ {elementExtras}
348
503
  {onchange}
349
504
  />
350
505
  {#if extraPropertyRows}
@@ -377,12 +532,46 @@
377
532
  /* Card chrome lives on .editor-section-card in ui-editor.css. */
378
533
  .variant-group {
379
534
  gap: var(--ui-space-12);
535
+ container-type: inline-size;
536
+ container-name: variant-group;
380
537
  }
381
538
 
539
+ /* Pin the preview + state-tab strip to the top of the page scroll so
540
+ property edits stay visually connected to the preview without scrolling.
541
+ The card background extends through the sticky band so the property grid
542
+ scrolls cleanly behind it. The element-grouped property layout (see
543
+ StateBlock) fans out horizontally, which keeps the property section
544
+ short enough that the sticky preview rarely steals usable space. */
382
545
  .tabs-preview {
546
+ position: sticky;
547
+ top: 0;
548
+ z-index: 2;
383
549
  display: flex;
384
550
  flex-direction: column;
385
551
  gap: var(--ui-space-20);
552
+ background: var(--ui-surface-low);
553
+ /* Bleed the background up through the card's top padding so content
554
+ scrolling behind doesn't peek between the viewport edge and the
555
+ pinned preview. The matching negative margin restores flow position.
556
+ Border-radius hugs the card's rounded top corners so the unpinned
557
+ state still reads as one continuous panel. */
558
+ margin: calc(-1 * var(--ui-space-20)) calc(-1 * var(--ui-space-20)) 0;
559
+ padding: var(--ui-space-20) var(--ui-space-20) 0;
560
+ border-radius: var(--ui-radius-md) var(--ui-radius-md) 0 0;
561
+ }
562
+
563
+ /* Soft fade at the bottom of the sticky band so property rows scrolling
564
+ up don't sharply cut against the pinned preview. Greyscale (no accent).
565
+ Sits below the preview body, above scrolling content. */
566
+ .tabs-preview::after {
567
+ content: '';
568
+ position: absolute;
569
+ left: 0;
570
+ right: 0;
571
+ bottom: calc(-1 * var(--ui-space-12));
572
+ height: var(--ui-space-12);
573
+ background: linear-gradient(to bottom, var(--ui-surface-low), transparent);
574
+ pointer-events: none;
386
575
  }
387
576
 
388
577
  .preview-header {
@@ -416,6 +605,13 @@
416
605
  box-sizing: border-box;
417
606
  }
418
607
 
608
+ @container variant-group (max-width: 32rem) {
609
+ .canvas-toolbar {
610
+ width: 100%;
611
+ height: auto;
612
+ }
613
+ }
614
+
419
615
  .canvas-toolbar :global(.canvas-toolbar-eyebrow) {
420
616
  font-size: var(--ui-font-size-xs);
421
617
  font-weight: var(--ui-font-weight-medium);
@@ -441,6 +637,7 @@
441
637
  vocabulary from the rest of the editor. */
442
638
  .canvas-toolbar :global(.canvas-toolbar-select) {
443
639
  width: 100%;
640
+ box-sizing: border-box;
444
641
  appearance: none;
445
642
  -webkit-appearance: none;
446
643
  padding: 0 var(--ui-space-24) 0 var(--ui-space-8);
@@ -466,9 +663,14 @@
466
663
  outline-offset: 2px;
467
664
  }
468
665
 
469
- /* Native <input> styled to match the toolbar's select chrome. */
666
+ /* Native <input> styled to match the toolbar's select chrome. Long values
667
+ ellipsize when blurred so the input doesn't grow or scroll-bleed past
668
+ the toolbar's 11rem column; focus restores native caret-driven scroll. */
470
669
  .canvas-toolbar :global(.canvas-toolbar-input) {
471
670
  width: 100%;
671
+ min-width: 0;
672
+ max-width: 100%;
673
+ box-sizing: border-box;
472
674
  padding: 0 var(--ui-space-8);
473
675
  min-height: 1.75rem;
474
676
  background: var(--ui-surface-low);
@@ -477,6 +679,7 @@
477
679
  color: var(--ui-text-primary);
478
680
  font-family: var(--ui-font-sans);
479
681
  font-size: var(--ui-font-size-sm);
682
+ text-overflow: ellipsis;
480
683
  transition: background-color var(--ui-transition-fast), border-color var(--ui-transition-fast);
481
684
  }
482
685
  .canvas-toolbar :global(.canvas-toolbar-input:hover) {
@@ -572,6 +775,17 @@
572
775
  gap: var(--ui-space-12);
573
776
  }
574
777
 
778
+ /* Sub-strip sits flush under the parts strip when a part has interaction states.
779
+ The eyebrow label distinguishes it from the parts row above. */
780
+ .tabs-selectors.substrip {
781
+ margin-top: var(--ui-space-6);
782
+ }
783
+ .tabs-selectors.substrip .state-eyebrow {
784
+ color: var(--ui-text-tertiary);
785
+ font-size: var(--ui-font-size-xs);
786
+ min-width: 2.5rem;
787
+ }
788
+
575
789
  .state-tabs {
576
790
  display: inline-flex;
577
791
  flex-wrap: wrap;
@@ -7,6 +7,7 @@ export const TYPE_FONT_PROPS = [
7
7
  { key: 'sizeVariable', label: 'font size', defaultGroupKey: 'font-size' },
8
8
  { key: 'weightVariable', label: 'font weight', defaultGroupKey: 'font-weight' },
9
9
  { key: 'lineHeightVariable', label: 'line height', defaultGroupKey: 'line-height' },
10
+ { key: 'letterSpacingVariable', label: 'letter spacing', defaultGroupKey: 'letter-spacing' },
10
11
  ] as const satisfies ReadonlyArray<{
11
12
  key: keyof TypeGroupConfig;
12
13
  label: string;
@@ -1,9 +1,9 @@
1
- import { componentRegistry } from '../registry';
1
+ import { getComponentRegistry } from '../registry';
2
2
 
3
3
  /**
4
4
  * Resolve a component id to its runtime source file path. Reads from the
5
- * single component registry no parallel mapping to maintain.
5
+ * merged component registry (built-ins + runtime registrations).
6
6
  */
7
7
  export function componentSourceFile(component: string): string {
8
- return componentRegistry[component as keyof typeof componentRegistry]?.sourceFile ?? '';
8
+ return getComponentRegistry()[component]?.sourceFile ?? '';
9
9
  }
@@ -1,16 +1,21 @@
1
1
  import type { ComponentSection } from './componentSectionType';
2
- import { componentRegistryEntries } from '../registry';
2
+ import { getComponentRegistryEntries } from '../registry';
3
3
 
4
4
  /**
5
- * Default editor sections — derived from the single component registry. Each
5
+ * Default editor sections — derived from the merged component registry. Each
6
6
  * section's `id` is the canonical lowercase component id (matches the runtime
7
- * filename, server scan, and `setComponentAlias` key); `label` is the
8
- * display string; `component` is the editor Svelte component.
7
+ * filename, server scan, and `setComponentAlias` key); `label` is the display
8
+ * string; `component` is the editor Svelte component.
9
9
  *
10
- * To add or reorder sections, edit `src/component-editor/registry.ts`.
10
+ * Recomputed on each call so consumer-registered components (added via
11
+ * `registerComponent()`) appear after the first-party set in iteration order.
12
+ *
13
+ * To add or reorder first-party sections, edit `src/editor/component-editor/registry.ts`.
11
14
  */
12
- export const defaultSections: ComponentSection[] = componentRegistryEntries.map((entry) => ({
13
- id: entry.id,
14
- label: entry.label,
15
- component: entry.editorComponent,
16
- }));
15
+ export function getDefaultSections(): ComponentSection[] {
16
+ return getComponentRegistryEntries().map((entry) => ({
17
+ id: entry.id,
18
+ label: entry.label,
19
+ component: entry.editorComponent,
20
+ }));
21
+ }
@@ -24,6 +24,15 @@ export type Token = {
24
24
  StateBlock partitions the panel into labeled subsections — typography
25
25
  and properties for each element render together. */
26
26
  element?: string;
27
+ /** Hint to the editor that this token's alias is a structured payload
28
+ (currently only `kind: 'gradient'`). Drives Copy-from's per-kind
29
+ branch — gradient aliases need family-swap of in-family stop colors
30
+ rather than a verbatim ref copy. */
31
+ kind?: 'gradient';
32
+ /** Color-family slug for this token's owning variant (e.g. `brand`,
33
+ `accent`). Set on gradient-kind tokens so Copy-from's family-swap
34
+ can compute the src→dst family substitution. */
35
+ family?: string;
27
36
  };
28
37
 
29
38
  /** Editor type-group: a fieldset containing a coordinated set of typography tokens
@@ -43,6 +52,8 @@ export type TypeGroupConfig = {
43
52
  weightLabel?: string;
44
53
  lineHeightVariable?: string;
45
54
  lineHeightLabel?: string;
55
+ letterSpacingVariable?: string;
56
+ letterSpacingLabel?: string;
46
57
  outlineWidthVariable?: string;
47
58
  outlineWidthLabel?: string;
48
59
  outlineColorVariable?: string;
@@ -4,9 +4,11 @@
4
4
  // migration that splits legacy single-bucket aliases into the new
5
5
  // {aliases, config} shape.
6
6
  //
7
- // What goes here: literal-valued knobs that don't translate to CSS vars
8
- // (e.g. Dialog's confirm/cancel variant string is consumed by Dialog.svelte
9
- // via `$editorState`, not via CSS cascade).
7
+ // What goes here: literal-valued knobs that live in the config bucket rather
8
+ // than the alias bucket. Some are runtime CSS values consumed by live
9
+ // components via the cascade (see CASCADING_COMPONENT_CONFIG_KEYS below);
10
+ // others are editor-only metadata that drive alias rewrites without ever
11
+ // reaching :root.
10
12
  //
11
13
  // What does NOT go here: aliases whose values are themselves CSS-var refs
12
14
  // — even if the value space is constrained (e.g. `--button-shimmer` →
@@ -16,4 +18,21 @@
16
18
  export const KNOWN_COMPONENT_CONFIG_KEYS: ReadonlySet<string> = new Set([
17
19
  '--dialog-confirm-variant',
18
20
  '--dialog-cancel-variant',
21
+ // SectionDivider per-variant `color-family` is editor metadata that drives
22
+ // the family-swap rewrite on aliases. It is not a runtime CSS value, so it
23
+ // stays in the config bucket. The other intrinsics (align, hairline,
24
+ // eyebrow/description visibility, eyebrow text-transform) now flow through
25
+ // the alias bucket as cascading CSS vars — see the 2026-05-22 migration.
26
+ '--sectiondivider-lg-color-family',
27
+ '--sectiondivider-md-color-family',
28
+ '--sectiondivider-sm-color-family',
29
+ ]);
30
+
31
+ // Subset of KNOWN_COMPONENT_CONFIG_KEYS that the renderer emits to :root as
32
+ // CSS vars so live components can read them via the cascade. Editor-only
33
+ // metadata (e.g. `--sectiondivider-*-color-family`, which drives an alias
34
+ // rewrite rather than a runtime value) is intentionally excluded.
35
+ export const CASCADING_COMPONENT_CONFIG_KEYS: ReadonlySet<string> = new Set([
36
+ '--dialog-confirm-variant',
37
+ '--dialog-cancel-variant',
19
38
  ]);
@@ -1,4 +1,4 @@
1
- import type { ComponentConfig, ComponentConfigMeta } from '../themes/themeTypes';
1
+ import type { AliasDiskValue, ComponentConfig, ComponentConfigMeta } from '../themes/themeTypes';
2
2
  import { versionedFileResource } from '../storage/files/versionedFileResourceClient';
3
3
 
4
4
  /**
@@ -23,7 +23,7 @@ export interface ComponentSummary {
23
23
  export interface ComponentProductionInfo {
24
24
  fileName: string;
25
25
  name: string;
26
- aliases: Record<string, string>;
26
+ aliases: Record<string, AliasDiskValue>;
27
27
  }
28
28
 
29
29
  export interface ComponentConfigList {
@@ -1,5 +1,5 @@
1
1
  import { get } from 'svelte/store';
2
- import type { ComponentConfig } from '../themes/themeTypes';
2
+ import type { AliasDiskValue, ComponentConfig } from '../themes/themeTypes';
3
3
  import { editorState, markComponentSaved } from '../store/editorStore';
4
4
  import type { CssVarRef } from '../store/editorTypes';
5
5
  import { CURRENT_COMPONENT_SCHEMA_VERSION } from '../themes/migrations';
@@ -24,8 +24,10 @@ export type SaveActiveComponentResult =
24
24
  | { ok: true; fileName: string; displayName: string }
25
25
  | { ok: false; reason: 'default' | 'no-state' | 'error'; error?: unknown };
26
26
 
27
- function refToString(ref: CssVarRef): string {
28
- return ref.kind === 'token' ? ref.name : ref.value;
27
+ function refToDiskValue(ref: CssVarRef): AliasDiskValue {
28
+ if (ref.kind === 'token') return ref.name;
29
+ if (ref.kind === 'literal') return ref.value;
30
+ return { kind: 'gradient', value: ref.value };
29
31
  }
30
32
 
31
33
  export async function saveActiveComponentConfig(
@@ -43,8 +45,8 @@ export async function saveActiveComponentConfig(
43
45
  const displayName = active?.name ?? fileName;
44
46
 
45
47
  const now = new Date().toISOString();
46
- const aliases: Record<string, string> = {};
47
- for (const [k, ref] of Object.entries(slice.aliases)) aliases[k] = refToString(ref);
48
+ const aliases: Record<string, AliasDiskValue> = {};
49
+ for (const [k, ref] of Object.entries(slice.aliases)) aliases[k] = refToDiskValue(ref);
48
50
 
49
51
  const data: ComponentConfig = {
50
52
  name: displayName,
@@ -1,4 +1,4 @@
1
- import type { Manifest, ManifestMeta, Theme, ComponentConfig } from '../themes/themeTypes';
1
+ import type { Manifest, ManifestMeta, ManifestBundle, Theme, ComponentConfig } from '../themes/themeTypes';
2
2
  import { versionedFileResource } from '../storage/files/versionedFileResourceClient';
3
3
  import { listComponents } from '../components/componentConfigService';
4
4
  import { getActiveTheme } from '../themes/themeService';
@@ -40,8 +40,9 @@ export interface ApplyManifestResult {
40
40
 
41
41
  /**
42
42
  * Server-side atomic apply: validate every referenced file exists, flip the
43
- * theme + each component's `_active.json` pointer, mark the manifest active,
44
- * and return the resolved theme + component configs in one payload. Clients
43
+ * theme + each component's `_active.json` and `_production.json` pointers,
44
+ * sync tokens.css/fonts.css from the new theme, mark the manifest active, and
45
+ * return the resolved theme + component configs in one payload. Clients
45
46
  * usually follow with a full page reload — manifest load is a "blow up the
46
47
  * world" action.
47
48
  */
@@ -114,3 +115,57 @@ export async function saveActiveManifest(displayName?: string): Promise<void> {
114
115
  };
115
116
  await saveManifest(active._fileName, manifest);
116
117
  }
118
+
119
+ export interface ImportManifestResult {
120
+ ok: boolean;
121
+ /** Final manifest filename (may be renamed if it collided with an existing one). */
122
+ manifest: string;
123
+ /** Keyed `theme:<orig>` / `componentConfig:<comp>/<orig>` / `manifest:<orig>` → final name. */
124
+ renames: Record<string, string>;
125
+ }
126
+
127
+ /**
128
+ * Fetch the manifest as a self-contained `ManifestBundle` and trigger a
129
+ * browser download. Hidden-anchor trick — no infrastructure beyond the
130
+ * existing GET `/api/manifests/:name/export` endpoint.
131
+ *
132
+ * See temp/manifest-robustness-plan.md §11.
133
+ */
134
+ export async function exportManifest(fileName: string): Promise<void> {
135
+ const res = await fetch(`/api/manifests/${encodeURIComponent(fileName)}/export`);
136
+ if (!res.ok) {
137
+ const err = await res.json().catch(() => ({ error: 'Export failed' }));
138
+ throw new Error(err.error || 'Export failed');
139
+ }
140
+ const blob = await res.blob();
141
+ const url = URL.createObjectURL(blob);
142
+ try {
143
+ const a = document.createElement('a');
144
+ a.href = url;
145
+ a.download = `${fileName}.bundle.json`;
146
+ document.body.appendChild(a);
147
+ a.click();
148
+ a.remove();
149
+ } finally {
150
+ URL.revokeObjectURL(url);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * POST a `ManifestBundle` to the import endpoint. Server materialises the
156
+ * inlined theme + component configs as fresh files (renaming on collision),
157
+ * rewrites the manifest's pointers, and returns the final manifest name plus
158
+ * the rename map so the UI can surface what got renamed.
159
+ */
160
+ export async function importManifest(bundle: ManifestBundle): Promise<ImportManifestResult> {
161
+ const res = await fetch('/api/manifests/import', {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify(bundle),
165
+ });
166
+ if (!res.ok) {
167
+ const err = await res.json().catch(() => ({ error: 'Import failed' }));
168
+ throw new Error(err.error || 'Import failed');
169
+ }
170
+ return res.json();
171
+ }