@motion-proto/live-tokens 0.7.1 → 0.8.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 (85) hide show
  1. package/dist-plugin/index.cjs +707 -90
  2. package/dist-plugin/index.d.cts +1 -0
  3. package/dist-plugin/index.d.ts +1 -0
  4. package/dist-plugin/index.js +707 -90
  5. package/package.json +2 -1
  6. package/src/app/site.css +1 -1
  7. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +34 -27
  8. package/src/editor/component-editor/DialogEditor.svelte +4 -4
  9. package/src/editor/component-editor/NotificationEditor.svelte +3 -1
  10. package/src/editor/component-editor/SectionDividerEditor.svelte +439 -112
  11. package/src/editor/component-editor/StandardButtonsEditor.svelte +13 -1
  12. package/src/editor/component-editor/editors.d.ts +10 -0
  13. package/src/editor/component-editor/scaffolding/AngleDial.svelte +52 -13
  14. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +10 -11
  15. package/src/editor/component-editor/scaffolding/LinkedBlock.svelte +0 -1
  16. package/src/editor/component-editor/scaffolding/RadialShapePad.svelte +483 -0
  17. package/src/editor/component-editor/scaffolding/ShadowBackdrop.svelte +15 -2
  18. package/src/editor/component-editor/scaffolding/StateBlock.svelte +103 -15
  19. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +9 -6
  20. package/src/editor/component-editor/scaffolding/TypeEditor.svelte +13 -1
  21. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +239 -25
  22. package/src/editor/component-editor/scaffolding/buildTypeGroupTokens.ts +1 -0
  23. package/src/editor/component-editor/scaffolding/types.ts +11 -0
  24. package/src/editor/core/components/componentConfigKeys.ts +8 -0
  25. package/src/editor/core/components/componentConfigService.ts +2 -2
  26. package/src/editor/core/components/componentPersist.ts +7 -5
  27. package/src/editor/core/manifests/manifestService.ts +58 -3
  28. package/src/editor/core/palettes/familySwap.ts +99 -0
  29. package/src/editor/core/palettes/paletteDerivation.ts +69 -0
  30. package/src/editor/core/palettes/tokenRegistry.ts +4 -1
  31. package/src/editor/core/store/editorStore.ts +206 -12
  32. package/src/editor/core/store/editorTypes.ts +55 -12
  33. package/src/editor/core/store/gradientSource.ts +192 -0
  34. package/src/editor/core/themes/migrations/2026-05-19-collapsiblesection-drop-frame-surface.ts +28 -0
  35. package/src/editor/core/themes/migrations/2026-05-19-sectiondivider-rich-gradient.ts +35 -0
  36. package/src/editor/core/themes/migrations/2026-05-20-sectiondivider-slim-variants.ts +82 -0
  37. package/src/editor/core/themes/migrations/2026-05-21-sectiondivider-spacing-to-padding.ts +24 -0
  38. package/src/editor/core/themes/migrations/2026-05-22-sectiondivider-intrinsics-to-css.ts +81 -0
  39. package/src/editor/core/themes/migrations/index.ts +10 -0
  40. package/src/editor/core/themes/slices/components.ts +18 -4
  41. package/src/editor/core/themes/slices/gradients.ts +88 -13
  42. package/src/editor/core/themes/themeInit.ts +2 -2
  43. package/src/editor/core/themes/themeTypes.ts +56 -1
  44. package/src/editor/overlay/ColumnsOverlay.svelte +0 -1
  45. package/src/editor/overlay/LiveEditorOverlay.svelte +1 -4
  46. package/src/editor/styles/ui-editor.css +1 -0
  47. package/src/editor/styles/ui-form-controls.css +19 -20
  48. package/src/editor/ui/BezierCurveEditor.svelte +114 -63
  49. package/src/editor/ui/EditorViewSwitcher.svelte +0 -1
  50. package/src/editor/ui/FileLoadList.svelte +22 -5
  51. package/src/editor/ui/FontStackEditor.svelte +214 -76
  52. package/src/editor/ui/GradientEditor.svelte +435 -215
  53. package/src/editor/ui/GradientStopPicker.svelte +11 -3
  54. package/src/editor/ui/ManifestFileManager.svelte +71 -4
  55. package/src/editor/ui/PaletteEditor.svelte +52 -79
  56. package/src/editor/ui/ProjectFontsSection.svelte +328 -293
  57. package/src/editor/ui/ThemeFileManager.svelte +0 -4
  58. package/src/editor/ui/UIFontFamilySelector.svelte +0 -1
  59. package/src/editor/ui/UIFontSizeSelector.svelte +3 -0
  60. package/src/editor/ui/UIInfoPopover.svelte +0 -1
  61. package/src/editor/ui/UILetterSpacingSelector.svelte +65 -0
  62. package/src/editor/ui/UIPaletteSelector.svelte +31 -4
  63. package/src/editor/ui/UIPillButton.svelte +33 -3
  64. package/src/editor/ui/UISegmentedControl.svelte +114 -0
  65. package/src/editor/ui/UITokenSelector.svelte +4 -1
  66. package/src/editor/ui/VariablesTab.svelte +41 -35
  67. package/src/editor/ui/palette/OverridesPanel.svelte +14 -37
  68. package/src/editor/ui/palette/PaletteBase.svelte +3 -3
  69. package/src/editor/ui/sections/ColumnsSection.svelte +1 -2
  70. package/src/editor/ui/sections/GradientsSection.svelte +1 -1
  71. package/src/editor/ui/sections/OverlaysSection.svelte +1 -1
  72. package/src/editor/ui/sections/ShadowsSection.svelte +1 -1
  73. package/src/system/components/Button.svelte +2 -2
  74. package/src/system/components/Card.svelte +29 -1
  75. package/src/system/components/CollapsibleSection.svelte +25 -2
  76. package/src/system/components/FloatingTokenTags.css +43 -24
  77. package/src/system/components/FloatingTokenTags.svelte +88 -137
  78. package/src/system/components/Notification.svelte +8 -1
  79. package/src/system/components/SectionDivider.svelte +456 -379
  80. package/src/system/styles/CONVENTIONS.md +1 -1
  81. package/src/system/styles/fonts.css +3 -16
  82. package/src/system/styles/tokens.css +356 -1199
  83. package/src/system/styles/tokens.generated.css +544 -0
  84. package/src/editor/component-editor/scaffolding/DividerEditor.svelte +0 -94
  85. package/src/editor/component-editor/scaffolding/GradientCard.svelte +0 -296
