@motion-proto/live-tokens 0.94.2 → 0.95.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 (108) hide show
  1. package/.claude/skills/live-tokens-create-page/SKILL.md +7 -3
  2. package/CHANGELOG.md +108 -0
  3. package/README.md +6 -6
  4. package/dist-plugin/{chunk-7XSUHJUH.js → chunk-4J6JPSVS.js} +53 -2
  5. package/dist-plugin/{chunk-W5VQV32K.js → chunk-GYRULB2X.js} +1 -1
  6. package/dist-plugin/{chunk-ONYAIXCZ.js → chunk-QQASYPW7.js} +32 -9
  7. package/dist-plugin/index.cjs +598 -17
  8. package/dist-plugin/index.d.cts +9 -1
  9. package/dist-plugin/index.d.ts +9 -1
  10. package/dist-plugin/index.js +513 -6
  11. package/dist-plugin/migrateData/index.cjs +32 -9
  12. package/dist-plugin/migrateData/index.js +2 -2
  13. package/dist-plugin/setColors/index.cjs +32 -22
  14. package/dist-plugin/setColors/index.js +2 -15
  15. package/dist-plugin/setGeometry/index.cjs +32 -9
  16. package/dist-plugin/setGeometry/index.js +1 -1
  17. package/dist-plugin/tokensCssMigrations/index.cjs +53 -2
  18. package/dist-plugin/tokensCssMigrations/index.d.cts +9 -2
  19. package/dist-plugin/tokensCssMigrations/index.d.ts +9 -2
  20. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  21. package/package.json +8 -3
  22. package/src/demo/ChromeSelect.svelte +111 -0
  23. package/src/demo/Demo.svelte +39 -5
  24. package/src/demo/DemoBar.svelte +33 -14
  25. package/src/demo/FloatingTokenTags.css +33 -4
  26. package/src/demo/FloatingTokenTags.svelte +81 -68
  27. package/src/demo/Section.svelte +1 -7
  28. package/src/demo/SketchSelect.svelte +36 -12
  29. package/src/demo/SourceLinks.svelte +5 -4
  30. package/src/demo/TestingLoops.svelte +128 -19
  31. package/src/demo/ThemeSelect.svelte +34 -11
  32. package/src/demo/evalResults.ts +7 -7
  33. package/src/demo/sections/SectionClaude.svelte +20 -13
  34. package/src/demo/sections/SectionFooter.svelte +4 -5
  35. package/src/demo/sections/SectionGetStarted.svelte +5 -6
  36. package/src/demo/sections/SectionHero.svelte +43 -23
  37. package/src/demo/sections/SectionKit.svelte +141 -45
  38. package/src/demo/sections/SectionLayers.svelte +3 -3
  39. package/src/demo/sections/SectionOffering.svelte +8 -23
  40. package/src/editor/bootstrap.ts +12 -6
  41. package/src/editor/component-editor/ImageEditor.svelte +1 -1
  42. package/src/editor/component-editor/ImageLightboxEditor.svelte +3 -4
  43. package/src/editor/component-editor/registration.ts +36 -0
  44. package/src/editor/component-editor/registry.ts +5 -12
  45. package/src/editor/component-editor/scaffolding/ShadowBackdrop.svelte +11 -2
  46. package/src/editor/component-editor/scaffolding/StateBlock.svelte +7 -0
  47. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +1 -2
  48. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +17 -2
  49. package/src/editor/core/store/editorPersistence.ts +6 -1
  50. package/src/editor/core/store/editorRenderer.ts +1 -4
  51. package/src/editor/core/store/editorStore.ts +5 -9
  52. package/src/editor/core/store/editorTypes.ts +2 -2
  53. package/src/editor/core/themes/migrations/2026-10-15-responsive-columns.ts +40 -0
  54. package/src/editor/core/themes/migrations/index.ts +2 -0
  55. package/src/editor/core/themes/slices/columns.ts +112 -42
  56. package/src/editor/docs/Docs.svelte +25 -22
  57. package/src/editor/docs/chapters.ts +1 -0
  58. package/src/editor/docs/content/editing-tokens.md +5 -3
  59. package/src/editor/docs/content/production-css.md +116 -0
  60. package/src/editor/docs/content/themes-workflow.md +2 -0
  61. package/src/editor/docs/content.generated.ts +3 -2
  62. package/src/editor/index.ts +2 -1
  63. package/src/editor/overlay/LiveEditorOverlay.svelte +67 -19
  64. package/src/editor/overlay/LiveTokensRouter.svelte +8 -4
  65. package/src/editor/pages/Editor.svelte +15 -1
  66. package/src/editor/pages/EditorShell.svelte +312 -99
  67. package/src/editor/skill-atlas/SkillAtlas.svelte +111 -24
  68. package/src/editor/skill-atlas/TreeCanvas.svelte +14 -0
  69. package/src/editor/skill-atlas/skillSources.generated.ts +1 -1
  70. package/src/editor/skill-atlas/trees/create-page.ts +24 -24
  71. package/src/editor/skill-atlas/wireLayout.ts +62 -0
  72. package/src/editor/ui/BezierCurveEditor.svelte +3 -0
  73. package/src/editor/ui/FontStackEditor.svelte +2 -2
  74. package/src/editor/ui/PaletteEditor.svelte +64 -6
  75. package/src/editor/ui/UIPaletteSelector.svelte +2 -0
  76. package/src/editor/ui/colors/ColorsTab.svelte +5 -2
  77. package/src/editor/ui/palette/OverridesPanel.svelte +1 -0
  78. package/src/editor/ui/palette/ScaleCurveEditor.svelte +127 -18
  79. package/src/editor/ui/sections/ColumnsSection.svelte +79 -49
  80. package/src/editor/ui/sections/ShadowsSection.svelte +14 -0
  81. package/src/editor/ui/sections/WashesSection.svelte +11 -0
  82. package/src/editor/ui/sketch/SketchTab.svelte +1 -1
  83. package/src/live-tokens/data/colors-and-type/autumn.json +1 -4
  84. package/src/live-tokens/data/colors-and-type/default.json +1 -4
  85. package/src/live-tokens/data/colors-and-type/halloween.json +1 -4
  86. package/src/live-tokens/data/colors-and-type/midnight-study.json +1 -4
  87. package/src/live-tokens/data/colors-and-type/ocean.json +1 -4
  88. package/src/live-tokens/data/colors-and-type/royal-velvet.json +1 -4
  89. package/src/live-tokens/data/colors-and-type/sketchy.json +1 -4
  90. package/src/live-tokens/data/colors-and-type/spring-meadow.json +1 -4
  91. package/src/live-tokens/data/colors-and-type/sunset.json +1 -4
  92. package/src/live-tokens/data/themes/autumn.json +1 -4
  93. package/src/live-tokens/data/themes/halloween.json +1 -4
  94. package/src/live-tokens/data/themes/midnight-study.json +1 -4
  95. package/src/live-tokens/data/themes/ocean.json +1 -4
  96. package/src/live-tokens/data/themes/royal-velvet.json +1 -4
  97. package/src/live-tokens/data/themes/sketchy.json +1 -4
  98. package/src/live-tokens/data/themes/spring-meadow.json +1 -4
  99. package/src/live-tokens/data/themes/sunset.json +1 -4
  100. package/src/live-tokens/data/tokens.generated.css +1 -4
  101. package/src/system/components/SectionDivider.svelte +4 -0
  102. package/src/system/styles/CONVENTIONS.md +1 -1
  103. package/src/system/styles/tokens.css +30 -4
  104. package/template/src/main.ts +2 -2
  105. package/template/src/pages/Home.svelte +11 -8
  106. package/template/vite.config.ts +7 -3
  107. package/src/system/assets/offering.webp +0 -0
  108. /package/dist-plugin/{chunk-O6GQ6GBH.js → chunk-A4TBP4MM.js} +0 -0
