@motion-proto/live-tokens 0.46.0 → 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 CHANGED
@@ -1,5 +1,67 @@
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
+
44
+ ## 0.46.1 — The Colors view collapses on its own width
45
+
46
+ ### Fixed
47
+
48
+ - **Colors view collapses by pane width, not viewport width.** `.pane` is now a
49
+ `container: pane / inline-size`, and the harmony columns (`44rem`) and wheel
50
+ rail (`26rem`) collapse against it. The pane is one column of a two-pane grid,
51
+ so a wide standalone window narrowed it just as the docked panel does while
52
+ the old `@media (max-width: 720px)` query never fired.
53
+
54
+ - **Swatch rows and derived-scale strips rank instead of shrinking to slivers.**
55
+ Below `28rem` of their own container both switch from flex to
56
+ `repeat(auto-fill, minmax(...))` grids, keeping a readable chip width and
57
+ wrapping into ranks. `auto-fill` holds the tracks steady, so a short last rank
58
+ never stretches.
59
+
60
+ - **`UIMenuButton` anchors correctly inside a container-type ancestor.** A
61
+ `container-type` (or transformed) ancestor re-parents a fixed element's
62
+ containing block off the viewport origin, so the menu landed offset from its
63
+ computed position. It now measures where it landed and re-anchors.
64
+
3
65
  ## 0.46.0 — Harmony axes are numbered, and you assign them with a picker
4
66
 
5
67
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -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
@@ -120,6 +133,14 @@
120
133
  }
121
134
  menu.style.left = `${left}px`;
122
135
  menu.style.top = `${top}px`;
136
+ // A container-type (or transformed) ancestor re-parents the fixed
137
+ // containing block off the viewport origin; measure where the menu landed
138
+ // and re-anchor so the viewport math above still holds.
139
+ const placed = menu.getBoundingClientRect();
140
+ if (Math.abs(placed.left - left) > 0.5 || Math.abs(placed.top - top) > 0.5) {
141
+ menu.style.left = `${left - (placed.left - left)}px`;
142
+ menu.style.top = `${top - (placed.top - top)}px`;
143
+ }
123
144
  menu.style.visibility = 'visible';
124
145
  }
125
146
 
@@ -171,6 +192,7 @@
171
192
  style="min-width: {menuMinWidth};"
172
193
  bind:this={menuEl}
173
194
  onkeydown={onMenuKeydown}
195
+ use:portal
174
196
  >
175
197
  <div class="menu-header" role="presentation">{header}</div>
176
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
- let selected = $state('Brand');
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
- selected = label;
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. The axis moves to that color's hue and 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>
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">&middot; {selectedSpec.displayLabel ?? selected}</span></h2>
314
+ <PaletteJumpButton family={selected} displayLabel={selectedSpec.displayLabel} target="tokens" />
310
315
  </header>
311
316
 
312
317
  <div class="group">
@@ -339,7 +344,11 @@
339
344
  }
340
345
  }
341
346
 
347
+ /* The pane is one column of a two-pane grid, so it goes narrow on a wide
348
+ standalone window just as it does in the docked panel. Every collapse
349
+ below keys on the pane's own width, never the viewport's. */
342
350
  .pane {
351
+ container: pane / inline-size;
343
352
  display: flex;
344
353
  flex-direction: column;
345
354
  gap: var(--ui-space-32);
@@ -376,6 +385,13 @@
376
385
  gap: var(--ui-space-2);
377
386
  }
378
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
+
379
395
  /* Every labeled region on this page carries the same heading, section and
380
396
  group alike: the old xs tertiary eyebrows read as captions and got skipped. */
381
397
  .title {
@@ -434,9 +450,8 @@
434
450
  gap: var(--ui-space-6);
435
451
  }
436
452
 
437
- /* The docked panel's iframe viewport is the panel itself, so this collapse
438
- fires there long before the standalone page ever gets this narrow. */
439
- @media (max-width: 720px) {
453
+ /* Below this the three columns squeeze the swatches into slivers. */
454
+ @container pane (max-width: 44rem) {
440
455
  .harmony-cols {
441
456
  grid-template-columns: minmax(0, 1fr);
442
457
  }
@@ -446,8 +461,10 @@
446
461
  justify-content: center;
447
462
  transform: rotate(90deg);
448
463
  }
464
+ }
449
465
 
