@motion-proto/live-tokens 0.45.0 → 0.46.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 (38) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist-plugin/index.cjs +1 -1
  3. package/dist-plugin/index.js +1 -1
  4. package/package.json +1 -1
  5. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +0 -1
  6. package/src/editor/component-editor/scaffolding/ComponentFileMenu.svelte +1 -1
  7. package/src/editor/core/palettes/colorHarmony.ts +35 -6
  8. package/src/editor/core/palettes/paletteDerivation.ts +1 -1
  9. package/src/editor/core/store/editorTypes.ts +2 -2
  10. package/src/editor/docs/CodeBlock.svelte +0 -2
  11. package/src/editor/docs/Docs.svelte +0 -3
  12. package/src/editor/pages/ComponentEditorPage.svelte +3 -5
  13. package/src/editor/pages/EditorShell.svelte +3 -5
  14. package/src/editor/ui/ColorEditPanel.svelte +19 -22
  15. package/src/editor/ui/EditorViewSwitcher.svelte +2 -3
  16. package/src/editor/ui/FileLoadList.svelte +7 -9
  17. package/src/editor/ui/ManifestFileManager.svelte +1 -3
  18. package/src/editor/ui/ProjectFontsSection.svelte +0 -4
  19. package/src/editor/ui/SurfacesTab.svelte +3 -3
  20. package/src/editor/ui/TextStylesSection.svelte +6 -8
  21. package/src/editor/ui/ThemeFileManager.svelte +1 -3
  22. package/src/editor/ui/UIEasingSelector.svelte +0 -2
  23. package/src/editor/ui/UIFontFamilySelector.svelte +9 -3
  24. package/src/editor/ui/UIMenuButton.svelte +230 -0
  25. package/src/editor/ui/UIPaletteSelector.svelte +0 -1
  26. package/src/editor/ui/VariablesTab.svelte +3 -4
  27. package/src/editor/ui/colors/AxisNumeral.svelte +70 -0
  28. package/src/editor/ui/colors/ColorReadouts.svelte +0 -2
  29. package/src/editor/ui/colors/ColorWheel.svelte +48 -29
  30. package/src/editor/ui/colors/ColorsTab.svelte +361 -172
  31. package/src/editor/ui/colors/HarmonyAxesList.svelte +264 -141
  32. package/src/editor/ui/colors/LightnessBar.svelte +0 -2
  33. package/src/editor/ui/colors/harmonyDrag.ts +10 -0
  34. package/src/editor/ui/colors/harmonyModeIcons.ts +5 -0
  35. package/src/editor/ui/colors/paletteBaseColor.ts +25 -17
  36. package/src/live-tokens/data/tokens.generated.css +28 -28
  37. package/src/system/components/ImageLightbox.svelte +0 -2
  38. package/src/system/components/SectionDivider.svelte +4 -4
@@ -1,32 +1,34 @@
1
1
  <script lang="ts">
2
+ import { cubicOut } from 'svelte/easing';
2
3
  import { oklchToHexClamped } from '../../core/palettes/oklch';
3
4
  import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
4
5
  import { editorState, beginSliderGesture } from '../../core/store/editorStore';
5
- import { applyHarmonyToAxes, modeActiveAxes, tintNeutralsFromAnchor, type HarmonyMode } from '../../core/palettes/colorHarmony';
6
+ import { applyHarmonyToAxes, axisStatuses, HARMONY_ELIGIBLE, type HarmonyMode } from '../../core/palettes/colorHarmony';
7
+ import AxisNumeral from './AxisNumeral.svelte';
6
8
  import ColorEditPanel from '../ColorEditPanel.svelte';
7
9
  import ColorWheel from './ColorWheel.svelte';
8
- import LightnessBar from './LightnessBar.svelte';
9
10
  import ColorReadouts from './ColorReadouts.svelte';
10
11
  import ColorStory from './ColorStory.svelte';
11
12
  import PaletteStepStrip from './PaletteStepStrip.svelte';
12
13
  import HarmonyAxesList from './HarmonyAxesList.svelte';
13
- import UIPillButton from '../UIPillButton.svelte';
14
14
  import {
15
15
  setBaseColor,
16
- setBaseColors,
17
16
  setAxisHues,
17
+ unbindFamily,
18
18
  palettesWithDefaults,
19
19
  } from './paletteBaseColor';