@@ -37,8 +37,20 @@
37
37
  return tokens;
38
38
  }
39
39
 
40
+ // Outline is the only variant that paints a surface tint on :active; the rest
41
+ // express press feedback through transform/shadow only. Expose just the one
42
+ // tunable property here rather than adding an active state to every variant.
43
+ const outlineActiveTokens: Token[] = [
44
+ { label: 'surface color', groupKey: 'surface', variable: '--button-outline-active-surface' },
45
+ ];
46
+
40
47
  function variantStates(v: Variant): Record<string, Token[]> {
41
- return Object.fromEntries(stateNames.map((s) => [s, variantStateTokens(v, s)]));
48
+ const out: Record<string, Token[]> = {};
49
+ out.default = variantStateTokens(v, 'default');
50
+ out.hover = variantStateTokens(v, 'hover');
51
+ if (v === 'outline') out.active = outlineActiveTokens;
52
+ out.disabled = variantStateTokens(v, 'disabled');
53
+ return out;
42
54
  }
43
55
  export const allTokens: Token[] = variants.flatMap((v) =>
44
56
  Object.values(variantStates(v)).flat(),
@@ -0,0 +1,10 @@
1
+ // Augment the global `*.svelte` ambient module to expose the named
2
+ // `allTokens` export that each `<Name>Editor.svelte` declares in its
3
+ // `<script module>` block. The default ambient (shipped via
4
+ // node_modules/svelte/types/index.d.ts) declares only a default export,
5
+ // so without this augmentation tsc fails on the named imports in
6
+ // registry.ts.
7
+
8
+ declare module '*.svelte' {
9
+ export const allTokens: import('./scaffolding/types').Token[];
10
+ }
@@ -9,10 +9,14 @@
9
9
  value?: number;
10
10
  label?: string;
11
11
  size?: number;
12
+ /** 'horizontal' (default) lays the label, dial, input, and degree mark on a
13
+ * single line. 'vertical' stacks the dial above the input — used when the
14
+ * dial sits in its own column with a section header providing the label. */
15
+ orientation?: 'horizontal' | 'vertical';
12
16
  onchange?: (payload: { value: number }) => void;
13
17
  }
14
18
 
15
- let { value = $bindable(0), label = 'Angle', size = 44, onchange }: Props = $props();
19
+ let { value = $bindable(0), label = 'Angle', size = 44, orientation = 'horizontal', onchange }: Props = $props();
16
20
 
17
21
  let dialEl: HTMLDivElement | undefined = $state();
18
22
  let dragging = $state(false);
