@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
@@ -1,50 +1,76 @@
1
1
  <script lang="ts">
2
2
  import { run } from 'svelte/legacy';
3
3
 
4
- /**
5
- * Visual gradient editor. Stops are draggable diamond handles below a live
6
- * ribbon; only the selected stop exposes its position + color controls (the
7
- * old list of every stop is replaced by this single-row pattern, mirroring
8
- * GradientCard.svelte). Stops can be added/removed with a minimum of two.
9
- */
4
+ // Visual gradient editor: draggable stop diamonds on a live ribbon.
5
+ // Bound to a GradientSource (theme via `variable`, or component via `source`).
10
6
  import { tick, onMount } from 'svelte';
11
- import {
12
- editorState,
13
- setGradient,
14
- setGradientType,
15
- setGradientAngle,
16
- setGradientStop,
17
- addGradientStop,
18
- removeGradientStop,
19
- } from '../core/store/editorStore';
7
+ import { get } from 'svelte/store';
20
8
  import type { GradientType, GradientTokenStop } from '../core/store/editorTypes';
9
+ import {
10
+ themeGradientSource,
11
+ snapshotGradient,
12
+ type GradientSource,
13
+ type GradientSourceSnapshot,
14
+ } from '../core/store/gradientSource';
21
15
  import GradientStopPicker from './GradientStopPicker.svelte';
22
16
  import AngleDial from '../component-editor/scaffolding/AngleDial.svelte';
17
+ import RadialShapePad from '../component-editor/scaffolding/RadialShapePad.svelte';
18
+ import UISegmentedControl from './UISegmentedControl.svelte';
19
+ import UIPillButton from './UIPillButton.svelte';
20
+ import { snapTokenToFamily } from '../core/palettes/familySwap';
23
21
 
24
22
  interface Props {
25
- variable: string;
23
+ /** Theme-gradient mode: variable name (e.g. `--gradient-1`). */
24
+ variable?: string;
25
+ /** Component-gradient mode: source adapter. Wins over `variable`. */
26
+ source?: GradientSource;
27
+ /** Header label above the ribbon; turns the editor into a 2-col grid. */
28
+ sectionLabel?: string;
29
+ /** Stable id for per-stop picker scratch vars. */
30
+ stopIdPrefix?: string;
31
+ /** Greys out tokens outside this family prefix in the stop picker. */
32
+ familyFilter?: string | null;
33
+ /** Show the "None" segment so the user can clear the fill outright. */
34
+ showNone?: boolean;
35
+ /** Called when the user picks "None" so the parent can zero ancillary tokens. */
36
+ onNone?: () => void;
26
37
  onsave?: () => void;
27
38
  oncancel?: () => void;
28
39
  }
29
40
 
30
- let { variable, onsave, oncancel }: Props = $props();
31
-
32
- /** Deep snapshot of the gradient at editor open, used to restore on Cancel. */
33
- let snapshot: { type: GradientType; angle: number; stops: GradientTokenStop[] } | null = null;
41
+ let {
42
+ variable,
43
+ source,
44
+ sectionLabel,
45
+ stopIdPrefix,
46
+ familyFilter = null,
47
+ showNone = false,
48
+ onNone,
49
+ onsave,
50
+ oncancel,
51
+ }: Props = $props();
52
+
53
+ // Captured once: callers remount when the target gradient changes.
54
+ // svelte-ignore state_referenced_locally
55
+ const gradientSource: GradientSource = source ?? themeGradientSource(variable!);
56
+ // Local const so Svelte 5's `$<store>` auto-subscription works.
57
+ const gradientSourceCurrent = gradientSource.current;
58
+ // svelte-ignore state_referenced_locally
59
+ const stopKeyPrefix: string = stopIdPrefix ?? variable ?? 'gradient-edit';
60
+
61
+ // Snapshot at open, restored on Cancel.
62
+ let snapshot: GradientSourceSnapshot | null = null;
34
63
  onMount(() => {
35
- const g = $editorState.gradients.tokens.find((t) => t.variable === variable);
36
- if (g) {
37
- snapshot = { type: g.type, angle: g.angle, stops: g.stops.map((s) => ({ ...s })) };
38
- }
64
+ snapshot = snapshotGradient(gradientSource);
39
65
  });
40
66
 
41
67
  function save() { onsave?.(); }
42
68
  function cancel() {
43
- if (snapshot) setGradient(variable, snapshot);
69
+ if (snapshot) gradientSource.setAll(snapshot);
44
70
  oncancel?.();
45
71
  }
