@motion-proto/live-tokens 0.46.1 → 0.47.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 +41 -0
- package/package.json +1 -1
- package/src/editor/core/store/paletteFocus.ts +26 -0
- package/src/editor/ui/BezierCurveEditor.svelte +9 -2
- package/src/editor/ui/PaletteEditor.svelte +53 -1
- package/src/editor/ui/UIMenuButton.svelte +14 -0
- package/src/editor/ui/colors/ColorsTab.svelte +16 -4
- package/src/editor/ui/colors/HarmonyAxesList.svelte +44 -9
- package/src/editor/ui/colors/paletteBaseColor.ts +30 -10
- package/src/editor/ui/palette/PaletteBase.svelte +20 -5
- package/src/editor/ui/palette/PaletteJumpButton.svelte +49 -0
- package/src/editor/ui/palette/ScaleCurveEditor.svelte +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.47.0 — Colors and Tokens hand palettes to each other
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Jump buttons link the two palette surfaces.** `PaletteJumpButton` sits in
|
|
8
|
+
the Colors view's Palette header (`Edit`, into Tokens) and on every Tokens
|
|
9
|
+
palette label (`Wheel`, into Colors). The new `paletteFocus` store carries the
|
|
10
|
+
family across: `selectedPalette` is now the Colors view's selection, so the
|
|
11
|
+
wheel is already showing the handed-over family when the switch lands, and the
|
|
12
|
+
one-shot `pendingPaletteFocus` opens the matching Tokens editor and scrolls it
|
|
13
|
+
into view. From the standalone Colors page the jump also navigates, since that
|
|
14
|
+
page has no Tokens surface to flip to.
|
|
15
|
+
|
|
16
|
+
- **`On assign` picks which hue survives an axis assignment.** A segmented
|
|
17
|
+
control above the axes list chooses between `Adopt swatch`, which moves the
|
|
18
|
+
axis to the color's hue and leaves the harmony custom, and `Adopt axis`, which
|
|
19
|
+
repaints the color onto the hue the axis already holds. Adopting the axis
|
|
20
|
+
moves no axis geometry, so an applied harmony mode survives the assignment.
|
|
21
|
+
`bindFamilyToAxis` takes the mode as a third argument and reconciles a traded
|
|
22
|
+
occupant the same way.
|
|
23
|
+
|
|
24
|
+
- **The base color anchor unlocks from the curve.** Double-clicking the locked
|
|
25
|
+
anchor in the lightness or saturation curve raises a confirm notice, and
|
|
26
|
+
accepting clears `anchorToBase`. The anchor carries a `<title>` saying so.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **The Tokens base color panel stays open with the curve editors.** Opening a
|
|
31
|
+
palette's controls pins `ColorEditPanel` in live-apply mode (no confirm or
|
|
32
|
+
cancel session) and marks the header swatch active, so base edits and curve
|
|
33
|
+
edits are visible at once.
|
|
34
|
+
|
|
35
|
+
- **`UIMenuButton` portals its menu to the enclosing `.editor-page`.** A dimmed
|
|
36
|
+
ancestor's opacity faded the popup and showed the page through it, which fixed
|
|
37
|
+
positioning alone cannot escape. The `--ui-*` tokens are scoped to
|
|
38
|
+
`.editor-page`, so the menu reparents there rather than to `<body>`.
|
|
39
|
+
|
|
40
|
+
- **The axes list drops its per-row role column.** `Anchor` names the first row
|
|
41
|
+
from a caption above the list, so no row reserves 4rem of dead space for a
|
|
42
|
+
word only one of them carries.
|
|
43
|
+
|
|
3
44
|
## 0.46.1 — The Colors view collapses on its own width
|
|
4
45
|
|
|
5
46
|
### Fixed
|
package/package.json
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { get, writable } from 'svelte/store';
|
|
2
|
+
import { setEditorView } from './editorViewStore';
|
|
3
|
+
import { navigate, route } from '../routing/router';
|
|
4
|
+
import { DEFAULT_COLORS_PATH, DEFAULT_EDITOR_PATH } from '../routing/ownedRoutes';
|
|
5
|
+
|
|
6
|
+
/** The palette family the Colors view has selected. Shared so the Tokens view
|
|
7
|
+
* can hand a family over to the wheel. */
|
|
8
|
+
export const selectedPalette = writable<string>('Brand');
|
|
9
|
+
|
|
10
|
+
/** One-shot: the family whose Tokens-view editor should open and scroll into
|
|
11
|
+
* view. The `PaletteEditor` that matches clears it. */
|
|
12
|
+
export const pendingPaletteFocus = writable<string | null>(null);
|
|
13
|
+
|
|
14
|
+
export function openPaletteInWheel(label: string) {
|
|
15
|
+
selectedPalette.set(label);
|
|
16
|
+
setEditorView('colors');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function openPaletteInTokens(label: string) {
|
|
20
|
+
selectedPalette.set(label);
|
|
21
|
+
pendingPaletteFocus.set(label);
|
|
22
|
+
setEditorView('tokens');
|
|
23
|
+
// The standalone Colors page has no Tokens surface to switch to, so the view
|
|
24
|
+
// flip alone would go nowhere.
|
|
25
|
+
if (get(route) === DEFAULT_COLORS_PATH) navigate(DEFAULT_EDITOR_PATH);
|
|
26
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
lockedAnchorIndex?: number | null;
|
|
21
21
|
onAnchorsChange?: (anchors: CurveAnchor[]) => void;
|
|
22
22
|
onOffsetChange?: (offset: number) => void;
|
|
23
|
+
onLockedAnchorUnlock?: (() => void) | null;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
let {
|
|
@@ -31,7 +32,8 @@
|
|
|
31
32
|
defaultAnchors = null,
|
|
32
33
|
lockedAnchorIndex = null,
|
|
33
34
|
onAnchorsChange = () => {},
|
|
34
|
-
onOffsetChange = () => {}
|
|
35
|
+
onOffsetChange = () => {},
|
|
36
|
+
onLockedAnchorUnlock = null
|
|
35
37
|
}: Props = $props();
|
|
36
38
|
|
|
37
39
|
function resetToDefault() {
|
|
@@ -380,7 +382,12 @@
|
|
|
380
382
|
<path
|
|
381
383
|
d="M{curveXToSvg(pt.x, w, padX)},{curveYToSvg(pt.y, cfg) - 6} l5,6 l-5,6 l-5,-6 Z"
|
|
382
384
|
class="curve-handle locked"
|
|
383
|
-
|
|
385
|
+
ondblclick={onLockedAnchorUnlock ? stopPropagation(onLockedAnchorUnlock) : undefined}
|
|
386
|
+
>
|
|
387
|
+
{#if onLockedAnchorUnlock}
|
|
388
|
+
<title>Base color anchor. Double-click to unlock.</title>
|
|
389
|
+
{/if}
|
|
390
|
+
</path>
|
|
384
391
|
{:else if isCornerAnchor(pt)}
|
|
385
392
|
<rect
|
|
386
393
|
x={curveXToSvg(pt.x, w, padX) - 4} y={curveYToSvg(pt.y, cfg) - 4}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
// Base-color edits route through the shared setter so a bound harmony axis
|
|
29
29
|
// follows the hue (invariant 1); the local `edit` would silently detach it.
|
|
30
30
|
import { setBaseColor } from './colors/paletteBaseColor';
|
|
31
|
+
import { pendingPaletteFocus } from '../core/store/paletteFocus';
|
|
31
32
|
import { showCopyPopover } from './copyPopover';
|
|
32
33
|
|
|
33
34
|
interface Props {
|
|
@@ -98,6 +99,16 @@
|
|
|
98
99
|
|
|
99
100
|
let showDerived = $state(false);
|
|
100
101
|
let paletteEditorOpen = $state(false);
|
|
102
|
+
let rootEl: HTMLElement | undefined = $state();
|
|
103
|
+
|
|
104
|
+
// Arriving from the Colors view's Edit button: open this family's controls and
|
|
105
|
+
// bring it into view, since the Tokens view stacks every family.
|
|
106
|
+
$effect(() => {
|
|
107
|
+
if ($pendingPaletteFocus !== label) return;
|
|
108
|
+
pendingPaletteFocus.set(null);
|
|
109
|
+
paletteEditorOpen = true;
|
|
110
|
+
tick().then(() => rootEl?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
|
111
|
+
});
|
|
101
112
|
|
|
102
113
|
function setLightnessCurve(a: CurveAnchor[]) { edit('lightnessCurve', a); }
|
|
103
114
|
function setSaturationCurve(a: CurveAnchor[]) { edit('saturationCurve', a); }
|
|
@@ -133,6 +144,12 @@
|
|
|
133
144
|
});
|
|
134
145
|
}
|
|
135
146
|
|
|
147
|
+
let anchorUnlockPrompt = $state(false);
|
|
148
|
+
function confirmBaseAnchorUnlock() {
|
|
149
|
+
setAnchorToBase(false);
|
|
150
|
+
anchorUnlockPrompt = false;
|
|
151
|
+
}
|
|
152
|
+
|
|
136
153
|
|
|
137
154
|
function startBaseEdit() {
|
|
138
155
|
if (editing.kind === 'editingBase') { confirmEdit(); return; }
|
|
@@ -466,7 +483,7 @@
|
|
|
466
483
|
});
|
|
467
484
|
</script>
|
|
468
485
|
|
|
469
|
-
<div class="palette-editor" style="--editor-base: {toHex(baseColor)}">
|
|
486
|
+
<div class="palette-editor" bind:this={rootEl} style="--editor-base: {toHex(baseColor)}">
|
|
470
487
|
<PaletteBase
|
|
471
488
|
{label}
|
|
472
489
|
{displayLabel}
|
|
@@ -476,6 +493,7 @@
|
|
|
476
493
|
{anchorStepLabel}
|
|
477
494
|
{isEditingBase}
|
|
478
495
|
{panelOpen}
|
|
496
|
+
pinnedOpen={paletteEditorOpen}
|
|
479
497
|
{editingColor}
|
|
480
498
|
{editPanelTitle}
|
|
481
499
|
{copiedKey}
|
|
@@ -552,6 +570,16 @@
|
|
|
552
570
|
</div>
|
|
553
571
|
{#if paletteEditorOpen}
|
|
554
572
|
<div class="curve-grid-span" style="grid-column: 2 / {paletteStepLightness.length + 2}">
|
|
573
|
+
{#if anchorUnlockPrompt && anchorToBase}
|
|
574
|
+
<div class="anchor-unlock-notice" role="alert">
|
|
575
|
+
<i class="fas fa-triangle-exclamation" aria-hidden="true"></i>
|
|
576
|
+
<span>Unlock the base color anchor? The palette will no longer pass through the base color.</span>
|
|
577
|
+
<div class="anchor-unlock-actions">
|
|
578
|
+
<UIPillButton size="compact" onclick={confirmBaseAnchorUnlock}>Unlock</UIPillButton>
|
|
579
|
+
<UIPillButton size="compact" variant="outline" onclick={() => anchorUnlockPrompt = false}>Cancel</UIPillButton>
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
{/if}
|
|
555
583
|
<ScaleCurveEditor
|
|
556
584
|
curveKey="lightness"
|
|
557
585
|
anchors={lightnessCurve}
|
|
@@ -560,6 +588,7 @@
|
|
|
560
588
|
defaults={DEFAULT_PALETTE_LIGHTNESS()}
|
|
561
589
|
offset={curveOffset['lightness'] ?? 0}
|
|
562
590
|
lockedAnchorIndex={lockedLightnessIdx}
|
|
591
|
+
onLockedAnchorUnlock={() => anchorUnlockPrompt = true}
|
|
563
592
|
onAnchorsChange={setLightnessCurve}
|
|
564
593
|
onOffsetChange={handleOffset}
|
|
565
594
|
/>
|
|
@@ -571,6 +600,7 @@
|
|
|
571
600
|
defaults={DEFAULT_PALETTE_SATURATION()}
|
|
572
601
|
offset={curveOffset['saturation'] ?? 0}
|
|
573
602
|
lockedAnchorIndex={lockedSaturationIdx}
|
|
603
|
+
onLockedAnchorUnlock={() => anchorUnlockPrompt = true}
|
|
574
604
|
onAnchorsChange={setSaturationCurve}
|
|
575
605
|
onOffsetChange={handleOffset}
|
|
576
606
|
/>
|
|
@@ -843,6 +873,28 @@
|
|
|
843
873
|
gap: var(--ui-space-8);
|
|
844
874
|
}
|
|
845
875
|
|
|
876
|
+
.anchor-unlock-notice {
|
|
877
|
+
display: flex;
|
|
878
|
+
align-items: center;
|
|
879
|
+
gap: var(--ui-space-6);
|
|
880
|
+
padding: var(--ui-space-6) var(--ui-space-12);
|
|
881
|
+
background: var(--ui-surface-high);
|
|
882
|
+
border: 1px solid var(--ui-border-low);
|
|
883
|
+
border-radius: var(--ui-radius-sm);
|
|
884
|
+
color: var(--ui-text-secondary);
|
|
885
|
+
font-size: var(--ui-font-size-sm);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
.anchor-unlock-notice i {
|
|
889
|
+
color: var(--ui-text-tertiary);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
.anchor-unlock-actions {
|
|
893
|
+
display: flex;
|
|
894
|
+
gap: var(--ui-space-4);
|
|
895
|
+
margin-left: auto;
|
|
896
|
+
}
|
|
897
|
+
|
|
846
898
|
.swatch.gray-swatch {
|
|
847
899
|
width: 100%;
|
|
848
900
|
height: calc(4rem + var(--ui-space-2));
|
|
@@ -97,6 +97,19 @@
|
|
|
97
97
|
document.removeEventListener('mousedown', onDocumentMousedown, true);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
/* Reparented out of the row: a trigger's surroundings can carry opacity
|
|
101
|
+
(dimmed rows do), which fades every descendant and would show the page
|
|
102
|
+
through the popup. Fixed positioning alone cannot escape that.
|
|
103
|
+
The host is the enclosing .editor-page, not <body>: every --ui-* token is
|
|
104
|
+
scoped to it, so a popup parked outside renders with no background at all.
|
|
105
|
+
Reparenting takes the node out of the range the component tears down, so
|
|
106
|
+
removal is the action's to do: a menu open when its trigger disappears
|
|
107
|
+
(selecting a family swaps the row's trigger) would otherwise be stranded. */
|
|
108
|
+
function portal(node: HTMLElement) {
|
|
109
|
+
(node.closest('.editor-page') ?? document.body).appendChild(node);
|
|
110
|
+
return { destroy: () => node.remove() };
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
/* Fixed positioning escapes any parent overflow/stacking context. Anchored
|
|
101
114
|
below the trigger, right edges aligned; flips above near the viewport
|
|
102
115
|
bottom. Inline visibility rather than a state flag so the item focus that
|
|
@@ -179,6 +192,7 @@
|
|
|
179
192
|
style="min-width: {menuMinWidth};"
|
|
180
193
|
bind:this={menuEl}
|
|
181
194
|
onkeydown={onMenuKeydown}
|
|
195
|
+
use:portal
|
|
182
196
|
>
|
|
183
197
|
<div class="menu-header" role="presentation">{header}</div>
|
|
184
198
|
{@render children({ close })}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
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 { selectedPalette } from '../../core/store/paletteFocus';
|
|
6
7
|
import { applyHarmonyToAxes, axisStatuses, HARMONY_ELIGIBLE, type HarmonyMode } from '../../core/palettes/colorHarmony';
|
|
7
8
|
import AxisNumeral from './AxisNumeral.svelte';
|
|
8
9
|
import ColorEditPanel from '../ColorEditPanel.svelte';
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
import ColorStory from './ColorStory.svelte';
|
|
12
13
|
import PaletteStepStrip from './PaletteStepStrip.svelte';
|
|
13
14
|
import HarmonyAxesList from './HarmonyAxesList.svelte';
|
|
15
|
+
import PaletteJumpButton from '../palette/PaletteJumpButton.svelte';
|
|
14
16
|
import {
|
|
15
17
|
setBaseColor,
|
|
16
18
|
setAxisHues,
|
|
@@ -21,7 +23,9 @@
|
|
|
21
23
|
import { HARMONY_DRAG_TYPE, startFamilyDrag } from './harmonyDrag';
|
|
22
24
|
import { maxChroma } from './colorWheelMath';
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
// Shared with the Tokens view: its wheel link hands a family over by writing
|
|
27
|
+
// the store, and this view is already showing it when the switch lands.
|
|
28
|
+
let selected = $derived($selectedPalette);
|
|
25
29
|
let activeMode = $state<HarmonyMode>('custom');
|
|
26
30
|
// Local UI only — deliberately NOT stored in PaletteConfig (shape unchanged).
|
|
27
31
|
let absoluteChroma = $state(false);
|
|
@@ -31,7 +35,7 @@
|
|
|
31
35
|
let axesInfoWrap: HTMLElement | undefined = $state();
|
|
32
36
|
|
|
33
37
|
function select(label: string) {
|
|
34
|
-
|
|
38
|
+
selectedPalette.set(label);
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
function toggleInfo() {
|
|
@@ -285,7 +289,7 @@
|
|
|
285
289
|
{#if axesInfoOpen}
|
|
286
290
|
<div class="info-box" role="region" aria-label="Harmony axes">
|
|
287
291
|
<strong class="info-title">Harmony axes</strong>
|
|
288
|
-
<p>Each axis owns a hue. Assign a color from the row menu, or drag one onto the axis.
|
|
292
|
+
<p>Each axis owns a hue. Assign a color from the row menu, or drag one onto the axis. On assign picks which hue survives: Adopt swatch moves the axis to the color's hue, Adopt axis repaints the color to the hue the axis already holds, which keeps an applied harmony true. Either way the color follows the axis from then on, so applying a harmony rotates every assigned color at once. To unassign, use the row menu, or drag the color off its axis and drop it anywhere else.</p>
|
|
289
293
|
</div>
|
|
290
294
|
{/if}
|
|
291
295
|
</div>
|
|
@@ -305,8 +309,9 @@
|
|
|
305
309
|
</section>
|
|
306
310
|
|
|
307
311
|
<section id="colors-selected" class="block">
|
|
308
|
-
<header class="block-head">
|
|
312
|
+
<header class="block-head head-row">
|
|
309
313
|
<h2 class="title">Palette <span class="mode-name">· {selectedSpec.displayLabel ?? selected}</span></h2>
|
|
314
|
+
<PaletteJumpButton family={selected} displayLabel={selectedSpec.displayLabel} target="tokens" />
|
|
310
315
|
</header>
|
|
311
316
|
|
|
312
317
|
<div class="group">
|
|
@@ -380,6 +385,13 @@
|
|
|
380
385
|
gap: var(--ui-space-2);
|
|
381
386
|
}
|
|
382
387
|
|
|
388
|
+
.block-head.head-row {
|
|
389
|
+
flex-direction: row;
|
|
390
|
+
align-items: center;
|
|
391
|
+
justify-content: space-between;
|
|
392
|
+
gap: var(--ui-space-12);
|
|
393
|
+
}
|
|
394
|
+
|
|
383
395
|
/* Every labeled region on this page carries the same heading, section and
|
|
384
396
|
group alike: the old xs tertiary eyebrows read as captions and got skipped. */
|
|
385
397
|
.title {
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
import UIMenuButton from '../UIMenuButton.svelte';
|
|
8
8
|
import UIOptionItem from '../UIOptionItem.svelte';
|
|
9
9
|
import UIOptionList from '../UIOptionList.svelte';
|
|
10
|
+
import UISegmentedControl from '../UISegmentedControl.svelte';
|
|
10
11
|
import { modeLabel } from './harmonyModeIcons';
|
|
11
|
-
import { bindFamilyToAxis, unbindFamily } from './paletteBaseColor';
|
|
12
|
+
import { bindFamilyToAxis, unbindFamily, type AdoptOnAssign } from './paletteBaseColor';
|
|
12
13
|
import { HARMONY_DRAG_TYPE, startFamilyDrag } from './harmonyDrag';
|
|
13
14
|
|
|
14
15
|
interface Props {
|
|
@@ -24,8 +25,23 @@
|
|
|
24
25
|
|
|
25
26
|
let { activeMode, selected, oncustomize }: Props = $props();
|
|
26
27
|
|
|
28
|
+
const ADOPT_OPTIONS = [
|
|
29
|
+
{
|
|
30
|
+
value: 'axis',
|
|
31
|
+
label: 'Adopt axis',
|
|
32
|
+
title: 'The color moves to the hue its new axis holds, so an applied harmony stays true',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
value: 'swatch',
|
|
36
|
+
label: 'Adopt swatch',
|
|
37
|
+
title: "The axis moves to the color's hue, which leaves the harmony custom",
|
|
38
|
+
},
|
|
39
|
+
] as const satisfies ReadonlyArray<{ value: AdoptOnAssign; label: string; title: string }>;
|
|
40
|
+
|
|
41
|
+
let adopt = $state<AdoptOnAssign>('swatch');
|
|
42
|
+
|
|
27
43
|
function assign(family: string, index: number) {
|
|
28
|
-
if (bindFamilyToAxis(family, index)) oncustomize();
|
|
44
|
+
if (bindFamilyToAxis(family, index, adopt)) oncustomize();
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
// Empty-row swatch shows the axis's own hue, at fixed preview L/C so only the
|
|
@@ -194,6 +210,15 @@
|
|
|
194
210
|
{/snippet}
|
|
195
211
|
|
|
196
212
|
<div class="axes-list" bind:this={listEl}>
|
|
213
|
+
<div class="adopt-row">
|
|
214
|
+
<span class="adopt-caption">On assign</span>
|
|
215
|
+
<UISegmentedControl
|
|
216
|
+
bind:value={adopt}
|
|
217
|
+
options={ADOPT_OPTIONS}
|
|
218
|
+
ariaLabel="Which hue survives an assignment"
|
|
219
|
+
/>
|
|
220
|
+
</div>
|
|
221
|
+
<span class="anchor-caption">Anchor</span>
|
|
197
222
|
{#each axes as axis, i (i)}
|
|
198
223
|
{@const status = statuses[i]}
|
|
199
224
|
{@const unused = status === 'unused'}
|
|
@@ -215,7 +240,6 @@
|
|
|
215
240
|
ondragleave={() => onDragLeave(i)}
|
|
216
241
|
ondrop={(e) => onDrop(e, i)}
|
|
217
242
|
>
|
|
218
|
-
<span class="role">{i === 0 ? 'Anchor' : ''}</span>
|
|
219
243
|
{#if axis.family !== null}
|
|
220
244
|
{@const family = axis.family}
|
|
221
245
|
<span class="swatch" style="--fill: {familyHex(family)}"></span>
|
|
@@ -317,13 +341,24 @@
|
|
|
317
341
|
background: var(--ui-surface-low);
|
|
318
342
|
}
|
|
319
343
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
344
|
+
.adopt-row {
|
|
345
|
+
display: flex;
|
|
346
|
+
align-items: center;
|
|
347
|
+
gap: var(--ui-space-8);
|
|
348
|
+
margin-bottom: var(--ui-space-4);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.adopt-caption {
|
|
326
352
|
color: var(--ui-text-secondary);
|
|
353
|
+
font-size: var(--ui-font-size-xs);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* Names the first row alone. Sitting above the list rather than in a column
|
|
357
|
+
every row reserves, so no row carries dead space for a word only one has. */
|
|
358
|
+
.anchor-caption {
|
|
359
|
+
padding-inline-start: calc(var(--ui-space-8) + 1px);
|
|
360
|
+
color: var(--ui-text-secondary);
|
|
361
|
+
font-size: var(--ui-font-size-xs);
|
|
327
362
|
}
|
|
328
363
|
|
|
329
364
|
/* Unused slot: nothing bound and no position dealt. The row states the reason
|
|
@@ -145,26 +145,31 @@ function applyAxisHue(s: EditorState, index: number, hue: number, chroma: number
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
/**
|
|
148
|
+
/** Which side of an assignment keeps its hue: `swatch` moves the axis onto the
|
|
149
|
+
* color, `axis` repaints the color onto the axis. */
|
|
150
|
+
export type AdoptOnAssign = 'swatch' | 'axis';
|
|
151
|
+
|
|
152
|
+
/** Bind a family to an axis; `adopt` decides which hue survives. Trade-places
|
|
149
153
|
* semantics: whatever occupied the destination takes the source's position
|
|
150
|
-
* (another axis,
|
|
154
|
+
* (another axis, reconciled the same way, or unassigned). One mutate.
|
|
151
155
|
* Returns whether any axis hue moved, which is what makes an applied harmony
|
|
152
156
|
* mode untrue — the caller drops to Custom on that. */
|
|
153
|
-
export function bindFamilyToAxis(family: string, index: number): boolean {
|
|
157
|
+
export function bindFamilyToAxis(family: string, index: number, adopt: AdoptOnAssign): boolean {
|
|
154
158
|
const srcIndex = get(editorState).harmonyAxes.findIndex((a) => a.family === family);
|
|
155
159
|
if (srcIndex === index) return false;
|
|
160
|
+
const reconcile = adopt === 'swatch' ? takeSeedHue : giveAxisHue;
|
|
156
161
|
let hueMoved = false;
|
|
157
162
|
mutate(`colors: assign ${family}`, (s) => {
|
|
158
163
|
const axes = s.harmonyAxes;
|
|
159
164
|
const occupant = axes[index].family;
|
|
160
165
|
axes[index].family = family;
|
|
161
|
-
hueMoved =
|
|
162
|
-
// Source was another axis: the occupant trades into it
|
|
163
|
-
//
|
|
164
|
-
//
|
|
166
|
+
hueMoved = reconcile(s, index, family);
|
|
167
|
+
// Source was another axis: the occupant trades into it and reconciles with
|
|
168
|
+
// it in turn. Source was unassigned (-1): the occupant floats free, and
|
|
169
|
+
// keeps whatever hue this assignment left it.
|
|
165
170
|
if (srcIndex !== -1) {
|
|
166
171
|
axes[srcIndex].family = occupant;
|
|
167
|
-
if (occupant !== null) hueMoved =
|
|
172
|
+
if (occupant !== null) hueMoved = reconcile(s, srcIndex, occupant) || hueMoved;
|
|
168
173
|
}
|
|
169
174
|
});
|
|
170
175
|
return hueMoved;
|
|
@@ -179,8 +184,8 @@ export function unbindFamily(family: string): void {
|
|
|
179
184
|
});
|
|
180
185
|
}
|
|
181
186
|
|
|
182
|
-
/**
|
|
183
|
-
*
|
|
187
|
+
/** The axis adopts the swatch: the seed the user chose is never repainted, so
|
|
188
|
+
* the axis is what moves. */
|
|
184
189
|
function takeSeedHue(s: EditorState, index: number, family: string): boolean {
|
|
185
190
|
const hue = normHue(ensureConfig(s, family).baseColor.h);
|
|
186
191
|
if (s.harmonyAxes[index].hue === hue) return false;
|
|
@@ -188,6 +193,21 @@ function takeSeedHue(s: EditorState, index: number, family: string): boolean {
|
|
|
188
193
|
return true;
|
|
189
194
|
}
|
|
190
195
|
|
|
196
|
+
/** The swatch adopts the axis: the color lands on the hue its new slot holds,
|
|
197
|
+
* which leaves the geometry untouched — hence never a hue move to report, so
|
|
198
|
+
* an applied harmony mode survives assignment. */
|
|
199
|
+
function giveAxisHue(s: EditorState, index: number, family: string): boolean {
|
|
200
|
+
// The axis hue is taken raw: the color must land exactly on it, and normHue
|
|
201
|
+
// is not identity in range — its round trip shifts the last bit.
|
|
202
|
+
const hue = s.harmonyAxes[index].hue;
|
|
203
|
+
const cfg = ensureConfig(s, family);
|
|
204
|
+
if (cfg.baseColor.h !== hue) {
|
|
205
|
+
cfg.baseColor = { l: cfg.baseColor.l, c: cfg.baseColor.c, h: hue };
|
|
206
|
+
syncBaseAnchor(cfg);
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
191
211
|
/** Set several seed colors in ONE undo entry (harmony apply, global rotate). */
|
|
192
212
|
export function setBaseColors(patch: Record<string, Oklch>, historyLabel = 'colors: harmony'): void {
|
|
193
213
|
transaction(historyLabel, (s) => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import ColorEditPanel from '../ColorEditPanel.svelte';
|
|
3
3
|
import Toggle from '../Toggle.svelte';
|
|
4
|
+
import PaletteJumpButton from './PaletteJumpButton.svelte';
|
|
4
5
|
import { beginSliderGesture } from '../../core/store/editorStore';
|
|
5
6
|
import { oklchToHexClamped, type Oklch } from '../../core/palettes/oklch';
|
|
6
7
|
|
|
@@ -32,6 +33,8 @@
|
|
|
32
33
|
anchorStepLabel?: string | null;
|
|
33
34
|
isEditingBase: boolean;
|
|
34
35
|
panelOpen: boolean;
|
|
36
|
+
/** Keep the panel open in live-apply mode (no confirm/cancel session) while the curve editors are visible. */
|
|
37
|
+
pinnedOpen?: boolean;
|
|
35
38
|
editingColor: Oklch | null;
|
|
36
39
|
editPanelTitle: string | null;
|
|
37
40
|
copiedKey: string | null;
|
|
@@ -52,6 +55,7 @@
|
|
|
52
55
|
anchorStepLabel = null,
|
|
53
56
|
isEditingBase,
|
|
54
57
|
panelOpen,
|
|
58
|
+
pinnedOpen = false,
|
|
55
59
|
editingColor,
|
|
56
60
|
editPanelTitle,
|
|
57
61
|
copiedKey,
|
|
@@ -73,7 +77,7 @@
|
|
|
73
77
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
74
78
|
<div
|
|
75
79
|
class="header-swatch"
|
|
76
|
-
class:active={isEditingBase}
|
|
80
|
+
class:active={isEditingBase || pinnedOpen}
|
|
77
81
|
style="background: {baseHex}"
|
|
78
82
|
onclick={onStartEdit}
|
|
79
83
|
role="button"
|
|
@@ -81,7 +85,10 @@
|
|
|
81
85
|
onkeydown={(e) => e.key === 'Enter' && onStartEdit()}
|
|
82
86
|
></div>
|
|
83
87
|
<div class="primary-info">
|
|
84
|
-
<
|
|
88
|
+
<div class="label-row">
|
|
89
|
+
<span class="editor-label">{displayLabel ?? label}</span>
|
|
90
|
+
<PaletteJumpButton family={label} {displayLabel} target="wheel" />
|
|
91
|
+
</div>
|
|
85
92
|
<button
|
|
86
93
|
class="base-hex clickable-hex"
|
|
87
94
|
type="button"
|
|
@@ -91,10 +98,11 @@
|
|
|
91
98
|
</div>
|
|
92
99
|
</div>
|
|
93
100
|
|
|
94
|
-
{#if isEditingBase && panelOpen && editingColor}
|
|
101
|
+
{#if pinnedOpen || (isEditingBase && panelOpen && editingColor)}
|
|
95
102
|
<ColorEditPanel
|
|
96
|
-
title={editPanelTitle}
|
|
103
|
+
title={isEditingBase ? editPanelTitle : 'Base Color'}
|
|
97
104
|
showRemoveOverride={false}
|
|
105
|
+
hideActions={!isEditingBase}
|
|
98
106
|
hue={baseOklch.h}
|
|
99
107
|
chroma={baseOklch.c}
|
|
100
108
|
lightness={baseOklch.l * 100}
|
|
@@ -127,7 +135,7 @@
|
|
|
127
135
|
.editor-primary {
|
|
128
136
|
display: flex;
|
|
129
137
|
align-items: center;
|
|
130
|
-
gap: var(--ui-space-
|
|
138
|
+
gap: var(--ui-space-12);
|
|
131
139
|
flex-shrink: 0;
|
|
132
140
|
}
|
|
133
141
|
|
|
@@ -137,6 +145,13 @@
|
|
|
137
145
|
gap: var(--ui-space-2);
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
.label-row {
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
gap: var(--ui-space-10);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
140
155
|
.header-swatch {
|
|
141
156
|
width: 4rem;
|
|
142
157
|
height: 4rem;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import UIPillButton from '../UIPillButton.svelte';
|
|
3
|
+
import { openPaletteInTokens, openPaletteInWheel } from '../../core/store/paletteFocus';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
family: string;
|
|
7
|
+
displayLabel?: string | null;
|
|
8
|
+
/** Which view the button hands this family over to. */
|
|
9
|
+
target: 'wheel' | 'tokens';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { family, displayLabel = null, target }: Props = $props();
|
|
13
|
+
|
|
14
|
+
let name = $derived(displayLabel ?? family);
|
|
15
|
+
let icon = $derived(target === 'wheel' ? 'fa-palette' : 'fa-sliders');
|
|
16
|
+
let label = $derived(target === 'wheel' ? 'Wheel' : 'Edit');
|
|
17
|
+
let title = $derived(
|
|
18
|
+
target === 'wheel'
|
|
19
|
+
? `Open ${name} on the color wheel`
|
|
20
|
+
: `Edit the ${name} palette in Tokens`
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
function jump() {
|
|
24
|
+
if (target === 'wheel') openPaletteInWheel(family);
|
|
25
|
+
else openPaletteInTokens(family);
|
|
26
|
+
}
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<span class="palette-jump">
|
|
30
|
+
<UIPillButton size="compact" variant="outline" {icon} {title} onclick={jump}>{label}</UIPillButton>
|
|
31
|
+
</span>
|
|
32
|
+
|
|
33
|
+
<style>
|
|
34
|
+
.palette-jump {
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* fa-palette loses its bowl at the pill's default icon size. The compact
|
|
39
|
+
pill's line box is shorter than the glyph draws, so the padding grows with
|
|
40
|
+
it rather than letting it cross the border. */
|
|
41
|
+
.palette-jump :global(.ui-pill) {
|
|
42
|
+
padding-block: var(--ui-space-6);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.palette-jump :global(.ui-pill i) {
|
|
46
|
+
font-size: var(--ui-font-size-md);
|
|
47
|
+
line-height: 1.2;
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
lockedAnchorIndex?: number | null;
|
|
27
27
|
onAnchorsChange: (anchors: CurveAnchor[]) => void;
|
|
28
28
|
onOffsetChange: (key: string, value: number) => void;
|
|
29
|
+
onLockedAnchorUnlock?: (() => void) | null;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
let {
|
|
@@ -37,7 +38,8 @@
|
|
|
37
38
|
offset = 0,
|
|
38
39
|
lockedAnchorIndex = null,
|
|
39
40
|
onAnchorsChange,
|
|
40
|
-
onOffsetChange
|
|
41
|
+
onOffsetChange,
|
|
42
|
+
onLockedAnchorUnlock = null
|
|
41
43
|
}: Props = $props();
|
|
42
44
|
</script>
|
|
43
45
|
|
|
@@ -48,6 +50,7 @@
|
|
|
48
50
|
defaultAnchors={defaults}
|
|
49
51
|
{offset}
|
|
50
52
|
{lockedAnchorIndex}
|
|
53
|
+
{onLockedAnchorUnlock}
|
|
51
54
|
{onAnchorsChange}
|
|
52
55
|
onOffsetChange={(v) => onOffsetChange(curveKey, v)}
|
|
53
56
|
/>
|