@motion-proto/live-tokens 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.claude/skills/live-tokens-add-component/SKILL.md +488 -0
  2. package/README.md +34 -0
  3. package/dist-plugin/index.cjs +707 -90
  4. package/dist-plugin/index.d.cts +1 -0
  5. package/dist-plugin/index.d.ts +1 -0
  6. package/dist-plugin/index.js +707 -90
  7. package/package.json +6 -2
  8. package/src/app/site.css +1 -1
  9. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +34 -27
  10. package/src/editor/component-editor/DialogEditor.svelte +4 -4
  11. package/src/editor/component-editor/NotificationEditor.svelte +3 -1
  12. package/src/editor/component-editor/SectionDividerEditor.svelte +439 -112
  13. package/src/editor/component-editor/StandardButtonsEditor.svelte +13 -1
  14. package/src/editor/component-editor/editors.d.ts +10 -0
  15. package/src/editor/component-editor/index.ts +16 -1
  16. package/src/editor/component-editor/registry.ts +103 -26
  17. package/src/editor/component-editor/scaffolding/AngleDial.svelte +52 -13
  18. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +10 -11
  19. package/src/editor/component-editor/scaffolding/ComponentsTab.svelte +2 -2
  20. package/src/editor/component-editor/scaffolding/LinkedBlock.svelte +0 -1
  21. package/src/editor/component-editor/scaffolding/RadialShapePad.svelte +483 -0
  22. package/src/editor/component-editor/scaffolding/ShadowBackdrop.svelte +15 -2
  23. package/src/editor/component-editor/scaffolding/StateBlock.svelte +103 -15
  24. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +9 -6
  25. package/src/editor/component-editor/scaffolding/TypeEditor.svelte +13 -1
  26. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +239 -25
  27. package/src/editor/component-editor/scaffolding/buildTypeGroupTokens.ts +1 -0
  28. package/src/editor/component-editor/scaffolding/componentSources.ts +3 -3
  29. package/src/editor/component-editor/scaffolding/defaultSections.ts +15 -10
  30. package/src/editor/component-editor/scaffolding/types.ts +11 -0
  31. package/src/editor/core/components/componentConfigKeys.ts +22 -3
  32. package/src/editor/core/components/componentConfigService.ts +2 -2
  33. package/src/editor/core/components/componentPersist.ts +7 -5
  34. package/src/editor/core/manifests/manifestService.ts +58 -3
  35. package/src/editor/core/palettes/familySwap.ts +99 -0
  36. package/src/editor/core/palettes/paletteDerivation.ts +69 -0
  37. package/src/editor/core/palettes/tokenRegistry.ts +4 -1
  38. package/src/editor/core/store/editorStore.ts +206 -12
  39. package/src/editor/core/store/editorTypes.ts +55 -12
  40. package/src/editor/core/store/gradientSource.ts +192 -0
  41. package/src/editor/core/themes/migrations/2026-05-19-collapsiblesection-drop-frame-surface.ts +28 -0
  42. package/src/editor/core/themes/migrations/2026-05-19-sectiondivider-rich-gradient.ts +35 -0
  43. package/src/editor/core/themes/migrations/2026-05-20-sectiondivider-slim-variants.ts +82 -0
  44. package/src/editor/core/themes/migrations/2026-05-21-sectiondivider-spacing-to-padding.ts +24 -0
  45. package/src/editor/core/themes/migrations/2026-05-22-sectiondivider-intrinsics-to-css.ts +81 -0
  46. package/src/editor/core/themes/migrations/index.ts +10 -0
  47. package/src/editor/core/themes/slices/components.ts +27 -4
  48. package/src/editor/core/themes/slices/gradients.ts +88 -13
  49. package/src/editor/core/themes/themeInit.ts +2 -2
  50. package/src/editor/core/themes/themeTypes.ts +56 -1
  51. package/src/editor/index.ts +10 -1
  52. package/src/editor/overlay/ColumnsOverlay.svelte +0 -1
  53. package/src/editor/overlay/LiveEditorOverlay.svelte +1 -4
  54. package/src/editor/pages/ComponentEditorPage.svelte +53 -3
  55. package/src/editor/pages/EditorShell.svelte +53 -3
  56. package/src/editor/styles/ui-editor.css +1 -0
  57. package/src/editor/styles/ui-form-controls.css +19 -20
  58. package/src/editor/ui/BezierCurveEditor.svelte +114 -63
  59. package/src/editor/ui/EditorViewSwitcher.svelte +0 -1
  60. package/src/editor/ui/FileLoadList.svelte +22 -5
  61. package/src/editor/ui/FontStackEditor.svelte +214 -76
  62. package/src/editor/ui/GradientEditor.svelte +435 -215
  63. package/src/editor/ui/GradientStopPicker.svelte +11 -3
  64. package/src/editor/ui/ManifestFileManager.svelte +71 -4
  65. package/src/editor/ui/PaletteEditor.svelte +52 -79
  66. package/src/editor/ui/ProjectFontsSection.svelte +328 -293
  67. package/src/editor/ui/ThemeFileManager.svelte +0 -4
  68. package/src/editor/ui/UIFontFamilySelector.svelte +0 -1
  69. package/src/editor/ui/UIFontSizeSelector.svelte +3 -0
  70. package/src/editor/ui/UIInfoPopover.svelte +0 -1
  71. package/src/editor/ui/UILetterSpacingSelector.svelte +65 -0
  72. package/src/editor/ui/UIPaletteSelector.svelte +31 -4
  73. package/src/editor/ui/UIPillButton.svelte +33 -3
  74. package/src/editor/ui/UISegmentedControl.svelte +114 -0
  75. package/src/editor/ui/UITokenSelector.svelte +4 -1
  76. package/src/editor/ui/VariablesTab.svelte +41 -35
  77. package/src/editor/ui/palette/OverridesPanel.svelte +14 -37
  78. package/src/editor/ui/palette/PaletteBase.svelte +3 -3
  79. package/src/editor/ui/sections/ColumnsSection.svelte +1 -2
  80. package/src/editor/ui/sections/GradientsSection.svelte +1 -1
  81. package/src/editor/ui/sections/OverlaysSection.svelte +1 -1
  82. package/src/editor/ui/sections/ShadowsSection.svelte +1 -1
  83. package/src/system/components/Button.svelte +2 -2
  84. package/src/system/components/Card.svelte +29 -1
  85. package/src/system/components/CollapsibleSection.svelte +25 -2
  86. package/src/system/components/Dialog.svelte +24 -4
  87. package/src/system/components/FloatingTokenTags.css +43 -24
  88. package/src/system/components/FloatingTokenTags.svelte +88 -137
  89. package/src/system/components/Notification.svelte +8 -1
  90. package/src/system/components/SectionDivider.svelte +532 -381
  91. package/src/system/styles/CONVENTIONS.md +1 -1
  92. package/src/system/styles/fonts.css +3 -16
  93. package/src/system/styles/tokens.css +356 -1199
  94. package/src/system/styles/tokens.generated.css +544 -0
  95. package/src/editor/component-editor/scaffolding/DividerEditor.svelte +0 -94
  96. package/src/editor/component-editor/scaffolding/GradientCard.svelte +0 -296
