@motion-proto/live-tokens 0.40.0 → 0.41.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist-plugin/{chunk-D77VD4Z6.js → chunk-H4TRUINI.js} +102 -13
  3. package/dist-plugin/index.cjs +288 -67
  4. package/dist-plugin/index.js +171 -55
  5. package/dist-plugin/tokensCssMigrations/index.cjs +102 -13
  6. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  7. package/package.json +3 -2
  8. package/src/app/site.css +97 -20
  9. package/src/editor/core/palettes/colorHarmony.ts +228 -0
  10. package/src/editor/core/palettes/contrast.ts +128 -0
  11. package/src/editor/core/palettes/oklch.ts +8 -0
  12. package/src/editor/core/palettes/paletteDerivation.ts +267 -72
  13. package/src/editor/core/palettes/recommendText.ts +216 -0
  14. package/src/editor/core/palettes/solveTextContrast.ts +251 -0
  15. package/src/editor/core/store/editorCore.ts +2 -1
  16. package/src/editor/core/store/editorPersistence.ts +18 -1
  17. package/src/editor/core/store/editorStore.ts +20 -1
  18. package/src/editor/core/store/editorTypes.ts +4 -0
  19. package/src/editor/core/store/editorViewStore.ts +7 -3
  20. package/src/editor/core/themes/migrations/2026-06-05-palette-unification.ts +11 -9
  21. package/src/editor/core/themes/migrations/2026-07-21-palette-oklch-basis.ts +51 -0
  22. package/src/editor/core/themes/migrations/2026-07-24-base-anchor-placement.ts +37 -0
  23. package/src/editor/core/themes/migrations/2026-07-25-background-spot-to-base.ts +40 -0
  24. package/src/editor/core/themes/themeTypes.ts +23 -3
  25. package/src/editor/docs/CodeBlock.svelte +1 -1
  26. package/src/editor/docs/Docs.svelte +30 -13
  27. package/src/editor/pages/EditorShell.svelte +31 -3
  28. package/src/editor/ui/ColorEditPanel.svelte +22 -16
  29. package/src/editor/ui/EditorViewSwitcher.svelte +43 -9
  30. package/src/editor/ui/FontStackEditor.svelte +0 -1
  31. package/src/editor/ui/PaletteEditor.svelte +117 -155
  32. package/src/editor/ui/ProjectFontsSection.svelte +0 -3
  33. package/src/editor/ui/TextStylesSection.svelte +173 -0
  34. package/src/editor/ui/UILineHeightSelector.svelte +6 -5
  35. package/src/editor/ui/UIPaletteSelector.svelte +6 -1
  36. package/src/editor/ui/UITextTransformSelector.svelte +106 -0
  37. package/src/editor/ui/VariablesTab.svelte +23 -10
  38. package/src/editor/ui/colors/ColorReadouts.svelte +123 -0
  39. package/src/editor/ui/colors/ColorStory.svelte +375 -0
  40. package/src/editor/ui/colors/ColorWheel.svelte +740 -0
  41. package/src/editor/ui/colors/ColorsTab.svelte +508 -0
  42. package/src/editor/ui/colors/HarmonyAxesList.svelte +320 -0
  43. package/src/editor/ui/colors/LightnessBar.svelte +261 -0
  44. package/src/editor/ui/colors/PaletteStepStrip.svelte +142 -0
  45. package/src/editor/ui/colors/colorWheelMath.ts +9 -0
  46. package/src/editor/ui/colors/harmony-icons/analogous.svg +13 -0
  47. package/src/editor/ui/colors/harmony-icons/complementary.svg +10 -0
  48. package/src/editor/ui/colors/harmony-icons/compound.svg +15 -0
  49. package/src/editor/ui/colors/harmony-icons/custom.svg +10 -0
  50. package/src/editor/ui/colors/harmony-icons/monochromatic.svg +11 -0
  51. package/src/editor/ui/colors/harmony-icons/shades.svg +9 -0
  52. package/src/editor/ui/colors/harmony-icons/split-complementary.svg +13 -0
  53. package/src/editor/ui/colors/harmony-icons/square.svg +13 -0
  54. package/src/editor/ui/colors/harmony-icons/tetradic.svg +15 -0
  55. package/src/editor/ui/colors/harmony-icons/triadic.svg +13 -0
  56. package/src/editor/ui/colors/harmonyModeIcons.ts +23 -0
  57. package/src/editor/ui/colors/paletteBaseColor.ts +214 -0
  58. package/src/editor/ui/index.ts +0 -1
  59. package/src/editor/ui/palette/OverridesPanel.svelte +6 -3
  60. package/src/editor/ui/palette/PaletteBase.svelte +16 -8
  61. package/src/editor/ui/palette/dockMagnify.ts +22 -0
  62. package/src/editor/ui/palette/paletteEditorState.ts +5 -4
  63. package/src/editor/ui/palette/paletteMath.ts +93 -181
  64. package/src/editor/ui/sections/textStyles.ts +82 -0
  65. package/src/editor/ui/sections/tokenScales.ts +3 -3
  66. package/src/live-tokens/data/themes/default.json +3 -3
  67. package/src/live-tokens/data/tokens.generated.css +96 -95
  68. package/src/system/components/Badge.svelte +11 -11
  69. package/src/system/components/Button.svelte +7 -7
  70. package/src/system/components/Callout.svelte +8 -8
  71. package/src/system/components/Card.svelte +2 -2
  72. package/src/system/components/CodeSnippet.svelte +1 -1
  73. package/src/system/components/CollapsibleSection.svelte +7 -7
  74. package/src/system/components/CornerBadge.svelte +1 -1
  75. package/src/system/components/Dialog.svelte +2 -2
  76. package/src/system/components/FloatingTokenTags.css +6 -5
  77. package/src/system/components/Input.svelte +6 -6
  78. package/src/system/components/MenuSelect.svelte +4 -4
  79. package/src/system/components/Notification.svelte +8 -8
  80. package/src/system/components/ProgressBar.svelte +2 -2
  81. package/src/system/components/RadioButton.svelte +4 -4
  82. package/src/system/components/SectionDivider.svelte +6 -6
  83. package/src/system/components/SegmentedControl.svelte +5 -5
  84. package/src/system/components/SideNavigation.svelte +12 -12
  85. package/src/system/components/TabBar.svelte +4 -4
  86. package/src/system/components/Table.svelte +2 -2
  87. package/src/system/components/Tooltip.svelte +1 -1
  88. package/src/system/styles/tokens.css +59 -6
  89. package/src/editor/ui/TextTab.svelte +0 -203
