@motion-proto/live-tokens 0.92.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.93.0 — Swatch swap and copy-paste
4
+
5
+ ### Added
6
+
7
+ - **The Colors view swaps two main colors by drag and drop.** Drop one swatch
8
+ of the main row on another and the two families trade seed colors in one
9
+ undo step. A harmony axis follows the color it held, so every axis keeps
10
+ its hue and an applied harmony stays true.
11
+ - **Every swatch copies and pastes its color.** ⌘C (Ctrl+C) copies the
12
+ selected swatch's seed as `oklch()` text, and ⌘V (Ctrl+V) sets the selected
13
+ swatch's seed from a hex or `oklch()` color on the clipboard.
14
+
15
+ ### Fixed
16
+
17
+ - **The type-scales migration derives an aliased size's tablet and phone
18
+ sizes from the alias.** A step size that aliases a primitive, such as
19
+ `--body-sm-font-size: var(--font-size-md)`, kept the scale's tablet and
20
+ phone sizes, so a 16px body sm fell to 14.6px on tablets. The migration now
21
+ resolves the alias through the file's top-level declarations and applies
22
+ the editor's rule for an edited size, so body sm holds 16px. A size it
23
+ cannot resolve, such as a `calc()`, keeps the scale's sizes.
24
+ - **The type-scales migration carries a step the project declared by hand.**
25
+ A step 0.91.2 did not ship, such as a project's own
26
+ `--heading-2xl-font-size: 1.75rem`, kept that value in its `-font-size`
27
+ while the 768px and 480px blocks re-pointed it at the scale's tablet and
28
+ phone sizes. The migration now moves the value into the step's desktop size,
29
+ points `-font-size` at it, and derives the tablet and phone sizes by the
30
+ editor's rule.
31
+ - A `tokens.css` that already ran the 0.92.0 migration never runs it again, so
32
+ these fixes reach projects that upgrade from 0.91 after this release.
33
+
3
34
  ## 0.92.0 — Type scales for text styles
4
35
 
5
36
  ### 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.93.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') {
@@ -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) => {