@@ -1,15 +1,11 @@
1
1
  <script module lang="ts">
2
2
  export type AnchorSide = 'top' | 'right' | 'bottom' | 'left';
3
3
 
4
- /** Which visual property of the central box this tag drives. */
5
4
  export type TagControl = 'surface' | 'radius' | 'border-color' | 'border-width' | 'font-family';
6
5
 
7
6
  /**
8
- * Where the kite string ties to the central box.
9
- * - Edge anchor: a point along one of the four box edges (`pos` 0..100).
10
- * A corner is just `pos: 0` or `pos: 100` on an adjacent edge.
11
- * - Inside anchor: a point on the box surface, expressed as % of the
12
- * box's own footprint (x: 0=left edge, 100=right edge).
7
+ * Where the kite string ties to the box. `inside` anchors are % of the
8
+ * box's footprint; edge anchors are `pos` 0..100 along the named side.
13
9
  */
14
10
  export type Anchor =
15
11
  | { side: AnchorSide; pos: number }
@@ -17,32 +13,30 @@
17
13
 
18
14
  export interface FloatingTag {
19
15
  icon?: string;
20
- /** Typically a CSS-variable name. Used as the initial value. */
16
+ /** Initial value; typically a CSS-variable name. */
21
17
  label: string;
22
18
  /** Tag center, in % of the stage. */
23
19
  top: number;
24
20
  left: number;
25
21
  /** Bob delay, seconds. Negative values offset the start. */
26
22
  delay?: number;
27
- /** Static tilt of the tag, degrees. */
23
+ /** Static tilt, degrees. */
28
24
  rotate?: number;
29
- /** Where this tag's string ties to the central box. */
30
25
  anchor: Anchor;
31
- /** Property of the central box this tag drives (live preview of the token). */
26
+ /** Visual property of the central box this tag drives. */
32
27
  controls?: TagControl;
28
+ /** `top` chip sits above the box and opens down; `bottom` mirrors. */
29
+ placement?: 'top' | 'bottom';
30
+ /** `right-cap` pivots the tilt around the chip's right cap. */
31
+ pivot?: 'center' | 'right-cap';
33
32
  }