46
72
 
47
- let gradient = $derived($editorState.gradients.tokens.find((t) => t.variable === variable));
73
+ let gradient = $derived($gradientSourceCurrent);
48
74
  let stopCount = $derived(gradient?.stops.length ?? 0);
49
75
 
50
76
  let selected = $state(0);
@@ -54,16 +80,20 @@
54
80
  });
55
81
 
56
82
  function setType(type: GradientType) {
57
- setGradientType(variable, type);
83
+ gradientSource.setType(type);
58
84
  }
59
85
 
60
86
  function onAngleChange(detail: { value: number }) {
61
- setGradientAngle(variable, detail.value);
87
+ gradientSource.setAngle(detail.value);
88
+ }
89
+
90
+ function onAspectChange(detail: { x: number; y: number }) {
91
+ gradientSource.setAspect(detail);
62
92
  }
63
93
 
64
94
  function setPosition(i: number, pct: number) {
65
95
  const clamped = Math.max(0, Math.min(100, Math.round(pct * 10) / 10));
66
- setGradientStop(variable, i, { position: clamped });
96
+ gradientSource.setStop(i, { position: clamped });
67
97
  }
68
98
 
69
99
  function onPositionInput(e: Event) {
@@ -72,23 +102,44 @@
72
102
  }
73
103
 
74
104
  function handleStopChange(i: number, payload: { color: string; opacity: number }) {
75
- setGradientStop(variable, i, { color: payload.color, opacity: payload.opacity });
105
+ // Picking a real color while `none` promotes to `solid`; `transparent` keeps `none`.
106
+ if (gradient?.type === 'none' && payload.color !== 'transparent') {
107
+ gradientSource.setType('solid');
108
+ }
109
+ gradientSource.setStop(i, { color: payload.color, opacity: payload.opacity });
110
+ }
111
+
112
+ // Mono on: snap to family. Mono off: mark off-palette so family swaps skip it.
113
+ function handleMonoToggle(i: number, mono: boolean) {
114
+ if (mono && familyFilter) {
115
+ const stop = gradient?.stops[i];
116
+ if (stop) {
117
+ const snapped = snapTokenToFamily(stop.color, familyFilter);
118
+ gradientSource.setStop(i, { monochrome: true, color: snapped });
119
+ return;
120
+ }
121
+ }
122
+ gradientSource.setStop(i, { monochrome: mono });
76
123
  }
77
124
 