20
20
  import { HARMONY_MODE_BUTTONS } from './harmonyModeIcons';
21
- import { dockGrow } from '../palette/dockMagnify';
21
+ import { HARMONY_DRAG_TYPE, startFamilyDrag } from './harmonyDrag';
22
+ import { maxChroma } from './colorWheelMath';
22
23
 
23
24
  let selected = $state('Brand');
24
25
  let activeMode = $state<HarmonyMode>('custom');
25
- let hoverMode = $state<HarmonyMode | null>(null);
26
26
  // Local UI only — deliberately NOT stored in PaletteConfig (shape unchanged).
27
27
  let absoluteChroma = $state(false);
28
28
  let infoOpen = $state(false);
29
29
  let infoWrap: HTMLElement | undefined = $state();
30
+ let axesInfoOpen = $state(false);
31
+ let axesInfoWrap: HTMLElement | undefined = $state();
30
32
 
31
33
  function select(label: string) {
32
34
  selected = label;
@@ -47,6 +49,15 @@
47
49
  return () => document.removeEventListener('click', onDoc);
48
50
  });
49
51
 
52
+ $effect(() => {
53
+ if (!axesInfoOpen) return;
54
+ const onDoc = (e: MouseEvent) => {
55
+ if (axesInfoWrap && !axesInfoWrap.contains(e.target as Node)) axesInfoOpen = false;
56
+ };
57
+ document.addEventListener('click', onDoc);
58
+ return () => document.removeEventListener('click', onDoc);
59
+ });
60
+
50
61
  function applyMode(mode: HarmonyMode) {
51
62
  if (mode === 'custom') {
52
63
  activeMode = 'custom';
@@ -59,23 +70,53 @@
59
70
  setAxisHues(target.map((a, i) => ({ index: i, hue: a.hue })), `colors: harmony ${mode}`);
60
71
  }
61
72
 
62
- function tintNeutrals() {
63
- const patch = tintNeutralsFromAnchor($editorState.palettes, $editorState.harmonyAxes[0].hue);
64
- if (Object.keys(patch).length) setBaseColors(patch, 'colors: tint neutrals from anchor');
73
+ const ELIGIBLE = new Set(HARMONY_ELIGIBLE);
74
+
75
+ const reduceMotion =
76
+ typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
77
+
78
+ /** The badge is the swatch's answer to "did it land": it fades up as the color
79
+ * takes an axis, and back out when the color comes home. It rides over the
80
+ * fill, so nothing reflows and opacity alone carries the change. */
81
+ function badgeFade(_node: Element) {
82
+ return {
83
+ duration: reduceMotion ? 0 : 200,
84
+ easing: cubicOut,
85
+ css: (t: number) => `opacity: ${t}; transform: translateY(-50%) scale(${0.85 + 0.15 * t});`,
86
+ };
87
+ }
88
+
89
+ // The swatch row is the home a family returns to: dropping a chip back here
90
+ // is the unassign gesture, which is why no separate tray exists.
91
+ let unassignHover = $state(false);
92
+
93
+ function onSwatchesDragOver(e: DragEvent) {
94
+ if (!(e.dataTransfer?.types ?? []).includes(HARMONY_DRAG_TYPE)) return;
95
+ e.preventDefault();
96
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
97
+ unassignHover = true;
98
+ }
99
+
100
+ function onSwatchesDrop(e: DragEvent) {
101
+ e.preventDefault();
102
+ unassignHover = false;
103
+ const family = e.dataTransfer?.getData(HARMONY_DRAG_TYPE);
104
+ if (family) unbindFamily(family);
65
105
  }
66
106
 
67
107
  let fullPalettes = $derived(palettesWithDefaults($editorState.palettes));
68
108
 
69
109
  let swatches = $derived.by(() => {
70
- // A family bound to an inactive axis edits in dot mode, so it is not "on the wheel".
71
- const active = modeActiveAxes(activeMode);
110
+ const statuses = axisStatuses(activeMode, $editorState.harmonyAxes);
72
111
  return PALETTE_SPECS.map((spec) => {
73
112
  const { l, c, h } = $editorState.palettes[spec.label]?.baseColor ?? spec.initialColor;
113
+ const index = $editorState.harmonyAxes.findIndex((a) => a.family === spec.label);
74
114
  return {
75
115
  label: spec.label,
76
116
  display: spec.displayLabel ?? spec.label,
77
117
  hex: oklchToHexClamped(l, c, h),
78
- onWheel: $editorState.harmonyAxes.some((a, i) => a.family === spec.label && active[i]),
118
+ axis: index === -1 ? null : { index, status: statuses[index] },
119
+ eligible: ELIGIBLE.has(spec.label),
79
120
  };
80
121
  });
81
122
  });
@@ -86,137 +127,171 @@
86
127
  let coreSwatches = $derived(swatches.filter((sw) => !FUNCTIONAL_FAMILIES.has(sw.label)));
87
128
  let functionalSwatches = $derived(swatches.filter((sw) => FUNCTIONAL_FAMILIES.has(sw.label)));
88
129
 
89
- let shownModeLabel = $derived(
90
- HARMONY_MODE_BUTTONS.find((b) => b.mode === (hoverMode ?? activeMode))?.label ?? '',
91
- );
92
-
93
130
  let selectedSpec = $derived(PALETTE_SPECS.find((s) => s.label === selected) ?? PALETTE_SPECS[0]);
94
131
  let selectedOklch = $derived($editorState.palettes[selected]?.baseColor ?? selectedSpec.initialColor);
132
+
133
+ const rFracOf = (l: number, c: number, h: number) =>
134
+ Math.min(1, Math.max(0, c / (maxChroma(l, h) || 1e-6)));
135
+
136
+ // H and L slider edits honor the Absolute Chroma toggle the way the wheel
137
+ // does: with it off, the relative saturation fraction holds while the gamut
138
+ // moves. An explicit chroma edit (C slider, hex, eyedropper) is taken as-is.
139
+ function editBase(h: number, c: number, lPct: number) {
140
+ const l = lPct / 100;
141
+ const cur = selectedOklch;
142
+ const chroma = absoluteChroma || c !== cur.c
143
+ ? c
144
+ : rFracOf(cur.l, cur.c, cur.h) * maxChroma(l, h);
145
+ setBaseColor(selected, { l, c: chroma, h });
146
+ }
95
147
  </script>
96
148
 
97
149
  <div class="colors-tab">
98
150
  <div class="pane">
99
151
  <section id="colors-wheel" class="block">
100
152
  <header class="block-head">
101
- <span class="eyebrow">Theme</span>
102
153
  <h2 class="title">Color Wheel</h2>
103
154
  </header>
104
155
 
105
- <ColorWheel
106
- {selected}
107
- {absoluteChroma}
108
- {activeMode}
109
- onSelect={select}
110
- discLightness={selectedOklch.l}
111
- onCustomize={() => (activeMode = 'custom')}
112
- />
113
-
114
- <LightnessBar {selected} {absoluteChroma} />
115
-
116
- <div class="group">
117
- <span class="eyebrow">Color Harmony <span class="mode-name">&middot; {shownModeLabel}</span></span>
118
- <div class="mode-row" role="group" aria-label="Color harmony mode">
119
- {#each HARMONY_MODE_BUTTONS as m (m.mode)}
120
- <button
121
- type="button"
122
- class="mode-btn"
123
- class:active={activeMode === m.mode}
124
- title={m.label}
125
- aria-label={m.label}
126
- aria-pressed={activeMode === m.mode}
127
- onclick={() => applyMode(m.mode)}
128
- onpointerenter={() => (hoverMode = m.mode)}
129
- onpointerleave={() => (hoverMode = null)}
130
- onfocus={() => (hoverMode = m.mode)}
131
- onblur={() => (hoverMode = null)}
132
- >{@html m.svg}</button>
133
- {/each}
134
- </div>
135
-
136
- <div class="harmony-actions">
137
- <UIPillButton
138
- icon="fa-fill-drip"
139
- title="Re-hue Neutral and Alternate to the anchor color (their own chroma and lightness kept)"
140
- onclick={tintNeutrals}
141
- >Tint neutrals from anchor</UIPillButton>
142
- </div>
143
-
144
- <div class="wheel-opts">
145
- <label class="opt">
146
- <input type="checkbox" bind:checked={absoluteChroma} />
147
- <span>Absolute Chroma</span>
148
- </label>
149
- <div class="info" bind:this={infoWrap}>
150
- <button
151
- type="button"
152
- class="info-btn"
153
- aria-expanded={infoOpen}
154
- aria-label="About Absolute Chroma"
155
- title="About Absolute Chroma"
156
- onclick={toggleInfo}
157
- ><i class="fas fa-circle-info" aria-hidden="true"></i></button>
158
- {#if infoOpen}
159
- <div class="info-box" role="region" aria-label="Absolute Chroma">
160
- <strong class="info-title">Absolute Chroma</strong>
161
- <p>When on, rotating a color keeps its exact chroma, its colorfulness. The reachable chroma changes from hue to hue, so the handle moves in and out as it orbits.</p>
162
- <p>When off, rotating keeps the color's relative saturation, its fraction of the available gamut. The handle stays a constant distance from the center, and colorfulness shifts a little across hues to stay in gamut.</p>
163
- </div>
164
- {/if}
165
- </div>
166
- </div>
167
- </div>
168
-
169
- <div class="group">
170
- <span class="eyebrow">Swatches</span>
171
- {#snippet swatchRow(row: typeof swatches)}
172
- {@const selIdx = row.findIndex((sw) => sw.label === selected)}
173
- <div class="swatch-row">
174
- {#each row as sw, i (sw.label)}
156
+ <div class="wheel-hero">
157
+ <div class="wheel-row">
158
+ <div class="mode-rail" role="group" aria-label="Color harmony mode">
159
+ {#each HARMONY_MODE_BUTTONS as m (m.mode)}
175
160
  <button
176
161
  type="button"
177
- class="swatch"
178
- class:active={selected === sw.label}
179
- style="--swatch-fill: {sw.hex}"
180
- style:flex-grow={dockGrow(i, selIdx === -1 ? null : selIdx)}
181
- title={sw.display}
182
- aria-label={`Select ${sw.display}`}
183
- aria-pressed={selected === sw.label}
184
- onclick={() => select(sw.label)}
185
- >
186
- <span class="chip">
187
- {#if sw.onWheel}<span class="wheel-dot" title="On the wheel" aria-hidden="true"></span>{/if}
188
- </span>
189
- <span class="swatch-label">{sw.label}</span>
190
- </button>
162
+ class="mode-btn"
163
+ class:active={activeMode === m.mode}
164
+ title={m.label}
165
+ aria-label={m.label}
166
+ aria-pressed={activeMode === m.mode}
167
+ onclick={() => applyMode(m.mode)}
168
+ >{@html m.svg}</button>
191
169
  {/each}
192
170
  </div>
193
- {/snippet}
194
- <div class="swatch-rows">
195
- {@render swatchRow(coreSwatches)}
196
- {@render swatchRow(functionalSwatches)}
171
+
172
+ <div class="wheel-slot">
173
+ <ColorWheel
174
+ {selected}
175
+ {absoluteChroma}
176
+ {activeMode}
177
+ onSelect={select}
178
+ discLightness={selectedOklch.l}
179
+ onCustomize={() => (activeMode = 'custom')}
180
+ />
181
+ </div>
197
182
  </div>
198
- </div>
199
183
 
200
- <div class="group">
201
- <span class="eyebrow">{selectedSpec.displayLabel ?? selected}</span>
202
184
  <ColorEditPanel
203
185
  title={selectedSpec.displayLabel ?? selected}
204
186
  hideActions
187
+ hidePreview
205
188
  hue={selectedOklch.h}
206
189
  chroma={selectedOklch.c}
207
190
  lightness={selectedOklch.l * 100}
208
- onHueChromaChange={(h, c, l) => setBaseColor(selected, { l: l / 100, c, h })}
191
+ onHueChromaChange={editBase}
209
192
  onSliderStart={() => beginSliderGesture(`colors: ${selected} base`)}
210
- />
193
+ >
194
+ {#snippet actions()}
195
+ <div class="wheel-opts">
196
+ <label class="opt">
197
+ <input type="checkbox" bind:checked={absoluteChroma} />
198
+ <span>Absolute Chroma</span>
199
+ </label>
200
+ <div class="info" bind:this={infoWrap}>
201
+ <button
202
+ type="button"
203
+ class="info-btn"
204
+ aria-expanded={infoOpen}
205
+ aria-label="About Absolute Chroma"
206
+ title="About Absolute Chroma"
207
+ onclick={toggleInfo}
208
+ ><i class="fas fa-circle-info" aria-hidden="true"></i></button>
209
+ {#if infoOpen}
210
+ <div class="info-box" role="region" aria-label="Absolute Chroma">
211
+ <strong class="info-title">Absolute Chroma</strong>
212
+ <p>When on, rotating a color keeps its exact chroma, its colorfulness. The reachable chroma changes from hue to hue, so the handle moves in and out as it orbits.</p>
213
+ <p>When off, rotating keeps the color's relative saturation, its fraction of the available gamut. The handle stays a constant distance from the center, and colorfulness shifts a little across hues to stay in gamut.</p>
214
+ </div>
215
+ {/if}
216
+ </div>
217
+ </div>
218
+ {/snippet}
219
+ </ColorEditPanel>
211
220
  </div>
212
221
 
213
- <div class="group">
214
- <span class="eyebrow">Derived scale</span>
215
- <PaletteStepStrip spec={selectedSpec} />
216
- </div>
222
+ <div class="harmony-cols">
223
+ <div class="group">
224
+ <h3 class="title">Selected color</h3>
225
+ {#snippet swatchRow(row: typeof swatches, acceptsDrop = false)}
226
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
227
+ <div
228
+ class="swatch-row"
229
+ class:drop-target={acceptsDrop && unassignHover}
230
+ ondragover={acceptsDrop ? onSwatchesDragOver : undefined}
231
+ ondragleave={acceptsDrop ? () => (unassignHover = false) : undefined}
232
+ ondrop={acceptsDrop ? onSwatchesDrop : undefined}
233
+ >
234
+ {#each row as sw (sw.label)}
235
+ <button
236
+ type="button"
237
+ class="swatch"
238
+ class:active={selected === sw.label}
239
+ class:draggable={sw.eligible}
240
+ style="--swatch-fill: {sw.hex}"
241
+ draggable={sw.eligible}
242
+ title={sw.eligible ? `${sw.display} · drag onto an axis to assign it` : sw.display}
243
+ aria-label={`Select ${sw.display}`}
244
+ aria-pressed={selected === sw.label}
245
+ ondragstart={(e) => startFamilyDrag(e, sw.label)}
246
+ onclick={() => select(sw.label)}
247
+ >
248
+ <span class="frame">
249
+ {#if sw.eligible}<span class="grip" aria-hidden="true">⋮⋮</span>{/if}
250
+ <span class="bar">
251
+ {#if sw.axis}
252
+ <span class="axis-badge" aria-hidden="true" transition:badgeFade>
253
+ <AxisNumeral index={sw.axis.index} status={sw.axis.status} scrim />
254
+ </span>
255
+ {/if}
256
+ </span>
257
+ </span>
258
+ <span class="swatch-label">{sw.label}</span>
259
+ </button>
260
+ {/each}
261
+ </div>
262
+ {/snippet}
263
+ <div class="swatch-rows">
264
+ {@render swatchRow(coreSwatches, true)}
265
+ {@render swatchRow(functionalSwatches)}
266
+ </div>
267
+ </div>
217
268
 
218
- <div class="group">
219
- <ColorReadouts color={selectedOklch} />
269
+ <div class="gutter" aria-hidden="true">
270
+ <i class="fas fa-arrows-left-right" title="Drag a color onto an axis to assign it, back to the swatches to unassign"></i>
271
+ </div>
272
+
273
+ <div class="group">
274
+ <div class="group-head">
275
+ <h3 class="title">Harmony axes</h3>
276
+ <div class="info" bind:this={axesInfoWrap}>
277
+ <button
278
+ type="button"
279
+ class="info-btn"
280
+ aria-expanded={axesInfoOpen}
281
+ aria-label="About harmony axes"
282
+ title="About harmony axes"
283
+ onclick={() => (axesInfoOpen = !axesInfoOpen)}
284
+ ><i class="fas fa-circle-info" aria-hidden="true"></i></button>
285
+ {#if axesInfoOpen}
286
+ <div class="info-box" role="region" aria-label="Harmony axes">
287
+ <strong class="info-title">Harmony axes</strong>
288
+ <p>Each axis owns a hue. Assign a color from the row menu, or drag one onto the axis. The axis moves to that color's hue and the color follows the axis from then on, so applying a harmony rotates every assigned color at once. To unassign, use the row menu, or drag the color off its axis and drop it anywhere else.</p>
289
+ </div>
290
+ {/if}
291
+ </div>
292
+ </div>
293
+ <HarmonyAxesList {activeMode} {selected} oncustomize={() => (activeMode = 'custom')} />
294
+ </div>
220
295
  </div>
221
296
  </section>
222
297
  </div>
@@ -224,16 +299,23 @@
224
299
  <div class="pane">
225
300
  <section id="colors-story" class="block">
226
301
  <header class="block-head">
227
- <span class="eyebrow">Proportional preview</span>
228
302
  <h2 class="title">Color Story</h2>
229
303
  </header>
230
304
  <ColorStory full={fullPalettes} />
231
305
  </section>
232
306
 
233
- <section id="colors-axes" class="block">
234
- <h2 class="title">Harmony axes</h2>
235
- <p class="axes-desc">Each axis owns a hue. Drop a color on an axis to bind it. The color adopts the axis hue and follows the axis. Drag a color to Unassigned to let it float free.</p>
236
- <HarmonyAxesList {activeMode} {selected} onSelect={select} />
307
+ <section id="colors-selected" class="block">
308
+ <header class="block-head">
309
+ <h2 class="title">Palette <span class="mode-name">&middot; {selectedSpec.displayLabel ?? selected}</span></h2>
310
+ </header>
311
+
312
+ <div class="group">
313
+ <PaletteStepStrip spec={selectedSpec} />
314
+ </div>
315
+
316
+ <div class="group">
317
+ <ColorReadouts color={selectedOklch} />
318
+ </div>
237
319
  </section>
238
320
  </div>
239
321
  </div>
@@ -264,11 +346,10 @@
264
346
  min-width: 0;
265
347
  }
266
348
 
267
- .axes-desc {
268
- margin: 0;
269
- font-size: var(--ui-font-size-md);
270
- line-height: 1.5;
271
- color: var(--ui-text-secondary);
349
+ .group-head {
350
+ display: flex;
351
+ align-items: center;
352
+ gap: var(--ui-space-6);
272
353
  }
273
354
 
274
355
  .block {
@@ -278,24 +359,25 @@
278
359
  min-width: 0;
279
360
  }
280
361
 
281
- .block-head {
362
+ /* The wheel and its lightness bar are one instrument, and the hero of the
363
+ view. Bind them tighter than the block rhythm and add the difference back
364
+ below, so the controls that follow stop reading at the wheel's weight. */
365
+ .wheel-hero {
282
366
  display: flex;
283
367
  flex-direction: column;
284
- gap: var(--ui-space-2);
285
- }
286
-
287
- .eyebrow {
288
- font-size: var(--ui-font-size-xs);
289
- font-weight: var(--ui-font-weight-semibold);
290
- color: var(--ui-text-tertiary);
291
- text-transform: uppercase;
292
- letter-spacing: 0.04em;
368
+ gap: var(--ui-space-12);
369
+ margin-bottom: var(--ui-space-12);
370
+ min-width: 0;
293
371
  }
294
372
 
295
- .eyebrow .mode-name {
296
- color: var(--ui-text-secondary);
373
+ .block-head {
374
+ display: flex;
375
+ flex-direction: column;
376
+ gap: var(--ui-space-2);
297
377
  }
298
378
 
379
+ /* Every labeled region on this page carries the same heading, section and
380
+ group alike: the old xs tertiary eyebrows read as captions and got skipped. */
299
381
  .title {
300
382
  font-size: var(--ui-font-size-2xl);
301
383
  font-weight: var(--ui-font-weight-semibold);
@@ -303,25 +385,87 @@
303
385
  margin: 0;
304
386
  }
305
387
 
388
+ .title .mode-name {
389
+ font-weight: var(--ui-font-weight-normal);
390
+ color: var(--ui-text-secondary);
391
+ }
392
+
306
393
  .group {
307
394
  display: flex;
308
395
  flex-direction: column;
309
- gap: var(--ui-space-8);
396
+ gap: var(--ui-space-12);
310
397
  min-width: 0;
311
398
  }
312
399
 
313
- .mode-row {
400
+ /* Swatches, drag gutter, axes. The axes column is the narrower of the two:
401
+ its rows are fixed furniture, while the swatches carry the color. */
402
+ .harmony-cols {
403
+ display: grid;
404
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 0.62fr);
405
+ gap: var(--ui-space-20);
406
+ align-items: start;
407
+ }
408
+
409
+ /* Names the gesture the two columns share, centered on the drag path. */
410
+ .gutter {
411
+ align-self: center;
412
+ display: flex;
413
+ color: var(--ui-text-muted);
414
+ font-size: var(--ui-font-size-md);
415
+ }
416
+
417
+ /* Rail anchored to the pane's left edge; the disc centers in whatever is left. */
418
+ .wheel-row {
314
419
  display: flex;
420
+ align-items: center;
421
+ gap: var(--ui-space-20);
422
+ min-width: 0;
423
+ }
424
+
425
+ .wheel-slot {
426
+ flex: 1 1 auto;
427
+ min-width: 0;
428
+ }
429
+
430
+ .mode-rail {
431
+ flex: 0 0 auto;
432
+ display: flex;
433
+ flex-direction: column;
315
434
  gap: var(--ui-space-6);
316
- flex-wrap: wrap;
435
+ }
436
+
437
+ /* The docked panel's iframe viewport is the panel itself, so this collapse
438
+ fires there long before the standalone page ever gets this narrow. */
439
+ @media (max-width: 720px) {
440
+ .harmony-cols {
441
+ grid-template-columns: minmax(0, 1fr);
442
+ }
443
+
444
+ /* Stacked, the drag runs top to bottom. */
445
+ .gutter {
446
+ justify-content: center;
447
+ transform: rotate(90deg);
448
+ }
449
+
450
+ /* Below the wheel's minimum the rail is taller than the disc it flanks. */
451
+ .wheel-row {
452
+ flex-direction: column;
453
+ align-items: stretch;
454
+ }
455
+
456
+ .mode-rail {
457
+ flex-direction: row;
458
+ flex-wrap: wrap;
459
+ justify-content: center;
460
+ }
317
461
  }
318
462
 
319
463
  .mode-btn {
320
464
  display: flex;
321
465
  align-items: center;
322
466
  justify-content: center;
323
- width: 3.375rem;
324
- height: 3.375rem;
467
+ width: 3rem;
468
+ height: 3rem;
325
469
  padding: 0;
326
470
  background: var(--ui-surface-lowest);
327
471
  border: 1px solid var(--ui-border-low);
@@ -353,6 +497,7 @@
353
497
  display: flex;
354
498
  align-items: center;
355
499
  gap: var(--ui-space-8);
500
+ margin-left: auto;
356
501
  }
357
502
 
358
503
  .opt {
@@ -426,11 +571,6 @@
426
571
  color: var(--ui-text-secondary);
427
572
  }
428
573
 
429
- .harmony-actions {
430
- display: flex;
431
- gap: var(--ui-space-8);
432
- }
433
-
434
574
  .swatch-rows {
435
575
  display: flex;
436
576
  flex-direction: column;
@@ -438,14 +578,23 @@
438
578
  min-width: 0;
439
579
  }
440
580
 
441
- /* Dock magnification, mirroring PaletteStepStrip: the flex-grow transition
442
- animates the selected swatch opening in place. */
443
581
  .swatch-row {
444
582
  display: flex;
445
583
  align-items: flex-end;
446
584
  gap: var(--ui-space-8);
585
+ border-radius: var(--ui-radius-md);
586
+ outline: 1px solid transparent;
587
+ outline-offset: var(--ui-space-6);
588
+ transition: outline-color var(--ui-transition-fast);
589
+ }
590
+
591
+ /* Mirrors the axis row's drop ring: this is where a chip goes to unassign. */
592
+ .swatch-row.drop-target {
593
+ outline-color: var(--ui-text-primary);
447
594
  }
448
595
 
596
+ /* Every swatch keeps one width: selection reads in the frame's height alone,
597
+ so nothing in the row shifts sideways as the selection moves. */
449
598
  .swatch {
450
599
  flex: 1 1 0;
451
600
  min-width: 0;
@@ -457,38 +606,57 @@
457
606
  background: none;
458
607
  border: none;
459
608
  cursor: pointer;
460
- transition: flex-grow var(--ui-transition-fast);
461
609
  }
462
610
 
463
- .chip {
611
+ /* Same grab idiom as the axes-list chips: these two are one drag surface. */
612
+ .swatch.draggable {
613
+ cursor: grab;
614
+ }
615
+
616
+ .swatch.draggable:active {
617
+ cursor: grabbing;
618
+ }
619
+
620
+ /* The same frame the axes-list chips wear, holding the color instead of a
621
+ name: one object in two states, wide here and compact once it lands. */
622
+ .frame {
464
623
  position: relative;
465
- display: block;
466
- height: 2.75rem;
624
+ display: flex;
625
+ align-items: center;
626
+ gap: var(--ui-space-6);
627
+ height: 2.25rem;
467
628
  /* Compensating margin: height + margin-top is constant every frame of the
468
629
  magnification, so the row's layout height never dips mid-transition.
469
- Chip grows upward, label pinned. */
630
+ Frame grows upward, label pinned. */
470
631
  margin-top: 0.5rem;
471
- border-radius: var(--ui-radius-sm);
472
- background: var(--swatch-fill);
632
+ padding: var(--ui-space-4) var(--ui-space-6);
633
+ background: var(--ui-surface-low);
473
634
  border: 1px solid var(--ui-border-low);
635
+ border-radius: var(--ui-radius-md);
474
636
  transition: border-color var(--ui-transition-fast), height var(--ui-transition-fast), margin-top var(--ui-transition-fast);
475
637
  }
476
638
 
477
- .swatch.active .chip {
478
- height: 3.25rem;
479
- margin-top: 0;
480
- }
481
-
482
- .swatch:hover .chip {
483
- border-color: var(--ui-border-high);
639
+ .bar {
640
+ position: relative;
641
+ flex: 1;
642
+ min-width: 0;
643
+ align-self: stretch;
644
+ border-radius: var(--ui-radius-sm);
645
+ background: var(--swatch-fill);
484
646
  }
485
647
 
486
- .swatch.active .chip {
648
+ .swatch.active .frame {
649
+ height: 2.75rem;
650
+ margin-top: 0;
487
651
  border-color: var(--ui-border-higher);
488
652
  outline: 2px solid var(--ui-text-primary);
489
653
  outline-offset: 1px;
490
654
  }
491
655
 
656
+ .swatch:hover .frame {
657
+ border-color: var(--ui-border-high);
658
+ }
659
+
492
660
  .swatch-label {
493
661
  font-size: var(--ui-font-size-xs);
494
662
  color: var(--ui-text-secondary);
@@ -502,18 +670,39 @@
502
670
  color: var(--ui-text-primary);
503
671
  }
504
672
 
505
- .wheel-dot {
673
+ /* Rides the head of the color bar, opposite the grip: how to send it, and
674
+ where it went. Larger than the row numerals because this is the one place
675
+ the assignment has to read at a glance across six swatches. The badge sits
676
+ on an arbitrary user-chosen fill, so no surface token can keep it legible.
677
+ This scrim is the one literal color the file is allowed. */
678
+ .axis-badge {
679
+ --numeral-size: 1.5rem;
680
+ --numeral-font-size: var(--ui-font-size-sm);
506
681
  position: absolute;
507
- top: var(--ui-space-4);
508
- right: var(--ui-space-4);
509
- width: 6px;
510
- height: 6px;
682
+ top: 50%;
683
+ left: var(--ui-space-4);
684
+ display: flex;
685
+ transform: translateY(-50%);
511
686
  border-radius: var(--ui-radius-full);
512
- background: var(--ui-text-primary);
513
- box-shadow: 0 0 0 1.5px var(--ui-surface-lowest);
687
+ background: rgba(0, 0, 0, 0.72);
514
688
  pointer-events: none;
515
689
  }
516
690
 
691
+ /* Reads on the frame, not the fill, exactly as the axes-list grip does. */
692
+ .grip {
693
+ flex: none;
694
+ color: var(--ui-text-secondary);
695
+ font-size: var(--ui-font-size-sm);
696
+ line-height: 1;
697
+ letter-spacing: -0.1em;
698
+ pointer-events: none;
699
+ user-select: none;
700
+ }
701
+
702
+ .swatch:hover .grip {
703
+ color: var(--ui-text-primary);
704
+ }
705
+
517
706
  @media (max-width: 1024px) {
518
707
  .colors-tab {
519
708
  grid-template-columns: minmax(0, 1fr);