@motion-proto/live-tokens 0.49.0 → 0.50.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 (63) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist-plugin/generateColorsAndType/index.cjs +13 -2
  3. package/dist-plugin/generateColorsAndType/index.js +13 -2
  4. package/dist-plugin/index.cjs +2 -0
  5. package/dist-plugin/index.js +2 -0
  6. package/package.json +6 -1
  7. package/src/editor/bootstrap.ts +2 -0
  8. package/src/editor/component-editor/ButtonEditor.svelte +1 -1
  9. package/src/editor/component-editor/CardEditor.svelte +5 -2
  10. package/src/editor/component-editor/IconButtonEditor.svelte +1 -1
  11. package/src/editor/component-editor/ImageEditor.svelte +1 -1
  12. package/src/editor/component-editor/SectionDividerEditor.svelte +4 -1
  13. package/src/editor/component-editor/SideNavigationEditor.svelte +6 -0
  14. package/src/editor/component-editor/TabBarEditor.svelte +1 -5
  15. package/src/editor/component-editor/scaffolding/ComponentEditorBase.svelte +8 -1
  16. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +10 -32
  17. package/src/editor/component-editor/scaffolding/StateBlock.svelte +2 -0
  18. package/src/editor/component-editor/scaffolding/TypeEditor.svelte +3 -1
  19. package/src/editor/component-editor/scaffolding/types.ts +3 -0
  20. package/src/editor/core/components/componentConfigService.ts +25 -0
  21. package/src/editor/core/cssVarSync.ts +65 -7
  22. package/src/editor/core/fonts/fontLoader.ts +12 -3
  23. package/src/editor/core/fonts/fontWeightAvailability.ts +97 -0
  24. package/src/editor/core/preview/lookPreview.ts +23 -18
  25. package/src/editor/core/store/editorRenderer.ts +22 -7
  26. package/src/editor/core/store/editorStore.ts +31 -0
  27. package/src/editor/core/store/gradientSource.ts +5 -0
  28. package/src/editor/core/themes/colorsAndTypeService.ts +2 -12
  29. package/src/editor/core/themes/migrations/2026-05-20-sectiondivider-slim-variants.ts +4 -1
  30. package/src/editor/core/themes/migrations/2026-05-21-sectiondivider-spacing-to-padding.ts +4 -1
  31. package/src/editor/core/themes/migrations/2026-05-25-cornerbadge-flatten-variants.ts +8 -0
  32. package/src/editor/core/themes/slices/fonts.ts +4 -5
  33. package/src/editor/core/themes/themeDocumentSync.ts +66 -0
  34. package/src/editor/core/themes/themeInit.ts +7 -10
  35. package/src/editor/core/themes/themeService.ts +10 -3
  36. package/src/editor/docs/content/themes-workflow.md +14 -11
  37. package/src/editor/docs/content/where-themes-live.md +7 -2
  38. package/src/editor/docs/content.generated.ts +2 -2
  39. package/src/editor/ui/FileLoadList.svelte +1 -0
  40. package/src/editor/ui/FontStackEditor.svelte +1 -2
  41. package/src/editor/ui/GradientEditor.svelte +5 -1
  42. package/src/editor/ui/ProjectFontsSection.svelte +0 -5
  43. package/src/editor/ui/ThemePanel.svelte +206 -42
  44. package/src/editor/ui/UIDialog.svelte +7 -1
  45. package/src/editor/ui/UIFontFamilySelector.svelte +13 -13
  46. package/src/editor/ui/UIFontSizeSelector.svelte +5 -5
  47. package/src/editor/ui/UIFontWeightSelector.svelte +52 -2
  48. package/src/editor/ui/UIOptionItem.svelte +14 -1
  49. package/src/editor/ui/UIPaddingSelector.svelte +4 -3
  50. package/src/editor/ui/UIPaletteSelector.svelte +20 -13
  51. package/src/editor/ui/UIRelinkConfirmDialog.svelte +18 -3
  52. package/src/editor/ui/UITextTransformSelector.svelte +5 -5
  53. package/src/editor/ui/UITokenSelector.svelte +29 -7
  54. package/src/live-tokens/data/themes/spring-meadow.json +14 -12
  55. package/src/system/components/Button.svelte +2 -1
  56. package/src/system/components/FloatingTokenTags.css +2 -1
  57. package/src/system/components/FloatingTokenTags.svelte +11 -0
  58. package/src/system/components/IconButton.svelte +2 -1
  59. package/src/system/components/Image.svelte +9 -3
  60. package/src/system/components/ImageLightbox.svelte +2 -0
  61. package/src/system/components/SectionDivider.svelte +20 -2
  62. package/src/system/components/SideNavigation.svelte +15 -1
  63. package/src/system/styles/fonts.css +6 -6
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.50.0 — Live editing stays in sync
4
+
5
+ ### Added
6
+
7
+ - **Save and Adopt can capture every unsaved component.** When component
8
+ editors have pending changes, the Theme panel offers to save them together
9
+ before saving or adopting the theme, so the look on screen can be persisted
10
+ without visiting each component editor first.
11
+
12
+ - **Font-weight choices follow the selected family.** The editor detects the
13
+ weights available for project and Google fonts, keeps the current custom
14
+ value visible, and avoids offering weights the font cannot render.
15
+
16
+ ### Fixed
17
+
18
+ - **Theme changes paint as one transaction.** Loading, previewing, and applying
19
+ a theme now update colors, type, and components together instead of exposing
20
+ intermediate mixed states or flashing through the previous live look.
21
+
22
+ - **Editor controls react reliably to live token changes.** Reused selectors,
23
+ gradient controls, linked aliases, typography, spacing, and component state
24
+ now refresh when their bound variable or upstream token changes.
25
+
26
+ - **Component saves preserve migrated values.** Theme-level saves serialize
27
+ the current component state consistently, including corrected migration
28
+ handling for Section Divider and Corner Badge configurations.
29
+
30
+ ## 0.49.1 — Automatic working-copy cleanup
31
+
32
+ ### Fixed
33
+
34
+ - **Upgrades clean up 0.48's materialised working copies automatically.** At
35
+ dev-server boot, a `_working.json` that exactly matches the active theme is
36
+ removed. A differing buffer is preserved as unsaved work, so upgrading needs
37
+ no manual migration and cannot discard real edits.
38
+
3
39
  ## 0.49.0 — Working buffers are active-theme deltas