34
33
  </script>
35
34
 
36
35
  <script lang="ts">
37
36
  import MenuSelect from './MenuSelect.svelte';
38
37
  import { SvelteMap } from 'svelte/reactivity';
39
- // Playground chrome lives in its own CSS file so live token edits in the
40
- // editor don't repaint our animation. The floating tag pills are rendered
41
- // with a self-contained `.ftt-tag` element (not the Badge component) so the
42
- // demo stays visually stable while the user edits badge-* tokens. The one
43
- // exception is the dropdown panel — it renders through MenuSelect on
44
- // purpose, so editing the menuselect-* tokens reshapes the in-flight UI.
45
- // See FloatingTokenTags.css.
38
+ // `.ftt-tag` is hand-rolled (not Badge) so editing badge-* tokens doesn't
39
+ // repaint the playground. The dropdown uses MenuSelect on purpose.
46
40
  import './FloatingTokenTags.css';
47
41
 
48
42
  interface Props {
@@ -50,7 +44,6 @@
50
44
  duration?: number;
51
45
  distance?: number;
52
46
  boxSize?: { w: number; h: number };
53
- /** Auto-cycle through values when idle. */
54
47
  autoplay?: boolean;
55
48
  }
56
49
 
@@ -62,58 +55,58 @@
62
55
  'font-family': 'Font family',
63
56
  };
64
57
 
65
- // Four candidate token values per control. Picked to span the visible
66
- // gamut so cycling produces noticeably different box states.
58
+ // Surfaces use the `-high` step and borders the `-strong` step so the box
59
+ // reads as the figure against the dark canvas. Picks span the hue wheel;
60
+ // `special` is too close to the background to pull weight here.
67
61
  const valueOptions: Record<TagControl, string[]> = {
68
- 'surface': ['--surface-brand-low', '--surface-danger-low', '--surface-accent-low', '--surface-special-low'],
69
- 'radius': ['--radius-none', '--radius-lg', '--radius-2xl', '--radius-full'],
70
- 'border-color': ['--border-brand', '--border-danger', '--border-success', '--border-special'],
71
- 'border-width': ['--border-width-1', '--border-width-2', '--border-width-3', '--border-width-5'],
72
- 'font-family': ['--font-display', '--font-sans', '--font-serif', '--font-mono'],
62
+ 'surface': ['--surface-brand-high', '--surface-accent-high', '--surface-success-high', '--surface-info-high'],
63
+ 'radius': ['--radius-none', '--radius-lg', '--radius-2xl', '--radius-full'],
64
+ 'border-color': ['--border-brand-strong', '--border-accent-strong','--border-success-strong','--border-info-strong'],
65
+ 'border-width': ['--border-width-1', '--border-width-2', '--border-width-3', '--border-width-5'],
66
+ 'font-family': ['--font-display', '--font-sans', '--font-serif', '--font-mono'],
73
67
  };
74
68
 
