@motion-proto/live-tokens 0.6.2 → 0.7.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 (212) hide show
  1. package/README.md +14 -13
  2. package/dist-plugin/index.cjs +147 -136
  3. package/dist-plugin/index.d.cts +1 -1
  4. package/dist-plugin/index.d.ts +1 -1
  5. package/dist-plugin/index.js +145 -135
  6. package/package.json +25 -40
  7. package/src/{component-editor → editor/component-editor}/BadgeEditor.svelte +8 -82
  8. package/src/{component-editor → editor/component-editor}/CalloutEditor.svelte +4 -4
  9. package/src/{component-editor → editor/component-editor}/CardEditor.svelte +28 -76
  10. package/src/{component-editor → editor/component-editor}/CollapsibleSectionEditor.svelte +3 -3
  11. package/src/{component-editor → editor/component-editor}/CornerBadgeEditor.svelte +31 -93
  12. package/src/{component-editor → editor/component-editor}/DialogEditor.svelte +60 -57
  13. package/src/editor/component-editor/ImageEditor.svelte +30 -0
  14. package/src/{component-editor → editor/component-editor}/InlineEditActionsEditor.svelte +6 -4
  15. package/src/editor/component-editor/MenuSelectEditor.svelte +160 -0
  16. package/src/{component-editor → editor/component-editor}/NotificationEditor.svelte +64 -37
  17. package/src/{component-editor → editor/component-editor}/ProgressBarEditor.svelte +5 -4
  18. package/src/{component-editor → editor/component-editor}/RadioButtonEditor.svelte +3 -3
  19. package/src/{component-editor → editor/component-editor}/SectionDividerEditor.svelte +57 -84
  20. package/src/{component-editor → editor/component-editor}/SegmentedControlEditor.svelte +2 -2
  21. package/src/{component-editor → editor/component-editor}/StandardButtonsEditor.svelte +16 -20
  22. package/src/{component-editor → editor/component-editor}/TabBarEditor.svelte +9 -14
  23. package/src/{component-editor → editor/component-editor}/TableEditor.svelte +9 -18
  24. package/src/{component-editor → editor/component-editor}/TooltipEditor.svelte +11 -47
  25. package/src/{component-editor → editor/component-editor}/registry.ts +28 -18
  26. package/src/{component-editor → editor/component-editor}/scaffolding/AngleDial.svelte +2 -2
  27. package/src/{component-editor → editor/component-editor}/scaffolding/ComponentEditorBase.svelte +3 -51
  28. package/src/{component-editor → editor/component-editor}/scaffolding/ComponentFileManager.svelte +144 -416
  29. package/src/{component-editor → editor/component-editor}/scaffolding/ComponentFileMenu.svelte +18 -170
  30. package/src/{component-editor → editor/component-editor}/scaffolding/ComponentsTab.svelte +2 -2
  31. package/src/{component-editor → editor/component-editor}/scaffolding/CopyFromMenu.svelte +44 -4
  32. package/src/{component-editor → editor/component-editor}/scaffolding/DividerEditor.svelte +1 -1
  33. package/src/{component-editor → editor/component-editor}/scaffolding/FieldsetWrapper.svelte +1 -1
  34. package/src/{component-editor → editor/component-editor}/scaffolding/GradientCard.svelte +6 -6
  35. package/src/{component-editor → editor/component-editor}/scaffolding/LinkageChart.svelte +6 -6
  36. package/src/{component-editor → editor/component-editor}/scaffolding/LinkedBlock.svelte +6 -11
  37. package/src/editor/component-editor/scaffolding/NonStylableConfig.svelte +38 -0
  38. package/src/{component-editor → editor/component-editor}/scaffolding/SaveAsDialog.svelte +66 -12
  39. package/src/editor/component-editor/scaffolding/ShadowBackdrop.svelte +72 -0
  40. package/src/editor/component-editor/scaffolding/ShadowBackdropControls.svelte +132 -0
  41. package/src/editor/component-editor/scaffolding/StateBlock.svelte +257 -0
  42. package/src/{component-editor → editor/component-editor}/scaffolding/TokenLayout.svelte +9 -7
  43. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +644 -0
  44. package/src/{component-editor → editor/component-editor}/scaffolding/editorContext.ts +19 -9
  45. package/src/{component-editor → editor/component-editor}/scaffolding/linkedBlock.ts +2 -2
  46. package/src/{component-editor → editor/component-editor}/scaffolding/types.ts +14 -0
  47. package/src/{lib → editor/core/components}/componentConfigService.ts +2 -2
  48. package/src/{lib → editor/core/components}/componentPersist.ts +5 -5
  49. package/src/editor/core/flashStatus.ts +30 -0
  50. package/src/{lib → editor/core/fonts}/fontLoader.ts +2 -2
  51. package/src/{lib → editor/core/fonts}/fontMigration.ts +4 -4
  52. package/src/{lib → editor/core/fonts}/fontParse.ts +1 -1
  53. package/src/editor/core/manifests/manifestService.ts +116 -0
  54. package/src/{lib → editor/core/palettes}/paletteDerivation.ts +2 -2
  55. package/src/{lib → editor/core/palettes}/tokenRegistry.ts +5 -5
  56. package/src/editor/core/productionPulse.ts +37 -0
  57. package/src/{lib → editor/core/routing}/router.ts +1 -1
  58. package/src/{lib/files/versionedFileResource.ts → editor/core/storage/files/versionedFileResourceClient.ts} +8 -1
  59. package/src/{lib → editor/core/store}/editorCore.ts +24 -8
  60. package/src/{lib → editor/core/store}/editorPersistence.ts +3 -3
  61. package/src/{lib → editor/core/store}/editorRenderer.ts +2 -2
  62. package/src/{lib → editor/core/store}/editorStore.ts +17 -17
  63. package/src/{lib → editor/core/store}/editorTypes.ts +1 -1
  64. package/src/{lib → editor/core/themes}/slices/columns.ts +2 -2
  65. package/src/{lib → editor/core/themes}/slices/components.ts +2 -2
  66. package/src/{lib → editor/core/themes}/slices/fonts.ts +1 -1
  67. package/src/{lib → editor/core/themes}/slices/gradients.ts +2 -2
  68. package/src/{lib → editor/core/themes}/slices/overlays.ts +1 -1
  69. package/src/{lib → editor/core/themes}/slices/palettes.ts +1 -1
  70. package/src/{lib → editor/core/themes}/slices/shadows.ts +3 -3
  71. package/src/{lib → editor/core/themes}/themeInit.ts +6 -6
  72. package/src/{lib → editor/core/themes}/themeService.ts +6 -6
  73. package/src/{lib → editor/core/themes}/themeTypes.ts +11 -7
  74. package/src/editor/index.ts +69 -0
  75. package/src/{lib → editor/overlay}/LiveEditorOverlay.svelte +79 -125
  76. package/src/{lib → editor/overlay}/columnsOverlay.ts +2 -2
  77. package/src/{pages → editor/pages}/ComponentEditorPage.svelte +12 -12
  78. package/src/{pages → editor/pages}/Editor.svelte +4 -4
  79. package/src/{pages → editor/pages}/EditorShell.svelte +18 -36
  80. package/src/{styles → editor/styles}/ui-editor.css +41 -21
  81. package/src/{styles → editor/styles}/ui-form-controls.css +8 -8
  82. package/src/{ui → editor/ui}/BezierCurveEditor.svelte +8 -8
  83. package/src/{ui → editor/ui}/ColorEditPanel.svelte +13 -13
  84. package/src/{ui → editor/ui}/EditorViewSwitcher.svelte +8 -6
  85. package/src/editor/ui/FileLoadList.svelte +350 -0
  86. package/src/editor/ui/FilePill.svelte +80 -0
  87. package/src/{ui → editor/ui}/FontStackEditor.svelte +7 -7
  88. package/src/{ui → editor/ui}/GradientEditor.svelte +11 -11
  89. package/src/{ui → editor/ui}/GradientStopPicker.svelte +1 -1
  90. package/src/editor/ui/ManifestFileManager.svelte +371 -0
  91. package/src/{ui → editor/ui}/PaletteEditor.svelte +132 -598
  92. package/src/{ui → editor/ui}/ProjectFontsSection.svelte +102 -144
  93. package/src/{ui → editor/ui}/SurfacesTab.svelte +3 -3
  94. package/src/{ui → editor/ui}/TextTab.svelte +3 -3
  95. package/src/{ui → editor/ui}/ThemeFileManager.svelte +286 -519
  96. package/src/{ui → editor/ui}/UICopyPopover.svelte +4 -4
  97. package/src/{ui → editor/ui}/UIFontFamilySelector.svelte +6 -6
  98. package/src/{ui → editor/ui}/UIFontSizeSelector.svelte +1 -1
  99. package/src/editor/ui/UIInfoPopover.svelte +244 -0
  100. package/src/{ui → editor/ui}/UILineHeightSelector.svelte +5 -5
  101. package/src/{ui → editor/ui}/UILinkToggle.svelte +2 -2
  102. package/src/{ui → editor/ui}/UIPaddingSelector.svelte +6 -6
  103. package/src/{ui → editor/ui}/UIPaletteSelector.svelte +26 -26
  104. package/src/editor/ui/UIPillButton.svelte +138 -0
  105. package/src/{ui → editor/ui}/UIRadio.svelte +2 -2
  106. package/src/{ui → editor/ui}/UIRelinkConfirmPopover.svelte +4 -4
  107. package/src/editor/ui/UISquareButton.svelte +172 -0
  108. package/src/{ui → editor/ui}/UITokenSelector.svelte +10 -10
  109. package/src/{ui → editor/ui}/UIVariantSelector.svelte +1 -1
  110. package/src/{ui → editor/ui}/VariablesTab.svelte +31 -8
  111. package/src/{ui → editor/ui}/palette/GradientStopEditor.svelte +13 -13
  112. package/src/{ui → editor/ui}/palette/OverridesPanel.svelte +13 -13
  113. package/src/{ui → editor/ui}/palette/PaletteBase.svelte +8 -5
  114. package/src/{ui → editor/ui}/palette/paletteEditorState.ts +1 -1
  115. package/src/editor/ui/palette/paletteMath.ts +275 -0
  116. package/src/{ui → editor/ui}/sections/ColumnsSection.svelte +137 -17
  117. package/src/{ui → editor/ui}/sections/GradientsSection.svelte +7 -7
  118. package/src/{ui → editor/ui}/sections/OverlaysSection.svelte +17 -17
  119. package/src/{ui → editor/ui}/sections/ShadowsSection.svelte +22 -22
  120. package/src/{ui → editor/ui}/sections/TokenScaleTable.svelte +3 -3
  121. package/src/{components → system/components}/Badge.svelte +0 -36
  122. package/src/{components → system/components}/Card.svelte +8 -62
  123. package/src/{components → system/components}/CornerBadge.svelte +8 -24
  124. package/src/{components → system/components}/Dialog.svelte +1 -1
  125. package/src/system/components/FloatingTokenTags.css +256 -0
  126. package/src/system/components/FloatingTokenTags.svelte +592 -0
  127. package/src/{components → system/components}/InlineEditActions.svelte +6 -4
  128. package/src/system/components/MenuSelect.svelte +229 -0
  129. package/src/{components → system/components}/ProgressBar.svelte +29 -11
  130. package/src/{components → system/components}/SegmentedControl.svelte +49 -43
  131. package/src/{components → system/components}/TabBar.svelte +81 -65
  132. package/src/{components → system/components}/Table.svelte +17 -3
  133. package/src/{components → system/components}/Tooltip.svelte +6 -4
  134. package/src/system/styles/CONVENTIONS.md +178 -0
  135. package/src/{styles → system/styles}/fonts.css +6 -3
  136. package/src/{styles → system/styles}/tokens.css +149 -29
  137. package/src/component-editor/ImageEditor.svelte +0 -74
  138. package/src/component-editor/scaffolding/NonStylableConfig.svelte +0 -62
  139. package/src/component-editor/scaffolding/ShadowBackdrop.svelte +0 -37
  140. package/src/component-editor/scaffolding/ShadowBackdropControls.svelte +0 -61
  141. package/src/component-editor/scaffolding/StateBlock.svelte +0 -132
  142. package/src/component-editor/scaffolding/VariantGroup.svelte +0 -310
  143. package/src/data/google-fonts.json +0 -75
  144. package/src/lib/index.ts +0 -68
  145. package/src/lib/presetService.ts +0 -214
  146. package/src/lib/productionPulse.ts +0 -32
  147. package/src/ui/PresetFileManager.svelte +0 -1116
  148. package/src/ui/UnsavedComponentsDialog.svelte +0 -315
  149. /package/src/{styles → app}/site.css +0 -0
  150. /package/src/{component-editor → editor/component-editor}/index.ts +0 -0
  151. /package/src/{component-editor → editor/component-editor}/scaffolding/DemoHeader.svelte +0 -0
  152. /package/src/{component-editor → editor/component-editor}/scaffolding/TypeEditor.svelte +0 -0
  153. /package/src/{component-editor → editor/component-editor}/scaffolding/buildTypeGroupTokens.ts +0 -0
  154. /package/src/{component-editor → editor/component-editor}/scaffolding/componentSectionType.ts +0 -0
  155. /package/src/{component-editor → editor/component-editor}/scaffolding/componentSources.ts +0 -0
  156. /package/src/{component-editor → editor/component-editor}/scaffolding/defaultSections.ts +0 -0
  157. /package/src/{component-editor → editor/component-editor}/scaffolding/siblings.ts +0 -0
  158. /package/src/{lib → editor/core/components}/componentConfigKeys.ts +0 -0
  159. /package/src/{lib → editor/core}/cssVarSync.ts +0 -0
  160. /package/src/{lib → editor/core/palettes}/oklch.ts +0 -0
  161. /package/src/{lib → editor/core/routing}/navLinkTypes.ts +0 -0
  162. /package/src/{lib → editor/core/routing}/parentRouteStore.ts +0 -0
  163. /package/src/{lib → editor/core/storage}/storage.ts +0 -0
  164. /package/src/{lib → editor/core/store}/editorConfig.ts +0 -0
  165. /package/src/{lib → editor/core/store}/editorConfigStore.ts +0 -0
  166. /package/src/{lib → editor/core/store}/editorKeybindings.ts +0 -0
  167. /package/src/{lib → editor/core/store}/editorViewStore.ts +0 -0
  168. /package/src/{lib → editor/core/themes}/migrations/2026-04-24-component-prefix-and-suffix-renames.ts +0 -0
  169. /package/src/{lib → editor/core/themes}/migrations/2026-04-24-legacy-keys-and-bg-to-canvas.ts +0 -0
  170. /package/src/{lib → editor/core/themes}/migrations/2026-04-27-segmentedcontrol-disabled-flatten.ts +0 -0
  171. /package/src/{lib → editor/core/themes}/migrations/2026-05-08-collapsiblesection-frame-and-cleanup.ts +0 -0
  172. /package/src/{lib → editor/core/themes}/migrations/2026-05-08-collapsiblesection-variant-namespace.ts +0 -0
  173. /package/src/{lib → editor/core/themes}/migrations/2026-05-10-sectiondivider-gradient-stops.ts +0 -0
  174. /package/src/{lib → editor/core/themes}/migrations/2026-05-13-primary-to-brand.ts +0 -0
  175. /package/src/{lib → editor/core/themes}/migrations/index.ts +0 -0
  176. /package/src/{lib → editor/core/themes}/parsers/globalRootBlock.ts +0 -0
  177. /package/src/{lib → editor/core/themes}/slices/domainVars.ts +0 -0
  178. /package/src/{lib → editor/overlay}/ColumnsOverlay.svelte +0 -0
  179. /package/src/{lib → editor/overlay}/overlayState.ts +0 -0
  180. /package/src/{pages → editor/pages}/ComponentEditorPage.svelte.d.ts +0 -0
  181. /package/src/{pages → editor/pages}/Editor.svelte.d.ts +0 -0
  182. /package/src/{ui → editor/ui}/Toggle.svelte +0 -0
  183. /package/src/{ui → editor/ui}/UIDialog.svelte +0 -0
  184. /package/src/{ui → editor/ui}/UIFontWeightSelector.svelte +0 -0
  185. /package/src/{ui → editor/ui}/UIOptionItem.svelte +0 -0
  186. /package/src/{ui → editor/ui}/UIOptionList.svelte +0 -0
  187. /package/src/{ui → editor/ui}/UIRadioGroup.svelte +0 -0
  188. /package/src/{lib → editor/ui}/copyPopover.ts +0 -0
  189. /package/src/{ui → editor/ui}/curveEngine.ts +0 -0
  190. /package/src/{ui → editor/ui}/index.ts +0 -0
  191. /package/src/{ui → editor/ui}/keepInViewport.ts +0 -0
  192. /package/src/{ui → editor/ui}/palette/ScaleCurveEditor.svelte +0 -0
  193. /package/src/{lib → editor/ui}/scrollSection.ts +0 -0
  194. /package/src/{ui → editor/ui}/sections/tokenScales.ts +0 -0
  195. /package/src/{ui → editor/ui}/variantScales.ts +0 -0
  196. /package/src/{assets → system/assets}/newspaper.webp +0 -0
  197. /package/src/{assets → system/assets}/offering.webp +0 -0
  198. /package/src/{components → system/components}/Button.svelte +0 -0
  199. /package/src/{components → system/components}/Callout.svelte +0 -0
  200. /package/src/{components → system/components}/CollapsibleSection.svelte +0 -0
  201. /package/src/{components → system/components}/Image.svelte +0 -0
  202. /package/src/{components → system/components}/Notification.svelte +0 -0
  203. /package/src/{components → system/components}/RadioButton.svelte +0 -0
  204. /package/src/{components → system/components}/SectionDivider.svelte +0 -0
  205. /package/src/{components → system/components}/types.ts +0 -0
  206. /package/src/{styles → system/styles}/_padding.scss +0 -0
  207. /package/src/{styles → system/styles}/fonts/Fraunces/Fraunces-italic-latin-ext.woff2 +0 -0
  208. /package/src/{styles → system/styles}/fonts/Fraunces/Fraunces-italic-latin.woff2 +0 -0
  209. /package/src/{styles → system/styles}/fonts/Fraunces/Fraunces-roman-latin-ext.woff2 +0 -0
  210. /package/src/{styles → system/styles}/fonts/Fraunces/Fraunces-roman-latin.woff2 +0 -0
  211. /package/src/{styles → system/styles}/fonts/Manrope/Manrope-latin-ext.woff2 +0 -0
  212. /package/src/{styles → system/styles}/fonts/Manrope/Manrope-latin.woff2 +0 -0
