@motion-proto/live-tokens 0.92.0 → 0.94.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.94.0 — Tokens-view swatch swap and copy-paste
4
+
5
+ ### Added
6
+
7
+ - **The Tokens view swaps two palettes' base colors by drag and drop.** Drop
8
+ one family's header swatch on another's and the two trade seed colors in one
9
+ undo step. A swap with a functional family leaves any harmony axis on the
10
+ family that held it, and the axis follows the new hue.
11
+ - **Every Tokens-view swatch copies and pastes its color.** Click a header,
12
+ palette step, or override swatch open, and ⌘C (Ctrl+C) copies its color as
13
+ `oklch()` text. ⌘V (Ctrl+V) pastes a hex or `oklch()` color into the open
14
+ swatch, inside its edit session, so Cancel takes the paste back.
15
+
16
+ ### Fixed
17
+
18
+ - **Pasting a swatch color needs no clipboard permission.** Paste reads the
19
+ browser's paste event, so Chrome no longer asks for clipboard access and
20
+ Safari no longer shows its Paste confirmation before ⌘V lands.
21
+
22
+ ## 0.93.0 — Swatch swap and copy-paste
23
+
24
+ ### Added
25
+
26
+ - **The Colors view swaps two main colors by drag and drop.** Drop one swatch
27
+ of the main row on another and the two families trade seed colors in one
28
+ undo step. A harmony axis follows the color it held, so every axis keeps
29
+ its hue and an applied harmony stays true.
30
+ - **Every swatch copies and pastes its color.** ⌘C (Ctrl+C) copies the
31
+ selected swatch's seed as `oklch()` text, and ⌘V (Ctrl+V) sets the selected
32
+ swatch's seed from a hex or `oklch()` color on the clipboard.
33
+
34
+ ### Fixed
35
+
36
+ - **The type-scales migration derives an aliased size's tablet and phone
37
+ sizes from the alias.** A step size that aliases a primitive, such as
38
+ `--body-sm-font-size: var(--font-size-md)`, kept the scale's tablet and
39
+ phone sizes, so a 16px body sm fell to 14.6px on tablets. The migration now
40
+ resolves the alias through the file's top-level declarations and applies
41
+ the editor's rule for an edited size, so body sm holds 16px. A size it
42
+ cannot resolve, such as a `calc()`, keeps the scale's sizes.
43
+ - **The type-scales migration carries a step the project declared by hand.**
44
+ A step 0.91.2 did not ship, such as a project's own
45
+ `--heading-2xl-font-size: 1.75rem`, kept that value in its `-font-size`
46
+ while the 768px and 480px blocks re-pointed it at the scale's tablet and
47
+ phone sizes. The migration now moves the value into the step's desktop size,
48
+ points `-font-size` at it, and derives the tablet and phone sizes by the
49
+ editor's rule.
50
+ - A `tokens.css` that already ran the 0.92.0 migration never runs it again, so
51
+ these fixes reach projects that upgrade from 0.91 after this release.
52
+
3
53
  ## 0.92.0 — Type scales for text styles
4
54
 
5
55
  ### Changed (breaking)
@@ -825,6 +825,18 @@ function literalRem(size) {
825
825
  if (!m) return null;
826
826
  return m[2] === "rem" ? Number(m[1]) : Number(m[1]) / 16;
827
827
  }
