@motion-proto/live-tokens 0.43.0 → 0.45.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.
@@ -1,14 +1,16 @@
1
1
  <script lang="ts">
2
- import { editorState } from '../../core/store/editorStore';
3
2
  import { oklchToHexClamped } from '../../core/palettes/oklch';
4
- import { palettesToVars, PALETTE_SPECS, type PaletteSpec } from '../../core/palettes/paletteDerivation';
5
- import { defaultPaletteConfig } from '../palette/paletteMath';
3
+ import { palettesToVars } from '../../core/palettes/paletteDerivation';
6
4
  import { contrastRatio } from '../../core/palettes/contrast';
7
- import { recommendNeutralText } from '../../core/palettes/recommendText';
8
- import { applySuggestedNeutralText } from './paletteBaseColor';
9
- import UIPillButton from '../UIPillButton.svelte';
10
5
  import type { PaletteConfig } from '../../core/themes/themeTypes';
11
6
 
7
+ interface Props {
8
+ /** Every family's config, spec defaults filled in (palettesWithDefaults). */
9
+ full: Record<string, PaletteConfig>;
10
+ }
11
+
12
+ let { full }: Props = $props();
13
+
12
14
  // 60-30-10 as an area composition: the background surface is the dominant 60%
13
15
  // field (the page), and Brand/Accent/Special are the tones shown against it.
14
16
  // Shares are flex-grow weights, not exact percentages.
@@ -18,6 +20,8 @@
18
20
  { label: 'Special', ns: 'special', share: 2 },
19
21
  ] as const;
20
22
 
23
+ const TEXT_STEPS = ['primary', 'secondary', 'tertiary'] as const;
24
+
21
25
  const FUNCTIONAL = [
22
26
  { label: 'Info', ns: 'info' },
23
27
  { label: 'Success', ns: 'success' },
@@ -30,31 +34,15 @@
30
34
  // 1:1. Derived --surface-* tokens deliberately do not appear here: their
31
35
  // lightness is curve-owned, which decouples the story from the wheel and
32
36
  // reads as broken.
33
- const SPEC_BY_LABEL: Record<string, PaletteSpec> = Object.fromEntries(PALETTE_SPECS.map((s) => [s.label, s]));
34
-
35
37
  function seedHex(label: string): string {
36
- const { l, c, h } = $editorState.palettes[label]?.baseColor ?? SPEC_BY_LABEL[label].initialColor;
38
+ const { l, c, h } = full[label].baseColor;
37
39
  return oklchToHexClamped(l, c, h);
38
40
  }
39
41
 
40
- // Text still renders from this store-derived map (text derivation tracks the
41
- // seed), never from the ambient :root variables — those stay at vendored
42
- // defaults for families a loaded theme doesn't configure, so a story bound
43
- // to them silently goes static.
44
- let full = $derived.by(() => {
45
- const map: Record<string, PaletteConfig> = {};
46
- for (const spec of PALETTE_SPECS) {
47
- map[spec.label] =
48
- $editorState.palettes[spec.label] ??
49
- defaultPaletteConfig({ baseColor: spec.initialColor, neutral: spec.neutral ?? false });
50
- }
51
- return map;
52
- });
53
-
42
+ // Text renders from this map (text derivation tracks the seed), never from the
43
+ // ambient :root variables — those stay at vendored defaults for families a
44
+ // loaded theme doesn't configure, so a story bound to them silently goes static.
54
45
  let vars = $derived(palettesToVars(full));
55
- let useBlackWhite = $state(false);
56
- let rec = $derived(recommendNeutralText(full, undefined, useBlackWhite));
57
- let partialCoverage = $derived(rec.suggestions.filter((s) => !s.current.coverage.full));
58
46
 
59
47
  // Shadow the consumed text variables locally with the store-derived values;
60
48
  // the page field is the Canvas family's SEED, not its derived page step.
@@ -79,8 +67,6 @@
79
67
  return r === null ? null : `${(Math.floor(r * 10) / 10).toFixed(1)}:1`;
80
68
  }
81
69
 
82
- let bgRatio = $derived(ratioOnBg('--text-primary'));
83
-
84
70
  // Inverted is measured against the fill it sits on (primary), not the page:
85
71
  // the pill below IS that pairing, so the number describes what is shown. A
86
72
  // mid-lightness primary makes this pairing inherently low-contrast — the
