@pixerate/editor-svelte 0.105.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.
@@ -0,0 +1,296 @@
1
+ <script lang="ts">
2
+ import ColorPicker from './ColorPicker.svelte';
3
+ import {
4
+ type ColorScheme,
5
+ type ColorValue,
6
+ type DualColor,
7
+ DEFAULT_SCHEME_SURFACES,
8
+ normalizeDualColor,
9
+ resolveColor,
10
+ setDualColorSide,
11
+ deriveDualColor,
12
+ checkDualColorContrast,
13
+ isSameInBothSchemes
14
+ } from '@pixerate/editor/color';
15
+
16
+ interface Props {
17
+ /** Dual colour value (CSS string, light-dark(), or { light, dark }) */
18
+ value?: ColorValue;
19
+ /** Called when the dual colour value changes */
20
+ onchange?: (value: DualColor) => void;
21
+ onChange?: (value: DualColor) => void;
22
+ /** Controlled active scheme mode ('light' | 'dark') */
23
+ scheme?: ColorScheme;
24
+ /** Called when the user toggles active scheme tab */
25
+ onschemechange?: (scheme: ColorScheme) => void;
26
+ onSchemeChange?: (scheme: ColorScheme) => void;
27
+ /** Expected background surfaces for derivation and preview */
28
+ surfaces?: DualColor;
29
+ /** Optional background colour to evaluate contrast against in both modes */
30
+ contrastAgainst?: ColorValue;
31
+ /** Whether alpha slider is shown */
32
+ alpha?: boolean;
33
+ /** Preset swatches */
34
+ swatches?: readonly string[];
35
+ /** Whether picker is disabled */
36
+ disabled?: boolean;
37
+ /** Additional CSS class name */
38
+ class?: string;
39
+ className?: string;
40
+ /** Gesture callbacks */
41
+ ongesturestart?: () => void;
42
+ onGestureStart?: () => void;
43
+ ongestureend?: () => void;
44
+ onGestureEnd?: () => void;
45
+ }
46
+
47
+ let {
48
+ value = DEFAULT_SCHEME_SURFACES,
49
+ onchange,
50
+ onChange,
51
+ scheme: controlledScheme,
52
+ onschemechange,
53
+ onSchemeChange,
54
+ surfaces = DEFAULT_SCHEME_SURFACES,
55
+ contrastAgainst,
56
+ alpha = false,
57
+ swatches,
58
+ disabled = false,
59
+ class: classProp,
60
+ className: classNameProp,
61
+ ongesturestart,
62
+ onGestureStart,
63
+ ongestureend,
64
+ onGestureEnd
65
+ }: Props = $props();
66
+
67
+ const className = $derived(classProp || classNameProp || '');
68
+
69
+ const triggerChange = (val: DualColor) => {
70
+ onchange?.(val);
71
+ onChange?.(val);
72
+ };
73
+
74
+ const triggerSchemeChange = (s: ColorScheme) => {
75
+ onschemechange?.(s);
76
+ onSchemeChange?.(s);
77
+ };
78
+
79
+ const triggerGestureStart = () => {
80
+ ongesturestart?.();
81
+ onGestureStart?.();
82
+ };
83
+
84
+ const triggerGestureEnd = () => {
85
+ ongestureend?.();
86
+ onGestureEnd?.();
87
+ };
88
+
89
+ // Internal active scheme state
90
+ let internalScheme = $state<ColorScheme>('light');
91
+ const activeScheme = $derived(controlledScheme ?? internalScheme);
92
+
93
+ // Normalised dual value
94
+ const dual = $derived<DualColor>(
95
+ normalizeDualColor(value) ?? DEFAULT_SCHEME_SURFACES
96
+ );
97
+
98
+ // Same in both mode state
99
+ let sameInBoth = $state<boolean>(false);
100
+ let initializedSame = false;
101
+
102
+ $effect(() => {
103
+ if (!initializedSame) {
104
+ sameInBoth = isSameInBothSchemes(dual);
105
+ initializedSame = true;
106
+ } else if (isSameInBothSchemes(dual)) {
107
+ sameInBoth = true;
108
+ }
109
+ });
110
+
111
+ const handleSchemeSelect = (nextScheme: ColorScheme) => {
112
+ if (disabled) return;
113
+ internalScheme = nextScheme;
114
+ triggerSchemeChange(nextScheme);
115
+ };
116
+
117
+ const handleToggleSameInBoth = (checked: boolean) => {
118
+ if (disabled) return;
119
+ sameInBoth = checked;
120
+ if (checked) {
121
+ const activeColor = activeScheme === 'dark' ? dual.dark : dual.light;
122
+ triggerChange({ light: activeColor, dark: activeColor });
123
+ }
124
+ };
125
+
126
+ const handleDerive = () => {
127
+ if (disabled) return;
128
+ triggerGestureStart();
129
+ const currentActive = activeScheme === 'dark' ? dual.dark : dual.light;
130
+ const derived = deriveDualColor(currentActive, {
131
+ from: activeScheme,
132
+ // An unreadable surfaces prop falls back to the defaults rather than throwing.
133
+ surfaces: normalizeDualColor(surfaces) ?? DEFAULT_SCHEME_SURFACES
134
+ });
135
+ triggerChange(derived);
136
+ triggerGestureEnd();
137
+ };
138
+
139
+ const handleInnerChange = (hex: string) => {
140
+ if (sameInBoth) {
141
+ triggerChange({ light: hex, dark: hex });
142
+ } else {
143
+ const updated = setDualColorSide(dual, activeScheme, hex);
144
+ triggerChange(updated);
145
+ }
146
+ };
147
+
148
+ const activeColor = $derived(activeScheme === 'dark' ? dual.dark : dual.light);
149
+ const activeContrastTarget = $derived(
150
+ contrastAgainst ? resolveColor(contrastAgainst, activeScheme) ?? undefined : undefined
151
+ );
152
+
153
+ const dualContrast = $derived(
154
+ // An unreadable contrastAgainst shows no badges rather than throwing during render.
155
+ contrastAgainst && normalizeDualColor(contrastAgainst)
156
+ ? checkDualColorContrast(dual, contrastAgainst)
157
+ : null
158
+ );
159
+
160
+ const CHECKERBOARD_BG =
161
+ 'linear-gradient(45deg, #2a2e39 25%, transparent 25%), linear-gradient(-45deg, #2a2e39 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #2a2e39 75%), linear-gradient(-45deg, transparent 75%, #2a2e39 75%)';
162
+ </script>
163
+
164
+ <div
165
+ class="pixerate-dual-color-picker {className}"
166
+ style="display: flex; flex-direction: column; gap: 12px; padding: 12px; background: #1a1d24; color: #e2e8f0; border-radius: 10px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); width: 260px; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; box-sizing: border-box;"
167
+ >
168
+ <!-- Segmented Control -->
169
+ <div
170
+ role="tablist"
171
+ aria-label="Color scheme mode"
172
+ style="display: flex; background: #111318; border-radius: 6px; padding: 2px; gap: 2px;"
173
+ >
174
+ <button
175
+ type="button"
176
+ role="tab"
177
+ aria-selected={activeScheme === 'light'}
178
+ onclick={() => handleSchemeSelect('light')}
179
+ {disabled}
180
+ style="flex: 1; padding: 6px 8px; border: none; border-radius: 4px; background: {activeScheme === 'light' ? '#2d3342' : 'transparent'}; color: {activeScheme === 'light' ? '#ffffff' : '#8c95a6'}; font-weight: {activeScheme === 'light' ? 600 : 400}; cursor: {disabled ? 'not-allowed' : 'pointer'}; font-size: 12px; transition: all 0.15s ease;"
181
+ >
182
+ Light
183
+ </button>
184
+ <button
185
+ type="button"
186
+ role="tab"
187
+ aria-selected={activeScheme === 'dark'}
188
+ onclick={() => handleSchemeSelect('dark')}
189
+ {disabled}
190
+ style="flex: 1; padding: 6px 8px; border: none; border-radius: 4px; background: {activeScheme === 'dark' ? '#2d3342' : 'transparent'}; color: {activeScheme === 'dark' ? '#ffffff' : '#8c95a6'}; font-weight: {activeScheme === 'dark' ? 600 : 400}; cursor: {disabled ? 'not-allowed' : 'pointer'}; font-size: 12px; transition: all 0.15s ease;"
191
+ >
192
+ Dark
193
+ </button>
194
+ </div>
195
+
196
+ <!-- Dual Preview Swatches -->
197
+ <div style="display: flex; gap: 8px;">
198
+ <!-- Light preview button -->
199
+ <button
200
+ type="button"
201
+ onclick={() => handleSchemeSelect('light')}
202
+ {disabled}
203
+ title="Light scheme colour preview"
204
+ style="flex: 1; display: flex; flex-direction: column; gap: 4px; padding: 6px; background: {activeScheme === 'light' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.2)'}; border: 1px solid {activeScheme === 'light' ? '#4f46e5' : 'rgba(255,255,255,0.08)'}; border-radius: 6px; cursor: {disabled ? 'not-allowed' : 'pointer'}; text-align: left;"
205
+ >
206
+ <div style="display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: #8c95a6;">
207
+ <span>Light</span>
208
+ <span style="font-family: monospace; font-size: 10px;">{dual.light}</span>
209
+ </div>
210
+ <div
211
+ style="height: 24px; border-radius: 4px; background-image: {CHECKERBOARD_BG}; background-size: 8px 8px; overflow: hidden; border: 1px solid rgba(0,0,0,0.15);"
212
+ >
213
+ <div style="width: 100%; height: 100%; background: {dual.light};"></div>
214
+ </div>
215
+ </button>
216
+
217
+ <!-- Dark preview button -->
218
+ <button
219
+ type="button"
220
+ onclick={() => handleSchemeSelect('dark')}
221
+ {disabled}
222
+ title="Dark scheme colour preview"
223
+ style="flex: 1; display: flex; flex-direction: column; gap: 4px; padding: 6px; background: {activeScheme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.2)'}; border: 1px solid {activeScheme === 'dark' ? '#4f46e5' : 'rgba(255,255,255,0.08)'}; border-radius: 6px; cursor: {disabled ? 'not-allowed' : 'pointer'}; text-align: left;"
224
+ >
225
+ <div style="display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: #8c95a6;">
226
+ <span>Dark</span>
227
+ <span style="font-family: monospace; font-size: 10px;">{dual.dark}</span>
228
+ </div>
229
+ <div
230
+ style="height: 24px; border-radius: 4px; background-image: {CHECKERBOARD_BG}; background-size: 8px 8px; overflow: hidden; border: 1px solid rgba(0,0,0,0.15);"
231
+ >
232
+ <div style="width: 100%; height: 100%; background: {dual.dark};"></div>
233
+ </div>
234
+ </button>
235
+ </div>
236
+
237
+ <!-- Dual Toolbar: Same in both toggle and Derive button -->
238
+ <div style="display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: #cbd5e1;">
239
+ <label style="display: flex; align-items: center; gap: 6px; cursor: {disabled ? 'not-allowed' : 'pointer'}; user-select: none;">
240
+ <input
241
+ type="checkbox"
242
+ checked={sameInBoth}
243
+ onchange={(e) => handleToggleSameInBoth(e.currentTarget.checked)}
244
+ {disabled}
245
+ style="margin: 0; cursor: {disabled ? 'not-allowed' : 'pointer'};"
246
+ />
247
+ Same in both
248
+ </label>
249
+
250
+ <button
251
+ type="button"
252
+ onclick={handleDerive}
253
+ {disabled}
254
+ title="Automatically derive the opposing scheme colour preserving contrast"
255
+ style="padding: 2px 8px; font-size: 11px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); border-radius: 4px; color: #cbd5e1; cursor: {disabled ? 'not-allowed' : 'pointer'};"
256
+ >
257
+ Derive {activeScheme === 'light' ? 'Dark' : 'Light'}
258
+ </button>
259
+ </div>
260
+
261
+ <!-- Embedded Headless ColorPicker for the active scheme side -->
262
+ <ColorPicker
263
+ value={activeColor}
264
+ onchange={handleInnerChange}
265
+ contrastAgainst={activeContrastTarget}
266
+ {alpha}
267
+ {swatches}
268
+ {disabled}
269
+ ongesturestart={triggerGestureStart}
270
+ ongestureend={triggerGestureEnd}
271
+ />
272
+
273
+ <!-- Optional Dual Contrast Badge Summary -->
274
+ {#if dualContrast}
275
+ <div
276
+ style="display: flex; gap: 8px; padding-top: 4px; border-top: 1px solid rgba(255,255,255,0.08); font-size: 11px;"
277
+ >
278
+ <div style="flex: 1; display: flex; justify-content: space-between; align-items: center;">
279
+ <span style="color: #8c95a6;">Light:</span>
280
+ <span
281
+ style="padding: 1px 4px; border-radius: 3px; font-weight: 600; font-family: monospace; background: {dualContrast.light.level === 'fail' ? '#7f1d1d' : '#14532d'}; color: {dualContrast.light.level === 'fail' ? '#fca5a5' : '#86efac'};"
282
+ >
283
+ {dualContrast.light.ratio}:1 ({dualContrast.light.level.toUpperCase()})
284
+ </span>
285
+ </div>
286
+ <div style="flex: 1; display: flex; justify-content: space-between; align-items: center;">
287
+ <span style="color: #8c95a6;">Dark:</span>
288
+ <span
289
+ style="padding: 1px 4px; border-radius: 3px; font-weight: 600; font-family: monospace; background: {dualContrast.dark.level === 'fail' ? '#7f1d1d' : '#14532d'}; color: {dualContrast.dark.level === 'fail' ? '#fca5a5' : '#86efac'};"
290
+ >
291
+ {dualContrast.dark.ratio}:1 ({dualContrast.dark.level.toUpperCase()})
292
+ </span>
293
+ </div>
294
+ </div>
295
+ {/if}
296
+ </div>
@@ -0,0 +1,34 @@
1
+ import { type ColorScheme, type ColorValue, type DualColor } from '@pixerate/editor/color';
2
+ interface Props {
3
+ /** Dual colour value (CSS string, light-dark(), or { light, dark }) */
4
+ value?: ColorValue;
5
+ /** Called when the dual colour value changes */
6
+ onchange?: (value: DualColor) => void;
7
+ onChange?: (value: DualColor) => void;
8
+ /** Controlled active scheme mode ('light' | 'dark') */
9
+ scheme?: ColorScheme;
10
+ /** Called when the user toggles active scheme tab */
11
+ onschemechange?: (scheme: ColorScheme) => void;
12
+ onSchemeChange?: (scheme: ColorScheme) => void;
13
+ /** Expected background surfaces for derivation and preview */
14
+ surfaces?: DualColor;
15
+ /** Optional background colour to evaluate contrast against in both modes */
16
+ contrastAgainst?: ColorValue;
17
+ /** Whether alpha slider is shown */
18
+ alpha?: boolean;
19
+ /** Preset swatches */
20
+ swatches?: readonly string[];
21
+ /** Whether picker is disabled */
22
+ disabled?: boolean;
23
+ /** Additional CSS class name */
24
+ class?: string;
25
+ className?: string;
26
+ /** Gesture callbacks */
27
+ ongesturestart?: () => void;
28
+ onGestureStart?: () => void;
29
+ ongestureend?: () => void;
30
+ onGestureEnd?: () => void;
31
+ }
32
+ declare const DualColorPicker: import("svelte").Component<Props, {}, "">;
33
+ type DualColorPicker = ReturnType<typeof DualColorPicker>;
34
+ export default DualColorPicker;
@@ -0,0 +1,19 @@
1
+ import type { ColorScheme, ColorValue } from '@pixerate/editor/color';
2
+ export interface ColorSchemeOptions {
3
+ /** Explicit scheme override ('light' or 'dark') */
4
+ override?: ColorScheme | null;
5
+ /** Attribute on document.documentElement to monitor for theme changes (default: 'data-theme') */
6
+ attribute?: string;
7
+ }
8
+ export interface ColorSchemeStore {
9
+ readonly current: ColorScheme;
10
+ destroy(): void;
11
+ }
12
+ /**
13
+ * Headless Svelte 5 rune tracking active colour scheme across media query and document attribute.
14
+ */
15
+ export declare function createColorScheme(options?: ColorSchemeOptions): ColorSchemeStore;
16
+ /**
17
+ * Pure helper resolving a ColorValue against a specific scheme.
18
+ */
19
+ export declare function resolvedColor(value: ColorValue, scheme: ColorScheme): string | null;
@@ -0,0 +1,76 @@
1
+ import { resolveColor } from '@pixerate/editor/color';
2
+ /**
3
+ * Headless Svelte 5 rune tracking active colour scheme across media query and document attribute.
4
+ */
5
+ export function createColorScheme(options = {}) {
6
+ let system = $state('light');
7
+ let attrValue = $state(null);
8
+ const attributeName = options.attribute ?? 'data-theme';
9
+ function readSystem() {
10
+ if (typeof window === 'undefined' || !window.matchMedia)
11
+ return 'light';
12
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
13
+ }
14
+ function readAttribute() {
15
+ if (typeof document === 'undefined')
16
+ return null;
17
+ const val = document.documentElement.getAttribute(attributeName)?.toLowerCase();
18
+ if (val === 'dark' || val === 'light')
19
+ return val;
20
+ return null;
21
+ }
22
+ system = readSystem();
23
+ attrValue = readAttribute();
24
+ let mqlListener = null;
25
+ let observer = null;
26
+ if (typeof window !== 'undefined' && window.matchMedia) {
27
+ const mql = window.matchMedia('(prefers-color-scheme: dark)');
28
+ mqlListener = (e) => {
29
+ system = e.matches ? 'dark' : 'light';
30
+ };
31
+ if (mql.addEventListener) {
32
+ mql.addEventListener('change', mqlListener);
33
+ }
34
+ else {
35
+ mql.addListener(mqlListener);
36
+ }
37
+ }
38
+ if (typeof document !== 'undefined' && typeof MutationObserver !== 'undefined') {
39
+ observer = new MutationObserver(() => {
40
+ attrValue = readAttribute();
41
+ });
42
+ observer.observe(document.documentElement, {
43
+ attributes: true,
44
+ attributeFilter: [attributeName]
45
+ });
46
+ }
47
+ function destroy() {
48
+ if (mqlListener && typeof window !== 'undefined' && window.matchMedia) {
49
+ const mql = window.matchMedia('(prefers-color-scheme: dark)');
50
+ if (mql.removeEventListener) {
51
+ mql.removeEventListener('change', mqlListener);
52
+ }
53
+ else {
54
+ mql.removeListener(mqlListener);
55
+ }
56
+ mqlListener = null;
57
+ }
58
+ if (observer) {
59
+ observer.disconnect();
60
+ observer = null;
61
+ }
62
+ }
63
+ const current = $derived(options.override ?? attrValue ?? system);
64
+ return {
65
+ get current() {
66
+ return current;
67
+ },
68
+ destroy
69
+ };
70
+ }
71
+ /**
72
+ * Pure helper resolving a ColorValue against a specific scheme.
73
+ */
74
+ export function resolvedColor(value, scheme) {
75
+ return resolveColor(value, scheme);
76
+ }
@@ -0,0 +1,9 @@
1
+ import type { ComponentProps } from 'svelte';
2
+ import type ColorPickerComponent from './ColorPicker.svelte';
3
+ import type DualColorPickerComponent from './DualColorPicker.svelte';
4
+ export { default as ColorPicker } from './ColorPicker.svelte';
5
+ export { default as DualColorPicker } from './DualColorPicker.svelte';
6
+ export type ColorPickerProps = ComponentProps<typeof ColorPickerComponent>;
7
+ export type DualColorPickerProps = ComponentProps<typeof DualColorPickerComponent>;
8
+ export { createColorScheme, resolvedColor, type ColorSchemeOptions, type ColorSchemeStore } from './colorScheme.svelte.js';
9
+ export * from '@pixerate/editor/color';
@@ -0,0 +1,5 @@
1
+ export { default as ColorPicker } from './ColorPicker.svelte';
2
+ export { default as DualColorPicker } from './DualColorPicker.svelte';
3
+ export { createColorScheme, resolvedColor } from './colorScheme.svelte.js';
4
+ // Re-export core colour module for consumer convenience
5
+ export * from '@pixerate/editor/color';
@@ -0,0 +1,236 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import type { FillMode } from './types.js';
4
+ import {
5
+ GradientController,
6
+ createGradient,
7
+ gradientToCss,
8
+ parseCssGradient
9
+ } from '@pixerate/editor/gradient';
10
+ import { formatHex, mixColors, parseColor, resolveColor } from '@pixerate/editor/color';
11
+ import ColorPicker from '../color/ColorPicker.svelte';
12
+ import GradientEditor from './GradientEditor.svelte';
13
+
14
+ interface Props {
15
+ /** Active CSS colour (hex/rgb/hsl etc.) or CSS gradient string. */
16
+ value: string;
17
+ /** Called when the fill value changes. */
18
+ onchange?: (value: string) => void;
19
+ onChange?: (value: string) => void;
20
+ /** Called when a continuous pointer drag starts. */
21
+ ongesturestart?: () => void;
22
+ onGestureStart?: () => void;
23
+ /** Called when a continuous pointer drag ends. */
24
+ ongestureend?: () => void;
25
+ onGestureEnd?: () => void;
26
+ /** Allowed fill modes. Default: ['solid', 'gradient']. */
27
+ modes?: readonly FillMode[];
28
+ /** Whether the alpha channel slider is shown on ColorPicker. Default: false. */
29
+ alpha?: boolean;
30
+ /** Preset swatches for ColorPicker. */
31
+ swatches?: readonly string[];
32
+ /** Whether the gradient editor shows preset buttons. Default: true. */
33
+ showPresets?: boolean;
34
+ /** Whether the gradient editor shows the CSS output with a copy button. Default: false. */
35
+ showCss?: boolean;
36
+ /** Additional CSS class names. */
37
+ class?: string;
38
+ className?: string;
39
+ /** Whether the picker is disabled. */
40
+ disabled?: boolean;
41
+ }
42
+
43
+ let {
44
+ value,
45
+ onchange,
46
+ onChange,
47
+ ongesturestart,
48
+ onGestureStart,
49
+ ongestureend,
50
+ onGestureEnd,
51
+ modes = ['solid', 'gradient'],
52
+ alpha = false,
53
+ swatches,
54
+ showPresets = true,
55
+ showCss = false,
56
+ class: klass = '',
57
+ className = '',
58
+ disabled = false
59
+ }: Props = $props();
60
+
61
+ function emitChange(v: string) {
62
+ onchange?.(v);
63
+ onChange?.(v);
64
+ }
65
+
66
+ function emitGestureStart() {
67
+ ongesturestart?.();
68
+ onGestureStart?.();
69
+ }
70
+
71
+ function emitGestureEnd() {
72
+ ongestureend?.();
73
+ onGestureEnd?.();
74
+ }
75
+
76
+ function createDefaultGradientFromColor(colorStr: string) {
77
+ const parsed = parseColor(colorStr);
78
+ const baseHex = parsed ? formatHex(parsed) : '#3b82f6';
79
+ const lighterHex = mixColors(baseHex, '#ffffff', 0.35, { space: 'oklab' }) ?? '#ffffff';
80
+ return createGradient([baseHex, lighterHex]);
81
+ }
82
+
83
+ // The controller and mode start from the first value; the effect below follows later ones.
84
+ const initialValue = untrack(() => value);
85
+ const initialGradient = parseCssGradient(initialValue);
86
+ const allows = (m: FillMode) => untrack(() => modes).includes(m);
87
+
88
+ let mode = $state<FillMode>(
89
+ initialGradient && allows('gradient') ? 'gradient' : allows('solid') ? 'solid' : 'gradient'
90
+ );
91
+ let isDragging = false;
92
+ // The last CSS this picker emitted, so the echo of our own change isn't loaded back in.
93
+ let emitted: string | null = null;
94
+ let loading = false;
95
+
96
+ const controller = new GradientController({
97
+ value: initialGradient ?? createDefaultGradientFromColor(initialValue),
98
+ readOnly: () => disabled,
99
+ onChange: (g) => {
100
+ if (loading || mode !== 'gradient') return;
101
+ emit(gradientToCss(g));
102
+ }
103
+ });
104
+
105
+ function emit(v: string) {
106
+ emitted = v;
107
+ emitChange(v);
108
+ }
109
+
110
+ function loadQuietly(next: unknown) {
111
+ loading = true;
112
+ try {
113
+ controller.load(next);
114
+ } finally {
115
+ loading = false;
116
+ }
117
+ }
118
+
119
+ // Follow the incoming value, without an undo step, unless it is our own change coming back.
120
+ $effect(() => {
121
+ const incoming = parseCssGradient(value);
122
+ const allowsGradient = modes.includes('gradient');
123
+ const allowsSolid = modes.includes('solid');
124
+ untrack(() => {
125
+ if (isDragging) return;
126
+ if (incoming && allowsGradient) {
127
+ mode = 'gradient';
128
+ if (value !== emitted) loadQuietly(incoming);
129
+ } else if (!incoming && allowsSolid) {
130
+ mode = 'solid';
131
+ }
132
+ });
133
+ });
134
+
135
+ function switchToSolid() {
136
+ if (mode === 'solid') return;
137
+ mode = 'solid';
138
+ const first = controller.getGradient().stops[0]?.color ?? '#000000';
139
+ // A dual stop has no single solid equivalent; keep its light half.
140
+ emit(resolveColor(first, 'light') ?? '#000000');
141
+ }
142
+
143
+ function switchToGradient() {
144
+ if (mode === 'gradient') return;
145
+ mode = 'gradient';
146
+ loadQuietly(createDefaultGradientFromColor(value));
147
+ emit(gradientToCss(controller.getGradient()));
148
+ }
149
+
150
+ function gradientGestureStart() {
151
+ isDragging = true;
152
+ emitGestureStart();
153
+ }
154
+
155
+ function gradientGestureEnd() {
156
+ isDragging = false;
157
+ emitGestureEnd();
158
+ }
159
+
160
+ const buttonClass =
161
+ 'inline-flex h-7 items-center justify-center rounded border border-border bg-background px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40 aria-pressed:bg-primary aria-pressed:text-primary-foreground';
162
+ </script>
163
+
164
+ {#if modes.length === 1 && modes[0] === 'solid'}
165
+ <div class="{klass} {className}" data-testid="fill-picker">
166
+ <ColorPicker
167
+ {value}
168
+ onchange={emitChange}
169
+ ongesturestart={emitGestureStart}
170
+ ongestureend={emitGestureEnd}
171
+ {alpha}
172
+ {swatches}
173
+ {disabled}
174
+ />
175
+ </div>
176
+ {:else if modes.length === 1 && modes[0] === 'gradient'}
177
+ <div class="{klass} {className}" data-testid="fill-picker">
178
+ <GradientEditor
179
+ {controller}
180
+ {showPresets}
181
+ {showCss}
182
+ ongesturestart={gradientGestureStart}
183
+ ongestureend={gradientGestureEnd}
184
+ />
185
+ </div>
186
+ {:else}
187
+ <div class="flex flex-col gap-2.5 {klass} {className}" data-testid="fill-picker">
188
+ <!-- Mode toggle tabs -->
189
+ <div class="flex rounded-md border border-border p-0.5">
190
+ <button
191
+ type="button"
192
+ class="flex-1 {buttonClass}"
193
+ aria-pressed={mode === 'solid'}
194
+ {disabled}
195
+ onclick={switchToSolid}
196
+ >
197
+ Solid
198
+ </button>
199
+ <button
200
+ type="button"
201
+ class="flex-1 {buttonClass}"
202
+ aria-pressed={mode === 'gradient'}
203
+ {disabled}
204
+ onclick={switchToGradient}
205
+ >
206
+ Gradient
207
+ </button>
208
+ </div>
209
+
210
+ {#if mode === 'solid'}
211
+ <ColorPicker
212
+ {value}
213
+ onchange={emitChange}
214
+ ongesturestart={() => {
215
+ isDragging = true;
216
+ emitGestureStart();
217
+ }}
218
+ ongestureend={() => {
219
+ isDragging = false;
220
+ emitGestureEnd();
221
+ }}
222
+ {alpha}
223
+ {swatches}
224
+ {disabled}
225
+ />
226
+ {:else}
227
+ <GradientEditor
228
+ {controller}
229
+ {showPresets}
230
+ {showCss}
231
+ ongesturestart={gradientGestureStart}
232
+ ongestureend={gradientGestureEnd}
233
+ />
234
+ {/if}
235
+ </div>
236
+ {/if}