@motion-proto/live-tokens 0.91.2 → 0.93.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 (49) hide show
  1. package/.claude/skills/live-tokens-create-page/SKILL.md +10 -3
  2. package/.claude/skills/live-tokens-set-type/SKILL.md +3 -3
  3. package/CHANGELOG.md +171 -0
  4. package/bin/lib/tokenVocabulary.mjs +2 -2
  5. package/bin/rules/testRuns.mjs +1 -1
  6. package/bin/rules/tokens.mjs +3 -3
  7. package/dist-plugin/chunk-7XSUHJUH.js +1652 -0
  8. package/dist-plugin/index.cjs +950 -15
  9. package/dist-plugin/index.js +1 -1
  10. package/dist-plugin/setType/index.cjs +23 -2
  11. package/dist-plugin/setType/index.js +23 -2
  12. package/dist-plugin/tokensCssMigrations/index.cjs +954 -15
  13. package/dist-plugin/tokensCssMigrations/index.d.cts +23 -3
  14. package/dist-plugin/tokensCssMigrations/index.d.ts +23 -3
  15. package/dist-plugin/tokensCssMigrations/index.js +5 -1
  16. package/package.json +1 -1
  17. package/src/app/site.css +243 -9
  18. package/src/demo/TestingLoops.svelte +9 -9
  19. package/src/editor/core/fonts/weightCoverage.ts +31 -2
  20. package/src/editor/core/palettes/tokenRegistry.ts +36 -37
  21. package/src/editor/core/store/editorKeybindings.ts +1 -1
  22. package/src/editor/core/themes/parsers/mediaBlocks.ts +46 -0
  23. package/src/editor/core/typeScale/typeScale.ts +341 -0
  24. package/src/editor/skill-atlas/SourcePane.svelte +3 -3
  25. package/src/editor/skill-atlas/skillSources.generated.ts +2 -2
  26. package/src/editor/skill-atlas/trees/create-page.ts +14 -14
  27. package/src/editor/skill-atlas/trees/set-type.ts +2 -2
  28. package/src/editor/ui/UIFontFamilySelector.svelte +5 -1
  29. package/src/editor/ui/UIFontWeightSelector.svelte +4 -0
  30. package/src/editor/ui/UILetterSpacingSelector.svelte +4 -0
  31. package/src/editor/ui/UILineHeightSelector.svelte +4 -0
  32. package/src/editor/ui/UITextTransformSelector.svelte +4 -0
  33. package/src/editor/ui/UIVariantSelector.svelte +6 -8
  34. package/src/editor/ui/VariablesTab.svelte +4 -4
  35. package/src/editor/ui/colors/ColorsTab.svelte +99 -5
  36. package/src/editor/ui/colors/paletteBaseColor.ts +17 -0
  37. package/src/editor/ui/sections/textStyles.ts +44 -92
  38. package/src/editor/ui/text-styles/ControlBody.svelte +100 -0
  39. package/src/editor/ui/text-styles/ResetIcon.svelte +60 -0
  40. package/src/editor/ui/text-styles/StepRow.svelte +330 -0
  41. package/src/editor/ui/text-styles/StyleControl.svelte +128 -0
  42. package/src/editor/ui/text-styles/TextStylesSection.svelte +551 -0
  43. package/src/editor/ui/text-styles/textStyleEdits.ts +308 -0
  44. package/src/editor/ui/variantScales.ts +16 -0
  45. package/src/system/styles/tokens.css +412 -48
  46. package/src/testing-js/page-compliance.contract.js +233 -80
  47. package/src/testing-js/page-compliance.contract.js.map +1 -1
  48. package/dist-plugin/chunk-GWMEYBZ2.js +0 -715
  49. package/src/editor/ui/TextStylesSection.svelte +0 -171
@@ -2,7 +2,7 @@ import type { SkillTree } from '../types';
2
2
 
