@pixerate/editor-svelte 0.104.0 → 0.106.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/README.md CHANGED
@@ -82,11 +82,13 @@ Tables are on by default in `createRichTextPreset` (`table: false` turns them of
82
82
  | `@pixerate/editor-svelte/image-editor` | `ImageEditor`, `createReactiveImageEditor`: crop, rotate/flip, adjustments, annotations, depth masks, Select & Mask (`SelectionOptionsBar`, `RefineEdgePanel`: magic wand, marquees, lasso, Refine Edge, layer masks), and `onInpaint` / `onRemoveBackground` AI hooks |
83
83
  | `@pixerate/editor-svelte/paint-editor` | `PaintEditor`, `createReactivePaintEditor`: natural-media painting with `PaintCanvas` (pen pressure, tilt and twist, coalesced and predicted events, palm rejection, view rotation and mirroring), `PaintToolbar` (tools, symmetry, perspective, tracing and reference images, save / open, PNG / JPEG / PSD), `BrushPanel` (library with thumbnails), `PaperPanel`, `ColorPanel` (wheel, harmonies, swatches, mixing pad) and `PaintLayersPanel` |
84
84
  | `@pixerate/editor-svelte/commands` | `CommandPalette`, `createCommandPalette` (`hotkey: "Mod+K"`; call `destroy()` when done), `createCommandShortcuts`, `useRegisterCommands` over a `CommandRegistry` from `@pixerate/editor/commands` (also re-exported from the root entry) |
85
- | `@pixerate/editor-svelte/persistence` | `createAutosave` (crash-recovery snapshots bound to a history / dirty flag, flushed on `pagehide`; call `destroy()` when done) and `<RecoveryPrompt>` (also re-exported from the root entry) |
85
+ | `@pixerate/editor-svelte/persistence` | `createAutosave` (crash-recovery snapshots bound to a history / dirty flag, flushed on `pagehide`; call `destroy()` when done), `createEditorAutosave` (the same for any editor's `EditorDocument`, e.g. `createMermaidDiagram().editorDocument`; `restore` loads the snapshot into the editor) and `<RecoveryPrompt>` (also re-exported from the root entry) |
86
86
  | `@pixerate/editor-svelte/generation` | `GenerationSettings` (controls generated from a model's capabilities, reference slots by role, plan adjustments; `onchange` / `onaddreference`), `createGenerationPlan(() => request, () => caps)`, `JobProgress`, `createJobProgress` (call `destroy()` when done) over `@pixerate/editor/generation` (also re-exported from the root entry) |
87
87
  | `@pixerate/editor-svelte/vector` | `VectorEditor` (toolbar, canvas and style panel), `VectorCanvas` (SVG canvas forwarding pointer, wheel, keyboard and clipboard events), `VectorToolbar`, `VectorStylePanel`, `VectorSceneNodes`, `VectorGradientDefs`, `createVectorEditor` (call `destroy()` when done) and `useVectorCommands` over `@pixerate/editor/vector` (see [the vector editor guide](../../docs/vector-editor.md)) |
88
88
  | `@pixerate/editor-svelte/chart` | `ChartEditor` (toolbar, chart, inspector and data grid), `ChartCanvas` (SVG chart with selection, drag-to-edit and keyboard editing), `ChartDataGrid`, `ChartInspector`, `createChartEditor` (call `destroy()` when done) and `useChartCommands` over `@pixerate/editor/chart` (see [the chart editor guide](../../docs/chart-editor.md)) |
89
89
  | `@pixerate/editor-svelte/parameters` | `ParametersPanel` (a whole schema with groups, presets, undo / reset / randomize and keyframe diamonds), `createParameters` (call `destroy()` when done), `useParameterCommands`, and the field controls `ParameterFieldControl`, `ParameterSlider`, `ParameterToggle`, `ParameterSelect`, `ParameterColor`, `ParameterVector`, `ParameterImage`, `ParameterText`, `KeyframeDiamond` over `@pixerate/editor/parameters` (see [the parameters guide](../../docs/parameters.md)) |
90
+ | `@pixerate/editor-svelte/color` | `ColorPicker` (interactive hex/rgb/hsl/oklch picker with alpha, swatches, eyedropper, recent history), `DualColorPicker` (dual light/dark mode color picker with theme preview and switch), `createColorScheme` / `resolvedColor` (the active scheme from the system, a root attribute or an override) |
91
+ | `@pixerate/editor-svelte/gradient` | `GradientEditor` (interactive stops bar, midpoints, presets, angle dial, type selector, OKLab/OKLCH/sRGB/linear-sRGB interpolation, CSS output), `FillPicker` (controlled solid-or-gradient fill picker), `createGradientController` |
90
92
  | `@pixerate/editor-svelte/canvas` | Runes for `@xyflow/svelte`: `createCanvasGraph`, `createCanvasClipboard`, `createCanvasDocking`, `createCanvasShortcuts`, `createCanvasInteractions`, `createCanvasExplosion`, `createCanvasNodeSync`, `createCanvasMultiDrag`, `createStreamingCanvas`, `AgentPresenceLayer`, `FloatingHorizontalScrollbar`, plus layout helpers |
91
93
 
92
94
  Spreadsheet persistence: `createReactiveSpreadsheet` exposes `document` (and passes it to `onDocumentChange`) with the defined names its formulas use in `document.names`, so saving that document and passing it back as `document` restores workbook, sheet-scoped and LAMBDA names. For several sheets, save `controller.getWorkbook()` instead.
@@ -1,4 +1,5 @@
1
- import { DiagramController, type DiagramActionResult, type DiagramControllerOptions, type DiagramState, type EditOp, type MermaidDocument } from '@pixerate/editor/diagram';
1
+ import { DiagramController, type DiagramActionResult, type DiagramControllerOptions, type DiagramState, type EditOp, type DiagramEditorDocument, type MermaidDocument } from '@pixerate/editor/diagram';
2
+ import type { EditorDocument } from '@pixerate/editor/persistence';
2
3
  import { type DiagramLayout, type DiagramLayoutOptions, type DiagramScene, type DiagramSceneTheme, type MermaidFlowEdge, type MermaidFlowNode } from '@pixerate/editor/canvas';
3
4
  export interface CreateMermaidDiagramOptions extends DiagramControllerOptions {
4
5
  /** Layout spacing and node measuring for new nodes (stored positions always win). */
@@ -16,6 +17,11 @@ type Placed = {
16
17
  };
17
18
  export interface SvelteMermaidDiagram {
18
19
  readonly controller: DiagramController;
20
+ /**
21
+ * The save / load / undo contract for this diagram: pass it to `createEditorAutosave`
22
+ * (`@pixerate/editor-svelte/persistence`) or `createDocumentToolProvider`.
23
+ */
24
+ readonly editorDocument: EditorDocument<DiagramEditorDocument>;
19
25
  /** The controller's state (reactive). */
20
26
  readonly state: DiagramState;
21
27
  readonly document: MermaidDocument;
@@ -1,4 +1,4 @@
1
- import { DiagramController } from '@pixerate/editor/diagram';
1
+ import { DiagramController, createDiagramEditorDocument } from '@pixerate/editor/diagram';
2
2
  import { CSS_VARIABLE_DIAGRAM_SCENE_THEME, DiagramStream, blendDiagramLayout, diagramRenameOp, familySceneFromLayout, easeDiagramTween, mermaidMovesFromCanvas, mermaidToCanvas } from '@pixerate/editor/canvas';
3
3
  /**
4
4
  * Svelte 5 rune: a Mermaid diagram on a Svelte Flow canvas. Subscribes to a
@@ -19,8 +19,10 @@ view = {}) {
19
19
  const unsubscribe = controller.subscribe((next) => {
20
20
  state = next;
21
21
  });
22
+ const editorDocument = createDiagramEditorDocument(controller);
22
23
  return {
23
24
  controller,
25
+ editorDocument,
24
26
  get state() {
25
27
  return state;
26
28
  },
@@ -68,7 +70,10 @@ view = {}) {
68
70
  select: (ids) => controller.select(ids),
69
71
  undo: () => void controller.undo(),
70
72
  redo: () => void controller.redo(),
71
- destroy: unsubscribe
73
+ destroy: () => {
74
+ unsubscribe();
75
+ editorDocument.destroy();
76
+ }
72
77
  };
73
78
  }
74
79
  /**
@@ -0,0 +1,546 @@
1
+ <script lang="ts">
2
+ import { onDestroy, untrack } from 'svelte';
3
+ import {
4
+ type ColorPickerState,
5
+ contrastRatio,
6
+ formatHex,
7
+ inSrgbGamut,
8
+ parseColor,
9
+ pickerAlphaBackground,
10
+ pickerAreaBackground,
11
+ pickerKeyStep,
12
+ pickerStateFromAlpha,
13
+ pickerStateFromArea,
14
+ pickerStateFromColor,
15
+ pickerStateFromHue,
16
+ pickerStateToHex,
17
+ pushRecentColor,
18
+ PICKER_HUE_BACKGROUND
19
+ } from '@pixerate/editor/color';
20
+
21
+ interface Props {
22
+ /** The current colour value (any valid CSS colour string or hex). */
23
+ value: string;
24
+ /** Called when the colour value changes. */
25
+ onchange?: (value: string) => void;
26
+ onChange?: (value: string) => void;
27
+ /** Called when a continuous pointer drag or keyboard interaction starts. */
28
+ ongesturestart?: () => void;
29
+ onGestureStart?: () => void;
30
+ /** Called when a continuous pointer drag or keyboard interaction ends. */
31
+ ongestureend?: () => void;
32
+ onGestureEnd?: () => void;
33
+ /** Whether the alpha channel slider is shown and alpha hex values emitted. Default: false. */
34
+ alpha?: boolean;
35
+ /** Palette swatches shown beneath the sliders. */
36
+ swatches?: readonly string[];
37
+ /** Controlled recent colours. If omitted, internal recents persisted in localStorage are used. */
38
+ recent?: readonly string[];
39
+ /** Called when the list of recent colours changes. */
40
+ onrecentchange?: (recent: readonly string[]) => void;
41
+ onRecentChange?: (recent: readonly string[]) => void;
42
+ /** If provided, renders live WCAG contrast ratio badges against this background colour. */
43
+ contrastAgainst?: string;
44
+ /** Whether the picker is disabled. */
45
+ disabled?: boolean;
46
+ /** Additional CSS class names for the outer container. */
47
+ class?: string;
48
+ className?: string;
49
+ /** Optional DOM id for the container. */
50
+ id?: string;
51
+ /** Accessible label for the colour picker group. */
52
+ 'aria-label'?: string;
53
+ }
54
+
55
+ let {
56
+ value,
57
+ onchange,
58
+ onChange,
59
+ ongesturestart,
60
+ onGestureStart,
61
+ ongestureend,
62
+ onGestureEnd,
63
+ alpha = false,
64
+ swatches,
65
+ recent,
66
+ onrecentchange,
67
+ onRecentChange,
68
+ contrastAgainst,
69
+ disabled = false,
70
+ class: classProp,
71
+ className,
72
+ id,
73
+ 'aria-label': ariaLabel = 'Color picker'
74
+ }: Props = $props();
75
+
76
+ const LOCAL_STORAGE_KEY = 'pixerate:color:recent';
77
+
78
+ function readLocalRecents(): string[] {
79
+ try {
80
+ if (typeof localStorage === 'undefined') return [];
81
+ const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
82
+ if (!raw) return [];
83
+ const parsed = JSON.parse(raw);
84
+ return Array.isArray(parsed) ? parsed : [];
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
89
+
90
+ function writeLocalRecents(list: readonly string[]): void {
91
+ try {
92
+ if (typeof localStorage !== 'undefined') {
93
+ localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(list));
94
+ }
95
+ } catch {}
96
+ }
97
+
98
+ /** Transparency checkerboard; themeable through `--pixerate-checker-a` / `--pixerate-checker-b`. */
99
+ const CHECKERBOARD_BG =
100
+ 'repeating-conic-gradient(var(--pixerate-checker-a, #d4d4d8) 0 25%, var(--pixerate-checker-b, #ffffff) 0 50%)';
101
+
102
+ const DEFAULT_STATE: ColorPickerState = { h: 0, s: 1, v: 1, alpha: 1 };
103
+
104
+ let pickerState = $state<ColorPickerState>(
105
+ untrack(() => pickerStateFromColor(value)) ?? DEFAULT_STATE
106
+ );
107
+
108
+ // Synchronise pickerState when external value changes
109
+ $effect(() => {
110
+ const prev = untrack(() => pickerState);
111
+ const next = pickerStateFromColor(value, prev);
112
+ if (next) {
113
+ pickerState = next;
114
+ }
115
+ });
116
+
117
+ // Callbacks dispatcher
118
+ function emitChange(nextVal: string) {
119
+ onchange?.(nextVal);
120
+ onChange?.(nextVal);
121
+ }
122
+
123
+ function emitGestureStart() {
124
+ ongesturestart?.();
125
+ onGestureStart?.();
126
+ }
127
+
128
+ function emitGestureEnd() {
129
+ ongestureend?.();
130
+ onGestureEnd?.();
131
+ }
132
+
133
+ function emitRecentChange(recents: readonly string[]) {
134
+ onrecentchange?.(recents);
135
+ onRecentChange?.(recents);
136
+ }
137
+
138
+ // Recents management
139
+ let internalRecent = $state<string[]>(readLocalRecents());
140
+ const activeRecent = $derived(recent ?? internalRecent);
141
+
142
+ function recordRecent(color: string) {
143
+ const updated = pushRecentColor(activeRecent, color, 12);
144
+ if (!recent) {
145
+ internalRecent = updated;
146
+ writeLocalRecents(updated);
147
+ }
148
+ emitRecentChange(updated);
149
+ }
150
+
151
+ // Derived styling & formatting
152
+ const currentHex = $derived(pickerStateToHex(pickerState, alpha));
153
+ const areaBg = $derived(pickerAreaBackground(pickerState));
154
+ const alphaBg = $derived(pickerAlphaBackground(pickerState));
155
+
156
+ // Contrast derived
157
+ const contrastResult = $derived.by(() => {
158
+ if (!contrastAgainst) return null;
159
+ const fg = parseColor(value);
160
+ const bg = parseColor(contrastAgainst);
161
+ if (!fg || !bg) return null;
162
+ const ratio = contrastRatio(fg, bg);
163
+ return {
164
+ ratio,
165
+ aa: ratio >= 4.5,
166
+ aaa: ratio >= 7
167
+ };
168
+ });
169
+
170
+ const liveDescription = $derived(
171
+ `Selected color: ${value}${
172
+ contrastResult
173
+ ? `, contrast ${contrastResult.ratio.toFixed(1)}:1 against ${contrastAgainst}, ${
174
+ contrastResult.aa ? 'passes AA' : 'fails AA'
175
+ }`
176
+ : ''
177
+ }`
178
+ );
179
+
180
+ // Text draft state
181
+ let textDraft = $state(untrack(() => value));
182
+ let isInvalid = $state(false);
183
+ let isClipped = $state(false);
184
+ let isTyping = false;
185
+
186
+ $effect(() => {
187
+ if (!isTyping) {
188
+ textDraft = value;
189
+ isInvalid = false;
190
+ const parsed = parseColor(value);
191
+ isClipped = Boolean(parsed && !inSrgbGamut(parsed));
192
+ }
193
+ });
194
+
195
+ function commitTextInput(target?: HTMLInputElement) {
196
+ isTyping = false;
197
+ const parsed = parseColor(textDraft);
198
+ if (parsed) {
199
+ isInvalid = false;
200
+ isClipped = !inSrgbGamut(parsed);
201
+ const formatted = formatHex(parsed);
202
+ emitChange(formatted);
203
+ recordRecent(formatted);
204
+ } else {
205
+ isInvalid = true;
206
+ textDraft = value;
207
+ if (target) {
208
+ target.value = value;
209
+ }
210
+ }
211
+ }
212
+
213
+ // EyeDropper API check
214
+ let hasEyeDropper = $state(false);
215
+ $effect(() => {
216
+ hasEyeDropper = typeof window !== 'undefined' && 'EyeDropper' in window;
217
+ });
218
+
219
+ async function handleEyeDropper() {
220
+ if (disabled || !hasEyeDropper) return;
221
+ try {
222
+ const EyeDropperClass = (window as unknown as { EyeDropper: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper;
223
+ const eyeDropper = new EyeDropperClass();
224
+ const res = await eyeDropper.open();
225
+ if (res?.sRGBHex) {
226
+ emitChange(res.sRGBHex);
227
+ recordRecent(res.sRGBHex);
228
+ }
229
+ } catch {
230
+ // User cancelled
231
+ }
232
+ }
233
+
234
+ // Pointer drags: listeners live on window and are removed on pointerup, or on unmount,
235
+ // which also ends the host's gesture so it never stays open.
236
+ let endDrag: (() => void) | null = null;
237
+ onDestroy(() => endDrag?.());
238
+
239
+ function startDrag(
240
+ e: PointerEvent,
241
+ next: (state: ColorPickerState, x: number, y: number) => ColorPickerState
242
+ ) {
243
+ if (disabled) return;
244
+ e.preventDefault();
245
+ endDrag?.();
246
+ emitGestureStart();
247
+
248
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
249
+ const update = (clientX: number, clientY: number) => {
250
+ const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
251
+ const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
252
+ pickerState = next(pickerState, x, y);
253
+ emitChange(pickerStateToHex(pickerState, alpha));
254
+ };
255
+ update(e.clientX, e.clientY);
256
+
257
+ const onPointerMove = (ev: PointerEvent) => update(ev.clientX, ev.clientY);
258
+ const end = () => {
259
+ window.removeEventListener('pointermove', onPointerMove);
260
+ window.removeEventListener('pointerup', onPointerUp);
261
+ window.removeEventListener('pointercancel', onPointerUp);
262
+ endDrag = null;
263
+ emitGestureEnd();
264
+ };
265
+ const onPointerUp = () => {
266
+ end();
267
+ recordRecent(pickerStateToHex(pickerState, alpha));
268
+ };
269
+ endDrag = end;
270
+ window.addEventListener('pointermove', onPointerMove);
271
+ window.addEventListener('pointerup', onPointerUp);
272
+ window.addEventListener('pointercancel', onPointerUp);
273
+ }
274
+
275
+ const handleAreaPointerDown = (e: PointerEvent) =>
276
+ startDrag(e, (state, x, y) => pickerStateFromArea(state, x, y));
277
+ const handleHuePointerDown = (e: PointerEvent) => startDrag(e, (state, x) => pickerStateFromHue(state, x));
278
+ const handleAlphaPointerDown = (e: PointerEvent) =>
279
+ startDrag(e, (state, x) => pickerStateFromAlpha(state, x));
280
+
281
+ // Keyboard navigation
282
+ function handleAreaKeyDown(e: KeyboardEvent) {
283
+ if (disabled) return;
284
+ const next = pickerKeyStep(pickerState, 'area', e.key, e.shiftKey);
285
+ if (next) {
286
+ e.preventDefault();
287
+ pickerState = next;
288
+ emitChange(pickerStateToHex(next, alpha));
289
+ recordRecent(pickerStateToHex(next, alpha));
290
+ }
291
+ }
292
+
293
+ function handleHueKeyDown(e: KeyboardEvent) {
294
+ if (disabled) return;
295
+ const next = pickerKeyStep(pickerState, 'hue', e.key, e.shiftKey);
296
+ if (next) {
297
+ e.preventDefault();
298
+ pickerState = next;
299
+ emitChange(pickerStateToHex(next, alpha));
300
+ recordRecent(pickerStateToHex(next, alpha));
301
+ }
302
+ }
303
+
304
+ function handleAlphaKeyDown(e: KeyboardEvent) {
305
+ if (disabled) return;
306
+ const next = pickerKeyStep(pickerState, 'alpha', e.key, e.shiftKey);
307
+ if (next) {
308
+ e.preventDefault();
309
+ pickerState = next;
310
+ emitChange(pickerStateToHex(next, alpha));
311
+ recordRecent(pickerStateToHex(next, alpha));
312
+ }
313
+ }
314
+
315
+ const computedClass = $derived(classProp || className || '');
316
+ </script>
317
+
318
+ <div
319
+ {id}
320
+ role="group"
321
+ aria-label={ariaLabel}
322
+ class={computedClass}
323
+ style="box-sizing: border-box; width: var(--pixerate-color-picker-width, 240px); border-radius: var(--pixerate-color-picker-radius, 8px); background: var(--pixerate-color-picker-bg, #1f2430); color: var(--pixerate-color-picker-fg, #f0f2f5); padding: 12px; display: flex; flex-direction: column; gap: 10px; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; user-select: none; opacity: {disabled ? 0.6 : 1}; pointer-events: {disabled ? 'none' : 'auto'};"
324
+ >
325
+ <!-- Live region for accessibility announcements -->
326
+ <span
327
+ role="status"
328
+ aria-live="polite"
329
+ style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;"
330
+ >
331
+ {liveDescription}
332
+ </span>
333
+
334
+ <!-- 2D Area: Saturation and Brightness -->
335
+ <div
336
+ role="slider"
337
+ tabindex={disabled ? -1 : 0}
338
+ aria-label="Saturation and brightness"
339
+ aria-valuenow={Math.round(pickerState.v * 100)}
340
+ aria-valuemin={0}
341
+ aria-valuemax={100}
342
+ aria-valuetext="Saturation {Math.round(pickerState.s * 100)}%, Brightness {Math.round(pickerState.v * 100)}%"
343
+ aria-disabled={disabled}
344
+ onpointerdown={handleAreaPointerDown}
345
+ onkeydown={handleAreaKeyDown}
346
+ style="position: relative; width: 100%; height: 150px; border-radius: 6px; background: {areaBg}; cursor: {disabled ? 'default' : 'crosshair'}; touch-action: none; outline: none;"
347
+ >
348
+ <!-- Thumb -->
349
+ <div
350
+ style="position: absolute; width: var(--pixerate-color-picker-thumb-size, 14px); height: var(--pixerate-color-picker-thumb-size, 14px); border-radius: 50%; transform: translate(-50%, -50%); left: {pickerState.s * 100}%; top: {(1 - pickerState.v) * 100}%; border: 2px solid #ffffff; box-shadow: 0 0 2px 1px rgba(0,0,0,0.6); pointer-events: none; background-color: {currentHex};"
351
+ ></div>
352
+ </div>
353
+
354
+ <!-- Hue Slider -->
355
+ <div
356
+ role="slider"
357
+ tabindex={disabled ? -1 : 0}
358
+ aria-label="Hue"
359
+ aria-valuenow={Math.round(pickerState.h)}
360
+ aria-valuemin={0}
361
+ aria-valuemax={360}
362
+ aria-valuetext="{Math.round(pickerState.h)} degrees"
363
+ aria-disabled={disabled}
364
+ onpointerdown={handleHuePointerDown}
365
+ onkeydown={handleHueKeyDown}
366
+ style="position: relative; width: 100%; height: 12px; border-radius: 6px; background: {PICKER_HUE_BACKGROUND}; cursor: {disabled ? 'default' : 'pointer'}; touch-action: none; outline: none;"
367
+ >
368
+ <div
369
+ style="position: absolute; top: 50%; transform: translate(-50%, -50%); left: {(pickerState.h / 360) * 100}%; width: var(--pixerate-color-picker-thumb-size, 14px); height: var(--pixerate-color-picker-thumb-size, 14px); border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0 0 2px 1px rgba(0,0,0,0.6); pointer-events: none; background-color: hsl({pickerState.h}, 100%, 50%);"
370
+ ></div>
371
+ </div>
372
+
373
+ <!-- Alpha Slider (conditional) -->
374
+ {#if alpha}
375
+ <div
376
+ role="slider"
377
+ tabindex={disabled ? -1 : 0}
378
+ aria-label="Alpha"
379
+ aria-valuenow={Math.round(pickerState.alpha * 100)}
380
+ aria-valuemin={0}
381
+ aria-valuemax={100}
382
+ aria-valuetext="{Math.round(pickerState.alpha * 100)}%"
383
+ aria-disabled={disabled}
384
+ onpointerdown={handleAlphaPointerDown}
385
+ onkeydown={handleAlphaKeyDown}
386
+ style="position: relative; width: 100%; height: 12px; border-radius: 6px; background-image: {CHECKERBOARD_BG}; background-size: 8px 8px; cursor: {disabled ? 'default' : 'pointer'}; touch-action: none; outline: none; overflow: hidden;"
387
+ >
388
+ <div
389
+ style="position: absolute; inset: 0; background: {alphaBg};"
390
+ ></div>
391
+ <div
392
+ style="position: absolute; top: 50%; transform: translate(-50%, -50%); left: {pickerState.alpha * 100}%; width: var(--pixerate-color-picker-thumb-size, 14px); height: var(--pixerate-color-picker-thumb-size, 14px); border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0 0 2px 1px rgba(0,0,0,0.6); pointer-events: none; background-color: {currentHex};"
393
+ ></div>
394
+ </div>
395
+ {/if}
396
+
397
+ <!-- Controls row: Swatch preview, Text Input, EyeDropper, Gamut Warning -->
398
+ <div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
399
+ <!-- Preview swatch with checkerboard -->
400
+ <div
401
+ style="position: relative; width: 28px; height: 28px; border-radius: 4px; background-image: {CHECKERBOARD_BG}; background-size: 8px 8px; overflow: hidden; flex-shrink: 0; border: 1px solid rgba(255,255,255,0.15);"
402
+ >
403
+ <div
404
+ style="width: 100%; height: 100%; background-color: {currentHex};"
405
+ ></div>
406
+ </div>
407
+
408
+ <!-- Text Field -->
409
+ <input
410
+ type="text"
411
+ value={textDraft}
412
+ {disabled}
413
+ aria-label="Color hex or CSS string"
414
+ aria-invalid={isInvalid ? 'true' : undefined}
415
+ oninput={(e) => {
416
+ isTyping = true;
417
+ textDraft = e.currentTarget.value;
418
+ isInvalid = false;
419
+ }}
420
+ onkeydown={(e) => {
421
+ if (e.key === 'Enter') {
422
+ e.preventDefault();
423
+ commitTextInput(e.currentTarget);
424
+ } else if (e.key === 'Escape') {
425
+ e.preventDefault();
426
+ isTyping = false;
427
+ isInvalid = false;
428
+ textDraft = value;
429
+ e.currentTarget.value = value;
430
+ }
431
+ }}
432
+ onblur={(e) => commitTextInput(e.currentTarget)}
433
+ style="flex: 1; min-width: 0; height: 28px; padding: 0 8px; font-size: 12px; font-family: monospace; border-radius: 4px; border: {isInvalid ? '1px solid #ef4444' : '1px solid rgba(255,255,255,0.2)'}; background: rgba(0,0,0,0.2); color: inherit; outline: none;"
434
+ />
435
+
436
+ <!-- EyeDropper button -->
437
+ {#if hasEyeDropper}
438
+ <button
439
+ type="button"
440
+ {disabled}
441
+ aria-label="Pick color from screen"
442
+ title="Pick color from screen"
443
+ onclick={handleEyeDropper}
444
+ style="display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.2); background: transparent; color: inherit; cursor: {disabled ? 'default' : 'pointer'};"
445
+ >
446
+ <svg
447
+ width="14"
448
+ height="14"
449
+ viewBox="0 0 24 24"
450
+ fill="none"
451
+ stroke="currentColor"
452
+ stroke-width="2"
453
+ stroke-linecap="round"
454
+ stroke-linejoin="round"
455
+ >
456
+ <path d="m19 11-8-8-8.5 8.5a2.12 2.12 0 0 0 0 3l2.5 2.5 1.5-1.5" />
457
+ <path d="m5 16-3 3 1 1 3-3" />
458
+ <path d="m14 6 4 4" />
459
+ </svg>
460
+ </button>
461
+ {/if}
462
+ </div>
463
+
464
+ <!-- Gamut clipping warning -->
465
+ {#if isClipped}
466
+ <div
467
+ role="note"
468
+ title="This colour was outside standard sRGB and clipped."
469
+ style="display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: #f59e0b; background: rgba(245, 158, 11, 0.1); padding: 2px 6px; border-radius: 4px; align-self: flex-start;"
470
+ >
471
+ <span>⚠ Clipped to sRGB</span>
472
+ </div>
473
+ {/if}
474
+
475
+ <!-- Contrast badge -->
476
+ {#if contrastResult}
477
+ <div
478
+ style="display: flex; align-items: center; justify-content: space-between; font-size: 11px; padding: 4px 8px; border-radius: 4px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);"
479
+ >
480
+ <span>
481
+ Contrast: <strong>{contrastResult.ratio.toFixed(2)}:1</strong>
482
+ </span>
483
+ <div style="display: flex; gap: 6px;">
484
+ <span
485
+ style="color: {contrastResult.aa ? '#10b981' : '#ef4444'}; font-weight: 600;"
486
+ >
487
+ AA {contrastResult.aa ? '✓' : '✗'}
488
+ </span>
489
+ <span
490
+ style="color: {contrastResult.aaa ? '#10b981' : '#ef4444'}; font-weight: 600;"
491
+ >
492
+ AAA {contrastResult.aaa ? '✓' : '✗'}
493
+ </span>
494
+ </div>
495
+ </div>
496
+ {/if}
497
+
498
+ <!-- Palette Swatches -->
499
+ {#if swatches && swatches.length > 0}
500
+ <div style="display: flex; flex-direction: column; gap: 4px;">
501
+ <span style="font-size: 10px; text-transform: uppercase; opacity: 0.6;">
502
+ Palette
503
+ </span>
504
+ <div style="display: flex; flex-wrap: wrap; gap: 4px;">
505
+ {#each swatches as color, idx (`${color}-${idx}`)}
506
+ <button
507
+ type="button"
508
+ data-testid="color-picker-swatch"
509
+ title={color}
510
+ {disabled}
511
+ aria-label="Color {color}"
512
+ onclick={() => {
513
+ emitChange(color);
514
+ recordRecent(color);
515
+ }}
516
+ style="width: 18px; height: 18px; border-radius: 3px; background-color: {color}; border: {color.toLowerCase() === value.toLowerCase() ? '2px solid #ffffff' : '1px solid rgba(255,255,255,0.2)'}; cursor: {disabled ? 'default' : 'pointer'}; padding: 0; outline: none;"
517
+ ></button>
518
+ {/each}
519
+ </div>
520
+ </div>
521
+ {/if}
522
+
523
+ <!-- Recent Colours -->
524
+ {#if activeRecent.length > 0}
525
+ <div style="display: flex; flex-direction: column; gap: 4px;">
526
+ <span style="font-size: 10px; text-transform: uppercase; opacity: 0.6;">
527
+ Recent
528
+ </span>
529
+ <div style="display: flex; flex-wrap: wrap; gap: 4px;">
530
+ {#each activeRecent as color, idx (`recent-${color}-${idx}`)}
531
+ <button
532
+ type="button"
533
+ data-testid="color-picker-recent"
534
+ {disabled}
535
+ aria-label="Color {color}"
536
+ onclick={() => {
537
+ emitChange(color);
538
+ recordRecent(color);
539
+ }}
540
+ style="width: 18px; height: 18px; border-radius: 3px; background-color: {color}; border: {color.toLowerCase() === value.toLowerCase() ? '2px solid #ffffff' : '1px solid rgba(255,255,255,0.2)'}; cursor: {disabled ? 'default' : 'pointer'}; padding: 0; outline: none;"
541
+ ></button>
542
+ {/each}
543
+ </div>
544
+ </div>
545
+ {/if}
546
+ </div>
@@ -0,0 +1,36 @@
1
+ interface Props {
2
+ /** The current colour value (any valid CSS colour string or hex). */
3
+ value: string;
4
+ /** Called when the colour value changes. */
5
+ onchange?: (value: string) => void;
6
+ onChange?: (value: string) => void;
7
+ /** Called when a continuous pointer drag or keyboard interaction starts. */
8
+ ongesturestart?: () => void;
9
+ onGestureStart?: () => void;
10
+ /** Called when a continuous pointer drag or keyboard interaction ends. */
11
+ ongestureend?: () => void;
12
+ onGestureEnd?: () => void;
13
+ /** Whether the alpha channel slider is shown and alpha hex values emitted. Default: false. */
14
+ alpha?: boolean;
15
+ /** Palette swatches shown beneath the sliders. */
16
+ swatches?: readonly string[];
17
+ /** Controlled recent colours. If omitted, internal recents persisted in localStorage are used. */
18
+ recent?: readonly string[];
19
+ /** Called when the list of recent colours changes. */
20
+ onrecentchange?: (recent: readonly string[]) => void;
21
+ onRecentChange?: (recent: readonly string[]) => void;
22
+ /** If provided, renders live WCAG contrast ratio badges against this background colour. */
23
+ contrastAgainst?: string;
24
+ /** Whether the picker is disabled. */
25
+ disabled?: boolean;
26
+ /** Additional CSS class names for the outer container. */
27
+ class?: string;
28
+ className?: string;
29
+ /** Optional DOM id for the container. */
30
+ id?: string;
31
+ /** Accessible label for the colour picker group. */
32
+ 'aria-label'?: string;
33
+ }
34
+ declare const ColorPicker: import("svelte").Component<Props, {}, "">;
35
+ type ColorPicker = ReturnType<typeof ColorPicker>;
36
+ export default ColorPicker;