@@ -0,0 +1,111 @@
1
+ <script lang="ts">
2
+ import UIMenuButton from '../editor/ui/UIMenuButton.svelte';
3
+ import UIOptionList from '../editor/ui/UIOptionList.svelte';
4
+ import UIOptionItem from '../editor/ui/UIOptionItem.svelte';
5
+
6
+ interface Props {
7
+ label: string;
8
+ items: { value: string; label: string }[];
9
+ value: string;
10
+ /** Trigger text when `value` matches no item. Defaults to the label. */
11
+ placeholder?: string;
12
+ disabled?: boolean;
13
+ error?: string;
14
+ onchange: (value: string) => void;
15
+ }
16
+
17
+ let {
18
+ label: heading,
19
+ items,
20
+ value,
21
+ placeholder = heading,
22
+ disabled = false,
23
+ error = '',
24
+ onchange,
25
+ }: Props = $props();
26
+
27
+ const selectedLabel = $derived(items.find((item) => item.value === value)?.label ?? placeholder);
28
+
29
+ function select(next: string, close: () => void): void {
30
+ close();
31
+ if (next === value) return;
32
+ onchange(next);
33
+ }
34
+ </script>
35
+
36
+ <!-- LabeledSelect's twin in the editor's --ui-* chrome. It needs an
37
+ .editor-page ancestor, which scopes those tokens and hosts the menu. -->
38
+ <div class="chrome-select" inert={disabled || items.length === 0}>
39
+ <span class="chrome-select-label">{heading}</span>
40
+
41
+ <UIMenuButton header={heading} triggerClass="chrome-select-trigger" menuMinWidth="12rem">
42
+ {#snippet trigger()}
43
+ <span>{selectedLabel}</span>
44
+ <i class="fas fa-chevron-down" aria-hidden="true"></i>
45
+ {/snippet}
46
+ {#snippet children({ close })}
47
+ <UIOptionList>
48
+ {#each items as item (item.value)}
49
+ <UIOptionItem active={item.value === value} onclick={() => select(item.value, close)}>
50
+ {#snippet label()}{item.label}{/snippet}
51
+ </UIOptionItem>
52
+ {/each}
53
+ </UIOptionList>
54
+ {/snippet}
55
+ </UIMenuButton>
56
+
57
+ {#if error}
58
+ <span class="error" aria-live="polite">{error}</span>
59
+ {/if}
60
+ </div>
61
+
62
+ <style>
63
+ .chrome-select {
64
+ display: flex;
65
+ flex-wrap: wrap;
66
+ align-items: center;
67
+ gap: var(--ui-space-8);
68
+ }
69
+
70
+ .chrome-select[inert] {
71
+ opacity: var(--ui-opacity-disabled);
72
+ }
73
+
74
+ .chrome-select-label {
75
+ font-size: var(--ui-font-size-xs);
76
+ font-weight: var(--ui-font-weight-semibold);
77
+ color: var(--ui-text-secondary);
78
+ }
79
+
80
+ .chrome-select :global(.chrome-select-trigger) {
81
+ min-width: 9rem;
82
+ justify-content: space-between;
83
+ gap: var(--ui-space-12);
84
+ padding: var(--ui-space-6) var(--ui-space-10);
85
+ font-size: var(--ui-font-size-xs);
86
+ color: var(--ui-text-primary);
87
+ background: var(--ui-surface-high);
88
+ border: 1px solid var(--ui-border);
89
+ border-radius: var(--ui-radius-md);
90
+ transition:
91
+ background var(--ui-transition-fast),
92
+ border-color var(--ui-transition-fast);
93
+ }
94
+
95
+ .chrome-select :global(.chrome-select-trigger:hover),
96
+ .chrome-select :global(.chrome-select-trigger[aria-expanded='true']) {
97
+ background: var(--ui-surface-highest);
98
+ border-color: var(--ui-border-higher);
99
+ }
100
+
101
+ .chrome-select :global(.chrome-select-trigger .fa-chevron-down) {
102
+ font-size: 0.625rem;
103
+ color: var(--ui-text-secondary);
104
+ }
105
+
106
+ .error {
107
+ max-width: 20rem;
108
+ font-size: var(--ui-font-size-xs);
109
+ color: var(--ui-link-broken);
110
+ }
111
+ </style>
@@ -36,14 +36,24 @@
36
36
  </div>
37
37
 
38
38
  <style>
39
+ /* Sections place themselves on these spans, and the full-width ones nest a
40
+ subgrid, so the breakpoints below are the only place a section's columns
41
+ change. minmax(0, 1fr) keeps a wide child from widening its track. */
39
42
  .kit {
43
+ --kit-content: 2 / -2;
44
+ --kit-narrow: 3 / -3;
45
+ --kit-section-lead: var(--space-32);
46
+ --kit-quote-pause: var(--space-32);
47
+ --kit-footer-lead: var(--space-32);
40
48
  display: grid;
41
- grid-template-columns: repeat(var(--columns-count), 1fr);
49
+ grid-template-columns: repeat(var(--columns-count), minmax(0, 1fr));
42
50
  column-gap: var(--columns-gutter);
43
51
  row-gap: var(--space-64);
52
+ box-sizing: content-box;
44
53
  max-width: var(--columns-max-width);
45
54
  margin: 0 auto;
46
- padding: var(--space-48) var(--space-32);
55
+ padding-block: var(--space-48);
56
+ padding-inline: var(--columns-margin);
47
57
  min-height: 100vh;
48
58
  }
49
59
 
@@ -57,11 +67,21 @@
57
67
  margin-bottom: 0;
58
68
  }
59
69
 
60
- /* Equal lead-in above these three sections: row-gap (64) + 48 = 112px. */
70
+ /* Every titled section opens with the same lead-in: row-gap, this margin,
71
+ and the SectionDivider's own top margin. The quote pauses between the
72
+ layers and the panel below it, and the footer sets off from the page. */
61
73
  .kit > :global(.kit-section),
62
74
  .kit > :global(.layers),
63
75
  .kit > :global(.claude) {
64
- margin-top: var(--space-32);
76
+ margin-top: var(--kit-section-lead);
77
+ }
78
+
79
+ .kit > :global(.offering) {
80
+ margin-block: var(--kit-quote-pause);
81
+ }
82
+
83
+ .kit > :global(.provenance) {
84
+ margin-top: var(--kit-footer-lead);
65
85
  }
66
86
 
67
87
  /* Inline code styling cascades into every section via :global descendants. */
@@ -75,10 +95,24 @@
75
95
  color: var(--text-primary);
76
96
  }
77
97
 
98
+ @media (max-width: 1200px) {
99
+ .kit {
100
+ --kit-content: 1 / -1;
101
+ --kit-narrow: 2 / -2;
102
+ }
103
+ }
104
+
78
105
  @media (max-width: 600px) {
106
+ .kit {
107
+ --kit-narrow: 1 / -1;
108
+ }
109
+
79
110
  .kit,
80
111
  .kit.has-bar {
81
- padding: var(--space-32) var(--space-16);
112
+ --kit-section-lead: var(--space-8);
113
+ --kit-quote-pause: var(--space-16);
114
+ --kit-footer-lead: var(--space-16);
115
+ padding-block: var(--space-32);
82
116
  row-gap: var(--space-48);
83
117
  }
84
118
  }
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import '../editor/styles/ui-editor.css';
2
3
  import ThemeSelect from './ThemeSelect.svelte';
3
4
  import SketchSelect from './SketchSelect.svelte';
4
5
 
@@ -11,11 +12,14 @@
11
12
  } = $props();
12
13
  </script>
13
14
 
14
- <div class="demo-bar">
15
+ <!-- Editor chrome, like the overlay: the bar paints from --ui-*, so every
16
+ theme leaves it legible, and data-live-tokens-chrome keeps sketch mode off
17
+ it. .editor-page scopes the --ui-* tokens and hosts the selects' menus. -->
18
+ <div class="demo-bar editor-page" data-live-tokens-chrome>
15
19
  <div class="bar">
16
20
  <div class="pickers">
17
- <ThemeSelect inline size="small" onpick={onThemePick} />
18
- <SketchSelect inline size="small" onpick={onSketchPick} />
21
+ <ThemeSelect chrome onpick={onThemePick} />
22
+ <SketchSelect chrome onpick={onSketchPick} />
19
23
  </div>
20
24
  </div>
21
25
  </div>
@@ -27,20 +31,22 @@
27
31
  left: 0;
28
32
  right: 0;
29
33
  z-index: var(--z-sticky);
30
- background: var(--scrim-high);
31
- backdrop-filter: blur(var(--blur-md));
32
- border-bottom: var(--border-width-1) solid var(--tint-high);
34
+ --sketch-icon-off: none;
35
+ background: var(--ui-surface-lower);
36
+ border-bottom: 1px solid var(--ui-border-low);
33
37
  }
34
38
 
35
39
  /* The bar repeats the demo's .kit grid, so its content lines up with the
36
40
  sections beneath it. The right side stays empty for the editor's collapsed
37
41
  toggle, which pins top-right. */
38
42
  .bar {
43
+ box-sizing: content-box;
39
44
  max-width: var(--columns-max-width);
40
45
  margin-inline: auto;
41
- padding: var(--space-12) var(--space-32);
46
+ padding-block: var(--space-12);
47
+ padding-inline: var(--columns-margin);
42
48
  display: grid;
43
- grid-template-columns: repeat(var(--columns-count), 1fr);
49
+ grid-template-columns: repeat(var(--columns-count), minmax(0, 1fr));
44
50
  column-gap: var(--columns-gutter);
45
51
  }
46
52
 
@@ -53,23 +59,36 @@
53
59
  row-gap: var(--space-8);
54
60
  }
