@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
@@ -22,8 +22,12 @@
22
22
  exportTheme,
23
23
  importTheme,
24
24
  } from '../core/themes/themeService';
25
+ import {
26
+ THEME_APPLIED_EVENT,
27
+ type AppliedThemeDetail,
28
+ } from '../core/themes/themeDocumentSync';
25
29
  import { countComponentsOffLook, lookProductionState } from '../core/themes/lookSummary';
26
- import { previewTheme, previewColorsAndType, revertPreview } from '../core/preview/lookPreview';
30
+ import { commitPreview, previewTheme, previewColorsAndType, revertPreview } from '../core/preview/lookPreview';
27
31
  import {
28
32
  deleteColorsAndType,
29
33
  hydrateColorsAndType,
@@ -41,10 +45,17 @@
41
45
  } from '../core/themes/loadRows';
42
46
  import {
43
47
  listComponents,
48
+ componentConfigFromState,
49
+ writeWorkingComponentConfig,
44
50
  type ComponentSummary,
45
51
  } from '../core/components/componentConfigService';
46
52
  import { fontPairingLabel } from '../core/fonts/fontPairing';
47
- import { componentDirty, editorState, colorsAndTypeDirty } from '../core/store/editorStore';
53
+ import {
54
+ componentDirty,
55
+ editorState,
56
+ colorsAndTypeDirty,
57
+ markComponentSaved,
58
+ } from '../core/store/editorStore';
48
59
  import { openThemeSlug } from '../core/store/editorConfigStore';
49
60
  import { editorView } from '../core/store/editorViewStore';
50
61
  import {
@@ -53,12 +64,14 @@
53
64
  bumpProductionRevision,
54
65
  liveMovedSinceBake,
55
66
  productionTheme,
67
+ bumpComponentActiveRevision,
56
68
  } from '../core/productionPulse';
57
69
  import { flashStatus } from '../core/flashStatus';
58
70
  import UIInfoPopover from './UIInfoPopover.svelte';
59
71
  import UIPillButton from './UIPillButton.svelte';
60
72
  import FileLoadList from './FileLoadList.svelte';
61
73
  import FilePill from './FilePill.svelte';
74
+ import UIDialog from './UIDialog.svelte';
62
75
  import SaveAsDialog from '../component-editor/scaffolding/SaveAsDialog.svelte';
63
76
 
64
77
  interface Props {
@@ -75,6 +88,9 @@
75
88
  let rows = $derived(buildLoadRows(files, colorsFiles));
76
89
  let showFileList = $state(false);
77
90
  let saveAsDialog = $state(false);
91
+ let saveComponentsDialog = $state(false);
92
+ let pendingComponentSave: (() => Promise<void>) | null = null;
93
+ let pendingComponentCount = $state(0);
78
94
  let saveStatus: 'idle' | 'saving' | 'saved' | 'error' = $state('idle');
79
95
 
80
96
  let currentDisplayName = $state('Motion Proto');
@@ -171,6 +187,19 @@
171
187
  await refreshProduction();
172
188
  });
173
189
 
190
+ // A consumer can load a theme from the host while this iframe stays open.
191
+ // The cross-document bridge hydrates this document's typed store; keep the
192
+ // panel's local identity/summary state on that same payload as well.
193
+ onMount(() => {
194
+ const handleThemeApplied = (event: Event) => {
195
+ const { result } = (event as CustomEvent<AppliedThemeDetail>).detail;
196
+ currentDisplayName = result.theme.name;
197
+ lookConfigs = result.theme.componentConfigs;
198
+ };
199
+ document.addEventListener(THEME_APPLIED_EVENT, handleThemeApplied);
200
+ return () => document.removeEventListener(THEME_APPLIED_EVENT, handleThemeApplied);
201
+ });
202
+
174
203
  // Re-read whenever an Adopt fires, here or in a component editor: it saves
175
204
  // the open theme and moves the production pointer, so the identity, the
176
205
  // component summary and the production state shown here all need to track.
@@ -188,10 +217,10 @@
188
217
  refreshComponents();
189
218
  });
190
219
 
