@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 +31 -0
- package/dist-plugin/{chunk-JOSSYGR2.js → chunk-7XSUHJUH.js} +20 -2
- package/dist-plugin/index.cjs +20 -2
- package/dist-plugin/index.js +1 -1
- package/dist-plugin/tokensCssMigrations/index.cjs +20 -2
- package/dist-plugin/tokensCssMigrations/index.js +1 -1
- package/package.json +1 -1
- package/src/editor/core/store/editorKeybindings.ts +1 -1
- package/src/editor/ui/colors/ColorsTab.svelte +99 -5
- package/src/editor/ui/colors/paletteBaseColor.ts +17 -0
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 ?
|
|
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: [
|
package/dist-plugin/index.cjs
CHANGED
|
@@ -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 ?
|
|
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: [
|
package/dist-plugin/index.js
CHANGED
|
@@ -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 ?
|
|
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: [
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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) => {
|