@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 +50 -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/PaletteEditor.svelte +35 -1
- package/src/editor/ui/colors/ColorsTab.svelte +89 -4
- package/src/editor/ui/colors/paletteBaseColor.ts +25 -1
- package/src/editor/ui/palette/OverridesPanel.svelte +2 -0
- package/src/editor/ui/palette/PaletteBase.svelte +49 -0
- package/src/editor/ui/swatchClipboard.ts +35 -0
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 ?
|
|
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') {
|
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|