@@ -96,19 +82,20 @@
96
82
 
97
83
  <div class="story" style={storyVars}>
98
84
  <div class="field">
99
- <div class="field-head">
100
- <span class="field-label">Canvas</span>
101
- {#if fmt(bgRatio)}<span class="ratio">{fmt(bgRatio)}</span>{/if}
85
+ <span class="field-label">Canvas</span>
86
+
87
+ <!-- Each step renders in its own token, so the word IS the sample and the
88
+ ratio beside it measures exactly what you are reading. -->
89
+ <div class="text-steps">
90
+ {#each TEXT_STEPS as step (step)}
91
+ {@const r = fmt(ratioOnBg(`--text-${step}`))}
92
+ <p class="text-step" style:color="var(--text-{step})">
93
+ {step}
94
+ {#if r}<span class="ratio">{r}</span>{/if}
95
+ </p>
96
+ {/each}
102
97
  </div>
103
- <p class="body">The dominant surface. Body text sits here.</p>
104
- <p class="secondary">
105
- Secondary text follows the same field.
106
- {#if fmt(ratioOnBg('--text-secondary'))}<span class="ratio">{fmt(ratioOnBg('--text-secondary'))}</span>{/if}
107
- </p>
108
- <p class="tertiary">
109
- Tertiary text steps back once more.
110
- {#if fmt(ratioOnBg('--text-tertiary'))}<span class="ratio">{fmt(ratioOnBg('--text-tertiary'))}</span>{/if}
111
- </p>
98
+
112
99
  <p class="inverted-row">
113
100
  <span class="inverted-pill">
114
101
  Inverted text flips onto primary.
@@ -116,52 +103,13 @@
116
103
  </span>
117
104
  </p>
118
105
 
119
- <!-- Always rendered: toggling "Use black and white" changes the
120
- suggestion, so the toggle must stay reachable even when the current
121
- values already match. -->
122
- <div class="suggest-row">
123
- <span class="suggest-label">Suggested</span>
124
- {#each rec.suggestions as s (s.step)}
125
- <span class="suggest-entry" class:dim={!s.differs}>
126
- <span class="fn-dot" style:--dot={s.suggested.hex}></span>
127
- <span class="suggest-step">{s.step}</span>
128
- <span class="ratio">{fmt(contrastRatio(s.suggested.hex, seedHex('Canvas')))}</span>
129
- </span>
130
- {/each}
131
- <label class="bw-toggle" title="Anchor the suggestion at pure white or black instead of the softened extreme">
132
- <input type="checkbox" bind:checked={useBlackWhite} />
133
- <span>Use black and white</span>
134
- </label>
135
- {#if rec.anyDiffers}
136
- <UIPillButton
137
- icon="fa-arrow-down-short-wide"
138
- title="Anchor on primary and step secondary/tertiary down by even lightness drops, floored at WCAG AA (one undo)"
139
- onclick={() => applySuggestedNeutralText(useBlackWhite)}
140
- >
141
- Use suggested
142
- </UIPillButton>
143
- {/if}
144
- </div>
145
-
146
- {#if partialCoverage.length > 0}
147
- <div
148
- class="coverage-alert"
149
- title="WCAG AA (4.5:1) coverage across the neutral surface steps. Informational, nothing is blocked."
150
- >
151
- <i class="fa-solid fa-triangle-exclamation"></i>
152
- <span>
153
- AA reach: {partialCoverage
154
- .map((s) => `${s.step} ${s.current.coverage.cleared}/${s.current.coverage.total} surfaces`)
155
- .join(' · ')}
156
- </span>
157
- </div>
158
- {/if}
159
-
160
106
  <div class="functional-row">
161
107
  {#each FUNCTIONAL as fn (fn.ns)}
162
108
  {@const r = fmt(ratioOnBg(`--text-${fn.ns}`))}
163
109
  <span class="fn-chip">
164
- <span class="fn-dot" style:--dot={seedHex(fn.label)}></span>
110
+ <!-- The dot shows the TEXT color the ratio measures (shadowed via
111
+ storyVars), matching the label — not the family's seed fill. -->
112
+ <span class="fn-dot" style:--dot="var(--text-{fn.ns})"></span>
165
113
  <span class="fn-label" style:color="var(--text-{fn.ns})">{fn.label}</span>
166
114
  {#if r}<span class="ratio">{r}</span>{/if}
167
115
  </span>
@@ -197,49 +145,25 @@
197
145
  }
198
146
 
199
147
  /* Transparent: the background surface reads directly, no card over it. */
148
+ /* No min-height: 0 — the field must keep its content min-height so tall
149
+ text wrapping grows the frame instead of overflowing onto the tone bars. */
200
150
  .field {
201
151
  flex-grow: 60;
202
152
  flex-basis: 0;
203
153
  display: flex;
204
154
  flex-direction: column;
205
- gap: var(--ui-space-4);
206
- min-height: 0;
207
- padding: var(--ui-space-8) var(--ui-space-12);
208
- color: var(--text-primary);
209
- }
210
-
211
- .field-head {
212
- display: flex;
213
- align-items: baseline;
214
- justify-content: space-between;
215
155
  gap: var(--ui-space-8);
156
+ padding: var(--ui-space-12);
157
+ color: var(--text-primary);
216
158
  }
217
159
 
218
160
  .field-label {
219
- font-size: var(--ui-font-size-sm);
161
+ font-size: var(--ui-font-size-md);
220
162
  font-weight: var(--ui-font-weight-semibold);
221
163
  }
222
164
 
223
- .body {
224
- margin: 0;
225
- font-size: var(--ui-font-size-sm);
226
- color: var(--text-primary);
227
- }
228
-
229
- .secondary {
230
- margin: 0;
231
- font-size: var(--ui-font-size-xs);
232
- color: var(--text-secondary);
233
- }
234
-
235
- .tertiary {
236
- margin: 0;
237
- font-size: var(--ui-font-size-xs);
238
- color: var(--text-tertiary);
239
- }
240
-
241
165
  .inverted-row {
242
- margin: 0;
166
+ margin: var(--ui-space-8) 0 0;
243
167
  }
244
168
 
245
169
  /* The pill's fill IS the primary text color: the flip is shown literally,
@@ -252,63 +176,30 @@
252
176
  border-radius: var(--ui-radius-full);
253
177
  background: var(--text-primary);
254
178
  color: var(--text-inverted);
255
- font-size: var(--ui-font-size-xs);
179
+ font-size: var(--ui-font-size-md);
256
180
  }
257
181
 
258
- .suggest-row {
182
+ .text-steps {
259
183
  display: flex;
260
- align-items: center;
261
- flex-wrap: wrap;
262
- gap: var(--ui-space-8);
263
- }
264
-
265
- .suggest-label {
266
- font-size: var(--ui-font-size-sm);
267
- font-weight: var(--ui-font-weight-semibold);
268
- opacity: 0.6;
269
- }
270
-
271
- .suggest-entry {
272
- display: flex;
273
- align-items: center;
184
+ flex-direction: column;
185
+ align-items: flex-start;
274
186
  gap: var(--ui-space-6);
275
187
  }
276
188
 
277
- .suggest-entry.dim {
278
- opacity: 0.45;
279
- }
280
-
281
- .suggest-step {
282
- font-size: var(--ui-font-size-xs);
283
- }
284
-
285
- .bw-toggle {
189
+ .text-step {
286
190
  display: flex;
287
- align-items: center;
288
- gap: var(--ui-space-4);
289
- font-size: var(--ui-font-size-xs);
290
- cursor: pointer;
291
- }
292
-
293
- .bw-toggle input {
191
+ align-items: baseline;
192
+ gap: var(--ui-space-8);
294
193
  margin: 0;
295
- cursor: pointer;
296
- }
297
-
298
- .coverage-alert {
299
- display: flex;
300
- align-items: center;
301
- gap: var(--ui-space-6);
302
- font-size: var(--ui-font-size-xs);
303
- opacity: 0.75;
194
+ font-size: var(--ui-font-size-md);
304
195
  }
305
196
 
306
197
  .functional-row {
307
198
  display: flex;
308
- gap: var(--ui-space-8);
199
+ gap: var(--ui-space-8) var(--ui-space-12);
309
200
  flex-wrap: wrap;
310
201
  margin-top: auto;
311
- padding-top: var(--ui-space-12);
202
+ padding-top: var(--ui-space-16);
312
203
  }
313
204
 
314
205
  .fn-chip {
@@ -326,17 +217,18 @@
326
217
  }
327
218
 
328
219
  .fn-label {
329
- font-size: var(--ui-font-size-xs);
220
+ font-size: var(--ui-font-size-md);
330
221
  font-weight: var(--ui-font-weight-semibold);
331
222
  }
332
223
 
224
+ /* Like .field, no min-height: 0 — the bars' min heights must grow the frame
225
+ rather than spill past its border when the field's text is tall. */
333
226
  .tones {
334
227
  display: flex;
335
228
  flex-direction: column;
336
229
  gap: var(--ui-space-8);
337
230
  flex-grow: 40;
338
231
  flex-basis: 0;
339
- min-height: 0;
340
232
  }
341
233
 
342
234
  .tone {
@@ -363,12 +255,12 @@
363
255
  }
364
256
 
365
257
  .tone-name {
366
- font-size: var(--ui-font-size-xs);
258
+ font-size: var(--ui-font-size-md);
367
259
  font-weight: var(--ui-font-weight-semibold);
368
260
  }
369
261
 
370
262
  .ratio {
371
- font-size: var(--ui-font-size-xs);
263
+ font-size: var(--ui-font-size-md);
372
264
  font-family: var(--ui-font-mono);
373
265
  opacity: 0.75;
374
266
  }
@@ -60,7 +60,7 @@
60
60
  const EXT_OFFSET = 20; // external handle radius beyond the disc rim (room for the dotted tether)
61
61
  const NUM_OFFSET = 24; // numeral radius beyond the external handles
62
62
  const MIN_SIZE = 240;
63
- const MAX_SIZE = 360;
63
+ const MAX_SIZE = 560;
64
64
 
65
65
  const normDeg = (d: number) => ((d % 360) + 360) % 360;
66
66
  const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
@@ -469,11 +469,11 @@
469
469
  its inner half — reads as one axis that extends outward as dots to the
470
470
  external handle. Same angle as the handle, so it tracks live. -->
471
471
  {#each visibleRender as t (t.index)}
472
- <line class="tether" x1={center} y1={center} x2={t.ext.x} y2={t.ext.y} />
472
+ <line class="tether" class:selected={t.selected} x1={center} y1={center} x2={t.ext.x} y2={t.ext.y} />
473
473
  {/each}
474
474
  {#each visibleRender as t (t.index)}
475
475
  {#if t.bound}
476
- <line class="rail" x1={center} y1={center} x2={t.dot.x} y2={t.dot.y} />
476
+ <line class="rail" class:selected={t.selected} x1={center} y1={center} x2={t.dot.x} y2={t.dot.y} />
477
477
  {/if}
478
478
  {/each}
479
479
  </svg>
@@ -483,7 +483,7 @@
483
483
  {/each}
484
484
 
485
485
  {#each visibleRender as t (t.index)}
486
- <span class="axis-num" style="left: {t.num.x}px; top: {t.num.y}px" aria-hidden="true">{t.index + 1}</span>
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>
487
487
  {/each}
488
488
 
489
489
  {#each visibleRender as t (t.index)}
@@ -583,7 +583,7 @@
583
583
  .rail {
584
584
  stroke: var(--ui-text-primary);
585
585
  stroke-width: 2;
586
- opacity: 0.65;
586
+ opacity: 0.5;
587
587
  }
588
588
 
589
589
  /* Dotted continuation of the axis out to the external handle. Greyscale. */
@@ -592,7 +592,20 @@
592
592
  stroke-width: 1.5;
593
593
  stroke-dasharray: 2 4;
594
594
  stroke-linecap: round;
595
- opacity: 0.9;
595
+ opacity: 0.7;
596
+ }
597
+
598
+ /* Selected family's axis: heavier stroke at full contrast, so the whole axis
599
+ reads as active alongside its enlarged dot and handle. */
600
+ .rail.selected {
601
+ stroke-width: 4;
602
+ opacity: 1;
603
+ }
604
+
605
+ .tether.selected {
606
+ stroke: var(--ui-text-primary);
607
+ stroke-width: 2.5;
608
+ opacity: 1;
596
609
  }
597
610
 
598
611
  /* Faint ring the external axis handles ride along. Decorative. */
@@ -725,12 +738,17 @@
725
738
  transform: translate(-50%, -50%);
726
739
  font-size: var(--ui-font-size-xs);
727
740
  font-weight: var(--ui-font-weight-semibold);
728
- color: var(--ui-text-primary);
741
+ color: var(--ui-text-tertiary);
729
742
  pointer-events: none;
730
743
  user-select: none;
731
744
  z-index: 3;
732
745
  }
733
746
 
747
+ .axis-num.selected {
748
+ color: var(--ui-text-primary);
749
+ font-size: var(--ui-font-size-sm);
750
+ }
751
+
734
752
  .dot:focus-visible,
735
753
  .ext-handle:focus-visible,
736
754
  .global-handle:focus-visible {
@@ -1,9 +1,7 @@
1
1
  <script lang="ts">
2
- import { flip } from 'svelte/animate';
3
- import { cubicOut } from 'svelte/easing';
4
2
  import { oklchToHexClamped } from '../../core/palettes/oklch';
5
3
  import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
6
- import { editorState, beginSliderGesture, transaction } from '../../core/store/editorStore';
4
+ import { editorState, beginSliderGesture } from '../../core/store/editorStore';
7
5
  import { applyHarmonyToAxes, modeActiveAxes, tintNeutralsFromAnchor, type HarmonyMode } from '../../core/palettes/colorHarmony';
8
6
  import ColorEditPanel from '../ColorEditPanel.svelte';
9
7
  import ColorWheel from './ColorWheel.svelte';
@@ -13,7 +11,12 @@
13
11
  import PaletteStepStrip from './PaletteStepStrip.svelte';
14
12
  import HarmonyAxesList from './HarmonyAxesList.svelte';
15
13
  import UIPillButton from '../UIPillButton.svelte';
16
- import { setBaseColor, setBaseColors, setAxisHues, applySolvedTextCurves } from './paletteBaseColor';
14
+ import {
15
+ setBaseColor,
16
+ setBaseColors,
17
+ setAxisHues,
18
+ palettesWithDefaults,
19
+ } from './paletteBaseColor';
17
20
  import { HARMONY_MODE_BUTTONS } from './harmonyModeIcons';
18
21
  import { dockGrow } from '../palette/dockMagnify';
19
22
 
@@ -61,9 +64,7 @@
61
64
  if (Object.keys(patch).length) setBaseColors(patch, 'colors: tint neutrals from anchor');
62
65
  }
63
66
 
64
- function deriveAccessibleText() {
65
- transaction('derive accessible text', applySolvedTextCurves);
66
- }
67
+ let fullPalettes = $derived(palettesWithDefaults($editorState.palettes));
67
68
 
68
69
  let swatches = $derived.by(() => {
69
70
  // A family bound to an inactive axis edits in dot mode, so it is not "on the wheel".
@@ -74,17 +75,16 @@
74
75
  label: spec.label,
75
76
  display: spec.displayLabel ?? spec.label,
76
77
  hex: oklchToHexClamped(l, c, h),
77
- l,
78
78
  onWheel: $editorState.harmonyAxes.some((a, i) => a.family === spec.label && active[i]),
79
79
  };
80
80
  });
81
81
  });
82
82
 
83
83
  const FUNCTIONAL_FAMILIES = new Set(['Info', 'Success', 'Warning', 'Danger']);
84
- // Light → dark, matching the palette ramps (white bookend on the left).
85
- const byLuminance = (arr: typeof swatches) => [...arr].sort((a, b) => b.l - a.l);
86
- let coreSwatches = $derived(byLuminance(swatches.filter((sw) => !FUNCTIONAL_FAMILIES.has(sw.label))));
87
- let functionalSwatches = $derived(byLuminance(swatches.filter((sw) => FUNCTIONAL_FAMILIES.has(sw.label))));
84
+ // Declaration order, never a computed one: sorting by luminance made the row
85
+ // reshuffle under the pointer while a lightness edit was in flight.
86
+ let coreSwatches = $derived(swatches.filter((sw) => !FUNCTIONAL_FAMILIES.has(sw.label)));
87
+ let functionalSwatches = $derived(swatches.filter((sw) => FUNCTIONAL_FAMILIES.has(sw.label)));
88
88
 
89
89
  let shownModeLabel = $derived(
90
90
  HARMONY_MODE_BUTTONS.find((b) => b.mode === (hoverMode ?? activeMode))?.label ?? '',
@@ -135,7 +135,6 @@
135
135
 
136
136
  <div class="harmony-actions">
137
137
  <UIPillButton
138
- size="compact"
139
138
  icon="fa-fill-drip"
140
139
  title="Re-hue Neutral and Alternate to the anchor color (their own chroma and lightness kept)"
141
140
  onclick={tintNeutrals}
@@ -183,7 +182,6 @@
183
182
  aria-label={`Select ${sw.display}`}
184
183
  aria-pressed={selected === sw.label}
185
184
  onclick={() => select(sw.label)}
186
- animate:flip={{ duration: 200, easing: cubicOut }}
187
185
  >
188
186
  <span class="chip">
189
187
  {#if sw.onWheel}<span class="wheel-dot" title="On the wheel" aria-hidden="true"></span>{/if}
@@ -225,24 +223,17 @@
225
223
 
226
224
  <div class="pane">
227
225
  <section id="colors-story" class="block">
228
- <div class="head-row">
229
- <header class="block-head">
230
- <span class="eyebrow">Proportional preview</span>
231
- <h2 class="title">Color Story</h2>
232
- </header>
233
- <UIPillButton
234
- icon="fa-wand-magic-sparkles"
235
- title="Solve each family's Text lightness curve so derived text meets WCAG AA against its surfaces (one undo)"
236
- onclick={deriveAccessibleText}
237
- >Derive accessible text</UIPillButton>
238
- </div>
239
- <ColorStory />
226
+ <header class="block-head">
227
+ <span class="eyebrow">Proportional preview</span>
228
+ <h2 class="title">Color Story</h2>
229
+ </header>
230
+ <ColorStory full={fullPalettes} />
240
231
  </section>
241
232
 
242
233
  <section id="colors-axes" class="block">
243
234
  <h2 class="title">Harmony axes</h2>
244
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>
245
- <HarmonyAxesList {activeMode} />
236
+ <HarmonyAxesList {activeMode} {selected} onSelect={select} />
246
237
  </section>
247
238
  </div>
248
239
  </div>
@@ -255,6 +246,17 @@
255
246
  align-items: start;
256
247
  }
257
248
 
249
+ /* Wide standalone layouts (Colors page, full-screen editor): pin the story
250
+ pane to the width it was designed at in the docked panel — wider stretches
251
+ the seed-color bars into heavy slabs — and let the wheel column absorb the
252
+ rest. The docked panel's iframe viewport stays below this breakpoint. */
253
+ @media (min-width: 1200px) {
254
+ .colors-tab {
255
+ grid-template-columns: minmax(0, 1fr) 30rem;
256
+ gap: var(--ui-space-48);
257
+ }
258
+ }
259
+
258
260
  .pane {
259
261
  display: flex;
260
262
  flex-direction: column;
@@ -264,7 +266,7 @@
264
266
 
265
267
  .axes-desc {
266
268
  margin: 0;
267
- font-size: var(--ui-font-size-sm);
269
+ font-size: var(--ui-font-size-md);
268
270
  line-height: 1.5;
269
271
  color: var(--ui-text-secondary);
270
272
  }
@@ -424,14 +426,6 @@
424
426
  color: var(--ui-text-secondary);
425
427
  }
426
428
 
427
- .head-row {
428
- display: flex;
429
- align-items: flex-end;
430
- justify-content: space-between;
431
- gap: var(--ui-space-12);
432
- flex-wrap: wrap;
433
- }
434
-
435
429
  .harmony-actions {
436
430
  display: flex;
437
431
  gap: var(--ui-space-8);
@@ -445,8 +439,7 @@
445
439
  }
446
440
 
447
441
  /* Dock magnification, mirroring PaletteStepStrip: the flex-grow transition
448
- animates the selected swatch opening while flip walks it to its
449
- luminance-sorted spot as lightness is dialed. */
442
+ animates the selected swatch opening in place. */
450
443
  .swatch-row {
451
444
  display: flex;
452
445
  align-items: flex-end;
@@ -12,9 +12,12 @@
12
12
  /** The applied harmony mode: an unbound slot is disabled when the geometry
13
13
  * deals it no distinct position (complementary enables only the first two). */
14
14
  activeMode: HarmonyMode;
15
+ /** Family selected in the swatch grid / wheel — its row (or chip) reads as active here. */
16
+ selected: string | null;
17
+ onSelect: (family: string) => void;
15
18
  }
16
19
 
17
- let { activeMode }: Props = $props();
20
+ let { activeMode, selected, onSelect }: Props = $props();
18
21
 
19
22
  const DRAG_TYPE = 'application/x-harmony-family';
20
23
  // Unbound swatch previews the hue a dropped color would take, at fixed preview
@@ -113,6 +116,7 @@
113
116
  <div
114
117
  class="axis-row"
115
118
  class:disabled
119
+ class:selected={axis.family !== null && axis.family === selected}
116
120
  class:drop-target={dragTarget === i}
117
121
  aria-disabled={disabled || undefined}
118
122
  ondragover={(e) => onDragOver(e, i)}
@@ -126,13 +130,16 @@
126
130
  <button
127
131
  type="button"
128
132
  class="chip"
133
+ class:selected={family === selected}
129
134
  draggable="true"
130
135
  data-family={family}
131
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}
132
138
  title="Drag to another axis or to Unassigned. Arrow keys move it, Delete unbinds."
133
139
  ondragstart={(e) => onDragStart(e, family)}
134
140
  ondragend={onDragEnd}
135
141
  onkeydown={(e) => onChipKeydown(e, family)}
142
+ onclick={() => onSelect(family)}
136
143
  >
137
144
  <span class="grip" aria-hidden="true">⋮⋮</span>
138
145
  <span class="chip-swatch" style="--fill: {familyHex(family)}"></span>
@@ -159,13 +166,16 @@
159
166
  <button
160
167
  type="button"
161
168
  class="chip"
169
+ class:selected={family === selected}
162
170
  draggable="true"
163
171
  data-family={family}
164
172
  aria-label={`${family}, unassigned, position ${positionCount} of ${positionCount}. Arrow up to bind it to ${AXIS_ROLES[lastAxis]}.`}
173
+ aria-pressed={family === selected}
165
174
  title={`Drag onto an axis to bind it. Arrow up binds it to ${AXIS_ROLES[lastAxis]}.`}
166
175
  ondragstart={(e) => onDragStart(e, family)}
167
176
  ondragend={onDragEnd}
168
177
  onkeydown={(e) => onChipKeydown(e, family)}
178
+ onclick={() => onSelect(family)}
169
179
  animate:flip={{ duration: 200, easing: cubicOut }}
170
180
  >
171
181
  <span class="grip" aria-hidden="true">⋮⋮</span>
@@ -195,7 +205,7 @@
195
205
  background: var(--ui-surface-lowest);
196
206
  border: 1px solid var(--ui-border-low);
197
207
  border-radius: var(--ui-radius-md);
198
- font-size: var(--ui-font-size-sm);
208
+ font-size: var(--ui-font-size-md);
199
209
  transition:
200
210
  border-color var(--ui-transition-fast),
201
211
  background var(--ui-transition-fast);
@@ -207,6 +217,19 @@
207
217
  background: var(--ui-surface-low);
208
218
  }
209
219
 
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);
225
+ }
226
+
227
+ .axis-row.selected .role,
228
+ .axis-row.selected .role-num {
229
+ color: var(--ui-text-primary);
230
+ font-weight: var(--ui-font-weight-semibold);
231
+ }
232
+
210
233
  .role {
211
234
  flex: none;
212
235
  width: 6.5rem;
@@ -241,7 +264,7 @@
241
264
 
242
265
  .empty {
243
266
  color: var(--ui-text-tertiary);
244
- font-size: var(--ui-font-size-sm);
267
+ font-size: var(--ui-font-size-md);
245
268
  }
246
269
 
247
270
  .grip {
@@ -262,7 +285,7 @@
262
285
  border: 1px solid var(--ui-border-low);
263
286
  border-radius: var(--ui-radius-md);
264
287
  color: var(--ui-text-primary);
265
- font-size: var(--ui-font-size-sm);
288
+ font-size: var(--ui-font-size-md);
266
289
  line-height: 1;
267
290
  cursor: grab;
268
291
  user-select: none;
@@ -272,6 +295,11 @@
272
295
  border-color: var(--ui-border-high);
273
296
  }
274
297
 
298
+ .chip.selected {
299
+ border-color: var(--ui-text-primary);
300
+ background: var(--ui-surface-high);
301
+ }
302
+
275
303
  .chip:focus-visible {
276
304
  outline: 2px solid var(--ui-border-higher);
277
305
  outline-offset: 2px;