@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,27 +1,35 @@
1
1
  <script lang="ts">
2
2
  import { tick } from 'svelte';
3
- import { flip } from 'svelte/animate';
4
- import { cubicOut } from 'svelte/easing';
5
3
  import { oklchToHexClamped } from '../../core/palettes/oklch';
6
4
  import { PALETTE_SPECS, type PaletteSpec } from '../../core/palettes/paletteDerivation';
7
- import { AXIS_ROLES, modeActiveAxes, type HarmonyMode, HARMONY_ELIGIBLE } from '../../core/palettes/colorHarmony';
5
+ import { AXIS_COUNT, activeAxisCount, axisLabel, axisStatuses, type HarmonyMode, HARMONY_ELIGIBLE } from '../../core/palettes/colorHarmony';
8
6
  import { editorState } from '../../core/store/editorStore';
7
+ import UIMenuButton from '../UIMenuButton.svelte';
8
+ import UIOptionItem from '../UIOptionItem.svelte';
9
+ import UIOptionList from '../UIOptionList.svelte';
10
+ import { modeLabel } from './harmonyModeIcons';
9
11
  import { bindFamilyToAxis, unbindFamily } from './paletteBaseColor';
12
+ import { HARMONY_DRAG_TYPE, startFamilyDrag } from './harmonyDrag';
10
13
 
11
14
  interface Props {
12
- /** The applied harmony mode: an unbound slot is disabled when the geometry
13
- * deals it no distinct position (complementary enables only the first two). */
15
+ /** The applied harmony mode: it deals a distinct position to some axes only,
16
+ * which is what leaves an axis off the wheel or unused (complementary deals two). */
14
17
  activeMode: HarmonyMode;
15
- /** Family selected in the swatch grid / wheel — its row (or chip) reads as active here. */
18
+ /** Family selected in the swatch grid / wheel — its row reads as active here. */
16
19
  selected: string | null;
17
- onSelect: (family: string) => void;
20
+ /** An assignment moves its axis to the color's hue, which leaves an applied
21
+ * mode no longer describing the geometry. Mirrors the wheel's handle drag. */
22
+ oncustomize: () => void;
18
23
  }
19
24
 
20
- let { activeMode, selected, onSelect }: Props = $props();
25
+ let { activeMode, selected, oncustomize }: Props = $props();
21
26
 
22
- const DRAG_TYPE = 'application/x-harmony-family';
23
- // Unbound swatch previews the hue a dropped color would take, at fixed preview
24
- // L/C so only the hue reads (Reserved judgment call 4).
27
+ function assign(family: string, index: number) {
28
+ if (bindFamilyToAxis(family, index)) oncustomize();
29
+ }
30
+
31
+ // Empty-row swatch shows the axis's own hue, at fixed preview L/C so only the
32
+ // hue reads. An assignment overwrites it with the color's hue.
25
33
  const PREVIEW_L = 0.65;
26
34
  const PREVIEW_C = 0.12;
27
35
 
@@ -30,36 +38,39 @@
30
38
  let listEl: HTMLElement | undefined = $state();
31
39
 
32
40
  let axes = $derived($editorState.harmonyAxes);
33
- let unassigned = $derived(HARMONY_ELIGIBLE.filter((f) => !axes.some((a) => a.family === f)));
34
- let axisDisabled = $derived(modeActiveAxes(activeMode).map((active, i) => !active && axes[i].family === null));
35
- // Keyboard sequence: the enabled axes then Unassigned.
36
- let axisSeq = $derived(axisDisabled.flatMap((disabled, i) => (disabled ? [] : [i])));
37
- let positionCount = $derived(axisSeq.length + 1);
38
- let lastAxis = $derived(axisSeq[axisSeq.length - 1]);
41
+ let statuses = $derived(axisStatuses(activeMode, axes));
42
+ let modeUsage = $derived(`${modeLabel(activeMode)} uses ${activeAxisCount(activeMode)}`);
43
+ // Keyboard move sequence: every axis that still accepts a family.
44
+ let axisSeq = $derived(statuses.flatMap((s, i) => (s === 'unused' ? [] : [i])));
39
45
 