55
61
 
56
- @media (max-width: 960px) {
62
+ @media (max-width: 1200px) {
57
63
  .pickers {
58
64
  grid-column: 1 / -1;
59
65
  }
60
66
  }
61
67
 
62
68
  /* No room beside the editor's toggle on a phone, so the pickers sit in the
63
- page below it and scroll away. */
69
+ page below it and scroll away, clearing the toggle only when it rests in
70
+ the top band. */
64
71
  @media (max-width: 600px) {
65
72
  .demo-bar {
66
73
  position: static;
67
- background: none;
68
- backdrop-filter: none;
69
- border-bottom: none;
70
74
  }
71
75
  .bar {
72
- padding: var(--space-64) var(--space-16) 0;
76
+ padding-block: max(var(--space-16), calc(var(--lt-pill-clearance, 0px) + var(--space-8))) var(--space-16);
77
+ }
78
+ /* Side by side, each label over a trigger that fills its half. */
79
+ .pickers {
80
+ display: grid;
81
+ grid-template-columns: repeat(2, minmax(0, 1fr));
82
+ gap: var(--space-12);
83
+ }
84
+ .pickers :global(.chrome-select) {
85
+ flex-direction: column;
86
+ align-items: stretch;
87
+ gap: var(--ui-space-4);
88
+ }
89
+ .pickers :global(.chrome-select-trigger) {
90
+ width: 100%;
91
+ min-width: 0;
73
92
  }
74
93
  }
75
94
  </style>
@@ -79,8 +79,10 @@
79
79
 
80
80
  .ftt-string {
81
81
  stroke: var(--ftt-contrast-color);
82
+ fill: none;
82
83
  stroke-width: 2;
83
84
  stroke-linecap: round;
85
+ stroke-linejoin: round;
84
86
  vector-effect: non-scaling-stroke;
85
87
  opacity: var(--ftt-string-opacity);
86
88
  transition: opacity 150ms ease, stroke 150ms ease;
@@ -124,8 +126,9 @@
124
126
 
125
127
  /* --- Floating tag wrapper ------------------------------------------------- */
126
128
  .ftt-float {
129
+ --ftt-shift-x: -50%;
127
130
  position: absolute;
128
- translate: -50% -50%;
131
+ translate: var(--ftt-shift-x) -50%;
129
132
  rotate: var(--ftt-rot, 0deg);
130
133
  animation: ftt-bob var(--ftt-bob-duration, 7s) ease-in-out infinite;
131
134
  z-index: 4;
@@ -134,7 +137,7 @@
134
137
  flex-direction: column;
135
138
  align-items: center;
136
139
  gap: 0.25rem;
137
- width: 14rem;
140
+ width: max-content;
138
141
  }
139
142
 
140
143
  /* Lift open tag so its dropdown paints over sibling tags. */
@@ -159,8 +162,8 @@
159
162
  }
160
163
 
161
164
  @keyframes ftt-bob {
162
- 0%, 100% { translate: -50% -50%; }
163
- 50% { translate: -50% calc(-50% + var(--ftt-bob-distance, -8px)); }
165
+ 0%, 100% { translate: var(--ftt-shift-x) -50%; }
166
+ 50% { translate: var(--ftt-shift-x) calc(-50% + var(--ftt-bob-distance, -8px)); }
164
167
  }
165
168
 
166
169
  /* --- Property label paired with each tag ---------------------------------- */
@@ -272,6 +275,32 @@
272
275
  z-index: 10;
273
276
  }