4
40
 
5
41
  ### Changed (breaking)
@@ -1034,7 +1034,10 @@ var VARIANTS2 = ["lg", "md", "sm"];
1034
1034
  var SUFFIX_RENAMES = [
1035
1035
  [/-title-border-width$/, "-title-outline-width"],
1036
1036
  [/-title-stroke-color$/, "-title-outline-color"],
1037
- [/-padding$/, "-spacing"]
1037
+ [
1038
+ /^--sectiondivider-(canvas|neutral|alternate|primary|accent|special)-padding$/,
1039
+ "--sectiondivider-$1-spacing"
1040
+ ]
1038
1041
  ];
1039
1042
  function renameKeySuffix(key) {
1040
1043
  for (const [re, repl] of SUFFIX_RENAMES) {
@@ -1085,7 +1088,10 @@ var componentMigration_2026_05_21_sectiondividerSpacingToPadding = {
1085
1088
  if (meta.component !== "sectiondivider") return { ...rawVars };
1086
1089
  const out = {};
1087
1090
  for (const [key, value] of Object.entries(rawVars)) {
1088
- const renamed = key.replace(/-spacing$/, "-padding");
1091
+ const renamed = key.replace(
1092
+ /^--sectiondivider-(lg|md|sm)-spacing$/,
1093
+ "--sectiondivider-$1-padding"
1094
+ );
1089
1095
  out[renamed] = value;
1090
1096
  }
1091
1097
  return out;
@@ -1241,6 +1247,7 @@ var componentMigration_2026_05_24_collapsiblesectionDropActiveState = {
1241
1247
  // src/editor/core/themes/migrations/2026-05-25-cornerbadge-flatten-variants.ts
1242
1248
  var VARIANTS3 = ["primary", "accent", "neutral", "alternate", "canvas", "special", "success", "warning", "danger", "info"];
1243
1249
  var RE = new RegExp(`^--corner-badge-(${VARIANTS3.join("|")})-(.+)$`);
1250
+ var CURRENT_COLOR_PROPS = /* @__PURE__ */ new Set(["surface", "border", "text"]);
1244
1251
  function flattenVariants(rawVars, meta) {
1245
1252
  if (meta.component !== "cornerbadge") return rawVars;
1246
1253
  const out = {};
@@ -1249,6 +1256,10 @@ function flattenVariants(rawVars, meta) {
1249
1256
  const m = key.match(RE);
1250
1257
  if (m) {
1251
1258
  const prop = m[2];
1259
+ if (CURRENT_COLOR_PROPS.has(prop)) {
1260
+ out[key] = value;
1261
+ continue;
1262
+ }
1252
1263
  if (!collected.has(prop)) collected.set(prop, value);
1253
1264
  continue;
1254
1265
  }
@@ -512,7 +512,10 @@ var VARIANTS2 = ["lg", "md", "sm"];
512
512
  var SUFFIX_RENAMES = [
513
513
  [/-title-border-width$/, "-title-outline-width"],
514
514
  [/-title-stroke-color$/, "-title-outline-color"],
515
- [/-padding$/, "-spacing"]
515
+ [
516
+ /^--sectiondivider-(canvas|neutral|alternate|primary|accent|special)-padding$/,
517
+ "--sectiondivider-$1-spacing"
518
+ ]
516
519
  ];
517
520
  function renameKeySuffix(key) {
518
521
  for (const [re, repl] of SUFFIX_RENAMES) {
@@ -563,7 +566,10 @@ var componentMigration_2026_05_21_sectiondividerSpacingToPadding = {
563
566
  if (meta.component !== "sectiondivider") return { ...rawVars };
564
567
  const out = {};
565
568
  for (const [key, value] of Object.entries(rawVars)) {
566
- const renamed = key.replace(/-spacing$/, "-padding");
569
+ const renamed = key.replace(
570
+ /^--sectiondivider-(lg|md|sm)-spacing$/,
571
+ "--sectiondivider-$1-padding"
572
+ );
567
573
  out[renamed] = value;
568
574
  }
569
575
  return out;
@@ -719,6 +725,7 @@ var componentMigration_2026_05_24_collapsiblesectionDropActiveState = {
719
725
  // src/editor/core/themes/migrations/2026-05-25-cornerbadge-flatten-variants.ts
720
726
  var VARIANTS3 = ["primary", "accent", "neutral", "alternate", "canvas", "special", "success", "warning", "danger", "info"];
721
727
  var RE = new RegExp(`^--corner-badge-(${VARIANTS3.join("|")})-(.+)$`);
728
+ var CURRENT_COLOR_PROPS = /* @__PURE__ */ new Set(["surface", "border", "text"]);
722
729
  function flattenVariants(rawVars, meta) {
723
730
  if (meta.component !== "cornerbadge") return rawVars;
724
731
  const out = {};
@@ -727,6 +734,10 @@ function flattenVariants(rawVars, meta) {
727
734
  const m = key.match(RE);
728
735
  if (m) {
729
736
  const prop = m[2];
737
+ if (CURRENT_COLOR_PROPS.has(prop)) {
738
+ out[key] = value;
739
+ continue;
740
+ }
730
741
  if (!collected.has(prop)) collected.set(prop, value);
731
742
  continue;
732
743
  }
@@ -2646,6 +2646,8 @@ ${lines.join("\n")}
2646
2646
  ensureColorsAndTypeDir();
2647
2647
  ensureComponentConfigsDir();
2648
2648
  const { productionPointerHeld } = ensureThemesDir((msg) => server.config.logger.warn(msg));
2649
+ const openTheme = activeTheme();
2650
+ if (openTheme) pruneMatchingWorking(openTheme);
2649
2651
  if (!productionPointerHeld || !import_fs4.default.existsSync(GENERATED_CSS_PATH)) regenerateTokensCss();
2650
2652
  if (opts.autoMigrate) autoMigrateAdditive((msg) => server.config.logger.info(msg));
2651
2653
  warnOnTokenDrift((msg) => server.config.logger.warn(msg));
@@ -1371,6 +1371,8 @@ ${lines.join("\n")}
1371
1371
  ensureColorsAndTypeDir();
1372
1372
  ensureComponentConfigsDir();
1373
1373
  const { productionPointerHeld } = ensureThemesDir((msg) => server.config.logger.warn(msg));
1374
+ const openTheme = activeTheme();
1375
+ if (openTheme) pruneMatchingWorking(openTheme);
1374
1376
  if (!productionPointerHeld || !fs.existsSync(GENERATED_CSS_PATH)) regenerateTokensCss();
1375
1377
  if (opts.autoMigrate) autoMigrateAdditive((msg) => server.config.logger.info(msg));
1376
1378
  warnOnTokenDrift((msg) => server.config.logger.warn(msg));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -110,6 +110,10 @@
110
110
  "check": "svelte-check --tsconfig ./tsconfig.json",
111
111
  "test": "vitest run",
112
112
  "test:watch": "vitest",
113
+ "test:e2e": "playwright test",
114
+ "test:e2e:components": "playwright test tests/e2e/component-render-contract.spec.ts",
115
+ "test:e2e:ui": "playwright test --ui",
116
+ "prepare:e2e": "node scripts/prepare-playwright-data.mjs",
113
117
  "build:plugin": "tsup",
114
118
  "build:lib": "npm run build:plugin",
115
119
  "deploy:local": "bash scripts/deploy-local.sh",
@@ -136,6 +140,7 @@
136
140
  "vite": "^8"
137
141
  },
138
142
  "devDependencies": {
143
+ "@playwright/test": "^1.62.1",
139
144
  "@sveltejs/vite-plugin-svelte": "^7.1.2",
140
145
  "@types/node": "^25.9.1",
141
146
  "happy-dom": "^20.9.0",
@@ -19,6 +19,7 @@ import * as cssVarSync from './core/cssVarSync';
19
19
  import * as router from './core/routing/router';
20
20
  import * as columnsOverlay from './overlay/columnsOverlay';
21
21
  import * as editorStore from './core/store/editorStore';
22
+ import * as themeDocumentSync from './core/themes/themeDocumentSync';
22
23
  import { initializeTheme } from './core/themes/themeInit';
23
24
  import { registerComponent, type RegisterComponentEntry } from './component-editor/registry';
24
25
 
@@ -38,6 +39,7 @@ export async function bootLiveTokens(
38
39
  editorStore.init();
39
40
 
40
41
  if (import.meta.env.DEV) {
42
+ themeDocumentSync.init();
41
43
  if (opts.components) {
42
44
  for (const entry of opts.components) {
43
45
  registerComponent(entry);
@@ -165,7 +165,7 @@
165
165
  {/if}
166
166
  {/snippet}
167
167
  {#snippet children({ activeState })}
168
- {@const forceClass = activeState === 'hover' ? 'force-hover' : ''}
168
+ {@const forceClass = activeState === 'hover' ? 'force-hover' : activeState === 'active' ? 'force-active' : ''}
169
169
  {@const isDisabled = activeState === 'disabled'}
170
170
  <div class="button-showcase-grid">
171
171
  <div class="button-showcase-item">
@@ -144,7 +144,10 @@
144
144
  >
145
145
  {#snippet stateActions(stateName)}
146
146
  {#if stateName === 'hover'}
147
- <div class="hover-control">
147
+ <div
148
+ class="hover-control"
149
+ data-token-variables="--card-hover-border-active --card-hover-shadow-active"
150
+ >
148
151
  <label class="hover-enable">
149
152
  <input type="checkbox" checked={hoverEnabled} onchange={(e) => setHoverEnabled(e.currentTarget.checked)} />
150
153
  <span>Use hover</span>
@@ -159,7 +162,7 @@
159
162
  {#snippet children({ activeState })}
160
163
  {@const previewClass = activeState === 'hover' ? 'force-hover' : ''}
161
164
  <div class="card-demo">
162
- <Card title="Card title" class={previewClass}>
165
+ <Card title="Card title" icon="fas fa-star" class={previewClass}>
163
166
  <div class="content-placeholder">Content Placeholder</div>
164
167
  </Card>
165
168
  </div>
@@ -140,7 +140,7 @@
140
140
  previewActions={sizeAction}
141
141
  >
142
142
  {#snippet children({ activeState })}
143
- {@const forceClass = activeState === 'hover' ? 'force-hover' : ''}
143
+ {@const forceClass = activeState === 'hover' ? 'force-hover' : activeState === 'active' ? 'force-active' : ''}
144
144
  {@const isDisabled = activeState === 'disabled'}
145
145
  <IconButton variant={v} icon={previewIcons[v]} ariaLabel={`${v} action`} disabled={isDisabled} class={forceClass} />
146
146
  {/snippet}
@@ -78,7 +78,7 @@
78
78
  <span>Overflow scaling (mask zoom to frame)</span>
79
79
  </label>
80
80
  {/snippet}
81
- <Image src={demoImageUrl} alt="Demo" variant="banner" zoom={useZoom ? undefined : false} {overflowScaling} />
81
+ <Image src={demoImageUrl} alt="Demo" variant="banner" zoom={useZoom ? undefined : false} {overflowScaling} forceHover={useZoom} />
82
82
  <p class="zoom-help">
83
83
  Use zoom on hover: every image scales on hover (a page can still force it per image with the
84
84
  <code>zoom</code> prop). Overflow scaling on (default): the content zooms inside the fixed frame,
@@ -66,6 +66,7 @@
66
66
  legend: '',
67
67
  element: 'title',
68
68
  colorVariable: `--sectiondivider-${v}-title`,
69
+ colorCanBeLinked: true,
69
70
  familyVariable: `--sectiondivider-${v}-title-font-family`,
70
71
  sizeVariable: `--sectiondivider-${v}-title-font-size`,
71
72
  weightVariable: `--sectiondivider-${v}-title-font-weight`,
@@ -78,6 +79,7 @@
78
79
  legend: '',
79
80
  element: 'description',
80
81
  colorVariable: `--sectiondivider-${v}-description`,
82
+ colorCanBeLinked: true,
81
83
  familyVariable: `--sectiondivider-${v}-description-font-family`,
82
84
  sizeVariable: `--sectiondivider-${v}-description-font-size`,
83
85
  weightVariable: `--sectiondivider-${v}-description-font-weight`,
@@ -87,6 +89,7 @@
87
89
  legend: '',
88
90
  element: 'eyebrow',
89
91
  colorVariable: `--sectiondivider-${v}-eyebrow`,
92
+ colorCanBeLinked: true,
90
93
  familyVariable: `--sectiondivider-${v}-eyebrow-font-family`,
91
94
  sizeVariable: `--sectiondivider-${v}-eyebrow-font-size`,
92
95
  weightVariable: `--sectiondivider-${v}-eyebrow-font-weight`,
@@ -399,7 +402,7 @@
399
402
  }
400
403
  </script>
401
404
 
402
- <ComponentEditorBase {component} title="Section Divider" description="Full-width section banner. Each variant (lg/md/sm) is a full preset: its own size, typography, colors, AND intrinsic properties (alignment, hairline, eyebrow/description visibility)." tokens={allTokens} {linked} variants={variantOptions}>
405
+ <ComponentEditorBase {component} title="Section Divider" description="Full-width section banner. Each variant (lg/md/sm) is a full preset: its own size, typography, colors, AND intrinsic properties (alignment, hairline, eyebrow/description visibility)." tokens={allTokens} {linked} variants={variantOptions} defaultVariant="md">
403
406
  {#each variants as v}
404
407
  <VariantGroup
405
408
  name={v.key}
@@ -48,6 +48,8 @@
48
48
  { label: 'surface color', groupKey: 'title-surface', variable: `--sidenavigation-title-${s}-surface` },
49
49
  { label: 'border color', groupKey: 'title-border', variable: `--sidenavigation-title-${s}-border` },
50
50
  { label: 'border width', canBeLinked: true, groupKey: 'title-border-width', variable: `--sidenavigation-title-${s}-border-width` },
51
+ { label: 'indicator color', groupKey: 'title-accent', variable: `--sidenavigation-title-${s}-accent` },
52
+ { label: 'indicator width', canBeLinked: true, groupKey: 'title-accent-width', variable: `--sidenavigation-title-${s}-accent-width` },
51
53
  { label: 'padding', canBeLinked: true, groupKey: 'title-padding', variable: `--sidenavigation-title-${s}-padding` },
52
54
  ];
53
55
  }
@@ -59,6 +61,8 @@
59
61
  { label: 'gap', canBeLinked: true, groupKey: 'title-gap', variable: '--sidenavigation-title-gap' },
60
62
  { label: 'corner radius', canBeLinked: true, groupKey: 'title-radius', variable: '--sidenavigation-title-radius' },
61
63
  { label: 'label surface color', groupKey: 'title-label-surface', variable: '--sidenavigation-title-label-surface' },
64
+ { label: 'label border color', groupKey: 'title-label-border', variable: '--sidenavigation-title-label-border' },
65
+ { label: 'label border width', canBeLinked: true, groupKey: 'title-label-border-width', variable: '--sidenavigation-title-label-border-width' },
62
66
  { label: 'label corner radius', canBeLinked: true, groupKey: 'title-label-radius', variable: '--sidenavigation-title-label-radius' },
63
67
  { label: 'label padding', canBeLinked: true, groupKey: 'title-label-padding', variable: '--sidenavigation-title-label-padding' },
64
68
  ];
@@ -214,6 +218,7 @@
214
218
  const linkableContexts = new Map<string, string>([
215
219
  ...STATEFUL_STATES.flatMap((s): Array<[string, string]> => [
216
220
  [`--sidenavigation-title-${s}-border-width`, `title ${s}`],
221
+ [`--sidenavigation-title-${s}-accent-width`, `title ${s}`],
217
222
  [`--sidenavigation-title-${s}-padding`, `title ${s}`],
218
223
  [`--sidenavigation-title-${s}-label-font-family`, `title ${s}`],
219
224
  [`--sidenavigation-title-${s}-label-font-size`, `title ${s}`],
@@ -252,6 +257,7 @@
252
257
  ]),
253
258
  ['--sidenavigation-panel-border-width', 'panel'],
254
259
  ['--sidenavigation-panel-padding', 'panel'],
260
+ ['--sidenavigation-title-label-border-width', 'title block'],
255
261
  ]);
256
262
  </script>
257
263
 
@@ -19,6 +19,7 @@
19
19
  { label: 'border width', element: 'frame', canBeLinked: true, groupKey: 'tab-border-width', variable: `--tabbar-${s}-tab-border-width` },
20
20
  { label: 'padding', element: 'frame', canBeLinked: true, groupKey: 'padding', variable: `--tabbar-${s}-padding` },
21
21
  { label: 'size', element: 'icon', canBeLinked: true, groupKey: 'icon-size', variable: `--tabbar-${s}-icon-size` },
22
+ { label: 'color', element: 'indicator', groupKey: 'indicator-color', variable: `--tabbar-${s}-border` },
22
23
  { label: 'width', element: 'indicator', canBeLinked: true, groupKey: 'indicator-border-width', variable: `--tabbar-${s}-indicator-border-width` },
23
24
  ];
24
25
  }
@@ -50,10 +51,6 @@
50
51
  ],
51
52
  ...Object.fromEntries(tabStateNames.map((s) => [`${s} tab`, tabStateTokens(s)])),
52
53
  };
53
- states['active tab'] = [
54
- ...states['active tab'],
55
- { label: 'color', element: 'indicator', groupKey: 'indicator-color', variable: '--tabbar-active-border' },
56
- ];
57
54
  const typeGroups: Record<string, TypeGroupConfig[]> = Object.fromEntries(
58
55
  tabStateNames.map((s) => [`${s} tab`, tabStateTypeGroups(s)]),
59
56
  );
@@ -71,7 +68,6 @@
71
68
 
72
69
  // Linking: shape props across tab states (same tab object).
73
70
  const linkableContexts = new Map<string, string>([
74
- [`--tabbar-active-border`, `active tab`] as const,
75
71
  ...tabStateNames.flatMap((s) => [
76
72
  [`--tabbar-${s}-tab-border-color`, `${s} tab`] as const,
77
73
  [`--tabbar-${s}-tab-border-width`, `${s} tab`] as const,
@@ -25,6 +25,9 @@
25
25
  with 2+ entries, a single variant tab strip is rendered that drives which
26
26
  VariantGroup is focused. */
27
27
  variants?: { value: string; label: string }[];
28
+ /** Variant selected when this component editor first opens. Falls back to
29
+ the first declared variant. */
30
+ defaultVariant?: string;
28
31
  config?: import('svelte').Snippet;
29
32
  children?: import('svelte').Snippet<[any]>;
30
33
  }
@@ -36,6 +39,7 @@
36
39
  tokens = [],
37
40
  linked = null,
38
41
  variants = [],
42
+ defaultVariant = '',
39
43
  config,
40
44
  children
41
45
  }: Props = $props();
@@ -52,7 +56,10 @@
52
56
  let showVariantTabs = $derived(variants.length >= 2);
53
57
  run(() => {
54
58
  if (showVariantTabs && ($focusedVariant === null || !variants.some((v) => v.value === $focusedVariant))) {
55
- focusedVariant.set(variants[0].value);
59
+ const preferred = variants.some((v) => v.value === defaultVariant)
60
+ ? defaultVariant
61
+ : variants[0].value;
62
+ focusedVariant.set(preferred);
56
63
  }
57
64
  });
58
65
  let resetVariables = $derived(tokens.map((t) => t.variable));
@@ -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 { get } from 'svelte/store';
9
8
  import type {
10
- AliasDiskValue,
11
- ComponentConfig,
12
9
  ComponentConfigMeta,
13
10
  LiveSource,
14
11
  } from '../../core/themes/themeTypes';
@@ -19,6 +16,7 @@
19
16
  saveComponentConfig,
20
17
  deleteComponentConfig,
21
18
  writeWorkingComponentConfig,
19
+ componentConfigFromState,
22
20
  } from '../../core/components/componentConfigService';
23
21
  import {
24
22
  editorState,
@@ -42,8 +40,6 @@
42
40
  saveAsTheme,
43
41
  } from '../../core/themes/themeService';
44
42
  import type { ThemeMeta } from '../../core/themes/themeTypes';
45
- import { CURRENT_COMPONENT_SCHEMA_VERSION } from '../../core/themes/migrations';
46
- import { refToDiskValue } from '../../core/store/cssVarRef';
47
43
  import { safeFetch } from '../../core/storage/storage';
48
44
  import { API_BASE } from '../../core/storage/apiBase';
49
45
  import { flashStatus } from '../../core/flashStatus';
@@ -182,36 +178,14 @@
182
178
  }
183
179
  }
184
180
 
185
- function currentAliases(): Record<string, AliasDiskValue> {
186
- const slice = get(editorState).components[component];
187
- if (!slice) return {};
188
- const out: Record<string, AliasDiskValue> = {};
189
- for (const [k, ref] of Object.entries(slice.aliases)) out[k] = refToDiskValue(ref);
190
- return out;
191
- }
192
-
193
- function currentConfig(): Record<string, unknown> {
194
- return get(editorState).components[component]?.config ?? {};
195
- }
196
-
197
- function currentConfigData(displayName: string): ComponentConfig {
198
- const now = new Date().toISOString();
199
- return {
200
- name: displayName,
201
- component,
202
- createdAt: now,
203
- updatedAt: now,
204
- aliases: { ...currentAliases() },
205
- config: { ...currentConfig() },
206
- schemaVersion: CURRENT_COMPONENT_SCHEMA_VERSION,
207
- };
208
- }
209
-
210
181
  /** Flush the editor state to the component's working buffer. A theme save
211
182
  * captures the buffers, so this is what puts these edits in reach of Save
212
183
  * and Adopt. */
213
184
  async function persist(): Promise<void> {
214
- await writeWorkingComponentConfig(component, currentConfigData(openTheme.name));
185
+ await writeWorkingComponentConfig(
186
+ component,
187
+ componentConfigFromState($editorState, component, openTheme.name),
188
+ );
215
189
  liveSource = 'working';
216
190
  markComponentSaved(component);
217
191
  bumpComponentActiveRevision();
@@ -237,7 +211,11 @@
237
211
  const { displayName, fileName } = detail;
238
212
  saveStatus = 'saving';
239
213
  try {
240
- await saveComponentConfig(component, fileName, currentConfigData(displayName));
214
+ await saveComponentConfig(
215
+ component,
216
+ fileName,
217
+ componentConfigFromState($editorState, component, displayName),
218
+ );
241
219
  await persist();
242
220
  flashStatus(setSaveStatus, 'saved');
243
221
  await refreshFiles();
@@ -129,6 +129,7 @@
129
129
  legend={tg.legend ?? ''}
130
130
  colorVariable={tg.colorVariable}
131
131
  colorLabel={tg.colorLabel ?? 'text color'}
132
+ colorCanBeLinked={tg.colorCanBeLinked ?? false}
132
133
  familyVariable={tg.familyVariable}
133
134
  familyLabel={tg.familyLabel ?? 'font family'}
134
135
  sizeVariable={tg.sizeVariable}
@@ -171,6 +172,7 @@
171
172
  legend={tg.legend ?? 'type'}
172
173
  colorVariable={tg.colorVariable}
173
174
  colorLabel={tg.colorLabel ?? 'text color'}
175
+ colorCanBeLinked={tg.colorCanBeLinked ?? false}
174
176
  familyVariable={tg.familyVariable}
175
177
  familyLabel={tg.familyLabel ?? 'font family'}
176
178
  sizeVariable={tg.sizeVariable}
@@ -15,6 +15,7 @@
15
15
  interface Props {
16
16
  colorVariable: string;
17
17
  colorLabel?: string;
18
+ colorCanBeLinked?: boolean;
18
19
  familyVariable?: string | undefined;
19
20
  familyLabel?: string;
20
21
  sizeVariable?: string | undefined;
@@ -42,6 +43,7 @@
42
43
  let {
43
44
  colorVariable,
44
45
  colorLabel = 'color',
46
+ colorCanBeLinked = false,
45
47
  familyVariable = undefined,
46
48
  familyLabel = 'family',
47
49
  sizeVariable = undefined,
@@ -65,7 +67,7 @@
65
67
  <FieldsetWrapper {legend}>
66
68
  <div class="type-grid">
67
69
  <span class="row-label">{colorLabel}</span>
68
- <UIPaletteSelector variable={colorVariable} {component} {onchange} />
70
+ <UIPaletteSelector variable={colorVariable} {component} canBeLinked={colorCanBeLinked} {onchange} />
69
71
 
70
72
  {#if familyVariable}
71
73
  <span class="row-label">{familyLabel}</span>
@@ -70,6 +70,9 @@ export type TypeGroupConfig = {
70
70
  legend?: string;
71
71
  colorVariable: string;
72
72
  colorLabel?: string;
73
+ /** Whether this typography color participates in its declared cross-context
74
+ * sibling group. Kept explicit because most state colors are independent. */
75
+ colorCanBeLinked?: boolean;
73
76
  familyVariable?: string;
74
77
  familyLabel?: string;
75
78
  sizeVariable?: string;
@@ -1,7 +1,10 @@
1
1
  import type { ComponentConfig, ComponentConfigMeta, LiveSource } from '../themes/themeTypes';
2
+ import type { EditorState } from '../store/editorTypes';
2
3
  import { versionedFileResource } from '../storage/files/versionedFileResourceClient';
3
4
  import { API_BASE } from '../storage/apiBase';
4
5
  import { liveMovedSinceBake } from '../productionPulse';
6
+ import { CURRENT_COMPONENT_SCHEMA_VERSION } from '../themes/migrations';
7
+ import { refToDiskValue } from '../store/cssVarRef';
5
8
 
6
9
  /**
7
10
  * REST client for per-component config files. Parallel to `colorsAndTypeService.ts`
@@ -59,6 +62,28 @@ export const deleteComponentConfig = (component: string, fileName: string): Prom
59
62
  export const getActiveComponentConfig = (component: string): Promise<ComponentConfig | null> =>
60
63
  resourceFor(component).getActive();
61
64
 
65
+ /** Serialize one live component slice into the on-disk config shape. Shared by
66
+ * the component editor's local checkpoint and the theme-level save-all flow. */
67
+ export function componentConfigFromState(
68
+ state: EditorState,
69
+ component: string,
70
+ displayName: string,
71
+ ): ComponentConfig {
72
+ const now = new Date().toISOString();
73
+ const slice = state.components[component];
74
+ return {
75
+ name: displayName,
76
+ component,
77
+ createdAt: now,
78
+ updatedAt: now,
79
+ aliases: Object.fromEntries(
80
+ Object.entries(slice?.aliases ?? {}).map(([name, ref]) => [name, refToDiskValue(ref)]),
81
+ ),
82
+ config: { ...(slice?.config ?? {}) },
83
+ schemaVersion: CURRENT_COMPONENT_SCHEMA_VERSION,
84
+ };
85
+ }
86
+
62
87
  /** Write the component's unsaved buffer. */
63
88
  export async function writeWorkingComponentConfig(
64
89
  component: string,
@@ -4,8 +4,10 @@
4
4
  * Writes to document.documentElement and — when running inside a same-origin
5
5
  * iframe (the live-preview overlay) — also writes to
6
6
  * window.parent.document.documentElement. This lets the overlay editor at
7
- * /live-tokens/editor drive the host site's :root in real time without any
8
- * message-passing infrastructure.
7
+ * /live-tokens/editor drive the host site's :root for ordinary live edits.
8
+ * Complete theme application additionally synchronizes each document's typed
9
+ * Svelte store through `themeDocumentSync`; copying CSS alone would leave an
10
+ * already-open editor disconnected when the load originates in the host.
9
11
  *
10
12
  * When the editor runs standalone at /live-tokens/editor (not inside the overlay iframe),
11
13
  * parentRoot is null and every call is a plain single-root write.
@@ -64,10 +66,54 @@ export function getSyncedDocuments(): Document[] {
64
66
  }
65
67
 
66
68
  export const CSS_VAR_CHANGE_EVENT = 'cssvar:change';
69
+ /** One notification for a completed CSS-variable transaction. Internal editor
70
+ * controls use this instead of reacting once per variable during theme loads. */
71
+ export const CSS_VARS_CHANGE_EVENT = 'cssvars:change';
72
+
73
+ export interface CssVarsChangeDetail {
74
+ names: string[];
75
+ }
76
+
77
+ let batchDepth = 0;
78
+ const pendingNames = new Set<string>();
79
+
80
+ function dispatchChanges(names: string[]): void {
81
+ if (typeof document === 'undefined' || names.length === 0) return;
82
+ document.dispatchEvent(new CustomEvent<CssVarsChangeDetail>(CSS_VARS_CHANGE_EVENT, {
83
+ detail: { names },
84
+ }));
85
+ // Preserve the public per-variable contract for consumers. These events are
86
+ // deliberately dispatched only after every write in a batch has landed, so
87
+ // a listener's computed-style read cannot force layout against a half-painted
88
+ // theme.
89
+ for (const name of names) {
90
+ document.dispatchEvent(new CustomEvent(CSS_VAR_CHANGE_EVENT, { detail: { name } }));
91
+ }
92
+ }
67
93
 
68
94
  function notifyChange(name: string): void {
69
- if (typeof document === 'undefined') return;
70
- document.dispatchEvent(new CustomEvent(CSS_VAR_CHANGE_EVENT, { detail: { name } }));
95
+ if (batchDepth > 0) {
96
+ pendingNames.add(name);
97
+ return;
98
+ }
99
+ dispatchChanges([name]);
100
+ }
101
+
102
+ /** Run a group of CSS-variable writes as one observable transaction. DOM
103
+ * styles are still applied synchronously; notifications wait until the outer
104
+ * batch completes. Nested batches coalesce into their parent. */
105
+ export function batchCssVarChanges<T>(fn: () => T): T {
106
+ batchDepth += 1;
107
+ try {
108
+ return fn();
109
+ } finally {
110
+ batchDepth -= 1;
111
+ if (batchDepth === 0 && pendingNames.size > 0) {
112
+ const names = Array.from(pendingNames);
113
+ pendingNames.clear();
114
+ dispatchChanges(names);
115
+ }
116
+ }
71
117
  }
72
118
 
73
119
  export function setCssVar(name: string, value: string): void {
@@ -86,9 +132,11 @@ export function removeCssVar(name: string): void {
86
132
 
87
133
  /** Apply a map of CSS variables to :root (and the parent :root when in an iframe). */
88
134
  export function applyCssVariables(variables: Record<string, string>): void {
89
- for (const [name, value] of Object.entries(variables)) {
90
- setCssVar(name, value);
91
- }
135
+ batchCssVarChanges(() => {
136
+ for (const [name, value] of Object.entries(variables)) {
137
+ setCssVar(name, value);
138
+ }
139
+ });
92
140
  }
93
141
 
94
142
  /** Remove all inline CSS custom properties from :root on both self and parent. */
@@ -122,3 +170,13 @@ export function scrapeCssVariables(): Record<string, string> {
122
170
  }
123
171
  return variables;
124
172
  }
173
+
174
+ /** Test-only: forget cached window/document roots so a test can install a
175
+ * synthetic same-origin parent before the next write resolves them. */
176
+ export function __resetCssVarSyncForTests(): void {
177
+ selfRoot = null;
178
+ parentRoot = null;
179
+ resolved = false;
180
+ batchDepth = 0;
181
+ pendingNames.clear();
182
+ }