40
46
  function familyHex(family: string): string {
41
47
  const { l, c, h } = $editorState.palettes[family]?.baseColor ?? SPEC_BY_LABEL[family].initialColor;
42
48
  return oklchToHexClamped(l, c, h);
43
49
  }
44
50
 
45
- // Keep focus on the moved family so keyboard bind/move/unbind chains without
46
- // hunting for the row it landed in.
51
+ // Keep focus on the moved family so keyboard moves chain without hunting for
52
+ // the row it landed in.
47
53
  async function refocus(family: string) {
48
54
  await tick();
49
55
  listEl?.querySelector<HTMLElement>(`[data-family="${family}"]`)?.focus();
50
56
  }
51
57
 
58
+ // The row a family leaves is where focus goes when it stops being a chip:
59
+ // both trigger forms answer to this selector, so an emptied row keeps focus.
60
+ async function refocusAxis(i: number) {
61
+ await tick();
62
+ listEl?.querySelector<HTMLElement>(`[data-axis="${i}"] [aria-haspopup="menu"]`)?.focus();
63
+ }
64
+
52
65
  function moveFamily(family: string, dir: -1 | 1) {
53
66
  const idx = axes.findIndex((a) => a.family === family);
54
- const pos = idx === -1 ? axisSeq.length : axisSeq.indexOf(idx);
55
- const target = pos + dir;
56
- if (target < 0 || target > axisSeq.length) return;
57
- if (target === axisSeq.length) unbindFamily(family);
58
- else bindFamilyToAxis(family, axisSeq[target]);
67
+ const target = axisSeq.indexOf(idx) + dir;
68
+ if (target < 0 || target >= axisSeq.length) return;
69
+ assign(family, axisSeq[target]);
59
70
  refocus(family);
60
71
  }
61
72
 