274
277
 
278
+ /* --- Stacked layout ------------------------------------------------------ */
279
+ /* Narrow stage: tags hang in a column left of the box and the strings elbow
280
+ into its top and bottom edges. `left` positions each tag's left edge. */
281
+ .ftt-stacked .ftt-box {
282
+ left: auto;
283
+ right: 0;
284
+ translate: 0 -50%;
285
+ width: 10rem;
286
+ padding: 0.75rem 1rem;
287
+ }
288
+
289
+ .ftt-stacked .ftt-float {
290
+ --ftt-shift-x: 0%;
291
+ rotate: 0deg;
292
+ align-items: flex-start;
293
+ }
294
+
295
+ .ftt-stacked .ftt-float-property {
296
+ padding-left: 0.75rem;
297
+ }
298
+
299
+ .ftt-stacked .ftt-dropdown-wrap {
300
+ left: 0;
301
+ translate: none;
302
+ }
303
+
275
304
  /* --- Reduced motion ------------------------------------------------------- */
276
305
  @media (prefers-reduced-motion: reduce) {
277
306
  .ftt-float {
@@ -18,6 +18,10 @@
18
18
  /** Tag center, in % of the stage. */
19
19
  top: number;
20
20
  left: number;
21
+ /** Stacked layout for a stage narrower than `NARROW_STAGE_PX`: tags form a
22
+ * column left of the box, `left` is the tag's left edge, and the string
23
+ * elbows into `anchor`. Nest anchors so higher tags land further right. */
24
+ narrow?: { top: number; left: number; anchor: Anchor };
21
25
  /** Bob delay, seconds. Negative values offset the start. */
22
26
  delay?: number;
23
27
  /** Static tilt, degrees. */
@@ -34,6 +38,7 @@
34
38
 
35
39
  <script lang="ts">
36
40
  import MenuSelect from '../system/components/MenuSelect.svelte';
41
+ import { untrack } from 'svelte';
37
42
  import { SvelteMap } from 'svelte/reactivity';
38
43
  import { contrastTokenForBackground } from '../system/backdrop';
39
44
  // `.ftt-tag` is hand-rolled (not Badge) so editing badge-* tokens doesn't
@@ -72,6 +77,7 @@
72
77
  icon: 'fas fa-fill-drip',
73
78
  label: '--surface-brand-high',
74
79
  top: 15, left: 20, delay: 0, rotate: 3,
80
+ narrow: { top: 6, left: 0, anchor: { side: 'top', pos: 90 } },
75
81
  anchor: { side: 'inside', x: 10, y: 40 },
76
82
  controls: 'surface',
77
83
  placement: 'top',
@@ -81,6 +87,7 @@
81
87
  icon: 'fas fa-font',
82
88
  label: '--font-display',
83
89
  top: 5, left: 45, delay: -0.9, rotate: 2,
90
+ narrow: { top: 20, left: 5, anchor: { side: 'top', pos: 64 } },
84
91
  anchor: { side: 'inside', x: 78, y: 28 },
85
92
  controls: 'font-family',
86
93
  placement: 'top',
@@ -89,6 +96,7 @@
89
96
  icon: 'fa-solid fa-bezier-curve',
90
97
  label: '--radius-2xl',
91
98
  top: 23, left: 75, delay: -1.8, rotate: 2,
99
+ narrow: { top: 34, left: 10, anchor: { side: 'top', pos: 38 } },
92
100
  anchor: { side: 'top', pos: 100 },
93
101
  controls: 'radius',
94
102
  placement: 'top',
@@ -97,6 +105,7 @@
97
105
  icon: 'fas fa-paint-roller',
98
106
  label: '--border-brand-strong',
99
107
  top: 79, left: 72, delay: -3.6, rotate: -2,
108
+ narrow: { top: 82, left: 8, anchor: { side: 'bottom', pos: 84 } },
100
109
  anchor: { side: 'bottom', pos: 75 },
101
110
  controls: 'border-color',
102
111
  placement: 'top',
@@ -105,6 +114,7 @@
105
114
  icon: 'fas fa-grip-lines',
106
115
  label: '--border-width-3',
107
116
  top: 79, left: 28, delay: -5.2, rotate: -4,
117
+ narrow: { top: 66, left: 3, anchor: { side: 'bottom', pos: 44 } },
108
118
  anchor: { side: 'bottom', pos: 25 },
109
119
  controls: 'border-width',
110
120
  placement: 'top',
@@ -138,8 +148,17 @@
138
148
  const DRAG_THRESHOLD_PX = 4;
139
149
  const dragStart = { px: 0, py: 0, tagIdx: -1, moved: false };
140
150
 
141
- function tagTop(i: number): number { return dragOverrides[i]?.top ?? tags[i].top; }
142
- function tagLeft(i: number): number { return dragOverrides[i]?.left ?? tags[i].left; }
151
+ const NARROW_STAGE_PX = 480;
152
+ let stageWidth = $state(0);
153
+ const stacked = $derived(
154
+ stageWidth > 0 && stageWidth < NARROW_STAGE_PX && tags.every(t => t.narrow),
155
+ );
156
+
157
+ function home(i: number): { top: number; left: number; anchor: Anchor } {
158
+ return (stacked && tags[i].narrow) || tags[i];
159
+ }
160
+ function tagTop(i: number): number { return dragOverrides[i]?.top ?? home(i).top; }
161
+ function tagLeft(i: number): number { return dragOverrides[i]?.left ?? home(i).left; }
143
162
 
144
163
  type BallState = { startedAt: number; duration: number; pendingValue: string };
145
164
  const ballStates = new SvelteMap<number, BallState>();
@@ -147,34 +166,38 @@
147
166
  const ballEls: HTMLSpanElement[] = $state([]);
148
167
 
149
168
  let stageEl: HTMLDivElement | undefined = $state();
169
+ let svgEl: SVGSVGElement | undefined = $state();
150
170
  let boxEl: HTMLDivElement | undefined = $state();
151
171
  const tagEls: HTMLSpanElement[] = $state([]);
152
- const lineEls: SVGLineElement[] = $state([]);
172
+ const pathEls: SVGPathElement[] = $state([]);
153
173
  const knotEls: HTMLSpanElement[] = $state([]);
154
174
 
155
- function anchorPoint(
156
- anchor: Anchor,
157
- cx = 50,
158
- cy = 50,
159
- w = boxSize.w,
160
- h = boxSize.h,
161
- ): { x: number; y: number } {
162
- const halfW = w / 2, halfH = h / 2;
175
+ type Rect = { x: number; y: number; w: number; h: number };
176
+
177
+ function anchorPoint(anchor: Anchor, box: Rect): { x: number; y: number } {
163
178
  if (anchor.side === 'inside') {
164
- return {
165
- x: cx - halfW + (anchor.x / 100) * w,
166
- y: cy - halfH + (anchor.y / 100) * h,
167
- };
179
+ return { x: box.x + (anchor.x / 100) * box.w, y: box.y + (anchor.y / 100) * box.h };
168
180
  }
169
181
  const t = anchor.pos / 100;
170
182
  switch (anchor.side) {
171
- case 'top': return { x: cx - halfW + t * w, y: cy - halfH };
172
- case 'bottom': return { x: cx - halfW + t * w, y: cy + halfH };
173
- case 'left': return { x: cx - halfW, y: cy - halfH + t * h };
174
- case 'right': return { x: cx + halfW, y: cy - halfH + t * h };
183
+ case 'top': return { x: box.x + t * box.w, y: box.y };
184
+ case 'bottom': return { x: box.x + t * box.w, y: box.y + box.h };
185
+ case 'left': return { x: box.x, y: box.y + t * box.h };
186
+ case 'right': return { x: box.x + box.w, y: box.y + t * box.h };
175
187
  }
176
188
  }
177
189
 
190
+ const ELBOW_RADIUS_PX = 14;
191
+ const px = (n: number) => n.toFixed(2);
192
+
193
+ // Runs level out of the tag, then turns through a rounded corner into the box.
194
+ function elbowPath(x1: number, y1: number, x2: number, y2: number): string {
195
+ const sx = Math.sign(x2 - x1), sy = Math.sign(y2 - y1);
196
+ const r = Math.min(ELBOW_RADIUS_PX, Math.abs(x2 - x1), Math.abs(y2 - y1));
197
+ return `M${px(x1)} ${px(y1)} H${px(x2 - sx * r)} `
198
+ + `Q${px(x2)} ${px(y1)} ${px(x2)} ${px(y1 + sy * r)} V${px(y2)}`;
199
+ }
200
+
178
201
  // Reads `boxValues` (not `currentValues`) so the box repaints at impact.
179
202
  function resolveControl(name: TagControl): string | undefined {
180
203
  const idx = tags.findIndex(t => t.controls === name);
@@ -216,31 +239,35 @@
216
239
 
217
240
  // Kite strings and energy balls are recomputed each frame from the box's
218
241
  // measured rect so intrinsic sizing drives anchor placement.
242
+ let lastViewBox = '';
219
243
  function syncFrame() {
220
- if (!stageEl) return;
244
+ if (!stageEl || !svgEl || !boxEl) return;
221
245
  syncConnectorContrast();
222
246
  syncBoxLabelContrast();
223
247
  const stageRect = stageEl.getBoundingClientRect();
224
248
  if (stageRect.width === 0 || stageRect.height === 0) return;
225
249
 
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
- }
250
+ // A pixel viewBox keeps the elbow corners round on any stage aspect.
251
+ const viewBox = `0 0 ${px(stageRect.width)} ${px(stageRect.height)}`;
252
+ if (viewBox !== lastViewBox) {
253
+ svgEl.setAttribute('viewBox', viewBox);
254
+ lastViewBox = viewBox;
236
255
  }
237
256
 
257
+ const br = boxEl.getBoundingClientRect();
258
+ const box: Rect = {
259
+ x: br.left - stageRect.left,
260
+ y: br.top - stageRect.top,
261
+ w: br.width,
262
+ h: br.height,
263
+ };
264
+
238
265
  const now = performance.now();
239
266
 
240
267
  for (let i = 0; i < tags.length; i++) {
241
268
  const tagEl = tagEls[i];
242
- const lineEl = lineEls[i];
243
- if (!tagEl || !lineEl) continue;
269
+ const pathEl = pathEls[i];
270
+ if (!tagEl || !pathEl) continue;
244
271
 
245
272
  // Tag endpoint: center of the pill's rounded cap on the box-facing side.
246
273
  const pill = tagEl.querySelector('.ftt-tag') as HTMLElement | null;
@@ -254,23 +281,19 @@
254
281
  radius = Math.min(raw, r.height / 2);
255
282
  }
256
283
 
257
- const onRight = tags[i].left > 50;
258
- const px = onRight ? r.left + radius : r.right - radius;
259
- const py = r.top + r.height / 2;
260
-
261
- const x1 = (px - stageRect.left) / stageRect.width * 100;
262
- const y1 = (py - stageRect.top) / stageRect.height * 100;
263
-
264
- lineEl.setAttribute('x1', x1.toFixed(3));
265
- lineEl.setAttribute('y1', y1.toFixed(3));
284
+ const capOnRight = stacked || tagLeft(i) <= 50;
285
+ const x1 = (capOnRight ? r.right - radius : r.left + radius) - stageRect.left;
286
+ const y1 = r.top + r.height / 2 - stageRect.top;
266
287
 
267
- const a = anchorPoint(tags[i].anchor, boxCx, boxCy, boxW, boxH);
268
- lineEl.setAttribute('x2', a.x.toFixed(3));
269
- lineEl.setAttribute('y2', a.y.toFixed(3));
288
+ const a = anchorPoint(home(i).anchor, box);
289
+ pathEl.setAttribute(
290
+ 'd',
291
+ stacked ? elbowPath(x1, y1, a.x, a.y) : `M${px(x1)} ${px(y1)} L${px(a.x)} ${px(a.y)}`,
292
+ );
270
293
  const knotEl = knotEls[i];
271
294
  if (knotEl) {
272
- knotEl.style.left = `${a.x.toFixed(3)}%`;
273
- knotEl.style.top = `${a.y.toFixed(3)}%`;
295
+ knotEl.style.left = `${px(a.x)}px`;
296
+ knotEl.style.top = `${px(a.y)}px`;
274
297
  }
275
298
 
276
299
  const ballEl = ballEls[i];
@@ -290,13 +313,10 @@
290
313
  triggerBloop();
291
314
  continue;
292
315
  }
293
- const x2 = parseFloat(lineEl.getAttribute('x2') || '0');
294
- const y2 = parseFloat(lineEl.getAttribute('y2') || '0');
295
316
  const eased = 1 - Math.pow(1 - t, 3);
296
- const bx = x1 + (x2 - x1) * eased;
297
- const by = y1 + (y2 - y1) * eased;
298
- ballEl.style.left = `${bx.toFixed(3)}%`;
299
- ballEl.style.top = `${by.toFixed(3)}%`;
317
+ const p = pathEl.getPointAtLength(pathEl.getTotalLength() * eased);
318
+ ballEl.style.left = `${px(p.x)}px`;
319
+ ballEl.style.top = `${px(p.y)}px`;
300
320
  ballEl.style.opacity = '1';
301
321
  }
302
322
  }
@@ -307,6 +327,7 @@
307
327
  syncFrame();
308
328
  rafId = requestAnimationFrame(loop);
309
329
  };