@@ -0,0 +1,592 @@
1
+ <script module lang="ts">
2
+ export type AnchorSide = 'top' | 'right' | 'bottom' | 'left';
3
+
4
+ /** Which visual property of the central box this tag drives. */
5
+ export type TagControl = 'surface' | 'radius' | 'border-color' | 'border-width' | 'font-family';
6
+
7
+ /**
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).
13
+ */
14
+ export type Anchor =
15
+ | { side: AnchorSide; pos: number }
16
+ | { side: 'inside'; x: number; y: number };
17
+
18
+ export interface FloatingTag {
19
+ icon?: string;
20
+ /** Typically a CSS-variable name. Used as the initial value. */
21
+ label: string;
22
+ /** Tag center, in % of the stage. */
23
+ top: number;
24
+ left: number;
25
+ /** Bob delay, seconds. Negative values offset the start. */
26
+ delay?: number;
27
+ /** Static tilt of the tag, degrees. */
28
+ rotate?: number;
29
+ /** Where this tag's string ties to the central box. */
30
+ anchor: Anchor;
31
+ /** Property of the central box this tag drives (live preview of the token). */
32
+ controls?: TagControl;
33
+ }
34
+ </script>
35
+
36
+ <script lang="ts">
37
+ import MenuSelect from './MenuSelect.svelte';
38
+ 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.
46
+ import './FloatingTokenTags.css';
47
+
48
+ interface Props {
49
+ tags?: FloatingTag[];
50
+ duration?: number;
51
+ distance?: number;
52
+ boxSize?: { w: number; h: number };
53
+ /** Auto-cycle through values when idle. */
54
+ autoplay?: boolean;
55
+ }
56
+
57
+ const controlLabels: Record<TagControl, string> = {
58
+ 'surface': 'Surface color',
59
+ 'radius': 'Corner radius',
60
+ 'border-color': 'Border color',
61
+ 'border-width': 'Border width',
62
+ 'font-family': 'Font family',
63
+ };
64
+
65
+ // Four candidate token values per control. Picked to span the visible
66
+ // gamut so cycling produces noticeably different box states.
67
+ 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'],
73
+ };
74
+
75
+ 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
+ {
84
+ 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 },
88
+ controls: 'surface',
89
+ },
90
+ {
91
+ icon: 'fas fa-font',
92
+ label: '--font-display',
93
+ top: 11.1, left: 40.8, delay: -0.9, rotate: 2,
94
+ anchor: { side: 'inside', x: 45, y: 28 },
95
+ controls: 'font-family',
96
+ },
97
+ {
98
+ icon: 'fa-solid fa-bezier-curve',
99
+ label: '--radius-2xl',
100
+ top: 19.8, left: 72.0, delay: -1.8, rotate: 2,
101
+ anchor: { side: 'top', pos: 100 },
102
+ controls: 'radius',
103
+ },
104
+ {
105
+ 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 },
109
+ controls: 'border-color',
110
+ },
111
+ {
112
+ icon: 'fas fa-grip-lines',
113
+ label: '--border-width-3',
114
+ top: 78, left: 33, delay: -5.2, rotate: -3,
115
+ anchor: { side: 'bottom', pos: 50 },
116
+ controls: 'border-width',
117
+ },
118
+ ];
119
+
120
+ let {
121
+ tags = defaultTags,
122
+ duration = 7,
123
+ distance = 8,
124
+ boxSize = { w: 14, h: 11 },
125
+ autoplay = true,
126
+ }: Props = $props();
127
+
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).
135
+ const defaultLabels = $derived(tags.map(t => t.label));
136
+ let overrides = $state<Record<number, string>>({});
137
+ let boxOverrides = $state<Record<number, string>>({});
138
+ const currentValues = $derived(defaultLabels.map((d, i) => overrides[i] ?? d));
139
+ const boxValues = $derived(defaultLabels.map((d, i) => boxOverrides[i] ?? d));
140
+
141
+ let openIdx = $state<number | null>(null);
142
+ let strobeIdx = $state<number | null>(null);
143
+ let flashingIdx = $state<number | null>(null);
144
+ let bloopActive = $state(false);
145
+
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
+ let dragOverrides = $state<Record<number, { top: number; left: number }>>({});
149
+ let draggingIdx = $state<number | null>(null);
150
+ const DRAG_THRESHOLD_PX = 4;
151
+ const dragStart = { px: 0, py: 0, tagIdx: -1, moved: false };
152
+
153
+ function tagTop(i: number): number { return dragOverrides[i]?.top ?? tags[i].top; }
154
+ function tagLeft(i: number): number { return dragOverrides[i]?.left ?? tags[i].left; }
155
+
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
+ type BallState = { startedAt: number; duration: number; pendingValue: string };
161
+ 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.
165
+ const ballEls: HTMLSpanElement[] = $state([]);
166
+
167
+ // --- Element refs --------------------------------------------------------
168
+ let stageEl: HTMLDivElement | undefined = $state();
169
+ let boxEl: HTMLDivElement | undefined = $state();
170
+ const tagEls: HTMLSpanElement[] = $state([]);
171
+ const lineEls: SVGLineElement[] = $state([]);
172
+ const knotEls: HTMLSpanElement[] = $state([]);
173
+
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
+ function anchorPoint(
179
+ anchor: Anchor,
180
+ cx = 50,
181
+ cy = 50,
182
+ w = boxSize.w,
183
+ h = boxSize.h,
184
+ ): { x: number; y: number } {
185
+ const halfW = w / 2, halfH = h / 2;
186
+ if (anchor.side === 'inside') {
187
+ return {
188
+ x: cx - halfW + (anchor.x / 100) * w,
189
+ y: cy - halfH + (anchor.y / 100) * h,
190
+ };
191
+ }
192
+ const t = anchor.pos / 100;
193
+ switch (anchor.side) {
194
+ case 'top': return { x: cx - halfW + t * w, y: cy - halfH };
195
+ case 'bottom': return { x: cx - halfW + t * w, y: cy + halfH };
196
+ case 'left': return { x: cx - halfW, y: cy - halfH + t * h };
197
+ case 'right': return { x: cx + halfW, y: cy - halfH + t * h };
198
+ }
199
+ }
200
+
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.
204
+ function resolveControl(name: TagControl): string | undefined {
205
+ const idx = tags.findIndex(t => t.controls === name);
206
+ return idx >= 0 ? boxValues[idx] : undefined;
207
+ }
208
+ const surfaceVar = $derived(resolveControl('surface'));
209
+ const radiusVar = $derived(resolveControl('radius'));
210
+ const borderColorVar = $derived(resolveControl('border-color'));
211
+ const borderWidthVar = $derived(resolveControl('border-width'));
212
+ const fontFamilyVar = $derived(resolveControl('font-family'));
213
+
214
+ function asVar(name: string | undefined): string | undefined {
215
+ return name ? `var(${name})` : undefined;
216
+ }
217
+
218
+ // --- Animation loop: kite strings + energy balls ------------------------
219
+ function syncFrame() {
220
+ if (!stageEl) return;
221
+ const stageRect = stageEl.getBoundingClientRect();
222
+ if (stageRect.width === 0 || stageRect.height === 0) return;
223
+
224
+ // Box geometry in stage-% space — measured from the live rect so that
225
+ // intrinsic sizing (content + padding) drives anchor placement.
226
+ let boxCx = 50, boxCy = 50;
227
+ let boxW = boxSize.w, boxH = boxSize.h;
228
+ if (boxEl) {
229
+ const br = boxEl.getBoundingClientRect();
230
+ if (br.width > 0 && br.height > 0) {
231
+ boxCx = ((br.left + br.width / 2) - stageRect.left) / stageRect.width * 100;
232
+ boxCy = ((br.top + br.height / 2) - stageRect.top) / stageRect.height * 100;
233
+ boxW = (br.width / stageRect.width) * 100;
234
+ boxH = (br.height / stageRect.height) * 100;
235
+ }
236
+ }
237
+
238
+ const now = performance.now();
239
+
240
+ for (let i = 0; i < tags.length; i++) {
241
+ const tagEl = tagEls[i];
242
+ const lineEl = lineEls[i];
243
+ if (!tagEl || !lineEl) continue;
244
+
245
+ // Tag-side endpoint: pill cap (corner-radius circle) center on the
246
+ // side closest to the central component.
247
+ const pill = tagEl.querySelector('.ftt-tag') as HTMLElement | null;
248
+ const target = (pill ?? tagEl) as HTMLElement;
249
+ const r = target.getBoundingClientRect();
250
+
251
+ let radius = 0;
252
+ if (pill) {
253
+ const cs = getComputedStyle(pill);
254
+ const raw = parseFloat(cs.borderTopLeftRadius || '0') || 0;
255
+ radius = Math.min(raw, r.height / 2);
256
+ }
257
+
258
+ const onRight = tags[i].left > 50;
259
+ const px = onRight ? r.left + radius : r.right - radius;
260
+ const py = r.top + r.height / 2;
261
+
262
+ const x1 = (px - stageRect.left) / stageRect.width * 100;
263
+ const y1 = (py - stageRect.top) / stageRect.height * 100;
264
+
265
+ lineEl.setAttribute('x1', x1.toFixed(3));
266
+ lineEl.setAttribute('y1', y1.toFixed(3));
267
+
268
+ // Box-side endpoint + knot — recomputed from live box geometry.
269
+ const a = anchorPoint(tags[i].anchor, boxCx, boxCy, boxW, boxH);
270
+ lineEl.setAttribute('x2', a.x.toFixed(3));
271
+ lineEl.setAttribute('y2', a.y.toFixed(3));
272
+ const knotEl = knotEls[i];
273
+ if (knotEl) {
274
+ knotEl.style.left = `${a.x.toFixed(3)}%`;
275
+ knotEl.style.top = `${a.y.toFixed(3)}%`;
276
+ }
277
+
278
+ // Energy ball traveling tag → box along the same line.
279
+ const ballEl = ballEls[i];
280
+ const state = ballStates.get(i);
281
+ if (!ballEl) continue;
282
+ if (!state) {
283
+ ballEl.style.opacity = '0';
284
+ continue;
285
+ }
286
+ const elapsed = now - state.startedAt;
287
+ const t = Math.min(1, elapsed / state.duration);
288
+ 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.
292
+ boxOverrides = { ...boxOverrides, [i]: state.pendingValue };
293
+ ballStates.delete(i);
294
+ ballEl.style.opacity = '0';
295
+ triggerBloop();
296
+ continue;
297
+ }
298
+ const x2 = parseFloat(lineEl.getAttribute('x2') || '0');
299
+ const y2 = parseFloat(lineEl.getAttribute('y2') || '0');
300
+ const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
301
+ const bx = x1 + (x2 - x1) * eased;
302
+ const by = y1 + (y2 - y1) * eased;
303
+ ballEl.style.left = `${bx.toFixed(3)}%`;
304
+ ballEl.style.top = `${by.toFixed(3)}%`;
305
+ ballEl.style.opacity = '1';
306
+ }
307
+ }
308
+
309
+ $effect(() => {
310
+ let rafId = 0;
311
+ const loop = () => {
312
+ syncFrame();
313
+ rafId = requestAnimationFrame(loop);
314
+ };
315
+ rafId = requestAnimationFrame(loop);
316
+ return () => cancelAnimationFrame(rafId);
317
+ });
318
+
319
+ // --- Selection / fire sequence ------------------------------------------
320
+ function pickValue(i: number, value: string) {
321
+ openIdx = null;
322
+ 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
+ overrides = { ...overrides, [i]: value };
327
+ flashingIdx = i;
328
+ window.setTimeout(() => {
329
+ if (flashingIdx === i) flashingIdx = null;
330
+ }, 500);
331
+ ballStates.set(i, { startedAt: performance.now(), duration: 520, pendingValue: value });
332
+ }
333
+
334
+ function triggerBloop() {
335
+ bloopActive = true;
336
+ window.setTimeout(() => { bloopActive = false; }, 480);
337
+ }
338
+
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.
341
+ const USER_HOLD_MS = 4000;
342
+ let lastUserActionAt = 0;
343
+ function noteUserAction() {
344
+ lastUserActionAt = performance.now();
345
+ }
346
+
347
+ function clickTag(i: number) {
348
+ if (!tags[i].controls) return;
349
+ noteUserAction();
350
+ openIdx = openIdx === i ? null : i;
351
+ }
352
+
353
+ 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.
356
+ if (currentValues[i] === value) return;
357
+ noteUserAction();
358
+ pickValue(i, value);
359
+ }
360
+
361
+ // --- Drag handlers ------------------------------------------------------
362
+ function onTagPointerDown(i: number, e: PointerEvent) {
363
+ dragStart.px = e.clientX;
364
+ dragStart.py = e.clientY;
365
+ dragStart.tagIdx = i;
366
+ dragStart.moved = false;
367
+ (e.currentTarget as Element).setPointerCapture(e.pointerId);
368
+ }
369
+
370
+ function onTagPointerMove(i: number, e: PointerEvent) {
371
+ if (dragStart.tagIdx !== i || !stageEl) return;
372
+ const dx = e.clientX - dragStart.px;
373
+ const dy = e.clientY - dragStart.py;
374
+ if (!dragStart.moved && Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) {
375
+ dragStart.moved = true;
376
+ draggingIdx = i;
377
+ openIdx = null;
378
+ noteUserAction(); // pause auto-cycle while dragging
379
+ }
380
+ if (dragStart.moved) {
381
+ const r = stageEl.getBoundingClientRect();
382
+ const x = ((e.clientX - r.left) / r.width) * 100;
383
+ const y = ((e.clientY - r.top) / r.height) * 100;
384
+ dragOverrides = { ...dragOverrides, [i]: { top: y, left: x } };
385
+ }
386
+ }
387
+
388
+ function onTagPointerUp(i: number, e: PointerEvent) {
389
+ if (dragStart.tagIdx !== i) return;
390
+ (e.currentTarget as Element).releasePointerCapture(e.pointerId);
391
+ if (dragStart.moved) {
392
+ const p = dragOverrides[i];
393
+ // eslint-disable-next-line no-console
394
+ console.log(
395
+ `[${i}] ${tags[i].controls ?? '?'}: top: ${p.top.toFixed(1)}, left: ${p.left.toFixed(1)},`,
396
+ );
397
+ } else {
398
+ clickTag(i);
399
+ }
400
+ draggingIdx = null;
401
+ dragStart.tagIdx = -1;
402
+ dragStart.moved = false;
403
+ }
404
+
405
+ // --- Auto-cycle ----------------------------------------------------------
406
+ let autoAlive = false;
407
+ let lastAutoTagIdx: number | null = null;
408
+ const sleep = (ms: number) => new Promise(r => window.setTimeout(r, ms));
409
+
410
+ const STROBE_STEP_MS = 125;
411
+ const BREATH_MS = 250;
412
+
413
+ async function autoLoop() {
414
+ while (autoAlive) {
415
+ await sleep(2400 + Math.random() * 2400);
416
+ if (!autoAlive) break;
417
+
418
+ // Hold off if the user clicked anything recently. Re-check after each
419
+ // partial sleep so a fresh click resets the wait.
420
+ while (autoAlive) {
421
+ const sinceUser = performance.now() - lastUserActionAt;
422
+ if (sinceUser >= USER_HOLD_MS) break;
423
+ await sleep(USER_HOLD_MS - sinceUser);
424
+ }
425
+ if (!autoAlive) break;
426
+
427
+ if (openIdx !== null) continue; // user is interacting
428
+
429
+ // Never the same tag twice in a row.
430
+ const tagCandidates = tags
431
+ .map((_, idx) => idx)
432
+ .filter(idx => idx !== lastAutoTagIdx && tags[idx].controls);
433
+ if (tagCandidates.length === 0) continue;
434
+ const i = tagCandidates[Math.floor(Math.random() * tagCandidates.length)];
435
+ const tag = tags[i];
436
+ const opts = valueOptions[tag.controls!];
437
+
438
+ // Never the same token twice in a row: exclude the currently-active
439
+ // value from the candidate set.
440
+ const currentIdx = opts.indexOf(currentValues[i]);
441
+ const candidates = opts
442
+ .map((_, k) => k)
443
+ .filter(k => k !== currentIdx);
444
+ if (candidates.length === 0) continue;
445
+ const finalIdx = candidates[Math.floor(Math.random() * candidates.length)];
446
+
447
+ openIdx = i;
448
+
449
+ // Breath: open menu, let it sit before any highlight appears.
450
+ await sleep(BREATH_MS);
451
+ if (!autoAlive || openIdx !== i) continue;
452
+
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.
456
+ for (let k = 0; k <= finalIdx; k++) {
457
+ if (!autoAlive || openIdx !== i) break;
458
+ strobeIdx = k;
459
+ await sleep(STROBE_STEP_MS);
460
+ }
461
+ if (!autoAlive || openIdx !== i) continue;
462
+
463
+ // Blink twice on the selection (off/on x2).
464
+ for (let blink = 0; blink < 2; blink++) {
465
+ if (!autoAlive || openIdx !== i) break;
466
+ strobeIdx = null;
467
+ await sleep(STROBE_STEP_MS);
468
+ if (!autoAlive || openIdx !== i) break;
469
+ strobeIdx = finalIdx;
470
+ await sleep(STROBE_STEP_MS);
471
+ }
472
+ if (!autoAlive || openIdx !== i) continue;
473
+
474
+ pickValue(i, opts[finalIdx]);
475
+ lastAutoTagIdx = i;
476
+ }
477
+ }
478
+
479
+ $effect(() => {
480
+ if (!autoplay) return;
481
+ autoAlive = true;
482
+ autoLoop();
483
+ return () => { autoAlive = false; };
484
+ });
485
+ </script>
486
+
487
+ <div
488
+ class="ftt-stage"
489
+ bind:this={stageEl}
490
+ style:--ftt-bob-duration="{duration}s"
491
+ style:--ftt-bob-distance="{-distance}px"
492
+ >
493
+ <!-- Kite strings -->
494
+ <svg class="ftt-strings" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
495
+ {#each tags as tag, i (i)}
496
+ {@const a = anchorPoint(tag.anchor)}
497
+ <line
498
+ bind:this={lineEls[i]}
499
+ x1={tag.left} y1={tag.top}
500
+ x2={a.x} y2={a.y}
501
+ class="ftt-string"
502
+ class:ftt-live={ballStates.has(i)}
503
+ />
504
+ {/each}
505
+ </svg>
506
+
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. -->
511
+ <div
512
+ bind:this={boxEl}
513
+ class="ftt-box"
514
+ class:ftt-bloop={bloopActive}
515
+ style:min-width="{boxSize.w}%"
516
+ style:min-height="{boxSize.h}%"
517
+ style:background={surfaceVar ? `color-mix(in srgb, var(${surfaceVar}) 50%, transparent)` : undefined}
518
+ style:border-radius={asVar(radiusVar)}
519
+ style:border-color={asVar(borderColorVar)}
520
+ style:border-width={asVar(borderWidthVar)}
521
+ >
522
+ <span
523
+ class="ftt-box-label"
524
+ style:font-family={asVar(fontFamilyVar)}
525
+ >I'm a button</span>
526
+ </div>
527
+
528
+ <!-- Anchor knots on the box. Initial position uses anchorPoint() defaults;
529
+ syncFrame imperatively updates each frame from the box's live rect. -->
530
+ {#each tags as tag, i (i)}
531
+ {@const a = anchorPoint(tag.anchor)}
532
+ <span
533
+ bind:this={knotEls[i]}
534
+ class="ftt-knot"
535
+ style:left="{a.x}%"
536
+ style:top="{a.y}%"
537
+ aria-hidden="true"
538
+ ></span>
539
+ {/each}
540
+
541
+ <!-- Energy balls — positioned each frame by syncFrame() while in flight. -->
542
+ {#each tags as _t, i (i)}
543
+ <span class="ftt-energy-ball" bind:this={ballEls[i]} aria-hidden="true"></span>
544
+ {/each}
545
+
546
+ <!-- Floating tags. -->
547
+ {#each tags as tag, i (i)}
548
+ <span
549
+ bind:this={tagEls[i]}
550
+ class="ftt-float"
551
+ class:ftt-flash={flashingIdx === i}
552
+ class:ftt-open={openIdx === i}
553
+ class:ftt-dragging={draggingIdx === i}
554
+ style:top="{tagTop(i)}%"
555
+ style:left="{tagLeft(i)}%"
556
+ style:animation-delay="{tag.delay ?? 0}s"
557
+ style:--ftt-rot="{tag.rotate ?? 0}deg"
558
+ >
559
+ {#if tag.controls}
560
+ <span class="ftt-float-property">{controlLabels[tag.controls]}</span>
561
+ {/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}
589
+ </span>
590
+ {/each}
591
+ </div>
592
+
@@ -47,6 +47,8 @@
47
47
  </div>
48
48
 
49
49
  <style lang="scss">
50
+ @use '../styles/padding' as *;
51
+
50
52
  :global(:root) {
51
53
  /* Save (default) */
52
54
  --inlineeditactions-save-default-surface: var(--surface-success-low);
@@ -110,7 +112,7 @@
110
112
  color: var(--inlineeditactions-save-default-text);
111
113
  border: var(--inlineeditactions-save-default-border-width) solid var(--inlineeditactions-save-default-border);
112
114
  border-radius: var(--inlineeditactions-save-default-radius);
113
- padding: var(--inlineeditactions-save-default-padding) calc(var(--inlineeditactions-save-default-padding) * 2);
115
+ @include themed-padding(--inlineeditactions-save-default-padding, $h: 2);
114
116
  font-size: var(--inlineeditactions-save-default-icon-size);
115
117
 
116
118
  &:hover:not(:disabled),
@@ -119,7 +121,7 @@
119
121
  color: var(--inlineeditactions-save-hover-text);
120
122
  border: var(--inlineeditactions-save-hover-border-width) solid var(--inlineeditactions-save-hover-border);
121
123
  border-radius: var(--inlineeditactions-save-hover-radius);
122
- padding: var(--inlineeditactions-save-hover-padding) calc(var(--inlineeditactions-save-hover-padding) * 2);
124
+ @include themed-padding(--inlineeditactions-save-hover-padding, $h: 2);
123
125
  font-size: var(--inlineeditactions-save-hover-icon-size);
124
126
  }
125
127
  }
@@ -129,7 +131,7 @@
129
131
  color: var(--inlineeditactions-cancel-default-text);
130
132
  border: var(--inlineeditactions-cancel-default-border-width) solid var(--inlineeditactions-cancel-default-border);
131
133
  border-radius: var(--inlineeditactions-cancel-default-radius);
132
- padding: var(--inlineeditactions-cancel-default-padding) calc(var(--inlineeditactions-cancel-default-padding) * 2);
134
+ @include themed-padding(--inlineeditactions-cancel-default-padding, $h: 2);
133
135
  font-size: var(--inlineeditactions-cancel-default-icon-size);
134
136
 
135
137
  &:hover:not(:disabled),
@@ -138,7 +140,7 @@
138
140
  color: var(--inlineeditactions-cancel-hover-text);
139
141
  border: var(--inlineeditactions-cancel-hover-border-width) solid var(--inlineeditactions-cancel-hover-border);
140
142
  border-radius: var(--inlineeditactions-cancel-hover-radius);
141
- padding: var(--inlineeditactions-cancel-hover-padding) calc(var(--inlineeditactions-cancel-hover-padding) * 2);
143
+ @include themed-padding(--inlineeditactions-cancel-hover-padding, $h: 2);
142
144
  font-size: var(--inlineeditactions-cancel-hover-icon-size);
143
145
  }
144
146
  }