@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
@@ -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;
@@ -7,7 +7,7 @@
7
7
  import UITokenSelector from './UITokenSelector.svelte';
8
8
  import UIOptionList from './UIOptionList.svelte';
9
9
  import UIOptionItem from './UIOptionItem.svelte';
10
- import { setCssVar, removeCssVar, CSS_VAR_CHANGE_EVENT } from '../core/cssVarSync';
10
+ import { setCssVar, removeCssVar, CSS_VARS_CHANGE_EVENT } from '../core/cssVarSync';
11
11
  import {
12
12
  editorState,
13
13
  setComponentAlias,
@@ -207,11 +207,11 @@
207
207
 
208
208
  onMount(() => {
209
209
  refreshFromState();
210
- document.addEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
210
+ document.addEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
211
211
  });
212
212
 
213
213
  onDestroy(() => {
214
- document.removeEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
214
+ document.removeEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
215
215
  });
216
216
 
217
217
  // Track `variable` alongside `$editorState` so a VariantGroup tabs view that
@@ -396,6 +396,7 @@
396
396
  <button
397
397
  type="button"
398
398
  class="merge-btn"
399
+ data-padding-parent-variable={variable}
399
400
  onclick={mergeToSingle}
400
401
  title="Use the same value for all sides"
401
402
  disabled={disabled || selectionsLocked}
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
- import { run } from 'svelte/legacy';
3
-
2
+ import { untrack } from 'svelte';
4
3
  import { slide } from 'svelte/transition';
5
4
  import { cubicOut, cubicIn } from 'svelte/easing';
6
5
  import { resolveAliasChain } from '../core/palettes/tokenRegistry';
@@ -528,13 +527,14 @@
528
527
  // The wrapper UITokenSelector forwards `var-change` only for the currently
529
528
  // bound variable, so prop swaps wouldn't otherwise refresh `chosenCategory`
530
529
  // / `chosenFamily` / `chosenStep` and the meta label drifts from the swatch.
531
- let lastSeenVariable: string | null = $state(null);
532
- run(() => {
533
- if (variable !== lastSeenVariable) {
534
- lastSeenVariable = variable;
530
+ $effect(() => {
531
+ // Track only the prop. The initialization writes local display state and
532
+ // must not subscribe the effect to that same state.
533
+ variable;
534
+ untrack(() => {
535
535
  initFromCurrent();
536
536
  captureSelfDefault();
537
- }
537
+ });
538
538
  });
539
539
 
540
540
  let chosenGradientToken = $derived(chosenGradient ? getGradientToken(chosenGradient) : undefined);
@@ -542,8 +542,11 @@
542
542
  let effectiveAngle = $derived(chosenGradientToken
543
543
  ? (chosenAngle ?? chosenGradientToken.angle)
544
544
  : 0);
545
- run(() => {
546
- angleInput = effectiveAngle;
545
+ $effect(() => {
546
+ const nextAngle = effectiveAngle;
547
+ untrack(() => {
548
+ angleInput = nextAngle;
549
+ });
547
550
  });
548
551
 
549
552
  let metaLabel = $derived(chosenNone
@@ -560,10 +563,14 @@
560
563
  ? allCategories.filter(c => c.id !== 'text' || familiesWithText.includes(selectedFamily!))
561
564
  : allCategories);
562
565
 
563
- run(() => {
564
- if (selectedFamily && !availableTabs.find(t => t.id === selectedTab)) {
565
- selectedTab = 'palette';
566
- }
566
+ $effect(() => {
567
+ const family = selectedFamily;
568
+ const tabs = availableTabs;
569
+ untrack(() => {
570
+ if (family && !tabs.find(t => t.id === selectedTab)) {
571
+ selectedTab = 'palette';
572
+ }
573
+ });
567
574
  });
568
575
  </script>
569
576
 
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
2
  import { onMount, onDestroy } from 'svelte';
3
- import { self } from 'svelte/legacy';
4
3
  import UIRadio from './UIRadio.svelte';
5
4
 
6
5
  type Candidate = { variable: string; alias: string };
@@ -73,8 +72,13 @@
73
72
  });
74
73
  </script>
75
74
 
76
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions a11y_no_static_element_interactions -->
77
- <div class="ui-relink-backdrop" onclick={self(handleCancel)}>
75
+ <div class="ui-relink-backdrop">
76
+ <button
77
+ type="button"
78
+ class="ui-relink-dismiss"
79
+ aria-label="Cancel linking"
80
+ onclick={handleCancel}
81
+ ></button>
78
82
  <div
79
83
  class="ui-relink-dialog"
80
84
  role="dialog"
@@ -135,7 +139,18 @@
135
139
  z-index: 10000;
136
140
  }
137
141
 
142
+ .ui-relink-dismiss {
143
+ position: absolute;
144
+ inset: 0;
145
+ padding: 0;
146
+ border: 0;
147
+ background: transparent;
148
+ cursor: default;
149
+ }
150
+
138
151
  .ui-relink-dialog {
152
+ position: relative;
153
+ z-index: 1;
139
154
  background: var(--ui-surface-higher);
140
155
  border: 1px solid var(--ui-border-high);
141
156
  border-radius: var(--ui-radius-md);
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { onMount, onDestroy } from 'svelte';
3
- import { CSS_VAR_CHANGE_EVENT } from '../core/cssVarSync';
3
+ import { CSS_VARS_CHANGE_EVENT, type CssVarsChangeDetail } from '../core/cssVarSync';
4
4
  import UITokenSelector from './UITokenSelector.svelte';
5
5
  import UIOptionList from './UIOptionList.svelte';
6
6
  import UIOptionItem from './UIOptionItem.svelte';
@@ -40,8 +40,8 @@
40
40
  }
41
41
 
42
42
  function handleVarChange(e: Event) {
43
- const detail = (e as CustomEvent<{ name: string }>).detail;
44
- if (detail?.name === variable) readResolved();
43
+ const names = (e as CustomEvent<CssVarsChangeDetail>).detail?.names ?? [];
44
+ if (names.includes(variable)) readResolved();
45
45
  }
46
46
 
47
47
  function select(keyword: string, close: () => void) {
@@ -58,10 +58,10 @@
58
58
 
59
59
  onMount(() => {
60
60
  readResolved();
61
- document.addEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
61
+ document.addEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
62
62
  });
63
63
  onDestroy(() => {
64
- document.removeEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
64
+ document.removeEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
65
65
  });
66
66
 
67
67
  let activeKey = $derived(options.find((o) => o.key === current)?.key ?? null);
@@ -1,7 +1,12 @@
1
1
  <script lang="ts">
2
2
  import { onMount, onDestroy } from 'svelte';
3
3
  import type { Snippet } from 'svelte';
4
- import { setCssVar, removeCssVar, CSS_VAR_CHANGE_EVENT } from '../core/cssVarSync';
4
+ import {
5
+ setCssVar,
6
+ removeCssVar,
7
+ CSS_VARS_CHANGE_EVENT,
8
+ type CssVarsChangeDetail,
9
+ } from '../core/cssVarSync';
5
10
  import type { CssVarRef } from '../core/store/editorTypes';
6
11
  import { cssStringToRef } from '../core/store/cssVarRef';
7
12
  import {
@@ -98,6 +103,18 @@
98
103
  let relinkOpen = $state(false);
99
104
  let relinkCandidates: { variable: string; alias: string }[] = $state([]);
100
105
 
106
+ /** Keep overflow metadata available through the native title tooltip without
107
+ * attaching a mouse-only interaction handler to otherwise static text. */
108
+ function mirrorTextToTitle(node: HTMLElement) {
109
+ const sync = () => {
110
+ node.title = node.textContent?.trim() ?? '';
111
+ };
112
+ sync();
113
+ const observer = new MutationObserver(sync);
114
+ observer.observe(node, { childList: true, characterData: true, subtree: true });
115
+ return { destroy: () => observer.disconnect() };
116
+ }
117
+
101
118
  let isLinkedFromData = $derived(canBeLinked && component && $editorState
102
119
  ? isComponentPropertyLinked(component, variable)
103
120
  : false);
@@ -251,22 +268,27 @@
251
268
  }
252
269
 
253
270
  function handleVarChange(e: Event) {
254
- const detail = (e as CustomEvent<{ name: string }>).detail;
255
- if (detail?.name === variable) onvarChange?.();
271
+ const names = (e as CustomEvent<CssVarsChangeDetail>).detail?.names ?? [];
272
+ if (names.includes(variable)) onvarChange?.();
256
273
  }
257
274
 
258
275
  onMount(() => {
259
276
  document.addEventListener('click', handleClickOutside, true);
260
- document.addEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
277
+ document.addEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
261
278
  });
262
279
 
263
280
  onDestroy(() => {
264
281
  document.removeEventListener('click', handleClickOutside, true);
265
- document.removeEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
282
+ document.removeEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
266
283
  });
267
284
  </script>
268
285
 
269
- <div class="ui-token-selector" class:disabled bind:this={container}>
286
+ <div
287
+ class="ui-token-selector"
288
+ class:disabled
289
+ data-token-variable={variable}
290
+ bind:this={container}
291
+ >
270
292
  <div class="ui-ts-trigger-wrap">
271
293
  <button
272
294
  class="ui-ts-trigger"
@@ -340,7 +362,7 @@
340
362
  {#if triggerMeta}
341
363
  <span
342
364
  class="ui-ts-meta-text"
343
- onmouseenter={(e) => { e.currentTarget.title = e.currentTarget.textContent ?? ''; }}
365
+ use:mirrorTextToTitle
344
366
  >{@render triggerMeta()}</span>
345
367
  {/if}
346
368
  </div>
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "Spring Meadow",
3
3
  "createdAt": "2026-08-13T10:21:46.867Z",
4
- "updatedAt": "2026-08-14T23:53:22.549Z",
4
+ "updatedAt": "2026-08-17T20:21:26.098Z",
5
5
  "schemaVersion": 3,
6
6
  "colorsAndType": {
7
7
  "name": "Spring Meadow",
@@ -3433,10 +3433,10 @@
3433
3433
  "sectiondivider": {
3434
3434
  "name": "spring-meadow",
3435
3435
  "component": "sectiondivider",
3436
- "createdAt": "2026-05-10T22:09:00.336Z",
3437
- "updatedAt": "2026-08-14T23:53:22.549Z",
3436
+ "createdAt": "2026-08-17T20:21:22.937Z",
3437
+ "updatedAt": "2026-08-17T20:21:22.937Z",
3438
3438
  "aliases": {
3439
- "--sectiondivider-lg-title-font-family": "--font-display",
3439
+ "--sectiondivider-lg-title-font-family": "--font-sans",
3440
3440
  "--sectiondivider-lg-title-font-weight": "--font-weight-bold",
3441
3441
  "--sectiondivider-lg-title-font-size": "--font-size-5xl",
3442
3442
  "--sectiondivider-lg-title-line-height": "--line-height-normal",
@@ -3465,9 +3465,9 @@
3465
3465
  "--sectiondivider-lg-border": "--color-transparent",
3466
3466
  "--sectiondivider-lg-title-outline-color": "--surface-canvas-lowest",
3467
3467
  "--sectiondivider-lg-hairline-color": "--border-brand-medium",
3468
- "--sectiondivider-md-title-font-family": "--font-display",
3469
- "--sectiondivider-md-title-font-weight": "--font-weight-bold",
3470
- "--sectiondivider-md-title-font-size": "--font-size-4xl",
3468
+ "--sectiondivider-md-title-font-family": "--font-sans",
3469
+ "--sectiondivider-md-title-font-weight": "--font-weight-extrabold",
3470
+ "--sectiondivider-md-title-font-size": "--font-size-5xl",
3471
3471
  "--sectiondivider-md-title-line-height": "--line-height-normal",
3472
3472
  "--sectiondivider-md-title-letter-spacing": "--letter-spacing-normal",
3473
3473
  "--sectiondivider-md-title-outline-width": "--border-width-4",
@@ -3488,13 +3488,13 @@
3488
3488
  "--sectiondivider-md-shadow": "--shadow-none",
3489
3489
  "--sectiondivider-md-hairline-thickness": "--border-width-1",
3490
3490
  "--sectiondivider-md-background": "--color-transparent",
3491
- "--sectiondivider-md-title": "--text-primary",
3491
+ "--sectiondivider-md-title": "--color-brand-700",
3492
3492
  "--sectiondivider-md-description": "--text-secondary",
3493
3493
  "--sectiondivider-md-eyebrow": "--text-tertiary",
3494
3494
  "--sectiondivider-md-border": "--color-transparent",
3495
- "--sectiondivider-md-title-outline-color": "--surface-accent-lowest",
3495
+ "--sectiondivider-md-title-outline-color": "color-mix(in srgb, var(--color-white) 34%, transparent)",
3496
3496
  "--sectiondivider-md-hairline-color": "--border-brand-medium",
3497
- "--sectiondivider-sm-title-font-family": "--font-display",
3497
+ "--sectiondivider-sm-title-font-family": "--font-sans",
3498
3498
  "--sectiondivider-sm-title-font-weight": "--font-weight-medium",
3499
3499
  "--sectiondivider-sm-title-font-size": "--font-size-3xl",
3500
3500
  "--sectiondivider-sm-title-line-height": "--line-height-normal",
@@ -3523,7 +3523,9 @@
3523
3523
  "--sectiondivider-sm-border": "--color-transparent",
3524
3524
  "--sectiondivider-sm-title-outline-color": "color-mix(in srgb, var(--surface-neutral-lowest) 25%, transparent)",
3525
3525
  "--sectiondivider-sm-hairline-color": "color-mix(in srgb, var(--border-brand-medium) 50%, transparent)"
3526
- }
3526
+ },
3527
+ "config": {},
3528
+ "schemaVersion": 21
3527
3529
  },
3528
3530
  "segmentedcontrol": {
3529
3531
  "name": "spring-meadow",
@@ -3901,4 +3903,4 @@
3901
3903
  }
3902
3904
  }
3903
3905
  }
3904
- }
3906
+ }
@@ -348,7 +348,8 @@
348
348
  color: var(--button-outline-hover-text);
349
349
  }
350
350
 
351
- &:active:not(:disabled) {
351
+ &:active:not(:disabled),
352
+ &.force-active:not(:disabled) {
352
353
  background: var(--button-outline-active-surface);
353
354
  }
354
355
 
@@ -17,6 +17,7 @@
17
17
 
18
18
  /* --- Central box ---------------------------------------------------------- */
19
19
  .ftt-box {
20
+ --ftt-box-contrast-color: var(--color-white);
20
21
  position: absolute;
21
22
  left: 50%;
22
23
  top: 50%;
@@ -46,7 +47,7 @@
46
47
  font-family: Manrope, system-ui, -apple-system, sans-serif;
47
48
  font-size: 1.25rem;
48
49
  font-weight: 700;
49
- color: rgba(255, 255, 255, 0.95);
50
+ color: var(--ftt-box-contrast-color);
50
51
  white-space: nowrap;
51
52
  user-select: none;
52
53
  pointer-events: none;
@@ -191,6 +191,7 @@
191
191
  }
192
192
 
193
193
  let lastPageBackground = '';
194
+ let lastBoxBackground = '';
194
195
  function syncConnectorContrast() {
195
196
  if (!stageEl) return;
196
197
  const styles = getComputedStyle(stageEl);
@@ -201,11 +202,21 @@
201
202
  stageEl.style.setProperty('--ftt-contrast-color', `var(${token})`);
202
203
  }
203
204
 
205
+ function syncBoxLabelContrast() {
206
+ if (!boxEl) return;
207
+ const background = getComputedStyle(boxEl).backgroundColor;
208
+ if (background === lastBoxBackground) return;
209
+ lastBoxBackground = background;
210
+ const token = contrastTokenForBackground(background);
211
+ boxEl.style.setProperty('--ftt-box-contrast-color', `var(${token})`);
212
+ }
213
+
204
214
  // Kite strings and energy balls are recomputed each frame from the box's
205
215
  // measured rect so intrinsic sizing drives anchor placement.
206
216
  function syncFrame() {
207
217
  if (!stageEl) return;
208
218
  syncConnectorContrast();
219
+ syncBoxLabelContrast();
209
220
  const stageRect = stageEl.getBoundingClientRect();
210
221
  if (stageRect.width === 0 || stageRect.height === 0) return;
211
222
 
@@ -229,7 +229,8 @@
229
229
  color: var(--iconbutton-outline-hover-icon);
230
230
  }
231
231
 
232
- &:active:not(:disabled) {
232
+ &:active:not(:disabled),
233
+ &.force-active:not(:disabled) {
233
234
  background: var(--iconbutton-outline-active-surface);
234
235
  }
235
236
 
@@ -17,6 +17,8 @@
17
17
  /** Content images default to lazy; pass `'eager'` for above-the-fold heroes. */
18
18
  loading?: 'lazy' | 'eager';
19
19
  decoding?: 'async' | 'sync' | 'auto';
20
+ /** Editor preview hook: paint the hover transform without pointer input. */
21
+ forceHover?: boolean;
20
22
  }
21
23
 
22
24
  let {
@@ -29,7 +31,8 @@
29
31
  srcset = undefined,
30
32
  sizes = undefined,
31
33
  loading = 'lazy',
32
- decoding = 'async'
34
+ decoding = 'async',
35
+ forceHover = false,
33
36
  }: Props = $props();
34
37
 
35
38
  const variantHeights: Record<string, string | undefined> = {
@@ -60,6 +63,7 @@
60
63
 
61
64
  <div
62
65
  class="image"
66
+ class:force-hover={forceHover}
63
67
  style:height={resolvedHeight}
64
68
  style:--image-zoom-hover={contentHover}
65
69
  style:--image-grow-hover={frameHover}
@@ -100,11 +104,13 @@
100
104
  transition: transform var(--duration-300) var(--ease-out-cubic);
101
105
  }
102
106
 
103
- .image:hover {
107
+ .image:hover,
108
+ .image.force-hover {
104
109
  transform: var(--image-grow-hover);
105
110
  }
106
111
 
107
- .image:hover img {
112
+ .image:hover img,
113
+ .image.force-hover img {
108
114
  transform: var(--image-zoom-hover);
109
115
  }
110
116
  </style>
@@ -585,6 +585,8 @@
585
585
  style:--imagelightbox-tile-object-fit={fit}
586
586
  type="button"
587
587
  aria-label={cover?.alt ? `Expand image: ${cover.alt}` : 'Expand image'}
588
+ aria-haspopup="dialog"
589
+ aria-expanded={open}
588
590
  onclick={openLightbox}
589
591
  >
590
592
  <img src={cover?.src} alt={cover?.alt} draggable="false" onload={onCoverLoad} />
@@ -63,11 +63,28 @@
63
63
  });
64
64
 
65
65
  onMount(() => {
66
+ let mounted = true;
67
+ const sync = () => { measure(); syncFilter(); };
66
68
  measure();
67
69
  syncFilter();
68
- const obs = new MutationObserver(() => { measure(); syncFilter(); });
70
+ const obs = new MutationObserver(sync);
69
71
  obs.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
70
- return () => obs.disconnect();
72
+
73
+ // A font-family variable changes before its webfont necessarily finishes
74
+ // loading. Re-run getBBox when the real face becomes available so the SVG
75
+ // viewBox and outline do not stay sized for the fallback font.
76
+ const fonts = document.fonts;
77
+ const onFontsLoaded = () => {
78
+ if (mounted) sync();
79
+ };
80
+ fonts?.addEventListener('loadingdone', onFontsLoaded);
81
+ void fonts?.ready.then(onFontsLoaded);
82
+
83
+ return () => {
84
+ mounted = false;
85
+ obs.disconnect();
86
+ fonts?.removeEventListener('loadingdone', onFontsLoaded);
87
+ };
71
88
  });
72
89
  </script>
73
90
 
@@ -531,6 +548,7 @@
531
548
  font-family: var(--_divider-title-font-family);
532
549
  font-weight: var(--_divider-title-font-weight);
533
550
  font-size: var(--_divider-title-font-size);
551
+ line-height: var(--_divider-title-line-height);
534
552
  letter-spacing: var(--_divider-title-letter-spacing);
535
553
  fill: currentColor;
536
554
  }