@motion-proto/live-tokens 0.93.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,24 @@
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
+
3
22
  ## 0.93.0 — Swatch swap and copy-paste
4
23
 
5
24
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.93.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": [
@@ -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"
@@ -1,9 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { cubicOut } from 'svelte/easing';
3
- import { cssColorToOklch, oklchToCss, oklchToHexClamped } from '../../core/palettes/oklch';
3
+ import { 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';
7
6
  import { selectedPalette } from '../../core/store/paletteFocus';
8
7
  import { applyHarmonyToAxes, axisStatuses, HARMONY_ELIGIBLE, type HarmonyMode } from '../../core/palettes/colorHarmony';
9
8
  import AxisNumeral from './AxisNumeral.svelte';
@@ -14,7 +13,7 @@
14
13
  import PaletteStepStrip from './PaletteStepStrip.svelte';
15
14
  import HarmonyAxesList from './HarmonyAxesList.svelte';
16
15
  import PaletteJumpButton from '../palette/PaletteJumpButton.svelte';
17
- import { showCopyPopover } from '../copyPopover';
16
+ import { isSwatchCopy, swatchPasteColor, copySwatchColor, showSwatchColor } from '../swatchClipboard';
18
17
  import {
19
18
  setBaseColor,
20
19
  setAxisHues,
@@ -157,31 +156,21 @@
157
156
  // Copy and paste act on the selected swatch, never the focused one: Safari
158
157
  // and macOS Firefox do not focus a button on click.
159
158
  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
- }
159
+ const sw = selectedSwatch;
160
+ if (!sw || !isSwatchCopy(e)) return;
161
+ e.preventDefault();
162
+ copySwatchColor(sw.color, swatchEls[sw.label]);
176
163
  }
177
164
 
178
- async function pasteInto(sw: Swatch, anchor: EventTarget | null) {
179
- const color = cssColorToOklch(await navigator.clipboard.readText());
180
- if (!color) return;
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();
181
170
  // A bound color drags its axis along, so an applied harmony no longer holds.
182
171
  if (sw.axis && color.h !== sw.color.h) activeMode = 'custom';
183
172
  setBaseColor(sw.label, color);
184
- showCopyPopover(oklchToCss(color.l, color.c, color.h), anchor);
173
+ showSwatchColor(color, swatchEls[sw.label]);
185
174
  }
186
175
 
187
176
  let fullPalettes = $derived(palettesWithDefaults($editorState.palettes));
@@ -204,6 +193,8 @@
204
193
 
205
194
  type Swatch = (typeof swatches)[number];
206
195
 
196
+ let selectedSwatch = $derived(swatches.find((s) => s.label === selected));
197
+
207
198
  const FUNCTIONAL_FAMILIES = new Set(['Info', 'Success', 'Warning', 'Danger']);
208
199
  // Declaration order, never a computed one: sorting by luminance made the row
209
200
  // reshuffle under the pointer while a lightness edit was in flight.
@@ -229,7 +220,7 @@
229
220
  }
230
221
  </script>
231
222
 
232
- <svelte:window onkeydown={onWindowKeydown} />
223
+ <svelte:window onkeydown={onWindowKeydown} onpaste={onWindowPaste} />
233
224
 
234
225
  <div class="colors-tab">
235
226
  <div class="pane">
@@ -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,8 +208,10 @@ 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. */
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. */
213
215
  export function swapBaseColors(a: string, b: string): void {
214
216
  if (a === b) return;
215
217
  mutate(`colors: swap ${a} and ${b}`, (s) => {
@@ -218,9 +220,14 @@ export function swapBaseColors(a: string, b: string): void {
218
220
  [ca.baseColor, cb.baseColor] = [cb.baseColor, ca.baseColor];
219
221
  syncBaseAnchor(ca);
220
222
  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;
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);
224
231
  }
225
232
  });
226
233
  }
@@ -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
+ }