@motion-proto/live-tokens 0.49.1 → 0.52.1

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 (111) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist-plugin/adjust/index.d.cts +1 -1
  3. package/dist-plugin/adjust/index.d.ts +1 -1
  4. package/dist-plugin/{chunk-76TFDTJO.js → chunk-JIGIIE5P.js} +60 -132
  5. package/dist-plugin/{chunk-44RSTAII.js → chunk-TKZBVIW5.js} +33 -5
  6. package/dist-plugin/chunk-YR6GPXW2.js +129 -0
  7. package/dist-plugin/generateColorsAndType/index.cjs +100 -33
  8. package/dist-plugin/generateColorsAndType/index.d.cts +1 -1
  9. package/dist-plugin/generateColorsAndType/index.d.ts +1 -1
  10. package/dist-plugin/generateColorsAndType/index.js +35 -14
  11. package/dist-plugin/index.cjs +95 -55
  12. package/dist-plugin/index.js +5 -3
  13. package/dist-plugin/{themeTypes-DSwKq-bj.d.cts → themeTypes-Dky03HL9.d.cts} +18 -5
  14. package/dist-plugin/{themeTypes-DSwKq-bj.d.ts → themeTypes-Dky03HL9.d.ts} +18 -5
  15. package/dist-plugin/tokensCssMigrations/index.cjs +71 -5
  16. package/dist-plugin/tokensCssMigrations/index.d.cts +20 -9
  17. package/dist-plugin/tokensCssMigrations/index.d.ts +20 -9
  18. package/dist-plugin/tokensCssMigrations/index.js +4 -1
  19. package/package.json +8 -1
  20. package/src/editor/bootstrap.ts +2 -0
  21. package/src/editor/component-editor/ButtonEditor.svelte +1 -1
  22. package/src/editor/component-editor/CardEditor.svelte +5 -2
  23. package/src/editor/component-editor/IconButtonEditor.svelte +1 -1
  24. package/src/editor/component-editor/ImageEditor.svelte +1 -1
  25. package/src/editor/component-editor/NotificationEditor.svelte +6 -0
  26. package/src/editor/component-editor/SectionDividerEditor.svelte +4 -1
  27. package/src/editor/component-editor/SideNavigationEditor.svelte +6 -0
  28. package/src/editor/component-editor/TabBarEditor.svelte +1 -5
  29. package/src/editor/component-editor/scaffolding/ComponentEditorBase.svelte +8 -1
  30. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +10 -32
  31. package/src/editor/component-editor/scaffolding/StateBlock.svelte +2 -0
  32. package/src/editor/component-editor/scaffolding/TypeEditor.svelte +3 -1
  33. package/src/editor/component-editor/scaffolding/types.ts +3 -0
  34. package/src/editor/core/components/componentConfigService.ts +25 -0
  35. package/src/editor/core/cssVarSync.ts +65 -7
  36. package/src/editor/core/fonts/fontLoader.ts +12 -3
  37. package/src/editor/core/fonts/fontWeightAvailability.ts +97 -0
  38. package/src/editor/core/palettes/oklch.ts +3 -137
  39. package/src/editor/core/palettes/paletteDerivation.ts +79 -37
  40. package/src/editor/core/preview/lookPreview.ts +23 -18
  41. package/src/editor/core/store/editorRenderer.ts +22 -7
  42. package/src/editor/core/store/editorStore.ts +31 -0
  43. package/src/editor/core/store/gradientSource.ts +5 -0
  44. package/src/editor/core/themes/colorsAndTypeService.ts +2 -12
  45. package/src/editor/core/themes/generateColorsAndType.ts +18 -8
  46. package/src/editor/core/themes/migrations/2026-05-20-sectiondivider-slim-variants.ts +4 -1
  47. package/src/editor/core/themes/migrations/2026-05-21-sectiondivider-spacing-to-padding.ts +4 -1
  48. package/src/editor/core/themes/migrations/2026-05-25-cornerbadge-flatten-variants.ts +8 -0
  49. package/src/editor/core/themes/slices/fonts.ts +4 -5
  50. package/src/editor/core/themes/themeDocumentSync.ts +66 -0
  51. package/src/editor/core/themes/themeInit.ts +7 -10
  52. package/src/editor/core/themes/themeService.ts +10 -3
  53. package/src/editor/core/themes/themeTypes.ts +18 -6
  54. package/src/editor/docs/content/editing-tokens.md +6 -2
  55. package/src/editor/docs/content/themes-workflow.md +14 -11
  56. package/src/editor/docs/content/where-themes-live.md +2 -2
  57. package/src/editor/docs/content.generated.ts +3 -3
  58. package/src/editor/ui/BezierCurveEditor.svelte +90 -67
  59. package/src/editor/ui/FileLoadList.svelte +1 -0
  60. package/src/editor/ui/FontStackEditor.svelte +1 -2
  61. package/src/editor/ui/GradientEditor.svelte +5 -1
  62. package/src/editor/ui/PaletteEditor.svelte +150 -44
  63. package/src/editor/ui/ProjectFontsSection.svelte +0 -5
  64. package/src/editor/ui/ThemePanel.svelte +206 -42
  65. package/src/editor/ui/Toggle.svelte +14 -2
  66. package/src/editor/ui/UIDialog.svelte +7 -1
  67. package/src/editor/ui/UIFontFamilySelector.svelte +13 -13
  68. package/src/editor/ui/UIFontSizeSelector.svelte +5 -5
  69. package/src/editor/ui/UIFontWeightSelector.svelte +52 -2
  70. package/src/editor/ui/UIOptionItem.svelte +14 -1
  71. package/src/editor/ui/UIPaddingSelector.svelte +4 -3
  72. package/src/editor/ui/UIPaletteSelector.svelte +20 -13
  73. package/src/editor/ui/UIRelinkConfirmDialog.svelte +18 -3
  74. package/src/editor/ui/UITextTransformSelector.svelte +5 -5
  75. package/src/editor/ui/UITokenSelector.svelte +29 -7
  76. package/src/editor/ui/colors/ColorStory.svelte +12 -8
  77. package/src/editor/ui/curveEngine.ts +101 -21
  78. package/src/editor/ui/palette/OverridesPanel.svelte +19 -5
  79. package/src/editor/ui/palette/PaletteBase.svelte +17 -78
  80. package/src/editor/ui/palette/ScaleCurveEditor.svelte +109 -20
  81. package/src/editor/ui/palette/curveSummary.ts +20 -0
  82. package/src/editor/ui/palette/dockMagnify.ts +5 -13
  83. package/src/editor/ui/palette/paletteMath.ts +7 -4
  84. package/src/live-tokens/data/colors-and-type/autumn.json +528 -528
  85. package/src/live-tokens/data/colors-and-type/default.json +247 -247
  86. package/src/live-tokens/data/colors-and-type/halloween.json +532 -532
  87. package/src/live-tokens/data/colors-and-type/midnight-study.json +538 -538
  88. package/src/live-tokens/data/colors-and-type/ocean.json +521 -521
  89. package/src/live-tokens/data/colors-and-type/royal-velvet.json +532 -532
  90. package/src/live-tokens/data/colors-and-type/spring-meadow.json +504 -504
  91. package/src/live-tokens/data/colors-and-type/sunset.json +531 -531
  92. package/src/live-tokens/data/themes/autumn.json +527 -527
  93. package/src/live-tokens/data/themes/halloween.json +531 -531
  94. package/src/live-tokens/data/themes/midnight-study.json +537 -537
  95. package/src/live-tokens/data/themes/ocean.json +520 -520
  96. package/src/live-tokens/data/themes/royal-velvet.json +531 -531
  97. package/src/live-tokens/data/themes/spring-meadow.json +516 -514
  98. package/src/live-tokens/data/themes/sunset.json +531 -531
  99. package/src/live-tokens/data/tokens.generated.css +282 -282
  100. package/src/system/components/Button.svelte +2 -1
  101. package/src/system/components/FloatingTokenTags.css +2 -2
  102. package/src/system/components/FloatingTokenTags.svelte +11 -0
  103. package/src/system/components/IconButton.svelte +2 -1
  104. package/src/system/components/Image.svelte +9 -3
  105. package/src/system/components/ImageLightbox.svelte +2 -0
  106. package/src/system/components/SectionDivider.svelte +20 -2
  107. package/src/system/components/SideNavigation.svelte +15 -1
  108. package/src/system/internal/backgroundContrast.ts +7 -0
  109. package/src/system/internal/oklch.ts +189 -0
  110. package/src/system/styles/fonts.css +6 -6
  111. package/src/system/styles/tokens.css +283 -283
