@motion-proto/live-tokens 0.46.1 → 0.47.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.
@@ -62,6 +62,8 @@
62
62
  const EXT_OFFSET = 20; // external handle radius beyond the disc rim (room for the dotted tether)
63
63
  const NUM_OFFSET = 24; // numeral radius beyond the external handles
64
64
  const FREE_NUM_OFFSET = 15; // free-dot numeral, diagonal clearance past the enlarged dot
65
+ const NUM_PERP = 20; // sideways clearance for a numeral moved beside its own marker
66
+ const NUM_STACK = 20; // radial gap between the numerals of co-hued unbound axes
65
67
  const MIN_SIZE = 240;
66
68
  const MAX_SIZE = 560;
67
69
 
@@ -128,6 +130,7 @@
128
130
  return {
129
131
  ...t,
130
132
  hue,
133
+ dotR,
131
134
  selected: t.bound && selected === t.family,
132
135
  dot: { x: center + dotR * Math.cos(rad(hue)), y: center - dotR * Math.sin(rad(hue)) },
133
136
  ext: { x: center + extRadius * Math.cos(rad(hue)), y: center - extRadius * Math.sin(rad(hue)) },
@@ -141,7 +144,37 @@
141
144
  }),
142
145
  );
143
146
 
144
- let visibleRender = $derived(axisRender.filter(isVisible));
147
+ // Monochromatic stacks every axis on one hue (custom does too, once the hues
148
+ // are dragged together), so the outer numeral slot is the same point for all
149
+ // of them. Each numeral then moves beside the marker it names — its own dot,
150
+ // or the external handle when the axis carries no color — which is the only
151
+ // thing that still tells the axes apart on a shared rail.
152
+ let visibleRender = $derived.by(() => {
153
+ const vis = axisRender.filter(isVisible);
154
+ const hueKey = (hue: number) => Math.round(normDeg(hue)) % 360;
155
+ const byHue = new Map<number, typeof vis>();
156
+ for (const t of vis) {
157
+ const group = byHue.get(hueKey(t.hue));
158
+ if (group) group.push(t);
159
+ else byHue.set(hueKey(t.hue), [t]);
160
+ }
161
+ return vis.map((t) => {
162
+ const group = byHue.get(hueKey(t.hue))!;
163
+ if (group.length < 2) return t;
164
+ // Bound axes separate by chroma radius on their own; unbound ones share the
165
+ // handle track, so they step outward past it instead.
166
+ const unboundBefore = group.slice(0, group.indexOf(t)).filter((g) => !g.bound).length;
167
+ const r = t.bound ? t.dotR : extRadius + unboundBefore * NUM_STACK;
168
+ const a = rad(t.hue);
169
+ return {
170
+ ...t,
171
+ num: {
172
+ x: center + r * Math.cos(a) - NUM_PERP * Math.sin(a),
173
+ y: center - r * Math.sin(a) - NUM_PERP * Math.cos(a),
174
+ },
175
+ };
176
+ });
177
+ });
145
178
 
146
179
  // Free dot for a selected family on no ACTIVE axis (Neutral, Alternate, …,
147
180
  // plus families whose axis the applied geometry deals no distinct slot): a 2D