78
- /** Insert a stop at the given percentage, inheriting color/opacity from the
79
- * given source stop. Selects the newly inserted stop once the store sort
80
- * settles. */
81
- async function insertStopAt(pct: number, source: GradientTokenStop) {
125
+ function stopValueLabel(stop: GradientTokenStop): string {
126
+ const op = stop.opacity ?? 100;
127
+ const base = stop.color.startsWith('--') ? stop.color.slice(2) : stop.color;
128
+ return op < 100 ? `${base} (${Math.round(op)}%)` : base;
129
+ }
130
+
131
+ // Inserts at pct inheriting from source stop, then selects after the sort settles.
132
+ async function insertStopAt(pct: number, sourceStop: GradientTokenStop) {
82
133
  const clamped = Math.max(0, Math.min(100, Math.round(pct * 10) / 10));
83
- addGradientStop(variable, {
134
+ gradientSource.addStop({
84
135
  position: clamped,
85
- color: source.color,
86
- opacity: source.opacity ?? 100,
136
+ color: sourceStop.color,
137
+ opacity: sourceStop.opacity ?? 100,
87
138
  });
88
139
  await tick();
89
- const after = $editorState.gradients.tokens.find((t) => t.variable === variable);
140
+ const after = get(gradientSource.current);
90
141
  if (after) {
91
- const idx = after.stops.findIndex((s) => s.position === clamped && s.color === source.color);
142
+ const idx = after.stops.findIndex((s) => s.position === clamped && s.color === sourceStop.color);
92
143
  if (idx >= 0) selected = idx;
93
144
  }
94
145
  }
@@ -96,8 +147,7 @@
96
147
  function addStop() {
97
148
  if (!gradient) return;
98
149
  const stops = gradient.stops;
99
- // Insert after the currently-selected stop, midway to its right neighbour
100
- // (or to 100% if it's the last stop).
150
+ // Midway to the right neighbour, or to 100% if last.
101
151
  const anchor = stops[selected] ?? stops[stops.length - 1];
102
152
  const next = stops[selected + 1];
103
153
  const newPos = next
@@ -106,13 +156,19 @@
106
156
  insertStopAt(newPos, anchor);
107
157
  }
108
158
 
109
- /** Click on the ribbon body inserts a stop at that position, picking up the
110
- * color/opacity of the closest existing stop so the new handle starts from
111
- * a sensible color rather than a default. */
159
+ // Inserts a stop at click position, inheriting from the nearest existing stop.
112
160
  function onRibbonClick(e: MouseEvent) {
113
161
  if (!gradient || e.button !== 0) return;
114
162
  const rect = barEl!.getBoundingClientRect();
115
- const pct = ((e.clientX - rect.left) / rect.width) * 100;
163
+ const x = e.clientX - rect.left;
164
+ let pct: number;
165
+ if (gradient.type === 'radial') {
166
+ // Radial: both halves map to the same distance from center.
167
+ const half = rect.width / 2;
168
+ pct = (Math.abs(x - half) / half) * 100;
169
+ } else {
170
+ pct = (x / rect.width) * 100;
171
+ }
116
172
  const nearest = gradient.stops.reduce(
117
173
  (best, s) => (Math.abs(s.position - pct) < Math.abs(best.position - pct) ? s : best),
118
174
  gradient.stops[0],
@@ -122,23 +178,32 @@
122
178
 
123
179
  function removeSelected() {
124
180
  if (!gradient || gradient.stops.length <= 2) return;
125
- removeGradientStop(variable, selected);
181
+ gradientSource.removeStop(selected);
126
182
  if (selected > 0) selected -= 1;
127
183
  }
128
184
 
129
- // ── Ribbon handle drag ─────────────────────────────────────────────────
185
+ // Ribbon handle drag
130
186
  let barEl: HTMLDivElement | undefined = $state();
131
187
  let dragIndex: number | null = $state(null);
188
+ // Drag origin side on radial ribbon: lets pointer x map symmetrically to stop position.
189
+ let dragSide: 'left' | 'right' | null = $state(null);
132
190
 
133
191
  function pctFromEvent(e: PointerEvent): number {
134
192
  const rect = barEl!.getBoundingClientRect();
135
193
  const x = e.clientX - rect.left;
194
+ if (gradient?.type === 'radial') {
195
+ const half = rect.width / 2;
196
+ return dragSide === 'left'
197
+ ? ((half - x) / half) * 100
198
+ : ((x - half) / half) * 100;
199
+ }
136
200
  return (x / rect.width) * 100;
137
201
  }
138
202
 
139
- function onHandleDown(e: PointerEvent, i: number) {
203
+ function onHandleDown(e: PointerEvent, i: number, side: 'left' | 'right' | null = null) {
140
204
  selected = i;
141
205
  dragIndex = i;
206
+ dragSide = side;
142
207
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
143
208
  setPosition(i, pctFromEvent(e));
144
209
  }
@@ -150,133 +215,275 @@
150
215
  if (dragIndex === null) return;
151
216
  (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
152
217
  dragIndex = null;
218
+ dragSide = null;
219
+ }
220
+
221
+ // Synthesise ribbon background from the snapshot so it renders before the
222
+ // CSS var has been pushed to :root. Radial mirrors stops across 50%.
223
+ function stopColorCss(s: GradientTokenStop): string {
224
+ const base = s.color.startsWith('--') ? `var(${s.color})` : s.color;
225
+ const op = s.opacity ?? 100;
226
+ return op >= 100 ? base : `color-mix(in srgb, ${base} ${Math.round(op)}%, transparent)`;
153
227
  }
154
228
 
155
- // Stop colors rendered into the diamonds: token refs become var(...).
229
+ let ribbonBg = $derived.by(() => {
230
+ if (!gradient) return 'transparent';
231
+ if (gradient.type === 'none') return 'transparent';
232
+ if (gradient.type === 'solid') {
233
+ const first = gradient.stops[0];
234
+ return first ? stopColorCss(first) : 'transparent';
235
+ }
236
+ const sorted = gradient.stops.slice().sort((a, b) => a.position - b.position);
237
+ if (gradient.type === 'radial') {
238
+ const leftStops = sorted.slice().reverse().map((s) => `${stopColorCss(s)} ${50 - s.position / 2}%`);
239
+ const rightStops = sorted.map((s) => `${stopColorCss(s)} ${50 + s.position / 2}%`);
240
+ return `linear-gradient(90deg, ${[...leftStops, ...rightStops].join(', ')})`;
241
+ }
242
+ const stopsCss = sorted.map((s) => `${stopColorCss(s)} ${s.position}%`).join(', ');
243
+ return `linear-gradient(90deg, ${stopsCss})`;
244
+ });
245
+
246
+ // Flat (solid/none) = single-stop passive UI. Linear/radial show full chrome.
247
+ let isFlat = $derived(gradient?.type === 'solid' || gradient?.type === 'none');
248
+ let isNone = $derived(gradient?.type === 'none');
249
+ let isRadial = $derived(gradient?.type === 'radial');
250
+ let isLinear = $derived(gradient?.type === 'linear');
251
+ // Right column carries the radial pad or angle dial.
252
+ let hasAside = $derived(isRadial || isLinear);
253
+
156
254
  let stopSwatches = $derived((gradient?.stops ?? []).map((s) => {
157
255
  const base = s.color.startsWith('--') ? `var(${s.color})` : s.color;
158
256
  const op = s.opacity ?? 100;
159
257
  return op >= 100 ? base : `color-mix(in srgb, ${base} ${Math.round(op)}%, transparent)`;
160
258
  }));
259
+
260
+ type TypeChoice = 'none' | 'solid' | 'linear' | 'radial';
261
+ let typeOptions = $derived(
262
+ [
263
+ ...(showNone ? [{ value: 'none' as TypeChoice, label: 'None' }] : []),
264
+ { value: 'solid' as TypeChoice, label: 'Solid' },
265
+ { value: 'linear' as TypeChoice, label: 'Linear' },
266
+ { value: 'radial' as TypeChoice, label: 'Radial' },
267
+ ],
268
+ );
269
+
270
+ function onTypeSelect(next: TypeChoice) {
271
+ setType(next as GradientType);
272
+ if (next === 'none') onNone?.();
273
+ }
161
274
  </script>
162
275
 
163
276
  {#if gradient}
164
- <div class="gradient-editor">
165
- <div class="ribbon-wrap">
166
- <!-- svelte-ignore a11y_click_events_have_key_events -->
167
- <div
168
- class="ribbon"
169
- bind:this={barEl}
170
- style="background: var({variable});"
171
- onclick={onRibbonClick}
172
- role="button"
173
- tabindex="-1"
174
- aria-label="Click to add a gradient stop"
175
- ></div>
176
- <div class="handles">
177
- {#each gradient.stops as stop, i (i)}
178
- <button
179
- type="button"
180
- class="handle"
181
- class:selected={selected === i}
182
- class:dragging={dragIndex === i}
183
- style="left: {stop.position}%; --stop-color: {stopSwatches[i]};"
184
- onpointerdown={(e) => onHandleDown(e, i)}
185
- onpointermove={onHandleMove}
186
- onpointerup={onHandleUp}
187
- onpointercancel={onHandleUp}
188
- title={`Stop ${i + 1} (${stop.position}%)`}
189
- aria-label={`Gradient stop ${i + 1}`}
190
- >
191
- <span class="handle-diamond"></span>
192
- </button>
193
- {/each}
277
+ <div class="gradient-editor" class:has-pad={hasAside}>
278
+ {#if sectionLabel || hasAside}
279
+ <div class="editor-header editor-section-left">
280
+ <span class="editor-section-label">{sectionLabel ?? ''}</span>
281
+ {#if !isFlat}
282
+ <div class="stop-actions">
283
+ <UIPillButton
284
+ variant="secondary"
285
+ size="compact"
286
+ icon="fa-plus"
287
+ title="Add stop"
288
+ onclick={addStop}
289
+ >Add stop</UIPillButton>
290
+ <UIPillButton
291
+ variant="secondary"
292
+ size="compact"
293
+ icon="fa-times"
294
+ title={gradient.stops.length <= 2 ? 'Gradient needs at least two stops' : 'Remove selected stop'}
295
+ disabled={gradient.stops.length <= 2}
296
+ onclick={removeSelected}
297
+ >Remove stop</UIPillButton>
298
+ </div>
299
+ {/if}
194
300
  </div>
195
- </div>
196
-
197
- <div class="controls-row">
198
- <div class="type-toggle" role="radiogroup">
199
- <button
200
- type="button"
201
- class:active={gradient.type === 'linear'}
202
- onclick={() => setType('linear')}
203
- >Linear</button>
204
- <button
205
- type="button"
206
- class:active={gradient.type === 'radial'}
207
- onclick={() => setType('radial')}
208
- >Radial</button>
301
+ {#if isRadial}
302
+ <span class="editor-section-label editor-section-right">Gradient shape</span>
303
+ {:else if isLinear}
304
+ <span class="editor-section-label editor-section-right">Gradient angle</span>
305
+ {/if}
306
+ {/if}
307
+ <div class="ribbon-stack">
308
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
309
+ <div
310
+ class="ribbon"
311
+ class:solid={isFlat}
312
+ class:none={isNone}
313
+ class:radial={isRadial}
314
+ bind:this={barEl}
315
+ style="background: {ribbonBg};"
316
+ onclick={isFlat ? undefined : onRibbonClick}
317
+ role={isFlat ? 'presentation' : 'button'}
318
+ tabindex="-1"
319
+ aria-label={isNone ? 'No fill' : isFlat ? 'Solid color preview' : isRadial ? 'Click the right side to add a gradient stop' : 'Click to add a gradient stop'}
320
+ >
321
+ {#if isRadial}<span class="center-divider" aria-hidden="true"></span>{/if}
322
+ </div>
323
+ {#if !isFlat}
324
+ <div class="handles" class:radial={isRadial}>
325
+ {#if isRadial}
326
+ {#each gradient.stops as stop, i (`mirror-${i}`)}
327
+ <button
328
+ type="button"
329
+ class="handle"
330
+ class:selected={selected === i}
331
+ class:dragging={dragIndex === i && dragSide === 'left'}
332
+ style="left: {50 - stop.position / 2}%; --stop-color: {stopSwatches[i]};"
333
+ onpointerdown={(e) => onHandleDown(e, i, 'left')}
334
+ onpointermove={onHandleMove}
335
+ onpointerup={onHandleUp}
336
+ onpointercancel={onHandleUp}
337
+ title={`Stop ${i + 1} (${stop.position}%) — linked pair`}
338
+ aria-label={`Gradient stop ${i + 1}, mirrored side`}
339
+ >
340
+ <span class="handle-diamond"></span>
341
+ </button>
342
+ {/each}
343
+ {/if}
344
+ {#each gradient.stops as stop, i (i)}
345
+ <button
346
+ type="button"
347
+ class="handle"
348
+ class:selected={selected === i}
349
+ class:dragging={dragIndex === i && dragSide !== 'left'}
350
+ style="left: {isRadial ? 50 + stop.position / 2 : stop.position}%; --stop-color: {stopSwatches[i]};"
351
+ onpointerdown={(e) => onHandleDown(e, i, isRadial ? 'right' : null)}
352
+ onpointermove={onHandleMove}
353
+ onpointerup={onHandleUp}
354
+ onpointercancel={onHandleUp}
355
+ title={`Stop ${i + 1} (${stop.position}%)`}
356
+ aria-label={`Gradient stop ${i + 1}`}
357
+ >
358
+ <span class="handle-diamond"></span>
359
+ </button>
360
+ {/each}
361
+ </div>
362
+ {/if}
363
+ </div>
364
+ {#if gradient.type === 'radial'}
365
+ <div class="ribbon-pad">
366
+ <RadialShapePad
367
+ x={gradient.aspectX ?? 1}
368
+ y={gradient.aspectY ?? 1}
369
+ onchange={onAspectChange}
370
+ />
209
371
  </div>
210
- {#if gradient.type === 'linear'}
211
- <div class="angle-slot">
212
- <AngleDial value={gradient.angle} onchange={onAngleChange} />
372
+ {:else if gradient.type === 'linear'}
373
+ <div class="ribbon-pad ribbon-pad-linear">
374
+ <AngleDial value={gradient.angle} size={64} orientation="vertical" label="" onchange={onAngleChange} />
375
+ </div>
376
+ {/if}
377
+
378
+ <div class="lower-row">
379
+ <UISegmentedControl
380
+ value={gradient.type as TypeChoice}
381
+ options={typeOptions}
382
+ ariaLabel="Gradient fill type"
383
+ onchange={onTypeSelect}
384
+ />
385
+ {#if gradient.stops[selected]}
386
+ {@const stop = gradient.stops[selected]}
387
+ {@const stopMono = stop.monochrome !== false}
388
+ <div class="stop-edit-row">
389
+ <span class="row-label">{isFlat ? 'Color' : `Stop ${selected + 1}`}</span>
390
+ {#if !isFlat}
391
+ <label class="pos-input">
392
+ <input
393
+ type="number"
394
+ min="0"
395
+ max="100"
396
+ step="0.1"
397
+ value={stop.position}
398
+ onchange={onPositionInput}
399
+ />
400
+ <span class="suffix">%</span>
401
+ </label>
402
+ {/if}
403
+ <div class="picker-column">
404
+ <div class="picker-slot">
405
+ <GradientStopPicker
406
+ stopId={`${stopKeyPrefix}-${selected}`}
407
+ color={stop.color}
408
+ opacity={stop.opacity ?? 100}
409
+ familyFilter={stopMono ? familyFilter : null}
410
+ onchange={(payload) => handleStopChange(selected, payload)}
411
+ />
412
+ </div>
413
+ {#if familyFilter !== null}
414
+ <label class="stop-mono-check">
415
+ <input
416
+ type="checkbox"
417
+ checked={stopMono}
418
+ onchange={(e) => handleMonoToggle(selected, (e.currentTarget as HTMLInputElement).checked)}
419
+ />
420
+ <span>Monochrome</span>
421
+ </label>
422
+ {/if}
423
+ </div>
424
+ <span class="stop-value-text" title={stopValueLabel(stop)}>{stopValueLabel(stop)}</span>
213
425
  </div>
214
426
  {/if}
215
- <div class="spacer"></div>
216
- <button
217
- type="button"
218
- class="ghost-btn"
219
- onclick={addStop}
220
- title="Add stop"
221
- >
222
- <i class="fas fa-plus"></i> Add stop
223
- </button>
224
- <button
225
- type="button"
226
- class="ghost-btn"
227
- onclick={removeSelected}
228
- disabled={gradient.stops.length <= 2}
229
- title={gradient.stops.length <= 2 ? 'Gradient needs at least two stops' : 'Remove selected stop'}
230
- >
231
- <i class="fas fa-times"></i> Remove
232
- </button>
233
427
  </div>
234
428
 
235
- {#if gradient.stops[selected]}
236
- <div class="stop-edit-row">
237
- <span class="row-label">Stop {selected + 1}</span>
238
- <label class="pos-input">
239
- <input
240
- type="number"
241
- min="0"
242
- max="100"
243
- step="0.1"
244
- value={gradient.stops[selected].position}
245
- onchange={onPositionInput}
246
- />
247
- <span class="suffix">%</span>
248
- </label>
249
- <div class="picker-slot">
250
- <GradientStopPicker
251
- stopId={`${variable}-${selected}`}
252
- color={gradient.stops[selected].color}
253
- opacity={gradient.stops[selected].opacity ?? 100}
254
- onchange={(payload) => handleStopChange(selected, payload)}
255
- />
256
- </div>
429
+ {#if onsave || oncancel}
430
+ <div class="footer-row">
431
+ <UIPillButton variant="secondary" size="compact" onclick={cancel}>Cancel</UIPillButton>
432
+ <UIPillButton variant="primary" size="compact" onclick={save}>Save</UIPillButton>
257
433
  </div>
258
434
  {/if}
259
-
260
- <div class="footer-row">
261
- <button type="button" class="ghost-btn" onclick={cancel}>Cancel</button>
262
- <button type="button" class="primary-btn" onclick={save}>Save</button>
263
- </div>
264
435
  </div>
265
436
  {/if}
266
437
 
267
438
  <style>
439
+ /* Header labels share grid tracks with the ribbon + pad below them. */
268
440
  .gradient-editor {
441
+ display: grid;
442
+ grid-template-columns: minmax(0, 1fr);
443
+ row-gap: var(--ui-space-12);
444
+ width: 100%;
445
+ min-width: 0;
446
+ }
447
+ .gradient-editor.has-pad {
448
+ grid-template-columns: minmax(0, 1fr) max-content;
449
+ column-gap: var(--ui-space-16);
450
+ }
451
+
452
+ .editor-section-label {
453
+ font-size: var(--ui-font-size-md);
454
+ font-weight: 500;
455
+ color: var(--ui-text-primary);
456
+ line-height: 1;
457
+ }
458
+ .editor-section-left { grid-column: 1; }
459
+ .editor-section-right { grid-column: 2; }
460
+
461
+ /* Header doubles as a toolbar: label left, Add/Remove flush right. */
462
+ .editor-header {
269
463
  display: flex;
270
- flex-direction: column;
464
+ align-items: center;
465
+ justify-content: space-between;
271
466
  gap: var(--ui-space-12);
272
- width: 100%;
273
467
  min-width: 0;
274
468
  }
275
469
 
276
- .ribbon-wrap {
470
+ .ribbon-stack {
471
+ grid-column: 1;
277
472
  display: flex;
278
473
  flex-direction: column;
279
474
  gap: var(--ui-space-8);
475
+ min-width: 0;
476
+ }
477
+
478
+ /* min-height reserves the radial pad's full height so swapping types doesn't shift the lower row. */
479
+ .ribbon-pad {
480
+ grid-column: 2;
481
+ align-self: start;
482
+ min-height: 94px;
483
+ }
484
+ .ribbon-pad-linear {
485
+ display: flex;
486
+ justify-content: center;
280
487
  }
281
488
 
282
489
  .ribbon {
@@ -287,13 +494,35 @@
287
494
  cursor: copy;
288
495
  }
289
496
 
497
+ /* Flat ribbon: passive swatch, no click affordance. */
498
+ .ribbon.solid {
499
+ cursor: default;
500
+ }
501
+
502
+ /* Radial: left half is a mirror, so suppress the copy cursor across the whole ribbon. */
503
+ .ribbon.radial {
504
+ cursor: default;
505
+ }
506
+
507
+ /* Marks the radial center (position 0). */
508
+ .center-divider {
509
+ position: absolute;
510
+ top: 0;
511
+ bottom: 0;
512
+ left: 50%;
513
+ width: 1px;
514
+ background: var(--ui-border);
515
+ pointer-events: none;
516
+ transform: translateX(-0.5px);
517
+ }
518
+
519
+
290
520
  .handles {
291
521
  position: relative;
292
522
  height: 1.25rem;
293
523
  }
294
524
 
295
- /* Button is a generous (1.25rem) transparent hit target; the visible marker
296
- lives in `.handle-diamond` inside. Mirrors GradientCard. */
525
+ /* 1.25rem hit target; visible marker is the inner .handle-diamond. */
297
526
  .handle {
298
527
  position: absolute;
299
528
  top: 0;
@@ -337,75 +566,83 @@
337
566
  box-shadow: 0 0 0 2px var(--ui-text-primary);
338
567
  }
339
568
 
340
- .controls-row {
569
+
570
+ /* Per-gradient controls under the ribbon: type selector + stop edit row. */
571
+ .lower-row {
572
+ grid-column: 1 / -1;
341
573
  display: flex;
342
- align-items: center;
574
+ align-items: flex-start;
343
575
  gap: var(--ui-space-12);
344
576
  flex-wrap: wrap;
345
577
  }
346
578
 
347
- .spacer { flex: 1 1 auto; }
348
-
349
- .type-toggle {
579
+ .stop-actions {
350
580
  display: inline-flex;
351
- border: 1px solid var(--ui-border-low);
352
- border-radius: var(--ui-radius-md);
353
- overflow: hidden;
354
- }
355
-
356
- .type-toggle button {
357
- padding: var(--ui-space-4) var(--ui-space-12);
358
- background: transparent;
359
- border: none;
360
- color: var(--ui-text-secondary);
361
- font-size: var(--ui-font-size-sm);
362
- cursor: pointer;
363
- font-family: inherit;
581
+ align-items: center;
582
+ gap: var(--ui-space-6);
583
+ flex: 0 0 auto;
364
584
  }
365
585
 
366
- .type-toggle button.active {
367
- background: var(--ui-surface-high);
368
- color: var(--ui-text-primary);
586
+ /* Row 1: label / percent / picker / slug. Row 2: Monochrome under picker. */
587
+ .stop-edit-row {
588
+ display: flex;
589
+ flex-wrap: wrap;
590
+ align-items: flex-start;
591
+ gap: var(--ui-space-12);
592
+ flex: 1 1 18rem;
593
+ min-width: 0;
369
594
  }
370
-
371
- .type-toggle button + button {
372
- border-left: 1px solid var(--ui-border-low);
595
+ .stop-edit-row > .row-label,
596
+ .stop-edit-row > .pos-input,
597
+ .stop-edit-row > .stop-value-text {
598
+ line-height: 1.75rem;
373
599
  }
374
600
 
375
- .ghost-btn {
376
- display: inline-flex;
377
- align-items: center;
601
+ .picker-column {
602
+ display: flex;
603
+ flex-direction: column;
604
+ align-items: flex-start;
378
605
  gap: var(--ui-space-6);
379
- padding: var(--ui-space-4) var(--ui-space-10);
380
- background: var(--ui-surface-low);
381
- border: 1px solid var(--ui-border-low);
382
- border-radius: var(--ui-radius-sm);
383
- color: var(--ui-text-secondary);
384
- font-size: var(--ui-font-size-sm);
385
- cursor: pointer;
386
- font-family: inherit;
606
+ flex: 0 0 auto;
387
607
  }
388
608
 
389
- .ghost-btn:hover:not(:disabled) {
390
- color: var(--ui-text-primary);
391
- border-color: var(--ui-border);
609
+ /* Hide picker's built-in meta; we render the slug ourselves. */
610
+ .stop-edit-row :global(.ui-ts-meta-text) {
611
+ display: none;
392
612
  }
393
613
 
394
- .ghost-btn:disabled {
395
- opacity: 0.4;
396
- cursor: not-allowed;
614
+ /* Stop slug, mirrors UITokenSelector meta typography. */
615
+ .stop-value-text {
616
+ flex: 1 1 0;
617
+ min-width: 0;
618
+ color: var(--ui-text-tertiary);
619
+ font-family: var(--ui-font-mono);
620
+ font-size: var(--ui-font-size-sm);
621
+ overflow: hidden;
622
+ text-overflow: ellipsis;
623
+ white-space: nowrap;
397
624
  }
398
625
 
399
- .stop-edit-row {
400
- display: flex;
401
- flex-wrap: wrap;
626
+ .stop-mono-check {
627
+ display: inline-flex;
402
628
  align-items: center;
403
- gap: var(--ui-space-12);
629
+ gap: var(--ui-space-6);
630
+ font-size: var(--ui-font-size-sm);
631
+ color: var(--ui-text-secondary);
632
+ cursor: pointer;
633
+ user-select: none;
634
+ flex: 0 0 auto;
404
635
  }
636
+ .stop-mono-check:hover { color: var(--ui-text-primary); }
637
+ .stop-mono-check input { margin: 0; cursor: pointer; }
405
638
 
639
+ /* Peers the section label; 1.5rem left-pad aligns with the ribbon's content edge. */
406
640
  .row-label {
407
- font-size: var(--ui-font-size-xs);
408
- color: var(--ui-text-secondary);
641
+ font-size: var(--ui-font-size-md);
642
+ font-weight: 500;
643
+ color: var(--ui-text-primary);
644
+ line-height: 1;
645
+ padding-left: 1.5rem;
409
646
  white-space: nowrap;
410
647
  }
411
648
 
@@ -418,7 +655,7 @@
418
655
  }
419
656
 
420
657
  .pos-input input {
421
- width: 4.5rem;
658
+ width: 2.25rem;
422
659
  padding: var(--ui-space-2) var(--ui-space-6);
423
660
  background: var(--ui-surface-lowest);
424
661
  border: 1px solid var(--ui-border-low);
@@ -437,34 +674,17 @@
437
674
  color: var(--ui-text-tertiary);
438
675
  }
439
676
 
677
+ /* 8rem matches the property-row token selector width (see TokenLayout). */
440
678
  .picker-slot {
441
- flex: 1 1 12rem;
442
- min-width: 8rem;
679
+ flex: 0 0 auto;
680
+ width: 8rem;
443
681
  }
444
682
 
445
683
  .footer-row {
684
+ grid-column: 1 / -1;
446
685
  display: flex;
447
686
  justify-content: flex-end;
448
687
  gap: var(--ui-space-8);
449
688
  padding-top: var(--ui-space-4);
450
689
  }
451
-
452
- .primary-btn {
453
- display: inline-flex;
454
- align-items: center;
455
- gap: var(--ui-space-6);
456
- padding: var(--ui-space-4) var(--ui-space-12);
457
- background: var(--ui-surface-high);
458
- border: 1px solid var(--ui-border-high);
459
- border-radius: var(--ui-radius-sm);
460
- color: var(--ui-text-primary);
461
- font-size: var(--ui-font-size-sm);
462
- cursor: pointer;
463
- font-family: inherit;
464
- }
465
-
466
- .primary-btn:hover {
467
- background: var(--ui-surface-higher);
468
- border-color: var(--ui-border-higher);
469
- }
470
690
  </style>