75
69
  const defaultTags: FloatingTag[] = [
76
- // Layout: surface + border-color flank the box at mid-height (far sides);
77
- // font + corner-radius sit above near the box; border-width sits below
78
- // centred. Roughly mirrors the user's sketch.
79
- // Tags spread outward (factor 1.25 from box centre) so the cluster fills
80
- // the available stage. Kite anchors are computed each frame against the
81
- // box's *measured* rect, so the central element can size itself like a
82
- // normal div (intrinsic to content + padding) and the strings still land.
83
70
  {
84
71
  icon: 'fas fa-fill-drip',
85
- label: '--surface-brand-low',
86
- top: 28, left: 18, delay: 0, rotate: -3,
87
- anchor: { side: 'inside', x: 10, y: 50 },
72
+ label: '--surface-brand-high',
73
+ top: 15, left: 20, delay: 0, rotate: 3,
74
+ anchor: { side: 'inside', x: 10, y: 40 },
88
75
  controls: 'surface',
76
+ placement: 'top',
77
+ pivot: 'right-cap',
89
78
  },
90
79
  {
91
80
  icon: 'fas fa-font',
92
81
  label: '--font-display',
93
- top: 11.1, left: 40.8, delay: -0.9, rotate: 2,
94
- anchor: { side: 'inside', x: 45, y: 28 },
82
+ top: 5, left: 45, delay: -0.9, rotate: 2,
83
+ anchor: { side: 'inside', x: 78, y: 28 },
95
84
  controls: 'font-family',
85
+ placement: 'top',
96
86
  },
97
87
  {
98
88
  icon: 'fa-solid fa-bezier-curve',
99
89
  label: '--radius-2xl',
100
- top: 19.8, left: 72.0, delay: -1.8, rotate: 2,
90
+ top: 23, left: 75, delay: -1.8, rotate: 2,
101
91
  anchor: { side: 'top', pos: 100 },
102
92
  controls: 'radius',
93
+ placement: 'top',
103
94
  },
104
95
  {
105
96
  icon: 'fas fa-paint-roller',
106
- label: '--border-brand',
107
- top: 75.5, left: 79.5, delay: -3.6, rotate: -2,
108
- anchor: { side: 'right', pos: 55 },
97
+ label: '--border-brand-strong',
98
+ top: 79, left: 72, delay: -3.6, rotate: -2,
99
+ anchor: { side: 'bottom', pos: 75 },
109
100
  controls: 'border-color',
101
+ placement: 'top',
110
102
  },
111
103
  {
112
104
  icon: 'fas fa-grip-lines',
113
105
  label: '--border-width-3',
114
- top: 78, left: 33, delay: -5.2, rotate: -3,
115
- anchor: { side: 'bottom', pos: 50 },
106
+ top: 79, left: 28, delay: -5.2, rotate: -4,
107
+ anchor: { side: 'bottom', pos: 25 },
116
108
  controls: 'border-width',
109
+ placement: 'top',
117
110
  },
118
111
  ];
119
112
 
@@ -125,13 +118,9 @@
125
118
  autoplay = true,
126
119
  }: Props = $props();
127
120
 
128
- // --- Per-tag mutable state ----------------------------------------------
129
- // Tag values are modelled as defaults (derived from the `tags` prop) plus
130
- // user overrides. This keeps `currentValues` reactive to prop changes
131
- // without losing user picks, and avoids capturing only the initial `tags`.
132
- // Two override layers, deliberately desynchronised: `overrides` drives the
133
- // floating tag's badge label (commits at selection); `boxOverrides` drives
134
- // the central component's style (commits only when the energy ball lands).
121
+ // Two override layers, deliberately desynchronised: `overrides` commits at
122
+ // selection (drives the tag label); `boxOverrides` commits at impact
123
+ // (drives the box's style) so the box swaps in sync with the bloop.
135
124
  const defaultLabels = $derived(tags.map(t => t.label));
136
125
  let overrides = $state<Record<number, string>>({});
137
126
  let boxOverrides = $state<Record<number, string>>({});
@@ -143,8 +132,6 @@
143
132
  let flashingIdx = $state<number | null>(null);
144
133
  let bloopActive = $state(false);
145
134
 
146
- // Drag state — per-tag overrides for {top, left} in stage % space. Click vs
147
- // drag is distinguished by a small pixel threshold on pointer movement.
148
135
  let dragOverrides = $state<Record<number, { top: number; left: number }>>({});
149
136
  let draggingIdx = $state<number | null>(null);
150
137
  const DRAG_THRESHOLD_PX = 4;
@@ -153,28 +140,17 @@
153
140
  function tagTop(i: number): number { return dragOverrides[i]?.top ?? tags[i].top; }
154
141
  function tagLeft(i: number): number { return dragOverrides[i]?.left ?? tags[i].left; }
155
142
 
156
- // Energy balls are imperative — keyed by tag index. Updated each rAF tick.
157
- // SvelteMap so the template can react to in-flight state (line glow).
158
- // `pendingValue` is the token swap that commits on impact, not on launch —
159
- // so the box visibly changes at the moment of the bloop.
160
143
  type BallState = { startedAt: number; duration: number; pendingValue: string };
161
144
  const ballStates = new SvelteMap<number, BallState>();
162
- // Element-ref arrays are `$state` so Svelte 5 considers
163
- // `bind:this={ballEls[i]}` etc. a reactive binding target. They're only
164
- // read imperatively from the rAF loop, so there's no extra reactivity cost.
145
+ // `$state` so `bind:this={ballEls[i]}` is a reactive binding target.
165
146
  const ballEls: HTMLSpanElement[] = $state([]);