@@ -63,8 +67,8 @@
63
67
  let indicatorTransform = $derived(`rotate(${value}deg)`);
64
68
  </script>
65
69
 
66
- <div class="angle-dial-row">
67
- {#if label}
70
+ <div class="angle-dial-row" class:vertical={orientation === 'vertical'}>
71
+ {#if label && orientation === 'horizontal'}
68
72
  <span class="dial-label">{label}:</span>
69
73
  {/if}
70
74
  <div
@@ -90,16 +94,18 @@
90
94
  <div class="indicator" style="transform: {indicatorTransform}"></div>
91
95
  <div class="hub"></div>
92
96
  </div>
93
- <input
94
- class="num"
95
- type="number"
96
- min="0"
97
- max="360"
98
- step="1"
99
- value={value}
100
- onchange={onInputChange}
101
- />
102
- <span class="suffix">°</span>
97
+ <div class="num-row" style={orientation === 'vertical' ? `width: ${size}px;` : ''}>
98
+ <input
99
+ class="num"
100
+ type="number"
101
+ min="0"
102
+ max="360"
103
+ step="1"
104
+ value={value}
105
+ onchange={onInputChange}
106
+ />
107
+ <span class="suffix">°</span>
108
+ </div>
103
109
  </div>
104
110
 
105
111
  <style>
@@ -110,6 +116,39 @@
110
116
  font-size: var(--ui-font-size-sm);
111
117
  color: var(--ui-text-secondary);
112
118
  }
119
+ /* Stacked layout: dial centered, input + degree mark on the row below. Used
120
+ when the dial sits in its own grid column with an external section label. */
121
+ .angle-dial-row.vertical {
122
+ flex-direction: column;
123
+ align-items: center;
124
+ gap: var(--ui-space-6);
125
+ }
126
+
127
+ .num-row {
128
+ display: inline-flex;
129
+ align-items: center;
130
+ gap: var(--ui-space-8);
131
+ }
132
+
133
+ /* Vertical mode: lock the row to the dial's width and overlay the degree
134
+ mark inside the input's right padding so the input fills the column
135
+ instead of the row growing past the dial. */
136
+ .angle-dial-row.vertical .num-row {
137
+ position: relative;
138
+ display: block;
139
+ }
140
+ .angle-dial-row.vertical .num {
141
+ width: 100%;
142
+ box-sizing: border-box;
143
+ padding-right: 1.25rem;
144
+ }
145
+ .angle-dial-row.vertical .suffix {
146
+ position: absolute;
147
+ right: var(--ui-space-6);
148
+ top: 50%;
149
+ transform: translateY(-50%);
150
+ pointer-events: none;
151
+ }
113
152
 
114
153
  .dial-label {
115
154
  user-select: none;
@@ -6,7 +6,7 @@
6
6
  import { onMount, onDestroy } from 'svelte';
7
7
  import UIInfoPopover from '../../ui/UIInfoPopover.svelte';
8
8
  import { get } from 'svelte/store';
9
- import type { ComponentConfig, ComponentConfigMeta } from '../../core/themes/themeTypes';
9
+ import type { AliasDiskValue, ComponentConfig, ComponentConfigMeta } from '../../core/themes/themeTypes';
10
10
  import { componentSourceFile } from './componentSources';
11
11
  import {
12
12
  loadComponentConfig,
@@ -125,15 +125,17 @@
125
125
  }
126
126
  }
127
127
 
128
- function refToString(ref: CssVarRef): string {
129
- return ref.kind === 'token' ? ref.name : ref.value;
128
+ function refToDiskValue(ref: CssVarRef): AliasDiskValue {
129
+ if (ref.kind === 'token') return ref.name;
130
+ if (ref.kind === 'literal') return ref.value;
131
+ return { kind: 'gradient', value: ref.value };
130
132
  }
131
133
 
132
- function currentAliases(): Record<string, string> {
134
+ function currentAliases(): Record<string, AliasDiskValue> {
133
135
  const slice = get(editorState).components[component];
134
136
  if (!slice) return {};
135
- const out: Record<string, string> = {};
136
- for (const [k, ref] of Object.entries(slice.aliases)) out[k] = refToString(ref);
137
+ const out: Record<string, AliasDiskValue> = {};
138
+ for (const [k, ref] of Object.entries(slice.aliases)) out[k] = refToDiskValue(ref);
137
139
  return out;
138
140
  }
139
141
 
@@ -342,7 +344,7 @@
342
344
  : isApplied
343
345
  ? 'Active config is applied to production'
344
346
  : ''}
345
- style="flex: 0 0 11.25rem; width: 11.25rem;"
347
+ style="flex: 0 1 11.25rem; min-width: 0; max-width: 11.25rem;"
346
348
  />
347
349
  <div class="cfm-actions">
348
350
  <ComponentFileMenu
@@ -383,7 +385,7 @@
383
385
  name={productionInfo?.name ?? '—'}
384
386
  isProtected={productionInfo?.fileName === 'default'}
385
387
  protectedTitle="Protected system config"
386
- style="flex: 0 0 11.25rem; width: 11.25rem;"
388
+ style="flex: 0 1 11.25rem; min-width: 0; max-width: 11.25rem;"
387
389
  />
388
390
  <div class="cfm-actions">
389
391
  <UISquareButton
@@ -483,7 +485,6 @@
483
485
  font-size: var(--ui-font-size-3xl);
484
486
  font-weight: var(--ui-font-weight-semibold);
485
487
  color: var(--ui-text-primary);
486
- letter-spacing: -0.015em;
487
488
  line-height: 1.1;
488
489
  }
489
490
 
@@ -536,7 +537,6 @@
536
537
  font-size: var(--ui-font-size-xs);
537
538
  font-weight: var(--ui-font-weight-semibold);
538
539
  text-transform: uppercase;
539
- letter-spacing: 0.08em;
540
540
  color: var(--ui-text-secondary);
541
541
  line-height: 1.1;
542
542
  }