@@ -52,6 +52,8 @@ interface TokensCssMigration {
52
52
  */
53
53
  /** Names declared as custom properties (`--x: …;`) anywhere in the source. */
54
54
  declare function collectDefinedTokens(css: string): Set<string>;
55
+ /** Declared name → value, last declaration winning (as the cascade would). */
56
+ declare function collectTokenValues(css: string): Map<string, string>;
55
57
  /** Names referenced via `var(--x)` anywhere in the source. */
56
58
  declare function collectReferencedTokens(css: string): Set<string>;
57
59
  interface ScaleEntry {
@@ -123,16 +125,25 @@ interface ContractViolation {
123
125
  id: string;
124
126
  /** Token names the migration removed or renamed away despite declaring `additive`. */
125
127
  removed: string[];
128
+ /** Token names whose value the migration rewrote despite declaring `additive`. */
129
+ changed: string[];
126
130
  }
127
131
  /**
128
- * Guardrail for the token-as-API contract: an `additive` migration must never
129
- * remove or rename a token. We verify behaviorally rather than trusting the
130
- * label — apply each additive migration to the package's own canonical
131
- * `tokens.css` (which defines the full current vocabulary) and flag any token
132
- * that disappears. A rename surfaces as the removal of its old name, so it is
133
- * caught too. This catches the dangerous direction: a breaking change shipped as
134
- * a backward-compatible one. (Over-labeling a no-op as `breaking` is harmless
135
- * and not checked.)
132
+ * Guardrail for the token-as-API contract: an `additive` migration must only
133
+ * ever insert new names — it must never remove or rename a token, and never
134
+ * rewrite the value of one a consumer already references. We verify
135
+ * behaviorally rather than trusting the label: apply each additive migration to
136
+ * the package's own canonical `tokens.css` (which defines the full current
137
+ * vocabulary) and flag any token that disappears or changes value. A rename
138
+ * surfaces as the removal of its old name, so it is caught too.
139
+ *
140
+ * The value half is what makes a value-rewriting migration (hex → `oklch()`)
141
+ * impossible to mislabel: it renames nothing, so a name-only guard would wave
142
+ * it through as additive and the dev plugin would auto-apply a breaking change
143
+ * to a consumer's vendored file.
144
+ *
145
+ * This catches the dangerous direction only. (Over-labeling a no-op as
146
+ * `breaking` is harmless and not checked.)
136
147
  */
137
148
  declare function findContractViolations(canonicalCss: string): ContractViolation[];
138
149
  type SemverBump = 'major' | 'minor' | 'patch' | 'none';
@@ -183,4 +194,4 @@ interface ValidateInput {
183
194
  */
184
195
  declare function validateTokensCss(input: ValidateInput): MissingToken[];
185
196
 
186
- export { type BreakingGateResult, type ComponentSource, type ContractViolation, type MissingToken, type RunResult, type SemverBump, TOKENS_CSS_MIGRATIONS, type TokensCssMigration, type ValidateInput, collectDefinedTokens, collectReferencedTokens, enforceBreakingRequiresMajor, ensureScale, findContractViolations, removeToken, removeTokensMatching, renameToken, runAdditiveTokensCssMigrations, runTokensCssMigrations, semverBumpType, validateTokensCss };
197
+ export { type BreakingGateResult, type ComponentSource, type ContractViolation, type MissingToken, type RunResult, type SemverBump, TOKENS_CSS_MIGRATIONS, type TokensCssMigration, type ValidateInput, collectDefinedTokens, collectReferencedTokens, collectTokenValues, enforceBreakingRequiresMajor, ensureScale, findContractViolations, removeToken, removeTokensMatching, renameToken, runAdditiveTokensCssMigrations, runTokensCssMigrations, semverBumpType, validateTokensCss };
@@ -2,6 +2,7 @@ import {
2
2
  TOKENS_CSS_MIGRATIONS,
3
3
  collectDefinedTokens,
4
4
  collectReferencedTokens,
5
+ collectTokenValues,
5
6
  enforceBreakingRequiresMajor,
6
7
  ensureScale,
7
8
  findContractViolations,
@@ -12,7 +13,8 @@ import {
12
13
  runTokensCssMigrations,
13
14
  semverBumpType,
14
15
  validateTokensCss
15
- } from "../chunk-44RSTAII.js";
16
+ } from "../chunk-TKZBVIW5.js";
17
+ import "../chunk-YR6GPXW2.js";
16
18
  import {
17
19
  readLiveTokensConfig
18
20
  } from "../chunk-D3ZVKOR4.js";
@@ -20,6 +22,7 @@ export {
20
22
  TOKENS_CSS_MIGRATIONS,
21
23
  collectDefinedTokens,
22
24
  collectReferencedTokens,
25
+ collectTokenValues,
23
26
  enforceBreakingRequiresMajor,
24
27
  ensureScale,
25
28
  findContractViolations,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.49.1",
3
+ "version": "0.52.1",
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,12 @@
110
110
  "check": "svelte-check --tsconfig ./tsconfig.json",
111
111
  "test": "vitest run",
112
112
  "test:watch": "vitest",
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",
115
+ "test:e2e:stateful": "playwright test tests/e2e/live-editing.spec.ts tests/e2e/theme-workflow.spec.ts --workers=1",
116
+ "test:e2e:components": "playwright test tests/e2e/component-render-contract.spec.ts",
117
+ "test:e2e:ui": "playwright test --ui",
118
+ "prepare:e2e": "node scripts/prepare-playwright-data.mjs",
113
119
  "build:plugin": "tsup",
114
120
  "build:lib": "npm run build:plugin",
115
121
  "deploy:local": "bash scripts/deploy-local.sh",
@@ -136,6 +142,7 @@
136
142
  "vite": "^8"
137
143
  },
138
144
  "devDependencies": {
145
+ "@playwright/test": "^1.62.1",
139
146
  "@sveltejs/vite-plugin-svelte": "^7.1.2",
140
147
  "@types/node": "^25.9.1",
141
148
  "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,
@@ -87,12 +87,14 @@
87
87
  let dismissible = $state(false);
88
88
  let rightOption: ButtonVariantOption = $state('none');
89
89
  let leftOption: ButtonVariantOption = $state('none');
90
+ let headerAction = $state(false);
90
91
  let actions = $derived(((): NotificationActions => {
91
92
  const a: NotificationActions = {};
92
93
  const right = toVariant(rightOption);
93
94
  const left = toVariant(leftOption);
94
95
  if (right) a.right = { label: 'Confirm', variant: right, onClick: () => {} };
95
96
  if (left) a.left = { label: 'Cancel', variant: left, onClick: () => {} };
97
+ if (headerAction) a.header = { label: 'Action', onClick: () => {} };
96
98
  return a;
97
99
  })());
98
100
  </script>
@@ -112,6 +114,10 @@
112
114
  <input type="checkbox" bind:checked={dismissible} />
113
115
  <span>Dismissible</span>
114
116
  </label>
117
+ <label class="toolbar-check">
118
+ <input type="checkbox" bind:checked={headerAction} />
119
+ <span>Header button</span>
120
+ </label>
115
121
  <label class="toolbar-field">
116
122
  <span>Right button</span>
117
123
  <select class="canvas-toolbar-select" bind:value={rightOption}>
@@ -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
+ }
@@ -6,7 +6,7 @@ import type {
6
6
  FontStackVariable,
7
7
  SystemCascadePreset,
8
8
  } from '../themes/themeTypes';
9
- import { setCssVar, getSyncedDocuments } from '../cssVarSync';
9
+ import { setCssVar, removeCssVar, getSyncedDocuments } from '../cssVarSync';
10
10
 
11
11
  export const SYSTEM_CASCADES: Record<SystemCascadePreset, string> = {
12
12
  'system-ui-sans':
@@ -15,6 +15,13 @@ export const SYSTEM_CASCADES: Record<SystemCascadePreset, string> = {
15
15
  'system-ui-mono': 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
16
16
  };
17
17
 
18
+ const FONT_STACK_VARIABLES: FontStackVariable[] = [
19
+ '--font-display',
20
+ '--font-sans',
21
+ '--font-serif',
22
+ '--font-mono',
23
+ ];
24
+
18
25
  const LINK_ATTR = 'data-font-source-id';
19
26
  const STYLE_ATTR = 'data-font-source-id';
20
27
 
@@ -124,7 +131,9 @@ export function resolveFontStackValues(
124
131
  */
125
132
  export function applyFontStacks(stacks: FontStack[], sources: FontSource[]): void {
126
133
  const resolved = resolveFontStackValues(stacks, sources);
127
- for (const [name, value] of Object.entries(resolved)) {
128
- setCssVar(name, value);
134
+ for (const name of FONT_STACK_VARIABLES) {
135
+ const value = resolved[name];
136
+ if (value) setCssVar(name, value);
137
+ else removeCssVar(name);
129
138
  }
130
139
  }