3
3
  export const createPage: SkillTree = {
4
4
  "id": "live-tokens-create-page",
5
- "digest": "sha256:e9eafa612a3b863b",
5
+ "digest": "sha256:dac3be326461ce44",
6
6
  "title": "create-page",
7
7
  "tagline": "Create a Page Using Live Tokens",
8
8
  "nodes": [
@@ -164,19 +164,19 @@ export const createPage: SkillTree = {
164
164
  },
165
165
  {
166
166
  "label": "Type",
167
- "lines": [105, 120],
167
+ "lines": [105, 127],
168
168
  "anchor": "### Type",
169
169
  "anchorEnd": "Use the semantic element for each place: one `h1`, an `h2` f"
170
170
  },
171
171
  {
172
172
  "label": "Size and emphasis",
173
- "lines": [122, 130],
173
+ "lines": [129, 137],
174
174
  "anchor": "### Size",
175
175
  "anchorEnd": "In a row of actions the primary sits last, on the right. Up "
176
176
  },
177
177
  {
178
178
  "label": "Spacing",
179
- "lines": [132, 146],
179
+ "lines": [139, 153],
180
180
  "anchor": "### Spacing",
181
181
  "anchorEnd": "Every section after the first opens with a hairline: `paddin"
182
182
  }
@@ -193,12 +193,12 @@ export const createPage: SkillTree = {
193
193
  "chips": [
194
194
  {
195
195
  "label": "LiveTokensRouter",
196
- "lines": [152, 152],
196
+ "lines": [159, 159],
197
197
  "anchor": "`<LiveTokensRouter pages={...}>`: add a `pages` entry with `"
198
198
  },
199
199
  {
200
200
  "label": "LiveEditorOverlay",
201
- "lines": [153, 153],
201
+ "lines": [160, 160],
202
202
  "anchor": "Manual `<LiveEditorOverlay>`: dispatch with `$derived.by(() "
203
203
  }
204
204
  ]
@@ -209,7 +209,7 @@ export const createPage: SkillTree = {
209
209
  "kind": "cli",
210
210
  "title": "Run check-page",
211
211
  "desc": "check-page --tests --strict --json applies every auto repair, proves the rendered page at its own route, and returns the fixes beside the remaining findings.",
212
- "lines": [170, 170],
212
+ "lines": [177, 177],
213
213
  "anchor": "Run `npx live-tokens check-page <file> --tests --strict --js"
214
214
  },
215
215
  {
@@ -218,7 +218,7 @@ export const createPage: SkillTree = {
218
218
  "kind": "gate",
219
219
  "title": "Repair from guidance",
220
220
  "desc": "Each remaining finding carries its guidance. Make each repair, then run check-page again.",
221
- "lines": [172, 172],
221
+ "lines": [179, 179],
222
222
  "anchor": "Each remaining finding carries a rule id, a line, and its `g"
223
223
  },
224
224
  {
@@ -227,31 +227,31 @@ export const createPage: SkillTree = {
227
227
  "kind": "chipset",
228
228
  "title": "Check the page in the browser",
229
229
  "desc": "The checkers cannot see a layout. Open the page at its width and check each line.",
230
- "lines": [174, 190],
230
+ "lines": [181, 197],
231
231
  "anchor": "The checkers cannot see a layout. Open the page at the width",
232
232
  "anchorEnd": "`references/interaction-sources.md` names the sources for th",
233
233
  "chips": [
234
234
  {
235
235
  "label": "Structure",
236
- "lines": [176, 180],
236
+ "lines": [183, 187],
237
237
  "anchor": "The first section holds what the user came for.",
238
238
  "anchorEnd": "The containers in a section align at the bottom."
239
239
  },
240
240
  {
241
241
  "label": "Actions",
242
- "lines": [181, 184],
242
+ "lines": [188, 191],
243
243
  "anchor": "The actions sit where the eye goes last, with the one primar",
244
244
  "anchorEnd": "An action that runs longer than a moment shows progress in a"
245
245
  },
246
246
  {
247
247
  "label": "Fields",
248
- "lines": [185, 186],
248
+ "lines": [192, 193],
249
249
  "anchor": "Every field has a default, and Reset restores it.",
250
250
  "anchorEnd": "Secondary settings sit in a `CollapsibleSection`. Every cont"
251
251
  },
252
252
  {
253
253
  "label": "Words and access",
254
- "lines": [187, 188],
254
+ "lines": [194, 195],
255
255
  "anchor": "Labels use the user's words, such as \"Export slices\".",
256
256
  "anchorEnd": "Every `img` has `alt` text. Focus order follows the reading "
257
257
  }
@@ -263,7 +263,7 @@ export const createPage: SkillTree = {
263
263
  "kind": "step",
264
264
  "title": "Read the page twice",
265
265
  "desc": "From a distance only the sections show. Up close, every border and bar earns its place or goes.",
266
- "lines": [192, 192],
266
+ "lines": [199, 199],
267
267
  "anchor": "Then read the page from a distance: the sections and their e"
268
268
  },
269
269
  {
@@ -2,7 +2,7 @@ import type { SkillTree } from '../types';
2
2
 
3
3
  export const setType: SkillTree = {
4
4
  "id": "live-tokens-set-type",
5
- "digest": "sha256:0c8d26a16313f9ee",
5
+ "digest": "sha256:51d5983b67b548cd",
6
6
  "title": "set-type",
7
7
  "tagline": "Set Typefaces from Google Fonts",
8
8
  "nodes": [
@@ -38,7 +38,7 @@ export const setType: SkillTree = {
38
38
  "label": "Choose the body face first",
39
39
  "lines": [30, 34],
40
40
  "anchor": "## Choose the body face first",
41
- "anchorEnd": "The shipped text styles ask the display face for 600 and the"
41
+ "anchorEnd": "The display and heading usages ask the display face for 600 "
42
42
  },
43
43
  {
44
44
  "label": "The font matrix",
@@ -15,6 +15,8 @@
15
15
  disabled?: boolean;
16
16
  selectionsLocked?: boolean;
17
17
  onchange?: () => void;
18
+ /** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
19
+ onwrite?: (value: string | null) => void;
18
20
  }
19
21
 
20
22
  let {
@@ -24,6 +26,7 @@
24
26
  disabled = false,
25
27
  selectionsLocked = false,
26
28
  onchange,
29
+ onwrite,
27
30
  }: Props = $props();
28
31
 
29
32
  const SOURCE_KIND_LABELS: Record<FontSourceKind, string> = {
@@ -191,6 +194,7 @@
191
194
  dropdownMinWidth="14rem"
192
195
  onreset={handleReset}
193
196
  onvarChange={initFromCurrent}
197
+ {onwrite}
194
198
  >
195
199
  {#snippet triggerTitle()}{activeLabel}{/snippet}
196
200
  {#snippet triggerMeta()}{displayFamily || '—'}{/snippet}
@@ -198,7 +202,7 @@
198
202
  {#snippet children({ close })}
199
203
 
200
204
  <UIOptionList>
201
- {#each options as opt}
205
+ {#each options as opt (opt.key)}
202
206
  <UIOptionItem
203
207
  active={chosenKey === opt.key}
204
208
  onclick={() => selectOption(opt.key, close)}
@@ -16,6 +16,8 @@
16
16
  disabled?: boolean;
17
17
  selectionsLocked?: boolean;
18
18
  onchange?: () => void;
19
+ /** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
20
+ onwrite?: (value: string | null) => void;
19
21
  }
20
22
 
21
23
  let {
@@ -25,6 +27,7 @@
25
27
  disabled = false,
26
28
  selectionsLocked = false,
27
29
  onchange,
30
+ onwrite,
28
31
  }: Props = $props();
29
32
 
30
33
  const options = [
@@ -87,6 +90,7 @@
87
90
  varPrefix="--font-weight-"
88
91
  {options}
89
92
  {onchange}
93
+ {onwrite}
90
94
  >
91
95
  {#snippet option({ opt, active, select })}
92
96
  {@const unsupported = availability !== null && !availability.weights.has(Number(opt.value))}
@@ -9,6 +9,8 @@
9
9
  disabled?: boolean;
10
10
  selectionsLocked?: boolean;
11
11
  onchange?: () => void;
12
+ /** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
13
+ onwrite?: (value: string | null) => void;
12
14
  }
13
15
 
14
16
  let {
@@ -18,6 +20,7 @@
18
20
  disabled = false,
19
21
  selectionsLocked = false,
20
22
  onchange,
23
+ onwrite,
21
24
  }: Props = $props();
22
25
 
23
26
  const options = [
@@ -38,6 +41,7 @@
38
41
  varPrefix="--letter-spacing-"
39
42
  {options}
40
43
  {onchange}
44
+ {onwrite}
41
45
  >
42
46
  {#snippet option({ opt, active, select })}
43
47
  <UIOptionItem {active} onclick={select}>
@@ -9,6 +9,8 @@
9
9
  disabled?: boolean;
10
10
  selectionsLocked?: boolean;
11
11
  onchange?: () => void;
12
+ /** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
13
+ onwrite?: (value: string | null) => void;
12
14
  }
13
15
 
14
16
  let {
@@ -18,6 +20,7 @@
18
20
  disabled = false,
19
21
  selectionsLocked = false,
20
22
  onchange,
23
+ onwrite,
21
24
  }: Props = $props();
22
25
 
23
26
  const options = [
@@ -39,6 +42,7 @@
39
42
  varPrefix="--line-height-"
40
43
  {options}
41
44
  {onchange}
45
+ {onwrite}
42
46
  >
43
47
  {#snippet option({ opt, active, select })}
44
48
 
@@ -12,6 +12,8 @@
12
12
  disabled?: boolean;
13
13
  selectionsLocked?: boolean;
14
14
  onchange?: () => void;
15
+ /** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
16
+ onwrite?: (value: string | null) => void;
15
17
  }
16
18
 
17
19
  let {
@@ -21,6 +23,7 @@
21
23
  disabled = false,
22
24
  selectionsLocked = false,
23
25
  onchange,
26
+ onwrite,
24
27
  }: Props = $props();
25
28
 
26
29
  // text-transform is a literal CSS keyword, not a scale alias — the picker
@@ -78,6 +81,7 @@
78
81
  dropdownMinWidth="12rem"
79
82
  onreset={handleReset}
80
83
  onvarChange={readResolved}
84
+ {onwrite}
81
85
  >
82
86
  {#snippet triggerTitle()}{activeLabel}{/snippet}
83
87
  {#snippet triggerMeta()}{current || '—'}{/snippet}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts" generics="T extends { key: string; label?: string; value?: string }">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { resolveAliasChain } from '../core/palettes/tokenRegistry';
4
+ import { parseVariantRef } from './variantScales';
4
5
  import UITokenSelector from './UITokenSelector.svelte';
5
6
  import UIOptionList from './UIOptionList.svelte';
6
7
  import UIOptionItem from './UIOptionItem.svelte';
@@ -28,6 +29,8 @@
28
29
  /** Trailing dropdown content (e.g. action buttons). */
29
30
  extras?: Snippet<[{ close: () => void }]>;
30
31
  onchange?: () => void;
32
+ /** Routes writes to the caller in place of the DOM; see `UITokenSelector`. */
33
+ onwrite?: (value: string | null) => void;
31
34
  }
32
35
 
33
36
  let {
@@ -46,6 +49,7 @@
46
49
  option: callerOption,
47
50
  extras: callerExtras,
48
51
  onchange,
52
+ onwrite,
49
53
  }: Props<T> = $props();
50
54
 
51
55
  let selector: UITokenSelector;
@@ -53,16 +57,9 @@
53
57
  let currentValue: string = $state('');
54
58
 
55
59
  let validKeys = $derived(new Set(options.map((o) => o.key)));
56
- let refMatcher = $derived.by(() => {
57
- const escaped = varPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
- return new RegExp(`var\\((${escaped}[^)\\s]+)\\)`);
59
- });
60
60
 
61
61
  function parseRef(value: string): string | null {
62
- const m = value.match(refMatcher);
63
- if (!m) return null;
64
- const key = m[1].slice(varPrefix.length);
65
- return validKeys.has(key) ? key : null;
62
+ return parseVariantRef(value, varPrefix, validKeys);
66
63
  }
67
64
 
68
65
  function readResolved() {
@@ -137,6 +134,7 @@
137
134
  {dropdownMaxWidth}
138
135
  onreset={handleReset}
139
136
  onvarChange={initFromCurrent}
137
+ {onwrite}
140
138
  >
141
139
  {#snippet triggerTitle()}
142
140
  {#if callerTriggerTitle}{@render callerTriggerTitle({ activeOption })}{:else}{activeOption?.label ?? ''}{/if}
@@ -4,7 +4,7 @@
4
4
  import { PALETTE_SPECS } from '../core/palettes/paletteDerivation';
5
5
  import FontStackEditor from './FontStackEditor.svelte';
6
6
  import ProjectFontsSection from './ProjectFontsSection.svelte';
7
- import TextStylesSection from './TextStylesSection.svelte';
7
+ import TextStylesSection from './text-styles/TextStylesSection.svelte';
8
8
  import ColumnsSection from './sections/ColumnsSection.svelte';
9
9
  import TokenScaleTable from './sections/TokenScaleTable.svelte';
10
10
  import WashesSection from './sections/WashesSection.svelte';
@@ -100,7 +100,6 @@
100
100
  </div>
101
101
 
102
102
  <div class="typography-group text-styles-group">
103
- <h3 class="group-title">Text Styles</h3>
104
103
  <TextStylesSection />
105
104
  </div>
106
105
 
@@ -254,10 +253,11 @@
254
253
  gap: var(--ui-space-32);
255
254
  }
256
255
 
257
- /* The definition table is wide (four pickers + preview per row); give it the
258
- full row so it isn't squeezed into an auto-fill column. */
256
+ /* Each step row holds three size inputs, a sample and four pickers, so the
257
+ group takes the full row. */
259
258
  .text-styles-group {
260
259
  grid-column: 1 / -1;
260
+ gap: var(--ui-space-16);
261
261
  }
262
262
 
263
263
  /* Utility Tokens */
@@ -1,8 +1,9 @@
1
1
  <script lang="ts">
2
2
  import { cubicOut } from 'svelte/easing';
3
- import { oklchToHexClamped } from '../../core/palettes/oklch';
3
+ import { cssColorToOklch, oklchToCss, oklchToHexClamped } from '../../core/palettes/oklch';
4
4
  import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
5
5
  import { editorState, beginSliderGesture } from '../../core/store/editorStore';
6
+ import { isEditable } from '../../core/store/editorKeybindings';
6
7
  import { selectedPalette } from '../../core/store/paletteFocus';
7
8
  import { applyHarmonyToAxes, axisStatuses, HARMONY_ELIGIBLE, type HarmonyMode } from '../../core/palettes/colorHarmony';
8
9
  import AxisNumeral from './AxisNumeral.svelte';
@@ -13,10 +14,12 @@
13
14
  import PaletteStepStrip from './PaletteStepStrip.svelte';
14
15
  import HarmonyAxesList from './HarmonyAxesList.svelte';
15
16
  import PaletteJumpButton from '../palette/PaletteJumpButton.svelte';
17
+ import { showCopyPopover } from '../copyPopover';
16
18
  import {
17
19
  setBaseColor,
18
20
  setAxisHues,
19
21
  unbindFamily,
22
+ swapBaseColors,
20
23
  palettesWithDefaults,
21
24
  } from './paletteBaseColor';
22
25
  import { HARMONY_MODE_BUTTONS } from './harmonyModeIcons';
@@ -108,23 +111,99 @@
108
111
  if (family) unbindFamily(family);
109
112
  }
110
113
 
114
+ // A swatch dropped on another swatch swaps their colors; a chip dropped there
115
+ // still unassigns. The swap source is held here because dataTransfer data is
116
+ // unreadable during dragover, and both ends of a swap live in this row.
117
+ let swapSource = $state<string | null>(null);
118
+ let swapTarget = $state<string | null>(null);
119
+
120
+ function onSwatchDragStart(e: DragEvent, family: string) {
121
+ startFamilyDrag(e, family);
122
+ swapSource = family;
123
+ }
124
+
125
+ function onSwatchDragEnd() {
126
+ swapSource = null;
127
+ swapTarget = null;
128
+ }
129
+
130
+ function acceptsSwap(sw: Swatch): boolean {
131
+ return sw.eligible && swapSource !== null && swapSource !== sw.label;
132
+ }
133
+
134
+ function onSwatchDragOver(e: DragEvent, sw: Swatch) {
135
+ if (!acceptsSwap(sw)) return;
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
139
+ swapTarget = sw.label;
140
+ unassignHover = false;
141
+ }
142
+
143
+ function onSwatchDragLeave(sw: Swatch) {
144
+ if (swapTarget === sw.label) swapTarget = null;
145
+ }
146
+
147
+ function onSwatchDrop(e: DragEvent, sw: Swatch) {
148
+ if (!acceptsSwap(sw)) return;
149
+ e.preventDefault();
150
+ e.stopPropagation();
151
+ swapBaseColors(swapSource!, sw.label);
152
+ onSwatchDragEnd();
153
+ }
154
+
155
+ const swatchEls: Record<string, HTMLButtonElement> = {};
156
+
157
+ // Copy and paste act on the selected swatch, never the focused one: Safari
158
+ // and macOS Firefox do not focus a button on click.
159
+ function onWindowKeydown(e: KeyboardEvent) {
160
+ if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return;
161
+ if (e.target instanceof HTMLElement && isEditable(e.target)) return;
162
+ const sw = swatches.find((s) => s.label === selected);
163
+ if (!sw) return;
164
+ const key = e.key.toLowerCase();
165
+ if (key === 'c') {
166
+ // Selected page text keeps the browser's own copy.
167
+ if (window.getSelection()?.isCollapsed === false) return;
168
+ e.preventDefault();
169
+ const css = oklchToCss(sw.color.l, sw.color.c, sw.color.h);
170
+ navigator.clipboard?.writeText(css);
171
+ showCopyPopover(css, swatchEls[sw.label]);
172
+ } else if (key === 'v') {
173
+ e.preventDefault();
174
+ pasteInto(sw, swatchEls[sw.label]);
175
+ }
176
+ }
177
+
178
+ async function pasteInto(sw: Swatch, anchor: EventTarget | null) {
179
+ const color = cssColorToOklch(await navigator.clipboard.readText());
180
+ if (!color) return;
181
+ // A bound color drags its axis along, so an applied harmony no longer holds.
182
+ if (sw.axis && color.h !== sw.color.h) activeMode = 'custom';
183
+ setBaseColor(sw.label, color);
184
+ showCopyPopover(oklchToCss(color.l, color.c, color.h), anchor);
185
+ }
186
+
111
187
  let fullPalettes = $derived(palettesWithDefaults($editorState.palettes));
112
188
 
113
189
  let swatches = $derived.by(() => {
114
190
  const statuses = axisStatuses(activeMode, $editorState.harmonyAxes);
115
191
  return PALETTE_SPECS.map((spec) => {
116
- const { l, c, h } = $editorState.palettes[spec.label]?.baseColor ?? spec.initialColor;
192
+ const color = $editorState.palettes[spec.label]?.baseColor ?? spec.initialColor;
117
193
  const index = $editorState.harmonyAxes.findIndex((a) => a.family === spec.label);
118
194
  return {
119
195
  label: spec.label,
120
196
  display: spec.displayLabel ?? spec.label,
121
- hex: oklchToHexClamped(l, c, h),
197
+ color,
198
+ hex: oklchToHexClamped(color.l, color.c, color.h),
122
199
  axis: index === -1 ? null : { index, status: statuses[index] },
123
200
  eligible: ELIGIBLE.has(spec.label),
124
201
  };
125
202
  });
126
203
  });
127
204
 
205
+ type Swatch = (typeof swatches)[number];
206
+
128
207
  const FUNCTIONAL_FAMILIES = new Set(['Info', 'Success', 'Warning', 'Danger']);
129
208
  // Declaration order, never a computed one: sorting by luminance made the row
130
209
  // reshuffle under the pointer while a lightness edit was in flight.
@@ -150,6 +229,8 @@
150
229
  }
151
230
  </script>
152
231
 
232
+ <svelte:window onkeydown={onWindowKeydown} />
233
+
153
234
  <div class="colors-tab">
154
235
  <div class="pane">
155
236
  <section id="colors-wheel" class="block">
@@ -227,7 +308,7 @@
227
308
  <div class="harmony-cols">
228
309
  <div class="group">
229
310
  <h3 class="title">Selected color</h3>
230
- {#snippet swatchRow(row: typeof swatches, acceptsDrop = false)}
311
+ {#snippet swatchRow(row: Swatch[], acceptsDrop = false)}
231
312
  <!-- svelte-ignore a11y_no_static_element_interactions -->
232
313
  <div
233
314
  class="swatch-row"
@@ -242,12 +323,18 @@
242
323
  class="swatch"
243
324
  class:active={selected === sw.label}
244
325
  class:draggable={sw.eligible}
326
+ class:swap-target={swapTarget === sw.label}
245
327
  style="--swatch-fill: {sw.hex}"
246
328
  draggable={sw.eligible}
247
329
  title={sw.eligible ? `${sw.display} · drag onto an axis to assign it` : sw.display}
248
330
  aria-label={`Select ${sw.display}`}
249
331
  aria-pressed={selected === sw.label}
250
- ondragstart={(e) => startFamilyDrag(e, sw.label)}
332
+ bind:this={swatchEls[sw.label]}
333
+ ondragstart={(e) => onSwatchDragStart(e, sw.label)}
334
+ ondragend={onSwatchDragEnd}
335
+ ondragover={(e) => onSwatchDragOver(e, sw)}
336
+ ondragleave={() => onSwatchDragLeave(sw)}
337
+ ondrop={(e) => onSwatchDrop(e, sw)}
251
338
  onclick={() => select(sw.label)}
252
339
  >
253
340
  <span class="frame">
@@ -685,6 +772,13 @@
685
772
  border-color: var(--ui-border-high);
686
773
  }
687
774
 
775
+ /* The axis row's drop ring, doubled so it never reads as selection. */
776
+ .swatch.swap-target .frame {
777
+ border-color: var(--ui-text-primary);
778
+ outline: 1px solid var(--ui-text-primary);
779
+ outline-offset: 1px;
780
+ }
781
+
688
782
  .swatch-label {
689
783
  font-size: var(--ui-font-size-xs);
690
784
  color: var(--ui-text-secondary);
@@ -208,6 +208,23 @@ function giveAxisHue(s: EditorState, index: number, family: string): boolean {
208
208
  return false;
209
209
  }
210
210
 
211
+ /** Two families trade seed colors. Each axis binding travels with the color it
212
+ * held, so every axis keeps its hue and an applied harmony stays true. */
213
+ export function swapBaseColors(a: string, b: string): void {
214
+ if (a === b) return;
215
+ mutate(`colors: swap ${a} and ${b}`, (s) => {
216
+ const ca = ensureConfig(s, a);
217
+ const cb = ensureConfig(s, b);
218
+ [ca.baseColor, cb.baseColor] = [cb.baseColor, ca.baseColor];
219
+ syncBaseAnchor(ca);
220
+ syncBaseAnchor(cb);
221
+ for (const axis of s.harmonyAxes) {
222
+ if (axis.family === a) axis.family = b;
223
+ else if (axis.family === b) axis.family = a;
224
+ }
225
+ });
226
+ }
227
+
211
228
  /** Set several seed colors in ONE undo entry (harmony apply, global rotate). */
212
229
  export function setBaseColors(patch: Record<string, Oklch>, historyLabel = 'colors: harmony'): void {
213
230
  transaction(historyLabel, (s) => {