@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
@@ -0,0 +1,230 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import type { Snippet } from 'svelte';
4
+
5
+ interface Props {
6
+ /** Heading line inside the menu; also its accessible name. */
7
+ header: string;
8
+ /** Accessible name for an icon-only trigger; a text trigger names itself. */
9
+ triggerLabel?: string;
10
+ /** Lets the caller style the trigger as its own furniture via `:global`. */
11
+ triggerClass?: string;
12
+ menuMinWidth?: string;
13
+ trigger: Snippet;
14
+ children: Snippet<[{ close: () => void }]>;
15
+ }
16
+
17
+ let {
18
+ header,
19
+ triggerLabel,
20
+ triggerClass = '',
21
+ menuMinWidth = '14rem',
22
+ trigger,
23
+ children,
24
+ }: Props = $props();
25
+
26
+ let open = $state(false);
27
+ let btnEl = $state<HTMLButtonElement | undefined>(undefined);
28
+ let menuEl = $state<HTMLDivElement | undefined>(undefined);
29
+ let initialItem = $state<'first' | 'last'>('first');
30
+
31
+ function items(): HTMLButtonElement[] {
32
+ return menuEl ? Array.from(menuEl.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')) : [];
33
+ }
34
+
35
+ function close(refocusTrigger = true) {
36
+ if (!open) return;
37
+ open = false;
38
+ if (refocusTrigger) btnEl?.focus();
39
+ }
40
+
41
+ function toggle() {
42
+ if (open) {
43
+ close();
44
+ } else {
45
+ initialItem = 'first';
46
+ open = true;
47
+ }
48
+ }
49
+
50
+ function onTriggerKeydown(e: KeyboardEvent) {
51
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
52
+ e.preventDefault();
53
+ initialItem = e.key === 'ArrowUp' ? 'last' : 'first';
54
+ open = true;
55
+ }
56
+
57
+ // Enter and Space activate the focused item natively: items are real buttons.
58
+ function onMenuKeydown(e: KeyboardEvent) {
59
+ if (e.key === 'Escape' || e.key === 'Tab') {
60
+ e.preventDefault();
61
+ close();
62
+ return;
63
+ }
64
+ const list = items();
65
+ if (list.length === 0) return;
66
+ const idx = list.indexOf(document.activeElement as HTMLButtonElement);
67
+ let next = -1;
68
+ if (e.key === 'ArrowDown') next = idx === -1 ? 0 : (idx + 1) % list.length;
69
+ else if (e.key === 'ArrowUp') next = idx === -1 ? list.length - 1 : (idx - 1 + list.length) % list.length;
70
+ else if (e.key === 'Home') next = 0;
71
+ else if (e.key === 'End') next = list.length - 1;
72
+ if (next === -1) return;
73
+ e.preventDefault();
74
+ list[next].focus();
75
+ }
76
+
77
+ function onWindowKeydown(e: KeyboardEvent) {
78
+ if (e.key === 'Escape' && open) close();
79
+ }
80
+
81
+ function onDocumentMousedown(e: MouseEvent) {
82
+ if (!open) return;
83
+ const target = e.target as Node | null;
84
+ if (target && (btnEl?.contains(target) || menuEl?.contains(target))) return;
85
+ // The user is directing attention elsewhere; refocusing the trigger would
86
+ // steal focus from whatever they clicked.
87
+ close(false);
88
+ }
89
+
90
+ onMount(() => {
91
+ window.addEventListener('keydown', onWindowKeydown);
92
+ document.addEventListener('mousedown', onDocumentMousedown, true);
93
+ });
94
+
95
+ onDestroy(() => {
96
+ window.removeEventListener('keydown', onWindowKeydown);
97
+ document.removeEventListener('mousedown', onDocumentMousedown, true);
98
+ });
99
+
100
+ /* Fixed positioning escapes any parent overflow/stacking context. Anchored
101
+ below the trigger, right edges aligned; flips above near the viewport
102
+ bottom. Inline visibility rather than a state flag so the item focus that
103
+ follows lands on an already-visible menu (hidden elements refuse focus). */
104
+ function position(): void {
105
+ const btn = btnEl;
106
+ const menu = menuEl;
107
+ if (!btn || !menu) return;
108
+ const br = btn.getBoundingClientRect();
109
+ const mr = menu.getBoundingClientRect();
110
+ const margin = 8;
111
+ const vw = window.innerWidth;
112
+ const vh = window.innerHeight;
113
+ let left = br.right - mr.width;
114
+ if (left < margin) left = margin;
115
+ if (left + mr.width > vw - margin) left = vw - margin - mr.width;
116
+ let top = br.bottom + margin / 2;
117
+ if (top + mr.height > vh - margin) {
118
+ top = br.top - margin / 2 - mr.height;
119
+ if (top < margin) top = margin;
120
+ }
121
+ menu.style.left = `${left}px`;
122
+ menu.style.top = `${top}px`;
123
+ menu.style.visibility = 'visible';
124
+ }
125
+
126
+ $effect(() => {
127
+ if (!open) return;
128
+ let raf = requestAnimationFrame(() => {
129
+ raf = requestAnimationFrame(() => {
130
+ // The composed option primitives expose no role prop; stamping
131
+ // menuitem + tabindex here is what makes the popup a real menu with
132
+ // roving focus instead of a cluster of tab stops.
133
+ for (const b of items()) {
134
+ b.setAttribute('role', 'menuitem');
135
+ b.tabIndex = -1;
136
+ }
137
+ position();
138
+ const list = items();
139
+ (initialItem === 'last' ? list[list.length - 1] : list[0])?.focus();
140
+ });
141
+ });
142
+ window.addEventListener('scroll', position, true);
143
+ window.addEventListener('resize', position);
144
+ return () => {
145
+ cancelAnimationFrame(raf);
146
+ window.removeEventListener('scroll', position, true);
147
+ window.removeEventListener('resize', position);
148
+ };
149
+ });
150
+ </script>
151
+
152
+ <button
153
+ type="button"
154
+ class="menu-trigger {triggerClass}"
155
+ aria-haspopup="menu"
156
+ aria-expanded={open}
157
+ aria-label={triggerLabel}
158
+ bind:this={btnEl}
159
+ onclick={toggle}
160
+ onkeydown={onTriggerKeydown}
161
+ >
162
+ {@render trigger()}
163
+ </button>
164
+
165
+ {#if open}
166
+ <div
167
+ class="menu"
168
+ role="menu"
169
+ tabindex="-1"
170
+ aria-label={header}
171
+ style="min-width: {menuMinWidth};"
172
+ bind:this={menuEl}
173
+ onkeydown={onMenuKeydown}
174
+ >
175
+ <div class="menu-header" role="presentation">{header}</div>
176
+ {@render children({ close })}
177
+ </div>
178
+ {/if}
179
+
180
+ <style>
181
+ .menu-trigger {
182
+ display: inline-flex;
183
+ align-items: center;
184
+ justify-content: center;
185
+ gap: var(--ui-space-4);
186
+ padding: 0;
187
+ background: transparent;
188
+ border: 0;
189
+ color: inherit;
190
+ font: inherit;
191
+ line-height: 1;
192
+ cursor: pointer;
193
+ }
194
+
195
+ .menu-trigger:focus-visible {
196
+ outline: 2px solid var(--ui-border-higher);
197
+ outline-offset: 2px;
198
+ }
199
+
200
+ .menu {
201
+ position: fixed;
202
+ top: 0;
203
+ left: 0;
204
+ max-width: calc(100vw - var(--ui-space-24));
205
+ background: var(--ui-surface-higher);
206
+ border: 1px solid var(--ui-border-high);
207
+ border-radius: var(--ui-radius-lg);
208
+ box-shadow: var(--ui-shadow-lg);
209
+ z-index: 1000;
210
+ overflow: hidden;
211
+ visibility: hidden;
212
+ }
213
+
214
+ .menu-header {
215
+ padding: var(--ui-space-8) var(--ui-space-12);
216
+ border-bottom: 1px solid var(--ui-border-low);
217
+ color: var(--ui-text-primary);
218
+ font-size: var(--ui-font-size-xs);
219
+ font-weight: var(--ui-font-weight-semibold);
220
+ line-height: 1.2;
221
+ }
222
+
223
+ /* Roving focus is the menu's selection cursor; the inset ring keeps it
224
+ inside the clipped rounded corners. */
225
+ .menu :global(button:focus-visible) {
226
+ background: var(--ui-hover);
227
+ outline: 2px solid var(--ui-border-higher);
228
+ outline-offset: -2px;
229
+ }
230
+ </style>
@@ -1059,7 +1059,6 @@
1059
1059
  font-size: var(--ui-font-size-xs);
1060
1060
  font-family: var(--ui-font-mono);
1061
1061
  color: var(--ui-text-tertiary);
1062
- text-transform: uppercase;
1063
1062
  border-top: 1px solid var(--ui-border-low);
1064
1063
  }
1065
1064
 
@@ -204,10 +204,9 @@
204
204
  /* Subsection title (used by Spacing & Borders) */
205
205
  .subsection-title {
206
206
  margin: var(--ui-space-16) 0 var(--ui-space-8);
207
- font-size: var(--ui-font-size-xs);
208
- font-weight: var(--ui-font-weight-semibold);
209
- color: var(--ui-text-tertiary);
210
- text-transform: uppercase;
207
+ font-size: var(--ui-font-size-xl);
208
+ font-weight: var(--ui-font-weight-bold);
209
+ color: var(--ui-text-primary);
211
210
  }
212
211
 
213
212
  .subsection-title:first-child {
@@ -0,0 +1,70 @@
1
+ <script lang="ts">
2
+ import type { AxisStatus } from '../../core/palettes/colorHarmony';
3
+
4
+ interface Props {
5
+ index: number;
6
+ status: AxisStatus;
7
+ /** The axis carries the family selected across the Colors surfaces. */
8
+ selected?: boolean;
9
+ /** Rendered over the swatch badge's scrim rather than an editor surface. */
10
+ scrim?: boolean;
11
+ }
12
+
13
+ let { index, status, selected = false, scrim = false }: Props = $props();
14
+ </script>
15
+
16
+ <!-- Carries no positioning: the wheel places it absolutely, the rows inline. -->
17
+ <span class="numeral" class:filled={status === 'on-wheel'} class:selected class:scrim>{index + 1}</span>
18
+
19
+ <style>
20
+ /* Hollow is the resting shape; filled means the axis has a rail on the wheel
21
+ right now. An unused axis is hollow too, but it never holds a family, so a
22
+ hollow numeral beside a color always reads "bound, off the wheel". */
23
+ .numeral {
24
+ box-sizing: border-box;
25
+ flex: none;
26
+ display: inline-flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ /* Callers that need a bigger numeral set these two; the row default is the
30
+ size the wheel and the axes list share. */
31
+ width: var(--numeral-size, 1rem);
32
+ height: var(--numeral-size, 1rem);
33
+ border: 1px dashed var(--ui-text-muted);
34
+ border-radius: var(--ui-radius-full);
35
+ color: var(--ui-text-muted);
36
+ font-size: var(--numeral-font-size, var(--ui-font-size-xs));
37
+ font-weight: var(--ui-font-weight-semibold);
38
+ /* No tabular figures: the box is a fixed circle, so nothing needs aligning,
39
+ and a padded tabular "1" sits off-center inside its own advance. */
40
+ line-height: 1;
41
+ user-select: none;
42
+ }
43
+
44
+ /* The scrim composites with the swatch fill beneath it, which lands close
45
+ enough to the muted token to erase the ring and the digit. */
46
+ .numeral.scrim:not(.filled) {
47
+ border-color: var(--ui-text-secondary);
48
+ color: var(--ui-text-secondary);
49
+ }
50
+
51
+ .numeral.filled {
52
+ border-style: solid;
53
+ border-color: var(--ui-border-higher);
54
+ background: var(--ui-surface-high);
55
+ color: var(--ui-text-secondary);
56
+ }
57
+
58
+ .numeral.filled.selected {
59
+ background: var(--ui-text-primary);
60
+ border-color: var(--ui-text-primary);
61
+ color: var(--ui-surface-lowest);
62
+ }
63
+
64
+ /* Selection raises contrast but never fills: filled is reserved for "has a
65
+ rail", so inverting here would make the list contradict the wheel. */
66
+ .numeral.selected:not(.filled) {
67
+ border-color: var(--ui-text-primary);
68
+ color: var(--ui-text-primary);
69
+ }
70
+ </style>
@@ -93,8 +93,6 @@
93
93
  font-size: var(--ui-font-size-xs);
94
94
  font-weight: var(--ui-font-weight-semibold);
95
95
  color: var(--ui-text-tertiary);
96
- text-transform: uppercase;
97
- letter-spacing: 0.04em;
98
96
  }
99
97
 
100
98
  .row-value {
@@ -5,7 +5,9 @@
5
5
  import { editorState, beginScope, commitScope, cancelScope, type Scope } from '../../core/store/editorStore';
6
6
  import { setBaseHueChroma, setBaseChroma, setAxisHue, setAxisHues } from './paletteBaseColor';
7
7
  import { maxChroma } from './colorWheelMath';
8
- import { applyHarmonyToAxes, modeActiveAxes, AXIS_ROLES, type HarmonyMode } from '../../core/palettes/colorHarmony';
8
+ import { applyHarmonyToAxes, axisLabel, axisStatuses, type HarmonyMode } from '../../core/palettes/colorHarmony';
9
+ import AxisNumeral from './AxisNumeral.svelte';
10
+ import { modeLabel } from './harmonyModeIcons';
9
11
 
10
12
  interface Props {
11
13
  selected: string | null;
@@ -29,12 +31,12 @@
29
31
 
30
32
  let { selected, onSelect, discLightness, onCustomize, absoluteChroma, activeMode, previewMode = null }: Props = $props();
31
33
 
32
- // One handle per axis (index = role). A bound axis reads its color from the
33
- // family's baseColor (hue equals axes[i].hue by invariant 1); an unbound axis
34
- // carries only its stored hue, previewing where a dropped color would land.
34
+ // One handle per axis. A bound axis reads its color from the family's
35
+ // baseColor (hue equals axes[i].hue by invariant 1); an unbound axis carries
36
+ // only its stored hue, previewing where an assigned color would land.
35
37
  let axisData = $derived(
36
38
  $editorState.harmonyAxes.map((axis, i) => {
37
- const common = { index: i, role: AXIS_ROLES[i], family: axis.family, bound: axis.family !== null };
39
+ const common = { index: i, label: axisLabel(i), family: axis.family, bound: axis.family !== null };
38
40
  if (axis.family !== null) {
39
41
  const spec = PALETTE_SPECS.find((s) => s.label === axis.family);
40
42
  const { l, c, h } = $editorState.palettes[axis.family]?.baseColor ?? spec!.initialColor;
@@ -50,8 +52,8 @@
50
52
  // active in the applied geometry (its slot is a distinct position). A bound
51
53
  // inactive axis keeps its family's color but is edited in dot mode, not axis
52
54
  // mode: the family renders as the free dot and sits out the global spin.
53
- let activeAxes = $derived(modeActiveAxes(activeMode));
54
- const isVisible = (t: { index: number }) => activeAxes[t.index];
55
+ let statuses = $derived(axisStatuses(activeMode, $editorState.harmonyAxes));
56
+ const isVisible = (t: { index: number }) => statuses[t.index] === 'on-wheel';
55
57
 
56
58
  // Reserved judgment call: keyboard nudge increments.
57
59
  const HUE_STEP = 2;
@@ -59,6 +61,7 @@
59
61
  const MARGIN = 52; // ring-to-edge gap: houses the external handles AND the axis numerals outside them
60
62
  const EXT_OFFSET = 20; // external handle radius beyond the disc rim (room for the dotted tether)
61
63
  const NUM_OFFSET = 24; // numeral radius beyond the external handles
64
+ const FREE_NUM_OFFSET = 15; // free-dot numeral, diagonal clearance past the enlarged dot
62
65
  const MIN_SIZE = 240;
63
66
  const MAX_SIZE = 560;
64
67
 
@@ -87,7 +90,7 @@
87
90
  // lossy and makes the non-dragged coordinate wobble). Writes also use the
88
91
  // pristine hue0/L0, so nothing accumulates across frames.
89
92
  type AxisView = {
90
- index: number; role: string; family: string | null; bound: boolean;
93
+ index: number; label: string; family: string | null; bound: boolean;
91
94
  hex: string; hue: number; chroma: number; lightness: number; rFrac: number;
92
95
  };
93
96
 
@@ -146,7 +149,9 @@
146
149
  // Writes go through setBaseHueChroma only — active axes never move; a bound
147
150
  // inactive axis follows its family via syncBoundAxisHue.
148
151
  let freeDot = $derived.by(() => {
149
- if (!selected || $editorState.harmonyAxes.some((a, i) => a.family === selected && activeAxes[i])) return null;
152
+ if (!selected) return null;
153
+ const axisIndex = $editorState.harmonyAxes.findIndex((a) => a.family === selected);
154
+ if (axisIndex !== -1 && statuses[axisIndex] === 'on-wheel') return null;
150
155
  const spec = PALETTE_SPECS.find((s) => s.label === selected);
151
156
  if (!spec) return null;
152
157
  const { l, c, h } = $editorState.palettes[selected]?.baseColor ?? spec.initialColor;
@@ -159,11 +164,22 @@
159
164
  }
160
165
  const r = rFrac * discRadius;
161
166
  return {
162
- family: selected, hex: oklchToHexClamped(l, c, h), hue, chroma: c, lightness: l,
167
+ family: selected, axisIndex: axisIndex === -1 ? null : axisIndex,
168
+ hex: oklchToHexClamped(l, c, h), hue, chroma: c, lightness: l,
163
169
  x: center + r * Math.cos(rad(hue)), y: center - r * Math.sin(rad(hue)),
164
170
  };
165
171
  });
166
172
 
173
+ // The dot serves two states: bound to an axis the mode left off the wheel, and
174
+ // unassigned. Only the first has an axis to name.
175
+ let freeDotLabel = $derived.by(() => {
176
+ if (!freeDot) return '';
177
+ const where = freeDot.axisIndex === null
178
+ ? 'unassigned'
179
+ : `${axisLabel(freeDot.axisIndex)}. Off the wheel in ${modeLabel(activeMode)}`;
180
+ return `${freeDot.family}, ${where}. Drag to adjust hue and chroma.`;
181
+ });
182
+
167
183
  let globalHandle = $derived.by(() => {
168
184
  const d = drag;
169
185
  const angle = d?.kind === 'global' ? d.angle : globalAngle;
@@ -315,7 +331,7 @@
315
331
  if (axis.family !== null) onSelect(axis.family);
316
332
  onCustomize();
317
333
  capture(e);
318
- openGesture(`colors: ${axis.role} axis rotate`);
334
+ openGesture(`colors: ${axis.label} rotate`);
319
335
  drag = { kind: 'axis', index: axis.index, angle: pointerAngle(e), start: startOf(axis) };
320
336
  applyAxis(e);
321
337
  }
@@ -483,7 +499,9 @@
483
499
  {/each}
484
500
 
485
501
  {#each visibleRender as t (t.index)}
486
- <span class="axis-num" class:selected={t.selected} style="left: {t.num.x}px; top: {t.num.y}px" aria-hidden="true">{t.index + 1}</span>
502
+ <span class="axis-num" style="left: {t.num.x}px; top: {t.num.y}px" aria-hidden="true">
503
+ <AxisNumeral index={t.index} status={statuses[t.index]} selected={t.selected} />
504
+ </span>
487
505
  {/each}
488
506
 
489
507
  {#each visibleRender as t (t.index)}
@@ -493,7 +511,7 @@
493
511
  class="dot"
494
512
  class:selected={t.selected}
495
513
  style="left: {t.dot.x}px; top: {t.dot.y}px; --fill: {t.hex}"
496
- aria-label={`${t.family} — drag along rail to adjust chroma`}
514
+ aria-label={`${t.family}, drag along the rail to adjust chroma`}
497
515
  title={`${t.family} (drag for chroma)`}
498
516
  onpointerdown={(e) => startChromaDrag(e, t)}
499
517
  onpointermove={moveDrag}
@@ -513,8 +531,8 @@
513
531
  class:unbound={!t.bound}
514
532
  class:selected={t.selected}
515
533
  style="left: {t.ext.x}px; top: {t.ext.y}px; transform: translate(-50%, -50%) rotate({t.iconRot}deg)"
516
- aria-label={t.bound ? `Rotate ${t.role} axis hue (${t.family})` : `Rotate ${t.role} axis hue`}
517
- title={t.bound ? `Rotate ${t.role} axis hue (${t.family})` : `Rotate ${t.role} axis hue`}
534
+ aria-label={t.bound ? `Rotate ${t.label} hue (${t.family})` : `Rotate ${t.label} hue`}
535
+ title={t.bound ? `Rotate ${t.label} hue (${t.family})` : `Rotate ${t.label} hue`}
518
536
  onpointerdown={(e) => startAxisDrag(e, t)}
519
537
  onpointermove={moveDrag}
520
538
  onpointerup={endDrag}
@@ -526,12 +544,21 @@
526
544
  {/each}
527
545
 
528
546
  {#if freeDot}
547
+ {#if freeDot.axisIndex !== null}
548
+ <span
549
+ class="axis-num"
550
+ style="left: {freeDot.x + FREE_NUM_OFFSET}px; top: {freeDot.y - FREE_NUM_OFFSET}px"
551
+ aria-hidden="true"
552
+ >
553
+ <AxisNumeral index={freeDot.axisIndex} status={statuses[freeDot.axisIndex]} selected />
554
+ </span>
555
+ {/if}
529
556
  <button
530
557
  type="button"
531
558
  class="dot selected"
532
559
  style="left: {freeDot.x}px; top: {freeDot.y}px; --fill: {freeDot.hex}"
533
- aria-label={`${freeDot.family} — drag to adjust hue and chroma`}
534
- title={`${freeDot.family} (drag freely)`}
560
+ aria-label={freeDotLabel}
561
+ title={freeDotLabel}
535
562
  onpointerdown={startFreeDrag}
536
563
  onpointermove={moveDrag}
537
564
  onpointerup={endDrag}
@@ -632,7 +659,7 @@
632
659
  }
633
660
 
634
661
  /* Unbound-axis ghost: a hollow greyscale marker on the external track (no color
635
- to preview, only where a dropped color's hue would land). */
662
+ to preview, only where an assigned color's hue would land). */
636
663
  .ghost.unbound {
637
664
  width: 0.7rem;
638
665
  height: 0.7rem;
@@ -731,24 +758,16 @@
731
758
  color: var(--ui-text-primary);
732
759
  }
733
760
 
734
- /* Axis numeral just outside the handle track. Positioned only — never rotated,
735
- so it stays upright at any angle. */
761
+ /* Axis numeral just outside the handle track. Positioning only — never
762
+ rotated, so the digit stays upright at any angle. */
736
763
  .axis-num {
737
764
  position: absolute;
738
765
  transform: translate(-50%, -50%);
739
- font-size: var(--ui-font-size-xs);
740
- font-weight: var(--ui-font-weight-semibold);
741
- color: var(--ui-text-tertiary);
766
+ display: flex;
742
767
  pointer-events: none;
743
- user-select: none;
744
768
  z-index: 3;
745
769
  }
746
770
 
747
- .axis-num.selected {
748
- color: var(--ui-text-primary);
749
- font-size: var(--ui-font-size-sm);
750
- }
751
-
752
771
  .dot:focus-visible,
753
772
  .ext-handle:focus-visible,
754
773
  .global-handle:focus-visible {