@@ -546,7 +546,6 @@
546
546
  align-items: center;
547
547
  gap: var(--ui-space-4);
548
548
  font-size: 0.75rem;
549
- letter-spacing: 0.02em;
550
549
  color: var(--ui-text-muted);
551
550
  line-height: 1;
552
551
  }
@@ -311,7 +311,6 @@
311
311
  font-size: var(--ui-font-size-md);
312
312
  font-weight: var(--ui-font-weight-medium);
313
313
  color: var(--ui-text-primary);
314
- letter-spacing: 0.005em;
315
314
  }
316
315
 
317
316
  /* Control row hosts a single TokenLayout-rendered selector + resolved value.
@@ -0,0 +1,483 @@
1
+ <script lang="ts">
2
+ /**
3
+ * 2D shape control for a radial gradient. Two independent stretch
4
+ * factors (X and Y, both 1 → 8) define the ellipse's semi-axes; rendering
5
+ * uses `base * aspectX` and `base * aspectY` so the data also encodes
6
+ * size (both = 8 is a circle 8× bigger than both = 1).
7
+ *
8
+ * Three concurrent affordances stay synced:
9
+ * - 2D pad with a draggable handle that lives at (aspectX, aspectY).
10
+ * The visible ellipse is drawn with semi-axes equal to the handle's
11
+ * offset from center, so the dot you drag IS the corner of the shape.
12
+ * - Horizontal slider beneath the pad — drags X only.
13
+ * - Vertical slider beside the pad — drags Y only.
14
+ *
15
+ * Range chosen so 1 = "no stretch" and 8 = "8× stretch" — same units the
16
+ * user sees in the W : H ratio readout.
17
+ */
18
+ import UIPillButton from '../../ui/UIPillButton.svelte';
19
+ interface Props {
20
+ /** Horizontal stretch (1 = unscaled, default). */
21
+ x?: number;
22
+ /** Vertical stretch (1 = unscaled, default). */
23
+ y?: number;
24
+ /** Edge length of the square pad in px. Sliders extend the bounding box. */
25
+ size?: number;
26
+ /** Clamp bounds for each axis. */
27
+ min?: number;
28
+ max?: number;
29
+ onchange?: (payload: { x: number; y: number }) => void;
30
+ }
31
+
32
+ let {
33
+ x = $bindable(1),
34
+ y = $bindable(1),
35
+ size = 80,
36
+ min = 1,
37
+ max = 8,
38
+ onchange,
39
+ }: Props = $props();
40
+
41
+ let padEl: HTMLDivElement | undefined = $state();
42
+ let xSliderEl: HTMLDivElement | undefined = $state();
43
+ let ySliderEl: HTMLDivElement | undefined = $state();
44
+ let drag: 'pad' | 'x' | 'y' | null = $state(null);
45
+
46
+ // Quadrant the handle currently lives in. The stored aspect values are
47
+ // always positive (CSS radial-gradient semi-axes must be), but the dot can
48
+ // be dragged into any of the four quadrants. The ellipse is symmetric, so
49
+ // mirroring the handle reads as "this corner" — the rendered shape is
50
+ // identical. Sign is session-local; on reload the dot defaults to top-right.
51
+ let signX = $state(1);
52
+ let signY = $state(1);
53
+
54
+ /** Inset (px) the pad uses to keep the corner handle inside its border.
55
+ * Sliders use a separate inset since their end-labels sit outside the
56
+ * rail entirely now. */
57
+ const PAD_INSET = 6;
58
+ const SLIDER_INSET = 0;
59
+
60
+ function clamp(v: number, lo: number, hi: number): number {
61
+ return Math.max(lo, Math.min(hi, v));
62
+ }
63
+
64
+ /** Snap near-integer values so the user can land cleanly on {1,2,…,8}. */
65
+ function snap(v: number): number {
66
+ const rounded = Math.round(v);
67
+ if (Math.abs(v - rounded) < 0.05) return rounded;
68
+ return Math.round(v * 10) / 10;
69
+ }
70
+
71
+ function emit(nx: number, ny: number) {
72
+ const cx = clamp(snap(nx), min, max);
73
+ const cy = clamp(snap(ny), min, max);
74
+ if (cx === x && cy === y) return;
75
+ x = cx;
76
+ y = cy;
77
+ onchange?.({ x: cx, y: cy });
78
+ }
79
+
80
+ /** Pad pointer → (aspectX, aspectY). The pointer's signed distance from
81
+ * center decides the quadrant; magnitude maps linearly into (min, max).
82
+ * signX/signY are updated here so the handle renders in the quadrant the
83
+ * user actually dragged to, even though the returned aspect values stay
84
+ * positive. Clamping at the edges is handled by `emit`. */
85
+ function aspectFromPad(e: PointerEvent): { x: number; y: number } {
86
+ const rect = padEl!.getBoundingClientRect();
87
+ const cx = rect.left + rect.width / 2;
88
+ const cy = rect.top + rect.height / 2;
89
+ const span = rect.width / 2 - PAD_INSET;
90
+ const dx = e.clientX - cx;
91
+ const dy = cy - e.clientY;
92
+ if (dx !== 0) signX = dx >= 0 ? 1 : -1;
93
+ if (dy !== 0) signY = dy >= 0 ? 1 : -1;
94
+ const nx = min + (Math.abs(dx) / span) * (max - min);
95
+ const ny = min + (Math.abs(dy) / span) * (max - min);
96
+ return { x: nx, y: ny };
97
+ }
98
+
99
+ function valueFromLinearSlider(el: HTMLDivElement, clientPx: number, axis: 'h' | 'v'): number {
100
+ const rect = el.getBoundingClientRect();
101
+ const len = axis === 'h' ? rect.width - 2 * SLIDER_INSET : rect.height - 2 * SLIDER_INSET;
102
+ const pos = axis === 'h'
103
+ ? clientPx - (rect.left + SLIDER_INSET)
104
+ : (rect.bottom - SLIDER_INSET) - clientPx;
105
+ const t = clamp(pos / len, 0, 1);
106
+ return min + t * (max - min);
107
+ }
108
+
109
+ function onPadDown(e: PointerEvent) {
110
+ drag = 'pad';
111
+ padEl!.setPointerCapture(e.pointerId);
112
+ const { x: nx, y: ny } = aspectFromPad(e);
113
+ emit(nx, ny);
114
+ }
115
+ function onXDown(e: PointerEvent) {
116
+ drag = 'x';
117
+ xSliderEl!.setPointerCapture(e.pointerId);
118
+ emit(valueFromLinearSlider(xSliderEl!, e.clientX, 'h'), y);
119
+ }
120
+ function onYDown(e: PointerEvent) {
121
+ drag = 'y';
122
+ ySliderEl!.setPointerCapture(e.pointerId);
123
+ emit(x, valueFromLinearSlider(ySliderEl!, e.clientY, 'v'));
124
+ }
125
+
126
+ function onMove(e: PointerEvent) {
127
+ if (drag === 'pad') {
128
+ const { x: nx, y: ny } = aspectFromPad(e);
129
+ emit(nx, ny);
130
+ } else if (drag === 'x') {
131
+ emit(valueFromLinearSlider(xSliderEl!, e.clientX, 'h'), y);
132
+ } else if (drag === 'y') {
133
+ emit(x, valueFromLinearSlider(ySliderEl!, e.clientY, 'v'));
134
+ }
135
+ }
136
+ function onUp(e: PointerEvent) {
137
+ if (!drag) return;
138
+ const target = drag === 'pad' ? padEl! : drag === 'x' ? xSliderEl! : ySliderEl!;
139
+ target.releasePointerCapture(e.pointerId);
140
+ drag = null;
141
+ }
142
+
143
+ /** Position of the pad handle and the rendered ellipse's bounding corner.
144
+ * Aspect values map to pixel magnitudes; signX/signY pick which of the
145
+ * ellipse's four corners the handle sits on. The ellipse itself is always
146
+ * centered with positive semi-axes (it's symmetric, so the shape reads
147
+ * identically in any quadrant). */
148
+ let pad = $derived.by(() => {
149
+ const span = size / 2 - PAD_INSET;
150
+ const tx = (x - min) / (max - min);
151
+ const ty = (y - min) / (max - min);
152
+ const rx = tx * span;
153
+ const ry = ty * span;
154
+ const center = size / 2;
155
+ return { center, rx, ry, handleX: center + signX * rx, handleY: center - signY * ry };
156
+ });
157
+
158
+ let xThumb = $derived(((x - min) / (max - min)));
159
+ let yThumb = $derived(((y - min) / (max - min)));
160
+
161
+ function parseAxisInput(raw: string): number | null {
162
+ const n = parseFloat(raw.trim());
163
+ return Number.isFinite(n) ? n : null;
164
+ }
165
+
166
+ function onXInput(e: Event) {
167
+ const v = parseAxisInput((e.target as HTMLInputElement).value);
168
+ if (v !== null) emit(v, y);
169
+ }
170
+ function onYInput(e: Event) {
171
+ const v = parseAxisInput((e.target as HTMLInputElement).value);
172
+ if (v !== null) emit(x, v);
173
+ }
174
+ </script>
175
+
176
+ <svelte:window onpointermove={onMove} onpointerup={onUp} onpointercancel={onUp} />
177
+
178
+ <div class="shape-pad">
179
+ <div class="pad-body">
180
+ <div class="grid">
181
+ <div
182
+ bind:this={padEl}
183
+ class="pad"
184
+ class:dragging={drag === 'pad'}
185
+ style="width: {size}px; height: {size}px;"
186
+ onpointerdown={onPadDown}
187
+ role="application"
188
+ aria-label="Drag to set the radial gradient's width and height"
189
+ >
190
+ <span class="axis axis-h" aria-hidden="true"></span>
191
+ <span class="axis axis-v" aria-hidden="true"></span>
192
+ <svg class="ellipse" viewBox="0 0 {size} {size}" aria-hidden="true">
193
+ <ellipse
194
+ cx={pad.center}
195
+ cy={pad.center}
196
+ rx={Math.max(3, pad.rx)}
197
+ ry={Math.max(3, pad.ry)}
198
+ fill="none"
199
+ stroke="currentColor"
200
+ stroke-width="1"
201
+ />
202
+ <line
203
+ x1={pad.center}
204
+ y1={pad.center}
205
+ x2={pad.handleX}
206
+ y2={pad.handleY}
207
+ stroke="currentColor"
208
+ stroke-width="1"
209
+ stroke-dasharray="2 2"
210
+ opacity="0.4"
211
+ />
212
+ </svg>
213
+ <span
214
+ class="handle"
215
+ class:dragging={drag === 'pad'}
216
+ style="left: {pad.handleX}px; top: {pad.handleY}px;"
217
+ aria-hidden="true"
218
+ ></span>
219
+ </div>
220
+
221
+ <div
222
+ bind:this={ySliderEl}
223
+ class="slider slider-v"
224
+ class:dragging={drag === 'y'}
225
+ style="height: {size}px;"
226
+ onpointerdown={onYDown}
227
+ role="slider"
228
+ aria-orientation="vertical"
229
+ aria-valuemin={min}
230
+ aria-valuemax={max}
231
+ aria-valuenow={y}
232
+ aria-label="Vertical stretch"
233
+ tabindex="0"
234
+ onkeydown={(e) => {
235
+ if (e.key === 'ArrowUp') { e.preventDefault(); emit(x, y + 0.5); }
236
+ else if (e.key === 'ArrowDown') { e.preventDefault(); emit(x, y - 0.5); }
237
+ }}
238
+ >
239
+ <span class="track" aria-hidden="true"></span>
240
+ <span class="thumb" style="bottom: calc({yThumb * 100}% - 4px);" aria-hidden="true"></span>
241
+ </div>
242
+
243
+ <div
244
+ bind:this={xSliderEl}
245
+ class="slider slider-h"
246
+ class:dragging={drag === 'x'}
247
+ style="width: {size}px;"
248
+ onpointerdown={onXDown}
249
+ role="slider"
250
+ aria-orientation="horizontal"
251
+ aria-valuemin={min}
252
+ aria-valuemax={max}
253
+ aria-valuenow={x}
254
+ aria-label="Horizontal stretch"
255
+ tabindex="0"
256
+ onkeydown={(e) => {
257
+ if (e.key === 'ArrowLeft') { e.preventDefault(); emit(x - 0.5, y); }
258
+ else if (e.key === 'ArrowRight') { e.preventDefault(); emit(x + 0.5, y); }
259
+ }}
260
+ >
261
+ <span class="track" aria-hidden="true"></span>
262
+ <span class="thumb" style="left: calc({xThumb * 100}% - 4px);" aria-hidden="true"></span>
263
+ </div>
264
+ </div>
265
+
266
+ <div class="readouts">
267
+ <label class="num-field">
268
+ <span class="num-label">W</span>
269
+ <input
270
+ type="number"
271
+ min={min}
272
+ max={max}
273
+ step="0.1"
274
+ value={x}
275
+ onchange={onXInput}
276
+ />
277
+ </label>
278
+ <label class="num-field">
279
+ <span class="num-label">H</span>
280
+ <input
281
+ type="number"
282
+ min={min}
283
+ max={max}
284
+ step="0.1"
285
+ value={y}
286
+ onchange={onYInput}
287
+ />
288
+ </label>
289
+ <UIPillButton
290
+ variant="secondary"
291
+ size="compact"
292
+ icon="fa-arrows-rotate"
293
+ title="Reset to circle (1 : 1)"
294
+ disabled={x === 1 && y === 1}
295
+ onclick={() => emit(1, 1)}
296
+ />
297
+ </div>
298
+ </div>
299
+ </div>
300
+
301
+ <style>
302
+ /* Two-column body: pad + sliders on the left, W/H/Reset on the right.
303
+ The section header ("Gradient shape") is owned by GradientEditor so it
304
+ can sit on the same baseline as the parent's section label. */
305
+ .shape-pad {
306
+ display: inline-flex;
307
+ color: var(--ui-text-secondary);
308
+ font-size: var(--ui-font-size-sm);
309
+ }
310
+
311
+ .pad-body {
312
+ display: inline-flex;
313
+ align-items: flex-start;
314
+ gap: var(--ui-space-12);
315
+ }
316
+
317
+ /* Pad in the top-left, Y slider snugged to its right edge, X slider
318
+ snugged to its bottom edge. Zero gaps so the rails read as extensions
319
+ of the pad's own axis lines rather than separate widgets. */
320
+ .grid {
321
+ display: grid;
322
+ grid-template-columns: max-content max-content;
323
+ grid-template-rows: max-content max-content;
324
+ gap: 0;
325
+ align-items: start;
326
+ }
327
+
328
+ .pad {
329
+ position: relative;
330
+ grid-row: 1;
331
+ grid-column: 1;
332
+ background: var(--ui-surface-lowest);
333
+ border: 1px solid var(--ui-border);
334
+ border-radius: var(--ui-radius-sm);
335
+ cursor: crosshair;
336
+ color: var(--ui-text-primary);
337
+ touch-action: none;
338
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
339
+ }
340
+ .pad:focus-visible { outline: 2px solid var(--ui-text-primary); outline-offset: 2px; }
341
+ .pad.dragging { cursor: grabbing; }
342
+
343
+ /* Faint center cross — anchors the gradient origin and shows the user
344
+ where the (1,1) state lives (handle parks right at the cross). */
345
+ .axis {
346
+ position: absolute;
347
+ background: var(--ui-border-low);
348
+ pointer-events: none;
349
+ }
350
+ .axis-h { left: 4px; right: 4px; top: 50%; height: 1px; }
351
+ .axis-v { top: 4px; bottom: 4px; left: 50%; width: 1px; }
352
+
353
+ .ellipse {
354
+ position: absolute;
355
+ inset: 0;
356
+ pointer-events: none;
357
+ color: var(--ui-text-primary);
358
+ }
359
+
360
+ /* Bigger, brighter dot than v1 + a subtle outer ring on hover so it reads
361
+ as "this is the thing you grab." The dashed line back to center makes
362
+ the relationship between handle position and shape semi-axes legible. */
363
+ .handle {
364
+ position: absolute;
365
+ width: 11px;
366
+ height: 11px;
367
+ margin-left: -5.5px;
368
+ margin-top: -5.5px;
369
+ border-radius: 50%;
370
+ background: var(--ui-text-primary);
371
+ border: 2px solid var(--ui-surface-lowest);
372
+ box-shadow: 0 0 0 1px var(--ui-text-primary), 0 0 6px rgba(255, 255, 255, 0.15);
373
+ cursor: grab;
374
+ pointer-events: none;
375
+ transition: box-shadow var(--ui-transition-fast);
376
+ }
377
+ .pad:hover .handle {
378
+ box-shadow: 0 0 0 1px var(--ui-text-primary), 0 0 10px rgba(255, 255, 255, 0.25);
379
+ }
380
+ .handle.dragging,
381
+ .pad.dragging .handle {
382
+ cursor: grabbing;
383
+ box-shadow: 0 0 0 2px var(--ui-text-primary), 0 0 12px rgba(255, 255, 255, 0.35);
384
+ }
385
+
386
+ /* Slider rails. Each rail hugs the pad edge it represents: X under the
387
+ pad's bottom, Y to the right of the pad's right edge. The thumbs are
388
+ small arrow triangles whose tips touch the rail — they read as "this
389
+ points at the value" instead of a dot drifting along the rail. */
390
+ .slider {
391
+ position: relative;
392
+ flex: none;
393
+ cursor: pointer;
394
+ touch-action: none;
395
+ }
396
+ .slider:focus-visible { outline: 2px solid var(--ui-text-primary); outline-offset: 2px; }
397
+
398
+ /* Horizontal slider: rail along the very top (flush against the pad's
399
+ bottom border), arrow thumb sits below pointing up at the rail. */
400
+ .slider-h {
401
+ grid-row: 2;
402
+ grid-column: 1;
403
+ height: 14px;
404
+ }
405
+ /* Vertical slider: rail along the very left (flush against the pad's
406
+ right border), arrow thumb sits to the right pointing left at the
407
+ rail. */
408
+ .slider-v {
409
+ grid-row: 1;
410
+ grid-column: 2;
411
+ width: 14px;
412
+ }
413
+
414
+ .track {
415
+ position: absolute;
416
+ background: var(--ui-border);
417
+ }
418
+ .slider-h .track { left: 0; right: 0; top: 0; height: 1px; border-radius: 1px; }
419
+ .slider-v .track { top: 0; bottom: 0; left: 0; width: 1px; border-radius: 1px; }
420
+
421
+ /* Arrow thumb — CSS triangle. The transparent borders create the legs
422
+ and the colored border creates the tip. We use `border-bottom` to make
423
+ the tip point UP, `border-right` to make it point LEFT. */
424
+ .thumb {
425
+ position: absolute;
426
+ width: 0;
427
+ height: 0;
428
+ pointer-events: none;
429
+ }
430
+ .slider-h .thumb {
431
+ top: 2px;
432
+ border-left: 4px solid transparent;
433
+ border-right: 4px solid transparent;
434
+ border-bottom: 6px solid var(--ui-text-primary);
435
+ }
436
+ .slider-v .thumb {
437
+ left: 2px;
438
+ border-top: 4px solid transparent;
439
+ border-bottom: 4px solid transparent;
440
+ border-right: 6px solid var(--ui-text-primary);
441
+ }
442
+ .slider.dragging .thumb {
443
+ filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.35));
444
+ }
445
+
446
+
447
+ /* Vertical stack beside the pad: W input, H input, Reset. Right-aligned
448
+ so the input field's right edges and the Reset button's right edge all
449
+ share a single vertical line — reads as a tidy aligned column. */
450
+ .readouts {
451
+ display: inline-flex;
452
+ flex-direction: column;
453
+ align-items: flex-end;
454
+ gap: var(--ui-space-6);
455
+ }
456
+
457
+ .num-field {
458
+ display: inline-flex;
459
+ align-items: center;
460
+ gap: var(--ui-space-6);
461
+ color: var(--ui-text-secondary);
462
+ }
463
+ .num-label {
464
+ font-family: var(--ui-font-mono);
465
+ font-size: var(--ui-font-size-xs);
466
+ color: var(--ui-text-tertiary);
467
+ }
468
+ .num-field input {
469
+ width: 3.25rem;
470
+ padding: var(--ui-space-2) var(--ui-space-6);
471
+ background: var(--ui-surface-lowest);
472
+ border: 1px solid var(--ui-border-low);
473
+ border-radius: var(--ui-radius-sm);
474
+ color: var(--ui-text-primary);
475
+ font-family: var(--ui-font-mono);
476
+ font-size: var(--ui-font-size-sm);
477
+ text-align: right;
478
+ }
479
+ .num-field input::-webkit-outer-spin-button,
480
+ .num-field input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
481
+
482
+
483
+ </style>