@@ -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">
@@ -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
- /* 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
@@ -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) => {
@@ -97,6 +97,54 @@ export function makeAnchor(x: number, y: number, tangentLen = 15): CurveAnchor {
97
97
  return { x, y, inDx: -tangentLen, inDy: 0, outDx: tangentLen, outDy: 0 };
98
98
  }
99
99
 
100
+ /** Share of a segment's x-span a tangent handle reaches across. At a third the
101
+ * cubic is the exact Hermite form of the slope, so the handle length carries no
102
+ * shape of its own, and the two facing handles can never cross. */
103
+ const HANDLE_SPAN = 1 / 3;
104
+
105
+ /**
106
+ * Slope to carry through (x, y), given whichever neighbours it has.
107
+ *
108
+ * The weighted harmonic mean of the two secants (Fritsch–Carlson): it always
109
+ * lands between them, so the segment cannot overshoot its own endpoints. The
110
+ * plain average (Catmull-Rom) can overshoot when the gaps differ, and on a
111
+ * lightness ramp an overshoot puts a step out of order with its neighbours,
112
+ * which is the one thing a ramp must not do.
113
+ */
114
+ function tangentSlope(
115
+ prev: CurveAnchor | null,
116
+ x: number,
117
+ y: number,
118
+ next: CurveAnchor | null,
119
+ ): number {
120
+ const hPrev = prev ? x - prev.x : 0;
121
+ const hNext = next ? next.x - x : 0;
122
+ const dPrev = hPrev > 0 ? (y - prev!.y) / hPrev : null;
123
+ const dNext = hNext > 0 ? (next!.y - y) / hNext : null;
124
+ if (dPrev === null) return dNext ?? 0;
125
+ if (dNext === null) return dPrev;
126
+ // Secants disagreeing in sign make this a turning point, where flat is the
127
+ // true slope rather than a failure to find one.
128
+ if (dPrev * dNext <= 0) return 0;
129
+ const wPrev = 2 * hNext + hPrev;
130
+ const wNext = hNext + 2 * hPrev;
131
+ return (wPrev + wNext) / (wPrev / dPrev + wNext / dNext);
132
+ }
133
+
134
+ /** An anchor at (x, y) whose handles lie along the local slope, so dropping it
135
+ * onto a run bends the line rather than flattening it. */
136
+ export function tangentAnchor(
137
+ x: number,
138
+ y: number,
139
+ prev: CurveAnchor | null,
140
+ next: CurveAnchor | null,
141
+ ): CurveAnchor {
142
+ const m = tangentSlope(prev, x, y, next);
143
+ const inDx = prev ? -(x - prev.x) * HANDLE_SPAN : 0;
144
+ const outDx = next ? (next.x - x) * HANDLE_SPAN : 0;
145
+ return { x, y, inDx, inDy: m * inDx, outDx, outDy: m * outDx };
146
+ }
147
+
100
148
  export function isCornerAnchor(a: CurveAnchor): boolean {
101
149
  return a.inDx === 0 && a.inDy === 0 && a.outDx === 0 && a.outDy === 0;
102
150
  }
@@ -32,6 +32,8 @@
32
32
  anchorStepLabel?: string | null;
33
33
  isEditingBase: boolean;
34
34
  panelOpen: boolean;
35
+ /** Keep the panel open in live-apply mode (no confirm/cancel session) while the curve editors are visible. */
36
+ pinnedOpen?: boolean;
35
37
  editingColor: Oklch | null;
36
38
  editPanelTitle: string | null;
37
39
  copiedKey: string | null;
@@ -52,6 +54,7 @@
52
54
  anchorStepLabel = null,
53
55
  isEditingBase,
54
56
  panelOpen,
57
+ pinnedOpen = false,
55
58
  editingColor,
56
59
  editPanelTitle,
57
60
  copiedKey,
@@ -73,7 +76,7 @@
73
76
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
74
77
  <div
75
78
  class="header-swatch"
76
- class:active={isEditingBase}
79
+ class:active={isEditingBase || pinnedOpen}
77
80
  style="background: {baseHex}"
78
81
  onclick={onStartEdit}
79
82
  role="button"
@@ -83,40 +86,56 @@
83
86
  <div class="primary-info">
84
87
  <span class="editor-label">{displayLabel ?? label}</span>
85
88
  <button
86
- class="base-hex clickable-hex"
89
+ class="base-hex"
90
+ class:copied={copiedKey === '__base__'}
87
91
  type="button"
92
+ title="Copy {baseHex}"
88
93
  onclick={(e) => onCopyBaseHex('__base__', baseHex, e)}
89
- >{copiedKey === '__base__' ? 'copied!' : baseHex}</button>
94
+ >
95
+ <span class="hex-value">{baseHex}</span>
96
+ <i
97
+ class="fas"
98
+ class:fa-copy={copiedKey !== '__base__'}
99
+ class:fa-check={copiedKey === '__base__'}
100
+ aria-hidden="true"
101
+ ></i>
102
+ </button>
90
103
  </div>
91
104
  </div>
92
- </div>
93
105
 