828
+ var ALIAS = /^var\(\s*(--[a-z0-9-]+)\s*\)$/i;
829
+ function resolvedRem(size, values) {
830
+ const seen = /* @__PURE__ */ new Set();
831
+ let value = size.trim();
832
+ for (let ref = ALIAS.exec(value); ref; ref = ALIAS.exec(value)) {
833
+ const next = values.get(ref[1]);
834
+ if (next === void 0 || seen.has(ref[1])) return null;
835
+ seen.add(ref[1]);
836
+ value = next.trim();
837
+ }
838
+ return literalRem(value);
839
+ }
828
840
  function editedSizes(prefix, rem) {
829
841
  const count = String(Number(rem.toFixed(4)));
830
842
  return COMPRESSED_VIEWPORTS.map((viewport) => ({
@@ -1453,6 +1465,12 @@ var DECLARED = new Map(
1453
1465
  USAGES.flatMap((u) => u.steps.flatMap((s) => s.entries)).concat(EYEBROW).map((e) => [e.name, e.value])
1454
1466
  );
1455
1467
  var TYPE_PRIMITIVES = ["--letter-spacing-", "--line-height-", "--font-weight-", "--font-size-", "--font-"];
1468
+ var CARRIED_PREFIXES = new Set(CARRIED.map((step) => step.prefix));
1469
+ function handDeclaredSteps(values) {
1470
+ return USAGES.flatMap((u) => u.steps).filter(
1471
+ ({ prefix }) => !CARRIED_PREFIXES.has(prefix) && values.has(`${prefix}-font-size`) && !values.has(`${prefix}-desktop-font-size`)
1472
+ ).map(({ prefix }) => ({ prefix, links: [] }));
1473
+ }
1456
1474
  var tokensCssMigration_2026_10_08_typeScales = {
1457
1475
  id: "2026-10-08-type-scales",
1458
1476
  kind: "breaking",
@@ -1461,12 +1479,12 @@ var tokensCssMigration_2026_10_08_typeScales = {
1461
1479
  let out = css;
1462
1480
  for (const [from, to] of RENAMES3) out = renameToken(out, from, to);
1463
1481
  const values = collectTokenValues(out);
1464
- for (const step of CARRIED) {
1482
+ for (const step of [...CARRIED, ...handDeclaredSteps(values)]) {
1465
1483
  const size = values.get(`${step.prefix}-font-size`);
1466
1484
  if (size === void 0) continue;
1467
1485
  const desktop = `${step.prefix}-desktop-font-size`;
1468
1486
  const edited = size !== step.size;
1469
- const rem = edited ? literalRem(size) : null;
1487
+ const rem = edited ? resolvedRem(size, values) : null;
1470
1488
  out = ensureScale(out, {
1471
1489
  anchorPrefixes: [`${step.prefix}-font-family`, `${step.prefix}-`],
1472
1490
  entries: [
@@ -4259,6 +4259,18 @@ function literalRem(size) {
4259
4259
  if (!m) return null;
4260
4260
  return m[2] === "rem" ? Number(m[1]) : Number(m[1]) / 16;
4261
4261
  }
4262
+ var ALIAS = /^var\(\s*(--[a-z0-9-]+)\s*\)$/i;
4263
+ function resolvedRem(size, values) {
4264
+ const seen = /* @__PURE__ */ new Set();
4265
+ let value = size.trim();
4266
+ for (let ref2 = ALIAS.exec(value); ref2; ref2 = ALIAS.exec(value)) {
4267
+ const next = values.get(ref2[1]);
4268
+ if (next === void 0 || seen.has(ref2[1])) return null;
4269
+ seen.add(ref2[1]);
4270
+ value = next.trim();
4271
+ }
4272
+ return literalRem(value);
4273
+ }
4262
4274
  function editedSizes(prefix, rem) {
4263
4275
  const count = String(Number(rem.toFixed(4)));
4264
4276
  return COMPRESSED_VIEWPORTS.map((viewport) => ({
@@ -4887,6 +4899,12 @@ var DECLARED = new Map(
4887
4899
  USAGES.flatMap((u) => u.steps.flatMap((s) => s.entries)).concat(EYEBROW).map((e) => [e.name, e.value])
4888
4900
  );
4889
4901
  var TYPE_PRIMITIVES = ["--letter-spacing-", "--line-height-", "--font-weight-", "--font-size-", "--font-"];
4902
+ var CARRIED_PREFIXES = new Set(CARRIED.map((step) => step.prefix));
4903
+ function handDeclaredSteps(values) {
4904
+ return USAGES.flatMap((u) => u.steps).filter(
4905
+ ({ prefix }) => !CARRIED_PREFIXES.has(prefix) && values.has(`${prefix}-font-size`) && !values.has(`${prefix}-desktop-font-size`)
4906
+ ).map(({ prefix }) => ({ prefix, links: [] }));
4907
+ }
4890
4908
  var tokensCssMigration_2026_10_08_typeScales = {
4891
4909
  id: "2026-10-08-type-scales",
4892
4910
  kind: "breaking",
@@ -4895,12 +4913,12 @@ var tokensCssMigration_2026_10_08_typeScales = {
4895
4913
  let out = css;
4896
4914
  for (const [from, to] of RENAMES14) out = renameToken2(out, from, to);
4897
4915
  const values = collectTokenValues(out);
4898
- for (const step of CARRIED) {
4916
+ for (const step of [...CARRIED, ...handDeclaredSteps(values)]) {
4899
4917
  const size = values.get(`${step.prefix}-font-size`);
4900
4918
  if (size === void 0) continue;
4901
4919
  const desktop = `${step.prefix}-desktop-font-size`;
4902
4920
  const edited = size !== step.size;
4903
- const rem = edited ? literalRem(size) : null;
4921
+ const rem = edited ? resolvedRem(size, values) : null;
4904
4922
  out = ensureScale(out, {
4905
4923
  anchorPrefixes: [`${step.prefix}-font-family`, `${step.prefix}-`],
4906
4924
  entries: [
@@ -23,7 +23,7 @@ import {
23
23
  runAdditiveTokensCssMigrations,
24
24
  runTokensCssMigrations,
25
25
  validateTokensCss
26
- } from "./chunk-JOSSYGR2.js";
26
+ } from "./chunk-7XSUHJUH.js";
27
27
  import {
28
28
  hexToOklch,
29
29
  oklchToRgb255,
@@ -914,6 +914,18 @@ function literalRem(size) {
914
914
  if (!m) return null;
915
915
  return m[2] === "rem" ? Number(m[1]) : Number(m[1]) / 16;
916
916
  }
917
+ var ALIAS = /^var\(\s*(--[a-z0-9-]+)\s*\)$/i;
918
+ function resolvedRem(size, values) {
919
+ const seen = /* @__PURE__ */ new Set();
920
+ let value = size.trim();
921
+ for (let ref = ALIAS.exec(value); ref; ref = ALIAS.exec(value)) {
922
+ const next = values.get(ref[1]);
923
+ if (next === void 0 || seen.has(ref[1])) return null;
924
+ seen.add(ref[1]);
925
+ value = next.trim();
926
+ }
927
+ return literalRem(value);
928
+ }
917
929
  function editedSizes(prefix, rem) {
918
930
  const count = String(Number(rem.toFixed(4)));
919
931
  return COMPRESSED_VIEWPORTS.map((viewport) => ({
@@ -1542,6 +1554,12 @@ var DECLARED = new Map(
1542
1554
  USAGES.flatMap((u) => u.steps.flatMap((s) => s.entries)).concat(EYEBROW).map((e) => [e.name, e.value])
1543
1555
  );
1544
1556
  var TYPE_PRIMITIVES = ["--letter-spacing-", "--line-height-", "--font-weight-", "--font-size-", "--font-"];
1557
+ var CARRIED_PREFIXES = new Set(CARRIED.map((step) => step.prefix));
1558
+ function handDeclaredSteps(values) {
1559
+ return USAGES.flatMap((u) => u.steps).filter(
1560
+ ({ prefix }) => !CARRIED_PREFIXES.has(prefix) && values.has(`${prefix}-font-size`) && !values.has(`${prefix}-desktop-font-size`)
1561
+ ).map(({ prefix }) => ({ prefix, links: [] }));
1562
+ }
1545
1563
  var tokensCssMigration_2026_10_08_typeScales = {
1546
1564
  id: "2026-10-08-type-scales",
1547
1565
  kind: "breaking",
@@ -1550,12 +1568,12 @@ var tokensCssMigration_2026_10_08_typeScales = {
1550
1568
  let out = css;
1551
1569
  for (const [from, to] of RENAMES3) out = renameToken(out, from, to);
1552
1570
  const values = collectTokenValues(out);
1553
- for (const step of CARRIED) {
1571
+ for (const step of [...CARRIED, ...handDeclaredSteps(values)]) {
1554
1572
  const size = values.get(`${step.prefix}-font-size`);
1555
1573
  if (size === void 0) continue;
1556
1574
  const desktop = `${step.prefix}-desktop-font-size`;
1557
1575
  const edited = size !== step.size;
1558
- const rem = edited ? literalRem(size) : null;
1576
+ const rem = edited ? resolvedRem(size, values) : null;
1559
1577
  out = ensureScale(out, {
1560
1578
  anchorPrefixes: [`${step.prefix}-font-family`, `${step.prefix}-`],
1561
1579
  entries: [
@@ -15,7 +15,7 @@ import {
15
15
  semverBumpType,
16
16
  setTokenValue,
17
17
  validateTokensCss
18
- } from "../chunk-JOSSYGR2.js";
18
+ } from "../chunk-7XSUHJUH.js";
19
19
  import "../chunk-7TQQTHI6.js";
20
20
  import {
21
21
  readLiveTokensConfig
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.92.0",
3
+ "version": "0.94.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -41,7 +41,7 @@ const TEXT_INPUT_TYPES = new Set([
41
41
  'date', 'datetime-local', 'month', 'time', 'week',
42
42
  ]);
43
43
 
44
- function isEditable(el: HTMLElement): boolean {
44
+ export function isEditable(el: HTMLElement): boolean {
45
45
  const tag = el.tagName;
46
46
  if (tag === 'TEXTAREA') return true;
47
47
  if (tag === 'INPUT') {
@@ -30,9 +30,16 @@
30
30
  import { editorState, mutate, setPaletteConfig, beginSliderGesture, beginScope, commitScope, cancelScope, type Scope } from '../core/store/editorStore';
31
31
  // Base-color edits route through the shared setter so a bound harmony axis
32
32
  // follows the hue (invariant 1); the local `edit` would silently detach it.
33
- import { setBaseColor } from './colors/paletteBaseColor';
33
+ import { setBaseColor, swapBaseColors } from './colors/paletteBaseColor';
34
34
  import { pendingPaletteFocus } from '../core/store/paletteFocus';
35
35
  import { showCopyPopover } from './copyPopover';
36
+ import {
37
+ activeSwatchFamily,
38
+ isSwatchCopy,
39
+ swatchPasteColor,
40
+ copySwatchColor,
41
+ showSwatchColor,
42
+ } from './swatchClipboard';
36
43
 
37
44
  // Full sRGB chroma range (gamutClamp trims per hue/lightness). Neutrals default
38
45
  // low but are not capped; their calm character comes from defaults, not a ceiling.
@@ -565,6 +572,29 @@
565
572
  if (!panelOpen || isEditingBase || !editingColor) return;
566
573
  dock = { title: editPanelTitle, color: editingColor, paletteIndex: editingPaletteIndex };
567
574
  });
575
+ $effect(() => {
576
+ if (editingKey !== null) activeSwatchFamily.set(label);
577
+ });
578
+
579
+ let ownsClipboard = $derived($activeSwatchFamily === label && editingKey !== null);
580
+ let openSwatchEl = $derived(rootEl?.querySelector(`[data-swatch-key="${editingKey}"]`) ?? null);
581
+
582
+ function onWindowKeydown(e: KeyboardEvent) {
583
+ if (!ownsClipboard || !isSwatchCopy(e)) return;
584
+ e.preventDefault();
585
+ copySwatchColor(editing.kind === 'editingStep' ? editing.draft : baseColor, openSwatchEl);
586
+ }
587
+
588
+ // Paste lands in the open session like a slider edit: Cancel takes it back.
589
+ function onWindowPaste(e: ClipboardEvent) {
590
+ const color = ownsClipboard ? swatchPasteColor(e) : null;
591
+ if (color === null) return;
592
+ e.preventDefault();
593
+ if (isEditingBase) setBaseColor(label, color);
594
+ else handleColorChange(color);
595
+ showSwatchColor(color, openSwatchEl);
596
+ }
597
+
568
598
  $effect(() => {
569
599
  // Touch each input so the effect tracks them (resnapScales reads indirectly).
570
600
  void baseColor;
@@ -595,6 +625,8 @@
595
625
  {/if}
596
626
  {/snippet}
597
627
 
628
+ <svelte:window onkeydown={onWindowKeydown} onpaste={onWindowPaste} />
629
+
598
630
  <div class="palette-editor" bind:this={rootEl} style="--editor-base: {toHex(baseColor)}">
599
631
  <PaletteBase
600
632
  {label}
@@ -606,6 +638,7 @@
606
638
  {copiedKey}
607
639
  onStartEdit={startBaseEdit}
608
640
  onCopyBaseHex={copyHex}
641
+ onSwapFrom={(family) => swapBaseColors(family, label)}
609
642
  >
610
643
  {#snippet setting()}
611
644
  <!-- The family's own swatch and name sit alongside, so the label drops
@@ -687,6 +720,7 @@
687
720
  class:anchored={ps.anchored}
688
721
  title={ps.anchored ? 'Base color' : undefined}
689
722
  style="background: {ps.effective}"
723
+ data-swatch-key={ps.key}
690
724
  onclick={() => handlePaletteClick(ps)}
691
725
  role="button"
692
726
  tabindex="0"
@@ -13,10 +13,12 @@
13
13
  import PaletteStepStrip from './PaletteStepStrip.svelte';
14
14
  import HarmonyAxesList from './HarmonyAxesList.svelte';
15
15
  import PaletteJumpButton from '../palette/PaletteJumpButton.svelte';
16
+ import { isSwatchCopy, swatchPasteColor, copySwatchColor, showSwatchColor } from '../swatchClipboard';
16
17
  import {
17
18
  setBaseColor,
18
19
  setAxisHues,
19
20
  unbindFamily,
21
+ swapBaseColors,
20
22
  palettesWithDefaults,
21
23
  } from './paletteBaseColor';
22
24
  import { HARMONY_MODE_BUTTONS } from './harmonyModeIcons';
@@ -108,23 +110,91 @@
108
110
  if (family) unbindFamily(family);
109
111
  }
110
112
 
113
+ // A swatch dropped on another swatch swaps their colors; a chip dropped there
114
+ // still unassigns. The swap source is held here because dataTransfer data is
115
+ // unreadable during dragover, and both ends of a swap live in this row.
116
+ let swapSource = $state<string | null>(null);
117
+ let swapTarget = $state<string | null>(null);
118
+
119
+ function onSwatchDragStart(e: DragEvent, family: string) {
120
+ startFamilyDrag(e, family);
121
+ swapSource = family;
122
+ }
123
+
124
+ function onSwatchDragEnd() {
125
+ swapSource = null;
126
+ swapTarget = null;
127
+ }
128
+
129
+ function acceptsSwap(sw: Swatch): boolean {
130
+ return sw.eligible && swapSource !== null && swapSource !== sw.label;
131
+ }
132
+
133
+ function onSwatchDragOver(e: DragEvent, sw: Swatch) {
134
+ if (!acceptsSwap(sw)) return;
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
138
+ swapTarget = sw.label;
139
+ unassignHover = false;
140
+ }
141
+
142
+ function onSwatchDragLeave(sw: Swatch) {
143
+ if (swapTarget === sw.label) swapTarget = null;
144
+ }
145
+
146
+ function onSwatchDrop(e: DragEvent, sw: Swatch) {
147
+ if (!acceptsSwap(sw)) return;
148
+ e.preventDefault();
149
+ e.stopPropagation();
150
+ swapBaseColors(swapSource!, sw.label);
151
+ onSwatchDragEnd();
152
+ }
153
+
154
+ const swatchEls: Record<string, HTMLButtonElement> = {};
155
+
156
+ // Copy and paste act on the selected swatch, never the focused one: Safari
157
+ // and macOS Firefox do not focus a button on click.
158
+ function onWindowKeydown(e: KeyboardEvent) {
159
+ const sw = selectedSwatch;
160
+ if (!sw || !isSwatchCopy(e)) return;
161
+ e.preventDefault();
162
+ copySwatchColor(sw.color, swatchEls[sw.label]);
163
+ }
164
+
165
+ function onWindowPaste(e: ClipboardEvent) {
166
+ const sw = selectedSwatch;
167
+ const color = sw ? swatchPasteColor(e) : null;
168
+ if (!sw || color === null) return;
169
+ e.preventDefault();
170
+ // A bound color drags its axis along, so an applied harmony no longer holds.
171
+ if (sw.axis && color.h !== sw.color.h) activeMode = 'custom';
172
+ setBaseColor(sw.label, color);
173
+ showSwatchColor(color, swatchEls[sw.label]);
174
+ }
175
+
111
176
  let fullPalettes = $derived(palettesWithDefaults($editorState.palettes));
112
177
 
113
178
  let swatches = $derived.by(() => {
114
179
  const statuses = axisStatuses(activeMode, $editorState.harmonyAxes);
115
180
  return PALETTE_SPECS.map((spec) => {
116
- const { l, c, h } = $editorState.palettes[spec.label]?.baseColor ?? spec.initialColor;
181
+ const color = $editorState.palettes[spec.label]?.baseColor ?? spec.initialColor;
117
182
  const index = $editorState.harmonyAxes.findIndex((a) => a.family === spec.label);
118
183
  return {
119
184
  label: spec.label,
120
185
  display: spec.displayLabel ?? spec.label,
121
- hex: oklchToHexClamped(l, c, h),
186
+ color,
187
+ hex: oklchToHexClamped(color.l, color.c, color.h),
122
188
  axis: index === -1 ? null : { index, status: statuses[index] },
123
189
  eligible: ELIGIBLE.has(spec.label),
124
190
  };
125
191
  });
126
192
  });
127
193
 
194
+ type Swatch = (typeof swatches)[number];
195
+
196
+ let selectedSwatch = $derived(swatches.find((s) => s.label === selected));
197
+
128
198
  const FUNCTIONAL_FAMILIES = new Set(['Info', 'Success', 'Warning', 'Danger']);
129
199
  // Declaration order, never a computed one: sorting by luminance made the row
130
200
  // reshuffle under the pointer while a lightness edit was in flight.
@@ -150,6 +220,8 @@
150
220
  }
151
221
  </script>
152
222
 
223
+ <svelte:window onkeydown={onWindowKeydown} onpaste={onWindowPaste} />
224
+
153
225
  <div class="colors-tab">
154
226
  <div class="pane">
155
227
  <section id="colors-wheel" class="block">
@@ -227,7 +299,7 @@
227
299
  <div class="harmony-cols">
228
300
  <div class="group">
229
301
  <h3 class="title">Selected color</h3>
230
- {#snippet swatchRow(row: typeof swatches, acceptsDrop = false)}
302
+ {#snippet swatchRow(row: Swatch[], acceptsDrop = false)}
231
303
  <!-- svelte-ignore a11y_no_static_element_interactions -->
232
304
  <div
233
305
  class="swatch-row"
@@ -242,12 +314,18 @@
242
314
  class="swatch"
243
315
  class:active={selected === sw.label}
244
316
  class:draggable={sw.eligible}
317
+ class:swap-target={swapTarget === sw.label}
245
318
  style="--swatch-fill: {sw.hex}"
246
319
  draggable={sw.eligible}
247
320
  title={sw.eligible ? `${sw.display} · drag onto an axis to assign it` : sw.display}
248
321
  aria-label={`Select ${sw.display}`}
249
322
  aria-pressed={selected === sw.label}
250
- ondragstart={(e) => startFamilyDrag(e, sw.label)}
323
+ bind:this={swatchEls[sw.label]}
324
+ ondragstart={(e) => onSwatchDragStart(e, sw.label)}
325
+ ondragend={onSwatchDragEnd}
326
+ ondragover={(e) => onSwatchDragOver(e, sw)}
327
+ ondragleave={() => onSwatchDragLeave(sw)}
328
+ ondrop={(e) => onSwatchDrop(e, sw)}
251
329
  onclick={() => select(sw.label)}
252
330
  >
253
331
  <span class="frame">
@@ -685,6 +763,13 @@
685
763
  border-color: var(--ui-border-high);
686
764
  }
687
765
 
766
+ /* The axis row's drop ring, doubled so it never reads as selection. */
767
+ .swatch.swap-target .frame {
768
+ border-color: var(--ui-text-primary);
769
+ outline: 1px solid var(--ui-text-primary);
770
+ outline-offset: 1px;
771
+ }
772
+
688
773
  .swatch-label {
689
774
  font-size: var(--ui-font-size-xs);
690
775
  color: var(--ui-text-secondary);
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { get } from 'svelte/store';
21
21
  import type { Oklch } from '../../core/palettes/oklch';
22
- import { axisLabel } from '../../core/palettes/colorHarmony';
22
+ import { axisLabel, HARMONY_ELIGIBLE } from '../../core/palettes/colorHarmony';
23
23
  import { PALETTE_SPECS, syncBaseAnchor, type PaletteSpec } from '../../core/palettes/paletteDerivation';
24
24
  import { defaultPaletteConfig } from '../palette/paletteMath';
25
25
  import { mutate, transaction, editorState } from '../../core/store/editorStore';
@@ -208,6 +208,30 @@ function giveAxisHue(s: EditorState, index: number, family: string): boolean {
208
208
  return false;
209
209
  }
210
210
 
211
+ /** Two families trade seed colors. Between two families an axis can hold, each
212
+ * binding travels with its color, so every axis keeps its hue and an applied
213
+ * harmony stays true. Otherwise a bound family keeps its axis, which follows
214
+ * the new hue as it does on any direct color edit. */
215
+ export function swapBaseColors(a: string, b: string): void {
216
+ if (a === b) return;
217
+ mutate(`colors: swap ${a} and ${b}`, (s) => {
218
+ const ca = ensureConfig(s, a);
219
+ const cb = ensureConfig(s, b);
220
+ [ca.baseColor, cb.baseColor] = [cb.baseColor, ca.baseColor];
221
+ syncBaseAnchor(ca);
222
+ syncBaseAnchor(cb);
223
+ if (HARMONY_ELIGIBLE.includes(a) && HARMONY_ELIGIBLE.includes(b)) {
224
+ for (const axis of s.harmonyAxes) {
225
+ if (axis.family === a) axis.family = b;
226
+ else if (axis.family === b) axis.family = a;
227
+ }
228
+ } else {
229
+ syncBoundAxisHue(s, a);
230
+ syncBoundAxisHue(s, b);
231
+ }
232
+ });
233
+ }
234
+
211
235
  /** Set several seed colors in ONE undo entry (harmony apply, global rotate). */
212
236
  export function setBaseColors(patch: Record<string, Oklch>, historyLabel = 'colors: harmony'): void {
213
237
  transaction(historyLabel, (s) => {
@@ -180,6 +180,7 @@
180
180
  class:active={editingKey === k}
181
181
  class:populated={k in overrides}
182
182
  class:matching={k in overrides && overrides[k] === dHex}
183
+ data-swatch-key={k}
183
184
  onclick={() => onOverrideClick(k, step, scale.title)}
184
185
  role="button"
185
186
  tabindex="0"
@@ -212,6 +213,7 @@
212
213
  class:active={editingKey === k || snapPickerKey === k}
213
214
  class:populated={k in overrides}
214
215
  class:matching={k in overrides && overrides[k] === dHex}
216
+ data-swatch-key={k}
215
217
  onclick={() => snapped ? onSnappedClick(k) : onOverrideClick(k, step, scale.title)}
216
218
  role="button"
217
219
  tabindex="0"
@@ -1,6 +1,14 @@
1
+ <script module lang="ts">
2
+ // Every family's header reads one drag source, because dataTransfer data is
3
+ // unreadable during dragover.
4
+ let swapSource: string | null = null;
5
+ const SWAP_DRAG_TYPE = 'application/x-palette-family';
6
+ </script>
7
+
1
8
  <script lang="ts">
2
9
  import type { Snippet } from 'svelte';
3
10
  import { oklchToHexClamped, type Oklch } from '../../core/palettes/oklch';
11
+ import { BASE_KEY } from './paletteEditorState';
4
12
 
5
13
  interface Props {
6
14
  /**
@@ -20,6 +28,8 @@
20
28
  copiedKey: string | null;
21
29
  onStartEdit: () => void;
22
30
  onCopyBaseHex: (key: string, hex: string, event?: MouseEvent) => void;
31
+ /** Another family's header swatch dropped on this one. */
32
+ onSwapFrom: (family: string) => void;
23
33
  actions?: Snippet;
24
34
  /** A family-level setting, shown beside the name while this family's
25
35
  * editor is open. Collapsed families keep the band to identity + actions. */
@@ -36,10 +46,34 @@
36
46
  copiedKey,
37
47
  onStartEdit,
38
48
  onCopyBaseHex,
49
+ onSwapFrom,
39
50
  actions,
40
51
  setting
41
52
  }: Props = $props();
42
53
 
54
+ let swapHover = $state(false);
55
+
56
+ function onDragStart(e: DragEvent) {
57
+ if (!e.dataTransfer) return;
58
+ e.dataTransfer.effectAllowed = 'move';
59
+ e.dataTransfer.setData(SWAP_DRAG_TYPE, label);
60
+ swapSource = label;
61
+ }
62
+
63
+ function onDragOver(e: DragEvent) {
64
+ if (swapSource === null || swapSource === label) return;
65
+ e.preventDefault();
66
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
67
+ swapHover = true;
68
+ }
69
+
70
+ function onDrop(e: DragEvent) {
71
+ swapHover = false;
72
+ if (swapSource === null || swapSource === label) return;
73
+ e.preventDefault();
74
+ onSwapFrom(swapSource);
75
+ }
76
+
43
77
  let baseHex = $derived(oklchToHexClamped(baseColor.l, baseColor.c, baseColor.h));
44
78
 
45
79
  // `pinnedOpen || isEditingBase` is exactly when the base panel shows: editing
@@ -54,11 +88,19 @@
54
88
  class="header-swatch"
55
89
  class:active={isEditingBase || pinnedOpen}
56
90
  class:selected
91
+ class:swap-target={swapHover}
57
92
  style="background: {baseHex}"
93
+ data-swatch-key={BASE_KEY}
94
+ draggable="true"
58
95
  onclick={onStartEdit}
59
96
  role="button"
60
97
  tabindex="0"
61
98
  onkeydown={(e) => e.key === 'Enter' && onStartEdit()}
99
+ ondragstart={onDragStart}
100
+ ondragend={() => (swapSource = null)}
101
+ ondragover={onDragOver}
102
+ ondragleave={() => (swapHover = false)}
103
+ ondrop={onDrop}
62
104
  ></div>
63
105
  <div class="primary-info">
64
106
  <span class="editor-label">{displayLabel ?? label}</span>
@@ -163,6 +205,13 @@
163
205
  inset 0 0 0 4px var(--ui-surface-lowest);
164
206
  }
165
207
 
208
+ /* About to receive a swap: the axis row's doubled drop ring. */
209
+ .header-swatch.swap-target {
210
+ border-color: var(--ui-text-primary);
211
+ outline: 1px solid var(--ui-text-primary);
212
+ outline-offset: 1px;
213
+ }
214
+
166
215
  .editor-label {
167
216
  font-size: var(--ui-font-size-xl);
168
217
  font-weight: var(--ui-font-weight-semibold);
@@ -0,0 +1,35 @@
1
+ import { writable } from 'svelte/store';
2
+ import { cssColorToOklch, oklchToCss, type Oklch } from '../core/palettes/oklch';
3
+ import { isEditable } from '../core/store/editorKeybindings';
4
+ import { showCopyPopover } from './copyPopover';
5
+
6
+ /** The Tokens-view family whose swatch opened last. Every family can hold an
7
+ * open swatch at once, and copy and paste act on this one alone. */
8
+ export const activeSwatchFamily = writable<string | null>(null);
9
+
10
+ const inTextField = (e: Event) => e.target instanceof HTMLElement && isEditable(e.target);
11
+
12
+ /** ⌘C on a swatch view. A text field or selected page text keeps the browser's
13
+ * own copy. Copy rides the key rather than the copy event because Safari fires
14
+ * no copy event without a text selection. */
15
+ export function isSwatchCopy(e: KeyboardEvent): boolean {
16
+ if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'c') return false;
17
+ return !inTextField(e) && window.getSelection()?.isCollapsed !== false;
18
+ }
19
+
20
+ /** The color a paste carries to a swatch, or null when a text field keeps the
21
+ * paste or the clipboard holds no hex or `oklch()` color. Paste rides the
22
+ * event because its clipboardData needs no read permission. */
23
+ export function swatchPasteColor(e: ClipboardEvent): Oklch | null {
24
+ if (inTextField(e)) return null;
25
+ return cssColorToOklch(e.clipboardData?.getData('text/plain') ?? '');
26
+ }
27
+
28
+ export function showSwatchColor(color: Oklch, anchor: Element | null): void {
29
+ showCopyPopover(oklchToCss(color.l, color.c, color.h), anchor);
30
+ }
31
+
32
+ export function copySwatchColor(color: Oklch, anchor: Element | null): void {
33
+ navigator.clipboard?.writeText(oklchToCss(color.l, color.c, color.h));
34
+ showSwatchColor(color, anchor);
35
+ }