mnfst 0.5.62 → 0.5.64

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,3033 @@
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 reads $x.manifest.colorpicker to know which $x.* sources to
501
+ // treat as library content, then merges them in listed order. Each source may contain:
502
+ // _tailwind: (optional) replaces the built-in Tailwind group entirely
503
+ // _ios: (optional) replaces the built-in iOS group entirely
504
+ // <Any Name>: custom groups appended to the library after built-ins
505
+ //
506
+ // Presence of _tailwind / _ios is "all or nothing" — no merge with built-in data.
507
+ // If multiple flagged sources both include _tailwind, the LAST source wins.
508
+
509
+ // Manifest's data proxy returns an empty object for ANY property access (graceful chain),
510
+ // so `src._tailwind` always looks truthy. We only treat an override as real if the source
511
+ // actually declared the key (`in` operator) AND the value has real content.
512
+ function _hasRealContent(v) {
513
+ if (v == null || typeof v !== 'object') return false;
514
+ return Object.keys(v).some(k => !k.startsWith('$')
515
+ && k !== 'valueOf' && k !== 'toString'
516
+ && k !== 'contentType');
517
+ }
518
+
519
+ // Takes an array of resolved source values, extracts _tailwind / _ios overrides and
520
+ // custom groups from each, and returns the final normalized groups array.
521
+ function composeLibraryFromSources(sources) {
522
+ let tailwindOverride = null;
523
+ let iosOverride = null;
524
+ const customGroups = [];
525
+
526
+ for (const src of sources) {
527
+ if (src == null || typeof src !== 'object' || Array.isArray(src)) continue;
528
+
529
+ // Extract override blocks only if actually declared in source AND have real content.
530
+ if ('_tailwind' in src && _hasRealContent(src._tailwind)) tailwindOverride = src._tailwind;
531
+ if ('_ios' in src && _hasRealContent(src._ios)) iosOverride = src._ios;
532
+
533
+ // All other (non-reserved, non-metadata) keys become custom groups
534
+ for (const [k, v] of _cleanLibraryEntries(src)) {
535
+ if (k === '_tailwind' || k === '_ios') continue;
536
+ if (!_hasRealContent(v)) continue;
537
+ customGroups.push(normalizeGroup(v, k));
538
+ }
539
+ }
540
+
541
+ // Assemble final group order: Recent → custom → Tailwind → iOS.
542
+ // Recent may be empty (pre-use state) — we render the group only when it has content.
543
+ const recent = _recentStore.list.slice(0, _recentMax);
544
+ const groups = [];
545
+ if (recent.length) {
546
+ const recentGroup = normalizeGroup(recent, 'Recent');
547
+ recentGroup._isRecent = true; // marker for contextmenu binding
548
+ groups.push(recentGroup);
549
+ }
550
+
551
+ groups.push(...customGroups);
552
+
553
+ if (tailwindOverride) {
554
+ // `_group` meta inside the override becomes the group heading; default to "Tailwind"
555
+ groups.push(normalizeGroup(tailwindOverride, tailwindOverride._group || 'Tailwind'));
556
+ } else {
557
+ groups.push(...normalizeLibraryInput(buildTailwindPreset()));
558
+ }
559
+
560
+ if (iosOverride) {
561
+ groups.push(normalizeGroup(iosOverride, iosOverride._group || 'iOS'));
562
+ } else {
563
+ groups.push(...normalizeLibraryInput(buildIosPreset()));
564
+ }
565
+
566
+ return groups;
567
+ }
568
+
569
+ // Returns an object that is BOTH callable (for localization) AND spreadable (for composition).
570
+ // `builder` is a function that takes optional labels and returns a preset object.
571
+ // Spreading the returned value exposes the default (unlabeled) preset's top-level keys.
572
+ function _makeCallablePreset(builder) {
573
+ const fn = (labels) => builder(labels);
574
+ Object.assign(fn, builder()); // default preset's keys become own enumerable props on fn
575
+ return fn;
576
+ }
577
+
578
+ // Render a group by cloning the group template and expanding nested templates in place.
579
+ // Returns a fragment with all top-level elements (scope applied to the first). `isRecent`
580
+ // threads down so palette/swatch layers can pick the Recent-specific swatch template.
581
+ function renderLibraryGroup(groupTpl, group) {
582
+ const frag = groupTpl.content.cloneNode(true);
583
+ const primary = frag.firstElementChild;
584
+ if (!primary) return frag;
585
+ primary.setAttribute('x-data', '{ group: ' + _jsonStringifyForAlpine(group) + ' }');
586
+
587
+ const isRecent = !!group._isRecent;
588
+
589
+ // Normalize: flat groups become single unnamed palette so templates work uniformly
590
+ const palettes = (group.palettes && group.palettes.length)
591
+ ? group.palettes
592
+ : (group.colors && group.colors.length ? [{ name: null, colors: group.colors }] : []);
593
+
594
+ const paletteTpl = frag.querySelector('template[x-colorpicker\\.library-palette]');
595
+ if (paletteTpl) {
596
+ const parent = paletteTpl.parentNode;
597
+ for (const p of palettes) parent.insertBefore(renderLibraryPalette(paletteTpl, p, isRecent), paletteTpl);
598
+ paletteTpl.remove();
599
+ } else {
600
+ // No palette template — look for a swatch template directly inside the group.
601
+ // Prefer the Recent-specific template for Recent groups, else fall back.
602
+ const swatchTpl = (isRecent ? frag.querySelector('template[x-colorpicker\\.library-recent-swatch]') : null)
603
+ || frag.querySelector('template[x-colorpicker\\.library-swatch]');
604
+ if (swatchTpl) {
605
+ const parent = swatchTpl.parentNode;
606
+ const allColors = palettes.flatMap(p => p.colors || []);
607
+ for (const sw of allColors) parent.insertBefore(renderLibrarySwatch(swatchTpl, sw), swatchTpl);
608
+ swatchTpl.remove();
609
+ }
610
+ }
611
+ return frag;
612
+ }
613
+
614
+ function renderLibraryPalette(paletteTpl, palette, isRecent) {
615
+ const frag = paletteTpl.content.cloneNode(true);
616
+ const primary = frag.firstElementChild;
617
+ if (!primary) return frag;
618
+ primary.setAttribute('x-data', '{ palette: ' + _jsonStringifyForAlpine(palette) + ' }');
619
+
620
+ // Recent palettes prefer library-recent-swatch template if defined.
621
+ const swatchTpl = (isRecent ? frag.querySelector('template[x-colorpicker\\.library-recent-swatch]') : null)
622
+ || frag.querySelector('template[x-colorpicker\\.library-swatch]');
623
+ if (swatchTpl) {
624
+ const parent = swatchTpl.parentNode;
625
+ for (const sw of (palette.colors || [])) parent.insertBefore(renderLibrarySwatch(swatchTpl, sw), swatchTpl);
626
+ swatchTpl.remove();
627
+ }
628
+ return frag;
629
+ }
630
+
631
+ // Per-clone counter for uniquifying nested dropdown/menu ids — mirrors the scheme
632
+ // used for gradient layer clones. Only menus that live INSIDE the swatch template
633
+ // get uniquified; menus placed at the library root are left alone (shared).
634
+ let _swatchCloneCounter = 0;
635
+
636
+ function renderLibrarySwatch(swatchTpl, swatch) {
637
+ const frag = swatchTpl.content.cloneNode(true);
638
+ const primary = frag.firstElementChild;
639
+ if (!primary) return frag;
640
+ // Apply the swatch scope to the primary element (typically the swatch button).
641
+ // Sibling elements like nested menus don't need swatch scope — their actions
642
+ // read from the dropdowns plugin's trigger ref.
643
+ primary.setAttribute('x-data', '{ swatch: ' + _jsonStringifyForAlpine(swatch) + ' }');
644
+ // Raw value + canonical key go on BOTH the actual apply-color element AND the
645
+ // primary wrapper (if different). That way `_updateActiveSwatches` can toggle
646
+ // `.active` on both, and dev CSS targeting either selector — the wrapper div
647
+ // (for layout effects like `order: -1`) or the button (for box-shadow) — works.
648
+ if (swatch && typeof swatch.value === 'string') {
649
+ const applyEl = frag.querySelector('[x-colorpicker\\.apply-color]');
650
+ const key = _swatchKeyOf(swatch.value);
651
+ const targets = new Set();
652
+ if (primary) targets.add(primary);
653
+ if (applyEl) targets.add(applyEl);
654
+ for (const t of targets) {
655
+ t.setAttribute('data-cp-value', swatch.value);
656
+ if (key) t.setAttribute('data-cp-key', key);
657
+ }
658
+ }
659
+ // Uniquify any nested dropdown/menu ids so per-swatch context menus don't
660
+ // collide. Skipped automatically when the menu lives outside the template.
661
+ uniquifyDropdownIdsIn(frag, 'swatch-' + (++_swatchCloneCounter));
662
+ return frag;
663
+ }
664
+
665
+ // Default fallback: if no library template supplied, render a small heading per group
666
+ // and a flex-wrap row of apply-color spans for each swatch.
667
+ function renderDefaultGroup(group) {
668
+ const root = document.createElement('div');
669
+ root.setAttribute('data-cp-library-group', group.name || '');
670
+ if (group.name) {
671
+ const h = document.createElement('small');
672
+ h.textContent = group.name;
673
+ root.appendChild(h);
674
+ }
675
+ const palettes = (group.palettes && group.palettes.length)
676
+ ? group.palettes
677
+ : (group.colors && group.colors.length ? [{ name: null, colors: group.colors }] : []);
678
+ for (const p of palettes) {
679
+ if (p.name) {
680
+ const ph = document.createElement('small');
681
+ ph.textContent = p.name;
682
+ root.appendChild(ph);
683
+ }
684
+ const row = document.createElement('div');
685
+ row.className = 'swatches';
686
+ for (const sw of (p.colors || [])) {
687
+ const span = document.createElement('span');
688
+ span.setAttribute('x-colorpicker.apply-color', '');
689
+ span.style.background = sw.value;
690
+ span.title = sw.name || sw.value;
691
+ row.appendChild(span);
692
+ }
693
+ root.appendChild(row);
694
+ }
695
+ return root;
696
+ }
697
+
698
+ function normalizePalette(input, paletteName) {
699
+ if (input == null) return { name: paletteName, colors: [] };
700
+ if (Array.isArray(input)) return { name: paletteName, colors: normalizeColorList(input) };
701
+ if (typeof input === 'object') {
702
+ // Meta key `_name` overrides the display name (lets devs keep object keys stable
703
+ // across locales while translating visible text).
704
+ const displayName = (typeof input._name === 'string') ? input._name : (input.name || paletteName);
705
+ // Explicit `_colors` / `colors` / `items` key holds the swatch list — use when
706
+ // you need to pair `_name` with an array of bare hex strings (YAML can't mix
707
+ // object keys and sequence items in the same node).
708
+ if ('_colors' in input || 'colors' in input || 'items' in input) {
709
+ return {
710
+ ...input,
711
+ name: displayName,
712
+ colors: normalizeColorList(input._colors || input.colors || input.items || [])
713
+ };
714
+ }
715
+ return { name: displayName, colors: normalizeColorList(input) };
716
+ }
717
+ return { name: paletteName, colors: [] };
718
+ }
719
+
720
+ // Accepts swatch forms:
721
+ // "#hex" → { value }
722
+ // { name, value } → plain
723
+ // { _name, _value } → meta-key form (localization-friendly)
724
+ function _coerceSwatch(item, fallbackName) {
725
+ if (item == null) return null;
726
+ if (typeof item === 'string') return fallbackName ? { name: fallbackName, value: item } : { value: item };
727
+ if (typeof item === 'object') {
728
+ const name = (typeof item._name === 'string') ? item._name : (item.name || fallbackName);
729
+ const value = (typeof item._value === 'string') ? item._value : item.value;
730
+ if (value == null) return null;
731
+ return name ? { name, value } : { value };
732
+ }
733
+ return null;
734
+ }
735
+
736
+ function normalizeColorList(input) {
737
+ if (input == null) return [];
738
+ if (Array.isArray(input)) {
739
+ return input.map(item => _coerceSwatch(item)).filter(Boolean);
740
+ }
741
+ if (typeof input === 'object') {
742
+ // Filter `_`-prefixed meta keys (e.g., `_name`, `_group`) so they're not mistaken for shades.
743
+ return Object.entries(input)
744
+ .filter(([k]) => !k.startsWith('_'))
745
+ .map(([name, value]) => _coerceSwatch(value, name))
746
+ .filter(Boolean);
747
+ }
748
+ return [];
749
+ }
750
+
751
+ // ---- Recent (cookie-backed, shared across all pickers) ----
752
+
753
+ const RECENT_COOKIE = 'manifest-colorpicker-recent';
754
+ let _recentMax = 10;
755
+ const _recentStore = window.Alpine?.reactive ? Alpine.reactive({ list: [] }) : { list: [] };
756
+
757
+ function loadRecent() {
758
+ try {
759
+ const match = document.cookie.split('; ').find(c => c.startsWith(RECENT_COOKIE + '='));
760
+ if (!match) return [];
761
+ const raw = decodeURIComponent(match.split('=')[1] || '');
762
+ const parsed = JSON.parse(raw);
763
+ return Array.isArray(parsed) ? parsed : [];
764
+ } catch { return []; }
765
+ }
766
+ function saveRecent() {
767
+ try {
768
+ const val = encodeURIComponent(JSON.stringify(_recentStore.list));
769
+ const exp = new Date(); exp.setFullYear(exp.getFullYear() + 1);
770
+ document.cookie = RECENT_COOKIE + '=' + val + '; path=/; expires=' + exp.toUTCString() + '; SameSite=Lax';
771
+ } catch {}
772
+ }
773
+ function pushRecent(value) {
774
+ if (!value || typeof value !== 'string') return;
775
+ const v = value.trim();
776
+ if (!v) return;
777
+ const list = _recentStore.list;
778
+ const existingIdx = list.indexOf(v);
779
+ if (existingIdx !== -1) list.splice(existingIdx, 1);
780
+ list.unshift(v);
781
+ while (list.length > _recentMax) list.pop();
782
+ saveRecent();
783
+ }
784
+ function clearRecent() { _recentStore.list = []; saveRecent(); }
785
+ function removeRecent(value) {
786
+ const idx = _recentStore.list.indexOf(value);
787
+ if (idx !== -1) { _recentStore.list.splice(idx, 1); saveRecent(); }
788
+ }
789
+ // Initial load from cookie
790
+ _recentStore.list = loadRecent();
791
+
792
+ // ---- Default fallback templates (used when developer doesn't supply their own) ----
793
+
794
+ // Solid-options panel — used both as a top-level tab body and as the
795
+ // accordion content under each gradient stop.
796
+ const DEFAULT_SOLID_TEMPLATE_HTML = `
797
+ <div>
798
+ <div class="canvas-wrapper">
799
+ <canvas x-colorpicker.set-canvas></canvas>
800
+ <div class="color-reticle"></div>
801
+ </div>
802
+ <div class="solid-options-inputs">
803
+ <input type="range" x-colorpicker.set-hue class="hue" />
804
+ <input type="range" x-colorpicker.set-alpha class="alpha" />
805
+ <div>
806
+ <button x-dropdown="color-space-menu" x-colorpicker.set-color-space class="ghost sm" aria-label="Color space"></button>
807
+ <menu popover id="color-space-menu">
808
+ <li x-colorpicker.set-color-space="hex">Hex</li>
809
+ <li x-colorpicker.set-color-space="rgb">RGB</li>
810
+ <li x-colorpicker.set-color-space="hsl">HSL</li>
811
+ <li x-colorpicker.set-color-space="oklch">OKLCH</li>
812
+ <hr>
813
+ <li x-colorpicker.grab-color><span x-icon class="color-icon-grab"></span><span>Grab color</span></li>
814
+ </menu>
815
+ <input type="text" x-colorpicker.set-color-value class="ghost sm" onClick="this.select()" />
816
+ <input type="number" x-colorpicker.set-alpha-value class="ghost sm no-spinner" min="0" max="100" step="1" onClick="this.select()" />
817
+ </div>
818
+ </div>
819
+ </div>
820
+ `;
821
+
822
+ // Gradient layer template — one of these per layer clone. Includes the
823
+ // gradient-type dropdown (with reactive icon class), angle input, stops bar
824
+ // with right-click context menu (Duplicate/Delete + inline library), and
825
+ // the per-stop solid-panel accordion.
826
+ const DEFAULT_LAYER_TEMPLATE_HTML = `
827
+ <div>
828
+ <div class="layer-options-wrapper">
829
+ <div class="layer-options-inputs">
830
+ <button class="ghost sm" x-dropdown="gradient-layer-options" aria-label="Layer options"><span :class="'gradient-layer-icon-' + layerType" x-icon></span></button>
831
+ <menu popover id="gradient-layer-options">
832
+ <li x-colorpicker.set-gradient-type="linear">
833
+ <span x-icon class="gradient-layer-icon-linear"></span><span>Linear Gradient</span>
834
+ </li>
835
+ <li x-colorpicker.set-gradient-type="radial">
836
+ <span x-icon class="gradient-layer-icon-radial"></span><span>Radial Gradient</span>
837
+ </li>
838
+ <li x-colorpicker.set-gradient-type="conic">
839
+ <span x-icon class="gradient-layer-icon-conic"></span><span>Conic Gradient</span>
840
+ </li>
841
+ <hr>
842
+ <li x-colorpicker.rotate-layer>Rotate 90°</li>
843
+ <li x-colorpicker.flip-layer>Flip Direction</li>
844
+ <hr>
845
+ <li x-colorpicker.add-layer-above>Add Above</li>
846
+ <li x-colorpicker.add-layer-below>Add Below</li>
847
+ <li x-colorpicker.duplicate-layer>Duplicate</li>
848
+ <hr>
849
+ <li x-colorpicker.move-layer-up :disabled="layerIndex === 0">Move Up</li>
850
+ <li x-colorpicker.move-layer-down :disabled="layerIndex === layerCount - 1">Move Down</li>
851
+ <hr>
852
+ <li x-colorpicker.remove-layer :disabled="layerCount === 1" class="negative">Remove</li>
853
+ </menu>
854
+
855
+ <div class="layer-angle-wrapper">
856
+ <input type="number" x-colorpicker.set-angle class="ghost sm no-spinner" min="0" max="360" onclick="select()" />
857
+ <span>°</span>
858
+ </div>
859
+
860
+ <div x-colorpicker.layer-stops-bar class="gradient-layer" x-dropdown.context="stop-context-menu"></div>
861
+ <menu popover id="stop-context-menu" class="stop-context-menu">
862
+ <li x-colorpicker.duplicate-stop>Duplicate</li>
863
+ <li x-colorpicker.delete-stop class="negative">Delete</li>
864
+ <hr>
865
+ <div x-colorpicker.library></div>
866
+ </menu>
867
+ </div>
868
+
869
+ <div x-colorpicker.solid></div>
870
+ </div>
871
+ </div>
872
+ `;
873
+
874
+ // Gradient panel — the entire Gradient tab body. Hosts the layer template
875
+ // (cloned per layer), the layers container, and the editable CSS string.
876
+ const DEFAULT_GRADIENT_TEMPLATE_HTML = `
877
+ <div>
878
+ <div x-colorpicker.gradient-layers></div>
879
+ <template x-colorpicker.layer-options>
880
+ ${DEFAULT_LAYER_TEMPLATE_HTML}
881
+ </template>
882
+ <div class="gradient-value-wrapper">
883
+ <textarea x-colorpicker.set-gradient-value class="ghost sm" spellcheck="false" rows="3" onclick="select()"></textarea>
884
+ </div>
885
+ </div>
886
+ `;
887
+
888
+ // Full default UI — used when an x-colorpicker has no children and no
889
+ // declared templates. Mirrors the canonical custom-picker structure:
890
+ // tabs row, three tab bodies, three matching templates.
891
+ const DEFAULT_FULL_UI_HTML = `
892
+ <div x-data="{ tab: 'solid' }">
893
+
894
+ <div class="tabs-wrapper" data-cp-tabs>
895
+ <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>
896
+ <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>
897
+ <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>
898
+ </div>
899
+
900
+ <div data-cp-panel="solid" x-colorpicker.solid x-show="tab === 'solid'"></div>
901
+ <div data-cp-panel="gradient" x-colorpicker.gradient x-show="tab === 'gradient'"></div>
902
+ <div data-cp-panel="library" x-colorpicker.library x-show="tab === 'library'"></div>
903
+
904
+ <template x-colorpicker.solid>
905
+ ${DEFAULT_SOLID_TEMPLATE_HTML}
906
+ </template>
907
+
908
+ <template x-colorpicker.gradient>
909
+ ${DEFAULT_GRADIENT_TEMPLATE_HTML}
910
+ </template>
911
+ </div>
912
+ `;
913
+
914
+ // Parse default template HTML ONCE at module load. Subsequent uses clone the
915
+ // already-parsed DocumentFragment — avoids re-running the HTML parser per layer.
916
+ function parseOnce(html) {
917
+ const t = document.createElement('template');
918
+ t.innerHTML = html.trim();
919
+ return t;
920
+ }
921
+ const _defaultSolidTpl = parseOnce(DEFAULT_SOLID_TEMPLATE_HTML);
922
+ const _defaultLayerTpl = parseOnce(DEFAULT_LAYER_TEMPLATE_HTML);
923
+ const _defaultGradientTpl = parseOnce(DEFAULT_GRADIENT_TEMPLATE_HTML);
924
+ const _defaultFullUiTpl = parseOnce(DEFAULT_FULL_UI_HTML);
925
+
926
+ // Default library layout — group > palette > swatch nesting. Mirrors the
927
+ // canonical custom-picker library template. Used when no dev-provided
928
+ // <template x-colorpicker.library> is present.
929
+ // Recent swatches use the per-clone nested context-menu pattern (each
930
+ // Recent swatch carries its own uniquified menu via uniquifyDropdownIdsIn,
931
+ // matching the convention for layer dropdowns).
932
+ const DEFAULT_LIBRARY_LAYOUT_HTML = `
933
+ <div class="library-wrapper">
934
+
935
+ <template x-colorpicker.library-group>
936
+ <div class="library-group">
937
+ <small x-text="group.name"></small>
938
+
939
+ <template x-colorpicker.library-palette>
940
+ <div class="library-palette">
941
+
942
+ <template x-colorpicker.library-swatch>
943
+ <button x-colorpicker.apply-color :style="\`background: \${swatch.value}\`" x-tooltip="\`\${swatch.name || swatch.value}\`"></button>
944
+ </template>
945
+
946
+ <template x-colorpicker.library-recent-swatch>
947
+ <div>
948
+ <button x-colorpicker.apply-color x-dropdown.context="recent-menu" :style="\`background: \${swatch.value}\`" x-tooltip="\`\${swatch.name || swatch.value}\`"></button>
949
+ <menu popover id="recent-menu">
950
+ <li x-colorpicker.remove-recent>Remove</li>
951
+ </menu>
952
+ </div>
953
+ </template>
954
+
955
+ </div>
956
+ </template>
957
+ </div>
958
+ </template>
959
+
960
+ </div>
961
+ `;
962
+ const _defaultLibraryLayoutTpl = parseOnce(DEFAULT_LIBRARY_LAYOUT_HTML);
963
+
964
+ // ---- Per-picker state ----
965
+
966
+ let pickerCounter = 0;
967
+
968
+ // Reactive registry of pickers keyed by element ID.
969
+ // Consumers read via the magic; reads are tracked even for not-yet-registered IDs,
970
+ // so bindings resolve correctly when a picker mounts later in the DOM.
971
+ const _pickerRegistry = window.Alpine?.reactive ? Alpine.reactive({}) : {};
972
+
973
+ // Fallback API used when a picker ID isn't registered yet; coerces to empty string.
974
+ const _nullApi = (() => {
975
+ const noop = () => {};
976
+ const empty = () => '';
977
+ return {
978
+ hex: '', formatted: '', css: '',
979
+ h: 0, s: 0, v: 100, a: 1,
980
+ format: 'hex', pickerMode: 'solid',
981
+ layers: [], activeLayer: null, activeStop: null,
982
+ activeLayerIndex: 0, activeStopIndex: 0, openStop: null,
983
+ [Symbol.toPrimitive]: empty, toString: empty, valueOf: empty,
984
+ addLayer: noop, duplicateLayer: noop, removeLayer: noop,
985
+ flipLayer: noop, rotateLayer: noop,
986
+ addStop: noop, duplicateStop: noop, deleteStop: noop,
987
+ setGradientType: noop, applyColor: noop, grabColor: noop,
988
+ setHue: noop, setAlpha: noop, setAlphaValue: noop,
989
+ setColorSpace: noop, setColorValue: noop, setAngle: noop, setGradientValue: noop,
990
+ selectStop: noop, toggleStop: noop,
991
+ setFromString: () => false, toFormattedString: empty, toHex: empty
992
+ };
993
+ })();
994
+
995
+ function createPickerState(rootEl) {
996
+ pickerCounter++;
997
+ const pickerUid = 'cp-' + pickerCounter;
998
+
999
+ const state = {
1000
+ rootEl,
1001
+ pickerUid,
1002
+
1003
+ // Data
1004
+ solidColor: { h: 0, s: 0, v: 100, a: 1 },
1005
+ solidFormat: 'hex',
1006
+ layers: [ makeDefaultLayer() ],
1007
+ activeLayerIndex: 0,
1008
+ activeStopIndex: 0,
1009
+ pickerMode: 'solid',
1010
+ openStop: null, // { layerIndex, stopIndex } | null
1011
+
1012
+ // Tab/panel filtering. When set (via array-literal directive expression
1013
+ // on the root, e.g. `x-colorpicker="['solid', 'gradient']"`), the default
1014
+ // injected UI is filtered + reordered to match. null/empty = all panels.
1015
+ allowedPanels: null,
1016
+
1017
+ // Elements that act as live labels for the current solid format (e.g. a
1018
+ // dropdown button whose text reads "Hex"/"RGB"/"HSL"/"OKLCH"). Refreshed
1019
+ // whenever the format changes via _refreshFormatLabels().
1020
+ formatLabelEls: [],
1021
+ // Elements bound to a specific format value (e.g. <li set-color-space="hex">).
1022
+ // Refreshed alongside the labels to toggle an `active` class on the current.
1023
+ formatChoiceEls: [],
1024
+
1025
+ // Element registry (populated by child directive handlers)
1026
+ hiddenInput: null,
1027
+ triggerBtn: null,
1028
+ solidTemplate: null, // <template x-colorpicker.solid>
1029
+ gradientTemplate: null, // <template x-colorpicker.gradient>
1030
+ layerTemplate: null, // <template x-colorpicker.layer-options>
1031
+ solidInstances: [], // <div x-colorpicker.solid> (not template)
1032
+ gradientInstances: [], // <div x-colorpicker.gradient> (not template)
1033
+ layersContainer: null, // <div x-colorpicker.gradient-layers>
1034
+ gradientValueInputs: [], // <textarea x-colorpicker.set-gradient-value>
1035
+
1036
+ // Library
1037
+ libraryContainers: [], // <div x-colorpicker.library> — every registered target (top-level tab, nested stop menus, etc.)
1038
+ libraryTemplate: null, // <template x-colorpicker.library> — dev layout (cloned into container)
1039
+ libraryRootValue: null, // expression from x-colorpicker.library="..." (data source)
1040
+
1041
+ // Recent-list commit tracking. `_recentBaseline` is the color at the start of
1042
+ // a commit cycle (popover open, or inline picker init / last commit).
1043
+ // `_lastChangeFromLibrary` is true if the most recent user change was picking
1044
+ // a preset swatch — those don't count as "recent" even if committed.
1045
+ // `_hasUserChange` is true if any non-library user interaction has happened
1046
+ // since the baseline was set.
1047
+ _recentBaseline: null,
1048
+ _lastChangeFromLibrary: false,
1049
+ _hasUserChange: false,
1050
+
1051
+ // The currently active solid-controls refs (inside Solid tab OR open-stop accordion)
1052
+ activeControls: null,
1053
+ solidTabRefs: null,
1054
+
1055
+ // Reactive version counter — bumped on every state change. The API
1056
+ // getters read this to establish a reactive dependency, then compute
1057
+ // values lazily. No upfront work when nothing is bound to $colorpicker(id).
1058
+ snapshot: window.Alpine?.reactive ? Alpine.reactive({ version: 0 }) : { version: 0 },
1059
+
1060
+ // ---- Active target accessors ----
1061
+
1062
+ get isGradient() { return this.pickerMode === 'gradient'; },
1063
+ activeLayer() { return this.layers[this.activeLayerIndex] || this.layers[0]; },
1064
+ activeStop() {
1065
+ const layer = this.activeLayer();
1066
+ return layer.stops[this.activeStopIndex] || layer.stops[0];
1067
+ },
1068
+
1069
+ get h() { return this.isGradient ? this.activeStop().color.h : this.solidColor.h; },
1070
+ set h(v) { if (this.isGradient) this.activeStop().color.h = v; else this.solidColor.h = v; },
1071
+ get s() { return this.isGradient ? this.activeStop().color.s : this.solidColor.s; },
1072
+ set s(v) { if (this.isGradient) this.activeStop().color.s = v; else this.solidColor.s = v; },
1073
+ get v() { return this.isGradient ? this.activeStop().color.v : this.solidColor.v; },
1074
+ set v(val) { if (this.isGradient) this.activeStop().color.v = val; else this.solidColor.v = val; },
1075
+ get a() { return this.isGradient ? this.activeStop().color.a : this.solidColor.a; },
1076
+ set a(v) { if (this.isGradient) this.activeStop().color.a = v; else this.solidColor.a = v; },
1077
+
1078
+ get format() { return this.isGradient ? (this.activeStop().format || 'hex') : this.solidFormat; },
1079
+ set format(v) {
1080
+ if (this.isGradient) this.activeStop().format = v;
1081
+ else this.solidFormat = v;
1082
+ },
1083
+
1084
+ toRgb() { return hsvToRgb(this.h, this.s, this.v); },
1085
+ toHex() { const {r,g,b} = this.toRgb(); return rgbToHex(r,g,b); },
1086
+ toFormattedString() {
1087
+ if (this.isGradient) return buildFullGradientString(this.layers);
1088
+ const {r,g,b} = this.toRgb();
1089
+ return formatColor(r, g, b, this.a, this.solidFormat);
1090
+ },
1091
+ toActiveColorString() {
1092
+ const {r,g,b} = this.toRgb();
1093
+ return formatColor(r, g, b, this.a, this.format);
1094
+ },
1095
+ toSwatchColor() {
1096
+ if (this.isGradient) return buildFullGradientString(this.layers);
1097
+ const {r,g,b} = this.toRgb();
1098
+ if (this.a < 1) return `rgba(${r},${g},${b},${this.a})`;
1099
+ return this.toHex();
1100
+ },
1101
+
1102
+ // ---- Mutators ----
1103
+
1104
+ setFromString(str) {
1105
+ if (typeof str !== 'string') return false;
1106
+ // Gradient input → parse into layers + activate gradient mode.
1107
+ if (/gradient\s*\(/i.test(str)) {
1108
+ const layers = parseGradientString(str);
1109
+ if (!layers || !layers.length) return false;
1110
+ this.layers = layers;
1111
+ this.activeLayerIndex = 0;
1112
+ this.activeStopIndex = 0;
1113
+ this.openStop = null;
1114
+ // Seed the picker's working solid color from the first stop so
1115
+ // syncUI / hex output / canvas reflect something coherent.
1116
+ const firstStop = layers[0].stops[0];
1117
+ if (firstStop) {
1118
+ this.h = firstStop.color.h; this.s = firstStop.color.s;
1119
+ this.v = firstStop.color.v; this.a = firstStop.color.a;
1120
+ }
1121
+ if (this.pickerMode !== 'gradient') this.pickerMode = 'gradient';
1122
+ if (this.layersContainer) this.renderLayers();
1123
+ return true;
1124
+ }
1125
+ // Solid input → existing behavior.
1126
+ const parsed = parseCssColor(str);
1127
+ if (!parsed) return false;
1128
+ const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b);
1129
+ this.h = hsv.h; this.s = hsv.s; this.v = hsv.v; this.a = parsed.a;
1130
+ const fmt = detectFormat(str);
1131
+ if (fmt) this.format = fmt;
1132
+ return true;
1133
+ },
1134
+
1135
+ selectStop(layerIndex, stopIndex) {
1136
+ this.activeLayerIndex = layerIndex;
1137
+ this.activeStopIndex = stopIndex;
1138
+ },
1139
+
1140
+ toggleStop(layerIndex, stopIndex) {
1141
+ const same = this.openStop && this.openStop.layerIndex === layerIndex && this.openStop.stopIndex === stopIndex;
1142
+ this.openStop = same ? null : { layerIndex, stopIndex };
1143
+ this.activeLayerIndex = layerIndex;
1144
+ this.activeStopIndex = stopIndex;
1145
+ this.renderLayers();
1146
+ // Gradient swatches in the library must be disabled while a stop is open
1147
+ // (CSS gradients can't contain nested gradients as stop colors).
1148
+ this._syncStopGradientDisable();
1149
+ },
1150
+
1151
+ addLayer() {
1152
+ this.layers.push(makeDefaultLayer());
1153
+ this.activeLayerIndex = this.layers.length - 1;
1154
+ this.activeStopIndex = 0;
1155
+ this.renderLayers(); this.syncToInput();
1156
+ },
1157
+
1158
+ // Insert a fresh default layer relative to the given index.
1159
+ // position: 'above' inserts before `i`; 'below' (default) inserts after.
1160
+ addLayerAt(i, position) {
1161
+ if (i == null || i < 0 || i > this.layers.length) return;
1162
+ const insertAt = position === 'above' ? i : i + 1;
1163
+ this.layers.splice(insertAt, 0, makeDefaultLayer());
1164
+ this.activeLayerIndex = insertAt;
1165
+ this.activeStopIndex = 0;
1166
+ this.renderLayers(); this.syncToInput();
1167
+ },
1168
+
1169
+ // Swap the layer at `i` with its neighbor. delta = -1 moves up, +1 moves down.
1170
+ // No-op at edges.
1171
+ moveLayer(i, delta) {
1172
+ const src = i;
1173
+ const dst = src + delta;
1174
+ if (src < 0 || src >= this.layers.length) return;
1175
+ if (dst < 0 || dst >= this.layers.length) return;
1176
+ const [layer] = this.layers.splice(src, 1);
1177
+ this.layers.splice(dst, 0, layer);
1178
+ // Preserve the "currently active layer" identity through the move
1179
+ if (this.activeLayerIndex === src) this.activeLayerIndex = dst;
1180
+ else if (delta > 0 && this.activeLayerIndex === dst) this.activeLayerIndex = src;
1181
+ else if (delta < 0 && this.activeLayerIndex === dst) this.activeLayerIndex = src;
1182
+ this.renderLayers(); this.syncToInput();
1183
+ },
1184
+
1185
+ duplicateLayer(i) {
1186
+ const src = this.layers[i]; if (!src) return;
1187
+ this.layers.splice(i + 1, 0, {
1188
+ type: src.type, angle: src.angle, position: { ...src.position },
1189
+ stops: src.stops.map(s => ({ color: { ...s.color }, position: s.position, format: s.format || 'hex' }))
1190
+ });
1191
+ this.activeLayerIndex = i + 1; this.activeStopIndex = 0;
1192
+ this.renderLayers(); this.syncToInput();
1193
+ },
1194
+
1195
+ removeLayer(i) {
1196
+ if (this.layers.length <= 1) return;
1197
+ this.layers.splice(i, 1);
1198
+ if (this.activeLayerIndex >= this.layers.length) this.activeLayerIndex = this.layers.length - 1;
1199
+ this.activeStopIndex = 0;
1200
+ this.renderLayers(); this.syncToInput();
1201
+ },
1202
+
1203
+ flipLayer(i) {
1204
+ const layer = this.layers[i]; if (!layer) return;
1205
+ for (const stop of layer.stops) stop.position = 100 - stop.position;
1206
+ this.renderLayers(); this.syncToInput();
1207
+ },
1208
+
1209
+ rotateLayer(i) {
1210
+ const layer = this.layers[i]; if (!layer) return;
1211
+ layer.angle = (layer.angle + 90) % 360;
1212
+ this.renderLayers(); this.syncToInput();
1213
+ },
1214
+
1215
+ setGradientType(i, type) {
1216
+ const layer = this.layers[i]; if (!layer) return;
1217
+ if (!GRADIENT_TYPES.includes(type)) return;
1218
+ layer.type = type;
1219
+ this.renderLayers(); this.syncToInput();
1220
+ },
1221
+
1222
+ addStop(layerIndex, position) {
1223
+ const layer = this.layers[layerIndex]; if (!layer) return;
1224
+ const sorted = layer.stops.slice().sort((a,b) => a.position - b.position);
1225
+ let before = sorted[0], after = sorted[sorted.length - 1];
1226
+ for (let k = 0; k < sorted.length - 1; k++) {
1227
+ if (sorted[k].position <= position && sorted[k+1].position >= position) {
1228
+ before = sorted[k]; after = sorted[k+1]; break;
1229
+ }
1230
+ }
1231
+ const range = after.position - before.position;
1232
+ const t = range === 0 ? 0.5 : (position - before.position) / range;
1233
+ layer.stops.push({
1234
+ color: {
1235
+ h: before.color.h + (after.color.h - before.color.h) * t,
1236
+ s: before.color.s + (after.color.s - before.color.s) * t,
1237
+ v: before.color.v + (after.color.v - before.color.v) * t,
1238
+ a: before.color.a + (after.color.a - before.color.a) * t,
1239
+ },
1240
+ position, format: before.format || 'hex'
1241
+ });
1242
+ this.activeLayerIndex = layerIndex;
1243
+ this.activeStopIndex = layer.stops.length - 1;
1244
+ this.renderLayers(); this.syncToInput();
1245
+ },
1246
+
1247
+ duplicateStop(layerIndex, stopIndex) {
1248
+ const layer = this.layers[layerIndex]; if (!layer) return;
1249
+ const src = layer.stops[stopIndex]; if (!src) return;
1250
+ layer.stops.push({ color: { ...src.color }, position: Math.min(100, src.position + 5), format: src.format || 'hex' });
1251
+ this.activeLayerIndex = layerIndex;
1252
+ this.activeStopIndex = layer.stops.length - 1;
1253
+ this.renderLayers(); this.syncToInput();
1254
+ },
1255
+
1256
+ deleteStop(layerIndex, stopIndex) {
1257
+ const layer = this.layers[layerIndex]; if (!layer) return;
1258
+ if (layer.stops.length <= 2) return;
1259
+ layer.stops.splice(stopIndex, 1);
1260
+ if (this.activeLayerIndex === layerIndex && this.activeStopIndex >= layer.stops.length)
1261
+ this.activeStopIndex = layer.stops.length - 1;
1262
+ this.renderLayers(); this.syncToInput();
1263
+ },
1264
+
1265
+ applyColor(str) {
1266
+ // Pure setter — Recent-list commits happen at the picker-close boundary
1267
+ // (popover close / inline focusout), not on every call.
1268
+ if (this.setFromString(str)) {
1269
+ this.syncUI();
1270
+ this.syncToInput();
1271
+ if (this.isGradient) this._refreshActiveStopVisuals();
1272
+ }
1273
+ },
1274
+
1275
+ // Toggle `.active` on library swatches whose canonical key matches the
1276
+ // picker's current color. Gradients compare as their full CSS string;
1277
+ // solids compare as 8-digit hex. Runs across every registered library
1278
+ // container (tab + any nested containers such as stop context menus).
1279
+ _updateActiveSwatches() {
1280
+ if (!this.libraryContainers.length) return;
1281
+ const current = this.isGradient
1282
+ ? this.toFormattedString()
1283
+ : (() => {
1284
+ const {r,g,b} = this.toRgb();
1285
+ return rgbToHex8(r, g, b, this.a);
1286
+ })();
1287
+ const key = _swatchKeyOf(current);
1288
+ for (const c of this.libraryContainers) {
1289
+ if (!c.isConnected) continue;
1290
+ const nodes = c.querySelectorAll('[data-cp-key]');
1291
+ for (const n of nodes) n.classList.toggle('active', key != null && n.getAttribute('data-cp-key') === key);
1292
+ }
1293
+ this._syncStopGradientDisable();
1294
+ },
1295
+
1296
+ // Gradient-valued library swatches must be un-pickable when the click would
1297
+ // try to apply that gradient as a gradient-stop color (CSS doesn't allow
1298
+ // nested gradients in stop positions). That's the case when:
1299
+ // • a stop accordion is currently open (openStop set), OR
1300
+ // • the library container itself is nested inside a stop-context-menu —
1301
+ // every click in such a menu targets the right-clicked stop's color.
1302
+ // Dev CSS styles [disabled] on apply-color elements; the click handler
1303
+ // also short-circuits as belt-and-braces.
1304
+ _syncStopGradientDisable() {
1305
+ const stopIsOpen = !!this.openStop;
1306
+ for (const c of this.libraryContainers) {
1307
+ if (!c.isConnected) continue;
1308
+ const containerInStopMenu = !!c.closest('menu[id^="stop-context-menu"]');
1309
+ const shouldDisable = stopIsOpen || containerInStopMenu;
1310
+ const gradNodes = c.querySelectorAll('[data-cp-key]');
1311
+ for (const n of gradNodes) {
1312
+ const k = n.getAttribute('data-cp-key') || '';
1313
+ const isGradient = k.includes('gradient(');
1314
+ if (!isGradient) continue;
1315
+ if (shouldDisable) n.setAttribute('disabled', '');
1316
+ else n.removeAttribute('disabled');
1317
+ }
1318
+ }
1319
+ },
1320
+
1321
+ // ---- Recent-list commit cycle ----
1322
+ //
1323
+ // Rules:
1324
+ // • Popover pickers commit on toggle→closed.
1325
+ // • Inline pickers commit on focusout past the rootEl.
1326
+ // • A commit pushes the current color to Recent IFF the user made a
1327
+ // non-library change since the last baseline and the color actually differs.
1328
+ // • Gradient mode never commits (stops are part of a gradient, not standalone).
1329
+ // • Programmatic api.applyColor calls don't mark user changes — only UI paths do.
1330
+
1331
+ _startCommitCycle() {
1332
+ this._recentBaseline = this._currentCommitValue();
1333
+ this._lastChangeFromLibrary = false;
1334
+ this._hasUserChange = false;
1335
+ },
1336
+
1337
+ _markUserChange(fromLibrary) {
1338
+ this._hasUserChange = true;
1339
+ this._lastChangeFromLibrary = !!fromLibrary;
1340
+ },
1341
+
1342
+ _currentCommitValue() {
1343
+ // Solid: canonical hex (format-independent). Gradient: full CSS string.
1344
+ try { return this.isGradient ? this.toFormattedString() : this.toHex(); }
1345
+ catch { return null; }
1346
+ },
1347
+
1348
+ _tryCommitRecent() {
1349
+ if (!this._hasUserChange) return; // no interaction since baseline
1350
+ if (this._lastChangeFromLibrary) return; // library picks don't count
1351
+ const current = this._currentCommitValue();
1352
+ if (!current) return;
1353
+ if (current === this._recentBaseline) return; // no actual change
1354
+ pushRecent(current);
1355
+ // Start a fresh cycle so repeated inline commits behave correctly
1356
+ this._startCommitCycle();
1357
+ },
1358
+
1359
+ // Shared-picker write-back: when a swatch with x-model triggered this picker,
1360
+ // push the current color through the swatch's model setter on commit. Unlike
1361
+ // Recent, this runs even when the change came from a library click — the user
1362
+ // clicked a library swatch intending to assign that color to their field.
1363
+ _commitToTrigger() {
1364
+ const trigger = this.triggerBtn;
1365
+ if (!trigger || typeof trigger._cpModelSetter !== 'function') return;
1366
+ if (!this._hasUserChange) return; // nothing to persist
1367
+ const value = this._currentCommitValue();
1368
+ if (value != null) trigger._cpModelSetter(value);
1369
+ },
1370
+
1371
+ async grabColor() {
1372
+ if (!window.EyeDropper) return;
1373
+ try {
1374
+ const result = await new EyeDropper().open();
1375
+ // Eyedropper is a single-color operation — route the result
1376
+ // to Solid mode and surface the solid controls. Mark as a
1377
+ // non-library user change; the usual commit boundary decides
1378
+ // whether it lands in Recent (user may tweak before closing).
1379
+ this._switchToSolidMode();
1380
+ this._markUserChange(false);
1381
+ this.applyColor(result.sRGBHex);
1382
+ } catch (e) { /* user cancelled */ }
1383
+ },
1384
+
1385
+ // Force the picker into solid mode and activate solid controls.
1386
+ // Also updates Alpine `tab` data (if the dev uses a tab-driven layout).
1387
+ _switchToSolidMode() {
1388
+ this.pickerMode = 'solid';
1389
+ this.openStop = null;
1390
+ const rootStack = this.rootEl._x_dataStack;
1391
+ const autoStack = this._autoTabScope?._x_dataStack;
1392
+ if (rootStack && rootStack[0] && 'tab' in rootStack[0]) rootStack[0].tab = 'solid';
1393
+ else if (autoStack && autoStack[0] && 'tab' in autoStack[0]) autoStack[0].tab = 'solid';
1394
+ if (this.solidTabRefs) this._activateControls(this.solidTabRefs);
1395
+ if (this.layersContainer) this.renderLayers();
1396
+ },
1397
+
1398
+ // Switch picker mode without touching the dev's `tab` Alpine data.
1399
+ // Used by library-tab applies — the user is browsing swatches and
1400
+ // shouldn't be flipped off the Library tab when they pick one.
1401
+ _setPickerMode(mode) {
1402
+ if (this.pickerMode === mode) return;
1403
+ this.pickerMode = mode;
1404
+ this.openStop = null;
1405
+ if (mode === 'solid' && this.solidTabRefs) this._activateControls(this.solidTabRefs);
1406
+ if (mode === 'gradient') this._activateControls(null);
1407
+ if (this.layersContainer) this.renderLayers();
1408
+ },
1409
+
1410
+ // Setter methods (mirror .set-* modifiers)
1411
+ setHue(v) {
1412
+ this.h = v;
1413
+ this.drawCanvas(); this.updateCanvasMarker();
1414
+ this.syncToInput(); this.updateColorInput();
1415
+ if (this.isGradient) this._refreshActiveStopVisuals();
1416
+ },
1417
+ setAlpha(v) {
1418
+ this.a = Math.max(0, Math.min(1, v));
1419
+ this.syncToInput(); this.updateColorInput(); this.updateAlphaInput();
1420
+ if (this.isGradient) this._refreshActiveStopVisuals();
1421
+ },
1422
+ setAlphaValue(percent) { this.setAlpha(percent / 100); },
1423
+ setColorSpace(fmt) {
1424
+ if (!FORMATS.includes(fmt)) return;
1425
+ this.format = fmt;
1426
+ this.updateColorInput();
1427
+ this._refreshFormatLabels();
1428
+ },
1429
+
1430
+ // Display label for each format (e.g. button text in a custom format dropdown)
1431
+ _formatLabel(fmt) {
1432
+ switch ((fmt || '').toLowerCase()) {
1433
+ case 'hex': return 'Hex';
1434
+ case 'rgb': return 'RGB';
1435
+ case 'hsl': return 'HSL';
1436
+ case 'oklch': return 'OKLCH';
1437
+ default: return fmt || '';
1438
+ }
1439
+ },
1440
+
1441
+ // Sync any registered format label elements to the current format and
1442
+ // toggle an `active` class on each format-choice element so devs can
1443
+ // style the active option without writing reactive bindings.
1444
+ _refreshFormatLabels() {
1445
+ const current = this.format;
1446
+ const label = this._formatLabel(current);
1447
+ for (const el of this.formatLabelEls) {
1448
+ if (!el || !el.isConnected) continue;
1449
+ if (el.textContent !== label) el.textContent = label;
1450
+ }
1451
+ for (const { el, fmt } of this.formatChoiceEls) {
1452
+ if (!el || !el.isConnected) continue;
1453
+ el.classList.toggle('active', fmt === current);
1454
+ }
1455
+ },
1456
+ setColorValue(str) {
1457
+ if (this.setFromString(str)) {
1458
+ this.drawCanvas(); this.updateCanvasMarker(); this.updateSliders();
1459
+ this.updateAlphaInput(); this.syncToInput();
1460
+ if (this.isGradient) this._refreshActiveStopVisuals();
1461
+ }
1462
+ },
1463
+ setAngle(i, degrees) {
1464
+ const layer = this.layers[i]; if (!layer) return;
1465
+ layer.angle = ((degrees % 360) + 360) % 360;
1466
+ this._refreshLayerVisuals(i);
1467
+ this.syncToInput();
1468
+ },
1469
+ setGradientValue(cssString) {
1470
+ // Edits don't parse back into layers; plugin writes the CSS var for the swatch.
1471
+ if (this.triggerBtn) this.triggerBtn.style.setProperty('--color-picker-swatch', cssString);
1472
+ },
1473
+
1474
+ // ---- Sync / render ----
1475
+
1476
+ syncToInput() {
1477
+ const swatchVal = this.toSwatchColor();
1478
+ if (this.hiddenInput) {
1479
+ // Native <input type="color"> only accepts #rrggbb; everything
1480
+ // else (synthesized type=hidden, dev-supplied type=hidden) gets
1481
+ // the full CSS string — solid color or gradient — so gradients
1482
+ // and non-hex formats round-trip without lossy conversion.
1483
+ const isNativeColorInput = this.hiddenInput.type === 'color';
1484
+ this.hiddenInput.value = isNativeColorInput ? this.toHex() : swatchVal;
1485
+ this.hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
1486
+ this.hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
1487
+ }
1488
+ if (this.triggerBtn) this.triggerBtn.style.setProperty('--color-picker-swatch', swatchVal);
1489
+ this.updateGradientValue();
1490
+ // Reflect current color in the library: any swatch whose canonical key
1491
+ // matches gets an `.active` class, all others lose it.
1492
+ this._updateActiveSwatches();
1493
+ // Refresh any custom format labels / choice highlights — covers paths
1494
+ // where format changes implicitly (setFromString parsing a new format).
1495
+ this._refreshFormatLabels();
1496
+
1497
+ // Bump reactive version — any $colorpicker(id).* reader re-runs.
1498
+ // No eager computation of hex/css/etc. unless somebody is actually bound to them.
1499
+ this.snapshot.version++;
1500
+ },
1501
+
1502
+ syncUI() {
1503
+ this.drawCanvas();
1504
+ this.updateSliders();
1505
+ this.updateColorInput();
1506
+ this.updateAlphaInput();
1507
+ this.updateCanvasMarker();
1508
+ },
1509
+
1510
+ drawCanvas() {
1511
+ const canvas = this.activeControls?.canvas;
1512
+ if (!canvas) return;
1513
+ const rect = canvas.getBoundingClientRect();
1514
+ if (rect.width <= 0) return; // not visible yet; ResizeObserver will redraw when it gains size
1515
+ let dimsChanged = false;
1516
+ if (canvas.width !== rect.width) { canvas.width = rect.width; dimsChanged = true; }
1517
+ if (canvas.height !== rect.height) { canvas.height = rect.height; dimsChanged = true; }
1518
+ // Skip repaint if hue unchanged and dimensions unchanged (setting canvas.* dims clears the pixels)
1519
+ if (!dimsChanged && canvas._cpLastHue === this.h) return;
1520
+ drawSvCanvas(canvas, this.h);
1521
+ canvas._cpLastHue = this.h;
1522
+ },
1523
+
1524
+ updateSliders() {
1525
+ const ac = this.activeControls; if (!ac) return;
1526
+ if (ac.hueSlider && document.activeElement !== ac.hueSlider) ac.hueSlider.value = this.h;
1527
+ if (ac.alphaSlider && document.activeElement !== ac.alphaSlider) {
1528
+ ac.alphaSlider.value = Math.round(this.a * 100);
1529
+ const {r,g,b} = this.toRgb();
1530
+ ac.alphaSlider.style.setProperty('--color-picker-alpha', `rgb(${r},${g},${b})`);
1531
+ }
1532
+ },
1533
+
1534
+ updateCanvasMarker() {
1535
+ const reticle = this.activeControls?.reticle; if (!reticle) return;
1536
+ reticle.style.left = this.s + '%';
1537
+ reticle.style.top = (100 - this.v) + '%';
1538
+ },
1539
+
1540
+ updateColorInput() {
1541
+ const ac = this.activeControls; if (!ac) return;
1542
+ if (ac.colorInput && document.activeElement !== ac.colorInput) ac.colorInput.value = this.toActiveColorString();
1543
+ if (ac.formatSelect && ac.formatSelect.value !== this.format) ac.formatSelect.value = this.format;
1544
+ },
1545
+
1546
+ updateAlphaInput() {
1547
+ const ac = this.activeControls; if (!ac) return;
1548
+ if (ac.alphaInput && document.activeElement !== ac.alphaInput) ac.alphaInput.value = Math.round(this.a * 100);
1549
+ },
1550
+
1551
+ updateGradientValue() {
1552
+ for (const ta of this.gradientValueInputs) {
1553
+ if (document.activeElement === ta) continue;
1554
+ ta.value = buildFullGradientString(this.layers);
1555
+ }
1556
+ },
1557
+
1558
+ // ---- Library rendering ----
1559
+ //
1560
+ // Templates are nested in natural HTML hierarchy (x-for style):
1561
+ // <template x-colorpicker.library>
1562
+ // <template x-colorpicker.library-group> <!-- scope: group -->
1563
+ // <template x-colorpicker.library-palette> <!-- scope: palette -->
1564
+ // <template x-colorpicker.library-swatch> <!-- scope: swatch -->
1565
+ // </template>
1566
+ // </template>
1567
+ // </template>
1568
+ // </template>
1569
+ //
1570
+ // Each inner <template> is replaced in-place by clones (siblings before it, then removed).
1571
+ // Data shape after normalization: [{ name?, colors?: Swatch[], palettes?: Palette[] }].
1572
+ // If a group has only `colors` (flat, e.g. Recent), it's auto-wrapped as a single
1573
+ // unnamed palette so nested templates still work uniformly.
1574
+
1575
+ renderLibrary() {
1576
+ if (this._libraryEffectBound) return;
1577
+ this._libraryEffectBound = true;
1578
+ if (!this.libraryContainers.length) return;
1579
+ // All registered containers share the same data source / render key. Use
1580
+ // the first for any Alpine scope evaluation that needs an element.
1581
+ const evalHost = this.libraryContainers[0];
1582
+
1583
+ if (this.libraryRootValue && window.Alpine?.effect && window.Alpine?.evaluateLater) {
1584
+ // Explicit expression — bypass discovery, re-render reactively when deps change
1585
+ const evalFn = Alpine.evaluateLater(evalHost, this.libraryRootValue);
1586
+ Alpine.effect(() => {
1587
+ evalFn(v => { this._libraryResolvedData = v; this._doRenderLibrary(); });
1588
+ });
1589
+ } else if (window.Alpine?.effect) {
1590
+ // Zero-config — read $x.manifest.colorpicker (string or array of source names)
1591
+ // and auto-merge those $x.* sources into the library. Everything happens
1592
+ // synchronously inside the effect so Alpine tracks all reactive deps ($locale,
1593
+ // $x.manifest, each $x.<name>) and re-runs the full pass on any change.
1594
+ // Evaluate against document.body so $x magic is always in scope (the
1595
+ // container may be detached/popover content without its own scope chain).
1596
+ const evalCtx = document.body;
1597
+
1598
+ // Manifest's data plugin REPLACES the $x.<source> proxy reference on load
1599
+ // rather than mutating in place. Alpine.effect can't catch that change via
1600
+ // property tracking, so we pair it with a short-lived poller that RE-READS
1601
+ // discovery until the data is loaded. We only actually re-render when the
1602
+ // serialized content has changed since the last render — otherwise each
1603
+ // poll tick would tear down and rebuild the whole library, thrashing the
1604
+ // DOM (and invalidating x-dropdown.context menu id lookups mid-flight).
1605
+ const keyOf = (names, collected) => {
1606
+ // Include the Recent list in the key so additions/removals trigger a
1607
+ // re-render. Reading _recentStore.list inside the Alpine.effect also
1608
+ // establishes reactivity on it, so cookie mutations (pushRecent /
1609
+ // removeRecent) fire the effect and change the key.
1610
+ const recentKey = _recentStore.list.slice(0, _recentMax).join(',');
1611
+ try { return recentKey + '#' + names.join('|') + '::' + JSON.stringify(collected); }
1612
+ catch { return recentKey + '#' + names.join('|') + '::[unserializable]'; }
1613
+ };
1614
+ const readSources = () => {
1615
+ let flag = null;
1616
+ try { flag = Alpine.evaluate(evalCtx, '$x && $x.manifest && $x.manifest.colorpicker'); } catch {}
1617
+ const raw = !flag ? [] : (Array.isArray(flag) ? flag : [flag]);
1618
+ const names = raw.filter(n => typeof n === 'string' && n.trim().length > 0);
1619
+ const collected = names.map(name => {
1620
+ try { return Alpine.evaluate(evalCtx, '$x.' + name); } catch { return null; }
1621
+ });
1622
+ const ready = names.length === 0 || collected.every(src => {
1623
+ if (!src || typeof src !== 'object') return false;
1624
+ return Object.keys(src).some(k => !k.startsWith('$')
1625
+ && !k.startsWith('_')
1626
+ && k !== 'contentType'
1627
+ && k !== 'valueOf' && k !== 'toString');
1628
+ });
1629
+ return { names, collected, ready };
1630
+ };
1631
+ const runDiscovery = () => {
1632
+ const { names, collected, ready } = readSources();
1633
+ const key = keyOf(names, collected);
1634
+ if (key !== this._libraryDiscoveredKey) {
1635
+ this._libraryDiscoveredKey = key;
1636
+ this._libraryDiscoveredData = collected;
1637
+ this._doRenderLibrary();
1638
+ }
1639
+ return ready;
1640
+ };
1641
+
1642
+ // Reactive deps trigger re-runs on locale switch / manifest load.
1643
+ Alpine.effect(() => {
1644
+ try { Alpine.evaluate(evalCtx, '$locale && $locale.current'); } catch {}
1645
+ try { Alpine.evaluate(evalCtx, '$x && $x.manifest && $x.manifest._loadedFrom'); } catch {}
1646
+ runDiscovery();
1647
+ // Kick the poller only until data is ready — it re-checks every 150ms
1648
+ // but skips actual re-render when the content is unchanged.
1649
+ if (!this._libraryPollTimer) {
1650
+ let attempts = 0;
1651
+ this._libraryPollTimer = setInterval(() => {
1652
+ attempts++;
1653
+ if (runDiscovery() || attempts > 80) { // max ~12s
1654
+ clearInterval(this._libraryPollTimer);
1655
+ this._libraryPollTimer = null;
1656
+ }
1657
+ }, 150);
1658
+ }
1659
+ });
1660
+ } else {
1661
+ this._doRenderLibrary();
1662
+ }
1663
+ },
1664
+
1665
+ _resolveLibraryGroups() {
1666
+ if (this.libraryRootValue) {
1667
+ let data = this._libraryResolvedData;
1668
+ if (data == null
1669
+ || (Array.isArray(data) && data.length === 0)
1670
+ || (typeof data === 'object' && !Array.isArray(data) && _cleanLibraryEntries(data).length === 0)) {
1671
+ data = buildDefaultLibrary();
1672
+ }
1673
+ let groups = normalizeLibraryInput(data);
1674
+ const totalSwatches = groups.reduce((n, g) => n + (g.colors?.length || 0)
1675
+ + (g.palettes?.reduce((m, p) => m + (p.colors?.length || 0), 0) || 0), 0);
1676
+ if (totalSwatches === 0) groups = normalizeLibraryInput(buildDefaultLibrary());
1677
+ return groups;
1678
+ }
1679
+ return composeLibraryFromSources(this._libraryDiscoveredData || []);
1680
+ },
1681
+
1682
+ // Render ONE container (used when a new container registers post-mount
1683
+ // — e.g. a gradient layer clone's inline library div). Avoids tearing
1684
+ // down every other container's x-dropdown.context init timers.
1685
+ _renderIntoContainer(container) {
1686
+ if (!container || !container.isConnected) return;
1687
+ const groups = this._resolveLibraryGroups();
1688
+ const layoutTpl = this.libraryTemplate || _defaultLibraryLayoutTpl;
1689
+ container.innerHTML = '';
1690
+ container.appendChild(layoutTpl.content.cloneNode(true));
1691
+ const groupTpl = container.querySelector('template[x-colorpicker\\.library-group]');
1692
+ if (groupTpl) {
1693
+ const parent = groupTpl.parentNode;
1694
+ for (const g of groups) parent.insertBefore(renderLibraryGroup(groupTpl, g), groupTpl);
1695
+ groupTpl.remove();
1696
+ } else {
1697
+ for (const g of groups) container.appendChild(renderDefaultGroup(g));
1698
+ }
1699
+ if (window.Alpine?.initTree) Alpine.initTree(container);
1700
+ // Newly-rendered swatches need active + gradient-disable state applied.
1701
+ this._updateActiveSwatches();
1702
+ },
1703
+
1704
+ _doRenderLibrary() {
1705
+ if (!this.libraryContainers.length) return;
1706
+ // Prune disconnected containers (removed by gradient layer re-render etc.)
1707
+ this.libraryContainers = this.libraryContainers.filter(c => c.isConnected);
1708
+ for (const container of this.libraryContainers) this._renderIntoContainer(container);
1709
+ // Active-class pass across all rendered swatches
1710
+ this._updateActiveSwatches();
1711
+ },
1712
+
1713
+ _activateControls(refs) {
1714
+ this.activeControls = refs || null;
1715
+ if (refs) this.syncUI();
1716
+ },
1717
+
1718
+ _refreshLayerVisuals(li) {
1719
+ const clone = this._getLayerClone(li);
1720
+ if (!clone) return;
1721
+ const bar = findInClone(clone, 'layer-stops-bar');
1722
+ if (bar) this._updateStopBarPreview(bar, this.layers[li]);
1723
+ },
1724
+
1725
+ _refreshActiveStopVisuals() {
1726
+ const clone = this._getLayerClone(this.activeLayerIndex);
1727
+ if (!clone) return;
1728
+ const bar = findInClone(clone, 'layer-stops-bar');
1729
+ if (bar) this._updateStopBarPreview(bar, this.activeLayer());
1730
+ const handle = bar?.querySelectorAll('[data-cp-stop-handle]')[this.activeStopIndex];
1731
+ if (handle) handle.style.backgroundColor = colorToRgba(this.activeStop().color);
1732
+ },
1733
+
1734
+ _updateStopBarPreview(barEl, layer) {
1735
+ const preview = layer.stops.slice().sort((a,b) => a.position - b.position)
1736
+ .map(s => `${colorToRgba(s.color)} ${s.position}%`).join(', ');
1737
+ barEl.style.background = `linear-gradient(to right, ${preview})`;
1738
+ },
1739
+
1740
+ _getLayerClone(li) {
1741
+ if (!this.layersContainer) return null;
1742
+ return this.layersContainer.querySelectorAll(':scope > [data-cp-layer-clone]')[li] || null;
1743
+ },
1744
+
1745
+ // ---- Layer rendering ----
1746
+
1747
+ renderLayers() {
1748
+ if (!this.layersContainer) return;
1749
+
1750
+ // Clamp openStop
1751
+ if (this.openStop) {
1752
+ const L = this.layers[this.openStop.layerIndex];
1753
+ if (!L || !L.stops[this.openStop.stopIndex]) this.openStop = null;
1754
+ }
1755
+
1756
+ // Clear existing clones
1757
+ this.layersContainer.querySelectorAll(':scope > [data-cp-layer-clone]').forEach(el => el.remove());
1758
+
1759
+ // Get or synthesize layer template (parsed ONCE, cloned per layer)
1760
+ const layerTpl = this.layerTemplate || _defaultLayerTpl;
1761
+
1762
+ let pendingActivation = null;
1763
+
1764
+ this.layers.forEach((layer, li) => {
1765
+ const frag = layerTpl.content.cloneNode(true);
1766
+ const root = frag.firstElementChild;
1767
+ if (!root) return;
1768
+
1769
+ root.setAttribute('data-cp-layer-clone', '');
1770
+ root.setAttribute('data-gradient-type', layer.type);
1771
+ root._cpLayerIndex = li;
1772
+
1773
+ // Expose the layer's position + type to the clone's Alpine scope so
1774
+ // devs can bind classes/attributes reactively. Available in scope:
1775
+ // layerType — 'linear' | 'radial' | 'conic'
1776
+ // layerIndex — 0-based position of this layer
1777
+ // layerCount — total number of layers in the picker
1778
+ // Examples:
1779
+ // :class="'layer-type-' + layerType"
1780
+ // :disabled="layerIndex === 0" (Move Up)
1781
+ // :disabled="layerIndex === layerCount - 1" (Move Down)
1782
+ // :disabled="layerCount === 1" (Remove)
1783
+ root.setAttribute('x-data', '{ '
1784
+ + 'layerType: ' + JSON.stringify(layer.type) + ', '
1785
+ + 'layerIndex: ' + li + ', '
1786
+ + 'layerCount: ' + this.layers.length
1787
+ + ' }');
1788
+
1789
+ // Uniquify x-dropdown / x-dropdown.context / x-dropdown.hover IDs
1790
+ // within this clone so per-layer dropdowns don't collide.
1791
+ uniquifyDropdownIdsIn(root, this.pickerUid + '-layer-' + li);
1792
+
1793
+ // Render stops bar content
1794
+ const bar = findInClone(root, 'layer-stops-bar');
1795
+ if (bar) this._renderStopBar(bar, layer, li);
1796
+
1797
+ // Set initial angle input value
1798
+ const angleInput = findInClone(root, 'set-angle');
1799
+ if (angleInput) angleInput.value = layer.angle;
1800
+
1801
+ // Populate accordion solid panel if this is the open stop's layer
1802
+ const nestedSolidInstance = findInClone(root, 'solid');
1803
+ if (nestedSolidInstance && this.openStop && this.openStop.layerIndex === li) {
1804
+ const refs = this._mountSolidInstance(nestedSolidInstance);
1805
+ if (refs) pendingActivation = refs;
1806
+ }
1807
+
1808
+ this.layersContainer.appendChild(root);
1809
+
1810
+ // Let Alpine/Manifest process the clone (x-dropdown, x-icon, and nested x-colorpicker directives)
1811
+ if (window.Alpine?.initTree) {
1812
+ requestAnimationFrame(() => Alpine.initTree(root));
1813
+ }
1814
+ });
1815
+
1816
+ if (pendingActivation) this._activateControls(pendingActivation);
1817
+ else if (this.isGradient) this._activateControls(null);
1818
+ },
1819
+
1820
+ _renderStopBar(barEl, layer, layerIndex) {
1821
+ barEl.innerHTML = '';
1822
+ this._updateStopBarPreview(barEl, layer);
1823
+
1824
+ // Drop-to-add-stop on bar click (non-handle clicks)
1825
+ barEl.onclick = (e) => {
1826
+ if (e.target.hasAttribute('data-cp-stop-handle')) return;
1827
+ const rect = barEl.getBoundingClientRect();
1828
+ this.addStop(layerIndex, Math.round(((e.clientX - rect.left) / rect.width) * 100));
1829
+ };
1830
+
1831
+ layer.stops.forEach((stop, si) => {
1832
+ const handle = document.createElement('div');
1833
+ handle.className = 'stop-handle';
1834
+ handle.setAttribute('data-cp-stop-handle', '');
1835
+ handle.style.left = stop.position + '%';
1836
+ handle.style.backgroundColor = colorToRgba(stop.color);
1837
+ // .active reflects whether this stop's accordion is currently open
1838
+ if (this.openStop && this.openStop.layerIndex === layerIndex && this.openStop.stopIndex === si) {
1839
+ handle.classList.add('active');
1840
+ }
1841
+
1842
+ let dragging = false, startX = 0, moved = false, cachedBarRect = null;
1843
+ const self = this;
1844
+ const applyDrag = (e) => {
1845
+ if (!cachedBarRect) cachedBarRect = barEl.getBoundingClientRect();
1846
+ const rect = cachedBarRect;
1847
+ stop.position = Math.max(0, Math.min(100, Math.round(((e.clientX - rect.left) / rect.width) * 100)));
1848
+ handle.style.left = stop.position + '%';
1849
+ self._updateStopBarPreview(barEl, layer);
1850
+ self.syncToInput();
1851
+ };
1852
+ const throttledDrag = rafThrottle(applyDrag);
1853
+ handle.addEventListener('pointerdown', (e) => {
1854
+ if (e.button !== 0) return;
1855
+ e.stopPropagation();
1856
+ self.selectStop(layerIndex, si);
1857
+ // .active is set by the re-render after toggleStop;
1858
+ // don't preemptively toggle here (would be wrong for drag-without-toggle).
1859
+ dragging = true; moved = false; startX = e.clientX;
1860
+ cachedBarRect = barEl.getBoundingClientRect();
1861
+ handle.setPointerCapture(e.pointerId);
1862
+ });
1863
+ handle.addEventListener('pointermove', (e) => {
1864
+ if (!dragging) return;
1865
+ if (Math.abs(e.clientX - startX) > 3) moved = true;
1866
+ if (moved) throttledDrag(e);
1867
+ });
1868
+ handle.addEventListener('pointerup', () => {
1869
+ // Only toggle/cleanup if we had a valid left-click drag session.
1870
+ // Right-click never sets `dragging=true` (pointerdown bails for button!==0),
1871
+ // so this pointerup would otherwise still call toggleStop() and
1872
+ // destroy the layer clone — killing the context menu that just opened.
1873
+ if (!dragging) return;
1874
+ const wasMoved = moved;
1875
+ dragging = false; moved = false; cachedBarRect = null;
1876
+ if (!wasMoved) self.toggleStop(layerIndex, si);
1877
+ });
1878
+ barEl.appendChild(handle);
1879
+ });
1880
+ },
1881
+
1882
+ // ---- Solid instance mounting ----
1883
+
1884
+ _mountSolidInstance(containerEl) {
1885
+ if (!containerEl) return null;
1886
+ containerEl.innerHTML = '';
1887
+ const source = this.solidTemplate || _defaultSolidTpl;
1888
+ const frag = source.content.cloneNode(true);
1889
+ // Uniquify any x-dropdown menu ids inside the cloned solid panel
1890
+ // (e.g. the `color-space-menu` from the default template) so two
1891
+ // pickers on the same page don't share the same popover element.
1892
+ uniquifyDropdownIdsIn(frag, this.pickerUid + '-solid');
1893
+ containerEl.appendChild(frag);
1894
+
1895
+ const refs = this._collectSolidRefs(containerEl);
1896
+ this._wireSolidControls(refs);
1897
+
1898
+ // Let Alpine process any x-* directives in the mounted content
1899
+ if (window.Alpine?.initTree) {
1900
+ requestAnimationFrame(() => Alpine.initTree(containerEl));
1901
+ }
1902
+
1903
+ return refs;
1904
+ },
1905
+
1906
+ // Mount the full gradient panel into an instance container.
1907
+ // Uses <template x-colorpicker.gradient> if provided, otherwise the default.
1908
+ _mountGradientInstance(containerEl) {
1909
+ if (!containerEl) return;
1910
+ containerEl.innerHTML = '';
1911
+ const source = this.gradientTemplate || _defaultGradientTpl;
1912
+ const frag = source.content.cloneNode(true);
1913
+ containerEl.appendChild(frag);
1914
+
1915
+ // Alpine processes the inner x-colorpicker.* directives (add-layer,
1916
+ // gradient-layers, layer-options template, set-gradient-value) which
1917
+ // register with THIS state via ancestor traversal.
1918
+ if (window.Alpine?.initTree) Alpine.initTree(containerEl);
1919
+ },
1920
+
1921
+ _collectSolidRefs(containerEl) {
1922
+ return {
1923
+ wrapper: containerEl.querySelector('.canvas-wrapper')
1924
+ || findInClone(containerEl, 'set-canvas')?.parentElement,
1925
+ canvas: findInClone(containerEl, 'set-canvas'),
1926
+ reticle: containerEl.querySelector('.color-reticle'),
1927
+ hueSlider: findInClone(containerEl, 'set-hue'),
1928
+ alphaSlider: findInClone(containerEl, 'set-alpha'),
1929
+ colorInput: findInClone(containerEl, 'set-color-value'),
1930
+ alphaInput: findInClone(containerEl, 'set-alpha-value'),
1931
+ formatSelect: findInClone(containerEl, 'set-color-space'),
1932
+ };
1933
+ },
1934
+
1935
+ _wireSolidControls(refs) {
1936
+ const self = this;
1937
+
1938
+ // Canvas pointer + resize-driven redraw
1939
+ if (refs.canvas && refs.wrapper) {
1940
+ let dragging = false;
1941
+ let cachedRect = null;
1942
+ const pick = (e) => {
1943
+ // Cache the rect while dragging; invalidated on pointerdown
1944
+ if (!cachedRect) cachedRect = refs.canvas.getBoundingClientRect();
1945
+ const rect = cachedRect;
1946
+ self.s = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
1947
+ self.v = Math.max(0, Math.min(100, (1 - (e.clientY - rect.top) / rect.height) * 100));
1948
+ self.syncToInput(); self.updateSliders(); self.updateColorInput(); self.updateCanvasMarker();
1949
+ if (self.isGradient) self._refreshActiveStopVisuals();
1950
+ };
1951
+ const throttledPick = rafThrottle(pick);
1952
+ refs.wrapper.addEventListener('pointerdown', (e) => {
1953
+ dragging = true;
1954
+ cachedRect = refs.canvas.getBoundingClientRect();
1955
+ refs.wrapper.setPointerCapture(e.pointerId);
1956
+ self._markUserChange(false);
1957
+ pick(e); // immediate on first click (not throttled)
1958
+ });
1959
+ refs.wrapper.addEventListener('pointermove', (e) => { if (dragging) throttledPick(e); });
1960
+ refs.wrapper.addEventListener('pointerup', () => { dragging = false; cachedRect = null; });
1961
+
1962
+ const ro = new ResizeObserver(() => {
1963
+ if (self.activeControls?.canvas !== refs.canvas) return;
1964
+ const r = refs.canvas.getBoundingClientRect();
1965
+ if (r.width > 0 && r.height > 0) { self.drawCanvas(); self.updateCanvasMarker(); }
1966
+ });
1967
+ ro.observe(refs.canvas);
1968
+ }
1969
+
1970
+ if (refs.hueSlider) {
1971
+ refs.hueSlider.min = 0; refs.hueSlider.max = 360; refs.hueSlider.step = 1;
1972
+ refs.hueSlider.addEventListener('input', () => {
1973
+ self._markUserChange(false);
1974
+ self.setHue(parseFloat(refs.hueSlider.value));
1975
+ });
1976
+ }
1977
+
1978
+ if (refs.alphaSlider) {
1979
+ refs.alphaSlider.min = 0; refs.alphaSlider.max = 100; refs.alphaSlider.step = 1;
1980
+ refs.alphaSlider.addEventListener('input', () => {
1981
+ self._markUserChange(false);
1982
+ self.setAlpha(parseFloat(refs.alphaSlider.value) / 100);
1983
+ });
1984
+ }
1985
+
1986
+ if (refs.colorInput) {
1987
+ refs.colorInput.addEventListener('input', () => {
1988
+ self._markUserChange(false);
1989
+ self.setColorValue(refs.colorInput.value);
1990
+ });
1991
+ refs.colorInput.addEventListener('blur', () => { refs.colorInput.value = self.toActiveColorString(); });
1992
+ }
1993
+
1994
+ if (refs.alphaInput) {
1995
+ refs.alphaInput.addEventListener('input', () => {
1996
+ const v = parseFloat(refs.alphaInput.value);
1997
+ if (!isNaN(v)) {
1998
+ self._markUserChange(false);
1999
+ self.setAlphaValue(v);
2000
+ }
2001
+ });
2002
+ }
2003
+
2004
+ if (refs.formatSelect) {
2005
+ refs.formatSelect.addEventListener('change', () => self.setColorSpace(refs.formatSelect.value));
2006
+ }
2007
+ },
2008
+
2009
+ // ---- Picker mount (after all children registered) ----
2010
+
2011
+ mount() {
2012
+ // Tier 2: if the container has no declared UI (templates are inert overrides),
2013
+ // inject the full default UI. Templates alone don't count as "declared UI".
2014
+ const noDeclared = !this.solidTemplate && !this.layerTemplate && !this.gradientTemplate
2015
+ && this.solidInstances.length === 0 && this.gradientInstances.length === 0
2016
+ && !this.layersContainer && this.gradientValueInputs.length === 0
2017
+ && !this.libraryContainers.length;
2018
+ const hasNonTemplateChildren = [...this.rootEl.children].some(c => c.tagName !== 'TEMPLATE');
2019
+ if (noDeclared && !hasNonTemplateChildren) {
2020
+ this._injectDefaultUI();
2021
+ }
2022
+
2023
+ // Initialize from hidden input value
2024
+ const initVal = this.hiddenInput ? this.hiddenInput.value : '#000000';
2025
+ this.setFromString(initVal || '#000000');
2026
+
2027
+ // Seed trigger swatch
2028
+ if (this.triggerBtn) this.triggerBtn.style.setProperty('--color-picker-swatch', this.toSwatchColor());
2029
+
2030
+ // Mount all solid-panel instances (Solid tab + any others)
2031
+ let firstSolidRefs = null;
2032
+ for (const inst of this.solidInstances) {
2033
+ const refs = this._mountSolidInstance(inst);
2034
+ if (refs && !firstSolidRefs) firstSolidRefs = refs;
2035
+ }
2036
+ this.solidTabRefs = firstSolidRefs;
2037
+
2038
+ // Mount all gradient-panel instances (the full gradient panel).
2039
+ // This populates them with the gradient template (or default), which in turn
2040
+ // registers gradient-layers / layer-options / set-gradient-value with this state.
2041
+ for (const inst of this.gradientInstances) this._mountGradientInstance(inst);
2042
+
2043
+ // Render gradient layers (only if a container is declared)
2044
+ if (this.layersContainer) this.renderLayers();
2045
+
2046
+ // Initial mode based on Alpine `tab` data, if present
2047
+ this._syncPickerModeFromTab();
2048
+
2049
+ // Activate controls based on mode
2050
+ if (!this.isGradient) this._activateControls(this.solidTabRefs);
2051
+
2052
+ // Wire click-based tab watcher (root + auto-injected wrapper)
2053
+ this.rootEl.addEventListener('click', () => {
2054
+ requestAnimationFrame(() => this._syncPickerModeFromTab());
2055
+ });
2056
+
2057
+ // Initial sync
2058
+ this.syncToInput();
2059
+
2060
+ // Render the library — _doRenderLibrary clones the (optional) library template
2061
+ // into the container and expands nested group/palette/swatch templates in-place.
2062
+ if (this.libraryContainers.length) this.renderLibrary();
2063
+
2064
+ // ---- Recent-list commit wiring ----
2065
+ // Seed the initial baseline. Popover pickers re-seed on toggle→open.
2066
+ this._startCommitCycle();
2067
+
2068
+ // Broad user-interaction detector: any pointerdown or input event inside
2069
+ // the picker marks the cycle as "user-touched". This covers all gradient
2070
+ // controls (add-layer, set-angle, stop drags, textarea edits, etc.) without
2071
+ // having to instrument each handler. Library swatches are detected by
2072
+ // scope ancestry so a preset pick is correctly flagged as library-sourced.
2073
+ this.rootEl.addEventListener('pointerdown', (e) => {
2074
+ const fromLibrary = !!e.target.closest('[x-data*="swatch:"]');
2075
+ this._markUserChange(fromLibrary);
2076
+ });
2077
+ this.rootEl.addEventListener('input', () => {
2078
+ // 'input' on form controls = user typing / dragging sliders
2079
+ this._markUserChange(false);
2080
+ });
2081
+
2082
+ if (this.rootEl.hasAttribute('popover')) {
2083
+ // Popover mode: open/close are the commit boundaries
2084
+ this.rootEl.addEventListener('toggle', (e) => {
2085
+ if (e.newState === 'open') this._startCommitCycle();
2086
+ if (e.newState === 'closed') {
2087
+ // Write to the triggering swatch's model FIRST — _tryCommitRecent
2088
+ // resets the user-change flag on success, which would otherwise
2089
+ // short-circuit _commitToTrigger.
2090
+ this._commitToTrigger();
2091
+ this._tryCommitRecent();
2092
+ }
2093
+ });
2094
+ } else {
2095
+ // Inline mode: commit when focus leaves the picker entirely
2096
+ this.rootEl.addEventListener('focusout', (e) => {
2097
+ const moved = e.relatedTarget;
2098
+ if (!moved || !this.rootEl.contains(moved)) {
2099
+ this._commitToTrigger();
2100
+ this._tryCommitRecent();
2101
+ }
2102
+ });
2103
+ }
2104
+
2105
+ // Register in the global registry so $colorpicker(id) bindings resolve
2106
+ if (this.rootEl.id) _pickerRegistry[this.rootEl.id] = this.api;
2107
+
2108
+ // Mark complete so the `library` directive handler knows to render into
2109
+ // any newly-registered containers (e.g., gradient layer library menus).
2110
+ this._mounted = true;
2111
+ },
2112
+
2113
+ _injectDefaultUI() {
2114
+ this.rootEl.innerHTML = '';
2115
+ this.rootEl.appendChild(_defaultFullUiTpl.content.cloneNode(true));
2116
+
2117
+ // Filter / reorder tabs and panels per `allowedPanels` (set on the
2118
+ // root state when the directive expression is a panel-list array).
2119
+ // No allowedPanels → render all three in their default order.
2120
+ const allowed = this.allowedPanels;
2121
+ if (allowed && allowed.length) {
2122
+ const wrapper = this.rootEl.firstElementChild;
2123
+ const tabBar = wrapper?.querySelector('[data-cp-tabs]');
2124
+ // Remove tab buttons not in allowed; reorder the rest in `allowed` order
2125
+ if (tabBar) {
2126
+ const tabBtns = Array.from(tabBar.querySelectorAll('[data-cp-tab]'));
2127
+ for (const b of tabBtns) {
2128
+ if (!allowed.includes(b.getAttribute('data-cp-tab'))) b.remove();
2129
+ }
2130
+ for (const name of allowed) {
2131
+ const b = tabBar.querySelector(`[data-cp-tab="${name}"]`);
2132
+ if (b) tabBar.appendChild(b);
2133
+ }
2134
+ // Single panel → no tabs needed, drop the bar entirely
2135
+ if (allowed.length === 1) tabBar.remove();
2136
+ }
2137
+ // Remove panel containers not in allowed; reorder the rest
2138
+ const panels = Array.from(wrapper?.querySelectorAll('[data-cp-panel]') || []);
2139
+ for (const p of panels) {
2140
+ if (!allowed.includes(p.getAttribute('data-cp-panel'))) p.remove();
2141
+ }
2142
+ // Reset the initial `tab` x-data to the first allowed panel and
2143
+ // strip x-show so the lone panel always renders when there's no tab bar.
2144
+ if (wrapper.hasAttribute('x-data')) {
2145
+ wrapper.setAttribute('x-data', `{ tab: '${allowed[0]}' }`);
2146
+ }
2147
+ if (allowed.length === 1) {
2148
+ const lone = wrapper.querySelector(`[data-cp-panel="${allowed[0]}"]`);
2149
+ if (lone) lone.removeAttribute('x-show');
2150
+ }
2151
+ }
2152
+
2153
+ // Alpine.initTree fires all x-* directives in the newly injected content,
2154
+ // which will register solidTemplate/layerTemplate/solidInstances/layersContainer
2155
+ // against THIS state (since ancestor traversal finds this.rootEl).
2156
+ if (window.Alpine?.initTree) {
2157
+ Alpine.initTree(this.rootEl);
2158
+ }
2159
+ // Stash the auto-injected tab scope wrapper so _syncPickerModeFromTab can read it
2160
+ this._autoTabScope = this.rootEl.firstElementChild;
2161
+ },
2162
+
2163
+ _syncPickerModeFromTab() {
2164
+ const rootTab = this.rootEl._x_dataStack?.[0]?.tab;
2165
+ const autoTab = this._autoTabScope?._x_dataStack?.[0]?.tab;
2166
+ const tab = rootTab || autoTab;
2167
+ if (!tab) return;
2168
+ // Only Solid/Gradient tabs drive the edit mode — Library (or any other
2169
+ // tab) preserves the current mode. Otherwise switching to Library while
2170
+ // editing a gradient would silently demote the picker to solid, breaking
2171
+ // the "active" indicator on gradient Recent swatches.
2172
+ let newMode = null;
2173
+ if (tab === 'gradient') newMode = 'gradient';
2174
+ else if (tab === 'solid') newMode = 'solid';
2175
+ if (!newMode || newMode === this.pickerMode) return;
2176
+ this.pickerMode = newMode;
2177
+ if (this.isGradient) {
2178
+ this.renderLayers();
2179
+ if (!this.openStop) this._activateControls(null);
2180
+ } else {
2181
+ this.openStop = null;
2182
+ this._activateControls(this.solidTabRefs);
2183
+ }
2184
+ this.syncToInput();
2185
+ },
2186
+ };
2187
+
2188
+ // ---- Public API exposed via $colorpicker magic ----
2189
+
2190
+ // Reading `state.snapshot.version` inside each getter registers a reactive
2191
+ // dependency. When syncToInput bumps the version, any Alpine effect that
2192
+ // read these getters re-runs — and only then do we compute the value.
2193
+ // Zero computation if nothing is bound.
2194
+ const track = () => state.snapshot.version;
2195
+ state.api = {
2196
+ // Reactive reads — lazily computed on demand
2197
+ get hex() { track(); return state.toHex(); },
2198
+ get formatted() { track(); return state.toActiveColorString(); },
2199
+ get css() { track(); return state.toFormattedString(); },
2200
+ get h() { track(); return state.h; },
2201
+ get s() { track(); return state.s; },
2202
+ get v() { track(); return state.v; },
2203
+ get a() { track(); return state.a; },
2204
+ get format() { track(); return state.format; },
2205
+ get pickerMode() { track(); return state.pickerMode; },
2206
+
2207
+ // Default string coercion → current CSS value. Lets the developer write
2208
+ // :style="`background: ${$colorpicker('id')}`"
2209
+ // x-text="$colorpicker('id')"
2210
+ // and get the color string directly without picking a specific property.
2211
+ [Symbol.toPrimitive]() { track(); return state.toFormattedString(); },
2212
+ toString() { track(); return state.toFormattedString(); },
2213
+ valueOf() { track(); return state.toFormattedString(); },
2214
+
2215
+ // Non-reactive references (direct state)
2216
+ get layers() { return state.layers; },
2217
+ get activeLayer() { return state.activeLayer(); },
2218
+ get activeStop() { return state.activeStop(); },
2219
+ get activeLayerIndex() { return state.activeLayerIndex; },
2220
+ get activeStopIndex() { return state.activeStopIndex; },
2221
+ get openStop() { return state.openStop; },
2222
+
2223
+ // Actions (mirror `.action` modifiers)
2224
+ addLayer: () => state.addLayer(),
2225
+ addLayerAbove: (i) => state.addLayerAt(i ?? state.activeLayerIndex, 'above'),
2226
+ addLayerBelow: (i) => state.addLayerAt(i ?? state.activeLayerIndex, 'below'),
2227
+ moveLayerUp: (i) => state.moveLayer(i ?? state.activeLayerIndex, -1),
2228
+ moveLayerDown: (i) => state.moveLayer(i ?? state.activeLayerIndex, +1),
2229
+ duplicateLayer: (i) => state.duplicateLayer(i ?? state.activeLayerIndex),
2230
+ removeLayer: (i) => state.removeLayer(i ?? state.activeLayerIndex),
2231
+ flipLayer: (i) => state.flipLayer(i ?? state.activeLayerIndex),
2232
+ rotateLayer: (i) => state.rotateLayer(i ?? state.activeLayerIndex),
2233
+ duplicateStop: (li, si) => state.duplicateStop(li ?? state.activeLayerIndex, si ?? state.activeStopIndex),
2234
+ deleteStop: (li, si) => state.deleteStop(li ?? state.activeLayerIndex, si ?? state.activeStopIndex),
2235
+ addStop: (li, pos) => state.addStop(li ?? state.activeLayerIndex, pos),
2236
+ setGradientType: (i, type) => state.setGradientType(i ?? state.activeLayerIndex, type),
2237
+ applyColor: (str) => state.applyColor(str),
2238
+ grabColor: () => state.grabColor(),
2239
+
2240
+ // Setters (mirror `.set-*` modifiers)
2241
+ setHue: (v) => state.setHue(v),
2242
+ setAlpha: (v) => state.setAlpha(v),
2243
+ setAlphaValue: (percent) => state.setAlphaValue(percent),
2244
+ setColorSpace: (fmt) => state.setColorSpace(fmt),
2245
+ setColorValue: (str) => state.setColorValue(str),
2246
+ setAngle: (i, deg) => state.setAngle(i ?? state.activeLayerIndex, deg),
2247
+ setGradientValue: (str) => state.setGradientValue(str),
2248
+
2249
+ // Selection / state helpers
2250
+ selectStop: (li, si) => state.selectStop(li, si),
2251
+ toggleStop: (li, si) => state.toggleStop(li, si),
2252
+ setFromString: (str) => state.setFromString(str),
2253
+ toFormattedString: () => state.toFormattedString(),
2254
+ toHex: () => state.toHex(),
2255
+
2256
+ // Library helpers (global to the plugin; same for every picker)
2257
+ get recent() { return _recentStore.list; },
2258
+ clearRecent: () => clearRecent(),
2259
+ removeRecent: (v) => removeRecent(v),
2260
+ pushRecent: (v) => pushRecent(v),
2261
+ get presets() { return { tailwind: buildTailwindPreset(), ios: buildIosPreset() }; },
2262
+ };
2263
+
2264
+ return state;
2265
+ }
2266
+
2267
+ // ---- Helpers for finding in-clone elements ----
2268
+
2269
+ function findInClone(root, modifier) {
2270
+ // Match any `x-colorpicker.<modifier>` (possibly with additional modifiers or a value)
2271
+ const attrName = 'x-colorpicker.' + modifier;
2272
+ if (root.hasAttribute && root.hasAttribute(attrName)) return root;
2273
+ return root.querySelector(`[${cssEscapeAttr(attrName)}]`);
2274
+ }
2275
+
2276
+ function cssEscapeAttr(name) {
2277
+ return name.replace(/\./g, '\\.');
2278
+ }
2279
+
2280
+ // Rewrite x-dropdown* trigger attributes and the matching <menu id="..."> inside
2281
+ // a cloned subtree so multiple clones don't share the same popover ID.
2282
+ function uniquifyDropdownIdsIn(root, suffix) {
2283
+ const attrs = ['x-dropdown', 'x-dropdown.context', 'x-dropdown.hover'];
2284
+ for (const attr of attrs) {
2285
+ const selector = '[' + attr.replace(/\./g, '\\.') + ']';
2286
+ const triggers = root.querySelectorAll(selector);
2287
+ for (const trigger of triggers) {
2288
+ const original = trigger.getAttribute(attr);
2289
+ if (!original || /[`${}]/.test(original)) continue; // skip Alpine template literals
2290
+ // Only rewrite if we can find the target menu INSIDE this clone
2291
+ let menu = null;
2292
+ try { menu = root.querySelector('#' + CSS.escape(original)); } catch {}
2293
+ if (!menu) continue;
2294
+ const newId = original + '--' + suffix;
2295
+ trigger.setAttribute(attr, newId);
2296
+ menu.id = newId;
2297
+ }
2298
+ }
2299
+ }
2300
+
2301
+ // Coalesce rapid-fire calls (pointermove, input) into at most one per animation frame.
2302
+ // The latest args win. Essential for keeping the main thread responsive on busy devices.
2303
+ function rafThrottle(fn) {
2304
+ let scheduled = false;
2305
+ let lastArgs;
2306
+ let lastThis;
2307
+ return function throttled(...args) {
2308
+ lastArgs = args;
2309
+ lastThis = this;
2310
+ if (scheduled) return;
2311
+ scheduled = true;
2312
+ requestAnimationFrame(() => {
2313
+ scheduled = false;
2314
+ fn.apply(lastThis, lastArgs);
2315
+ });
2316
+ };
2317
+ }
2318
+
2319
+ // JSON-serialize a value for use in an Alpine x-data attribute via setAttribute.
2320
+ // setAttribute does NOT decode HTML entities, so no escaping needed — JSON is
2321
+ // already valid JS literal syntax that Alpine can parse directly.
2322
+ function _jsonStringifyForAlpine(v) {
2323
+ try { return JSON.stringify(v); } catch { return '{}'; }
2324
+ }
2325
+
2326
+ // Evaluate an expression in the scope of a given element, outside a directive context
2327
+ function evaluateLaterShim(el, expression) {
2328
+ if (!window.Alpine?.evaluateLater) {
2329
+ return (cb) => { try { cb(new Function('return ' + expression)()); } catch { cb(null); } };
2330
+ }
2331
+ return Alpine.evaluateLater(el, expression);
2332
+ }
2333
+
2334
+ function findAncestorState(el) {
2335
+ let n = el;
2336
+ while (n) {
2337
+ if (n._colorpickerState) return n._colorpickerState;
2338
+ n = n.parentElement;
2339
+ }
2340
+ return null;
2341
+ }
2342
+
2343
+ function findLayerContext(el) {
2344
+ let n = el;
2345
+ while (n) {
2346
+ if (n.hasAttribute && n.hasAttribute('data-cp-layer-clone')) {
2347
+ return { layerIndex: n._cpLayerIndex, cloneRoot: n };
2348
+ }
2349
+ n = n.parentElement;
2350
+ }
2351
+ return null;
2352
+ }
2353
+
2354
+ // ---- Directive registration ----
2355
+
2356
+ function registerPlugin() {
2357
+ if (!window.Alpine || typeof Alpine.directive !== 'function') return;
2358
+
2359
+ Alpine.directive('colorpicker', (el, { modifiers, expression }, { cleanup, evaluateLater }) => {
2360
+ // Root: no modifiers
2361
+ if (!modifiers || modifiers.length === 0) {
2362
+ // <template x-colorpicker> → registered as the page-wide default
2363
+ // override. Every bare swatch (`<button x-colorpicker.swatch>`) that
2364
+ // would otherwise auto-create an empty popover instead clones from
2365
+ // this template. Only one default per page; first declaration wins.
2366
+ if (el.tagName === 'TEMPLATE') {
2367
+ if (expression || el.id) {
2368
+ // Id-keyed templates are no longer supported — declare the
2369
+ // picker inline (`<menu id="X" popover x-colorpicker>`) or
2370
+ // wrap it in a Manifest HTML component for reuse.
2371
+ 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 {}
2372
+ return;
2373
+ }
2374
+ registerDefaultColorpickerTemplate(el);
2375
+ return;
2376
+ }
2377
+
2378
+ const state = createPickerState(el);
2379
+ el._colorpickerState = state;
2380
+
2381
+ // Panel-list expression: `x-colorpicker="['solid', 'gradient']"`.
2382
+ // Parsed once here and used by _injectDefaultUI to filter + reorder
2383
+ // the default UI's tabs and panels. Anything else is ignored.
2384
+ state.allowedPanels = parsePanelsExpression(expression);
2385
+
2386
+ // Find the form-participation input
2387
+ state.hiddenInput = el.querySelector('input[type=color], input[type=hidden]');
2388
+
2389
+ // Find trigger button (any button with x-dropdown pointing to this element's ID)
2390
+ const id = el.id;
2391
+ state.triggerBtn = id ? document.querySelector(`[x-dropdown="${id}"]`) : null;
2392
+ if (state.triggerBtn) {
2393
+ state.triggerBtn.addEventListener('click', () => {
2394
+ requestAnimationFrame(() => state.syncUI());
2395
+ });
2396
+ }
2397
+
2398
+ // Defer mount so all child directives have had a chance to register.
2399
+ // setTimeout (rather than rAF) so we still fire when the tab is
2400
+ // backgrounded or rAF is throttled — mount must happen for the
2401
+ // picker to work, and it doesn't need to be sync'd with paint.
2402
+ setTimeout(() => state.mount(), 0);
2403
+
2404
+ cleanup(() => {
2405
+ if (el.id) delete _pickerRegistry[el.id];
2406
+ delete el._colorpickerState;
2407
+ });
2408
+ return;
2409
+ }
2410
+
2411
+ // Child hook
2412
+ const role = modifiers[0];
2413
+
2414
+ // Swatches work as triggers OUTSIDE any picker — handle before the ancestor check.
2415
+ // We only assign IDs + generate the popover tag; the dropdown plugin (x-dropdown)
2416
+ // owns popover mechanics, anchor positioning, and transitions.
2417
+ if (role === 'swatch') {
2418
+ if (el._cpSwatchWired) return; // guard against re-firing via initTree
2419
+ el._cpSwatchWired = true;
2420
+
2421
+ // ---- Optional x-model binding ----
2422
+ // When a swatch carries x-model, the expression is the source of truth for
2423
+ // that swatch's color. The plugin:
2424
+ // • reactively shows the model value as the swatch background (via CSS var)
2425
+ // • exposes a read accessor the picker uses on open to load the value
2426
+ // • exposes a write accessor the picker uses on close to persist changes
2427
+ // The dev can still apply inline style / class overrides on top.
2428
+ const modelExpr = el.getAttribute('x-model');
2429
+ if (modelExpr && window.Alpine?.evaluateLater && window.Alpine?.effect) {
2430
+ try {
2431
+ const readFn = Alpine.evaluateLater(el, modelExpr);
2432
+ // Reactive background preview
2433
+ Alpine.effect(() => {
2434
+ readFn(v => {
2435
+ if (typeof v === 'string' && v.length) {
2436
+ el.style.setProperty('--color-picker-swatch', v);
2437
+ el.setAttribute('data-cp-model-value', v);
2438
+ }
2439
+ });
2440
+ });
2441
+ el._cpModelGetter = (cb) => readFn(cb);
2442
+ // Writer: evaluate `<modelExpr> = <JSON-stringified value>`. JSON.stringify
2443
+ // ensures the value is safely serialized (color strings + gradient CSS
2444
+ // are all JSON-safe).
2445
+ el._cpModelSetter = (v) => {
2446
+ try { Alpine.evaluate(el, `${modelExpr} = ${JSON.stringify(v)}`); } catch {}
2447
+ };
2448
+ } catch {}
2449
+ }
2450
+
2451
+ // ---- Initial color via `value` attribute ----
2452
+ // The swatch can carry a `value="#abc123"` attribute (mirrors native
2453
+ // <input type="color"> semantics). It seeds the picker on first open
2454
+ // and the swatch's CSS var so the border-color derivation paints
2455
+ // correctly before any interaction.
2456
+ const valueAttr = el.getAttribute('value');
2457
+ if (valueAttr && !el.style.getPropertyValue('--color-picker-swatch')) {
2458
+ el.style.setProperty('--color-picker-swatch', valueAttr);
2459
+ }
2460
+
2461
+ // ---- Form participation via `name` attribute ----
2462
+ // When the swatch has `name=`, the plugin synthesizes a sibling
2463
+ // <input type="hidden"> with that name (or adopts a matching one
2464
+ // already in the DOM). syncToInput then writes the picker's hex
2465
+ // value to it, dispatching input/change events for form code.
2466
+ // No `name` → no synthesized input — purely decorative swatch.
2467
+ const nameAttr = el.getAttribute('name');
2468
+ if (nameAttr) {
2469
+ let hidden = el.parentElement?.querySelector?.(
2470
+ `:scope > input[type=hidden][name="${nameAttr.replace(/"/g, '\\"')}"]`
2471
+ );
2472
+ if (!hidden) {
2473
+ hidden = document.createElement('input');
2474
+ hidden.type = 'hidden';
2475
+ hidden.name = nameAttr;
2476
+ hidden.value = valueAttr || '';
2477
+ el.after(hidden);
2478
+ el._cpSynthesizedHidden = hidden;
2479
+ }
2480
+ el._cpHiddenInput = hidden;
2481
+ // Drop `name` from the swatch itself so the form doesn't pick up
2482
+ // both the (typically empty) button and the hidden input.
2483
+ if (el.tagName === 'BUTTON') el.removeAttribute('name');
2484
+ }
2485
+
2486
+ cleanup(() => {
2487
+ if (el._cpSynthesizedHidden && el._cpSynthesizedHidden.isConnected) {
2488
+ el._cpSynthesizedHidden.remove();
2489
+ }
2490
+ });
2491
+
2492
+ const wireSwatchTo = (target) => {
2493
+ if (!target) return;
2494
+
2495
+ const isDialog = target.tagName === 'DIALOG';
2496
+
2497
+ // For non-dialog popover targets (menu / div with popover), delegate
2498
+ // open/close + anchor positioning to the dropdowns plugin so the
2499
+ // picker appears anchored to the swatch like a dropdown menu.
2500
+ // <dialog> targets are NOT routed through x-dropdown — dialogs are
2501
+ // modal/centered surfaces, not anchored to a trigger. We open them
2502
+ // imperatively on click via showPopover() or showModal().
2503
+ if (!isDialog
2504
+ && target.hasAttribute('popover')
2505
+ && !el.hasAttribute('popovertarget')
2506
+ && !el.hasAttribute('x-dropdown')) {
2507
+ el.setAttribute('x-dropdown', target.id);
2508
+ if (window.Alpine?.initTree) Alpine.initTree(el);
2509
+ }
2510
+
2511
+ // Retarget the picker to this swatch on click (load its color + point writes here)
2512
+ el.addEventListener('click', (e) => {
2513
+ // Open dialogs imperatively. Prefer popover semantics when the
2514
+ // dialog has a `popover` attribute (light-dismiss); otherwise
2515
+ // open as a true modal with backdrop and focus trap.
2516
+ if (isDialog) {
2517
+ e.preventDefault();
2518
+ try {
2519
+ if (target.hasAttribute('popover')) {
2520
+ if (!target.matches(':popover-open')) target.showPopover();
2521
+ } else {
2522
+ if (!target.open) target.showModal();
2523
+ }
2524
+ } catch {}
2525
+ }
2526
+
2527
+ const retarget = () => {
2528
+ // Picker state may live on `target` itself (e.g. <menu x-colorpicker>)
2529
+ // or on a descendant when the target is a wrapping container such
2530
+ // as <dialog> hosting <div x-colorpicker> inside.
2531
+ let st = target._colorpickerState;
2532
+ if (!st) {
2533
+ const inner = target.querySelector?.('[x-colorpicker]');
2534
+ if (inner && inner._colorpickerState) st = inner._colorpickerState;
2535
+ }
2536
+ if (!st) { setTimeout(retarget, 0); return; }
2537
+ st.triggerBtn = el;
2538
+ // Route form-participation writes to this swatch's hidden
2539
+ // input (synthesized from `name`, or dev-supplied sibling).
2540
+ // Falls back to whatever the picker container already had
2541
+ // — preserves the existing inline-input flow.
2542
+ if (el._cpHiddenInput) st.hiddenInput = el._cpHiddenInput;
2543
+ // Load the trigger's current value. Priority:
2544
+ // 1. x-model getter (shared-picker flow)
2545
+ // 2. paired hidden input value (form-participation flow)
2546
+ // 3. `value` attribute on the swatch
2547
+ // 4. --color-picker-swatch CSS var (auto / inline-style flow)
2548
+ // 5. fallback '#000000'
2549
+ if (el._cpModelGetter) {
2550
+ el._cpModelGetter(v => { if (typeof v === 'string' && v.length) st.setFromString(v); });
2551
+ } else {
2552
+ const current = (el._cpHiddenInput && el._cpHiddenInput.value)
2553
+ || el.getAttribute('value')
2554
+ || el.style.getPropertyValue('--color-picker-swatch')
2555
+ || getComputedStyle(el).getPropertyValue('--color-picker-swatch').trim()
2556
+ || '#000000';
2557
+ if (current) st.setFromString(current);
2558
+ }
2559
+ // Defer heavy UI sync so the popover's entry transition runs unimpeded
2560
+ setTimeout(() => st.syncUI(), 0);
2561
+ };
2562
+ retarget();
2563
+ });
2564
+ };
2565
+
2566
+ // Panel-list expression on a swatch (`x-colorpicker.swatch="['solid']"`)
2567
+ // → not an id, not a template literal — auto-create a popover and pass
2568
+ // the panels through so its picker UI is filtered to that subset.
2569
+ const panelsExpr = parsePanelsExpression(expression);
2570
+
2571
+ if (!expression) {
2572
+ // Bare swatch → auto-create popover with generated ID
2573
+ wireSwatchTo(createSwatchPopover());
2574
+ } else if (panelsExpr) {
2575
+ // Panel list → auto-create popover with the expression preserved
2576
+ wireSwatchTo(createSwatchPopover(undefined, expression));
2577
+ } else if (expression.includes('${') || expression.includes('`')) {
2578
+ // Alpine template literal → resolve, then look up or auto-create
2579
+ const evaluator = evaluateLater(expression);
2580
+ evaluator(val => {
2581
+ if (!val) return;
2582
+ const t = resolvePickerById(val) || createSwatchPopover(val);
2583
+ wireSwatchTo(t);
2584
+ });
2585
+ } else {
2586
+ // Static ID → resolve via inline / template / auto
2587
+ const t = resolvePickerById(expression) || createSwatchPopover(expression);
2588
+ wireSwatchTo(t);
2589
+ }
2590
+ return;
2591
+ }
2592
+
2593
+ // All other child hooks require an ancestor picker state
2594
+ const state = findAncestorState(el);
2595
+ if (!state) return;
2596
+
2597
+ switch (role) {
2598
+ case 'solid':
2599
+ if (el.tagName === 'TEMPLATE') {
2600
+ state.solidTemplate = el;
2601
+ } else if (!findLayerContext(el)) {
2602
+ // Top-level instance (e.g. Solid tab). Accordion instances
2603
+ // inside a layer clone are handled by renderLayers directly.
2604
+ state.solidInstances.push(el);
2605
+ }
2606
+ return;
2607
+
2608
+ case 'layer-options':
2609
+ if (el.tagName === 'TEMPLATE') state.layerTemplate = el;
2610
+ return;
2611
+
2612
+ case 'gradient':
2613
+ if (el.tagName === 'TEMPLATE') state.gradientTemplate = el;
2614
+ else state.gradientInstances.push(el);
2615
+ return;
2616
+
2617
+ case 'gradient-layers':
2618
+ state.layersContainer = el;
2619
+ return;
2620
+
2621
+ case 'layer-stops-bar':
2622
+ // Handled per-clone in _renderStopBar
2623
+ return;
2624
+
2625
+ // Actions
2626
+ case 'add-layer':
2627
+ el.addEventListener('click', () => state.addLayer());
2628
+ return;
2629
+
2630
+ case 'grab-color':
2631
+ el.addEventListener('click', () => state.grabColor());
2632
+ return;
2633
+
2634
+ case 'apply-color': {
2635
+ el.addEventListener('click', () => {
2636
+ // Respect disabled attribute (used when the swatch is a gradient
2637
+ // and the user is editing a gradient stop — CSS doesn't allow
2638
+ // gradients as color stop values).
2639
+ if (el.hasAttribute('disabled')) return;
2640
+ const cs = window.getComputedStyle(el);
2641
+ const raw = el.style.background || el.style.backgroundColor || cs.backgroundColor;
2642
+ if (!raw) return;
2643
+ // Library-swatch clicks are marked so the commit cycle knows not
2644
+ // to record them as "recent" even if the picker closes afterwards.
2645
+ const fromLibrary = !!el.closest('[x-data*="swatch:"]');
2646
+ const fromStopMenu = !!el.closest('menu[id^="stop-context-menu"]');
2647
+ state._markUserChange(fromLibrary);
2648
+
2649
+ // Top-level library picks (NOT inside a stop-context-menu) replace
2650
+ // the WHOLE field — switch picker mode to match the swatch's value
2651
+ // type so a solid swatch doesn't become a stop in an existing
2652
+ // gradient and a gradient swatch doesn't get parsed as the active
2653
+ // stop's color. Stop-menu picks intentionally write to the right-
2654
+ // clicked stop and stay in gradient mode.
2655
+ // Use _setPickerMode (not _switchToSolidMode) — we don't want to
2656
+ // flip the user off whatever tab they're on (typically Library).
2657
+ const valueIsGradient = raw.includes('gradient(');
2658
+ if (fromLibrary && !fromStopMenu) {
2659
+ state._setPickerMode(valueIsGradient ? 'gradient' : 'solid');
2660
+ }
2661
+
2662
+ state.applyColor(raw);
2663
+ });
2664
+ return;
2665
+ }
2666
+
2667
+ case 'remove-recent': {
2668
+ // Expected placement: a menu item inside a <menu popover> referenced by
2669
+ // x-dropdown.context on a Recent swatch. The dropdowns plugin stashes the
2670
+ // triggering element on `menu._triggerEl`. We read its `data-cp-value`
2671
+ // (the raw stored form) and remove that entry from the Recent cookie.
2672
+ el.addEventListener('click', () => {
2673
+ const menu = el.closest('[popover]');
2674
+ const trigger = menu?._triggerEl || menu?._triggerHost;
2675
+ const value = trigger?.getAttribute?.('data-cp-value');
2676
+ if (value) removeRecent(value);
2677
+ });
2678
+ return;
2679
+ }
2680
+
2681
+ case 'duplicate-layer':
2682
+ case 'remove-layer':
2683
+ case 'flip-layer':
2684
+ case 'rotate-layer':
2685
+ case 'add-layer-above':
2686
+ case 'add-layer-below':
2687
+ case 'move-layer-up':
2688
+ case 'move-layer-down':
2689
+ case 'duplicate-stop':
2690
+ case 'delete-stop':
2691
+ case 'set-gradient-type': {
2692
+ el.addEventListener('click', () => {
2693
+ // Respect :disabled bindings — Alpine toggles the attribute on the
2694
+ // element; a disabled menu item shouldn't fire its action.
2695
+ if (el.hasAttribute('disabled')) return;
2696
+ const ctx = findLayerContext(el);
2697
+ const li = ctx ? ctx.layerIndex : state.activeLayerIndex;
2698
+ switch (role) {
2699
+ case 'duplicate-layer': state.duplicateLayer(li); break;
2700
+ case 'remove-layer': state.removeLayer(li); break;
2701
+ case 'flip-layer': state.flipLayer(li); break;
2702
+ case 'rotate-layer': state.rotateLayer(li); break;
2703
+ case 'add-layer-above': state.addLayerAt(li, 'above'); break;
2704
+ case 'add-layer-below': state.addLayerAt(li, 'below'); break;
2705
+ case 'move-layer-up': state.moveLayer(li, -1); break;
2706
+ case 'move-layer-down': state.moveLayer(li, +1); break;
2707
+ case 'duplicate-stop': state.duplicateStop(li, state.activeStopIndex); break;
2708
+ case 'delete-stop': state.deleteStop(li, state.activeStopIndex); break;
2709
+ case 'set-gradient-type': {
2710
+ // Value comes from the expression (Alpine parsed) or the attribute
2711
+ const type = expression || el.getAttribute('x-colorpicker.set-gradient-type');
2712
+ state.setGradientType(li, (type || '').replace(/['"]/g, ''));
2713
+ break;
2714
+ }
2715
+ }
2716
+ });
2717
+ return;
2718
+ }
2719
+
2720
+ // Inputs inside layer (angle)
2721
+ case 'set-angle': {
2722
+ // Input listener
2723
+ el.addEventListener('input', () => {
2724
+ const ctx = findLayerContext(el);
2725
+ const li = ctx ? ctx.layerIndex : state.activeLayerIndex;
2726
+ state.setAngle(li, parseFloat(el.value) || 0);
2727
+ });
2728
+ // Drag-scrub
2729
+ let scrubbing = false, scrubStartX = 0, scrubStartAngle = 0, scrubLi = 0;
2730
+ el.addEventListener('pointerdown', (e) => {
2731
+ if (document.activeElement === el) return;
2732
+ e.preventDefault();
2733
+ const ctx = findLayerContext(el);
2734
+ scrubLi = ctx ? ctx.layerIndex : state.activeLayerIndex;
2735
+ scrubbing = true;
2736
+ scrubStartX = e.clientX;
2737
+ scrubStartAngle = state.layers[scrubLi]?.angle || 0;
2738
+ el.setPointerCapture(e.pointerId);
2739
+ });
2740
+ const applyScrub = (e) => {
2741
+ const newAngle = scrubStartAngle + (e.clientX - scrubStartX);
2742
+ state.setAngle(scrubLi, Math.round(newAngle));
2743
+ el.value = state.layers[scrubLi]?.angle || 0;
2744
+ };
2745
+ const throttledScrub = rafThrottle(applyScrub);
2746
+ el.addEventListener('pointermove', (e) => {
2747
+ if (!scrubbing) return;
2748
+ throttledScrub(e);
2749
+ });
2750
+ el.addEventListener('pointerup', (e) => {
2751
+ if (scrubbing) {
2752
+ scrubbing = false;
2753
+ if (Math.abs(e.clientX - scrubStartX) < 3) { el.focus(); el.select(); }
2754
+ }
2755
+ });
2756
+ return;
2757
+ }
2758
+
2759
+ // Gradient CSS textarea
2760
+ case 'set-gradient-value':
2761
+ state.gradientValueInputs.push(el);
2762
+ el.addEventListener('input', () => {
2763
+ state.setGradientValue(el.value);
2764
+ });
2765
+ return;
2766
+
2767
+ // Solid-tab controls (wired via _wireSolidControls when mounted)
2768
+ // If the developer places them OUTSIDE a solid-panel instance, wire individually:
2769
+ case 'set-canvas':
2770
+ case 'set-hue':
2771
+ case 'set-alpha':
2772
+ case 'set-alpha-value':
2773
+ case 'set-color-value': {
2774
+ // These are typically inside a solid-panel template/instance.
2775
+ // (The usual path handles them inside _mountSolidInstance.)
2776
+ return;
2777
+ }
2778
+
2779
+ // set-color-space supports two roles:
2780
+ // • With expression (`<li x-colorpicker.set-color-space="hex">`) →
2781
+ // click sets that format. Tracked so we can toggle .active on the
2782
+ // current choice.
2783
+ // • Without expression (`<button x-colorpicker.set-color-space>`) →
2784
+ // reactive label whose text reflects the active format. Useful as
2785
+ // a dropdown trigger that shows the current format.
2786
+ // • <select x-colorpicker.set-color-space> still works through the
2787
+ // legacy _wireSolidControls flow inside a solid-panel instance.
2788
+ case 'set-color-space': {
2789
+ if (el.tagName === 'SELECT') return; // legacy flow handles it
2790
+ const raw = (expression || '').replace(/['"`]/g, '').trim().toLowerCase();
2791
+ if (raw) {
2792
+ // Choice element — click selects this format
2793
+ state.formatChoiceEls.push({ el, fmt: raw });
2794
+ el.addEventListener('click', () => {
2795
+ if (el.hasAttribute('disabled')) return;
2796
+ state.setColorSpace(raw);
2797
+ });
2798
+ } else {
2799
+ // Label element — text reflects current format
2800
+ state.formatLabelEls.push(el);
2801
+ }
2802
+ state._refreshFormatLabels();
2803
+ return;
2804
+ }
2805
+
2806
+ // ---- Library ----
2807
+
2808
+ case 'library': {
2809
+ if (el.tagName === 'TEMPLATE') {
2810
+ // Dev-defined library layout — cloned into the container at render time.
2811
+ // Nested <template x-colorpicker.library-group/palette/swatch> are resolved
2812
+ // in-place during rendering (x-for style).
2813
+ state.libraryTemplate = el;
2814
+ } else {
2815
+ // Container where the library renders. Multiple containers are supported
2816
+ // (e.g., the primary tab AND inline menus like stop-context-menu); each
2817
+ // receives an independent clone of the library template. The expression
2818
+ // from the FIRST container wins as the data source; others reuse it.
2819
+ if (!state.libraryContainers.includes(el)) {
2820
+ state.libraryContainers.push(el);
2821
+ // If the picker is already mounted, render into ONLY this new
2822
+ // container so other containers' in-flight directive inits
2823
+ // (x-dropdown.context menu lookups, tooltips, etc.) aren't
2824
+ // torn down mid-init.
2825
+ if (state._mounted) state._renderIntoContainer(el);
2826
+ }
2827
+ if (expression && !state.libraryRootValue) state.libraryRootValue = expression;
2828
+ }
2829
+ return;
2830
+ }
2831
+
2832
+ case 'library-group':
2833
+ case 'library-palette':
2834
+ case 'library-swatch':
2835
+ case 'library-recent-swatch': {
2836
+ // Nested templates — no registration. Resolved via querySelector at render time
2837
+ // (so their position in the HTML determines where clones land).
2838
+ // `library-recent-swatch` is an optional alternate template used only for
2839
+ // swatches inside the Recent group (typically wires up x-dropdown.context).
2840
+ return;
2841
+ }
2842
+
2843
+ }
2844
+ });
2845
+
2846
+ // $colorpicker — accessor that is both callable (`$colorpicker('id')`) and property-readable
2847
+ // (`$colorpicker.hex` — uses nearest ancestor picker).
2848
+ Alpine.magic('colorpicker', (el) => {
2849
+ const localState = findAncestorState(el);
2850
+ const localApi = localState?.api || null;
2851
+
2852
+ // Function form: `$colorpicker('picker-id')` → that picker's API.
2853
+ // Reads from the reactive registry so bindings resolve even when the
2854
+ // picker is declared later in the DOM than its consumer.
2855
+ const byId = (id) => {
2856
+ if (!id) return localApi;
2857
+ // Reactive read: tracks the key even if not yet registered
2858
+ const api = _pickerRegistry[id];
2859
+ if (api) return api;
2860
+ // Allow lookup by swatch button ID (resolve through its popovertarget)
2861
+ const el2 = document.getElementById(id);
2862
+ if (el2 && el2.hasAttribute('popovertarget')) {
2863
+ const popoverId = el2.getAttribute('popovertarget');
2864
+ const api2 = _pickerRegistry[popoverId];
2865
+ if (api2) return api2;
2866
+ }
2867
+ return _nullApi;
2868
+ };
2869
+
2870
+ return new Proxy(byId, {
2871
+ get(fn, prop) {
2872
+ // Coerce `${$colorpicker}` (no call) to the local picker's CSS string
2873
+ if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') {
2874
+ return () => (localApi ? localApi.css : '');
2875
+ }
2876
+ // Global helpers (picker-agnostic) — useful for library composition.
2877
+ // Each is BOTH callable AND spreadable:
2878
+ // {...$colorpicker.tailwind} → default English preset
2879
+ // $colorpicker.tailwind(labels) → localized preset (same values, translated names)
2880
+ // {...$colorpicker.tailwind(labels)} → spread the localized result
2881
+ if (prop === 'presets') return _makeCallablePreset(buildDefaultLibrary);
2882
+ if (prop === 'tailwind') return _makeCallablePreset(buildTailwindPreset);
2883
+ if (prop === 'ios') return _makeCallablePreset(buildIosPreset);
2884
+ if (prop === 'recent') return _recentStore.list.slice(0, _recentMax);
2885
+ if (localApi && prop in localApi) return localApi[prop];
2886
+ return fn[prop];
2887
+ },
2888
+ has(fn, prop) {
2889
+ if (prop === 'presets' || prop === 'tailwind' || prop === 'ios' || prop === 'recent') return true;
2890
+ return (localApi && prop in localApi) || prop in fn;
2891
+ }
2892
+ });
2893
+ });
2894
+ }
2895
+
2896
+ // ---- Picker resolution: inline / default-template / auto-generated ----
2897
+ //
2898
+ // Two ways a dev can declare a picker:
2899
+ // 1. Live inline element: <menu id="brand-picker" popover x-colorpicker>…</menu>
2900
+ // <dialog id="brand-picker" x-colorpicker>…</dialog>
2901
+ // <div id="brand-picker" x-colorpicker>…</div>
2902
+ // 2. Auto-created (bare): <button x-colorpicker.swatch> generates its own popover.
2903
+ // By default, the popover is filled with the plugin's
2904
+ // hardcoded fallback UI. Devs can override it page-wide
2905
+ // by adding a single `<template x-colorpicker>` (no id)
2906
+ // anywhere in the markup — the auto-creator clones from
2907
+ // that instead.
2908
+ //
2909
+ // For "componentize and reuse" use cases that previously needed an id-keyed template,
2910
+ // wrap the picker in a Manifest HTML component (`<x-my-picker>`) and drop it wherever
2911
+ // it's needed. This keeps the plugin's resolution model deliberately small.
2912
+
2913
+ let _defaultColorpickerTemplate = null; // <template x-colorpicker> (no id)
2914
+
2915
+ function registerDefaultColorpickerTemplate(tpl) {
2916
+ // First wins. Subsequent declarations are ignored — explicit, no surprises.
2917
+ if (!tpl || _defaultColorpickerTemplate) return;
2918
+ _defaultColorpickerTemplate = tpl;
2919
+ }
2920
+
2921
+ // Resolve a swatch's target by id. Inline elements only — templates are never
2922
+ // looked up by id anymore (use a `<menu id="X" x-colorpicker>` or wrap in an
2923
+ // HTML component for that pattern).
2924
+ function resolvePickerById(id) {
2925
+ if (!id) return null;
2926
+ const live = document.getElementById(id);
2927
+ if (live && live.tagName !== 'TEMPLATE') return live;
2928
+ return null;
2929
+ }
2930
+
2931
+ // ---- Auto-created fallback popover per swatch ----
2932
+
2933
+ let _swatchPopoverCounter = 0;
2934
+ function nextAutoSwatchId() {
2935
+ _swatchPopoverCounter++;
2936
+ while (document.getElementById('colorpicker-swatch-' + _swatchPopoverCounter)) _swatchPopoverCounter++;
2937
+ return 'colorpicker-swatch-' + _swatchPopoverCounter;
2938
+ }
2939
+
2940
+ function createSwatchPopover(customId, panelsExpr) {
2941
+ const id = customId || nextAutoSwatchId();
2942
+
2943
+ // If a default `<template x-colorpicker>` is registered, clone its root
2944
+ // and use that as the swatch's popover. Preserves the dev's chosen
2945
+ // wrapper (menu / dialog / div) and any attributes they put on it.
2946
+ // The dev's content inside the template is rendered verbatim (mount's
2947
+ // noDeclared check sees real children and skips _injectDefaultUI).
2948
+ // If the template exists in the DOM but its directive hasn't fired yet
2949
+ // (source-order race), scan for it now so swatches earlier in the tree
2950
+ // still pick it up.
2951
+ if (!_defaultColorpickerTemplate) {
2952
+ const candidates = document.querySelectorAll('template[x-colorpicker]');
2953
+ for (const t of candidates) {
2954
+ if (!t.id && !t.getAttribute('x-colorpicker')) {
2955
+ registerDefaultColorpickerTemplate(t);
2956
+ break;
2957
+ }
2958
+ }
2959
+ }
2960
+ let root = null;
2961
+ if (_defaultColorpickerTemplate) {
2962
+ const frag = _defaultColorpickerTemplate.content.cloneNode(true);
2963
+ root = frag.firstElementChild;
2964
+ }
2965
+ if (root) {
2966
+ if (!root.hasAttribute('x-colorpicker')) root.setAttribute('x-colorpicker', panelsExpr || '');
2967
+ else if (panelsExpr) root.setAttribute('x-colorpicker', panelsExpr);
2968
+ if (root.tagName !== 'DIALOG' && !root.hasAttribute('popover')) root.setAttribute('popover', '');
2969
+ root.id = id;
2970
+ document.body.appendChild(root);
2971
+ if (window.Alpine?.initTree) Alpine.initTree(root);
2972
+ return root;
2973
+ }
2974
+
2975
+ // No default template → empty <menu> populated by _injectDefaultUI on mount.
2976
+ const menu = document.createElement('menu');
2977
+ menu.setAttribute('popover', '');
2978
+ // Pass the panel-list expression through to the root x-colorpicker directive
2979
+ // so the auto-created popover only shows the panels the swatch requested.
2980
+ menu.setAttribute('x-colorpicker', panelsExpr || '');
2981
+ menu.id = id;
2982
+ menu.className = 'colorpicker dropdown-menu';
2983
+ document.body.appendChild(menu);
2984
+ if (window.Alpine?.initTree) Alpine.initTree(menu);
2985
+ return menu;
2986
+ }
2987
+
2988
+ // Parse a directive expression as a panel list. Accepts JS array literals like
2989
+ // "['solid', 'gradient']" or "[\"library\"]". Returns a normalized array of
2990
+ // recognized panel names, or null if the expression isn't a panel list.
2991
+ const _validPanels = ['solid', 'gradient', 'library'];
2992
+ function parsePanelsExpression(expr) {
2993
+ if (!expr || typeof expr !== 'string') return null;
2994
+ const trimmed = expr.trim();
2995
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null;
2996
+ try {
2997
+ // JSON.parse after normalizing single quotes — the expressions we accept
2998
+ // are simple string-array literals, so a quote swap is safe.
2999
+ const arr = JSON.parse(trimmed.replace(/'/g, '"'));
3000
+ if (!Array.isArray(arr)) return null;
3001
+ const out = arr
3002
+ .map(s => typeof s === 'string' ? s.trim().toLowerCase() : null)
3003
+ .filter(s => s && _validPanels.includes(s));
3004
+ return out.length ? out : null;
3005
+ } catch {
3006
+ return null;
3007
+ }
3008
+ }
3009
+
3010
+ registerPlugin();
3011
+ }
3012
+
3013
+ // Track initialization
3014
+ let colorpickerPluginInitialized = false;
3015
+ function ensureColorpickerPluginInitialized() {
3016
+ if (colorpickerPluginInitialized) return;
3017
+ if (!window.Alpine || typeof window.Alpine.directive !== 'function') return;
3018
+ colorpickerPluginInitialized = true;
3019
+ initializeColorpickerPlugin();
3020
+ // Process any x-colorpicker elements already in the DOM
3021
+ if (window.Alpine && typeof window.Alpine.initTree === 'function') {
3022
+ document.querySelectorAll('[x-colorpicker]').forEach(el => { if (!el.__x) window.Alpine.initTree(el); });
3023
+ }
3024
+ }
3025
+ window.ensureColorpickerPluginInitialized = ensureColorpickerPluginInitialized;
3026
+
3027
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ensureColorpickerPluginInitialized);
3028
+ document.addEventListener('alpine:init', ensureColorpickerPluginInitialized);
3029
+ if (window.Alpine && typeof window.Alpine.directive === 'function') setTimeout(ensureColorpickerPluginInitialized, 0);
3030
+ else {
3031
+ const check = setInterval(() => { if (window.Alpine && typeof window.Alpine.directive === 'function') { clearInterval(check); ensureColorpickerPluginInitialized(); } }, 10);
3032
+ setTimeout(() => clearInterval(check), 5000);
3033
+ }