166
147
 
167
- // --- Element refs --------------------------------------------------------
168
148
  let stageEl: HTMLDivElement | undefined = $state();
169
149
  let boxEl: HTMLDivElement | undefined = $state();
170
150
  const tagEls: HTMLSpanElement[] = $state([]);
171
151
  const lineEls: SVGLineElement[] = $state([]);
172
152
  const knotEls: HTMLSpanElement[] = $state([]);
173
153
 
174
- // --- Anchor math ---------------------------------------------------------
175
- // `cx`/`cy`/`w`/`h` are the box's centre and dimensions in stage-% space.
176
- // Defaults fall back to `boxSize` for the first paint (before syncFrame
177
- // measures the actual box). Runtime calls in syncFrame pass the live rect.
178
154
  function anchorPoint(
179
155
  anchor: Anchor,
180
156
  cx = 50,
@@ -198,9 +174,7 @@
198
174
  }
199
175
  }
200
176
 
201
- // Resolve the active CSS-var name for each control. Reads `boxValues` (not
202
- // `currentValues`) so the central component only repaints once the ball
203
- // commits its payload at impact.
177
+ // Reads `boxValues` (not `currentValues`) so the box repaints at impact.
204
178
  function resolveControl(name: TagControl): string | undefined {
205
179
  const idx = tags.findIndex(t => t.controls === name);
206
180
  return idx >= 0 ? boxValues[idx] : undefined;
@@ -215,14 +189,13 @@
215
189
  return name ? `var(${name})` : undefined;
216
190
  }
217
191
 
218
- // --- Animation loop: kite strings + energy balls ------------------------
192
+ // Kite strings and energy balls are recomputed each frame from the box's
193
+ // measured rect so intrinsic sizing drives anchor placement.
219
194
  function syncFrame() {
220
195
  if (!stageEl) return;
221
196
  const stageRect = stageEl.getBoundingClientRect();
222
197
  if (stageRect.width === 0 || stageRect.height === 0) return;
223
198
 
224
- // Box geometry in stage-% space — measured from the live rect so that
225
- // intrinsic sizing (content + padding) drives anchor placement.
226
199
  let boxCx = 50, boxCy = 50;
227
200
  let boxW = boxSize.w, boxH = boxSize.h;
228
201
  if (boxEl) {
@@ -242,8 +215,7 @@
242
215
  const lineEl = lineEls[i];
243
216
  if (!tagEl || !lineEl) continue;
244
217
 
245
- // Tag-side endpoint: pill cap (corner-radius circle) center on the
246
- // side closest to the central component.
218
+ // Tag endpoint: center of the pill's rounded cap on the box-facing side.
247
219
  const pill = tagEl.querySelector('.ftt-tag') as HTMLElement | null;
248
220
  const target = (pill ?? tagEl) as HTMLElement;
249
221
  const r = target.getBoundingClientRect();
@@ -265,7 +237,6 @@
265
237
  lineEl.setAttribute('x1', x1.toFixed(3));
266
238
  lineEl.setAttribute('y1', y1.toFixed(3));
267
239
 
268
- // Box-side endpoint + knot — recomputed from live box geometry.
269
240
  const a = anchorPoint(tags[i].anchor, boxCx, boxCy, boxW, boxH);
270
241
  lineEl.setAttribute('x2', a.x.toFixed(3));
271
242
  lineEl.setAttribute('y2', a.y.toFixed(3));
@@ -275,7 +246,6 @@
275
246
  knotEl.style.top = `${a.y.toFixed(3)}%`;
276
247
  }
277
248
 
278
- // Energy ball traveling tag → box along the same line.
279
249
  const ballEl = ballEls[i];
280
250
  const state = ballStates.get(i);
281
251
  if (!ballEl) continue;
@@ -286,9 +256,7 @@
286
256
  const elapsed = now - state.startedAt;
287
257
  const t = Math.min(1, elapsed / state.duration);
288
258
  if (t >= 1) {
289
- // Commit the box's token swap at impact so its appearance changes in
290
- // sync with the bloop (the "pops up and grows larger" beat). Until
291
- // this point the box keeps rendering the previous value.
259
+ // Commit at impact so the box's appearance changes with the bloop.
292
260
  boxOverrides = { ...boxOverrides, [i]: state.pendingValue };
293
261
  ballStates.delete(i);
294
262
  ballEl.style.opacity = '0';
@@ -297,7 +265,7 @@
297
265
  }
298
266
  const x2 = parseFloat(lineEl.getAttribute('x2') || '0');
299
267
  const y2 = parseFloat(lineEl.getAttribute('y2') || '0');
300
- const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
268
+ const eased = 1 - Math.pow(1 - t, 3);
301
269
  const bx = x1 + (x2 - x1) * eased;
302
270
  const by = y1 + (y2 - y1) * eased;
303
271
  ballEl.style.left = `${bx.toFixed(3)}%`;
@@ -316,13 +284,9 @@
316
284
  return () => cancelAnimationFrame(rafId);
317
285
  });