62
- function onChipKeydown(e: KeyboardEvent, family: string) {
73
+ function onChipKeydown(e: KeyboardEvent, family: string, i: number) {
63
74
  if (e.key === 'ArrowUp') {
64
75
  e.preventDefault();
65
76
  moveFamily(family, -1);
@@ -69,125 +80,188 @@
69
80
  } else if (e.key === 'Delete' || e.key === 'Backspace') {
70
81
  e.preventDefault();
71
82
  unbindFamily(family);
72
- refocus(family);
83
+ refocusAxis(i);
73
84
  }
74
85
  }
75
86
 
76
- let dragTarget = $state<number | 'unassigned' | null>(null);
87
+ function assignFromMenu(family: string, index: number) {
88
+ const wasEmpty = axes[index].family === null;
89
+ assign(family, index);
90
+ // An empty row's text trigger is replaced by the chip, so focus would die
91
+ // with it; follow the family, as every keyboard move does.
92
+ if (wasEmpty) refocus(family);
93
+ }
94
+
95
+ function clearFromMenu(index: number) {
96
+ const family = axes[index].family;
97
+ if (family === null) return;
98
+ unbindFamily(family);
99
+ // Same reason: the chevron trigger leaves with the chip.
100
+ refocusAxis(index);
101
+ }
77
102
 
78
- function onDragStart(e: DragEvent, family: string) {
79
- if (!e.dataTransfer) return;
80
- e.dataTransfer.effectAllowed = 'move';
81
- e.dataTransfer.setData(DRAG_TYPE, family);
103
+ // The row is the control, so a click anywhere in it opens the menu. Routed
104
+ // through the trigger's own click rather than an imperative open, so the
105
+ // button keeps sole ownership of the open state and of aria-expanded.
106
+ function onRowClick(e: MouseEvent) {
107
+ const el = e.target as HTMLElement;
108
+ if (el.closest('[aria-haspopup="menu"]')) return;
109
+ (e.currentTarget as HTMLElement).querySelector<HTMLButtonElement>('[aria-haspopup="menu"]')?.click();
82
110
  }
83
111
 
84
- function onDragOver(e: DragEvent, target: number | 'unassigned') {
85
- if (!(e.dataTransfer?.types ?? []).includes(DRAG_TYPE)) return;
86
- if (typeof target === 'number' && axisDisabled[target]) return;
112
+ let dragTarget = $state<number | null>(null);
113
+
114
+ function onDragOver(e: DragEvent, target: number) {
115
+ if (!(e.dataTransfer?.types ?? []).includes(HARMONY_DRAG_TYPE)) return;
116
+ if (statuses[target] === 'unused') return;
87
117
  e.preventDefault();
88
118
  if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
89
119
  dragTarget = target;
90
120
  }
91
121
 
92
- function onDragLeave(target: number | 'unassigned') {
122
+ function onDragLeave(target: number) {
93
123
  if (dragTarget === target) dragTarget = null;
94
124
  }
95
125
 
96
- function onDrop(e: DragEvent, target: number | 'unassigned') {
126
+ function onDrop(e: DragEvent, target: number) {
97
127
  e.preventDefault();
98
- const family = e.dataTransfer?.getData(DRAG_TYPE);
128
+ const family = e.dataTransfer?.getData(HARMONY_DRAG_TYPE);
99
129
  dragTarget = null;
100
130
  if (!family) return;
101
- if (typeof target === 'number' && axisDisabled[target]) return;
131
+ if (statuses[target] === 'unused') return;
102
132
  // Self-drop is a no-op: the setters early-return when nothing changes.
103
- if (target === 'unassigned') unbindFamily(family);
104
- else bindFamilyToAxis(family, target);
133
+ assign(family, target);
105
134
  }
106
135
 
136
+ // Window-scoped: a drag can start in the swatch row, whose dragend never
137
+ // reaches this component, and abandoning it there would strand the highlight.
107
138
  function onDragEnd() {
108
139
  dragTarget = null;
109
140
  }
141
+
142
+ // Opening the whole page as a drop zone is what lets a release over dead
143
+ // space mean something. Only our own payload, so file drops are untouched.
144
+ function onWindowDragOver(e: DragEvent) {
145
+ if (!(e.dataTransfer?.types ?? []).includes(HARMONY_DRAG_TYPE)) return;
146
+ e.preventDefault();
147
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
148
+ }
149
+
150
+ /** The drop no axis row and no swatch row claimed: letting a color go over
151
+ * dead space is the quickest way off an axis. A drop target that handled it
152
+ * has already called preventDefault on this same event. Escape cancels a
153
+ * drag without ever firing a drop, so it still leaves the color where it is. */
154
+ function onWindowDrop(e: DragEvent) {
155
+ if (e.defaultPrevented) return;
156
+ e.preventDefault();
157
+ dragTarget = null;
158
+ const family = e.dataTransfer?.getData(HARMONY_DRAG_TYPE);
159
+ if (family) unbindFamily(family);
160
+ }
110
161
  </script>
111
162
 
163
+ <svelte:window ondragend={onDragEnd} ondragover={onWindowDragOver} ondrop={onWindowDrop} />
164
+
165
+ {#snippet axisMenu(i: number, close: () => void)}
166
+ <UIOptionList>
167
+ {#each HARMONY_ELIGIBLE as family (family)}
168
+ {@const from = axes.findIndex((a) => a.family === family)}
169
+ <UIOptionItem
170
+ active={from === i}
171
+ onclick={() => {
172
+ assignFromMenu(family, i);
173
+ close();
174
+ }}
175
+ >
176
+ {#snippet preview()}<span class="menu-swatch" style="--fill: {familyHex(family)}"></span>{/snippet}
177
+ {#snippet label()}{family}{/snippet}
178
+ {#snippet meta()}{#if from !== -1 && from !== i}moves from {axisLabel(from)}{/if}{/snippet}
179
+ </UIOptionItem>
180
+ {/each}
181
+ </UIOptionList>
182
+ <div class="menu-sep" role="separator"></div>
183
+ <UIOptionList>
184
+ <UIOptionItem
185
+ active={axes[i].family === null}
186
+ onclick={() => {
187
+ clearFromMenu(i);
188
+ close();
189
+ }}
190
+ >
191
+ {#snippet label()}Leave empty{/snippet}
192
+ </UIOptionItem>
193
+ </UIOptionList>
194
+ {/snippet}
195
+
112
196
  <div class="axes-list" bind:this={listEl}>
113
197
  {#each axes as axis, i (i)}
114
- {@const disabled = axisDisabled[i]}
115
- <!-- svelte-ignore a11y_no_static_element_interactions -->
198
+ {@const status = statuses[i]}
199
+ {@const unused = status === 'unused'}
200
+ {@const rowSelected = axis.family !== null && axis.family === selected}
201
+ <!-- The click is a shortcut to the trigger button inside, which stays the
202
+ keyboard and screen-reader path. -->
203
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
116
204
  <div
117
205
  class="axis-row"
118
- class:disabled
119
- class:selected={axis.family !== null && axis.family === selected}
206
+ class:unused
207
+ class:selected={rowSelected}
120
208
  class:drop-target={dragTarget === i}
121
- aria-disabled={disabled || undefined}
209
+ class:off-wheel={status === 'off-wheel'}
210
+ class:openable={!unused}
211
+ data-axis={i}
212
+ aria-disabled={unused || undefined}
213
+ onclick={onRowClick}
122
214
  ondragover={(e) => onDragOver(e, i)}
123
215
  ondragleave={() => onDragLeave(i)}
124
216
  ondrop={(e) => onDrop(e, i)}
125
217
  >
126
- <span class="role"><span class="role-num">{i + 1}.</span> {AXIS_ROLES[i]}</span>
218
+ <span class="role">{i === 0 ? 'Anchor' : ''}</span>
127
219
  {#if axis.family !== null}
128
220
  {@const family = axis.family}
129
221
  <span class="swatch" style="--fill: {familyHex(family)}"></span>
130
222
  <button
131
223
  type="button"
132
224
  class="chip"
133
- class:selected={family === selected}
134
225
  draggable="true"
135
226
  data-family={family}
136
- aria-label={`${family}, ${AXIS_ROLES[i]} axis, position ${i + 1} of ${positionCount}. Arrow up or down to move it, Delete to unbind.`}
137
- aria-pressed={family === selected}
138
- title="Drag to another axis or to Unassigned. Arrow keys move it, Delete unbinds."
139
- ondragstart={(e) => onDragStart(e, family)}
140
- ondragend={onDragEnd}
141
- onkeydown={(e) => onChipKeydown(e, family)}
142
- onclick={() => onSelect(family)}
227
+ aria-label={`${family}, on ${axisLabel(i)}${i === 0 ? '' : ` of ${AXIS_COUNT}`}. Arrow up or down to move it, Delete to unassign.`}
228
+ title="Drag to another axis, or anywhere off the list to unassign. Arrow keys move it, Delete unassigns."
229
+ ondragstart={(e) => startFamilyDrag(e, family)}
230
+ onkeydown={(e) => onChipKeydown(e, family, i)}
143
231
  >
144
232
  <span class="grip" aria-hidden="true">⋮⋮</span>
145
233
  <span class="chip-swatch" style="--fill: {familyHex(family)}"></span>
146
234
  <span class="chip-name">{family}</span>
147
235
  </button>
236
+ {#if status === 'off-wheel'}
237
+ <span
238
+ class="warn fas fa-triangle-exclamation"
239
+ role="img"
240
+ aria-label={`Off the wheel. ${modeUsage}.`}
241
+ title={`Off the wheel &middot; ${modeUsage}`}
242
+ ></span>
243
+ {/if}
244
+ <UIMenuButton
245
+ header={`Assign to ${axisLabel(i)}`}
246
+ triggerLabel={`Assign to ${axisLabel(i)}`}
247
+ triggerClass="axis-menu-trigger"
248
+ >
249
+ {#snippet trigger()}<i class="fas fa-chevron-down chev" aria-hidden="true"></i>{/snippet}
250
+ {#snippet children({ close })}{@render axisMenu(i, close)}{/snippet}
251
+ </UIMenuButton>
148
252
  {:else}
149
253
  <span class="swatch preview" style="--fill: {oklchToHexClamped(PREVIEW_L, PREVIEW_C, axis.hue)}"></span>
150
- <span class="empty">Empty</span>
254
+ {#if unused}
255
+ <span class="reason">unused &middot; {modeUsage}</span>
256
+ {:else}
257
+ <UIMenuButton header={`Assign to ${axisLabel(i)}`} triggerClass="assign-trigger">
258
+ {#snippet trigger()}<span>Assign a color…</span><i class="fas fa-chevron-down chev" aria-hidden="true"></i>{/snippet}
259
+ {#snippet children({ close })}{@render axisMenu(i, close)}{/snippet}
260
+ </UIMenuButton>
261
+ {/if}
151
262
  {/if}
152
263
  </div>
153
264
  {/each}
154
-
155
- <!-- svelte-ignore a11y_no_static_element_interactions -->
156
- <div
157
- class="unassigned"
158
- class:drop-target={dragTarget === 'unassigned'}
159
- ondragover={(e) => onDragOver(e, 'unassigned')}
160
- ondragleave={() => onDragLeave('unassigned')}
161
- ondrop={(e) => onDrop(e, 'unassigned')}
162
- >
163
- <span class="eyebrow">Unassigned</span>
164
- <div class="chips">
165
- {#each unassigned as family (family)}
166
- <button
167
- type="button"
168
- class="chip"
169
- class:selected={family === selected}
170
- draggable="true"
171
- data-family={family}
172
- aria-label={`${family}, unassigned, position ${positionCount} of ${positionCount}. Arrow up to bind it to ${AXIS_ROLES[lastAxis]}.`}
173
- aria-pressed={family === selected}
174
- title={`Drag onto an axis to bind it. Arrow up binds it to ${AXIS_ROLES[lastAxis]}.`}
175
- ondragstart={(e) => onDragStart(e, family)}
176
- ondragend={onDragEnd}
177
- onkeydown={(e) => onChipKeydown(e, family)}
178
- onclick={() => onSelect(family)}
179
- animate:flip={{ duration: 200, easing: cubicOut }}
180
- >
181
- <span class="grip" aria-hidden="true">⋮⋮</span>
182
- <span class="chip-swatch" style="--fill: {familyHex(family)}"></span>
183
- <span class="chip-name">{family}</span>
184
- </button>
185
- {/each}
186
- {#if unassigned.length === 0}
187
- <span class="empty">Every color is bound to an axis.</span>
188
- {/if}
189
- </div>
190
- </div>
191
265
  </div>
192
266
 
193
267
  <style>
@@ -211,43 +285,66 @@
211
285
  background var(--ui-transition-fast);
212
286
  }
213
287
 
214
- .axis-row.drop-target,
215
- .unassigned.drop-target {
216
- border-color: var(--ui-border-higher);
288
+ /* About to receive a drop: a doubled ring. Ring count is what separates it
289
+ from selection, which keeps a single line. Never dashed (dashed means
290
+ unused) and never colored. */
291
+ .axis-row.drop-target {
292
+ border-color: var(--ui-text-primary);
217
293
  background: var(--ui-surface-low);
294
+ outline: 1px solid var(--ui-text-primary);
295
+ outline-offset: 1px;
218
296
  }
219
297
 
220
- /* The family selected in the swatch grid / wheel: the row's numeral and role
221
- go full-contrast so the selection reads across both surfaces. */
222
- .axis-row.selected {
223
- border-color: var(--ui-text-primary);
224
- background: var(--ui-surface-low);
298
+ /* The whole row opens the menu, so it hovers as one control. */
299
+ .axis-row.openable {
300
+ cursor: pointer;
301
+ }
302
+
303
+ /* Selection and the drop ring already own the border; hover must not undo them. */
304
+ .axis-row.openable:not(.selected):not(.drop-target):hover {
305
+ border-color: var(--ui-border-high);
225
306
  }
226
307
 
227
- .axis-row.selected .role,
228
- .axis-row.selected .role-num {
308
+ .axis-row.openable:hover :global(.axis-menu-trigger),
309
+ .axis-row.openable:hover :global(.assign-trigger) {
229
310
  color: var(--ui-text-primary);
230
- font-weight: var(--ui-font-weight-semibold);
231
311
  }
232
312
 
313
+ /* The family selected in the swatch grid / wheel. The row takes one border and
314
+ the chip inside stays plain, so the row lights up once. */
315
+ .axis-row.selected {
316
+ border-color: var(--ui-text-primary);
317
+ background: var(--ui-surface-low);
318
+ }
319
+
320
+ /* Only axis 1 fills this column, but every row reserves it so the swatches,
321
+ chips and pickers start at one x. Sized to fit "Anchor". The axis number
322
+ itself lives only on the left swatches, which is where a color is read. */
233
323
  .role {
234
324
  flex: none;
235
- width: 6.5rem;
325
+ width: 4rem;
236
326
  color: var(--ui-text-secondary);
237
327
  }
238
328
 
239
- .role-num {
240
- color: var(--ui-text-muted);
241
- font-variant-numeric: tabular-nums;
242
- }
243
-
244
- /* Disabled slot: the applied geometry deals it no distinct position. The Empty
245
- label stays visible; shape + dimming carry the state. */
246
- .axis-row.disabled {
329
+ /* Unused slot: nothing bound and no position dealt. The row states the reason
330
+ in text; shape and dimming only echo it. */
331
+ .axis-row.unused {
247
332
  border-style: dashed;
248
333
  opacity: 0.45;
249
334
  }
250
335
 
336
+ /* Bound, but the applied mode deals this axis no position. Dimming carries it
337
+ and the triangle names it; never dashed, which is reserved for unused. */
338
+ .axis-row.off-wheel {
339
+ opacity: 0.6;
340
+ }
341
+
342
+ .warn {
343
+ flex: none;
344
+ color: var(--ui-text-secondary);
345
+ font-size: var(--ui-font-size-sm);
346
+ }
347
+
251
348
  .swatch {
252
349
  flex: none;
253
350
  width: 0.375rem;
@@ -262,20 +359,24 @@
262
359
  opacity: 0.7;
263
360
  }
264
361
 
265
- .empty {
362
+ .reason {
266
363
  color: var(--ui-text-tertiary);
267
- font-size: var(--ui-font-size-md);
364
+ font-size: var(--ui-font-size-sm);
268
365
  }
269
366
 
270
367
  .grip {
271
368
  flex: none;
272
- color: var(--ui-text-muted);
273
- font-size: var(--ui-font-size-xs);
369
+ color: var(--ui-text-secondary);
370
+ font-size: var(--ui-font-size-sm);
274
371
  line-height: 1;
275
372
  letter-spacing: -0.1em;
276
373
  user-select: none;
277
374
  }
278
375
 
376
+ .chip:hover .grip {
377
+ color: var(--ui-text-primary);
378
+ }
379
+
279
380
  .chip {
280
381
  display: inline-flex;
281
382
  align-items: center;
@@ -295,11 +396,6 @@
295
396
  border-color: var(--ui-border-high);
296
397
  }
297
398
 
298
- .chip.selected {
299
- border-color: var(--ui-text-primary);
300
- background: var(--ui-surface-high);
301
- }
302
-
303
399
  .chip:focus-visible {
304
400
  outline: 2px solid var(--ui-border-higher);
305
401
  outline-offset: 2px;
@@ -318,31 +414,58 @@
318
414
  border: 1px solid var(--ui-border-low);
319
415
  }
320
416
 
321
- .unassigned {
322
- display: flex;
323
- flex-direction: column;
324
- gap: var(--ui-space-8);
325
- margin-top: var(--ui-space-4);
326
- padding: var(--ui-space-8);
327
- border: 1px dashed var(--ui-border-low);
328
- border-radius: var(--ui-radius-md);
329
- transition:
330
- border-color var(--ui-transition-fast),
331
- background var(--ui-transition-fast);
417
+ /* Row furniture, not pills: the triggers match the editor's quiet
418
+ icon-button idiom (see UIInfoPopover's info-btn). */
419
+ .axis-row :global(.axis-menu-trigger) {
420
+ margin-left: auto;
421
+ width: var(--ui-space-24);
422
+ height: var(--ui-space-24);
423
+ border-radius: var(--ui-radius-sm);
424
+ color: var(--ui-text-secondary);
425
+ font-size: var(--ui-font-size-xs);
426
+ transition: color var(--ui-transition-fast);
332
427
  }
333
428
 
334
- .eyebrow {
335
- font-size: var(--ui-font-size-xs);
336
- color: var(--ui-text-muted);
337
- text-transform: uppercase;
338
- letter-spacing: 0.04em;
429
+ .axis-row :global(.axis-menu-trigger:hover),
430
+ .axis-row :global(.axis-menu-trigger[aria-expanded='true']) {
431
+ color: var(--ui-text-primary);
432
+ }
433
+
434
+ /* Claims the rest of the row so its chevron lands where a filled row's does:
435
+ every row carries the same control, whether or not it holds a color. */
436
+ .axis-row :global(.assign-trigger) {
437
+ flex: 1;
438
+ justify-content: space-between;
439
+ padding: var(--ui-space-4) 0;
440
+ color: var(--ui-text-secondary);
441
+ font-size: var(--ui-font-size-md);
442
+ transition: color var(--ui-transition-fast);
339
443
  }
340
444
 
341
- .chips {
445
+ .chev {
342
446
  display: flex;
343
- flex-wrap: wrap;
344
- gap: var(--ui-space-6);
345
- min-height: 1.75rem;
346
447
  align-items: center;
448
+ justify-content: center;
449
+ width: var(--ui-space-24);
450
+ font-size: var(--ui-font-size-xs);
451
+ }
452
+
453
+ .axis-row :global(.assign-trigger:hover),
454
+ .axis-row :global(.assign-trigger[aria-expanded='true']) {
455
+ color: var(--ui-text-primary);
347
456
  }
457
+
458
+ .menu-swatch {
459
+ flex: none;
460
+ width: 0.85rem;
461
+ height: 0.85rem;
462
+ border-radius: var(--ui-radius-sm);
463
+ background: var(--fill);
464
+ border: 1px solid var(--ui-border-low);
465
+ }
466
+
467
+ .menu-sep {
468
+ border-top: 1px solid var(--ui-border-low);
469
+ }
470
+
348
471
  </style>
@@ -202,8 +202,6 @@
202
202
  font-size: var(--ui-font-size-xs);
203
203
  font-weight: var(--ui-font-weight-semibold);
204
204
  color: var(--ui-text-tertiary);
205
- text-transform: uppercase;
206
- letter-spacing: 0.04em;
207
205
  }
208
206
 
209
207
  .lightness-value {
@@ -0,0 +1,10 @@
1
+ /** Drag payload shared by every surface that can hand a family to an axis: the
2
+ * axes list's own chips and the Selected color swatch row. The drop targets
3
+ * live in HarmonyAxesList and gate on this type alone. */
4
+ export const HARMONY_DRAG_TYPE = 'application/x-harmony-family';
5
+
6
+ export function startFamilyDrag(e: DragEvent, family: string): void {
7
+ if (!e.dataTransfer) return;
8
+ e.dataTransfer.effectAllowed = 'move';
9
+ e.dataTransfer.setData(HARMONY_DRAG_TYPE, family);
10
+ }
@@ -21,3 +21,8 @@ export const HARMONY_MODE_BUTTONS: { mode: HarmonyMode; label: string; svg: stri
21
21
  { mode: 'compound', label: 'Compound', svg: compoundSvg },
22
22
  { mode: 'custom', label: 'Custom', svg: customSvg },
23
23
  ];
24
+
25
+ /** Display name for copy and aria; the raw mode id must never reach a string. */
26
+ export function modeLabel(mode: HarmonyMode): string {
27
+ return HARMONY_MODE_BUTTONS.find((b) => b.mode === mode)!.label;
28
+ }