@motion-proto/live-tokens 0.54.2 → 0.55.1

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,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.55.1 — The anchored step is the base color
4
+
5
+ ### Fixed
6
+
7
+ - **Editing the anchored palette step edits the base color.** The anchor pins
8
+ the lightness, saturation and hue curves through one step, so that swatch was
9
+ already rendering the base color. Clicking it opened a per-step override,
10
+ which forked the two apart: the ramp moved, the family's base swatch and hex
11
+ did not, and the palette stopped passing through the base color the anchor
12
+ guarantees it passes through. That step now opens the base color itself. The
13
+ whole ramp re-derives, a bound harmony axis follows the hue, and the header
14
+ swatch moves with it. Both swatches carry the selection ring together and the
15
+ panel titles itself "Base Color > 500", so the pairing is stated rather than
16
+ inferred. An override left on the anchored step by an earlier build is cleared
17
+ when the step is opened, inside the edit session, so Cancel and undo both put
18
+ it back. Every other step keeps its own override, unchanged.
19
+
20
+ ### Changed
21
+
22
+ - **The color editor docks under the row it edits.** It rendered at the bottom
23
+ of the family block, past the curve editors and the Text, Surfaces and Borders
24
+ section, far enough from the swatch that opened it to read as unrelated
25
+ chrome. It now opens directly beneath the ramp for a palette step, or beneath
26
+ the derived row that owns the step, with a caret on the step's own column. The
27
+ caret is placed on the grid rather than positioned, so it stays under its
28
+ swatch at any width. Showing and hiding use the disclosure motion the
29
+ expandable sections already use.
30
+
31
+ - **Selection reads by weight.** The editor's chrome is greyscale by design, so
32
+ a selected swatch is marked by a heavy ring drawn inside it: a white band
33
+ between two dark keylines, which survives both ends of a ramp running white to
34
+ black where a single-tone ring disappears at one end. The ring is inset rather
35
+ than an outline, which would close the gutter between neighbouring swatches,
36
+ or a thicker border, which would resize the swatch inside its own grid.
37
+
38
+ ## 0.55.0 — A focused palette arrives ready
39
+
40
+ ### Changed
41
+
42
+ - **A palette focused before mount opens without the reveal.** `paletteEditorOpen`
43
+ started false and flipped in an effect, so a deep link that set the focus
44
+ ahead of mount landed the visitor mid-animation, reading as a page still
45
+ loading. The editor now reads the pending focus when it constructs and opens
46
+ on the first frame; the scroll that brings it into view jumps rather than
47
+ gliding, since nothing expanded for the motion to match. A jump from the
48
+ Colors view, where the editor is already mounted, keeps its reveal.
49
+
50
+ - **"Base color must appear in palette" moved to the family band.** The toggle
51
+ sets `anchorToBase`, which is family-level config persisted in the theme,
52
+ but it sat in the base-colour panel among the controls that edit the colour's
53
+ *value* — and it only occupied a row of its own because it wrapped out of that
54
+ panel's title row. It now sits beside the family's name as
55
+ "Must appear in palette", the swatch alongside supplying the subject the label
56
+ drops; the full sentence stays as its title. The colour panel closes back up
57
+ to a title row and its sliders. Collapsed families are untouched: the setting
58
+ shows only while that family's editor is open, so a page of ten collapsed
59
+ palettes does not repeat it ten times.
60
+
61
+ - **The palette band holds two columns at every width.** With the setting on
62
+ the band's row, a band too narrow for it wrapped the buttons to the left edge,
63
+ under the swatch. The setting sits in the identity column instead, a third
64
+ line under the hex — name, value and constraint all describe the family and
65
+ share its left rail — which leaves the buttons a column of their own that no
66
+ width takes away. They wrap among themselves against the right edge before the
67
+ cluster ever drops a line. The swatch keeps its square: the taller column
68
+ would otherwise stretch the chip into a rectangle.
69
+
3
70
  ## 0.54.2 — Palette focus is public API