450
- /* Below the wheel's minimum the rail is taller than the disc it flanks. */
466
+ /* Below the wheel's minimum the rail is taller than the disc it flanks. */
467
+ @container pane (max-width: 26rem) {
451
468
  .wheel-row {
452
469
  flex-direction: column;
453
470
  align-items: stretch;
@@ -572,6 +589,7 @@
572
589
  }
573
590
 
574
591
  .swatch-rows {
592
+ container: swatches / inline-size;
575
593
  display: flex;
576
594
  flex-direction: column;
577
595
  gap: var(--ui-space-8);
@@ -593,6 +611,17 @@
593
611
  outline-color: var(--ui-text-primary);
594
612
  }
595
613
 
614
+ /* Too narrow for one swatch per family: wrap into ranks that keep a readable
615
+ swatch width rather than shrinking toward slivers. auto-fill keeps every
616
+ rank on the same tracks, so a short last rank never stretches. */
617
+ @container swatches (max-width: 28rem) {
618
+ .swatch-row {
619
+ display: grid;
620
+ grid-template-columns: repeat(auto-fill, minmax(5.25rem, 1fr));
621
+ gap: var(--ui-space-8);
622
+ }
623
+ }
624
+
596
625
  /* Every swatch keeps one width: selection reads in the frame's height alone,
597
626
  so nothing in the row shifts sideways as the selection moves. */
598
627
  .swatch {
@@ -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
- /* Only axis 1 fills this column, but every row reserves it so the swatches,
321
- chips and pickers start at one x. Sized to fit "Anchor". The axis number
322
- itself lives only on the left swatches, which is where a color is read. */
323
- .role {
324
- flex: none;
325
- width: 4rem;
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
@@ -51,33 +51,51 @@
51
51
  }
52
52
  </script>
53
53
 
54
- <div class="strip" role="group" aria-label={`${spec.label} derived scale`}>
55
- {#each steps as s, i (s.label)}
56
- <div
57
- class="spot"
58
- class:anchored={anchoredStep === i}
59
- class:background={backgroundStep === i}
60
- style:flex-grow={dockGrow(i, anchoredStep)}
61
- title={stepTitle(s, i)}
62
- >
63
- <span class="chip" style:background={s.hex}>
64
- <span class="aa" class:fail={!s.aa} style:color={s.textHex}>Aa</span>
65
- </span>
66
- <span class="step-label">{s.label}</span>
67
- </div>
68
- {/each}
54
+ <div class="strip-viewport">
55
+ <div class="strip" role="group" aria-label={`${spec.label} derived scale`}>
56
+ {#each steps as s, i (s.label)}
57
+ <div
58
+ class="spot"
59
+ class:anchored={anchoredStep === i}
60
+ class:background={backgroundStep === i}
61
+ style:flex-grow={dockGrow(i, anchoredStep)}
62
+ title={stepTitle(s, i)}
63
+ >
64
+ <span class="chip" style:background={s.hex}>
65
+ <span class="aa" class:fail={!s.aa} style:color={s.textHex}>Aa</span>
66
+ </span>
67
+ <span class="step-label">{s.label}</span>
68
+ </div>
69
+ {/each}
70
+ </div>
69
71
  </div>
70
72
  {#if isGradient}
71
73
  <p class="hint">The page background is currently a gradient; the base color takes over when gradient mode is off.</p>
72
74
  {/if}
73
75
 
74
76
  <style>
77
+ .strip-viewport {
78
+ container: strip / inline-size;
79
+ min-width: 0;
80
+ }
81
+
75
82
  .strip {
76
83
  display: flex;
77
84
  align-items: flex-end;
78
85
  gap: var(--ui-space-4);
79
86
  }
80
87
 
88
+ /* Eleven steps shrink to slivers before the pane is done narrowing: below
89
+ that, rank the chips on a grid that keeps a readable width. Grid ignores
90
+ the dock-magnify flex-grow, so the anchored step reads by height alone. */
91
+ @container strip (max-width: 28rem) {
92
+ .strip {
93
+ display: grid;
94
+ grid-template-columns: repeat(auto-fill, minmax(2.5rem, 1fr));
95
+ gap: var(--ui-space-8) var(--ui-space-4);
96
+ }
97
+ }
98
+
81
99
  .spot {
82
100
  flex: 1 1 0;
83
101
  min-width: 0;
@@ -145,26 +145,31 @@ function applyAxisHue(s: EditorState, index: number, hue: number, chroma: number
145
145
  }
146
146
  }
147
147
 
148
- /** Bind a family to an axis; the axis moves to the family's hue. Trade-places
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, which likewise moves to it, or unassigned). One mutate.
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 = takeSeedHue(s, index, family);
162
- // Source was another axis: the occupant trades into it, keeping its color
163
- // and taking that axis to its hue. Source was unassigned (-1): the occupant
164
- // simply floats free, color kept.
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 = takeSeedHue(s, srcIndex, occupant) || 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
- /** Assignment must never repaint a seed the user chose, so the axis is what
183
- * moves. Harmony modes and axis drags stay the steps that move colors. */
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
- <span class="editor-label">{displayLabel ?? label}</span>
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-8);
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
  />