330
+ untrack(syncFrame);
310
331
  rafId = requestAnimationFrame(loop);
311
332
  return () => cancelAnimationFrame(rafId);
312
333
  });
@@ -469,17 +490,16 @@
469
490
 
470
491
  <div
471
492
  class="ftt-stage"
493
+ class:ftt-stacked={stacked}
472
494
  bind:this={stageEl}
495
+ bind:clientWidth={stageWidth}
473
496
  style:--ftt-bob-duration="{duration}s"
474
497
  style:--ftt-bob-distance="{-distance}px"
475
498
  >
476
- <svg class="ftt-strings" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
477
- {#each tags as tag, i (i)}
478
- {@const a = anchorPoint(tag.anchor)}
479
- <line
480
- bind:this={lineEls[i]}
481
- x1={tag.left} y1={tag.top}
482
- x2={a.x} y2={a.y}
499
+ <svg class="ftt-strings" bind:this={svgEl} aria-hidden="true">
500
+ {#each tags as _t, i (i)}
501
+ <path
502
+ bind:this={pathEls[i]}
483
503
  class="ftt-string"
484
504
  class:ftt-live={ballStates.has(i)}
485
505
  />
@@ -504,15 +524,8 @@
504
524
  >I'm a button</span>
505
525
  </div>
506
526
 
507
- {#each tags as tag, i (i)}
508
- {@const a = anchorPoint(tag.anchor)}
509
- <span
510
- bind:this={knotEls[i]}
511
- class="ftt-knot"
512
- style:left="{a.x}%"
513
- style:top="{a.y}%"
514
- aria-hidden="true"
515
- ></span>
527
+ {#each tags as _t, i (i)}
528
+ <span bind:this={knotEls[i]} class="ftt-knot" aria-hidden="true"></span>
516
529
  {/each}
517
530
 
518
531
  {#each tags as _t, i (i)}
@@ -33,14 +33,8 @@
33
33
 
34
34
  <style>
35
35
  .demo-section {
36
- grid-column: 2 / span 10;
36
+ grid-column: var(--kit-content);
37
37
  display: flex;
38
38
  flex-direction: column;
39
39
  }
40
-
41
- @media (max-width: 960px) {
42
- .demo-section {
43
- grid-column: 1 / -1;
44
- }
45
- }
46
40
  </style>