4
71
 
5
72
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.54.2",
3
+ "version": "0.55.1",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -8,6 +8,9 @@
8
8
 
9
9
  interface Props {
10
10
  title?: string | null;
11
+ /** Ties the panel to the swatch it was opened from, when it renders docked
12
+ * beneath that swatch's row. */
13
+ highlighted?: boolean;
11
14
  showRemoveOverride?: boolean;
12
15
  /** Hide the confirm/cancel actions for live-apply consumers (Colors view
13
16
  * readout) where edits commit through the store as they happen. */
@@ -37,6 +40,7 @@
37
40
 
38
41
  let {
39
42
  title = null,
43
+ highlighted = false,
40
44
  showRemoveOverride = false,
41
45
  hideActions = false,
42
46
  hidePreview = false,
@@ -133,7 +137,7 @@
133
137
  }
134
138
  </script>
135
139
 
136
- <div class="hsl-panel">
140
+ <div class="hsl-panel" class:highlighted>
137
141
  <div class="hsl-panel-header">
138
142
  {#if !hidePreview}
139
143
  <div class="hsl-preview" style="background: {previewHex}"></div>
@@ -250,6 +254,17 @@
250
254
  border-radius: var(--ui-radius-md);
251
255
  }
252
256
 
257
+ /* Docked to a swatch: the border thickens to the weight of that swatch's
258
+ selection ring, so the pair reads as one object. */
259
+ .hsl-panel.highlighted {
260
+ border-width: 2px;
261
+ border-color: var(--ui-border-higher);
262
+ }
263
+
264
+ .hsl-panel.highlighted .hsl-panel-title {
265
+ color: var(--ui-text-primary);
266
+ }
267
+
253
268
  .hsl-panel-header {
254
269
  display: flex;
255
270
  align-items: center;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { onMount, onDestroy, tick } from 'svelte';
3
+ import { get } from 'svelte/store';
3
4
  import { oklchToHexClamped, type Oklch } from '../core/palettes/oklch';
4
5
  import { type CurveAnchor, lightnessCurveConfig, saturationCurveConfig, hueCurveConfig } from './curveEngine';
5
6
  import ColorEditPanel from './ColorEditPanel.svelte';
@@ -114,7 +115,14 @@
114
115
  }
115
116
 
116
117
  let showDerived = $state(false);
117
- let paletteEditorOpen = $state(false);
118
+ // A deep link sets the focus before this mounts, so the controls are open on
119
+ // the first frame: UIReveal's `{#if}` never toggles and there is no reveal to
120
+ // sit through. Landing mid-animation reads as a page still loading.
121
+ // svelte-ignore state_referenced_locally
122
+ // The mount-time value is the whole point: a family's label is fixed for the
123
+ // life of the instance (PALETTE_SPECS keys the `{#each}` by it).
124
+ const focusedOnMount = get(pendingPaletteFocus) === label;
125
+ let paletteEditorOpen = $state(focusedOnMount);
118
126
  let rootEl: HTMLElement | undefined = $state();
119
127
 
120
128
  // Arriving from the Colors view's Edit button: open this family's controls and
@@ -123,7 +131,11 @@
123
131
  if ($pendingPaletteFocus !== label) return;
124
132
  pendingPaletteFocus.set(null);
125
133
  paletteEditorOpen = true;
126
- tick().then(() => rootEl?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
134
+ // Nothing expanded when we opened on mount, so there is no motion for a
135
+ // smooth scroll to match — jump, and the first frame is the final state.
136
+ tick().then(() =>
137
+ rootEl?.scrollIntoView({ behavior: focusedOnMount ? 'auto' : 'smooth', block: 'start' }),
138
+ );
127
139
  });
128
140
 
129
141
  function setLightnessCurve(a: CurveAnchor[]) { edit('lightnessCurve', a); }
@@ -195,8 +207,19 @@
195
207
  openSession();
196
208
  }
197
209
 