@@ -0,0 +1,320 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import { flip } from 'svelte/animate';
4
+ import { cubicOut } from 'svelte/easing';
5
+ import { oklchToHexClamped } from '../../core/palettes/oklch';
6
+ import { PALETTE_SPECS, type PaletteSpec } from '../../core/palettes/paletteDerivation';
7
+ import { AXIS_ROLES, modeActiveAxes, type HarmonyMode, HARMONY_ELIGIBLE } from '../../core/palettes/colorHarmony';
8
+ import { editorState } from '../../core/store/editorStore';
9
+ import { bindFamilyToAxis, unbindFamily } from './paletteBaseColor';
10
+
11
+ interface Props {
12
+ /** The applied harmony mode: an unbound slot is disabled when the geometry
13
+ * deals it no distinct position (complementary enables only the first two). */
14
+ activeMode: HarmonyMode;
15
+ }
16
+
17
+ let { activeMode }: Props = $props();
18
+
19
+ const DRAG_TYPE = 'application/x-harmony-family';
20
+ // Unbound swatch previews the hue a dropped color would take, at fixed preview
21
+ // L/C so only the hue reads (Reserved judgment call 4).
22
+ const PREVIEW_L = 0.65;
23
+ const PREVIEW_C = 0.12;
24
+
25
+ const SPEC_BY_LABEL: Record<string, PaletteSpec> = Object.fromEntries(PALETTE_SPECS.map((s) => [s.label, s]));
26
+
27
+ let listEl: HTMLElement | undefined = $state();
28
+
29
+ let axes = $derived($editorState.harmonyAxes);
30
+ let unassigned = $derived(HARMONY_ELIGIBLE.filter((f) => !axes.some((a) => a.family === f)));
31
+ let axisDisabled = $derived(modeActiveAxes(activeMode).map((active, i) => !active && axes[i].family === null));
32
+ // Keyboard sequence: the enabled axes then Unassigned.
33
+ let axisSeq = $derived(axisDisabled.flatMap((disabled, i) => (disabled ? [] : [i])));
34
+ let positionCount = $derived(axisSeq.length + 1);
35
+ let lastAxis = $derived(axisSeq[axisSeq.length - 1]);
36
+
37
+ function familyHex(family: string): string {
38
+ const { l, c, h } = $editorState.palettes[family]?.baseColor ?? SPEC_BY_LABEL[family].initialColor;
39
+ return oklchToHexClamped(l, c, h);
40
+ }
41
+
42
+ // Keep focus on the moved family so keyboard bind/move/unbind chains without
43
+ // hunting for the row it landed in.
44
+ async function refocus(family: string) {
45
+ await tick();
46
+ listEl?.querySelector<HTMLElement>(`[data-family="${family}"]`)?.focus();
47
+ }
48
+
49
+ function moveFamily(family: string, dir: -1 | 1) {
50
+ const idx = axes.findIndex((a) => a.family === family);
51
+ const pos = idx === -1 ? axisSeq.length : axisSeq.indexOf(idx);
52
+ const target = pos + dir;
53
+ if (target < 0 || target > axisSeq.length) return;
54
+ if (target === axisSeq.length) unbindFamily(family);
55
+ else bindFamilyToAxis(family, axisSeq[target]);
56
+ refocus(family);
57
+ }
58
+
59
+ function onChipKeydown(e: KeyboardEvent, family: string) {
60
+ if (e.key === 'ArrowUp') {
61
+ e.preventDefault();
62
+ moveFamily(family, -1);
63
+ } else if (e.key === 'ArrowDown') {
64
+ e.preventDefault();
65
+ moveFamily(family, 1);
66
+ } else if (e.key === 'Delete' || e.key === 'Backspace') {
67
+ e.preventDefault();
68
+ unbindFamily(family);
69
+ refocus(family);
70
+ }
71
+ }
72
+
73
+ let dragTarget = $state<number | 'unassigned' | null>(null);
74
+
75
+ function onDragStart(e: DragEvent, family: string) {
76
+ if (!e.dataTransfer) return;
77
+ e.dataTransfer.effectAllowed = 'move';
78
+ e.dataTransfer.setData(DRAG_TYPE, family);
79
+ }
80
+
81
+ function onDragOver(e: DragEvent, target: number | 'unassigned') {
82
+ if (!(e.dataTransfer?.types ?? []).includes(DRAG_TYPE)) return;
83
+ if (typeof target === 'number' && axisDisabled[target]) return;
84
+ e.preventDefault();
85
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
86
+ dragTarget = target;
87
+ }
88
+
89
+ function onDragLeave(target: number | 'unassigned') {
90
+ if (dragTarget === target) dragTarget = null;
91
+ }
92
+
93
+ function onDrop(e: DragEvent, target: number | 'unassigned') {
94
+ e.preventDefault();
95
+ const family = e.dataTransfer?.getData(DRAG_TYPE);
96
+ dragTarget = null;
97
+ if (!family) return;
98
+ if (typeof target === 'number' && axisDisabled[target]) return;
99
+ // Self-drop is a no-op: the setters early-return when nothing changes.
100
+ if (target === 'unassigned') unbindFamily(family);
101
+ else bindFamilyToAxis(family, target);
102
+ }
103
+
104
+ function onDragEnd() {
105
+ dragTarget = null;
106
+ }
107
+ </script>
108
+
109
+ <div class="axes-list" bind:this={listEl}>
110
+ {#each axes as axis, i (i)}
111
+ {@const disabled = axisDisabled[i]}
112
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
113
+ <div
114
+ class="axis-row"
115
+ class:disabled
116
+ class:drop-target={dragTarget === i}
117
+ aria-disabled={disabled || undefined}
118
+ ondragover={(e) => onDragOver(e, i)}
119
+ ondragleave={() => onDragLeave(i)}
120
+ ondrop={(e) => onDrop(e, i)}
121
+ >
122
+ <span class="role"><span class="role-num">{i + 1}.</span> {AXIS_ROLES[i]}</span>
123
+ {#if axis.family !== null}
124
+ {@const family = axis.family}
125
+ <span class="swatch" style="--fill: {familyHex(family)}"></span>
126
+ <button
127
+ type="button"
128
+ class="chip"
129
+ draggable="true"
130
+ data-family={family}
131
+ aria-label={`${family}, ${AXIS_ROLES[i]} axis, position ${i + 1} of ${positionCount}. Arrow up or down to move it, Delete to unbind.`}
132
+ title="Drag to another axis or to Unassigned. Arrow keys move it, Delete unbinds."
133
+ ondragstart={(e) => onDragStart(e, family)}
134
+ ondragend={onDragEnd}
135
+ onkeydown={(e) => onChipKeydown(e, family)}
136
+ >
137
+ <span class="grip" aria-hidden="true">⋮⋮</span>
138
+ <span class="chip-swatch" style="--fill: {familyHex(family)}"></span>
139
+ <span class="chip-name">{family}</span>
140
+ </button>
141
+ {:else}
142
+ <span class="swatch preview" style="--fill: {oklchToHexClamped(PREVIEW_L, PREVIEW_C, axis.hue)}"></span>
143
+ <span class="empty">Empty</span>
144
+ {/if}
145
+ </div>
146
+ {/each}
147
+
148
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
149
+ <div
150
+ class="unassigned"
151
+ class:drop-target={dragTarget === 'unassigned'}
152
+ ondragover={(e) => onDragOver(e, 'unassigned')}
153
+ ondragleave={() => onDragLeave('unassigned')}
154
+ ondrop={(e) => onDrop(e, 'unassigned')}
155
+ >
156
+ <span class="eyebrow">Unassigned</span>
157
+ <div class="chips">
158
+ {#each unassigned as family (family)}
159
+ <button
160
+ type="button"
161
+ class="chip"
162
+ draggable="true"
163
+ data-family={family}
164
+ aria-label={`${family}, unassigned, position ${positionCount} of ${positionCount}. Arrow up to bind it to ${AXIS_ROLES[lastAxis]}.`}
165
+ title={`Drag onto an axis to bind it. Arrow up binds it to ${AXIS_ROLES[lastAxis]}.`}
166
+ ondragstart={(e) => onDragStart(e, family)}
167
+ ondragend={onDragEnd}
168
+ onkeydown={(e) => onChipKeydown(e, family)}
169
+ animate:flip={{ duration: 200, easing: cubicOut }}
170
+ >
171
+ <span class="grip" aria-hidden="true">⋮⋮</span>
172
+ <span class="chip-swatch" style="--fill: {familyHex(family)}"></span>
173
+ <span class="chip-name">{family}</span>
174
+ </button>
175
+ {/each}
176
+ {#if unassigned.length === 0}
177
+ <span class="empty">Every color is bound to an axis.</span>
178
+ {/if}
179
+ </div>
180
+ </div>
181
+ </div>
182
+
183
+ <style>
184
+ .axes-list {
185
+ display: flex;
186
+ flex-direction: column;
187
+ gap: var(--ui-space-6);
188
+ }
189
+
190
+ .axis-row {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: var(--ui-space-10);
194
+ padding: var(--ui-space-6) var(--ui-space-8);
195
+ background: var(--ui-surface-lowest);
196
+ border: 1px solid var(--ui-border-low);
197
+ border-radius: var(--ui-radius-md);
198
+ font-size: var(--ui-font-size-sm);
199
+ transition:
200
+ border-color var(--ui-transition-fast),
201
+ background var(--ui-transition-fast);
202
+ }
203
+
204
+ .axis-row.drop-target,
205
+ .unassigned.drop-target {
206
+ border-color: var(--ui-border-higher);
207
+ background: var(--ui-surface-low);
208
+ }
209
+
210
+ .role {
211
+ flex: none;
212
+ width: 6.5rem;
213
+ color: var(--ui-text-secondary);
214
+ }
215
+
216
+ .role-num {
217
+ color: var(--ui-text-muted);
218
+ font-variant-numeric: tabular-nums;
219
+ }
220
+
221
+ /* Disabled slot: the applied geometry deals it no distinct position. The Empty
222
+ label stays visible; shape + dimming carry the state. */
223
+ .axis-row.disabled {
224
+ border-style: dashed;
225
+ opacity: 0.45;
226
+ }
227
+
228
+ .swatch {
229
+ flex: none;
230
+ width: 0.375rem;
231
+ height: 1.5rem;
232
+ border-radius: var(--ui-radius-sm);
233
+ background: var(--fill);
234
+ border: 1px solid var(--ui-border-low);
235
+ }
236
+
237
+ .swatch.preview {
238
+ border-style: dashed;
239
+ opacity: 0.7;
240
+ }
241
+
242
+ .empty {
243
+ color: var(--ui-text-tertiary);
244
+ font-size: var(--ui-font-size-sm);
245
+ }
246
+
247
+ .grip {
248
+ flex: none;
249
+ color: var(--ui-text-muted);
250
+ font-size: var(--ui-font-size-xs);
251
+ line-height: 1;
252
+ letter-spacing: -0.1em;
253
+ user-select: none;
254
+ }
255
+
256
+ .chip {
257
+ display: inline-flex;
258
+ align-items: center;
259
+ gap: var(--ui-space-6);
260
+ padding: var(--ui-space-4) var(--ui-space-8);
261
+ background: var(--ui-surface-low);
262
+ border: 1px solid var(--ui-border-low);
263
+ border-radius: var(--ui-radius-md);
264
+ color: var(--ui-text-primary);
265
+ font-size: var(--ui-font-size-sm);
266
+ line-height: 1;
267
+ cursor: grab;
268
+ user-select: none;
269
+ }
270
+
271
+ .chip:hover {
272
+ border-color: var(--ui-border-high);
273
+ }
274
+
275
+ .chip:focus-visible {
276
+ outline: 2px solid var(--ui-border-higher);
277
+ outline-offset: 2px;
278
+ }
279
+
280
+ .chip:active {
281
+ cursor: grabbing;
282
+ }
283
+
284
+ .chip-swatch {
285
+ flex: none;
286
+ width: 0.85rem;
287
+ height: 0.85rem;
288
+ border-radius: var(--ui-radius-sm);
289
+ background: var(--fill);
290
+ border: 1px solid var(--ui-border-low);
291
+ }
292
+
293
+ .unassigned {
294
+ display: flex;
295
+ flex-direction: column;
296
+ gap: var(--ui-space-8);
297
+ margin-top: var(--ui-space-4);
298
+ padding: var(--ui-space-8);
299
+ border: 1px dashed var(--ui-border-low);
300
+ border-radius: var(--ui-radius-md);
301
+ transition:
302
+ border-color var(--ui-transition-fast),
303
+ background var(--ui-transition-fast);
304
+ }
305
+
306
+ .eyebrow {
307
+ font-size: var(--ui-font-size-xs);
308
+ color: var(--ui-text-muted);
309
+ text-transform: uppercase;
310
+ letter-spacing: 0.04em;
311
+ }
312
+
313
+ .chips {
314
+ display: flex;
315
+ flex-wrap: wrap;
316
+ gap: var(--ui-space-6);
317
+ min-height: 1.75rem;
318
+ align-items: center;
319
+ }
320
+ </style>
@@ -0,0 +1,261 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import { oklchToHex, gamutClamp } from '../../core/palettes/oklch';
4
+ import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
5
+ import { editorState, beginScope, commitScope, cancelScope, type Scope } from '../../core/store/editorStore';
6
+ import { maxChroma } from './colorWheelMath';
7
+ import { setBaseLightnessChroma } from './paletteBaseColor';
8
+
9
+ interface Props {
10
+ selected: string | null;
11
+ /** Shared with the wheel: off preserves relative saturation as L moves
12
+ * (wheel dot holds radius); on preserves absolute chroma (dot drifts,
13
+ * desaturates at the L extremes where that chroma leaves gamut). */
14
+ absoluteChroma: boolean;
15
+ }
16
+
17
+ let { selected, absoluteChroma }: Props = $props();
18
+
19
+ const L_STEP = 0.02;
20
+ const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
21
+
22
+ let track: HTMLDivElement | undefined = $state();
23
+ let canvas: HTMLCanvasElement | undefined = $state();
24
+ let trackWidth = $state(300);
25
+
26
+ // The store holds unclamped OKLCH intent, so the thumb, strip, and every write
27
+ // read straight from it — no gesture-start pinning. A lossless write means the
28
+ // re-derived value doesn't twitch, which is what let the old workaround go.
29
+ let selectedColor = $derived.by(() => {
30
+ const spec = PALETTE_SPECS.find((s) => s.label === selected) ?? PALETTE_SPECS[0];
31
+ return (selected != null && $editorState.palettes[selected]?.baseColor) || spec.initialColor;
32
+ });
33
+
34
+ let dragScope: Scope | null = null;
35
+
36
+ const rFracOf = (l: number, c: number, h: number) => clamp(c / (maxChroma(l, h) || 1e-6), 0, 1);
37
+
38
+ let barHue = $derived(selectedColor.h);
39
+ let barChroma0 = $derived(selectedColor.c);
40
+ let barRFrac0 = $derived(rFracOf(selectedColor.l, selectedColor.c, selectedColor.h));
41
+ let renderL = $derived(selectedColor.l);
42
+
43
+ $effect(() => {
44
+ const el = track;
45
+ if (!el || typeof ResizeObserver === 'undefined') return;
46
+ const ro = new ResizeObserver((entries) => {
47
+ const w = entries[0]?.contentRect.width ?? 0;
48
+ if (w > 0) trackWidth = Math.round(w);
49
+ });
50
+ ro.observe(el);
51
+ return () => ro.disconnect();
52
+ });
53
+
54
+ // L 0→1 swept at the store hue, gamut-clamped per column for painting.
55
+ $effect(() => {
56
+ const cv = canvas;
57
+ if (!cv) return;
58
+ const hue = barHue;
59
+ const mode = absoluteChroma;
60
+ const chroma0 = barChroma0;
61
+ const rFrac0 = barRFrac0;
62
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
63
+ const px = Math.max(1, Math.round(trackWidth * dpr));
64
+ cv.width = px;
65
+ cv.height = 1;
66
+ const ctx = cv.getContext('2d');
67
+ if (ctx) renderStrip(ctx, px, hue, mode, chroma0, rFrac0);
68
+ });
69
+
70
+ // L 0→1 swept left→right at the fixed hue, gamut-clamped per column. Relative:
71
+ // per-column chroma = rFrac0·maxChroma(L, hue) (constant colorfulness fraction).
72
+ // Absolute: chroma0 held, clamped inward where it leaves gamut at the extremes.
73
+ function renderStrip(
74
+ ctx: CanvasRenderingContext2D,
75
+ px: number,
76
+ hue: number,
77
+ mode: boolean,
78
+ chroma0: number,
79
+ rFrac0: number,
80
+ ) {
81
+ const img = ctx.createImageData(px, 1);
82
+ const data = img.data;
83
+ for (let x = 0; x < px; x++) {
84
+ const L = px === 1 ? 0.5 : x / (px - 1);
85
+ const chroma = mode ? chroma0 : rFrac0 * maxChroma(L, hue);
86
+ const g = gamutClamp(L, chroma, hue);
87
+ const hex = oklchToHex(g.l, g.c, g.h);
88
+ const i = x * 4;
89
+ data[i] = parseInt(hex.slice(1, 3), 16);
90
+ data[i + 1] = parseInt(hex.slice(3, 5), 16);
91
+ data[i + 2] = parseInt(hex.slice(5, 7), 16);
92
+ data[i + 3] = 255;
93
+ }
94
+ ctx.putImageData(img, 0, 0);
95
+ }
96
+
97
+ function lFromEvent(e: PointerEvent): number {
98
+ const r = track!.getBoundingClientRect();
99
+ return clamp((e.clientX - r.left) / r.width, 0, 1);
100
+ }
101
+
102
+ function startDrag(e: PointerEvent) {
103
+ if (e.button !== 0 || selected == null) return;
104
+ e.preventDefault();
105
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
106
+ dragScope = beginScope({ label: `colors: ${selected} lightness`, collapseToOne: true, clipUndoFloor: true });
107
+ window.addEventListener('keydown', onGestureKey, true);
108
+ applyDrag(e);
109
+ }
110
+
111
+ function applyDrag(e: PointerEvent) {
112
+ if (!dragScope || selected == null) return;
113
+ const L = lFromEvent(e);
114
+ const sc = selectedColor;
115
+ const chroma = absoluteChroma ? sc.c : rFracOf(sc.l, sc.c, sc.h) * maxChroma(L, sc.h);
116
+ setBaseLightnessChroma(selected, L, chroma);
117
+ }
118
+
119
+ function endDrag() {
120
+ if (!dragScope) return;
121
+ commitScope(dragScope);
122
+ dragScope = null;
123
+ window.removeEventListener('keydown', onGestureKey, true);
124
+ }
125
+
126
+ function onGestureKey(e: KeyboardEvent) {
127
+ if (e.key !== 'Escape' || !dragScope) return;
128
+ e.preventDefault();
129
+ cancelScope(dragScope);
130
+ dragScope = null;
131
+ window.removeEventListener('keydown', onGestureKey, true);
132
+ }
133
+
134
+ // Discrete keyboard nudge — one store mutation = one undo (no scope).
135
+ function thumbKey(e: KeyboardEvent) {
136
+ if (selected == null) return;
137
+ let dir = 0;
138
+ if (e.key === 'ArrowLeft') dir = -1;
139
+ else if (e.key === 'ArrowRight') dir = 1;
140
+ else return;
141
+ e.preventDefault();
142
+ const sc = selectedColor;
143
+ const newL = clamp(sc.l + dir * L_STEP, 0, 1);
144
+ const chroma = absoluteChroma ? sc.c : rFracOf(sc.l, sc.c, sc.h) * maxChroma(newL, sc.h);
145
+ setBaseLightnessChroma(selected, newL, chroma);
146
+ }
147
+
148
+ onDestroy(() => {
149
+ if (dragScope) {
150
+ cancelScope(dragScope);
151
+ dragScope = null;
152
+ }
153
+ if (typeof window !== 'undefined') window.removeEventListener('keydown', onGestureKey, true);
154
+ });
155
+ </script>
156
+
157
+ <div class="lightness">
158
+ <div class="lightness-head">
159
+ <span class="lightness-label">Lightness</span>
160
+ <span class="lightness-value">{Math.round(renderL * 100)}%</span>
161
+ </div>
162
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
163
+ <div
164
+ class="lbar"
165
+ bind:this={track}
166
+ onpointerdown={startDrag}
167
+ onpointermove={applyDrag}
168
+ onpointerup={endDrag}
169
+ onpointercancel={endDrag}
170
+ onlostpointercapture={endDrag}
171
+ >
172
+ <canvas class="lbar-strip" bind:this={canvas} aria-hidden="true"></canvas>
173
+ <button
174
+ type="button"
175
+ class="lbar-thumb"
176
+ style="left: {renderL * 100}%"
177
+ role="slider"
178
+ aria-label={`Lightness${selected ? ` — ${selected}` : ''}`}
179
+ aria-valuemin="0"
180
+ aria-valuemax="100"
181
+ aria-valuenow={Math.round(renderL * 100)}
182
+ title="Drag to set lightness"
183
+ onkeydown={thumbKey}
184
+ ></button>
185
+ </div>
186
+ </div>
187
+
188
+ <style>
189
+ .lightness {
190
+ display: flex;
191
+ flex-direction: column;
192
+ gap: var(--ui-space-6);
193
+ }
194
+
195
+ .lightness-head {
196
+ display: flex;
197
+ align-items: baseline;
198
+ justify-content: space-between;
199
+ }
200
+
201
+ .lightness-label {
202
+ font-size: var(--ui-font-size-xs);
203
+ font-weight: var(--ui-font-weight-semibold);
204
+ color: var(--ui-text-tertiary);
205
+ text-transform: uppercase;
206
+ letter-spacing: 0.04em;
207
+ }
208
+
209
+ .lightness-value {
210
+ font-size: var(--ui-font-size-sm);
211
+ font-family: var(--ui-font-mono);
212
+ color: var(--ui-text-secondary);
213
+ }
214
+
215
+ .lbar {
216
+ position: relative;
217
+ width: 100%;
218
+ height: 1.75rem;
219
+ border-radius: var(--ui-radius-md);
220
+ border: 1px solid var(--ui-border-low);
221
+ touch-action: none;
222
+ cursor: pointer;
223
+ }
224
+
225
+ /* The only element carrying real colour — the swept-L gradient. Rounded to
226
+ the frame instead of clipping the parent, so the taller thumb can overhang
227
+ the bar at the L extremes without being cut off. */
228
+ .lbar-strip {
229
+ position: absolute;
230
+ inset: 0;
231
+ width: 100%;
232
+ height: 100%;
233
+ display: block;
234
+ border-radius: var(--ui-radius-md);
235
+ }
236
+
237
+ /* Greyscale thumb chrome — a stem slightly taller than the bar. */
238
+ .lbar-thumb {
239
+ position: absolute;
240
+ top: 50%;
241
+ transform: translate(-50%, -50%);
242
+ width: 0.6rem;
243
+ height: calc(100% + 0.5rem);
244
+ padding: 0;
245
+ border-radius: var(--ui-radius-sm);
246
+ background: var(--ui-surface-low);
247
+ border: 2px solid var(--ui-text-primary);
248
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5);
249
+ cursor: grab;
250
+ touch-action: none;
251
+ }
252
+
253
+ .lbar-thumb:active {
254
+ cursor: grabbing;
255
+ }
256
+
257
+ .lbar-thumb:focus-visible {
258
+ outline: 2px solid var(--ui-border-higher);
259
+ outline-offset: 2px;
260
+ }
261
+ </style>