318
286
 
319
- // --- Selection / fire sequence ------------------------------------------
320
287
  function pickValue(i: number, value: string) {
321
288
  openIdx = null;
322
289
  strobeIdx = null;
323
- // Commit the tag's badge label immediately so the user gets a "you picked
324
- // this" confirmation. The central box waits — its commit is carried by
325
- // the energy ball and lands at impact.
326
290
  overrides = { ...overrides, [i]: value };
327
291
  flashingIdx = i;
328
292
  window.setTimeout(() => {
@@ -336,8 +300,7 @@
336
300
  window.setTimeout(() => { bloopActive = false; }, 480);
337
301
  }
338
302
 
339
- // User-interaction cooldown: any click pauses the auto-loop for at least
340
- // USER_HOLD_MS so the user can play without being interrupted.
303
+ // Any click pauses auto-cycle for at least this long.
341
304
  const USER_HOLD_MS = 4000;
342
305
  let lastUserActionAt = 0;
343
306
  function noteUserAction() {
@@ -351,14 +314,12 @@
351
314
  }
352
315
 
353
316
  function userPick(i: number, value: string) {
354
- // The current value can never be re-picked the dropdown item is also
355
- // rendered disabled, this guard is a safety net.
317
+ // The matching dropdown item is also rendered disabled; this is a safety net.
356
318
  if (currentValues[i] === value) return;
357
319
  noteUserAction();
358
320
  pickValue(i, value);
359
321
  }
360
322
 
361
- // --- Drag handlers ------------------------------------------------------
362
323
  function onTagPointerDown(i: number, e: PointerEvent) {
363
324
  dragStart.px = e.clientX;
364
325
  dragStart.py = e.clientY;
@@ -375,7 +336,7 @@
375
336
  dragStart.moved = true;
376
337
  draggingIdx = i;
377
338
  openIdx = null;
378
- noteUserAction(); // pause auto-cycle while dragging
339
+ noteUserAction();
379
340
  }
380
341
  if (dragStart.moved) {
381
342
  const r = stageEl.getBoundingClientRect();
@@ -402,7 +363,6 @@
402
363
  dragStart.moved = false;
403
364
  }
404
365
 
405
- // --- Auto-cycle ----------------------------------------------------------
406
366
  let autoAlive = false;
407
367
  let lastAutoTagIdx: number | null = null;
408
368
  const sleep = (ms: number) => new Promise(r => window.setTimeout(r, ms));
@@ -415,8 +375,7 @@
415
375
  await sleep(2400 + Math.random() * 2400);
416
376
  if (!autoAlive) break;
417
377
 
418
- // Hold off if the user clicked anything recently. Re-check after each
419
- // partial sleep so a fresh click resets the wait.
378
+ // Hold off while the user is interacting; re-check on each fresh click.
420
379
  while (autoAlive) {
421
380
  const sinceUser = performance.now() - lastUserActionAt;
422
381
  if (sinceUser >= USER_HOLD_MS) break;
@@ -424,7 +383,7 @@
424
383
  }
425
384
  if (!autoAlive) break;
426
385
 
427
- if (openIdx !== null) continue; // user is interacting
386
+ if (openIdx !== null) continue;
428
387
 
429
388
  // Never the same tag twice in a row.
430
389
  const tagCandidates = tags
@@ -435,8 +394,7 @@
435
394
  const tag = tags[i];
436
395
  const opts = valueOptions[tag.controls!];
437
396
 
438
- // Never the same token twice in a row: exclude the currently-active
439
- // value from the candidate set.
397
+ // Never the same token twice in a row.
440
398
  const currentIdx = opts.indexOf(currentValues[i]);
441
399
  const candidates = opts
442
400
  .map((_, k) => k)
@@ -446,13 +404,11 @@
446
404
 
447
405
  openIdx = i;
448
406
 
449
- // Breath: open menu, let it sit before any highlight appears.
407
+ // Let the menu sit open before any highlight appears.
450
408
  await sleep(BREATH_MS);
451
409
  if (!autoAlive || openIdx !== i) continue;
452
410
 
453
- // Step from the top down to the chosen item, one step per STROBE_STEP_MS.
454
- // Landing position is wherever finalIdx lands — could be the first item
455
- // (no stepping), could be the fourth.
411
+ // Step from the top down to the chosen item.
456
412
  for (let k = 0; k <= finalIdx; k++) {
457
413
  if (!autoAlive || openIdx !== i) break;
458
414
  strobeIdx = k;
@@ -460,7 +416,7 @@
460
416
  }
461
417
  if (!autoAlive || openIdx !== i) continue;
462
418
 
463
- // Blink twice on the selection (off/on x2).
419
+ // Blink twice on the selection.
464
420
  for (let blink = 0; blink < 2; blink++) {
465
421
  if (!autoAlive || openIdx !== i) break;
466
422
  strobeIdx = null;
@@ -490,7 +446,6 @@
490
446
  style:--ftt-bob-duration="{duration}s"
491
447
  style:--ftt-bob-distance="{-distance}px"
492
448
  >
493
- <!-- Kite strings -->
494
449
  <svg class="ftt-strings" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
495
450
  {#each tags as tag, i (i)}
496
451
  {@const a = anchorPoint(tag.anchor)}
@@ -504,17 +459,14 @@
504
459
  {/each}
505
460
  </svg>
506
461
 
507
- <!-- Central component driven by the active token of each tag. Sized
508
- intrinsically: width/height grow to fit content + padding. boxSize
509
- is a baseline (min-width/min-height) so a short label can't shrink
510
- the box past a sensible footprint. -->
462
+ <!-- Box is intrinsically sized to content + padding; boxSize is a floor. -->
511
463
  <div
512
464
  bind:this={boxEl}
513
465
  class="ftt-box"
514
466
  class:ftt-bloop={bloopActive}
515
467
  style:min-width="{boxSize.w}%"
516
468
  style:min-height="{boxSize.h}%"
517
- style:background={surfaceVar ? `color-mix(in srgb, var(${surfaceVar}) 50%, transparent)` : undefined}
469
+ style:background={asVar(surfaceVar)}
518
470
  style:border-radius={asVar(radiusVar)}
519
471
  style:border-color={asVar(borderColorVar)}
520
472
  style:border-width={asVar(borderWidthVar)}
@@ -525,8 +477,6 @@
525
477
  >I'm a button</span>
526
478
  </div>
527
479
 
528
- <!-- Anchor knots on the box. Initial position uses anchorPoint() defaults;
529
- syncFrame imperatively updates each frame from the box's live rect. -->
530
480
  {#each tags as tag, i (i)}
531
481
  {@const a = anchorPoint(tag.anchor)}
532
482
  <span
@@ -538,12 +488,10 @@
538
488
  ></span>
539
489
  {/each}
540
490
 
541
- <!-- Energy balls — positioned each frame by syncFrame() while in flight. -->
542
491
  {#each tags as _t, i (i)}
543
492
  <span class="ftt-energy-ball" bind:this={ballEls[i]} aria-hidden="true"></span>
544
493
  {/each}
545
494
 
546
- <!-- Floating tags. -->
547
495
  {#each tags as tag, i (i)}
548
496
  <span
549
497
  bind:this={tagEls[i]}
@@ -551,6 +499,8 @@
551
499
  class:ftt-flash={flashingIdx === i}
552
500
  class:ftt-open={openIdx === i}
553
501
  class:ftt-dragging={draggingIdx === i}
502
+ data-placement={tag.placement ?? 'top'}
503
+ data-pivot={tag.pivot ?? 'center'}
554
504
  style:top="{tagTop(i)}%"
555
505
  style:left="{tagLeft(i)}%"
556
506
  style:animation-delay="{tag.delay ?? 0}s"
@@ -559,34 +509,35 @@
559
509
  {#if tag.controls}
560
510
  <span class="ftt-float-property">{controlLabels[tag.controls]}</span>
561
511
  {/if}
562
- <button
563
- type="button"
564
- class="ftt-tag-trigger"
565
- onpointerdown={(e) => onTagPointerDown(i, e)}
566
- onpointermove={(e) => onTagPointerMove(i, e)}
567
- onpointerup={(e) => onTagPointerUp(i, e)}
568
- aria-haspopup="listbox"
569
- aria-expanded={openIdx === i}
570
- >
571
- <span class="ftt-tag">
572
- {#if tag.icon}<span class="ftt-tag-icon"><i class={tag.icon}></i></span>{/if}
573
- <span class="ftt-tag-label">{currentValues[i]}</span>
574
- </span>
575
- </button>
576
-
577
- {#if openIdx === i && tag.controls}
578
- {@const opts = valueOptions[tag.controls]}
579
- {@const strobeValue = strobeIdx !== null ? opts[strobeIdx] : null}
580
- <div class="ftt-dropdown-wrap">
581
- <MenuSelect
582
- items={opts.map((opt) => ({ value: opt, label: opt }))}
583
- value={currentValues[i]}
584
- forceHoverValue={strobeValue}
585
- onchange={(v) => userPick(i, v)}
586
- />
587
- </div>
588
- {/if}
512
+ <span class="ftt-chip-host">
513
+ <button
514
+ type="button"
515
+ class="ftt-tag-trigger"
516
+ onpointerdown={(e) => onTagPointerDown(i, e)}
517
+ onpointermove={(e) => onTagPointerMove(i, e)}
518
+ onpointerup={(e) => onTagPointerUp(i, e)}
519
+ aria-haspopup="listbox"
520
+ aria-expanded={openIdx === i}
521
+ >
522
+ <span class="ftt-tag">
523
+ {#if tag.icon}<span class="ftt-tag-icon"><i class={tag.icon}></i></span>{/if}
524
+ <span class="ftt-tag-label">{currentValues[i]}</span>
525
+ </span>
526
+ </button>
527
+
528
+ {#if openIdx === i && tag.controls}
529
+ {@const opts = valueOptions[tag.controls]}
530
+ {@const strobeValue = strobeIdx !== null ? opts[strobeIdx] : null}
531
+ <div class="ftt-dropdown-wrap">
532
+ <MenuSelect
533
+ items={opts.map((opt) => ({ value: opt, label: opt }))}
534
+ value={currentValues[i]}
535
+ forceHoverValue={strobeValue}
536
+ onchange={(v) => userPick(i, v)}
537
+ />
538
+ </div>
539
+ {/if}
540
+ </span>
589
541
  </span>
590
542
  {/each}
591
543
  </div>
592
-
@@ -113,6 +113,7 @@
113
113
  :global(:root) {
114
114
  /* Info */
115
115
  --notification-info-surface: var(--surface-info);
116
+ --notification-info-action-surface: var(--surface-neutral-lowest);
116
117
  --notification-info-border: var(--border-info);
117
118
  --notification-info-border-width: var(--border-width-1);
118
119
  --notification-info-radius: var(--radius-md);
@@ -132,6 +133,7 @@
132
133
 
133
134
  /* Success */
134
135
  --notification-success-surface: var(--surface-success);
136
+ --notification-success-action-surface: var(--surface-neutral-lowest);
135
137
  --notification-success-border: var(--border-success);
136
138
  --notification-success-border-width: var(--border-width-1);
137
139
  --notification-success-radius: var(--radius-md);
@@ -151,6 +153,7 @@
151
153
 
152
154
  /* Warning */
153
155
  --notification-warning-surface: var(--surface-warning);
156
+ --notification-warning-action-surface: var(--surface-neutral-lowest);
154
157
  --notification-warning-border: var(--border-warning);
155
158
  --notification-warning-border-width: var(--border-width-1);
156
159
  --notification-warning-radius: var(--radius-md);
@@ -170,6 +173,7 @@
170
173
 
171
174
  /* Danger */
172
175
  --notification-danger-surface: var(--surface-danger);
176
+ --notification-danger-action-surface: var(--surface-neutral-lowest);
173
177
  --notification-danger-border: var(--border-danger);
174
178
  --notification-danger-border-width: var(--border-width-1);
175
179
  --notification-danger-radius: var(--radius-md);
@@ -240,6 +244,10 @@
240
244
  font-weight: var(--notification-#{$variant}-title-font-weight);
241
245
  line-height: var(--notification-#{$variant}-title-line-height);
242
246
  }
247
+
248
+ .action-button-backdrop {
249
+ background: var(--notification-#{$variant}-action-surface);
250
+ }
243
251
  }
244
252
  }
245
253
  }
@@ -270,7 +278,6 @@
270
278
 
271
279
  .action-button-backdrop {
272
280
  flex-shrink: 0;
273
- background: var(--surface-neutral-lowest);
274
281
  border-radius: var(--radius-md);
275
282
  }
276
283