198
- function handlePaletteClick(ps: { label: string; lightness: number; index: number }) {
210
+ function handlePaletteClick(ps: { label: string; index: number; anchored: boolean }) {
199
211
  const k = paletteStepKey(ps.label);
212
+ // The anchored step is not a copy of the base color, it IS the base color:
213
+ // the anchor pins the curves through it. So editing it edits the base, and
214
+ // the header swatch and the whole ramp follow. An override here would fork
215
+ // the two apart and quietly break the guarantee the anchor makes.
216
+ if (ps.anchored) {
217
+ if (isEditingBase) { confirmEdit(); return; }
218
+ startBaseEdit();
219
+ // After openSession, so cancel and undo both put it back.
220
+ if (k in overrides) removeOverride(k);
221
+ return;
222
+ }
200
223
  if (editingKey === k) {
201
224
  confirmEdit();
202
225
  return;
@@ -493,6 +516,10 @@
493
516
  let overridesHex = $derived(Object.fromEntries(Object.entries(overrides).map(([k, v]) => [k, toHex(v)])));
494
517
 
495
518
  let isEditingBase = $derived(isBaseEdit(editing));
519
+ // Naming the step the base sits on is what says the header swatch and that
520
+ // ramp swatch are one value, not two that happen to match.
521
+ let anchoredStep = $derived(paletteComputed.find(ps => ps.anchored) ?? null);
522
+ let basePanelTitle = $derived(anchoredStep ? `Base Color \u203A ${anchoredStep.label}` : 'Base Color');
496
523
  let editingColor = $derived(isEditingBase ? baseColor : editingDraft);
497
524
  let editingStepInfo = $derived((() => {
498
525
  if (!editingKey || isEditingBase) return null;
@@ -508,11 +535,28 @@
508
535
  return null;
509
536
  })());
510
537
  let panelOpen = $derived(editingKey !== null && (isEditingBase || (editingDraft !== null && editingStepInfo !== null)));
511
- let editPanelTitle = $derived(isEditingBase
512
- ? 'Base Color'
513
- : editingStepInfo
514
- ? `${editingStepInfo.scale} \u203A ${editingStepInfo.step}`
515
- : null);
538
+ // The panel docks under the row that owns the step being edited, so these
539
+ // place it: a column for the palette caret, a row slot for the derived scales.
540
+ let editingPaletteIndex = $derived.by(() => {
541
+ if (!panelOpen || isEditingBase) return null;
542
+ const i = paletteComputed.findIndex(ps => ps.key === editingKey);
543
+ return i === -1 ? null : i;
544
+ });
545
+ let editingDerivedRow = $derived.by(() => {
546
+ if (!panelOpen || isEditingBase || editingPaletteIndex !== null) return null;
547
+ const scale = scales.find(s => s.title === editingStepInfo?.scale);
548
+ if (!scale) return null;
549
+ return scale.isText ? 'text' : 'solid';
550
+ });
551
+ let editPanelTitle = $derived(editingStepInfo ? `${editingStepInfo.scale} \u203A ${editingStepInfo.step}` : null);
552
+ // The reveal's outro needs something to render: closing the panel resets
553
+ // `editing` to idle, and every value below it goes null in the same tick, so a
554
+ // live read would slide an empty box shut. Held until the next edit replaces it.
555
+ let dock: { title: string | null; color: Oklch; paletteIndex: number | null } | null = $state(null);
556
+ $effect(() => {
557
+ if (!panelOpen || isEditingBase || !editingColor) return;
558
+ dock = { title: editPanelTitle, color: editingColor, paletteIndex: editingPaletteIndex };
559
+ });
516
560
  $effect(() => {
517
561
  // Touch each input so the effect tracks them (resnapScales reads indirectly).
518
562
  void baseColor;
@@ -525,6 +569,24 @@
525
569
  });
526
570
  </script>
527
571
 
572
+ {#snippet stepEditPanel()}
573
+ {#if dock}
574
+ <ColorEditPanel
575
+ title={dock.title}
576
+ highlighted
577
+ showRemoveOverride
578
+ hue={dock.color.h}
579
+ chroma={dock.color.c}
580
+ lightness={dock.color.l * 100}
581
+ onHueChromaChange={(h, c, l) => handleColorChange({ l: l / 100, c, h })}
582
+ onConfirm={confirmEdit}
583
+ onCancel={cancelEdit}
584
+ onRemoveOverride={() => editingKey && removeOverride(editingKey)}
585
+ onSliderStart={() => beginSliderGesture(`edit ${label} ${editingKey ?? 'color'}`)}
586
+ />
587
+ {/if}
588
+ {/snippet}
589
+
528
590
  <div class="palette-editor" bind:this={rootEl} style="--editor-base: {toHex(baseColor)}">
529
591
  <PaletteBase
530
592
  {label}
@@ -532,10 +594,23 @@
532
594
  {baseColor}
533
595
  {isEditingBase}
534
596
  pinnedOpen={paletteEditorOpen}
597
+ selected={isEditingBase}
535
598
  {copiedKey}
536
599
  onStartEdit={startBaseEdit}
537
600
  onCopyBaseHex={copyHex}
538
601
  >
602
+ {#snippet setting()}
603
+ <!-- The family's own swatch and name sit alongside, so the label drops
604
+ its subject; the title carries the full sentence. -->
605
+ <span class="base-anchor-toggle" title="Base color must appear in palette">
606
+ <Toggle
607
+ checked={anchorToBase}
608
+ onchange={(v) => setAnchorToBase(v ?? !anchorToBase)}
609
+ label="Must appear in palette"
610
+ />
611
+ </span>
612
+ {/snippet}
613
+
539
614
  {#snippet actions()}
540
615
  <PaletteJumpButton family={label} {displayLabel} target="wheel" />
541
616
  <UIPillButton size="compact" variant="outline" onclick={clearPaletteOverrides}>Clear Overrides</UIPillButton>
@@ -549,7 +624,7 @@
549
624
  <div class="swatch-grid" style="--swatch-cols: {paletteStepLightness.length + 2}">
550
625
  <div class="base-panel" style="grid-column: 2 / {paletteStepLightness.length + 2}">
551
626
  <ColorEditPanel
552
- title={isEditingBase ? editPanelTitle : 'Base Color'}
627
+ title={basePanelTitle}
553
628
  showRemoveOverride={false}
554
629
  hideActions={!isEditingBase}
555
630
  hidePreview
@@ -563,18 +638,7 @@
563
638
  onCancel={cancelEdit}
564
639
  onRemoveOverride={() => {}}
565
640
  onSliderStart={() => beginSliderGesture(`edit ${label} base`)}
566
- >
567
- {#snippet actions()}
568
- <div class="base-anchor-toggle">
569
- <Toggle
570
- checked={anchorToBase}
571
- onchange={(v) => setAnchorToBase(v ?? !anchorToBase)}
572
- label="Base color must appear in palette"
573
- labelFirst
574
- />
575
- </div>
576
- {/snippet}
577
- </ColorEditPanel>
641
+ />
578
642
  </div>
579
643
  </div>
580
644
  </UIReveal>
@@ -604,21 +668,21 @@
604
668
  </div>
605
669
  {#each paletteComputed as ps}
606
670
  <div class="step-column">
607
- <button class="step-label copyable-label" class:copied={copiedLabelKey === ps.key} type="button" onclick={(e) => copyVarName(ps.key, `--color-${cssNamespace}-${ps.label}`, e)}>
671
+ <button class="step-label copyable-label" class:copied={copiedLabelKey === ps.key} class:selected={editingKey === ps.key || (isEditingBase && ps.anchored)} type="button" onclick={(e) => copyVarName(ps.key, `--color-${cssNamespace}-${ps.label}`, e)}>
608
672
  {copiedLabelKey === ps.key ? 'copied!' : ps.label}
609
673
  </button>
610
674
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
611
675
  <div
612
676
  class="swatch gray-swatch"
613
- class:active={editingKey === ps.key}
677
+ class:active={editingKey === ps.key || (isEditingBase && ps.anchored)}
614
678
  class:overridden={ps.key in overrides}
615
679
  class:anchored={ps.anchored}
616
680
  title={ps.anchored ? 'Base color' : undefined}
617
681
  style="background: {ps.effective}"
618
- onclick={() => handlePaletteClick({ label: ps.label, lightness: ps.lightness, index: ps.index })}
682
+ onclick={() => handlePaletteClick(ps)}
619
683
  role="button"
620
684
  tabindex="0"
621
- onkeydown={(e) => e.key === 'Enter' && handlePaletteClick({ label: ps.label, lightness: ps.lightness, index: ps.index })}
685
+ onkeydown={(e) => e.key === 'Enter' && handlePaletteClick(ps)}
622
686
  >
623
687
  {#if ps.key in overrides}
624
688
  <span class="override-lock" title="Palette override: this step is set by hand, not derived from the curve">
@@ -640,6 +704,17 @@
640
704
  </button>
641
705
  <div class="swatch gray-swatch bookend" style="background: #000000"></div>
642
706
  </div>
707
+
708
+ <UIReveal open={editingPaletteIndex !== null} style="grid-column: 2 / {paletteStepLightness.length + 2}">
709
+ <!-- The reveal has to own the grid slot, so the dock rebuilds the
710
+ ramp's tracks inside it: the caret is then placed on the step's
711
+ own column and points at it at any width, no arithmetic. -->
712
+ <div class="dock" style="--dock-cols: {paletteStepLightness.length}">
713
+ <div class="edit-caret" style="grid-column: {(dock?.paletteIndex ?? 0) + 1}"></div>
714
+ <div class="docked-panel">{@render stepEditPanel()}</div>
715
+ </div>
716
+ </UIReveal>
717
+
643
718
  <UIReveal open={paletteEditorOpen} style="grid-column: 2 / {paletteStepLightness.length + 2}">
644
719
  <div class="curve-grid-span">
645
720
  {#if anchorUnlockPrompt && anchorToBase}
@@ -774,29 +849,19 @@
774
849
  {@render overridesPanel(scale, true, derivedHexForBase)}
775
850
  {/each}
776
851
  </div>
852
+ <UIReveal open={editingDerivedRow === 'text'}>
853
+ <div class="docked-panel">{@render stepEditPanel()}</div>
854
+ </UIReveal>
777
855
  <div class="scales-row">
778
856
  {#each scales.filter(s => !s.isText) as scale}
779
857
  {@render overridesPanel(scale, true, derivedHexForBase)}
780
858
  {/each}
781
859
  </div>
860
+ <UIReveal open={editingDerivedRow === 'solid'}>
861
+ <div class="docked-panel">{@render stepEditPanel()}</div>
862
+ </UIReveal>
782
863
  </div>
783
864
  </UIReveal>
784
-
785
- <!-- Color Edit Panel (non-base edits) -->
786
- {#if !isEditingBase && panelOpen && editingColor}
787
- <ColorEditPanel
788
- title={editPanelTitle}
789
- showRemoveOverride={!!editingKey}
790
- hue={editingColor.h}
791
- chroma={editingColor.c}
792
- lightness={editingColor.l * 100}
793
- onHueChromaChange={(h, c, l) => handleColorChange({ l: l / 100, c, h })}
794
- onConfirm={confirmEdit}
795
- onCancel={cancelEdit}
796
- onRemoveOverride={() => editingKey && removeOverride(editingKey)}
797
- onSliderStart={() => beginSliderGesture(`edit ${label} ${editingKey ?? 'color'}`)}
798
- />
799
- {/if}
800
865
  </div>
801
866
 
802
867
  <style>
@@ -924,6 +989,12 @@
924
989
  color: var(--ui-text-accent, var(--ui-text-primary));
925
990
  }
926
991
 
992
+ /* The name the docked panel's title repeats back. */
993
+ .step-label.copyable-label.selected {
994
+ color: var(--ui-text-primary);
995
+ font-weight: var(--ui-font-weight-semibold);
996
+ }
997
+
927
998
  /* Swatches */
928
999
 
929
1000
  .swatch {
@@ -988,8 +1059,10 @@
988
1059
  min-width: 0;
989
1060
  }
990
1061
 
1062
+ /* A box of its own so the title has something to hover; placement in the
1063
+ band belongs to PaletteBase. */
991
1064
  .base-anchor-toggle {
992
- margin-left: auto;
1065
+ display: flex;
993
1066
  }
994
1067
 
995
1068
  .curve-grid-span {
@@ -998,6 +1071,34 @@
998
1071
  gap: var(--ui-space-8);
999
1072
  }
1000
1073
 
1074
+ /* Same tracks and gutter as the ramp above, so a column here lands under the
1075
+ swatch of the same index. */
1076
+ .dock {
1077
+ display: grid;
1078
+ grid-template-columns: repeat(var(--dock-cols), minmax(0, 1fr));
1079
+ gap: var(--ui-space-4) var(--swatch-gap, var(--ui-space-4));
1080
+ }
1081
+
1082
+ /* Docked under the row it edits, pointed at the swatch it edits. */
1083
+ .docked-panel {
1084
+ grid-column: 1 / -1;
1085
+ min-width: 0;
1086
+ }
1087
+
1088
+ .edit-caret {
1089
+ display: flex;
1090
+ justify-content: center;
1091
+ align-items: flex-end;
1092
+ height: var(--ui-space-6);
1093
+ }
1094
+
1095
+ .edit-caret::after {
1096
+ content: '';
1097
+ border-left: var(--ui-space-6) solid transparent;
1098
+ border-right: var(--ui-space-6) solid transparent;
1099
+ border-bottom: var(--ui-space-6) solid var(--ui-border-higher);
1100
+ }
1101
+
1001
1102
  .anchor-unlock-notice {
1002
1103
  display: flex;
1003
1104
  align-items: center;
@@ -1042,10 +1143,17 @@
1042
1143
  border-color: var(--ui-border-high);
1043
1144
  }
1044
1145
 
1045
- .swatch.gray-swatch.active {
1046
- border-color: var(--ui-border-higher);
1047
- outline: 2px solid var(--ui-border-high);
1048
- outline-offset: 1px;
1146
+ /* A ring drawn inside the swatch, weight only: the editor stays greyscale, so
1147
+ selection has to out-weigh the anchored step's emphasized border rather
1148
+ than out-colour it. Insets, because a 3px outline would eat the column gap
1149
+ and a 3px border would resize the swatch mid-grid. The dark keylines either
1150
+ side of the white band keep it legible at both ends of the ramp. */
1151
+ .swatch.gray-swatch.active,
1152
+ .swatch.gray-swatch.anchored.active {
1153
+ border-color: var(--ui-surface-lowest);
1154
+ box-shadow:
1155
+ inset 0 0 0 3px var(--ui-text-primary),
1156
+ inset 0 0 0 4px var(--ui-surface-lowest);
1049
1157
  }
1050
1158
 
1051
1159
  /* The step the base color is placed at: taller, with an emphasized border. It
@@ -424,9 +424,12 @@
424
424
  border-color: var(--ui-border-high);
425
425
  }
426
426
 
427
+ /* Same weight-only selection language as the palette ramp, at the smaller
428
+ swatch's scale. */
427
429
  .override-slot.active {
428
- border-color: var(--ui-border-higher);
429
- outline: 1px solid var(--ui-border-high);
430
+ border-style: solid;
431
+ border-color: var(--ui-surface-lowest);
432
+ outline: 2px solid var(--ui-text-primary);
430
433
  outline-offset: 1px;
431
434
  }
432
435
 
@@ -14,10 +14,16 @@
14
14
  isEditingBase: boolean;
15
15
  /** Keep the swatch reading as active while the curve editors are visible. */
16
16
  pinnedOpen?: boolean;
17
+ /** The same ring the ramp's anchored step wears, so the two swatches read
18
+ * as the one value they are. */
19
+ selected?: boolean;
17
20
  copiedKey: string | null;
18
21
  onStartEdit: () => void;
19
22
  onCopyBaseHex: (key: string, hex: string, event?: MouseEvent) => void;
20
23
  actions?: Snippet;
24
+ /** A family-level setting, shown beside the name while this family's
25
+ * editor is open. Collapsed families keep the band to identity + actions. */
26
+ setting?: Snippet;
21
27
  }
22
28
 
23
29
  let {
@@ -26,13 +32,19 @@
26
32
  baseColor,
27
33
  isEditingBase,
28
34
  pinnedOpen = false,
35
+ selected = false,
29
36
  copiedKey,
30
37
  onStartEdit,
31
38
  onCopyBaseHex,
32
- actions
39
+ actions,
40
+ setting
33
41
  }: Props = $props();
34
42
 
35
43
  let baseHex = $derived(oklchToHexClamped(baseColor.l, baseColor.c, baseColor.h));
44
+
45
+ // `pinnedOpen || isEditingBase` is exactly when the base panel shows: editing
46
+ // the base implies both a panel and a colour to put in it.
47
+ let showSetting = $derived(!!setting && (pinnedOpen || isEditingBase));
36
48
  </script>
37
49
 
38
50
  <div class="editor-top">
@@ -41,6 +53,7 @@
41
53
  <div
42
54
  class="header-swatch"
43
55
  class:active={isEditingBase || pinnedOpen}
56
+ class:selected
44
57
  style="background: {baseHex}"
45
58
  onclick={onStartEdit}
46
59
  role="button"
@@ -64,6 +77,10 @@
64
77
  aria-hidden="true"
65
78
  ></i>
66
79
  </button>
80
+
81
+ {#if showSetting}
82
+ <div class="header-setting">{@render setting?.()}</div>
83
+ {/if}
67
84
  </div>
68
85
  </div>
69
86
 
@@ -100,12 +117,28 @@
100
117
  .header-actions {
101
118
  display: flex;
102
119
  align-items: center;
120
+ flex-wrap: wrap;
121
+ justify-content: flex-end;
103
122
  gap: var(--ui-space-8);
104
123
  }
105
124
 
125
+ /* Third line of the identity, under the hex: name, value, constraint all
126
+ describe the family and share its left rail. Keeping it out of the band's
127
+ row is what leaves the buttons a column of their own at every width. */
128
+ .header-setting {
129
+ display: flex;
130
+ align-items: center;
131
+ margin-top: var(--ui-space-4);
132
+ }
133
+
134
+ /* The setting makes the identity column taller than the swatch, and the row
135
+ stretches its items; left alone the chip would grow into a rectangle. It is
136
+ a colour sample, so it stays square and hangs from the top of the column. */
106
137
  .header-swatch {
107
138
  width: 4.5rem;
108
139
  min-height: 4.5rem;
140
+ aspect-ratio: 1;
141
+ align-self: flex-start;
109
142
  border-radius: var(--ui-radius-md);
110
143
  border: 2px solid var(--ui-border);
111
144
  flex-shrink: 0;
@@ -122,6 +155,14 @@
122
155
  outline-offset: 1px;
123
156
  }
124
157
 
158
+ .header-swatch.selected {
159
+ border-color: var(--ui-surface-lowest);
160
+ outline: none;
161
+ box-shadow:
162
+ inset 0 0 0 3px var(--ui-text-primary),
163
+ inset 0 0 0 4px var(--ui-surface-lowest);
164
+ }
165
+
125
166
  .editor-label {
126
167
  font-size: var(--ui-font-size-xl);
127
168
  font-weight: var(--ui-font-weight-semibold);