191
- // A capture reads the server's live state, so the colors and type on screen
192
- // go to their buffer first — that is what makes Save and Adopt mean the look
193
- // in front of you. A component's edits live in its own editor's state, which
194
- // this panel cannot write, so those are the only ones a capture leaves behind.
220
+ // A capture reads the server's live state, so colors and type go to their
221
+ // buffer first. Component editors retain their own save boundary; when any
222
+ // are dirty, the theme panel offers to flush all of them in one explicit
223
+ // step before continuing.
195
224
  //
196
225
  // The gate is `colorsAndTypeDirty`, not history: history counts every entry,
197
226
  // components included, so it would flush the colors over component-only work.
@@ -200,20 +229,45 @@
200
229
  await persistColorsAndType(get(editorState), currentDisplayName);
201
230
  }
202
231
 
203
- function confirmUnsavedComponents(action: string): boolean {
204
- if (dirtyComponentCount === 0) return true;
205
- const n = dirtyComponentCount;
206
- return window.confirm(
207
- `${n === 1 ? '1 component has' : `${n} components have`} unsaved edits. Those stay out `
208
- + `until you save them in the component editor. ${action}`,
209
- );
232
+ async function flushComponents(displayName = currentDisplayName): Promise<void> {
233
+ const dirtyComponents = Object.entries(get(componentDirty))
234
+ .filter(([, dirty]) => dirty)
235
+ .map(([component]) => component);
236
+ if (dirtyComponents.length === 0) return;
237
+
238
+ const state = get(editorState);
239
+ await Promise.all(dirtyComponents.map((component) =>
240
+ writeWorkingComponentConfig(
241
+ component,
242
+ componentConfigFromState(state, component, displayName),
243
+ ),
244
+ ));
245
+ for (const component of dirtyComponents) markComponentSaved(component);
246
+ bumpComponentActiveRevision();
210
247
  }
211
248
 
212
- async function handleSave() {
213
- if (activeIsProtected) return;
214
- if (!confirmUnsavedComponents('Save the theme anyway?')) return;
249
+ function continueWithComponentChoice(action: () => Promise<void>): void {
250
+ const dirtyCount = Object.values(get(componentDirty)).filter(Boolean).length;
251
+ if (dirtyCount === 0) {
252
+ void action();
253
+ return;
254
+ }
255
+ pendingComponentCount = dirtyCount;
256
+ pendingComponentSave = action;
257
+ saveComponentsDialog = true;
258
+ }
259
+
260
+ async function confirmSaveAllComponents(): Promise<void> {
261
+ const action = pendingComponentSave;
262
+ pendingComponentSave = null;
263
+ saveComponentsDialog = false;
264
+ if (action) await action();
265
+ }
266
+
267
+ async function runSave(saveComponents: boolean) {
215
268
  saveStatus = 'saving';
216
269
  try {
270
+ if (saveComponents) await flushComponents();
217
271
  await flushColors();
218
272
  await saveActiveTheme(currentDisplayName);
219
273
  await refreshActive();
@@ -223,10 +277,21 @@
223
277
  }
224
278
  }
225
279
 
280
+ function handleSave() {
281
+ if (activeIsProtected) return;
282
+ continueWithComponentChoice(() => runSave(true));
283
+ }
284
+
226
285
  function openSaveAs() {
227
- if (!confirmUnsavedComponents('Save the theme anyway?')) return;
228
- showFileList = false;
229
- saveAsDialog = true;
286
+ continueWithComponentChoice(async () => {
287
+ try {
288
+ await flushComponents();
289
+ showFileList = false;
290
+ saveAsDialog = true;
291
+ } catch {
292
+ flashStatus(setSaveStatus, 'error');
293
+ }
294
+ });
230
295
  }
231
296
 
232
297
  async function confirmSaveAs(detail: { displayName: string; fileName: string }) {
@@ -266,10 +331,9 @@
266
331
  return 'Save this theme and ship it to production';
267
332
  });
268
333
 
269
- async function handleAdopt() {
334
+ function handleAdopt() {
270
335
  if (production.inProduction || adoptStatus === 'adopting') return;
271
- if (!confirmUnsavedComponents('Ship the theme anyway?')) return;
272
- await runAdopt();
336
+ continueWithComponentChoice(() => runAdopt(true));
273
337
  }
274
338
 
275
339
  /**
@@ -278,9 +342,10 @@
278
342
  * Adopt, and which file holds the look is bookkeeping they should not have
279
343
  * to think about, so it forks to a theme of their own first.
280
344
  */
281
- async function runAdopt() {
345
+ async function runAdopt(saveComponents = false) {
282
346
  adoptStatus = 'adopting';
283
347
  try {
348
+ if (saveComponents) await flushComponents();
284
349
  await flushColors();
285
350
  if (activeIsProtected) {
286
351
  const taken = new Set((await listThemes()).map((m) => m.fileName));
@@ -329,6 +394,13 @@
329
394
  previewLayer = null;
330
395
  }
331
396
 
397
+ function acceptPreview() {
398
+ commitPreview();
399
+ previewRow = null;
400
+ previewLook = null;
401
+ previewLayer = null;
402
+ }
403
+
332
404
  /** Paint whatever is selected, through the engine the mode calls for. A
333
405
  * theme's colors and type are the slice embedded in it, so one look
334
406
  * previews either way. */
@@ -386,26 +458,54 @@
386
458
  if (!row) return;
387
459
  if (unsavedEdits) {
388
460
  const ok = window.confirm(
389
- 'Loading a theme will reload the editor and discard unsaved changes. Continue?',
461
+ 'Loading a theme will replace the editor’s current state and discard unsaved changes. Continue?',
390
462
  );
391
463
  if (!ok) return;
392
464
  }
393
- // The window stays open until the page reloads: closing it would revert the
394
- // preview and flash the outgoing look while Apply is in flight.
465
+ // The exact theme being opened is already painted. Hand that preview to
466
+ // the store load without restoring the old look across the request.
467
+ acceptPreview();
468
+ let result: Awaited<ReturnType<typeof applyTheme>>;
395
469
  try {
396
- const result = await applyTheme(row.slug);
470
+ result = await applyTheme(row.slug);
397
471
  if (result.skippedComponents.length > 0) {
398
472
  window.alert(
399
473
  `Loaded "${row.name}". These components are not installed here, so their `
400
474
  + `saved settings were skipped:\n\n${result.skippedComponents.join(', ')}`,
401
475
  );
402
476
  }
403
- // applyTheme opens the theme: it clears working deltas and points
404
- // `themes/_active.json` at it. Reload to rehydrate through the resolver.
405
- window.location.reload();
477
+ currentDisplayName = result.theme.name;
478
+ lookConfigs = result.theme.componentConfigs;
479
+ showFileList = false;
406
480
  } catch (err) {
407
481
  window.alert(`Failed to load theme: ${(err as Error).message}`);
482
+ // The selection remains visible after a failed request; put its preview
483
+ // back so the dialog and page continue to agree.
484
+ previewRow = row;
485
+ previewLook = row.kind === 'look' ? await loadTheme(row.slug).catch(() => null) : null;
486
+ if (previewLook) await repaint();
487
+ return;
488
+ }
489
+
490
+ // The picker is a one-step choose-and-ship flow. Package/user presets are
491
+ // already saved documents, so adopting can publish them directly. The
492
+ // protected baseline cannot be production's editable document; mirror the
493
+ // normal Adopt behavior and fork it first when it is selected.
494
+ adoptStatus = 'adopting';
495
+ try {
496
+ if (row.slug === 'default') {
497
+ const taken = new Set((await listThemes()).map((m) => m.fileName));
498
+ await saveAsTheme(freshName('my-theme', taken), 'My Theme');
499
+ await refreshActive();
500
+ }
501
+ await adoptLook();
502
+ bumpProductionRevision();
503
+ flashStatus(setAdoptStatus, 'done');
504
+ } catch (err) {
505
+ window.alert(`Theme loaded, but could not be adopted: ${(err as Error).message}`);
506
+ flashStatus(setAdoptStatus, 'error', { durationMs: 3000 });
408
507
  }
508
+ await Promise.all([refreshFiles(), refreshComponents(), refreshProduction()]);
409
509
  }
410
510
 
411
511
  /**
@@ -554,6 +654,11 @@
554
654
  if (showFileList) refreshFiles();
555
655
  }
556
656
 
657
+ function openThemePicker() {
658
+ showFileList = true;
659
+ refreshFiles();
660
+ }
661
+
557
662
  function rowBadge(row: LoadRow) {
558
663
  return row.kind === 'layer'
559
664
  ? { label: 'colors & type', title: 'Holds colors and type only, no shapes' }
@@ -575,7 +680,7 @@
575
680
  <strong>Load</strong> opens the list. Picking a theme shows it on the page as a preview, so you can try each look with nothing written to disk. Pick another to compare, or <strong>Cancel</strong> to go back to where you were.
576
681
  </p>
577
682
  <p>
578
- <strong>Save</strong> opens the previewed theme: the editor works on it from then on, and components it does not carry go back to their defaults. Trying a theme never changes what your site ships.
683
+ <strong>Save</strong> opens and adopts the previewed theme: the editor works on it from then on and production ships it immediately. Components it does not carry go back to their defaults. Previewing and cancelling never change what your site ships.
579
684
  </p>
580
685
  <p>
581
686
  <strong>Colors and type only</strong> in that window takes the palette and the fonts and leaves your shapes and component settings alone.
@@ -584,7 +689,7 @@
584
689
  The <strong>active</strong> theme is the one the editor has open. <strong>Adopt</strong> saves it and ships it to production, colors and type plus every component you changed. The line under the name says whether production is running this theme.
585
690
  </p>
586
691
  <p>
587
- Save and Adopt both take the colors and type on screen as they are. Component edits are the exception: save those in the component's own editor first.
692
+ If components have unsaved edits, Save and Adopt offer to save all of them before continuing. You can cancel to review or save components individually.
588
693
  </p>
589
694
  <p>
590
695
  <strong>Motion Proto</strong> is the protected baseline. To start customizing, <strong>Save As</strong> a new theme first.
@@ -603,13 +708,21 @@
603
708
  </span>
604
709
  {/if}
605
710
  </div>
606
- <FilePill
607
- name={currentDisplayName}
608
- isProtected={activeIsProtected}
609
- protectedTitle="Protected default theme"
610
- title={currentDisplayName}
611
- style="display: flex;"
612
- />
711
+ <button
712
+ type="button"
713
+ class="theme-name-trigger"
714
+ onclick={openThemePicker}
715
+ title="Open the Theme Picker"
716
+ aria-label={`Open Theme Picker. Current theme: ${currentDisplayName}`}
717
+ >
718
+ <FilePill
719
+ name={currentDisplayName}
720
+ isProtected={activeIsProtected}
721
+ protectedTitle="Protected default theme"
722
+ title={currentDisplayName}
723
+ style="display: flex;"
724
+ />
725
+ </button>
613
726
  <span class="mfm-prod-status" class:applied={production.inProduction}>
614
727
  <i class="mfm-status-dot" aria-hidden="true"></i>
615
728
  <span>
@@ -668,7 +781,7 @@
668
781
  class="mfm-btn mfm-btn-row"
669
782
  class:active={showFileList}
670
783
  onclick={toggleFileList}
671
- title="Preview a theme, then save it to load it"
784
+ title="Open the Theme Picker"
672
785
  >
673
786
  <i class="fas fa-folder-open"></i>
674
787
  <span>Load…</span>
@@ -741,13 +854,29 @@
741
854
  style="display: none;"
742
855
  />
743
856
 
857
+ <UIDialog
858
+ bind:show={saveComponentsDialog}
859
+ title="Unsaved component edits"
860
+ cancelLabel="Cancel"
861
+ confirmLabel={pendingComponentCount === 1 ? 'Save component' : 'Save all components'}
862
+ onconfirm={confirmSaveAllComponents}
863
+ width="400px"
864
+ >
865
+ <p class="save-components-message">
866
+ {pendingComponentCount === 1
867
+ ? '1 component has unsaved edits.'
868
+ : `${pendingComponentCount} components have unsaved edits.`}
869
+ Save {pendingComponentCount === 1 ? 'it' : 'all of them'} and continue?
870
+ </p>
871
+ </UIDialog>
872
+
744
873
  <FileLoadList
745
874
  bind:show={showFileList}
746
- title="Load Theme"
875
+ title="Theme Picker"
747
876
  files={rows}
748
877
  activeFileName={activeRowId}
749
878
  selectedFileName={previewRow?.fileName ?? null}
750
- selectedBadge={{ label: 'Preview', title: 'Shown on the page now. Save to keep it.' }}
879
+ selectedBadge={{ label: 'Preview', title: 'Shown on the page now. Save to use and publish it.' }}
751
880
  {rowBadge}
752
881
  cancelLabel={previewRow ? 'Cancel' : 'Close'}
753
882
  confirmLabel={previewRow ? 'Save' : ''}
@@ -788,6 +917,13 @@
788
917
  />
789
918
 
790
919
  <style>
920
+ .save-components-message {
921
+ margin: 0;
922
+ color: var(--ui-text-secondary);
923
+ font-size: var(--ui-font-size-sm);
924
+ line-height: 1.5;
925
+ }
926
+
791
927
  .look-panel {
792
928
  --mfm-active: #5aa85e;
793
929
  --mfm-rail-neutral: var(--ui-border);
@@ -850,6 +986,34 @@
850
986
  color: var(--ui-text-tertiary);
851
987
  }
852
988
 
989
+ .theme-name-trigger {
990
+ display: block;
991
+ width: 100%;
992
+ padding: 0;
993
+ border: 0;
994
+ background: transparent;
995
+ color: inherit;
996
+ font: inherit;
997
+ text-align: inherit;
998
+ cursor: pointer;
999
+ }
1000
+
1001
+ .theme-name-trigger :global(.file-pill) {
1002
+ width: 100%;
1003
+ }
1004
+
1005
+ .theme-name-trigger:hover :global(.file-pill),
1006
+ .theme-name-trigger:focus-visible :global(.file-pill) {
1007
+ border-color: var(--ui-border-higher);
1008
+ box-shadow: inset 0 0 0 1px var(--ui-border-high);
1009
+ }
1010
+
1011
+ .theme-name-trigger:focus-visible {
1012
+ outline: 2px solid var(--ui-text-accent);
1013
+ outline-offset: 2px;
1014
+ border-radius: var(--ui-radius-md);
1015
+ }
1016
+
853
1017
  .mfm-badge {
854
1018
  display: inline-flex;
855
1019
  align-items: center;
@@ -3,10 +3,18 @@
3
3
  checked?: boolean;
4
4
  disabled?: boolean;
5
5
  label?: string;
6
+ /** Put the label before the switch, for rows that align to a right edge. */
7
+ labelFirst?: boolean;
6
8
  onchange?: (checked: boolean) => void;
7
9
  }
8
10
 
9
- let { checked = $bindable(false), disabled = false, label = '', onchange }: Props = $props();
11
+ let {
12
+ checked = $bindable(false),
13
+ disabled = false,
14
+ label = '',
15
+ labelFirst = false,
16
+ onchange,
17
+ }: Props = $props();
10
18
 
11
19
  function toggle() {
12
20
  if (disabled) return;
@@ -15,7 +23,7 @@
15
23
  }
16
24
  </script>
17
25
 
18
- <label class="toggle" class:disabled>
26
+ <label class="toggle" class:disabled class:label-first={labelFirst}>
19
27
  <button
20
28
  type="button"
21
29
  role="switch"
@@ -41,6 +49,10 @@
41
49
  cursor: pointer;
42
50
  user-select: none;
43
51
 
52
+ &.label-first {
53
+ flex-direction: row-reverse;
54
+ }
55
+
44
56
  &.disabled {
45
57
  cursor: not-allowed;
46
58
 
@@ -55,7 +55,13 @@
55
55
  {#if show}
56
56
  <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions, a11y_no_static_element_interactions -->
57
57
  <div class="ui-dialog-backdrop" onclick={self(handleClose)}>
58
- <div class="ui-dialog" style="width: {width}; max-width: {width};">
58
+ <div
59
+ class="ui-dialog"
60
+ role="dialog"
61
+ aria-modal="true"
62
+ aria-label={title || 'Dialog'}
63
+ style="width: {width}; max-width: {width};"
64
+ >
59
65
  {#if title}
60
66
  <div class="ui-dialog-header">
61
67
  <h3 class="ui-dialog-title">{title}</h3>
@@ -1,10 +1,8 @@
1
1
  <script lang="ts">
2
- import { run } from 'svelte/legacy';
3
-
4
- import { onMount, onDestroy } from 'svelte';
2
+ import { onMount, onDestroy, untrack } from 'svelte';
5
3
  import { resolveAliasChain } from '../core/palettes/tokenRegistry';
6
4
  import { editorState } from '../core/store/editorStore';
7
- import { CSS_VAR_CHANGE_EVENT } from '../core/cssVarSync';
5
+ import { CSS_VARS_CHANGE_EVENT, type CssVarsChangeDetail } from '../core/cssVarSync';
8
6
  import type { FontFamily, FontSource, FontSourceKind } from '../core/themes/themeTypes';
9
7
  import UITokenSelector from './UITokenSelector.svelte';
10
8
  import UIOptionList from './UIOptionList.svelte';
@@ -93,8 +91,8 @@
93
91
  }
94
92
 
95
93
  function handleVarChange(e: Event) {
96
- const detail = (e as CustomEvent<{ name: string }>).detail;
97
- if (detail?.name?.startsWith('--font-')) readResolved();
94
+ const names = (e as CustomEvent<CssVarsChangeDetail>).detail?.names ?? [];
95
+ if (names.some((name) => name.startsWith('--font-'))) readResolved();
98
96
  }
99
97
 
100
98
  function initFromCurrent() {
@@ -160,19 +158,21 @@
160
158
 
161
159
  // Re-derive `chosenKey` / `chosenFamilyId` when `variable` changes (the
162
160
  // VariantGroup tabs view reuses the same selector instance across states).
163
- let lastSeenVariable: string | null = $state(null);
164
- run(() => {
165
- if (variable !== lastSeenVariable) {
166
- lastSeenVariable = variable;
161
+ $effect(() => {
162
+ // A tab can reuse this instance with a different variable. Track only the
163
+ // prop; initialization writes local selector state and is intentionally
164
+ // untracked to prevent a self-triggering effect.
165
+ variable;
166
+ untrack(() => {
167
167
  initFromCurrent();
168
- }
168
+ });
169
169
  });
170
170
 
171
171
  onMount(() => {
172
- document.addEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
172
+ document.addEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
173
173
  });
174
174
  onDestroy(() => {
175
- document.removeEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
175
+ document.removeEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
176
176
  });
177
177
 
178
178
  let activeLabel = $derived(chosenFamilyId
@@ -2,7 +2,7 @@
2
2
  import { run } from 'svelte/legacy';
3
3
 
4
4
  import { onMount, onDestroy } from 'svelte';
5
- import { CSS_VAR_CHANGE_EVENT } from '../core/cssVarSync';
5
+ import { CSS_VARS_CHANGE_EVENT, type CssVarsChangeDetail } from '../core/cssVarSync';
6
6
  import UIVariantSelector from './UIVariantSelector.svelte';
7
7
 
8
8
  interface Props {
@@ -74,8 +74,8 @@
74
74
  }
75
75
 
76
76
  function handleVarChange(e: Event) {
77
- const detail = (e as CustomEvent<{ name: string }>).detail;
78
- if (detail?.name?.startsWith('--font-size-') || detail?.name?.startsWith('--icon-size-')) readPxValues();
77
+ const names = (e as CustomEvent<CssVarsChangeDetail>).detail?.names ?? [];
78
+ if (names.some((name) => name.startsWith('--font-size-') || name.startsWith('--icon-size-'))) readPxValues();
79
79
  }
80
80
 
81
81
  // Re-read sizes when the target variable's scale changes
@@ -86,10 +86,10 @@
86
86
 
87
87
  onMount(() => {
88
88
  readPxValues();
89
- document.addEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
89
+ document.addEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
90
90
  });
91
91
  onDestroy(() => {
92
- document.removeEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
92
+ document.removeEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
93
93
  });
94
94
  </script>
95
95
 
@@ -1,4 +1,11 @@
1
1
  <script lang="ts">
2
+ import { onDestroy, onMount } from 'svelte';
3
+ import { editorState } from '../core/store/editorStore';
4
+ import { CSS_VARS_CHANGE_EVENT, type CssVarsChangeDetail } from '../core/cssVarSync';
5
+ import {
6
+ fontWeightAvailability,
7
+ inferFontFamilyVariable,
8
+ } from '../core/fonts/fontWeightAvailability';
2
9
  import UIVariantSelector from './UIVariantSelector.svelte';
3
10
  import UIOptionItem from './UIOptionItem.svelte';
4
11
 
@@ -31,6 +38,44 @@
31
38
  { key: 'extrabold', label: 'Extra Bold', value: '800' },
32
39
  { key: 'black', label: 'Black', value: '900' },
33
40
  ] as const;
41
+
42
+ let availability = $state<ReturnType<typeof fontWeightAvailability>>(null);
43
+ let familyVariable = $derived(inferFontFamilyVariable(variable));
44
+
45
+ function refreshAvailability() {
46
+ if (!familyVariable) {
47
+ availability = null;
48
+ return;
49
+ }
50
+ const familyValue = getComputedStyle(document.documentElement)
51
+ .getPropertyValue(familyVariable)
52
+ .trim();
53
+ availability = fontWeightAvailability(
54
+ familyValue,
55
+ $editorState.fonts.sources,
56
+ $editorState.fonts.stacks,
57
+ );
58
+ }
59
+
60
+ function handleVarChange(event: Event) {
61
+ const names = (event as CustomEvent<CssVarsChangeDetail>).detail?.names ?? [];
62
+ if (
63
+ (familyVariable && names.includes(familyVariable))
64
+ || names.some((name) => /^--font-(?:display|sans|serif|mono)$/.test(name))
65
+ ) {
66
+ refreshAvailability();
67
+ }
68
+ }
69
+
70
+ $effect(() => {
71
+ variable;
72
+ $editorState.fonts.sources;
73
+ $editorState.fonts.stacks;
74
+ refreshAvailability();
75
+ });
76
+
77
+ onMount(() => document.addEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange));
78
+ onDestroy(() => document.removeEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange));
34
79
  </script>
35
80
 
36
81
  <UIVariantSelector
@@ -44,8 +89,13 @@
44
89
  {onchange}
45
90
  >
46
91
  {#snippet option({ opt, active, select })}
47
-
48
- <UIOptionItem {active} onclick={select}>
92
+ {@const unsupported = availability !== null && !availability.weights.has(Number(opt.value))}
93
+ <UIOptionItem
94
+ {active}
95
+ disabled={unsupported}
96
+ title={unsupported ? `${opt.label} is not available for ${availability?.familyName}` : ''}
97
+ onclick={select}
98
+ >
49
99
  {#snippet preview()}
50
100
  <span class="weight-sample" style="font-weight: var(--font-weight-{opt.key});">A</span>
51
101
  {/snippet}
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
2
  interface Props {
3
3
  active?: boolean;
4
+ disabled?: boolean;
5
+ title?: string;
4
6
  preview?: import('svelte').Snippet;
5
7
  label?: import('svelte').Snippet;
6
8
  meta?: import('svelte').Snippet;
@@ -9,6 +11,8 @@
9
11
 
10
12
  let {
11
13
  active = false,
14
+ disabled = false,
15
+ title = '',
12
16
  preview,
13
17
  label,
14
18
  meta,
@@ -16,7 +20,7 @@
16
20
  }: Props = $props();
17
21
  </script>
18
22
 
19
- <button class="ui-option-item" class:active {onclick}>
23
+ <button class="ui-option-item" class:active {onclick} {disabled} {title}>
20
24
  {#if preview}
21
25
  <span class="ui-option-preview">
22
26
  {@render preview?.()}
@@ -64,6 +68,15 @@
64
68
  color: var(--ui-text-primary);
65
69
  }
66
70
 
71
+ .ui-option-item:disabled {
72
+ opacity: 0.4;
73
+ cursor: not-allowed;
74
+ }
75
+
76
+ .ui-option-item:disabled:hover {
77
+ background: none;
78
+ }
79
+
67
80
  .ui-option-preview {
68
81
  flex-shrink: 0;
69
82
  display: inline-flex;