mnfst 0.5.63 → 0.5.65

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,3156 @@
1
+ /* Manifest Color Picker
2
+
3
+ Directive: x-colorpicker[.<modifier>[="value"]]
4
+
5
+ Root (no modifier): declares a picker container.
6
+ Children (with modifier): declare hooks. Three families:
7
+
8
+ • Layout (template OR instance — determined by host element tag)
9
+ .solid canvas + hue/alpha sliders + value + format
10
+ .gradient full gradient panel (layers container + layer template)
11
+ .layer-options one gradient layer's UI
12
+ .gradient-layers container holding cloned .layer-options instances
13
+ .layer-stops-bar stops bar (inside a layer)
14
+
15
+ • Actions (click triggers behavior)
16
+ .add-layer .apply-color .grab-color
17
+ .duplicate-layer .remove-layer
18
+ .flip-layer .rotate-layer
19
+ .duplicate-stop .delete-stop
20
+ .set-gradient-type="linear|radial|conic"
21
+
22
+ • Inputs (state-bound control)
23
+ .set-canvas .set-hue .set-alpha
24
+ .set-alpha-value .set-color-space .set-color-value
25
+ .set-angle .set-gradient-value
26
+
27
+ Magic: $colorpicker — returns the nearest ancestor picker's API
28
+ for programmatic control (addLayer, setHue, layers, activeStop, ...).
29
+ */
30
+
31
+ function initializeColorpickerPlugin() {
32
+
33
+ // ---- Parse any CSS color string via the browser ----
34
+
35
+ const parseCtx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
36
+
37
+ function parseCssColor(str) {
38
+ if (!str || typeof str !== 'string') return null;
39
+ str = str.trim();
40
+ if (!str) return null;
41
+ const hex8 = str.match(/^#([0-9a-f]{8})$/i);
42
+ if (hex8) {
43
+ const n = parseInt(hex8[1], 16);
44
+ return { r: (n >> 24) & 255, g: (n >> 16) & 255, b: (n >> 8) & 255, a: (n & 255) / 255 };
45
+ }
46
+ const hex4 = str.match(/^#([0-9a-f]{4})$/i);
47
+ if (hex4) {
48
+ const c = hex4[1];
49
+ return { r: parseInt(c[0]+c[0],16), g: parseInt(c[1]+c[1],16), b: parseInt(c[2]+c[2],16), a: parseInt(c[3]+c[3],16)/255 };
50
+ }
51
+ parseCtx.clearRect(0, 0, 1, 1);
52
+ parseCtx.fillStyle = '#00000000';
53
+ parseCtx.fillStyle = str;
54
+ if (parseCtx.fillStyle === '#00000000' && str !== '#00000000' && str !== 'transparent') {
55
+ parseCtx.fillStyle = '#01010101';
56
+ parseCtx.fillStyle = str;
57
+ if (parseCtx.fillStyle === '#01010101') return null;
58
+ }
59
+ parseCtx.fillRect(0, 0, 1, 1);
60
+ const d = parseCtx.getImageData(0, 0, 1, 1).data;
61
+ return { r: d[0], g: d[1], b: d[2], a: +(d[3] / 255).toFixed(3) };
62
+ }
63
+
64
+ // ---- Color conversions ----
65
+
66
+ function rgbToHex(r, g, b) { return '#' + [r,g,b].map(v => Math.round(v).toString(16).padStart(2,'0')).join(''); }
67
+ function rgbToHex8(r, g, b, a) { return '#' + [r,g,b,Math.round(a*255)].map(v => Math.round(v).toString(16).padStart(2,'0')).join(''); }
68
+
69
+ function rgbToHsv(r, g, b) {
70
+ r/=255; g/=255; b/=255;
71
+ const max=Math.max(r,g,b), min=Math.min(r,g,b), d=max-min;
72
+ let h=0, s=max===0?0:d/max, v=max;
73
+ if(d!==0){ if(max===r)h=((g-b)/d+(g<b?6:0))/6; else if(max===g)h=((b-r)/d+2)/6; else h=((r-g)/d+4)/6; }
74
+ return {h:h*360, s:s*100, v:v*100};
75
+ }
76
+
77
+ function hsvToRgb(h, s, v) {
78
+ h/=360; s/=100; v/=100;
79
+ let r,g,b; const i=Math.floor(h*6), f=h*6-i, p=v*(1-s), q=v*(1-f*s), t=v*(1-(1-f)*s);
80
+ switch(i%6){ case 0:r=v;g=t;b=p;break; case 1:r=q;g=v;b=p;break; case 2:r=p;g=v;b=t;break; case 3:r=p;g=q;b=v;break; case 4:r=t;g=p;b=v;break; case 5:r=v;g=p;b=q;break; }
81
+ return {r:Math.round(r*255), g:Math.round(g*255), b:Math.round(b*255)};
82
+ }
83
+
84
+ function rgbToHsl(r, g, b) {
85
+ r/=255; g/=255; b/=255;
86
+ const max=Math.max(r,g,b), min=Math.min(r,g,b), l=(max+min)/2;
87
+ let h=0, s=0;
88
+ if(max!==min){ const d=max-min; s=l>0.5?d/(2-max-min):d/(max+min); if(max===r)h=((g-b)/d+(g<b?6:0))/6; else if(max===g)h=((b-r)/d+2)/6; else h=((r-g)/d+4)/6; }
89
+ return {h:Math.round(h*360), s:Math.round(s*100), l:Math.round(l*100)};
90
+ }
91
+
92
+ function srgbToLinear(c){ return c<=0.04045?c/12.92:Math.pow((c+0.055)/1.055,2.4); }
93
+
94
+ function rgbToOklch(r, g, b) {
95
+ const lr=srgbToLinear(r/255), lg=srgbToLinear(g/255), lb=srgbToLinear(b/255);
96
+ const l_=0.4122214708*lr+0.5363325363*lg+0.0514459929*lb, m_=0.2119034982*lr+0.6806995451*lg+0.1073969566*lb, s_=0.0883024619*lr+0.2817188376*lg+0.6299787005*lb;
97
+ const l1=Math.cbrt(l_), m1=Math.cbrt(m_), s1=Math.cbrt(s_);
98
+ const L=0.2104542553*l1+0.7936177850*m1-0.0040720468*s1, a=1.9779984951*l1-2.4285922050*m1+0.4505937099*s1, bk=0.0259040371*l1+0.7827717662*m1-0.8086757660*s1;
99
+ let H=Math.atan2(bk,a)*180/Math.PI; if(H<0)H+=360;
100
+ return {l:+(L*100).toFixed(2), c:+Math.sqrt(a*a+bk*bk).toFixed(4), h:+H.toFixed(1)};
101
+ }
102
+
103
+ const FORMATS = ['hex', 'rgb', 'hsl', 'oklch'];
104
+
105
+ function roundA(a) { const v=Math.round(a*100); return v===100?'1':(v/100).toString(); }
106
+
107
+ // Canonical dedupe key for a color or gradient value. Used to tag library swatches
108
+ // with a `data-cp-key` that the picker can match against its current color to toggle
109
+ // an `active` class. Parses any CSS color via parseCssColor → 8-digit hex. Gradients
110
+ // are compared as their normalized CSS string.
111
+ function _swatchKeyOf(value) {
112
+ if (typeof value !== 'string') return null;
113
+ const v = value.trim();
114
+ if (!v) return null;
115
+ if (v.includes('gradient(')) return v;
116
+ const c = parseCssColor(v);
117
+ if (!c) return null;
118
+ return rgbToHex8(c.r, c.g, c.b, c.a);
119
+ }
120
+
121
+ function formatColor(r, g, b, a, mode) {
122
+ const hasA = a < 1;
123
+ switch (mode) {
124
+ case 'hex': return hasA ? rgbToHex8(r,g,b,a) : rgbToHex(r,g,b);
125
+ case 'rgb': return `rgb(${r} ${g} ${b}${hasA?' / '+roundA(a):''})`;
126
+ case 'hsl': { const c=rgbToHsl(r,g,b); return `hsl(${c.h} ${c.s}% ${c.l}%${hasA?' / '+roundA(a):''})`; }
127
+ case 'oklch': { const c=rgbToOklch(r,g,b); return `oklch(${c.l}% ${c.c} ${c.h}${hasA?' / '+roundA(a):''})`; }
128
+ default: return rgbToHex(r,g,b);
129
+ }
130
+ }
131
+
132
+ function detectFormat(str) {
133
+ str = (str || '').trim().toLowerCase();
134
+ if (str.startsWith('#')) return 'hex';
135
+ if (str.startsWith('rgb')) return 'rgb';
136
+ if (str.startsWith('hsl')) return 'hsl';
137
+ if (str.startsWith('oklch')) return 'oklch';
138
+ return null;
139
+ }
140
+
141
+ function colorToRgba(col) {
142
+ const {r,g,b} = hsvToRgb(col.h, col.s, col.v);
143
+ return col.a < 1 ? `rgba(${r},${g},${b},${col.a})` : rgbToHex(r,g,b);
144
+ }
145
+
146
+ // ---- Canvas (SV plane) ----
147
+
148
+ function drawSvCanvas(canvas, hue) {
149
+ // Cache the 2D context on the canvas element. No `willReadFrequently` —
150
+ // we only ever write to this canvas, so we want GPU-accelerated compositing.
151
+ const ctx = canvas._cpCtx || (canvas._cpCtx = canvas.getContext('2d'));
152
+ const w = canvas.width, h = canvas.height;
153
+ const hr = hsvToRgb(hue, 100, 100);
154
+ const hG = ctx.createLinearGradient(0,0,w,0);
155
+ hG.addColorStop(0,'#fff'); hG.addColorStop(1,`rgb(${hr.r},${hr.g},${hr.b})`);
156
+ ctx.fillStyle = hG; ctx.fillRect(0,0,w,h);
157
+ const vG = ctx.createLinearGradient(0,0,0,h);
158
+ vG.addColorStop(0,'rgba(0,0,0,0)'); vG.addColorStop(1,'#000');
159
+ ctx.fillStyle = vG; ctx.fillRect(0,0,w,h);
160
+ }
161
+
162
+ // ---- Gradient string builders ----
163
+
164
+ const GRADIENT_TYPES = ['linear', 'radial', 'conic'];
165
+
166
+ function buildLayerString(layer) {
167
+ const stops = layer.stops.slice().sort((a,b) => a.position - b.position)
168
+ .map(s => `${colorToRgba(s.color)} ${s.position}%`).join(', ');
169
+ switch (layer.type) {
170
+ case 'linear': return `linear-gradient(${layer.angle}deg, ${stops})`;
171
+ case 'radial': return `radial-gradient(circle at ${layer.position.x}% ${layer.position.y}%, ${stops})`;
172
+ case 'conic': return `conic-gradient(from ${layer.angle}deg at ${layer.position.x}% ${layer.position.y}%, ${stops})`;
173
+ default: return `linear-gradient(${layer.angle}deg, ${stops})`;
174
+ }
175
+ }
176
+
177
+ function buildFullGradientString(layers) {
178
+ return layers.map(buildLayerString).join(', ');
179
+ }
180
+
181
+ // ---- Gradient parsing (reverse of buildLayerString / buildFullGradientString) ----
182
+ //
183
+ // Parses a CSS gradient string back into the layers/stops structure the picker
184
+ // uses internally. Tolerant of whatever the browser serializes from inline
185
+ // styles (rgb(...) and rgba(...) instead of hex, etc.).
186
+
187
+ // Split a string on top-level commas (commas not inside parentheses).
188
+ function _splitTopLevelCommas(str) {
189
+ const out = [];
190
+ let depth = 0, start = 0;
191
+ for (let i = 0; i < str.length; i++) {
192
+ const c = str[i];
193
+ if (c === '(') depth++;
194
+ else if (c === ')') depth--;
195
+ else if (c === ',' && depth === 0) { out.push(str.slice(start, i).trim()); start = i + 1; }
196
+ }
197
+ if (start < str.length) out.push(str.slice(start).trim());
198
+ return out;
199
+ }
200
+
201
+ // Split a string into individual gradient calls — top-level segments that each
202
+ // start with `<type>-gradient(`. Handles multi-layer gradients like
203
+ // "linear-gradient(...), radial-gradient(...)".
204
+ function _splitGradientLayers(str) {
205
+ const segments = _splitTopLevelCommas(str);
206
+ return segments.filter(s => /^(linear|radial|conic)-gradient\s*\(/i.test(s));
207
+ }
208
+
209
+ function _parseGradientLayer(layerStr) {
210
+ const m = layerStr.match(/^(linear|radial|conic)-gradient\s*\(([\s\S]*)\)\s*$/i);
211
+ if (!m) return null;
212
+ const type = m[1].toLowerCase();
213
+ const parts = _splitTopLevelCommas(m[2]);
214
+
215
+ let angle = 90, x = 50, y = 50;
216
+ let stopsStart = 0;
217
+ const first = parts[0] || '';
218
+
219
+ if (type === 'linear') {
220
+ // "<angle>deg" prefix is optional; default 90 if absent
221
+ const am = first.match(/^([-\d.]+)\s*deg/i);
222
+ if (am) { angle = parseFloat(am[1]); stopsStart = 1; }
223
+ } else if (type === 'radial') {
224
+ // "circle at X% Y%" or similar — pull X/Y if present
225
+ const pm = first.match(/at\s+([-\d.]+)%\s+([-\d.]+)%/i);
226
+ if (pm) { x = parseFloat(pm[1]); y = parseFloat(pm[2]); stopsStart = 1; }
227
+ else if (/^(circle|ellipse|closest|farthest)/i.test(first)) stopsStart = 1;
228
+ } else if (type === 'conic') {
229
+ const am = first.match(/from\s+([-\d.]+)\s*deg/i);
230
+ const pm = first.match(/at\s+([-\d.]+)%\s+([-\d.]+)%/i);
231
+ if (am) angle = parseFloat(am[1]);
232
+ if (pm) { x = parseFloat(pm[1]); y = parseFloat(pm[2]); }
233
+ if (am || pm) stopsStart = 1;
234
+ }
235
+
236
+ const stops = parts.slice(stopsStart).map(_parseGradientStop).filter(Boolean);
237
+ if (stops.length === 0) return null;
238
+ return { type, angle, position: { x, y }, stops };
239
+ }
240
+
241
+ // Parses "<color> <position>%" — color may be any CSS color form
242
+ // (hex, rgb(), rgba(), hsl(), oklch(), named, etc.).
243
+ function _parseGradientStop(s) {
244
+ const trimmed = s.trim();
245
+ // Position is the trailing "<num>%" — color is everything before
246
+ const m = trimmed.match(/^(.+?)\s+([-\d.]+)%\s*$/);
247
+ if (!m) return null;
248
+ const rgb = parseCssColor(m[1].trim());
249
+ if (!rgb) return null;
250
+ const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b);
251
+ return {
252
+ color: { h: hsv.h, s: hsv.s, v: hsv.v, a: rgb.a },
253
+ position: parseFloat(m[2]),
254
+ format: 'hex'
255
+ };
256
+ }
257
+
258
+ function parseGradientString(str) {
259
+ if (typeof str !== 'string') return null;
260
+ const trimmed = str.trim();
261
+ if (!/gradient\s*\(/i.test(trimmed)) return null;
262
+ const layerStrs = _splitGradientLayers(trimmed);
263
+ if (layerStrs.length === 0) return null;
264
+ const layers = layerStrs.map(_parseGradientLayer).filter(Boolean);
265
+ return layers.length ? layers : null;
266
+ }
267
+
268
+ // ---- Layer / stop factories ----
269
+
270
+ function makeDefaultStop(hue, position) {
271
+ return { color: { h: hue, s: 100, v: 100, a: 1 }, position, format: 'hex' };
272
+ }
273
+
274
+ function makeDefaultLayer() {
275
+ return {
276
+ type: 'linear', angle: 90, position: { x: 50, y: 50 },
277
+ stops: [ makeDefaultStop(0, 0), makeDefaultStop(240, 100) ]
278
+ };
279
+ }
280
+
281
+ // ---- Built-in preset palettes ----
282
+
283
+ // Tailwind v4 default theme (exact OKLCH values from tailwindcss/theme.css)
284
+ // Ref: tailwindlabs/tailwindcss — packages/tailwindcss/theme.css
285
+ const TAILWIND_HUES = ['red','orange','amber','yellow','lime','green','emerald','teal','cyan','sky','blue','indigo','violet','purple','fuchsia','pink','rose','slate','gray','zinc','neutral','stone','mauve','olive','mist','taupe'];
286
+ const TAILWIND_SHADES = ['50','100','200','300','400','500','600','700','800','900','950'];
287
+ const TAILWIND_COLORS = {
288
+ red: ['oklch(97.1% 0.013 17.38)','oklch(93.6% 0.032 17.717)','oklch(88.5% 0.062 18.334)','oklch(80.8% 0.114 19.571)','oklch(70.4% 0.191 22.216)','oklch(63.7% 0.237 25.331)','oklch(57.7% 0.245 27.325)','oklch(50.5% 0.213 27.518)','oklch(44.4% 0.177 26.899)','oklch(39.6% 0.141 25.723)','oklch(25.8% 0.092 26.042)'],
289
+ orange: ['oklch(98% 0.016 73.684)','oklch(95.4% 0.038 75.164)','oklch(90.1% 0.076 70.697)','oklch(83.7% 0.128 66.29)','oklch(75% 0.183 55.934)','oklch(70.5% 0.213 47.604)','oklch(64.6% 0.222 41.116)','oklch(55.3% 0.195 38.402)','oklch(47% 0.157 37.304)','oklch(40.8% 0.123 38.172)','oklch(26.6% 0.079 36.259)'],
290
+ amber: ['oklch(98.7% 0.022 95.277)','oklch(96.2% 0.059 95.617)','oklch(92.4% 0.12 95.746)','oklch(87.9% 0.169 91.605)','oklch(82.8% 0.189 84.429)','oklch(76.9% 0.188 70.08)','oklch(66.6% 0.179 58.318)','oklch(55.5% 0.163 48.998)','oklch(47.3% 0.137 46.201)','oklch(41.4% 0.112 45.904)','oklch(27.9% 0.077 45.635)'],
291
+ yellow: ['oklch(98.7% 0.026 102.212)','oklch(97.3% 0.071 103.193)','oklch(94.5% 0.129 101.54)','oklch(90.5% 0.182 98.111)','oklch(85.2% 0.199 91.936)','oklch(79.5% 0.184 86.047)','oklch(68.1% 0.162 75.834)','oklch(55.4% 0.135 66.442)','oklch(47.6% 0.114 61.907)','oklch(42.1% 0.095 57.708)','oklch(28.6% 0.066 53.813)'],
292
+ lime: ['oklch(98.6% 0.031 120.757)','oklch(96.7% 0.067 122.328)','oklch(93.8% 0.127 124.321)','oklch(89.7% 0.196 126.665)','oklch(84.1% 0.238 128.85)','oklch(76.8% 0.233 130.85)','oklch(64.8% 0.2 131.684)','oklch(53.2% 0.157 131.589)','oklch(45.3% 0.124 130.933)','oklch(40.5% 0.101 131.063)','oklch(27.4% 0.072 132.109)'],
293
+ green: ['oklch(98.2% 0.018 155.826)','oklch(96.2% 0.044 156.743)','oklch(92.5% 0.084 155.995)','oklch(87.1% 0.15 154.449)','oklch(79.2% 0.209 151.711)','oklch(72.3% 0.219 149.579)','oklch(62.7% 0.194 149.214)','oklch(52.7% 0.154 150.069)','oklch(44.8% 0.119 151.328)','oklch(39.3% 0.095 152.535)','oklch(26.6% 0.065 152.934)'],
294
+ emerald: ['oklch(97.9% 0.021 166.113)','oklch(95% 0.052 163.051)','oklch(90.5% 0.093 164.15)','oklch(84.5% 0.143 164.978)','oklch(76.5% 0.177 163.223)','oklch(69.6% 0.17 162.48)','oklch(59.6% 0.145 163.225)','oklch(50.8% 0.118 165.612)','oklch(43.2% 0.095 166.913)','oklch(37.8% 0.077 168.94)','oklch(26.2% 0.051 172.552)'],
295
+ teal: ['oklch(98.4% 0.014 180.72)','oklch(95.3% 0.051 180.801)','oklch(91% 0.096 180.426)','oklch(85.5% 0.138 181.071)','oklch(77.7% 0.152 181.912)','oklch(70.4% 0.14 182.503)','oklch(60% 0.118 184.704)','oklch(51.1% 0.096 186.391)','oklch(43.7% 0.078 188.216)','oklch(38.6% 0.063 188.416)','oklch(27.7% 0.046 192.524)'],
296
+ cyan: ['oklch(98.4% 0.019 200.873)','oklch(95.6% 0.045 203.388)','oklch(91.7% 0.08 205.041)','oklch(86.5% 0.127 207.078)','oklch(78.9% 0.154 211.53)','oklch(71.5% 0.143 215.221)','oklch(60.9% 0.126 221.723)','oklch(52% 0.105 223.128)','oklch(45% 0.085 224.283)','oklch(39.8% 0.07 227.392)','oklch(30.2% 0.056 229.695)'],
297
+ sky: ['oklch(97.7% 0.013 236.62)','oklch(95.1% 0.026 236.824)','oklch(90.1% 0.058 230.902)','oklch(82.8% 0.111 230.318)','oklch(74.6% 0.16 232.661)','oklch(68.5% 0.169 237.323)','oklch(58.8% 0.158 241.966)','oklch(50% 0.134 242.749)','oklch(44.3% 0.11 240.79)','oklch(39.1% 0.09 240.876)','oklch(29.3% 0.066 243.157)'],
298
+ blue: ['oklch(97% 0.014 254.604)','oklch(93.2% 0.032 255.585)','oklch(88.2% 0.059 254.128)','oklch(80.9% 0.105 251.813)','oklch(70.7% 0.165 254.624)','oklch(62.3% 0.214 259.815)','oklch(54.6% 0.245 262.881)','oklch(48.8% 0.243 264.376)','oklch(42.4% 0.199 265.638)','oklch(37.9% 0.146 265.522)','oklch(28.2% 0.091 267.935)'],
299
+ indigo: ['oklch(96.2% 0.018 272.314)','oklch(93% 0.034 272.788)','oklch(87% 0.065 274.039)','oklch(78.5% 0.115 274.713)','oklch(67.3% 0.182 276.935)','oklch(58.5% 0.233 277.117)','oklch(51.1% 0.262 276.966)','oklch(45.7% 0.24 277.023)','oklch(39.8% 0.195 277.366)','oklch(35.9% 0.144 278.697)','oklch(25.7% 0.09 281.288)'],
300
+ violet: ['oklch(96.9% 0.016 293.756)','oklch(94.3% 0.029 294.588)','oklch(89.4% 0.057 293.283)','oklch(81.1% 0.111 293.571)','oklch(70.2% 0.183 293.541)','oklch(60.6% 0.25 292.717)','oklch(54.1% 0.281 293.009)','oklch(49.1% 0.27 292.581)','oklch(43.2% 0.232 292.759)','oklch(38% 0.189 293.745)','oklch(28.3% 0.141 291.089)'],
301
+ purple: ['oklch(97.7% 0.014 308.299)','oklch(94.6% 0.033 307.174)','oklch(90.2% 0.063 306.703)','oklch(82.7% 0.119 306.383)','oklch(71.4% 0.203 305.504)','oklch(62.7% 0.265 303.9)','oklch(55.8% 0.288 302.321)','oklch(49.6% 0.265 301.924)','oklch(43.8% 0.218 303.724)','oklch(38.1% 0.176 304.987)','oklch(29.1% 0.149 302.717)'],
302
+ fuchsia: ['oklch(97.7% 0.017 320.058)','oklch(95.2% 0.037 318.852)','oklch(90.3% 0.076 319.62)','oklch(83.3% 0.145 321.434)','oklch(74% 0.238 322.16)','oklch(66.7% 0.295 322.15)','oklch(59.1% 0.293 322.896)','oklch(51.8% 0.253 323.949)','oklch(45.2% 0.211 324.591)','oklch(40.1% 0.17 325.612)','oklch(29.3% 0.136 325.661)'],
303
+ pink: ['oklch(97.1% 0.014 343.198)','oklch(94.8% 0.028 342.258)','oklch(89.9% 0.061 343.231)','oklch(82.3% 0.12 346.018)','oklch(71.8% 0.202 349.761)','oklch(65.6% 0.241 354.308)','oklch(59.2% 0.249 0.584)','oklch(52.5% 0.223 3.958)','oklch(45.9% 0.187 3.815)','oklch(40.8% 0.153 2.432)','oklch(28.4% 0.109 3.907)'],
304
+ rose: ['oklch(96.9% 0.015 12.422)','oklch(94.1% 0.03 12.58)','oklch(89.2% 0.058 10.001)','oklch(81% 0.117 11.638)','oklch(71.2% 0.194 13.428)','oklch(64.5% 0.246 16.439)','oklch(58.6% 0.253 17.585)','oklch(51.4% 0.222 16.935)','oklch(45.5% 0.188 13.697)','oklch(41% 0.159 10.272)','oklch(27.1% 0.105 12.094)'],
305
+ slate: ['oklch(98.4% 0.003 247.858)','oklch(96.8% 0.007 247.896)','oklch(92.9% 0.013 255.508)','oklch(86.9% 0.022 252.894)','oklch(70.4% 0.04 256.788)','oklch(55.4% 0.046 257.417)','oklch(44.6% 0.043 257.281)','oklch(37.2% 0.044 257.287)','oklch(27.9% 0.041 260.031)','oklch(20.8% 0.042 265.755)','oklch(12.9% 0.042 264.695)'],
306
+ gray: ['oklch(98.5% 0.002 247.839)','oklch(96.7% 0.003 264.542)','oklch(92.8% 0.006 264.531)','oklch(87.2% 0.01 258.338)','oklch(70.7% 0.022 261.325)','oklch(55.1% 0.027 264.364)','oklch(44.6% 0.03 256.802)','oklch(37.3% 0.034 259.733)','oklch(27.8% 0.033 256.848)','oklch(21% 0.034 264.665)','oklch(13% 0.028 261.692)'],
307
+ zinc: ['oklch(98.5% 0 0)','oklch(96.7% 0.001 286.375)','oklch(92% 0.004 286.32)','oklch(87.1% 0.006 286.286)','oklch(70.5% 0.015 286.067)','oklch(55.2% 0.016 285.938)','oklch(44.2% 0.017 285.786)','oklch(37% 0.013 285.805)','oklch(27.4% 0.006 286.033)','oklch(21% 0.006 285.885)','oklch(14.1% 0.005 285.823)'],
308
+ neutral: ['oklch(98.5% 0 0)','oklch(97% 0 0)','oklch(92.2% 0 0)','oklch(87% 0 0)','oklch(70.8% 0 0)','oklch(55.6% 0 0)','oklch(43.9% 0 0)','oklch(37.1% 0 0)','oklch(26.9% 0 0)','oklch(20.5% 0 0)','oklch(14.5% 0 0)'],
309
+ stone: ['oklch(98.5% 0.001 106.423)','oklch(97% 0.001 106.424)','oklch(92.3% 0.003 48.717)','oklch(86.9% 0.005 56.366)','oklch(70.9% 0.01 56.259)','oklch(55.3% 0.013 58.071)','oklch(44.4% 0.011 73.639)','oklch(37.4% 0.01 67.558)','oklch(26.8% 0.007 34.298)','oklch(21.6% 0.006 56.043)','oklch(14.7% 0.004 49.25)'],
310
+ mauve: ['oklch(98.5% 0 0)','oklch(96% 0.003 325.6)','oklch(92.2% 0.005 325.62)','oklch(86.5% 0.012 325.68)','oklch(71.1% 0.019 323.02)','oklch(54.2% 0.034 322.5)','oklch(43.5% 0.029 321.78)','oklch(36.4% 0.029 323.89)','oklch(26.3% 0.024 320.12)','oklch(21.2% 0.019 322.12)','oklch(14.5% 0.008 326)'],
311
+ olive: ['oklch(98.8% 0.003 106.5)','oklch(96.6% 0.005 106.5)','oklch(93% 0.007 106.5)','oklch(88% 0.011 106.6)','oklch(73.7% 0.021 106.9)','oklch(58% 0.031 107.3)','oklch(46.6% 0.025 107.3)','oklch(39.4% 0.023 107.4)','oklch(28.6% 0.016 107.4)','oklch(22.8% 0.013 107.4)','oklch(15.3% 0.006 107.1)'],
312
+ mist: ['oklch(98.7% 0.002 197.1)','oklch(96.3% 0.002 197.1)','oklch(92.5% 0.005 214.3)','oklch(87.2% 0.007 219.6)','oklch(72.3% 0.014 214.4)','oklch(56% 0.021 213.5)','oklch(45% 0.017 213.2)','oklch(37.8% 0.015 216)','oklch(27.5% 0.011 216.9)','oklch(21.8% 0.008 223.9)','oklch(14.8% 0.004 228.8)'],
313
+ taupe: ['oklch(98.6% 0.002 67.8)','oklch(96% 0.002 17.2)','oklch(92.2% 0.005 34.3)','oklch(86.8% 0.007 39.5)','oklch(71.4% 0.014 41.2)','oklch(54.7% 0.021 43.1)','oklch(43.8% 0.017 39.3)','oklch(36.7% 0.016 35.7)','oklch(26.8% 0.011 36.5)','oklch(21.4% 0.009 43.1)','oklch(14.7% 0.004 49.3)']
314
+ };
315
+ function titleCase(s) {
316
+ return s.charAt(0).toUpperCase() + s.slice(1);
317
+ }
318
+ // Tailwind as a single group with one sub-palette per hue.
319
+ // `labels` (optional) localizes names: { _group, _base, _black, _white, red, orange, ... }.
320
+ // Missing labels fall back to English title-case.
321
+ function buildTailwindPreset(labels) {
322
+ const L = labels || {};
323
+ const groupName = L._group || 'Tailwind';
324
+ const sub = {};
325
+ for (const hue of TAILWIND_HUES) {
326
+ const shades = TAILWIND_COLORS[hue];
327
+ if (!shades) continue;
328
+ const paletteLabel = L[hue] || titleCase(hue);
329
+ sub[paletteLabel] = shades.map((value, i) => ({
330
+ name: paletteLabel + ' ' + TAILWIND_SHADES[i],
331
+ value
332
+ }));
333
+ }
334
+ sub[L._base || 'Base'] = [
335
+ { name: L._black || 'Black', value: '#000' },
336
+ { name: L._white || 'White', value: '#fff' }
337
+ ];
338
+ return { [groupName]: sub };
339
+ }
340
+
341
+ // iOS system colors (light + dark, exact values from Apple HIG, iOS 15+)
342
+ // Returns one group per color with the light/dark pair, plus a system-gray group.
343
+ const IOS_SYSTEM_COLORS = [
344
+ { name: 'red', light: '#FF3B30', dark: '#FF453A' },
345
+ { name: 'orange', light: '#FF9500', dark: '#FF9F0A' },
346
+ { name: 'yellow', light: '#FFCC00', dark: '#FFD60A' },
347
+ { name: 'green', light: '#34C759', dark: '#30D158' },
348
+ { name: 'mint', light: '#00C7BE', dark: '#63E6E2' },
349
+ { name: 'teal', light: '#30B0C7', dark: '#40CBE0' },
350
+ { name: 'cyan', light: '#32ADE6', dark: '#64D2FF' },
351
+ { name: 'blue', light: '#007AFF', dark: '#0A84FF' },
352
+ { name: 'indigo', light: '#5856D6', dark: '#5E5CE6' },
353
+ { name: 'purple', light: '#AF52DE', dark: '#BF5AF2' },
354
+ { name: 'pink', light: '#FF2D55', dark: '#FF375F' },
355
+ { name: 'brown', light: '#A2845E', dark: '#AC8E68' }
356
+ ];
357
+ const IOS_SYSTEM_GRAYS = [
358
+ { name: 'gray', light: '#8E8E93', dark: '#8E8E93' },
359
+ { name: 'gray2', light: '#AEAEB2', dark: '#636366' },
360
+ { name: 'gray3', light: '#C7C7CC', dark: '#48484A' },
361
+ { name: 'gray4', light: '#D1D1D6', dark: '#3A3A3C' },
362
+ { name: 'gray5', light: '#E5E5EA', dark: '#2C2C2E' },
363
+ { name: 'gray6', light: '#F2F2F7', dark: '#1C1C1E' }
364
+ ];
365
+ // iOS as a single group with 4 sub-palettes. `labels` localizes:
366
+ // { _group, _colorLight, _colorDark, _shadesLight, _shadesDark, red, orange, ..., gray, gray2, ... }
367
+ function buildIosPreset(labels) {
368
+ const L = labels || {};
369
+ const groupName = L._group || 'iOS';
370
+ // Swatch names are prefixed with the tone ("Light - " / "Dark - ") so users
371
+ // can still distinguish them by name once a color lands in Recent (where
372
+ // palette context is lost). The `_lightPrefix` / `_darkPrefix` meta keys
373
+ // let devs translate or remove the prefixes.
374
+ const lightPrefix = L._lightPrefix != null ? L._lightPrefix : 'Light - ';
375
+ const darkPrefix = L._darkPrefix != null ? L._darkPrefix : 'Dark - ';
376
+ const baseColorName = (c) => L[c.name] || titleCase(c.name);
377
+ const formatGray = (name) => {
378
+ if (L[name]) return L[name];
379
+ const m = name.match(/^(gray)(\d+)?$/);
380
+ return m ? (titleCase(m[1]) + (m[2] ? ' ' + m[2] : '')) : titleCase(name);
381
+ };
382
+ // Each tone gets one palette: system colors first, then grayscale shades.
383
+ const light = [
384
+ ...IOS_SYSTEM_COLORS.map(c => ({ name: lightPrefix + baseColorName(c), value: c.light })),
385
+ ...IOS_SYSTEM_GRAYS.map(g => ({ name: lightPrefix + formatGray(g.name), value: g.light }))
386
+ ];
387
+ const dark = [
388
+ ...IOS_SYSTEM_COLORS.map(c => ({ name: darkPrefix + baseColorName(c), value: c.dark })),
389
+ ...IOS_SYSTEM_GRAYS.map(g => ({ name: darkPrefix + formatGray(g.name), value: g.dark }))
390
+ ];
391
+ return {
392
+ [groupName]: {
393
+ [L._light || 'Light']: light,
394
+ [L._dark || 'Dark']: dark
395
+ }
396
+ };
397
+ }
398
+
399
+ // ---- Library data normalization ----
400
+
401
+ // Normalize into: Group[] where each Group = { name?, colors?: Swatch[], palettes?: Palette[], ...extras }
402
+ // A Group has EITHER flat `colors` OR nested `palettes`, never both. Each Palette = { name?, colors: Swatch[], ...extras }.
403
+ // Swatch = { name?, value, ...extras }. Hierarchy is strictly Group > Palette > Swatch (no deeper nesting).
404
+ // Filter proxy/magic noise: drop $-prefixed keys (Manifest magic like $route, $x, $watch),
405
+ // symbol keys, function values, and the few inherited Object.prototype names that can leak
406
+ // through exotic proxies.
407
+ const _LIB_PROTO_NOISE = new Set(['valueOf', 'toString', 'constructor', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString']);
408
+ function _cleanLibraryEntries(input) {
409
+ return Object.entries(input).filter(([k, v]) => {
410
+ if (typeof k !== 'string') return false;
411
+ if (k.startsWith('$')) return false; // Manifest magic ($route, $locale, $x, ...)
412
+ if (k.startsWith('_')) return false; // Manifest data metadata (_loadedFrom, _locale, _sourceType, ...)
413
+ if (k === 'contentType') return false; // Manifest fetched-resource metadata
414
+ if (_LIB_PROTO_NOISE.has(k)) return false;
415
+ if (v == null) return false;
416
+ if (typeof v === 'function') return false;
417
+ return true;
418
+ });
419
+ }
420
+
421
+ function normalizeLibraryInput(input) {
422
+ if (input == null) return [];
423
+ if (Array.isArray(input)) {
424
+ const firstObjChild = input.find(x => x && typeof x === 'object' && !Array.isArray(x));
425
+ const looksLikeGroups = firstObjChild && ('colors' in firstObjChild || 'items' in firstObjChild || 'palettes' in firstObjChild || 'subgroups' in firstObjChild);
426
+ if (looksLikeGroups) return input.map(g => normalizeGroup(g, g.name));
427
+ return [{ colors: normalizeColorList(input) }];
428
+ }
429
+ if (typeof input === 'object') {
430
+ return _cleanLibraryEntries(input).map(([k, v]) => normalizeGroup(v, k));
431
+ }
432
+ return [];
433
+ }
434
+
435
+ function normalizeGroup(input, groupName) {
436
+ if (input == null) return { name: groupName, colors: [] };
437
+
438
+ // Explicit metadata form
439
+ if (!Array.isArray(input) && typeof input === 'object'
440
+ && ('colors' in input || 'items' in input || 'palettes' in input || 'subgroups' in input)) {
441
+ const out = { ...input, name: input._group || input.name || groupName };
442
+ const palettesSrc = input.palettes || input.subgroups;
443
+ if (palettesSrc) {
444
+ out.palettes = (palettesSrc || []).map(p => normalizePalette(p, p?.name));
445
+ delete out.subgroups;
446
+ } else {
447
+ out.colors = normalizeColorList(input.colors || input.items || []);
448
+ }
449
+ return out;
450
+ }
451
+
452
+ // Array → flat colors list
453
+ if (Array.isArray(input)) {
454
+ return { name: groupName, colors: normalizeColorList(input) };
455
+ }
456
+
457
+ // Object — `_group` meta overrides heading, nested values = palettes, string values = flat colors
458
+ if (typeof input === 'object') {
459
+ const resolvedName = (typeof input._group === 'string') ? input._group : groupName;
460
+ const entries = _cleanLibraryEntries(input);
461
+ const hasNestedValues = entries.some(([, v]) => v && typeof v === 'object');
462
+ if (hasNestedValues) {
463
+ return {
464
+ name: resolvedName,
465
+ palettes: entries.map(([k, v]) => normalizePalette(v, k))
466
+ };
467
+ }
468
+ return { name: resolvedName, colors: normalizeColorList(Object.fromEntries(entries)) };
469
+ }
470
+
471
+ return { name: groupName, colors: [] };
472
+ }
473
+
474
+ // Merge built-in Recent + Tailwind + iOS into a single default data object.
475
+ // Used when `x-colorpicker.library` has no expression AND no ancestor $x data.
476
+ // Optional `labels` is forwarded to the Tailwind / iOS builders for localization.
477
+ function buildDefaultLibrary(labels) {
478
+ const L = labels || {};
479
+ return {
480
+ [L._recent || 'Recent']: _recentStore.list.slice(0, _recentMax),
481
+ ...buildTailwindPreset(L.tailwind),
482
+ ...buildIosPreset(L.ios)
483
+ };
484
+ }
485
+
486
+ // ---- Auto-discovery of colorpicker data sources ----
487
+ //
488
+ // Devs register data sources in manifest.json using the standard Manifest conventions,
489
+ // then flag which ones contain color library content via a top-level `colorpicker` entry:
490
+ //
491
+ // {
492
+ // "data": {
493
+ // "myColors": "/data/colors.yaml" // non-localized
494
+ // "brand": { "en": "/data/brand.en.yaml", "fr": "..." } // per-locale
495
+ // },
496
+ // "colorpicker": "myColors" // single source
497
+ // "colorpicker": ["myColors", "brand"] // multiple sources
498
+ // }
499
+ //
500
+ // The colorpicker plugin scans $x.manifest.data for entries flagged with a
501
+ // `colorpicker:` key, then merges the loaded $x.<name> values in declaration
502
+ // order. Each source may contain:
503
+ // _tailwind: (optional) replaces the built-in Tailwind group entirely
504
+ // _ios: (optional) replaces the built-in iOS group entirely
505
+ // <Any Name>: custom groups appended to the library after built-ins
506
+ //
507
+ // Presence of _tailwind / _ios is "all or nothing" — no merge with built-in data.
508
+ // If multiple flagged sources both include _tailwind, the LAST source wins.
509
+
510
+ // Manifest's data proxy returns an empty object for ANY property access (graceful chain),
511
+ // so `src._tailwind` always looks truthy. We only treat an override as real if the source
512
+ // actually declared the key (`in` operator) AND the value has real content.
513
+ function _hasRealContent(v) {
514
+ if (v == null || typeof v !== 'object') return false;
515
+ return Object.keys(v).some(k => !k.startsWith('$')
516
+ && k !== 'valueOf' && k !== 'toString'
517
+ && k !== 'contentType');
518
+ }
519
+
520
+ // Takes an array of resolved source values, extracts _tailwind / _ios overrides and
521
+ // custom groups from each, and returns the final normalized groups array.
522
+ function composeLibraryFromSources(sources) {
523
+ let tailwindOverride = null;
524
+ let iosOverride = null;
525
+ const customGroups = [];
526
+
527
+ for (const src of sources) {
528
+ if (src == null || typeof src !== 'object' || Array.isArray(src)) continue;
529
+
530
+ // Extract override blocks only if actually declared in source AND have real content.
531
+ if ('_tailwind' in src && _hasRealContent(src._tailwind)) tailwindOverride = src._tailwind;
532
+ if ('_ios' in src && _hasRealContent(src._ios)) iosOverride = src._ios;
533
+
534
+ // All other (non-reserved, non-metadata) keys become custom groups
535
+ for (const [k, v] of _cleanLibraryEntries(src)) {
536
+ if (k === '_tailwind' || k === '_ios') continue;
537
+ if (!_hasRealContent(v)) continue;
538
+ customGroups.push(normalizeGroup(v, k));
539
+ }
540
+ }
541
+
542
+ // Assemble final group order: Recent → custom → Tailwind → iOS.
543
+ // Recent may be empty (pre-use state) — we render the group only when it has content.
544
+ const recent = _recentStore.list.slice(0, _recentMax);
545
+ const groups = [];
546
+ if (recent.length) {
547
+ const recentGroup = normalizeGroup(recent, 'Recent');
548
+ recentGroup._isRecent = true; // marker — renderer picks the `library-recent-swatch` template over `library-swatch`
549
+ groups.push(recentGroup);
550
+ }
551
+
552
+ groups.push(...customGroups);
553
+
554
+ if (tailwindOverride) {
555
+ // `_group` meta inside the override becomes the group heading; default to "Tailwind"
556
+ groups.push(normalizeGroup(tailwindOverride, tailwindOverride._group || 'Tailwind'));
557
+ } else {
558
+ groups.push(...normalizeLibraryInput(buildTailwindPreset()));
559
+ }
560
+
561
+ if (iosOverride) {
562
+ groups.push(normalizeGroup(iosOverride, iosOverride._group || 'iOS'));
563
+ } else {
564
+ groups.push(...normalizeLibraryInput(buildIosPreset()));
565
+ }
566
+
567
+ return groups;
568
+ }
569
+
570
+ // Walk a normalized groups array and resolve any reference-style strings in
571
+ // user-facing labels (group / palette / swatch names) against Alpine's scope.
572
+ // Two reference shapes are recognized:
573
+ // • Bare path: "$x.colorLabels.primary" → Alpine.evaluate(...)
574
+ // • Template: "${$locale.t('brand.primary')}" → Alpine.evaluate(...) as a literal
575
+ // Plain strings pass through unchanged. Failed lookups return the original
576
+ // string so a missing key surfaces as-is rather than rendering empty.
577
+ //
578
+ // Reading via Alpine.evaluate inside the surrounding render effect registers
579
+ // reactive deps on the referenced data — locale switches and content updates
580
+ // re-trigger the render automatically.
581
+ function _resolveLibraryRefs(groups) {
582
+ if (!Array.isArray(groups) || groups.length === 0) return groups;
583
+ const ctx = document.body;
584
+ const resolve = (val) => {
585
+ if (typeof val !== 'string' || val.length === 0) return val;
586
+ const trimmed = val.trim();
587
+ const isBareRef = trimmed.startsWith('$x.') || trimmed.startsWith('$locale')
588
+ || trimmed.startsWith('$x[') || trimmed.startsWith('$locale[');
589
+ const hasInterp = /\$\{[^}]+\}/.test(trimmed);
590
+ if (!isBareRef && !hasInterp) return val;
591
+ try {
592
+ if (window.Alpine?.evaluate) {
593
+ const expr = isBareRef && !hasInterp ? trimmed : '`' + trimmed + '`';
594
+ const out = Alpine.evaluate(ctx, expr);
595
+ if (out == null) return val;
596
+ return typeof out === 'string' ? out : String(out);
597
+ }
598
+ } catch {}
599
+ return val;
600
+ };
601
+ for (const g of groups) {
602
+ if (g && typeof g.name === 'string') g.name = resolve(g.name);
603
+ if (Array.isArray(g?.palettes)) {
604
+ for (const p of g.palettes) {
605
+ if (p && typeof p.name === 'string') p.name = resolve(p.name);
606
+ if (Array.isArray(p?.colors)) {
607
+ for (const c of p.colors) {
608
+ if (c && typeof c.name === 'string') c.name = resolve(c.name);
609
+ }
610
+ }
611
+ }
612
+ }
613
+ if (Array.isArray(g?.colors)) {
614
+ for (const c of g.colors) {
615
+ if (c && typeof c.name === 'string') c.name = resolve(c.name);
616
+ }
617
+ }
618
+ }
619
+ return groups;
620
+ }
621
+
622
+ // Returns an object that is BOTH callable (for localization) AND spreadable (for composition).
623
+ // `builder` is a function that takes optional labels and returns a preset object.
624
+ // Spreading the returned value exposes the default (unlabeled) preset's top-level keys.
625
+ function _makeCallablePreset(builder) {
626
+ const fn = (labels) => builder(labels);
627
+ Object.assign(fn, builder()); // default preset's keys become own enumerable props on fn
628
+ return fn;
629
+ }
630
+
631
+ // Render a group by cloning the group template and expanding nested templates in place.
632
+ // Returns a fragment with all top-level elements (scope applied to the first). `isRecent`
633
+ // threads down so palette/swatch layers can pick the Recent-specific swatch template.
634
+ function renderLibraryGroup(groupTpl, group) {
635
+ const frag = groupTpl.content.cloneNode(true);
636
+ const primary = frag.firstElementChild;
637
+ if (!primary) return frag;
638
+ primary.setAttribute('x-data', '{ group: ' + _jsonStringifyForAlpine(group) + ' }');
639
+
640
+ const isRecent = !!group._isRecent;
641
+
642
+ // Normalize: flat groups become single unnamed palette so templates work uniformly
643
+ const palettes = (group.palettes && group.palettes.length)
644
+ ? group.palettes
645
+ : (group.colors && group.colors.length ? [{ name: null, colors: group.colors }] : []);
646
+
647
+ const paletteTpl = frag.querySelector('template[x-colorpicker\\.library-palette]');
648
+ if (paletteTpl) {
649
+ const parent = paletteTpl.parentNode;
650
+ for (const p of palettes) parent.insertBefore(renderLibraryPalette(paletteTpl, p, isRecent), paletteTpl);
651
+ paletteTpl.remove();
652
+ } else {
653
+ // No palette template — look for a swatch template directly inside the group.
654
+ // Prefer the Recent-specific template for Recent groups, else fall back.
655
+ const swatchTpl = (isRecent ? frag.querySelector('template[x-colorpicker\\.library-recent-swatch]') : null)
656
+ || frag.querySelector('template[x-colorpicker\\.library-swatch]');
657
+ if (swatchTpl) {
658
+ const parent = swatchTpl.parentNode;
659
+ const allColors = palettes.flatMap(p => p.colors || []);
660
+ for (const sw of allColors) parent.insertBefore(renderLibrarySwatch(swatchTpl, sw), swatchTpl);
661
+ swatchTpl.remove();
662
+ }
663
+ }
664
+ return frag;
665
+ }
666
+
667
+ function renderLibraryPalette(paletteTpl, palette, isRecent) {
668
+ const frag = paletteTpl.content.cloneNode(true);
669
+ const primary = frag.firstElementChild;
670
+ if (!primary) return frag;
671
+ primary.setAttribute('x-data', '{ palette: ' + _jsonStringifyForAlpine(palette) + ' }');
672
+
673
+ // Recent palettes prefer library-recent-swatch template if defined.
674
+ const swatchTpl = (isRecent ? frag.querySelector('template[x-colorpicker\\.library-recent-swatch]') : null)
675
+ || frag.querySelector('template[x-colorpicker\\.library-swatch]');
676
+ if (swatchTpl) {
677
+ const parent = swatchTpl.parentNode;
678
+ for (const sw of (palette.colors || [])) parent.insertBefore(renderLibrarySwatch(swatchTpl, sw), swatchTpl);
679
+ swatchTpl.remove();
680
+ }
681
+ return frag;
682
+ }
683
+
684
+ // Per-clone counter for uniquifying nested dropdown/menu ids — mirrors the scheme
685
+ // used for gradient layer clones. Only menus that live INSIDE the swatch template
686
+ // get uniquified; menus placed at the library root are left alone (shared).
687
+ let _swatchCloneCounter = 0;
688
+
689
+ function renderLibrarySwatch(swatchTpl, swatch) {
690
+ const frag = swatchTpl.content.cloneNode(true);
691
+ const primary = frag.firstElementChild;
692
+ if (!primary) return frag;
693
+ // Apply the swatch scope to the primary element (typically the swatch button).
694
+ // Sibling elements like nested menus don't need swatch scope — their actions
695
+ // read from the dropdowns plugin's trigger ref.
696
+ primary.setAttribute('x-data', '{ swatch: ' + _jsonStringifyForAlpine(swatch) + ' }');
697
+ // Raw value + canonical key go on BOTH the actual apply-color element AND the
698
+ // primary wrapper (if different). That way `_updateActiveSwatches` can toggle
699
+ // `.active` on both, and dev CSS targeting either selector — the wrapper div
700
+ // (for layout effects like `order: -1`) or the button (for box-shadow) — works.
701
+ if (swatch && typeof swatch.value === 'string') {
702
+ const applyEl = frag.querySelector('[x-colorpicker\\.apply-color]');
703
+ const key = _swatchKeyOf(swatch.value);
704
+ const targets = new Set();
705
+ if (primary) targets.add(primary);
706
+ if (applyEl) targets.add(applyEl);
707
+ for (const t of targets) {
708
+ t.setAttribute('data-cp-value', swatch.value);
709
+ if (key) t.setAttribute('data-cp-key', key);
710
+ }
711
+ }
712
+ // Uniquify any nested dropdown/menu ids so per-swatch context menus don't
713
+ // collide. Skipped automatically when the menu lives outside the template.
714
+ uniquifyDropdownIdsIn(frag, 'swatch-' + (++_swatchCloneCounter));
715
+ return frag;
716
+ }
717
+
718
+ // Default fallback: if no library template supplied, render a small heading per group
719
+ // and a flex-wrap row of apply-color spans for each swatch.
720
+ function renderDefaultGroup(group) {
721
+ const root = document.createElement('div');
722
+ root.setAttribute('data-cp-library-group', group.name || '');
723
+ if (group.name) {
724
+ const h = document.createElement('small');
725
+ h.textContent = group.name;
726
+ root.appendChild(h);
727
+ }
728
+ const palettes = (group.palettes && group.palettes.length)
729
+ ? group.palettes
730
+ : (group.colors && group.colors.length ? [{ name: null, colors: group.colors }] : []);
731
+ for (const p of palettes) {
732
+ if (p.name) {
733
+ const ph = document.createElement('small');
734
+ ph.textContent = p.name;
735
+ root.appendChild(ph);
736
+ }
737
+ const row = document.createElement('div');
738
+ row.className = 'swatches';
739
+ for (const sw of (p.colors || [])) {
740
+ const span = document.createElement('span');
741
+ span.setAttribute('x-colorpicker.apply-color', '');
742
+ span.style.background = sw.value;
743
+ span.title = sw.name || sw.value;
744
+ row.appendChild(span);
745
+ }
746
+ root.appendChild(row);
747
+ }
748
+ return root;
749
+ }
750
+
751
+ function normalizePalette(input, paletteName) {
752
+ if (input == null) return { name: paletteName, colors: [] };
753
+ if (Array.isArray(input)) return { name: paletteName, colors: normalizeColorList(input) };
754
+ if (typeof input === 'object') {
755
+ // Meta key `_name` overrides the display name (lets devs keep object keys stable
756
+ // across locales while translating visible text).
757
+ const displayName = (typeof input._name === 'string') ? input._name : (input.name || paletteName);
758
+ // Explicit `_colors` / `colors` / `items` key holds the swatch list — use when
759
+ // you need to pair `_name` with an array of bare hex strings (YAML can't mix
760
+ // object keys and sequence items in the same node).
761
+ if ('_colors' in input || 'colors' in input || 'items' in input) {
762
+ return {
763
+ ...input,
764
+ name: displayName,
765
+ colors: normalizeColorList(input._colors || input.colors || input.items || [])
766
+ };
767
+ }
768
+ return { name: displayName, colors: normalizeColorList(input) };
769
+ }
770
+ return { name: paletteName, colors: [] };
771
+ }
772
+
773
+ // Accepts swatch forms:
774
+ // "#hex" → { value }
775
+ // { name, value } → plain
776
+ // { _name, _value } → meta-key form (localization-friendly)
777
+ function _coerceSwatch(item, fallbackName) {
778
+ if (item == null) return null;
779
+ if (typeof item === 'string') return fallbackName ? { name: fallbackName, value: item } : { value: item };
780
+ if (typeof item === 'object') {
781
+ const name = (typeof item._name === 'string') ? item._name : (item.name || fallbackName);
782
+ const value = (typeof item._value === 'string') ? item._value : item.value;
783
+ if (value == null) return null;
784
+ return name ? { name, value } : { value };
785
+ }
786
+ return null;
787
+ }
788
+
789
+ function normalizeColorList(input) {
790
+ if (input == null) return [];
791
+ if (Array.isArray(input)) {
792
+ return input.map(item => _coerceSwatch(item)).filter(Boolean);
793
+ }
794
+ if (typeof input === 'object') {
795
+ // Filter `_`-prefixed meta keys (e.g., `_name`, `_group`) so they're not mistaken for shades.
796
+ return Object.entries(input)
797
+ .filter(([k]) => !k.startsWith('_'))
798
+ .map(([name, value]) => _coerceSwatch(value, name))
799
+ .filter(Boolean);
800
+ }
801
+ return [];
802
+ }
803
+
804
+ // ---- Recent (cookie-backed, shared across all pickers) ----
805
+
806
+ const RECENT_COOKIE = 'manifest-colorpicker-recent';
807
+ let _recentMax = 10;
808
+ const _recentStore = window.Alpine?.reactive ? Alpine.reactive({ list: [] }) : { list: [] };
809
+
810
+ function loadRecent() {
811
+ try {
812
+ const match = document.cookie.split('; ').find(c => c.startsWith(RECENT_COOKIE + '='));
813
+ if (!match) return [];
814
+ const raw = decodeURIComponent(match.split('=')[1] || '');
815
+ const parsed = JSON.parse(raw);
816
+ return Array.isArray(parsed) ? parsed : [];
817
+ } catch { return []; }
818
+ }
819
+ function saveRecent() {
820
+ try {
821
+ const val = encodeURIComponent(JSON.stringify(_recentStore.list));
822
+ const exp = new Date(); exp.setFullYear(exp.getFullYear() + 1);
823
+ document.cookie = RECENT_COOKIE + '=' + val + '; path=/; expires=' + exp.toUTCString() + '; SameSite=Lax';
824
+ } catch {}
825
+ }
826
+ function pushRecent(value) {
827
+ if (!value || typeof value !== 'string') return;
828
+ const v = value.trim();
829
+ if (!v) return;
830
+ const list = _recentStore.list;
831
+ const existingIdx = list.indexOf(v);
832
+ if (existingIdx !== -1) list.splice(existingIdx, 1);
833
+ list.unshift(v);
834
+ while (list.length > _recentMax) list.pop();
835
+ saveRecent();
836
+ }
837
+ function clearRecent() { _recentStore.list = []; saveRecent(); }
838
+ function removeRecent(value) {
839
+ const idx = _recentStore.list.indexOf(value);
840
+ if (idx !== -1) { _recentStore.list.splice(idx, 1); saveRecent(); }
841
+ }
842
+ // Initial load from cookie
843
+ _recentStore.list = loadRecent();
844
+
845
+ // ---- Default fallback templates (used when developer doesn't supply their own) ----
846
+
847
+ // Solid-options panel — used both as a top-level tab body and as the
848
+ // accordion content under each gradient stop.
849
+ const DEFAULT_SOLID_TEMPLATE_HTML = `
850
+ <div>
851
+ <div class="canvas-wrapper">
852
+ <canvas x-colorpicker.set-canvas></canvas>
853
+ <div class="color-reticle"></div>
854
+ </div>
855
+ <div class="solid-options-inputs">
856
+ <input type="range" x-colorpicker.set-hue class="hue" />
857
+ <input type="range" x-colorpicker.set-alpha class="alpha" />
858
+ <div>
859
+ <button x-dropdown="color-space-menu" x-colorpicker.set-color-space class="ghost sm" aria-label="Color space"></button>
860
+ <menu popover id="color-space-menu">
861
+ <li x-colorpicker.set-color-space="hex">Hex</li>
862
+ <li x-colorpicker.set-color-space="rgb">RGB</li>
863
+ <li x-colorpicker.set-color-space="hsl">HSL</li>
864
+ <li x-colorpicker.set-color-space="oklch">OKLCH</li>
865
+ <hr>
866
+ <li x-colorpicker.grab-color><span x-icon class="color-icon-grab"></span><span>Grab color</span></li>
867
+ </menu>
868
+ <input type="text" x-colorpicker.set-color-value class="ghost sm" onClick="this.select()" />
869
+ <input type="number" x-colorpicker.set-alpha-value class="ghost sm no-spinner" min="0" max="100" step="1" onClick="this.select()" />
870
+ </div>
871
+ </div>
872
+ </div>
873
+ `;
874
+
875
+ // Gradient layer template — one of these per layer clone. Includes the
876
+ // gradient-type dropdown (with reactive icon class), angle input, stops bar
877
+ // with right-click context menu (Duplicate/Delete + inline library), and
878
+ // the per-stop solid-panel accordion.
879
+ const DEFAULT_LAYER_TEMPLATE_HTML = `
880
+ <div>
881
+ <div class="layer-options-wrapper">
882
+ <div class="layer-options-inputs">
883
+ <button class="ghost sm" x-dropdown="gradient-layer-options" aria-label="Layer options"><span :class="'gradient-layer-icon-' + layerType" x-icon></span></button>
884
+ <menu popover id="gradient-layer-options">
885
+ <li x-colorpicker.set-gradient-type="linear">
886
+ <span x-icon class="gradient-layer-icon-linear"></span><span>Linear Gradient</span>
887
+ </li>
888
+ <li x-colorpicker.set-gradient-type="radial">
889
+ <span x-icon class="gradient-layer-icon-radial"></span><span>Radial Gradient</span>
890
+ </li>
891
+ <li x-colorpicker.set-gradient-type="conic">
892
+ <span x-icon class="gradient-layer-icon-conic"></span><span>Conic Gradient</span>
893
+ </li>
894
+ <hr>
895
+ <li x-colorpicker.rotate-layer>Rotate 90°</li>
896
+ <li x-colorpicker.flip-layer>Flip Direction</li>
897
+ <hr>
898
+ <li x-colorpicker.add-layer-above>Add Above</li>
899
+ <li x-colorpicker.add-layer-below>Add Below</li>
900
+ <li x-colorpicker.duplicate-layer>Duplicate</li>
901
+ <hr>
902
+ <li x-colorpicker.move-layer-up :disabled="layerIndex === 0">Move Up</li>
903
+ <li x-colorpicker.move-layer-down :disabled="layerIndex === layerCount - 1">Move Down</li>
904
+ <hr>
905
+ <li x-colorpicker.remove-layer :disabled="layerCount === 1" class="negative">Remove</li>
906
+ </menu>
907
+
908
+ <div class="layer-angle-wrapper">
909
+ <input type="number" x-colorpicker.set-angle class="ghost sm no-spinner" min="0" max="360" onclick="select()" />
910
+ <span>°</span>
911
+ </div>
912
+
913
+ <div x-colorpicker.layer-stops-bar class="gradient-layer" x-dropdown.context="stop-context-menu"></div>
914
+ <menu popover id="stop-context-menu" class="stop-context-menu">
915
+ <li x-colorpicker.duplicate-stop>Duplicate</li>
916
+ <li x-colorpicker.delete-stop class="negative">Delete</li>
917
+ <hr>
918
+ <div x-colorpicker.library></div>
919
+ </menu>
920
+ </div>
921
+
922
+ <div x-colorpicker.solid></div>
923
+ </div>
924
+ </div>
925
+ `;
926
+
927
+ // Gradient panel — the entire Gradient tab body. Hosts the layer template
928
+ // (cloned per layer), the layers container, and the editable CSS string.
929
+ const DEFAULT_GRADIENT_TEMPLATE_HTML = `
930
+ <div>
931
+ <div x-colorpicker.gradient-layers></div>
932
+ <template x-colorpicker.layer-options>
933
+ ${DEFAULT_LAYER_TEMPLATE_HTML}
934
+ </template>
935
+ <div class="gradient-value-wrapper">
936
+ <textarea x-colorpicker.set-gradient-value class="ghost sm" spellcheck="false" rows="3" onclick="select()"></textarea>
937
+ </div>
938
+ </div>
939
+ `;
940
+
941
+ // Full default UI — used when an x-colorpicker has no children and no
942
+ // declared templates. Mirrors the canonical custom-picker structure:
943
+ // tabs row, three tab bodies, three matching templates.
944
+ const DEFAULT_FULL_UI_HTML = `
945
+ <div x-data="{ tab: 'solid' }">
946
+
947
+ <div class="tabs-wrapper" data-cp-tabs>
948
+ <button data-cp-tab="solid" class="ghost sm" :class="tab === 'solid' && 'selected'" @click="tab = 'solid'" x-tooltip="Solid" aria-label="Solid"><span x-icon class="color-icon-solid"></span></button>
949
+ <button data-cp-tab="gradient" class="ghost sm" :class="tab === 'gradient' && 'selected'" @click="tab = 'gradient'" x-tooltip="Gradient" aria-label="Gradient"><span x-icon class="color-icon-gradient"></span></button>
950
+ <button data-cp-tab="library" class="ghost sm" :class="tab === 'library' && 'selected'" @click="tab = 'library'" x-tooltip="Library" aria-label="Library"><span x-icon class="color-icon-library"></span></button>
951
+ </div>
952
+
953
+ <div data-cp-panel="solid" x-colorpicker.solid x-show="tab === 'solid'"></div>
954
+ <div data-cp-panel="gradient" x-colorpicker.gradient x-show="tab === 'gradient'"></div>
955
+ <div data-cp-panel="library" x-colorpicker.library x-show="tab === 'library'"></div>
956
+
957
+ <template x-colorpicker.solid>
958
+ ${DEFAULT_SOLID_TEMPLATE_HTML}
959
+ </template>
960
+
961
+ <template x-colorpicker.gradient>
962
+ ${DEFAULT_GRADIENT_TEMPLATE_HTML}
963
+ </template>
964
+ </div>
965
+ `;
966
+
967
+ // Parse default template HTML ONCE at module load. Subsequent uses clone the
968
+ // already-parsed DocumentFragment — avoids re-running the HTML parser per layer.
969
+ function parseOnce(html) {
970
+ const t = document.createElement('template');
971
+ t.innerHTML = html.trim();
972
+ return t;
973
+ }
974
+ const _defaultSolidTpl = parseOnce(DEFAULT_SOLID_TEMPLATE_HTML);
975
+ const _defaultLayerTpl = parseOnce(DEFAULT_LAYER_TEMPLATE_HTML);
976
+ const _defaultGradientTpl = parseOnce(DEFAULT_GRADIENT_TEMPLATE_HTML);
977
+ const _defaultFullUiTpl = parseOnce(DEFAULT_FULL_UI_HTML);
978
+
979
+ // Default library layout — group > palette > swatch nesting. Mirrors the
980
+ // canonical custom-picker library template. Used when no dev-provided
981
+ // <template x-colorpicker.library> is present.
982
+ // Recent swatches use the per-clone nested context-menu pattern (each
983
+ // Recent swatch carries its own uniquified menu via uniquifyDropdownIdsIn,
984
+ // matching the convention for layer dropdowns).
985
+ const DEFAULT_LIBRARY_LAYOUT_HTML = `
986
+ <div class="library-wrapper">
987
+
988
+ <template x-colorpicker.library-group>
989
+ <div class="library-group">
990
+ <small x-text="group.name"></small>
991
+
992
+ <template x-colorpicker.library-palette>
993
+ <div class="library-palette">
994
+
995
+ <template x-colorpicker.library-swatch>
996
+ <button x-colorpicker.apply-color :style="\`background: \${swatch.value}\`" x-tooltip="\`\${swatch.name || swatch.value}\`"></button>
997
+ </template>
998
+
999
+ <template x-colorpicker.library-recent-swatch>
1000
+ <div>
1001
+ <button x-colorpicker.apply-color x-dropdown.context="recent-menu" :style="\`background: \${swatch.value}\`" x-tooltip="\`\${swatch.name || swatch.value}\`"></button>
1002
+ <menu popover id="recent-menu">
1003
+ <li x-colorpicker.remove-recent>Remove</li>
1004
+ </menu>
1005
+ </div>
1006
+ </template>
1007
+
1008
+ </div>
1009
+ </template>
1010
+ </div>
1011
+ </template>
1012
+
1013
+ </div>
1014
+ `;
1015
+ const _defaultLibraryLayoutTpl = parseOnce(DEFAULT_LIBRARY_LAYOUT_HTML);
1016
+
1017
+ // ---- Per-picker state ----
1018
+
1019
+ let pickerCounter = 0;
1020
+
1021
+ // Reactive registry of pickers keyed by element ID.
1022
+ // Consumers read via the magic; reads are tracked even for not-yet-registered IDs,
1023
+ // so bindings resolve correctly when a picker mounts later in the DOM.
1024
+ const _pickerRegistry = window.Alpine?.reactive ? Alpine.reactive({}) : {};
1025
+
1026
+ // Fallback API used when a picker ID isn't registered yet; coerces to empty string.
1027
+ const _nullApi = (() => {
1028
+ const noop = () => {};
1029
+ const empty = () => '';
1030
+ return {
1031
+ hex: '', formatted: '', css: '',
1032
+ h: 0, s: 0, v: 100, a: 1,
1033
+ format: 'hex', pickerMode: 'solid',
1034
+ layers: [], activeLayer: null, activeStop: null,
1035
+ activeLayerIndex: 0, activeStopIndex: 0, openStop: null,
1036
+ [Symbol.toPrimitive]: empty, toString: empty, valueOf: empty,
1037
+ addLayer: noop, duplicateLayer: noop, removeLayer: noop,
1038
+ flipLayer: noop, rotateLayer: noop,
1039
+ addStop: noop, duplicateStop: noop, deleteStop: noop,
1040
+ setGradientType: noop, applyColor: noop, grabColor: noop,
1041
+ setHue: noop, setAlpha: noop, setAlphaValue: noop,
1042
+ setColorSpace: noop, setColorValue: noop, setAngle: noop, setGradientValue: noop,
1043
+ selectStop: noop, toggleStop: noop,
1044
+ setFromString: () => false, toFormattedString: empty, toHex: empty
1045
+ };
1046
+ })();
1047
+
1048
+ function createPickerState(rootEl) {
1049
+ pickerCounter++;
1050
+ const pickerUid = 'cp-' + pickerCounter;
1051
+
1052
+ const state = {
1053
+ rootEl,
1054
+ pickerUid,
1055
+
1056
+ // Data
1057
+ solidColor: { h: 0, s: 0, v: 100, a: 1 },
1058
+ solidFormat: 'hex',
1059
+ layers: [ makeDefaultLayer() ],
1060
+ activeLayerIndex: 0,
1061
+ activeStopIndex: 0,
1062
+ pickerMode: 'solid',
1063
+ openStop: null, // { layerIndex, stopIndex } | null
1064
+
1065
+ // Tab/panel filtering. When set (via array-literal directive expression
1066
+ // on the root, e.g. `x-colorpicker="['solid', 'gradient']"`), the default
1067
+ // injected UI is filtered + reordered to match. null/empty = all panels.
1068
+ allowedPanels: null,
1069
+
1070
+ // Elements that act as live labels for the current solid format (e.g. a
1071
+ // dropdown button whose text reads "Hex"/"RGB"/"HSL"/"OKLCH"). Refreshed
1072
+ // whenever the format changes via _refreshFormatLabels().
1073
+ formatLabelEls: [],
1074
+ // Elements bound to a specific format value (e.g. <li set-color-space="hex">).
1075
+ // Refreshed alongside the labels to toggle an `active` class on the current.
1076
+ formatChoiceEls: [],
1077
+
1078
+ // Element registry (populated by child directive handlers)
1079
+ hiddenInput: null,
1080
+ triggerBtn: null,
1081
+ solidTemplate: null, // <template x-colorpicker.solid>
1082
+ gradientTemplate: null, // <template x-colorpicker.gradient>
1083
+ layerTemplate: null, // <template x-colorpicker.layer-options>
1084
+ solidInstances: [], // <div x-colorpicker.solid> (not template)
1085
+ gradientInstances: [], // <div x-colorpicker.gradient> (not template)
1086
+ layersContainer: null, // <div x-colorpicker.gradient-layers>
1087
+ gradientValueInputs: [], // <textarea x-colorpicker.set-gradient-value>
1088
+
1089
+ // Library
1090
+ libraryContainers: [], // <div x-colorpicker.library> — every registered target (top-level tab, nested stop menus, etc.)
1091
+ libraryTemplate: null, // <template x-colorpicker.library> — dev layout (cloned into container)
1092
+ libraryRootValue: null, // expression from x-colorpicker.library="..." (data source)
1093
+
1094
+ // Recent-list commit tracking. `_recentBaseline` is the color at the start of
1095
+ // a commit cycle (popover open, or inline picker init / last commit).
1096
+ // `_lastChangeFromLibrary` is true if the most recent user change was picking
1097
+ // a preset swatch — those don't count as "recent" even if committed.
1098
+ // `_hasUserChange` is true if any non-library user interaction has happened
1099
+ // since the baseline was set.
1100
+ _recentBaseline: null,
1101
+ _lastChangeFromLibrary: false,
1102
+ _hasUserChange: false,
1103
+
1104
+ // The currently active solid-controls refs (inside Solid tab OR open-stop accordion)
1105
+ activeControls: null,
1106
+ solidTabRefs: null,
1107
+
1108
+ // Reactive version counter — bumped on every state change. The API
1109
+ // getters read this to establish a reactive dependency, then compute
1110
+ // values lazily. No upfront work when nothing is bound to $colorpicker(id).
1111
+ snapshot: window.Alpine?.reactive ? Alpine.reactive({ version: 0 }) : { version: 0 },
1112
+
1113
+ // ---- Active target accessors ----
1114
+
1115
+ get isGradient() { return this.pickerMode === 'gradient'; },
1116
+ activeLayer() { return this.layers[this.activeLayerIndex] || this.layers[0]; },
1117
+ activeStop() {
1118
+ const layer = this.activeLayer();
1119
+ return layer.stops[this.activeStopIndex] || layer.stops[0];
1120
+ },
1121
+
1122
+ get h() { return this.isGradient ? this.activeStop().color.h : this.solidColor.h; },
1123
+ set h(v) { if (this.isGradient) this.activeStop().color.h = v; else this.solidColor.h = v; },
1124
+ get s() { return this.isGradient ? this.activeStop().color.s : this.solidColor.s; },
1125
+ set s(v) { if (this.isGradient) this.activeStop().color.s = v; else this.solidColor.s = v; },
1126
+ get v() { return this.isGradient ? this.activeStop().color.v : this.solidColor.v; },
1127
+ set v(val) { if (this.isGradient) this.activeStop().color.v = val; else this.solidColor.v = val; },
1128
+ get a() { return this.isGradient ? this.activeStop().color.a : this.solidColor.a; },
1129
+ set a(v) { if (this.isGradient) this.activeStop().color.a = v; else this.solidColor.a = v; },
1130
+
1131
+ get format() { return this.isGradient ? (this.activeStop().format || 'hex') : this.solidFormat; },
1132
+ set format(v) {
1133
+ if (this.isGradient) this.activeStop().format = v;
1134
+ else this.solidFormat = v;
1135
+ },
1136
+
1137
+ toRgb() { return hsvToRgb(this.h, this.s, this.v); },
1138
+ toHex() { const {r,g,b} = this.toRgb(); return rgbToHex(r,g,b); },
1139
+ toFormattedString() {
1140
+ if (this.isGradient) return buildFullGradientString(this.layers);
1141
+ const {r,g,b} = this.toRgb();
1142
+ return formatColor(r, g, b, this.a, this.solidFormat);
1143
+ },
1144
+ toActiveColorString() {
1145
+ const {r,g,b} = this.toRgb();
1146
+ return formatColor(r, g, b, this.a, this.format);
1147
+ },
1148
+ toSwatchColor() {
1149
+ if (this.isGradient) return buildFullGradientString(this.layers);
1150
+ const {r,g,b} = this.toRgb();
1151
+ if (this.a < 1) return `rgba(${r},${g},${b},${this.a})`;
1152
+ return this.toHex();
1153
+ },
1154
+
1155
+ // ---- Mutators ----
1156
+
1157
+ setFromString(str) {
1158
+ if (typeof str !== 'string') return false;
1159
+ // Gradient input → parse into layers + activate gradient mode.
1160
+ if (/gradient\s*\(/i.test(str)) {
1161
+ const layers = parseGradientString(str);
1162
+ if (!layers || !layers.length) return false;
1163
+ this.layers = layers;
1164
+ this.activeLayerIndex = 0;
1165
+ this.activeStopIndex = 0;
1166
+ this.openStop = null;
1167
+ // Seed the picker's working solid color from the first stop so
1168
+ // syncUI / hex output / canvas reflect something coherent.
1169
+ const firstStop = layers[0].stops[0];
1170
+ if (firstStop) {
1171
+ this.h = firstStop.color.h; this.s = firstStop.color.s;
1172
+ this.v = firstStop.color.v; this.a = firstStop.color.a;
1173
+ }
1174
+ if (this.pickerMode !== 'gradient') this.pickerMode = 'gradient';
1175
+ if (this.layersContainer) this.renderLayers();
1176
+ return true;
1177
+ }
1178
+ // Solid input → existing behavior.
1179
+ const parsed = parseCssColor(str);
1180
+ if (!parsed) return false;
1181
+ const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b);
1182
+ this.h = hsv.h; this.s = hsv.s; this.v = hsv.v; this.a = parsed.a;
1183
+ const fmt = detectFormat(str);
1184
+ if (fmt) this.format = fmt;
1185
+ return true;
1186
+ },
1187
+
1188
+ selectStop(layerIndex, stopIndex) {
1189
+ this.activeLayerIndex = layerIndex;
1190
+ this.activeStopIndex = stopIndex;
1191
+ },
1192
+
1193
+ toggleStop(layerIndex, stopIndex) {
1194
+ const same = this.openStop && this.openStop.layerIndex === layerIndex && this.openStop.stopIndex === stopIndex;
1195
+ this.openStop = same ? null : { layerIndex, stopIndex };
1196
+ this.activeLayerIndex = layerIndex;
1197
+ this.activeStopIndex = stopIndex;
1198
+ this.renderLayers();
1199
+ // Gradient swatches in the library must be disabled while a stop is open
1200
+ // (CSS gradients can't contain nested gradients as stop colors).
1201
+ this._syncStopGradientDisable();
1202
+ },
1203
+
1204
+ addLayer() {
1205
+ this.layers.push(makeDefaultLayer());
1206
+ this.activeLayerIndex = this.layers.length - 1;
1207
+ this.activeStopIndex = 0;
1208
+ this.renderLayers(); this.syncToInput();
1209
+ },
1210
+
1211
+ // Insert a fresh default layer relative to the given index.
1212
+ // position: 'above' inserts before `i`; 'below' (default) inserts after.
1213
+ addLayerAt(i, position) {
1214
+ if (i == null || i < 0 || i > this.layers.length) return;
1215
+ const insertAt = position === 'above' ? i : i + 1;
1216
+ this.layers.splice(insertAt, 0, makeDefaultLayer());
1217
+ this.activeLayerIndex = insertAt;
1218
+ this.activeStopIndex = 0;
1219
+ this.renderLayers(); this.syncToInput();
1220
+ },
1221
+
1222
+ // Swap the layer at `i` with its neighbor. delta = -1 moves up, +1 moves down.
1223
+ // No-op at edges.
1224
+ moveLayer(i, delta) {
1225
+ const src = i;
1226
+ const dst = src + delta;
1227
+ if (src < 0 || src >= this.layers.length) return;
1228
+ if (dst < 0 || dst >= this.layers.length) return;
1229
+ const [layer] = this.layers.splice(src, 1);
1230
+ this.layers.splice(dst, 0, layer);
1231
+ // Preserve the "currently active layer" identity through the move
1232
+ if (this.activeLayerIndex === src) this.activeLayerIndex = dst;
1233
+ else if (delta > 0 && this.activeLayerIndex === dst) this.activeLayerIndex = src;
1234
+ else if (delta < 0 && this.activeLayerIndex === dst) this.activeLayerIndex = src;
1235
+ this.renderLayers(); this.syncToInput();
1236
+ },
1237
+
1238
+ duplicateLayer(i) {
1239
+ const src = this.layers[i]; if (!src) return;
1240
+ this.layers.splice(i + 1, 0, {
1241
+ type: src.type, angle: src.angle, position: { ...src.position },
1242
+ stops: src.stops.map(s => ({ color: { ...s.color }, position: s.position, format: s.format || 'hex' }))
1243
+ });
1244
+ this.activeLayerIndex = i + 1; this.activeStopIndex = 0;
1245
+ this.renderLayers(); this.syncToInput();
1246
+ },
1247
+
1248
+ removeLayer(i) {
1249
+ if (this.layers.length <= 1) return;
1250
+ this.layers.splice(i, 1);
1251
+ if (this.activeLayerIndex >= this.layers.length) this.activeLayerIndex = this.layers.length - 1;
1252
+ this.activeStopIndex = 0;
1253
+ this.renderLayers(); this.syncToInput();
1254
+ },
1255
+
1256
+ flipLayer(i) {
1257
+ const layer = this.layers[i]; if (!layer) return;
1258
+ for (const stop of layer.stops) stop.position = 100 - stop.position;
1259
+ this.renderLayers(); this.syncToInput();
1260
+ },
1261
+
1262
+ rotateLayer(i) {
1263
+ const layer = this.layers[i]; if (!layer) return;
1264
+ layer.angle = (layer.angle + 90) % 360;
1265
+ this.renderLayers(); this.syncToInput();
1266
+ },
1267
+
1268
+ setGradientType(i, type) {
1269
+ const layer = this.layers[i]; if (!layer) return;
1270
+ if (!GRADIENT_TYPES.includes(type)) return;
1271
+ layer.type = type;
1272
+ this.renderLayers(); this.syncToInput();
1273
+ },
1274
+
1275
+ addStop(layerIndex, position) {
1276
+ const layer = this.layers[layerIndex]; if (!layer) return;
1277
+ const sorted = layer.stops.slice().sort((a,b) => a.position - b.position);
1278
+ let before = sorted[0], after = sorted[sorted.length - 1];
1279
+ for (let k = 0; k < sorted.length - 1; k++) {
1280
+ if (sorted[k].position <= position && sorted[k+1].position >= position) {
1281
+ before = sorted[k]; after = sorted[k+1]; break;
1282
+ }
1283
+ }
1284
+ const range = after.position - before.position;
1285
+ const t = range === 0 ? 0.5 : (position - before.position) / range;
1286
+ layer.stops.push({
1287
+ color: {
1288
+ h: before.color.h + (after.color.h - before.color.h) * t,
1289
+ s: before.color.s + (after.color.s - before.color.s) * t,
1290
+ v: before.color.v + (after.color.v - before.color.v) * t,
1291
+ a: before.color.a + (after.color.a - before.color.a) * t,
1292
+ },
1293
+ position, format: before.format || 'hex'
1294
+ });
1295
+ this.activeLayerIndex = layerIndex;
1296
+ this.activeStopIndex = layer.stops.length - 1;
1297
+ this.renderLayers(); this.syncToInput();
1298
+ },
1299
+
1300
+ duplicateStop(layerIndex, stopIndex) {
1301
+ const layer = this.layers[layerIndex]; if (!layer) return;
1302
+ const src = layer.stops[stopIndex]; if (!src) return;
1303
+ layer.stops.push({ color: { ...src.color }, position: Math.min(100, src.position + 5), format: src.format || 'hex' });
1304
+ this.activeLayerIndex = layerIndex;
1305
+ this.activeStopIndex = layer.stops.length - 1;
1306
+ this.renderLayers(); this.syncToInput();
1307
+ },
1308
+
1309
+ deleteStop(layerIndex, stopIndex) {
1310
+ const layer = this.layers[layerIndex]; if (!layer) return;
1311
+ if (layer.stops.length <= 2) return;
1312
+ layer.stops.splice(stopIndex, 1);
1313
+ if (this.activeLayerIndex === layerIndex && this.activeStopIndex >= layer.stops.length)
1314
+ this.activeStopIndex = layer.stops.length - 1;
1315
+ this.renderLayers(); this.syncToInput();
1316
+ },
1317
+
1318
+ applyColor(str) {
1319
+ // Pure setter — Recent-list commits happen at the picker-close boundary
1320
+ // (popover close / inline focusout), not on every call.
1321
+ if (this.setFromString(str)) {
1322
+ this.syncUI();
1323
+ this.syncToInput();
1324
+ if (this.isGradient) this._refreshActiveStopVisuals();
1325
+ }
1326
+ },
1327
+
1328
+ // Toggle `.active` on library swatches whose canonical key matches the
1329
+ // picker's current color. Gradients compare as their full CSS string;
1330
+ // solids compare as 8-digit hex. Runs across every registered library
1331
+ // container (tab + any nested containers such as stop context menus).
1332
+ _updateActiveSwatches() {
1333
+ if (!this.libraryContainers.length) return;
1334
+ const current = this.isGradient
1335
+ ? this.toFormattedString()
1336
+ : (() => {
1337
+ const {r,g,b} = this.toRgb();
1338
+ return rgbToHex8(r, g, b, this.a);
1339
+ })();
1340
+ const key = _swatchKeyOf(current);
1341
+ for (const c of this.libraryContainers) {
1342
+ if (!c.isConnected) continue;
1343
+ const nodes = c.querySelectorAll('[data-cp-key]');
1344
+ for (const n of nodes) n.classList.toggle('active', key != null && n.getAttribute('data-cp-key') === key);
1345
+ }
1346
+ this._syncStopGradientDisable();
1347
+ },
1348
+
1349
+ // Gradient-valued library swatches must be un-pickable when the click would
1350
+ // try to apply that gradient as a gradient-stop color (CSS doesn't allow
1351
+ // nested gradients in stop positions). That's the case when:
1352
+ // • a stop accordion is currently open (openStop set), OR
1353
+ // • the library container itself is nested inside a stop-context-menu —
1354
+ // every click in such a menu targets the right-clicked stop's color.
1355
+ // Dev CSS styles [disabled] on apply-color elements; the click handler
1356
+ // also short-circuits as belt-and-braces.
1357
+ _syncStopGradientDisable() {
1358
+ const stopIsOpen = !!this.openStop;
1359
+ for (const c of this.libraryContainers) {
1360
+ if (!c.isConnected) continue;
1361
+ const containerInStopMenu = !!c.closest('menu[id^="stop-context-menu"]');
1362
+ const shouldDisable = stopIsOpen || containerInStopMenu;
1363
+ const gradNodes = c.querySelectorAll('[data-cp-key]');
1364
+ for (const n of gradNodes) {
1365
+ const k = n.getAttribute('data-cp-key') || '';
1366
+ const isGradient = k.includes('gradient(');
1367
+ if (!isGradient) continue;
1368
+ if (shouldDisable) n.setAttribute('disabled', '');
1369
+ else n.removeAttribute('disabled');
1370
+ }
1371
+ }
1372
+ },
1373
+
1374
+ // ---- Recent-list commit cycle ----
1375
+ //
1376
+ // Rules:
1377
+ // • Popover pickers commit on toggle→closed.
1378
+ // • Inline pickers commit on focusout past the rootEl.
1379
+ // • A commit pushes the current color to Recent IFF the user made a
1380
+ // non-library change since the last baseline and the color actually differs.
1381
+ // • Gradient mode never commits (stops are part of a gradient, not standalone).
1382
+ // • Programmatic api.applyColor calls don't mark user changes — only UI paths do.
1383
+
1384
+ _startCommitCycle() {
1385
+ this._recentBaseline = this._currentCommitValue();
1386
+ this._lastChangeFromLibrary = false;
1387
+ this._hasUserChange = false;
1388
+ },
1389
+
1390
+ _markUserChange(fromLibrary) {
1391
+ this._hasUserChange = true;
1392
+ this._lastChangeFromLibrary = !!fromLibrary;
1393
+ },
1394
+
1395
+ _currentCommitValue() {
1396
+ // Solid: canonical hex (format-independent). Gradient: full CSS string.
1397
+ try { return this.isGradient ? this.toFormattedString() : this.toHex(); }
1398
+ catch { return null; }
1399
+ },
1400
+
1401
+ _tryCommitRecent() {
1402
+ if (!this._hasUserChange) return; // no interaction since baseline
1403
+ if (this._lastChangeFromLibrary) return; // library picks don't count
1404
+ const current = this._currentCommitValue();
1405
+ if (!current) return;
1406
+ if (current === this._recentBaseline) return; // no actual change
1407
+ pushRecent(current);
1408
+ // Start a fresh cycle so repeated inline commits behave correctly
1409
+ this._startCommitCycle();
1410
+ },
1411
+
1412
+ // Shared-picker write-back: when a swatch with x-model triggered this picker,
1413
+ // push the current color through the swatch's model setter on commit. Unlike
1414
+ // Recent, this runs even when the change came from a library click — the user
1415
+ // clicked a library swatch intending to assign that color to their field.
1416
+ _commitToTrigger() {
1417
+ const trigger = this.triggerBtn;
1418
+ if (!trigger || typeof trigger._cpModelSetter !== 'function') return;
1419
+ if (!this._hasUserChange) return; // nothing to persist
1420
+ const value = this._currentCommitValue();
1421
+ if (value != null) trigger._cpModelSetter(value);
1422
+ },
1423
+
1424
+ async grabColor() {
1425
+ if (!window.EyeDropper) return;
1426
+ try {
1427
+ const result = await new EyeDropper().open();
1428
+ // Eyedropper is a single-color operation — route the result
1429
+ // to Solid mode and surface the solid controls. Mark as a
1430
+ // non-library user change; the usual commit boundary decides
1431
+ // whether it lands in Recent (user may tweak before closing).
1432
+ this._switchToSolidMode();
1433
+ this._markUserChange(false);
1434
+ this.applyColor(result.sRGBHex);
1435
+ } catch (e) { /* user cancelled */ }
1436
+ },
1437
+
1438
+ // Force the picker into solid mode and activate solid controls.
1439
+ // Also updates Alpine `tab` data (if the dev uses a tab-driven layout).
1440
+ _switchToSolidMode() {
1441
+ this.pickerMode = 'solid';
1442
+ this.openStop = null;
1443
+ const rootStack = this.rootEl._x_dataStack;
1444
+ const autoStack = this._autoTabScope?._x_dataStack;
1445
+ if (rootStack && rootStack[0] && 'tab' in rootStack[0]) rootStack[0].tab = 'solid';
1446
+ else if (autoStack && autoStack[0] && 'tab' in autoStack[0]) autoStack[0].tab = 'solid';
1447
+ if (this.solidTabRefs) this._activateControls(this.solidTabRefs);
1448
+ if (this.layersContainer) this.renderLayers();
1449
+ },
1450
+
1451
+ // Switch picker mode without touching the dev's `tab` Alpine data.
1452
+ // Used by library-tab applies — the user is browsing swatches and
1453
+ // shouldn't be flipped off the Library tab when they pick one.
1454
+ _setPickerMode(mode) {
1455
+ if (this.pickerMode === mode) return;
1456
+ this.pickerMode = mode;
1457
+ this.openStop = null;
1458
+ if (mode === 'solid' && this.solidTabRefs) this._activateControls(this.solidTabRefs);
1459
+ if (mode === 'gradient') this._activateControls(null);
1460
+ if (this.layersContainer) this.renderLayers();
1461
+ },
1462
+
1463
+ // Setter methods (mirror .set-* modifiers)
1464
+ setHue(v) {
1465
+ this.h = v;
1466
+ this.drawCanvas(); this.updateCanvasMarker();
1467
+ this.syncToInput(); this.updateColorInput();
1468
+ if (this.isGradient) this._refreshActiveStopVisuals();
1469
+ },
1470
+ setAlpha(v) {
1471
+ this.a = Math.max(0, Math.min(1, v));
1472
+ this.syncToInput(); this.updateColorInput(); this.updateAlphaInput();
1473
+ if (this.isGradient) this._refreshActiveStopVisuals();
1474
+ },
1475
+ setAlphaValue(percent) { this.setAlpha(percent / 100); },
1476
+ setColorSpace(fmt) {
1477
+ if (!FORMATS.includes(fmt)) return;
1478
+ this.format = fmt;
1479
+ this.updateColorInput();
1480
+ this._refreshFormatLabels();
1481
+ },
1482
+
1483
+ // Display label for each format (e.g. button text in a custom format dropdown)
1484
+ _formatLabel(fmt) {
1485
+ switch ((fmt || '').toLowerCase()) {
1486
+ case 'hex': return 'Hex';
1487
+ case 'rgb': return 'RGB';
1488
+ case 'hsl': return 'HSL';
1489
+ case 'oklch': return 'OKLCH';
1490
+ default: return fmt || '';
1491
+ }
1492
+ },
1493
+
1494
+ // Sync any registered format label elements to the current format and
1495
+ // toggle an `active` class on each format-choice element so devs can
1496
+ // style the active option without writing reactive bindings.
1497
+ _refreshFormatLabels() {
1498
+ const current = this.format;
1499
+ const label = this._formatLabel(current);
1500
+ for (const el of this.formatLabelEls) {
1501
+ if (!el || !el.isConnected) continue;
1502
+ if (el.textContent !== label) el.textContent = label;
1503
+ }
1504
+ for (const { el, fmt } of this.formatChoiceEls) {
1505
+ if (!el || !el.isConnected) continue;
1506
+ el.classList.toggle('active', fmt === current);
1507
+ }
1508
+ },
1509
+ setColorValue(str) {
1510
+ if (this.setFromString(str)) {
1511
+ this.drawCanvas(); this.updateCanvasMarker(); this.updateSliders();
1512
+ this.updateAlphaInput(); this.syncToInput();
1513
+ if (this.isGradient) this._refreshActiveStopVisuals();
1514
+ }
1515
+ },
1516
+ setAngle(i, degrees) {
1517
+ const layer = this.layers[i]; if (!layer) return;
1518
+ layer.angle = ((degrees % 360) + 360) % 360;
1519
+ this._refreshLayerVisuals(i);
1520
+ this.syncToInput();
1521
+ },
1522
+ setGradientValue(cssString) {
1523
+ // Edits don't parse back into layers; plugin writes the CSS var for the swatch.
1524
+ if (this.triggerBtn) this.triggerBtn.style.setProperty('--color-picker-swatch', cssString);
1525
+ },
1526
+
1527
+ // ---- Sync / render ----
1528
+
1529
+ syncToInput() {
1530
+ const swatchVal = this.toSwatchColor();
1531
+ if (this.hiddenInput) {
1532
+ // Native <input type="color"> only accepts #rrggbb; everything
1533
+ // else (synthesized type=hidden, dev-supplied type=hidden) gets
1534
+ // the full CSS string — solid color or gradient — so gradients
1535
+ // and non-hex formats round-trip without lossy conversion.
1536
+ const isNativeColorInput = this.hiddenInput.type === 'color';
1537
+ this.hiddenInput.value = isNativeColorInput ? this.toHex() : swatchVal;
1538
+ this.hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
1539
+ this.hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
1540
+ }
1541
+ if (this.triggerBtn) this.triggerBtn.style.setProperty('--color-picker-swatch', swatchVal);
1542
+ this.updateGradientValue();
1543
+ // Reflect current color in the library: any swatch whose canonical key
1544
+ // matches gets an `.active` class, all others lose it.
1545
+ this._updateActiveSwatches();
1546
+ // Refresh any custom format labels / choice highlights — covers paths
1547
+ // where format changes implicitly (setFromString parsing a new format).
1548
+ this._refreshFormatLabels();
1549
+
1550
+ // Bump reactive version — any $colorpicker(id).* reader re-runs.
1551
+ // No eager computation of hex/css/etc. unless somebody is actually bound to them.
1552
+ this.snapshot.version++;
1553
+ },
1554
+
1555
+ syncUI() {
1556
+ this.drawCanvas();
1557
+ this.updateSliders();
1558
+ this.updateColorInput();
1559
+ this.updateAlphaInput();
1560
+ this.updateCanvasMarker();
1561
+ },
1562
+
1563
+ drawCanvas() {
1564
+ const canvas = this.activeControls?.canvas;
1565
+ if (!canvas) return;
1566
+ const rect = canvas.getBoundingClientRect();
1567
+ if (rect.width <= 0) return; // not visible yet; ResizeObserver will redraw when it gains size
1568
+ let dimsChanged = false;
1569
+ if (canvas.width !== rect.width) { canvas.width = rect.width; dimsChanged = true; }
1570
+ if (canvas.height !== rect.height) { canvas.height = rect.height; dimsChanged = true; }
1571
+ // Skip repaint if hue unchanged and dimensions unchanged (setting canvas.* dims clears the pixels)
1572
+ if (!dimsChanged && canvas._cpLastHue === this.h) return;
1573
+ drawSvCanvas(canvas, this.h);
1574
+ canvas._cpLastHue = this.h;
1575
+ },
1576
+
1577
+ updateSliders() {
1578
+ const ac = this.activeControls; if (!ac) return;
1579
+ if (ac.hueSlider && document.activeElement !== ac.hueSlider) ac.hueSlider.value = this.h;
1580
+ if (ac.alphaSlider && document.activeElement !== ac.alphaSlider) {
1581
+ ac.alphaSlider.value = Math.round(this.a * 100);
1582
+ const {r,g,b} = this.toRgb();
1583
+ ac.alphaSlider.style.setProperty('--color-picker-alpha', `rgb(${r},${g},${b})`);
1584
+ }
1585
+ },
1586
+
1587
+ updateCanvasMarker() {
1588
+ const reticle = this.activeControls?.reticle; if (!reticle) return;
1589
+ reticle.style.left = this.s + '%';
1590
+ reticle.style.top = (100 - this.v) + '%';
1591
+ },
1592
+
1593
+ updateColorInput() {
1594
+ const ac = this.activeControls; if (!ac) return;
1595
+ if (ac.colorInput && document.activeElement !== ac.colorInput) ac.colorInput.value = this.toActiveColorString();
1596
+ if (ac.formatSelect && ac.formatSelect.value !== this.format) ac.formatSelect.value = this.format;
1597
+ },
1598
+
1599
+ updateAlphaInput() {
1600
+ const ac = this.activeControls; if (!ac) return;
1601
+ if (ac.alphaInput && document.activeElement !== ac.alphaInput) ac.alphaInput.value = Math.round(this.a * 100);
1602
+ },
1603
+
1604
+ updateGradientValue() {
1605
+ for (const ta of this.gradientValueInputs) {
1606
+ if (document.activeElement === ta) continue;
1607
+ ta.value = buildFullGradientString(this.layers);
1608
+ }
1609
+ },
1610
+
1611
+ // ---- Library rendering ----
1612
+ //
1613
+ // Templates are nested in natural HTML hierarchy (x-for style):
1614
+ // <template x-colorpicker.library>
1615
+ // <template x-colorpicker.library-group> <!-- scope: group -->
1616
+ // <template x-colorpicker.library-palette> <!-- scope: palette -->
1617
+ // <template x-colorpicker.library-swatch> <!-- scope: swatch -->
1618
+ // </template>
1619
+ // </template>
1620
+ // </template>
1621
+ // </template>
1622
+ //
1623
+ // Each inner <template> is replaced in-place by clones (siblings before it, then removed).
1624
+ // Data shape after normalization: [{ name?, colors?: Swatch[], palettes?: Palette[] }].
1625
+ // If a group has only `colors` (flat, e.g. Recent), it's auto-wrapped as a single
1626
+ // unnamed palette so nested templates still work uniformly.
1627
+
1628
+ renderLibrary() {
1629
+ if (this._libraryEffectBound) return;
1630
+ this._libraryEffectBound = true;
1631
+ if (!this.libraryContainers.length) return;
1632
+ // All registered containers share the same data source / render key. Use
1633
+ // the first for any Alpine scope evaluation that needs an element.
1634
+ const evalHost = this.libraryContainers[0];
1635
+
1636
+ if (this.libraryRootValue && window.Alpine?.effect && window.Alpine?.evaluateLater) {
1637
+ // Explicit expression — bypass discovery, re-render reactively when deps change
1638
+ const evalFn = Alpine.evaluateLater(evalHost, this.libraryRootValue);
1639
+ Alpine.effect(() => {
1640
+ evalFn(v => { this._libraryResolvedData = v; this._doRenderLibrary(); });
1641
+ });
1642
+ } else if (window.Alpine?.effect) {
1643
+ // Zero-config — scan `$x.manifest.data` for entries flagged with a
1644
+ // `colorpicker:` key (mirroring how `locales:`, `appwriteTableId`, etc.
1645
+ // self-identify their plugin). Each flagged entry is loaded normally
1646
+ // by the data plugin; we collect the resulting `$x.<name>` values and
1647
+ // merge them into the library in declaration order.
1648
+ //
1649
+ // Multiple sources are supported — split your default-palette overrides
1650
+ // (`_tailwind`, `_ios`) and your custom palettes across as many files
1651
+ // as you like. Order in `manifest.data` determines render order.
1652
+ //
1653
+ // Everything happens synchronously inside the effect so Alpine tracks
1654
+ // all reactive deps ($locale, $x.manifest, each $x.<name>) and re-runs
1655
+ // the full pass on any change. Evaluate against document.body so $x
1656
+ // magic is always in scope (the container may be detached/popover
1657
+ // content without its own scope chain).
1658
+ const evalCtx = document.body;
1659
+
1660
+ // Manifest's data plugin REPLACES the $x.<source> proxy reference on load
1661
+ // rather than mutating in place. Alpine.effect can't catch that change via
1662
+ // property tracking, so we pair it with a short-lived poller that RE-READS
1663
+ // discovery until the data is loaded. We only actually re-render when the
1664
+ // serialized content has changed since the last render — otherwise each
1665
+ // poll tick would tear down and rebuild the whole library, thrashing the
1666
+ // DOM (and invalidating x-dropdown.context menu id lookups mid-flight).
1667
+ const keyOf = (names, collected) => {
1668
+ // Include the Recent list in the key so additions/removals trigger a
1669
+ // re-render. Reading _recentStore.list inside the Alpine.effect also
1670
+ // establishes reactivity on it, so cookie mutations (pushRecent /
1671
+ // removeRecent) fire the effect and change the key.
1672
+ const recentKey = _recentStore.list.slice(0, _recentMax).join(',');
1673
+ try { return recentKey + '#' + names.join('|') + '::' + JSON.stringify(collected); }
1674
+ catch { return recentKey + '#' + names.join('|') + '::[unserializable]'; }
1675
+ };
1676
+ const readSources = () => {
1677
+ // Discover names by scanning manifest.data for entries with a
1678
+ // `colorpicker` key. The key may hold a path string or a locale
1679
+ // map — the data plugin handles loading either shape; we just
1680
+ // need the set of names whose loaded data should feed the library.
1681
+ let dataMap = null;
1682
+ try { dataMap = Alpine.evaluate(evalCtx, '$x && $x.manifest && $x.manifest.data'); } catch {}
1683
+ const names = [];
1684
+ if (dataMap && typeof dataMap === 'object') {
1685
+ for (const name of Object.keys(dataMap)) {
1686
+ if (name.startsWith('$') || name.startsWith('_')) continue;
1687
+ if (name === 'valueOf' || name === 'toString' || name === 'contentType') continue;
1688
+ const entry = dataMap[name];
1689
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)
1690
+ && entry.colorpicker !== undefined) {
1691
+ names.push(name);
1692
+ }
1693
+ }
1694
+ }
1695
+ const collected = names.map(name => {
1696
+ try { return Alpine.evaluate(evalCtx, '$x.' + name); } catch { return null; }
1697
+ });
1698
+ const ready = names.length === 0 || collected.every(src => {
1699
+ if (!src || typeof src !== 'object') return false;
1700
+ return Object.keys(src).some(k => !k.startsWith('$')
1701
+ && !k.startsWith('_')
1702
+ && k !== 'contentType'
1703
+ && k !== 'valueOf' && k !== 'toString');
1704
+ });
1705
+ return { names, collected, ready };
1706
+ };
1707
+ const runDiscovery = () => {
1708
+ const { names, collected, ready } = readSources();
1709
+ const key = keyOf(names, collected);
1710
+ if (key !== this._libraryDiscoveredKey) {
1711
+ this._libraryDiscoveredKey = key;
1712
+ this._libraryDiscoveredData = collected;
1713
+ this._doRenderLibrary();
1714
+ }
1715
+ return ready;
1716
+ };
1717
+
1718
+ // Reactive deps trigger re-runs on locale switch / manifest load.
1719
+ Alpine.effect(() => {
1720
+ try { Alpine.evaluate(evalCtx, '$locale && $locale.current'); } catch {}
1721
+ try { Alpine.evaluate(evalCtx, '$x && $x.manifest && $x.manifest._loadedFrom'); } catch {}
1722
+ runDiscovery();
1723
+ // Kick the poller only until data is ready — it re-checks every 150ms
1724
+ // but skips actual re-render when the content is unchanged.
1725
+ if (!this._libraryPollTimer) {
1726
+ let attempts = 0;
1727
+ this._libraryPollTimer = setInterval(() => {
1728
+ attempts++;
1729
+ if (runDiscovery() || attempts > 80) { // max ~12s
1730
+ clearInterval(this._libraryPollTimer);
1731
+ this._libraryPollTimer = null;
1732
+ }
1733
+ }, 150);
1734
+ }
1735
+ });
1736
+ } else {
1737
+ this._doRenderLibrary();
1738
+ }
1739
+ },
1740
+
1741
+ _resolveLibraryGroups() {
1742
+ let groups;
1743
+ if (this.libraryRootValue) {
1744
+ let data = this._libraryResolvedData;
1745
+ if (data == null
1746
+ || (Array.isArray(data) && data.length === 0)
1747
+ || (typeof data === 'object' && !Array.isArray(data) && _cleanLibraryEntries(data).length === 0)) {
1748
+ data = buildDefaultLibrary();
1749
+ }
1750
+ groups = normalizeLibraryInput(data);
1751
+ const totalSwatches = groups.reduce((n, g) => n + (g.colors?.length || 0)
1752
+ + (g.palettes?.reduce((m, p) => m + (p.colors?.length || 0), 0) || 0), 0);
1753
+ if (totalSwatches === 0) groups = normalizeLibraryInput(buildDefaultLibrary());
1754
+ } else {
1755
+ groups = composeLibraryFromSources(this._libraryDiscoveredData || []);
1756
+ }
1757
+ // Resolve any `$x.<path>` or `${...}` template-literal references in
1758
+ // group / palette / swatch names, so a single colorpicker file can
1759
+ // chain into a separate localization data source without dev-side
1760
+ // template tweaks. Reactive reads inside `Alpine.evaluate` register
1761
+ // deps on the surrounding render effect → locale switches re-render.
1762
+ return _resolveLibraryRefs(groups);
1763
+ },
1764
+
1765
+ // Render ONE container (used when a new container registers post-mount
1766
+ // — e.g. a gradient layer clone's inline library div). Avoids tearing
1767
+ // down every other container's x-dropdown.context init timers.
1768
+ _renderIntoContainer(container) {
1769
+ if (!container || !container.isConnected) return;
1770
+ const groups = this._resolveLibraryGroups();
1771
+ const layoutTpl = this.libraryTemplate || _defaultLibraryLayoutTpl;
1772
+ container.innerHTML = '';
1773
+ container.appendChild(layoutTpl.content.cloneNode(true));
1774
+ const groupTpl = container.querySelector('template[x-colorpicker\\.library-group]');
1775
+ if (groupTpl) {
1776
+ const parent = groupTpl.parentNode;
1777
+ for (const g of groups) parent.insertBefore(renderLibraryGroup(groupTpl, g), groupTpl);
1778
+ groupTpl.remove();
1779
+ } else {
1780
+ for (const g of groups) container.appendChild(renderDefaultGroup(g));
1781
+ }
1782
+ if (window.Alpine?.initTree) Alpine.initTree(container);
1783
+ // Newly-rendered swatches need active + gradient-disable state applied.
1784
+ this._updateActiveSwatches();
1785
+ },
1786
+
1787
+ _doRenderLibrary() {
1788
+ if (!this.libraryContainers.length) return;
1789
+ // Prune disconnected containers (removed by gradient layer re-render etc.)
1790
+ this.libraryContainers = this.libraryContainers.filter(c => c.isConnected);
1791
+ for (const container of this.libraryContainers) this._renderIntoContainer(container);
1792
+ // Active-class pass across all rendered swatches
1793
+ this._updateActiveSwatches();
1794
+ },
1795
+
1796
+ _activateControls(refs) {
1797
+ this.activeControls = refs || null;
1798
+ if (refs) this.syncUI();
1799
+ },
1800
+
1801
+ _refreshLayerVisuals(li) {
1802
+ const clone = this._getLayerClone(li);
1803
+ if (!clone) return;
1804
+ const bar = findInClone(clone, 'layer-stops-bar');
1805
+ if (bar) this._updateStopBarPreview(bar, this.layers[li]);
1806
+ },
1807
+
1808
+ _refreshActiveStopVisuals() {
1809
+ const clone = this._getLayerClone(this.activeLayerIndex);
1810
+ if (!clone) return;
1811
+ const bar = findInClone(clone, 'layer-stops-bar');
1812
+ if (bar) this._updateStopBarPreview(bar, this.activeLayer());
1813
+ const handle = bar?.querySelectorAll('[data-cp-stop-handle]')[this.activeStopIndex];
1814
+ if (handle) handle.style.backgroundColor = colorToRgba(this.activeStop().color);
1815
+ },
1816
+
1817
+ _updateStopBarPreview(barEl, layer) {
1818
+ const preview = layer.stops.slice().sort((a,b) => a.position - b.position)
1819
+ .map(s => `${colorToRgba(s.color)} ${s.position}%`).join(', ');
1820
+ barEl.style.background = `linear-gradient(to right, ${preview})`;
1821
+ },
1822
+
1823
+ _getLayerClone(li) {
1824
+ if (!this.layersContainer) return null;
1825
+ return this.layersContainer.querySelectorAll(':scope > [data-cp-layer-clone]')[li] || null;
1826
+ },
1827
+
1828
+ // ---- Layer rendering ----
1829
+
1830
+ renderLayers() {
1831
+ if (!this.layersContainer) return;
1832
+
1833
+ // Clamp openStop
1834
+ if (this.openStop) {
1835
+ const L = this.layers[this.openStop.layerIndex];
1836
+ if (!L || !L.stops[this.openStop.stopIndex]) this.openStop = null;
1837
+ }
1838
+
1839
+ // Clear existing clones
1840
+ this.layersContainer.querySelectorAll(':scope > [data-cp-layer-clone]').forEach(el => el.remove());
1841
+
1842
+ // Get or synthesize layer template (parsed ONCE, cloned per layer)
1843
+ const layerTpl = this.layerTemplate || _defaultLayerTpl;
1844
+
1845
+ let pendingActivation = null;
1846
+
1847
+ this.layers.forEach((layer, li) => {
1848
+ const frag = layerTpl.content.cloneNode(true);
1849
+ const root = frag.firstElementChild;
1850
+ if (!root) return;
1851
+
1852
+ root.setAttribute('data-cp-layer-clone', '');
1853
+ root.setAttribute('data-gradient-type', layer.type);
1854
+ root._cpLayerIndex = li;
1855
+
1856
+ // Expose the layer's position + type to the clone's Alpine scope so
1857
+ // devs can bind classes/attributes reactively. Available in scope:
1858
+ // layerType — 'linear' | 'radial' | 'conic'
1859
+ // layerIndex — 0-based position of this layer
1860
+ // layerCount — total number of layers in the picker
1861
+ // Examples:
1862
+ // :class="'layer-type-' + layerType"
1863
+ // :disabled="layerIndex === 0" (Move Up)
1864
+ // :disabled="layerIndex === layerCount - 1" (Move Down)
1865
+ // :disabled="layerCount === 1" (Remove)
1866
+ root.setAttribute('x-data', '{ '
1867
+ + 'layerType: ' + JSON.stringify(layer.type) + ', '
1868
+ + 'layerIndex: ' + li + ', '
1869
+ + 'layerCount: ' + this.layers.length
1870
+ + ' }');
1871
+
1872
+ // Uniquify x-dropdown / x-dropdown.context / x-dropdown.hover IDs
1873
+ // within this clone so per-layer dropdowns don't collide.
1874
+ uniquifyDropdownIdsIn(root, this.pickerUid + '-layer-' + li);
1875
+
1876
+ // Render stops bar content
1877
+ const bar = findInClone(root, 'layer-stops-bar');
1878
+ if (bar) this._renderStopBar(bar, layer, li);
1879
+
1880
+ // Set initial angle input value
1881
+ const angleInput = findInClone(root, 'set-angle');
1882
+ if (angleInput) angleInput.value = layer.angle;
1883
+
1884
+ // Populate accordion solid panel if this is the open stop's layer
1885
+ const nestedSolidInstance = findInClone(root, 'solid');
1886
+ if (nestedSolidInstance && this.openStop && this.openStop.layerIndex === li) {
1887
+ const refs = this._mountSolidInstance(nestedSolidInstance);
1888
+ if (refs) pendingActivation = refs;
1889
+ }
1890
+
1891
+ this.layersContainer.appendChild(root);
1892
+
1893
+ // Let Alpine/Manifest process the clone (x-dropdown, x-icon, and nested x-colorpicker directives)
1894
+ if (window.Alpine?.initTree) {
1895
+ requestAnimationFrame(() => Alpine.initTree(root));
1896
+ }
1897
+ });
1898
+
1899
+ if (pendingActivation) this._activateControls(pendingActivation);
1900
+ else if (this.isGradient) this._activateControls(null);
1901
+ },
1902
+
1903
+ _renderStopBar(barEl, layer, layerIndex) {
1904
+ barEl.innerHTML = '';
1905
+ this._updateStopBarPreview(barEl, layer);
1906
+
1907
+ // Drop-to-add-stop on bar click (non-handle clicks)
1908
+ barEl.onclick = (e) => {
1909
+ if (e.target.hasAttribute('data-cp-stop-handle')) return;
1910
+ const rect = barEl.getBoundingClientRect();
1911
+ this.addStop(layerIndex, Math.round(((e.clientX - rect.left) / rect.width) * 100));
1912
+ };
1913
+
1914
+ layer.stops.forEach((stop, si) => {
1915
+ const handle = document.createElement('div');
1916
+ handle.className = 'stop-handle';
1917
+ handle.setAttribute('data-cp-stop-handle', '');
1918
+ handle.style.left = stop.position + '%';
1919
+ handle.style.backgroundColor = colorToRgba(stop.color);
1920
+ // .active reflects whether this stop's accordion is currently open
1921
+ if (this.openStop && this.openStop.layerIndex === layerIndex && this.openStop.stopIndex === si) {
1922
+ handle.classList.add('active');
1923
+ }
1924
+
1925
+ let dragging = false, startX = 0, moved = false, cachedBarRect = null;
1926
+ const self = this;
1927
+ const applyDrag = (e) => {
1928
+ if (!cachedBarRect) cachedBarRect = barEl.getBoundingClientRect();
1929
+ const rect = cachedBarRect;
1930
+ stop.position = Math.max(0, Math.min(100, Math.round(((e.clientX - rect.left) / rect.width) * 100)));
1931
+ handle.style.left = stop.position + '%';
1932
+ self._updateStopBarPreview(barEl, layer);
1933
+ self.syncToInput();
1934
+ };
1935
+ const throttledDrag = rafThrottle(applyDrag);
1936
+ handle.addEventListener('pointerdown', (e) => {
1937
+ if (e.button !== 0) return;
1938
+ e.stopPropagation();
1939
+ self.selectStop(layerIndex, si);
1940
+ // .active is set by the re-render after toggleStop;
1941
+ // don't preemptively toggle here (would be wrong for drag-without-toggle).
1942
+ dragging = true; moved = false; startX = e.clientX;
1943
+ cachedBarRect = barEl.getBoundingClientRect();
1944
+ handle.setPointerCapture(e.pointerId);
1945
+ });
1946
+ handle.addEventListener('pointermove', (e) => {
1947
+ if (!dragging) return;
1948
+ if (Math.abs(e.clientX - startX) > 3) moved = true;
1949
+ if (moved) throttledDrag(e);
1950
+ });
1951
+ handle.addEventListener('pointerup', () => {
1952
+ // Only toggle/cleanup if we had a valid left-click drag session.
1953
+ // Right-click never sets `dragging=true` (pointerdown bails for button!==0),
1954
+ // so this pointerup would otherwise still call toggleStop() and
1955
+ // destroy the layer clone — killing the context menu that just opened.
1956
+ if (!dragging) return;
1957
+ const wasMoved = moved;
1958
+ dragging = false; moved = false; cachedBarRect = null;
1959
+ if (!wasMoved) self.toggleStop(layerIndex, si);
1960
+ });
1961
+ barEl.appendChild(handle);
1962
+ });
1963
+ },
1964
+
1965
+ // ---- Solid instance mounting ----
1966
+
1967
+ _mountSolidInstance(containerEl) {
1968
+ if (!containerEl) return null;
1969
+ containerEl.innerHTML = '';
1970
+ const source = this.solidTemplate || _defaultSolidTpl;
1971
+ const frag = source.content.cloneNode(true);
1972
+ // Uniquify any x-dropdown menu ids inside the cloned solid panel
1973
+ // (e.g. the `color-space-menu` from the default template) so two
1974
+ // pickers on the same page don't share the same popover element.
1975
+ uniquifyDropdownIdsIn(frag, this.pickerUid + '-solid');
1976
+ containerEl.appendChild(frag);
1977
+
1978
+ const refs = this._collectSolidRefs(containerEl);
1979
+ this._wireSolidControls(refs);
1980
+
1981
+ // Let Alpine process any x-* directives in the mounted content
1982
+ if (window.Alpine?.initTree) {
1983
+ requestAnimationFrame(() => Alpine.initTree(containerEl));
1984
+ }
1985
+
1986
+ return refs;
1987
+ },
1988
+
1989
+ // Mount the full gradient panel into an instance container.
1990
+ // Uses <template x-colorpicker.gradient> if provided, otherwise the default.
1991
+ _mountGradientInstance(containerEl) {
1992
+ if (!containerEl) return;
1993
+ containerEl.innerHTML = '';
1994
+ const source = this.gradientTemplate || _defaultGradientTpl;
1995
+ const frag = source.content.cloneNode(true);
1996
+ containerEl.appendChild(frag);
1997
+
1998
+ // Alpine processes the inner x-colorpicker.* directives (add-layer,
1999
+ // gradient-layers, layer-options template, set-gradient-value) which
2000
+ // register with THIS state via ancestor traversal.
2001
+ if (window.Alpine?.initTree) Alpine.initTree(containerEl);
2002
+ },
2003
+
2004
+ _collectSolidRefs(containerEl) {
2005
+ return {
2006
+ wrapper: containerEl.querySelector('.canvas-wrapper')
2007
+ || findInClone(containerEl, 'set-canvas')?.parentElement,
2008
+ canvas: findInClone(containerEl, 'set-canvas'),
2009
+ reticle: containerEl.querySelector('.color-reticle'),
2010
+ hueSlider: findInClone(containerEl, 'set-hue'),
2011
+ alphaSlider: findInClone(containerEl, 'set-alpha'),
2012
+ colorInput: findInClone(containerEl, 'set-color-value'),
2013
+ alphaInput: findInClone(containerEl, 'set-alpha-value'),
2014
+ formatSelect: findInClone(containerEl, 'set-color-space'),
2015
+ };
2016
+ },
2017
+
2018
+ _wireSolidControls(refs) {
2019
+ const self = this;
2020
+
2021
+ // Canvas pointer + resize-driven redraw
2022
+ if (refs.canvas && refs.wrapper) {
2023
+ let dragging = false;
2024
+ let cachedRect = null;
2025
+ const pick = (e) => {
2026
+ // Cache the rect while dragging; invalidated on pointerdown
2027
+ if (!cachedRect) cachedRect = refs.canvas.getBoundingClientRect();
2028
+ const rect = cachedRect;
2029
+ self.s = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
2030
+ self.v = Math.max(0, Math.min(100, (1 - (e.clientY - rect.top) / rect.height) * 100));
2031
+ self.syncToInput(); self.updateSliders(); self.updateColorInput(); self.updateCanvasMarker();
2032
+ if (self.isGradient) self._refreshActiveStopVisuals();
2033
+ };
2034
+ const throttledPick = rafThrottle(pick);
2035
+ refs.wrapper.addEventListener('pointerdown', (e) => {
2036
+ dragging = true;
2037
+ cachedRect = refs.canvas.getBoundingClientRect();
2038
+ refs.wrapper.setPointerCapture(e.pointerId);
2039
+ self._markUserChange(false);
2040
+ pick(e); // immediate on first click (not throttled)
2041
+ });
2042
+ refs.wrapper.addEventListener('pointermove', (e) => { if (dragging) throttledPick(e); });
2043
+ refs.wrapper.addEventListener('pointerup', () => { dragging = false; cachedRect = null; });
2044
+
2045
+ const ro = new ResizeObserver(() => {
2046
+ if (self.activeControls?.canvas !== refs.canvas) return;
2047
+ const r = refs.canvas.getBoundingClientRect();
2048
+ if (r.width > 0 && r.height > 0) { self.drawCanvas(); self.updateCanvasMarker(); }
2049
+ });
2050
+ ro.observe(refs.canvas);
2051
+ }
2052
+
2053
+ if (refs.hueSlider) {
2054
+ refs.hueSlider.min = 0; refs.hueSlider.max = 360; refs.hueSlider.step = 1;
2055
+ refs.hueSlider.addEventListener('input', () => {
2056
+ self._markUserChange(false);
2057
+ self.setHue(parseFloat(refs.hueSlider.value));
2058
+ });
2059
+ }
2060
+
2061
+ if (refs.alphaSlider) {
2062
+ refs.alphaSlider.min = 0; refs.alphaSlider.max = 100; refs.alphaSlider.step = 1;
2063
+ refs.alphaSlider.addEventListener('input', () => {
2064
+ self._markUserChange(false);
2065
+ self.setAlpha(parseFloat(refs.alphaSlider.value) / 100);
2066
+ });
2067
+ }
2068
+
2069
+ if (refs.colorInput) {
2070
+ refs.colorInput.addEventListener('input', () => {
2071
+ self._markUserChange(false);
2072
+ self.setColorValue(refs.colorInput.value);
2073
+ });
2074
+ refs.colorInput.addEventListener('blur', () => { refs.colorInput.value = self.toActiveColorString(); });
2075
+ }
2076
+
2077
+ if (refs.alphaInput) {
2078
+ refs.alphaInput.addEventListener('input', () => {
2079
+ const v = parseFloat(refs.alphaInput.value);
2080
+ if (!isNaN(v)) {
2081
+ self._markUserChange(false);
2082
+ self.setAlphaValue(v);
2083
+ }
2084
+ });
2085
+ }
2086
+
2087
+ if (refs.formatSelect) {
2088
+ refs.formatSelect.addEventListener('change', () => self.setColorSpace(refs.formatSelect.value));
2089
+ }
2090
+ },
2091
+
2092
+ // ---- Picker mount (after all children registered) ----
2093
+
2094
+ mount() {
2095
+ // Tier 2: if the container has no declared UI (templates are inert overrides),
2096
+ // inject the full default UI. Templates alone don't count as "declared UI".
2097
+ const noDeclared = !this.solidTemplate && !this.layerTemplate && !this.gradientTemplate
2098
+ && this.solidInstances.length === 0 && this.gradientInstances.length === 0
2099
+ && !this.layersContainer && this.gradientValueInputs.length === 0
2100
+ && !this.libraryContainers.length;
2101
+ const hasNonTemplateChildren = [...this.rootEl.children].some(c => c.tagName !== 'TEMPLATE');
2102
+ if (noDeclared && !hasNonTemplateChildren) {
2103
+ this._injectDefaultUI();
2104
+ }
2105
+
2106
+ // Auto-popovers register their picker state BEFORE the swatch hook gets
2107
+ // a chance to set `x-dropdown` on the trigger element, so the early
2108
+ // triggerBtn lookup misses. By mount-time (setTimeout 0) the swatch
2109
+ // wiring has finished, so re-query if we don't have one yet.
2110
+ if (!this.triggerBtn && this.rootEl.id) {
2111
+ this.triggerBtn = document.querySelector(`[x-dropdown="${this.rootEl.id}"]`);
2112
+ }
2113
+
2114
+ // Initialize the picker state from whatever source has a real value.
2115
+ // Resolution priority matches the retarget flow on swatch-click so the
2116
+ // picker's reactive value reads (`$colorpicker(id)`, .hex, .css, etc.)
2117
+ // are correct from first paint — not just after the user opens the menu.
2118
+ // 1. trigger swatch's x-model getter
2119
+ // 2. trigger swatch's paired hidden input (form-participation flow)
2120
+ // 3. trigger swatch's `value` attribute
2121
+ // 4. picker container's own hidden input child
2122
+ // 5. fallback '#000000'
2123
+ let initVal = '';
2124
+ const tb = this.triggerBtn;
2125
+ if (tb) {
2126
+ if (tb._cpModelGetter) {
2127
+ tb._cpModelGetter(v => { if (typeof v === 'string' && v.length) initVal = v; });
2128
+ }
2129
+ if (!initVal && tb._cpHiddenInput && tb._cpHiddenInput.value) initVal = tb._cpHiddenInput.value;
2130
+ if (!initVal && tb.getAttribute('value')) initVal = tb.getAttribute('value');
2131
+ }
2132
+ if (!initVal && this.hiddenInput) initVal = this.hiddenInput.value;
2133
+ this.setFromString(initVal || '#000000');
2134
+
2135
+ // Seed trigger swatch CSS var so the border-color derivation paints
2136
+ // correctly before any interaction.
2137
+ if (this.triggerBtn) this.triggerBtn.style.setProperty('--color-picker-swatch', this.toSwatchColor());
2138
+
2139
+ // Mount all solid-panel instances (Solid tab + any others)
2140
+ let firstSolidRefs = null;
2141
+ for (const inst of this.solidInstances) {
2142
+ const refs = this._mountSolidInstance(inst);
2143
+ if (refs && !firstSolidRefs) firstSolidRefs = refs;
2144
+ }
2145
+ this.solidTabRefs = firstSolidRefs;
2146
+
2147
+ // Mount all gradient-panel instances (the full gradient panel).
2148
+ // This populates them with the gradient template (or default), which in turn
2149
+ // registers gradient-layers / layer-options / set-gradient-value with this state.
2150
+ for (const inst of this.gradientInstances) this._mountGradientInstance(inst);
2151
+
2152
+ // Render gradient layers (only if a container is declared)
2153
+ if (this.layersContainer) this.renderLayers();
2154
+
2155
+ // Initial mode based on Alpine `tab` data, if present
2156
+ this._syncPickerModeFromTab();
2157
+
2158
+ // Activate controls based on mode
2159
+ if (!this.isGradient) this._activateControls(this.solidTabRefs);
2160
+
2161
+ // Wire click-based tab watcher (root + auto-injected wrapper)
2162
+ this.rootEl.addEventListener('click', () => {
2163
+ requestAnimationFrame(() => this._syncPickerModeFromTab());
2164
+ });
2165
+
2166
+ // Initial sync
2167
+ this.syncToInput();
2168
+
2169
+ // Render the library — _doRenderLibrary clones the (optional) library template
2170
+ // into the container and expands nested group/palette/swatch templates in-place.
2171
+ if (this.libraryContainers.length) this.renderLibrary();
2172
+
2173
+ // ---- Recent-list commit wiring ----
2174
+ // Seed the initial baseline. Popover pickers re-seed on toggle→open.
2175
+ this._startCommitCycle();
2176
+
2177
+ // Broad user-interaction detector: any pointerdown or input event inside
2178
+ // the picker marks the cycle as "user-touched". This covers all gradient
2179
+ // controls (add-layer, set-angle, stop drags, textarea edits, etc.) without
2180
+ // having to instrument each handler. Library swatches are detected by
2181
+ // scope ancestry so a preset pick is correctly flagged as library-sourced.
2182
+ this.rootEl.addEventListener('pointerdown', (e) => {
2183
+ const fromLibrary = !!e.target.closest('[x-data*="swatch:"]');
2184
+ this._markUserChange(fromLibrary);
2185
+ });
2186
+ this.rootEl.addEventListener('input', () => {
2187
+ // 'input' on form controls = user typing / dragging sliders
2188
+ this._markUserChange(false);
2189
+ });
2190
+
2191
+ if (this.rootEl.hasAttribute('popover')) {
2192
+ // Popover mode: open/close are the commit boundaries
2193
+ this.rootEl.addEventListener('toggle', (e) => {
2194
+ if (e.newState === 'open') this._startCommitCycle();
2195
+ if (e.newState === 'closed') {
2196
+ // Write to the triggering swatch's model FIRST — _tryCommitRecent
2197
+ // resets the user-change flag on success, which would otherwise
2198
+ // short-circuit _commitToTrigger.
2199
+ this._commitToTrigger();
2200
+ this._tryCommitRecent();
2201
+ }
2202
+ });
2203
+ } else {
2204
+ // Inline mode: commit when focus leaves the picker entirely
2205
+ this.rootEl.addEventListener('focusout', (e) => {
2206
+ const moved = e.relatedTarget;
2207
+ if (!moved || !this.rootEl.contains(moved)) {
2208
+ this._commitToTrigger();
2209
+ this._tryCommitRecent();
2210
+ }
2211
+ });
2212
+ }
2213
+
2214
+ // Register in the global registry so $colorpicker(id) bindings resolve
2215
+ if (this.rootEl.id) _pickerRegistry[this.rootEl.id] = this.api;
2216
+
2217
+ // Mark complete so the `library` directive handler knows to render into
2218
+ // any newly-registered containers (e.g., gradient layer library menus).
2219
+ this._mounted = true;
2220
+ },
2221
+
2222
+ _injectDefaultUI() {
2223
+ this.rootEl.innerHTML = '';
2224
+ this.rootEl.appendChild(_defaultFullUiTpl.content.cloneNode(true));
2225
+
2226
+ // Filter / reorder tabs and panels per `allowedPanels` (set on the
2227
+ // root state when the directive expression is a panel-list array).
2228
+ // No allowedPanels → render all three in their default order.
2229
+ const allowed = this.allowedPanels;
2230
+ if (allowed && allowed.length) {
2231
+ const wrapper = this.rootEl.firstElementChild;
2232
+ const tabBar = wrapper?.querySelector('[data-cp-tabs]');
2233
+ // Remove tab buttons not in allowed; reorder the rest in `allowed` order
2234
+ if (tabBar) {
2235
+ const tabBtns = Array.from(tabBar.querySelectorAll('[data-cp-tab]'));
2236
+ for (const b of tabBtns) {
2237
+ if (!allowed.includes(b.getAttribute('data-cp-tab'))) b.remove();
2238
+ }
2239
+ for (const name of allowed) {
2240
+ const b = tabBar.querySelector(`[data-cp-tab="${name}"]`);
2241
+ if (b) tabBar.appendChild(b);
2242
+ }
2243
+ // Single panel → no tabs needed, drop the bar entirely
2244
+ if (allowed.length === 1) tabBar.remove();
2245
+ }
2246
+ // Remove panel containers not in allowed; reorder the rest
2247
+ const panels = Array.from(wrapper?.querySelectorAll('[data-cp-panel]') || []);
2248
+ for (const p of panels) {
2249
+ if (!allowed.includes(p.getAttribute('data-cp-panel'))) p.remove();
2250
+ }
2251
+ // Reset the initial `tab` x-data to the first allowed panel and
2252
+ // strip x-show so the lone panel always renders when there's no tab bar.
2253
+ if (wrapper.hasAttribute('x-data')) {
2254
+ wrapper.setAttribute('x-data', `{ tab: '${allowed[0]}' }`);
2255
+ }
2256
+ if (allowed.length === 1) {
2257
+ const lone = wrapper.querySelector(`[data-cp-panel="${allowed[0]}"]`);
2258
+ if (lone) lone.removeAttribute('x-show');
2259
+ }
2260
+ }
2261
+
2262
+ // Alpine.initTree fires all x-* directives in the newly injected content,
2263
+ // which will register solidTemplate/layerTemplate/solidInstances/layersContainer
2264
+ // against THIS state (since ancestor traversal finds this.rootEl).
2265
+ if (window.Alpine?.initTree) {
2266
+ Alpine.initTree(this.rootEl);
2267
+ }
2268
+ // Stash the auto-injected tab scope wrapper so _syncPickerModeFromTab can read it
2269
+ this._autoTabScope = this.rootEl.firstElementChild;
2270
+ },
2271
+
2272
+ _syncPickerModeFromTab() {
2273
+ const rootTab = this.rootEl._x_dataStack?.[0]?.tab;
2274
+ const autoTab = this._autoTabScope?._x_dataStack?.[0]?.tab;
2275
+ const tab = rootTab || autoTab;
2276
+ if (!tab) return;
2277
+ // Only Solid/Gradient tabs drive the edit mode — Library (or any other
2278
+ // tab) preserves the current mode. Otherwise switching to Library while
2279
+ // editing a gradient would silently demote the picker to solid, breaking
2280
+ // the "active" indicator on gradient Recent swatches.
2281
+ let newMode = null;
2282
+ if (tab === 'gradient') newMode = 'gradient';
2283
+ else if (tab === 'solid') newMode = 'solid';
2284
+ if (!newMode || newMode === this.pickerMode) return;
2285
+ this.pickerMode = newMode;
2286
+ if (this.isGradient) {
2287
+ this.renderLayers();
2288
+ if (!this.openStop) this._activateControls(null);
2289
+ } else {
2290
+ this.openStop = null;
2291
+ this._activateControls(this.solidTabRefs);
2292
+ }
2293
+ this.syncToInput();
2294
+ },
2295
+ };
2296
+
2297
+ // ---- Public API exposed via $colorpicker magic ----
2298
+
2299
+ // Reading `state.snapshot.version` inside each getter registers a reactive
2300
+ // dependency. When syncToInput bumps the version, any Alpine effect that
2301
+ // read these getters re-runs — and only then do we compute the value.
2302
+ // Zero computation if nothing is bound.
2303
+ const track = () => state.snapshot.version;
2304
+ state.api = {
2305
+ // Reactive reads — lazily computed on demand
2306
+ get hex() { track(); return state.toHex(); },
2307
+ get formatted() { track(); return state.toActiveColorString(); },
2308
+ get css() { track(); return state.toFormattedString(); },
2309
+ get h() { track(); return state.h; },
2310
+ get s() { track(); return state.s; },
2311
+ get v() { track(); return state.v; },
2312
+ get a() { track(); return state.a; },
2313
+ get format() { track(); return state.format; },
2314
+ get pickerMode() { track(); return state.pickerMode; },
2315
+
2316
+ // Default string coercion → current CSS value. Lets the developer write
2317
+ // :style="`background: ${$colorpicker('id')}`"
2318
+ // x-text="$colorpicker('id')"
2319
+ // and get the color string directly without picking a specific property.
2320
+ [Symbol.toPrimitive]() { track(); return state.toFormattedString(); },
2321
+ toString() { track(); return state.toFormattedString(); },
2322
+ valueOf() { track(); return state.toFormattedString(); },
2323
+
2324
+ // Non-reactive references (direct state)
2325
+ get layers() { return state.layers; },
2326
+ get activeLayer() { return state.activeLayer(); },
2327
+ get activeStop() { return state.activeStop(); },
2328
+ get activeLayerIndex() { return state.activeLayerIndex; },
2329
+ get activeStopIndex() { return state.activeStopIndex; },
2330
+ get openStop() { return state.openStop; },
2331
+
2332
+ // Actions (mirror `.action` modifiers)
2333
+ addLayer: () => state.addLayer(),
2334
+ addLayerAbove: (i) => state.addLayerAt(i ?? state.activeLayerIndex, 'above'),
2335
+ addLayerBelow: (i) => state.addLayerAt(i ?? state.activeLayerIndex, 'below'),
2336
+ moveLayerUp: (i) => state.moveLayer(i ?? state.activeLayerIndex, -1),
2337
+ moveLayerDown: (i) => state.moveLayer(i ?? state.activeLayerIndex, +1),
2338
+ duplicateLayer: (i) => state.duplicateLayer(i ?? state.activeLayerIndex),
2339
+ removeLayer: (i) => state.removeLayer(i ?? state.activeLayerIndex),
2340
+ flipLayer: (i) => state.flipLayer(i ?? state.activeLayerIndex),
2341
+ rotateLayer: (i) => state.rotateLayer(i ?? state.activeLayerIndex),
2342
+ duplicateStop: (li, si) => state.duplicateStop(li ?? state.activeLayerIndex, si ?? state.activeStopIndex),
2343
+ deleteStop: (li, si) => state.deleteStop(li ?? state.activeLayerIndex, si ?? state.activeStopIndex),
2344
+ addStop: (li, pos) => state.addStop(li ?? state.activeLayerIndex, pos),
2345
+ setGradientType: (i, type) => state.setGradientType(i ?? state.activeLayerIndex, type),
2346
+ applyColor: (str) => state.applyColor(str),
2347
+ grabColor: () => state.grabColor(),
2348
+
2349
+ // Setters (mirror `.set-*` modifiers)
2350
+ setHue: (v) => state.setHue(v),
2351
+ setAlpha: (v) => state.setAlpha(v),
2352
+ setAlphaValue: (percent) => state.setAlphaValue(percent),
2353
+ setColorSpace: (fmt) => state.setColorSpace(fmt),
2354
+ setColorValue: (str) => state.setColorValue(str),
2355
+ setAngle: (i, deg) => state.setAngle(i ?? state.activeLayerIndex, deg),
2356
+ setGradientValue: (str) => state.setGradientValue(str),
2357
+
2358
+ // Selection / state helpers
2359
+ selectStop: (li, si) => state.selectStop(li, si),
2360
+ toggleStop: (li, si) => state.toggleStop(li, si),
2361
+ setFromString: (str) => state.setFromString(str),
2362
+ toFormattedString: () => state.toFormattedString(),
2363
+ toHex: () => state.toHex(),
2364
+
2365
+ // Library helpers (global to the plugin; same for every picker)
2366
+ get recent() { return _recentStore.list; },
2367
+ clearRecent: () => clearRecent(),
2368
+ removeRecent: (v) => removeRecent(v),
2369
+ pushRecent: (v) => pushRecent(v),
2370
+ get presets() { return { tailwind: buildTailwindPreset(), ios: buildIosPreset() }; },
2371
+ };
2372
+
2373
+ return state;
2374
+ }
2375
+
2376
+ // ---- Helpers for finding in-clone elements ----
2377
+
2378
+ function findInClone(root, modifier) {
2379
+ // Match any `x-colorpicker.<modifier>` (possibly with additional modifiers or a value)
2380
+ const attrName = 'x-colorpicker.' + modifier;
2381
+ if (root.hasAttribute && root.hasAttribute(attrName)) return root;
2382
+ return root.querySelector(`[${cssEscapeAttr(attrName)}]`);
2383
+ }
2384
+
2385
+ function cssEscapeAttr(name) {
2386
+ return name.replace(/\./g, '\\.');
2387
+ }
2388
+
2389
+ // Rewrite x-dropdown* trigger attributes and the matching <menu id="..."> inside
2390
+ // a cloned subtree so multiple clones don't share the same popover ID.
2391
+ function uniquifyDropdownIdsIn(root, suffix) {
2392
+ const attrs = ['x-dropdown', 'x-dropdown.context', 'x-dropdown.hover'];
2393
+ for (const attr of attrs) {
2394
+ const selector = '[' + attr.replace(/\./g, '\\.') + ']';
2395
+ const triggers = root.querySelectorAll(selector);
2396
+ for (const trigger of triggers) {
2397
+ const original = trigger.getAttribute(attr);
2398
+ if (!original || /[`${}]/.test(original)) continue; // skip Alpine template literals
2399
+ // Only rewrite if we can find the target menu INSIDE this clone
2400
+ let menu = null;
2401
+ try { menu = root.querySelector('#' + CSS.escape(original)); } catch {}
2402
+ if (!menu) continue;
2403
+ const newId = original + '--' + suffix;
2404
+ trigger.setAttribute(attr, newId);
2405
+ menu.id = newId;
2406
+ }
2407
+ }
2408
+ }
2409
+
2410
+ // Coalesce rapid-fire calls (pointermove, input) into at most one per animation frame.
2411
+ // The latest args win. Essential for keeping the main thread responsive on busy devices.
2412
+ function rafThrottle(fn) {
2413
+ let scheduled = false;
2414
+ let lastArgs;
2415
+ let lastThis;
2416
+ return function throttled(...args) {
2417
+ lastArgs = args;
2418
+ lastThis = this;
2419
+ if (scheduled) return;
2420
+ scheduled = true;
2421
+ requestAnimationFrame(() => {
2422
+ scheduled = false;
2423
+ fn.apply(lastThis, lastArgs);
2424
+ });
2425
+ };
2426
+ }
2427
+
2428
+ // JSON-serialize a value for use in an Alpine x-data attribute via setAttribute.
2429
+ // setAttribute does NOT decode HTML entities, so no escaping needed — JSON is
2430
+ // already valid JS literal syntax that Alpine can parse directly.
2431
+ function _jsonStringifyForAlpine(v) {
2432
+ try { return JSON.stringify(v); } catch { return '{}'; }
2433
+ }
2434
+
2435
+ // Evaluate an expression in the scope of a given element, outside a directive context
2436
+ function evaluateLaterShim(el, expression) {
2437
+ if (!window.Alpine?.evaluateLater) {
2438
+ return (cb) => { try { cb(new Function('return ' + expression)()); } catch { cb(null); } };
2439
+ }
2440
+ return Alpine.evaluateLater(el, expression);
2441
+ }
2442
+
2443
+ function findAncestorState(el) {
2444
+ let n = el;
2445
+ while (n) {
2446
+ if (n._colorpickerState) return n._colorpickerState;
2447
+ n = n.parentElement;
2448
+ }
2449
+ return null;
2450
+ }
2451
+
2452
+ function findLayerContext(el) {
2453
+ let n = el;
2454
+ while (n) {
2455
+ if (n.hasAttribute && n.hasAttribute('data-cp-layer-clone')) {
2456
+ return { layerIndex: n._cpLayerIndex, cloneRoot: n };
2457
+ }
2458
+ n = n.parentElement;
2459
+ }
2460
+ return null;
2461
+ }
2462
+
2463
+ // ---- Directive registration ----
2464
+
2465
+ function registerPlugin() {
2466
+ if (!window.Alpine || typeof Alpine.directive !== 'function') return;
2467
+
2468
+ Alpine.directive('colorpicker', (el, { modifiers, expression }, { cleanup, evaluateLater }) => {
2469
+ // Root: no modifiers
2470
+ if (!modifiers || modifiers.length === 0) {
2471
+ // <template x-colorpicker> → registered as the page-wide default
2472
+ // override. Every bare swatch (`<button x-colorpicker.swatch>`) that
2473
+ // would otherwise auto-create an empty popover instead clones from
2474
+ // this template. Only one default per page; first declaration wins.
2475
+ if (el.tagName === 'TEMPLATE') {
2476
+ if (expression || el.id) {
2477
+ // Id-keyed templates are no longer supported — declare the
2478
+ // picker inline (`<menu id="X" popover x-colorpicker>`) or
2479
+ // wrap it in a Manifest HTML component for reuse.
2480
+ try { console.warn('[colorpicker] Id-keyed <template x-colorpicker> is no longer supported. Use a live inline element with the same id, or wrap the picker in an HTML component.'); } catch {}
2481
+ return;
2482
+ }
2483
+ registerDefaultColorpickerTemplate(el);
2484
+ return;
2485
+ }
2486
+
2487
+ const state = createPickerState(el);
2488
+ el._colorpickerState = state;
2489
+
2490
+ // Panel-list expression: `x-colorpicker="['solid', 'gradient']"`.
2491
+ // Parsed once here and used by _injectDefaultUI to filter + reorder
2492
+ // the default UI's tabs and panels. Anything else is ignored.
2493
+ state.allowedPanels = parsePanelsExpression(expression);
2494
+
2495
+ // Find the form-participation input
2496
+ state.hiddenInput = el.querySelector('input[type=color], input[type=hidden]');
2497
+
2498
+ // Find trigger button (any button with x-dropdown pointing to this element's ID)
2499
+ const id = el.id;
2500
+ state.triggerBtn = id ? document.querySelector(`[x-dropdown="${id}"]`) : null;
2501
+ if (state.triggerBtn) {
2502
+ state.triggerBtn.addEventListener('click', () => {
2503
+ requestAnimationFrame(() => state.syncUI());
2504
+ });
2505
+ }
2506
+
2507
+ // Defer mount so all child directives have had a chance to register.
2508
+ // setTimeout (rather than rAF) so we still fire when the tab is
2509
+ // backgrounded or rAF is throttled — mount must happen for the
2510
+ // picker to work, and it doesn't need to be sync'd with paint.
2511
+ setTimeout(() => state.mount(), 0);
2512
+
2513
+ cleanup(() => {
2514
+ if (el.id) delete _pickerRegistry[el.id];
2515
+ delete el._colorpickerState;
2516
+ });
2517
+ return;
2518
+ }
2519
+
2520
+ // Child hook
2521
+ const role = modifiers[0];
2522
+
2523
+ // Swatches work as triggers OUTSIDE any picker — handle before the ancestor check.
2524
+ // We only assign IDs + generate the popover tag; the dropdown plugin (x-dropdown)
2525
+ // owns popover mechanics, anchor positioning, and transitions.
2526
+ if (role === 'swatch') {
2527
+ if (el._cpSwatchWired) return; // guard against re-firing via initTree
2528
+ el._cpSwatchWired = true;
2529
+
2530
+ // ---- Optional x-model binding ----
2531
+ // When a swatch carries x-model, the expression is the source of truth for
2532
+ // that swatch's color. The plugin:
2533
+ // • reactively shows the model value as the swatch background (via CSS var)
2534
+ // • exposes a read accessor the picker uses on open to load the value
2535
+ // • exposes a write accessor the picker uses on close to persist changes
2536
+ // The dev can still apply inline style / class overrides on top.
2537
+ const modelExpr = el.getAttribute('x-model');
2538
+ if (modelExpr && window.Alpine?.evaluateLater && window.Alpine?.effect) {
2539
+ try {
2540
+ const readFn = Alpine.evaluateLater(el, modelExpr);
2541
+ // Reactive background preview
2542
+ Alpine.effect(() => {
2543
+ readFn(v => {
2544
+ if (typeof v === 'string' && v.length) {
2545
+ el.style.setProperty('--color-picker-swatch', v);
2546
+ el.setAttribute('data-cp-model-value', v);
2547
+ }
2548
+ });
2549
+ });
2550
+ el._cpModelGetter = (cb) => readFn(cb);
2551
+ // Writer: evaluate `<modelExpr> = <JSON-stringified value>`. JSON.stringify
2552
+ // ensures the value is safely serialized (color strings + gradient CSS
2553
+ // are all JSON-safe).
2554
+ el._cpModelSetter = (v) => {
2555
+ try { Alpine.evaluate(el, `${modelExpr} = ${JSON.stringify(v)}`); } catch {}
2556
+ };
2557
+ } catch {}
2558
+ }
2559
+
2560
+ // ---- Initial color via `value` attribute ----
2561
+ // The swatch can carry a `value="#abc123"` attribute (mirrors native
2562
+ // <input type="color"> semantics). It seeds the picker on first open
2563
+ // and the swatch's CSS var so the border-color derivation paints
2564
+ // correctly before any interaction.
2565
+ const valueAttr = el.getAttribute('value');
2566
+ if (valueAttr && !el.style.getPropertyValue('--color-picker-swatch')) {
2567
+ el.style.setProperty('--color-picker-swatch', valueAttr);
2568
+ }
2569
+
2570
+ // ---- Form participation via `name` attribute ----
2571
+ // When the swatch has `name=`, the plugin synthesizes a sibling
2572
+ // <input type="hidden"> with that name (or adopts a matching one
2573
+ // already in the DOM). syncToInput then writes the picker's hex
2574
+ // value to it, dispatching input/change events for form code.
2575
+ // No `name` → no synthesized input — purely decorative swatch.
2576
+ const nameAttr = el.getAttribute('name');
2577
+ if (nameAttr) {
2578
+ let hidden = el.parentElement?.querySelector?.(
2579
+ `:scope > input[type=hidden][name="${nameAttr.replace(/"/g, '\\"')}"]`
2580
+ );
2581
+ if (!hidden) {
2582
+ hidden = document.createElement('input');
2583
+ hidden.type = 'hidden';
2584
+ hidden.name = nameAttr;
2585
+ hidden.value = valueAttr || '';
2586
+ el.after(hidden);
2587
+ el._cpSynthesizedHidden = hidden;
2588
+ }
2589
+ el._cpHiddenInput = hidden;
2590
+ // Drop `name` from the swatch itself so the form doesn't pick up
2591
+ // both the (typically empty) button and the hidden input.
2592
+ if (el.tagName === 'BUTTON') el.removeAttribute('name');
2593
+ }
2594
+
2595
+ cleanup(() => {
2596
+ if (el._cpSynthesizedHidden && el._cpSynthesizedHidden.isConnected) {
2597
+ el._cpSynthesizedHidden.remove();
2598
+ }
2599
+ });
2600
+
2601
+ const wireSwatchTo = (target) => {
2602
+ if (!target) return;
2603
+
2604
+ // Alias the picker's api under the swatch's id so consumers reading
2605
+ // via `$colorpicker('<swatch-id>')` track the right reactive key
2606
+ // (otherwise the auto-popover registers under `colorpicker-swatch-N`
2607
+ // and the consumer effect's tracked dep on `_pickerRegistry['<swatch-id>']`
2608
+ // would never fire). Run after mount so the api exists.
2609
+ const aliasToSwatchId = () => {
2610
+ if (!el.id) return;
2611
+ const st = target._colorpickerState
2612
+ || target.querySelector?.('[x-colorpicker]')?._colorpickerState;
2613
+ if (st && st.api) _pickerRegistry[el.id] = st.api;
2614
+ else setTimeout(aliasToSwatchId, 0);
2615
+ };
2616
+ aliasToSwatchId();
2617
+
2618
+ const isDialog = target.tagName === 'DIALOG';
2619
+
2620
+ // For non-dialog popover targets (menu / div with popover), delegate
2621
+ // open/close + anchor positioning to the dropdowns plugin so the
2622
+ // picker appears anchored to the swatch like a dropdown menu.
2623
+ // <dialog> targets are NOT routed through x-dropdown — dialogs are
2624
+ // modal/centered surfaces, not anchored to a trigger. We open them
2625
+ // imperatively on click via showPopover() or showModal().
2626
+ if (!isDialog
2627
+ && target.hasAttribute('popover')
2628
+ && !el.hasAttribute('popovertarget')
2629
+ && !el.hasAttribute('x-dropdown')) {
2630
+ el.setAttribute('x-dropdown', target.id);
2631
+ if (window.Alpine?.initTree) Alpine.initTree(el);
2632
+ }
2633
+
2634
+ // Retarget the picker to this swatch on click (load its color + point writes here)
2635
+ el.addEventListener('click', (e) => {
2636
+ // Open dialogs imperatively. Prefer popover semantics when the
2637
+ // dialog has a `popover` attribute (light-dismiss); otherwise
2638
+ // open as a true modal with backdrop and focus trap.
2639
+ if (isDialog) {
2640
+ e.preventDefault();
2641
+ try {
2642
+ if (target.hasAttribute('popover')) {
2643
+ if (!target.matches(':popover-open')) target.showPopover();
2644
+ } else {
2645
+ if (!target.open) target.showModal();
2646
+ }
2647
+ } catch {}
2648
+ }
2649
+
2650
+ const retarget = () => {
2651
+ // Picker state may live on `target` itself (e.g. <menu x-colorpicker>)
2652
+ // or on a descendant when the target is a wrapping container such
2653
+ // as <dialog> hosting <div x-colorpicker> inside.
2654
+ let st = target._colorpickerState;
2655
+ if (!st) {
2656
+ const inner = target.querySelector?.('[x-colorpicker]');
2657
+ if (inner && inner._colorpickerState) st = inner._colorpickerState;
2658
+ }
2659
+ if (!st) { setTimeout(retarget, 0); return; }
2660
+ st.triggerBtn = el;
2661
+ // Route form-participation writes to this swatch's hidden
2662
+ // input (synthesized from `name`, or dev-supplied sibling).
2663
+ // Falls back to whatever the picker container already had
2664
+ // — preserves the existing inline-input flow.
2665
+ if (el._cpHiddenInput) st.hiddenInput = el._cpHiddenInput;
2666
+ // Load the trigger's current value. Priority:
2667
+ // 1. x-model getter (shared-picker flow)
2668
+ // 2. paired hidden input value (form-participation flow)
2669
+ // 3. `value` attribute on the swatch
2670
+ // 4. --color-picker-swatch CSS var (auto / inline-style flow)
2671
+ // 5. fallback '#000000'
2672
+ if (el._cpModelGetter) {
2673
+ el._cpModelGetter(v => { if (typeof v === 'string' && v.length) st.setFromString(v); });
2674
+ } else {
2675
+ const current = (el._cpHiddenInput && el._cpHiddenInput.value)
2676
+ || el.getAttribute('value')
2677
+ || el.style.getPropertyValue('--color-picker-swatch')
2678
+ || getComputedStyle(el).getPropertyValue('--color-picker-swatch').trim()
2679
+ || '#000000';
2680
+ if (current) st.setFromString(current);
2681
+ }
2682
+ // Defer heavy UI sync so the popover's entry transition runs unimpeded
2683
+ setTimeout(() => st.syncUI(), 0);
2684
+ };
2685
+ retarget();
2686
+ });
2687
+ };
2688
+
2689
+ // Panel-list expression on a swatch (`x-colorpicker.swatch="['solid']"`)
2690
+ // → not an id, not a template literal — auto-create a popover and pass
2691
+ // the panels through so its picker UI is filtered to that subset.
2692
+ const panelsExpr = parsePanelsExpression(expression);
2693
+
2694
+ if (!expression) {
2695
+ // Bare swatch → auto-create popover with generated ID
2696
+ wireSwatchTo(createSwatchPopover());
2697
+ } else if (panelsExpr) {
2698
+ // Panel list → auto-create popover with the expression preserved
2699
+ wireSwatchTo(createSwatchPopover(undefined, expression));
2700
+ } else if (expression.includes('${') || expression.includes('`')) {
2701
+ // Alpine template literal → resolve, then look up or auto-create
2702
+ const evaluator = evaluateLater(expression);
2703
+ evaluator(val => {
2704
+ if (!val) return;
2705
+ const t = resolvePickerById(val) || createSwatchPopover(val);
2706
+ wireSwatchTo(t);
2707
+ });
2708
+ } else {
2709
+ // Static ID → resolve via inline / template / auto
2710
+ const t = resolvePickerById(expression) || createSwatchPopover(expression);
2711
+ wireSwatchTo(t);
2712
+ }
2713
+ return;
2714
+ }
2715
+
2716
+ // All other child hooks require an ancestor picker state
2717
+ const state = findAncestorState(el);
2718
+ if (!state) return;
2719
+
2720
+ switch (role) {
2721
+ case 'solid':
2722
+ if (el.tagName === 'TEMPLATE') {
2723
+ state.solidTemplate = el;
2724
+ } else if (!findLayerContext(el)) {
2725
+ // Top-level instance (e.g. Solid tab). Accordion instances
2726
+ // inside a layer clone are handled by renderLayers directly.
2727
+ state.solidInstances.push(el);
2728
+ }
2729
+ return;
2730
+
2731
+ case 'layer-options':
2732
+ if (el.tagName === 'TEMPLATE') state.layerTemplate = el;
2733
+ return;
2734
+
2735
+ case 'gradient':
2736
+ if (el.tagName === 'TEMPLATE') state.gradientTemplate = el;
2737
+ else state.gradientInstances.push(el);
2738
+ return;
2739
+
2740
+ case 'gradient-layers':
2741
+ state.layersContainer = el;
2742
+ return;
2743
+
2744
+ case 'layer-stops-bar':
2745
+ // Handled per-clone in _renderStopBar
2746
+ return;
2747
+
2748
+ // Actions
2749
+ case 'add-layer':
2750
+ el.addEventListener('click', () => state.addLayer());
2751
+ return;
2752
+
2753
+ case 'grab-color':
2754
+ el.addEventListener('click', () => state.grabColor());
2755
+ return;
2756
+
2757
+ case 'apply-color': {
2758
+ el.addEventListener('click', () => {
2759
+ // Respect disabled attribute (used when the swatch is a gradient
2760
+ // and the user is editing a gradient stop — CSS doesn't allow
2761
+ // gradients as color stop values).
2762
+ if (el.hasAttribute('disabled')) return;
2763
+ const cs = window.getComputedStyle(el);
2764
+ const raw = el.style.background || el.style.backgroundColor || cs.backgroundColor;
2765
+ if (!raw) return;
2766
+ // Library-swatch clicks are marked so the commit cycle knows not
2767
+ // to record them as "recent" even if the picker closes afterwards.
2768
+ const fromLibrary = !!el.closest('[x-data*="swatch:"]');
2769
+ const fromStopMenu = !!el.closest('menu[id^="stop-context-menu"]');
2770
+ state._markUserChange(fromLibrary);
2771
+
2772
+ // Top-level library picks (NOT inside a stop-context-menu) replace
2773
+ // the WHOLE field — switch picker mode to match the swatch's value
2774
+ // type so a solid swatch doesn't become a stop in an existing
2775
+ // gradient and a gradient swatch doesn't get parsed as the active
2776
+ // stop's color. Stop-menu picks intentionally write to the right-
2777
+ // clicked stop and stay in gradient mode.
2778
+ // Use _setPickerMode (not _switchToSolidMode) — we don't want to
2779
+ // flip the user off whatever tab they're on (typically Library).
2780
+ const valueIsGradient = raw.includes('gradient(');
2781
+ if (fromLibrary && !fromStopMenu) {
2782
+ state._setPickerMode(valueIsGradient ? 'gradient' : 'solid');
2783
+ }
2784
+
2785
+ state.applyColor(raw);
2786
+ });
2787
+ return;
2788
+ }
2789
+
2790
+ case 'remove-recent': {
2791
+ // Expected placement: a menu item inside a <menu popover> referenced by
2792
+ // x-dropdown.context on a Recent swatch. The dropdowns plugin stashes the
2793
+ // triggering element on `menu._triggerEl`. We read its `data-cp-value`
2794
+ // (the raw stored form) and remove that entry from the Recent cookie.
2795
+ el.addEventListener('click', () => {
2796
+ const menu = el.closest('[popover]');
2797
+ const trigger = menu?._triggerEl || menu?._triggerHost;
2798
+ const value = trigger?.getAttribute?.('data-cp-value');
2799
+ if (value) removeRecent(value);
2800
+ });
2801
+ return;
2802
+ }
2803
+
2804
+ case 'duplicate-layer':
2805
+ case 'remove-layer':
2806
+ case 'flip-layer':
2807
+ case 'rotate-layer':
2808
+ case 'add-layer-above':
2809
+ case 'add-layer-below':
2810
+ case 'move-layer-up':
2811
+ case 'move-layer-down':
2812
+ case 'duplicate-stop':
2813
+ case 'delete-stop':
2814
+ case 'set-gradient-type': {
2815
+ el.addEventListener('click', () => {
2816
+ // Respect :disabled bindings — Alpine toggles the attribute on the
2817
+ // element; a disabled menu item shouldn't fire its action.
2818
+ if (el.hasAttribute('disabled')) return;
2819
+ const ctx = findLayerContext(el);
2820
+ const li = ctx ? ctx.layerIndex : state.activeLayerIndex;
2821
+ switch (role) {
2822
+ case 'duplicate-layer': state.duplicateLayer(li); break;
2823
+ case 'remove-layer': state.removeLayer(li); break;
2824
+ case 'flip-layer': state.flipLayer(li); break;
2825
+ case 'rotate-layer': state.rotateLayer(li); break;
2826
+ case 'add-layer-above': state.addLayerAt(li, 'above'); break;
2827
+ case 'add-layer-below': state.addLayerAt(li, 'below'); break;
2828
+ case 'move-layer-up': state.moveLayer(li, -1); break;
2829
+ case 'move-layer-down': state.moveLayer(li, +1); break;
2830
+ case 'duplicate-stop': state.duplicateStop(li, state.activeStopIndex); break;
2831
+ case 'delete-stop': state.deleteStop(li, state.activeStopIndex); break;
2832
+ case 'set-gradient-type': {
2833
+ // Value comes from the expression (Alpine parsed) or the attribute
2834
+ const type = expression || el.getAttribute('x-colorpicker.set-gradient-type');
2835
+ state.setGradientType(li, (type || '').replace(/['"]/g, ''));
2836
+ break;
2837
+ }
2838
+ }
2839
+ });
2840
+ return;
2841
+ }
2842
+
2843
+ // Inputs inside layer (angle)
2844
+ case 'set-angle': {
2845
+ // Input listener
2846
+ el.addEventListener('input', () => {
2847
+ const ctx = findLayerContext(el);
2848
+ const li = ctx ? ctx.layerIndex : state.activeLayerIndex;
2849
+ state.setAngle(li, parseFloat(el.value) || 0);
2850
+ });
2851
+ // Drag-scrub
2852
+ let scrubbing = false, scrubStartX = 0, scrubStartAngle = 0, scrubLi = 0;
2853
+ el.addEventListener('pointerdown', (e) => {
2854
+ if (document.activeElement === el) return;
2855
+ e.preventDefault();
2856
+ const ctx = findLayerContext(el);
2857
+ scrubLi = ctx ? ctx.layerIndex : state.activeLayerIndex;
2858
+ scrubbing = true;
2859
+ scrubStartX = e.clientX;
2860
+ scrubStartAngle = state.layers[scrubLi]?.angle || 0;
2861
+ el.setPointerCapture(e.pointerId);
2862
+ });
2863
+ const applyScrub = (e) => {
2864
+ const newAngle = scrubStartAngle + (e.clientX - scrubStartX);
2865
+ state.setAngle(scrubLi, Math.round(newAngle));
2866
+ el.value = state.layers[scrubLi]?.angle || 0;
2867
+ };
2868
+ const throttledScrub = rafThrottle(applyScrub);
2869
+ el.addEventListener('pointermove', (e) => {
2870
+ if (!scrubbing) return;
2871
+ throttledScrub(e);
2872
+ });
2873
+ el.addEventListener('pointerup', (e) => {
2874
+ if (scrubbing) {
2875
+ scrubbing = false;
2876
+ if (Math.abs(e.clientX - scrubStartX) < 3) { el.focus(); el.select(); }
2877
+ }
2878
+ });
2879
+ return;
2880
+ }
2881
+
2882
+ // Gradient CSS textarea
2883
+ case 'set-gradient-value':
2884
+ state.gradientValueInputs.push(el);
2885
+ el.addEventListener('input', () => {
2886
+ state.setGradientValue(el.value);
2887
+ });
2888
+ return;
2889
+
2890
+ // Solid-tab controls (wired via _wireSolidControls when mounted)
2891
+ // If the developer places them OUTSIDE a solid-panel instance, wire individually:
2892
+ case 'set-canvas':
2893
+ case 'set-hue':
2894
+ case 'set-alpha':
2895
+ case 'set-alpha-value':
2896
+ case 'set-color-value': {
2897
+ // These are typically inside a solid-panel template/instance.
2898
+ // (The usual path handles them inside _mountSolidInstance.)
2899
+ return;
2900
+ }
2901
+
2902
+ // set-color-space supports two roles:
2903
+ // • With expression (`<li x-colorpicker.set-color-space="hex">`) →
2904
+ // click sets that format. Tracked so we can toggle .active on the
2905
+ // current choice.
2906
+ // • Without expression (`<button x-colorpicker.set-color-space>`) →
2907
+ // reactive label whose text reflects the active format. Useful as
2908
+ // a dropdown trigger that shows the current format.
2909
+ // • <select x-colorpicker.set-color-space> still works through the
2910
+ // legacy _wireSolidControls flow inside a solid-panel instance.
2911
+ case 'set-color-space': {
2912
+ if (el.tagName === 'SELECT') return; // legacy flow handles it
2913
+ const raw = (expression || '').replace(/['"`]/g, '').trim().toLowerCase();
2914
+ if (raw) {
2915
+ // Choice element — click selects this format
2916
+ state.formatChoiceEls.push({ el, fmt: raw });
2917
+ el.addEventListener('click', () => {
2918
+ if (el.hasAttribute('disabled')) return;
2919
+ state.setColorSpace(raw);
2920
+ });
2921
+ } else {
2922
+ // Label element — text reflects current format
2923
+ state.formatLabelEls.push(el);
2924
+ }
2925
+ state._refreshFormatLabels();
2926
+ return;
2927
+ }
2928
+
2929
+ // ---- Library ----
2930
+
2931
+ case 'library': {
2932
+ if (el.tagName === 'TEMPLATE') {
2933
+ // Dev-defined library layout — cloned into the container at render time.
2934
+ // Nested <template x-colorpicker.library-group/palette/swatch> are resolved
2935
+ // in-place during rendering (x-for style).
2936
+ state.libraryTemplate = el;
2937
+ } else {
2938
+ // Container where the library renders. Multiple containers are supported
2939
+ // (e.g., the primary tab AND inline menus like stop-context-menu); each
2940
+ // receives an independent clone of the library template. The expression
2941
+ // from the FIRST container wins as the data source; others reuse it.
2942
+ if (!state.libraryContainers.includes(el)) {
2943
+ state.libraryContainers.push(el);
2944
+ // If the picker is already mounted, render into ONLY this new
2945
+ // container so other containers' in-flight directive inits
2946
+ // (x-dropdown.context menu lookups, tooltips, etc.) aren't
2947
+ // torn down mid-init.
2948
+ if (state._mounted) state._renderIntoContainer(el);
2949
+ }
2950
+ if (expression && !state.libraryRootValue) state.libraryRootValue = expression;
2951
+ }
2952
+ return;
2953
+ }
2954
+
2955
+ case 'library-group':
2956
+ case 'library-palette':
2957
+ case 'library-swatch':
2958
+ case 'library-recent-swatch': {
2959
+ // Nested templates — no registration. Resolved via querySelector at render time
2960
+ // (so their position in the HTML determines where clones land).
2961
+ // `library-recent-swatch` is an optional alternate template used only for
2962
+ // swatches inside the Recent group (typically wires up x-dropdown.context).
2963
+ return;
2964
+ }
2965
+
2966
+ }
2967
+ });
2968
+
2969
+ // $colorpicker — accessor that is both callable (`$colorpicker('id')`) and property-readable
2970
+ // (`$colorpicker.hex` — uses nearest ancestor picker).
2971
+ Alpine.magic('colorpicker', (el) => {
2972
+ const localState = findAncestorState(el);
2973
+ const localApi = localState?.api || null;
2974
+
2975
+ // Function form: `$colorpicker('picker-id')` → that picker's API.
2976
+ // Reads from the reactive registry so bindings resolve even when the
2977
+ // picker is declared later in the DOM than its consumer.
2978
+ const byId = (id) => {
2979
+ if (!id) return localApi;
2980
+ // Reactive read: tracks the key even if not yet registered
2981
+ const api = _pickerRegistry[id];
2982
+ if (api) return api;
2983
+ // Allow lookup by swatch button ID (resolve through its popovertarget)
2984
+ const el2 = document.getElementById(id);
2985
+ if (el2 && el2.hasAttribute('popovertarget')) {
2986
+ const popoverId = el2.getAttribute('popovertarget');
2987
+ const api2 = _pickerRegistry[popoverId];
2988
+ if (api2) return api2;
2989
+ }
2990
+ return _nullApi;
2991
+ };
2992
+
2993
+ return new Proxy(byId, {
2994
+ get(fn, prop) {
2995
+ // Coerce `${$colorpicker}` (no call) to the local picker's CSS string
2996
+ if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') {
2997
+ return () => (localApi ? localApi.css : '');
2998
+ }
2999
+ // Global helpers (picker-agnostic) — useful for library composition.
3000
+ // Each is BOTH callable AND spreadable:
3001
+ // {...$colorpicker.tailwind} → default English preset
3002
+ // $colorpicker.tailwind(labels) → localized preset (same values, translated names)
3003
+ // {...$colorpicker.tailwind(labels)} → spread the localized result
3004
+ if (prop === 'presets') return _makeCallablePreset(buildDefaultLibrary);
3005
+ if (prop === 'tailwind') return _makeCallablePreset(buildTailwindPreset);
3006
+ if (prop === 'ios') return _makeCallablePreset(buildIosPreset);
3007
+ if (prop === 'recent') return _recentStore.list.slice(0, _recentMax);
3008
+ if (localApi && prop in localApi) return localApi[prop];
3009
+ return fn[prop];
3010
+ },
3011
+ has(fn, prop) {
3012
+ if (prop === 'presets' || prop === 'tailwind' || prop === 'ios' || prop === 'recent') return true;
3013
+ return (localApi && prop in localApi) || prop in fn;
3014
+ }
3015
+ });
3016
+ });
3017
+ }
3018
+
3019
+ // ---- Picker resolution: inline / default-template / auto-generated ----
3020
+ //
3021
+ // Two ways a dev can declare a picker:
3022
+ // 1. Live inline element: <menu id="brand-picker" popover x-colorpicker>…</menu>
3023
+ // <dialog id="brand-picker" x-colorpicker>…</dialog>
3024
+ // <div id="brand-picker" x-colorpicker>…</div>
3025
+ // 2. Auto-created (bare): <button x-colorpicker.swatch> generates its own popover.
3026
+ // By default, the popover is filled with the plugin's
3027
+ // hardcoded fallback UI. Devs can override it page-wide
3028
+ // by adding a single `<template x-colorpicker>` (no id)
3029
+ // anywhere in the markup — the auto-creator clones from
3030
+ // that instead.
3031
+ //
3032
+ // For "componentize and reuse" use cases that previously needed an id-keyed template,
3033
+ // wrap the picker in a Manifest HTML component (`<x-my-picker>`) and drop it wherever
3034
+ // it's needed. This keeps the plugin's resolution model deliberately small.
3035
+
3036
+ let _defaultColorpickerTemplate = null; // <template x-colorpicker> (no id)
3037
+
3038
+ function registerDefaultColorpickerTemplate(tpl) {
3039
+ // First wins. Subsequent declarations are ignored — explicit, no surprises.
3040
+ if (!tpl || _defaultColorpickerTemplate) return;
3041
+ _defaultColorpickerTemplate = tpl;
3042
+ }
3043
+
3044
+ // Resolve a swatch's target by id. Inline elements only — templates are never
3045
+ // looked up by id anymore (use a `<menu id="X" x-colorpicker>` or wrap in an
3046
+ // HTML component for that pattern).
3047
+ function resolvePickerById(id) {
3048
+ if (!id) return null;
3049
+ const live = document.getElementById(id);
3050
+ if (live && live.tagName !== 'TEMPLATE') return live;
3051
+ return null;
3052
+ }
3053
+
3054
+ // ---- Auto-created fallback popover per swatch ----
3055
+
3056
+ let _swatchPopoverCounter = 0;
3057
+ function nextAutoSwatchId() {
3058
+ _swatchPopoverCounter++;
3059
+ while (document.getElementById('colorpicker-swatch-' + _swatchPopoverCounter)) _swatchPopoverCounter++;
3060
+ return 'colorpicker-swatch-' + _swatchPopoverCounter;
3061
+ }
3062
+
3063
+ function createSwatchPopover(customId, panelsExpr) {
3064
+ const id = customId || nextAutoSwatchId();
3065
+
3066
+ // If a default `<template x-colorpicker>` is registered, clone its root
3067
+ // and use that as the swatch's popover. Preserves the dev's chosen
3068
+ // wrapper (menu / dialog / div) and any attributes they put on it.
3069
+ // The dev's content inside the template is rendered verbatim (mount's
3070
+ // noDeclared check sees real children and skips _injectDefaultUI).
3071
+ // If the template exists in the DOM but its directive hasn't fired yet
3072
+ // (source-order race), scan for it now so swatches earlier in the tree
3073
+ // still pick it up.
3074
+ if (!_defaultColorpickerTemplate) {
3075
+ const candidates = document.querySelectorAll('template[x-colorpicker]');
3076
+ for (const t of candidates) {
3077
+ if (!t.id && !t.getAttribute('x-colorpicker')) {
3078
+ registerDefaultColorpickerTemplate(t);
3079
+ break;
3080
+ }
3081
+ }
3082
+ }
3083
+ let root = null;
3084
+ if (_defaultColorpickerTemplate) {
3085
+ const frag = _defaultColorpickerTemplate.content.cloneNode(true);
3086
+ root = frag.firstElementChild;
3087
+ }
3088
+ if (root) {
3089
+ if (!root.hasAttribute('x-colorpicker')) root.setAttribute('x-colorpicker', panelsExpr || '');
3090
+ else if (panelsExpr) root.setAttribute('x-colorpicker', panelsExpr);
3091
+ if (root.tagName !== 'DIALOG' && !root.hasAttribute('popover')) root.setAttribute('popover', '');
3092
+ root.id = id;
3093
+ document.body.appendChild(root);
3094
+ if (window.Alpine?.initTree) Alpine.initTree(root);
3095
+ return root;
3096
+ }
3097
+
3098
+ // No default template → empty <menu> populated by _injectDefaultUI on mount.
3099
+ const menu = document.createElement('menu');
3100
+ menu.setAttribute('popover', '');
3101
+ // Pass the panel-list expression through to the root x-colorpicker directive
3102
+ // so the auto-created popover only shows the panels the swatch requested.
3103
+ menu.setAttribute('x-colorpicker', panelsExpr || '');
3104
+ menu.id = id;
3105
+ menu.className = 'colorpicker dropdown-menu';
3106
+ document.body.appendChild(menu);
3107
+ if (window.Alpine?.initTree) Alpine.initTree(menu);
3108
+ return menu;
3109
+ }
3110
+
3111
+ // Parse a directive expression as a panel list. Accepts JS array literals like
3112
+ // "['solid', 'gradient']" or "[\"library\"]". Returns a normalized array of
3113
+ // recognized panel names, or null if the expression isn't a panel list.
3114
+ const _validPanels = ['solid', 'gradient', 'library'];
3115
+ function parsePanelsExpression(expr) {
3116
+ if (!expr || typeof expr !== 'string') return null;
3117
+ const trimmed = expr.trim();
3118
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null;
3119
+ try {
3120
+ // JSON.parse after normalizing single quotes — the expressions we accept
3121
+ // are simple string-array literals, so a quote swap is safe.
3122
+ const arr = JSON.parse(trimmed.replace(/'/g, '"'));
3123
+ if (!Array.isArray(arr)) return null;
3124
+ const out = arr
3125
+ .map(s => typeof s === 'string' ? s.trim().toLowerCase() : null)
3126
+ .filter(s => s && _validPanels.includes(s));
3127
+ return out.length ? out : null;
3128
+ } catch {
3129
+ return null;
3130
+ }
3131
+ }
3132
+
3133
+ registerPlugin();
3134
+ }
3135
+
3136
+ // Track initialization
3137
+ let colorpickerPluginInitialized = false;
3138
+ function ensureColorpickerPluginInitialized() {
3139
+ if (colorpickerPluginInitialized) return;
3140
+ if (!window.Alpine || typeof window.Alpine.directive !== 'function') return;
3141
+ colorpickerPluginInitialized = true;
3142
+ initializeColorpickerPlugin();
3143
+ // Process any x-colorpicker elements already in the DOM
3144
+ if (window.Alpine && typeof window.Alpine.initTree === 'function') {
3145
+ document.querySelectorAll('[x-colorpicker]').forEach(el => { if (!el.__x) window.Alpine.initTree(el); });
3146
+ }
3147
+ }
3148
+ window.ensureColorpickerPluginInitialized = ensureColorpickerPluginInitialized;
3149
+
3150
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ensureColorpickerPluginInitialized);
3151
+ document.addEventListener('alpine:init', ensureColorpickerPluginInitialized);
3152
+ if (window.Alpine && typeof window.Alpine.directive === 'function') setTimeout(ensureColorpickerPluginInitialized, 0);
3153
+ else {
3154
+ const check = setInterval(() => { if (window.Alpine && typeof window.Alpine.directive === 'function') { clearInterval(check); ensureColorpickerPluginInitialized(); } }, 10);
3155
+ setTimeout(() => clearInterval(check), 5000);
3156
+ }