94
- {#if isEditingBase && panelOpen && editingColor}
95
- <ColorEditPanel
96
- title={editPanelTitle}
97
- showRemoveOverride={false}
98
- hue={baseOklch.h}
99
- chroma={baseOklch.c}
100
- lightness={baseOklch.l * 100}
101
- chromaMax={CHROMA_MAX}
102
- chromaHint={pickerChromaHint}
103
- onHueChromaChange={onBaseChange}
104
- onConfirm={onConfirm}
105
- onCancel={onCancel}
106
- onRemoveOverride={() => {}}
107
- onSliderStart={() => beginSliderGesture(`edit ${label} base`)}
108
- >
109
- {#snippet actions()}
110
- <Toggle
111
- checked={anchorToBase}
112
- onchange={(v) => onAnchorToBaseChange(v ?? !anchorToBase)}
113
- label={anchorStepLabel ? `Base color placed at ${anchorStepLabel}` : 'Place base color in palette'}
114
- />
115
- {/snippet}
116
- </ColorEditPanel>
117
- {/if}
106
+ {#if pinnedOpen || (isEditingBase && panelOpen && editingColor)}
107
+ <div class="editor-controls">
108
+ <ColorEditPanel
109
+ title={isEditingBase ? editPanelTitle : 'Base Color'}
110
+ showRemoveOverride={false}
111
+ hideActions={!isEditingBase}
112
+ hidePreview
113
+ hue={baseOklch.h}
114
+ chroma={baseOklch.c}
115
+ lightness={baseOklch.l * 100}
116
+ chromaMax={CHROMA_MAX}
117
+ chromaHint={pickerChromaHint}
118
+ onHueChromaChange={onBaseChange}
119
+ onConfirm={onConfirm}
120
+ onCancel={onCancel}
121
+ onRemoveOverride={() => {}}
122
+ onSliderStart={() => beginSliderGesture(`edit ${label} base`)}
123
+ >
124
+ {#snippet actions()}
125
+ <Toggle
126
+ checked={anchorToBase}
127
+ onchange={(v) => onAnchorToBaseChange(v ?? !anchorToBase)}
128
+ label={anchorStepLabel ? `Base color placed at ${anchorStepLabel}` : 'Place base color in palette'}
129
+ />
130
+ {/snippet}
131
+ </ColorEditPanel>
132
+ </div>
133
+ {/if}
134
+ </div>
118
135
 
119
136
  <style>
137
+ /* Identity and controls read as one band: the sliders sit beside the swatch
138
+ rather than under it, which is what leaves the palette below its height. */
120
139
  .editor-top {
121
140
  display: flex;
122
141
  align-items: flex-start;
@@ -126,20 +145,28 @@
126
145
 
127
146
  .editor-primary {
128
147
  display: flex;
129
- align-items: center;
130
- gap: var(--ui-space-8);
148
+ align-items: stretch;
149
+ gap: var(--ui-space-12);
131
150
  flex-shrink: 0;
132
151
  }
133
152
 
134
153
  .primary-info {
135
154
  display: flex;
136
155
  flex-direction: column;
156
+ align-items: flex-start;
137
157
  gap: var(--ui-space-2);
138
158
  }
139
159
 
160
+ /* Basis, not width: the panel takes the rest of the row and drops below only
161
+ when it can't hold that much. */
162
+ .editor-controls {
163
+ flex: 1 1 26rem;
164
+ min-width: 0;
165
+ }
166
+
140
167
  .header-swatch {
141
- width: 4rem;
142
- height: 4rem;
168
+ width: 4.5rem;
169
+ min-height: 4.5rem;
143
170
  border-radius: var(--ui-radius-md);
144
171
  border: 2px solid var(--ui-border);
145
172
  flex-shrink: 0;
@@ -163,13 +190,9 @@
163
190
  }
164
191
 
165
192
  .base-hex {
166
- font-size: var(--ui-font-size-md);
167
- color: var(--ui-text-secondary);
168
- font-family: var(--ui-font-mono);
169
- }
170
-
171
- .clickable-hex {
172
- align-self: flex-start;
193
+ display: inline-flex;
194
+ align-items: center;
195
+ gap: var(--ui-space-6);
173
196
  background: none;
174
197
  border: none;
175
198
  cursor: pointer;
@@ -182,15 +205,31 @@
182
205
  text-align: left;
183
206
  }
184
207
 
185
- .clickable-hex:hover {
208
+ /* The glyph only announces itself on approach; the hex is the content. */
209
+ .base-hex i {
210
+ font-size: var(--ui-font-size-xs);
211
+ color: var(--ui-text-muted);
212
+ transition: color var(--ui-transition-fast);
213
+ }
214
+
215
+ .base-hex:hover {
186
216
  background: var(--ui-surface-highest);
187
217
  color: var(--ui-text-primary);
188
218
  }
189
219
 
220
+ .base-hex:hover i {
221
+ color: var(--ui-text-secondary);
222
+ }
223
+
224
+ .base-hex.copied,
225
+ .base-hex.copied i {
226
+ color: var(--ui-text-accent);
227
+ }
228
+
190
229
  @media (max-width: 1280px) {
191
230
  .header-swatch {
192
- width: 3rem;
193
- height: 3rem;
231
+ width: 3.5rem;
232
+ min-height: 3.5rem;
194
233
  }
195
234
  }
196
235
  </style>
@@ -0,0 +1,44 @@
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 min-height carries the taller glyph without changing the pill's
40
+ height, so it still lines up with the plain pills beside it. */
41
+ .palette-jump :global(.ui-pill i) {
42
+ font-size: var(--ui-font-size-md);
43
+ }
44
+ </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
  />