PageSourceSearch

https://colir.space/app/

html colir.space collected 2026-09-30 22:45:31 UTC 654,097 bytes, 14,426 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3
4<head>
5<link rel="stylesheet" href="../assets/icons/icons.css">
6<script src="../assets/icons/icons.js"></script>
6
7  <meta charset="UTF-8" />
8  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9  <style>
10    /* Startup preloader. Cosmetic only: it never touches app state and is removed
11       from the DOM once startup finishes. The breathing loop has to survive the
12       main thread being blocked by the app's inline scripts, so it animates
13       transform only and stays on the compositor. The reveal runs afterwards,
14       when the main thread is free, and can afford a mask animation. */
15    .colir-pre {
16      --colir-pre-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Cpath fill='%23000' d='M115.81 55.81H95.6726C78.2828 55.81 64.19 41.7172 64.19 24.3274V4.19004C64.19 1.96909 62.4614 0.171723 60.2862 0.0228964V0C60.1946 0 60.0916 0 60 0C27.0864 0 0 27.0864 0 60C0 92.9136 27.0864 120 60 120C62.3125 120 64.19 118.123 64.19 115.81V95.6726C64.19 78.2828 78.2828 64.19 95.6726 64.19H115.81C118.123 64.19 120 62.3125 120 60C120 57.6875 118.123 55.81 115.81 55.81Z'/%3E%3C/svg%3E");
17      position: fixed;
18      inset: 0;
19      z-index: 2147483000;
20      pointer-events: none;
21      transition: opacity 300ms ease;
22    }
23
24    .colir-pre__sheet,
25    .colir-pre__veil {
26      position: fixed;
27      inset: 0;
28    }
29
30    /* Sits behind the veil. Only ever seen through the logo-shaped cut-out,
31       which is what makes the swap from drawn logo to cut-out seamless. */
32    .colir-pre__sheet {
33      background: #f5f5f5;
34    }
35
36    .colir-pre__veil {
37      background: #0d0d0d;
38    }
39
40    .colir-pre__logo {
41      position: fixed;
42      top: 50%;
43      left: 50%;
44      width: 72px;
45      height: 72px;
46      margin: -36px 0 0 -36px;
47      fill: #f5f5f5;
48      transform-origin: 50% 50%;
49      will-change: transform;
50      animation: colirPreBreathe 1300ms cubic-bezier(0.45, 0, 0.55, 1) infinite alternate;
51    }
52
53    @keyframes colirPreBreathe {
54      from { transform: scale(0.93); }
55      to   { transform: scale(1.07); }
56    }
57
58    /* Reveal: the veil becomes an off-black sheet with a logo-shaped hole punched
59       through it, and the hole scales up until the app is uncovered. */
60    .colir-pre.is-revealing .colir-pre__logo {
61      display: none;
62    }
63
64    .colir-pre.is-revealing .colir-pre__veil {
65      -webkit-mask-image: linear-gradient(#000, #000), var(--colir-pre-logo);
66              mask-image: linear-gradient(#000, #000), var(--colir-pre-logo);
67      -webkit-mask-repeat: no-repeat, no-repeat;
68              mask-repeat: no-repeat, no-repeat;
69      -webkit-mask-position: center, center;
70              mask-position: center, center;
71      -webkit-mask-composite: xor;
72              mask-composite: exclude;
73      animation: colirPreOpen 580ms both;
74    }
75
76    .colir-pre.is-revealing .colir-pre__sheet {
77      animation: colirPreFadeOut 220ms 110ms both;
78    }
79
80    @keyframes colirPreOpen {
81      0% {
82        -webkit-mask-size: 100% 100%, 72px 72px;
83                mask-size: 100% 100%, 72px 72px;
84        animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1);
85      }
86      /* Anticipation dip before the expansion, so the growth reads as snappy. */
87      18% {
88        -webkit-mask-size: 100% 100%, 56px 56px;
89                mask-size: 100% 100%, 56px 56px;
90        animation-timing-function: cubic-bezier(0.5, 0, 0.85, 0.5);
91      }
92      /* The logo's concave notches mean the shape only clears the viewport once
93         it is far larger than the screen. The inscribed radius is 18.75 of the
94         120-unit box, so full coverage needs roughly 4.6x the longest viewport
95         edge; 500vmax keeps a margin at any aspect ratio. Accelerating into that
96         size means the screen clears right as the animation ends, with no need to
97         fade the veil and let the app show through early. */
98      100% {
99        -webkit-mask-size: 100% 100%, 500vmax 500vmax;
100                mask-size: 100% 100%, 500vmax 500vmax;
101      }
102    }
103
104    @keyframes colirPreFadeOut {
105      to { opacity: 0; }
106    }
107
108    .colir-pre.is-fading {
109      opacity: 0;
110    }
111
112    @media (prefers-reduced-motion: reduce) {
113      .colir-pre__logo { animation: none; }
114    }
115
116    /* Template and share links reload the app only to apply a gradient, so the
117       intro is skipped for them entirely. */
118    .colir-pre-skip .colir-pre { display: none; }
119  #upgradeModal .pricing-grid {
120    display: flex;
121    flex-wrap: nowrap;
122    align-items: stretch;
123    gap: 12px;
124    width: 100%;
125    margin-bottom: 8px;
126    text-align: left;
127  }
128  #upgradeModal .pricing-card-wrap {
129    flex: 1 1 0;
130    min-width: 0;
131    display: flex;
132  }
133  #upgradeModal .pricing-card-wrap:has(.pricing-card.featured) {
134    padding: 2px;
135    background: linear-gradient(to bottom, rgba(25, 210, 96, 0.26), rgba(13, 108, 49, 0.26));
136    border-radius: 18px;
137  }
138  #upgradeModal .pricing-card-wrap--lifetime {
139    padding: 2px;
140    background: linear-gradient(225deg, rgba(209, 216, 167, 0.26), rgba(93, 93, 93, 0.26));
141    border-radius: 18px;
142  }
143  #upgradeModal .pricing-card {
144    background: #161616;
145    border: 1px solid #2a2a2a;
146    border-radius: 16px;
147    padding: 22px 20px;
148    text-align: left;
149    width: 100%;
150    flex: 1 1 auto;
151    box-sizing: border-box;
152    display: flex;
153    flex-direction: column;
154  }
155  #upgradeModal .pricing-card.featured {
156    background: linear-gradient(to bottom left, #0D2207 0%, #0F1200 70%);
157    border: none;
158    border-radius: 16px;
159  }
160  #upgradeModal .pricing-card-wrap--lifetime .pricing-card {
161    background: linear-gradient(225deg, #3E4030 0%, #13140F 55%);
162    border: none;
163    border-radius: 16px;
164  }
165  #upgradeModal .pricing-card-title {
166    display: flex;
167    align-items: center;
168    justify-content: space-between;
169    gap: 8px;
170    margin-bottom: 0;
171  }
172  #upgradeModal .pricing-card-title-text {
173    font-size: 15px;
174    font-weight: 600;
175    color: #19D260;
176  }
177  #upgradeModal .pricing-card-title-text.is-grey { color: #C7CABA; }
178  #upgradeModal .pricing-card-title-text.is-muted { color: var(--muted); }
179  #upgradeModal .pricing-tag {
180    border: 1px solid #19D260;
181    border-radius: 6px;
182    padding: 3px 10px;
183    font-size: 10px;
184    font-weight: 600;
185    letter-spacing: 0.04em;
186    color: #19D260;
187    flex: 0 0 auto;
188  }
189  #upgradeModal .pricing-tag--grey { border-color: #C7CABA;
189 color: #C7CABA; }
190  #upgradeModal .pricing-card .price {
191    font-size: 36px;
192    font-weight: 700;
193    margin: 8px 0 2px;
194    line-height: 1.1;
195    letter-spacing: -0.03em;
196  }
197  #upgradeModal .pricing-card .price--green { color: #19D260; }
198  #upgradeModal .pricing-card .price--grey { color: #C7CABA; }
199  #upgradeModal .pricing-card .price-unit {
200    font-size: 15px;
201    font-weight: 500;
202    opacity: 0.7;
203  }
204  #upgradeModal .pricing-card .price-sub {
205    font-size: 12px;
206    color: var(--muted);
207    opacity: 0.7;
208    margin-bottom: 14px;
209  }
210  #upgradeModal .pricing-card ul {
211    list-style: none;
212    margin: 0 0 16px;
213    padding: 0;
214    flex-grow: 1;
215  }
216  #upgradeModal .pricing-card li {
217    padding: 5px 0;
218    color: var(--muted);
219    font-size: 12px;
220    display: flex;
221    align-items: center;
222    gap: 8px;
223    line-height: 1.45;
224  }
225  #upgradeModal .pricing-card li .material-icons {
226    font-size: 16px;
227    color: #19D260;
228    width: 16px;
229    flex: 0 0 16px;
230  }
231  #upgradeModal .pricing-card li .material-icons.icon-grey { color: #C7CABA; }
232  #upgradeModal .pricing-card .owned-copy {
233    color: var(--muted);
234    font-size: 13px;
235    line-height: 1.5;
236    margin: 12px 0 16px;
237    flex-grow: 1;
238  }
239  #upgradeModal .pricing-card .cta {
240    width: 100%;
241    margin-top: auto;
242    padding: 12px 16px;
243    border: none;
244    border-radius: 10px;
245    font-size: 13px;
246    font-weight: 600;
247    letter-spacing: 0.01em;
248    cursor: pointer;
249    display: inline-flex;
250    align-items: center;
251    justify-content: center;
252    white-space: nowrap;
253  }
254  #upgradeModal .pricing-card .cta--green { background: #19D260; color: #111; }
255  #upgradeModal .pricing-card .cta--green:hover { background: #22e86e; }
256  #upgradeModal .pricing-card .cta--grey { background: #C7CABA; color: #111; }
257  #upgradeModal .pricing-card .cta--grey:hover { background: #d4d7c8; }
258  #upgradeModal .pricing-card .cta--owned {
259    background: var(--primary);
260    color: var(--primary-text);
261  }
262  #upgradeModal .pricing-card .cta--owned:hover { background: var(--primary-hover); }
263  #upgradeModal .upgrade-panel .actions { margin-top: 4px; justify-content: flex-end; }
264#upgradeModal .pricing-grid {
265      display: flex;
266      flex-wrap: wrap;
267      justify-content: center;
268      align-items: stretch;
269      gap: 24px;
270      width: 100%;
271    }#upgradeModal .pricing-card-wrap {
272      flex: 1 1 300px;
273      min-width: 260px;
274      max-width: none;
275      box-sizing: border-box;
276      display: flex;
277      padding: 1px;
278      border-radius: calc(18px + 1px);
279      background: transparent;
280    }#upgradeModal .pricing-card-wrap:has(.cp-free) {
281      background: rgba(255,255,255,.02);
282    }#upgradeModal .pricing-card-wrap:has(.cp-pro) {
283      background: rgba(120,255,180,.05);
284    }#upgradeModal .pricing-card-wrap:has(.cp-perp) {
285      background: rgba(255,244,214,.03);
286    }#upgradeModal .cp-card {
287      --cp-r: 18px;
288      position: relative;
289      flex: 1;
290      display: flex;
291      border-radius: var(--cp-r);
292      padding: 34px;
293      overflow: hidden;
294      transition: border-color .6s;
295      box-sizing: border-box;
296      width: 100%;
297    }#upgradeModal .cp-card > .cp-body {
298      position: relative;
299      display: flex;
300      flex-direction: column;
301      gap: 24px;
302      flex: 1;
303      min-width: 0;
304      z-index: 1;
305    }#upgradeModal .cp-layer {
306      position: absolute;
307      inset: 0;
308      pointer-events: none;
309      border-radius: calc(var(--cp-r) - 1px);
310    }#upgradeModal .cp-warm {
311      background: radial-gradient(rgba(255,255,255,.035) .5px, transparent .5px);
312      background-size: 3px 3px;
313    }#upgradeModal .cp-grain {
314      background-image: radial-gradient(rgba(255,255,255,.055) .6px, transparent .6px);
315      background-size: 3px 3px;
316    }#upgradeModal .cp-head {
317      display: flex;
318      flex-wrap: wrap;
319      justify-content: space-between;
320      align-items: flex-start;
321      gap: 12px;
322    }#upgradeModal .cp-headtext {
323      flex: 1 1 150px;
324      min-width: 0;
325      display: flex;
326      flex-direction: column;
327      gap: 6px;
328    }#upgradeModal .cp-plan {
329      font-size: 17px;
330      font-weight: 600;
331      letter-spacing: -.01em;
332    }#upgradeModal .cp-price-row {
333      display: flex;
334      align-items: flex-end;
335      gap: 4px;
336    }#upgradeModal .cp-price {
337      font-size: 48px;
338      font-weight: 800;
339      line-height: 1;
340      letter-spacing: -.03em;
341      -webkit-background-clip: text;
342      background-clip: text;
343      color: transparent;
344    }#upgradeModal .cp-note {
345      font-size: 12.5px;
346    }#upgradeModal .cp-badge {
347      flex: none;
348      white-space: nowrap;
349      display: inline-flex;
350      align-items: center;
351      height: 41px;
352      box-sizing: border-box;
353      padding: 0 18px;
354      font-size: 12.5px;
355      font-weight: 700;
356      letter-spacing: .08em;
357      text-transform: uppercase;
358      border-radius: 999px;
359    }#upgradeModal .cp-feats {
360      display: flex;
361      flex-direction: column;
362      gap: 14px;
363    }#upgradeModal .cp-feat {
364      display: flex;
365      align-items: center;
366      gap: 12px;
367      font-size: 14.5px;
368      line-height: 1.4;
369    }#upgradeModal .cp-feat .material-icons {
370      font-size: 16px;
371      width: 16px;
372      height: 16px;
373      line-height: 16px;
374      flex: none;
375    }#upgradeModal .cp-figma-icon {
376      width: 16px;
377      height: 16px;
378      flex: none;
379      display: block;
380      overflow: visible;
381    }#upgradeModal .cp-cta {
382      width: 100%;
383      padding: 16px;
384      border-radius: 10px;
385      border: none;
386      font-family: inherit;
387      font-size: 15px;
388      font-weight: 700;
389      cursor: pointer;
390      text-decoration: none;
391      display: block;
392      text-align: center;
393      box-sizing: border-box;
394      transition: background .25s, box-shadow .25s;
395    }#upgradeModal .cp-foot {
396      margin-top: auto;
397      padding-top: 10px;
398    }#upgradeModal .cp-free {
399      background: linear-gradient(200deg, #151412 0%, #0f0f0e 50%, #0c0c0b 100%);
400      border: 1px solid #2a2a28;
401      box-shadow: 0 1px 0 rgba(0,0,0,.7), 0 26px 64px -34px #000;
402    }#upgradeModal .cp-free:hover {
403      border-color: #35342f;
404    }#upgradeModal .cp-free .cp-edge {
405      box-shadow: inset 0 1px 0 rgba(255,255,255,.18), inset 0 0 0 1px rgba(255,255,255,.045), inset 0 -40px 60px -50px rgba(255,255,255,.18);
406    }#upgradeModal .cp-free .cp-body {
407      gap: 26px;
408    }#upgradeModal .cp-wordmark {
409      flex: 1 1 150px;
410      min-width: 0;
411      font-size: 52px;
412      font-weight: 800;
413      letter-spacing: -.03em;
414      line-height: 1;
415      background: linear-gradient(160deg, #8d8d86, #484843);
416      -webkit-background-clip: text;
417      background-clip: text;
418      color: transparent;
419    }#upgradeModal .cp-free .cp-feat {
420      color: #a3a39d;
421    }#upgradeModal .cp-free .cp-feat .material-icons {
422      color: #8a8a82;
423    }#upgradeModal .cp-free .cp-cta {
424      padding: 15px;
425      font-weight: 600;
426      background: rgba(255,255,255,.035);
427      color: #f2f2f0;
428      box-shadow: inset 0 0 0 1px rgba(255,255,255,.11), inset 0 1px 0 rgba(255,255,255,.16);
429    }#upgradeModal .cp-free .cp-cta:hover {
430      background: rgba(255,255,255,.06);
431      color: #f2f2f0;
432    }#upgradeModal .cp-pro {
433      background: linear-gradient(200deg, #15301a 0%, #0a1a0e 50%, #050c07 100%);
434      border: 1px solid #26804c;
435      box-shadow: 0 1px 0 rgba(0,0,0,.7), 0 32px 74px -34px rgba(20,180,100,.45);
436    }#upgradeModal .cp-pro:hover {
437      border-color: #2c8a4f;
438    }#upgradeModal .cp-pro .cp-pattern {
439      position: absolute;
440      inset: -40% -10% auto -55%;
441      height: 190%;
442      pointer-events: none;
443      background: repeating-radial-gradient(circle at 0% 30%, transparent 0 26px, rgba(52,209,127,.16) 26px 27px);
444      -webkit-mask-image: linear-gradient(120deg, #000 0%, transparent 72%);
445      mask-image: linear-gradient(120deg, #000 0%, transparent 72%);
446    }#upgradeModal .cp-pro .cp-edge {
447      box-shadow: inset 0 1px 0 rgba(190,255,220,.3), inset 0 0 0 1px rgba(120,255,180,.07), inset 0 -40px 60px -50px rgba(190,255,220,.3);
448    }#upgradeModal .cp-pro .cp-plan {
449      color: #34d17f;
450    }#upgradeModal .cp-pro .cp-price {
451      background-image: linear-gradient(150deg, #f4fff8 0%, #8fe4b4 100%);
452    }#upgradeModal .cp-pro .cp-note {
453      color: #7c9c88;
454    }#upgradeModal .cp-per {
455      font-size: 15px;
456      font-weight: 500;
457      color: #34d17f;
458      padding-bottom: 5px;
459    }#upgradeModal .cp-pro .cp-feat {
460      color: #d3ebdd;
461    }#upgradeModal .cp-pro .cp-feat .material-icons {
462      color: #34d17f;
463    }#upgradeModal .cp-pro .cp-cta {
464      background: #3ed070;
465      color: #04160c;
466      box-shadow: 0 0 0 1px rgba(52,209,127,.5), 0 12px 34px -14px rgba(52,209,127,.7), inset 0 1px 0 rgba(255,255,255,.45);
467    }#upgradeModal .cp-pro .cp-cta:hover {
468      background: #55e083;
469      color: #04160c;
470      box-shadow: 0 0 0 1px rgba(52,209,127,.6), 0 14px 38px -12px rgba(52,209,127,.75);
471    }#upgradeModal .cp-toggle {
472      flex: none;
473      position: relative;
474      display: flex;
475      padding: 4px;
476      border-radius: 999px;
477      background: rgba(0,0,0,.45);
478      box-shadow: inset 0 0 0 1px rgba(52,209,127,.35), inset 0 1px 0 rgba(190,255,220,.18);
479    }#upgradeModal .cp-thumb {
480      position: absolute;
481      top: 4px;
482      bottom: 4px;
483      width: calc(50% - 4px);
484      left: 4px;
485      border-radius: 999px;
486      background: rgba(52,209,127,.26);
487      box-shadow: inset 0 0 0 1px rgba(52,209,127,.6);
488      transition: left .28s cubic-bezier(.4,0,.2,1);
489    }#upgradeModal .cp-toggle[data-billing="annual"] .cp-thumb {
490      left: 50%;
491    }#upgradeModal .cp-toggle button {
492      position: relative;
493      flex: none;
494      width: 92px;
495      text-align: center;
496      padding: 9px 0;
497      border: none;
498      background: none;
499      cursor: pointer;
500      font-family: inherit;
501      font-size: 12.5px;
502      font-weight: 700;
503      letter-spacing: .08em;
504      text-transform: uppercase;
505      color: #8caa99;
506      transition: color .25s;
507      -webkit-appearance: none;
508      appearance: none;
509    }#upgradeModal .cp-toggle button[aria-pressed="true"] {
510      color: #5ee89a;
511    }#upgradeModal .cp-perp {
512      background: linear-gradient(200deg, #1d1a12 0%, #12100b 50%, #0a0907 100%);
513      border: 1px solid #3b3529;
514      box-shadow: 0 1px 0 rgba(0,0,0,.7), 0 26px 64px -34px #000;
515    }#upgradeModal .cp-perp:hover {
516      border-color: #45402c;
517    }#upgradeModal .cp-perp .cp-pattern {
518      position: absolute;
519      inset: -40% -10% auto -55%;
520      height: 190%;
521      pointer-events: none;
522      background-image: radial-gradient(circle at 50% 100%, transparent 0 15px, rgba(255,244,214,.07) 15px 16px, transparent 16px), radial-gradient(circle at 50% 0%, transparent 0 15px, rgba(255,244,214,.07) 15px 16px, transparent 16px);
523      background-size: 64px 32px;
524      background-position: 0 0, 32px 0;
525      -webkit-mask-image: linear-gradient(200deg, #000 0%, transparent 80%);
526      mask-image: linear-gradient(200deg, #000 0%, transparent 80%);
527    }#upgradeModal .cp-perp .cp-grain {
528      background-image: radial-gradient(rgba(255,255,255,.05) .6px, transparent .6px);
529    }#upgradeModal .cp-perp .cp-edge {
530      box-shadow: inset 0 1px 0 rgba(255,248,225,.24), inset 0 0 0 1px rgba(255,244,214,.05), inset 0 -40px 60px -50px rgba(255,248,225,.24);
531    }#upgradeModal .cp-perp .cp-plan {
532      color: #e6dfcd;
533    }#upgradeModal .cp-perp .cp-price {
534      background-image: linear-gradient(150deg, #fff9e6 0%, #d9c07a 42%, #8a6f2e 100%);
535    }#upgradeModal .cp-perp .cp-note {
536      color: #918a78;
537    }#upgradeModal .cp-perp .cp-badge {
538      color: #e2dac4;
539      background: rgba(255,244,214,.03);
540      box-shadow: inset 0 0 0 1px rgba(216,207,184,.28), inset 0 1px 0 rgba(255,248,225,.3);
541    }#upgradeModal .cp-perp .cp-feat {
542      color: #cfc8b6;
543    }#upgradeModal .cp-perp .cp-feat .material-icons {
544      color: #8d8570;
545    }#upgradeModal .cp-perp .cp-cta {
546      background: #ded6c0;
547      color: #17150f;
548      box-shadow: inset 0 1px 0 rgba(255,255,255,.6), 0 12px 34px -20px rgba(230,223,205,.5);
549    }#upgradeModal .cp-perp .cp-cta:hover {
550      background: #efe8d4;
551      color: #17150f;
552    }
553
554
555#upgradeModal .upgrade-panel { width: min(1120px, 96vw); max-width:1120px; max-height:92vh; overflow:auto; }
556#upgradeModal .pricing-grid { gap:14px; font-family:'Google Sans Flex', 'Google Sans', sans-serif; -webkit-font-smoothing:antialiased; }
557#upgradeModal .pricing-card-wrap { min-width:240px; }
558#upgradeModal .cp-cta { cursor:pointer; border:0; width:100%; font-family:inherit; text-align:center; }
559#upgradeModal .cp-cta:disabled { opacity:.5; cursor:not-allowed; }
560#upgradeModal .cp-card { min-height:360px; }
561#upgradeModal .cp-feat { font-size:13px; }
562#upgradeModal .cp-toggle button:focus-visible, #upgradeModal .cp-cta:focus-visible { outline:2px solid white; outline-offset:3px; }
563
564
565#upgradeModal .help-modal-content { width: min(1180px, 96vw); max-width:1180px; }
566#upgradeModal .cp-card { padding:24px; }
567.export-approval-dialog { max-width:440px; background:#181818; color:#eee; border:1px solid #555; border-radius:14px; padding:24px; }
568.export-approval-dialog::backdrop { background:rgba(0,0,0,.6); }
569.export-approval-dialog button { padding:10px 16px; margin-top:12px; }
570</style>
571  
571<script>
572    // Figma embed bootstrap. Does nothing at all without ?embed=figma, so a normal
573    // visitor pays one regex test and no network request. Everything else lives in
574    // figma-bridge.js, which is only fetched in embed mode.
575    //
576    // This has to run before the first localStorage read (the consent check further
577    // down) because storage is partitioned inside a plugin frame and can be blocked
578    // outright, in which case the app's unguarded localStorage calls would throw.
579    (function () {
580      try {
581        if (!/[?&]embed=figma(&|$)/.test(location.search)) return;
582
583        var root = document.documentElement;
584        root.classList.add('colir-figma', 'colir-pre-skip');
585
586        // Swap in an in-memory Storage only if the real one is unusable.
587        ['localStorage', 'sessionStorage'].forEach(function (name) {
588          try {
589            var probe = window[name];
590            probe.setItem('__colir_probe', '1');
591            probe.removeItem('__colir_probe');
592            return;
593          } catch (e) { /* unusable, fall through */ }
594
595          var map = {};
596          var shim = {
597            getItem: function (k) { return Object.prototype.hasOwnProperty.call(map, k) ? map[k] : null; },
598            setItem: function (k, v) { map[k] = String(v); },
599            removeItem: function (k) { delete map[k]; },
600            clear: function () { map = {}; },
601            key: function (i) { return Object.keys(map)[i] || null; }
602          };
603          Object.defineProperty(shim, 'length', { get: function () { return Object.keys(map).length; } });
604          try {
605            Object.defineProperty(window, name, { value: shim, configurable: true, writable: true });
606          } catch (e) { /* nothing more we can do */ }
607        });
608
609        // Inline so the controls never flash hidden behind the 768px breakpoint.
610        var style = document.createElement('style');
611        style.textContent =
612          'html.colir-figma .sidebar,html.colir-figma .output-area{display:flex !important}' +
613          'html.colir-figma .mobile-message,html.colir-figma .floating-history{display:none !important}' +
614          'html.colir-figma #exportScale,html.colir-figma #figmaPluginBtn{display:none !important;visibility:hidden !important}';
615        document.head.appendChild(style);
616
617        var s = document.createElement('script');
618        s.src = 'figma-bridge.js?v=beta-effects-20260916';
619        s.defer = true;
620        document.head.appendChild(s);
621      } catch (e) { /* never let embed setup affect a normal load */ }
622    })();
623
624    // A gradient hash means this load came from a template or share link. This
625    // runs before the overlay markup is parsed, so it can never paint.
626    if (window.location.hash) document.documentElement.classList.add('colir-pre-skip');
627  </script>
627
628  <title>Colir</title>
629  <meta name="robots" content="noindex, nofollow" />
630  <link rel="canonical" href="https://colir.space/app/" />
631  
631<script type="text/javascript">
632    (function(c,l,a,r,i,t,y){
633      c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
634      t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
635      y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
636    })(window, document, "clarity", "script", "wi754sqgdz");
637  </script>
637
638  <link rel="preconnect" href="https://fonts.googleapis.com">
639  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
640  <link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
641  <link href="https://fonts.googleapis.com/css2?family=Google+Sans+Flex:wght@400;500;600;700;800&display=swap" rel="stylesheet">
642  
642<script src="https://cdn.jsdelivr.net/npm/[email protected]/libs/lz-string.min.js"></script>
642
643  <link rel="icon" type="image/png" sizes="16x16" href="/Logos/logo_16_dark.png" media="(prefers-color-scheme: light)">
644  <link rel="icon" type="image/png" sizes="32x32" href="/Logos/logo_32_dark.png" media="(prefers-color-scheme: light)">
645  <link rel="icon" type="image/png" sizes="48x48" href="/Logos/logo_48_dark.png" media="(prefers-color-scheme: light)">
646  <link rel="icon" type="image/png" sizes="16x16" href="/Logos/16.png" media="(prefers-color-scheme: dark)">
647  <link rel="icon" type="image/png" sizes="32x32" href="/Logos/32.png" media="(prefers-color-scheme: dark)">
648  <link rel="icon" type="image/png" sizes="48x48" href="/Logos/48.png" media="(prefers-color-scheme: dark)">
649  <link rel="icon" type="image/png" sizes="32x32" href="/Logos/32.png">
650  <style>
651    :root {
652      color-scheme: dark;
653      font-family: 'Google Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
654      --panel: #121212;
655      --chrome-well: #0d0d0d;
656      --stroke: #2a2a2a;
657      --separator: #383838;
658      --primary: #E37434;
659      --primary-hover: #f0854a;
660      --bg: #0d0d0d;
661      --text: #ffffff;
662      --text-secondary: #b0b0b0;
663      --input-bg: #222222;
664      --input-border: #333333;
665      --input-border-hover: #3d3d3d;
666      --shadow-primary: rgba(0, 0, 0, 0.3);
667    }
668
669    * {
670      box-sizing: border-box;
671      user-select: none;
672    }
673
674    input,
675    textarea {
676      user-select: text;
677    }
678
679    /* Form controls do not inherit font-family from an ancestor, so without this every
680       button label falls back to the browser's default UI font instead of Google Sans. */
681    button,
682    input,
683    select,
684    textarea {
685      font-family: inherit;
686    }
687
688    html, body {
689      margin: 0;
690      padding: 0;
691      width: 100%;
692      height: 100%;
693      overflow: hidden;
694    }
695
696    body {
697      background: #0d0d0d;
698      height: 100vh;
699      font-family: inherit;
700      display: flex;
701      color: var(--text);
702    }
703
704    /* Mobile message */
705    .mobile-message {
706      display: none;
707      position: fixed;
708      top: 0;
709      left: 0;
710      width: 100%;
711      height: 100vh;
712      background: #0d0d0d;
713      flex-direction: column;
714      align-items: center;
715      justify-content: center;
716      padding: 40px 20px;
717      text-align: center;
718      z-index: 10000;
719    }
720
721    .mobile-message .logo {
722      width: 120px;
723      height: 120px;
724      margin-bottom: 24px;
725    }
726
727    .mobile-message h1 {
728      font-size: 24px;
729      font-weight: 600;
730      color: var(--text);
731      margin: 0 0 12px 0;
732    }
733
734    .mobile-message p {
735      font-size: 16px;
736      color: var(--text-secondary);
737      margin: 0;
738      line-height: 1.5;
739      max-width: 300px;
740    }
741
742    .app-pro-tag {
743      border: 1px solid rgba(255, 255, 255, 0.3);
744      border-radius: 6px;
745      padding: 2px 8px;
746      font-size: 11px;
747      font-weight: 600;
748      letter-spacing: 0.04em;
749      color: rgba(255, 255, 255, 0.8);
750      text-transform: uppercase;
751      background: rgba(255, 255, 255, 0.05);
752    }
753
754    /* Hide main content on mobile */
755    @media (max-width: 768px) {
756      .sidebar,
757      .output-area,
758      .floating-history,
759      .floating-curves,
760      .floating-export,
761      .floating-export-count,
762      .help-button {
763        display: none !important;
764      }
765
766      .mobile-message {
767        display: flex;
768      }
769
770      body {
771        overflow: auto;
772      }
773    }
774
775    .sidebar {
776      width: 320px;
777      min-width: 320px;
778      background: var(--bg);
779      border-right: 1px solid var(--stroke);
780      overflow-y: auto;
781      overflow-x: hidden;
782      padding: 12px 14px;
783      display: flex;
784      flex-direction: column;
785      gap: 0;
786      height: 100vh;
787    }
788
789    #modeTabAnimate:disabled { opacity: .45; cursor: default; }
790    .maintenance-notice {
791      position: fixed;
792      inset: auto 20px 100px auto;
793      z-index: 2147483647;
794      box-sizing: border-box;
795      width: min(360px, calc(100vw - 32px));
796      max-height: calc(100dvh - 140px);
797      overflow: auto;
798      margin: 0;
799      padding: 20px;
800      box-shadow: 0 8px 32px #0003;
801      border: 1px solid #dedbd3;
802      border-radius: 10px;
803      background: #f5f3ed;
804      color: #302f2b;
805      font-size: 13px;
806      line-height: 1.5;
807      overflow-wrap: anywhere;
808    }
809    .maintenance-notice strong { display: block; padding-right: 28px; font-size: 15px; }
810    .maintenance-notice p { margin: 8px 0 0; }
811    .maintenance-notice button { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: #302f2b; font: 24px/1 sans-serif; cursor: pointer; }
812    .maintenance-notice button:hover { background: #e8e5dd; }
813    .maintenance-notice button:focus-visible { outline: 2px solid #302f2b; outline-offset: 2px; }
814    @media (max-width: 600px) {
815      .maintenance-notice { right: 16px; bottom: calc(140px + env(safe-area-inset-bottom)); max-height: calc(100dvh - 180px); }
816    }
817
818    .sidebar::-webkit-scrollbar {
819      width: 8px;
820    }
821
822    .sidebar::-webkit-scrollbar-track {
823      background: transparent;
824    }
825
826    .sidebar::-webkit-scrollbar-thumb {
827      background: transparent;
828      border-radius: 4px;
829    }
830
831    .sidebar:hover::-webkit-scrollbar-thumb {
832      background: var(--input-border);
833    }
834
835    .sidebar::-webkit-scrollbar-thumb:hover {
836      background: var(--input-border-hover);
837    }
838
839    .sidebar {
840      scrollbar-color: transparent transparent;
841    }
842
843    .sidebar:hover {
844      scrollbar-color: var(--input-border) transparent;
845    }
846
847    .output-area {
848      flex: 1;
849      display: flex;
850      flex-direction: column;
851      align-items: center;
852      justify-content: flex-start;
853      background: #0d0d0d;
854      position: relative;
855      overflow: hidden;
856      height: 100vh;
857      width: 100%;
858      contain: layout style paint;
859      /* Chrome-specific fix for rendering artifacts */
860      -webkit-transform: translateZ(0);
861      transform: translateZ(0);
862      isolation: isolate;
863    }
864
865    .ui-islands-container {
866      width: 100%;
867      display: flex;
868      align-items: center;
869      justify-content: space-between;
870      gap: 12px;
871      min-height: 0;
872      padding: 14px 20px;
873      box-sizing: border-box;
874      flex-shrink: 0;
875    }
876
877    .ui-islands-container > .floating-history {
878      display: none !important;
879    }
880
881    .ui-islands-container .btn,
882    .ui-islands-container .btn.secondary,
883    .ui-islands-container select,
884    .ui-islands-container .curve-toggle,
885    .ui-islands-container .toggle-btn {
886      min-height: 40px;
887      box-sizing: border-box;
888    }
889
890    .floating-randomize {
891      display: flex;
892      align-items: center;
893      flex-shrink: 0;
894      z-index: 1000;
895    }
896
897    .floating-curves {
898      flex: 1 0 auto;
899      justify-content: center;
900      display: flex;
901      flex-wrap: nowrap;
902      gap: 8px;
903      align-items: center;
904    }
905
906    .floating-curves > * {
907      flex-shrink: 0;
908    }
909
910    .floating-curves .btn,
911    .floating-curves .toggle-btn,
912    .floating-curves .curve-toggle {
913      white-space: nowrap;
914    }
915
916    .floating-randomize .btn,
917    .floating-curves .btn,
918    .floating-curves .toggle-btn,
919    .floating-curves .curve-toggle-group {
920      height: 40px;
921      min-height: 40px;
922      box-sizing: border-box;
923    }
924
925    .floating-randomize .btn,
926    .floating-curves .btn,
927    .floating-curves .toggle-btn,
928    .floating-curves .curve-toggle {
929      display: flex;
930      align-items: center;
931      justify-content: center;
932      gap: 6px;
933      padding: 0 12px;
934      line-height: 1;
935      font-size: 13px;
936    }
937
938    .floating-randomize .btn {
939      padding: 0 14px;
940      font-size: 14px;
941    }
942
943    .floating-curves .curve-toggle-group {
944      display: inline-flex;
945      align-items: stretch;
946    }
947
948    .floating-curves .curve-toggle {
949      height: auto;
950      min-height: 0;
951      padding: 0 14px;
952      font-size: 14px;
953    }
954
955    .floating-curves .toggle-btn.on {
956      border-color: transparent;
957    }
958
959    /* Login inset glass — scoped (not global .btn / checkout / export modal) */
960    .floating-randomize .btn,
961    .floating-curves .btn,
962    .floating-curves .btn.secondary,
963    .floating-curves .toggle-btn,
964    .floating-export .btn.secondary,
965    .sidebar .blend-btn,
966    .sidebar .app-login-btn {
967      background: rgba(255,255,255,.035);
968      border: none;
969      box-shadow: inset 0 0 0 1px rgba(255,255,255,.11), inset 0 1px 0 rgba(255,255,255,.16);
970      color: #f2f2f0;
971    }
972
973    .floating-randomize .btn:hover,
974    .floating-curves .btn:hover,
975    .floating-curves .btn.secondary:hover,
976    .floating-curves .toggle-btn:hover,
977    .floating-export .btn.secondary:hover,
978    .sidebar .blend-btn:hover,
979    .sidebar .app-login-btn:hover {
980      background: rgba(255,255,255,.06);
981      border: none;
982      box-shadow: inset 0 0 0 1px rgba(255,255,255,.11), inset 0 1px 0 rgba(255,255,255,.16);
983      color: #f2f2f0;
984    }
985
986    /* Toggle on = off-white pill (X/Y, Smoothing, Curves, blend/One-Duo active) */
987    .floating-curves .toggle-btn.on {
988      background: #f5f5f5;
989      border: none;
990      color: #1a1a1a;
991      box-shadow: none;
992    }
993
994    .floating-curves .toggle-btn.on:hover {
995      background: #e8e8e8;
996      color: #1a1a1a;
997    }
998
999    .floating-curves .toggle-btn.on .material-icons,
1000    .floating-curves .toggle-btn.on span:not(.material-icons) {
1001      color: #1a1a1a;
1002    }
1003
1004    .floating-curves .curve-toggle-group,
1005    .sidebar .curve-toggle-group {
1006      background: rgba(255,255,255,.035);
1007      border: none;
1008      box-shadow: inset 0 0 0 1px rgba(255,255,255,.11), inset 0 1px 0 rgba(255,255,255,.16);
1009      overflow: hidden;
1010    }
1011
1012    .sidebar .curve-toggle-group {
1013      padding: 0 !important;
1014      align-items: stretch;
1015    }
1016
1017    .sidebar .curve-toggle {
1018      min-height: 0;
1019      line-height: 1;
1020    }
1021
1022    .sidebar .curve-toggle:first-child {
1023      border-radius: 999px 0 0 999px;
1024    }
1025
1026    .sidebar .curve-toggle:last-child {
1027      border-radius: 0 999px 999px 0;
1028    }
1029
1030    .floating-curves .curve-toggle,
1031    .sidebar .curve-toggle {
1032      background: transparent;
1033      border: none;
1034      box-shadow: none;
1035      color: #f2f2f0;
1036    }
1037
1038    .floating-curves .curve-toggle:hover,
1039    .sidebar .curve-toggle:hover {
1040      background: rgba(255,255,255,.06);
1041      color: #f2f2f0;
1042    }
1043
1044    .floating-curves .curve-toggle.active,
1045    .sidebar .curve-toggle.active {
1046      background: #f5f5f5;
1047      color: #1a1a1a;
1048    }
1049
1050    .floating-curves .curve-toggle.active:hover,
1051    .sidebar .curve-toggle.active:hover {
1052      background: #e8e8e8;
1053      color: #1a1a1a;
1054    }
1055
1056    .sidebar .blend-btn.active,
1057    .sidebar .blend-btn.active:hover {
1058      background: #f5f5f5;
1059      color: #1a1a1a;
1060      border: none;
1061      box-shadow: none;
1062    }
1063
1064    .sidebar .blend-btn.active .material-icons {
1065      color: #1a1a1a;
1066    }
1067
1068    /* Sidebar + 1x select: glass fill; keep chevron via background-image */
1069    .sidebar select,
1070    #exportScale {
1071      background-color: rgba(255,255,255,.035) !important;
1072      border: none !important;
1073      box-shadow: inset 0 0 0 1px rgba(255,255,255,.11), inset 0 1px 0 rgba(255,255,255,.16) !important;
1074      color: #f2f2f0 !important;
1075    }
1076
1077    #exportScale {
1078      inline-size: 64px;
1079      flex: 0 0 64px;
1080      box-sizing: border-box;
1081    }
1082
1083    .sidebar select:hover,
1084    .sidebar select:focus,
1085    #exportScale:hover {
1086      background-color: rgba(255,255,255,.06) !important;
1087    }
1088
1089    .export-format-picker {
1090      position: relative;
1091      flex-shrink: 0;
1092    }
1093
1094    .export-format-picker #exportFormat {
1095      display: none !important;
1096    }
1097
1098    #exportFormatBtn {
1099      padding: 10px 32px 10px 14px;
1100      border-radius: 6px;
1101      border: none;
1102      background-color: rgba(255,255,255,.035);
1103      background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 12 12%22%3E%3Cpath fill=%22%23ffffff%22 d=%22M6 9L1 4h10z%22/%3E%3C/svg%3E');
1104      background-repeat: no-repeat;
1105      background-position: right 12px center;
1106      box-shadow: inset 0 0 0 1px rgba(255,255,255,.11), inset 0 1px 0 rgba(255,255,255,.16);
1107      color: #f2f2f0;
1108      font: inherit;
1109      font-size: 14px;
1110      font-weight: 600;
1111      line-height: 1.2;
1112      min-width: 88px;
1113      text-align: left;
1114      cursor: pointer;
1115    }
1116
1117    #exportFormatBtn:hover,
1118    #exportFormatBtn[aria-expanded="true"] {
1119      background-color: rgba(255,255,255,.06);
1120    }
1121
1122    .export-format-menu {
1123      position: absolute;
1124      top: calc(100% + 8px);
1125      right: 0;
1126      z-index: 2000;
1127      min-width: 0;
1128      width: max-content;
1129      padding: 12px 8px;
1130      display: flex;
1131      flex-direction: column;
1132      gap: 4px;
1133      background: var(--panel);
1134      border: 1px solid var(--stroke);
1135      border-radius: 10px;
1136      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
1137      box-sizing: border-box;
1138    }
1139
1140    .export-format-menu[hidden] {
1141      display: none !important;
1142    }
1143
1144    .export-format-label {
1145      display: flex;
1146      align-items: center;
1147      gap: 8px;
1148      padding: 8px 8px 6px;
1149      font-size: 11px;
1150      font-weight: 600;
1151      letter-spacing: 0.02em;
1152      color: var(--text-secondary);
1153    }
1154
1155    .export-format-label .material-icons {
1156      font-size: 16px;
1157      width: 16px;
1158      height: 16px;
1159      line-height: 1;
1160      flex-shrink: 0;
1161    }
1162
1163    .export-format-separator {
1164      height: 1px;
1165      margin: 10px 4px;
1166      background: var(--stroke);
1167    }
1168
1169    .export-format-menu [role="option"] {
1170      display: block;
1171      width: 100%;
1172      padding: 10px 8px;
1173      border: 0;
1174      border-radius: 8px;
1175      background: transparent;
1176      color: #f2f2f0;
1177      font: inherit;
1178      font-size: 14px;
1179      font-weight: 600;
1180      text-align: center;
1181      cursor: pointer;
1182    }
1183
1184    .export-format-menu [role="option"]:hover,
1185    .export-format-menu [role="option"]:focus-visible {
1186      background: rgba(255,255,255,.06);
1187      outline: none;
1188    }
1189
1190    .export-format-menu [role="option"][aria-selected="true"] {
1191      background: #f5f5f5;
1192      color: #1a1a1a;
1193    }
1194
1195    .export-format-menu [role="option"][aria-selected="true"]:hover,
1196    .export-format-menu [role="option"][aria-selected="true"]:focus-visible {
1197      background: #e8e8e8;
1198    }
1199
1200    /* Export: lime fill stays (JS flash writes background); inset glass edge only */
1201    .floating-export #exportBtn {
1202      border: none;
1203      box-shadow: inset 0 0 0 1px rgba(255,255,255,.28), inset 0 1px 0 rgba(255,255,255,.4), 0 4px 12px rgba(162, 215, 41, 0.25) !important;
1204    }
1205
1206    .floating-export #exportBtn:hover {
1207      box-shadow: inset 0 0 0 1px rgba(26,26,26,.12), inset 0 1px 0 rgba(255,255,255,.55), 0 4px 12px rgba(162, 215, 41, 0.2) !important;
1208    }
1209
1210    .floating-randomize .btn .material-icons,
1211    .floating-curves .btn .material-icons,
1212    .floating-curves .toggle-btn .material-icons,
1213    .floating-curves .curve-toggle .material-icons,
1214    .floating-curves .curve-toggle .material-symbols-outlined {
1215      font-size: 16px;
1216      width: 16px;
1217      height: 16px;
1218      line-height: 1;
1219      margin: 0;
1220      display: inline-flex;
1221      align-items: center;
1222      justify-content: center;
1223      flex-shrink: 0;
1224    }
1225
1226    .floating-curves .curve-separator {
1227      display: none;
1228    }
1229
1230    .floating-export {
1231      flex-shrink: 0;
1232    }
1233
1234    .canvas-root {
1235      position: relative;
1236      width: 100%;
1237      flex: 1;
1238      min-height: 600px;
1239      overflow: hidden;
1240      display: flex;
1241      align-items: center;
1242      justify-content: center;
1243      padding-left: 24px;
1244      padding-right: 24px;
1245      padding-bottom: 24px;
1246      margin-top: 24px;
1247      contain: layout style paint;
1248    }
1249
1250    .canvas-wrapper {
1251      position: relative;
1252      flex-shrink: 0;
1253      transform: translate3d(0, 0, 0); /* Force GPU acceleration and ensure integer positioning */
1254      backface-visibility: hidden;
1255      will-change: transform;
1256      overflow: hidden;
1257      clip-path: inset(0);
1258      contain: strict;
1259      background: #0d0d0d;
1259 /* Match output-area background to hide any overflow */
1260      box-shadow: 0 0 0 1px #0d0d0d; /* Cover any edge artifacts */
1261      /* Chrome-specific fix for rendering artifacts */
1262      -webkit-transform: translate3d(0, 0, 0);
1263      isolation: isolate;
1264      /* Additional Chrome fix - ensure no sub-pixel rendering */
1265      -webkit-font-smoothing: antialiased;
1266      -moz-osx-font-smoothing: grayscale;
1267    }
1268
1269
1270    .help-button {
1271      position: fixed;
1272      bottom: 20px;
1273      right: 20px;
1274      z-index: 1000;
1275      background: #2a2a2a;
1276      border: none;
1277      border-radius: 50%;
1278      width: 48px;
1279      height: 48px;
1280      display: flex;
1281      align-items: center;
1282      justify-content: center;
1283      cursor: pointer;
1284      color: #f2f2f0;
1285      text-decoration: none;
1286      -webkit-appearance: none;
1287      appearance: none;
1288      box-shadow: inset 0 0 0 1px rgba(255,255,255,.11), inset 0 1px 0 rgba(255,255,255,.16);
1289      transition: background .25s, box-shadow .25s, color .25s, filter 0.1s ease;
1290    }
1291
1292    .help-button:hover {
1293      background: #333333;
1294      color: #f2f2f0;
1295      transform: none;
1296    }
1297
1298    .help-button .material-icons {
1299      filter: drop-shadow(0 0 0px rgba(255, 255, 255, 0));
1300      transition: filter 0.1s ease;
1301      font-size: 24px;
1302    }
1303
1304    .help-button:hover .material-icons {
1305      filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.48));
1306    }
1307
1308    .help-modal {
1309      position: fixed;
1310      top: 0;
1311      left: 0;
1312      width: 100%;
1313      height: 100%;
1314      background: rgba(0, 0, 0, 0.7);
1315      z-index: 2000;
1316      display: none;
1317      align-items: center;
1318      justify-content: center;
1319      backdrop-filter: blur(4px);
1320    }
1321
1322    .help-modal.visible {
1323      display: flex;
1324    }
1325
1326    .app-login-btn {
1327      padding: 0 16px;
1328      height: 42px;
1329      border-radius: 8px;
1330      background: rgba(255, 255, 255, 0.08);
1331      border: 1px solid rgba(255, 255, 255, 0.12);
1332      color: var(--text);
1333      font-size: 14px;
1334      font-weight: 500;
1335      font-family: inherit;
1336      cursor: pointer;
1337      transition: background 0.15s ease, border-color 0.15s ease;
1338    }
1339    .app-login-btn:hover {
1340      background: rgba(255, 255, 255, 0.14);
1341      border-color: rgba(255, 255, 255, 0.2);
1342    }
1343    /* Login modal: same form as login page (no orange, 1:1 styles) */
1344    #loginModalContent {
1345      --login-primary: #F5F5F0;
1346      --login-text: #F5F5F0;
1347      --login-text-secondary: #b0b0b0;
1348      --login-error: #e57373;
1349      --login-success: #81c784;
1350    }
1351    #loginModalContent .form-group { margin-bottom: 16px; }
1352    #loginModalContent .form-group label {
1353      display: block;
1354      font-size: 13px;
1355      font-weight: 500;
1356      margin-bottom: 6px;
1357      color: var(--login-text-secondary);
1358    }
1359    #loginModalContent .form-group input {
1360      width: 100%;
1361      padding: 12px 14px;
1362      background: rgba(13, 13, 13, 0.6);
1363      border: 1px solid rgba(255,255,255,0.08);
1364      border-radius: 8px;
1365      color: var(--login-text);
1366      font-size: 14px;
1367      font-family: inherit;
1368      outline: none;
1369      transition: border-color 0.15s ease;
1370      box-sizing: border-box;
1371    }
1372    #loginModalContent .form-group input:focus { border-color: rgba(245, 245, 240, 0.4); }
1373    #loginModalContent .form-group input::placeholder { color: #666; }
1374    #loginModalContent .divider {
1375      display: flex;
1376      align-items: center;
1377      gap: 16px;
1378      margin: 24px 0;
1379    }
1380    #loginModalContent .divider::before,
1381    #loginModalContent .divider::after {
1382      content: '';
1383      flex: 1;
1384      height: 1px;
1385      background: rgba(255, 255, 255, 0.2);
1386    }
1387    #loginModalContent .divider span {
1388      font-size: 12px;
1389      color: var(--login-text-secondary);
1390      text-transform: uppercase;
1391      letter-spacing: 0.05em;
1392    }
1393    #loginModalContent .form-row {
1394      display: flex;
1395      align-items: center;
1396      justify-content: space-between;
1397      margin-bottom: 24px;
1398    }
1399    #loginModalContent .remember-me {
1400      display: flex;
1401      flex-direction: row;
1402      align-items: center;
1403      gap: 8px;
1404      cursor: pointer;
1405      font-size: 13px;
1406      color: var(--login-text-secondary);
1407    }
1408    #loginModalContent .remember-me input[type="checkbox"] {
1409      width: 16px;
1410      height: 16px;
1411      accent-color: var(--login-primary);
1412      cursor: pointer;
1413    }
1414    #loginModalContent .forgot-link {
1415      font-size: 13px;
1416      color: var(--login-text-secondary);
1417      text-decoration: none;
1418    }
1419    #loginModalContent .forgot-link:hover { color: var(--login-primary); text-decoration: underline; }
1420    #loginModalContent .submit-btn {
1421      display: flex;
1422      align-items: center;
1423      justify-content: center;
1424      width: 100%;
1425      padding: 12px;
1426      background: var(--login-primary);
1427      color: #0a0a0a;
1428      border: none;
1429      border-radius: 8px;
1430      font-size: 14px;
1431      font-weight: 600;
1432      font-family: inherit;
1433      cursor: pointer;
1434      transition: opacity 0.15s ease;
1435    }
1436    #loginModalContent .submit-btn:hover { opacity: 0.95; }
1437    #loginModalContent .submit-btn:disabled { opacity: 0.5;
1437 cursor: not-allowed; }
1438    #loginModalContent .toggle-mode {
1439      text-align: center;
1440      margin-top: 24px;
1441      font-size: 14px;
1442      color: var(--login-text-secondary);
1443    }
1444    #loginModalContent .toggle-mode a {
1445      color: var(--login-primary);
1446      text-decoration: none;
1447      font-weight: 500;
1448    }
1449    #loginModalContent .toggle-mode a:hover { text-decoration: underline; }
1450    #loginModalContent .error-msg,
1451    #loginModalContent .success-msg {
1452      padding: 10px 14px;
1453      border-radius: 8px;
1454      font-size: 13px;
1455      margin-bottom: 16px;
1456      display: none;
1457    }
1458    #loginModalContent .error-msg.visible,
1459    #loginModalContent .success-msg.visible { display: block; }
1460    #loginModalContent .error-msg {
1461      background: rgba(229, 115, 115, 0.12);
1462      border: 1px solid rgba(229, 115, 115, 0.3);
1463      color: var(--login-error);
1464    }
1465    #loginModalContent .success-msg {
1466      background: rgba(129, 199, 132, 0.12);
1467      border: 1px solid rgba(129, 199, 132, 0.3);
1468      color: var(--login-success);
1469    }
1470    #loginModalContent .google-btn {
1471      display: flex;
1472      align-items: center;
1473      justify-content: center;
1474      gap: 10px;
1475      width: 100%;
1476      padding: 12px;
1477      background: #fff;
1478      color: #333;
1479      border: none;
1480      border-radius: 8px;
1481      font-size: 14px;
1482      font-weight: 600;
1483      font-family: inherit;
1484      cursor: pointer;
1485      transition: background 0.15s ease;
1486    }
1487    #loginModalContent .google-btn:hover { background: #f0f0f0; }
1488    #loginModalContent .google-btn svg { width: 20px; height: 20px; }
1489    #loginModalContent .back-link {
1490      font-size: 13px;
1491      color: var(--login-text-secondary);
1492      text-decoration: none;
1493      margin-bottom: 24px;
1494      display: inline-block;
1495    }
1496    #loginModalContent .back-link:hover { color: var(--login-primary); }
1497
1498    .help-modal-content {
1499      background: var(--panel);
1500      border: 1px solid var(--stroke);
1501      border-radius: 12px;
1502      padding: 24px;
1503      max-width: 500px;
1504      width: 90%;
1505      max-height: 80vh;
1506      overflow-y: auto;
1507      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
1508      position: relative;
1509      margin: 20px;
1510    }
1511
1512    .help-modal-header {
1513      display: flex;
1514      justify-content: space-between;
1515      align-items: center;
1516      margin-bottom: 20px;
1517    }
1518
1519    .help-modal-header h2 {
1520      margin: 0;
1521      font-size: 20px;
1522      font-weight: 600;
1523      color: var(--text);
1524    }
1525
1526    .help-modal-close {
1527      background: transparent;
1528      border: none;
1529      color: var(--text-secondary);
1530      cursor: pointer;
1531      padding: 4px;
1532      display: flex;
1533      align-items: center;
1534      justify-content: center;
1535      border-radius: 4px;
1536      transition: all 0.15s ease;
1537    }
1538
1539    .help-modal-close:hover {
1540      background: var(--input-bg);
1541      color: var(--text);
1542    }
1543
1544    .help-modal-close .material-icons {
1545      font-size: 20px;
1546    }
1547
1548    .help-modal-content h3 {
1549      margin: 16px 0 8px 0;
1550      font-size: 14px;
1551      font-weight: 600;
1552      color: var(--text);
1553    }
1554
1555    .help-modal-content p {
1556      margin: 0 0 12px 0;
1557      font-size: 13px;
1558      line-height: 1.6;
1559      color: var(--text-secondary);
1560    }
1561
1562    .help-modal-content ul {
1563      margin: 0 0 12px 0;
1564      padding-left: 20px;
1565      font-size: 13px;
1566      line-height: 1.6;
1567      color: var(--text-secondary);
1568    }
1569
1570    .help-modal-content li {
1571      margin-bottom: 6px;
1572    }
1573
1574    .templates-modal-content {
1575      width: 80vw;
1576      max-width: 1100px;
1577    }
1578
1579    .templates-grid {
1580      display: grid;
1581      grid-template-columns: repeat(4, minmax(0, 1fr));
1582      gap: 16px;
1583    }
1584
1585    .template-tile {
1586      border: 1px solid var(--stroke);
1587      border-radius: 12px;
1588      background: var(--input-bg);
1589      color: var(--text);
1590      cursor: pointer;
1591      display: flex;
1592      flex-direction: column;
1593      gap: 10px;
1594      padding: 10px;
1595      text-align: left;
1596      transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
1597    }
1598
1599    .template-tile:hover {
1600      background: rgba(255, 255, 255, 0.08);
1601      border-color: rgba(255, 255, 255, 0.2);
1602      transform: translateY(-1px);
1603    }
1604
1605    .template-tile img {
1606      display: block;
1607      width: 100%;
1608      height: auto;
1609      border-radius: 8px;
1610    }
1611
1612    .template-tile-name {
1613      font-size: 13px;
1614      font-weight: 600;
1615      text-align: center;
1616    }
1617
1618    @media (max-width: 900px) {
1619      .templates-grid {
1620        grid-template-columns: repeat(2, minmax(0, 1fr));
1621      }
1622    }
1623
1624    @media (max-width: 560px) {
1625      .templates-modal-content {
1626        width: 92vw;
1627      }
1628
1629      .templates-grid {
1630        grid-template-columns: 1fr;
1631      }
1632    }
1633
1634    .shell {
1635      display: flex;
1636      flex-direction: column;
1637      gap: 16px;
1638    }
1639
1640    .sidebar .shell {
1641      gap: 8px;
1642    }
1643
1644    header {
1645      display: flex;
1646      justify-content: space-between;
1647      align-items: center;
1648    }
1649
1650    h1 {
1651      margin: 0;
1652      font-size: 20px;
1653      font-weight: 500;
1654      letter-spacing: -0.01em;
1655      color: var(--text);
1656      transition: text-shadow 0.1s ease, filter 0.1s ease;
1657      cursor: default;
1658    }
1659
1660    h1:hover {
1661      text-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
1662    }
1663
1664    h1 img {
1665      transition: filter 0.1s ease;
1666    }
1667
1668    h1:hover img {
1669      filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.6));
1670    }
1671
1672    .btn {
1673      background: var(--primary);
1674      color: white;
1675      border: none;
1676      padding: 10px 14px;
1677      border-radius: 10px;
1678      font-weight: 500;
1679      font-family: inherit;
1680      cursor: pointer;
1681      transition: all 0.15s ease;
1682      box-shadow: 0 4px 12px var(--shadow-primary);
1683    }
1684
1685    .btn:hover {
1686      background: var(--primary-hover);
1687      box-shadow: 0 6px 16px var(--shadow-primary);
1688    }
1689
1690    .btn:hover:not(#randomBtn):not(#exportBtn):not(#videoStart) .material-icons,
1691    .btn:hover:not(#randomBtn):not(#exportBtn):not(#videoStart) span:not(.material-icons) {
1692      filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.48));
1693      text-shadow: 0 0 12px rgba(255, 255, 255, 0.48);
1694      transition: filter 0.1s ease, text-shadow 0.1s ease;
1695    }
1696
1697    .btn:not(#randomBtn):not(#exportBtn):not(#videoStart) .material-icons,
1698    .btn:not(#randomBtn):not(#exportBtn):not(#videoStart) span:not(.material-icons) {
1699      filter: drop-shadow(0 0 0px rgba(255, 255, 255, 0));
1700      text-shadow: 0 0 0px rgba(255, 255, 255, 0);
1701      transition: filter 0.1s ease, text-shadow 0.1s ease;
1702    }
1703
1704    #randomBtn {
1705      --random-btn-gradient: linear-gradient(135deg, rgb(255, 89, 94) 0%, rgb(106, 76, 147) 100%);
1706      background: rgba(255,255,255,.035);
1707      color: #f2f2f0;
1708      border: none;
1709      box-shadow: inset 0 0 0 1px rgba(255,255,255,.11), inset 0 1px 0 rgba(255,255,255,.16);
1710      font-weight: 700;
1711      position: relative;
1712      overflow: visible;
1713      z-index: 0;
1714      transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
1715    }
1716
1717    #randomBtn:hover {
1718      background: var(--random-btn-gradient);
1719      background-size: 200% 200%;
1720      animation: randomBtnGradientShift 3s ease infinite;
1721      color: #F5F5F0;
1722      border-color: transparent;
1723      box-shadow: inset 0 0 0 1px rgba(245, 245, 240, 0.2);
1724      text-shadow: 0 1px 1px rgba(0, 0, 0, 0.28);
1725    }
1726
1727    @keyframes randomBtnGradientShift {
1728      0% { background-position: 0% 50%; }
1729      50% { background-position: 100% 50%; }
1730      100% { background-position: 0% 50%; }
1731    }
1732
1733    #randomBtn > * {
1734      position: relative;
1735      z-index: 1;
1736    }
1737
1738    #randomBtn:active {
1739      transform: scale(0.98);
1740    }
1741
1742    #randomBtn .material-icons,
1743    #randomBtn span:not(.material-icons) {
1744      color: inherit;
1745      position: relative;
1746      z-index: 1;
1747    }
1748
1749    #randomBtn:hover .material-icons,
1750    #randomBtn:hover span:not(.material-icons) {
1751      color: #F5F5F0;
1752      filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.28));
1753    }
1754
1755    .btn:active {
1756      transform: translateY(0);
1757    }
1758
1759    .btn.loading {
1760      cursor: wait;
1761      opacity: 0.8;
1762      pointer-events: none;
1763    }
1764
1765    .btn.loading .material-icons {
1766      animation: spin 1s linear infinite;
1767    }
1768
1769    #exportBtn {
1770      min-width: auto;
1771      width: auto;
1772      flex-shrink: 0;
1773      display: flex;
1774      padding-right: 12px;
1775      align-items: center;
1776      justify-content: flex-start;
1777      gap: 4px;
1778      transition: background 0.1s ease;
1779    }
1780
1781    #exportBtn:hover {
1782      background: #F5F5F0 !important;
1783    }
1784
1785    @keyframes spin {
1786      from {
1787        transform: rotate(0deg);
1788      }
1789      to {
1790        transform: rotate(360deg);
1791      }
1792    }
1793
1794    .btn.secondary {
1795      background: var(--input-bg);
1796      color: var(--text);
1797      box-shadow: none;
1798      border: 1px solid var(--input-border);
1799    }
1800
1801    .btn.secondary .material-icons,
1802    .btn.secondary span:not(.material-icons) {
1803      filter: drop-shadow(0 0 0px rgba(255, 255, 255, 0));
1804      text-shadow: 0 0 0px rgba(255, 255, 255, 0);
1805      transition: filter 0.1s ease, text-shadow 0.1s ease;
1806    }
1807
1808    .btn.secondary:hover {
1809      background: #2a2a2a;
1810      border-color: var(--input-border-hover);
1811    }
1812
1813    .btn.secondary:hover .material-icons,
1814    .btn.secondary:hover span:not(.material-icons) {
1815      filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.48));
1816      text-shadow: 0 0 12px rgba(255, 255, 255, 0.48);
1817    }
1818
1819    #figmaPluginBtn {
1820      text-decoration: none;
1821      color: var(--text);
1822    }
1823
1824    #figmaPluginBtn:visited,
1825    #figmaPluginBtn:hover,
1826    #figmaPluginBtn:focus,
1827    #figmaPluginBtn:focus-visible {
1828      color: var(--text);
1829      text-decoration: none;
1830    }
1831
1832    #figmaPluginBtn .figma-plugin-icon {
1833      width: 14px;
1834      height: 21px;
1835      display: block;
1836      flex-shrink: 0;
1837    }
1838
1839    .panel {
1840      margin-left: -14px;
1841      margin-right: -14px;
1842      padding: 14px 14px;
1843      border-bottom: 1px solid var(--separator);
1844      display: flex;
1845      flex-direction: column;
1846      gap: 8px;
1847    }
1848
1849    .panel:last-child {
1850      border-bottom: none;
1851    }
1852
1853    .sidebar .panel:first-of-type {
1854      border-top: 1px solid var(--separator);
1855    }
1856
1857    .panel h3 {
1858      margin: 0 0 4px;
1859      font-size: 12px;
1860      font-weight: 500;
1861      color: var(--text);
1862      letter-spacing: 0.02em;
1863      text-transform: uppercase;
1864      display: flex;
1865      align-items: center;
1866      justify-content: space-between;
1867      user-select: none;
1868    }
1869
1870    .panel.collapsible h3 {
1871      margin-bottom: 0;
1872    }
1873
1874    /* Keep switch-enabled headers optically centred without shifting on expand. */
1875    .panel.collapsible {
1876      padding-top: 9px;
1877      gap: 0;
1878    }
1879
1880    /* Keep the header static; this gap is animated with the controls instead
1881       of appearing as a separate flex-layout jump. */
1882    .panel.collapsible > .controls {
1883      margin-top: 8px;
1884    }
1885
1886    /* Sub-feature header inside a panel (matches .panel h3 styling) */
1887    .subfeature-head {
1888      margin: 0;
1889      font-size: 12px;
1890      font-weight: 500;
1891      color: var(--text);
1892      letter-spacing: 0.02em;
1893      text-transform: uppercase;
1894      display: flex;
1895      align-items: center;
1896      justify-content: space-between;
1897      user-select: none;
1898    }
1899
1900    .panel.collapsed .controls {
1901      display: none;
1902    }
1903
1904    /* When the panel's feature switch is OFF, dim and block the controls */
1905    .panel.panel-disabled .controls {
1906      opacity: 0.35;
1907      pointer-events: none;
1908      user-select: none;
1909    }
1910
1911    /* Chevron button to collapse a switch-enabled section while it stays ON */
1912    .collapse-toggle {
1913      display: inline-flex;
1914      align-items: center;
1915      justify-content: center;
1916      width: 22px;
1917      height: 22px;
1918      padding: 0;
1919      border: none;
1920      border-radius: 4px;
1921      background: transparent;
1922      color: var(--text-secondary);
1923      cursor: pointer;
1924      flex-shrink: 0;
1925    }
1926
1927    .collapse-toggle:hover {
1928      color: var(--text);
1929    }
1930
1931    .collapse-toggle .material-icons {
1932      font-size: 20px;
1933      transition: transform 0.15s ease;
1934    }
1935
1936    .panel.collapsed > h3 .collapse-toggle .material-icons,
1937    .subfeature-head.collapsed .collapse-toggle .material-icons {
1938      transform: rotate(-90deg);
1939    }
1940
1941    /* PRO tag shown on the beta panel header for non-PRO users */
1942    .beta-pro-lock {
1943      display: inline-flex;
1944      align-items: center;
1945      flex-shrink: 0;
1946      cursor: pointer;
1947    }
1948
1949    .beta-pro-lock img {
1950      display: block;
1951      width: 40px;
1952      height: 20px;
1953    }
1954
1955    /* Collapsible body of a beta subfeature (matches .controls layout) */
1956    .subfeature-body {
1957      display: grid;
1958      grid-template-columns: 1fr;
1959      gap: 8px;
1960    }
1961
1962    .surface-beta-feature {
1963      display: grid;
1964      gap: 10px;
1965    }
1966
1967    .subfeature-body.collapsed {
1968      display: none;
1969    }
1970
1971    .controls {
1972      display: grid;
1973      grid-template-columns: 1fr;
1974      gap: 10px;
1975    }
1976
1977
1978    label {
1979      display: flex;
1980      flex-direction: column;
1981      font-size: 12px;
1982      color: var(--text-secondary);
1983      gap: 4px;
1984      font-weight: 500;
1985    }
1986
1987    .canvas-size-field {
1988      position: relative;
1989      width: 100%;
1990    }
1991
1992    .canvas-size-field input {
1993      width: 100%;
1994      box-sizing: border-box;
1995      padding-right: 30px;
1996    }
1997
1998    .canvas-size-unit {
1999      position: absolute;
2000      top: 50%;
2001      pointer-events: none;
2002      user-select: none;
2003    }
2004
2005    .canvas-size-unit {
2006      right: 10px;
2007      transform: translateY(-50%);
2008      color: var(--text-secondary);
2009      font-size: 12px;
2010      font-weight: 500;
2011      opacity: 0.6;
2012    }
2013
2014    input,
2015    select {
2016      padding: 8px 10px;
2017      border-radius: 6px;
2018      border: 1px solid var(--input-border);
2019      font-size: 14px;
2020      font-family: inherit;
2021      font-weight: 400;
2022      background: var(--input-bg);
2023      color: var(--text);
2024      transition: border-color 0.15s ease, background-color 0.15s ease;
2025    }
2026
2027    /* Native popup menus need opaque colors, independently of the closed control. */
2028    select { color-scheme: dark; }
2029    select option,
2030    select optgroup {
2031      background-color: #202020;
2032      color: #f2f2f0;
2033    }
2034    select option:disabled { color: #999; }
2035
2036    /* Keep real selects as the source of truth for keyboard input, saved designs
2037       and the previous/next controls. Supporting browsers expose their native
2038       picker in the top layer, so the sidebar cannot clip the rounded menu. */
2039    @supports (appearance: base-select) and selector(::picker(select)) {
2040      select:not([hidden]), select:not([hidden])::picker(select) {
2041        appearance: base-select;
2042      }
2043      select:not([hidden]) {
2044        /* Existing controls set appearance and corner radius inline. */
2045        appearance: base-select !important;
2046        border-radius: 8px !important;
2047      }
2048      select:not([hidden])::picker-icon {
2049        /* The existing closed controls already have a chevron background. */
2050        display: none;
2051      }
2052      select:not([hidden])::picker(select) {
2053        min-inline-size: max(112px, anchor-size(width));
2054        max-inline-size: calc(100vw - 16px);
2055        max-block-size: min(280px, calc(100vh - 24px));
2056        margin-block: 4px;
2057        padding: 4px;
2058        border: 1px solid var(--stroke);
2059        border-radius: 10px;
2060        background: var(--panel);
2061        color: var(--text);
2062        box-shadow: 0 8px 24px rgba(0, 0, 0, .4);
2063        overflow-y: auto;
2064        overscroll-behavior: contain;
2065        scrollbar-width: thin;
2066        scrollbar-color: #444 transparent;
2067      }
2068      select:not([hidden]) option {
2069        min-block-size: 26px;
2070        margin-block: 1px;
2071        padding: 4px 8px;
2072        border: 0;
2073        border-radius: 6px;
2074        background: transparent;
2075        color: #f2f2f0;
2076        font: inherit;
2077        line-height: 1.4;
2078        cursor: pointer;
2079      }
2080      /* Adjacent export menus share the format picker's roomier spacing. */
2081      #exportScale::picker(select) {
2082        inline-size: anchor-size(width);
2083        min-inline-size: anchor-size(width);
2084        box-sizing: border-box;
2085        margin-block: 8px;
2086        padding: 12px 8px;
2087        flex-direction: column;
2088        gap: 4px;
2089      }
2090      /* Preserve the native picker's display:none while it is closed. */
2091      #exportScale:open::picker(select) {
2092        display: flex;
2093      }
2094      #exportScale option {
2095        min-block-size: 38px;
2096        margin: 0;
2097        padding: 10px 8px;
2098        border-radius: 8px;
2099        line-height: normal;
2100        justify-content: center;
2101        text-align: center;
2102      }
2103      select:not([hidden]) option::checkmark { display: none; }
2104      select:not([hidden]) option:not(:disabled):hover,
2105      select:not([hidden]) option:focus-visible {
2106        outline: none;
2107        background: rgba(255, 255, 255, .06);
2108      }
2109      select:not([hidden]) option:checked {
2110        background: #f5f5f5;
2111        color: #1a1a1a;
2112      }
2113      select:not([hidden]) option:checked:not(:disabled):hover,
2114      select:not([hidden]) option:checked:focus-visible {
2115        background: #e8e8e8;
2116        color: #1a1a1a;
2117      }
2118      select:not([hidden]) option:disabled { color: #888; cursor: default; }
2119      select:not([hidden]):focus-visible {
2120        outline: 2px solid #f5f5f5;
2121        outline-offset: 2px;
2122      }
2123      @media (prefers-reduced-motion: no-preference) {
2124        select:not([hidden])::picker(select) {
2125          animation: picker-enter 120ms ease-out;
2126        }
2127      }
2128    }
2129
2130    input:hover,
2131    select:hover {
2132      border-color: var(--input-border-hover);
2133    }
2134
2135    input:focus,
2136    select:focus {
2137      outline: none;
2138      border-color: #f5f5f5;
2139      background: #2a2a2a;
2140    }
2141
2142    /* Number inputs only: darker bg and stroke */
2143    input[type="number"] {
2144      background: #0d0d0d;
2145      border-color: #252525;
2146      border-radius: 6px;
2147    }
2148    input[type="number"]:hover {
2149      border-color: #2e2e2e;
2150    }
2151    input[type="number"]:focus {
2152      background: #0d0d0d;
2153      border-color: #f5f5f5;
2154    }
2155
2156    /* Range slider: no focus stroke; gradient fill + 20 vertical lines on active */
2157    .range-slider-wrap {
2158      position: relative;
2159      display: flex;
2160      align-items: center;
2161      min-width: 0;
2162    }
2163    .slider-with-input .range-slider-wrap {
2164      flex: 1;
2165      min-width: 0;
2166      max-width: none !important;
2167      width: 100% !important;
2168    }
2169    /* Track-bg and fill span full wrap (no left inset); thumb margin makes thumb reach 0 and 100% */
2170    .range-slider-wrap .range-slider-track-bg {
2171      position: absolute;
2172      left: 0;
2173      right: 0;
2174      top: 50%;
2175      transform: translateY(-50%);
2176      height: 8px;
2177      border-radius: 4px;
2178      background-color: var(--input-bg);
2179      background-image: repeating-linear-gradient(
2180        90deg,
2181        transparent 0 3.7px,
2182        rgba(255, 255, 255, 0.06) 3.7px 4.7px
2183      );
2184      background-size: 4.7px calc(100% - 4px);
2185      background-position: center;
2186      background-repeat: repeat-x;
2187      pointer-events: none;
2188      z-index: 0;
2189    }
2190    .range-slider-wrap .range-slider-fill {
2191      position: absolute;
2192      left: 0;
2193      right: 0;
2194      top: 50%;
2195      transform: translateY(-50%);
2196      height: 8px;
2197      border-radius: 4px;
2198      pointer-events: none;
2199      /* Gradient mapped to full track; thumb is the split: visible left, clipped right */
2200      clip-path: inset(0 calc(100% - var(--range-percent, 0) * 1%) 0 0);
2201      /* The fill sits over the ticked base track, masking ticks as it advances. */
2202      background: linear-gradient(to right, var(--input-bg), color-mix(in srgb, var(--input-bg) 50%, white));
2203      opacity: 0;
2204      transition: opacity 450ms ease;
2205      z-index: 1;
2206    }
2207    .range-slider-wrap.active .range-slider-fill {
2208      opacity: 1;
2209      transition: opacity 100ms ease;
2210    }
2211    .range-slider-wrap input[type="range"] {
2212      position: relative;
2213      z-index: 2;
2214      width: 100%;
2215      flex: 1;
2216      min-width: 0;
2217      background: transparent;
2218      margin: 0;
2219    }
2220    .range-slider-wrap input[type="range"]::-webkit-slider-runnable-track {
2221      background: transparent;
2222    }
2223    .range-slider-wrap input[type="range"]::-moz-range-track {
2224      background: transparent;
2225    }
2226
2227    input[type="range"] {
2228      -webkit-appearance: none;
2229      appearance: none;
2230      height: 8px;
2231      background: var(--input-bg);
2232      border-radius: 4px;
2233      outline: none;
2234      border: none;
2235      box-shadow: none;
2236      padding: 0;
2237    }
2238    input[type="range"]:focus {
2239      outline: none;
2240      box-shadow: none;
2241    }
2242    input[type="range"]:focus-visible {
2243      outline: none;
2244      box-shadow: none;
2245    }
2246
2247    input[type="range"]::-webkit-slider-thumb {
2248      -webkit-appearance: none;
2249      appearance: none;
2250      width: 18px;
2251      height: 18px;
2252      background: #f5f5f5;
2253      border: 1px solid rgba(26, 26, 26, 0.14);
2254      border-radius: 7px;
2255      cursor: pointer;
2256      box-shadow:
2257        inset 0 1px 0 rgba(255, 255, 255, 0.62),
2258        inset 0 -1px 0 rgba(26, 26, 26, 0.1),
2259        0 1px 2px rgba(0, 0, 0, 0.24);
2260    }
2261    input[type="range"]:focus::-webkit-slider-thumb,
2262    input[type="range"]:active::-webkit-slider-thumb {
2263      outline: none;
2264      box-shadow:
2265        inset 0 1px 0 rgba(255, 255, 255, 0.62),
2266        inset 0 -1px 0 rgba(26, 26, 26, 0.1),
2267        0 1px 2px rgba(0, 0, 0, 0.24);
2268    }
2269
2270    input[type="range"]::-moz-range-thumb {
2271      width: 18px;
2272      height: 18px;
2273      background: #f5f5f5;
2274      border: 1px solid rgba(26, 26, 26, 0.14);
2275      border-radius: 7px;
2276      cursor: pointer;
2277      box-shadow:
2278        inset 0 1px 0 rgba(255, 255, 255, 0.62),
2279        inset 0 -1px 0 rgba(26, 26, 26, 0.1),
2280        0 1px 2px rgba(0, 0, 0, 0.24);
2281    }
2282    input[type="range"]:focus::-moz-range-thumb,
2283    input[type="range"]:active::-moz-range-thumb {
2284      outline: none;
2285      box-shadow:
2286        inset 0 1px 0 rgba(255, 255, 255, 0.62),
2287        inset 0 -1px 0 rgba(26, 26, 26, 0.1),
2288        0 1px 2px rgba(0, 0, 0, 0.24);
2289    }
2290
2291    input[type="number"]::-webkit-inner-spin-button,
2292    input[type="number"]::-webkit-outer-spin-button {
2293      -webkit-appearance: none;
2294      appearance: none;
2295      margin: 0;
2296    }
2297
2298    input[type="number"] {
2299      -moz-appearance: textfield;
2300      appearance: textfield;
2301    }
2302
2303    .slider-with-input {
2304      display: flex;
2305      gap: 8px;
2306      align-items: center;
2307    }
2308
2309    label:has(> .slider-with-input) {
2310      display: grid;
2311      grid-template-columns: 64px minmax(0, 1fr);
2312      align-items: center;
2313      gap: 8px 8px;
2314      width: 100%;
2315    }
2316
2317    label:has(> .slider-with-input) > .slider-with-input {
2318      container-type: inline-size;
2319      display: grid !important;
2320      grid-template-columns: minmax(0, 1fr) 42px;
2321      gap: 8px;
2322      min-width: 0;
2323      width: 100% !important;
2324      align-items: center;
2325    }
2326
2327    label:has(> .slider-with-input) .slider-with-input .range-slider-wrap {
2328      width: 100%;
2329      min-width: 0;
2330    }
2331
2332    label:has(> .slider-with-input) .slider-with-input input[type="range"] {
2333      max-width: none !important;
2334      width: calc(100cqw - 50px) !important;
2335      min-width: 0 !important;
2336      flex: none !important;
2337      box-sizing: border-box;
2338      justify-self: stretch;
2339      align-self: center;
2340    }
2341
2342    label:has(> .slider-with-input) .slider-with-input input[type="number"] {
2343      width: 100% !important;
2344      flex: none !important;
2345      height: 27px !important;
2346      padding: 0 4px !important;
2347      font-size: 12px;
2348      box-sizing: border-box;
2349      text-align: center;
2350    }
2351
2352    .blend-mode-buttons {
2353      display: grid;
2354      grid-template-columns: repeat(6, 1fr);
2355      gap: 6px;
2356      pointer-events: none;
2357    }
2358
2359    .blend-mode-buttons > .blend-btn {
2360      pointer-events: auto;
2361    }
2362
2363    label > .blend-mode-buttons {
2364      pointer-events: none;
2365    }
2366
2367    label > .blend-mode-buttons > .blend-btn {
2368      pointer-events: auto;
2369    }
2370
2371    .blend-btn {
2372      aspect-ratio: 1;
2373      border: 1px solid var(--input-border);
2374      background: var(--input-bg);
2375      border-radius: 8px;
2376      cursor: pointer;
2377      display: flex;
2378      align-items: center;
2379      justify-content: center;
2380      padding: 0;
2381      transition: all 0.15s ease;
2382      color: var(--text);
2383    }
2384
2385    .blend-btn:hover {
2386      background: #2a2a2a;
2387      border-color: var(--input-border-hover);
2388      border-width: 1px;
2389    }
2390
2391    #blendModeButtons > .blend-btn:hover .material-icons,
2392    #blendModeButtons > .blend-btn:hover .material-symbols-outlined {
2393      filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.48)) !important;
2394    }
2395
2396
2397    .gradient-controls .blend-btn:hover .material-icons,
2398    .gradient-controls .blend-btn:hover .material-symbols-outlined {
2399      filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.48));
2400    }
2401
2402    .blend-btn.active {
2403      background: #f5f5f5;
2404      border-color: #f5f5f5;
2405      color: #1a1a1a;
2406    }
2407
2408    .blend-btn.active .material-icons {
2409      color: #1a1a1a;
2410    }
2411
2412    .blend-btn.disabled {
2413      opacity: 0.4;
2414      cursor: not-allowed;
2415      pointer-events: none;
2416    }
2417
2418    .blend-btn.disabled:hover {
2419      background: var(--input-bg);
2420      border-color: var(--input-border);
2421    }
2422
2423    .blend-btn .material-icons,
2424    .blend-btn .material-symbols-outlined {
2425      font-size: 18px;
2426    }
2427
2428    input:disabled,
2429    input[disabled] {
2430      opacity: 0.4;
2431      cursor: not-allowed;
2432    }
2433
2434    input[type="range"]:disabled {
2435      opacity: 0.4;
2436      cursor: not-allowed;
2437    }
2438
2439    input[type="range"]:disabled::-webkit-slider-thumb {
2440      cursor: not-allowed;
2441      opacity: 0.4;
2442    }
2443
2444    input[type="range"]:disabled::-moz-range-thumb {
2445      cursor: not-allowed;
2446      opacity: 0.4;
2447    }
2448
2449    label.disabled {
2450      opacity: 0.4;
2451      pointer-events: none;
2452    }
2453
2454    label.disabled input,
2455    label.disabled input[type="range"] {
2456      opacity: 0.4;
2457      cursor: not-allowed;
2458    }
2459
2460    .grid {
2461      display: grid;
2462      grid-template-columns: 1fr;
2463      gap: 8px;
2464    }
2465
2466    #gradientCanvas,
2467    #xCurveCanvas,
2468    #yCurveCanvas {
2469      display: block;
2470      position: absolute;
2471      top: 0;
2472      left: 0;
2473      contain: strict;
2474      clip-path: inset(0);
2475    }
2476
2477    #gradientCanvas {
2478      pointer-events: none;
2479      image-rendering: -moz-crisp-edges;
2480      image-rendering: -webkit-crisp-edges;
2481      image-rendering: pixelated;
2482      image-rendering: crisp-edges;
2483    }
2484
2485    #xCurveCanvas,
2486    #yCurveCanvas {
2487      z-index: 1;
2488    }
2489
2490    #xCurveCanvas,
2491    #yCurveCanvas {
2492      pointer-events: none;
2493      /* re-enabled for active curve */
2494    }
2495
2496    #xCurveCanvas.curves-enabled,
2497    #yCurveCanvas.curves-enabled {
2498      pointer-events: all;
2499    }
2500    
2501    /* Ensure active curve is on top and receives events */
2502    #xCurveCanvas[style*="z-index: 10"],
2503    #yCurveCanvas[style*="z-index: 10"] {
2504      pointer-events: all !important;
2505    }
2506
2507    #yCurveCanvas {
2508      mix-blend-mode: normal; /* Changed from multiply to normal for visibility */
2509      isolation: isolate;
2510    }
2511
2512    #xCurveCanvas {
2513      mix-blend-mode: normal; /* Changed from screen to normal for visibility */
2514      isolation: isolate;
2515    }
2516    
2517    /* When curve is active, use normal blend mode */
2518    #yCurveCanvas.curves-enabled.active-curve {
2519      mix-blend-mode: normal;
2520    }
2521
2522    #xCurveCanvas.curves-enabled.active-curve {
2523      mix-blend-mode: normal;
2524    }
2525    
2526    /* Inactive curves should also use normal blend mode for visibility */
2527    #yCurveCanvas.curves-enabled:not(.active-curve) {
2528      mix-blend-mode: normal;
2529    }
2530
2531    #xCurveCanvas.curves-enabled:not(.active-curve) {
2532      mix-blend-mode: normal;
2533    }
2534
2535    .legend {
2536      display: flex;
2537      justify-content: space-between;
2538      font-size: 12px;
2539      color: var(--text-secondary);
2540      padding: 0 4px;
2541    }
2542
2543    .palette-grid {
2544      display: grid;
2545      grid-template-columns: repeat(8, 1fr);
2546      gap: 4px;
2547    }
2548
2549    .swatch {
2550      width: 26px;
2551      height: 26px;
2552      border-radius: 6px;
2553      cursor: pointer;
2554      border: 1px solid rgba(0, 0, 0, 0.08);
2555      transition: transform 0.08s ease;
2556    }
2557
2558    .swatch:hover {
2559      transform: scale(1.06);
2560    }
2561
2562    .row {
2563      display: flex;
2564      gap: 8px;
2565      align-items: center;
2566      flex-wrap: wrap;
2567    }
2568
2569    /* iOS-style switch */
2570    .ios-switch {
2571      position: relative;
2572      display: inline-block;
2573      width: 44px;
2574      height: 26px;
2575      flex-shrink: 0;
2576    }
2577
2578    .ios-switch input {
2579      opacity: 0;
2580      width: 0;
2581      height: 0;
2582    }
2583
2584    .ios-switch-slider {
2585      position: absolute;
2586      cursor: pointer;
2587      top: 0;
2588      left: 0;
2589      right: 0;
2590      bottom: 0;
2591      background-color: rgba(255, 255, 255, 0.2);
2592      transition: 0.3s;
2593      border-radius: 26px;
2594    }
2595
2596    .ios-switch-slider:before {
2597      position: absolute;
2598      content: "";
2599      height: 20px;
2600      width: 20px;
2601      left: 3px;
2602      bottom: 3px;
2603      background-color: white;
2604      transition: 0.3s;
2605      border-radius: 50%;
2606      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
2607    }
2608
2609    .ios-switch input:checked + .ios-switch-slider {
2610      background-color: #f5f5f5;
2611    }
2612
2613    .ios-switch input:checked + .ios-switch-slider:before {
2614      transform: translateX(18px);
2615      background-color: #1a1a1a;
2616    }
2617
2618    .ios-switch input:focus + .ios-switch-slider {
2619      box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3);
2620    }
2621
2622    .pill {
2623      padding: 6px 8px;
2624      border-radius: 10px;
2625      background: var(--input-bg);
2626      border: 1px solid var(--input-border);
2627      font-size: 12px;
2628      font-family: inherit;
2629      font-weight: 400;
2630      color: var(--text);
2631    }
2632
2633    .note {
2634      font-size: 12px;
2635      color: var(--text-secondary);
2636    }
2637
2638    .toggle-btn {
2639      border-radius: 999px;
2640      border: 1px solid var(--input-border);
2641      padding: 4px 10px;
2642      font-size: 11px;
2643      font-family: inherit;
2644      font-weight: 500;
2645      background: var(--input-bg);
2646      color: var(--text);
2647      cursor: pointer;
2648      transition: all 0.15s ease;
2649    }
2650
2651    .toggle-btn .material-icons,
2652    .toggle-btn span:not(.material-icons) {
2653      filter: drop-shadow(0 0 0px rgba(255, 255, 255, 0));
2654      text-shadow: 0 0 0px rgba(255, 255, 255, 0);
2655      transition: filter 0.1s ease, text-shadow 0.1s ease;
2656    }
2657
2658    .toggle-btn:hover {
2659      background: #2a2a2a;
2660      border-color: var(--input-border-hover);
2661      border-width: 1px;
2662    }
2663
2664    .toggle-btn:hover .material-icons,
2665    .toggle-btn:hover span:not(.material-icons) {
2666      filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.48));
2667      text-shadow: 0 0 12px rgba(255, 255, 255, 0.48);
2668    }
2669
2670    .toggle-btn.on {
2671      background: #f5f5f5;
2672      color: #1a1a1a;
2673      border-color: transparent;
2674    }
2675
2676    .toggle-btn.on:hover {
2677      background: #e8e8e8;
2678    }
2679
2680    .toggle-btn.on:hover .material-icons,
2681    .toggle-btn.on:hover span:not(.material-icons) {
2682      filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.48));
2683      text-shadow: 0 0 12px rgba(255, 255, 255, 0.48);
2684    }
2685
2686    .toggle-btn.on .material-icons {
2687      color: #1a1a1a;
2688    }
2689
2690    .curve-toggle-group {
2691      display: inline-flex;
2692      border-radius: 999px;
2693      border: 1px solid var(--input-border);
2694      overflow: hidden;
2695      background: var(--input-bg);
2696    }
2697
2698    .curve-toggle {
2699      border: none;
2700      padding: 8px 16px;
2701      font-size: 14px;
2702      font-family: inherit;
2703      font-weight: 500;
2704      background: transparent;
2705      cursor: pointer;
2706      color: var(--text);
2707      transition: all 0.15s ease;
2708    }
2709
2710    .curve-toggle .material-symbols-outlined {
2711      font-size: 18px;
2712      vertical-align: middle;
2713      font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
2714    }
2715
2716    .curve-toggle .material-icons,
2717    .curve-toggle .material-symbols-outlined,
2718    .curve-toggle span:not(.material-icons):not(.material-symbols-outlined) {
2719      filter: drop-shadow(0 0 0px rgba(255, 255, 255, 0));
2720      text-shadow: 0 0 0px rgba(255, 255, 255, 0);
2721      transition: filter 0.1s ease, text-shadow 0.1s ease;
2722    }
2723
2724    .curve-toggle:hover {
2725      background: rgba(255, 255, 255, 0.05);
2726    }
2727
2728    .curve-toggle:hover .material-icons,
2729    .curve-toggle:hover .material-symbols-outlined,
2730    .curve-toggle:hover span:not(.material-icons):not(.material-symbols-outlined) {
2731      filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.48));
2732      text-shadow: 0 0 12px rgba(255, 255, 255, 0.48);
2733    }
2734
2735    .curve-toggle.active {
2736      background: #f5f5f5;
2737      color: #1a1a1a;
2738    }
2739
2740    .curve-toggle.active:hover {
2741      background: #e8e8e8;
2742    }
2743
2744    .curve-toggle.active:hover .material-icons,
2745    .curve-toggle.active:hover .material-symbols-outlined,
2746    .curve-toggle.active:hover span:not(.material-icons):not(.material-symbols-outlined) {
2747      filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.48));
2748      text-shadow: 0 0 12px rgba(255, 255, 255, 0.48);
2749    }
2750
2751    .gradient-picker-section {
2752      display: flex;
2753      flex-direction: column;
2754      gap: 4px;
2755    }
2756
2757    .gradient-bar-container {
2758      position: relative;
2759      width: 100%;
2760      height: 40px;
2761      border-radius: 8px;
2762      overflow: hidden;
2763      border: 1px solid var(--input-border);
2764      cursor: pointer;
2765      background: linear-gradient(to right, #3b6bff, #8b5cf6);
2766    }
2767
2768    .gradient-bar {
2769      width: 100%;
2770      height: 100%;
2771      position: relative;
2772    }
2773
2774    .gradient-stops {
2775      position: absolute;
2776      top: 0;
2777      left: 0;
2778      width: 100%;
2779      height: 100%;
2780      pointer-events: none;
2781    }
2782
2783    .gradient-stop {
2784      position: absolute;
2785      width: 24px;
2786      height: 32px;
2787      border: 2px solid rgba(255, 255, 255, 0.6);
2788      border-radius: 4px;
2789      box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2);
2790      cursor: pointer;
2791      pointer-events: all;
2792      transform: translate(-50%, -50%);
2793      top: 50%;
2794      transition: transform 0.1s ease, border-width 0.1s ease, border-color 0.1s ease;
2795    }
2796
2797    .gradient-stop:hover {
2798      transform: translate(var(--stop-transform-x, -50%), -50%) scale(1.1);
2799    }
2800
2801    .gradient-stop.selected {
2802      border: 4px solid white;
2803      box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2);
2804    }
2805
2806    .gradient-controls {
2807      display: flex;
2808      align-items: center;
2809      justify-content: space-between;
2810      gap: 8px;
2811      width: 100%;
2812      margin-top: 4px;
2813    }
2814
2815    /* Keep Color actions as compact icon controls, matching blend-mode chevrons. */
2816    #invertGradientBtn,
2817    #redistributeStopsBtn,
2818    #shuffleColorsBtn,
2819    #removeStopBtn {
2820      width: 32px;
2821      height: 32px;
2822      aspect-ratio: auto;
2823    }
2824
2825    #invertGradientBtn .material-icons,
2826    #redistributeStopsBtn .material-symbols-outlined,
2827    #shuffleColorsBtn .material-icons,
2828    #removeStopBtn .material-symbols-outlined {
2829      font-size: 18px;
2830    }
2831
2832    #removeStopBtn .material-symbols-outlined {
2833      font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
2834    }
2835    
2836    .gradient-controls-left {
2837      display: flex;
2838      align-items: center;
2839      gap: 8px;
2840    }
2841    
2842    .gradient-controls-left .blend-btn:nth-child(1) {
2843      grid-column: 1 / 2;
2844    }
2845    
2846    .gradient-controls-left .blend-btn:nth-child(2) {
2847      grid-column: 2 / 3;
2848    }
2849    
2850    .gradient-controls-left .blend-btn:nth-child(3) {
2851      grid-column: 3 / 4;
2852    }
2853    
2854    .gradient-controls-right {
2855      display: flex;
2856      align-items: center;
2857    }
2858    
2859    .gradient-controls-right .blend-btn {
2860      grid-column: 6 / 7;
2861      justify-self: end;
2862    }
2863
2864    .btn .material-icons,
2865    .toggle-btn .material-icons {
2866      font-size: 16px;
2867      vertical-align: middle;
2868      margin-right: 4px;
2869      line-height: 1;
2870    }
2871
2872    .btn.secondary .material-icons {
2873      font-size: 14px;
2874    }
2875
2876    .btn .material-icons:only-child,
2877    .toggle-btn .material-icons:only-child {
2878      margin-right: 0;
2879    }
2880
2881    .spectrum-container {
2882      position: relative;
2883      width: 100%;
2884      height: 200px;
2885      border-radius: 8px;
2886      overflow: hidden;
2887      border: 1px solid var(--input-border);
2888      cursor: crosshair;
2889    }
2890
2891    #stopSpectrumCanvas, #patternSpectrumCanvas {
2892      width: 100%;
2893      height: 100%;
2894      display: block;
2895    }
2896
2897    .spectrum-marker {
2898      position: absolute;
2899      width: 12px;
2900      height: 12px;
2901      border: 2px solid white;
2902      border-radius: 50%;
2903      pointer-events: none;
2904      box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
2905      transform: translate(-50%, -50%);
2906    }
2907
2908    .hue-slider-container {
2909      position: relative;
2910      width: 20px;
2911      height: 200px;
2912      border-radius: 8px;
2913      overflow: hidden;
2914      border: 1px solid var(--input-border);
2915      cursor: pointer;
2916      flex-shrink: 0;
2917    }
2918
2919    #stopHueCanvas, #patternHueCanvas {
2920      width: 100%;
2921      height: 100%;
2922      display: block;
2923    }
2924
2925    .hue-marker {
2926      position: absolute;
2927      left: 0;
2928      width: 100%;
2929      height: 4px;
2930      background: white;
2931      border: 1px solid rgba(0, 0, 0, 0.3);
2932      pointer-events: none;
2933      transform: translateY(-50%);
2934    }
2935
2936    /* Pattern color picker (Figma-style: preview + hex, click swatch shows spectrum + hue inline) */
2937    .pattern-color-row {
2938      display: flex;
2939      align-items: center;
2940      gap: 8px;
2941      width: 100%;
2942    }
2943    .pattern-color-swatch {
2944      width: 36px;
2945      height: 32px;
2946      border-radius: 8px;
2947      border: 1px solid var(--input-border);
2948      background: #ffffff;
2949      cursor: pointer;
2950      flex-shrink: 0;
2951    }
2952    .pattern-color-hex-input {
2953      flex: 1;
2954      min-width: 0;
2955      padding: 8px 10px;
2956      border-radius: 6px;
2957      border: 1px solid #252525;
2958      background: #0d0d0d;
2959      color: var(--text);
2960      font-size: 13px;
2961      font-family: Monaco, Menlo, monospace;
2962    }
2963    .pattern-color-hex-input:hover {
2964      border-color: #2e2e2e;
2965    }
2966    .pattern-color-hex-input:focus {
2967      background: #0d0d0d;
2968      border-color: #f5f5f5;
2969    }
2970    .pattern-color-dropdown {
2971      display: none;
2972      position: fixed;
2973      z-index: 2000;
2974      width: 240px;
2975      box-sizing: border-box;
2976      padding: 10px;
2977      background: var(--panel);
2978      border: 1px solid var(--stroke);
2979      border-radius: 8px;
2980      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
2981    }
2982    .pattern-color-dropdown.visible {
2983      display: block;
2984    }
2985    @keyframes menu-enter {
2986      from { opacity: 0; transform: translateY(-4px); }
2987      to { opacity: 1; transform: translateY(0); }
2988    }
2989    @keyframes picker-enter {
2990      from { opacity: 0; }
2991      to { opacity: 1; }
2992    }
2993    @media (prefers-reduced-motion: no-preference) {
2994      .export-format-menu:not([hidden]) {
2995        animation: menu-enter 120ms cubic-bezier(.2, .8, .2, 1);
2996      }
2997      .pattern-color-dropdown.visible {
2998        animation: picker-enter 120ms ease-out;
2999      }
3000    }
3001
3002    .pattern-color-dropdown-header {
3003      display: flex;
3004      align-items: center;
3005      justify-content: space-between;
3006      margin-bottom: 8px;
3007      font-size: 12px;
3008      color: var(--text);
3009    }
3010    .pattern-color-picker-inner {
3011      display: flex;
3012      flex-direction: row;
3013      gap: 12px;
3014      align-items: flex-start;
3015    }
3016    .pattern-spectrum-container {
3017      flex: 1;
3018      min-width: 0;
3019      height: 200px;
3020    }
3021    .pattern-hue-container {
3022      width: 20px;
3023      height: 200px;
3024      flex-shrink: 0;
3025    }
3026
3027    .floating-history {
3028      position: relative;
3029      z-index: 1001;
3030      background: rgba(26, 26, 26, 0.9);
3031      padding: 8px;
3032      border-radius: 12px;
3033      backdrop-filter: blur(8px);
3034      border: 1px solid var(--stroke);
3035      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
3036      display: flex;
3037      gap: 8px;
3038      align-items: center;
3039      box-sizing: border-box;
3040    }
3041
3042    .floating-curves {
3043      position: relative;
3044      z-index: 1000;
3045      padding: 0;
3046      margin: 0;
3047      display: flex;
3048      gap: 8px;
3049      align-items: center;
3050      box-sizing: border-box;
3051    }
3052    
3053    .floating-export {
3054      position: relative;
3055      z-index: 1000;
3056      padding: 0;
3057      display: flex;
3058      gap: 8px;
3059      align-items: center;
3060      box-sizing: border-box;
3061      width: auto;
3062    }
3063
3064    .floating-export-count {
3065      position: fixed;
3066      z-index: 10000;
3067      background: rgba(26, 26, 26, 0.95);
3068      border-radius: 12px;
3069      backdrop-filter: blur(8px);
3070      border: 1px solid var(--stroke);
3071      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
3072      box-sizing: border-box;
3073      min-width: 0;
3074      width: max-content;
3075      display: none;
3076      align-items: center;
3077      gap: 8px;
3078      padding: 12px;
3079      pointer-events: auto;
3080      white-space: normal;
3081    }
3082    
3083    #exportCountText {
3084      min-width: 0;
3085      overflow-wrap: anywhere;
3086      line-height: 1.4;
3087    }
3088
3089    .floating-export-count.show {
3090      display: flex !important;
3091    }
3092    
3093    .floating-export-count .close-btn {
3094      background: transparent;
3095      border: none;
3096      color: var(--text-secondary);
3097      cursor: pointer;
3098      padding: 4px;
3099      display: flex;
3100      align-items: center;
3101      justify-content: center;
3102      border-radius: 4px;
3103      transition: all 0.15s ease;
3104      flex-shrink: 0;
3105      width: 24px;
3106      height: 24px;
3107    }
3108    
3109    .floating-export-count .close-btn:hover {
3110      background: rgba(255, 255, 255, 0.1);
3111      color: var(--text);
3112    }
3113    
3114    .floating-export-count .close-btn .material-icons {
3115      font-size: 18px;
3116    }
3117
3118    /* Share toast notification */
3119    .share-toast {
3120      position: fixed;
3121      top: 80px;
3122      right: 20px;
3123      background: rgba(26, 26, 26, 0.95);
3124      border: 1px solid var(--stroke);
3125      border-radius: 12px;
3126      padding: 12px 20px;
3127      display: flex;
3128      align-items: center;
3129      gap: 12px;
3130      z-index: 10001;
3131      opacity: 0;
3132      transform: translateY(-10px);
3133      transition: opacity 0.3s ease, transform 0.3s ease;
3134      pointer-events: none;
3135      backdrop-filter: blur(8px);
3136      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
3137    }
3138
3139    .share-toast.show {
3140      opacity: 1;
3141      transform: translateY(0);
3142      pointer-events: auto;
3143    }
3144
3145    .share-toast .material-icons {
3146      font-size: 20px;
3147      color: #4CAF50;
3148    }
3149
3150    .share-toast span {
3151      font-size: 14px;
3152      color: var(--text);
3153      font-weight: 500;
3154    }
3155
3156    /* Responsive layout for smaller desktop screens */
3157    @media (max-width: 1600px) {
3158      .floating-curves {
3159        max-width: 100%;
3160        align-self: start;
3161      }
3162    }
3163
3164    /* Stick export to the right on smaller screens so it stays visible */
3165    @media (max-width: 1400px) {
3166      .floating-export {
3167        position: fixed;
3168        right: 20px;
3169        top: 80px;
3170        z-index: 1001;
3171        background: var(--panel);
3172      border: 1px solid var(--stroke);
3173        border-radius: 8px;
3174        padding: 6px 10px;
3175      }
3176    }
3177    .cookie-banner {
3178      position: fixed;
3179      bottom: 0;
3180      left: 0;
3181      right: 0;
3182      z-index: 9999;
3183      background: var(--panel);
3184      border-top: 1px solid rgba(245, 245, 240, 0.12);
3185      padding: 16px 24px;
3186      display: flex;
3187      flex-wrap: wrap;
3188      align-items: center;
3189      justify-content: center;
3190      gap: 16px;
3191      box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
3192    }
3193    .cookie-banner.hidden { display: none; }
3194    .cookie-banner p {
3195      margin: 0;
3196      font-size: 14px;
3197      color: var(--text-secondary);
3198      line-height: 1.5;
3199    }
3200    .cookie-banner-actions { display: flex; gap: 12px; flex-shrink: 0; }
3201    .cookie-banner button {
3202      padding: 10px 20px;
3203      border-radius: 8px;
3204      font-size: 14px;
3205      font-weight: 600;
3206      cursor: pointer;
3207      border: none;
3208      font-family: inherit;
3209    }
3210    .cookie-banner .cookie-accept {
3211      background: var(--primary);
3212      color: #0a0a0a;
3213    }
3214    .cookie-banner .cookie-accept:hover { opacity: 0.95; }
3215    .cookie-banner .cookie-necessary {
3216      background: transparent;
3217      color: var(--text-secondary);
3218      border: 1px solid rgba(245, 245, 240, 0.3);
3219    }
3220    .cookie-banner .cookie-necessary:hover { background: rgba(245, 245, 240, 0.08); color: var(--primary); }
3221  </style>
3222  
3223  
3223<script>
3224    window.dataLayer = window.dataLayer || [];
3225    function gtag(){dataLayer.push(arguments);}
3226    var colirCookieConsent = localStorage.getItem('colir_cookie_consent');
3227    if (colirCookieConsent === 'all') {
3228      var s = document.createElement('script');
3229      s.async = true;
3230      s.src = 'https://www.googletagmanager.com/gtag/js?id=G-PGS3X1TPPZ';
3231      document.head.appendChild(s);
3232    gtag('js', new Date());
3233    gtag('config', 'G-PGS3X1TPPZ');
3234    }
3235  </script>
3235
3236  
3237  <!-- Google Sign-In -->
3238  
3238<script src="https://accounts.google.com/gsi/client" async defer data-auto_select="false" data-cancel_on_tap_outside="true"></script>
3238
3239  <link rel="stylesheet" href="animation/animation.css?v=flow-travel-20260916" />
3240</head>
3241
3242<body>
3243  <aside id="maintenanceNotice" class="maintenance-notice" popover="manual" role="status" aria-label="A note on loading">
3244    <strong>A note on loading</strong>
3245    <p>Some effects may take a little longer to become available when you first open Colir. You can keep working while they prepare.</p>
3246    <p>Something not working? Contact us at [email protected].</p>
3247    <button type="button" id="maintenanceDismiss" aria-label="Close loading notice">×</button>
3248  </aside>
3249  
3249<script>
3250    (() => {
3251      const notice = document.getElementById('maintenanceNotice');
3252      const storageKey = 'colir-loading-notice-v1-dismissed';
3253      const dismiss = () => { notice.hidePopover?.(); notice.hidden = true; try { localStorage.setItem(storageKey, '1'); } catch (_) {} };
3254      document.getElementById('maintenanceDismiss').addEventListener('click', dismiss);
3255      document.addEventListener('keydown', event => {
3256        if (event.key === 'Escape' && !notice.hidden && (!notice.showPopover || notice.matches(':popover-open'))) {
3257          event.preventDefault(); dismiss();
3258        }
3259      });
3260      let dismissed = false;
3261      try { dismissed = localStorage.getItem(storageKey) === '1'; } catch (_) {}
3262      if (dismissed) notice.hidden = true;
3263      else notice.showPopover?.();
3264    })();
3265  </script>
3265
3266  <div class="colir-pre" id="colirPreloader" aria-hidden="true">
3267    <div class="colir-pre__sheet"></div>
3268    <div class="colir-pre__veil"></div>
3269    <svg class="colir-pre__logo" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
3270      <path d="M115.81 55.81H95.6726C78.2828 55.81 64.19 41.7172 64.19 24.3274V4.19004C64.19 1.96909 62.4614 0.171723 60.2862 0.0228964V0C60.1946 0 60.0916 0 60 0C27.0864 0 0 27.0864 0 60C0 92.9136 27.0864 120 60 120C62.3125 120 64.19 118.123 64.19 115.81V95.6726C64.19 78.2828 78.2828 64.19 95.6726 64.19H115.81C118.123 64.19 120 62.3125 120 60C120 57.6875 118.123 55.81 115.81 55.81Z" />
3271    </svg>
3272  </div>
3273  
3273<script>
3274    window.__colirPreloader = (function () {
3275      var root = document.getElementById('colirPreloader');
3276      if (!root) return { done: function () {} };
3277
3278      // Flagged in <head> for template and share loads; stay completely inert.
3279      if (document.documentElement.classList.contains('colir-pre-skip')) {
3280        if (root.parentNode) root.parentNode.removeChild(root);
3281        return { done: function () {} };
3282      }
3283
3284      var logo = root.querySelector('.colir-pre__logo');
3285      var startedAt = Date.now();
3286      var finished = false;
3287
3288      var MIN_VISIBLE = 750;
3289      var FAILSAFE = 31000;
3290
3291      var reduceMotion = false;
3292      try {
3293        reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
3294      } catch (e) {}
3295
3296      // The cut-out depends on compositing two mask layers; fall back to a plain
3297      // fade anywhere that isn't supported.
3298      var canCutOut = false;
3299      try {
3300        canCutOut = CSS.supports('mask-composite', 'exclude') ||
3301                    CSS.supports('-webkit-mask-composite', 'xor');
3302      } catch (e) {}
3303
3304      function remove() {
3305        if (root && root.parentNode) root.parentNode.removeChild(root);
3306        root = null;
3307      }
3308
3309      function fadeOut() {
3310        if (!root) return;
3311        root.classList.add('is-fading');
3312        setTimeout(remove, 400);
3313      }
3314
3315      function reveal() {
3316        if (!root) return;
3317        if (reduceMotion || !canCutOut) {
3318          fadeOut();
3319          return;
3320        }
3321
3322        // Freeze the breathing loop wherever it currently is, then settle back to
3323        // 1:1 so the drawn logo and the cut-out line up exactly at the swap.
3324        var current = '';
3325        try { current = window.getComputedStyle(logo).transform; } catch (e) {}
3326        logo.style.animation = 'none';
3327        logo.style.transform = (current && current !== 'none') ? current : 'scale(1)';
3328        void logo.offsetWidth;
3329        logo.style.transition = 'transform 150ms cubic-bezier(0.4, 0, 0.2, 1)';
3330        logo.style.transform = 'scale(1)';
3331
3332        setTimeout(function () {
3333          if (!root) return;
3334          // Same shape, same size, same position, with the off-white sheet behind
3335          // it, so swapping the drawn logo for the hole is pixel-identical.
3336          root.classList.add('is-revealing');
3337          setTimeout(remove, 900);
3338        }, 170);
3339      }
3340
3341      function done() {
3342        if (finished) return;
3343        finished = true;
3344        setTimeout(reveal, Math.max(0, MIN_VISIBLE - (Date.now() - startedAt)));
3345      }
3346
3347      // Four independent ways out, so a failure during startup can never leave
3348      // the user stranded behind the overlay.
3349      setTimeout(done, FAILSAFE);
3350      // Graphics readiness completes startup; load alone is insufficient.
3351      window.addEventListener('error', done);
3352      window.addEventListener('unhandledrejection', done);
3353
3354      return { done: done };
3355    })();
3356  </script>
3356
3357  <div id="cookieBanner" class="cookie-banner" role="dialog" aria-label="Cookie consent">
3357<script>if(localStorage.getItem('colir_cookie_consent'))document.getElementById('cookieBanner').classList.add('hidden');</script>
3357
3358    <p>We use necessary cookies and, with your consent, analytics. You can accept all or only necessary.</p>
3359    <div class="cookie-banner-actions">
3360      <button type="button" class="cookie-accept" id="cookieAccept">Accept</button>
3361      <button type="button" class="cookie-necessary" id="cookieNecessary">Only necessary</button>
3362    </div>
3363  </div>
3364  <div class="mobile-message">
3365    <img src="/Logos/logo.svg" alt="Colir" class="logo" />
3366    <h1>Colir</h1>
3367    <p>This tool works on desktop for now. Please visit on a desktop or laptop computer.</p>
3368  </div>
3369
3370  <div class="sidebar">
3371    <div class="shell">
3372      <header>
3373        <h1 style="display:flex; align-items:center; gap:12px;">
3374          <a href="/" target="_blank" rel="noopener" title="Colir home" style="display:inline-flex; align-items:center; gap:12px; color:inherit; text-decoration:none; cursor:pointer;">
3375            <img src="/Logos/logo.svg" alt="Colir" style="height:32px; width:auto;" />
3376            Colir
3377          </a>
3378          <span id="appProTag" class="app-pro-tag" style="display: none;">Pro</span>
3379        </h1>
3380        <div class="row" style="display:flex; align-items:center; gap:12px;">
3381          <a id="appUserProfile" href="/account/" style="display: none; padding: 6px; background: var(--panel); border: 1px solid var(--stroke); border-radius: 999px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 0; font-size: 12px; text-decoration: none; color: inherit;" title="Account">
3382            <span id="appUserAvatarWrap" style="position: relative; width: 28px; height: 28px; flex-shrink: 0;">
3383              <img id="appUserAvatar" alt="" style="width: 28px; height: 28px; border-radius: 50%; object-fit: cover; display: none;" />
3384              <span id="appUserAvatarFallback" style="display: none; width: 28px; height: 28px; border-radius: 50%; background: var(--stroke); color: var(--text); font-size: 11px; font-weight: 600; line-height: 28px; text-align: center; user-select: none;" aria-hidden="true"></span>
3385            </span>
3386          </a>
3387          <button id="appLoginButton" class="app-login-btn" style="display: none;" type="button" onclick="openAppLoginModal()">Login</button>
3388        </div>
3389      </header>
3390      <div id="appRailModes" class="animation-mode-switch" role="tablist" aria-label="Editor mode"></div>
3391
3392      <div class="panel">
3393        <button class="blend-btn" id="templatesBtn" title="Open Curated Templates" style="width:100%; aspect-ratio: auto; height: auto; padding: 8px 12px; display: flex; align-items: center; justify-content: center; gap: 6px; margin-bottom: 12px;">
3394          <span class="material-icons" style="font-size:18px;" data-icon="dashboard_customize" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M384 277V341H448V384H384V448H341V384H277V341H341V277ZM64 277H235V448H64ZM277 64H448V235H277ZM64 64H235V235H64Z"></path></svg></span>
3395          <span style="font-size:12px;">Templates</span>
3396        </button>
3397        <h3>Canvas</h3>
3398        <div class="controls">
3399          <div style="display:flex; flex-direction:row; gap:12px; align-items:flex-start; width:100%;">
3400            <label style="margin:0; flex:1;">Width
3401              <div class="canvas-size-field">
3402                <input type="number" id="widthInput" value="900" min="200" max="2000" />
3403                <span class="canvas-size-unit" aria-hidden="true">px</span>
3404              </div>
3405            </label>
3406            <label style="margin:0; flex:1;">Height
3407              <div class="canvas-size-field">
3408                <input type="number" id="heightInput" value="520" min="200" max="2000" />
3409                <span class="canvas-size-unit" aria-hidden="true">px</span>
3410              </div>
3411            </label>
3412          </div>
3413          <label>Global blend
3414            <div style="display: flex; align-items: center; gap: 8px; width: 100%;">
3415              <button class="blend-btn" id="blendModePrev" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Previous blend mode" aria-label="Previous blend mode">
3416                <span class="material-icons" style="font-size: 18px;" data-icon="chevron_left" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M329 158 231 256 329 354 299 384 171 256 299 128Z"></path></svg></span>
3417              </button>
3418              <div style="flex: 1; position: relative;">
3419                <select id="blendMode" style="width: 100%; padding: 8px 32px 8px 32px; border-radius: 6px; border: 1px solid var(--input-border); background: var(--input-bg); color: var(--text); font-size: 12px; cursor: pointer; appearance: none; -webkit-appearance: none; -moz-appearance: none; background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 12 12%22%3E%3Cpath fill=%22%23ffffff%22 d=%22M6 9L1 4h10z%22/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: right 12px center;">
3420              <option value="avg">Average</option>
3421              <option value="mul">Multiply</option>
3422              <option value="screen">Screen</option>
3423              <option value="overlay">Overlay</option>
3424              <option value="softlight">Soft Light</option>
3425              <option value="hardlight">Hard Light</option>
3426              <option value="difference" selected>Difference</option>
3427              <option value="exclusion">Exclusion</option>
3428              <option value="darken">Darken</option>
3429              <option value="lighten">Lighten</option>
3430              <option value="colorburn">Color Burn</option>
3431              <option value="colordodge">Color Dodge</option>
3432            </select>
3433                <span id="blendModeIcon" class="material-icons" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 18px; pointer-events: none; color: var(--text);" data-icon="compare" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false">
3433<path d="M405 64Q422 64 435.0 77.0Q448 90 448 107V405Q448 422 435.0 435.0Q422 448 405 448H299V256L405 384V107H299V64ZM213 384V256L107 384ZM213 64V21H256V491H213V448H107Q90 448 77.0 435.0Q64 422 64 405V107Q64 90 77.0 77.0Q90 64 107 64Z"></path></svg></span>
3434              </div>
3435              <button class="blend-btn" id="blendModeNext" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Next blend mode" aria-label="Next blend mode">
3436                <span class="material-icons" style="font-size: 18px;" data-icon="chevron_right" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M213 128 341 256 213 384 183 354 281 256 183 158Z"></path></svg></span>
3437              </button>
3438            </div>
3439          </label>
3440          <label>Feather
3441            <div class="slider-with-input" style="width:100%;">
3442            <input type="range" id="featherInput" value="18" min="1" max="40" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3443              <input type="number" id="featherValue" value="18" min="1" max="40" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3444            </div>
3445          </label>
3446          <label>Noise
3447            <div class="slider-with-input" style="width:100%;">
3448              <input type="range" id="noiseInput" value="6" min="0" max="30" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3449              <input type="number" id="noiseValue" value="6" min="0" max="30" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3450            </div>
3451          </label>
3452        </div>
3453        </div>
3454
3455      <div class="panel">
3456        <h3>Colors</h3>
3457        <div class="gradient-picker-section">
3458          <div class="gradient-bar-container" style="margin-top:0;">
3459            <div class="gradient-bar" id="gradientBar"></div>
3460            <div class="gradient-stops" id="gradientStops"></div>
3461          </div>
3462          <div class="gradient-controls">
3463            <div class="gradient-controls-left">
3464              <button class="blend-btn" id="invertGradientBtn" title="Invert Gradient" aria-label="Invert Gradient"><span class="material-icons" data-icon="swap_horiz" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M448 192 363 277V213H213V171H363V107ZM149 235V299H299V341H149V405L64 320Z"></path></svg></span></button>
3465              <button class="blend-btn" id="redistributeStopsBtn" title="Redistribute Stops Evenly" aria-label="Redistribute Stops Evenly"><span class="material-symbols-outlined" data-icon="symbols:align_justify_space_even" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960" aria-hidden="true" focusable="false"><path d="M800 880V80H880V880ZM80 880V80H160V880ZM560 680V280H680V680ZM280 680V280H400V680Z"></path></svg></span></button>
3466              <button class="blend-btn" id="shuffleColorsBtn" title="Shuffle Colors" aria-label="Shuffle Colors"><span class="material-icons" data-icon="shuffle" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M316 286 383 353 427 309V427H309L353 383L286 316ZM309 85H427V203L383 159L115 427L85 397L353 129ZM226 196 196 226 85 115 115 85Z"></path></svg></span></button>
3467            </div>
3468            <div class="gradient-controls-right">
3469              <button class="blend-btn" id="removeStopBtn" title="Remove Selected" aria-label="Remove Selected"><span class="material-symbols-outlined" data-icon="symbols:delete" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960" aria-hidden="true" focusable="false"><path d="M280 840Q247 840 223.5 816.5Q200 793 200 760V240H160V160H360V120H600V160H800V240H760V760Q760 793 736.5 816.5Q713 840 680 840ZM680 240H280V760Q280 760 280.0 760.0Q280 760 280 760H680Q680 760 680.0 760.0Q680 760 680 760ZM360 680H440V320H360ZM520 680H600V320H520ZM280 240V760Q280 760 280.0 760.0Q280 760 280 760Q280 760 280.0 760.0Q280 760 280 760Z"></path></svg></span></button>
3470            </div>
3471          </div>
3472          <div class="pattern-color-row" id="stopColorRow" style="margin-top:12px;">
3473            <button type="button" id="stopColorSwatch" class="pattern-color-swatch" title="Pick color"></button>
3474            <input type="text" id="stopColorHexInput" placeholder="#ff595e" maxlength="7" class="pattern-color-hex-in
3474put" />
3475          </div>
3476          <div class="pattern-color-dropdown" id="stopColorDropdown">
3477            <div class="pattern-color-dropdown-header">
3478              <span>Stop color</span>
3479              <button type="button" class="help-modal-close" id="stopColorDropdownClose" title="Close" aria-label="Close">
3480                <span class="material-icons" data-icon="close" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 137 286 256 405 375 375 405 256 286 137 405 107 375 226 256 107 137 137 107 256 226 375 107Z"></path></svg></span>
3481              </button>
3482          </div>
3483            <div class="pattern-color-picker-inner">
3484              <div class="spectrum-container pattern-spectrum-container">
3485                <canvas id="stopSpectrumCanvas" width="200" height="200"></canvas>
3486                <div class="spectrum-marker" id="stopSpectrumMarker"></div>
3487              </div>
3488              <div class="hue-slider-container pattern-hue-container">
3489                <canvas id="stopHueCanvas" width="20" height="200"></canvas>
3490                <div class="hue-marker" id="stopHueMarker"></div>
3491              </div>
3492          </div>
3493        </div>
3494          <div style="margin-top:12px; display:flex; flex-direction:column; gap:12px;">
3495            <label style="font-size:11px; margin:0; width:100%;">
3496              Color Template <!-- Vibrant = saturated, Random = true random -->
3497              <select id="colorTemplateSelect" style="width:100%; padding:8px 12px; border-radius:6px; border:1px solid var(--input-border); background:var(--input-bg); color:var(--text); font-size:12px; cursor:pointer; appearance:none; -webkit-appearance:none; -moz-appearance:none; background-image:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 12 12%22%3E%3Cpath fill=%22%23ffffff%22 d=%22M6 9L1 4h10z%22/%3E%3C/svg%3E'); background-repeat:no-repeat; background-position:right 12px center;">
3498                <option value="vibrant">Vibrant</option>
3499                <option value="random">Random</option>
3500                <option value="warm">Warm</option>
3501                <option value="cool">Cool</option>
3502                <option value="dark">Dark</option>
3503                <option value="light">Light</option>
3504                <option value="complementary">Complementary</option>
3505                <option value="analogous">Analogous</option>
3506                <option value="triadic">Triadic</option>
3507                <option value="monochromatic">Monochromatic</option>
3508              </select>
3509            </label>
3510            <button class="blend-btn" id="randomizeColorsBtn" title="Randomize Colors" style="width:100%; aspect-ratio: auto; height: auto; padding: 8px 12px; display: flex; align-items: center; justify-content: center; gap: 6px;">
3511              <span class="material-icons" style="font-size:18px;" data-icon="palette" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M256 43Q212 43 173.5 59.5Q135 76 105.5 105.5Q76 135 59.5 173.5Q43 212 43 256Q43 300 59.5 338.5Q76 377 105.5 406.5Q135 436 173.5 452.5Q212 469 256 469Q278 469 293.5 453.5Q309 438 309 416Q309 396 296 380Q293 377 293 373Q293 369 296.0 366.0Q299 363 304 363H341Q377 363 406.0 345.5Q435 328 452.0 299.0Q469 270 469 235Q469 195 452.5 160.0Q436 125 406.5 99.0Q377 73 338.5 58.0Q300 43 256 43ZM373 277Q360 277 350.5 268.0Q341 259 341 245Q341 232 350.5 222.5Q360 213 373 213Q387 213 396.0 222.5Q405 232 405 245Q405 259 396.0 268.0Q387 277 373 277ZM309 192Q296 192 286.5 182.5Q277 173 277 160Q277 147 286.5 137.5Q296 128 309 128Q323 128 332.0 137.5Q341 147 341 160Q341 173 332.0 182.5Q323 192 309 192ZM107 245Q107 232 116.0 222.5Q125 213 139 213Q152 213 161.5 222.5Q171 232 171 245Q171 259 161.5 268.0Q152 277 139 277Q125 277 116.0 268.0Q107 259 107 245ZM235 160Q235 173 225.5 182.5Q216 192 203 192Q189 192 180.0 182.5Q171 173 171 160Q171 147 180.0 137.5Q189 128 203 128Q216 128 225.5 137.5Q235 147 235 160Z"></path></svg></span>
3512              <span style="font-size:12px;">Randomize Colors</span>
3513            </button>
3514            <label style="font-size:11px; margin:0; width:100%;">
3515              Colors
3516              <div class="slider-with-input" style="width:100%;">
3517                <input type="range" id="multiColorCountSlider" value="5" min="2" max="20" style="flex:1; min-width:0; max-width:calc(100% - 60px);" />
3518                <input type="number" id="multiColorCount" value="5" min="2" max="20" step="1" style="width:45px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3519      </div>
3520            </label>
3521          </div>
3522        </div>
3523      </div>
3524
3525        <div class="panel collapsible collapsed" id="distortionPanel">
3526        <h3>
3527          <span style="display: inline-flex; align-items: center; gap: 6px;"><span class="material-icons panel-title-icon" style="font-size: 18px; color: var(--text);" data-icon="waves" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M426 190Q450 202 469 202V244Q440 244 406 227Q380 214 363 214Q345 214 319 227Q285 244 256.0 244.0Q227 244 193 227Q167 214 149 214Q132 214 106 227Q72 244 43 244V202Q62 202 86 190Q120 173 149.0 173.0Q178 173 212 190Q236 202 256.0 202.0Q276 202 300 190Q334 173 363.0 173.0Q392 173 426 190ZM426 95Q452 108 469 108V149Q440 149 406 132Q380 119 363 119Q345 119 319 132Q285 149 256.0 149.0Q227 149 193 132Q167 119 149 119Q132 119 106 132Q72 149 43 149V108Q60 108 86 95Q120 78 149.0 78.0Q178 78 212 95Q238 108 256.0 108.0Q274 108 300 95Q334 78 363.0 78.0Q392 78 426 95ZM426 285Q450 297 469 297V339Q440 339 406 322Q380 309 363 309Q345 309 319 322Q285 339 256.0 339.0Q227 339 193 322Q167 309 149 309Q132 309 106 322Q72 339 43 339V297Q62 297 86 285Q120 268 149.0 268.0Q178 268 212 285Q236 297 256.0 297.0Q276 297 300 285Q334 268 363.0 268.0Q392 268 426 285ZM426 380Q450 392 469 392V434Q440 434 406 417Q380 404 363 404Q345 404 319 417Q285 434 256.0 434.0Q227 434 193 417Q167 404 149 404Q132 404 106 417Q72 434 43 434V392Q62 392 86 380Q122 362 149.0 362.0Q176 362 212 380Q236 392 256.0 392.0Q276 392 300 380Q336 362 363.0 362.0Q390 362 426 380Z"></path></svg></span>Distortion</span>
3528          <span style="display: inline-flex; align-items: center; gap: 6px;">
3529            <label class="ios-switch" style="margin: 0;">
3530              <input type="checkbox" id="distortionEnabled" />
3531              <span class="ios-switch-slider"></span>
3532            </label>
3533            <button type="button" class="collapse-toggle" id="distortionCollapseBtn" title="Collapse / expand" aria-label="Collapse / expand"><span class="material-icons" data-icon="expand_more" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M354 183 384 213 256 341 128 213 158 183 256 281Z"></path></svg></span></button>
3534          </span>
3535        </h3>
3536        <div class="controls">
3537          <label>Type
3538            <div style="display: flex; align-items: center; gap: 8px; width: 100%;">
3539              <button class="blend-btn" id="procDispPrev" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Previous type" aria-label="Previous type">
3540                <span class="material-icons" style="font-size: 18px;" data-icon="chevron_left" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M329 158 231 256 329 354 299 384 171 256 299 128Z"></path></svg></span>
3541              </button>
3542              <div style="flex: 1; position: relative;">
3543                <select id="procDispType" style="width: 100%; padding: 8px 32px 8px 32px; border-radius: 6px; border: 1px solid var(--input-border); background: var(--input-bg); color: var(--text); font-size: 12px; cursor: pointer; appearance: none; -webkit-appearance: none; -moz-appearance: none; background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 12 12%22%3E%3Cpath fill=%22%23ffffff%22 d=%22M6 9L1 4h10z%22/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: right 12px center;">
3544                  <option value="organic" selected>Organic</option>
3545                  <option value="ridged">Ridged Noise</option>
3546                  <option value="waves">Waves</option>
3547                  <option value="kura">Kura</option>
3548                  <option value="tayu">Tayu</option>
3549                </select>
3550                <span class="material-icons" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 18px; pointer-events: none; color: var(--text);" data-icon="waves" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M426 190Q450 202 469 202V244Q440 244 406 227Q380 214 363 214Q345 214 319 227Q285 244 256.0 244.0Q227 244 193 227Q167 214 149 214Q132 214 106 227Q72 244 43 244V202Q62 202 86 190Q120 173 149.0 173.0Q178 173 212 190Q236 202 256.0 202.0Q276 202 300 190Q334 173 363.0 173.0Q392 173 426 190ZM426 95Q452 108 469 108V149Q440 149 406 132Q380 119 363 119Q345 119 319 132Q285 149 256.0 149.0Q227 149 193 132Q167 119 149 119Q132 119 106 132Q72 149 43 149V108Q60 108 86 95Q120 78 149.0 78.0Q178 78 212 95Q238 108 256.0 108.0Q274 108 300 95Q334 78 363.0 78.0Q392 78 426 95ZM426 285Q450 297 469 297V339Q440 339 406 322Q380 309 363 309Q345 309 319 322Q285 339 256.0 339.0Q227 339 193 322Q167 309 149 309Q132 309 106 322Q72 339 43 339V297Q62 297 86 285Q120 268 149.0 268.0Q178 268 212 285Q236 297 256.0 297.0Q276 297 300 285Q334 268 363.0 268.0Q392 268 426 285ZM426 380Q450 392 469 392V434Q440 434 406 417Q380 404 363 404Q345 404 319 417Q285 434 256.0 434.0Q227 434 193 417Q167 404 149 404Q132 404 106 417Q72 434 43 434V392Q62 392 86 380Q122 362 149.0 362.0Q176 362 212 380Q236 392 256.0 392.0Q276 392 300 380Q336 362 363.0 362.0Q390 362 426 380Z"></path></svg></span>
3551    </div>
3552              <button class="blend-btn" id="procDispNext" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Next type" aria-label="Next type">
3553                <span class="material-icons" style="font-size: 18px;" data-icon="chevron_right" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M213 128 341 256 213 384 183 354 281 256 183 158Z"></path></svg></span>
3554              </button>
3555  </div>
3556          </label>
3557          <label>Intensity
3558            <div class="slider-with-input" style="width:100%;">
3559              <input type="range" id="procDispIntensity" value="2" min="1" max="20" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3560              <input type="number" id="procDispIntensityValue" value="2" min="1" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3561      </div>
3562          </label>
3563          <label>Scale
3564            <div class="slider-with-input" style="width:100%;">
3565              <input type="range" id="procDispScale" value="0.5" min="0.5" max="3.0" step="0.1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3566              <input type="number" id="procDispScaleValue" value="0.5" min="0.1" max="20" step="0.1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3567            </div>
3568          </label>
3569          <label>Seed
3570            <div class="slider-with-input" style="width:100%;">
3571              <input type="range" id="procDispSeed" value="0" min="0" max="1000" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3572              <input type="number" id="procDispSeedValue" value="0" min="0" max="10000" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3573            </div>
3574          </label>
3575          <!-- FBM/Ridged/Turbulence specific controls -->
3576          <div id="procDispOctavesRow" style="display: none; flex-direction: column; gap: 10px;">
3577            <label>Octaves
3578              <div class="slider-with-input" style="width:100%;">
3579                <input type="range" id="procDispOctaves" value="1" min="1" max="3" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3580                <input type="number" id="procDispOctavesValue" value="1" min="1" max="8" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3581              </div>
3582            </label>
3583          </div>
3584          <div id="procDispLacunarityRow" style="display: none; flex-direction: column; gap: 10px;">
3585            <label>Lacunarity
3586              <div class="slider-with-input" style="width:100%;">
3587                <input type="range" id="procDispLacunarity" value="1.5" min="1.5" max="2.5" step="0.1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3588                <input type="number" id="procDispLacunarityValue" value="1.5" min="1.5" max="4.0" step="0.1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3589              </div>
3590            </label>
3591          </div>
3592          <div id="procDispPersistenceRow" style="display: none;">
3593            <label>Persistence
3594              <div class="slider-with-input" style="width:100%;">
3595                <input type="range" id="procDispPersistence" value="0.3" min="0.1" max="0.5" step="0.05" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3596                <input type="number" id="procDispPersistenceValue" value="0.3" min="0.1" max="0.9" step="0.05" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3597              </div>
3598            </label>
3599          </div>
3600          <!-- Waves specific controls -->
3601          <div id="procDispWaveFreqRow" style="display: none;">
3602            <label>Frequency
3603              <div class="slider-with-input" style="width:100%;">
3604                <input type="range" id="procDispWaveFreq" value="3" min="1" max="10" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3605                <input type="number" id="procDispWaveFreqValue" value="3" min="1" max="30" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3606              </div>
3607            </label>
3608          </div>
3609          <!-- Voronoi specific controls -->
3610          <div id="procDispVoronoiJitterRow" style="display: none;">
3611            <label>Jitter
3612              <div class="slider-with-input" style="width:100%;">
3613                <input type="range" id="procDispVoronoiJitter" value="1.0" min="0.0" max="1.0" step="0.05" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3614                <input type="number" id="procDispVoronoiJitterValue" value="1.0" min="0.0" max="1.0" step="0.05" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3615              </div>
3616            </label>
3617          </div>
3618          <!-- Organic specific controls -->
3619          <div id="procDispWarpStrengthRow" style="display: none;">
3620            <label>Warp
3621              <div class="slider-with-input" style="width:100%;">
3622                <input type="range" id="procDispWarpStrength" value="2.6" min="0.5" max="3.0" step="0.1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3623                <input type="number" id="procDispWarpStrengthValue" value="2.6" min="0.5" max="10.0" step="0.1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3624              </div>
3625            </label>
3626          </div>
3627          <!-- Kura specific controls -->
3628          <div id="procDispMixRow" style="display: none;">
3629            <label>Mix
3630              <div class="slider-with-input" style="width:100%;">
3631                <input type="range" id="procDispMix" value="0.5" min="0.0" max="1.0" step="0.01" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3632                <input type="number" id="procDispMixValue" value="0.5" min="0.0" max="1.0" step="0.01" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3633              </div>
3634            </label>
3635          </div>
3636          <!-- Tayu specific controls -->
3637          <div id="procDispTayuMixRow" style="display: none;">
3638            <label>Mix
3639              <div class="slider-with-input" style="width:100%;">
3640                <input type="range" id="procDispTayuMix" value="0.5" min="0.0" max="1.0" step="0.01" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3641                <input type="number" id="procDispTayuMixValue" value="0.5" min="0.0" max="1.0" step="0.01" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3642              </div>
3643            </label>
3644          </div>
3645          <label>Relief
3646            <div class="slider-with-input" style="width:100%;">
3647              <input type="range" id="materialStrengthInput" value="0" min="0" max="50" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3648              <input type="number" id="materialStrength" value="0" min="0" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3649            </div>
3650          </label>
3651    </div>
3652  </div>
3653
3654        <div class="panel collapsible" id="glitterPanel">
3655        <h3>
3656          <span style="display: inline-flex; align-items: center; gap: 6px;"><span class="material-icons panel-title-icon" style="font-size: 18px; color: var(--text);" data-icon="star" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M256 368 124 448 159 298 43 197 196 184 256 43 316 184 469 197 353 298 388 448Z"></path></svg></span>Glitter</span>
3657          <span style="display: inline-flex; align-items: center; gap: 6px;">
3658            <label class="ios-switch" style="margin: 0;">
3659              <input type="checkbox" id="glitterEnabled" checked />
3660              <span class="ios-switch-slider"></span>
3661            </label>
3662            <button type="button" class="collapse-toggle" id="glitterCollapseBtn" title="Collapse / expand" aria-label="Collapse / expand"><span class="material-icons" data-icon="expand_more" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M354 183 384 213 256 341 128 213 158 183 256 281Z"></path></svg></span></button>
3663          </span>
3664        </h3>
3665        <div class="controls">
3666          <label>
3667            Density
3668            <div class="slider-with-input" style="width:100%;">
3669              <input type="range" id="glitterDensityInput" value="30" min="1" max="100" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3670              <input type="number" id="glitterDensityValue" value="30" min="1" max="100" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3671            </div>
3672            </label>
3673          <label>
3674            Size Min
3675            <div class="slider-with-input" style="width:100%;">
3676              <input type="range" id="glitterSizeMinInput" value="0.5" min="0.1" max="3" step="0.1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3677              <input type="number" id="glitterSizeMinValue" value="0.5" min="0.1" max="3" step="0.1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3678          </div>
3679            </label>
3680          <label>
3681            Size Max
3682            <div class="slider-with-input" style="width:100%;">
3683              <input type="range" id="glitterSizeMaxInput" value="3" min="0.2" max="5" step="0.1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3684              <input type="number" id="glitterSizeMaxValue" value="3" min="0.2" max="5" step="0.1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3685          </div>
3686            </label>
3687          <label>
3688            Opacity
3689            <div class="slider-with-input" style="width:100%;">
3690              <input type="range" id="glitterOpacity" value="100" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3691              <input type="number" id="glitterOpacityValue" value="100" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3692        </div>
3693          </label>
3694          <label>Blend mode
3695            <div style="display: flex; align-items: center; gap: 8px; width: 100%;">
3696              <button class="blend-btn" id="glitterBlendPrev" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Previous blend mode" aria-label="Previous blend mode">
3697                <span class="material-icons" style="font-size: 18px;" data-icon="chevron_left" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M329 158 231 256 329 354 299 384 171 256 299 128Z"></path></svg></span>
3698              </button>
3699              <div style="flex: 1; position: relative;">
3700                <select id="glitterBlendMode" style="width: 100%; padding: 8px 32px 8px 32px; border-radius: 6px; border: 1px solid var(--input-border); background: var(--input-bg); color: var(--text); font-size: 12px; cursor: pointer; appearance: none; -webkit-appearance: none; -moz-appearance: none; background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 12 12%22%3E%3Cpath fill=%22%23ffffff%22 d=%22M6 9L1 4h10z%22/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: right 12px center;">
3701                  <option value="normal" selected>Normal</option>
3702                  <option value="mul">Multiply</option>
3703                  <option value="screen">Screen</option>
3704                  <option value="overlay">Overlay</option>
3705                  <option value="softlight">Soft Light</option>
3706                  <option value="hardlight">Hard Light</option>
3707                  <option value="difference">Difference</option>
3708                  <option value="exclusion">Exclusion</option>
3709                  <option value="darken">Darken</option>
3710                  <option value="lighten">Lighten</option>
3711                  <option value="colorburn">Color Burn</option>
3712                  <option value="colordodge">Color Dodge</option>
3713                </select>
3714                <span id="glitterBlendModeIcon" class="material-icons" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 18px; pointer-events: none; color: var(--text);" data-icon="layers" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M256 341Q247 334 174.5 277.5Q102 221 64 192L256 43L448 192Q410 221 338.0 277.0Q266 333 256 341ZM256 396 413 273 448 300 256 449 64 300 99 273Z"></path></svg></span>
3715      </div>
3716              <button class="blend-btn" id="glitterBlendNext" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Next blend mode" aria-label="Next blend mode">
3717                <span class="material-icons" style="font-size: 18px;" data-icon="chevron_right" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M213 128 341 256 213 384 183 354 281 256 183 158Z"></path></svg></span>
3718              </button>
3719            </div>
3720          </label>
3721    </div>
3722  </div>
3723
3724        <div class="panel collapsible collapsed" id="patternsPanel">
3725        <h3>
3726          <span style="display: inline-flex; align-items: center; gap: 6px;"><span class="material-icons panel-title-icon" style="font-size: 18px; color: var(--text);" data-icon="grid_on" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M427 171V85H341V171ZM427 299V213H341V299ZM427 427V341H341V427ZM299 171V85H213V171ZM299 299V213H213V299ZM299 427V341H213V427ZM171 171V85H85V171ZM171 299V213H85V299ZM171 427V341H85V427ZM427 43Q444 43 456.5 55.5Q469 68 469 85V427Q469 444 456.5 456.5Q444 469 427 469H85Q68 469 55.5 456.5Q43 444 43 427V85Q43 68 55.5 55.5Q68 43 85 43Z"></path></svg></span>Patterns</span>
3727          <span style="display: inline-flex; align-items: center; gap: 6px;">
3728            <label class="ios-switch" style="margin: 0;">
3729              <input type="checkbox" id="patternsEnabled" />
3730              <span class="ios-switch-slider"></span>
3731            </label>
3732            <button type="button" class="collapse-toggle" id="patternsCollapseBtn" title="Collapse / expand" aria-label="Collapse / expand"><span class="material-icons" data-icon="expand_more" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false">
3732<path d="M354 183 384 213 256 341 128 213 158 183 256 281Z"></path></svg></span></button>
3733          </span>
3734        </h3>
3735        <div class="controls">
3736          <label>Type
3737            <div style="display: flex; align-items: center; gap: 8px; width: 100%;">
3738              <button class="blend-btn" id="patternTypePrev" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Previous type" aria-label="Previous type">
3739                <span class="material-icons" style="font-size: 18px;" data-icon="chevron_left" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M329 158 231 256 329 354 299 384 171 256 299 128Z"></path></svg></span>
3740              </button>
3741              <div style="flex: 1; position: relative;">
3742                <select id="patternType" style="width: 100%; padding: 8px 32px 8px 32px; border-radius: 6px; border: 1px solid var(--input-border); background: var(--input-bg); color: var(--text); font-size: 12px; cursor: pointer; appearance: none; -webkit-appearance: none; -moz-appearance: none; background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 12 12%22%3E%3Cpath fill=%22%23ffffff%22 d=%22M6 9L1 4h10z%22/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: right 12px center;">
3743                  <option value="dot" selected>Dot (grid)</option>
3744                  <option value="line">Line</option>
3745                </select>
3746                <span class="material-icons" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 18px; pointer-events: none; color: var(--text);" data-icon="grid_on" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M427 171V85H341V171ZM427 299V213H341V299ZM427 427V341H341V427ZM299 171V85H213V171ZM299 299V213H213V299ZM299 427V341H213V427ZM171 171V85H85V171ZM171 299V213H85V299ZM171 427V341H85V427ZM427 43Q444 43 456.5 55.5Q469 68 469 85V427Q469 444 456.5 456.5Q444 469 427 469H85Q68 469 55.5 456.5Q43 444 43 427V85Q43 68 55.5 55.5Q68 43 85 43Z"></path></svg></span>
3747      </div>
3748              <button class="blend-btn" id="patternTypeNext" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Next type" aria-label="Next type">
3749                <span class="material-icons" style="font-size: 18px;" data-icon="chevron_right" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M213 128 341 256 213 384 183 354 281 256 183 158Z"></path></svg></span>
3750              </button>
3751            </div>
3752          </label>
3753          <div id="patternDotRow" style="display: flex; flex-direction: column; gap: 8px;">
3754            <label>Dot size
3755              <div class="slider-with-input" style="width:100%;">
3756                <input type="range" id="patternDotSize" value="3" min="1" max="50" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3757                <input type="number" id="patternDotSizeValue" value="3" min="1" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3758              </div>
3759            </label>
3760            <label>Distance
3761              <div class="slider-with-input" style="width:100%;">
3762                <input type="range" id="patternDotDistance" value="24" min="4" max="120" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3763                <input type="number" id="patternDotDistanceValue" value="24" min="4" max="200" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3764              </div>
3765            </label>
3766          </div>
3767          <div id="patternLineRow" style="display: none; flex-direction: column; gap: 8px;">
3768            <label>Thickness
3769              <div class="slider-with-input" style="width:100%;">
3770                <input type="range" id="patternLineThickness" value="1" min="0.5" max="20" step="0.5" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3771                <input type="number" id="patternLineThicknessValue" value="1" min="0.5" max="50" step="0.5" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3772              </div>
3773            </label>
3774            <label>Distance
3775              <div class="slider-with-input" style="width:100%;">
3776                <input type="range" id="patternLineDistance" value="24" min="4" max="120" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3777                <input type="number" id="patternLineDistanceValue" value="24" min="4" max="200" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3778              </div>
3779            </label>
3780            <label>Angle
3781              <div class="slider-with-input" style="width:100%;">
3782                <input type="range" id="patternLineAngle" value="
37820" min="0" max="360" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3783                <input type="number" id="patternLineAngleValue" value="0" min="0" max="360" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3784              </div>
3785            </label>
3786            <label>Line axis
3787              <div style="display: flex; align-items: center; gap: 8px; width: 100%;">
3788                <button class="blend-btn" id="patternLineAxisPrev" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Previous line axis" aria-label="Previous line axis">
3789                  <span class="material-icons" style="font-size: 18px;" data-icon="chevron_left" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M329 158 231 256 329 354 299 384 171 256 299 128Z"></path></svg></span>
3790                </button>
3791                <div style="flex: 1; position: relative;">
3792                  <select id="patternLineAxis" style="width: 100%; padding: 8px 32px 8px 32px; border-radius: 6px; border: 1px solid var(--input-border); background: var(--input-bg); color: var(--text); font-size: 12px; cursor: pointer; appearance: none; -webkit-appearance: none; -moz-appearance: none; background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 12 12%22%3E%3Cpath fill=%22%23ffffff%22 d=%22M6 9L1 4h10z%22/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: right 12px center;">
3793                    <option value="1" selected>One</option>
3794                    <option value="2">Duo</option>
3795                  </select>
3796                  <span id="patternLineAxisIcon" class="material-icons" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 18px; pointer-events: none; color: var(--text);" data-icon="remove" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 277H107V235H405Z"></path></svg></span>
3797                </div>
3798                <button class="blend-btn" id="patternLineAxisNext" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Next line axis" aria-label="Next line axis">
3799                  <span class="material-icons" style="font-size: 18px;" data-icon="chevron_right" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M213 128 341 256 213 384 183 354 281 256 183 158Z"></path></svg></span>
3800                </button>
3801              </div>
3802            </label>
3803          </div>
3804          <label>Color
3805            <div class="pattern-color-row" id="patternColorRow">
3806              <button type="button" id="patternColorSwatch" class="pattern-color-swatch" title="Pick color"></button>
3807              <input type="text" id="patternColorHexInput" placeholder="#ffffff" maxlength="7" class="pattern-color-hex-input" />
3808              <input type="hidden" id="patternColorPicker" value="#ffffff" />
3809            </div>
3810            <div class="pattern-color-dropdown" id="patternColorDropdown">
3811              <div class="pattern-color-dropdown-header">
3812                <span>Pattern color</span>
3813                <button type="button" class="help-modal-close" id="patternColorDropdownClose" title="Close" aria-label="Close">
3814                  <span class="material-icons" data-icon="close" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 137 286 256 405 375 375 405 256 286 137 405 107 375 226 256 107 137 137 107 256 226 375 107Z"></path></svg></span>
3815                </button>
3816              </div>
3817              <div class="pattern-color-picker-inner">
3818                <div class="spectrum-container pattern-spectrum-container">
3819                  <canvas id="patternSpectrumCanvas" width="200" height="200"></canvas>
3820                  <div class="spectrum-marker" id="patternSpectrumMarker"></div>
3821                </div>
3822                <div class="hue-slider-container pattern-hue-container">
3823                  <canvas id="patternHueCanvas" width="20" height="200"></canvas>
3824                  <div class="hue-marker" id="patternHueMarker"></div>
3825                </div>
3826              </div>
3827            </div>
3828          </label>
3829          <label>Opacity
3830            <div class="slider-with-input" style="width:100%;">
3831              <input type="range" id="patternOpacity" value="100" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3832              <input type="number" id="patternOpacityValue" value="100" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3833            </div>
3834          </label>
3835          <label>Blend mode
3836            <div style="display: flex; align-items: center; gap: 8px; width: 100%;">
3837              <button class="blend-btn" id="patternBlendPrev" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Previous blend mode" aria-label="Previous blend mode">
3838                <span class="material-icons" style="font-size: 18px;" data-icon="chevron_left" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M329 158 231 256 329 354 299 384 171 256 299 128Z"></path></svg></span>
3839              </button>
3840              <div style="flex: 1; position: relative;">
3841                <select id="patternBlendMode" style="width: 100%; padding: 8px 32px 8px 32px; border-radius: 6px; border: 1px solid var(--input-border); background: var(--input-bg); color: var(--text); font-size: 12px; cursor: pointer; appearance: none; -webkit-appearance: none; -moz-appearance: none; background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 12 12%22%3E%3Cpath fill=%22%23ffffff%22 d=%22M6 9L1 4h10z%22/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: right 12px center;">
3842                  <option value="normal" selected>Normal</option>
3843                  <option value="mul">Multiply</option>
3844                  <option value="screen">Screen</option>
3845                  <option value="overlay">Overlay</option>
3846                  <option value="darken">Darken</option>
3847                </select>
3848                <span id="patternBlendModeIcon" class="material-icons" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 18px; pointer-events: none; color: var(--text);" data-icon="layers" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M256 341Q247 334 174.5 277.5Q102 221 64 192L256 43L448 192Q410 221 338.0 277.0Q266 333 256 341ZM256 396 413 273 448 300 256 449 64 300 99 273Z"></path></svg></span>
3849              </div>
3850              <button class="blend-btn" id="patternBlendNext" style="flex-shrink: 0; width: 32px; height: 32px; padding: 0;" title="Next blend mode" aria-label="Next blend mode">
3851                <span class="material-icons" style="font-size: 18px;" data-icon="chevron_right" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M213 128 341 256 213 384 183 354 281 256 183 158Z"></path></svg></span>
3852              </button>
3853            </div>
3854          </label>
3855    </div>
3856  </div>
3857
3858        <div class="panel collapsible collapsed" id="betaPanel">
3859        <h3>
3860          <span style="display: inline-flex; align-items: center; gap: 6px;"><span class="material-icons panel-title-icon" style="font-size: 18px; color: var(--text);" data-icon="science" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M422 393 299 228V139L327 103Q332 97 329.0 91.0Q326 85 319 85H193Q186 85 183.0 91.0Q180 97 185 103L213 139V228L90 393Q84 400 85.0 407.5Q86 415 92.0 421.0Q98 427 107 427H405Q414 427 420.0 421.0Q426 415 427.0 407.5Q428 400 422 393Z"></path></svg></span><span><span style="color: #7dd3fc;">Beta</span> Features</span></span>
3861          <span style="display: inline-flex; align-items: center; gap: 6px;">
3862            <input type="checkbox" id="betaEnabled" checked style="display: none;" />
3863            <span class="beta-pro-lock" id="betaProLock" style="display: none;" title="Available with Colir PRO"><img src="PRO_tag.svg" alt="Colir PRO" width="40" height="20" /></span>
3864            <button type="button" class="collapse-toggle" id="betaCollapseBtn" title="Collapse / expand" aria-label="Collapse / expand"><span class="material-icons" data-icon="expand_more" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M354 183 384 213 256 341 128 213 158 183 256 281Z"></path></svg></span></button>
3865          </span>
3866        </h3>
3867        <div class="controls">
3868          <div class="subfeature-head collapsed">
3869            <span style="display: inline-flex; align-items: center; gap: 6px;"><span class="material-icons panel-title-icon" style="font-size: 18px; color: var(--text);" data-icon="blur_on" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M276.5 190.5Q286 181 299.0 181.0Q312 181 321.5 190.5Q331 200 331.0 213.0Q331 226 321.5 235.5Q312 245 299.0 245.0Q286 245 276.5 235.5Q267 226 267.0 213.0Q267 200 276.5 190.5ZM276.5 276.5Q286 267 299.0 267.0Q312 267 321.5 276.5Q331 286 331.0 299.0Q331 312 321.5 321.5Q312 331 299.0 331.0Q286 331 276.5 321.5Q267 312 267.0 299.0Q267 286 276.5 276.5ZM198.0 369.0Q204 363 213.0 363.0Q222 363 228.5 369.0Q235 375 235.0 384.0Q235 393 228.5 399.0Q222 405 213.0 405.0Q204 405 198.0 399.0Q192 393 192.0 384.0Q192 375 198.0 369.0ZM190.5 190.5Q200 181 213.0 181.0Q226 181 235.5 190.5Q245 200 245.0 213.0Q245 226 235.5 235.5Q226 245 213.0 245.0Q200 245 190.5 235.5Q181 226 181.0 213.0Q181 200 190.5 190.5ZM299 437Q309 437 309.0 448.0Q309 459 299 459Q288 459 288.0 448.0Q288 437 299 437ZM283.5 369.0Q290 363 299.0 363.0Q308 363 314.0 369.0Q320 375 320.0 384.0Q320 393 314.0 399.0Q308 405 299.0 405.0Q290 405 283.5 399.0Q277 393 277.0 384.0Q277 375 283.5 369.0ZM459 299Q459 309 448.0 309.0Q437 309 437 299Q437 288 448.0 288.0Q459 288 459 299ZM369.0 113.0Q375 107 384.0 107.0Q393 107 399.0 113.0Q405 119 405.0 128.0Q405 137 399.0 143.0Q393 149 384.0 149.0Q375 149 369.0 143.0Q363 137 363.0 128.0Q363 119 369.0 113.0ZM369.0 198.0Q375 192 384.0 192.0Q393 192 399.0 198.0Q405 204 405.0 213.0Q405 222 399.0 228.5Q393 235 384.0 235.0Q375 235 369.0 228.5Q363 222 363.0 213.0Q363 204 369.0 198.0ZM369.0 369.0Q375 363 384.0 363.0Q393 363 399.0 369.0Q405 375 405.0 384.0Q405 393 399.0 399.0Q393 405 384.0 405.0Q375 405 369.0 399.0Q363 393 363.0 384.0Q363 375 369.0 369.0ZM369.0 283.5Q375 277 384.0 277.0Q393 277 399.0 283.5Q405 290 405.0 299.0Q405 308 399.0 314.0Q393 320 384.0 320.0Q375 320 369.0 314.0Q363 308 363.0 299.0Q363 290 369.0 283.5ZM190.5 276.5Q200 267 213.0 267.0Q226 267 235.5 276.5Q245 286 245.0 299.0Q245 312 235.5 321.5Q226 331 213.0 331.0Q200 331 190.5 321.5Q181 312 181.0 299.0Q181 286 190.5 276.5ZM228.5 143.0Q222 149 213.0 149.0Q204 149 198.0 143.0Q192 137 192.0 128.0Q192 119 198.0 113.0Q204 107 213.0 107.0Q222 107 228.5 113.0Q235 119 235.0 128.0Q235 137 228.5 143.0ZM213 75Q203 75 203.0 64.0Q203 53 213 53Q224 53 224.0 64.0Q224 75 213 75ZM213 437Q224 437 224.0 448.0Q224 459 213 459Q203 459 203.0 448.0Q203 437 213 437ZM75 299Q75 309 64.0 309.0Q53 309 53 299Q53 288 64.0 288.0Q75 288 75 299ZM299 75Q288 75 288.0 64.0Q288 53 299 53Q309 53 309.0 64.0Q309 75 299 75ZM314.0 143.0Q308 149 299.0 149.0Q290 149 283.5 143.0Q277 137 277.0 128.0Q277 119 283.5 113.0Q290 107 299.0 107.0Q308 107 314.0 113.0Q320 119 320.0 128.0Q320 137 314.0 143.0ZM437 213Q437 203 448.0 203.0Q459 203 459 213Q459 224 448.0 224.0Q437 224 437 213ZM113.0 113.0Q119 107 128.0 107.0Q137 107 143.0 113.0Q149 119 149.0 128.0Q149 137 143.0 143.0Q137 149 128.0 149.0Q119 149 113.0 143.0Q107 137 107.0 128.0Q107 119 113.0 113.0ZM75 213Q75 224 64.0 224.0Q53 224 53 213Q53 203 64.0 203.0Q75 203 75 213ZM113.0 198.0Q119 192 128.0 192.0Q137 192 143.0 198.0Q149 204 149.0 213.0Q149 222 143.0 228.5Q137 235 128.0 235.0Q119 235 113.0 228.5Q107 222 107.0 213.0Q107 204 113.0 198.0ZM113.0 369.0Q119 363 128.0 363.0Q137 363 143.0 369.0Q149 375 149.0 384.0Q149 393 143.0 399.0Q137 405 128.0 405.0Q119 405 113.0 399.0Q107 393 107.0 384.0Q107 375 113.0 369.0ZM113.0 283.5Q119 277 128.0 277.0Q137 277 143.0 283.5Q149 290 149.0 299.0Q149 308 143.0 314.0Q137 320 128.0 320.0Q119 320 113.0 314.0Q107 308 107.0 299.0Q107 290 113.0 283.5Z"></path></svg></span>Chromatic aberration</span>
3870            <span style="display: inline-flex; align-items: center; gap: 6px;">
3871              <label class="ios-switch" style="margin: 0; flex-shrink: 0;">
3872                <input type="checkbox" id="chromaticEnabled" />
3873                <span class="ios-switch-slider"></span>
3874              </label>
3875              <button type="button" class="collapse-toggle" id="chromaticCollapseBtn" title="Collapse / expand" aria-label="Collapse / expand"><span class="material-icons" data-icon="expand_more" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M354 183 384 213 256 341 128 213 158 183 256 281Z"></path></svg></span></button>
3876            </span>
3877          </div>
3878          <div class="subfeature-body collapsed" id="chromaticBody">
3879          <label>Intensity
3880            <div class="slider-with-input" style="width:100%;">
3881              <input type="range" id="caIntensityInput" value="100" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3882              <input type="number" id="caIntensityValue" value="100" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3883            </div>
3884          </label>
3885          </div>
3886          <div style="height:1px; background: var(--border, rgba(255,255,255,0.1)); margin: 4px 0;"></div>
3887          <div class="subfeature-head collapsed">
3888            <span style="display: inline-flex; align-items: center; gap: 6px;"><span class="material-icons panel-title-icon" style="font-size: 18px; color: var(--text);" data-icon="air" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M309 363Q309 380 300.5 394.5Q292 409 277.5 418.0Q263 427 245 427Q228 427 213.5 418.0Q199 409 190.0 394.5Q181 380 181 363H224Q224 371 230.5 377.5Q237 384 245 384Q254 384 260.5 377.5Q267 371 267 363Q267 354 260.5 347.5Q254 341 245 341H43V299H245Q263 299 277.5 307.5Q292 316 300.5 330.5Q309 345 309 363ZM405 139Q405 118 395.0 101.0Q385 84 368.0 74.0Q351 64 331 64Q310 64 293.0 74.0Q276 84 266.0 101.0Q256 118 256 139H299Q299 125 308.0 116.0Q317 107 331 107Q344 107 353.5 116.0Q363 125 363 139Q363 152 353.5 161.5Q344 171 331 171H43V213H331Q351 213 368.0 203.0Q385 193 395.0 176.0Q405 159 405 139ZM395 235H43V277H395Q408 277 417.5 286.5Q427 296 427 309Q427 323 417.5 332.0Q408 341 395 341V384Q415 384 432.0 374.0Q449 364 459.0 347.0Q469 330 469 309Q469 289 459.0 272.0Q449 255 432.0 245.0Q415 235 395 235Z"></path></svg></span>Directional flow</span>
3889            <span style="display: inline-flex; align-items: center; gap: 6px;">
3890              <label class="ios-switch" style="margin: 0; flex-shrink: 0;">
3891                <input type="checkbox" id="flowEnabled" />
3892                <span class="ios-switch-slider"></span>
3893              </label>
3894              <button type="button" class="collapse-toggle" id="flowCollapseBtn" title="Collapse / expand" aria-label="Collapse / expand"><span class="material-icons" data-icon="expand_more" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M354 183 384 213 256 341 128 213 158 183 256 281Z"></path></svg></span></button>
3895            </span>
3896          </div>
3897          <div class="subfeature-body collapsed" id="flowBody">
3898          <label>Intensity
3899            <div class="slider-with-input" style="width:100%;">
3900              <input type="range" id="flowIntensityInput" value="100" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3901              <input type="number" id="flowIntensityValue" value="100" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3902            </div>
3903          </label>
3904          <label style="display: none;">Length
3905            <div class="slider-with-input" style="width:100%;">
3906              <input type="range" id="flowLengthInput" value="100" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3907              <input type="number" id="flowLengthValue" value="100" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3908            </div>
3909          </label>
3910          <label style="display: none;">Steps
3911            <div class="slider-with-input" style="width:100%;">
3912              <input type="range" id="flowStepsInput" value="50" min="2" max="50" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3913              <input type="number" id="flowStepsValue" value="50" min="2" max="50" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3914            </div>
3915          </label>
3916          <label style="display: none;">Decay
3917            <div class="slider-with-input" style="width:100%;">
3918              <input type="range" id="flowDecayInput" value="0" min="0" max="6" step="0.1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3919              <input type="number" id="flowDecayValue" value="0" min="0" max="6" step="0.1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3920            </div>
3921          </label>
3922          <label>Flow scale
3923            <div class="slider-with-input" style="width:100%;">
3924              <input type="range" id="flowScaleInput" value="0.5" min="0.5" max="12" step="0.1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3925              <input type="number" id="flowScaleValue" value="0.5" min="0.5" max="12" step="0.1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3926            </div>
3927          </label>
3928          <label>Flow detail
3929            <div class="slider-with-input" style="width:100%;">
3930              <input type="range" id="flowDetailInput" value="4" min="1" max="6" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3931              <input type="number" id="flowDetailValue" value="4" min="1" max="6" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3932            </div>
3933          </label>
3934          <label>Direction
3935            <div class="slider-with-input" style="width:100%;">
3936              <input type="range" id="flowDirectionInput" value="180" min="0" max="360" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3937              <input type="number" id="flowDirectionValue" value="180" min="0" max="360" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3938            </div>
3939          </label>
3940          <label>Irregularity
3941            <div class="slider-with-input" style="width:100%;">
3942              <input type="range" id="flowIrregularityInput" value="100" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3943              <input type="number" id="flowIrregularityValue" value="100" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3944            </div>
3945          </label>
3946          <label>Seed
3947            <div class="slider-with-input" style="width:100%;">
3948              <input type="range" id="flowSeedInput" value="42" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3949              <input type="number" id="flowSeedValue" value="42" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3950            </div>
3951          </label>
3952          </div>
3953          <div style="height:1px; background: var(--border, rgba(255,255,255,0.1)); margin: 4px 0;"></div>
3954          <div class="subfeature-head collapsed">
3955            <span style="display: inline-flex; align-items: center; gap: 6px;"><span class="material-icons panel-title-icon" style="font-size: 18px; color: var(--text);" data-icon="description" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M277 192H395L277 75ZM341 299V256H171V299ZM341 384V341H171V384ZM299 43 427 171V427Q427 444 414.0 456.5Q401 469 384 469H128Q111 469 98.0 456.5Q85 444 85 427L86 85Q86 68 98.5 55.5Q111 43 128 43Z"></path></svg></span>Paper texture</span>
3956            <span style="display: inline-flex; align-items: center; gap: 6px;">
3957              <label class="ios-switch" style="margin: 0; flex-shrink: 0;">
3958                <input type="checkbox" id="paperEnabled" />
3959                <span class="ios-switch-slider"></span>
3960              </label>
3961              <button type="button" class="collapse-toggle" id="paperCollapseBtn" title="Collapse / expand" aria-label="Collapse / expand"><span class="material-icons" data-icon="expand_more" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M354 183 384 213 256 341 128 213 158 183 256 281Z"></path></svg></span></button>
3962            </span>
3963          </div>
3964          <div class="subfeature-body collapsed" id="paperBody">
3965          <label>Intensity
3966            <div class="slider-with-input" style="width:100%;">
3967              <input type="range" id="paperIntensityInput" value="28" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3968              <input type="number" id="paperIntensityValue" value="28" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3969            </div>
3970          </label>
3971          <label style="display: none;">Grain
3972            <div class="slider-with-input" style="width:100%;">
3973              <input type="range" id="paperGrainInput" value="28" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3974              <input type="number" id="paperGrainValue" value="28" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3975            </div>
3976          </label>
3977          <label style="display: none;">Grain scale
3978            <div class="slider-with-input" style="width:100%;">
3979              <input type="range" id="paperGrainScaleInput" value="2" min="0.05" max="2" step="0.01" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3980              <input type="number" id="paperGrainScaleValue" value="2" min="0.05" max="2" step="0.01" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3981            </div>
3982          </label>
3983          <label style="display: none;">Fiber
3984            <div class="slider-with-input" style="width:100%;">
3985              <input type="range" id="paperFiberInput" value="52" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3986              <input type="number" id="paperFiberValue" value="52" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3987            </div>
3988          </label>
3989          <label style="display: none;">Fiber scale
3990            <div class="slider-with-input" style="width:100%;">
3991              <input type="range" id="paperFiberScaleInput" value="775" min="20" max="800" step="5" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3992              <input type="number" id="paperFiberScaleValue" value="775" min="20" max="800" step="5" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3993            </div>
3994          </label>
3995          <label style="display: none;">Folds
3996            <div class="slider-with-input" style="width:100%;">
3997              <input type="range" id="paperFoldInput" value="17" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
3998              <input type="number" id="paperFoldValue" value="17" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
3999            </div>
4000          </label>
4001          <label style="display: none;">Fold scale
4002            <div class="slider-with-input" style="width:100%;">
4003              <input type="range" id="paperFoldScaleInput" value="2" min="0.5" max="16" step="0.1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
4004              <input type="number" id="paperFoldScaleValue" value="2" min="0.5" max="16" step="0.1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
4005            </div>
4006          </label>
4007          <label style="display: none;">Roughness
4008            <div class="slider-with-input" style="width:100%;">
4009              <input type="range" id="paperRoughInput" value="46" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
4010              <input type="number" id="paperRoughValue" value="46" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
4011            </div>
4012          </label>
4013          <label style="display: none;">Roughness scale
4014            <div class="slider-with-input" style="width:100%;">
4015              <input type="range" id="paperRoughScaleInput" value="330" min="10" max="600" step="5" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
4016              <input type="number" id="paperRoughScaleValue" value="330" min="10" max="600" step="5" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
4017            </div>
4018          </label>
4019          <label style="display: none;">Speckle
4020            <div class="slider-with-input" style="width:100%;">
4021              <input type="range" id="paperSpeckInput" value="0" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
4022              <input type="number" id="paperSpeckValue" value="0" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
4023            </div>
4024          </label>
4025          <label style="display: none;">Displacement
4026            <div class="slider-with-input" style="width:100%;">
4027              <input type="range" id="paperDisplaceInput" value="2" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
4028              <input type="number" id="paperDisplaceValue" value="2" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
4029            </div>
4030          </label>
4031          <label style="display: none;">Master scale
4032            <div class="slider-with-input" style="width:100%;">
4033              <input type="range" id="paperScaleInput" value="3.8" min="0.2" max="4" step="0.05" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
4034              <input type="number" id="paperScaleValue" value="3.8" min="0.2" max="4" step="0.05" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
4035            </div>
4036          </label>
4037          <label>Seed
4038            <div class="slider-with-input" style="width:100%;">
4039              <input type="range" id="paperSeedInput" value="12" min="0" max="100" step="1" style="flex:1; min-width:0; max-width:calc(100% - 68px);" />
4040              <input type="number" id="paperSeedValue" value="12" min="0" max="100" step="1" style="width:60px; padding:4px 6px; font-size:12px; flex-shrink:0;" />
4041            </div>
4042          </label>
4043          </div>
4044        </div>
4045      </div>
4046
4047    </div>
4048  </div>
4049
4050  <div class="output-area">
4051    <div class="ui-islands-container">
4052  <div class="floating-randomize">
4053    <button class="btn" id="randomBtn"><span class="material-icons" data-icon="auto_awesome" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 192 432 133 491 107 432 80 405 21 379 80 320 107 379 133ZM245 203 192 85 139 203 21 256 139 309 192 427 245 309 363 256ZM405 320 379 379 320 405 379 432 405 491 432 432 491 405 432 379Z"></path></svg></span> Randomize</button>
4054  </div>
4055  <div class="floating-curves">
4056    <div class="curve-toggle-group">
4057      <button class="curve-toggle active" id="editXBtn"><span class="material-symbols-outlined" data-icon="symbols:vital_signs" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960" aria-hidden="true" focusable="false"><path d="M304 761 212 520H40V440H268L360 684L544 199Q551 182 566.0 171.0Q581 160 600.0 160.0Q619 160 634.0 171.0Q649 182 656 199L748 440H920V520H692L600 276L416 761Q409 778 394.0 789.0Q379 800 360.0 800.0Q341 800 326.0 789.0Q311 778 304 761Z"></path></svg></span> X</button>
4058      <button class="curve-toggle" id="editYBtn"><span class="material-symbols-outlined" style="transform: rotate(90deg);" data-icon="symbols:vital_signs" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960" aria-hidden="true" focusable="false"><path d="M304 761 212 520H40V440H268L360 684L544 199Q551 182 566.0 171.0Q581 160 600.0 160.0Q619 160 634.0 171.0Q649 182 656 199L748 440H920V520H692L600 276L416 761Q409 778 394.0 789.0Q379 800 360.0 800.0Q341 800 326.0 789.0Q311 778 304 761Z"></path></svg></span> Y</button>
4059    </div>
4060    <button class="btn secondary" id="invertCurveBtn"><span class="material-icons" data-icon="swap_horiz" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M448 192 363 277V213H213V171H363V107ZM149 235V299H299V341H149V405L64 320Z"></path></svg></span> Invert</button>
4061    <button class="btn secondary" id="randomizeCurvesBtn"><span class="material-icons" data-icon="casino" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false">
4061<path d="M329.0 183.0Q338 192 352.0 192.0Q366 192 375.0 183.0Q384 174 384.0 160.0Q384 146 375.0 137.0Q366 128 352.0 128.0Q338 128 329.0 137.0Q320 146 320.0 160.0Q320 174 329.0 183.0ZM329.0 375.0Q338 384 352.0 384.0Q366 384 375.0 375.0Q384 366 384.0 352.0Q384 338 375.0 329.0Q366 320 352.0 320.0Q338 320 329.0 329.0Q320 338 320.0 352.0Q320 366 329.0 375.0ZM233.0 279.0Q242 288 256.0 288.0Q270 288 279.0 279.0Q288 270 288.0 256.0Q288 242 279.0 233.0Q270 224 256.0 224.0Q242 224 233.0 233.0Q224 242 224.0 256.0Q224 270 233.0 279.0ZM137.0 183.0Q146 192 160.0 192.0Q174 192 183.0 183.0Q192 174 192.0 160.0Q192 146 183.0 137.0Q174 128 160.0 128.0Q146 128 137.0 137.0Q128 146 128.0 160.0Q128 174 137.0 183.0ZM137.0 375.0Q146 384 160.0 384.0Q174 384 183.0 375.0Q192 366 192.0 352.0Q192 338 183.0 329.0Q174 320 160.0 320.0Q146 320 137.0 329.0Q128 338 128.0 352.0Q128 366 137.0 375.0ZM405 64Q422 64 435.0 77.0Q448 90 448 107V405Q448 422 435.0 435.0Q422 448 405 448H107Q90 448 77.0 435.0Q64 422 64 405V107Q64 90 77.0 77.0Q90 64 107 64Z"></path></svg></span> Randomize curves</button>
4062    <div class="curve-separator"></div>
4063    <button class="toggle-btn on" id="curveSmoothingToggle" title="Toggle curve smoothing"><span class="material-icons" data-icon="waves" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M426 190Q450 202 469 202V244Q440 244 406 227Q380 214 363 214Q345 214 319 227Q285 244 256.0 244.0Q227 244 193 227Q167 214 149 214Q132 214 106 227Q72 244 43 244V202Q62 202 86 190Q120 173 149.0 173.0Q178 173 212 190Q236 202 256.0 202.0Q276 202 300 190Q334 173 363.0 173.0Q392 173 426 190ZM426 95Q452 108 469 108V149Q440 149 406 132Q380 119 363 119Q345 119 319 132Q285 149 256.0 149.0Q227 149 193 132Q167 119 149 119Q132 119 106 132Q72 149 43 149V108Q60 108 86 95Q120 78 149.0 78.0Q178 78 212 95Q238 108 256.0 108.0Q274 108 300 95Q334 78 363.0 78.0Q392 78 426 95ZM426 285Q450 297 469 297V339Q440 339 406 322Q380 309 363 309Q345 309 319 322Q285 339 256.0 339.0Q227 339 193 322Q167 309 149 309Q132 309 106 322Q72 339 43 339V297Q62 297 86 285Q120 268 149.0 268.0Q178 268 212 285Q236 297 256.0 297.0Q276 297 300 285Q334 268 363.0 268.0Q392 268 426 285ZM426 380Q450 392 469 392V434Q440 434 406 417Q380 404 363 404Q345 404 319 417Q285 434 256.0 434.0Q227 434 193 417Q167 404 149 404Q132 404 106 417Q72 434 43 434V392Q62 392 86 380Q122 362 149.0 362.0Q176 362 212 380Q236 392 256.0 392.0Q276 392 300 380Q336 362 363.0 362.0Q390 362 426 380Z"></path></svg></span><span>Smoothing</span></button>
4064    <div class="curve-separator"></div>
4065    <button class="toggle-btn on" id="toggleCurvesBtn" title="Show or hide curves" aria-pressed="true"><span class="material-icons toggle-curves-icon" data-icon="visibility" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M211.0 211.0Q230 192 256.0 192.0Q282 192 301.0 211.0Q320 230 320.0 256.0Q320 282 301.0 301.0Q282 320 256.0 320.0Q230 320 211.0 301.0Q192 282 192.0 256.0Q192 230 211.0 211.0ZM180.5 331.5Q212 363 256.0 363.0Q300 363 331.5 331.5Q363 300 363.0 256.0Q363 212 331.5 180.5Q300 149 256.0 149.0Q212 149 180.5 180.5Q149 212 149.0 256.0Q149 300 180.5 331.5ZM491 256Q463 328 399.0 372.0Q335 416 256.0 416.0Q177 416 113.0 372.0Q49 328 21 256Q49 184 113.0 140.0Q177 96 256.0 96.0Q335 96 399.0 140.0Q463 184 491 256Z"></path></svg></span><span class="toggle-curves-text">Curves</span></button>
4066  </div>
4067
4068  <div class="floating-export">
4069        <button class="btn secondary" id="shareBtn" style="padding: 10px; font-size: 14px; display: flex; align-items: center; justify-content: center; width: 42px; height: 42px; flex-shrink: 0;" title="Share gradient link" aria-label="Share gradient link">
4070          <span class="material-icons" style="font-size: 20px;" data-icon="share" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M384 343Q410 343 428.0 361.5Q446 380 446 405Q446 431 427.5 449.5Q409 468 384.0 468.0Q359 468 340.5 449.5Q322 431 322 405Q322 395 323 391L172 303Q153 320 128 320Q102 320 83.0 301.0Q64 282 64.0 256.0Q64 230 83.0 211.0Q102 192 128 192Q153 192 172 209L322 122Q320 112 320 107Q320 81 339.0 62.0Q358 43 384.0 43.0Q410 43 429.0 62.0Q448 81 448.0 107.0Q448 133 429.0 152.0Q410 171 384 171Q360 171 340 153L190 241Q192 251 192.0 256.0Q192 261 190 271L342 359Q360 343 384 343Z"></path></svg></span>
4071        </button>
4072        <a class="btn secondary" id="figmaPluginBtn" href="https://www.figma.com/community/plugin/1673800851873344284/colir-gradients" target="_blank" rel="noopener noreferrer" title="Figma plugin" aria-label="Figma plugin" style="padding: 10px; font-size: 14px; display: flex; align-items: center; justify-content: center; width: 42px; height: 42px; flex-shrink: 0;">
4073          <span>
4074            <svg class="figma-plugin-icon" viewBox="0 0 38 57" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
4075              <path fill="#1ABCFE" d="M19 28.5a9.5 9.5 0 1 1 19 0 9.5 9.5 0 0 1-19 0z"/>
4076              <path fill="#0ACF83" d="M0 47.5A9.5 9.5 0 0 1 9.5 38H19v9.5a9.5 9.5 0 1 1-19 0z"/>
4077              <path fill="#FF7262" d="M19 0v19h9.5a9.5 9.5 0 1 0 0-19H19z"/>
4078              <path fill="#F24E1E" d="M0 9.5A9.5 9.5 0 0 0 9.5 19H19V0H9.5A9.5 9.5 0 0 0 0 9.5z"/>
4079              <path fill="#A259FF" d="M0 28.5A9.5 9.5 0 0 0 9.5 38H19V19H9.5A9.5 9.5 0 0 0 0 28.5z"/>
4080            </svg>
4081          </span>
4082        </a>
4083    <select id="exportScale"
4084      style="padding:10px 32px 10px 14px; border-radius:6px; border:1px solid var(--input-border); background:var(--input-bg); color:var(--text); font-size:14px; font-weight:600; cursor:pointer; appearance:none; -webkit-appearance:none; -moz-appearance:none; background-image:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 12 12%22%3E%3Cpath fill=%22%23ffffff%22 d=%22M6 9L1 4h10z%22/%3E%3C/svg%3E'); background-repeat:no-repeat; background-position:right 12px center;">
4085      <option value="1">1x</option>
4086      <option value="2">2x</option>
4087      <option value="3">3x</option>
4088      <option value="4">4x</option>
4089    </select>
4090        <div class="export-format-picker">
4091          <select id="exportFormat" hidden aria-hidden="true" tabindex="-1">
4092            <option value="png" selected>PNG</option>
4093            <option value="webp">WebP</option>
4094            <option value="mp4">MP4</option>
4095            <option value="webm">WebM</option>
4096          </select>
4097          <button type="button" id="exportFormatBtn" aria-haspopup="listbox" aria-expanded="false" aria-controls="exportFormatMenu" aria-label="Export format">PNG</button>
4098          <div id="exportFormatMenu" class="export-format-menu" role="listbox" aria-label="Export format" hidden>
4099            <div class="export-format-label"><span class="material-icons" data-icon="image" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M181 288 107 384H405L309 256L235 352ZM448 405Q448 422 435.0 435.0Q422 448 405 448H107Q90 448 77.0 435.0Q64 422 64 405V107Q64 90 77.0 77.0Q90 64 107 64H405Q422 64 435.0 77.0Q448 90 448 10
40997Z"></path></svg></span><span>Image</span></div>
4100            <button type="button" role="option" data-value="png" aria-selected="true">PNG</button>
4101            <button type="button" role="option" data-value="webp" aria-selected="false">WebP</button>
4102            <div class="export-format-separator" role="separator"></div>
4103            <div class="export-format-label"><span class="material-icons" data-icon="videocam" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M363 224 448 139V373L363 288V363Q363 372 356.5 378.0Q350 384 341 384H85Q76 384 70.0 378.0Q64 372 64 363V149Q64 140 70.0 134.0Q76 128 85 128H341Q350 128 356.5 134.0Q363 140 363 149Z"></path></svg></span><span>Video</span></div>
4104            <button type="button" role="option" data-value="mp4" aria-selected="false">MP4</button>
4105            <button type="button" role="option" data-value="webm" aria-selected="false">WebM</button>
4106          </div>
4107        </div>
4108        <button class="btn secondary" id="copyGradientBtn" style="padding: 10px 12px; font-size: 14px; display: none; align-items: center; gap: 6px; min-width: 120px;" title="Copy Gradient JSON">
4109          <span class="material-icons" style="font-size: 18px;" data-icon="content_copy" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 448V149H171V448ZM405 107Q422 107 435.0 119.5Q448 132 448 149V448Q448 465 435.0 478.0Q422 491 405 491H171Q154 491 141.0 478.0Q128 465 128 448V149Q128 132 141.0 119.5Q154 107 171 107ZM341 21V64H85V363H43V64Q43 47 55.5 34.0Q68 21 85 21Z"></path></svg></span>
4110          <span>Copy JSON</span>
4111        </button>
4112    <button class="btn" id="exportBtn"
4113      style="background:#A2D729; box-shadow:0 4px 12px rgba(162, 215, 41, 0.25);
4113 color:#1a1a1a; flex-shrink: 0; display: flex; align-items: center; justify-content: center; gap: 4px; padding: 10px 12px;"><span class="material-icons export-icon" style="color:#1a1a1a; width: 16px; flex-shrink: 0; display: inline-block; text-align: center;" data-icon="download" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M107 427H405V384H107ZM405 192H320V64H192V192H107L256 341Z"></path></svg></span><span class="export-text">Export</span></button>
4114      </div>
4115  <div class="floating-history" aria-hidden="true" style="display: none;">
4116    <button class="btn secondary" id="undoBtn" style="padding:8px 12px; font-size:13px;" title="Undo (Ctrl/Cmd+Z)"><span class="material-icons" data-icon="undo" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M267 171Q340 171 398.0 214.0Q456 257 479 325L429 341Q412 289 367.5 256.5Q323 224 267 224Q205 224 157 264L235 341H43V149L119 226Q182 171 267 171Z"></path></svg></span> Undo</button>
4117    <button class="btn secondary" id="redoBtn" style="padding:8px 12px; font-size:13px;" title="Redo (Ctrl/Cmd+R)"><span class="material-icons" data-icon="redo" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M393 226 469 149V341H277L355 264Q307 224 245 224Q194 224 146.5 258.0Q99 292 83 341L33 325Q55 257 113.5 214.0Q172 171 245 171Q330 171 393 226Z"></path></svg></span> Redo</button>
4118  </div>
4119  </div>
4120
4121    <div class="floating-export-count" id="exportCountDisplay" style="display: none;">
4122      <span id="exportCountText" style="font-size: 14px; color: var(--text-secondary); flex: 1; text-align: center;">Checking export allowance…</span>
4123      <button class="close-btn" id="closeExportCount" title="Close" aria-label="Close">
4124        <span class="material-icons" data-icon="close" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 137 286 256 405 375 375 405 256 286 137 405 107 375 226 256 107 137 137 107 256 226 375 107Z"></path></svg></span>
4125  </button>
4126    </div>
4127
4128    <div class="canvas-root">
4129      <div class="canvas-wrapper" id="canvasWrapper">
4130        <canvas id="gradientCanvas" width="900" height="520"></canvas>
4131        <canvas id="xCurveCanvas" width="900" height="520"></canvas>
4132        <canvas id="yCurveCanvas" width="900" height="520"></canvas>
4133      </div>
4134    </div>
4135  </div>
4136
4137  <a href="/guide/" target="_blank" rel="noopener noreferrer" class="help-button" id="helpButton" title="Open guide">
4138    <span class="material-icons" data-icon="help_outline" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M341 213Q341 240 309.0 268.5Q277 297 277 320H235Q235 297 245.0 280.5Q255 264 267.0 256.5Q279 249 289.0 238.0Q299 227 299 213Q299 196 286.0 183.5Q273 171 256.0 171.0Q239 171 226.0 183.5Q213 196 213 213H171Q171 178 196.0 153.0Q221 128 256.0 128.0Q291 128 316.0 153.0Q341 178 341 213ZM135.5 376.5Q186 427 256.0 427.0Q326 427 376.5 376.5Q427 326 427.0 256.0Q427 186 376.5 135.5Q326 85 256.0 85.0Q186 85 135.5 135.5Q85 186 85.0 256.0Q85 326 135.5 376.5ZM105.5 105.5Q168 43 256.0 43.0Q344 43 406.5 105.5Q469 168 469.0 256.0Q469 344 406.5 406.5Q344 469 256.0 469.0Q168 469 105.5 406.5Q43 344 43.0 256.0Q43 168 105.5 105.5ZM235 384V341H277V384Z"></path></svg></span>
4139  </a>
4140
4141  <div class="help-modal" id="helpModal">
4142    <div class="help-modal-content">
4143      <div class="help-modal-header">
4144        <h2>How to Use Colir</h2>
4145        <button class="help-modal-close" id="closeHelpModal" aria-label="Close">
4146          <span class="material-icons" data-icon="close" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 137 286 256 405 375 375 405 256 286 137 405 107 375 226 256 107 137 137 107 256 226 375 107Z"></path></svg></span>
4147        </button>
4148      </div>
4149      <div>
4150        <h3>Curves</h3>
4151        <p>Colir uses two curves (X and Y) to control the gradient. Switch between them using the X and Y tabs at the top.</p>
4152        
4153        <h3>Adding Points</h3>
4154        <p>Left click on a curve to add a new control point at that position.</p>
4155        
4156        <h3>Removing Points</h3>
4157        <p>Right click on a point to remove it from the curve.</p>
4158        
4159        <h3>Moving Points</h3>
4160        <p>Click and drag any point to adjust the curve shape. The active point will glow white and be slightly larger.</p>
4161        
4162        <h3>Gradient Stops</h3>
4163        <p>Click on the gradient bar to add color stops, or drag existing stops to reposition them. Click a stop to select it and change its color.</p>
4164        
4165        <h3>Randomize</h3>
4166        <p>Use the Randomize button to generate random curves, colors, and settings. Individual randomize buttons are available for colors and curves.</p>
4167        
4168        <div style="margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--stroke);">
4169          <p style="margin: 0; font-size: 12px; color: var(--text-secondary);">
4170            <strong style="color: var(--text);">Need Help?</strong> <a href="mailto:[email protected]" style="color: var(--primary); text-decoration: none;">[email protected]</a>
4171          </p>
4172        </div>
4173      </div>
4174    </div>
4175  </div>
4176
4177  <div class="help-modal" id="templatesModal">
4178    <div class="help-modal-content templates-modal-content" id="templatesModalContent">
4179      <div class="help-modal-header">
4180        <h2>Curated Templates</h2>
4181        <button class="help-modal-close" id="closeTemplatesModal" type="button" title="Close" aria-label="Close">
4182          <span class="material-icons" data-icon="close" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 137 286 256 405 375 375 405 256 286 137 405 107 375 226 256 107 137 137 107 256 226 375 107Z"></path></svg></span>
4183        </button>
4184      </div>
4185      <div class="templates-grid" id="templatesGrid"></div>
4186    </div>
4187  </div>
4188
4189  <!-- Login Modal (for export) -->
4190  <div class="help-modal" id="loginModal">
4191    <div class="help-modal-content" id="loginModalContent" style="max-width: 400px;">
4192      <div class="help-modal-header">
4193        <h2 id="appLoginModalTitle">Log in</h2>
4194        <button class="help-modal-close" id="closeLoginModal" type="button" aria-label="Close">
4195          <span class="material-icons" data-icon="close" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 137 286 256 405 375 375 405 256 286 137 405 107 375 226 256 107 137 137 107 256 226 375 107Z"></path></svg></span>
4196        </button>
4197      </div>
4198      <div id="appLoginModalBody">
4199        <div id="appModalError" class="error-msg"></div>
4200        <div id="appModalSuccess" class="success-msg"></div>
4201        <button type="button" class="google-btn" id="appModalGoogleBtn">
4202          <svg viewBox="0 0 24 24" width="20" height="20"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
4203          Continue with Google
4204        </button>
4205        <div class="divider"><span>or</span></div>
4206        <form id="appLoginForm">
4207          <div class="form-group" id="appModalNameGroup" style="display: none;">
4208            <label for="appModalNameInput">Name (optional)</label>
4209            <input type="text" id="appModalNameInput" placeholder="Your name" autocomplete="name" />
4210          </div>
4211          <div class="form-group">
4212            <label for="appModalEmail">Email</label>
4213            <input type="email" id="appModalEmail" placeholder="[email protected]" required autocomplete="email" />
4214          </div>
4215          <div class="form-group">
4216            <label for="appModalPassword">Password</label>
4217            <input type="password" id="appModalPassword" placeholder="At least 8 characters" required minlength="8" autocomplete="current-password" />
4218          </div>
4219          <div class="form-row">
4220            <label class="remember-me">
4221              <input type="checkbox" id="appModalRemember" checked />
4222              Remember me
4223            </label>
4224            <a href="#" class="forgot-link" id="appModalShowForgot">Forgot password?</a>
4225          </div>
4226          <button type="submit" class="submit-btn" id="appModalSubmit">Log in</button>
4227        </form>
4228        <div class="toggle-mode" id="appModalToggleRow">
4229          Don't have an account? <a href="#" id="appModalToggleSignUp">Sign up</a>
4230        </div>
4231        <div id="appModalForgotView" style="display: none;">
4232          <a href="#" class="back-link" id="appModalBackToLogin">← Back to login</a>
4233          <h3 style="margin: 0 0 8px 0; font-size: 18px;">Reset Password</h3>
4234          <p style="margin: 0 0 16px 0; font-size: 13px; color: var(--login-text-secondary);">Enter your email and we'll send you a reset link.</p>
4235          <div id="appModalForgotError" class="error-msg"></div>
4236          <div id="appModalForgotSuccess" class="success-msg"></div>
4237          <form id="appModalForgotForm">
4238            <div class="form-group">
4239              <label for="appModalForgotEmail">Email</label>
4240              <input type="email" id="appModalForgotEmail" placeholder="[email protected]" required autocomplete="email" />
4241            </div>
4242            <button type="submit" class="submit-btn" id="appModalForgotBtn" style="margin-top: 8px;">Send Reset Link</button>
4243          </form>
4244        </div>
4245      </div>
4246    </div>
4247  </div>
4248
4249  <!-- Export Modal -->
4250  <div class="help-modal" id="exportModal">
4251    <div class="help-modal-content">
4252      <div class="help-modal-header">
4253        <h2>Export Gradient</h2>
4254        <button class="help-modal-close" id="closeExportModal" aria-label="Close">
4255          <span class="material-icons" data-icon="close" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 137 286 256 405 375 375 405 256 286 137 405 107 375 226 256 107 137 137 107 256 226 375 107Z"></path></svg></span>
4256        </button>
4257      </div>
4258      <div>
4259        <div style="margin-bottom: 20px;">
4260          <label style="display: flex; align-items: flex-start; gap: 12px; cursor: pointer; padding: 12px; border-radius: 8px; border: 1px solid var(--stroke); background: var(--input-bg);">
4261            <input type="checkbox" id="exportTermsCheckbox" style="margin-top: 2px; cursor: pointer;" />
4262            <span style="font-size: 14px; color: var(--text); line-height: 1.5;">
4263              I understand this export is for <strong>personal/non-commercial use only</strong>. Commercial use requires a PRO license.
4264            </span>
4265          </label>
4266        </div>
4267        <div style="margin-bottom: 20px; padding: 12px; background: var(--bg); border-radius: 8px; text-align: center;">
4268          <p style="margin: 0; font-size: 14px; color: var(--text-secondary);">
4269            <strong id="exportCounter" style="color: var(--text); font-size: 18px; display: block; margin-top: 4px;">Checking export allowance…</strong>
4270          </p>
4271        </div>
4272        <div style="display: flex; gap: 12px;">
4273          <button class="btn" id="confirmExportBtn" style="flex: 1; background: #A2D729; color: #1a1a1a;" disabled>
4274            <span class="material-icons" style="font-size: 18px; margin-right: 4px;" data-icon="download" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M107 427H405V384H107ZM405 192H320V64H192V192H107L256 341Z"></path></svg></span>
4275            Export
4276          </button>
4277        </div>
4278        <div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--stroke); text-align: center;">
4279          <p style="margin: 0; font-size: 12px; color: var(--text-secondary);">
4280            Need unlimited exports? <a href="/#pricing" id="upgradeLink" style="color: var(--primary); text-decoration: none; font-weight: 600;">Upgrade to PRO — from $9/mo</a>
4281          </p>
4282        </div>
4283      </div>
4284    </div>
4285  </div>
4286
4287  <!-- Upgrade Modal -->
4288  <div class="help-modal" id="upgradeModal" role="dialog" aria-modal="true" aria-labelledby="upgradeModalTitle" tabindex="-1">
4289    <div class="help-modal-content">
4290      <div class="help-modal-header">
4291        <h2 id="upgradeModalTitle">Upgrade to PRO</h2>
4292        <button class="help-modal-close" id="closeUpgradeModal" type="button" aria-label="Close upgrade dialog">
4293          <span class="material-icons" data-icon="close" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 137 286 256 405 375 375 405 256 286 137 405 107 375 226 256 107 137 137 107 256 226 375 107Z"></path></svg></span>
4294        </button>
4295      </div>
4296      <div style="text-align: center;">
4297        <p id="upgradeModalMessage" style="margin-bottom: 24px; color: var(--text-secondary);">
4298          You've reached your free export limit. Upgrade to PRO for unlimited exports and commercial use.
4299        </p>
4300      <div class="pricing-grid">
4301        <div class="pricing-card-wrap">
4302          <div class="cp-card cp-free">
4303            <div class="cp-layer cp-warm"></div>
4304            <div class="cp-layer cp-edge"></div>
4305            <div class="cp-body">
4306              <div class="cp-head">
4307                <div class="cp-wordmark">FREE</div>
4308              </div>
4309              <div class="cp-feats">
4310                <div class="cp-feat"><span class="material-icons" data-icon="credit_card_off" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M467 467 45 45 15 75 48 108Q43 117 43 128V384Q43 402 55.5 414.5Q68 427 85 427H366L437 497ZM85 256V171H110L196 256ZM146 85H427Q444 85 456.5 97.5Q469 110 469 128V384Q469 395 464 404L316 256H427V171H231Z"></path></svg></span>No credit card needed</div>
4311                <div class="cp-feat"><span class="material-icons" data-icon="event_repeat" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false">
4311<path d="M448 256V128Q448 110 435.5 97.5Q423 85 405 85H384V43H341V85H171V43H128V85H107Q89 85 76.5 97.5Q64 110 64 128V427Q64 444 76.5 456.5Q89 469 107 469H256V427H107V213H405V256ZM334 427Q341 450 360.5 465.0Q380 480 405 480Q426 480 443.0 470.0Q460 460 470.0 443.0Q480 426 480 405Q480 385 470.0 368.0Q460 351 443.0 341.0Q426 331 405 331Q390 331 376.5 336.5Q363 342 353 352H384V384H299V299H331V329Q345 315 364.5 307.0Q384 299 405 299Q435 299 459.0 313.0Q483 327 497.5 351.5Q512 376 512 405Q512 435 497.5 459.0Q483 483 459.0 497.5Q435 512 405 512Q380 512 358.0 501.0Q336 490 321.0 470.5Q306 451 301 427Z"></path></svg></span>3 exports per day, up to 10 per month</div>
4312                <div class="cp-feat"><span class="material-icons" data-icon="hd" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M309 288V224H352V288ZM277 192V320H363Q372 320 378.0 314.0Q384 308 384 299V213Q384 204 378.0 198.0Q372 192 363 192ZM235 320V192H203V245H160V192H128V320H160V277H203V320ZM405 64Q422 64 435.0 77.0Q448 90 448 10
43127V405Q448 422 435.0 435.0Q422 448 405 448H107Q89 448 76.5 435.5Q64 423 64 405V107Q64 89 76.5 76.5Q89 64 107 64Z"></path></svg></span>Full HD resolution limit</div>
4313                <div class="cp-feat"><span class="material-icons" data-icon="verified" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M491 256 439 196 446 118 369 100 329 32 256 63 183 32 143 100 66 117 73 196 21 256 73 316 66 394 143 412 183 480 256 449 329 480 369 412 446 394 439 316ZM215 357 134 275 166 244 215 294 340 168 372 200Z"></path></svg></span>No watermarks</div>
4314                <div class="cp-feat"><span class="material-icons" data-icon="person" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M427 384V427H85V384Q85 346 143.5 322.5Q202 299 256.0 299.0Q310 299 368.5 322.5Q427 346 427 384ZM316.0 231.0Q291 256 256.0 256.0Q221 256 196.0 231.0Q171 206 171.0 171.0Q171 136 196.0 110.5Q221 85 256.0 85.0Q291 85 316.0 110.5Q341 136 341.0 171.0Q341 206 316.0 231.0Z"></path></svg></span>Personal use only</div>
4315              </div>
4316              <div class="cp-foot"><button type="button" id="upgradeKeepFree" class="cp-cta">Keep creating for free</button></div>
4317            </div>
4318          </div>
4319        </div>
4320
4321        <div class="pricing-card-wrap">
4322          <div class="cp-card cp-pro">
4323            <div class="cp-layer cp-warm"></div>
4324            <div class="cp-pattern"></div>
4325            <div class="cp-layer cp-grain"></div>
4326            <div class="cp-layer cp-edge"></div>
4327            <div class="cp-body">
4328              <div class="cp-head">
4329                <div class="cp-headtext">
4330                  <div class="cp-plan">Colir PRO</div>
4331                  <div class="cp-price-row">
4332                    <span class="cp-price" id="app-cp-pro-price">$9</span>
4333                    <span class="cp-per" id="app-cp-pro-per">/mo</span>
4334                  </div>
4335                  <div class="cp-note">VAT may apply</div>
4336                </div>
4337                <div class="cp-toggle" id="app-cp-toggle" data-billing="monthly">
4338                  <div class="cp-thumb"></div>
4339                  <button type="button" data-billing="monthly" aria-pressed="true">Monthly</button>
4340                  <button type="button" data-billing="annual" aria-pressed="false">Annual</button>
4341                </div>
4342              </div>
4343              <div class="cp-feats">
4344                <div class="cp-feat"><span class="material-icons" data-icon="grid_view" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 405V320H320V405ZM277 277H448V448H277ZM405 192V107H320V192ZM277 64H448V235H277ZM192 405V320H107V405ZM64 277H235V448H64ZM192 192V107H107V192ZM64 64H235V235H64Z"></path></svg></span>Everything in Free</div>
4345                <div class="cp-feat"><span class="material-icons" data-icon="all_inclusive" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M397 371H396Q377 371 353.5 361.0Q330 351 315 337L288 313L320 285L345 306Q367 328 397.0 328.0Q427 328 448.0 307.0Q469 286 469.0 256.0Q469 226 448.0 205.0Q427 184 397.0 184.0Q367 184 346 205Q275 267 256 285L196 338Q163 371 115.0 371.0Q67 371 33.5 337.0Q0 303 0.0 256.0Q0 209 33.5 175.0Q67 141 115 141H116Q136 141 159.5 151.0Q183 161 197 175L224 199L191 227L167 206Q145 184 115.0 184.0Q85 184 64.0 205.0Q43 226 43.0 256.0Q43 286 64.0 307.0Q85 328 115.0 328.0Q145 328 166 307Q237 245 256 227L316 174Q349 141 397.0 141.0Q445 141 478.5 175.0Q512 209 512.0 256.0Q512 303 478.5 337.0Q445 371 397 371Z"></path></svg></span>Unlimited exports</div>
4346                <div class="cp-feat"><span class="material-icons" data-icon="4k" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M384 320 336 256 384 192H347L309 240V192H277V320H309V272L347 320ZM256 288V256H235V192H203V256H171V192H139V288H203V320H235V288ZM405 64Q422 64 435.0 77.0Q448 90 448 10
43467V405Q448 422 435.0 435.0Q422 448 405 448H107Q90 448 77.0 435.0Q64 422 64 405V107Q64 90 77.0 77.0Q90 64 107 64Z"></path></svg></span>No resolution limit</div>
4347                <div class="cp-feat">
4348                  <svg class="cp-figma-icon" viewBox="0 0 38 57" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
4349                    <path fill="#1ABCFE" d="M19 28.5a9.5 9.5 0 1 1 19 0 9.5 9.5 0 0 1-19 0z"/>
4350                    <path fill="#0ACF83" d="M0 47.5A9.5 9.5 0 0 1 9.5 38H19v9.5a9.5 9.5 0 1 1-19 0z"/>
4351                    <path fill="#FF7262" d="M19 0v19h9.5a9.5 9.5 0 1 0 0-19H19z"/>
4352                    <path fill="#F24E1E" d="M0 9.5A9.5 9.5 0 0 0 9.5 19H19V0H9.5A9.5 9.5 0 0 0 0 9.5z"/>
4353                    <path fill="#A259FF" d="M0 28.5A9.5 9.5 0 0 0 9.5 38H19V19H9.5A9.5 9.5 0 0 0 0 28.5z"/>
4354                  </svg>
4355                  Unlimited Figma plugin use
4356                </div>
4357                <div class="cp-feat"><span class="material-icons" data-icon="business_center" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M299 149V107H213V149ZM427 149Q444 149 456.5 162.0Q469 175 469 192V256Q469 273 456.5 286.0Q444 299 427 299H299V256H213V299H85Q67 299 55.0 286.5Q43 274 43 256V192Q43 175 55.5 162.0Q68 149 85 149H170V107L213 64H298L341 107V149ZM213 341H299V320H448V405Q448 423 435.5 435.5Q423 448 405 448H107Q89 448 76.5 435.5Q64 423 64 405V320H213Z"></path></svg></span>Commercial use allowed</div>
4358                <div class="cp-feat"><span class="material-icons" data-icon="public" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M382 371Q427 323 427 256Q427 203 397.5 160.0Q368 117 320 98V107Q320 124 307.0 136.5Q294 149 277 149H235V192Q235 201 228.5 207.0Q222 213 213 213H171V256H299Q308 256 314.0 262.0Q320 268 320 277V341H341Q371 341 382 371ZM235 425V384Q218 384 205.0 371.0Q192 358 192 341V320L90 218Q85 238 85 256Q85 321 128.5 369.0Q172 417 235 425ZM105.5 105.5Q168 43 256.0 43.0Q344 43 406.5 105.5Q469 168 469.0 256.0Q469 344 406.5 406.5Q344 469 256.0 469.0Q168 469 105.5 406.5Q43 344 43.0 256.0Q43 168 105.5 105.5Z"></path></svg></span>Client work allowed</div>
4359                <div class="cp-feat"><span class="material-icons" data-icon="science" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M422 393 299 228V139L327 103Q332 97 329.0 91.0Q326 85 319 85H193Q186 85 183.0 91.0Q180 97 185 103L213 139V228L90 393Q84 400 85.0 407.5Q86 415 92.0 421.0Q98 427 107 427H405Q414 427 420.0 421.0Q426 415 427.0 407.5Q428 400 422 393Z"></path></svg></span>Access to Beta features</div>
4360              </div>
4361              <div class="cp-foot"><a href="#pricing" class="cp-cta" id="purchaseProMonthlyBtn">Get PRO — $9/mo</a></div>
4362            </div>
4363          </div>
4364        </div>
4365
4366        <div class="pricing-card-wrap">
4367          <div class="cp-card cp-perp">
4368            <div class="cp-layer cp-warm"></div>
4369            <div class="cp-pattern"></div>
4370            <div class="cp-layer cp-grain"></div>
4371            <div class="cp-layer cp-edge"></div>
4372            <div class="cp-body">
4373              <div class="cp-head">
4374                <div class="cp-headtext">
4375                  <div class="cp-plan">Lifetime PRO License</div>
4376                  <div class="cp-price">$99</div>
4377                  <div class="cp-note">VAT may apply</div>
4378                </div>
4379                <span class="cp-badge">Lifetime</span>
4380              </div>
4381              <div class="cp-feats">
4382                <div class="cp-feat"><span class="material-icons" data-icon="grid_view" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 405V320H320V405ZM277 277H448V448H277ZM405 192V107H320V192ZM277 64H448V235H277ZM192 405V320H107V405ZM64 277H235V448H64ZM192 192V107H107V192ZM64 64H235V235H64Z"></path></svg></span>Everything in Free</div>
4383                <div class="cp-feat"><span class="material-icons" data-icon="all_inclusive" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false">
4383<path d="M397 371H396Q377 371 353.5 361.0Q330 351 315 337L288 313L320 285L345 306Q367 328 397.0 328.0Q427 328 448.0 307.0Q469 286 469.0 256.0Q469 226 448.0 205.0Q427 184 397.0 184.0Q367 184 346 205Q275 267 256 285L196 338Q163 371 115.0 371.0Q67 371 33.5 337.0Q0 303 0.0 256.0Q0 209 33.5 175.0Q67 141 115 141H116Q136 141 159.5 151.0Q183 161 197 175L224 199L191 227L167 206Q145 184 115.0 184.0Q85 184 64.0 205.0Q43 226 43.0 256.0Q43 286 64.0 307.0Q85 328 115.0 328.0Q145 328 166 307Q237 245 256 227L316 174Q349 141 397.0 141.0Q445 141 478.5 175.0Q512 209 512.0 256.0Q512 303 478.5 337.0Q445 371 397 371Z"></path></svg></span>Unlimited exports</div>
4384                <div class="cp-feat"><span class="material-icons" data-icon="4k" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M384 320 336 256 384 192H347L309 240V192H277V320H309V272L347 320ZM256 288V256H235V192H203V256H171V192H139V288H203V320H235V288ZM405 64Q422 64 435.0 77.0Q448 90 448 107V405Q448 422 435.0 435.0Q422 448 405 448H107Q90 448 77.0 435.0Q64 422 64 405V107Q64 90 77.0 77.0Q90 64 107 64Z"></path></svg></span>No resolution limit</div>
4385                <div class="cp-feat">
4386                  <svg class="cp-figma-icon" viewBox="0 0 38 57" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
4387                    <path fill="#1ABCFE" d="M19 28.5a9.5 9.5 0 1 1 19 0 9.5 9.5 0 0 1-19 0z"/>
4388                    <path fill="#0ACF83" d="M0 47.5A9.5 9.5 0 0 1 9.5 38H19v9.5a9.5 9.5 0 1 1-19 0z"/>
4389                    <path fill="#FF7262" d="M19 0v19h9.5a9.5 9.5 0 1 0 0-19H19z"/>
4390                    <path fill="#F24E1E" d="M0 9.5A9.5 9.5 0 0 0 9.5 19H19V0H9.5A9.5 9.5 0 0 0 0 9.5z"/>
4391                    <path fill="#A259FF" d="M0 28.5A9.5 9.5 0 0 0 9.5 38H19V19H9.5A9.5 9.5 0 0 0 0 28.5z"/>
4392                  </svg>
4393                  Unlimited Figma plugin use
4394                </div>
4395                <div class="cp-feat"><span class="material-icons" data-icon="business_center" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M299 149V107H213V149ZM427 149Q444 149 456.5 162.0Q469 175 469 192V256Q469 273 456.5 286.0Q444 299 427 299H299V256H213V299H85Q67 299 55.0 286.5Q43 274 43 256V192Q43 175 55.5 162.0Q68 149 85 149H170V107L213 64H298L341 107V149ZM213 341H299V320H448V405Q448 423 435.5 435.5Q423 448 405 448H107Q89 448 76.5 435.5Q64 423 64 405V320H213Z"></path></svg></span>Commercial use allowed</div>
4396                <div class="cp-feat"><span class="material-icons" data-icon="public" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M382 371Q427 323 427 256Q427 203 397.5 160.0Q368 117 320 98V107Q320 124 307.0 136.5Q294 149 277 149H235V192Q235 201 228.5 207.0Q222 213 213 213H171V256H299Q308 256 314.0 262.0Q320 268 320 277V341H341Q371 341 382 371ZM235 425V384Q218 384 205.0 371.0Q192 358 192 341V320L90 218Q85 238 85 256Q85 321 128.5 369.0Q172 417 235 425ZM105.5 105.5Q168 43 256.0 43.0Q344 43 406.5 105.5Q469 168 469.0 256.0Q469 344 406.5 406.5Q344 469 256.0 469.0Q168 469 105.5 406.5Q43 344 43.0 256.0Q43 168 105.5 105.5Z"></path></svg></span>Client work allowed</div>
4397                <div class="cp-feat"><span class="material-icons" data-icon="science" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M422 393 299 228V139L327 103Q332 97 329.0 91.0Q326 85 319 85H193Q186 85 183.0 91.0Q180 97 185 103L213 139V228L90 393Q84 400 85.0 407.5Q86 415 92.0 421.0Q98 427 107 427H405Q414 427 420.0 421.0Q426 415 427.0 407.5Q428 400 422 393Z"></path></svg></span>Access to Beta features</div>
4398                <div class="cp-feat"><span class="material-icons" data-icon="verified" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M491 256 439 196 446 118 369 100 329 32 256 63 183 32 143 100 66 117 73 196 21 256 73 316 66 394 143 412 183 480 256 449 329 480 369 412 446 394 439 316ZM215 357 134 275 166 244 215 294 340 168 372 200Z"></path></svg></span>Yours forever. No renewals.</div>
4399              </div>
4400              <div class="cp-foot"><a href="#pricing" class="cp-cta" id="purchaseProPerpetualBtn">Get Lifetime — $99</a></div>
4401            </div>
4402          </div>
4403        </div>
4404      </div>
4405        <p style="margin-top: 14px; font-size: 12px; color: var(--text-secondary);">
4406          Secure checkout via Paddle. Cancel anytime on the subscription plan.
4407        </p>
4408      </div>
4409    </div>
4410  </div>
4411
4412  <!-- Paddle Success Modal -->
4413  <div class="help-modal" id="paddleSuccessModal">
4414    <div class="help-modal-content" style="
4415      width: 713px;
4416      height: 221px;
4417      max-width: calc(100vw - 32px);
4418      max-height: calc(100vh - 32px);
4419      padding: 0;
4420      position: relative;
4421      background: url('/Templates/PRO_purchased.png') center / cover no-repeat;
4422      overflow: hidden;
4423      display: flex;
4424      align-items: center;
4425      justify-content: center;
4426    ">
4427      <button class="help-modal-close" id="closePaddleSuccessModal" type="button" style="
4428        position: absolute;
4429        top: 12px;
4430        right: 12px;
4431        width: 36px;
4432        height: 36px;
4433        border-radius: 999px;
4434        border: 1px solid rgba(247, 238, 224, 0.28);
4435        background: rgba(0, 0, 0, 0.24);
4436        color: #F7EEE0;
4437        display: inline-flex;
4438        align-items: center;
4439        justify-content: center;
4440      " aria-label="Close">
4441        <span class="material-icons" style="font-size: 20px;" data-icon="close" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false"><path d="M405 137 286 256 405 375 375 405 256 286 137 405 107 375 226 256 107 137 137 107 256 226 375 107Z"></path></svg></span>
4442      </button>
4443
4444      <div style="
4445        width: 100%;
4446        height: 100%;
4447        display: flex;
4448        flex-direction: column;
4449        align-items: center;
4450        justify-content: center;
4451        text-align: center;
4452        gap: 14px;
4453      ">
4454        <div style="display: flex; flex-direction: column; gap: 6px;">
4455          <div style="font-size: 16px; color: #F7EEE0; letter-spacing: 0.2px;">Welcome to</div>
4456          <div style="font-size: 42px; color: #F7EEE0; font-weight: 700; line-height: 1.02;">Colir PRO</div>
4457        </div>
4458
4459        <button class="btn" id="paddleSuccessOkBtn" type="button" style="
4460          background: #F7EEE0;
4461          color: #1a1a1a;
4462          font-weight: 700;
4463          padding: 12px 22px;
4464          border-radius: 10px;
4465        ">
4466          Start Creating
4467        </button>
4468      </div>
4469    </div>
4470  </div>
4471
4472  
4472<script src="graphics-runtime.js?v=graphics-debug-1"></script>
4472
4473  
4473<script src="surface-filters.js?v=beta-ui-20260916"></script>
4473
4474  
4474<script src="silk-programs.js"></script>
4474
4475  
4475<script src="surface-filter-renderer.js"></script>
4475
4476  
4476<script src="shader-programs.js?v=beta-effects-20260916"></script>
4476
4477  
4477<script src="shader-warmup.js?v=progressive-1"></script>
4477
4478  
4478<script src="program-cache.js?v=progressive-1"></script>
4478
4479  
4479<script src="graphics-debug.js?v=graphics-debug-1"></script>
4479
4480  
4480<script src="animation/core.js?v=flow-travel-20260916"></script>
4480
4481  
4481<script>
4482    // Utility: Catmull-Rom spline for smooth curves through points.
4483    // tension: -1 to 1, where -1 is tight (inward), 0 is standard, 1 is loose (outward)
4484    function catmullRom(p0, p1, p2, p3, t, tension = 0) {
4485      // Adjust control points based on tension
4486      // Negative tension pulls curve inward, positive pushes it outward
4487      const s = (1 - tension) * 0.5;
4488      const v0 = (p2 - p0) * s;
4489      const v1 = (p3 - p1) * s;
4490      const t2 = t * t;
4491      const t3 = t2 * t;
4492      return (2 * p1 - 2 * p2 + v0 + v1) * t3 +
4493        (-3 * p1 + 3 * p2 - 2 * v0 - v1) * t2 +
4494        v0 * t +
4495        p1;
4496    }
4497
4498    // Seeded random number generator (Linear Congruential Generator)
4499    class SeededRNG {
4500      constructor(seed) {
4501        this.seed = seed || 0;
4502      }
4503      setSeed(seed) {
4504        this.seed = seed;
4505      }
4506      next() {
4507        // LCG parameters (used by many implementations)
4508        this.seed = (this.seed * 1664525 + 1013904223) % (2 ** 32);
4509        return (this.seed >>> 0) / (2 ** 32);
4510      }
4511      random() {
4512        return this.next();
4513      }
4514      randomInt(min, max) {
4515        return Math.floor(this.random() * (max - min + 1)) + min;
4516      }
4517    }
4518
4519    class CurveTubeRenderer {
4520      constructor() {
4521        this.ok = false;
4522        this.LUT_SIZE = 256;
4523        this._lutKey = null;
4524        this.canvas = document.createElement('canvas');
4525        this.canvas.width = 1;
4526        this.canvas.height = 1;
4527        ColirGraphicsRuntime.note('curves', 'context creation');
4528        const gl = this.canvas.getContext('webgl', {
4529          alpha: true,
4530          premultipliedAlpha: true,
4531          preserveDrawingBuffer: true,
4532          antialias: false,
4533          depth: false,
4534          stencil: false,
4535        });
4536        this.gl = gl;
4537        this.lifecycle = ColirGraphicsRuntime.create({name:'curves', canvas:this.canvas,
4538          initialize: async signal => {
4539            if(this.pending)await this.pending.catch(()=>{});
4540            ColirGraphicsRuntime.check(signal);
4541            if (!this.gl) this.gl = this.canvas.getContext('webgl', {alpha:true,premultipliedAlpha:true,preserveDrawingBuffer:true,antialias:false,depth:false,stencil:false});
4542            if (!this.gl) throw new Error('Curve graphics are unavailable.');
4543            ColirGraphicsRuntime.inspect('curves', this.gl);
4544            if (this.prog) this.gl.deleteProgram(this.prog);
4545            if (this.buffer) this.gl.deleteBuffer(this.buffer);
4546            if (this.lutTex) this.gl.deleteTexture(this.lutTex);
4547            this._lutKey = null;
4548            this.pending=this._init(this.gl, signal);
4549            await this.pending;
4550          },
4551          onState: state => {
4552            this.ok = state === 'ready';
4553            if(state === 'lost')this.linkAbort?.abort();
4554            if (this.ok) renderGradient();
4555          }});
4556        // The Design preparation queue starts this renderer after scene programs.
4557      }
4558      async _init(gl, signal) {
4559        const vsSrc = `
4560          attribute vec2 a_position;
4561          attribute float a_side;
4562          attribute float a_value;
4563          attribute float a_along;
4564          attribute vec2 a_normal;
4565          uniform vec2 u_resolution;
4566          uniform vec2 u_offset;
4567          varying float v_side;
4568          varying float v_value;
4569          varying float v_along;
4570          varying vec2 v_normal;
4571          void main() {
4572            vec2 pos = a_position + u_offset;
4573            vec2 clip = (pos / u_resolution) * 2.0 - 1.0;
4574            clip.y *= -1.0;
4575            gl_Position = vec4(clip, 0.0, 1.0);
4576            v_side = a_side;
4577            v_value = a_value;
4578            v_along = a_along;
4579            v_normal = a_normal;
4580          }
4581        `;
4582        const fsSrc = `
4583          precision mediump float;
4584          uniform sampler2D u_gradLUT;
4585          uniform float u_opacity;
4586          uniform float u_isActive;
4587          uniform float u_pass;
4588          varying float v_side;
4589          varying float v_value;
4590          varying float v_along;
4591          varying vec2 v_normal;
4592          void main() {
4593            float r2 = v_side * v_side + v_along * v_along;
4594            float r = sqrt(r2);
4595            if (r > 1.0) discard;
4596            if (u_pass < 0.5) {
4597              float fall = exp(-r * r * 2.2);
4598              float a = fall * 0.10 * u_opacity;
4599              gl_FragColor = vec4(0.0, 0.0, 0.0, a);
4600              return;
4601            }
4602            vec3 base = texture2D(u_gradLUT, vec2(clamp(v_value, 0.0, 1.0), 0.5)).rgb;
4603            float z = sqrt(max(0.0, 1.0 - r2));
4604            vec2 n2 = normalize(v_normal);
4605            vec2 t2 = vec2(-n2.y, n2.x);
4606            vec3 N = normalize(vec3(n2 * v_side + t2 * v_along, z));
4607            vec3 L = normalize(vec3(-0.42, -0.72, 0.48));
4608            float ndotl = max(0.0, dot(N, L));
4609            float wrap = ndotl * 0.625 + 0.375;
4610            float active = u_isActive;
4611            float frostAmt = mix(0.459, 0.765, wrap);
4612            vec3 frost = mix(base, vec3(1.0), frostAmt);
4613            vec3 col = frost;
4614            col += vec3(1.0) * pow(ndotl, 3.2) * mix(0.22, 0.48, active);
4615            float rim = smoothstep(0.58, 0.92, r);
4616            vec3 rimCol = mix(vec3(0.82, 0.835, 0.855), vec3(1.0), ndotl);
4617            col = mix(col, rimCol, rim * mix(0.55, 0.85, ndotl));
4618            float a = mix(0.527, 0.799, wrap) * u_opacity;
4619            a *= smoothstep(1.0, 0.8, r);
4620            a *= mix(0.65, 1.0, active);
4621            gl_FragColor = vec4(col * a, a);
4622          }
4623        `;
4624        this.linkAbort = new AbortController();
4625        const prog = await ColirGraphicsRuntime.link(gl, vsSrc, fsSrc, this.linkAbort.signal, 'curves');
4626        try{ColirGraphicsRuntime.check(signal);}catch(error){gl.deleteProgram(prog);throw error;}
4627        ColirGraphicsRuntime.note('curves', 'resource setup', {}, signal);
4628        this.prog = prog;
4629        this.attribs = {
4630          position: gl.getAttribLocation(prog, 'a_position'),
4631          side: gl.getAttribLocation(prog, 'a_side'),
4632          value: gl.getAttribLocation(prog, 'a_value'),
4633          along: gl.getAttribLocation(prog, 'a_along'),
4634          normal: gl.getAttribLocation(prog, 'a_normal'),
4635        };
4636        this.uniforms = {
4637          resolution: gl.getUniformLocation(prog, 'u_resolution'),
4638          offset: gl.getUniformLocation(prog, 'u_offset'),
4639          gradLUT: gl.getUniformLocation(prog, 'u_gradLUT'),
4640          opacity: gl.getUniformLocation(prog, 'u_opacity'),
4641          isActive: gl.getUniformLocation(prog, 'u_isActive'),
4642          pass: gl.getUniformLocation(prog, 'u_pass'),
4643        };
4644        this.buffer = gl.createBuffer();
4645        this.lutTex = gl.createTexture();
4646        gl.bindTexture(gl.TEXTURE_2D, this.lutTex);
4647        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
4648        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
4649        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
4650        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
4651        const fallback = new Uint8Array(this.LUT_SIZE * 4);
4652        for (let i = 0; i < this.LUT_SIZE; i++) {
4653          const g = 220;
4654          fallback[i * 4] = g;
4655          fallback[i * 4 + 1] = g;
4656          fallback[i * 4 + 2] = g;
4657          fallback[i * 4 + 3] = 255;
4658        }
4659        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.LUT_SIZE, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, fallback);
4660        gl.enable(gl.BLEND);
4661        gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
4662        gl.disable(gl.DEPTH_TEST);
4663        return true;
4664      }
4665      resize(w, h) {
4666        w = Math.max(1, w | 0);
4667        h = Math.max(1, h | 0);
4668        if (this.canvas.width === w && this.canvas.height === h) return;
4669        this.canvas.width = w;
4670        this.canvas.height = h;
4671      }
4672      updateLUT() {
4673        if (!this.ok) return;
4674        if (typeof sampleGradientSrgb01 !== 'function') return;
4675        let stops;
4676        let paletteKey = '';
4677        try {
4678          stops = gradientStopsArray;
4679        } catch (e) {
4680          return;
4681        }
4682        if (!stops) return;
4683        try {
4684          if (activePalette) {
4685            paletteKey = activePalette.map(c => `${c.r},${c.g},${c.b}`).join(';');
4686          }
4687        } catch (e) { /* palette not ready */ }
4688        const stopsKey = stops.map(s => `${s.position}:${s.color.r},${s.color.g},${s.color.b}`).join('|');
4689        const key = `${stopsKey}|${paletteKey}`;
4690        if (this._lutKey === key) return;
4691        const data = new Uint8Array(this.LUT_SIZE * 4);
4692        for (let i = 0; i < this.LUT_SIZE; i++) {
4693          const t = i / (this.LUT_SIZE - 1);
4694          const c = sampleGradientSrgb01(t);
4695          const idx = i * 4;
4696          data[idx] = Math.max(0, Math.min(255, Math.round(c.r * 255)));
4697          data[idx + 1] = Math.max(0, Math.min(255, Math.round(c.g * 255)));
4698          data[idx + 2] = Math.max(0, Math.min(255, Math.round(c.b * 255)));
4699          data[idx + 3] = 255;
4700        }
4701        const gl = this.gl;
4702        gl.bindTexture(gl.TEXTURE_2D, this.lutTex);
4703        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.LUT_SIZE, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
4704        this._lutKey = key;
4705      }
4706      _buildMesh(samples, radius) {
4707        const n = samples.length;
4708        if (n < 2 || radius <= 0) return null;
4709        const tangents = new Array(n);
4710        for (let i = 0; i < n; i++) {
4711          let dx;
4712          let dy;
4713          if (i === 0) {
4714            dx = samples[1].x - samples[0].x;
4715            dy = samples[1].y - samples[0].y;
4716          } else if (i === n - 1) {
4717            dx = samples[n - 1].x - samples[n - 2].x;
4718            dy = samples[n - 1].y - samples[n - 2].y;
4719          } else {
4720            dx = samples[i + 1].x - samples[i - 1].x;
4721            dy = samples[i + 1].y - samples[i - 1].y;
4722          }
4723          const len = Math.hypot(dx, dy) || 1;
4724          const tx = dx / len;
4725          const ty = dy / len;
4726          tangents[i] = { x: tx, y: ty, nx: -ty, ny: tx };
4727        }
4728        const verts = new Float32Array((n + 2) * 2 * 7);
4729        let o = 0;
4730        const emit = (x, y, side, v, along, nx, ny) => {
4731          verts[o++] = x;
4732          verts[o++] = y;
4733          verts[o++] = side;
4734          verts[o++] = v;
4735          verts[o++] = along;
4736          verts[o++] = nx;
4737          verts[o++] = ny;
4738        };
4739        const s0 = samples[0];
4740        const t0 = tangents[0];
4741        emit(s0.x - t0.x * radius + t0.nx * radius, s0.y - t0.y * radius + t0.ny * radius, 1, s0.v, -1, t0.nx, t0.ny);
4742        emit(s0.x - t0.x * radius - t0.nx * radius, s0.y - t0.y * radius - t0.ny * radius, -1, s0.v, -1, t0.nx, t0.ny);
4743        for (let i = 0; i < n; i++) {
4744          const s = samples[i];
4745          const t = tangents[i];
4746          emit(s.x + t.nx * radius, s.y + t.ny * radius, 1, s.v, 0, t.nx, t.ny);
4747          emit(s.x - t.nx * radius, s.y - t.ny * radius, -1, s.v, 0, t.nx, t.ny);
4748        }
4749        const sN = samples[n - 1];
4750        const tN = tangents[n - 1];
4751        emit(sN.x + tN.x * radius + tN.nx * radius, sN.y + tN.y * radius + tN.ny * radius, 1, sN.v, 1, tN.nx, tN.ny);
4752        emit(sN.x + tN.x * radius - tN.nx * radius, sN.y + tN.y * radius - tN.ny * radius, -1, sN.v, 1, tN.nx, tN.ny);
4753        return verts;
4754      }
4755      _splitRuns(samples, joints, trimRadius) {
4756        if (!joints || !joints.length || !trimRadius) return [samples];
4757        const keep = (s) => {
4758          for (let i = 0; i < joints.length; i++) {
4759            if (Math.hypot(s.x - joints[i].x, s.y - joints[i].y) < trimRadius) return false;
4760          }
4761          return true;
4762        };
4763        const runs = [];
4764        let cur = [];
4765        for (let i = 0; i < samples.length; i++) {
4766          if (keep(samples[i])) {
4767            cur.push(samples[i]);
4768          } else if (cur.length) {
4769            if (cur.length >= 2) runs.push(cur);
4770            cur = [];
4771          }
4772        }
4773        if (cur.length >= 2) runs.push(cur);
4774        return runs;
4775      }
4776      draw(samples, opts) {
4777        if (!this.ok || !samples || samples.length < 2) return false;
4778        const gl = this.gl;
4779        const radius = opts.radius;
4780        const runs = this._splitRuns(samples, opts.joints, opts.trimRadius);
4781        if (!runs.length) return false;
4782        const w = this.canvas.width;
4783        const h = this.canvas.height;
4784        gl.viewport(0, 0, w, h);
4785        gl.clearColor(0, 0, 0, 0);
4786        gl.clear(gl.COLOR_BUFFER_BIT);
4787        gl.useProgram(this.prog);
4788        gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
4789        const stride = 7 * 4;
4790        const enable = (loc, size, offset) => {
4791          if (loc < 0) return;
4792          gl.enableVertexAttribArray(loc);
4793          gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride, offset);
4794        };
4795        enable(this.attribs.position, 2, 0);
4796        enable(this.attribs.side, 1, 8);
4797        enable(this.attribs.value, 1, 12);
4798        enable(this.attribs.along, 1, 16);
4799        enable(this.attribs.normal, 2, 20);
4800        gl.activeTexture(gl.TEXTURE0);
4801        gl.bindTexture(gl.TEXTURE_2D, this.lutTex);
4802        gl.uniform1i(this.uniforms.gradLUT, 0);
4803        const dpr = opts.dpr || 1;
4804        gl.uniform2f(this.uniforms.resolution, w, h);
4805        gl.uniform1f(this.uniforms.opacity, opts.opacity);
4806        gl.uniform1f(this.uniforms.isActive, opts.isActive ? 1 : 0);
4807        for (let i = 0; i < runs.length; i++) {
4808          const verts = this._buildMesh(runs[i], radius);
4809          if (!verts) continue;
4810          const shadowVerts = this._buildMesh(runs[i], radius * 1.48);
4811          if (shadowVerts) {
4812            gl.bufferData(gl.ARRAY_BUFFER, shadowVerts, gl.STREAM_DRAW);
4813            gl.uniform1f(this.uniforms.pass, 0);
4814            gl.uniform2f(this.uniforms.offset, 0.05 * dpr, 0.1 * dpr);
4815            gl.drawArrays(gl.TRIANGLE_STRIP, 0, shadowVerts.length / 7);
4816          }
4817          gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STREAM_DRAW);
4818          gl.uniform1f(this.uniforms.pass, 1);
4819          gl.uniform2f(this.uniforms.offset, 0, 0);
4820          ColirGraphicsRuntime.once('curves', 'first draw submitted');
4821          gl.drawArrays(gl.TRIANGLE_STRIP, 0, verts.length / 7);
4822          if (!gl.isContextLost()) ColirGraphicsRuntime.once('curves', 'first draw returned');
4823        }
4824        return true;
4825      }
4826    }
4827
4828    const tubeRenderer = new CurveTubeRenderer();
4829
4830    function redrawCurveOverlays() {
4831      try { if (!curvesVisible) return; } catch (e) { /* curvesVisible not ready */ }
4832      if (tubeRenderer) tubeRenderer.updateLUT();
4833      try {
4834        let ax = true;
4835        let ay = false;
4836        try {
4837          if (typeof activeCurve !== 'undefined') {
4838            ax = activeCurve === 'x';
4839            ay = activeCurve === 'y';
4840          }
4841        } catch (e) { /* activeCurve not ready */ }
4842        xEditor.draw(ax);
4843        yEditor.draw(ay);
4844      } catch (e) { /* editors not ready */ }
4845    }
4846
4847    class CurveEditor {
4848      constructor(canvas, orientation = 'horizontal', curveId = '') {
4849        this.canvas = canvas;
4850        this.ctx = canvas.getContext('2d');
4851        this.orientation = orientation;
4852        this.curveId = curveId; // 'x' or 'y'
4853        this.interactionEnabled = true;
4854        this._interactionFrames = new Set();
4855        this._interactionTimers = new Set();
4856        this._drawRaf = null;
4857        this._drawActiveOverride = null;
4858        this.points = [
4859          { t: 0.0, v: 0.0 },
4860          { t: 0.33, v: 0.7 },
4861          { t: 0.66, v: 0.3 },
4862          { t: 1.0, v: 1.0 },
4863        ];
4864        this.dragIndex = null;
4865        this.hoverIndex = null; // Track which point is being hovered
4866        this.radius = 8; // CSS pixels — constant on screen regardless of document size
4867        this.pointPadding = 2; // CSS-pixel inset so handles stay fully visible
4868        this.hitRadiusCss = 16;
4869        this.magneticThresholdCss = 12;
4870        this.magneticSnapGapCss = 16;
4871        this.maxPoints = 100; // Allow many points (effectively unlimited for manual addition)
4872        this.mousePos = null; // Track mouse position for preview line
4873        this.animationStartTime = null;
4874        this.previousDragIndex = null;
4875        this.animationDuration = 100; // 100ms
4876        this.swapAnimDuration = 33; // follow / curve damp time constant
4877        this.dampProximityCss = 22; // only damp when another point is this close on t
4878        this.swapAnim = null;
4879        this._magneticLock = null;
4880        this._followRaf = null;
4881        this._followLast = 0;
4882        this._dampedMap = null;
4883        this._dampUntilSettled = false;
4884        this._syncFollow(true);
4885        this._bind();
4886        this.draw();
4887      }
4888      setInteractionEnabled(enabled) {
4889        if (this.interactionEnabled === enabled) return;
4890        this.interactionEnabled = enabled;
4891        if (enabled) return;
4892        // Animate owns the overlay. Cancel editor work before capturing its base.
4893        for (const id of this._interactionFrames) cancelAnimationFrame(id);
4894        for (const id of this._interactionTimers) clearTimeout(id);
4895        this._interactionFrames.clear();
4896        this._interactionTimers.clear();
4897        this._drawRaf = null;
4898        this.dragIndex = this.hoverIndex = this.previousDragIndex = null;
4899        this.mousePos = this.animationStartTime = this.swapAnim = null;
4900        this._magneticLock = this._followRaf = this._dampedMap = null;
4901        this._followLast = 0;
4902        this._dampUntilSettled = false;
4903        this._syncFollow(true);
4904      }
4905      _queueInteraction(callback, delay) {
4906        if (!this.interactionEnabled) return null;
4907        const frames = delay === undefined;
4908        const pending = frames ? this._interactionFrames : this._interactionTimers;
4909        const run = time => {
4910          pending.delete(id);
4911          if (this.interactionEnabled) callback(time);
4912        };
4913        const id = frames ? requestAnimationFrame(run) : setTimeout(run, delay);
4914        pending.add(id);
4915        return id;
4916      }
4917      requestDraw(isActiveOverride = null) {
4918        if (!this.interactionEnabled) return;
4919        this._drawActiveOverride = isActiveOverride;
4920        if (this._drawRaf !== null) return;
4921        this._drawRaf = this._queueInteraction(() => {
4922          this._drawRaf = null;
4923          this.draw(this._drawActiveOverride);
4924        });
4925      }
4926      _syncFollow(immediate) {
4927        this.points.forEach(p => {
4928          if (immediate || p.followT == null || p.followV == null) {
4929            p.followT = p.t;
4930            p.followV = p.v;
4931          }
4932        });
4933      }
4934      _evalPts() {
4935        return this.points
4936          .map(p => ({
4937            t: p.followT != null ? p.followT : p.t,
4938            v: p.followV != null ? p.followV : p.v,
4939          }))
4940          .sort((a, b) => a.t - b.t);
4941      }
4942      _nearNeighborOnT(pt) {
4943        if (!pt) return null;
4944        const threshold = this._tFromCssPx(this.dampProximityCss || 22);
4945        let best = null;
4946        let bestDist = Infinity;
4947        for (const other of this.points) {
4948          if (other === pt) continue;
4949          const dist = Math.abs(other.t - pt.t);
4950          if (dist < threshold && dist < bestDist) {
4951            best = other;
4952            bestDist = dist;
4953          }
4954        }
4955        return best;
4956      }
4957      _followError() {
4958        let maxErr = 0;
4959        for (const p of this.points) {
4960          if (p.followT == null || p.followV == null) continue;
4961          maxErr = Math.max(maxErr, Math.abs(p.t - p.followT), Math.abs(p.v - p.followV));
4962        }
4963        return maxErr;
4964      }
4965      _shouldDampCurve() {
4966        const dragged = this.dragIndex != null ? this.points[this.dragIndex] : null;
4967        if (dragged && this._nearNeighborOnT(dragged)) return true;
4968        return !!this._dampUntilSettled && this._followError() > 0.0008;
4969      }
4970      _dampSamples(target) {
4971        const n = target.length;
4972        if (!this._dampedMap) this._dampedMap = new Map();
4973        let entry = this._dampedMap.get(n);
4974        const now = performance.now();
4975        if (!this._shouldDampCurve()) {
4976          const snapped = target.slice();
4977          this._dampedMap.set(n, { samples: snapped, at: now });
4978          return snapped;
4979        }
4980        if (!entry) {
4981          entry = { samples: target.slice(), at: now };
4982          this._dampedMap.set(n, entry);
4983          return entry.samples;
4984        }
4985        const dt = Math.min(0.05, Math.max(0, (now - entry.at) / 1000));
4986        entry.at = now;
4987        const tau = (this.swapAnimDuration || 400) / 1000;
4988        const alpha = dt <= 0 ? 1 : 1 - Math.exp(-dt / tau);
4989        for (let i = 0; i < n; i++) {
4990          entry.samples[i] += (target[i] - entry.samples[i]) * alpha;
4991        }
4992        return entry.samples;
4993      }
4994      _kickFollow() {
4995        if (this._followRaf) return;
4996        this._followLast = performance.now();
4997        const tick = (now) => {
4998          this._followRaf = null;
4999          const dt = Math.min(0.05, Math.max(0, (now - this._followLast) / 1000));
5000          this._followLast = now;
5001          const tau = (this.swapAnimDuration || 400) / 1000;
5002          const alpha = dt <= 0 ? 1 : 1 - Math.exp(-dt / tau);
5003          let maxErr = 0;
5004          for (const p of this.points) {
5005            if (p.followT == null) p.followT = p.t;
5006            if (p.followV == null) p.followV = p.v;
5007            p.followT += (p.t - p.followT) * alpha;
5008            p.followV += (p.v - p.followV) * alpha;
5009            maxErr = Math.max(maxErr, Math.abs(p.t - p.followT), Math.abs(p.v - p.followV));
5010          }
5011          let isActive = null;
5012          try {
5013            if (typeof activeCurve !== 'undefined') isActive = this.curveId === activeCurve;
5014          } catch (e) {
5015            isActive = null;
5016          }
5017          this.draw(isActive);
5018          if (typeof renderGradient === 'function') renderGradient();
5019          const dragged = this.dragIndex != null ? this.points[this.dragIndex] : null;
5020          const near = this._nearNeighborOnT(dragged);
5021          if (near) this._dampUntilSettled = true;
5022          if (near || (this._dampUntilSettled && maxErr > 0.0008)) {
5023            this._followRaf = this._queueInteraction(tick);
5024          } else {
5025            this._dampUntilSettled = false;
5026            this._syncFollow(true);
5027          }
5028        };
5029        this._followRaf = this._queueInteraction(tick);
5030      }
5031      _displaySize() {
5032        const rect = this.canvas.getBoundingClientRect();
5033        let w = rect.width;
5034        let h = rect.height;
5035        if (w < 2) w = parseFloat(this.canvas.style.width) || 1;
5036        if (h < 2) h = parseFloat(this.canvas.style.height) || 1;
5037        return { w, h };
5038      }
5039      _dpr() {
5040        const { w } = this._displaySize();
5041        const dpr = this.canvas.width / w;
5042        return Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
5043      }
5044      _cssPaddingNorm() {
5045        const { w, h } = this._displaySize();
5046        const pad = this.radius + this.pointPadding;
5047        if (this.orientation === 'horizontal') {
5048          return { t: pad / w, v: pad / h };
5049        }
5050        return { t: pad / h, v: pad / w };
5051      }
5052      _tFromCssPx(px) {
5053        const { w, h } = this._displaySize();
5054        return px / (this.orientation === 'horizontal' ? w : h);
5055      }
5056      _clampPoint(t, v) {
5057        const { t: pt, v: pv } = this._cssPaddingNorm();
5058        return {
5059          t: Math.min(1 - pt, Math.max(pt, t)),
5060          v: Math.min(1 - pv, Math.max(pv, v)),
5061        };
5062      }
5063      _pointScreenPos(pt, w, h, dpr = this._dpr()) {
5064        const paddingX = (this.radius + this.pointPadding) * dpr;
5065        const paddingY = (this.radius + this.pointPadding) * dpr;
5066        const normalizedT = Math.min(1 - (paddingX / w), Math.max(paddingX / w, pt.t));
5067        const normalizedV = Math.min(1 - (paddingY / h), Math.max(paddingY / h, pt.v));
5068        const px = this.orientation === 'horizontal' ? normalizedT * w : normalizedV * w;
5069        const py = this.orientation === 'horizontal' ? (1 - normalizedV) * h : normalizedT * h;
5070        return { px, py };
5071      }
5072      _applyMagneticT(desiredT, dragged) {
5073        const threshold = this._tFromCssPx(this.magneticThresholdCss);
5074        const snapGap = this._tFromCssPx(this.magneticSnapGapCss);
5075
5076        if (this._magneticLock) {
5077          const n = this._magneticLock.neighbor;
5078          if (n && n !== dragged && this.points.includes(n)) {
5079            const desiredSide = desiredT >= n.t ? 1 : -1;
5080            const dist = Math.abs(desiredT - n.t);
5081            if (desiredSide === this._magneticLock.side && dist >= snapGap * 0.5) {
5082              this._magneticLock = null;
5083              return { t: desiredT, neighbor: null };
5084            }
5085            if (desiredSide !== this._magneticLock.side && dist > threshold) {
5086              const backSide = -this._magneticLock.side;
5087              this._magneticLock = { neighbor: n, side: backSide };
5088              const snapped = this._clampPoint(n.t + backSide * snapGap, dragged.v).t;
5089              if ((snapped - n.t) * backSide > 0) {
5090                return { t: snapped, neighbor: n };
5091              }
5092              this._magneticLock = null;
5093              return { t: desiredT, neighbor: null };
5094            }
5095            const held = this._clampPoint(n.t + this._magneticLock.side * snapGap, dragged.v).t;
5096            return { t: held, neighbor: null };
5097          }
5098          this._magneticLock = null;
5099        }
5100
5101        let best = null;
5102        let bestDist = Infinity;
5103        for (const neighbor of this.points) {
5104          if (neighbor === dragged) continue;
5105          const dist = Math.abs(desiredT - neighbor.t);
5106          const crossing = (desiredT - neighbor.t) * (dragged.t - neighbor.t) < 0;
5107          const movingToward = Math.abs(desiredT - neighbor.t) < Math.abs(dragged.t - neighbor.t) - 1e-9;
5108          if ((crossing || (movingToward && dist < threshold)) && dist < bestDist) {
5109            best = neighbor;
5110            bestDist = dist;
5111          }
5112        }
5113        if (!best) return { t: desiredT, neighbor: null };
5114
5115        const currentSide = dragged.t >= best.t ? 1 : -1;
5116        const desiredSide = desiredT >= best.t ? 1 : -1;
5117        const crossing = (desiredT - best.t) * (dragged.t - best.t) < 0;
5118        const passSide = crossing ? desiredSide : -currentSide;
5119        let nextT;
5120        if (crossing && Math.abs(desiredT - best.t) >= snapGap) {
5121          nextT = desiredT;
5122        } else {
5123          nextT = this._clampPoint(best.t + passSide * snapGap, dragged.v).t;
5124        }
5125        if ((nextT - best.t) * passSide <= 0) {
5126          return { t: desiredT, neighbor: null };
5127        }
5128        this._magneticLock = { neighbor: best, side: passSide };
5129        return { t: nextT, neighbor: best };
5130      }
5131      setMaxPoints(max) {
5132        this.maxPoints = Math.max(100, max); // Set high limit to allow many points
5133        this.enforceComplexity();
5134      }
5135      enforceComplexity() {
5136        // If we have more points than max, remove excess points (keep first and last)
5137        if (this.points.length > this.maxPoints) {
5138          const toRemove = this.points.length - this.maxPoints;
5139          // Remove points from the middle, keeping first and last
5140          const indicesToRemove = [];
5141          for (let i = 1; i < this.points.length - 1 && indicesToRemove.length < toRemove; i++) {
5142            indicesToRemove.push(i);
5143          }
5144          // Remove in reverse order to maintain indices
5145          indicesToRemove.reverse().forEach(idx => {
5146            this.points.splice(idx, 1);
5147          });
5148          this.draw();
5149        }
5150      }
5151      setPoints(pts) {
5152        this.points = pts.sort((a, b) => a.t - b.t);
5153        this._syncFollow(true);
5154        this._dampedMap = null;
5155        this.enforceComplexity();
5156        this.draw();
5157      }
5158      invert() {
5159        this.points.forEach(p => p.v = 1 - p.v);
5160        this._syncFollow(true);
5161        this._dampedMap = null;
5162        this.draw();
5163      }
5164      randomize(rng, complexity) {
5165        const r = rng || Math;
5166        const count = complexity || this.maxPoints;
5167        const pts = Array.from({ length: count }, (_, i) => ({
5168          t: i / (count - 1),
5169          v: r.random()
5170        }));
5171        this.setPoints(pts);
5172      }
5173      evaluateSamples(n) {
5174        const samples = [];
5175        for (let i = 0; i < n; i++) {
5176          samples.push(this.evaluate(i / (n - 1)));
5177        }
5178        return this._dampSamples(samples).slice();
5179      }
5180      evaluate(t) {
5181        const smoothingToggle = document.getElementById('curveSmoothingToggle');
5182        return ColirMotion.evaluateCurve(this._evalPts(), t, smoothingToggle ? smoothingToggle.classList.contains('on') : true);
5183      }
5184
5185      _bind() {
5186        const pos = (e) => {
5187          const rect = this.canvas.getBoundingClientRect();
5188          // Convert display coordinates to normalized 0-1 coordinates
5189          // rect.width/height are CSS display size, canvas.width/height are internal buffer size
5190          return {
5191            x: (e.clientX - rect.left) / rect.width,
5192            y: (e.clientY - rect.top) / rect.height,
5193          };
5194        };
5195        this.canvas.addEventListener('pointerdown', (e) => {
5196          if (!this.interactionEnabled || !this.canvas.classList.contains('curves-enabled')) return;
5197          const p = pos(e);
5198          const { hit, idx } = this._hitTest(p);
5199          if (hit) {
5200            this.previousDragIndex = this.dragIndex;
5201            this.dragIndex = idx;
5202            this.animationStartTime = performance.now();
5203            this.draw();
5204          } else {
5205            const t = this.orientation === 'horizontal' ? p.x : p.y;
5206            const v = this.orientation === 'horizontal' ? 1 - p.y : p.x;
5207            const clamped = this._clampPoint(t, v);
5208            if (typeof saveHistory === 'function') {
5209              saveHistory();
5210            }
5211            const newPt = { t: clamped.t, v: clamped.v, followT: clamped.t, followV: clamped.v };
5212            this.points.push(newPt);
5213            this.points.sort((a, b) => a.t - b.t);
5214            this.previousDragIndex = this.dragIndex;
5215            this.dragIndex = this.points.indexOf(newPt);
5216            this.animationStartTime = performance.now();
5217            this.draw();
5218            if (typeof renderGradient === 'function') {
5219              renderGradient();
5220            }
5221          }
5222        });
5223        // Right-click to remove points
5224        this.canvas.addEventListener('contextmenu', (e) => {
5225          if (!this.interactionEnabled || !this.canvas.classList.contains('curves-enabled')) return;
5226          e.preventDefault();
5227          const p = pos(e);
5228          const { hit, idx } = this._hitTest(p);
5229          // Ensure minimum of 2 points - can only remove if we have more than 2
5230          if (hit && this.points.length > 2) {
5231            // Don't remove if it's the first or last point (keep endpoints)
5232            if (idx > 0 && idx < this.points.length - 1) {
5233              this.points.splice(idx, 1);
5234              this.draw();
5235              // Trigger gradient update
5236              if (typeof renderGradient === 'function') {
5237                renderGradient();
5238              }
5239            }
5240          }
5241        });
5242        window.addEventListener('pointermove', (e) => {
5243          if (!this.interactionEnabled) return;
5244          if (this.dragIndex !== null) {
5245            const p = pos(e);
5246            const t = this.orientation === 'horizontal' ? p.x : p.y;
5247            const v = this.orientation === 'horizontal' ? 1 - p.y : p.x;
5248            const clamped = this._clampPoint(t, v);
5249            const dragged = this.points[this.dragIndex];
5250            if (!dragged) return;
5251
5252            dragged.t = clamped.t;
5253            dragged.v = clamped.v;
5254            this.points.sort((a, b) => a.t - b.t);
5255            this.dragIndex = this.points.indexOf(dragged);
5256            const near = this._nearNeighborOnT(dragged);
5257            if (near) this._dampUntilSettled = true;
5258            const dampOn = !!(near || (this._dampUntilSettled && this._followError() > 0.0008));
5259            if (dampOn) {
5260              this._kickFollow();
5261            } else {
5262              this._dampUntilSettled = false;
5263              this._syncFollow(true);
5264            }
5265
5266            this.requestDraw();
5267            if (typeof renderGradient === 'function') {
5268              renderGradient();
5269            }
5270          } else {
5271            // Not dragging - check for hover
5272            if (!this.canvas.classList.contains('curves-enabled')) {
5273              // Reset hover if curves are disabled
5274              if (this.hoverIndex !== null) {
5275                this.previousDragIndex = this.hoverIndex;
5276                this.hoverIndex = null;
5277                this.animationStartTime = performance.now();
5278                this.requestDraw();
5279                this._queueInteraction(() => {
5280                  if (this.hoverIndex === null && this.previousDragIndex !== null) {
5281                    this.previousDragIndex = null;
5282                    this.requestDraw();
5283                  }
5284                }, this.animationDuration);
5285              }
5286              return;
5287            }
5288            
5289            // Check if mouse is over the canvas
5290            const rect = this.canvas.getBoundingClientRect();
5291            const mouseX = e.clientX;
5292            const mouseY = e.clientY;
5293            const isOverCanvas = mouseX >= rect.left && mouseX <= rect.right && 
5294                                 mouseY >= rect.top && mouseY <= rect.bottom;
5295            
5296            if (isOverCanvas) {
5297              const p = pos(e);
5298              const { hit, idx } = this._hitTest(p);
5299              if (hit && this.hoverIndex !== idx) {
5300                this.hoverIndex = idx;
5301                this.animationStartTime = performance.now();
5302                this.requestDraw();
5303              } else if (!hit && this.hoverIndex !== null) {
5304                this.previousDragIndex = this.hoverIndex;
5305                this.hoverIndex = null;
5306                this.animationStartTime = performance.now();
5307                this.requestDraw();
5308                // Clear previousDragIndex after animation completes
5309                this._queueInteraction(() => {
5310                  if (this.hoverIndex === null && this.previousDragIndex !== null) {
5311                    this.previousDragIndex = null;
5312                    this.requestDraw();
5313                  }
5314                }, this.animationDuration);
5315              }
5316            } else if (this.hoverIndex !== null) {
5317              // Mouse left canvas - reset hover
5318              this.previousDragIndex = this.hoverIndex;
5319              this.hoverIndex = null;
5320              this.animationStartTime = performance.now();
5321              this.requestDraw();
5322              this._queueInteraction(() => {
5323                if (this.hoverIndex === null && this.previousDragIndex !== null) {
5324                  this.previousDragIndex = null;
5325                  this.requestDraw();
5326                }
5327              }, this.animationDuration);
5328            }
5329          }
5330        });
5331        window.addEventListener('pointerup', () => { 
5332          if (!this.interactionEnabled) return;
5333          if (this.dragIndex !== null) {
5334            // Save history when curve point drag ends
5335            if (typeof saveHistory === 'function') {
5336              saveHistory();
5337            }
5338            // Final update when dragging ends
5339            if (typeof renderGradient === 'function') {
5340              renderGradient();
5341            }
5342            this.dragIndex = null;
5343            this._magneticLock = null;
5344            // Don't reset hoverIndex here - let pointermove handle it
5345            this.draw();
5346          }
5347        });
5348        
5349        // Reset hover when mouse leaves canvas
5350        this.canvas.addEventListener('pointerleave', () => {
5351          if (!this.interactionEnabled) return;
5352          if (this.hoverIndex !== null) {
5353            this.previousDragIndex = this.hoverIndex;
5354            this.hoverIndex = null;
5355            this.animationStartTime = performance.now();
5356            this.draw();
5357            // Clear previousDragIndex after animation completes
5358            this._queueInteraction(() => {
5359              if (this.hoverIndex === null && this.previousDragIndex !== null) {
5360                this.previousDragIndex = null;
5361                this.draw();
5362              }
5363            }, this.animationDuration);
5364          }
5365        });
5366        
5367        // Helper to get active state
5368        const getActiveState = () => {
5369          try {
5370            if (typeof activeCurve !== 'undefined') {
5371              return this.curveId === activeCurve;
5372            }
5373          } catch (e) {
5374            // Fallback
5375          }
5376          return this.curveId === 'x'; // Default
5377        };
5378        
5379        // Track mouse position for preview line
5380        this.canvas.addEventListener('pointermove', (e) => {
5381          if (!this.interactionEnabled) return;
5382          if (!this.canvas.classList.contains('curves-enabled')) {
5383            this.mousePos = null;
5384            this.requestDraw(getActiveState());
5385            return;
5386          }
5387          const p = pos(e);
5388          this.mousePos = p;
5389          // Only redraw if not currently dragging a point
5390          if (this.dragIndex === null) {
5391            this.requestDraw(getActiveState());
5392          }
5393        });
5394        
5395        this.canvas.addEventListener('pointerleave', () => {
5396          if (!this.interactionEnabled) return;
5397          this.mousePos = null;
5398          this.requestDraw(getActiveState());
5399        });
5400      }
5401      _hitTest(p) {
5402        const { w, h } = this._displaySize();
5403        const pts = this.points;
5404        for (let i = 0; i < pts.length; i++) {
5405          const visual = this._clampPoint(pts[i].t, pts[i].v);
5406          const px = this.orientation === 'horizontal' ? visual.t : visual.v;
5407          const py = this.orientation === 'horizontal' ? 1 - visual.v : visual.t;
5408          const dx = (p.x - px) * w;
5409          const dy = (p.y - py) * h;
5410          if (Math.hypot(dx, dy) < this.hitRadiusCss) return { hit: true, idx: i };
5411        }
5412        return { hit: false, idx: -1 };
5413      }
5414      _drawGlassPuck(ctx, px, py, radius, alpha, dpr = this._dpr()) {
5415        const r = Math.max(1, radius);
5416        const a = Math.max(0, Math.min(1, alpha));
5417        const lx = px - r * 0.42;
5418        const ly = py - r * 0.72;
5419        const shadowR = r * 1.48;
5420        const peakA = 0.10 * a;
5421        const ox = 0.05 * dpr;
5422        const oy = 0.1 * dpr;
5423        ctx.save();
5424        ctx.globalCompositeOperation = 'source-over';
5425        if (peakA > 0 && shadowR > 0) {
5426          const sg = ctx.createRadialGradient(px + ox, py + oy, 0, px + ox, py + oy, shadowR);
5427          sg.addColorStop(0, `rgba(0, 0, 0, ${peakA})`);
5428          sg.addColorStop(0.3, `rgba(0, 0, 0, ${peakA * 0.82})`);
5429          sg.addColorStop(0.5, `rgba(0, 0, 0, ${peakA * 0.577})`);
5430          sg.addColorStop(0.7, `rgba(0, 0, 0, ${peakA * 0.34})`);
5431          sg.addColorStop(0.85, `rgba(0, 0, 0, ${peakA * 0.204})`);
5432          sg.addColorStop(1, 'rgba(0, 0, 0, 0)');
5433          ctx.fillStyle = sg;
5434          ctx.beginPath();
5435          ctx.arc(px + ox, py + oy, shadowR, 0, Math.PI * 2);
5436          ctx.fill();
5437        }
5438        ctx.save();
5439        ctx.beginPath();
5440        ctx.arc(px, py, r, 0, Math.PI * 2);
5441        ctx.clip();
5442        let src = null;
5443        try { src = gradientCanvas; } catch (e) { src = null; }
5444        if (src && src.width && src.height) {
5445          const pad = r * 1.8;
5446          const sx = (px / this.canvas.width) * src.width;
5447          const sy = (py / this.canvas.height) * src.height;
5448          const sw = (pad / this.canvas.width) * src.width;
5449          const sh = (pad / this.canvas.height) * src.height;
5450          ctx.filter = `blur(${Math.max(2, r * 0.28)}px)`;
5451          ctx.drawImage(src, sx - sw, sy - sh, sw * 2, sh * 2, px - pad, py - pad, pad * 2, pad * 2);
5452          ctx.filter = 'none';
5453        }
5454        const frost = ctx.createRadialGradient(lx, ly, r * 0.04, px, py, r);
5455        frost.addColorStop(0, `rgba(255, 255, 255, ${0.92 * a})`);
5456        frost.addColorStop(0.52, `rgba(255, 255, 255, ${0.7 * a})`);
5457        frost.addColorStop(1, `rgba(255, 255, 255, ${0.36 * a})`);
5458        ctx.fillStyle = frost;
5459        ctx.beginPath();
5460        ctx.arc(px, py, r, 0, Math.PI * 2);
5461        ctx.fill();
5462        const hi = ctx.createRadialGradient(lx, ly, 0, lx, ly, r * 0.82);
5463        hi.addColorStop(0, `rgba(255, 255, 255, ${0.48 * a})`);
5464        hi.addColorStop(0.4, `rgba(255, 255, 255, ${0.14 * a})`);
5465        hi.addColorStop(1, 'rgba(255, 255, 255, 0)');
5466        ctx.fillStyle = hi;
5467        ctx.beginPath();
5468        ctx.arc(px, py, r, 0, Math.PI * 2);
5469        ctx.fill();
5470        ctx.restore();
5471        ctx.restore();
5472      }
5473      draw(isActiveOverride = null) {
5474        if (!this.interactionEnabled) return;
5475        // A synchronous state change supersedes any pending pointer/hover draw.
5476        if (this._drawRaf !== null) {
5477          cancelAnimationFrame(this._drawRaf);
5478          this._interactionFrames.delete(this._drawRaf);
5479          this._drawRaf = null;
5480        }
5481        const { ctx, canvas } = this;
5482        const w = canvas.width;
5483        const h = canvas.height;
5484        const dpr = this._dpr();
5485        const tAxisPx = this.orientation === 'horizontal' ? w : h;
5486        const samples = Math.max(200, Math.ceil(tAxisPx));
5487        ctx.clearRect(0, 0, w, h);
5488        
5489        // Clip drawing to canvas bounds to prevent overflow artifacts
5490        ctx.save();
5491        ctx.beginPath();
5492        ctx.rect(0, 0, w, h);
5493        ctx.clip();
5494        
5495        // Handle size animation for active point
5496        const now = performance.now();
5497        
5498        // Calculate animation progress (0 to 1)
5499        let animationProgress = 1; // Default to fully animated
5500        if (this.animationStartTime !== null) {
5501          const elapsed = now - this.animationStartTime;
5502          animationProgress = Math.min(1, elapsed / this.animationDuration);
5503          // If animation is still in progress, request another frame
5504          if (animationProgress < 1) {
5505            this.requestDraw(isActiveOverride);
5506          } else {
5507            // Animation complete, clear start time
5508            this.animationStartTime = null;
5509          }
5510        }
5511
5512        let swapProgress = 1;
5513        if (this.swapAnim) {
5514          const swapElapsed = now - this.swapAnim.start;
5515          swapProgress = Math.min(1, swapElapsed / this.swapAnim.duration);
5516          if (swapProgress < 1) {
5517            this.requestDraw(isActiveOverride);
5518          } else {
5519            this.swapAnim = null;
5520          }
5521        }
5522        const swapEase = 1 - Math.pow(1 - swapProgress, 3);
5523        // grid removed
5524        // curve - check if this is the active curve
5525        // Use override if provided, otherwise try to access activeCurve
5526        let isActive = false;
5527        if (isActiveOverride !== null) {
5528          isActive = isActiveOverride;
5529        } else {
5530          try {
5531            if (typeof activeCurve !== 'undefined') {
5532              isActive = this.curveId === activeCurve;
5533            }
5534          } catch (e) {
5535            // If activeCurve not accessible, default to showing curve normally
5536            isActive = this.curveId === 'x'; // Default to x being active
5537          }
5538        }
5539        
5540        const overlayVals = [];
5541        const points = this._evalPts();
5542        const smooth = document.getElementById('curveSmoothingToggle')?.classList.contains('on') ?? true;
5543        for (let i = 0; i <= samples; i++) overlayVals.push(ColirMotion.evaluateCurve(points, i / samples, smooth));
5544        const dampedVals = this._dampSamples(overlayVals);
5545        const tubePts = [];
5546        for (let i = 0; i <= samples; i++) {
5547          const t = i / samples;
5548          const v = dampedVals[i];
5549          const px = this.orientation === 'horizontal' ? t * w : v * w;
5550          const py = this.orientation === 'horizontal' ? (1 - v) * h : t * h;
5551          tubePts.push({ x: px, y: py, v });
5552        }
5553
5554        let drewTube = false;
5555        if (isActive && tubeRenderer && tubeRenderer.ok) {
5556          if (tubeRenderer.canvas.width !== w || tubeRenderer.canvas.height !== h) {
5557            tubeRenderer.resize(w, h);
5558          }
5559          tubeRenderer.updateLUT();
5560          const tubeR = 3 * dpr;
5561          const joints = this.points.map(pt => {
5562            const live = this._pointScreenPos(pt, w, h, dpr);
5563            return { x: live.px, y: live.py };
5564          });
5565          drewTube = tubeRenderer.draw(tubePts, {
5566            radius: tubeR,
5567            joints,
5568            trimRadius: this.radius * dpr + tubeR + this.radius * dpr * 0.4,
5569            opacity: 1,
5570            isActive: true,
5571            dpr,
5572          });
5573          if (drewTube) {
5574            ctx.save();
5575            ctx.globalCompositeOperation = 'source-over';
5576            ctx.globalAlpha = 1;
5577            ctx.drawImage(tubeRenderer.canvas, 0, 0);
5578            ctx.restore();
5579          }
5580        }
5581
5582        const inactiveAlpha = 0.35;
5583        const screenRadius = this.radius * dpr;
5584        const inactivePointPos = this.points.map(pt => {
5585          const live = this._pointScreenPos(pt, w, h, dpr);
5586          let px = live.px;
5587          let py = live.py;
5588          const swapItem = this.swapAnim && this.swapAnim.items.find(item => item.ref === pt);
5589          if (swapItem && swapProgress < 1) {
5590            px = swapItem.fromX + (live.px - swapItem.fromX) * swapEase;
5591            py = swapItem.fromY + (live.py - swapItem.fromY) * swapEase;
5592          }
5593          return { px, py };
5594        });
5595
5596        if (!drewTube) {
5597          ctx.save();
5598          ctx.globalCompositeOperation = 'source-over';
5599          ctx.lineCap = 'round';
5600          ctx.lineJoin = 'round';
5601          ctx.beginPath();
5602          for (let i = 0; i < tubePts.length; i++) {
5603            if (i === 0) ctx.moveTo(tubePts[i].x, tubePts[i].y);
5604            else ctx.lineTo(tubePts[i].x, tubePts[i].y);
5605          }
5606          if (isActive) {
5607            ctx.shadowBlur = 12 * dpr;
5608            ctx.shadowColor = 'rgba(255, 255, 255, 0.8)';
5609            ctx.strokeStyle = '#ffffff';
5610            ctx.lineWidth = 4 * dpr;
5611            ctx.stroke();
5612          } else {
5613            ctx.setLineDash([8 * dpr, 8 * dpr]);
5614            ctx.strokeStyle = `rgba(255, 255, 255, ${inactiveAlpha})`;
5615            ctx.lineWidth = 4 * dpr;
5616            ctx.stroke();
5617            ctx.setLineDash([]);
5618            ctx.globalCompositeOperation = 'destination-out';
5619            ctx.fillStyle = '#000';
5620            for (let i = 0; i < inactivePointPos.length; i++) {
5621              const p = inactivePointPos[i];
5622              ctx.beginPath();
5623              ctx.arc(p.px, p.py, screenRadius, 0, Math.PI * 2);
5624              ctx.fill();
5625            }
5626          }
5627          ctx.setLineDash([]);
5628          ctx.shadowBlur = 0;
5629          ctx.restore();
5630        }
5631        
5632        // Draw preview line if mouse is over canvas and this is the active curve
5633        if (isActive && this.mousePos !== null) {
5634          ctx.save();
5635          ctx.globalCompositeOperation = 'source-over';
5636          ctx.setLineDash([2 * dpr, 2 * dpr]);
5637          // Draw dark background line for better visibility
5638          ctx.beginPath();
5639          if (this.orientation === 'horizontal') {
5640            // For X axis (horizontal curve): draw vertical line at mouse X position
5641            const x = Math.max(0, Math.min(w, this.mousePos.x * w));
5642            ctx.moveTo(x, 0);
5643            ctx.lineTo(x, h);
5644          } else {
5645            // For Y axis (vertical curve): draw horizontal line at mouse Y position
5646            const y = Math.max(0, Math.min(h, this.mousePos.y * h));
5647            ctx.moveTo(0, y);
5648            ctx.lineTo(w, y);
5649          }
5650          ctx.strokeStyle = 'rgba(0, 0, 0, 0.1625)';
5651          ctx.lineWidth = 2 * dpr;
5652          ctx.stroke();
5653          ctx.strokeStyle = 'rgba(255, 255, 255, 0.7475)';
5654          ctx.lineWidth = 1 * dpr;
5655          ctx.stroke();
5656          ctx.setLineDash([]);
5657          ctx.restore();
5658        }
5659        
5660        // points
5661        ctx.save();
5662        ctx.globalCompositeOperation = 'source-over';
5663        this.points.forEach((pt, idx) => {
5664          const live = this._pointScreenPos(pt, w, h, dpr);
5665          let px = live.px;
5666          let py = live.py;
5667          const swapItem = this.swapAnim && this.swapAnim.items.find(item => item.ref === pt);
5668          if (swapItem && swapProgress < 1) {
5669            px = swapItem.fromX + (live.px - swapItem.fromX) * swapEase;
5670            py = swapItem.fromY + (live.py - swapItem.fromY) * swapEase;
5671          }
5672          const isPointActive = this.hoverIndex === idx || this.dragIndex === idx;
5673          const isSwapPair = !!swapItem && swapProgress < 1;
5674          
5675          if (!isActive) {
5676            ctx.fillStyle = `rgba(255, 255, 255, ${inactiveAlpha})`;
5677            ctx.beginPath();
5678            ctx.arc(px, py, screenRadius, 0, Math.PI * 2);
5679            ctx.fill();
5680          } else {
5681            let currentRadius = screenRadius;
5682            const isHovered = this.hoverIndex === idx;
5683            const isAnimatingRelease = this.previousDragIndex === idx && this.hoverIndex === null && this.dragIndex === null && this.animationStartTime !== null;
5684            
5685            if (isHovered || isPointActive) {
5686              const targetSize = screenRadius * 1.2;
5687              currentRadius = screenRadius + (targetSize - screenRadius) * animationProgress;
5688            } else if (isAnimatingRelease) {
5689              const targetSize = screenRadius * 1.2;
5690              currentRadius = targetSize - (targetSize - screenRadius) * animationProgress;
5691            }
5692
5693            if (isSwapPair) {
5694              const pulse = Math.sin(swapProgress * Math.PI);
5695              currentRadius = screenRadius * (1 + 0.2 * pulse);
5696            }
5697
5698            const puckAlpha = 1;
5699            this._drawGlassPuck(ctx, px, py, currentRadius, puckAlpha, dpr);
5700          }
5701        });
5702        ctx.restore();
5703        ctx.restore(); // Restore clipping region
5704      }
5705      drawGhost(points) {
5706        const { ctx, canvas } = this;
5707        const w = canvas.width;
5708        const h = canvas.height;
5709        const dpr = this._dpr();
5710        ctx.clearRect(0, 0, w, h);
5711        if (!points || points.length < 2) return;
5712        const smoothingToggle = document.getElementById('curveSmoothingToggle');
5713        const smooth = smoothingToggle ? smoothingToggle.classList.contains('on') : true;
5714        const tAxisPx = this.orientation === 'horizontal' ? w : h;
5715        const samples = Math.max(200, Math.ceil(tAxisPx));
5716        const inactiveAlpha = 0.35;
5717        const screenRadius = this.radius * dpr;
5718        ctx.save();
5719        ctx.beginPath();
5720        ctx.rect(0, 0, w, h);
5721        ctx.clip();
5722        ctx.save();
5723        ctx.globalCompositeOperation = 'source-over';
5724        ctx.lineCap = 'round';
5725        ctx.lineJoin = 'round';
5726        ctx.beginPath();
5727        for (let i = 0; i <= samples; i++) {
5728          const t = i / samples;
5729          const v = ColirMotion.evaluateCurve(points, t, smooth);
5730          const px = this.orientation === 'horizontal' ? t * w : v * w;
5731          const py = this.orientation === 'horizontal' ? (1 - v) * h : t * h;
5732          if (i === 0) ctx.moveTo(px, py);
5733          else ctx.lineTo(px, py);
5734        }
5735        ctx.setLineDash([8 * dpr, 8 * dpr]);
5736        ctx.strokeStyle = `rgba(255, 255, 255, ${inactiveAlpha})`;
5737        ctx.lineWidth = 4 * dpr;
5738        ctx.stroke();
5739        ctx.setLineDash([]);
5740        ctx.globalCompositeOperation = 'destination-out';
5741        ctx.fillStyle = '#000';
5742        const positions = points.map(pt => this._pointScreenPos(pt, w, h, dpr));
5743        for (const p of positions) {
5744          ctx.beginPath();
5745          ctx.arc(p.px, p.py, screenRadius, 0, Math.PI * 2);
5746          ctx.fill();
5747        }
5748        ctx.restore();
5749        ctx.save();
5750        ctx.globalCompositeOperation = 'source-over';
5751        ctx.fillStyle = `rgba(255, 255, 255, ${inactiveAlpha})`;
5752        for (const p of positions) {
5753          ctx.beginPath();
5754          ctx.arc(p.px, p.py, screenRadius, 0, Math.PI * 2);
5755          ctx.fill();
5756        }
5757        ctx.restore();
5758        ctx.restore();
5759      }
5760    }
5761
5762    // Main gradient logic
5763    const gradientCanvas = document.getElementById('gradientCanvas');
5764    const canvasWrapper = document.getElementById('canvasWrapper');
5765    const gctx = gradientCanvas.getContext('2d');
5766    const xEditor = new CurveEditor(document.getElementById('xCurveCanvas'), 'horizontal', 'x');
5767    const yEditor = new CurveEditor(document.getElementById('yCurveCanvas'), 'vertical', 'y');
5768    
5769
5770    // WebGL renderer setup
5771    let gl = null;
5772    let glCanvas = null;
5773    let glProgram = null;
5774    let glBuffers = null;
5775    let xSamplesTexture = null;
5776    let ySamplesTexture = null;
5777    let noiseTexture = null;
5778    let glUniformLocations = null; // Cache uniform locations for performance
5779    let positionLoc = null; // Cache attribute location
5780    let cachedXSamples = null;
5781    let cachedYSamples = null;
5782    let cachedXWidth = 0;
5783    let cachedYHeight = 0;
5784    let cachedXPoints = null;
5785    let cachedYPoints = null;
5786    let cachedStops = null;
5787    let cachedPalette = null;
5788    let cachedXTextureData = null;
5789    let cachedYTextureData = null;
5790    let cachedNoiseAmount = null;
5791    let cachedFeather = null;
5792    let cachedBlendMode = null;
5793    let cachedResolution = null;
5794    let renderScheduled = false; // Throttle renders to once per frame
5795    let globalTransitionRaf = 0;
5796    let globalTransitionGen = 0;
5797    let enableProfiling = new URLSearchParams(location.search).has('graphicsDiagnostics'); // Set to false to disable profiling
5798    let gradLUTTexture = null;
5799    // Ping-pong framebuffers for multi-pass post-processing (flow smear, chromatic aberration)
5800    let fboA = null, fboTexA = null;
5801    let fboB = null, fboTexB = null;
5802    let fboC = null, fboTexC = null;
5803    let fboTexW = 0, fboTexH = 0;
5804    const GRAD_LUT_SIZE = 4096;
5805    let cachedGradLUTKey = null;
5806    const NOISE_TEX_SIZE = 256;
5807    const noiseField = new Uint8Array(NOISE_TEX_SIZE * NOISE_TEX_SIZE);
5808    /** Fixed gradient-space noise tuning (no UI). */
5809    const GRADIENT_NOISE_SCALE_FIXED = 75;
5810    const GRADIENT_NOISE_MIX = 53 / 100;
5811    let gradientNoiseUniformsCached = null;
5812    const GRADIENT_NOISE_JITTER = 0.25;
5813
5814    
5815    let graphicsLimits = null, programCache = null;
5816    function selectedDistortion(){return document.getElementById('distortionEnabled')?.checked ? ({organic:0,ridged:1,waves:2,kura:3,tayu:4}[document.getElementById('procDispType')?.value] ?? 0) : -1;}
5817    function preferredDistortion(){return ({organic:0,ridged:1,waves:2,kura:3,tayu:4}[document.getElementById('procDispType')?.value] ?? 0);}
5818    let lastReadiness = '';
5819    function announceReadiness(){
5820      updateDistortionReadiness();
5821      const api=window.ColirRenderPrograms;
5822      if(api){const status={basicReady:api.basicReady,designReady:api.designReady,animationReady:api.animationReady};const key=JSON.stringify(status);if(key!==lastReadiness){lastReadiness=key;ColirGraphicsRuntime.note('gradient','current design readiness',status);}}
5823      window.dispatchEvent(new CustomEvent('colir-animation-ready',{detail:{state:programCache?.animationState || 'pending'}}));
5824    }
5825
5826    glCanvas = document.createElement('canvas');
5827    const graphicsNotice = document.createElement('div');
5828    graphicsNotice.setAttribute('role', 'status');
5829    graphicsNotice.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:100000;padding:12px 16px;border-radius:10px;background:#202020;color:white;max-width:75vw;font:13px sans-serif';
5830    const graphicsMessage = document.createElement('span');
5831    const graphicsRetry = document.createElement('button');
5832    graphicsRetry.textContent = 'Retry graphics'; graphicsRetry.type = 'button'; graphicsRetry.style.marginLeft = '12px';
5833    graphicsNotice.append(graphicsMessage, graphicsRetry); document.body.append(graphicsNotice);
5834    let surfaceRenderer = null, surfacePreparation = null;
5835    function resetGraphicsResources() {
5836      surfaceRenderer?.dispose(); surfaceRenderer = null; surfacePreparation = null;
5837      if (gl) {
5838        for (const texture of [xSamplesTexture,ySamplesTexture,noiseTexture,gradLUTTexture,fboTexA,fboTexB,fboTexC]) if(texture&&gl.isTexture(texture))gl.deleteTexture(texture);
5839        for (const buffer of Object.values(glBuffers || {})) if(buffer&&gl.isBuffer(buffer))gl.deleteBuffer(buffer);
5840        for (const fbo of [fboA,fboB,fboC]) if(fbo&&gl.isFramebuffer(fbo))gl.deleteFramebuffer(fbo);
5841        programCache?.dispose();
5842      }
5843      glProgram = glUniformLocations = glBuffers = graphicsLimits = null;
5844      xSamplesTexture = ySamplesTexture = noiseTexture = gradLUTTexture = null;
5845      fboA = fboB = fboC = fboTexA = fboTexB = fboTexC = null; fboTexW = fboTexH = 0;
5846      cachedXSamples = cachedYSamples = cachedXPoints = cachedYPoints = cachedStops = cachedPalette = null;
5847      cachedXTextureData = cachedYTextureData = cachedResolution = cachedGradLUTKey = null;
5848      cachedNoiseAmount = cachedFeather = cachedBlendMode = gradientNoiseUniformsCached = null;
5849    }
5850    const graphics = window.ColirGraphics = ColirGraphicsRuntime.create({name:'gradient', canvas:glCanvas,
5851      initialize: async signal => { programCache?.cancel(); if(programCache)await programCache.pending; ColirGraphicsRuntime.check(signal); resetGraphicsResources(); await initWebGL(signal); if(!tubeRenderer.ok)await tubeRenderer.lifecycle.start(); if(!tubeRenderer.ok)throw Error('Curve graphics are unavailable. Retry.'); ColirGraphicsRuntime.check(signal); const oldSize=[tubeRenderer.canvas.width,tubeRenderer.canvas.height]; tubeRenderer.resize(16,16); tubeRenderer.draw([{x:1,y:4,v:0},{x:15,y:12,v:1}],{radius:1,joints:[],trimRadius:0,opacity:1,isActive:true,dpr:1}); const pixel=new Uint8Array(4);tubeRenderer.gl.readPixels(8,8,1,1,tubeRenderer.gl.RGBA,tubeRenderer.gl.UNSIGNED_BYTE,pixel);tubeRenderer.resize(...oldSize); },
5852      onState: (state, error) => {
5853        if(state!=='ready'){programCache?.cancel(state==='lost');announceReadiness();}
5854        document.querySelector('.sidebar')?.toggleAttribute('inert',state!=='ready');
5855        document.querySelector('.output-area')?.toggleAttribute('inert',state!=='ready');
5856        graphicsNotice.hidden = state === 'ready' || state === 'initializing'; graphicsRetry.hidden = state === 'initializing';
5857        graphicsMessage.textContent = state === 'initializing' ? 'Preparing graphics…' :
5858          state === 'lost' ? 'Graphics interrupted. Your design is preserved; preview updates are paused.' :
5859          (error?.message || 'Graphics are unavailable. Your design is preserved.');
5860        if (state === 'ready') { announceReadiness(); ColirGraphicsRuntime.note('gradient','basic graphics ready'); renderGradient(); requestAnimationFrame(()=>window.__colirPreloader?.done()); programCache.animation(preferredDistortion()).catch(()=>{}); }
5861        else if(state==='failed'||state==='lost')window.__colirPreloader?.done();
5862      }});
5863    window.addEventListener('colir-graphics-state', event => {
5864      if (event.detail.renderer !== 'curves') return;
5865      if(graphics.state==='ready'){document.querySelector('.sidebar')?.toggleAttribute('inert',!tubeRenderer.ok);document.querySelector('.output-area')?.toggleAttribute('inert',!tubeRenderer.ok);announceReadiness();}
5866      if (['lost','failed'].includes(event.detail.state)) { graphicsNotice.hidden = false; graphicsRetry.hidden = false; graphicsMessage.textContent = 'Curve graphics interrupted. Your design is preserved. Retry graphics.'; }
5867      else if (event.detail.state === 'ready' && graphics.state === 'ready') graphicsNotice.hidden = true;
5868    });
5869    window.ColirRetryGraphics = () => { if(graphics.state !== 'initializing')graphicsRetry.click(); };
5870    graphicsRetry.addEventListener('click', () => { if(graphics.state==='ready'&&programCache?.animationState==='failed'){graphicsNotice.hidden=true;programCache.animation(preferredDistortion()).catch(()=>{});return;} graphics.retry(); });
5871    function updateDistortionReadiness(){
5872      const api=window.ColirRenderPrograms;if(!api)return;
5873      const select=document.getElementById('procDispType'),motion=window.ColirAnimation?.settings()?.distortionMotion;
5874      const animated=!!(window.ColirAnimation?.active&&motion);
5875      for(const [i,option] of Array.from(select.options).entries())option.disabled=!api.distortionReady(i,animated);
5876      document.getElementById('distortionEnabled').disabled=!api.distortionReady(preferredDistortion(),animated);
5877      const choices=Array.from(select.options).filter(o=>!o.disabled).length;
5878      for(const id of ['procDispPrev','procDispNext'])document.getElementById(id).disabled=choices<2;
5879    }
5880    function prioritizeCurrentDesign(){
5881      if(!programCache)return;
5882      const motion=window.ColirAnimation?.settings()||window.__colirPendingAnimation;
5883      programCache.prioritize(selectedDistortion(),!!motion?.distortionMotion);
5884      announceReadiness();
5885    }
5886    window.ColirRenderPrograms={
5887      get basicReady(){return graphics.state==='ready'&&tubeRenderer.ok;},
5888      get designReady(){return this.basicReady&&programCache.ready(selectedDistortion());},
5889      get animationReady(){return this.designReady&&(!(window.ColirAnimation?.settings()?.distortionMotion&&selectedDistortion()>=0)||programCache.ready(selectedDistortion(),true));},
5890      distortionReady(type,animated=false){return this.basicReady&&programCache.ready(type)&&(!animated||programCache.ready(type,true));},
5891      get allReady(){return programCache?.animationState==='ready';},
5892      get count(){return programCache?.entries.size||0;},get timings(){return programCache?.timings.slice()||[];}
5893    };
5894    function requireGraphics(width, height) {
5895      graphics.assertReady();
5896      if(!tubeRenderer.ok)throw Error('Curve graphics are unavailable. Retry graphics.');
5897      if (!gl || gl.isContextLost()) throw new Error('Graphics context lost. Retry graphics.');
5898      if (width !== undefined) ColirGraphicsRuntime.validateSize(width, height, graphicsLimits);
5899    }
5900
5901    async function initWebGL(signal) {
5902      // Create a hidden WebGL canvas for rendering
5903      ColirGraphicsRuntime.note('gradient', 'context creation', {}, signal);
5904      gl = glCanvas.getContext('webgl', { preserveDrawingBuffer: true }) || glCanvas.getContext('experimental-webgl', { preserveDrawingBuffer: true });
5905      
5906      if (!gl) {
5907        throw new Error('WebGL is unavailable. Check browser graphics supp
5907ort, then retry.');
5908      }
5909      ColirGraphicsRuntime.inspect('gradient', gl);
5910      ColirGraphicsRuntime.note('gradient', 'context ready', {}, signal);
5911      if ('drawingBufferColorSpace' in gl) gl.drawingBufferColorSpace = 'srgb';
5912      
5913      // Enable necessary WebGL features
5914      gl.enable(gl.BLEND);
5915      gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
5916      // Ensure 1-byte alignment for 1px-wide LUMINANCE textures
5917      gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
5918
5919      const sources = ColirShaderPrograms;
5920      graphicsLimits = ColirGraphicsRuntime.limits(gl);
5921      ColirGraphicsRuntime.validateSize(gradientCanvas.width, gradientCanvas.height, graphicsLimits);
5922      glCanvas.width = gradientCanvas.width; glCanvas.height = gradientCanvas.height;
5923      programCache = ColirProgramCache.create({gl,sources,link:ColirGraphicsRuntime.link,warm:ColirWarmProgram,
5924        entry:(program,key)=>{
5925      const uniforms = {
5926        resolution: gl.getUniformLocation(program, 'u_resolution'),
5927        noiseAmount: gl.getUniformLocation(program, 'u_noiseAmount'),
5928        gradientNoiseScale: gl.getUniformLocation(program, 'u_gradientNoiseScale'),
5929        gradientNoiseMix: gl.getUniformLocation(program, 'u_gradientNoiseMix'),
5930        gradientNoiseJitter: gl.getUniformLocation(program, 'u_gradientNoiseJitter'),
5931        feather: gl.getUniformLocation(program, 'u_feather'),
5932        blendMode: gl.getUniformLocation(program, 'u_blendMode'),
5933        stopCount: gl.getUniformLocation(program, 'u_stopCount'),
5934        stopPositions: gl.getUniformLocation(program, 'u_stopPositions'),
5935        stopColors: gl.getUniformLocation(program, 'u_stopColors'),
5936        paletteCount: gl.getUniformLocation(program, 'u_paletteCount'),
5937        paletteColors: gl.getUniformLocation(program, 'u_paletteColors'),
5938        gradLUT: gl.getUniformLocation(program, 'u_gradLUT'),
5939        xSamples: gl.getUniformLocation(program, 'u_xSamples'),
5940        ySamples: gl.getUniformLocation(program, 'u_ySamples'),
5941        noise: gl.getUniformLocation(program, 'u_noise'),
5942        glitterEnabled: gl.getUniformLocation(program, 'u_glitterEnabled'),
5943        glitterDensity: gl.getUniformLocation(program, 'u_glitterDensity'),
5944        glitterOpacity: gl.getUniformLocation(program, 'u_glitterOpacity'),
5945        glitterBlendMode: gl.getUniformLocation(program, 'u_glitterBlendMode'),
5946        glitterSizeMin: gl.getUniformLocation(program, 'u_glitterSizeMin'),
5947        glitterSizeMax: gl.getUniformLocation(program, 'u_glitterSizeMax'),
5948        glitterTime: gl.getUniformLocation(program, 'u_glitterTime'),
5949        glitterRate: gl.getUniformLocation(program, 'u_glitterRate'),
5950        glitterSpeed: gl.getUniformLocation(program, 'u_glitterSpeed'),
5951        glitterPop: gl.getUniformLocation(program, 'u_glitterPop'),
5952        // Procedural distortion uniforms
5953        procDispEnabled: gl.getUniformLocation(program, 'u_procDispEnabled'),
5954        procDispType: gl.getUniformLocation(program, 'u_procDispType'),
5955        procDispIntensity: gl.getUniformLocation(program, 'u_procDispIntensity'),
5956        procDispScale: gl.getUniformLocation(program, 'u_procDispScale'),
5957        procDispOctaves: gl.getUniformLocation(program, 'u_procDispOctaves'),
5958        procDispLacunarity: gl.getUniformLocation(program, 'u_procDispLacunarity'),
5959        procDispPersistence: gl.getUniformLocation(program, 'u_procDispPersistence'),
5960        procDispWaveFreq: gl.getUniformLocation(program, 'u_procDispWaveFreq'),
5961        procDispVoronoiJitter: gl.getUniformLocation(program, 'u_procDispVoronoiJitter'),
5962        procDispWarpStrength: gl.getUniformLocation(program, 'u_procDispWarpStrength'),
5963        procDispMix: gl.getUniformLocation(program, 'u_procDispMix'),
5964        procDispTayuMix: gl.getUniformLocation(program, 'u_procDispTayuMix'),
5965        animationOffset: gl.getUniformLocation(program, 'u_animationOffset'),
5966        animationPhase: gl.getUniformLocation(program, 'u_animationPhase'),
5967        procDispSeed: gl.getUniformLocation(program, 'u_procDispSeed'),
5968        materialEnabled: gl.getUniformLocation(program, 'u_materialEnabled'),
5969        materialStrength: gl.getUniformLocation(program, 'u_materialStrength'),
5970        patternType: gl.getUniformLocation(program, 'u_patternType'),
5971        patternDotSize: gl.getUniformLocation(program, 'u_patternDotSize'),
5972        patternDotDistance: gl.getUniformLocation(program, 'u_patternDotDistance'),
5973        patternLineThickness: gl.getUniformLocation(program, 'u_patternLineThickness'),
5974        patternLineDistance: gl.getUniformLocation(program, 'u_patternLineDistance'),
5975        patternLineAngle: gl.getUniformLocation(program, 'u_patternLineAngle'),
5976        patternLineMode: gl.getUniformLocation(program, 'u_patternLineMode'),
5977        patternColor: gl.getUniformLocation(program, 'u_patternColor'),
5978        patternBlendMode: gl.getUniformLocation(program, 'u_patternBlendMode'),
5979        patternOpacity: gl.getUniformLocation(program, 'u_patternOpacity'),
5980        patternMotionDirection: gl.getUniformLocation(program, 'u_patternMotionDirection'),
5981        patternMotion: gl.getUniformLocation(program, 'u_patternMotion'),
5982        patternMotionSize: gl.getUniformLocation(program, 'u_patternMotionSize'),
5983        chromaticEnabled: gl.getUniformLocation(program, 'u_chromaticEnabled'),
5984        caMode: gl.getUniformLocation(program, 'u_caMode'),
5985        caStrength: gl.getUniformLocation(program, 'u_caStrength'),
5986        caNoiseScale: gl.getUniformLocation(program, 'u_caNoiseScale'),
5987        caEdgeMask: gl.getUniformLocation(program, 'u_caEdgeMask'),
5988        caSeed: gl.getUniformLocation(program, 'u_caSeed'),
5989        betaCaOffset: gl.getUniformLocation(program, 'u_betaCaOffset'),
5990        betaCaRotation: gl.getUniformLocation(program, 'u_betaCaRotation'),
5991        betaFlowOffset: gl.getUniformLocation(program, 'u_betaFlowOffset'),
5992        betaPaperOffset: gl.getUniformLocation(program, 'u_betaPaperOffset'),
5993
5994        caFbmOctaves: gl.getUniformLocation(program, 'u_caFbmOctaves'),
5995        caIrregularity: gl.getUniformLocation(program, 'u_caIrregularity'),
5996        caRbBalance: gl.getUniformLocation(program, 'u_caRbBalance'),
5997        caOpacity: gl.getUniformLocation(program, 'u_caOpacity'),
5998        chromaNoiseStrength: gl.getUniformLocation(program, 'u_chromaNoiseStrength'),
5999        chromaNoiseScale: gl.getUniformLocation(program, 'u_chromaNoiseScale'),
6000        flowEnabled: gl.getUniformLocation(program, 'u_flowEnabled'),
6001        flowOpacity: gl.getUniformLocation(program, 'u_flowOpacity'),
6002        flowLength: gl.getUniformLocation(program, 'u_flowLength'),
6003        flowSteps: gl.getUniformLocation(program, 'u_flowSteps'),
6004        flowDecay: gl.getUniformLocation(program, 'u_flowDecay'),
6005        flowScale: gl.getUniformLocation(program, 'u_flowScale'),
6006        flowOctaves: gl.getUniformLocation(program, 'u_flowOctaves'),
6007        flowAngle: gl.getUniformLocation(program, 'u_flowAngle'),
6008        flowIrregularity: gl.getUniformLocation(program, 'u_flowIrregularity'),
6009        flowSeed: gl.getUniformLocation(program, 'u_flowSeed'),
6010        pass: gl.getUniformLocation(program, 'u_pass'),
6011        srcTex: gl.getUniformLocation(program, 'u_srcTex'),
6012        paperEnabled: gl.getUniformLocation(program, 'u_paperEnabled'),
6013        paperOpacity: gl.getUniformLocation(program, 'u_paperOpacity'),
6014        paperGrain: gl.getUniformLocation(program, 'u_paperGrain'),
6015        paperFiber: gl.getUniformLocation(program, 'u_paperFiber'),
6016        paperFold: gl.getUniformLocation(program, 'u_paperFold'),
6017        paperRough: gl.getUniformLocation(program, 'u_paperRough'),
6018        paperSpeck: gl.getUniformLocation(program, 'u_paperSpeck'),
6019        paperDisplace: gl.getUniformLocation(program, 'u_paperDisplace'),
6020        paperScale: gl.getUniformLocation(program, 'u_paperScale'),
6021        paperGrainScale: gl.getUniformLocation(program, 'u_paperGrainScale'),
6022        paperFiberScale: gl.getUniformLocation(program, 'u_paperFiberScale'),
6023        paperFoldScale: gl.getUniformLocation(program, 'u_paperFoldScale'),
6024        paperRoughScale: gl.getUniformLocation(program, 'u_paperRoughScale'),
6025        paperRefRes: gl.getUniformLocation(program, 'u_paperRefRes'),
6026        paperSeed: gl.getUniformLocation(program, 'u_paperSeed'),
6027      };
6028          return {program,key,uniforms,position:gl.getAttribLocation(program,'a_position'),cache:{}};
6029        },
6030        canBackground:()=>!document.hidden && !window.ColirAnimation?.exporting && !document.getElementById('exportBtn')?.classList.contains('loading'),
6031        onState:(state,detail)=>{ColirGraphicsRuntime.note('gradient','program preparation '+state,detail||{});announceReadiness();if(graphics.state==='ready')renderGradient();
6032          if(state==='failed'){graphicsNotice.hidden=false;graphicsRetry.hidden=false;graphicsMessage.textContent='Some graphics options are unavailable. Retry to prepare them again.';}
6033        }});
6034      do { await programCache.design(signal,selectedDistortion()); } while(!programCache.ready(selectedDistortion()));
6035      ColirGraphicsRuntime.check(signal);
6036      const initialProgram=programCache.select(0,selectedDistortion(),false);
6037      glProgram=initialProgram.program;glUniformLocations=initialProgram.uniforms;
6038      // Create fullscreen quad
6039      const positionBuffer = gl.createBuffer();
6040      gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
6041      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
6042        -1, -1,
6043         1, -1,
6044        -1,  1,
6045         1,  1
6046      ]), gl.STATIC_DRAW);
6047
6048      glBuffers = {
6049        position: positionBuffer
6050      };
6051      
6052      // Cache attribute location
6053      positionLoc = gl.getAttribLocation(glProgram, 'a_position');
6054
6055      // Create noise texture (procedural noise)
6056      noiseTexture = gl.createTexture();
6057      gl.bindTexture(gl.TEXTURE_2D, noiseTexture);
6058      // Deterministic noise field so preview/export match exactly.
6059      let seed = 1337 >>> 0;
6060      for (let i = 0; i < noiseField.length; i++) {
6061        seed ^= (seed << 13) >>> 0;
6062        seed ^= (seed >>> 17) >>> 0;
6063        seed ^= (seed << 5) >>> 0;
6064        noiseField[i] = seed & 0xff;
6065      }
6066      gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, NOISE_TEX_SIZE, NOISE_TEX_SIZE, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, noiseField);
6067      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
6068      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
6069      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
6070      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
6071
6072      // Create gradient LUT texture (uploaded on demand)
6073      gradLUTTexture = gl.createTexture();
6074      gl.bindTexture(gl.TEXTURE_2D, gradLUTTexture);
6075      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
6076      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
6077      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
6078      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
6079      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, GRAD_LUT_SIZE, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
6080
6081      window.currentNormalMap = 'blocks.jpg';
6082      gradientNoiseUniformsCached = null;
6083      
6084      // Load initial texture
6085      if (window.loadNormalMapTexture) {
6086        window.loadNormalMapTexture('blocks.jpg');
6087      }
6088
6089      return true;
6090    }
6091
6092    // (Re)allocate the ping-pong post-processing framebuffers to match the render size.
6093    function ensureFBOSize(w, h) {
6094      if (!gl) return;
6095      if (fboTexW === w && fboTexH === h && fboTexA && fboTexB && fboTexC) return;
6096      const setup = (tex, fbo) => {
6097        gl.bindTexture(gl.TEXTURE_2D, tex);
6098        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
6099        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
6100        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
6101        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
6102        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
6103        gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
6104        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
6105      };
6106      if (!fboTexA) { fboTexA = gl.createTexture(); fboA = gl.createFramebuffer(); }
6107      if (!fboTexB) { fboTexB = gl.createTexture(); fboB = gl.createFramebuffer(); }
6108      if (!fboTexC) { fboTexC = gl.createTexture(); fboC = gl.createFramebuffer(); }
6109      setup(fboTexA, fboA);
6110      setup(fboTexB, fboB);
6111      setup(fboTexC, fboC);
6112      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
6113      fboTexW = w;
6114      fboTexH = h;
6115    }
6116
6117    function updateCurveTextures(xSamples, ySamples, forceUpdate = false) {
6118      if (!gl) return;
6119      
6120      // Validate inputs
6121      if (!xSamples || !Array.isArray(xSamples) || xSamples.length === 0) {
6122        console.error('updateCurveTextures: Invalid xSamples', xSamples);
6123        return;
6124      }
6125      if (!ySamples || !Array.isArray(ySamples) || ySamples.length === 0) {
6126        console.error('updateCurveTextures: Invalid ySamples', ySamples);
6127        return;
6128      }
6129
6130      // Update X samples texture only if data changed
6131      if (!xSamplesTexture) {
6132        xSamplesTexture = gl.createTexture();
6133        gl.bindTexture(gl.TEXTURE_2D, xSamplesTexture);
6134        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
6135        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
6136        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
6137        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
6138      }
6139
6140      // Use RGBA format with 16-bit precision packing to avoid banding
6141      // Pack value (0-1) into R and G channels: R = high byte, G = low byte
6142      // This gives us 65536 levels instead of 256, eliminating visible steps
6143      const xData = new Uint8Array(xSamples.length * 4); // RGBA = 4 bytes per pixel
6144      for (let i = 0; i < xSamples.length; i++) {
6145        const value = xSamples[i];
6146        // Pack into 16-bit: R = high byte, G = low byte
6147        const value16 = Math.floor(value * 65535);
6148        const r = Math.floor(value16 / 256); // High byte
6149        const g = value16 % 256; // Low byte
6150        const idx = i * 4;
6151        xData[idx + 0] = r;     // R
6152        xData[idx + 1] = g;     // G
6153        xData[idx + 2] = 0;     // B (unused)
6154        xData[idx + 3] = 255;   // A (opaque)
6155      }
6156      
6157      // Only update texture if data changed
6158      if (forceUpdate || !cachedXTextureData || cachedXTextureData.length !== xData.length) {
6159        gl.bindTexture(gl.TEXTURE_2D, xSamplesTexture);
6160        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, xSamples.length, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, xData);
6161        cachedXTextureData = new Uint8Array(xData);
6162      } else {
6163        // Check if data actually changed
6164        let dataChanged = false;
6165        for (let i = 0; i < xData.length; i++) {
6166          if (cachedXTextureData[i] !== xData[i]) {
6167            dataChanged = true;
6168            break;
6169          }
6170        }
6171        if (dataChanged) {
6172          gl.bindTexture(gl.TEXTURE_2D, xSamplesTexture);
6173          gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, xSamples.length, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, xData);
6174          cachedXTextureData = new Uint8Array(xData);
6175        }
6176      }
6177
6178      // Update Y samples texture only if data changed
6179      if (!ySamplesTexture) {
6180        ySamplesTexture = gl.createTexture();
6181        gl.bindTexture(gl.TEXTURE_2D, ySamplesTexture);
6182        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
6183        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
6184        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
6185        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
6186      }
6187
6188      // Create Y data array - store directly (no flip)
6189      // ySamples[0] (top) -> yData[0] -> texture Y=0
6190      // ySamples[h-1] (bottom) -> yData[h-1] -> texture Y=1
6191      // Use RGBA format with 16-bit precision packing to avoid banding
6192      const yData = new Uint8Array(ySamples.length * 4); // RGBA = 4 bytes per pixel
6193      for (let i = 0; i < ySamples.length; i++) {
6194        const value = ySamples[i];
6195        // Pack into 16-bit: R = high byte, G = low byte
6196        const value16 = Math.floor(value * 65535);
6197        const r = Math.floor(value16 / 256); // High byte
6198        const g = value16 % 256; // Low byte
6199        const idx = i * 4;
6200        yData[idx + 0] = r;     // R
6201        yData[idx + 1] = g;     // G
6202        yData[idx + 2] = 0;     // B (unused)
6203        yData[idx + 3] = 255;   // A (opaque)
6204      }
6205      
6206      // Only update texture if data changed
6207      if (forceUpdate || !cachedYTextureData || cachedYTextureData.length !== yData.length) {
6208        gl.bindTexture(gl.TEXTURE_2D, ySamplesTexture);
6209        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, ySamples.length, 0, gl.RGBA, gl.UNSIGNED_BYTE, yData);
6210        cachedYTextureData = new Uint8Array(yData);
6211      } else {
6212        // Check if data actually changed
6213        let dataChanged = false;
6214        for (let i = 0; i < yData.length; i++) {
6215          if (cachedYTextureData[i] !== yData[i]) {
6216            dataChanged = true;
6217            break;
6218          }
6219        }
6220        if (dataChanged) {
6221          gl.bindTexture(gl.TEXTURE_2D, ySamplesTexture);
6222          gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, ySamples.length, 0, gl.RGBA, gl.UNSIGNED_BYTE, yData);
6223          cachedYTextureData = new Uint8Array(yData);
6224        }
6225      }
6226    }
6227
6228    function renderGradientWebGLAtSize(width, height, skipReadPixels = false, animationFrame = null) {
6229      const betaPreview = skipReadPixels ? animationFrame?.betaPreview : null;
6230      const animatedFlow = window.canUseFlowAnimation?.() ? animationFrame?.flow : null;
6231      requireGraphics(width, height);
6232      if(!window.ColirRenderPrograms.designReady || (animationFrame && Math.abs(animationFrame.phase||0)>0 && !programCache.ready(selectedDistortion(),selectedDistortion()>=0)))throw Error('Graphics are not ready. Please retry.');
6233
6234      const profile = enableProfiling ? { steps: {} } : null;
6235      const startTime = enableProfiling ? performance.now() : 0;
6236      let stepStart = startTime;
6237
6238      const w = width;
6239      const h = height;
6240
6241      // Update WebGL canvas size
6242      if (glCanvas.width !== w || glCanvas.height !== h) {
6243        glCanvas.width = w;
6244        glCanvas.height = h;
6245        gl.viewport(0, 0, w, h);
6246      }
6247
6248      // Keep post-processing framebuffers sized to the render target
6249      ensureFBOSize(w, h);
6250
6251      // Content keys detect coordinated point changes; animation never uses editor damping.
6252      const xPoints = animationFrame ? animationFrame.xPoints : xEditor._evalPts();
6253      const yPoints = animationFrame ? animationFrame.yPoints : yEditor._evalPts();
6254      const smooth = animationFrame ? animationFrame.smooth : document.getElementById('curveSmoothingToggle').classList.contains('on');
6255      const xKey = JSON.stringify([xPoints, smooth, !!animationFrame]);
6256      const yKey = JSON.stringify([yPoints, smooth, !!animationFrame]);
6257      if (!cachedXSamples || cachedXPoints !== xKey || cachedXWidth !== w) {
6258        cachedXSamples = animationFrame ? ColirMotion.samples(xPoints, w, smooth) : xEditor.evaluateSamples(w);
6259        cachedXWidth = w;
6260        cachedXPoints = xKey;
6261      }
6262      if (!cachedYSamples || cachedYPoints !== yKey || cachedYHeight !== h) {
6263        cachedYSamples = animationFrame ? ColirMotion.samples(yPoints, h, smooth) : yEditor.evaluateSamples(h);
6264        cachedYHeight = h;
6265        cachedYPoints = yKey;
6266      }
6267
6268      // Update textures - always update to ensure they're current
6269      if (cachedXSamples && cachedYSamples) {
6270        const texStart = performance.now();
6271        updateCurveTextures(cachedXSamples, cachedYSamples, true);
6272        if (enableProfiling) {
6273          profile.steps.textureUpload = performance.now() - texStart;
6274        }
6275      }
6276      
6277      if (enableProfiling) {
6278        stepStart = performance.now();
6279      }
6280
6281      function applyProgram(entry) {
6282      glProgram=entry.program;glUniformLocations=entry.uniforms;positionLoc=entry.position;
6283      cachedResolution=entry.cache.cachedResolution ?? null;
6284      cachedNoiseAmount=entry.cache.cachedNoiseAmount ?? null;
6285      cachedFeather=entry.cache.cachedFeather ?? null;
6286      cachedBlendMode=entry.cache.cachedBlendMode ?? null;
6287      gradientNoiseUniformsCached=entry.cache.gradientNoiseUniformsCached ?? null;
6288      cachedStops=entry.cache.cachedStops ?? null;
6289      cachedPalette=entry.cache.cachedPalette ?? null;
6290      // Use program
6291      gl.useProgram(glProgram);
6292
6293      // Set up attributes (use cached location)
6294      gl.enableVertexAttribArray(positionLoc);
6295      gl.bindBuffer(gl.ARRAY_BUFFER, glBuffers.position);
6296      gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
6297
6298      // Set uniforms (only update if changed)
6299      const resolution = `${w},${h}`;
6300      if (cachedResolution !== resolution) {
6301        gl.uniform2f(glUniformLocations.resolution, w, h);
6302        cachedResolution = resolution;
6303      }
6304      
6305      const noiseAmount = parseInt(noiseInput.value) / 100;
6306      if (cachedNoiseAmount !== noiseAmount) {
6307        gl.uniform1f(glUniformLocations.noiseAmount, noiseAmount);
6308        cachedNoiseAmount = noiseAmount;
6309      }
6310
6311      const gnKey = `${GRADIENT_NOISE_SCALE_FIXED},${GRADIENT_NOISE_MIX},${GRADIENT_NOISE_JITTER}`;
6312      if (gradientNoiseUniformsCached !== gnKey) {
6313        if (glUniformLocations.gradientNoiseScale !== null) {
6314          gl.uniform1f(glUniformLocations.gradientNoiseScale, GRADIENT_NOISE_SCALE_FIXED);
6315        }
6316        if (glUniformLocations.gradientNoiseMix !== null) {
6317          gl.uniform1f(glUniformLocations.gradientNoiseMix, GRADIENT_NOISE_MIX);
6318        }
6319        if (glUniformLocations.gradientNoiseJitter !== null) {
6320          gl.uniform1f(glUniformLocations.gradientNoiseJitter, GRADIENT_NOISE_JITTER);
6321        }
6322        gradientNoiseUniformsCached = gnKey;
6323      }
6324      
6325      const feather = parseInt(featherInput.value) / 100;
6326      if (cachedFeather !== feather) {
6327        gl.uniform1f(glUniformLocations.feather, feather);
6328        cachedFeather = feather;
6329      }
6330
6331      // Blend mode mapping
6332      const blendModeMap = {
6333        'mul': 0, 'screen': 1, 'overlay': 2, 'softlight': 3, 'hardlight': 4,
6334        'difference': 5, 'exclusion': 6, 'darken': 7, 'lighten': 8,
6335        'colorburn': 9, 'colordodge': 10, 'avg': 11
6336      };
6337      const blendModeValue = blendModeMap[blendMode.value] || 11;
6338      if (cachedBlendMode !== blendModeValue) {
6339        gl.uniform1i(glUniformLocations.blendMode, blendModeValue);
6340        cachedBlendMode = blendModeValue;
6341      }
6342
6343      // Glitter uniforms
6344      if (glUniformLocations.glitterEnabled !== null) {
6345        const glitterEnabled = document.getElementById('glitterEnabled');
6346        const isEnabled = glitterEnabled && glitterEnabled.checked ? 1 : 0;
6347        gl.uniform1i(glUniformLocations.glitterEnabled, isEnabled);
6348      }
6349      if (glUniformLocations.glitterDensity !== null) {
6350        const glitterDensityInput = document.getElementById('glitterDensityInput');
6351        // UI shows 1-100, but we need to map to 0-5000 (multiply by 50)
6352        const uiValue = glitterDensityInput ? parseFloat(glitterDensityInput.value) || 50 : 50;
6353        const density = uiValue * 50; // Convert 1-100 UI scale to 50-5000 actual value
6354        gl.uniform1f(glUniformLocations.glitterDensity, density);
6355      }
6356      if (glUniformLocations.glitterOpacity !== null) {
6357        const glitterOpacityValue = document.getElementById('glitterOpacityValue') || document.getElementById('glitterOpacity');
6358        const opacity = glitterOpacityValue ? parseFloat(glitterOpacityValue.value) / 100.0 : 1.0;
6359        gl.uniform1f(glUniformLocations.glitterOpacity, Math.max(0.0, Math.min(1.0, opacity)));
6360      }
6361      if (glUniformLocations.glitterBlendMode !== null) {
6362        const blendEl = document.getElementById('glitterBlendMode');
6363        const v = blendEl ? blendEl.value : 'normal';
6364        const modeInt = v === 'normal' ? 0 :
6365          v === 'mul' ? 1 :
6366          v === 'screen' ? 2 :
6367          v === 'overlay' ? 3 :
6368          v === 'softlight' ? 4 :
6369          v === 'hardlight' ? 5 :
6370          v === 'difference' ? 6 :
6371          v === 'exclusion' ? 7 :
6372          v === 'darken' ? 8 :
6373          v === 'lighten' ? 9 :
6374          v === 'colorburn' ? 10 : 11;
6375        gl.uniform1i(glUniformLocations.glitterBlendMode, modeInt);
6376      }
6377      if (glUniformLocations.glitterSizeMin !== null) {
6378        const glitterSizeMinInput = document.getElementById('glitterSizeMinInput');
6379        const sizeMin = glitterSizeMinInput ? parseFloat(glitterSizeMinInput.value) || 0.5 : 0.5;
6380        gl.uniform1f(glUniformLocations.glitterSizeMin, sizeMin);
6381      }
6382      if (glUniformLocations.glitterSizeMax !== null) {
6383        const glitterSizeMaxInput = document.getElementById('glitterSizeMaxInput');
6384        const sizeMax = glitterSizeMaxInput ? parseFloat(glitterSizeMaxInput.value) || 3 : 3;
6385        gl.uniform1f(glUniformLocations.glitterSizeMax, sizeMax);
6386      }
6387      if (glUniformLocations.glitterTime !== null) {
6388        gl.uniform1f(glUniformLocations.glitterTime, Number.isFinite(animationFrame?.glitterTime) ? animationFrame.glitterTime : 0);
6389      }
6390      if (glUniformLocations.glitterRate !== null) {
6391        gl.uniform1f(glUniformLocations.glitterRate, Number.isFinite(animationFrame?.glitterRate) ? animationFrame.glitterRate : 0);
6392      }
6393      if (glUniformLocations.glitterSpeed !== null) {
6394        gl.uniform1f(glUniformLocations.glitterSpeed, Number.isFinite(animationFrame?.glitterSpeed) ? animationFrame.glitterSpeed : 0);
6395      }
6396      if (glUniformLocations.glitterPop !== null) {
6397        gl.uniform1i(glUniformLocations.glitterPop, animationFrame?.glitterPop ? 1 : 0);
6398      }
6399
6400      // Cache gradient stops - only recompute if changed
6401      const colorOffset = Number.isFinite(animationFrame?.colorOffset) ? animationFrame.colorOffset : 0;
6402      const renderStops = gradientStopsArray;
6403      const stopsKey = JSON.stringify(renderStops.map(s => ({ p: s.position, r: s.color.r, g: s.color.g, b: s.color.b })));
6404      const paletteKey = activePalette ? JSON.stringify(activePalette.map(c => ({ r: c.r, g: c.g, b: c.b }))) : '';
6405      const stopsChanged = !cachedStops || cachedStops !== stopsKey;
6406      const paletteChanged = !cachedPalette || cachedPalette !== paletteKey;
6407      
6408      // Update the OKLCH->sRGB gradient LUT used by the shader (preview/export parity).
6409      // Keyed by both stops and palette so palette-driven gradients are captured.
6410      updateGradientLUTTexture(`${stopsKey}|${paletteKey}|cf:${colorOffset}`, null, colorOffset);
6411
6412      if (stopsChanged) {
6413        const stops = renderStops.slice().sort((a, b) => a.position - b.position);
6414        const stopCount = Math.min(stops.length, 32);
6415        const stopPositions = new Float32Array(32);
6416        const stopColors = new Float32Array(32 * 3);
6417        
6418        for (let i = 0; i < stopCount; i++) {
6419          stopPositions[i] = stops[i].position;
6420          stopColors[i * 3 + 0] = stops[i].color.r / 255;
6421          stopColors[i * 3 + 1] = stops[i].color.g / 255;
6422          stopColors[i * 3 + 2] = stops[i].color.b / 255;
6423        }
6424
6425        gl.uniform1i(glUniformLocations.stopCount, stopCount);
6426        gl.uniform1fv(glUniformLocations.stopPositions, stopPositions);
6427        gl.uniform3fv(glUniformLocations.stopColors, stopColors);
6428        cachedStops = stopsKey;
6429      }
6430      
6431      if (paletteChanged) {
6432        const paletteCount = activePalette ? Math.min(activePalette.length, 32) : 0;
6433        const paletteColors = new Float32Array(32 * 3);
6434        if (activePalette && activePalette.length > 0) {
6435          for (let i = 0; i < paletteCount; i++) {
6436            paletteColors[i * 3 + 0] = activePalette[i].r / 255;
6437            paletteColors[i * 3 + 1] = activePalette[i].g / 255;
6438            paletteColors[i * 3 + 2] = activePalette[i].b / 255;
6439          }
6440        }
6441        gl.uniform1i(glUniformLocations.paletteCount, paletteCount);
6442        gl.uniform3fv(glUniformLocations.paletteColors, paletteColors);
6443        cachedPalette = paletteKey;
6444      }
6445
6446      // Set textures
6447      gl.activeTexture(gl.TEXTURE0);
6448      gl.bindTexture(gl.TEXTURE_2D, xSamplesTexture);
6449      gl.uniform1i(glUniformLocations.xSamples, 0);
6450
6451      gl.activeTexture(gl.TEXTURE1);
6452      gl.bindTexture(gl.TEXTURE_2D, ySamplesTexture);
6453      gl.uniform1i(glUniformLocations.ySamples, 1);
6454
6455      // Only bind noise texture if noise is actually being used
6456      if (cachedNoiseAmount > 0.0) {
6457        gl.activeTexture(gl.TEXTURE2);
6458        gl.bindTexture(gl.TEXTURE_2D, noiseTexture);
6459        gl.uniform1i(glUniformLocations.noise, 2);
6460      }
6461
6462      // Bind gradient LUT texture (texture unit 3)
6463      gl.activeTexture(gl.TEXTURE3);
6464      gl.bindTexture(gl.TEXTURE_2D, gradLUTTexture);
6465      gl.uniform1i(glUniformLocations.gradLUT, 3);
6466
6467      // Procedural distortion uniforms
6468      const motionOffset = animationFrame ? animationFrame.offset : [0, 0];
6469      gl.uniform2f(glUniformLocations.animationOffset, motionOffset[0], motionOffset[1]);
6470      if (glUniformLocations.animationPhase !== null) {
6471        gl.uniform1f(glUniformLocations.animationPhase, animationFrame && Number.isFinite(animationFrame.phase) ? animationFrame.phase : 0);
6472      }
6473      const procDispEnabledEl = document.getElementById('distortionEnabled');
6474      const procDispTypeEl = document.getElementById('procDispType');
6475      const procDispIntensityEl = document.getElementById('procDispIntensity');
6476      const procDispScaleEl = document.getElementById('procDispScale');
6477      const procDispSeedEl = document.getElementById('procDispSeed');
6478      const procDispOctavesEl = document.getElementById('procDispOctaves');
6479      const procDispLacunarityEl = document.getElementById('procDispLacunarity');
6480      const procDispPersistenceEl = document.getElementById('procDispPersistence');
6481      const procDispWaveFreqEl = document.getElementById('procDispWaveFreq');
6482      const procDispVoronoiJitterEl = document.getElementById('procDispVoronoiJitter');
6483      const procDispWarpStrengthEl = document.getElementById('procDispWarpStrength');
6484      
6485      if (glUniformLocations.procDispEnabled !== null) {
6486        const isEnabled = procDispEnabledEl ? procDispEnabledEl.checked : false;
6487        gl.uniform1i(glUniformLocations.procDispEnabled, isEnabled ? 1 : 0);
6488      }
6489      if (glUniformLocations.procDispType !== null) {
6490        const typeMap = { 'organic': 0, 'ridged': 1, 'waves': 2, 'kura': 3, 'tayu': 4 };
6491        const typeValue = procDispTypeEl ? (typeMap[procDispTypeEl.value] || 0) : 0;
6492        gl.uniform1i(glUniformLocations.procDispType, typeValue);
6493      }
6494      if (glUniformLocations.procDispIntensity !== null) {
6495        // Remap 1-100 to 0.0-0.5 for visible displacement
6496        const intensity = procDispIntensityEl ? parseFloat(procDispIntensityEl.value) / 200.0 : 0.075;
6497        gl.uniform1f(glUniformLocations.procDispIntensity, intensity);
6498      }
6499      if (glUniformLocations.procDispScale !== null) {
6500        const scale = procDispScaleEl ? parseFloat(procDispScaleEl.value) : 3.0;
6501        gl.uniform1f(glUniformLocations.procDispScale, scale);
6502      }
6503      if (glUniformLocations.procDispSeed !== null) {
6504        // Convert seed to vec2 offset - use seed value to create a 2D offset
6505        const seedValue = procDispSeedEl ? parseFloat(procDispSeedEl.value) : 0.0;
6506        // Create a pseudo-random 2D offset from seed using simple hash
6507        const seedX = Math.sin(seedValue * 12.9898) * 43758.5453 % 1.0 * 100.0;
6508        const seedY = Math.sin(seedValue * 78.233) * 43758.5453 % 1.0 * 100.0;
6509        gl.uniform2f(glUniformLocations.procDispSeed, seedX, seedY);
6510      }
6511      if (glUniformLocations.procDispOctaves !== null) {
6512        const octaves = procDispOctavesEl ? parseInt(procDispOctavesEl.value) : 4;
6513        gl.uniform1i(glUniformLocations.procDispOctaves, octaves);
6514      }
6515      if (glUniformLocations.procDispLacunarity !== null) {
6516        const lacunarity = procDispLacunarityEl ? parseFloat(procDispLacunarityEl.value) : 2.0;
6517        gl.uniform1f(glUniformLocations.procDispLacunarity, lacunarity);
6518      }
6519      if (glUniformLocations.procDispPersistence !== null) {
6520        const persistence = procDispPersistenceEl ? parseFloat(procDispPersistenceEl.value) : 0.3;
6521        gl.uniform1f(glUniformLocations.procDispPersistence, persistence);
6522      }
6523      if (glUniformLocations.procDispWaveFreq !== null) {
6524        const waveFreq = procDispWaveFreqEl ? parseFloat(procDispWaveFreqEl.value) : 3.0;
6525        gl.uniform1f(glUniformLocations.procDispWaveFreq, waveFreq);
6526      }
6527      if (glUniformLocations.procDispVoronoiJitter !== null) {
6528        const voronoiJitter = procDispVoronoiJitterEl ? parseFloat(procDispVoronoiJitterEl.value) : 1.0;
6529        gl.uniform1f(glUniformLocations.procDispVoronoiJitter, voronoiJitter);
6530      }
6531      if (glUniformLocations.procDispWarpStrength !== null) {
6532        const warpStrength = procDispWarpStrengthEl ? parseFloat(procDispWarpStrengthEl.value) : 2.6;
6533        gl.uniform1f(glUniformLocations.procDispWarpStrength, warpStrength);
6534      }
6535      if (glUniformLocations.procDispMix !== null) {
6536        const procDispMixEl = document.getElementById('procDispMix');
6537        const mix = procDispMixEl ? parseFloat(procDispMixEl.value) : 0.5;
6538        gl.uniform1f(glUniformLocations.procDispMix, mix);
6539      }
6540      if (glUniformLocations.procDispTayuMix !== null) {
6541        const procDispTayuMixEl = document.getElementById('procDispTayuMix');
6542        const tayuMix = procDispTayuMixEl ? parseFloat(procDispTayuMixEl.value) : 0.5;
6543        gl.uniform1f(glUniformLocations.procDispTayuMix, tayuMix);
6544      }
6545      if (glUniformLocations.materialEnabled !== null && glUniformLocations.materialStrength !== null) {
6546        const materialStrengthEl = document.getElementById('materialStrength');
6547        const raw = materialStrengthEl ? parseFloat(materialStrengthEl.value) : 0;
6548        const strength = Math.max(0.0, raw / 100.0);
6549        gl.uniform1i(glUniformLocations.materialEnabled, strength > 0.001 ? 1 : 0);
6550        gl.uniform1f(glUniformLocations.materialStrength, strength);
6551      }
6552      if (glUniformLocations.patternType !== null) {
6553        const patternMotion = animationFrame?.patternMotion;
6554        gl.uniform2f(glUniformLocations.patternMotionDirection, patternMotion?.direction[0] || 0, patternMotion?.direction[1] || 0);
6555        gl.uniform3f(glUniformLocations.patternMotion, patternMotion?.travel || 0, patternMotion?.phase || 0, patternMotion?.wave || 0);
6556        if (glUniformLocations.patternMotionSize !== null) {
6557          gl.uniform1f(glUniformLocations.patternMotionSize, Number.isFinite(patternMotion?.size) ? patternMotion.size : 0.05);
6558        }
6559        const patternsEnabledEl = document.getElementById('patternsEnabled');
6560        const patternTypeEl = document.getElementById('patternType');
6561        const enabled = patternsEnabledEl ? patternsEnabledEl.checked : false;
6562        const typeVal = patternTypeEl ? patternTypeEl.value : 'dot';
6563        const typeInt = enabled ? (typeVal === 'dot' ? 1 : 2) : 0;
6564        gl.uniform1i(glUniformLocations.patternType, typeInt);
6565      }
6566      if (glUniformLocations.patternDotSize !== null) {
6567        const el = document.getElementById('patternDotSizeValue') || document.getElementById('patternDotSize');
6568        gl.uniform1f(glUniformLocations.patternDotSize, el ? parseFloat(el.value) || 3 : 3);
6569      }
6570      if (glUniformLocations.patternDotDistance !== null) {
6571        const el = document.getElementById('patternDotDistanceValue') || document.getElementById('patternDotDistance');
6572        gl.uniform1f(glUniformLocations.patternDotDistance, el ? parseFloat(el.value) || 24 : 24);
6573      }
6574      if (glUniformLocations.patternLineThickness !== null) {
6575        const el = document.getElementById('patternLineThicknessValue') || document.getElementById('patternLineThickness');
6576        gl.uniform1f(glUniformLocations.patternLineThickness, el ? parseFloat(el.value) || 1 : 1);
6577      }
6578      if (glUniformLocations.patternLineDistance !== null) {
6579        const el = document.getElementById('patternLineDistanceValue') || document.getElementById('patternLineDistance');
6580        gl.uniform1f(glUniformLocations.patternLineDistance, el ? parseFloat(el.value) || 24 : 24);
6581      }
6582      if (glUniformLocations.patternLineAngle !== null) {
6583        const el = document.getElementById('patternLineAngleValue') || document.getElementById('patternLineAngle');
6584        gl.uniform1f(glUniformLocations.patternLineAngle, el ? parseFloat(el.value) || 0 : 0);
6585      }
6586      if (glUniformLocations.patternLineMode !== null) {
6587        const axisEl = document.getElementById('patternLineAxis');
6588        gl.uniform1i(glUniformLocations.patternLineMode, axisEl && axisEl.value === '2' ? 1 : 0);
6589      }
6590      if (glUniformLocations.patternColor !== null) {
6591        const colorEl = document.getElementById('patternColorPicker');
6592        let r = 1, g = 1, b = 1;
6593        if (colorEl && colorEl.value) {
6594          const hex = colorEl.value.replace(/^#/, '');
6595          if (hex.length >= 6) {
6596            r = parseInt(hex.slice(0, 2), 16) / 255;
6597            g = parseInt(hex.slice(2, 4), 16) / 255;
6598            b = parseInt(hex.slice(4, 6), 16) / 255;
6599          }
6600        }
6601        gl.uniform3f(glUniformLocations.patternColor, r, g, b);
6602      }
6603      if (glUniformLocations.patternOpacity !== null) {
6604        const opacityEl = document.getElementById('patternOpacityValue') || document.getElementById('patternOpacity');
6605        const opacity = opacityEl ? parseFloat(opacityEl.value) / 100.0 : 1.0;
6606        gl.uniform1f(glUniformLocations.patternOpacity, Math.max(0.0, Math.min(1.0, opacity)));
6607      }
6608      if (glUniformLocations.patternBlendMode !== null) {
6609        const blendEl = document.getElementById('patternBlendMode');
6610        const v = blendEl ? blendEl.value : 'normal';
6611        const modeInt = v === 'normal' ? 0 : v === 'mul' ? 1 : v === 'screen' ? 2 : v === 'overlay' ? 3 : 4;
6612        gl.uniform1i(glUniformLocations.patternBlendMode, modeInt);
6613      }
6614
6615      for (const [key, filter] of [['betaCaOffset','chromatic'], ['betaFlowOffset','flow'], ['betaPaperOffset','paper']]) {
6616        if (glUniformLocations[key] != null) gl.uniform2fv(glUniformLocations[key], (filter === 'flow' ? animatedFlow?.offset : betaPreview?.[filter]?.offset) || [0, 0]);
6617      }
6618      if (glUniformLocations.betaCaRotation != null) gl.uniform1f(glUniformLocations.betaCaRotation, betaPreview?.chromatic?.rotation || 0);
6619      // Beta: chromatic aberration (preset tuning + user intensity)
6620      const betaEnabledEl = document.getElementById('betaEnabled');
6621      const chromaticEnabledEl = document.getElementById('chromaticEnabled');
6622      const chromaticOn = !!betaPreview?.chromatic?.enabled || !!(betaEnabledEl && betaEnabledEl.checked && chromaticEnabledEl && chromaticEnabledEl.checked);
6623      const CA_PRESET = {
6624        mode: 2,
6625        strength: 0.008,
6626        noiseScale: 259,
6627        fbmOctaves: 4,
6628        irregularity: 1.0,
6629        rbBalance: 0.78,
6630        edgeMask: 1.0,
6631        chromaNoise: 0.0648,
6632        chromaNoiseScale: 0.01,
6633        seed: 42,
6634        ...betaPreview?.chromatic
6635      };
6636      if (glUniformLocations.chromaticEnabled !== null) {
6637        gl.uniform1i(glUniformLocations.chromaticEnabled, chromaticOn ? 1 : 0);
6638      }
6639      if (glUniformLocations.caMode !== null) {
6640        gl.uniform1i(glUniformLocations.caMode, chromaticOn ? CA_PRESET.mode : 1);
6641      }
6642      if (glUniformLocations.caStrength !== null) {
6643        gl.uniform1f(glUniformLocations.caStrength, chromaticOn ? CA_PRESET.strength : 0);
6644      }
6645      if (glUniformLocations.caOpacity !== null) {
6646        const el = document.getElementById('caIntensityValue') || document.getElementById('caIntensityInput');
6647        const uiVal = betaPreview?.chromatic?.intensity ?? (chromaticOn && el ? parseFloat(el.value) : 0);
6648        gl.uniform1f(glUniformLocations.caOpacity, Math.max(0.0, Math.min(100.0, uiVal)) / 100.0);
6649      }
6650      if (glUniformLocations.caNoiseScale !== null) {
6651        gl.uniform1f(glUniformLocations.caNoiseScale, CA_PRESET.noiseScale);
6652      }
6653      if (glUniformLocations.caEdgeMask !== null) {
6654        gl.uniform1f(glUniformLocations.caEdgeMask, CA_PRESET.edgeMask);
6655      }
6656      if (glUniformLocations.caSeed !== null) {
6657        gl.uniform1f(glUniformLocations.caSeed, CA_PRESET.seed);
6658      }
6659      if (glUniformLocations.caFbmOctaves !== null) {
6660        gl.uniform1i(glUniformLocations.caFbmOctaves, CA_PRESET.fbmOctaves);
6661      }
6662      if (glUniformLocations.caIrregularity !== null) {
6663        gl.uniform1f(glUniformLocations.caIrregularity, CA_PRESET.irregularity);
6664      }
6665      if (glUniformLocations.caRbBalance !== null) {
6666        gl.uniform1f(glUniformLocations.caRbBalance, CA_PRESET.rbBalance);
6667      }
6668      if (glUniformLocations.chromaNoiseStrength !== null) {
6669        const el = document.getElementById('caIntensityValue') || document.getElementById('caIntensityInput');
6670        const opacity = (betaPreview?.chromatic?.intensity ?? (chromaticOn && el ? Math.max(0, Math.min(100, parseFloat(el.value))) : 0)) / 100;
6671        gl.uniform1f(glUniformLocations.chromaNoiseStrength, chromaticOn ? CA_PRESET.chromaNoise * opacity : 0);
6672      }
6673      if (glUniformLocations.chromaNoiseScale !== null) {
6674        gl.uniform1f(glUniformLocations.chromaNoiseScale, CA_PRESET.chromaNoiseScale);
6675      }
6676
6677      // Beta: directional flow advection (smear)
6678      const flowEnabledEl = document.getElementById('flowEnabled');
6679      const flowOn = !!animatedFlow?.enabled || !!(betaEnabledEl && betaEnabledEl.checked && flowEnabledEl && flowEnabledEl.checked);
6680      const flowReadVal = (id, fallback) => {
6681        const key = id.slice(4, -5).replace(/^./, c => c.toLowerCase());
6682        if (animatedFlow && key in animatedFlow) return animatedFlow[key];
6683        const el = document.getElementById(id);
6684        const v = el ? parseFloat(el.value) : NaN;
6685        return Number.isNaN(v) ? fallback : v;
6686      };
6687      if (glUniformLocations.flowEnabled !== null) {
6688        gl.uniform1i(glUniformLocations.flowEnabled, flowOn ? 1 : 0);
6689      }
6690      if (glUniformLocations.flowOpacity !== null) {
6691        const uiVal = flowOn ? flowReadVal('flowIntensityValue', 100) : 0;
6692        gl.uniform1f(glUniformLocations.flowOpacity, Math.max(0.0, Math.min(100.0, uiVal)) / 100.0);
6693      }
6694      if (glUniformLocations.flowLength !== null) {
6695        const uiVal = Math.max(0.0, Math.min(100.0, flowReadVal('flowLengthValue', 100)));
6696        gl.uniform1f(glUniformLocations.flowLength, uiVal * 0.002);
6697      }
6698      if (glUniformLocations.flowSteps !== null) {
6699        const uiVal = Math.round(Math.max(2.0, Math.min(50.0, flowReadVal('flowStepsValue', 50))));
6700        gl.uniform1i(glUniformLocations.flowSteps, uiVal);
6701      }
6702      if (glUniformLocations.flowDecay !== null) {
6703        gl.uniform1f(glUniformLocations.flowDecay, Math.max(0.0, Math.min(6.0, flowReadVal('flowDecayValue', 0))));
6704      }
6705      if (glUniformLocations.flowScale !== null) {
6706        gl.uniform1f(glUniformLocations.flowScale, Math.max(0.5, Math.min(12.0, flowReadVal('flowScaleValue', 0.5))));
6707      }
6708      if (glUniformLocations.flowOctaves !== null) {
6709        const uiVal = Math.round(Math.max(1.0, Math.min(6.0, flowReadVal('flowDetailValue', 4))));
6710        gl.uniform1i(glUniformLocations.flowOctaves, uiVal);
6711      }
6712      if (glUniformLocations.flowAngle !== null) {
6713        const deg = Math.max(0.0, Math.min(360.0, flowReadVal('flowDirectionValue', 180)));
6714        gl.uniform1f(glUniformLocations.flowAngle, deg * Math.PI / 180.0);
6715      }
6716      if (glUniformLocations.flowIrregularity !== null) {
6717        const uiVal = Math.max(0.0, Math.min(100.0, flowReadVal('flowIrregularityValue', 100)));
6718        gl.uniform1f(glUniformLocations.flowIrregularity, uiVal / 100.0);
6719      }
6720      if (glUniformLocations.flowSeed !== null) {
6721        gl.uniform1f(glUniformLocations.flowSeed, flowReadVal('flowSeedValue', 42));
6722      }
6723
6724      // Beta: paper texture surface overlay
6725      const paperEnabledEl = document.getElementById('paperEnabled');
6726      const paperOn = !!betaPreview?.paper?.enabled || !!(betaEnabledEl && betaEnabledEl.checked && paperEnabledEl && paperEnabledEl.checked);
6727      const paperReadVal = (id, fallback) => {
6728        const key = id.slice(5, -5).replace(/^./, c => c.toLowerCase());
6729        if (betaPreview?.paper && key in betaPreview.paper) return betaPreview.paper[key];
6730        const el = document.getElementById(id);
6731        const v = el ? parseFloat(el.value) : NaN;
6732        return Number.isNaN(v) ? fallback : v;
6733      };
6734      if (glUniformLocations.paperEnabled !== null) {
6735        gl.uniform1i(glUniformLocations.paperEnabled, paperOn ? 1 : 0);
6736      }
6737      if (glUniformLocations.paperOpacity !== null) {
6738        const uiVal = paperOn ? paperReadVal('paperIntensityValue', 28) : 0;
6739        gl.uniform1f(glUniformLocations.paperOpacity, Math.max(0.0, Math.min(100.0, uiVal)) / 100.0);
6740      }
6741      if (glUniformLocations.paperGrain !== null) {
6742        gl.uniform1f(glUniformLocations.paperGrain, Math.max(0.0, Math.min(100.0, paperReadVal('paperGrainValue', 28))) / 100.0);
6743      }
6744      if (glUniformLocations.paperFiber !== null) {
6745        gl.uniform1f(glUniformLocations.paperFiber, Math.max(0.0, Math.min(100.0, paperReadVal('paperFiberValue', 52))) / 100.0);
6746      }
6747      if (glUniformLocations.paperFold !== null) {
6748        gl.uniform1f(glUniformLocations.paperFold, Math.max(0.0, Math.min(100.0, paperReadVal('paperFoldValue', 17))) / 100.0);
6749      }
6750      if (glUniformLocations.paperRough !== null) {
6751        gl.uniform1f(glUniformLocations.paperRough, Math.max(0.0, Math.min(100.0, paperReadVal('paperRoughValue', 46))) / 100.0);
6752      }
6753      if (glUniformLocations.paperSpeck !== null) {
6754        gl.uniform1f(glUniformLocations.paperSpeck, Math.max(0.0, Math.min(100.0, paperReadVal('paperSpeckValue', 0))) / 100.0);
6755      }
6756      if (glUniformLocations.paperDisplace !== null) {
6757        gl.uniform1f(glUniformLocations.paperDisplace, Math.max(0.0, Math.min(100.0, paperReadVal('paperDisplaceValue', 2))) / 100.0);
6758      }
6759      if (glUniformLocations.paperScale !== null) {
6760        gl.uniform1f(glUniformLocations.paperScale, Math.max(0.2, Math.min(4.0, paperReadVal('paperScaleValue', 3.8))));
6761      }
6762      if (glUniformLocations.paperGrainScale !== null) {
6763        gl.uniform1f(glUniformLocations.paperGrainScale, Math.max(0.05, Math.min(2.0, paperReadVal('paperGrainScaleValue', 2))));
6764      }
6765      if (glUniformLocations.paperFiberScale !== null) {
6766        gl.uniform1f(glUniformLocations.paperFiberScale, Math.max(20.0, Math.min(800.0, paperReadVal('paperFiberScaleValue', 775))));
6767      }
6768      if (glUniformLocations.paperFoldScale !== null) {
6769        gl.uniform1f(glUniformLocations.paperFoldScale, Math.max(0.5, Math.min(16.0, paperReadVal('paperFoldScaleValue', 2))));
6770      }
6771      if (glUniformLocations.paperRoughScale !== null) {
6772        gl.uniform1f(glUniformLocations.paperRoughScale, Math.max(10.0, Math.min(600.0, paperReadVal('paperRoughScaleValue', 330))));
6773      }
6774      if (glUniformLocations.paperRefRes !== null) {
6775        const paperAspect = h > 0 ? (w / h) : 1.0;
6776        const PAPER_REF_HEIGHT = 520.0;
6777        gl.uniform2f(glUniformLocations.paperRefRes, PAPER_REF_HEIGHT * paperAspect, PAPER_REF_HEIGHT);
6778      }
6779      if (glUniformLocations.paperSeed !== null) {
6780        gl.uniform1f(glUniformLocations.paperSeed, paperReadVal('paperSeedValue', 12));
6781      }
6782
6783      entry.cache.cachedResolution=cachedResolution;
6784      entry.cache.cachedNoiseAmount=cachedNoiseAmount;
6785      entry.cache.cachedFeather=cachedFeather;
6786      entry.cache.cachedBlendMode=cachedBlendMode;
6787      entry.cache.gradientNoiseUniformsCached=gradientNoiseUniformsCached;
6788      entry.cache.cachedStops=cachedStops;
6789      entry.cache.cachedPalette=cachedPalette;
6790      if(animationFrame?.uniforms)for(const [key,value]of Object.entries(animationFrame.uniforms)){if(Number.isFinite(value)&&glUniformLocations[key]!=null&&key.startsWith('procDisp'))gl.uniform1f(glUniformLocations[key],value);}
6791      }
6792      // Render
6793      if (enableProfiling) {
6794        const setupTime = performance.now();
6795        profile.steps.uniformSetup = setupTime - stepStart;
6796        stepStart = setupTime;
6797      }
6798      
6799      gl.clearColor(0, 0, 0, 1);
6800
6801      const drawStart = enableProfiling ? performance.now() : 0;
6802
6803      const betaOn=!!document.getElementById('betaEnabled')?.checked;
6804      const patternsOnDraw=!!document.getElementById('patternsEnabled')?.checked;
6805      const flowOnDraw=!!animatedFlow?.enabled || betaOn&&!!document.getElementById('flowEnabled')?.checked;
6806      const caOnDraw=!!betaPreview?.chromatic?.enabled || betaOn&&!!document.getElementById('chromaticEnabled')?.checked;
6807      const paperOnDraw=!!betaPreview?.paper?.enabled || betaOn&&!!document.getElementById('paperEnabled')?.checked;
6808      const anyPost = flowOnDraw || caOnDraw || patternsOnDraw || paperOnDraw;
6809      const needComposite = flowOnDraw || patternsOnDraw;
6810
6811      // Draw a single full-screen pass to the given target (null = on-screen canvas).
6812      const drawPass = (passVal, targetFBO, srcTex, suppressSensorGrain = false) => {
6813        const type=selectedDistortion(),evolving=!!(animationFrame&&Math.abs(animationFrame.phase||0)>0);
6814        applyProgram(programCache.select(passVal,type,evolving));
6815        if (suppressSensorGrain && glUniformLocations.chromaNoiseStrength !== null) gl.uniform1f(glUniformLocations.chromaNoiseStrength, 0);
6816        gl.bindFramebuffer(gl.FRAMEBUFFER, targetFBO);
6817        gl.viewport(0, 0, w, h);
6818        if (srcTex) {
6819          gl.activeTexture(gl.TEXTURE4);
6820          gl.bindTexture(gl.TEXTURE_2D, srcTex);
6821          if (glUniformLocations.srcTex !== null) gl.uniform1i(glUniformLocations.srcTex, 4);
6822        }
6823        if (glUniformLocations.pass !== null) gl.uniform1i(glUniformLocations.pass, passVal);
6824        if (targetFBO === null) {
6825          gl.enable(gl.SCISSOR_TEST);
6826          gl.scissor(0, 0, w, h);
6827        } else {
6828          gl.disable(gl.SCISSOR_TEST);
6829        }
6830        gl.clear(gl.COLOR_BUFFER_BIT);
6831        ColirGraphicsRuntime.once('gradient', 'first draw submitted', {width:w,height:h});
6832        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
6833        if (!gl.isContextLost()) ColirGraphicsRuntime.once('gradient', 'first draw returned');
6834        if (targetFBO === null) gl.disable(gl.SCISSOR_TEST);
6835      };
6836
6837      const settings = ColirSurfaceFilters.snapshot();
6838      if (!betaOn) { settings.silk.enabled = false; settings.bloom.enabled = false; }
6839      if (betaPreview?.silk) settings.silk = betaPreview.silk;
6840      if (betaPreview?.bloom) settings.bloom = betaPreview.bloom;
6841      const surfaceActive = (settings.silk.enabled && settings.silk.amount > 0) || (settings.bloom.enabled && settings.bloom.amount > 0);
6842      const key = `${animationFrame && Math.abs(animationFrame.phase || 0) > 0 && selectedDistortion() >= 0 ? 'animated' : 'static'}-${selectedDistortion()}`;
6843      if (surfaceActive || betaPreview?.silk || betaPreview?.bloom) {
6844        surfaceRenderer ||= ColirSurfaceRenderer.create(gl);
6845        const requiredFields = betaPreview?.silk && selectedDistortion() >= 0
6846          ? [`static-${selectedDistortion()}`, `animated-${selectedDistortion()}`] : [key];
6847        const preparationKey = requiredFields.join(',');
6848        if (settings.silk.enabled && (settings.silk.amount > 0 || betaPreview?.silk) && requiredFields.some(field => !surfaceRenderer.fieldReady(field))) {
6849          const owner = surfaceRenderer;
6850          if (surfacePreparation?.owner !== owner || surfacePreparation.key !== preparationKey) {
6851            surfacePreparation = {owner, key: preparationKey};
6852            if (betaPreview?.silk) ColirAnimation.betaStatus('silk', 'Preparing Silk…');
6853            else ColirSurfaceFilters.status('Preparing Silk…');
6854            Promise.all(requiredFields.map(field => owner.prepareField(field))).then(() => {
6855              if (surfaceRenderer !== owner) return;
6856              surfacePreparation = null; ColirSurfaceFilters.status(''); window.ColirAnimation?.betaStatus?.('silk', ''); renderGradient();
6857            }).catch(error => {
6858              if (surfaceRenderer === owner) {
6859                if (betaPreview?.silk) { ColirAnimation.betaStatus('silk', 'Silk could not prepare. Toggle it off and on to retry.', true); renderGradient(); }
6860                else ColirSurfaceFilters.status('Silk could not prepare. Toggle it off and on to retry.');
6861              }
6862            });
6863          }
6864          if (skipReadPixels) return false; // Preview keeps its last complete frame during preparation.
6865          throw new Error('Silk is preparing. Please retry shortly.');
6866        }
6867      }
6868      if (surfaceActive) {
6869        try { surfaceRenderer.render(w, h, settings, {composite: needComposite, chromatic: caOnDraw, paper: paperOnDraw}, drawPass,
6870          () => programCache.select(0, selectedDistortion(), key.startsWith('animated')).program, key);
6871        } catch (error) {
6872          if (!betaPreview?.silk && !betaPreview?.bloom) throw error;
6873          for (const name of ['silk', 'bloom']) if (betaPreview?.[name]) window.ColirAnimation.betaStatus(name, 'Could not prepare preview. Toggle off and on to retry. ' + error.message, true);
6874          return renderGradientWebGLAtSize(w, h, skipReadPixels, window.ColirAnimation.currentFrame());
6875        }
6876        window.ColirSurfaceFilterStats = surfaceRenderer.stats;
6877      } else {
6878        if (surfaceRenderer && !betaPreview?.silk && !betaPreview?.bloom) { surfaceRenderer.dispose(); surfaceRenderer = null; surfacePreparation = null; ColirSurfaceFilters.status(''); }
6879        window.ColirSurfaceFilterStats = surfaceRenderer?.stats || null;
6880      if (!anyPost) {
6881        // Fast path: render the full scene straight to the screen (unchanged behavior).
6882        drawPass(0, null, null);
6883      } else {
6884        // When paper is on it is the very last pass, so the pre-paper result is
6885        // routed into fboC instead of straight to the screen.
6886        const preTarget = paperOnDraw ? fboC : null;
6887        if (!needComposite && !caOnDraw) {
6888          // Paper is the only active post effect: render full scene then texture it.
6889          drawPass(0, preTarget, null);
6890        } else {
6891          // Pass 0: scene (or smearable base when flow is on) -> texA
6892          drawPass(0, fboA, null);
6893          // Pass 1 (composite): flow smear + noise + glitter + patterns
6894          if (needComposite) {
6895            drawPass(1, caOnDraw ? fboB : preTarget, fboTexA);
6896          }
6897          // Pass 2 (chromatic aberration): final lens step
6898          if (caOnDraw) {
6899            const caSrc = needComposite ? fboTexB : fboTexA;
6900            drawPass(2, preTarget, caSrc);
6901          }
6902        }
6903        // Pass 3 (paper texture): surface overlay -> screen
6904        if (paperOnDraw) {
6905          drawPass(3, null, fboTexC);
6906        }
6907      }
6908
6909      } // End opt-in surface filter path.
6910
6911      // Ensure subsequent readPixels/drawImage target the on-screen canvas.
6912      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
6913      if (enableProfiling) {
6914        // Note: WebGL draw is async, so we measure the call time, not actual GPU time
6915        profile.steps.webglDraw = performance.now() - drawStart;
6916        stepStart = performance.now();
6917      }
6918
6919      // If skipReadPixels is true, we'll use drawImage instead (much faster)
6920      requireGraphics(w, h);
6921      if (skipReadPixels) {
6922        return null; // Signal to use drawImage
6923      }
6924
6925      // Only read pixels if we need to (for ImageData)
6926      if (!skipReadPixels) {
6927        // Read pixels from WebGL (readPixels always reads bottom-to-top)
6928        const readStart = enableProfiling ? performance.now() : 0;
6929        const pixels = new Uint8Array(w * h * 4);
6930        gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
6931        if (enableProfiling) {
6932          profile.steps.readPixels = performance.now() - readStart;
6933          stepStart = performance.now();
6934        }
6935
6936        // The preview flips glCanvas when drawing it to gradientCanvas in both modes.
6937        // readPixels' bottom-to-top rows already match that displayed orientation.
6938        const img = gctx.createImageData(w, h);
6939        const imgData = img.data;
6940        for (let y = 0; y < h; y++) {
6941          const srcRow = y * w * 4;
6942          const dstRow = y * w * 4;
6943          // Direct copy is faster than subarray for large images
6944          for (let i = 0; i < w * 4; i++) {
6945            imgData[dstRow + i] = pixels[srcRow + i];
6946          }
6947        }
6948        if (enableProfiling) {
6949          profile.steps.imageDataCopy = performance.now() - stepStart;
6950          stepStart = performance.now();
6951        }
6952
6953
6954        if (enableProfiling) {
6955          profile.total = performance.now() - startTime;
6956          console.log('Render Profile:', profile);
6957        }
6958
6959        return img; // Return ImageData instead of drawing
6960      }
6961      
6962      if (enableProfiling) {
6963        profile.total = performance.now() - startTime;
6964        console.log('Render Profile:', profile);
6965      }
6966      
6967      return null;
6968    }
6969
6970
6971    function renderGradientWebGL(animationFrame = null) {
6972      if (!window.ColirRenderPrograms.designReady || !gl || gl.isContextLost()) return;
6973      if(animationFrame && !window.ColirRenderPrograms.animationReady)animationFrame=null;
6974      
6975      const w = gradientCanvas.width;
6976      const h = gradientCanvas.height;
6977      
6978      // Use fast path: render to WebGL canvas and copy with drawImage
6979      // This avoids the expensive readPixels + putImageData path
6980      const result = renderGradientWebGLAtSize(w, h, true, animationFrame);
6980 // skipReadPixels = true
6981      if (result === false) return; // A new filter is preparing; retain the displayed preview.
6982      if (result === null) {
6983        // Successfully rendered to WebGL canvas, now copy to 2D canvas using drawImage
6984        // Clear canvas first to prevent any overflow artifacts
6985        gctx.clearRect(0, 0, w, h);
6986        // Disable smoothing for pixel-perfect rendering
6987        gctx.imageSmoothingEnabled = false;
6988        // Clip to exact canvas bounds to prevent any overflow
6989        gctx.save();
6990        gctx.beginPath();
6991        gctx.rect(0, 0, w, h);
6992        gctx.clip();
6993        // Flip Y by scaling and translating (WebGL has Y=0 at bottom, canvas has Y=0 at top)
6994        gctx.scale(1, -1);
6995        gctx.translate(0, -Math.round(h));
6996        // Explicitly specify dimensions to ensure pixel-perfect rendering
6997        gctx.drawImage(glCanvas, 0, 0, Math.round(w), Math.round(h));
6998        gctx.restore();
6999      } else {
7000        throw new Error('Graphics rendering did not complete. Retry graphics.');
7001      }
7002    }
7003
7004    function renderGradientCPU() {
7005      const w = gradientCanvas.width;
7006      const h = gradientCanvas.height;
7007      const xSamples = xEditor.evaluateSamples(w);
7008      const ySamples = yEditor.evaluateSamples(h);
7009
7010      const img = gctx.createImageData(w, h);
7011      const noiseAmt = parseInt(noiseInput.value) / 100;
7012      const feather = parseInt(featherInput.value) / 100;
7013      for (let y = 0; y < h; y++) {
7014        for (let x = 0; x < w; x++) {
7015          const idx = (y * w + x) * 4;
7016          let vx = xSamples[x];
7017          let vy = ySamples[y];
7018          if (feather > 0) {
7019            const fx = Math.sin((x / w) * Math.PI) ** feather;
7020            const fy = Math.sin((y / h) * Math.PI) ** feather;
7021            vx = lerp(vx, fx, 0.2);
7022            vy = lerp(vy, fy, 0.2);
7023          }
7024          const mixVal = blend(vx, vy, blendMode.value);
7025          let v = mixVal;
7026          if (noiseAmt > 0) {
7027            const n01 = sampleHybridNoise01(x, y, vx, vy, GRADIENT_NOISE_SCALE_FIXED, GRADIENT_NOISE_MIX);
7028            const n = (n01 - 0.5) * noiseAmt;
7029            v = Math.min(1, Math.max(0, mixVal + n));
7030          }
7031
7032          const col = getColorFromGradient(v);
7033
7034          img.data[idx + 0] = col.r;
7035          img.data[idx + 1] = col.g;
7036          img.data[idx + 2] = col.b;
7037          img.data[idx + 3] = 255;
7038        }
7039      }
7040      
7041      // Clear and clip to exact bounds before drawing
7042      gctx.clearRect(0, 0, w, h);
7043      gctx.save();
7044      gctx.beginPath();
7045      gctx.rect(0, 0, w, h);
7046      gctx.clip();
7047      gctx.putImageData(img, 0, 0);
7048      gctx.restore();
7049    }
7050    const widthInput = document.getElementById('widthInput');
7051    const heightInput = document.getElementById('heightInput');
7052    const blendMode = document.getElementById('blendMode');
7053    const noiseInput = document.getElementById('noiseInput');
7054    const noiseValue = document.getElementById('noiseValue');
7055    const featherInput = document.getElementById('featherInput');
7056    const featherValue = document.getElementById('featherValue');
7057    
7058    // Range slider wrap: gradient fill + 20 lines, active state (100ms in / 450ms out)
7059    function initRangeSliderWraps(root = document) {
7060      function setRangePercent(wrap, input) {
7061        const min = parseFloat(input.min) || 0;
7062        const max = parseFloat(input.max) || 100;
7063        const val = parseFloat(input.value) || min;
7064        const pct = max === min ? 0 : ((val - min) / (max - min)) * 100;
7065        wrap.style.setProperty('--range-percent', String(pct));
7066      }
7067      function onPointerDown(e) {
7068        const wrap = e.currentTarget.closest('.range-slider-wrap');
7069        if (wrap) wrap.classList.add('active');
7070      }
7071      function onPointerUp() {
7072        document.querySelectorAll('.range-slider-wrap.active').forEach(w => w.classList.remove('active'));
7073      }
7074      root.querySelectorAll('.slider-with-input').forEach(container => {
7075        const input = container.querySelector('input[type="range"]');
7076        if (!input || input.closest('.range-slider-wrap')) return;
7077        const wrap = document.createElement('div');
7078        wrap.className = 'range-slider-wrap';
7079        if (input.style.cssText) {
7080          wrap.style.cssText = input.style.cssText;
7081          input.removeAttribute('style');
7082        }
7083        const trackBg = document.createElement('div');
7084        trackBg.className = 'range-slider-track-bg';
7085        trackBg.setAttribute('aria-hidden', 'true');
7086        const fill = document.createElement('div');
7087        fill.className = 'range-slider-fill';
7088        fill.setAttribute('aria-hidden', 'true');
7089        container.insertBefore(wrap, input);
7090        wrap.appendChild(trackBg);
7091        wrap.appendChild(fill);
7092        wrap.appendChild(input);
7093        setRangePercent(wrap, input);
7094        input.addEventListener('input', () => setRangePercent(wrap, input));
7095        input.addEventListener('change', () => setRangePercent(wrap, input));
7096        input.addEventListener('pointerdown', onPointerDown);
7097        const numInput = container.querySelector('input[type="number"]');
7098        if (numInput) {
7099          numInput.addEventListener('input', () => setRangePercent(wrap, input));
7100          numInput.addEventListener('change', () => setRangePercent(wrap, input));
7101        }
7102      });
7103      window.addEventListener('pointerup', onPointerUp);
7104      window.addEventListener('pointercancel', onPointerUp);
7105    }
7106    initRangeSliderWraps();
7107    
7108    // Blend mode dropdown with arrow buttons
7109    const blendModePrev = document.getElementById('blendModePrev');
7110    const blendModeNext = document.getElementById('blendModeNext');
7111    const blendModeIcon = document.getElementById('blendModeIcon');
7112    const blendModes = ['avg', 'mul', 'screen', 'overlay', 'softlight', 'hardlight', 'difference', 'exclusion', 'darken', 'lighten', 'colorburn', 'colordodge'];
7113    const blendModeIcons = {
7114      'normal': 'layers',
7115      'avg': 'blur_on',
7116      'mul': 'close',
7117      'screen': 'add',
7118      'overlay': 'layers',
7119      'softlight': 'wb_sunny',
7120      'hardlight': 'brightness_high',
7121      'difference': 'compare',
7122      'exclusion': 'remove_circle',
7123      'darken': 'dark_mode',
7124      'lighten': 'light_mode',
7125      'colorburn': 'whatshot',
7126      'colordodge': 'flare'
7127    };
7128
7129    function syncBlendModeIcon(select, iconEl) {
7130      if (!iconEl) return;
7131      ColirIcons.set(iconEl, blendModeIcons[select && select.value] || 'layers');
7132    }
7133    
7134    function updateBlendModeIcon(mode) {
7135      if (blendModeIcon) ColirIcons.set(blendModeIcon, blendModeIcons[mode] || 'layers');
7136    }
7137    
7138    function updateBlendMode(mode) {
7139        saveHistory(); // Save state before changing blend mode
7140        blendMode.value = mode;
7141      updateBlendModeIcon(mode);
7142        renderGradient();
7143    }
7144    
7145    blendMode.addEventListener('change', () => {
7146      updateBlendMode(blendMode.value);
7147    });
7148    
7149    blendModePrev.addEventListener('click', () => {
7150      const currentIndex = blendModes.indexOf(blendMode.value);
7151      const prevIndex = currentIndex > 0 ? currentIndex - 1 : blendModes.length - 1;
7152      updateBlendMode(blendModes[prevIndex]);
7153    });
7154    
7155    blendModeNext.addEventListener('click', () => {
7156      const currentIndex = blendModes.indexOf(blendMode.value);
7157      const nextIndex = currentIndex < blendModes.length - 1 ? currentIndex + 1 : 0;
7158      updateBlendMode(blendModes[nextIndex]);
7159    });
7160    
7161    // Initialize icon on load
7162    updateBlendModeIcon(blendMode.value);
7163
7164    // Noise slider and input sync
7165    noiseInput.addEventListener('input', () => {
7166      noiseValue.value = noiseInput.value;
7167      renderGradient();
7168    });
7169    
7170    noiseValue.addEventListener('input', () => {
7171      // Allow user to type freely, only sync slider if value is valid
7172      const numVal = parseInt(noiseValue.value);
7173      if (!isNaN(numVal)) {
7174        const val = Math.min(30, Math.max(0, numVal));
7175        noiseInput.value = val;
7176        renderGradient();
7177      }
7178    });
7179    
7180    noiseValue.addEventListener('blur', () => {
7181      // Validate and clamp on blur
7182      const val = Math.min(30, Math.max(0, parseInt(noiseValue.value) || 0));
7183      noiseValue.value = val;
7184      noiseInput.value = val;
7185      renderGradient();
7186    });
7187    
7188    noiseValue.addEventListener('keydown', (e) => {
7189      if (e.key === 'Enter') {
7190        e.preventDefault();
7191        noiseValue.blur();
7192      }
7193    });
7194
7195    // Feather slider and input sync
7196    featherInput.addEventListener('input', () => {
7197      featherValue.value = featherInput.value;
7198      renderGradient();
7199    });
7200    
7201    featherValue.addEventListener('input', () => {
7202      // Allow user to type freely, only sync slider if value is valid
7203      const numVal = parseInt(featherValue.value);
7204      if (!isNaN(numVal)) {
7205        const val = Math.min(40, Math.max(1, numVal));
7206        featherInput.value = val;
7207        renderGradient();
7208      }
7209    });
7210    
7211    featherValue.addEventListener('blur', () => {
7212      // Validate and clamp on blur
7213      const val = Math.min(40, Math.max(1, parseInt(featherValue.value) || 1));
7214      featherValue.value = val;
7215      featherInput.value = val;
7216      renderGradient();
7217    });
7218    
7219    featherValue.addEventListener('keydown', (e) => {
7220      if (e.key === 'Enter') {
7221        e.preventDefault();
7222        featherValue.blur();
7223      }
7224    });
7225
7226    // Collapsible panels - controlled by toggles
7227    const glitterPanel = document.getElementById('glitterPanel');
7228    
7229    // Glitter toggle controls panel expansion
7230    const glitterEnabled = document.getElementById('glitterEnabled');
7231    function syncGlitterDisabledState() {
7232      if (!glitterEnabled || !glitterPanel) return;
7233      glitterPanel.classList.toggle('panel-disabled', !glitterEnabled.checked);
7234      glitterPanel.classList.toggle('collapsed', !glitterEnabled.checked);
7235    }
7236    if (glitterEnabled && glitterPanel) {
7237      syncGlitterDisabledState();
7238      glitterEnabled.addEventListener('change', () => {
7239        syncGlitterDisabledState();
7240        renderGradient();
7241      });
7242    }
7243    
7244    // Relief slider (inside Distortion panel; slider 0-50, input can be > 50)
7245    const materialStrengthInput = document.getElementById('materialStrengthInput');
7246    const materialStrengthValue = document.getElementById('materialStrength');
7247    if (materialStrengthInput && materialStrengthValue) {
7248      materialStrengthInput.addEventListener('input', () => {
7249        materialStrengthValue.value = materialStrengthInput.value;
7250        renderGradient();
7251      });
7252      materialStrengthValue.addEventListener('input', () => {
7253        const v = Math.max(0, parseInt(materialStrengthValue.value, 10) || 0);
7254        materialStrengthValue.value = v;
7255        materialStrengthInput.value = Math.min(50, v);
7256        renderGradient();
7257      });
7258      materialStrengthValue.addEventListener('blur', () => {
7259        const v = Math.max(0, parseInt(materialStrengthValue.value, 10) || 0);
7260        materialStrengthValue.value = v;
7261        materialStrengthInput.value = Math.min(50, v);
7262        renderGradient();
7263      });
7264    }
7265    
7266    // ============================================
7267    // Distortion Controls (Procedural)
7268    // ============================================
7269    const procDispEnabled = document.getElementById('distortionEnabled');
7270    const procDispPanel = document.getElementById('distortionPanel');
7271    const procDispType = document.getElementById('procDispType');
7272    const procDispPrev = document.getElementById('procDispPrev');
7273    const procDispNext = document.getElementById('procDispNext');
7274    const procDispIntensity = document.getElementById('procDispIntensity');
7275    const procDispIntensityValue = document.getElementById('procDispIntensityValue');
7276    const procDispScale = document.getElementById('procDispScale');
7277    const procDispScaleValue = document.getElementById('procDispScaleValue');
7278    const procDispOctaves = document.getElementById('procDispOctaves');
7279    const procDispOctavesValue = document.getElementById('procDispOctavesValue');
7280    const procDispLacunarity = document.getElementById('procDispLacunarity');
7281    const procDispLacunarityValue = document.getElementById('procDispLacunarityValue');
7282    const procDispPersistence = document.getElementById('procDispPersistence');
7283    const procDispPersistenceValue = document.getElementById('procDispPersistenceValue');
7284    const procDispWaveFreq = document.getElementById('procDispWaveFreq');
7285    const procDispWaveFreqValue = document.getElementById('procDispWaveFreqValue');
7286    const procDispVoronoiJitter = document.getElementById('procDispVoronoiJitter');
7287    const procDispVoronoiJitterValue = document.getElementById('procDispVoronoiJitterValue');
7288    const procDispWarpStrength = document.getElementById('procDispWarpStrength');
7289    const procDispWarpStrengthValue = document.getElementById('procDispWarpStrengthValue');
7290    const procDispMix = document.getElementById('procDispMix');
7291    const procDispMixValue = document.getElementById('procDispMixValue');
7292    
7293    // Rows for conditional display
7294    const procDispOctavesRow = document.getElementById('procDispOctavesRow');
7295    const procDispLacunarityRow = document.getElementById('procDispLacunarityRow');
7296    const procDispPersistenceRow = document.getElementById('procDispPersistenceRow');
7297    const procDispWaveFreqRow = document.getElementById('procDispWaveFreqRow');
7298    const procDispVoronoiJitterRow = document.getElementById('procDispVoronoiJitterRow');
7299    const procDispWarpStrengthRow = document.getElementById('procDispWarpStrengthRow');
7300    const procDispMixRow = document.getElementById('procDispMixRow');
7301    
7302    const procDispTypes = ['organic', 'ridged', 'waves', 'kura', 'tayu'];
7303    
7304    // Function to show/hide type-specific controls
7305    function updateProcDispControls() {
7306      const type = procDispType ? procDispType.value : 'organic';
7307      const showOctaves = ['organic', 'ridged', 'kura', 'tayu'].includes(type);
7308      const showLacunarity = ['organic', 'ridged', 'kura', 'tayu'].includes(type);
7309      const showPersistence = ['organic', 'ridged', 'kura', 'tayu'].includes(type);
7310      const showWaveFreq = type === 'waves';
7311      const showVoronoiJitter = type === 'tayu';
7312      const showWarpStrength = type === 'organic';
7313      const showMix = type === 'kura';
7314      const showTayuMix = type === 'tayu';
7315      
7316      if (procDispOctavesRow) procDispOctavesRow.style.display = showOctaves ? 'flex' : 'none';
7317      if (procDispLacunarityRow) procDispLacunarityRow.style.display = showLacunarity ? 'flex' : 'none';
7318      if (procDispPersistenceRow) procDispPersistenceRow.style.display = showPersistence ? 'block' : 'none';
7319      if (procDispWaveFreqRow) procDispWaveFreqRow.style.display = showWaveFreq ? 'block' : 'none';
7320      if (procDispVoronoiJitterRow) procDispVoronoiJitterRow.style.display = showVoronoiJitter ? 'block' : 'none';
7321      if (procDispWarpStrengthRow) procDispWarpStrengthRow.style.display = showWarpStrength ? 'block' : 'none';
7322      if (procDispMixRow) procDispMixRow.style.display = showMix ? 'block' : 'none';
7323      const procDispTayuMixRow = document.getElementById('procDispTayuMixRow');
7324      if (procDispTayuMixRow) procDispTayuMixRow.style.display = showTayuMix ? 'block' : 'none';
7325    }
7326    
7327    function syncDistortionPanelState() {
7328      if (!procDispEnabled || !procDispPanel) return;
7329      procDispPanel.classList.toggle('panel-disabled', !procDispEnabled.checked);
7330      procDispPanel.classList.toggle('collapsed', !procDispEnabled.checked);
7331    }
7332
7333    // Toggle panel expansion
7334    if (procDispEnabled && procDispPanel) {
7335      // Check if there's a share link in the URL hash - if so, skip initial collapse
7336      // because restoration will handle the panel state
7337      const hasShareLink = window.location.hash && window.location.hash.length > 1;
7338      
7339      // Only set initial collapsed state if not restoring from share link
7340      if (!hasShareLink) {
7341        syncDistortionPanelState();
7342      }
7343      
7344      procDispEnabled.addEventListener('change', () => {
7345        syncDistortionPanelState();
7346        renderGradient();
7347      });
7348    }
7349    
7350    // Type selector
7351    if (procDispType) {
7352      procDispType.addEventListener('change', () => {
7353        announceReadiness();
7354        updateProcDispControls();
7355        renderGradient();
7356      });
7357      updateProcDispControls(); // Initial state
7358    }
7359    
7360    // Prev/Next buttons for type
7361    if (procDispPrev && procDispType) {
7362      procDispPrev.addEventListener('click', () => {
7363        const currentIndex = procDispTypes.indexOf(procDispType.value);
7364        let prevIndex=currentIndex;do {prevIndex=(prevIndex+procDispTypes.length-1)%procDispTypes.length;} while(procDispType.options[prevIndex].disabled&&prevIndex!==currentIndex);
7365        procDispType.value = procDispTypes[prevIndex];
7366        announceReadiness();
7367        updateProcDispControls();
7368        renderGradient();
7369      });
7370    }
7371    
7372    if (procDispNext && procDispType) {
7373      procDispNext.addEventListener('click', () => {
7374        const currentIndex = procDispTypes.indexOf(procDispType.value);
7375        let nextIndex=currentIndex;do {nextIndex=(nextIndex+1)%procDispTypes.length;} while(procDispType.options[nextIndex].disabled&&nextIndex!==currentIndex);
7376        procDispType.value = procDispTypes[nextIndex];
7377        announceReadiness();
7378        updateProcDispControls();
7379        renderGradient();
7380      });
7381    }
7382    
7383    // Intensity slider
7384    if (procDispIntensity && procDispIntensityValue) {
7385      procDispIntensity.addEventListener('input', () => {
7386        procDispIntensityValue.value = procDispIntensity.value;
7387        renderGradient();
7388      });
7389      procDispIntensityValue.addEventListener('input', () => {
7390        const val = Math.max(1, Math.min(100, parseInt(procDispIntensityValue.value) || 2));
7391        // Clamp slider to its range (1-20), but allow number input to go up to 100
7392        const sliderVal = Math.max(1, Math.min(20, val));
7393        procDispIntensity.value = sliderVal;
7394        renderGradient();
7395      });
7396      procDispIntensityValue.addEventListener('blur', () => {
7397        const val = Math.max(1, Math.min(100, parseInt(procDispIntensityValue.value) || 2));
7398        procDispIntensityValue.value = val;
7399        // Clamp slider to its range (1-20), but allow number input to go up to 100
7400        const sliderVal = Math.max(1, Math.min(20, val));
7401        procDispIntensity.value = sliderVal;
7402        renderGradient();
7403      });
7404    }
7405    
7406    // Scale slider
7407    if (procDispScale && procDispScaleValue) {
7408      procDispScale.addEventListener('input', () => {
7409        procDispScaleValue.value = parseFloat(procDispScale.value).toFixed(1);
7410        renderGradient();
7411      });
7412      procDispScaleValue.addEventListener('input', () => {
7413        const val = Math.max(0.1, Math.min(20, parseFloat(procDispScaleValue.value) || 0.5));
7414        // Clamp slider to its range (0.5-3.0), but allow number input to go up to 20
7415        const sliderVal = Math.max(0.5, Math.min(3.0, val));
7416        procDispScale.value = sliderVal;
7417        renderGradient();
7418      });
7419      procDispScaleValue.addEventListener('blur', () => {
7420        const val = Math.max(0.1, Math.min(20, parseFloat(procDispScaleValue.value) || 0.5));
7421        procDispScaleValue.value = val.toFixed(1);
7422        // Clamp slider to its range (0.5-3.0), but allow number input to go up to 20
7423        const sliderVal = Math.max(0.5, Math.min(3.0, val));
7424        procDispScale.value = sliderVal;
7425        renderGradient();
7426      });
7427      procDispScaleValue.value = parseFloat(procDispScale.value).toFixed(1);
7428    }
7429    
7430    // Seed slider
7431    const procDispSeed = document.getElementById('procDispSeed');
7432    const procDispSeedValue = document.getElementById('procDispSeedValue');
7433    if (procDispSeed && procDispSeedValue) {
7434      procDispSeed.addEventListener('input', () => {
7435        procDispSeedValue.value = procDispSeed.value;
7436        renderGradient();
7437      });
7438      procDispSeedValue.addEventListener('input', () => {
7439        const val = Math.max(0, Math.min(10000, parseInt(procDispSeedValue.value) || 0));
7440        // Clamp slider to its range (0-1000), but allow number input to go up to 10000
7441        const sliderVal = Math.max(0, Math.min(1000, val));
7442        procDispSeed.value = sliderVal;
7443        renderGradient();
7444      });
7445      procDispSeedValue.addEventListener('blur', () => {
7446        const val = Math.max(0, Math.min(10000, parseInt(procDispSeedValue.value) || 0));
7447        procDispSeedValue.value = val;
7448        // Clamp slider to its range (0-1000), but allow number input to go up to 10000
7449        const sliderVal = Math.max(0, Math.min(1000, val));
7450        procDispSeed.value = sliderVal;
7451        renderGradient();
7452      });
7453      procDispSeedValue.value = procDispSeed.value;
7454    }
7455    
7456    // ============================================
7457    // Patterns panel
7458    // ============================================
7459    const patternsEnabled = document.getElementById('patternsEnabled');
7460    const patternsPanel = document.getElementById('patternsPanel');
7461    const patternType = document.getElementById('patternType');
7462    const patternTypePrev = document.getElementById('patternTypePrev');
7463    const patternTypeNext = document.getElementById('patternTypeNext');
7464    const patternDotRow = document.getElementById('patternDotRow');
7465    const patternLineRow = document.getElementById('patternLineRow');
7466    const patternTypes = ['dot', 'line'];
7467    
7468    function syncPatternsPanelState() {
7469      if (!patternsEnabled || !patternsPanel) return;
7470      patternsPanel.classList.toggle('panel-disabled', !patternsEnabled.checked);
7471      patternsPanel.classList.toggle('collapsed', !patternsEnabled.checked);
7472    }
7473    if (patternsEnabled && patternsPanel) {
7474      syncPatternsPanelState();
7475      patternsEnabled.addEventListener('change', () => { syncPatternsPanelState(); renderGradient(); });
7476    }
7477
7478    // Chevron buttons: collapse/expand a panel's controls without changing its
7479    // switch. The whole header row acts as a click target (except the switch).
7480    function wireCollapseChevron(btnId, panelEl) {
7481      const btn = document.getElementById(btnId);
7482      if (!btn || !panelEl) return;
7483      const toggle = () => panelEl.classList.toggle('collapsed');
7484      btn.addEventListener('click', (e) => {
7485        e.preventDefault();
7486        e.stopPropagation();
7487        toggle();
7488      });
7489      const head = btn.closest('h3');
7490      if (head) {
7491        head.style.cursor = 'pointer';
7492        head.addEventListener('click', (e) => {
7493          if (e.target.closest('.ios-switch')) return;
7494          toggle();
7495        });
7496      }
7497    }
7498    wireCollapseChevron('distortionCollapseBtn', procDispPanel);
7499    wireCollapseChevron('glitterCollapseBtn', glitterPanel);
7500    wireCollapseChevron('patternsCollapseBtn', patternsPanel);
7501    
7502    function updatePatternTypeVisibility() {
7503      const type = patternType ? patternType.value : 'dot';
7504      if (patternDotRow) patternDotRow.style.display = type === 'dot' ? 'flex' : 'none';
7505      if (patternLineRow) patternLineRow.style.display = type === 'line' ? 'flex' : 'none';
7506    }
7507    
7508    if (patternType) {
7509      patternType.addEventListener('change', () => { updatePatternTypeVisibility(); renderGradient(); });
7510      updatePatternTypeVisibility();
7511    }
7512    if (patternTypePrev && patternType) {
7513      patternTypePrev.addEventListener('click', () => {
7514        const i = patternTypes.indexOf(patternType.value);
7515        patternType.value = patternTypes[(i - 1 + 2) % 2];
7516        updatePatternTypeVisibility();
7517        renderGradient();
7518      });
7519    }
7520    if (patternTypeNext && patternType) {
7521      patternTypeNext.addEventListener('click', () => {
7522        const i = patternTypes.indexOf(patternType.value);
7523        patternType.value = patternTypes[(i + 1) % 2];
7524        updatePatternTypeVisibility();
7525        renderGradient();
7526      });
7527    }
7528    
7529    const patternDotSizeSlider = document.getElementById('patternDotSize');
7530    const patternDotSizeValue = document.getElementById('patternDotSizeValue');
7531    if (patternDotSizeSlider && patternDotSizeValue) {
7532      patternDotSizeSlider.addEventListener('input', () => { patternDotSizeValue.value = patternDotSizeSlider.value; renderGradient(); });
7533      patternDotSizeValue.addEventListener('input', () => {
7534        const v = Math.max(1, parseFloat(patternDotSizeValue.value) || 1);
7535        patternDotSizeValue.value = v;
7536        patternDotSizeSlider.value = Math.min(50, v);
7537        renderGradient();
7538      });
7539      patternDotSizeValue.addEventListener('blur', () => {
7540        const v = Math.max(1, parseFloat(patternDotSizeValue.value) || 1);
7541        patternDotSizeValue.value = v;
7542        patternDotSizeSlider.value = Math.min(50, v);
7543        renderGradient();
7544      });
7545    }
7546    ['patternDotDistance', 'patternLineThickness', 'patternLineDistance', 'patternLineAngle'].forEach(name => {
7547      const slider = document.getElementById(name);
7548      const valueEl = document.getElementById(name + 'Value');
7549      if (slider && valueEl) {
7550        slider.addEventListener('input', () => { valueEl.value = slider.value; renderGradient(); });
7551        valueEl.addEventListener('input', () => { valueEl.value = valueEl.value; slider.value = Math.min(parseFloat(slider.max) || 999, Math.max(parseFloat(slider.min) || 0, parseFloat(valueEl.value) || 0)); renderGradient(); });
7552        valueEl.addEventListener('blur', () => { const v = Math.max(parseFloat(slider.min) || 0, Math.min(parseFloat(slider.max) || 999, parseFloat(valueEl.value) || 0)); valueEl.value = v; slider.value = v; renderGradient(); });
7553      }
7554    });
7555    const patternOpacitySlider = document.getElementById('patternOpacity');
7556    const patternOpacityValue = document.getElementById('patternOpacityValue');
7557    if (patternOpacitySlider && patternOpacityValue) {
7558      patternOpacitySlider.addEventListener('input', () => { patternOpacityValue.value = patternOpacitySlider.value; renderGradient(); });
7559      patternOpacityValue.addEventListener('input', () => { const v = Math.max(0, Math.min(100, parseFloat(patternOpacityValue.value) || 100)); patternOpacityValue.value = v; patternOpacitySlider.value = v; renderGradient(); });
7560      patternOpacityValue.addEventListener('blur', () => { const v = Math.max(0, Math.min(100, parseFloat(patternOpacityValue.value) || 100)); patternOpacityValue.value = v; patternOpacitySlider.value = v; renderGradient(); });
7561    }
7562    
7563    const patternLineAxis = document.getElementById('patternLineAxis');
7564    const patternLineAxisPrev = document.getElementById('patternLineAxisPrev');
7565    const patternLineAxisNext = document.getElementById('patternLineAxisNext');
7566    const patternLineAxisIcon = document.getElementById('patternLineAxisIcon');
7567    const patternLineAxes = ['1', '2'];
7568    function updatePatternLineAxisIcon() {
7569      if (patternLineAxisIcon) ColirIcons.set(patternLineAxisIcon, patternLineAxis && patternLineAxis.value === '2' ? 'drag_handle' : 'remove');
7570    }
7571    if (patternLineAxis) patternLineAxis.addEventListener('change', () => { updatePatternLineAxisIcon(); renderGradient(); });
7572    if (patternLineAxisPrev && patternLineAxis) patternLineAxisPrev.addEventListener('click', () => {
7573      const i = patternLineAxes.indexOf(patternLineAxis.value);
7574      patternLineAxis.value = patternLineAxes[(i - 1 + patternLineAxes.length) % patternLineAxes.length];
7575      updatePatternLineAxisIcon();
7576      renderGradient();
7577    });
7578    if (patternLineAxisNext && patternLineAxis) patternLineAxisNext.addEventListener('click', () => {
7579      const i = patternLineAxes.indexOf(patternLineAxis.value);
7580      patternLineAxis.value = patternLineAxes[(i + 1) % patternLineAxes.length];
7581      updatePatternLineAxisIcon();
7582      renderGradient();
7583    });
7584    updatePatternLineAxisIcon();
7585    
7586    const patternColorSwatch = document.getElementById('patternColorSwatch');
7587    const patternColorPicker = document.getElementById('patternColorPicker');
7588    const patternColorRow = document.getElementById('patternColorRow');
7589    const patternColorDropdown = document.getElementById('patternColorDropdown');
7590    const patternColorDropdownClose = document.getElementById('patternColorDropdownClose');
7591    const patternColorHexInput = document.getElementById('patternColorHexInput');
7592    const patternSpectrumCanvas = document.getElementById('patternSpectrumCanvas');
7593    const patternSpectrumMarker = document.getElementById('patternSpectrumMarker');
7594    const patternHueCanvas = document.getElementById('patternHueCanvas');
7595    const patternHueMarker = document.getElementById('patternHueMarker');
7596
7597    let patternHue = 0, patternSat = 0, patternLight = 100;
7598    let patternPickerInteracting = false;
7599
7600    function drawPatternHueSlider() {
7601      if (!patternHueCanvas) return;
7602      const ctx = patternHueCanvas.getContext('2d');
7603      const rect = patternHueCanvas.getBoundingClientRect();
7604      patternHueCanvas.width = rect.width;
7605      patternHueCanvas.height = rect.height;
7606      const w = patternHueCanvas.width, h = patternHueCanvas.height;
7607      const gradient = ctx.createLinearGradient(0, 0, 0, h);
7608      for (let i = 0; i <= 360; i += 30) {
7609        const rgb = hslToRgb(i, 100, 50);
7610        gradient.addColorStop(i / 360, `rgb(${rgb.r},${rgb.g},${rgb.b})`);
7611      }
7612      ctx.fillStyle = gradient;
7613      ctx.fillRect(0, 0, w, h);
7614    }
7615
7616    function drawPatternSpectrum() {
7617      if (!patternSpectrumCanvas) return;
7618      const ctx = patternSpectrumCanvas.getContext('2d');
7619      const rect = patternSpectrumCanvas.getBoundingClientRect();
7620      patternSpectrumCanvas.width = rect.width;
7621      patternSpectrumCanvas.height = rect.height;
7622      const width = patternSpectrumCanvas.width, height = patternSpectrumCanvas.height;
7623      for (let x = 0; x < width; x++) {
7624        const s = (x / width) * 100;
7625        const grad = ctx.createLinearGradient(0, 0, 0, height);
7626        for (let l = 100; l >= 0; l -= 10) {
7627          const rgb = hslToRgb(patternHue, s, l);
7628          grad.addColorStop((100 - l) / 100, `rgb(${rgb.r},${rgb.g},${rgb.b})`);
7629        }
7630        ctx.fillStyle = grad;
7631        ctx.fillRect(x, 0, 1, height);
7632      }
7633    }
7634
7635    function updatePatternMarkers() {
7636      if (!patternSpectrumCanvas || !patternHueCanvas) return;
7637      const spectrumRect = patternSpectrumCanvas.getBoundingClientRect();
7638      const hueRect = patternHueCanvas.getBoundingClientRect();
7639      const sx = (patternSat / 100) * spectrumRect.width;
7640      const sy = (1 - patternLight / 100) * spectrumRect.height;
7641      const hy = (patternHue / 360) * hueRect.height;
7642      if (patternSpectrumMarker) {
7643        patternSpectrumMarker.style.left = sx + 'px';
7644        patternSpectrumMarker.style.top = sy + 'px';
7645      }
7646      if (patternHueMarker) patternHueMarker.style.top = hy + 'px';
7647    }
7648
7649    function updatePatternColorFromHsl() {
7650      const rgb = hslToRgb(patternHue, patternSat, patternLight);
7651      const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
7652      if (patternColorSwatch) patternColorSwatch.style.background = hex;
7653      if (patternColorPicker) patternColorPicker.value = hex;
7654      if (patternColorHexInput) patternColorHexInput.value = hex;
7655      if (typeof renderGradient === 'function') renderGradient();
7656    }
7657
7658    function updatePatternColorFromHex(hex) {
7659      if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return;
7660      const rgb = hexToRgb(hex);
7661      const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
7662      patternHue = hsl.h;
7663      patternSat = hsl.s;
7664      patternLight = hsl.l;
7665      drawPatternSpectrum();
7666      updatePatternMarkers();
7667      if (patternColorSwatch) patternColorSwatch.style.background = hex;
7668      if (patternColorPicker) patternColorPicker.value = hex;
7669      if (patternColorHexInput) patternColorHexInput.value = hex;
7670      if (typeof renderGradient === 'function') renderGradient();
7671    }
7672
7673    function openPatternColorDropdown() {
7674      let hex = (patternColorPicker && patternColorPicker.value) ? patternColorPicker.value : '#ffffff';
7675      if (!hex.startsWith('#')) hex = '#' + hex;
7676      if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
7677        const rgb = hexToRgb(hex);
7678        const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
7679        patternHue = hsl.h;
7680        patternSat = hsl.s;
7681        patternLight = hsl.l;
7682      }
7683      if (patternColorHexInput) patternColorHexInput.value = hex;
7684      if (patternColorDropdown && patternColorSwatch) {
7685        patternColorDropdown.classList.add('visible');
7686        const rect = patternColorSwatch.getBoundingClientRect();
7687        const dropdownHeight = 220;
7688        const dropdownWidth = 240;
7689        const gap = 4;
7690        let top = rect.bottom + gap;
7691        let left = rect.left;
7692        if (top + dropdownHeight >
7692 window.innerHeight - 8) top = rect.top - dropdownHeight - gap;
7693        if (left + dropdownWidth > window.innerWidth - 8) left = window.innerWidth - dropdownWidth - 8;
7694        if (left < 8) left = 8;
7695        patternColorDropdown.style.top = top + 'px';
7696        patternColorDropdown.style.left = left + 'px';
7697        requestAnimationFrame(() => {
7698          drawPatternHueSlider();
7699          drawPatternSpectrum();
7700          updatePatternMarkers();
7701        });
7702      }
7703    }
7704
7705    function closePatternColorDropdown() {
7706      if (patternColorDropdown) patternColorDropdown.classList.remove('visible');
7707    }
7708
7709    if (patternColorSwatch) {
7710      patternColorSwatch.addEventListener('click', (e) => {
7711        e.stopPropagation();
7712        if (patternPickerInteracting && patternColorDropdown && patternColorDropdown.classList.contains('visible')) {
7713          patternPickerInteracting = false;
7714          return;
7715        }
7716        if (!patternColorDropdown || !patternColorDropdown.classList.contains('visible')) {
7717          openPatternColorDropdown();
7718        }
7719      });
7720    }
7721    if (patternColorDropdownClose) {
7722      patternColorDropdownClose.addEventListener('click', (e) => {
7723        e.stopPropagation();
7724        closePatternColorDropdown();
7725      });
7726    }
7727    if (patternColorDropdown) {
7728      patternColorDropdown.addEventListener('pointerdown', (e) => {
7729        patternPickerInteracting = true;
7730      });
7731    }
7732    window.addEventListener('pointerup', () => {
7733      if (patternPickerInteracting) {
7734        patternPickerInteracting = false;
7735      }
7736    });
7737    document.addEventListener('click', (e) => {
7738      if (!patternColorDropdown || !patternColorDropdown.classList.contains('visible')) return;
7739      const row = patternColorRow;
7740      const dropdown = patternColorDropdown;
7741      if (row && dropdown && !row.contains(e.target) && !dropdown.contains(e.target)) {
7742        closePatternColorDropdown();
7743      }
7744    });
7745    document.addEventListener('keydown', (e) => {
7746      if (e.key === 'Escape' && patternColorDropdown && patternColorDropdown.classList.contains('visible')) {
7747        closePatternColorDropdown();
7748      }
7749    });
7750
7751    if (patternSpectrumCanvas) {
7752      patternSpectrumCanvas.addEventListener('pointerdown', (e) => {
7753        e.stopPropagation();
7754        const rect = patternSpectrumCanvas.getBoundingClientRect();
7755        const update = (ev) => {
7756          const x = Math.max(0, Math.min(ev.clientX - rect.left, rect.width));
7757          const y = Math.max(0, Math.min(ev.clientY - rect.top, rect.height));
7758          patternSat = (x / rect.width) * 100;
7759          patternLight = 100 - (y / rect.height) * 100;
7760          updatePatternColorFromHsl();
7761          updatePatternMarkers();
7762        };
7763        update(e);
7764        const onMove = (ev) => { update(ev); ev.stopPropagation(); };
7765        const onUp = () => {
7766          window.removeEventListener('pointermove', onMove);
7767          window.removeEventListener('pointerup', onUp);
7768        };
7769        window.addEventListener('pointermove', onMove);
7770        window.addEventListener('pointerup', onUp);
7771      });
7772    }
7773    if (patternHueCanvas) {
7774      patternHueCanvas.addEventListener('pointerdown', (e) => {
7775        e.stopPropagation();
7776        const rect = patternHueCanvas.getBoundingClientRect();
7777        const update = (ev) => {
7778          const y = Math.max(0, Math.min(ev.clientY - rect.top, rect.height));
7779          patternHue = (y / rect.height) * 360;
7780          drawPatternSpectrum();
7781          updatePatternColorFromHsl();
7782          updatePatternMarkers();
7783        };
7784        update(e);
7785        const onMove = (ev) => { update(ev); ev.stopPropagation(); };
7786        const onUp = () => {
7787          window.removeEventListener('pointermove', onMove);
7788          window.removeEventListener('pointerup', onUp);
7789        };
7790        window.addEventListener('pointermove', onMove);
7791        window.addEventListener('pointerup', onUp);
7792      });
7793    }
7794    if (patternColorHexInput) {
7795      patternColorHexInput.addEventListener('change', () => {
7796        let val = patternColorHexInput.value;
7797        if (!val.startsWith('#')) val = '#' + val;
7798        if (/^#[0-9A-Fa-f]{6}$/.test(val)) updatePatternColorFromHex(val);
7799      });
7800      patternColorHexInput.addEventListener('keydown', (e) => {
7801        if (e.key === 'Enter') {
7802          e.preventDefault();
7803          patternColorHexInput.blur();
7804        }
7805      });
7806    }
7807    if (patternColorSwatch && patternColorPicker && patternColorHexInput) {
7808      const initialHex = patternColorPicker.value || '#ffffff';
7809      patternColorSwatch.style.background = initialHex;
7810      patternColorHexInput.value = initialHex;
7811    }
7812    
7813    const patternBlendMode = document.getElementById('patternBlendMode');
7814    const patternBlendPrev = document.getElementById('patternBlendPrev');
7815    const patternBlendNext = document.getElementById('patternBlendNext');
7816    const patternBlendModeIcon = document.getElementById('patternBlendModeIcon');
7817    const patternBlendModes = ['normal', 'mul', 'screen', 'overlay', 'darken'];
7818    const syncPatternBlendIcon = () => syncBlendModeIcon(patternBlendMode, patternBlendModeIcon);
7819    if (patternBlendMode) patternBlendMode.addEventListener('change', () => { syncPatternBlendIcon(); renderGradient(); });
7820    if (patternBlendPrev && patternBlendMode) patternBlendPrev.addEventListener('click', () => {
7821      const i = patternBlendModes.indexOf(patternBlendMode.value);
7822      patternBlendMode.value = patternBlendModes[(i - 1 + 5) % 5];
7823      syncPatternBlendIcon();
7824      renderGradient();
7825    });
7826    if (patternBlendNext && patternBlendMode) patternBlendNext.addEventListener('click', () => {
7827      const i = patternBlendModes.indexOf(patternBlendMode.value);
7828      patternBlendMode.value = patternBlendModes[(i + 1) % 5];
7829      syncPatternBlendIcon();
7830      renderGradient();
7831    });
7832    syncPatternBlendIcon();
7833
7834    // ============================================
7835    // Beta panel
7836    // ============================================
7837    const betaEnabled = document.getElementById('betaEnabled');
7838    const betaPanel = document.getElementById('betaPanel');
7839    const chromaticEnabled = document.getElementById('chromaticEnabled');
7840
7841    function syncBetaPanelState() {
7842      // Beta is always active (hidden checkbox stays checked so effects and
7843      // serialization keep working); the header chevron only collapses the UI.
7844      if (betaEnabled) betaEnabled.checked = true;
7845      syncChromaticBody();
7846      syncFlowBody();
7847      syncPaperBody();
7848    }
7849
7850    // Kill switch for the PRO gating of beta features. Set to false to roll
7851    // back to the previous behavior (beta panel open to everyone) without
7852    // reverting any other code — can be flipped directly on the server.
7853    const BETA_GATE_ENABLED = true;
7854
7855    function isProUser() {
7856      // Inside the Figma plugin the shell owns the licence. The iframe's
7857      // localStorage can still hold a previous web session, which must not
7858      // unlock beta or paint a Pro badge after the plugin has signed out.
7859      try {
7860        if (/[?&]embed=figma/.test(location.search)) return window.__colirFigmaPro === true;
7861      } catch (e) { /* fall through */ }
7862      try {
7863        if (!(localStorage.getItem('colir_user') || sessionStorage.getItem('colir_user'))) return false;
7864      } catch (e2) { /* storage blocked */ }
7865      return localStorage.getItem('colir_license_is_pro') === '1';
7866    }
7867
7868    // Animation uses the same Beta PRO entitlement as Design on every origin.
7869    window.canUseFlowAnimation = () => !BETA_GATE_ENABLED || isProUser();
7870
7871    // Beta features are PRO-only. The panel stays visible for everyone, but
7872    // non-PRO users see it locked: collapsed, lock badge instead of the
7873    // chevron, and all beta effects forced off.
7874    function updateBetaLockState() {
7875      window.ColirAnimation?.accessChanged();
7876      if (!betaPanel) return;
7877      betaPanel.style.display = '';
7878      const locked = BETA_GATE_ENABLED && !isProUser();
7879      betaPanel.classList.toggle('beta-locked', locked);
7880      const lockBadge = document.getElementById('betaProLock');
7881      if (lockBadge) lockBadge.style.display = locked ? 'inline-flex' : 'none';
7882      const chevronBtn = document.getElementById('betaCollapseBtn');
7883      if (chevronBtn) chevronBtn.style.display = locked ? 'none' : '';
7884      if (!locked) return;
7885      betaPanel.classList.add('collapsed');
7886      // Strip any active beta effect (e.g. a lapsed PRO subscription)
7887      let changed = false;
7888      ['chromaticEnabled', 'flowEnabled', 'paperEnabled'].forEach((id) => {
7889        const el = document.getElementById(id);
7890        if (el && el.checked) { el.checked = false; changed = true; }
7891      });
7892      if (changed) {
7893        syncBetaPanelState();
7894        renderGradient();
7895      }
7896    }
7897
7898    // Beta subfeature collapse: chevron toggles the body; the switch syncs it
7899    // (on = expanded, off = collapsed). Returns a sync function.
7900    function wireSubfeatureCollapse(btnId, bodyId, toggleEl) {
7901      const btn = document.getElementById(btnId);
7902      const body = document.getElementById(bodyId);
7903      if (!btn || !body) return () => {};
7904      const head = btn.closest('.subfeature-head');
7905      const setCollapsed = (collapsed) => {
7906        body.classList.toggle('collapsed', collapsed);
7907        if (head) head.classList.toggle('collapsed', collapsed);
7908      };
7909      btn.addEventListener('click', (e) => {
7910        e.preventDefault();
7911        e.stopPropagation();
7912        setCollapsed(!body.classList.contains('collapsed'));
7913      });
7914      if (head) {
7915        head.style.cursor = 'pointer';
7916        head.addEventListener('click', (e) => {
7917          if (e.target.closest('.ios-switch')) return;
7918          setCollapsed(!body.classList.contains('collapsed'));
7919        });
7920      }
7921      if (toggleEl) {
7922        toggleEl.addEventListener('change', () => setCollapsed(!toggleEl.checked));
7923      }
7924      return () => setCollapsed(!(toggleEl && toggleEl.checked));
7925    }
7926    const syncChromaticBody = wireSubfeatureCollapse('chromaticCollapseBtn', 'chromaticBody', chromaticEnabled);
7927    const syncFlowBody = wireSubfeatureCollapse('flowCollapseBtn', 'flowBody', document.getElementById('flowEnabled'));
7928    const syncPaperBody = wireSubfeatureCollapse('paperCollapseBtn', 'paperBody', document.getElementById('paperEnabled'));
7929
7930    function wireBetaSliderPair(slider, valueEl, min, max, step) {
7931      if (!slider || !valueEl) return;
7932      slider.addEventListener('input', () => {
7933        valueEl.value = slider.value;
7934        renderGradient();
7935      });
7936      valueEl.addEventListener('input', () => {
7937        let v = parseFloat(valueEl.value);
7938        if (Number.isNaN(v)) v = min;
7939        v = Math.max(min, Math.min(max, v));
7940        valueEl.value = step >= 1 ? Math.round(v) : v;
7941        slider.value = v;
7942        renderGradient();
7943      });
7944      valueEl.addEventListener('blur', () => {
7945        let v = parseFloat(valueEl.value);
7946        if (Number.isNaN(v)) v = min;
7947        v = Math.max(min, Math.min(max, v));
7948        valueEl.value = step >= 1 ? Math.round(v) : v;
7949        slider.value = v;
7950        renderGradient();
7951      });
7952    }
7953
7954    syncBetaPanelState();
7955    updateBetaLockState();
7956
7957    // Beta panel expansion is PRO-gated: for non-PRO users a header click
7958    // routes to login (if logged out) or to the upgrade offer.
7959    (function wireBetaPanelGate() {
7960      if (!betaPanel) return;
7961      const handleClick = (e) => {
7962        e.preventDefault();
7963        e.stopPropagation();
7964        if (!BETA_GATE_ENABLED || isProUser()) {
7965          betaPanel.classList.toggle('collapsed');
7966          return;
7967        }
7968        betaPanel.classList.add('collapsed');
7969        const loggedIn = !!(localStorage.getItem('colir_user') || sessionStorage.getItem('colir_user'));
7970        if (!loggedIn) {
7971          window.pendingBetaUnlock = true;
7972          if (typeof openAppLoginModal === 'function') openAppLoginModal();
7973        } else if (typeof openUpgradeModal === 'function') {
7974          openUpgradeModal('beta');
7975        }
7976      };
7977      const btn = document.getElementById('betaCollapseBtn');
7978      if (btn) btn.addEventListener('click', handleClick);
7979      const head = betaPanel.querySelector(':scope > h3');
7980      if (head) {
7981        head.style.cursor = 'pointer';
7982        head.addEventListener('click', (e) => {
7983          if (e.target.closest('.ios-switch')) return;
7984          handleClick(e);
7985        });
7986      }
7987    })();
7988
7989    ColirSurfaceFilters.prepareForExport = async (animated = false) => {
7990      const silk = ColirSurfaceFilters.snapshot().silk;
7991      if (!document.getElementById('betaEnabled')?.checked || !silk.enabled || silk.amount <= 0) return;
7992      surfaceRenderer ||= ColirSurfaceRenderer.create(gl);
7993      const owner = surfaceRenderer, type = selectedDistortion();
7994      await owner.prepareField('static-' + type);
7995      if (animated && type >= 0) await owner.prepareField('animated-' + type);
7996      if (surfaceRenderer !== owner || gl.isContextLost()) throw new Error('Graphics changed. Please retry the export.');
7997    };
7998    ColirSurfaceFilters.mount(commit => { renderGradient(); if (commit) saveHistory(); });
7999
8000    // Animated expand/collapse (150ms eased) for collapsible panels and beta
8001    // subfeatures. Watches 'collapsed' class changes (chevrons, switches,
8002    // share-link restore all toggle it) and animates the revealed element's
8003    // height. The controls' own top margin is animated, keeping the header
8004    // itself stationary as the body appears or disappears.
8005    (function initCollapseAnimations() {
8006      if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
8007      const pairs = [];
8008      ['distortionPanel', 'glitterPanel', 'patternsPanel', 'betaPanel'].forEach((id) => {
8009        const panel = document.getElementById(id);
8010        const body = panel ? panel.querySelector(':scope > .controls') : null;
8011        if (panel && body) pairs.push({ watch: panel, body: body, display: 'grid', exp
8011andedMarginTop: 8 });
8012      });
8013      ['chromaticBody', 'flowBody', 'paperBody', 'silkBody', 'bloomBody'].forEach((id) => {
8014        const body = document.getElementById(id);
8015        if (body) pairs.push({ watch: body, body: body, display: 'grid', expandedMarginTop: 0 });
8016      });
8017      function runCollapseAnim(pair, collapsing) {
8018        const el = pair.body;
8019        if (el._collapseAnim) {
8020          el._collapseAnim.onfinish = null;
8021          el._collapseAnim.cancel();
8022          el._collapseAnim = null;
8023        }
8024        el.style.display = '';
8025        el.style.overflow = '';
8026        // Class already applied, so when collapsing the element is display:none;
8027        // show it inline for the duration of the animation.
8028        if (collapsing) el.style.display = pair.display;
8029        const h = el.scrollHeight;
8030        if (h === 0) { el.style.display = ''; return; }
8031        el.style.overflow = 'hidden';
8032        const expandedOpacity = pair.watch.classList.contains('panel-disabled') ? 0.35 : 1;
8033        const expanded = {
8034          height: h + 'px',
8035          opacity: expandedOpacity,
8036          marginTop: pair.expandedMarginTop + 'px'
8037        };
8038        const hidden = { height: '0px', opacity: 0, marginTop: '0px' };
8039        const anim = el.animate(
8040          collapsing ? [expanded, hidden] : [hidden, expanded],
8041          { duration: 150, easing: 'ease' }
8042        );
8043        el._collapseAnim = anim;
8044        anim.onfinish = () => {
8045          el.style.display = '';
8046          el.style.overflow = '';
8047          el._collapseAnim = null;
8048        };
8049      }
8050      pairs.forEach((pair) => {
8051        pair.prev = pair.watch.classList.contains('collapsed');
8052        const obs = new MutationObserver(() => {
8053          const now = pair.watch.classList.contains('collapsed');
8054          if (now === pair.prev) return;
8055          pair.prev = now;
8056          runCollapseAnim(pair, now);
8057        });
8058        obs.observe(pair.watch, { attributes: true, attributeFilter: ['class'] });
8059      });
8060    })();
8061    if (chromaticEnabled) {
8062      chromaticEnabled.addEventListener('change', () => renderGradient());
8063    }
8064    wireBetaSliderPair(
8065      document.getElementById('caIntensityInput'),
8066      document.getElementById('caIntensityValue'),
8067      0, 100, 1
8068    );
8069    const flowEnabledToggle = document.getElementById('flowEnabled');
8070    if (flowEnabledToggle) {
8071      flowEnabledToggle.addEventListener('change', () => renderGradient());
8072    }
8073    wireBetaSliderPair(document.getElementById('flowIntensityInput'), document.getElementById('flowIntensityValue'), 0, 100, 1);
8074    wireBetaSliderPair(document.getElementById('flowLengthInput'), document.getElementById('flowLengthValue'), 0, 100, 1);
8075    wireBetaSliderPair(document.getElementById('flowStepsInput'), document.getElementById('flowStepsValue'), 2, 50, 1);
8076    wireBetaSliderPair(document.getElementById('flowDecayInput'), document.getElementById('flowDecayValue'), 0, 6, 0.1);
8077    wireBetaSliderPair(document.getElementById('flowScaleInput'), document.getElementById('flowScaleValue'), 0.5, 12, 0.1);
8078    wireBetaSliderPair(document.getElementById('flowDetailInput'), document.getElementById('flowDetailValue'), 1, 6, 1);
8079    wireBetaSliderPair(document.getElementById('flowDirectionInput'), document.getElementById('flowDirectionValue'), 0, 360, 1);
8080    wireBetaSliderPair(document.getElementById('flowIrregularityInput'), document.getElementById('flowIrregularityValue'), 0, 100, 1);
8081    wireBetaSliderPair(document.getElementById('flowSeedInput'), document.getElementById('flowSeedValue'), 0, 100, 1);
8082    const paperEnabledToggle = document.getElementById('paperEnabled');
8083    if (paperEnabledToggle) {
8084      paperEnabledToggle.addEventListener('change', () => renderGradient());
8085    }
8086    wireBetaSliderPair(document.getElementById('paperIntensityInput'), document.getElementById('paperIntensityValue'), 0, 100, 1);
8087    wireBetaSliderPair(document.getElementById('paperGrainInput'), document.getElementById('paperGrainValue'), 0, 100, 1);
8088    wireBetaSliderPair(document.getElementById('paperFiberInput'), document.getElementById('paperFiberValue'), 0, 100, 1);
8089    wireBetaSliderPair(document.getElementById('paperFoldInput'), document.getElementById('paperFoldValue'), 0, 100, 1);
8090    wireBetaSliderPair(document.getElementById('paperRoughInput'), document.getElementById('paperRoughValue'), 0, 100, 1);
8091    wireBetaSliderPair(document.getElementById('paperSpeckInput'), document.getElementById('paperSpeckValue'), 0, 100, 1);
8092    wireBetaSliderPair(document.getElementById('paperDisplaceInput'), document.getElementById('paperDisplaceValue'), 0, 100, 1);
8093    wireBetaSliderPair(document.getElementById('paperScaleInput'), document.getElementById('paperScaleValue'), 0.2, 4, 0.05);
8094    wireBetaSliderPair(document.getElementById('paperGrainScaleInput'), document.getElementById('paperGrainScaleValue'), 0.05, 2, 
80940.01);
8095    wireBetaSliderPair(document.getElementById('paperFiberScaleInput'), document.getElementById('paperFiberScaleValue'), 20, 800, 5);
8096    wireBetaSliderPair(document.getElementById('paperFoldScaleInput'), document.getElementById('paperFoldScaleValue'), 0.5, 16, 0.1);
8097    wireBetaSliderPair(document.getElementById('paperRoughScaleInput'), document.getElementById('paperRoughScaleValue'), 10, 600, 5);
8098    wireBetaSliderPair(document.getElementById('paperSeedInput'), document.getElementById('paperSeedValue'), 0, 100, 1);
8099    window.syncBetaPanelState = syncBetaPanelState;
8100    window.isProUser = isProUser;
8101    // Kept under the old name: updateProTag/checkAppAuthStatus call it after
8102    // every license check, which now re-evaluates the beta lock.
8103    window.updateBetaPanelVisibility = updateBetaLockState;
8104    window.expandBetaPanel = function () {
8105      updateBetaLockState();
8106      if (betaPanel && (!BETA_GATE_ENABLED || isProUser())) betaPanel.classList.remove('collapsed');
8107    };
8108    
8109    // Octaves slider
8110    if (procDispOctaves && procDispOctavesValue) {
8111      procDispOctaves.addEventListener('input', () => {
8112        procDispOctavesValue.value = procDispOctaves.value;
8113        renderGradient();
8114      });
8115      procDispOctavesValue.addEventListener('input', () => {
8116        const val = Math.max(1, Math.min(8, parseInt(procDispOctavesValue.value) || 1));
8117        // Clamp slider to its range (1-3), but allow number input to go up to 8
8118        const sliderVal = Math.max(1, Math.min(3, val));
8119        procDispOctaves.value = sliderVal;
8120        renderGradient();
8121      });
8122      procDispOctavesValue.addEventListener('blur', () => {
8123        const val = Math.max(1, Math.min(8, parseInt(procDispOctavesValue.value) || 1));
8124        procDispOctavesValue.value = val;
8125        // Clamp slider to its range (1-3), but allow number input to go up to 8
8126        const sliderVal = Math.max(1, Math.min(3, val));
8127        procDispOctaves.value = sliderVal;
8128        renderGradient();
8129      });
8130    }
8131    
8132    // Lacunarity slider
8133    if (procDispLacunarity && procDispLacunarityValue) {
8134      procDispLacunarity.addEventListener('input', () => {
8135        procDispLacunarityValue.value = parseFloat(procDispLacunarity.value).toFixed(1);
8136        renderGradient();
8137      });
8138      procDispLacunarityValue.addEventListener('input', () => {
8139        const val = Math.max(1.5, Math.min(4.0, parseFloat(procDispLacunarityValue.value) || 1.5));
8140        // Clamp slider to its range (1.5-2.5), but allow number input to go up to 4.0
8141        const sliderVal = Math.max(1.5, Math.min(2.5, val));
8142        procDispLacunarity.value = sliderVal;
8143        renderGradient();
8144      });
8145      procDispLacunarityValue.addEventListener('blur', () => {
8146        const val = Math.max(1.5, Math.min(4.0, parseFloat(procDispLacunarityValue.value) || 1.5));
8147        procDispLacunarityValue.value = val.toFixed(1);
8148        // Clamp slider to its range (1.5-2.5), but allow number input to go up to 4.0
8149        const sliderVal = Math.max(1.5, Math.min(2.5, val));
8150        procDispLacunarity.value = sliderVal;
8151        renderGradient();
8152      });
8153      procDispLacunarityValue.value = parseFloat(procDispLacunarity.value).toFixed(1);
8154    }
8155    
8156    // Persistence slider
8157    if (procDispPersistence && procDispPersistenceValue) {
8158      procDispPersistence.addEventListener('input', () => {
8159        procDispPersistenceValue.value = parseFloat(procDispPersistence.value).toFixed(2);
8160        renderGradient();
8161      });
8162      procDispPersistenceValue.addEventListener('input', () => {
8163        const val = Math.max(0.1, Math.min(0.9, parseFloat(procDispPersistenceValue.value) || 0.3));
8164        // Clamp slider to its range (0.1-0.5), but allow number input to go up to 0.9
8165        const sliderVal = Math.max(0.1, Math.min(0.5, val));
8166        procDispPersistence.value = sliderVal;
8167        renderGradient();
8168      });
8169      procDispPersistenceValue.addEventListener('blur', () => {
8170        const val = Math.max(0.1, Math.min(0.9, parseFloat(procDispPersistenceValue.value) || 0.3));
8171        procDispPersistenceValue.value = val.toFixed(2);
8172        // Clamp slider to its range (0.1-0.5), but allow number input to go up to 0.9
8173        const sliderVal = Math.max(0.1, Math.min(0.5, val));
8174        procDispPersistence.value = sliderVal;
8175        renderGradient();
8176      });
8177      procDispPersistenceValue.value = parseFloat(procDispPersistence.value).toFixed(2);
8178    }
8179    
8180    // Wave Frequency slider
8181    if (procDispWaveFreq && procDispWaveFreqValue) {
8182      procDispWaveFreq.addEventListener('input', () => {
8183        procDispWaveFreqValue.value = procDispWaveFreq.value;
8184        renderGradient();
8185      });
8186      procDispWaveFreqValue.addEventListener('input', () => {
8187        const val = Math.max(1, Math.min(30, parseInt(procDispWaveFreqValue.value) || 3));
8188        // Clamp slider to its range (1-10), but allow number input to go up to 30
8189        const sliderVal = Math.max(1, Math.min(10, val));
8190        procDispWaveFreq.value = sliderVal;
8191        renderGradient();
8192      });
8193      procDispWaveFreqValue.addEventListener('blur', () => {
8194        const val = Math.max(1, Math.min(30, parseInt(procDispWaveFreqValue.value) || 3));
8195        procDispWaveFreqValue.value = val;
8196        // Clamp slider to its range (1-10), but allow number input to go up to 30
8197        const sliderVal = Math.max(1, Math.min(10, val));
8198        procDispWaveFreq.value = sliderVal;
8199        renderGradient();
8200      });
8201    }
8202    
8203    // Voronoi Jitter slider
8204    if (procDispVoronoiJitter && procDispVoronoiJitterValue) {
8205      procDispVoronoiJitter.addEventListener('input', () => {
8206        procDispVoronoiJitterValue.value = parseFloat(procDispVoronoiJitter.value).toFixed(2);
8207        renderGradient();
8208      });
8209      procDispVoronoiJitterValue.addEventListener('input', () => {
8210        const val = Math.max(0, Math.min(1, parseFloat(procDispVoronoiJitterValue.value) || 1.0));
8211        procDispVoronoiJitter.value = val;
8212        renderGradient();
8213      });
8214      procDispVoronoiJitterValue.addEventListener('blur', () => {
8215        const val = Math.max(0, Math.min(1, parseFloat(procDispVoronoiJitterValue.value) || 1.0));
8216        procDispVoronoiJitterValue.value = val.toFixed(2);
8217        procDispVoronoiJitter.value = val;
8218        renderGradient();
8219      });
8220      procDispVoronoiJitterValue.value = parseFloat(procDispVoronoiJitter.value).toFixed(2);
8221    }
8222    
8223    // Warp Strength slider (for Organic type)
8224    if (procDispWarpStrength && procDispWarpStrengthValue) {
8225      procDispWarpStrength.addEventListener('input', () => {
8226        procDispWarpStrengthValue.value = parseFloat(procDispWarpStrength.value).toFixed(1);
8227        renderGradient();
8228      });
8229      procDispWarpStrengthValue.addEventListener('input', () => {
8230        const val = Math.max(0.5, Math.min(10.0, parseFloat(procDispWarpStrengthValue.value) || 2.6));
8231        // Clamp slider to its range (0.5-3.0), but allow number input to go up to 10.0
8232        const sliderVal = Math.max(0.5, Math.min(3.0, val));
8233        procDispWarpStrength.value = sliderVal;
8234        renderGradient();
8235      });
8236      procDispWarpStrengthValue.addEventListener('blur', () => {
8237        const val = Math.max(0.5, Math.min(10.0, parseFloat(procDispWarpStrengthValue.value) || 2.6));
8238        procDispWarpStrengthValue.value = val.toFixed(1);
8239        // Clamp slider to its range (0.5-3.0), but allow number input to go up to 10.0
8240        const sliderVal = Math.max(0.5, Math.min(3.0, val));
8241        procDispWarpStrength.value = sliderVal;
8242        renderGradient();
8243      });
8244      procDispWarpStrengthValue.value = parseFloat(procDispWarpStrength.value).toFixed(1);
8245    }
8246    
8247    // Mix slider (for Kura)
8248    if (procDispMix && procDispMixValue) {
8249      procDispMix.addEventListener('input', () => {
8250        procDispMixValue.value = parseFloat(procDispMix.value).toFixed(2);
8251        renderGradient();
8252      });
8253      procDispMixValue.addEventListener('input', () => {
8254        const val = Math.max(0.0, Math.min(1.0, parseFloat(procDispMixValue.value) || 0.5));
8255        procDispMix.value = val;
8256        procDispMixValue.value = val.toFixed(2);
8257        renderGradient();
8258      });
8259      procDispMixValue.addEventListener('blur', () => {
8260        const val = Math.max(0.0, Math.min(1.0, parseFloat(procDispMixValue.value) || 0.5));
8261        procDispMix.value = val;
8262        procDispMixValue.value = val.toFixed(2);
8263        renderGradient();
8264      });
8265    }
8266    
8267    // Tayu Mix slider
8268    const procDispTayuMix = document.getElementById('procDispTayuMix');
8269    const procDispTayuMixValue = document.getElementById('procDispTayuMixValue');
8270    if (procDispTayuMix && procDispTayuMixValue) {
8271      procDispTayuMix.addEventListener('input', () => {
8272        procDispTayuMixValue.value = parseFloat(procDispTayuMix.value).toFixed(2);
8273        renderGradient();
8274      });
8275      procDispTayuMixValue.addEventListener('input', () => {
8276        const val = Math.max(0.0, Math.min(1.0, parseFloat(procDispTayuMixValue.value) || 0.5));
8277        procDispTayuMix.value = val;
8278        procDispTayuMixValue.value = val.toFixed(2);
8279        renderGradient();
8280      });
8281      procDispTayuMixValue.addEventListener('blur', () => {
8282        const val = Math.max(0.0, Math.min(1.0, parseFloat(procDispTayuMixValue.value) || 0.5));
8283        procDispTayuMix.value = val;
8284        procDispTayuMixValue.value = val.toFixed(2);
8285        renderGradient();
8286      });
8287    }
8288    
8289    function copyTextToClipboard(text) {
8290      if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
8291        return navigator.clipboard.writeText(text);
8292      }
8293      return new Promise(function (resolve, reject) {
8294        try {
8295          var ta = document.createElement('textarea');
8296          ta.value = text;
8297          ta.setAttribute('readonly', '');
8298          ta.style.cssText = 'position:fixed;left:-9999px;top:0';
8299          document.body.appendChild(ta);
8300          ta.focus();
8301          ta.select();
8302          var ok = document.execCommand('copy');
8303          document.body.removeChild(ta);
8304          if (ok) resolve();
8305          else reject(new Error('Copy command was rejected'));
8306        } catch (e) {
8307          reject(e);
8308        }
8309      });
8310    }
8311
8312    // Copy Gradient JSON
8313    const copyGradientBtn = document.getElementById('copyGradientBtn');
8314    copyGradientBtn.addEventListener('click', () => {
8315      const gradientData = generateGradientJSON();
8316      const jsonString = JSON.stringify(gradientData, null, 2);
8317      copyTextToClipboard(jsonString).then(() => {
8318        const originalText = copyGradientBtn.querySelector('span:last-child').textContent;
8319        copyGradientBtn.querySelector('span:last-child').textContent = 'Copied!';
8320        setTimeout(() => {
8321          copyGradientBtn.querySelector('span:last-child').textContent = originalText;
8322        }, 2000);
8323      }).catch(err => {
8324        console.error('Failed to copy:', err);
8325        alert('Failed to copy to clipboard');
8326      });
8327    });
8328
8329    // Share gradient functionality
8330    function showShareToast() {
8331      // Create toast if it doesn't exist
8332      let toast = document.getElementById('shareToast');
8333      if (!toast) {
8334        toast = document.createElement('div');
8335        toast.id = 'shareToast';
8336        toast.className = 'share-toast';
8337        toast.innerHTML = '<span class="material-icons" data-icon="check_circle" aria-hidden="true" translate="no"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true" focusable="false">
8337<path d="M213 363 405 171 375 140 213 302 137 226 107 256ZM105.5 105.5Q168 43 256.0 43.0Q344 43 406.5 105.5Q469 168 469.0 256.0Q469 344 406.5 406.5Q344 469 256.0 469.0Q168 469 105.5 406.5Q43 344 43.0 256.0Q43 168 105.5 105.5Z"></path></svg></span><span>Link copied to clipboard!</span>';
8338        document.body.appendChild(toast);
8339      }
8340      
8341      // Position toast below export island
8342      const exportIsland = document.querySelector('.floating-export');
8343      if (exportIsland) {
8344        const exportRect = exportIsland.getBoundingClientRect();
8345        const toastTop = exportRect.bottom + 8; // 8px spacing below export island
8346        toast.style.top = `${toastTop}px`;
8347        toast.style.right = `${window.innerWidth - exportRect.right}px`; // Align with export island right edge
8348      } else {
8349        // Fallback: top right with default spacing
8350        toast.style.top = '80px';
8351        toast.style.right = '20px';
8352      }
8353      
8354      // Show toast
8355      toast.classList.add('show');
8356      
8357      // Hide after 3 seconds
8358      setTimeout(() => {
8359        toast.classList.remove('show');
8360      }, 3000);
8361    }
8362
8363    function shareGradient() {
8364      try {
8365        // Use compact format for shorter URLs
8366        const gradientData = generateCompactGradientJSON();
8367        const jsonString = JSON.stringify(gradientData);
8368        
8369        // Check if LZString is available
8370        if (typeof LZString === 'undefined') {
8371          console.error('LZString library not loaded');
8372          alert('Share functionality requires LZString library. Please refresh the page.');
8373          return;
8374        }
8375        
8376        // Compress using LZ-string
8377        const compressed = LZString.compressToEncodedURIComponent(jsonString);
8378        
8379        // Update URL hash
8380        const shareUrl = window.location.origin + window.location.pathname + '#' + compressed;
8381        
8382        // Copy to clipboard (Clipboard API is missing in some nested iframes, e.g. Figma)
8383        copyTextToClipboard(shareUrl).then(() => {
8384          // Path + hash only so any existing ?query (e.g. feature flags) is dropped from the address bar
8385          window.history.replaceState(null, '', window.location.pathname + '#' + compressed);
8386          showShareToast();
8387        }).catch(err => {
8388          console.error('Failed to copy to clipboard:', err);
8389          // Fallback: show URL in alert
8390          alert('Share link:\n\n' + shareUrl);
8391        });
8392      } catch (error) {
8393        console.error('Error sharing gradient:', error);
8394        alert('Failed to generate share link: ' + (error.message || 'Unknown error'));
8395      }
8396    }
8397
8398    const shareBtn = document.getElementById('shareBtn');
8399    if (shareBtn) {
8400      shareBtn.addEventListener('click', shareGradient);
8401    }
8402
8403    // Load gradient from URL hash
8404    function loadGradientFromHash() {
8405      const hash = window.location.hash.substring(1); // Remove #
8406      if (!hash) return;
8407      
8408      try {
8409        // Check if LZString is available
8410        if (typeof LZString === 'undefined') {
8411          console.warn('LZString library not loaded, cannot decode share link');
8412          return;
8413        }
8414        
8415        // Decompress
8416        const jsonString = LZString.decompressFromEncodedURIComponent(hash);
8417        if (!jsonString) {
8418          console.warn('Failed to decompress share link');
8419          return;
8420        }
8421        
8422        const gradientData = JSON.parse(jsonString);
8423        
8424        // Wait for variables to be initialized
8425        function tryRestore() {
8426          let varsReady = false;
8427          try {
8428            varsReady = (typeof widthInput !== 'undefined' && typeof heightInput !== 'undefined' &&
8429                        typeof xEditor !== 'undefined' && typeof yEditor !== 'undefined' &&
8430                        typeof gradientStopsArray !== 'undefined' && typeof blendMode !== 'undefined' &&
8431                        widthInput && heightInput && xEditor && yEditor && blendMode);
8432          } catch (e) {
8433            varsReady = false;
8434          }
8435          
8436          if (!varsReady) {
8437            setTimeout(tryRestore, 100);
8438            return;
8439          }
8440          
8441          // Restore gradient from data
8442          restoreGradientFromData(gradientData);
8443          
8444          // Mark share load so we don't override with localStorage state
8445          window.__shareGradientLoaded = true;
8446
8447          // Clear hash after loading for cleaner URL
8448          window.history.replaceState(null, '', window.location.pathname);
8449        }
8450        
8451        tryRestore();
8452      } catch (error) {
8453        console.error('Failed to load gradient from URL:', error);
8454      }
8455    }
8456
8457    // Restore gradient from data object (reusable function)
8458    // Handles both old format (colorStops, xCurve, yCurve) and new compact format (c, x, y)
8459    function restoreGradientFromData(state) {
8460      const previousPreview=window.ColirRenderPrograms?.designReady ? document.createElement('canvas') : null;
8461      if(previousPreview){previousPreview.width=gradientCanvas.width;previousPreview.height=gradientCanvas.height;previousPreview.getContext('2d').drawImage(gradientCanvas,0,0);}
8462      try {
8463        const DEFAULTS = {
8464          w: 900,
8465          h: 520,
8466          s: true,
8467          b: 'difference',
8468          n: 6,
8469          // Legacy compact `nt` (screen|gradient): ignored for rendering; always hybrid (former "gradient space").
8470          nt: 'gradient',
8471          ns: 75,
8472          nmix: 53,
8473          f: 18,
8474          g: { e: true, d: 30, min: 0.5, max: 3, o: 100, b: 'normal' },
8475          d: { e: false, t: 'organic', i: 2, sc: 0.5, seed: 0 },
8476          p: { e: false, t: 'dot', ds: 3, dd: 24, lt: 1, ld: 24, la: 0, lx: 1, c: '#ffffff', o: 100, b: 'normal' },
8477          bt: { e: false, c: { e: false, i: 100 }, fl: { e: false, i: 100, l: 100, st: 50, d: 0, sc: 0.5, o: 4, a: 180, ir: 100, s: 42 }, pp: { e: false, i: 28, s: 12 } }
8478        };
8479
8480        // Detect format: new compact format has 'c', 'x', 'y' keys
8481        const isCompactFormat = state.c !== undefined && state.x !== undefined && state.y !== undefined;
8482
8483        // Restore canvas size
8484        const canvasWidth = isCompactFormat ? (state.w !== undefined ? state.w : DEFAULTS.w) : state.canvasWidth;
8485        const canvasHeight = isCompactFormat ? (state.h !== undefined ? state.h : DEFAULTS.h) : state.canvasHeight;
8486        if (canvasWidth && widthInput) widthInput.value = canvasWidth;
8487        if (canvasHeight && heightInput) heightInput.value = canvasHeight;
8488        if ((canvasWidth || canvasHeight) && typeof setCanvasSize === 'function') {
8489          setCanvasSize();
8490        }
8491        
8492        // Restore gradient stops
8493        if (isCompactFormat && state.c && state.c.length > 0) {
8494          // Compact format: [[r,g,b,pos], ...]
8495          gradientStopsArray = state.c.map((stop, idx) => ({
8496            id: stopIdCounter++,
8497            position: stop[3],
8498            color: { r: stop[0], g: stop[1], b: stop[2] }
8499          }));
8500        } else if (state.colorStops && state.colorStops.length > 0) {
8501          // Old format: [{position, color: {r,g,b}}]
8502          gradientStopsArray = state.colorStops.map((stop, idx) => {
8503            // New format: OKLCH master with optional RGB cache
8504            if (stop && stop.oklch && typeof stop.oklch.l === 'number') {
8505              const l = stop.oklch.l;
8506              const c = stop.oklch.c;
8507              const h = stop.oklch.h;
8508              const srgb = gamutMapOklchToSrgb01(l, c, h);
8509              return {
8510                id: stopIdCounter++,
8511                position: stop.position,
8512                color: {
8513                  r: Math.max(0, Math.min(255, Math.round(srgb.r * 255))),
8514                  g: Math.max(0, Math.min(255, Math.round(srgb.g * 255))),
8515                  b: Math.max(0, Math.min(255, Math.round(srgb.b * 255))),
8516                },
8517                oklch: { l, c, h }
8518              };
8519            }
8520            // Legacy RGB-only format
8521            return {
8522              id: stopIdCounter++,
8523              position: stop.position,
8524              color: { r: stop.color.r, g: stop.color.g, b: stop.color.b }
8525            };
8526          });
8527        }
8528        // Update gradient bar UI
8529        if (gradientStopsArray.length > 0 && typeof renderGradientBar === 'function') {
8530          renderGradientBar();
8531        }
8532
8533        // Match "Amount of colors" to restored stops (share links / template redirects)
8534        const restoredStopCount = gradientStopsArray.length;
8535        if (restoredStopCount >= 2) {
8536          const countInput = document.getElementById('multiColorCount');
8537          const countSlider = document.getElementById('multiColorCountSlider');
8538          const clamped = Math.min(20, Math.max(2, restoredStopCount));
8539          if (countInput) countInput.value = clamped;
8540          if (countSlider) countSlider.value = clamped;
8541          if (selectedStopIndex >= restoredStopCount) {
8542            selectedStopIndex = Math.max(0, restoredStopCount - 1);
8543          }
8544          if (selectedStopId != null && !gradientStopsArray.some(s => s.id === selectedStopId)) {
8545            selectedStopId = gradientStopsArray[selectedStopIndex]?.id ?? null;
8546          }
8547        }
8548
8549        // Restore curves
8550        if (isCompactFormat) {
8551          // Compact format: [[t,v], ...]
8552          if (state.x && state.x.length > 0 && xEditor) {
8553            xEditor.points = state.x.map(p => ({ t: p[0], v: p[1] }));
8554            xEditor.draw();
8555          }
8556          if (state.y && state.y.length > 0 && yEditor) {
8557            yEditor.points = state.y.map(p => ({ t: p[0], v: p[1] }));
8558            yEditor.draw();
8559          }
8560        } else {
8561          // Old format: {points: [{t,v}]}
8562          if (state.xCurve && state.xCurve.points && xEditor) {
8563            xEditor.points = state.xCurve.points.map(p => ({ t: p.t, v: p.v }));
8564            xEditor.draw();
8565          }
8566          if (state.yCurve && state.yCurve.points && yEditor) {
8567            yEditor.points = state.yCurve.points.map(p => ({ t: p.t, v: p.v }));
8568            yEditor.draw();
8569          }
8570        }
8571        
8572        // Restore settings
8573        const curveSmoothing = isCompactFormat ? (state.s !== undefined ? state.s : DEFAULTS.s) : state.curveSmoothing;
8574        if (curveSmoothing !== undefined) {
8575          const curveSmoothingToggle = document.getElementById('curveSmoothingToggle');
8576          if (curveSmoothingToggle) {
8577            if (curveSmoothing) {
8578              curveSmoothingToggle.classList.add('on');
8579            } else {
8580              curveSmoothingToggle.classList.remove('on');
8581            }
8582            cachedXSamples = null;
8583            cachedYSamples = null;
8584          }
8585        }
8586        
8587        const blendModeValue = isCompactFormat ? (state.b !== undefined ? state.b : DEFAULTS.b) : state.blendMode;
8588        if (blendModeValue !== undefined && blendMode) {
8589          blendMode.value = blendModeValue;
8590          if (typeof updateBlendModeIcon === 'function') {
8591            updateBlendModeIcon(blendModeValue);
8592          }
8593        }
8594        
8595        const noiseVal = isCompactFormat ? (state.n !== undefined ? state.n : DEFAULTS.n) : state.noise;
8596        if (noiseVal !== undefined && noiseInput && noiseValue) {
8597          noiseInput.value = noiseVal;
8598          noiseValue.value = noiseVal;
8599        }
8600
8601        const featherVal = isCompactFormat ? (state.f !== undefined ? state.f : DEFAULTS.f) : state.feather;
8602        if (featherVal !== undefined && featherInput && featherValue) {
8603          featherInput.value = featherVal;
8604          featherValue.value = featherVal;
8605        }
8606        
8607        // Restore glitter
8608        let glitterData = null;
8609        if (isCompactFormat) {
8610          if (state.g !== undefined) {
8611            glitterData = {
8612              enabled: state.g.e !== undefined ? state.g.e : DEFAULTS.g.e,
8613              density: state.g.d !== undefined ? state.g.d : DEFAULTS.g.d,
8614              sizeMin: state.g.min !== undefined ? state.g.min : DEFAULTS.g.min,
8615              sizeMax: state.g.max !== undefined ? state.g.max : DEFAULTS.g.max,
8616              opacity: state.g.o !== undefined ? state.g.o : DEFAULTS.g.o,
8617              blendMode: state.g.b !== undefined ? state.g.b : DEFAULTS.g.b
8618            };
8619          } else {
8620            glitterData = DEFAULTS.g;
8621          }
8622        } else if (state.glitter) {
8623          glitterData = state.glitter;
8624        }
8625        
8626        if (glitterData) {
8627          const glitterEnabled = document.getElementById('glitterEnabled');
8628          const glitterDensity = document.getElementById('glitterDensityInput');
8629          const glitterDensityValue = document.getElementById('glitterDensityValue');
8630          const glitterSizeMin = document.getElementById('glitterSizeMinInput');
8631          const glitterSizeMinValue = document.getElementById('glitterSizeMinValue');
8632          const glitterSizeMax = document.getElementById('glitterSizeMaxInput');
8633          const glitterSizeMaxValue = document.getElementById('glitterSizeMaxValue');
8634          const glitterOpacity = document.getElementById('glitterOpacity');
8635          const glitterOpacityValue = document.getElementById('glitterOpacityValue');
8636          const glitterBlendMode = document.getElementById('glitterBlendMode');
8637          if (glitterEnabled) glitterEnabled.checked = glitterData.enabled;
8638          if (glitterDensity) glitterDensity.value = glitterData.density;
8639          if (glitterDensityValue) glitterDensityValue.value = glitterData.density;
8640          if (glitterSizeMin) glitterSizeMin.value = glitterData.sizeMin;
8641          if (glitterSizeMinValue) glitterSizeMinValue.value = glitterData.sizeMin;
8642          if (glitterSizeMax) glitterSizeMax.value = glitterData.sizeMax;
8643          if (glitterSizeMaxValue) glitterSizeMaxValue.value = glitterData.sizeMax;
8644          if (glitterOpacity) glitterOpacity.value = glitterData.opacity !== undefined ? glitterData.opacity : 100;
8645          if (glitterOpacityValue) glitterOpacityValue.value = glitterData.opacity !== undefined ? glitterData.opacity : 100;
8646          if (glitterBlendMode) {
8647            glitterBlendMode.value = glitterData.blendMode || 'normal';
8648            if (typeof syncBlendModeIcon === 'function') {
8649              syncBlendModeIcon(glitterBlendMode, document.getElementById('glitterBlendModeIcon'));
8650            }
8651          }
8652
8653          const glitterPanel = document.getElementById('glitterPanel');
8654          if (glitterPanel && glitterEnabled) {
8655            glitterPanel.classList.toggle('panel-disabled', !glitterEnabled.checked);
8656            glitterPanel.classList.toggle('collapsed', !glitterEnabled.checked);
8657          }
8658        }
8659        
8660        // Restore distortion if present
8661        let distortionData = null;
8662        if (isCompactFormat) {
8663          if (state.d !== undefined) {
8664            distortionData = {
8665              enabled: state.d.e !== undefined ? state.d.e : DEFAULTS.d.e,
8666              type: state.d.t !== undefined ? state.d.t : DEFAULTS.d.t,
8667              intensity: state.d.i !== undefined ? state.d.i : DEFAULTS.d.i,
8668              scale: state.d.sc !== undefined ? state.d.sc : DEFAULTS.d.sc,
8669              seed: state.d.seed !== undefined ? state.d.seed : DEFAULTS.d.seed,
8670              octaves: state.d.o,
8671              lacunarity: state.d.l,
8672              persistence: state.d.p,
8673              waveFreq: state.d.wf,
8674              voronoiJitter: state.d.vj,
8675              warpStrength: state.d.ws,
8676              mix: state.d.m,
8677              tayuMix: state.d.tm
8678            };
8679          } else {
8680            distortionData = DEFAULTS.d;
8681          }
8682        } else if (state.distortion) {
8683          distortionData = state.distortion;
8684        }
8685        
8686        if (distortionData) {
8687          const distortionEnabled = document.getElementById('distortionEnabled');
8688          const procDispType = document.getElementById('procDispType');
8689          const procDispIntensity = document.getElementById('procDispIntensity');
8690          const procDispIntensityValue = document.getElementById('procDispIntensityValue');
8691          const procDispScale = document.getElementById('procDispScale');
8692          const procDispScaleValue = document.getElementById('procDispScaleValue');
8693          const procDispSeed = document.getElementById('procDispSeed');
8694          const procDispSeedValue = document.getElementById('procDispSeedValue');
8695          const procDispOctaves = document.getElementById('procDispOctaves');
8696          const procDispOctavesValue = document.getElementById('procDispOctavesValue');
8697          const procDispLacunarity = document.getElementById('procDispLacunarity');
8698          const procDispLacunarityValue = document.getElementById('procDispLacunarityValue');
8699          const procDispPersistence = document.getElementById('procDispPersistence');
8700          const procDispPersistenceValue = document.getElementById('procDispPersistenceValue');
8701          const procDispWaveFreq = document.getElementById('procDispWaveFreq');
8702          const procDispWaveFreqValue = document.getElementById('procDispWaveFreqValue');
8703          const procDispVoronoiJitter = document.getElementById('procDispVoronoiJitter');
8704          const procDispVoronoiJitterValue = document.getElementById('procDispVoronoiJitterValue');
8705          const procDispWarpStrength = document.getElementById('procDispWarpStrength');
8706          const procDispWarpStrengthValue = document.getElementById('procDispWarpStrengthValue');
8707          
8708          if (distortionEnabled) {
8709            distortionEnabled.checked = distortionData.enabled;
8710          }
8711          
8712          // Update panel state immediately after setting checkbox (same pattern as glitter)
8713          if (typeof syncDistortionPanelState === 'function') {
8714            syncDistortionPanelState();
8715          }
8716          
8717          if (procDispType && distortionData.type) procDispType.value = distortionData.type;
8718          if (procDispIntensity && distortionData.intensity !== undefined) {
8719            procDispIntensity.value = distortionData.intensity;
8720            if (procDispIntensityValue) procDispIntensityValue.value = distortionData.intensity;
8721          }
8722          if (procDispScale && distortionData.scale !== undefined) {
8723            procDispScale.value = distortionData.scale;
8724            if (procDispScaleValue) procDispScaleValue.value = distortionData.scale.toFixed(1);
8725          }
8726          if (procDispSeed && distortionData.seed !== undefined) {
8727            procDispSeed.value = distortionData.seed;
8728            if (procDispSeedValue) procDispSeedValue.value = distortionData.seed;
8729          }
8730          if (procDispOctaves && distortionData.octaves !== undefined) {
8731            procDispOctaves.value = distortionData.octaves;
8732            if (procDispOctavesValue) procDispOctavesValue.value = distortionData.octaves;
8733          }
8734          if (procDispLacunarity && distortionData.lacunarity !== undefined) {
8735            procDispLacunarity.value = distortionData.lacunarity;
8736            if (procDispLacunarityValue) procDispLacunarityValue.value = distortionData.lacunarity.toFixed(1);
8737          }
8738          if (procDispPersistence && distortionData.persistence !== undefined) {
8739            procDispPersistence.value = distortionData.persistence;
8740            if (procDispPersistenceValue) procDispPersistenceValue.value = distortionData.persistence.toFixed(2);
8741          }
8742          if (procDispWaveFreq && distortionData.waveFreq !== undefined) {
8743            procDispWaveFreq.value = distortionData.waveFreq;
8744            if (procDispWaveFreqValue) procDispWaveFreqValue.value = distortionData.waveFreq;
8745          }
8746          if (procDispVoronoiJitter && distortionData.voronoiJitter !== undefined) {
8747            procDispVoronoiJitter.value = distortionData.voronoiJitter;
8748            if (procDispVoronoiJitterValue) procDispVoronoiJitterValue.value = distortionData.voronoiJitter.toFixed(2);
8749          }
8750          if (procDispWarpStrength && distortionData.warpStrength !== undefined) {
8751            procDispWarpStrength.value = distortionData.warpStrength;
8752            if (procDispWarpStrengthValue) procDispWarpStrengthValue.value = distortionData.warpStrength.toFixed(1);
8753          }
8754          const procDispMix = document.getElementById('procDispMix');
8755          const procDispMixValue = document.getElementById('procDispMixValue');
8756          if (procDispMix && distortionData.mix !== undefined) {
8757            procDispMix.value = distortionData.mix;
8758            if (procDispMixValue) procDispMixValue.value = distortionData.mix.toFixed(2);
8759          }
8760          const procDispTayuMix = document.getElementById('procDispTayuMix');
8761          const procDispTayuMixValue = document.getElementById('procDispTayuMixValue');
8762          if (procDispTayuMix && distortionData.tayuMix !== undefined) {
8763            procDispTayuMix.value = distortionData.tayuMix;
8764            if (procDispTayuMixValue) procDispTayuMixValue.value = distortionData.tayuMix.toFixed(2);
8765          }
8766          if (typeof updateProcDispControls === 'function') {
8767            updateProcDispControls();
8768          }
8769        }
8770        
8771        // Restore pattern if present
8772        let patternData = null;
8773        if (isCompactFormat) {
8774          if (state.p !== undefined) {
8775            patternData = {
8776              enabled: state.p.e !== undefined ? state.p.e : DEFAULTS.p.e,
8777              type: state.p.t !== undefined ? state.p.t : DEFAULTS.p.t,
8778              dotSize: state.p.ds !== undefined ? state.p.ds : DEFAULTS.p.ds,
8779              dotDistance: state.p.dd !== undefined ? state.p.dd : DEFAULTS.p.dd,
8780              lineThickness: state.p.lt !== undefined ? state.p.lt : DEFAULTS.p.lt,
8781              lineDistance: state.p.ld !== undefined ? state.p.ld : DEFAULTS.p.ld,
8782              lineAngle: state.p.la !== undefined ? state.p.la : DEFAULTS.p.la,
8783              lineAxis: state.p.lx !== undefined ? state.p.lx : DEFAULTS.p.lx,
8784              color: state.p.c !== undefined ? state.p.c : DEFAULTS.p.c,
8785              opacity: state.p.o !== undefined ? state.p.o : DEFAULTS.p.o,
8786              blendMode: state.p.b !== undefined ? state.p.b : DEFAULTS.p.b
8787            };
8788          } else {
8789            patternData = DEFAULTS.p;
8790          }
8791        } else if (state.pattern) {
8792          patternData = state.pattern;
8793        }
8794        if (patternData) {
8795          const patternsEnabledEl = document.getElementById('patternsEnabled');
8796          const patternTypeEl = document.getElementById('patternType');
8797          const patternDotSizeEl = document.getElementById('patternDotSize');
8798          const patternDotSizeValueEl = document.getElementById('patternDotSizeValue');
8799          const patternDotDistanceEl = document.getElementById('patternDotDistance');
8800          const patternDotDistanceValueEl = document.getElementById('patternDotDistanceValue');
8801          const patternLineThicknessEl = document.getElementById('patternLineThickness');
8802          const patternLineThicknessValueEl = document.getElementById('patternLineThicknessValue');
8803          const patternLineDistanceEl = document.getElementById('patternLineDistance');
8804          const patternLineDistanceValueEl = document.getElementById('patternLineDistanceValue');
8805          const patternLineAngleEl = document.getElementById('patternLineAngle');
8806          const patternLineAngleValueEl = document.getElementById('patternLineAngleValue');
8807          const patternLineAxisEl = document.getElementById('patternLineAxis');
8808          const patternColorPickerEl = document.getElementById('patternColorPicker');
8809          const patternColorHexInputEl = document.getElementById('patternColorHexInput');
8810          const patternColorSwatchEl = document.getElementById('patternColorSwatch');
8811          const patternOpacityEl = document.getElementById('patternOpacity');
8812          const patternOpacityValueEl = document.getElementById('patternOpacityValue');
8813          const patternBlendModeEl = document.getElementById('patternBlendMode');
8814          if (patternsEnabledEl) patternsEnabledEl.checked = patternData.enabled;
8815          if (patternTypeEl && patternData.type) patternTypeEl.value = patternData.type;
8816          if (patternDotSizeEl && patternData.dotSize !== undefined) {
8817            patternDotSizeEl.value = patternData.dotSize;
8818            if (patternDotSizeValueEl) patternDotSizeValueEl.value = patternData.dotSize;
8819          }
8820          if (patternDotDistanceEl && patternData.dotDistance !== undefined) {
8821            patternDotDistanceEl.value = patternData.dotDistance;
8822            if (patternDotDistanceValueEl) patternDotDistanceValueEl.value = patternData.dotDistance;
8823          }
8824          if (patternLineThicknessEl && patternData.lineThickness !== undefined) {
8825            patternLineThicknessEl.value = patternData.lineThickness;
8826            if (patternLineThicknessValueEl) patternLineThicknessValueEl.value = patternData.lineThickness;
8827          }
8828          if (patternLineDistanceEl && patternData.lineDistance !== undefined) {
8829            patternLineDistanceEl.value = patternData.lineDistance;
8830            if (patternLineDistanceValueEl) patternLineDistanceValueEl.value = patternData.lineDistance;
8831          }
8832          if (patternLineAngleEl && patternData.lineAngle !== undefined) {
8833            patternLineAngleEl.value = patternData.lineAngle;
8834            if (patternLineAngleValueEl) patternLineAngleValueEl.value = patternData.lineAngle;
8835          }
8836          if (patternLineAxisEl && patternData.lineAxis !== undefined) {
8837            patternLineAxisEl.value = patternData.lineAxis === 1 ? '1' : '2';
8838            if (typeof updatePatternLineAxisIcon === 'function') updatePatternLineAxisIcon();
8839          }
8840          const hex = (patternData.color && /^#[0-9A-Fa-f]{6}$/.test(patternData.color)) ? patternData.color : '#ffffff';
8841          if (patternColorPickerEl) patternColorPickerEl.value = hex;
8842          if (patternColorHexInputEl) patternColorHexInputEl.value = hex;
8843          if (patternColorSwatchEl) patternColorSwatchEl.style.background = hex;
8844          if (patternOpacityEl && patternData.opacity !== undefined) {
8845            patternOpacityEl.value = patternData.opacity;
8846            if (patternOpacityValueEl) patternOpacityValueEl.value = patternData.opacity;
8847          }
8848          if (patternBlendModeEl && patternData.blendMode) {
8849            patternBlendModeEl.value = patternData.blendMode;
8850            if (typeof syncBlendModeIcon === 'function') {
8851              syncBlendModeIcon(patternBlendModeEl, document.getElementById('patternBlendModeIcon'));
8852            }
8853          }
8854          if (typeof syncPatternsPanelState === 'function') syncPatternsPanelState();
8855          if (typeof updatePatternTypeVisibility === 'function') updatePatternTypeVisibility();
8856        }
8857
8858        // Restore beta / chromatic aberration if present
8859        let betaData = null;
8860        if (isCompactFormat && state.bt !== undefined) {
8861          const fl = state.bt.fl || {};
8862          const pp = state.bt.pp || {};
8863          betaData = {
8864            enabled: state.bt.e !== undefined ? state.bt.e : DEFAULTS.bt.e,
8865            chromatic: {
8866              enabled: state.bt.c && state.bt.c.e !== undefined ? state.bt.c.e : DEFAULTS.bt.c.e,
8867              intensity: state.bt.c && state.bt.c.i !== undefined ? state.bt.c.i : DEFAULTS.bt.c.i
8868            },
8869            flow: {
8870              enabled: fl.e !== undefined ? fl.e : DEFAULTS.bt.fl.e,
8871              intensity: fl.i !== undefined ? fl.i : DEFAULTS.bt.fl.i,
8872              length: fl.l !== undefined ? fl.l : DEFAULTS.bt.fl.l,
8873              steps: fl.st !== undefined ? fl.st : DEFAULTS.bt.fl.st,
8874              decay: fl.d !== undefined ? fl.d : DEFAULTS.bt.fl.d,
8875              scale: fl.sc !== undefined ? fl.sc : DEFAULTS.bt.fl.sc,
8876              detail: fl.o !== undefined ? fl.o : DEFAULTS.bt.fl.o,
8877              direction: fl.a !== undefined ? fl.a : DEFAULTS.bt.fl.a,
8878              irregularity: fl.ir !== undefined ? fl.ir : DEFAULTS.bt.fl.ir,
8879              seed: fl.s !== undefined ? fl.s : DEFAULTS.bt.fl.s
8880            },
8881            paper: {
8882              enabled: pp.e !== undefined ? pp.e : DEFAULTS.bt.pp.e,
8883              intensity: pp.i !== undefined ? pp.i : DEFAULTS.bt.pp.i,
8884              seed: pp.s !== undefined ? pp.s : DEFAULTS.bt.pp.s
8885            }
8886          };
8887        } else if (isCompactFormat) {
8888          betaData = DEFAULTS.bt;
8889        } else if (state.beta) {
8890          betaData = state.beta;
8891        } else {
8892          betaData = DEFAULTS.bt;
8893        }
8894        if (betaData) {
8895          const betaEnabledEl = document.getElementById('betaEnabled');
8896          const chromaticEnabledEl = document.getElementById('chromaticEnabled');
8897          const caIntensityInputEl = document.getElementById('caIntensityInput');
8898          const caIntensityValueEl = document.getElementById('caIntensityValue');
8899          const enabled = betaData.enabled !== undefined ? betaData.enabled : !!betaData.e;
8900          const chrom = betaData.chromatic || betaData.c || {};
8901          if (betaEnabledEl) betaEnabledEl.checked = enabled;
8902          if (chromaticEnabledEl) chromaticEnabledEl.checked = chrom.enabled !== undefined ? chrom.enabled : !!chrom.e;
8903          const intensity = chrom.intensity !== undefined ? chrom.intensity
8904            : (chrom.i !== undefined ? chrom.i
8905            : (chrom.strength !== undefined ? chrom.strength
8906            : (chrom.s !== undefined ? chrom.s : DEFAULTS.bt.c.i)));
8907          if (caIntensityInputEl) caIntensityInputEl.value = intensity;
8908          if (caIntensityValueEl) caIntensityValueEl.value = intensity;
8909
8910          const flow = betaData.flow || betaData.fl || {};
8911          const flowEnabledEl = document.getElementById('flowEnabled');
8912          if (flowEnabledEl) flowEnabledEl.checked = flow.enabled !== undefined ? flow.enabled : !!flow.e;
8913          const setFlowPair = (inputId, valueId, val) => {
8914            if (val === undefined) return;
8915            const inEl = document.getElementById(inputId);
8916            const valEl = document.getElementById(valueId);
8917            if (inEl) inEl.value = val;
8918            if (valEl) valEl.value = val;
8919          };
8920          setFlowPair('flowIntensityInput', 'flowIntensityValue', flow.intensity !== undefined ? flow.intensity : flow.i);
8921          setFlowPair('flowLengthInput', 'flowLengthValue', flow.length !== undefined ? flow.length : flow.l);
8922          setFlowPair('flowStepsInput', 'flowStepsValue', flow.steps !== undefined ? flow.steps : flow.st);
8923          setFlowPair('flowDecayInput', 'flowDecayValue', flow.decay !== undefined ? flow.decay : flow.d);
8924          setFlowPair('flowScaleInput', 'flowScaleValue', flow.scale !== undefined ? flow.scale : flow.sc);
8925          setFlowPair('flowDetailInput', 'flowDetailValue', flow.detail !== undefined ? flow.detail : flow.o);
8926          setFlowPair('flowDirectionInput', 'flowDirectionValue', flow.direction !== undefined ? flow.direction : flow.a);
8927          setFlowPair('flowIrregularityInput', 'flowIrregularityValue', flow.irregularity !== undefined ? flow.irregularity : flow.ir);
8928          setFlowPair('flowSeedInput', 'flowSeedValue', flow.seed !== undefined ? flow.seed : flow.s);
8929
8930          const paper = betaData.paper || betaData.pp || {};
8931          const paperEnabledEl = document.getElementById('paperEnabled');
8932          if (paperEnabledEl) paperEnabledEl.checked = paper.enabled !== undefined ? paper.enabled : !!paper.e;
8933          setFlowPair('paperIntensityInput', 'paperIntensityValue', paper.intensity !== undefined ? paper.intensity : paper.i);
8934          setFlowPair('paperSeedInput', 'paperSeedValue', paper.seed !== undefined ? paper.seed : paper.s);
8935
8936          // Beta effects are PRO-only: strip them when restoring state
8937          // (share links, templates, pending-login restore) for non-PRO users.
8938          if (BETA_GATE_ENABLED && !isProUser()) {
8939            if (chromaticEnabledEl) chromaticEnabledEl.checked = false;
8940            if (flowEnabledEl) flowEnabledEl.checked = false;
8941            if (paperEnabledEl) paperEnabledEl.checked = false;
8942          }
8943
8944          if (typeof window.syncBetaPanelState === 'function') window.syncBetaPanelState();
8945        }
8946        
8947        // Restore material if present
8948        let materialData = null;
8949        if (isCompactFormat) {
8950          if (state.m !== undefined) {
8951            materialData = {
8952              enabled: state.m.e !== undefined ? state.m.e : false,
8953              preset: state.m.p !== undefined ? state.m.p : 'metal',
8954              strength: state.m.st !== undefined ? state.m.st : 50
8955            };
8956          }
8957        } else if (state.material) {
8958          materialData = state.material;
8959        }
8960        if (materialData) {
8961          const materialStrengthEl = document.getElementById('materialStrength');
8962          const materialStrengthInputEl = document.getElementById('materialStrengthInput');
8963          if (materialStrengthEl != null && materialData.strength !== undefined) {
8964            const st = Math.max(0, materialData.strength);
8965            materialStrengthEl.value = st;
8966            if (materialStrengthInputEl) materialStrengthInputEl.value = Math.min(50, st);
8967          }
8968        }
8969        
8970        // Sync color picker to the first stop of the freshly restored gradient
8971        // so the swatch/hex never lingers on the previous (e.g. default #ff595e) value.
8972        if (gradientStopsArray.length > 0) {
8973          selectedStopIndex = 0;
8974          selectedStopId = gradientStopsArray[0].id;
8975          if (typeof updateColorPreviewFromSelectedStop === 'function') {
8976            updateColorPreviewFromSelectedStop();
8977          }
8978          if (typeof renderGradientBar === 'function') renderGradientBar();
8979        }
8980
8981        ColirSurfaceFilters.restore(state.surfaceFilters);
8982        const anim = state.a !== undefined ? state.a : state.animation;
8983        if (window.ColirAnimation) window.ColirAnimation.restore(anim);
8984        else window.__colirPendingAnimation = anim || null;
8985        prioritizeCurrentDesign();
8986        if(previousPreview&&!window.ColirRenderPrograms.designReady)gctx.drawImage(previousPreview,0,0,gradientCanvas.width,gradientCanvas.height);
8987
8988        // Trigger render after a small delay to ensure DOM is updated
8989        if (typeof renderGradient === 'function') {
8990          // Small delay to ensure DOM updates are complete
8991          setTimeout(() => {
8992            renderGradient();
8993          }, 50);
8994        }
8995      } catch (error) {
8996        console.error('Error restoring gradient from data:', error);
8997      }
8998    }
8999
9000    // Helper function to sync slider and number input
9001    function syncSliderInput(sliderId, inputId, render = true) {
9002      const slider = document.getElementById(sliderId);
9003      const input = document.getElementById(inputId);
9004      if (!slider || !input) return;
9005      
9006      slider.addEventListener('input', () => {
9007        input.value = slider.value;
9008        if (render) renderGradient();
9009      });
9010      
9011      input.addEventListener('input', () => {
9012        const numVal = parseFloat(input.value);
9013        if (!isNaN(numVal)) {
9014          const min = parseFloat(slider.min);
9015          const max = parseFloat(slider.max);
9016          const val = Math.min(max, Math.max(min, numVal));
9017          slider.value = val;
9018          input.value = val;
9019          if (render) renderGradient();
9020        }
9021      });
9022      
9023      input.addEventListener('blur', () => {
9024        const numVal = parseFloat(input.value);
9025        if (!isNaN(numVal)) {
9026          const min = parseFloat(slider.min);
9027          const max = parseFloat(slider.max);
9028          const val = Math.min(max, Math.max(min, numVal));
9029          slider.value = val;
9030          input.value = val;
9031          if (render) renderGradient();
9032        }
9033      });
9034    }
9035
9036
9037    function updateColorCountControls() {
9038      // Amount of colors slider is always active now
9039      if (multiColorCountSlider) {
9040        multiColorCountSlider.disabled = false;
9041        const colorCountContainer = multiColorCountSlider.closest('label');
9042        if (colorCountContainer) {
9043          colorCountContainer.classList.remove('disabled');
9044        }
9045      }
9046      if (multiColorCount) {
9047        multiColorCount.disabled = false;
9048      }
9049    }
9050    
9051    // Canvas size change handlers
9052    widthInput.addEventListener('change', () => {
9053      if (!isRestoringHistory) {
9054        saveHistory(); // Save state before changing canvas size
9055      }
9056      setCanvasSize();
9057    });
9058    
9059    widthInput.addEventListener('keydown', (e) => {
9060      if (e.key === 'Enter') {
9061        e.preventDefault();
9062        widthInput.blur();
9063      }
9064    });
9065    
9066    heightInput.addEventListener('change', () => {
9067      if (!isRestoringHistory) {
9068        saveHistory(); // Save state before changing canvas size
9069      }
9070      setCanvasSize();
9071    });
9072    
9073    heightInput.addEventListener('keydown', (e) => {
9074      if (e.key === 'Enter') {
9075        e.preventDefault();
9076        heightInput.blur();
9077      }
9078    });
9079    
9080    const exportScale = document.getElementById('exportScale');
9081    const exportFormat = document.getElementById('exportFormat');
9082    const gradientBar = document.getElementById('gradientBar');
9083    const gradientStops = document.getElementById('gradientStops');
9084    const removeStopBtn = document.getElementById('removeStopBtn');
9085    const stopColorSwatch = document.getElementById('stopColorSwatch');
9086    const stopColorHexInput = document.getElementById('stopColorHexInput');
9087    const stopColorRow = document.getElementById('stopColorRow');
9088    const stopColorDropdown = document.getElementById('stopColorDropdown');
9089    const stopColorDropdownClose = document.getElementById('stopColorDropdownClose');
9090    const stopSpectrumCanvas = document.getElementById('stopSpectrumCanvas');
9091    const stopHueCanvas = document.getElementById('stopHueCanvas');
9092    const stopSpectrumMarker = document.getElementById('stopSpectrumMarker');
9093    const stopHueMarker = document.getElementById('stopHueMarker');
9094    let stopPickerInteracting = false;
9095
9096    // Gradient stops state
9097    let stopIdCounter = 0;
9098    let gradientStopsArray = [
9099      { id: stopIdCounter++, position: 0, color: { r: 255, g: 89, b: 94 } },    // #ff595e
9100      { id: stopIdCounter++, position: 0.25, color: { r: 255, g: 202, b: 58 } }, // #ffca3a
9101      { id: stopIdCounter++, position: 0.5, color: { r: 138, g: 201, b: 38 } },   // #8ac926
9102      { id: stopIdCounter++, position: 0.75, color: { r: 25, g: 130, b: 196 } }, // #1982c4
9103      { id: stopIdCounter++, position: 1, color: { r: 106, g: 76, b: 147 } }      // #6a4c93
9104    ];
9105    let selectedStopIndex = -1; // -1 means no stop is selected
9106    let selectedStopId = null; // Store the ID of the selected stop for reliable lookup
9107
9108    // Color picker state (for editing selected stop)
9109    let currentHue = 240; // Blue in HSL (0-360)
9110    let currentSaturation = 100; // 0-100
9111    let currentLightness = 50; // 0-100
9112    const toggleCurvesBtn = document.getElementById('toggleCurvesBtn');
9113    const toggleCurvesIcon = toggleCurvesBtn.querySelector('.toggle-curves-icon');
9114    const editXBtn = document.getElementById('editXBtn');
9115    const editYBtn = document.getElementById('editYBtn');
9116    const invertCurveBtn = document.getElementById('invertCurveBtn');
9117    const randomizeColorsBtn = document.getElementById('randomizeColorsBtn');
9118    const colorTemplateSelect = document.getElementById('colorTemplateSelect');
9119    const multiColorCount = document.getElementById('multiColorCount');
9120    const multiColorCountSlider = document.getElementById('multiColorCountSlider');
9121    const randomizeCurvesBtn = document.getElementById('randomizeCurvesBtn');
9122    // Seeded RNG for reproducible randomization
9123    const seededRNG = new SeededRNG(0);
9124
9125    // Initialize complexity - fixed defaults: X = 4, Y = 3
9126    const initXComplexity = 4;
9127    const initYComplexity = 3;
9128    xEditor.setMaxPoints(initXComplexity);
9129    yEditor.setMaxPoints(initYComplexity);
9130
9131    // When multi-color random is used, this holds an array of RGB colors.
9132    let activePalette = null;
9133    
9134    // Undo/Redo history (10 steps)
9135    const historyMaxSteps = 10;
9136    let history = [];
9137    let historyIndex = -1;
9138    let isRestoringHistory = false; // Flag to prevent saving history during restore
9139    
9140    // Save current state to history
9141    let lastSaveTime = 0;
9142    const SAVE_THROTTLE_MS = 50; // Prevent duplicate saves within 50ms
9143    
9144    function saveHistory() {
9145      if (isRestoringHistory) return;
9146      
9147      // Throttle saves to prevent duplicate saves from rapid events
9148      const now = performance.now();
9149      if (now - lastSaveTime < SAVE_THROTTLE_MS) {
9150        return; // Skip if save was called too recently
9151      }
9152      lastSaveTime = now;
9153      
9154      // Remove any future history if we're not at the end
9155      if (historyIndex < history.length - 1) {
9156        history = history.slice(0, historyIndex + 1);
9157      }
9158      
9159      const state = {
9160        surfaceFilters: ColirSurfaceFilters.snapshot(),
9161        gradientStops: JSON.parse(JSON.stringify(gradientStopsArray)), // Deep copy
9162        xPoints: JSON.parse(JSON.stringify(xEditor.points)), // Deep copy
9163        yPoints: JSON.parse(JSON.stringify(yEditor.points)), // Deep copy
9164        canvasWidth: parseInt(widthInput.value) || 900,
9165        canvasHeight: parseInt(heightInput.value) || 520,
9166        blendMode: blendMode.value,
9167        noise: parseInt(noiseInput.value) || 0,
9168        feather: parseInt(featherInput.value) || 0
9169      };
9170      
9171      // Limit history to max steps BEFORE adding new state
9172      if (history.length >= historyMaxSteps) {
9173        history.shift(); // Remove oldest
9174        // Don't adjust historyIndex here - we'll set it correctly after push
9175      }
9176      
9177      history.push(state);
9178      // historyIndex should point to the last element (newly added state)
9179      historyIndex = history.length - 1;
9180      
9181      // Update button states
9182      if (typeof updateHistoryButtons === 'function') {
9183        updateHistoryButtons();
9184      }
9185    }
9186    
9187    // Restore state from history
9188    function restoreHistory(direction) {
9189      if (direction === 'undo' && historyIndex > 0) {
9190        historyIndex--;
9191      } else if (direction === 'redo' && historyIndex < history.length - 1) {
9192        historyIndex++;
9193      } else {
9194        return; // Can't undo/redo further
9195      }
9196      
9197      const state = history[historyIndex];
9198      if (!state) return;
9199      
9200      isRestoringHistory = true;
9201      
9202      ColirSurfaceFilters.restore(state.surfaceFilters);
9203      // Restore gradient stops
9204      gradientStopsArray = JSON.parse(JSON.stringify(state.gradientStops));
9205      renderGradientBar();
9206      
9207      // Restore curve points
9208      xEditor.setPoints(JSON.parse(JSON.stringify(state.xPoints)));
9209      yEditor.setPoints(JSON.parse(JSON.stringify(state.yPoints)));
9210      
9211      // Restore canvas size (setCanvasSize won't save history due to isRestoringHistory flag)
9212      widthInput.value = state.canvasWidth;
9213      heightInput.value = state.canvasHeight;
9214      setCanvasSize();
9215      
9216      // Restore blend mode
9217      blendMode.value = state.blendMode;
9218      
9219      // Restore noise and feather
9220      noiseInput.value = state.noise;
9221      noiseValue.value = state.noise;
9222      featherInput.value = state.feather;
9223      featherValue.value = state.feather;
9224      
9225      // Redraw everything
9226      xEditor.draw(activeCurve === 'x');
9227      yEditor.draw(activeCurve === 'y');
9228      renderGradient();
9229      
9230      // Update button states
9231      if (typeof updateHistoryButtons === 'function') {
9232        updateHistoryButtons();
9233      }
9234      
9235      isRestoringHistory = false;
9236    }
9237    
9238    // Undo/Redo buttons
9239    const undoBtn = document.getElementById('undoBtn');
9240    const redoBtn = document.getElementById('redoBtn');
9241    
9242    // Update button states based on history
9243    function updateHistoryButtons() {
9244      if (undoBtn) {
9245        // Can undo only if we have history and we're not at the first state
9246        const canUndo = history.length > 0 && historyIndex > 0;
9247        undoBtn.disabled = !canUndo;
9248        undoBtn.style.opacity = canUndo ? '1' : '0.5';
9249        undoBtn.style.cursor = canUndo ? 'pointer' : 'not-allowed';
9250        undoBtn.style.pointerEvents = canUndo ? 'auto' : 'none';
9251      }
9252      if (redoBtn) {
9253        // Can redo only if we have future history
9254        const canRedo = history.length > 0 && historyIndex < history.length - 1;
9255        redoBtn.disabled = !canRedo;
9256        redoBtn.style.opacity = canRedo ? '1' : '0.5';
9257        redoBtn.style.cursor = canRedo ? 'pointer' : 'not-allowed';
9258        redoBtn.style.pointerEvents = canRedo ? 'auto' : 'none';
9259      }
9260    }
9261    
9262    if (undoBtn) {
9263      undoBtn.addEventListener('click', (e) => {
9264        if (history.length === 0 || historyIndex <= 0) {
9265          e.preventDefault();
9266          e.stopPropagation();
9267          return false;
9268        }
9269        restoreHistory('undo');
9270        updateHistoryButtons();
9271      });
9272    }
9273    
9274    if (redoBtn) {
9275      redoBtn.addEventListener('click', (e) => {
9276        if (history.length === 0 || historyIndex >= history.length - 1) {
9277          e.preventDefault();
9278          e.stopPropagation();
9279          return false;
9280        }
9281        restoreHistory('redo');
9282        updateHistoryButtons();
9283      });
9284    }
9285    
9286    // Keyboard shortcuts for undo/redo
9287    document.addEventListener('keydown', (e) => {
9288      // Check for Ctrl/Cmd + Z (undo) or Ctrl/Cmd + Shift + Z / Ctrl/Cmd + R (redo)
9289      const isUndo = (e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey;
9290      const isRedo = ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) || 
9291                     ((e.ctrlKey || e.metaKey) && e.key === 'r');
9292      
9293      // Don't trigger if user is typing in an input field
9294      if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
9295        // Allow undo/redo in text inputs, but check if it's a color/hex input
9296        if (e.target.id === 'stopColorHexInput' || e.target.type === 'text') {
9297          return; // Let browser handle text undo/redo
9298        }
9299      }
9300      
9301      if (isUndo) {
9302        if (history.length === 0 || historyIndex <= 0) {
9303          e.preventDefault();
9304          return;
9305        }
9306        e.preventDefault();
9307        restoreHistory('undo');
9308        updateHistoryButtons();
9309      } else if (isRedo) {
9310        if (history.length === 0 || historyIndex >= history.length - 1) {
9311          e.preventDefault();
9312          return;
9313        }
9314        e.preventDefault();
9315        restoreHistory('redo');
9316        updateHistoryButtons();
9317      }
9318    });
9319    
9320    // Set default color count to 5 (matches initial gradientStopsArray)
9321    if (multiColorCountSlider) {
9322      multiColorCountSlider.value = 5;
9323    }
9324    if (multiColorCount) {
9325      multiColorCount.value = 5;
9326    }
9327    // Initialize color count controls (always enabled)
9328    updateColorCountControls();
9329    let curvesVisible = true;
9330    let activeCurve = 'x'; // 'x' or 'y'
9331
9332    // Curves UI
9333    toggleCurvesBtn.addEventListener('click', () => {
9334      curvesVisible = !curvesVisible;
9335      toggleCurvesBtn.classList.toggle('on', curvesVisible);
9336      toggleCurvesBtn.setAttribute('aria-pressed', curvesVisible ? 'true' : 'false');
9337      ColirIcons.set(toggleCurvesIcon, curvesVisible ? 'visibility' : 'visibility_off');
9338      document.getElementById('xCurveCanvas').style.display = curvesVisible ? '' : 'none';
9339      document.getElementById('yCurveCanvas').style.display = curvesVisible ? '' : 'none';
9340      // Also update class for hit testing
9341      if (curvesVisible) {
9342        document.getElementById('xCurveCanvas').classList.add('curves-enabled');
9343        document.getElementById('yCurveCanvas').classList.add('curves-enabled');
9344        redrawCurveOverlays();
9345      } else {
9346        document.getElementById('xCurveCanvas').classList.remove('curves-enabled');
9347        document.getElementById('yCurveCanvas').classList.remove('curves-enabled');
9348      }
9349    });
9350
9351    // Randomize curves button - randomize on click with smooth transition
9352    randomizeCurvesBtn.addEventListener('click', () => {
9353      saveHistory(); // Save state before randomizing curves
9354      
9355      // Store old curve points for transition
9356      const oldXPoints = JSON.parse(JSON.stringify(xEditor.points));
9357      const oldYPoints = JSON.parse(JSON.stringify(yEditor.points));
9358      const oldXComplexity = xEditor.points.length;
9359      const oldYComplexity = yEditor.points.length;
9360      
9361      // Generate new curves
9362      const seed = Math.floor(Math.random() * 1000000);
9363      const xComplexity = Math.floor(Math.random() * 3) + 3; // 3, 4, or 5
9364      const yComplexity = Math.floor(Math.random() * 3) + 3; // 3, 4, or 5
9365      
9366      // Generate new points using separate RNG instances to ensure consistent randomness
9367      const xRNG = new SeededRNG(seed);
9368      const yRNG = new SeededRNG(seed + 1000000); // Different seed for Y to ensure different values
9369      
9370      const newXPoints = [];
9371      const newYPoints = [];
9372      
9373      // Generate X curve points
9374      for (let i = 0; i < xComplexity; i++) {
9375        const t = i / (xComplexity - 1);
9376        newXPoints.push({
9377          t: t,
9378          v: xRNG.random()
9379        });
9380      }
9381      
9382      // Generate Y curve points
9383      for (let i = 0; i < yComplexity; i++) {
9384        const t = i / (yComplexity - 1);
9385        newYPoints.push({
9386          t: t,
9387          v: yRNG.random()
9388        });
9389      }
9390      
9391      // Set max points first
9392      xEditor.setMaxPoints(xComplexity);
9393      yEditor.setMaxPoints(yComplexity);
9394      
9395      // Helper function to sample curve value at a given t
9396      function sampleCurveAtT(oldPoints, t) {
9397        if (oldPoints.length === 0) return 0;
9398        if (oldPoints.length === 1) return oldPoints[0].v;
9399        
9400        // Find the segment that contains t
9401        for (let i = 0; i < oldPoints.length - 1; i++) {
9402          if (t >
9402= oldPoints[i].t && t <= oldPoints[i + 1].t) {
9403            // Interpolate between these two points
9404            const t0 = oldPoints[i].t;
9405            const t1 = oldPoints[i + 1].t;
9406            const v0 = oldPoints[i].v;
9407            const v1 = oldPoints[i + 1].v;
9408            const localT = (t - t0) / (t1 - t0);
9409            return v0 + (v1 - v0) * localT;
9410          }
9411        }
9412        
9413        // If t is outside range, return closest point value
9414        if (t <= oldPoints[0].t) return oldPoints[0].v;
9415        return oldPoints[oldPoints.length - 1].v;
9416      }
9417      
9418      // Create starting points for animation
9419      // If point count changed, sample old curve at new point positions
9420      const startXPoints = newXPoints.map(newPoint => ({
9421        t: newPoint.t,
9422        v: sampleCurveAtT(oldXPoints, newPoint.t)
9423      }));
9424      
9425      const startYPoints = newYPoints.map(newPoint => ({
9426        t: newPoint.t,
9427        v: sampleCurveAtT(oldYPoints, newPoint.t)
9428      }));
9429      
9430      // Set initial points to start positions
9431      xEditor.setPoints(startXPoints);
9432      yEditor.setPoints(startYPoints);
9433      
9434      // Animate transition over 250ms
9435      const duration = 250; // milliseconds
9436      const startTime = performance.now();
9437      
9438      function animateCurveTransition(currentTime) {
9439        const elapsed = currentTime - startTime;
9440        const progress = Math.min(elapsed / duration, 1);
9441        
9442        // Ease-in-out for smooth transition
9443        const eased = progress < 0.5
9444          ? 2 * progress * progress
9445          : 1 - Math.pow(-2 * progress + 2, 2) / 2;
9446        
9447        // Interpolate X curve points
9448        const currentXPoints = newXPoints.map((newPoint, i) => ({
9449          t: newPoint.t,
9450          v: startXPoints[i].v + (newPoint.v - startXPoints[i].v) * eased
9451        }));
9452        
9453        // Interpolate Y curve points
9454        const currentYPoints = newYPoints.map((newPoint, i) => ({
9455          t: newPoint.t,
9456          v: startYPoints[i].v + (newPoint.v - startYPoints[i].v) * eased
9457        }));
9458        
9459        // Update curves
9460        xEditor.setPoints(currentXPoints);
9461        yEditor.setPoints(currentYPoints);
9462        
9463        // Invalidate cache to force re-evaluation
9464        cachedXSamples = null;
9465        cachedYSamples = null;
9466        
9467        // Update display
9468        xEditor.draw(activeCurve === 'x');
9469        yEditor.draw(activeCurve === 'y');
9470        renderGradient();
9471        
9472        // Continue animation if not complete
9473        if (progress < 1) {
9474          requestAnimationFrame(animateCurveTransition);
9475        } else {
9476          // Ensure final points are set exactly
9477          xEditor.setPoints(newXPoints);
9478          yEditor.setPoints(newYPoints);
9479          cachedXSamples = null;
9480          cachedYSamples = null;
9481          xEditor.draw(activeCurve === 'x');
9482          yEditor.draw(activeCurve === 'y');
9483          renderGradient();
9484        }
9485      }
9486      
9487      requestAnimationFrame(animateCurveTransition);
9488    });
9489
9490    // Determine which editor is active
9491    function updateActiveCurveUI() {
9492      editXBtn.classList.toggle('active', activeCurve === 'x');
9493      editYBtn.classList.toggle('active', activeCurve === 'y');
9494      // Bring canvas to front and manage pointer events
9495      const xCanvas = document.getElementById('xCurveCanvas');
9496      const yCanvas = document.getElementById('yCurveCanvas');
9497      
9498      if (activeCurve === 'x') {
9499        xCanvas.style.zIndex = 10;
9500        yCanvas.style.zIndex = 1;
9501        // Disable pointer events on inactive canvas to prevent blocking
9502        if (yCanvas.classList.contains('curves-enabled')) {
9503          yCanvas.style.pointerEvents = 'none';
9504        }
9505        xCanvas.style.pointerEvents = 'all';
9506        // Add active class for blend mode override
9507        xCanvas.classList.add('active-curve');
9508        yCanvas.classList.remove('active-curve');
9509      } else {
9510        xCanvas.style.zIndex = 1;
9511        yCanvas.style.zIndex = 10;
9512        // Disable pointer events on inactive canvas to prevent blocking
9513        if (xCanvas.classList.contains('curves-enabled')) {
9514          xCanvas.style.pointerEvents = 'none';
9515        }
9516        yCanvas.style.pointerEvents = 'all';
9517        // Add active class for blend mode override
9518        yCanvas.classList.add('active-curve');
9519        xCanvas.classList.remove('active-curve');
9520      }
9521      
9522      // Redraw curves to reflect active state
9523      xEditor.draw(activeCurve === 'x');
9524      yEditor.draw(activeCurve === 'y');
9525    }
9526    updateActiveCurveUI();
9527
9528    editXBtn.addEventListener('click', () => {
9529      activeCurve = 'x';
9530      updateActiveCurveUI();
9531    });
9532    editYBtn.addEventListener('click', () => {
9533      activeCurve = 'y';
9534      updateActiveCurveUI();
9535    });
9536
9537    invertCurveBtn.addEventListener('click', () => {
9538      if (activeCurve === 'x') xEditor.invert();
9539      else yEditor.invert();
9540      renderGradient();
9541    });
9542
9543    // Curve smoothness toggle (Sharp/Smooth)
9544    // Curve smoothing toggle
9545    const curveSmoothingToggle = document.getElementById('curveSmoothingToggle');
9546    
9547    if (curveSmoothingToggle) {
9548      curveSmoothingToggle.addEventListener('click', () => {
9549        curveSmoothingToggle.classList.toggle('on');
9550        // Invalidate curve samples cache so gradient updates with new smoothing
9551        cachedXSamples = null;
9552        cachedYSamples = null;
9553        xEditor.draw(activeCurve === 'x');
9554        yEditor.draw(activeCurve === 'y');
9555        renderGradient();
9556      });
9557    }
9558
9559    let canvasSizeInitialized = false;
9560
9561    function setCanvasSize() {
9562      // Don't save history here - it's called from restoreHistory and would create duplicate entries
9563      // History should be saved by the action that triggers the size change (user input)
9564      
9565      let userW, userH;
9566      
9567      // Only set initial size on first load, otherwise use user input values
9568      if (!canvasSizeInitialized) {
9569        // Calculate available space with 50px padding on each side
9570        const sidebar = document.querySelector('.sidebar');
9571        const sidebarWidth = sidebar ? sidebar.offsetWidth : 320;
9572        const sidePadding = 50; // 50px padding on left and right
9573        const bottomPadding = 50; // 50px padding on bottom
9574        
9575        // Calculate available width: window width - sidebar - left padding - right padding
9576        const outputAreaWidth = window.innerWidth - sidebarWidth;
9577        const availableW = Math.max(300, outputAreaWidth - sidePadding * 2);
9578        
9579        // Get UI islands container and canvas-root for height calculation
9580        const outputArea = document.querySelector('.output-area');
9581        const uiIslandsContainer = document.querySelector('.ui-islands-container');
9582        const canvasRoot = document.querySelector('.canvas-root');
9583        
9584        // Calculate available height: output area height - UI islands container height - 50px margin - bottom padding
9585        const uiIslandsHeight = uiIslandsContainer ? uiIslandsContainer.offsetHeight : 0;
9586        const topMargin = 50; // 50px margin-top on canvas-root (defined in CSS)
9587        const outputAreaHeight = outputArea ? outputArea.clientHeight : window.innerHeight;
9588        const availableH = Math.max(200, outputAreaHeight - uiIslandsHeight - topMargin - bottomPadding);
9589        
9590        
9591        // Round to even numbers (divisible by 2) for auto-calculated size only
9592        // This helps with rendering performance, but users can still set odd numbers manually
9593        userW = Math.round(availableW / 2) * 2;
9594        userH = Math.round(availableH / 2) * 2;
9595
9596        // Set initial values in inputs
9597        if (widthInput) widthInput.value = userW;
9598        if (heightInput) heightInput.value = userH;
9599        canvasSizeInitialized = true;
9600      } else {
9601        // Use current input values (user can set their own size)
9602        userW = parseInt(widthInput.value) || 900;
9603        userH = parseInt(heightInput.value) || 520;
9604      }
9605
9606      // Set canvas to user dimensions directly (internal resolution)
9607      gradientCanvas.width = userW;
9608      gradientCanvas.height = userH;
9609
9610      // Calculate display size - scale down if canvas is larger than available space
9611      const outputArea = document.querySelector('.output-area');
9612      const canvasRoot = document.querySelector('.canvas-root');
9613      const uiIslandsContainer = document.querySelector('.ui-islands-container');
9614      
9615      // Account for 50px padding on each side
9616      const sidePadding = 50;
9617      const bottomPadding = 50;
9618      
9619      // Get actual computed padding from CSS
9620      let computedBottomPadding = bottomPadding;
9621      let computedLeftPadding = sidePadding;
9622      let computedRightPadding = sidePadding;
9623      
9624      if (canvasRoot) {
9625        const computedStyle = getComputedStyle(canvasRoot);
9626        computedBottomPadding = parseFloat(computedStyle.paddingBottom) || bottomPadding;
9627        computedLeftPadding = parseFloat(computedStyle.paddingLeft) || sidePadding;
9628        computedRightPadding = parseFloat(computedStyle.paddingRight) || sidePadding;
9629      }
9630      
9631      // Calculate available space: output area size minus padding on sides
9632      // Height: canvas-root height (which is flex: 1 and accounts for UI islands container + margin)
9633      const availableW = outputArea ? Math.max(1, Math.floor(outputArea.clientWidth - computedLeftPadding - computedRightPadding)) : userW;
9634      const canvasRootHeight = canvasRoot ? canvasRoot.clientHeight : (outputArea ? outputArea.clientHeight : userH);
9635      const availableH = Math.max(1, Math.floor(canvasRootHeight - computedBottomPadding));
9636      
9637      // Calculate scale factor to fit within available space
9638      const scaleW = availableW / userW;
9639      const scaleH = availableH / userH;
9640      const scale = Math.min(1.0, scaleW, scaleH); // Don't scale up, only down
9641      
9642      
9643      // Calculate display dimensions
9644      const displayW = Math.round(userW * scale);
9645      const displayH = Math.round(userH * scale);
9646
9647      // Set wrapper to display dimensions (rounded to integers)
9648      if (canvasWrapper) {
9649        canvasWrapper.style.width = `${displayW}px`;
9650        canvasWrapper.style.height = `${displayH}
9650px`;
9651      }
9652
9653      // Set canvas CSS dimensions to display size (scaled if needed)
9654      gradientCanvas.style.width = `${displayW}px`;
9655      gradientCanvas.style.height = `${displayH}px`;
9656
9657      // Curve overlays use display size × devicePixelRatio so stroke/points stay constant on screen
9658      const xCurveCanvas = document.getElementById('xCurveCanvas');
9659      const yCurveCanvas = document.getElementById('yCurveCanvas');
9660      const curveDpr = window.devicePixelRatio || 1;
9661      const curveW = Math.max(1, Math.round(displayW * curveDpr));
9662      const curveH = Math.max(1, Math.round(displayH * curveDpr));
9663      xCurveCanvas.width = curveW;
9664      xCurveCanvas.height = curveH;
9665      yCurveCanvas.width = curveW;
9666      yCurveCanvas.height = curveH;
9667      xCurveCanvas.style.width = `${displayW}px`;
9668      xCurveCanvas.style.height = `${displayH}px`;
9669      yCurveCanvas.style.width = `${displayW}px`;
9670      yCurveCanvas.style.height = `${displayH}px`;
9671      
9672      // Enable image smoothing when scaled down for better quality
9673      if (scale < 1.0) {
9674        gctx.imageSmoothingEnabled = true;
9675      } else {
9676        // Disable image smoothing for pixel-perfect rendering when not scaled
9677        gctx.imageSmoothingEnabled = false;
9678      }
9679      xCurveCanvas.getContext('2d').imageSmoothingEnabled = true;
9680      yCurveCanvas.getContext('2d').imageSmoothingEnabled = true;
9681
9682      if (tubeRenderer && tubeRenderer.ok) {
9683        tubeRenderer.resize(curveW, curveH);
9684        tubeRenderer.updateLUT();
9685      }
9686      xEditor.draw(activeCurve === 'x');
9687      yEditor.draw(activeCurve === 'y');
9688      renderGradient();
9689    }
9690
9691    function lerp(a, b, t) { return a + (b - a) * t; }
9692
9693    function clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; }
9694    function srgbToLinear01(u) {
9695      u = clamp01(u);
9696      return u <= 0.04045 ? (u / 12.92) : Math.pow((u + 0.055) / 1.055, 2.4);
9697    }
9698    function linearToSrgb01(u) {
9699      u = clamp01(u);
9700      return u <= 0.0031308 ? (12.92 * u) : (1.055 * Math.pow(u, 1 / 2.4) - 0.055);
9701    }
9702
9703    function oklabToLinearSrgb01(L, a, b) {
9704      // OKLab -> linear sRGB (Björn Ottosson)
9705      const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
9706      const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
9707      const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
9708      const l = l_ * l_ * l_;
9709      const m = m_ * m_ * m_;
9710      const s = s_ * s_ * s_;
9711      const r =  4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
9712      const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
9713      const bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
9714      return { r, g, b: bl };
9715    }
9716
9717    function linearSrgbToOklab(r, g, b) {
9718      // linear sRGB -> OKLab (Björn Ottosson)
9719      const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
9720      const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
9721      const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
9722      const l_ = Math.cbrt(l);
9723      const m_ = Math.cbrt(m);
9724      const s_ = Math.cbrt(s);
9725      const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
9726      const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
9727      const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
9728      return { L, a, b: bb };
9729    }
9730
9731    function oklabToOklch(L, a, b) {
9732      const C = Math.sqrt(a * a + b * b);
9733      let h = Math.atan2(b, a) * (180 / Math.PI);
9734      if (h < 0) h += 360;
9735      return { l: L, c: C, h };
9736    }
9737
9738    function oklchToOklab(l, c, hDeg) {
9739      const h = (hDeg * Math.PI) / 180;
9740      return { L: l, a: c * Math.cos(h), b: c * Math.sin(h) };
9741    }
9742
9743    function rgb8ToOklch(rgb) {
9744      const r = srgbToLinear01(rgb.r / 255);
9745      const g = srgbToLinear01(rgb.g / 255);
9746      const b = srgbToLinear01(rgb.b / 255);
9747      const lab = linearSrgbToOklab(r, g, b);
9748      return oklabToOklch(lab.L, lab.a, lab.b);
9749    }
9750
9751    function inSrgbGamutLinear(rgbLin) {
9752      return rgbLin.r >= 0 && rgbLin.r <= 1 && rgbLin.g >= 0 && rgbLin.g <= 1 && rgbLin.b >= 0 && rgbLin.b <= 1;
9753    }
9754
9755    function gamutMapOklchToSrgb01(l, c, h) {
9756      // High-quality intent: map into sRGB gamut with minimal perceptual change.
9757      // Implementation: OKLab-based bisection on chroma (stable, preserves L/h).
9758      let lo = 0.0;
9759      let hi = Math.max(0, c);
9760      let best = { r: 0, g: 0, b: 0 };
9761      for (let i = 0; i < 26; i++) {
9762        const mid = (lo + hi) * 0.5;
9763        const lab = oklchToOklab(l, mid, h);
9764        const lin = oklabToLinearSrgb01(lab.L, lab.a, lab.b);
9765        if (inSrgbGamutLinear(lin)) {
9766          best = lin;
9767          lo = mid;
9768        } else {
9769          hi = mid;
9770        }
9771      }
9772      return {
9773        r: linearToSrgb01(best.r),
9774        g: linearToSrgb01(best.g),
9775        b: linearToSrgb01(best.b)
9776      };
9777    }
9778
9779    function lerpHueDeg(h1, h2, t) {
9780      const a = ((h2 - h1 + 540) % 360) - 180; // shortest path in [-180, 180)
9781      const h = h1 + a * t;
9782      return (h % 360 + 360) % 360;
9783    }
9784
9785    function mixOklchColors(a, b, localT) {
9786      const o1 = a.oklch || (a.oklch = rgb8ToOklch(a.color || a));
9787      const o2 = b.oklch || (b.oklch = rgb8ToOklch(b.color || b));
9788      return gamutMapOklchToSrgb01(lerp(o1.l, o2.l, localT), lerp(o1.c, o2.c, localT), lerpHueDeg(o1.h, o2.h, localT));
9789    }
9790
9791    function sampleGradientSrgb01(t, sourceStops, colorOffset) {
9792      t = clamp01(t);
9793      const flowing = Number.isFinite(colorOffset) && colorOffset !== 0;
9794
9795      // Palette acts like evenly spaced stops.
9796      if (!sourceStops && activePalette && activePalette.length > 0) {
9797        if (activePalette.length === 1) {
9798          const o = rgb8ToOklch(activePalette[0]);
9799          return gamutMapOklchToSrgb01(o.l, o.c, o.h);
9800        }
9801        const seg = 1 / (activePalette.length - 1);
9802        if (flowing) {
9803          const period = 1 + seg;
9804          t = ColirMotion.wrapToPeriod(t, colorOffset, period);
9805          if (t > 1) return mixOklchColors(activePalette[activePalette.length - 1], activePalette[0], (t - 1) / seg);
9806        }
9807        const idxSeg = Math.min(activePalette.length - 2, Math.floor(t / seg));
9808        const localT = (t - idxSeg * seg) / seg;
9809        return mixOklchColors(activePalette[idxSeg], activePalette[idxSeg + 1], localT);
9810      }
9811
9812      // Gradient stops.
9813      const stops = (sourceStops || gradientStopsArray).slice().sort((a, b) => a.position - b.position);
9814      if (flowing && stops.length > 1) {
9815        const { period, wrapGap } = ColirMotion.colorFlowPeriod(stops.map(s => s.position));
9816        t = ColirMotion.wrapToPeriod(t, colorOffset, period);
9817        if (t > 1) return mixOklchColors(stops[stops.length - 1], stops[0], (t - 1) / wrapGap);
9818      }
9819      if (stops.length === 0) return { r: 0, g: 0, b: 0 };
9820      if (stops.length === 1) {
9821        const o = stops[0].oklch || (stops[0].oklch = rgb8ToOklch(stops[0].color));
9822        return gamutMapOklchToSrgb01(o.l, o.c, o.h);
9823      }
9824      if (t <= stops[0].position) {
9825        const o = stops[0].oklch || (stops[0].oklch = rgb8ToOklch(stops[0].color));
9826        return gamutMapOklchToSrgb01(o.l, o.c, o.h);
9827      }
9828      if (t >= stops[stops.length - 1].position) {
9829        const o = stops[stops.length - 1].oklch || (stops[stops.length - 1].oklch = rgb8ToOklch(stops[stops.length - 1].color));
9830        return gamutMapOklchToSrgb01(o.l, o.c, o.h);
9831      }
9832      for (let i = 0; i < stops.length - 1; i++) {
9833        const s1 = stops[i];
9834        const s2 = stops[i + 1];
9835        if (t >= s1.position && t <= s2.position) {
9836          const localT = (t - s1.position) / (s2.position - s1.position);
9837          const o1 = s1.oklch || (s1.oklch = rgb8ToOklch(s1.color));
9838          const o2 = s2.oklch || (s2.oklch = rgb8ToOklch(s2.color));
9839          const l = lerp(o1.l, o2.l, localT);
9840          const c = lerp(o1.c, o2.c, localT);
9841          const h = lerpHueDeg(o1.h, o2.h, localT);
9842          return gamutMapOklchToSrgb01(l, c, h);
9843        }
9844      }
9845      const o = stops[0].oklch || (stops[0].oklch = rgb8ToOklch(stops[0].color));
9846      return gamutMapOklchToSrgb01(o.l, o.c, o.h);
9847    }
9848
9849    function updateGradientLUTTexture(lutKey, sourceStops, colorOffset) {
9850      if (!gl || !gradLUTTexture || !glUniformLocations || !glUniformLocations.gradLUT) return;
9851      if (cachedGradLUTKey === lutKey) return;
9852
9853      const data = new Uint8Array(GRAD_LUT_SIZE * 4);
9854      for (let i = 0; i < GRAD_LUT_SIZE; i++) {
9855        const t = i / (GRAD_LUT_SIZE - 1);
9856        const c = sampleGradientSrgb01(t, sourceStops, colorOffset);
9857        const idx = i * 4;
9858        data[idx + 0] = Math.max(0, Math.min(255, Math.round(c.r * 255)));
9859        data[idx + 1] = Math.max(0, Math.min(255, Math.round(c.g * 255)));
9860        data[idx + 2] = Math.max(0, Math.min(255, Math.round(c.b * 255)));
9861        data[idx + 3] = 255;
9862      }
9863
9864      gl.activeTexture(gl.TEXTURE3);
9865      gl.bindTexture(gl.TEXTURE_2D, gradLUTTexture);
9866      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, GRAD_LUT_SIZE, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
9867      gl.uniform1i(glUniformLocations.gradLUT, 3);
9868      cachedGradLUTKey = lutKey;
9869      redrawCurveOverlays();
9870    }
9871
9872    function fract(x) { return x - Math.floor(x); }
9873
9874    function glslHash2(x, y) {
9875      const h = x * (127.1 + 269.5) + y * (311.7 + 183.3);
9876      return fract(Math.sin(h) * 43758.5453);
9877    }
9878
9879    function sampleNoise01(x, y) {
9880      // x,y are texel-space coordinates; repeat and bilinear sample like WebGL LINEAR.
9881      const x0f = Math.floor(x);
9882      const y0f = Math.floor(y);
9883      const tx = x - x0f;
9884      const ty = y - y0f;
9885      const x0 = ((x0f % NOISE_TEX_SIZE) + NOISE_TEX_SIZE) % NOISE_TEX_SIZE;
9886      const y0 = ((y0f % NOISE_TEX_SIZE) + NOISE_TEX_SIZE) % NOISE_TEX_SIZE;
9887      const x1 = (x0 + 1) % NOISE_TEX_SIZE;
9888      const y1 = (y0 + 1) % NOISE_TEX_SIZE;
9889      const n00 = noiseField[y0 * NOISE_TEX_SIZE + x0] / 255;
9890      const n10 = noiseField[y0 * NOISE_TEX_SIZE + x1] / 255;
9891      const n01 = noiseField[y1 * NOISE_TEX_SIZE + x0] / 255;
9892      const n11 = noiseField[y1 * NOISE_TEX_SIZE + x1] / 255;
9893      const nx0 = lerp(n00, n10, tx);
9894      const nx1 = lerp(n01, n11, tx);
9895      return lerp(nx0, nx1, ty);
9896    }
9897
9898    function sampleHybridNoise01(x, y, vx, vy, scale, mixAmount) {
9899      const screenNoise = sampleNoise01(x, y);
9900      const jitterX = glslHash2(x * 0.017, y * 0.017) * GRADIENT_NOISE_JITTER;
9901      const jitterY = glslHash2(x * 0.019, y * 0.019) * GRADIENT_NOISE_JITTER;
9902      const gradientNoise = sampleNoise01(
9903        (vx * scale + jitterX) * NOISE_TEX_SIZE,
9904        (vy * scale + jitterY) * NOISE_TEX_SIZE
9905      );
9906      return lerp(screenNoise, gradientNoise, Math.max(0, Math.min(1, mixAmount)));
9907    }
9908
9909    // Get color from gradient stops at position t (0-1)
9910    function getColorFromGradient(t) {
9911      const c = sampleGradientSrgb01(t);
9912      return {
9913        r: Math.max(0, Math.min(255, Math.round(c.r * 255))),
9914        g: Math.max(0, Math.min(255, Math.round(c.g * 255))),
9915        b: Math.max(0, Math.min(255, Math.round(c.b * 255))),
9916      };
9917    }
9918
9919
9920    function renderGradient() {
9921      if(graphics.state==='ready'&&!window.ColirRenderPrograms.animationReady)prioritizeCurrentDesign();
9922      // Throttle renders to once per frame using requestAnimationFrame
9923      if (renderScheduled) return;
9924      renderScheduled = true;
9925      requestAnimationFrame(() => {
9926        try {
9927          renderGradientWebGL(window.ColirAnimation?.currentFrame() || null);
9928        } catch (error) {
9929          graphicsNotice.hidden = false; graphicsMessage.textContent = error.message;
9930        } finally {
9931          renderScheduled = false;
9932        }
9933      });
9934    }
9935
9936    function blend(a, b, mode) {
9937      // Normalize inputs to 0-1 range
9938      const base = Math.min(1, Math.max(0, a));
9939      const blend = Math.min(1, Math.max(0, b));
9940
9941      switch (mode) {
9942        case 'mul':
9943          return base * blend;
9944        case 'screen':
9945          return 1 - (1 - base) * (1 - blend);
9946        case 'overlay':
9947          return base < 0.5
9948            ? 2 * base * blend
9949            : 1 - 2 * (1 - base) * (1 - blend);
9950        case 'softlight':
9951          return blend < 0.5
9952            ? 2 * base * blend + base * base * (1 - 2 * blend)
9953            : Math.sqrt(base) * (2 * blend - 1) + 2 * base * (1 - blend);
9954        case 'hardlight':
9955          return blend < 0.5
9956            ? 2 * base * blend
9957            : 1 - 2 * (1 - base) * (1 - blend);
9958        case 'difference':
9959          return Math.abs(base - blend);
9960        case 'exclusion':
9961          return base + blend - 2 * base * blend;
9962        case 'darken':
9963          return Math.min(base, blend);
9964        case 'lighten':
9965          return Math.max(base, blend);
9966        case 'colorburn':
9967          return blend === 0 ? 0 : Math.max(0, 1 - (1 - base) / blend);
9968        case 'colordodge':
9969          return blend === 1 ? 1 : Math.min(1, base / (1 - blend));
9970        case 'avg':
9971        default:
9972          return (base + blend) / 2;
9973      }
9974    }
9975
9976
9977    // Function to generate gradient JSON data
9978    function generateGradientJSON() {
9979      const sortedStops = [...gradientStopsArray].sort((a, b) => a.position - b.position);
9980      const colorStops = sortedStops.map(stop => ({
9981        position: stop.position,
9982        // OKLCH is the canonical export format;
9982 RGB is included for compatibility.
9983        oklch: (() => {
9984          const o = stop.oklch || (stop.oklch = rgb8ToOklch(stop.color));
9985          return { l: o.l, c: o.c, h: o.h };
9986        })(),
9987        color: { r: stop.color.r, g: stop.color.g, b: stop.color.b }
9988      }));
9989
9990      const xPoints = [...xEditor.points].sort((a, b) => a.t - b.t).map(p => ({
9991        t: p.t,
9992        v: p.v
9993      }));
9994
9995      const yPoints = [...yEditor.points].sort((a, b) => a.t - b.t).map(p => ({
9996        t: p.t,
9997        v: p.v
9998      }));
9999
10000      const curveSmoothingToggle = document.getElementById('curveSmoothingToggle');
10001      const isSmooth = curveSmoothingToggle ? curveSmoothingToggle.classList.contains('on') : true;
10002
10003      const data = {
10004        colorSpace: 'oklch',
10005        colorStops: colorStops,
10006        noiseMode: 'gradient',
10007        gradientNoiseScale: GRADIENT_NOISE_SCALE_FIXED,
10008        gradientNoiseMix: Math.round(GRADIENT_NOISE_MIX * 100),
10009        xCurve: {
10010          points: xPoints
10011        },
10012        yCurve: {
10013          points: yPoints
10014        },
10015        canvasWidth: widthInput ? parseInt(widthInput.value) || undefined : undefined,
10016        canvasHeight: heightInput ? parseInt(heightInput.value) || undefined : undefined,
10017        curveSmoothing: isSmooth,
10018        blendMode: blendMode.value,
10019        noise: parseInt(noiseInput.value),
10020        feather: parseInt(featherInput.value),
10021        glitter: {
10022          enabled: document.getElementById('glitterEnabled').checked,
10023          density: parseInt(document.getElementById('glitterDensityInput').value),
10024          sizeMin: parseFloat(document.getElementById('glitterSizeMinInput').value),
10025          sizeMax: parseFloat(document.getElementById('glitterSizeMaxInput').value),
10026          opacity: parseInt(document.getElementById('glitterOpacityValue').value),
10027          blendMode: document.getElementById('glitterBlendMode').value
10028        }
10029      };
10030      
10031      // Include distortion settings (always, so disabled state is preserved)
10032      const distortionEnabled = document.getElementById('distortionEnabled');
10033      const procDispType = document.getElementById('procDispType');
10034      const procDispIntensity = document.getElementById('procDispIntensity');
10035      const procDispScale = document.getElementById('procDispScale');
10036      const procDispSeed = document.getElementById('procDispSeed');
10037      const procDispOctaves = document.getElementById('procDispOctaves');
10038      const procDispLacunarity = document.getElementById('procDispLacunarity');
10039      const procDispPersistence = document.getElementById('procDispPersistence');
10040      const procDispWaveFreq = document.getElementById('procDispWaveFreq');
10041      const procDispVoronoiJitter = document.getElementById('procDispVoronoiJitter');
10042      const procDispWarpStrength = document.getElementById('procDispWarpStrength');
10043
10044      data.distortion = {
10045        enabled: distortionEnabled ? distortionEnabled.checked : false,
10046        type: procDispType ? procDispType.value : 'organic',
10047        intensity: procDispIntensity ? parseInt(procDispIntensity.value) : 2,
10048        scale: procDispScale ? parseFloat(procDispScale.value) : 0.5,
10049        seed: procDispSeed ? parseInt(procDispSeed.value) : 0
10050      };
10051
10052      if (procDispOctaves) data.distortion.octaves = parseInt(procDispOctaves.value);
10053      if (procDispLacunarity) data.distortion.lacunarity = parseFloat(procDispLacunarity.value);
10054      if (procDispPersistence) data.distortion.persistence = parseFloat(procDispPersistence.value);
10055      if (procDispWaveFreq) data.distortion.waveFreq = parseInt(procDispWaveFreq.value);
10056      if (procDispVoronoiJitter) data.distortion.voronoiJitter = parseFloat(procDispVoronoiJitter.value);
10057      if (procDispWarpStrength) data.distortion.warpStrength = parseFloat(procDispWarpStrength.value);
10058      const procDispMix = document.getElementById('procDispMix');
10059      if (procDispMix) data.distortion.mix = parseFloat(procDispMix.value);
10060      const procDispTayuMix = document.getElementById('procDispTayuMix');
10061      if (procDispTayuMix) data.distortion.tayuMix = parseFloat(procDispTayuMix.value);
10062      const materialStrength = document.getElementById('materialStrength');
10063      const reliefVal = materialStrength ? parseInt(materialStrength.value, 10) : 0;
10064      data.material = {
10065        enabled: reliefVal > 0,
10066        preset: 'plastic',
10067        strength: reliefVal
10068      };
10069
10070      const betaEnabledEl = document.getElementById('betaEnabled');
10071      const chromaticEnabledEl = document.getElementById('chromaticEnabled');
10072      const caIntensityEl = document.getElementById('caIntensityValue') || document.getElementById('caIntensityInput');
10073      const flowEnabledEl = document.getElementById('flowEnabled');
10074      const flowNum = (id, fallback) => {
10075        const el = document.getElementById(id);
10076        const v = el ? parseFloat(el.value) : NaN;
10077        return Number.isNaN(v) ? fallback : v;
10078      };
10079      data.beta = {
10080        enabled: betaEnabledEl ? betaEnabledEl.checked : false,
10081        chromatic: {
10082          enabled: chromaticEnabledEl ? chromaticEnabledEl.checked : false,
10083          intensity: caIntensityEl ? parseInt(caIntensityEl.value, 10) : 100
10084        },
10085        flow: {
10086          enabled: flowEnabledEl ? flowEnabledEl.checked : false,
10087          intensity: flowNum('flowIntensityValue', 100),
10088          length: flowNum('flowLengthValue', 100),
10089          steps: flowNum('flowStepsValue', 50),
10090          decay: flowNum('flowDecayValue', 0),
10091          scale: flowNum('flowScaleValue', 0.5),
10092          detail: flowNum('flowDetailValue', 4),
10093          direction: flowNum('flowDirectionValue', 180),
10094          irregularity: flowNum('flowIrregularityValue', 100),
10095          seed: flowNum('flowSeedValue', 42)
10096        },
10097        paper: {
10098          enabled: (() => { const el = document.getElementById('paperEnabled'); return el ? el.checked : false; })(),
10099          intensity: flowNum('paperIntensityValue', 28),
10100          seed: flowNum('paperSeedValue', 12)
10101        }
10102      };
10103
10104      const patternsEnabledEl = document.getElementById('patternsEnabled');
10105      const patternTypeEl = document.getElementById('patternType');
10106      const patternDotSizeEl = document.getElementById('patternDotSize');
10107      const patternDotDistanceEl = document.getElementById('patternDotDistance');
10108      const patternLineThicknessEl = document.getElementById('patternLineThickness');
10109      const patternLineDistanceEl = document.getElementById('patternLineDistance');
10110      const patternLineAngleEl = document.getElementById('patternLineAngle');
10111      const patternLineAxisEl = document.getElementById('patternLineAxis');
10112      const patternColorPickerEl = document.getElementById('patternColorPicker');
10113      const patternOpacityEl = document.getElementById('patternOpacity');
10114      const patternOpacityValueEl = document.getElementById('patternOpacityValue');
10115      const patternBlendModeEl = document.getElementById('patternBlendMode');
10116      data.pattern = {
10117        enabled: patternsEnabledEl ? patternsEnabledEl.checked : false,
10118        type: patternTypeEl ? patternTypeEl.value : 'dot',
10119        dotSize: patternDotSizeEl ? parseInt(patternDotSizeEl.value) : 3,
10120        dotDistance: patternDotDistanceEl ? parseInt(patternDotDistanceEl.value) : 24,
10121        lineThickness: patternLineThicknessEl ? Math.round(parseFloat(patternLineThicknessEl.value) * 10) / 10 : 1,
10122        lineDistance: patternLineDistanceEl ? parseInt(patternLineDistanceEl.value) : 24,
10123        lineAngle: patternLineAngleEl ? parseInt(patternLineAngleEl.value) : 0,
10124        lineAxis: patternLineAxisEl && patternLineAxisEl.value === '2' ? 2 : 1,
10125        color: patternColorPickerEl ? (patternColorPickerEl.value || '#ffffff') : '#ffffff',
10126        opacity: patternOpacityValueEl ? parseInt(patternOpacityValueEl.value) : (patternOpacityEl ? parseInt(patternOpacityEl.value) : 100),
10127        blendMode: patternBlendModeEl ? patternBlendModeEl.value : 'normal'
10128      };
10129
10130      const surfaceSettings = ColirSurfaceFilters.snapshot();
10131      if (JSON.stringify(surfaceSettings) !== JSON.stringify(ColirSurfaceFilters.normalize())) data.surfaceFilters = surfaceSettings;
10132      if (window.ColirAnimation?.configured) data.animation = window.ColirAnimation.settings();
10133      return data;
10134            }
10135
10136    // Compact JSON format for shorter URLs
10137    // Keys: c=colors, x=xCurve, y=yCurve, w=width, h=height, s=smoothing, b=blendMode, n=noise, nt=legacy noise type (ignored), ns=noiseScale, nmix=noiseMix, f=feather, g=glitter, d=distortion, p=pattern, m=material, bt=beta, a=animation
10138    // Animation: v=version, d=duration, cf/cd/cs=color, cm/cx/cy/csp/cr/cn=curves, dm/dsp/da/de/dt=distortion, gm/gf/gs=glitter, pm/pn/pd/ps/pa/pz=patterns. Legacy verbose `animation` is still accepted.
10139    // Glitter: e=enabled, d=density, min=sizeMin, max=sizeMax, o=opacity, b=blendMode
10140    // Distortion: e=enabled, t=type, i=intensity, sc=scale, seed=seed, o=octaves, l=lacunarity, p=persistence, wf=waveFreq, vj=voronoiJitter, ws=warpStrength, m=mix, tm=tayuMix
10141    // Pattern: e=enabled, t=type(dot|line), ds=dotSize, dd=dotDistance, lt=lineThickness, ld=lineDistance, la=lineAngle, lx=lineAxis(1|2), c=color(hex), o=opacity, b=blendMode
10142    // Material: e=enabled, p=preset, st=strength
10143    // Beta: e=enabled, c={ e, i=intensity }, fl={ e=enabled, i=intensity, l=length, st=steps, d=decay, sc=scale, o=detail/octaves, a=direction(deg), ir=irregularity, s=seed }, pp=paper{ e=enabled, i=intensity, s=seed }
10144    // Compact: nt legacy only (screen|gradient; rendering is always hybrid). ns, nmix when non-default.
10145    function generateCompactGradientJSON() {
10146      const DEFAULTS = {
10147        w: 900,
10148        h: 520,
10149        s: true,
10150        b: 'difference',
10151        n: 6,
10152        // Omitted from new shares; legacy `nt` in URLs is ignored (always hybrid noise).
10153        nt: 'gradient',
10154        ns: 75,
10155        nmix: 53,
10156        f: 18,
10157        g: { e: true, d: 30, min: 0.5, max: 3, o: 100, b: 'normal' },
10158        d: { e: false, t: 'organic', i: 2, sc: 0.5, seed: 0 },
10159        p: { e: false, t: 'dot', ds: 3, dd: 24, lt: 1, ld: 24, la: 0, lx: 1, c: '#ffffff', o: 100, b: 'normal' },
10160        m: { e: false, p: 'plastic', st: 0 },
10161        bt: { e: false, c: { e: false, i: 100 }, fl: { e: false, i: 100, l: 100, st: 50, d: 0, sc: 0.5, o: 4, a: 180, ir: 100, s: 42 }, pp: { e: false, i: 28, s: 12 } }
10162      };
10163
10164      const data = {};
10165
10166      // Colors: [[r,g,b,pos], ...] - always include
10167      const sortedStops = [...gradientStopsArray].sort((a, b) => a.position - b.position);
10168      data.c = sortedStops.map(stop => [
10169        stop.color.r,
10170        stop.color.g,
10171        stop.color.b,
10172        Math.round(stop.position * 1000) / 1000
10173      ]);
10174
10175      // X curve: [[t,v], ...] - always include
10176      data.x = [...xEditor.points].sort((a, b) => a.t - b.t).map(p => [
10177        Math.round(p.t * 1000) / 1000,
10178        Math.round(p.v * 1000) / 1000
10179      ]);
10180
10181      // Y curve: [[t,v], ...] - always include
10182      data.y = [...yEditor.points].sort((a, b) => a.t - b.t).map(p => [
10183        Math.round(p.t * 1000) / 1000,
10184        Math.round(p.v * 1000) / 1000
10185      ]);
10186
10187      // Canvas size - only if not default
10188      const canvasWidth = widthInput ? parseInt(widthInput.value) : DEFAULTS.w;
10189      const canvasHeight = heightInput ? parseInt(heightInput.value) : DEFAULTS.h;
10190      if (canvasWidth !== DEFAULTS.w) data.w = canvasWidth;
10191      if (canvasHeight !== DEFAULTS.h) data.h = canvasHeight;
10192
10193      // Curve smoothing - only if false (default is true)
10194      const curveSmoothingToggle = document.getElementById('curveSmoothingToggle');
10195      const isSmooth = curveSmoothingToggle ? curveSmoothingToggle.classList.contains('on') : true;
10196      if (!isSmooth) data.s = false;
10197
10198      // Blend mode - only if not default
10199      if (blendMode.value !== DEFAULTS.b) data.b = blendMode.value;
10200
10201      // Noise - only if not default
10202      const noise = parseInt(noiseInput.value);
10203      if (noise !== DEFAULTS.n) data.n = noise;
10204
10205      // Gradient noise tuning - only if not default (fixed in-app; still round-trip old links)
10206      const ns = GRADIENT_NOISE_SCALE_FIXED;
10207      if (ns !== DEFAULTS.ns) data.ns = ns;
10208      const nmix = Math.round(GRADIENT_NOISE_MIX * 100);
10209      if (nmix !== DEFAULTS.nmix) data.nmix = nmix;
10210
10211      // Feather - only if not default
10212      const feather = parseInt(featherInput.value);
10213      if (feather !== DEFAULTS.f) data.f = feather;
10214
10215      // Glitter - only if different from defaults
10216      const glitterEnabled = document.getElementById('glitterEnabled');
10217      const glitterDensityInput = document.getElementById('glitterDensityInput');
10218      const glitterSizeMinInput = document.getElementById('glitterSizeMinInput');
10219      const glitterSizeMaxInput = document.getElementById('glitterSizeMaxInput');
10220      const glitterOpacityInput = document.getElementById('glitterOpacityValue');
10221      const glitterBlendMode = document.getElementById('glitterBlendMode');
10222      
10223      const glitter = {
10224        e: glitterEnabled ? glitterEnabled.checked : DEFAULTS.g.e,
10225        d: glitterDensityInput ? parseInt(glitterDensityInput.value) : DEFAULTS.g.d,
10226        min: glitterSizeMinInput ? Math.round(parseFloat(glitterSizeMinInput.value) * 100) / 100 : DEFAULTS.g.min,
10227        max: glitterSizeMaxInput ? Math.round(parseFloat(glitterSizeMaxInput.value) * 100) / 100 : DEFAULTS.g.max,
10228        o: glitterOpacityInput ? Math.round(parseFloat(glitterOpacityInput.value)) : DEFAULTS.g.o,
10229        b: glitterBlendMode ? glitterBlendMode.value : DEFAULTS.g.b
10230      };
10231
10232      if (!glitter.e || glitter.d !== DEFAULTS.g.d || glitter.min !== DEFAULTS.g.min || glitter.max !== DEFAULTS.g.max || glitter.o !== DEFAULTS.g.o || glitter.b !== DEFAULTS.g.b) {
10233        data.g = {};
10234        if (!glitter.e) data.g.e = false;
10235        if (glitter.d !== DEFAULTS.g.d) data.g.d = glitter.d;
10236        if (glitter.min !== DEFAULTS.g.min) data.g.min = glitter.min;
10237        if (glitter.max !== DEFAULTS.g.max) data.g.max = glitter.max;
10238        if (glitter.o !== DEFAULTS.g.o) data.g.o = glitter.o;
10239        if (glitter.b !== DEFAULTS.g.b) data.g.b = glitter.b;
10240      }
10241
10242      // Distortion - only if enabled or non-default
10243      const distortionEnabled = document.getElementById('distortionEnabled');
10244      const procDispType = document.getElementById('procDispType');
10245      const procDispIntensity = document.getElementById('procDispIntensity');
10246      const procDispScale = document.getElementById('procDispScale');
10247      const procDispSeed = document.getElementById('procDispSeed');
10248      const procDispOctaves = document.getElementById('procDispOctaves');
10249      const procDispLacunarity = document.getElementById('procDispLacunarity');
10250      const procDispPersistence = document.getElementById('procDispPersistence');
10251      const procDispWaveFreq = document.getElementById('procDispWaveFreq');
10252      const procDispVoronoiJitter = document.getElementById('procDispVoronoiJitter');
10253      const procDispWarpStrength = document.getElementById('procDispWarpStrength');
10254      const procDispMix = document.getElementById('procDispMix');
10255      const procDispTayuMix = document.getElementById('procDispTayuMix');
10256      const distortion = {
10257        e: distortionEnabled ? distortionEnabled.checked : DEFAULTS.d.e,
10258        t: procDispType ? procDispType.value : DEFAULTS.d.t,
10259        i: procDispIntensity ? parseInt(procDispIntensity.value) : DEFAULTS.d.i,
10260        sc: procDispScale ? Math.round(parseFloat(procDispScale.value) * 10) / 10 : DEFAULTS.d.sc,
10261        seed: procDispSeed ? parseInt(procDispSeed.value) : DEFAULTS.d.seed
10262      };
10263
10264      if (procDispOctaves) distortion.o = parseInt(procDispOctaves.value);
10265      if (procDispLacunarity) distortion.l = Math.round(parseFloat(procDispLacunarity.value) * 10) / 10;
10266      if (procDispPersistence) distortion.p = Math.round(parseFloat(procDispPersistence.value) * 100) / 100;
10267      if (procDispWaveFreq) distortion.wf = parseInt(procDispWaveFreq.value);
10268      if (procDispVoronoiJitter) distortion.vj = Math.round(parseFloat(procDispVoronoiJitter.value) * 100) / 100;
10269      if (procDispWarpStrength) distortion.ws = Math.round(parseFloat(procDispWarpStrength.value) * 10) / 10;
10270      if (procDispMix) distortion.m = Math.round(parseFloat(procDispMix.value) * 100) / 100;
10271      if (procDispTayuMix) distortion.tm = Math.round(parseFloat(procDispTayuMix.value) * 100) / 100;
10272
10273      // Include distortion if enabled or if any value differs from default
10274      const hasNonDefaultDistortion = distortion.e !== DEFAULTS.d.e ||
10275        distortion.t !== DEFAULTS.d.t ||
10276        distortion.i !== DEFAULTS.d.i ||
10277        distortion.sc !== DEFAULTS.d.sc ||
10278        distortion.seed !== DEFAULTS.d.seed ||
10279        distortion.o !== undefined ||
10280        distortion.l !== undefined ||
10281        distortion.p !== undefined ||
10282        distortion.wf !== undefined ||
10283        distortion.vj !== undefined ||
10284        distortion.ws !== undefined ||
10285        distortion.m !== undefined ||
10286        distortion.tm !== undefined;
10287
10288      if (hasNonDefaultDistortion) {
10289        data.d = {};
10290        if (distortion.e !== DEFAULTS.d.e) data.d.e = distortion.e;
10291        if (distortion.t !== DEFAULTS.d.t) data.d.t = distortion.t;
10292        if (distortion.i !== DEFAULTS.d.i) data.d.i = distortion.i;
10293        if (distortion.sc !== DEFAULTS.d.sc) data.d.sc = distortion.sc;
10294        if (distortion.seed !== DEFAULTS.d.seed) data.d.seed = distortion.seed;
10295        if (distortion.o !== undefined) data.d.o = distortion.o;
10296        if (distortion.l !== undefined) data.d.l = distortion.l;
10297        if (distortion.p !== undefined) data.d.p = distortion.p;
10298        if (distortion.wf !== undefined) data.d.wf = distortion.wf;
10299        if (distortion.vj !== undefined) data.d.vj = distortion.vj;
10300        if (distortion.ws !== undefined) data.d.ws = distortion.ws;
10301        if (distortion.m !== undefined) data.d.m = distortion.m;
10302        if (distortion.tm !== undefined) data.d.tm = distortion.tm;
10303      }
10304
10305      // Pattern - only if enabled or non-default
10306      const patternsEnabled = document.getElementById('patternsEnabled');
10307      const patternType = document.getElementById('patternType');
10308      const patternDotSize = document.getElementById('patternDotSize');
10309      const patternDotDistance = document.getElementById('patternDotDistance');
10310      const patternLineThickness = document.getElementById('patternLineThickness');
10311      const patternLineDistance = document.getElementById('patternLineDistance');
10312      const patternLineAngle = document.getElementById('patternLineAngle');
10313      const patternLineAxis = document.getElementById('patternLineAxis');
10314      const patternColorPicker = document.getElementById('patternColorPicker');
10315      const patternOpacity = document.getElementById('patternOpacity');
10316      const patternOpacityValue = document.getElementById('patternOpacityValue');
10317      const patternBlendMode = document.getElementById('patternBlendMode');
10318      const pattern = {
10319        e: patternsEnabled ? patternsEnabled.checked : DEFAULTS.p.e,
10320        t: patternType ? patternType.value : DEFAULTS.p.t,
10321        ds: patternDotSize ? parseInt(patternDotSize.value) : DEFAULTS.p.ds,
10322        dd: patternDotDistance ? parseInt(patternDotDistance.value) : DEFAULTS.p.dd,
10323        lt: patternLineThickness ? Math.round(parseFloat(patternLineThickness.value) * 10) / 10 : DEFAULTS.p.lt,
10324        ld: patternLineDistance ? parseInt(patternLineDistance.value) : DEFAULTS.p.ld,
10325        la: patternLineAngle ? parseInt(patternLineAngle.value) : DEFAULTS.p.la,
10326        lx: patternLineAxis && patternLineAxis.value === '2' ? 2 : 1,
10327        c: patternColorPicker ? (patternColorPicker.value || DEFAULTS.p.c) : DEFAULTS.p.c,
10328        o: patternOpacityValue ? parseInt(patternOpacityValue.value) : (patternOpacity ? parseInt(patternOpacity.value) : DEFAULTS.p.o),
10329        b: patternBlendMode ? patternBlendMode.value : DEFAULTS.p.b
10330      };
10331      const hasNonDefaultPattern = pattern.e !== DEFAULTS.p.e ||
10332        pattern.t !== DEFAULTS.p.t ||
10333        pattern.ds !== DEFAULTS.p.ds ||
10334        pattern.dd !== DEFAULTS.p.dd ||
10335        pattern.lt !== DEFAULTS.p.lt ||
10336        pattern.ld !== DEFAULTS.p.ld ||
10337        pattern.la !== DEFAULTS.p.la ||
10338        pattern.lx !== DEFAULTS.p.lx ||
10339        (pattern.c && pattern.c.toLowerCase() !== DEFAULTS.p.c.toLowerCase()) ||
10340        pattern.o !== DEFAULTS.p.o ||
10341        pattern.b !== DEFAULTS.p.b;
10342      if (hasNonDefaultPattern) {
10343        data.p = {};
10344        if (pattern.e !== DEFAULTS.p.e) data.p.e = pattern.e;
10345        if (pattern.t !== DEFAULTS.p.t) data.p.t = pattern.t;
10346        if (pattern.ds !== DEFAULTS.p.ds) data.p.ds = pattern.ds;
10347        if (pattern.dd !== DEFAULTS.p.dd) data.p.dd = pattern.dd;
10348        if (pattern.lt !== DEFAULTS.p.lt) data.p.lt = pattern.lt;
10349        if (pattern.ld !== DEFAULTS.p.ld) data.p.ld = pattern.ld;
10350        if (pattern.la !== DEFAULTS.p.la) data.p.la = pattern.la;
10351        if (pattern.lx !== DEFAULTS.p.lx) data.p.lx = pattern.lx;
10352        if (pattern.c && pattern.c.toLowerCase() !== DEFAULTS.p.c.toLowerCase()) data.p.c = pattern.c;
10353        if (pattern.o !== DEFAULTS.p.o) data.p.o = pattern.o;
10354        if (pattern.b !== DEFAULTS.p.b) data.p.b = pattern.b;
10355      }
10356
10357      // Material - only if enabled or non-default
10358      const materialStrengthElCompact = document.getElementById('materialStrength');
10359      const reliefValCompact = materialStrengthElCompact ? parseInt(materialStrengthElCompact.value, 10) : 0;
10360      const material = {
10361        e: reliefValCompact > 0,
10362        p: 'plastic',
10363        st: reliefValCompact
10364      };
10365      if (material.e !== DEFAULTS.m.e || material.st !== DEFAULTS.m.st) {
10366        data.m = {};
10367        if (material.e !== DEFAULTS.m.e) data.m.e = material.e;
10368        if (material.st !== DEFAULTS.m.st) data.m.st = material.st;
10369      }
10370
10371      const betaEnabledCompact = document.getElementById('betaEnabled');
10372      const chromaticEnabledCompact = document.getElementById('chromaticEnabled');
10373      const caIntensityCompact = document.getElementById('caIntensityValue') || document.getElementById('caIntensityInput');
10374      const flowEnabledCompact = document.getElementById('flowEnabled');
10375      const flowNumCompact = (id, fallback) => {
10376        const el = document.getElementById(id);
10377        const v = el ? parseFloat(el.value) : NaN;
10378        return Number.isNaN(v) ? fallback : v;
10379      };
10380      const beta = {
10381        e: betaEnabledCompact ? betaEnabledCompact.checked : DEFAULTS.bt.e,
10382        c: {
10383          e: chromaticEnabledCompact ? chromaticEnabledCompact.checked : DEFAULTS.bt.c.e,
10384          i: caIntensityCompact ? parseInt(caIntensityCompact.value, 10) : DEFAULTS.bt.c.i
10385        },
10386        fl: {
10387          e: flowEnabledCompact ? flowEnabledCompact.checked : DEFAULTS.bt.fl.e,
10388          i: flowNumCompact('flowIntensityValue', DEFAULTS.bt.fl.i),
10389          l: flowNumCompact('flowLengthValue', DEFAULTS.bt.fl.l),
10390          st: flowNumCompact('flowStepsValue', DEFAULTS.bt.fl.st),
10391          d: flowNumCompact('flowDecayValue', DEFAULTS.bt.fl.d),
10392          sc: flowNumCompact('flowScaleValue', DEFAULTS.bt.fl.sc),
10393          o: flowNumCompact('flowDetailValue', DEFAULTS.bt.fl.o),
10394          a: flowNumCompact('flowDirectionValue', DEFAULTS.bt.fl.a),
10395          ir: flowNumCompact('flowIrregularityValue', DEFAULTS.bt.fl.ir),
10396          s: flowNumCompact('flowSeedValue', DEFAULTS.bt.fl.s)
10397        },
10398        pp: {
10399          e: (() => { const el = document.getElementById('paperEnabled'); return el ? el.checked : DEFAULTS.bt.pp.e; })(),
10400          i: flowNumCompact('paperIntensityValue', DEFAULTS.bt.pp.i),
10401          s: flowNumCompact('paperSeedValue', DEFAULTS.bt.pp.s)
10402        }
10403      };
10404      const hasNonDefaultChromatic = beta.e !== DEFAULTS.bt.e ||
10405        beta.c.e !== DEFAULTS.bt.c.e ||
10406        beta.c.i !== DEFAULTS.bt.c.i;
10407      const hasNonDefaultFlow = beta.fl.e !== DEFAULTS.bt.fl.e ||
10408        beta.fl.i !== DEFAULTS.bt.fl.i ||
10409        beta.fl.l !== DEFAULTS.bt.fl.l ||
10410        beta.fl.st !== DEFAULTS.bt.fl.st ||
10411        beta.fl.d !== DEFAULTS.bt.fl.d ||
10412        beta.fl.sc !== DEFAULTS.bt.fl.sc ||
10413        beta.fl.o !== DEFAULTS.bt.fl.o ||
10414        beta.fl.a !== DEFAULTS.bt.fl.a ||
10415        beta.fl.ir !== DEFAULTS.bt.fl.ir ||
10416        beta.fl.s !== DEFAULTS.bt.fl.s;
10417      const hasNonDefaultPaper = beta.pp.e !== DEFAULTS.bt.pp.e ||
10418        beta.pp.i !== DEFAULTS.bt.pp.i ||
10419        beta.pp.s !== DEFAULTS.bt.pp.s;
10420      if (hasNonDefaultChromatic || hasNonDefaultFlow || hasNonDefaultPaper) {
10421        data.bt = {};
10422        if (beta.e !== DEFAULTS.bt.e) data.bt.e = beta.e;
10423        if (hasNonDefaultChromatic) {
10424          data.bt.c = {};
10425          if (beta.c.e !== DEFAULTS.bt.c.e) data.bt.c.e = beta.c.e;
10426          if (beta.c.i !== DEFAULTS.bt.c.i) data.bt.c.i = beta.c.i;
10427        }
10428        if (hasNonDefaultFlow) {
10429          data.bt.fl = {};
10430          if (beta.fl.e !== DEFAULTS.bt.fl.e) data.bt.fl.e = beta.fl.e;
10431          if (beta.fl.i !== DEFAULTS.bt.fl.i) data.bt.fl.i = beta.fl.i;
10432          if (beta.fl.l !== DEFAULTS.bt.fl.l) data.bt.fl.l = beta.fl.l;
10433          if (beta.fl.st !== DEFAULTS.bt.fl.st) data.bt.fl.st = beta.fl.st;
10434          if (beta.fl.d !== DEFAULTS.bt.fl.d) data.bt.fl.d = beta.fl.d;
10435          if (beta.fl.sc !== DEFAULTS.bt.fl.sc) data.bt.fl.sc = beta.fl.sc;
10436          if (beta.fl.o !== DEFAULTS.bt.fl.o) data.bt.fl.o = beta.fl.o;
10437          if (beta.fl.a !== DEFAULTS.bt.fl.a) data.bt.fl.a = beta.fl.a;
10438          if (beta.fl.ir !== DEFAULTS.bt.fl.ir) data.bt.fl.ir = beta.fl.ir;
10439          if (beta.fl.s !== DEFAULTS.bt.fl.s) data.bt.fl.s = beta.fl.s;
10440        }
10441        if (hasNonDefaultPaper) {
10442          data.bt.pp = {};
10443          if (beta.pp.e !== DEFAULTS.bt.pp.e) data.bt.pp.e = beta.pp.e;
10444          if (beta.pp.i !== DEFAULTS.bt.pp.i) data.bt.pp.i = beta.pp.i;
10445          if (beta.pp.s !== DEFAULTS.bt.pp.s) data.bt.pp.s = beta.pp.s;
10446        }
10447      }
10448
10449      const surfaceFilters = ColirSurfaceFilters.snapshot();
10450      if (JSON.stringify(surfaceFilters) !== JSON.stringify(ColirSurfaceFilters.normalize())) data.surfaceFilters = surfaceFilters;
10451      if (window.ColirAnimation?.configured) {
10452        const packed = window.ColirMotion && typeof window.ColirMotion.pack === 'function'
10453          ? window.ColirMotion.pack(window.ColirAnimation.settings())
10454          : null;
10455        if (packed) data.a = packed;
10456      }
10457      return data;
10458    }
10459
10460    // Export
10461    const exportBtn = document.getElementById('exportBtn');
10462    const exportBtnIcon = exportBtn.querySelector('.export-icon');
10463    const exportBtnText = exportBtn.querySelector('.export-text');
10464    
10465    // Idle label stays "Export"; format is shown in the format picker.
10466    function updateExportButtonText() {
10467      if (!document.documentElement.classList.contains('colir-figma')) {
10468        const format=document.getElementById('exportFormat')?.value;
10469        const unavailable = format==='json'?false:['mp4','webm'].includes(format)?!window.ColirRenderPrograms?.animationReady:!window.ColirRenderPrograms?.designReady;
10470        const button = document.getElementById('exportBtn');
10471        if (button) { button.disabled = unavailable || button.classList.contains('loading'); button.title = ''; }
10472      }
10473      if (exportBtnText) {
10474        exportBtnText.textContent = exportFormat.value === 'json' ? 'Export JSON' : 'Export';
10475      }
10476    }
10477
10478    (function initExportFormatPicker() {
10479      const picker = document.querySelector('.export-format-picker');
10480      const btn = document.getElementById('exportFormatBtn');
10481      const menu = document.getElementById('exportFormatMenu');
10482      if (!picker || !btn || !menu || !exportFormat) return;
10483      const allOptions = [...menu.querySelectorAll('[role="option"]')];
10484      const embedded = document.documentElement.classList.contains('colir-figma');
10485      const options = allOptions.filter(opt => !embedded || ['png', 'mp4'].includes(opt.dataset.value));
10486      if (embedded) {
10487        allOptions.filter(opt => !options.includes(opt)).forEach(opt => opt.remove());
10488        [...exportFormat.options].filter(opt => !['png', 'mp4'].includes(opt.value)).forEach(opt => opt.remove());
10489        if (!['png', 'mp4'].includes(exportFormat.value)) exportFormat.value = 'png';
10490      }
10491      const labels = { png: 'PNG', webp: 'WebP', mp4: 'MP4', webm: 'WebM' };
10492
10493      function sync() {
10494        const value = exportFormat.value || 'png';
10495        btn.textContent = labels[value] || value.toUpperCase();
10496        options.forEach(opt => {
10497          const selected = opt.dataset.value === value;
10498          opt.setAttribute('aria-selected', String(selected));
10499        });
10500      }
10501
10502      function close() {
10503        if (menu.hidden) return;
10504        menu.hidden = true;
10505        btn.setAttribute('aria-expanded', 'false');
10506      }
10507
10508      function open() {
10509        sync();
10510        menu.hidden = false;
10511        btn.setAttribute('aria-expanded', 'true');
10512        const selected = options.find(opt => opt.dataset.value === exportFormat.value) || options[0];
10513        selected?.focus();
10514      }
10515
10516      function pick(value) {
10517        if (!value) return;
10518        if (exportFormat.value !== value) {
10519          exportFormat.value = value;
10520          exportFormat.dispatchEvent(new Event('change', { bubbles: true }));
10521        } else {
10522          sync();
10523        }
10524        close();
10525        btn.focus();
10526      }
10527
10528      btn.addEventListener('click', e => {
10529        e.stopPropagation();
10530        menu.hidden ? open() : close();
10531      });
10532      btn.addEventListener('keydown', e => {
10533        if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
10534          e.preventDefault();
10535          if (menu.hidden) open();
10536        }
10537      });
10538      options.forEach(opt => {
10539        opt.addEventListener('click', e => {
10540          e.preventDefault();
10541          pick(opt.dataset.value);
10542        });
10543      });
10544      menu.addEventListener('keydown', e => {
10545        const current = options.indexOf(document.activeElement);
10546        if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); return; }
10547        if (e.key === 'ArrowDown') {
10548          e.preventDefault();
10549          options[(current + 1 + options.length) % options.length].focus();
10550        } else if (e.key === 'ArrowUp') {
10551          e.preventDefault();
10552          options[(current - 1 + options.length) % options.length].focus();
10553        } else if (e.key === 'Home') {
10554          e.preventDefault();
10555          options[0].focus();
10556        } else if (e.key === 'End') {
10557          e.preventDefault();
10558          options[options.length - 1].focus();
10559        } else if (e.key === 'Enter' || e.key === ' ') {
10560          e.preventDefault();
10561          if (current >= 0) pick(options[current].dataset.value);
10562        }
10563      });
10564      document.addEventListener('click', e => {
10565        if (!picker.contains(e.target)) close();
10566      });
10567      exportFormat.addEventListener('change', sync);
10568      sync();
10569    })();
10570
10571    exportFormat.addEventListener('change', updateExportButtonText);
10572    updateExportButtonText(); // Initialize
10573    
10574    exportBtn.addEventListener('click', () => {
10575      const format = exportFormat.value;
10576      performExport(format);
10577    });
10578
10579    // Global scroll handler - scroll sidebar from anywhere on page
10580    const sidebarElement = document.querySelector('.sidebar');
10581    document.addEventListener('wheel', (e) => {
10582      // Skip if over input/textarea/select or modal
10583      const target = e.target;
10584      if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.closest('.help-modal, .animation-dialog')) {
10585        return;
10586      }
10587      
10588      // Scroll sidebar when mouse is over it
10589      if (!sidebarElement) return;
10590      
10591      // Skip if over floating UI elements (curves, export, export count, help button)
10592      if (target.closest('.floating-randomize') || target.closest('.floating-curves') || target.closest('.floating-export') || target.closest('.floating-export-count') || target.closest('.help-button')) {
10593        return;
10594      }
10595      
10596      // Check if sidebar is scrollable
10597      if (sidebarElement && sidebarElement.scrollHeight > sidebarElement.clientHeight) {
10598        e.preventDefault();
10599        sidebarElement.scrollTop += e.deltaY;
10600      }
10601    }, { passive: false });
10602
10603    // Glitter controls
10604    const glitterDensityInput = document.getElementById('glitterDensityInput');
10605    const glitterDensityValue = document.getElementById('glitterDensityValue');
10606    const glitterSizeMinInput = document.getElementById('glitterSizeMinInput');
10607    const glitterSizeMinValue = document.getElementById('glitterSizeMinValue');
10608    const glitterSizeMaxInput = document.getElementById('glitterSizeMaxInput');
10609    const glitterSizeMaxValue = document.getElementById('glitterSizeMaxValue');
10610    const glitterOpacity = document.getElementById('glitterOpacity');
10611    const glitterOpacityValue = document.getElementById('glitterOpacityValue');
10612    const glitterBlendMode = document.getElementById('glitterBlendMode');
10613    const glitterBlendPrev = document.getElementById('glitterBlendPrev');
10614    const glitterBlendNext = document.getElementById('glitterBlendNext');
10615    
10616    if (glitterDensityInput && glitterDensityValue) {
10617      glitterDensityInput.addEventListener('input', () => {
10618        glitterDensityValue.value = glitterDensityInput.value;
10619      renderGradient();
10620    });
10621
10622      glitterDensityValue.addEventListener('input', () => {
10623        // Allow user to type freely, only sync slider if value is valid
10624        const numVal = parseFloat(glitterDensityValue.value);
10625        if (!isNaN(numVal)) {
10626          const val = Math.max(1, Math.min(100, numVal));
10627          glitterDensityInput.value = val;
10628          renderGradient();
10629        }
10630      });
10631      
10632      glitterDensityValue.addEventListener('blur', () => {
10633        // Validate and clamp on blur
10634        const val = Math.max(1, Math.min(100, parseFloat(glitterDensityValue.value) || 50));
10635        glitterDensityInput.value = val;
10636        glitterDensityValue.value = val;
10637      renderGradient();
10638    });
10639
10640      glitterDensityValue.addEventListener('keydown', (e) => {
10641        if (e.key === 'Enter') {
10642          e.preventDefault();
10643          glitterDensityValue.blur();
10644        }
10645      });
10646    }
10647    
10648    if (glitterSizeMinInput && glitterSizeMinValue) {
10649      glitterSizeMinInput.addEventListener('input', () => {
10650        glitterSizeMinValue.value = glitterSizeMinInput.value;
10651      renderGradient();
10652    });
10653      
10654      glitterSizeMinValue.addEventListener('input', () => {
10655        // Allow user to type freely, only sync slider if value is valid
10656        const numVal = parseFloat(glitterSizeMinValue.value);
10657        if (!isNaN(numVal)) {
10658          const val = Math.max(0.1, Math.min(3, numVal));
10659          glitterSizeMinInput.value = val;
10660      renderGradient();
10661        }
10662      });
10663      
10664      glitterSizeMinValue.addEventListener('blur', () => {
10665        // Validate and clamp on blur
10666        const val = Math.max(0.1, Math.min(3, parseFloat(glitterSizeMinValue.value) || 0.5));
10667        glitterSizeMinInput.value = val;
10668        glitterSizeMinValue.value = val;
10669      renderGradient();
10670    });
10671      
10672      glitterSizeMinValue.addEventListener('keydown', (e) => {
10673        if (e.key === 'Enter') {
10674          e.preventDefault();
10675          glitterSizeMinValue.blur();
10676        }
10677      });
10678    }
10679    
10680    if (glitterSizeMaxInput && glitterSizeMaxValue) {
10681      glitterSizeMaxInput.addEventListener('input', () => {
10682        glitterSizeMaxValue.value = glitterSizeMaxInput.value;
10683      renderGradient();
10684    });
10685      
10686      glitterSizeMaxValue.addEventListener('input', () => {
10687        // Allow user to type freely, only sync slider if value is valid
10688        const numVal = parseFloat(glitterSizeMaxValue.value);
10689        if (!isNaN(numVal)) {
10690          const val = Math.max(0.2, Math.min(5, numVal));
10691          glitterSizeMaxInput.value = val;
10692          renderGradient();
10693        }
10694      });
10695      
10696      glitterSizeMaxValue.addEventListener('blur', () => {
10697        // Validate and clamp on blur
10698        const val = Math.max(0.2, Math.min(5, parseFloat(glitterSizeMaxValue.value) || 3));
10699        glitterSizeMaxInput.value = val;
10700        glitterSizeMaxValue.value = val;
10701        renderGradient();
10702      });
10703      
10704      glitterSizeMaxValue.addEventListener('keydown', (e) => {
10705        if (e.key === 'Enter') {
10706          e.preventDefault();
10707          glitterSizeMaxValue.blur();
10708        }
10709      });
10710    }
10711
10712    if (glitterOpacity && glitterOpacityValue) {
10713      glitterOpacity.addEventListener('input', () => {
10714        glitterOpacityValue.value = glitterOpacity.value;
10715        renderGradient();
10716      });
10717      glitterOpacityValue.addEventListener('input', () => {
10718        const v = Math.max(0, Math.min(100, parseFloat(glitterOpacityValue.value) || 100));
10719        glitterOpacityValue.value = v;
10720        glitterOpacity.value = v;
10721        renderGradient();
10722      });
10723      glitterOpacityValue.addEventListener('blur', () => {
10724        const v = Math.max(0, Math.min(100, parseFloat(glitterOpacityValue.value) || 100));
10725        glitterOpacityValue.value = v;
10726        glitterOpacity.value = v;
10727        renderGradient();
10728      });
10729      glitterOpacityValue.addEventListener('keydown', (e) => {
10730        if (e.key === 'Enter') {
10731          e.preventDefault();
10732          glitterOpacityValue.blur();
10733        }
10734      });
10735    }
10736
10737    const glitterBlendModes = ['normal', 'mul', 'screen', 'overlay', 'softlight', 'hardlight', 'difference', 'exclusion', 'darken', 'lighten', 'colorburn', 'colordodge'];
10738    const glitterBlendModeIcon = document.getElementById('glitterBlendModeIcon');
10739    const syncGlitterBlendIcon = () => syncBlendModeIcon(glitterBlendMode, glitterBlendModeIcon);
10740    if (glitterBlendMode) glitterBlendMode.addEventListener('change', () => { syncGlitterBlendIcon(); renderGradient(); });
10741    if (glitterBlendPrev && glitterBlendMode) glitterBlendPrev.addEventListener('click', () => {
10742      const i = glitterBlendModes.indexOf(glitterBlendMode.value);
10743      glitterBlendMode.value = glitterBlendModes[(i - 1 + glitterBlendModes.length) % glitterBlendModes.length];
10744      syncGlitterBlendIcon();
10745      renderGradient();
10746    });
10747    if (glitterBlendNext && glitterBlendMode) glitterBlendNext.addEventListener('click', () => {
10748      const i = glitterBlendModes.indexOf(glitterBlendMode.value);
10749      glitterBlendMode.value = glitterBlendModes[(i + 1) % glitterBlendModes.length];
10750      syncGlitterBlendIcon();
10751      renderGradient();
10752    });
10753    syncGlitterBlendIcon();
10754
10755    // Help button opens guide in new tab (no modal)
10756    const helpModal = document.getElementById('helpModal');
10757    const closeHelpModal = document.getElementById('closeHelpModal');
10758    const templatesBtn = document.getElementById('templatesBtn');
10759    const templatesModal = document.getElementById('templatesModal');
10760    const closeTemplatesModal = document.getElementById('closeTemplatesModal');
10761    const templatesGrid = document.getElementById('templatesGrid');
10762    const curatedTemplateSlugs = [
10763      'aurora',
10764      'corv',
10765      'friday',
10766      'giger',
10767      'hlo',
10768      'kinmino',
10769      'lau',
10770      'lo',
10771      'lurux',
10772      'magma',
10773      'plasma',
10774      'polumia',
10775      'potik',
10776      'promin',
10777      'vertex',
10778      'zelen'
10779    ];
10780
10781    function appRelativeAssetUrl(path) {
10782      const { origin, pathname } = window.location;
10783      if (pathname.endsWith('/')) return origin + pathname + path;
10784      if (pathname.endsWith('.html')) return origin + pathname.slice(0, pathname.lastIndexOf('/') + 1) + path;
10785      return origin + pathname + '/' + path;
10786    }
10787
10788    function formatTemplateName(slug) {
10789      const specialNames = {
10790        hlo: 'HLO',
10791        lo: 'LO'
10792      };
10793      if (specialNames[slug]) return specialNames[slug];
10794      return slug
10795        .split('-')
10796        .map(part => part.charAt(0).toUpperCase() + part.slice(1))
10797        .join(' ');
10798    }
10799
10800    function renderTemplatesGrid() {
10801      if (!templatesGrid || templatesGrid.dataset.rendered === 'true') return;
10802      curatedTemplateSlugs.forEach(slug => {
10803        const tile = document.createElement('button');
10804        tile.type = 'button';
10805        tile.className = 'template-tile';
10806        tile.dataset.templateSlug = slug;
10807        tile.title = `Open ${formatTemplateName(slug)} template`;
10808
10809        const img = document.createElement('img');
10810        img.src = appRelativeAssetUrl(`templates/template_${slug}.png`);
10811        img.alt = `${formatTemplateName(slug)} template preview`;
10812        img.loading = 'lazy';
10813
10814        const name = document.createElement('span');
10815        name.className = 'template-tile-name';
10816        name.textContent = formatTemplateName(slug);
10817
10818        tile.appendChild(img);
10819        tile.appendChild(name);
10820        tile.addEventListener('click', () => {
10821          window.location.href = `/templates/${slug}`;
10822        });
10823        templatesGrid.appendChild(tile);
10824      });
10825      templatesGrid.dataset.rendered = 'true';
10826    }
10827
10828    function openTemplatesModal() {
10829      renderTemplatesGrid();
10830      if (templatesModal) templatesModal.classList.add('visible');
10831    }
10832
10833    function closeTemplatesModalView() {
10834      if (templatesModal) templatesModal.classList.remove('visible');
10835    }
10836
10837    if (closeHelpModal) {
10838      closeHelpModal.addEventListener('click', () => {
10839        helpModal.classList.remove('visible');
10840      });
10841    }
10842
10843    // Close modal when clicking outside
10844    if (helpModal) {
10845      helpModal.addEventListener('click', (e) => {
10846        if (e.target === helpModal) {
10847          helpModal.classList.remove('visible');
10848        }
10849      });
10850    }
10851
10852    if (templatesBtn) templatesBtn.addEventListener('click', openTemplatesModal);
10853    if (closeTemplatesModal) closeTemplatesModal.addEventListener('click', closeTemplatesModalView);
10854    if (templatesModal) {
10855      templatesModal.addEventListener('click', (e) => {
10856        if (e.target === templatesModal) {
10857          closeTemplatesModalView();
10858        }
10859      });
10860    }
10861    document.addEventListener('keydown', (e) => {
10862      if (e.key === 'Escape' && templatesModal && templatesModal.classList.contains('visible')) {
10863        closeTemplatesModalView();
10864      }
10865    });
10866
10867    // Deep link: /app/?templates=1 opens the curated templates modal once.
10868    (function openTemplatesFromQuery() {
10869      const params = new URLSearchParams(window.location.search);
10870      if (params.get('templates') !== '1') return;
10871      openTemplatesModal();
10872      params.delete('templates');
10873      const qs = params.toString();
10874      const next = window.location.pathname + (qs ? '?' + qs : '') + window.location.hash;
10875      history.replaceState(null, '', next);
10876    })();
10877
10878    // Randomize
10879    // Randomize
10880    // Randomize
10881    const randomBtn = document.getElementById('randomBtn');
10882
10883    function sampleCurveAtTForTransition(oldPoints, t) {
10884      if (oldPoints.length === 0) return 0;
10885      if (oldPoints.length === 1) return oldPoints[0].v;
10886      for (let i = 0; i < oldPoints.length - 1; i++) {
10887        if (t >
10887= oldPoints[i].t && t <= oldPoints[i + 1].t) {
10888          const t0 = oldPoints[i].t;
10889          const t1 = oldPoints[i + 1].t;
10890          const v0 = oldPoints[i].v;
10891          const v1 = oldPoints[i + 1].v;
10892          const localT = (t - t0) / (t1 - t0);
10893          return v0 + (v1 - v0) * localT;
10894        }
10895      }
10896      if (t <= oldPoints[0].t) return oldPoints[0].v;
10897      return oldPoints[oldPoints.length - 1].v;
10898    }
10899
10900    /** Eased curve + color transition; set blend/noise/etc. before calling. */
10901    function animateGlobalCurveColorTransition(oldXPoints, oldYPoints, oldColors, newXPoints, newYPoints, newColors) {
10902      if (globalTransitionRaf) {
10903        cancelAnimationFrame(globalTransitionRaf);
10904        globalTransitionRaf = 0;
10905      }
10906      const transitionGen = ++globalTransitionGen;
10907      xEditor.setMaxPoints(newXPoints.length);
10908      yEditor.setMaxPoints(newYPoints.length);
10909
10910      const startXPoints = newXPoints.map(newPoint => ({
10911        t: newPoint.t,
10912        v: sampleCurveAtTForTransition(oldXPoints, newPoint.t)
10913      }));
10914      const startYPoints = newYPoints.map(newPoint => ({
10915        t: newPoint.t,
10916        v: sampleCurveAtTForTransition(oldYPoints, newPoint.t)
10917      }));
10918
10919      xEditor.setPoints(startXPoints);
10920      yEditor.setPoints(startYPoints);
10921
10922      const startColors = newColors.map((newColor, i) => {
10923        if (i < oldColors.length) {
10924          return { ...oldColors[i] };
10925        }
10926        return oldColors.length > 0 ? { ...oldColors[oldColors.length - 1] } : { r: 128, g: 128, b: 128 };
10927      });
10928
10929      gradientStopsArray.forEach((stop, i) => {
10930        stop.color = startColors[i] || { r: 128, g: 128, b: 128 };
10931        if (stop.oklch) delete stop.oklch;
10932      });
10933
10934      const colorDuration = 340;
10935      const curveDuration = 250;
10936      const duration = Math.max(colorDuration, curveDuration);
10937      const startTime = performance.now();
10938
10939      function animateGlobalTransition(currentTime) {
10940        if (transitionGen !== globalTransitionGen) return;
10941        const elapsed = currentTime - startTime;
10942        const colorProgress = Math.min(elapsed / colorDuration, 1);
10943        const curveProgress = Math.min(elapsed / curveDuration, 1);
10944
10945        const colorEased = colorProgress < 0.5
10946          ? 2 * colorProgress * colorProgress
10947          : 1 - Math.pow(-2 * colorProgress + 2, 2) / 2;
10948
10949        const curveEased = curveProgress < 0.5
10950          ? 2 * curveProgress * curveProgress
10951          : 1 - Math.pow(-2 * curveProgress + 2, 2) / 2;
10952
10953        const currentXPoints = newXPoints.map((newPoint, i) => ({
10954          t: newPoint.t,
10955          v: startXPoints[i].v + (newPoint.v - startXPoints[i].v) * curveEased
10956        }));
10957        const currentYPoints = newYPoints.map((newPoint, i) => ({
10958          t: newPoint.t,
10959          v: startYPoints[i].v + (newPoint.v - startYPoints[i].v) * curveEased
10960        }));
10961
10962        xEditor.setPoints(currentXPoints);
10963        yEditor.setPoints(currentYPoints);
10964
10965        gradientStopsArray.forEach((stop, i) => {
10966          const startColor = startColors[i] || { r: 128, g: 128, b: 128 };
10967          const endColor = newColors[i] || { r: 128, g: 128, b: 128 };
10968          stop.color = {
10969            r: Math.round(startColor.r + (endColor.r - startColor.r) * colorEased),
10970            g: Math.round(startColor.g + (endColor.g - startColor.g) * colorEased),
10971            b: Math.round(startColor.b + (endColor.b - startColor.b) * colorEased)
10972          };
10973          if (stop.oklch) delete stop.oklch;
10974        });
10975
10976        cachedXSamples = null;
10977        cachedYSamples = null;
10978        xEditor.draw(activeCurve === 'x');
10979        yEditor.draw(activeCurve === 'y');
10980        renderGradientBar();
10981        updateColorPreviewFromSelectedStop();
10982        renderGradient();
10983
10984        if (elapsed < duration) {
10985          globalTransitionRaf = requestAnimationFrame(animateGlobalTransition);
10986        } else {
10987          globalTransitionRaf = 0;
10988          xEditor.setPoints(newXPoints);
10989          yEditor.setPoints(newYPoints);
10990          gradientStopsArray.forEach((stop, i) => {
10991            stop.color = newColors[i] || startColors[i] || { r: 128, g: 128, b: 128 };
10992            if (stop.oklch) delete stop.oklch;
10993          });
10994          cachedXSamples = null;
10995          cachedYSamples = null;
10996          xEditor.draw(activeCurve === 'x');
10997          yEditor.draw(activeCurve === 'y');
10998          renderGradientBar();
10999          updateColorPreviewFromSelectedStop();
11000          renderGradient();
11001        }
11002      }
11003
11004      globalTransitionRaf = requestAnimationFrame(animateGlobalTransition);
11005    }
11006
11007    function runLegacyRandomize() {
11008      saveHistory(); // Save state before global randomize
11009      
11010      // Store old state for transitions
11011      const oldXPoints = JSON.parse(JSON.stringify(xEditor.points));
11012      const oldYPoints = JSON.parse(JSON.stringify(yEditor.points));
11013      const oldColors = gradientStopsArray.map(stop => ({ ...stop.color }));
11014      
11015      // STEP 1: Set amount of colors FIRST (3-11)
11016      const colorCount = Math.floor(Math.random() * 9) + 3; // 3 to 11
11017      multiColorCount.value = colorCount;
11018      multiColorCountSlider.value = colorCount;
11019      
11020      // STEP 2: Generate new curves
11021      const curveSeed = Math.floor(Math.random() * 1000000);
11022      const xComplexity = Math.floor(Math.random() * 3) + 3; // 3, 4, or 5
11023      const yComplexity = Math.floor(Math.random() * 3) + 3; // 3, 4, or 5
11024      
11025      // Generate new curve points
11026      const xRNG = new SeededRNG(curveSeed);
11027      const yRNG = new SeededRNG(curveSeed + 1000000);
11028      
11029      const newXPoints = [];
11030      const newYPoints = [];
11031      
11032      for (let i = 0; i < xComplexity; i++) {
11033        const t = i / (xComplexity - 1);
11034        newXPoints.push({ t: t, v: xRNG.random() });
11035      }
11036      
11037      for (let i = 0; i < yComplexity; i++) {
11038        const t = i / (yComplexity - 1);
11039        newYPoints.push({ t: t, v: yRNG.random() });
11040      }
11041      
11042      // STEP 3: Generate new colors
11043      const colorSeed = Math.floor(Math.random() * 1000000);
11044      const colorRNG = new SeededRNG(colorSeed);
11045      
11046      // Adjust stop count to match the new count
11047      const currentCount = gradientStopsArray.length;
11048      if (colorCount !== currentCount) {
11049        gradientStopsArray.sort((a, b) => a.position - b.position);
11050        
11051        if (colorCount < currentCount) {
11052          gradientStopsArray.splice(colorCount);
11053        } else if (colorCount > currentCount) {
11054          while (gradientStopsArray.length < colorCount) {
11055            const newIndex = gradientStopsArray.length;
11056            const position = newIndex / (colorCount - 1);
11057            const col = getColorFromGradient(position);
11058            gradientStopsArray.push({ id: stopIdCounter++, position, color: col });
11059          }
11060        }
11061        
11062        if (selectedStopIndex >= gradientStopsArray.length) {
11063          selectedStopIndex = Math.max(0, gradientStopsArray.length - 1);
11064        }
11065        
11066        const finalCount = gradientStopsArray.length;
11067        if (finalCount > 1) {
11068          gradientStopsArray.forEach((stop, i) => {
11069            stop.position = i / (finalCount - 1);
11070          });
11071        } else if (finalCount === 1) {
11072          gradientStopsArray[0].position = 0;
11073        }
11074      }
11075      
11076      // Generate new colors
11077      const newColors = [];
11078      gradientStopsArray.forEach((stop, i) => {
11079        stop.position = i / (colorCount - 1);
11080        newColors.push({
11081          r: colorRNG.randomInt(0, 255),
11082          g: colorRNG.randomInt(0, 255),
11083          b: colorRNG.randomInt(0, 255)
11084        });
11085      });
11086      
11087      // Clear activePalette
11088        activePalette = null;
11089      
11090      // Update selected stop if needed
11091      if (selectedStopIndex >= gradientStopsArray.length) {
11092        selectedStopIndex = 0;
11093        selectedStopId = gradientStopsArray[0]?.id || null;
11094      }
11095      
11096      // STEP 4: Randomize global blend mode (instant, no transition)
11097      const blendModes = ['avg', 'mul', 'screen', 'overlay', 'softlight', 'hardlight', 'difference', 'exclusion', 'darken', 'lighten', 'colorburn', 'colordodge'];
11098      const randomBlendMode = blendModes[Math.floor(Math.random() * blendModes.length)];
11099      blendMode.value = randomBlendMode;
11100      updateBlendModeIcon(randomBlendMode);
11101      
11102      // STEP 5: Randomize noise (instant, no transition)
11103      const randomNoise = Math.floor(Math.random() * 10) + 2; // 2 to 11
11104      noiseInput.value = randomNoise;
11105      noiseValue.value = randomNoise;
11106      
11107      // STEP 6: Randomize glitter (instant, no transition)
11108      const glitterEnabledCheckbox = document.getElementById('glitterEnabled');
11109      const glitterDensityInput = document.getElementById('glitterDensityInput');
11110      const glitterDensityValue = document.getElementById('glitterDensityValue');
11111      const glitterSizeMinInput = document.getElementById('glitterSizeMinInput');
11112      const glitterSizeMinValue = document.getElementById('glitterSizeMinValue');
11113      const glitterSizeMaxInput = document.getElementById('glitterSizeMaxInput');
11114      const glitterSizeMaxValue = document.getElementById('glitterSizeMaxValue');
11115      
11116      if (glitterEnabledCheckbox && glitterDensityInput && glitterDensityValue) {
11117        // Randomize density: 7-56 (UI scale)
11118        const randomDensity = Math.floor(Math.random() * 50) + 7; // 7 to 56
11119        glitterDensityInput.value = randomDensity;
11120        glitterDensityValue.value = randomDensity;
11121      }
11122      
11123      if (glitterSizeMinInput && glitterSizeMinValue) {
11124        // Size min remains fixed at 0.5
11125        glitterSizeMinInput.value = 0.5;
11126        glitterSizeMinValue.value = 0.5;
11127      }
11128      
11129      if (glitterSizeMaxInput && glitterSizeMaxValue) {
11130        // Randomize size max: 2-3
11131        const randomSizeMax = Math.random() * 1 + 2; // 2 to 3
11132        // Round to nearest 0.5
11133        const roundedSizeMax = Math.round(randomSizeMax * 2) / 2;
11134        glitterSizeMaxInput.value = roundedSizeMax;
11135        glitterSizeMaxValue.value = roundedSizeMax;
11136      }
11137      
11138      // STEP 7: Randomize distortion (instant, no transition)
11139      const distortionEnabled = document.getElementById('distortionEnabled');
11140      const procDispType = document.getElementById('procDispType');
11141      const procDispIntensity = document.getElementById('procDispIntensity');
11142      const procDispIntensityValue = document.getElementById('procDispIntensityValue');
11143      const procDispScale = document.getElementById('procDispScale');
11144      const procDispScaleValue = document.getElementById('procDispScaleValue');
11145      const procDispSeed = document.getElementById('procDispSeed');
11146      const procDispSeedValue = document.getElementById('procDispSeedValue');
11147      const procDispOctaves = document.getElementById('procDispOctaves');
11148      const procDispOctavesValue = document.getElementById('procDispOctavesValue');
11149      const procDispLacunarity = document.getElementById('procDispLacunarity');
11150      const procDispLacunarityValue = document.getElementById('procDispLacunarityValue');
11151      const procDispPersistence = document.getElementById('procDispPersistence');
11152      const procDispPersistenceValue = document.getElementById('procDispPersistenceValue');
11153      const procDispWaveFreq = document.getElementById('procDispWaveFreq');
11154      const procDispWaveFreqValue = document.getElementById('procDispWaveFreqValue');
11155      const procDispVoronoiJitter = document.getElementById('procDispVoronoiJitter');
11156      const procDispVoronoiJitterValue = document.getElementById('procDispVoronoiJitterValue');
11157      const procDispWarpStrength = document.getElementById('procDispWarpStrength');
11158      const procDispWarpStrengthValue = document.getElementById('procDispWarpStrengthValue');
11159      const procDispPanel = document.getElementById('distortionPanel');
11160      
11161      if (distortionEnabled) {
11162        // 70% chance to enable distortion
11163        const shouldEnable = Math.random() < 0.7;
11164        distortionEnabled.checked = shouldEnable;
11165        
11166        if (typeof syncDistortionPanelState === 'function') {
11167          syncDistortionPanelState();
11168        } else if (procDispPanel) {
11169          procDispPanel.classList.toggle('panel-disabled', !shouldEnable);
11170          procDispPanel.classList.toggle('collapsed', !shouldEnable);
11171        }
11172        
11173        if (shouldEnable && procDispType) {
11174          // Randomize type - use the correct procDispTypes array instead of hardcoded list
11175          const randomType = procDispTypes[Math.floor(Math.random() * procDispTypes.length)];
11176          procDispType.value = randomType;
11177          
11178          // Randomize intensity (1-20)
11179          const randomIntensity = Math.floor(Math.random() * 20) + 1;
11180          if (procDispIntensity) procDispIntensity.value = randomIntensity;
11181          if (procDispIntensityValue) procDispIntensityValue.value = randomIntensity;
11182          
11183          // Randomize scale (0.5-3.0)
11184          const randomScale = Math.round((Math.random() * 2.5 + 0.5) * 10) / 10; // Round to 1 decimal
11185          if (procDispScale) procDispScale.value = randomScale;
11186          if (procDispScaleValue) procDispScaleValue.value = randomScale.toFixed(1);
11187          
11188          // Randomize seed (0-1000)
11189          const randomSeed = Math.floor(Math.random() * 1001);
11190          if (procDispSeed) procDispSeed.value = randomSeed;
11191          if (procDispSeedValue) procDispSeedValue.value = randomSeed;
11192          
11193          // Randomize type-specific controls based on selected type
11194          if (randomType === 'organic' || randomType === 'ridged' || randomType === 'kura' || randomType === 'tayu') {
11195            // Octaves (1-3)
11196            const randomOctaves = Math.floor(Math.random() * 3) + 1;
11197            if (procDispOctaves) procDispOctaves.value = randomOctaves;
11198            if (procDispOctavesValue) procDispOctavesValue.value = randomOctaves;
11199            
11200            // Lacunarity (1.5-2.5)
11201            const randomLacunarity = Math.round((Math.random() * 1.0 + 1.5) * 10) / 10;
11202            if (procDispLacunarity) procDispLacunarity.value = randomLacunarity;
11203            if (procDispLacunarityValue) procDispLacunarityValue.value = randomLacunarity.toFixed(1);
11204            
11205            // Persistence (0.1-0.5)
11206            const randomPersistence = Math.round((Math.random() * 0.4 + 0.1) * 100) / 100;
11207            if (procDispPersistence) procDispPersistence.value = randomPersistence;
11208            if (procDispPersistenceValue) procDispPersistenceValue.value = randomPersistence.toFixed(2);
11209          }
11210          
11211          if (randomType === 'organic') {
11212            // Warp Strength (0.5-3.0)
11213            const randomWarpStrength = Math.round((Math.random() * 2.5 + 0.5) * 10) / 10;
11214            if (procDispWarpStrength) procDispWarpStrength.value = randomWarpStrength;
11215            if (procDispWarpStrengthValue) procDispWarpStrengthValue.value = randomWarpStrength.toFixed(1);
11216          }
11217          
11218          if (randomType === 'waves') {
11219            // Wave Frequency (1-10)
11220            const randomWaveFreq = Math.floor(Math.random() * 10) + 1;
11221            if (procDispWaveFreq) procDispWaveFreq.value = randomWaveFreq;
11222            if (procDispWaveFreqValue) procDispWaveFreqValue.value = randomWaveFreq;
11223          }
11224          
11225          if (randomType === 'kura') {
11226            // Mix (0.0-1.0)
11227            const randomMix = Math.round((Math.random() * 1.0) * 100) / 100;
11228            const procDispMix = document.getElementById('procDispMix');
11229            const procDispMixValue = document.getElementById('procDispMixValue');
11230            if (procDispMix) procDispMix.value = randomMix;
11231            if (procDispMixValue) procDispMixValue.value = randomMix.toFixed(2);
11232          }
11233          
11234          if (randomType === 'tayu') {
11235            // Tayu Mix (0.0-1.0)
11236            const randomTayuMix = Math.round((Math.random() * 1.0) * 100) / 100;
11237            const procDispTayuMix = document.getElementById('procDispTayuMix');
11238            const procDispTayuMixValue = document.getElementById('procDispTayuMixValue');
11239            if (procDispTayuMix) procDispTayuMix.value = randomTayuMix;
11240            if (procDispTayuMixValue) procDispTayuMixValue.value = randomTayuMix.toFixed(2);
11241            // Voronoi Jitter (0.0-1.0) - also needed for Tayu
11242            const randomJitter = Math.round((Math.random() * 1.0) * 100) / 100;
11243            if (procDispVoronoiJitter) procDispVoronoiJitter.value = randomJitter;
11244            if (procDispVoronoiJitterValue) procDispVoronoiJitterValue.value = randomJitter.toFixed(2);
11245          }
11246          
11247          // Update UI to show/hide type-specific controls
11248          if (typeof updateProcDispControls === 'function') {
11249            updateProcDispControls();
11250          }
11251        }
11252      }
11253      
11254      // Material Relief randomize (10% chance, value 1–5 when applied)
11255      const materialStrengthInputRand = document.getElementById('materialStrengthInput');
11256      const materialStrengthValueRand = document.getElementById('materialStrength');
11257      if (materialStrengthInputRand && materialStrengthValueRand) {
11258        const st = Math.random() < 0.1 ? Math.floor(Math.random() * 5) + 1 : 0;
11259        materialStrengthInputRand.value = st;
11260        materialStrengthValueRand.value = st;
11261      }
11262
11263      animateGlobalCurveColorTransition(oldXPoints, oldYPoints, oldColors, newXPoints, newYPoints, newColors);
11264    }
11265
11266    const SMART_RANDOMIZE_POOL_SIZE = 24;
11267    const SMART_RANDOMIZE_TOP_POOL = 5;
11268    let smartRandomizeAssetsPromise = null;
11269
11270    // Trained ranker is default ON. Rollback: ?smartRandomize=0 (sets colirDisableTrainedRandomize) or restore runLegacyRandomize in the click handler.
11271    function shouldUseTrainedRandomize() {
11272      try {
11273        const params = new URLSearchParams(window.location.search);
11274        if (params.get('smartRandomize') === '0') {
11275          localStorage.setItem('colirDisableTrainedRandomize', '1');
11276          localStorage.removeItem('colirSmartRandomize');
11277          return false;
11278        }
11279        if (params.get('smartRandomize') === '1') {
11280          localStorage.removeItem('colirDisableTrainedRandomize');
11281          localStorage.removeItem('colirSmartRandomize');
11282          return true;
11283        }
11284        if (localStorage.getItem('colirDisableTrainedRandomize') === '1') {
11285          return false;
11286        }
11287        return true;
11288      } catch (error) {
11289        return true;
11290      }
11291    }
11292
11293    function smartRandomizeAssetBaseUrl() {
11294      const { origin, pathname } = window.location;
11295      if (pathname.endsWith('/')) return origin + pathname;
11296      if (pathname.endsWith('.html')) return origin + pathname.slice(0, pathname.lastIndexOf('/') + 1);
11297      return origin + pathname + '/';
11298    }
11299
11300    async function loadSmartRandomizeAssets() {
11301      if (!smartRandomizeAssetsPromise) {
11302        smartRandomizeAssetsPromise = (async () => {
11303          let model;
11304          const assetBase = smartRandomizeAssetBaseUrl();
11305          const bundledRes = await fetch(new URL('models/ranker-v3.json', assetBase));
11306          if (bundledRes.ok) {
11307            model = await bundledRes.json();
11308          } else {
11309            const localTrainerRes = await fetch('/local/gradient-trainer/models/ranker-v3.json');
11310            if (!localTrainerRes.ok) throw new Error('Smart randomize model unavailable');
11311            model = await localTrainerRes.json();
11312          }
11313          let jsonl = '';
11314          try {
11315            const prefBundled = await fetch(new URL('models/preferences.jsonl', assetBase));
11316            if (prefBundled.ok) jsonl = await prefBundled.text();
11317          } catch (e) { /* optional */ }
11318          if (!jsonl) {
11319            try {
11320              const prefTrainer = await fetch('/local/gradient-trainer/data/preferences.jsonl');
11321              if (prefTrainer.ok) jsonl = await prefTrainer.text();
11322            } catch (e) { /* optional */ }
11323          }
11324          return {
11325            model,
11326            anchors: buildSmartRandomizeAnchors(jsonl)
11327          };
11328        })();
11329      }
11330      return smartRandomizeAssetsPromise;
11331    }
11332
11333    function buildSmartRandomizeAnchors(jsonl) {
11334      const rows = new Map();
11335      if (!jsonl) return [];
11336
11337      jsonl.split('\n').filter(Boolean).forEach(line => {
11338        let event;
11339        try {
11340          event = JSON.parse(line);
11341        } catch (error) {
11342          return;
11343        }
11344        if (!event.comparisonId) return;
11345        if (event.eventType === 'vote') {
11346          rows.set(event.comparisonId, event);
11347        } else if (event.eventType === 'revote' && rows.has(event.comparisonId)) {
11348          rows.get(event.comparisonId).label = event.label;
11349        }
11350      });
11351
11352      const anchors = [];
11353      rows.forEach(row => {
11354        if (row.label === 'both_nice') {
11355          anchors.push(row.left, row.right);
11356        } else if (row.label === 'left_wins') {
11357          anchors.push(row.left);
11358        } else if (row.label === 'right_wins') {
11359          anchors.push(row.right);
11360        }
11361      });
11362
11363      return anchors.filter(candidate => candidate && candidate.gradient).slice(0, 180);
11364    }
11365
11366    function smartClamp(value, min, max) {
11367      return Math.max(min, Math.min(max, value));
11368    }
11369
11370    function smartStats(values) {
11371      if (!values.length) return { avg: 0, min: 0, max: 0, range: 0, variance: 0 };
11372      const avg = values.reduce((sum, value) => sum + value, 0) / values.length;
11373      const min = Math.min(...values);
11374      const max = Math.max(...values);
11375      const variance = values.reduce((sum, value) => sum + (value - avg) ** 2, 0) / values.length;
11376      return { avg, min, max, range: max - min, variance };
11377    }
11378
11379    function smartRgbToHsl(r, g, b) {
11380      r /= 255;
11381      g /= 255;
11382      b /= 255;
11383      const max = Math.max(r, g, b);
11384      const min = Math.min(r, g, b);
11385      const l = (max + min) / 2;
11386      let h = 0;
11387      let s = 0;
11388
11389      if (max !== min) {
11390        const d = max - min;
11391        s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
11392        if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
11393        else if (max === g) h = (b - r) / d + 2;
11394        else h = (r - g) / d + 4;
11395        h /= 6;
11396      }
11397
11398      return { h: h * 360, s, l };
11399    }
11400
11401    function smartCircularDistance(a, b) {
11402      const diff = Math.abs(a - b) % 360;
11403      return Math.min(diff, 360 - diff);
11404    }
11405
11406    function smartColorDistance(a, b) {
11407      const dr = (a[0] - b[0]) / 255;
11408      const dg = (a[1] - b[1]) / 255;
11409      const db = (a[2] - b[2]) / 255;
11410      return Math.sqrt(dr * dr + dg * dg + db * db) / Math.sqrt(3);
11411    }
11412
11413    function smartRandomCurve(count) {
11414      return Array.from({ length: count }, (_, index) => [
11415        Number((index / (count - 1)).toFixed(3)),
11416        Number(Math.random().toFixed(3))
11417      ]);
11418    }
11419
11420    function smartColorsByTemplate(template, count) {
11421      const colors = [];
11422      const templateName = template || 'vibrant';
11423      const pushHsl = (h, s, l) => colors.push(hslToRgb(h, s, l));
11424
11425      if (templateName === 'random') {
11426        for (let i = 0; i < count; i++) {
11427          colors.push({
11428            r: Math.floor(Math.random() * 256),
11429            g: Math.floor(Math.random() * 256),
11430            b: Math.floor(Math.random() * 256)
11431          });
11432        }
11433      } else if (templateName === 'warm') {
11434        for (let i = 0; i < count; i++) pushHsl(Math.random() * 60, 60 + Math.random() * 40, 40 + Math.random() * 50);
11435      } else if (templateName === 'cool') {
11436        for (let i = 0; i < count; i++) pushHsl(180 + Math.random() * 120, 60 + Math.random() * 40, 40 + Math.random() * 50);
11437      } else if (templateName === 'dark') {
11438        for (let i = 0; i < count; i++) pushHsl(Math.random() * 360, 15 + Math.random() * 15, 6 + Math.random() * 18);
11439      } else if (templateName === 'light') {
11440        for (let i = 0; i < count; i++) pushHsl(Math.random() * 360, 30 + Math.random() * 30, 70 + Math.random() * 25);
11441      } else if (templateName === 'complementary') {
11442        const baseHue = Math.random() * 360;
11443        for (let i = 0; i < count; i++) pushHsl(i % 2 === 0 ? baseHue : (baseHue + 180) % 360, 60 + Math.random() * 40, 40 + Math.random() * 50);
11444      } else if (templateName === 'analogous') {
11445        const baseHue = Math.random() * 360;
11446        const spread = 30 + Math.random() * 30;
11447        for (let i = 0; i < count; i++) pushHsl((baseHue + (Math.random() - 0.5) * spread + 360) % 360, 60 + Math.random() * 40, 40 + Math.random() * 50);
11448      } else if (templateName === 'triadic') {
11449        const baseHue = Math.random() * 360;
11450        for (let i = 0; i < count; i++) pushHsl((baseHue + (i % 3) * 120) % 360, 60 + Math.random() * 40, 40 + Math.random() * 50);
11451      } else if (templateName === 'monochromatic') {
11452        const baseHue = Math.random() * 360;
11453        for (let i = 0; i < count; i++) pushHsl(baseHue, 30 + Math.random() * 70, 20 + Math.random() * 70);
11454      } else {
11455        for (let i = 0; i < count; i++) pushHsl(Math.random() * 360, 75 + Math.random() * 25, 45 + Math.random() * 30);
11456      }
11457
11458      return colors.map((color, index) => [
11459        color.r,
11460        color.g,
11461        color.b,
11462        Number((index / (count - 1)).toFixed(3))
11463      ]);
11464    }
11465
11466    function smartCandidateVector(candidate) {
11467      const gradient = candidate.gradient || {};
11468      const oldFeatures = candidate.features || {};
11469      const colors = gradient.c || [];
11470      const hsl = colors.map(color => smartRgbToHsl(color[0], color[1], color[2]));
11471      const redStats = smartStats(colors.map(color =>
11471 color[0] / 255));
11472      const greenStats = smartStats(colors.map(color => color[1] / 255));
11473      const blueStats = smartStats(colors.map(color => color[2] / 255));
11474      const hues = hsl.map(color => color.h);
11475      const saturations = hsl.map(color => color.s);
11476      const lightnesses = hsl.map(color => color.l);
11477      const luminances = colors.map(([r, g, b]) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255);
11478      const stopPositions = colors.map(color => Number.isFinite(Number(color[3])) ? Number(color[3]) : 0);
11479      const stopGaps = stopPositions.slice(1).map((position, index) => Math.abs(position - stopPositions[index]));
11480      const adjacentDistances = colors.slice(1).map((color, index) => smartColorDistance(color, colors[index]));
11481      const hueDistances = hues.slice(1).map((hue, index) => smartCircularDistance(hue, hues[index]) / 180);
11482      const satStats = smartStats(saturations);
11483      const lightStats = smartStats(lightnesses);
11484      const lumStats = smartStats(luminances);
11485      const stopStats = smartStats(stopGaps);
11486      const colorDistanceStats = smartStats(adjacentDistances);
11487      const hueDistanceStats = smartStats(hueDistances);
11488      const colorTemplate = candidate.colorTemplate || oldFeatures.colorTemplate || 'unknown';
11489      const blend = oldFeatures.blendMode || gradient.b || 'difference';
11490      const distortionEnabledValue = !!(gradient.d && gradient.d.e);
11491      const distortionType = distortionEnabledValue ? (gradient.d.t || 'enabled') : 'none';
11492      const glitterEnabledValue = gradient.g ? gradient.g.e !== false : oldFeatures.glitterEnabled !== false;
11493
11494      return {
11495        bias: 1,
11496        colorCount: colors.length / 12,
11497        xPoints: (gradient.x || []).length / 8,
11498        yPoints: (gradient.y || []).length / 8,
11499        noise: (gradient.n ?? oldFeatures.noise ?? 6) / 12,
11500        feather: (gradient.f ?? oldFeatures.feather ?? 18) / 40,
11501        luminanceRange: lumStats.range,
11502        avgR: colors.length ? redStats.avg : 128 / 255,
11503        avgG: colors.length ? greenStats.avg : 128 / 255,
11504        avgB: colors.length ? blueStats.avg : 128 / 255,
11505        avgSaturation: satStats.avg,
11506        saturationRange: satStats.range,
11507        saturationVariance: satStats.variance,
11508        avgLightness: lightStats.avg,
11509        lightnessRange: lightStats.range,
11510        lightnessVariance: lightStats.variance,
11511        avgLuminance: lumStats.avg,
11512        luminanceVariance: lumStats.variance,
11513        hueSpread: hueDistanceStats.range,
11514        avgHueStep: hueDistanceStats.avg,
11515        hueStepVariance: hueDistanceStats.variance,
11516        avgAdjacentColorDistance: colorDistanceStats.avg,
11517        adjacentColorVariance: colorDistanceStats.variance,
11518        stopSpacingRange: stopStats.range,
11519        stopSpacingVariance: stopStats.variance,
11520        darkExtremeRatio: lightnesses.filter(value => value < 0.18).length / Math.max(colors.length, 1),
11521        lightExtremeRatio: lightnesses.filter(value => value > 0.82).length / Math.max(colors.length, 1),
11522        midtoneRatio: lightnesses.filter(value => value >= 0.25 && value <= 0.75).length / Math.max(colors.length, 1),
11523        distortionEnabled: distortionEnabledValue ? 1 : 0,
11524        distortionIntensity: distortionEnabledValue ? (gradient.d.i || 0) / 20 : 0,
11525        distortionScale: distortionEnabledValue ? (gradient.d.sc || 0) / 3 : 0,
11526        glitterEnabled: glitterEnabledValue ? 1 : 0,
11527        glitterDensity: gradient.g ? (gradient.g.d || 0) / 60 : 0,
11528        materialStrength: gradient.m ? (gradient.m.st || 0) / 5 : 0,
11529        [`template:${colorTemplate}`]: 1,
11530        [`blend:${blend}`]: 1,
11531        [`distortion:${distortionType}`]: 1,
11532        [`templateBlend:${colorTemplate}:${blend}`]: 1
11533      };
11534    }
11535
11536    function smartScoreCandidate(candidate, model) {
11537      const vector = smartCandidateVector(candidate);
11538      const scoreWith = weights => {
11539        if (!weights) return 0;
11540        return Object.entries(vector).reduce((score, [key, value]) => score + (weights[key] || 0) * value, 0);
11541      };
11542      const pairwiseScore = scoreWith(model.pairwiseWeights || model.weights);
11543      const qualityScore = scoreWith(model.qualityWeights);
11544      return pairwiseScore + qualityScore * (model.scoring?.qualityWeight ?? 0.55);
11545    }
11546
11547    function smartExtractFeatures(gradient, colorTemplate) {
11548      const vector = smartCandidateVector({ gradient, colorTemplate });
11549      return {
11550        colorCount: (gradient.c || []).length,
11551        colorTemplate,
11552        blendMode: gradient.b || 'difference',
11553        noise: gradient.n ?? 6,
11554        feather: gradient.f ?? 18,
11555        distortionEnabled: !!(gradient.d && gradient.d.e),
11556        glitterEnabled: gradient.g ? gradient.g.e !== false : true,
11557        materialStrength: gradient.m ? gradient.m.st || 0 : 0,
11558        luminanceRange: Number(vector.luminanceRange.toFixed(3)),
11559        avgLightness: Number(vector.avgLightness.toFixed(3)),
11560        hueSpread: Number(vector.hueSpread.toFixed(3)),
11561        avgAdjacentColorDistance: Number(vector.avgAdjacentColorDistance.toFixed(3))
11562      };
11563    }
11564
11565    function smartMaterializeCandidate(gradient, colorTemplate) {
11566      return {
11567        colorTemplate,
11568        gradient,
11569        features: smartExtractFeatures(gradient, colorTemplate)
11570      };
11571    }
11572
11573    function generateSmartCandidate() {
11574      const templates = ['vibrant', 'random', 'warm', 'cool', 'dark', 'light', 'complementary', 'analogous', 'triadic', 'monochromatic'];
11575      const blendModes = ['avg', 'mul', 'screen', 'overlay', 'softlight', 'hardlight', 'difference', 'exclusion', 'darken', 'lighten', 'colorburn', 'colordodge'];
11576      const distortionTypes = ['organic', 'ridged', 'waves', 'kura', 'tayu'];
11577      const colorTemplate = templates[Math.floor(Math.random() * templates.length)];
11578      const colorCount = Math.floor(Math.random() * 6) + 3;
11579      const distortionOn = Math.random() < 0.58;
11580      const gradient = {
11581        c: smartColorsByTemplate(colorTemplate, colorCount),
11582        x: smartRandomCurve(Math.floor(Math.random() * 3) + 3),
11583        y: smartRandomCurve(Math.floor(Math.random() * 3) + 3),
11584        b: blendModes[Math.floor(Math.random() * blendModes.length)],
11585        n: Math.floor(Math.random() * 8) + 2,
11586        f: Math.floor(Math.random() * 31) + 2,
11587        g: {
11588          e: true,
11589          d: Math.floor(Math.random() * 36) + 7,
11590          min: 0.5,
11591          max: [2, 2.5, 3][Math.floor(Math.random() * 3)],
11592          o: 100,
11593          b: 'normal'
11594        },
11595        d: distortionOn ? {
11596          e: true,
11597          t: distortionTypes[Math.floor(Math.random() * distortionTypes.length)],
11598          i: Math.floor(Math.random() * 15) + 1,
11599          sc: Math.round((Math.random() * 2.1 + 0.5) * 10) / 10,
11600          seed: Math.floor(Math.random() * 1001)
11601        } : { e: false }
11602      };
11603
11604      if (Math.random() < 0.08) {
11605        gradient.m = { e: true, p: 'plastic', st: Math.floor(Math.random() * 4) + 1 };
11606      }
11607
11608      return smartMaterializeCandidate(gradient, colorTemplate);
11609    }
11610
11611    function isSmartConstrainedCandidate(candidate) {
11612      const features = candidate.features || {};
11613      const gradient = candidate.gradient || {};
11614      const colorCount = features.colorCount || 0;
11615      const avgLightness = features.avgLightness ?? 0.5;
11616      const luminanceRange = features.luminanceRange ?? 0;
11617      const hueSpread = features.hueSpread ?? 0;
11618      const adjacentDistance = features.avgAdjacentColorDistance ?? 0;
11619      const noise = gradient.n ?? features.noise ?? 6;
11620      const glitterDensity = gradient.g ? gradient.g.d || 0 : 0;
11621      const distortionIntensity = gradient.d && gradient.d.e ? gradient.d.i || 0 : 0;
11622
11623      if (colorCount < 3 || colorCount > 8) return false;
11624      if (avgLightness < 0.16 || avgLightness > 0.88) return false;
11625      if (luminanceRange < 0.08 || luminanceRange > 0.86) return false;
11626      if (hueSpread > 0.95 && adjacentDistance > 0.58) return false;
11627      if (noise > 9) return false;
11628      if (glitterDensity > 42) return false;
11629      if (distortionIntensity > 15) return false;
11630      if (['colorburn', 'colordodge', 'hardlight', 'difference'].includes(features.blendMode) && luminanceRange > 0.72) return false;
11631      return true;
11632    }
11633
11634    function generateSmartConstrainedCandidate() {
11635      let best = null;
11636      let bestPenalty = Infinity;
11637      for (let attempt = 0; attempt < 10; attempt++) {
11638        const candidate = generateSmartCandidate();
11639        if (isSmartConstrainedCandidate(candidate)) return candidate;
11640        const features = candidate.features || {};
11641        const penalty = Math.abs((features.avgLightness ?? 0.5) - 0.52)
11642          + Math.max(0, (features.colorCount || 0) - 8) * 0.2
11643          + Math.max(0, 0.1 - (features.luminanceRange ?? 0)) * 2
11644          + Math.max(0, (candidate.gradient?.n ?? 6) - 9) * 0.1;
11645        if (penalty < bestPenalty) {
11646          bestPenalty = penalty;
11647          best = candidate;
11648        }
11649      }
11650      return best || generateSmartCandidate();
11651    }
11652
11653    function mutateSmartAnchor(anchor) {
11654      const gradient = JSON.parse(JSON.stringify(anchor.gradient));
11655      gradient.c = (gradient.c || []).map(color => [
11656        smartClamp(Math.round(color[0] + Math.floor(Math.random() * 49) - 24), 0, 255),
11657        smartClamp(Math.round(color[1] + Math.floor(Math.random() * 49) - 24), 0, 255),
11658        smartClamp(Math.round(color[2] + Math.floor(Math.random() * 49) - 24), 0, 255),
11659        smartClamp((color[3] ?? 0) + (Math.random() * 0.07 - 0.035), 0, 1)
11660      ]).sort((a, b) => a[3] - b[3]);
11661      if (gradient.c.length >= 2) {
11662        gradient.c[0][3] = 0;
11663        gradient.c[gradient.c.length - 1][3] = 1;
11664      }
11665      gradient.x = (gradient.x || []).map(([x, y]) => [x, smartClamp(y + (Math.random() * 0.24 - 0.12), 0, 1)]);
11666      gradient.y = (gradient.y || []).map(([x, y]) => [x, smartClamp(y + (Math.random() * 0.24 - 0.12), 0, 1)]);
11667      gradient.n = smartClamp((gradient.n ?? 6) + Math.floor(Math.random() * 5) - 2, 2, 9);
11668      gradient.f = smartClamp((gradient.f ?? 18) + Math.floor(Math.random() * 13) - 6, 2, 32);
11669      if (gradient.g) gradient.g.d = smartClamp((gradient.g.d ?? 20) + Math.floor(Math.random() * 17) - 8, 7, 42);
11670      if (gradient.d && gradient.d.e) {
11671        gradient.d.i = smartClamp((gradient.d.i ?? 8) + Math.floor(Math.random() * 9) - 4, 1, 15);
11672        gradient.d.sc = smartClamp((gradient.d.sc ?? 1.5) + (Math.random() * 0.8 - 0.4), 0.5, 2.6);
11673        gradient.d.seed = Math.floor(Math.random() * 1001);
11674        if (Math.random() < 0.18) gradient.d = { e: false };
11675      }
11676      return smartMaterializeCandidate(gradient, anchor.colorTemplate || anchor.features?.colorTemplate || 'anchor');
11677    }
11678
11679    function generateSmartRankedCandidate(model, anchors) {
11680      const pool = [];
11681      for (let i = 0; i < SMART_RANDOMIZE_POOL_SIZE; i++) {
11682        const useAnchor = anchors.length && Math.random() < 0.4;
11683        const candidate = useAnchor
11684          ? mutateSmartAnchor(anchors[Math.floor(Math.random() * anchors.length)])
11685          : generateSmartConstrainedCandidate();
11686        pool.push({
11687          ...candidate,
11688          modelScore: smartScoreCandidate(candidate, model)
11689        });
11690      }
11691
11692      pool.sort((a, b) => b.modelScore - a.modelScore);
11693      const topPool = pool.slice(0, SMART_RANDOMIZE_TOP_POOL);
11694      return topPool[Math.floor(Math.random() * topPool.length)] || pool[0];
11695    }
11696
11697    function applySmartRandomizeCandidate(candidate) {
11698      const gradient = candidate.gradient;
11699      const colorCount = gradient.c.length;
11700      const oldXPoints = JSON.parse(JSON.stringify(xEditor.points));
11701      const oldYPoints = JSON.parse(JSON.stringify(yEditor.points));
11702      const oldColors = gradientStopsArray.map(stop => ({ ...stop.color }));
11703      const newXPoints = gradient.x.map(([t, v]) => ({ t, v }));
11704      const newYPoints = gradient.y.map(([t, v]) => ({ t, v }));
11705      const newColors = gradient.c.map(color => ({ r: color[0], g: color[1], b: color[2] }));
11706
11707      multiColorCount.value = colorCount;
11708      multiColorCountSlider.value = colorCount;
11709      gradientStopsArray.sort((a, b) => a.position - b.position);
11710      while (gradientStopsArray.length > colorCount) gradientStopsArray.pop();
11711      while (gradientStopsArray.length < colorCount) {
11712        gradientStopsArray.push({ id: stopIdCounter++, position: 0, color: { r: 128, g: 128, b: 128 } });
11713      }
11714      gradientStopsArray.forEach((stop, index) => {
11715        stop.position = gradient.c[index][3];
11716      });
11717
11718      activePalette = null;
11719      selectedStopIndex = 0;
11720      selectedStopId = gradientStopsArray[0]?.id || null;
11721
11722      blendMode.value = gradient.b || 'difference';
11723      updateBlendModeIcon(blendMode.value);
11724      noiseInput.value = gradient.n ?? 6;
11725      noiseValue.value = gradient.n ?? 6;
11726      featherInput.value = gradient.f ?? 18;
11727      featherValue.value = gradient.f ?? 18;
11728
11729      const glitterEnabledCheckbox = document.getElementById('glitterEnabled');
11730      const glitterDensityInput = document.getElementById('glitterDensityInput');
11731      const glitterDensityValue = document.getElementById('glitterDensityValue');
11732      const glitterSizeMinInput = document.getElementById('glitterSizeMinInput');
11733      const glitterSizeMinValue = document.getElementById('glitterSizeMinValue');
11734      const glitterSizeMaxInput = document.getElementById('glitterSizeMaxInput');
11735      const glitterSizeMaxValue = document.getElementById('glitterSizeMaxValue');
11736      if (glitterEnabledCheckbox && gradient.g) glitterEnabledCheckbox.checked = gradient.g.e !== false;
11737      if (glitterDensityInput && gradient.g) glitterDensityInput.value = gradient.g.d ?? 20;
11738      if (glitterDensityValue && gradient.g) glitterDensityValue.value = gradient.g.d ?? 20;
11739      if (glitterSizeMinInput && gradient.g) glitterSizeMinInput.value = gradient.g.min ?? 0.5;
11740      if (glitterSizeMinValue && gradient.g) glitterSizeMinValue.value = gradient.g.min ?? 0.5;
11741      if (glitterSizeMaxInput && gradient.g) glitterSizeMaxInput.value = gradient.g.max ?? 2.5;
11742      if (glitterSizeMaxValue && gradient.g) glitterSizeMaxValue.value = gradient.g.max ?? 2.5;
11743
11744      const distortionEnabled = document.getElementById('distortionEnabled');
11745      const procDispType = document.getElementById('procDispType');
11746      const procDispIntensity = document.getElementById('procDispIntensity');
11747      const procDispIntensityValue = document.getElementById('procDispIntensityValue');
11748      const procDispScale = document.getElementById('procDispScale');
11749      const procDispScaleValue = document.getElementById('procDispScaleValue');
11750      const procDispSeed = document.getElementById('procDispSeed');
11751      const procDispSeedValue = document.getElementById('procDispSeedValue');
11752      const procDispPanel = document.getElementById('distortionPanel');
11753      const distortionOn = !!(gradient.d && gradient.d.e);
11754      if (distortionEnabled) distortionEnabled.checked = distortionOn;
11755      if (procDispPanel) {
11756        procDispPanel.classList.toggle('panel-disabled', !distortionOn);
11757        procDispPanel.classList.toggle('collapsed', !distortionOn);
11758      }
11759      if (distortionOn) {
11760        if (procDispType) procDispType.value = gradient.d.t || 'organic';
11761        if (procDispIntensity) procDispIntensity.value = gradient.d.i || 8;
11762        if (procDispIntensityValue) procDispIntensityValue.value = gradient.d.i || 8;
11763        if (procDispScale) procDispScale.value = gradient.d.sc || 1.5;
11764        if (procDispScaleValue) procDispScaleValue.value = Number(gradient.d.sc || 1.5).toFixed(1);
11765        if (procDispSeed) procDispSeed.value = gradient.d.seed || 0;
11766        if (procDispSeedValue) procDispSeedValue.value = gradient.d.seed || 0;
11767      }
11768      if (typeof updateProcDispControls === 'function') updateProcDispControls();
11769
11770      const materialStrengthInputRand = document.getElementById('materialStrengthInput');
11771      const materialStrengthValueRand = document.getElementById('materialStrength');
11772      const materialStrength = gradient.m ? gradient.m.st || 0 : 0;
11773      if (materialStrengthInputRand) materialStrengthInputRand.value = materialStrength;
11774      if (materialStrengthValueRand) materialStrengthValueRand.value = materialStrength;
11775
11776      animateGlobalCurveColorTransition(oldXPoints, oldYPoints, oldColors, newXPoints, newYPoints, newColors);
11777    }
11778
11779    async function runTrainedRandomizeWithFallback() {
11780      try {
11781        const { model, anchors } = await loadSmartRandomizeAssets();
11782        const startedAt = performance.now();
11783        const candidate = generateSmartRankedCandidate(model, anchors);
11784        const elapsed = performance.now() - startedAt;
11785        if (!candidate || elapsed > 140) {
11786          throw new Error(`Smart randomize took ${Math.round(elapsed)}ms`);
11787        }
11788        saveHistory();
11789        applySmartRandomizeCandidate(candidate);
11790        console.info(`Smart Randomize applied in ${Math.round(elapsed)}ms`, {
11791          score: Number(candidate.modelScore?.toFixed?.(3) || 0),
11792          template: candidate.colorTemplate
11793        });
11794      } catch (error) {
11795        console.warn('Smart Randomize fallback:', error);
11796        runLegacyRandomize();
11797      }
11798    }
11799
11800    if (shouldUseTrainedRandomize()) {
11801      loadSmartRandomizeAssets().catch(error => console.warn('Smart Randomize preload failed:', error));
11802    }
11803
11804    randomBtn.addEventListener('click', () => {
11805      if (shouldUseTrainedRandomize()) {
11806        runTrainedRandomizeWithFallback();
11807      } else {
11808        runLegacyRandomize();
11809      }
11810    });
11811
11812
11813
11814    function hexToRgb(hex) {
11815      const clean = hex.replace('#', '');
11816      const num = parseInt(clean, 16);
11817      return {
11818        r: (num >> 16) & 255,
11819        g: (num >> 8) & 255,
11820        b: num & 255
11821      };
11822    }
11823
11824    function rgbToHex(r, g, b) {
11825      return '#' + [r, g, b].map(x => {
11826        const hex = x.toString(16);
11827        return hex.length === 1 ? '0' + hex : hex;
11828      }).join('');
11829    }
11830
11831    function rgbToHsl(r, g, b) {
11832      r /= 255; g /= 255; b /= 255;
11833      const max = Math.max(r, g, b), min = Math.min(r, g, b);
11834      let h, s, l = (max + min) / 2;
11835      if (max === min) {
11836        h = s = 0;
11837      } else {
11838        const d = max - min;
11839        s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
11840        switch (max) {
11841          case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
11842          case g: h = ((b - r) / d + 2) / 6; break;
11843          case b: h = ((r - g) / d + 4) / 6; break;
11844        }
11845      }
11846      return { h: h * 360, s: s * 100, l: l * 100 };
11847    }
11848
11849    function hslToRgb(h, s, l) {
11850      h /= 360; s /= 100; l /= 100;
11851      let r, g, b;
11852      if (s === 0) {
11853        r = g = b = l;
11854      } else {
11855        const hue2rgb = (p, q, t) => {
11856          if (t < 0) t += 1;
11857          if (t > 1) t -= 1;
11858          if (t < 1 / 6) return p + (q - p) * 6 * t;
11859          if (t < 1 / 2) return q;
11860          if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
11861          return p;
11862        };
11863        const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
11864        const p = 2 * l - q;
11865        r = hue2rgb(p, q, h + 1 / 3);
11866        g = hue2rgb(p, q, h);
11867        b = hue2rgb(p, q, h - 1 / 3);
11868      }
11869      return {
11870        r: Math.round(r * 255),
11871        g: Math.round(g * 255),
11872        b: Math.round(b * 255)
11873      };
11874    }
11875
11876    // Gradient stop management
11877    function renderGradientBar() {
11878      // Sort stops by position (create new array to avoid mutating original)
11879      const stops = [...gradientStopsArray].sort((a, b) => a.position - b.position);
11880      const gradientStr = stops.map(s => {
11881        const hex = rgbToHex(s.color.r, s.color.g, s.color.b);
11882        return `${hex} ${s.position * 100}%`;
11883      }).join(', ');
11884      gradientBar.style.background = `linear-gradient(to right, ${gradientStr})`;
11885
11886      // Map existing DOM elements by their stop id so each stop keeps its
11887      // own element across renders. This prevents neighbour stops from
11888      // "morphing" through CSS transitions when another stop is removed.
11889      const existingEls = Array.from(gradientStops.querySelectorAll('.gradient-stop'));
11890      const elementByStopId = new Map();
11891      existingEls.forEach(el => {
11892        const sid = el.dataset.stopId;
11893        if (sid !== '' && sid != null) {
11894          elementByStopId.set(parseInt(sid), el);
11895        }
11896      });
11897
11898      const usedEls = new Set();
11899      stops.forEach((stop, sortedIndex) => {
11900        // Find original index in unsorted array using unique ID
11901        let index = gradientStopsArray.findIndex(s => s.id === stop.id);
11902        
11903        // Fallback: if ID matching fails, try to match by position and color
11904        if (index === -1) {
11905          index = gradientStopsArray.findIndex(s => 
11906            s.position === stop.position && 
11907            s.color.r === stop.color.r && 
11908            s.color.g === stop.color.g && 
11909            s.color.b === stop.color.b
11910          );
11911        }
11912        
11913        // If still not found, use sortedIndex as fallback (shouldn't happen)
11914        if (index === -1) {
11915          index = sortedIndex;
11916        }
11917        
11918        // Calculate transform to prevent overflow at edges
11919        // Stop is 24px wide, so 12px on each side
11920        // At position 0, we want left edge at 0%, so translateX should be 0 instead of -50%
11921        // At position 1, we want right edge at 100%, so translateX should be -100% instead of -50%
11922        const isLeftmost = sortedIndex === 0;
11923        const isRightmost = sortedIndex === stops.length - 1;
11924        let transformX = '-50%';
11925        if (isLeftmost && stop.position <= 0.01) {
11926          transformX = '0%'; // Leftmost stop - align left edge
11927        } else if (isRightmost && stop.position >= 0.99) {
11928          transformX = '-100%'; // Rightmost stop - align right edge
11929        }
11930        
11931        // Check if this stop is selected by comparing ID (more reliable than index after sorting)
11932        const isSelected = selectedStopId !== null ? stop.id === selectedStopId : (index === selectedStopIndex);
11933
11934        let el = elementByStopId.get(stop.id);
11935        if (!el) {
11936          el = document.createElement('div');
11937          gradientStops.appendChild(el);
11938        }
11939        el.dataset.stopId = String(stop.id);
11940        el.dataset.index = index;
11941        el.style.left = (stop.position * 100) + '%';
11942        el.style.transform = `translate(${transformX}, -50%)`;
11943        el.style.setProperty('--stop-transform-x', transformX);
11944        el.style.backgroundColor = rgbToHex(stop.color.r, stop.color.g, stop.color.b);
11945        el.className = 'gradient-stop' + (isSelected ? ' selected' : '');
11946        usedEls.add(el);
11947      });
11948
11949      // Remove any DOM elements that no longer correspond to a stop.
11950      existingEls.forEach(el => {
11951        if (!usedEls.has(el) && el.parentNode) {
11952          el.parentNode.removeChild(el);
11953        }
11954      });
11955      updateRandomBtnGradient();
11956    }
11957
11958    function updateRandomBtnGradient() {
11959      const randomBtnEl = document.getElementById('randomBtn');
11960      if (!randomBtnEl || !gradientStopsArray.length) return;
11961      const sorted = [...gradientStopsArray].sort((a, b) => a.position - b.position);
11962      const first = sorted[0].color;
11963      const last = sorted[sorted.length - 1].color;
11964      const r1 = first.r, g1 = first.g, b1 = first.b;
11965      const r2 = last.r, g2 = last.g, b2 = last.b;
11966      const gradient = `linear-gradient(135deg, rgb(${r1}, ${g1}, ${b1}) 0%, rgb(${r2}, ${g2}, ${b2}) 100%)`;
11967      randomBtnEl.style.setProperty('--random-btn-gradient', gradient);
11968    }
11969
11970    function selectStop(index) {
11971      // Allow -1 to deselect
11972      if (index < -1 || index >= gradientStopsArray.length) {
11973        console.warn('Invalid stop index:', index);
11974        return;
11975      }
11976      selectedStopIndex = index;
11977      if (index < 0) {
11978        // Deselect
11979        selectedStopId = null;
11980        renderGradientBar();
11981        return;
11982      }
11983      const stop = gradientStopsArray[index];
11984      if (!stop) {
11985        console.warn('Stop not found at index:', index);
11986        return;
11987      }
11988      selectedStopId = stop.id; // Store the ID for reliable lookup
11989      const rgb = stop.color;
11990      const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
11991      currentHue = hsl.h;
11992      currentSaturation = hsl.s;
11993      currentLightness = hsl.l;
11994      // Update color picker display (swatch + hex)
11995      const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
11996      if (stopColorHexInput) stopColorHexInput.value = hex;
11997      if (stopColorSwatch) stopColorSwatch.style.background = hex;
11998      // If stop color dropdown is open, redraw spectrum/hue and markers
11999      if (stopColorDropdown && stopColorDropdown.classList.contains('visible')) {
12000        drawStopSpectrum();
12001        drawStopHueSlider();
12002        updateStopMarkers();
12003      }
12004      renderGradientBar();
12005    }
12006
12007    let colorUpdateTimeout = null;
12008    function updateSelectedStopColor(r, g, b) {
12009      // Find the selected stop by ID (more reliable than index after sorting)
12010      let selectedStop = null;
12011      if (selectedStopId !== null) {
12012        selectedStop = gradientStopsArray.find(s => s.id === selectedStopId);
12013      } else if (selectedStopIndex >= 0 && selectedStopIndex < gradientStopsArray.length) {
12014        selectedStop = gradientStopsArray[selectedStopIndex];
12015        // Update selectedStopId for future lookups
12016        if (selectedStop) {
12017          selectedStopId = selectedStop.id;
12018        }
12019      }
12020      if (selectedStop) {
12021        selectedStop.color = { r, g, b };
12022        // Invalidate cached OKLCH (it will be recomputed lazily from RGB).
12023        if (selectedStop.oklch) delete selectedStop.oklch;
12024        // Update selectedStopIndex to match the current position in array (for compatibility)
12025        if (selectedStopId !== null) {
12026          selectedStopIndex = gradientStopsArray.findIndex(s => s.id === selectedStopId);
12027        }
12028        renderGradientBar();
12029        
12030        // Preview every display frame even while pointer events keep arriving.
12031        // Only history saving waits for a pause in input.
12032        renderGradient();
12033        if (colorUpdateTimeout) {
12034          clearTimeout(colorUpdateTimeout);
12035        }
12036        colorUpdateTimeout = setTimeout(() => {
12037          colorUpdateTimeout = null;
12038          saveHistory(); // Save history when color change is complete
12039        }, 16);
12040      }
12041    }
12042
12043    function addStop(position) {
12044      // Sort stops to find adjacent stops
12045      const stops = [...gradientStopsArray].sort((a, b) => a.position - b.position);
12046      
12047      // Find the two stops that the new position falls between
12048      let col;
12049      if (stops.length === 0) {
12050        // Default color if no stops exist
12051        col = { r: 128, g: 128, b: 128 };
12052      } else if (position <= stops[0].position) {
12053        // Before first stop - use first stop's color
12054        col = { ...stops[0].color };
12055      } else if (position >= stops[stops.length - 1].position) {
12056        // After last stop - use last stop's color
12057        col = { ...stops[stops.length - 1].color };
12058      } else {
12059        // Between two stops - interpolate
12060        for (let i = 0; i < stops.length - 1; i++) {
12061          const s1 = stops[i];
12062          const s2 = stops[i + 1];
12063          if (position >= s1.position && position <= s2.position) {
12064            const localT = (position - s1.position) / (s2.position - s1.position);
12065            col = {
12066              r: Math.round(lerp(s1.color.r, s2.color.r, localT)),
12067              g: Math.round(lerp(s1.color.g, s2.color.g, localT)),
12068              b: Math.round(lerp(s1.color.b, s2.color.b, localT))
12069            };
12070            break;
12071          }
12072        }
12073      }
12074      
12075      const newStop = { id: stopIdCounter++, position, color: col };
12076      gradientStopsArray.push(newStop);
12077      // Find the index of the newly added stop by ID (in case array gets sorted)
12078      selectedStopIndex = gradientStopsArray.findIndex(s => s.id === newStop.id);
12079      selectedStopId = newStop.id; // Store the ID for reliable lookup
12080      selectStop(selectedStopIndex);
12081      renderGradientBar();
12082      // Immediately render gradient (no throttling for addition)
12083      renderGradient();
12084    }
12085
12086    function removeStop(index) {
12087      if (gradientStopsArray.length > 2) {
12088        saveHistory(); // Save state before removing stop
12089        // Clear any pending color update timeouts
12090        if (colorUpdateTimeout) {
12091          clearTimeout(colorUpdateTimeout);
12092          colorUpdateTimeout = null;
12093        }
12094        
12095        const removedId = gradientStopsArray[index]?.id ?? null;
12096        gradientStopsArray.splice(index, 1);
12097        // Re-sync selection. selectedStopIndex may be stale after the
12098        // WebGL render path sorts gradientStopsArray; trust selectedStopId.
12099        if (selectedStopId !== null && selectedStopId === removedId) {
12100          // Removed the selected stop: move selection to a neighbour.
12101          selectedStopIndex = Math.min(index, gradientStopsArray.length - 1);
12102          selectedStopIndex = Math.max(0, selectedStopIndex);
12103          selectedStopId = gradientStopsArray[selectedStopIndex]?.id ?? null;
12104          if (typeof updateColorPreviewFromSelectedStop === 'function') {
12105            updateColorPreviewFromSelectedStop();
12106          }
12107        } else if (selectedStopId !== null) {
12108          const newIdx = gradientStopsArray.findIndex(s => s.id === selectedStopId);
12109          if (newIdx >= 0) selectedStopIndex = newIdx;
12110        } else if (selectedStopIndex >= gradientStopsArray.length) {
12111          selectedStopIndex = gradientStopsArray.length - 1;
12112        }
12113        
12114        // Update the colors input field and slider to match the new number of stops
12115        const newCount = gradientStopsArray.length;
12116        multiColorCount.value = newCount;
12117        if (newCount >= 2 && newCount <= 20) {
12118          multiColorCountSlider.value = newCount;
12119        }
12120        
12121        renderGradientBar();
12122        // Immediately render gradient (no throttling for removal)
12123        renderGradient();
12124      }
12125    }
12126
12127    function updateColorPreviewFromSelectedStop() {
12128      // Update color preview to show the selected stop's color, or first stop if none selected
12129      let stopToShow = null;
12130      if (selectedStopIndex >= 0 && selectedStopIndex < gradientStopsArray.length) {
12131        stopToShow = gradientStopsArray[selectedStopIndex];
12132      } else if (gradientStopsArray.length > 0) {
12133        stopToShow = gradientStopsArray[0];
12134      }
12135      
12136      if (stopToShow) {
12137        const hex = rgbToHex(stopToShow.color.r, stopToShow.color.g, stopToShow.color.b);
12138        if (stopColorHexInput) stopColorHexInput.value = hex;
12139        if (stopColorSwatch) stopColorSwatch.style.background = hex;
12140        // If a stop is selected, sync HSL state (for when dropdown is opened)
12141        if (selectedStopIndex >= 0) {
12142          const hsl = rgbToHsl(stopToShow.color.r, stopToShow.color.g, stopToShow.color.b);
12143          currentHue = hsl.h;
12144          currentSaturation = hsl.s;
12145          currentLightness = hsl.l;
12146          if (stopColorDropdown && stopColorDropdown.classList.contains('visible')) {
12147            drawStopSpectrum();
12148            drawStopHueSlider();
12149            updateStopMarkers();
12150          }
12151        }
12152      }
12153    }
12154    
12155    function randomizeGradientStops() {
12156      const count = parseInt(multiColorCount.value) || 5;
12157      
12158      // Sort by position first
12159      gradientStopsArray.sort((a, b) => a.position - b.position);
12160      
12161      // Adjust stop count to match requested count
12162      if (gradientStopsArray.length < count) {
12163        // Add stops evenly distributed
12164        while (gradientStopsArray.length < count) {
12165          const newIndex = gradientStopsArray.length;
12166          const position = newIndex / (count - 1);
12167          const col = getColorFromGradient(position);
12168          gradientStopsArray.push({ id: stopIdCounter++, position, color: col });
12169        }
12170      } else if (gradientStopsArray.length > count) {
12171        // Remove last stops until we reach the desired count
12172        while (gradientStopsArray.length > count) {
12173          // Remove the last stop (keep first stop at position 0)
12174          if (gradientStopsArray.length > 1) {
12175            gradientStopsArray.pop();
12176          } else {
12177            break; // Safety break
12178          }
12179        }
12180      }
12181      
12182      // Redistribute positions evenly and randomize colors
12183      gradientStopsArray.forEach((stop, i) => {
12184        stop.position = i / (count - 1);
12185        stop.color = {
12186          r: seededRNG.randomInt(0, 255),
12187          g: seededRNG.randomInt(0, 255),
12188          b: seededRNG.randomInt(0, 255)
12189        };
12190        if (stop.oklch) delete stop.oklch;
12191      });
12192      
12193      // Clear activePalette since we're using gradient stops
12194      activePalette = null;
12195      
12196      // Update selected stop if needed
12197      if (selectedStopIndex >= gradientStopsArray.length) {
12198        selectedStopIndex = 0;
12199        selectedStopId = gradientStopsArray[0]?.id || null;
12200      }
12201      
12202      // Always update both the bar and the gradient
12203      renderGradientBar();
12204      updateColorPreviewFromSelectedStop();
12205      renderGradient();
12206    }
12207
12208    function updateColorFromHsl() {
12209      const rgb = hslToRgb(currentHue, currentSaturation, currentLightness);
12210      const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
12211      if (stopColorHexInput) stopColorHexInput.value = hex;
12212      if (stopColorSwatch) stopColorSwatch.style.background = hex;
12213      updateSelectedStopColor(rgb.r, rgb.g, rgb.b);
12214    }
12215
12216    function updateColorFromHex(hex) {
12217      const rgb = hexToRgb(hex);
12218      const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
12219      currentHue = hsl.h;
12220      currentSaturation = hsl.s;
12221      currentLightness = hsl.l;
12222      if (stopColorSwatch) stopColorSwatch.style.background = hex;
12223      updateSelectedStopColor(rgb.r, rgb.g, rgb.b);
12224      if (stopColorDropdown && stopColorDropdown.classList.contains('visible')) {
12225        drawStopSpectrum();
12226        drawStopHueSlider();
12227        updateStopMarkers();
12228      }
12229    }
12230
12231    function drawStopHueSlider() {
12232      if (!stopHueCanvas) return;
12233      const ctx = stopHueCanvas.getContext('2d');
12234      const rect = stopHueCanvas.getBoundingClientRect();
12235      stopHueCanvas.width = rect.width;
12236      stopHueCanvas.height = rect.height;
12237      const w = stopHueCanvas.width, h = stopHueCanvas.height;
12238      const gradient = ctx.createLinearGradient(0, 0, 0, h);
12239      for (let i = 0; i <= 360; i += 30) {
12240        const rgb = hslToRgb(i, 100, 50);
12241        gradient.addColorStop(i / 360, `rgb(${rgb.r},${rgb.g},${rgb.b})`);
12242      }
12243      ctx.fillStyle = gradient;
12244      ctx.fillRect(0, 0, w, h);
12245    }
12246
12247    function drawStopSpectrum() {
12248      if (!stopSpectrumCanvas) return;
12249      const ctx = stopSpectrumCanvas.getContext('2d');
12250      const rect = stopSpectrumCanvas.getBoundingClientRect();
12251      stopSpectrumCanvas.width = rect.width;
12252      stopSpectrumCanvas.height = rect.height;
12253      const width = stopSpectrumCanvas.width, height = stopSpectrumCanvas.height;
12254      for (let x = 0; x < width; x++) {
12255        const s = (x / width) * 100;
12256        const grad = ctx.createLinearGradient(0, 0, 0, height);
12257        for (let l = 100; l >= 0; l -= 10) {
12258          const rgb = hslToRgb(currentHue, s, l);
12259          grad.addColorStop((100 - l) / 100, `rgb(${rgb.r},${rgb.g},${rgb.b})`);
12260        }
12261        ctx.fillStyle = grad;
12262        ctx.fillRect(x, 0, 1, height);
12263      }
12264    }
12265
12266    function updateStopMarkers() {
12267      if (!stopSpectrumCanvas || !stopHueCanvas) return;
12268      const spectrumRect = stopSpectrumCanvas.getBoundingClientRect();
12269      const hueRect = stopHueCanvas.getBoundingClientRect();
12270      const sx = (currentSaturation / 100) * spectrumRect.width;
12271      const sy = (1 - currentLightness / 100) * spectrumRect.height;
12272      const hy = (currentHue / 360) * hueRect.height;
12273      if (stopSpectrumMarker) {
12274        stopSpectrumMarker.style.left = sx + 'px';
12275        stopSpectrumMarker.style.top = sy + 'px';
12276      }
12277      if (stopHueMarker) stopHueMarker.style.top = hy + 'px';
12278    }
12279
12280    function openStopColorDropdown() {
12281      const stopToShow = selectedStopIndex >= 0 && gradientStopsArray[selectedStopIndex]
12282        ? gradientStopsArray[selectedStopIndex]
12283        : gradientStopsArray[0];
12284      if (!stopToShow) return;
12285      const hex = rgbToHex(stopToShow.color.r, stopToShow.color.g, stopToShow.color.b);
12286      const hsl = rgbToHsl(stopToShow.color.r, stopToShow.color.g, stopToShow.color.b);
12287      currentHue = hsl.h;
12288      currentSaturation = hsl.s;
12289      currentLightness = hsl.l;
12290      if (stopColorHexInput) stopColorHexInput.value = hex;
12291      if (stopColorSwatch) stopColorSwatch.style.background = hex;
12292      if (stopColorDropdown && stopColorSwatch) {
12293        stopColorDropdown.classList.add('visible');
12294        const rect = stopColorSwatch.getBoundingClientRect();
12295        const dropdownHeight = 220;
12296        const dropdownWidth = 240;
12297        const gap = 4;
12298        let top = rect.bottom + gap;
12299        let left = rect.left;
12300        if (top + dropdownHeight > window.innerHeight - 8) top = rect.top - dropdownHeight - gap;
12301        if (left + dropdownWidth > window.innerWidth - 8) left = window.innerWidth - dropdownWidth - 8;
12302        if (left < 8) left = 8;
12303        stopColorDropdown.style.top = top + 'px';
12304        stopColorDropdown.style.left = left + 'px';
12305        requestAnimationFrame(() => {
12306          drawStopHueSlider();
12307          drawStopSpectrum();
12308          updateStopMarkers();
12309        });
12310      }
12311    }
12312
12313    function closeStopColorDropdown() {
12314      if (stopColorDropdown) stopColorDropdown.classList.remove('visible');
12315    }
12316
12317
12318    // Gradient stop interactions
12319    let wasDragging = false; // Track if we just finished dragging
12320    let isPointerDown = false;
12321    let hasMoved = false;
12322    let pointerDownX = null;
12323    const gradientBarContainer = document.querySelector('.gradient-bar-container');
12324    
12325    // Prevent pointerdown on container from interfering with stop dragging
12326    gradientBarContainer.addEventListener('pointerdown', (e) => {
12327      // If clicking on a stop, let the stop handle it - don't interfere
12328      if (e.target.classList.contains('gradient-stop') || e.target.closest('.gradient-stop')) {
12329        // Don't prevent default or stop propagation - let the stop's handler take over
12330        return;
12331      }
12332    });
12333    
12334    gradientBarContainer.addEventListener('click', (e) => {
12335      // Don't add stop if we just finished dragging a stop
12336      if (wasDragging) {
12337        wasDragging = false;
12338        return;
12339      }
12340      // Only add stop if clicking on the gradient bar itself, not on a stop
12341      if (!e.target.classList.contains('gradient-stop') && !e.target.closest('.gradient-stop')) {
12342        const rect = gradientBar.getBoundingClientRect();
12343        const position = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
12344        addStop(position);
12345      }
12346    });
12347
12348    gradientStops.addEventListener('click', (e) => {
12349      if (e.target.classList.contains('gradient-stop')) {
12350        const index = parseInt(e.target.dataset.index);
12351        selectStop(index);
12352      }
12353    });
12354
12355    // Drag stops
12356    let draggingStop = null;
12357    let renderGradientTimeout = null;
12358    let originalStopPositions = null; // Store original positions when drag starts
12359    
12360    gradientStops.addEventListener('pointerdown', (e) => {
12361      // Check if clicking on a stop or any child element
12362      const stopElement = e.target.classList.contains('gradient-stop') 
12363        ? e.target 
12364        : e.target.closest('.gradient-stop');
12365      
12366      if (stopElement) {
12367        // Try to get index from dataset, fallback to finding by stopId
12368        let index = parseInt(stopElement.dataset.index);
12369        if (isNaN(index) && stopElement.dataset.stopId) {
12370          const stopId = parseInt(stopElement.dataset.stopId);
12371          index = gradientStopsArray.findIndex(s => s.id === stopId);
12372        }
12373        if (isNaN(index) || index < 0 || index >= gradientStopsArray.length) return;
12374        
12375        isPointerDown = true;
12376        hasMoved = false;
12377        pointerDownX = e.clientX;
12378        
12379        // Select the stop and update color picker
12380        selectStop(index);
12381        
12382        draggingStop = {
12383          index: index,
12384          element: stopElement,
12385          stopId: gradientStopsArray[index].id
12386        };
12387        e.preventDefault();
12388        e.stopPropagation();
12389        e.stopImmediatePropagation(); // Prevent any other handlers from running
12390        
12391        // Store original positions of all stops to prevent them from moving
12392        originalStopPositions = gradientStopsArray.map((stop, idx) => ({
12393          index: idx,
12394          id: stop.id,
12395          position: stop.position
12396        }));
12397        
12398        // Disable pointer events on all other stops while dragging
12399        // This allows the dragged stop to pass over other stops
12400        const allStops = gradientStops.querySelectorAll('.gradient-stop');
12401        allStops.forEach((stop) => {
12402          const stopIndex = parseInt(stop.dataset.index);
12403          if (stopIndex !== index) {
12404            stop.style.pointerEvents = 'none';
12405          } else {
12406            // Bring dragged stop to front with higher z-index
12407            stop.style.zIndex = '1000';
12408          }
12409        });
12410      }
12411    });
12412
12413    window.addEventListener('pointermove', (e) => {
12414      if (isPointerDown && draggingStop && originalStopPositions) {
12415        // Check if mouse actually moved (more than 3px)
12416        if (!hasMoved && pointerDownX !== null) {
12417          if (Math.abs(e.clientX - pointerDownX) > 3) {
12418            hasMoved = true;
12419          } else {
12420            return; // Don't process drag until mouse has moved enough
12421          }
12422        }
12423        
12424        // Process drag if mouse has moved.
12425        // NOTE: use `stopId != null` because the first stop's id can be 0,
12426        // which is falsy and would otherwise skip the drag block entirely.
12427        if (hasMoved && draggingStop && draggingStop.stopId != null) {
12428          // Find the dragged stop by ID (not index, to ensure we get the right one)
12429          const draggedStop = gradientStopsArray.find(s => s.id === draggingStop.stopId);
12430          if (!draggedStop) return;
12431          
12432          const rect = gradientBarContainer.getBoundingClientRect();
12433          let position = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
12434          
12435          // Constrain position to avoid overlapping with other stops (minimum distance)
12436          const minDistance = 0.01; // 1% minimum distance
12437          
12438          // Check against original positions of other stops (by ID)
12439          originalStopPositions.forEach(original => {
12440            if (original.id === draggingStop.stopId) return; // Skip the dragged stop
12441            
12442            const otherPosition = original.position;
12443            
12444            // If we're too close to another stop, push away
12445            if (Math.abs(position - otherPosition) < minDistance) {
12446              if (position > otherPosition) {
12447                position = otherPosition + minDistance;
12448              } else {
12449                position = Math.max(0, otherPosition - minDistance);
12450              }
12451              // Clamp to valid range
12452              position = Math.max(0, Math.min(1, position));
12453            }
12454          });
12455          
12456          // Ensure position is valid (not NaN or undefined)
12457          if (isNaN(position) || position === undefined) {
12458            return;
12459          }
12460          
12461          // Restore all other stops to their original positions (by ID, not index)
12462          originalStopPositions.forEach(original => {
12463            if (original.id !== draggingStop.stopId) {
12464              const stop = gradientStopsArray.find(s => s.id === original.id);
12465              if (stop) {
12466                // Only update position, never touch color
12467                stop.position = original.position;
12468              }
12469            }
12470          });
12471          
12472          // Update ONLY the dragged stop's position (never touch color)
12473          draggedStop.position = position;
12474          
12475          // Sort stops by position to maintain order
12476          // Note: This mutates the array, but the draggedStop reference is still valid
12477          gradientStopsArray.sort((a, b) => a.position - b.position);
12478          
12479          // Update gradient bar immediately for smooth visual feedback
12480          renderGradientBar();
12481          
12482          // Throttle gradient rendering during drag for performance
12483          if (renderGradientTimeout) {
12484            clearTimeout(renderGradientTimeout);
12485          }
12486          renderGradientTimeout = setTimeout(() => {
12487            renderGradient();
12488          }, 16); // ~60fps throttling
12489        }
12490      }
12491    });
12492
12493    window.addEventListener('pointerup', (e) => {
12494      if (draggingStop) {
12495        // Save history when gradient stop drag ends
12496        saveHistory();
12497        // Clear any pending render
12498        if (renderGradientTimeout) {
12499          clearTimeout(renderGradientTimeout);
12500          renderGradientTimeout = null;
12501        }
12502        // Final render on release
12503        renderGradient();
12504        
12505        // Re-enable pointer events on all stops
12506        const allStops = gradientStops.querySelectorAll('.gradient-stop');
12507        allStops.forEach((stop) => {
12508          stop.style.pointerEvents = 'all';
12509          stop.style.zIndex = '';
12510        });
12511        
12512        // Mark that we were dragging to prevent click handler from adding stops
12513        wasDragging = true;
12514        // Clear the flag after a short delay to allow click events to be processed first
12515        setTimeout(() => {
12516          wasDragging = false;
12517        }, 100);
12518        
12519        // Reset pointer state
12520        isPointerDown = false;
12521        hasMoved = false;
12522        pointerDownX = null;
12523        
12524        // Clear original positions
12525        originalStopPositions = null;
12526        draggingStop = null;
12527      }
12528    });
12529
12530    removeStopBtn.addEventListener('click', () => {
12531      if (gradientStopsArray.length <= 2) return;
12532      // Prefer id-based lookup; selectedStopIndex can be stale after the
12533      // WebGL render path sorts gradientStopsArray (see ~line 4307).
12534      const targetIndex = (selectedStopId !== null)
12535        ? gradientStopsArray.findIndex(s => s.id === selectedStopId)
12536        : selectedStopIndex;
12537      if (targetIndex >= 0) removeStop(targetIndex);
12538    });
12539
12540    // Invert gradient function
12541    function invertGradient() {
12542      saveHistory(); // Save state before inverting
12543      gradientStopsArray.forEach(stop => {
12544        stop.position = 1 - stop.position;
12545      });
12546      renderGradientBar();
12547      renderGradient();
12548    }
12549
12550    const invertGradientBtn = document.getElementById('invertGradientBtn');
12551    invertGradientBtn.addEventListener('click', () => {
12552      invertGradient();
12553    });
12554
12555    const redistributeStopsBtn = document.getElementById('redistributeStopsBtn');
12556    redistributeStopsBtn.addEventListener('click', () => {
12557      saveHistory(); // Save state before redistributing
12558      // Sort stops by position first
12559      gradientStopsArray.sort((a, b) => a.position - b.position);
12560      // Redistribute positions evenly
12561      const count = gradientStopsArray.length;
12562      if (count > 1) {
12563        gradientStopsArray.forEach((stop, i) => {
12564          stop.position = i / (count - 1);
12565        });
12566        renderGradientBar();
12567        renderGradient();
12568      }
12569    });
12570
12571    // Shuffle colors between stops
12572    let __shuffleRafId = null;
12573    let __shuffleTargetColors = null;
12574    function shuffleColors() {
12575      // If a previous shuffle animation is still running, cancel it and snap
12576      // each stop to that animation's intended target, so a rapid follow-up
12577      // click starts from a clean, fully-resolved state instead of from
12578      // mid-transition (averaged) values.
12579      if (__shuffleRafId !== null) {
12580        cancelAnimationFrame(__shuffleRafId);
12581        __shuffleRafId = null;
12582        if (__shuffleTargetColors) {
12583          gradientStopsArray.forEach((stop, i) => {
12584            const target = __shuffleTargetColors[i];
12585            if (target) {
12586              stop.color = { r: target.r, g: target.g, b: target.b };
12587              if (stop.oklch) delete stop.oklch;
12588            }
12589          });
12590        }
12591        __shuffleTargetColors = null;
12592      }
12593      saveHistory(); // Save state before shuffling
12594      
12595      // Store old colors for transition
12596      const oldColors = gradientStopsArray.map(stop => ({ ...stop.color }));
12597      
12598      // Create an array of all colors
12599      const colors = gradientStopsArray.map(stop => ({ ...stop.color }));
12600      
12601      // Shuffle the colors array using Fisher-Yates algorithm
12602      for (let i = colors.length - 1; i > 0; i--) {
12603        const j = Math.floor(Math.random() * (i + 1));
12604        [colors[i], colors[j]] = [colors[j], colors[i]];
12605      }
12606      
12607      // New shuffled colors
12608      const newColors = colors;
12609      __shuffleTargetColors = newColors;
12610      
12611      // Get transition duration (same as randomize colors)
12612      const duration = 340; // Fixed transition duration in milliseconds
12613      const startTime = performance.now();
12614      
12615      function animateTransition(currentTime) {
12616        const elapsed = currentTime - startTime;
12617        const progress = Math.min(elapsed / duration, 1);
12618        
12619        // Ease-in-out for smooth transition
12620        const eased = progress < 0.5
12621          ? 2 * progress * progress
12622          : 1 - Math.pow(-2 * progress + 2, 2) / 2;
12623        
12624        // Interpolate colors
12625        gradientStopsArray.forEach((stop, i) => {
12626          const oldColor = oldColors[i];
12627          const newColor = newColors[i];
12628          
12629          stop.color = {
12630            r: Math.round(oldColor.r + (newColor.r - oldColor.r) * eased),
12631            g: Math.round(oldColor.g + (newColor.g - oldColor.g) * eased),
12632            b: Math.round(oldColor.b + (newColor.b - oldColor.b) * eased)
12633          };
12634          if (stop.oklch) delete stop.oklch;
12635        });
12636        
12637        // Update display
12638        renderGradientBar();
12639        updateColorPreviewFromSelectedStop();
12640        renderGradient();
12641        
12642        // Continue animation if not complete
12643        if (progress < 1) {
12644          __shuffleRafId = requestAnimationFrame(animateTransition);
12645        } else {
12646          // Ensure final colors are set exactly
12647          gradientStopsArray.forEach((stop, i) => {
12648            stop.color = newColors[i];
12649            if (stop.oklch) delete stop.oklch;
12650          });
12651          renderGradientBar();
12652          updateColorPreviewFromSelectedStop();
12653          renderGradient();
12654          __shuffleRafId = null;
12655          __shuffleTargetColors = null;
12656        }
12657      }
12658      
12659      __shuffleRafId = requestAnimationFrame(animateTransition);
12660    }
12661
12662    const shuffleColorsBtn = document.getElementById('shuffleColorsBtn');
12663    shuffleColorsBtn.addEventListener('click', () => {
12664      shuffleColors();
12665    });
12666
12667    // Color conversion helpers: RGB to HSL and HSL to RGB
12668    function rgbToHsl(r, g, b) {
12669      r /= 255;
12670      g /= 255;
12671      b /= 255;
12672      const max = Math.max(r, g, b);
12673      const min = Math.min(r, g, b);
12674      let h, s, l = (max + min) / 2;
12675
12676      if (max === min) {
12677        h = s = 0; // achromatic
12678      } else {
12679        const d = max - min;
12680        s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
12681        switch (max) {
12682          case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
12683          case g: h = ((b - r) / d + 2) / 6; break;
12684          case b: h = ((r - g) / d + 4) / 6; break;
12685        }
12686      }
12687      return { h: h * 360, s: s * 100, l: l * 100 };
12688    }
12689
12690    function hslToRgb(h, s, l) {
12691      h /= 360;
12692      s /= 100;
12693      l /= 100;
12694      let r, g, b;
12695
12696      if (s === 0) {
12697        r = g = b = l; // achromatic
12698      } else {
12699        const hue2rgb = (p, q, t) => {
12700          if (t < 0) t += 1;
12701          if (t > 1) t -= 1;
12702          if (t < 1/6) return p + (q - p) * 6 * t;
12703          if (t < 1/2) return q;
12704          if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
12705          return p;
12706        };
12707
12708        const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
12709        const p = 2 * l - q;
12710        r = hue2rgb(p, q, h + 1/3);
12711        g = hue2rgb(p, q, h);
12712        b = hue2rgb(p, q, h - 1/3);
12713      }
12714
12715      return {
12716        r: Math.round(r * 255),
12717        g: Math.round(g * 255),
12718        b: Math.round(b * 255)
12719      };
12720    }
12721
12722    // Generate colors based on template
12723    function generateColorsByTemplate(template, count, rng) {
12724      const colors = [];
12725      const templateName = template || 'vibrant';
12726
12727      switch (templateName) {
12728        case 'vibrant':
12729          // Saturated, vibrant colors (high saturation, medium-high lightness)
12730          for (let i = 0; i < count; i++) {
12731            const h = rng.random() * 360;
12732            const s = 75 + rng.random() * 25;   // 75–100%
12733            const l = 45 + rng.random() * 30;   // 45–75%
12734            colors.push(hslToRgb(h, s, l));
12735          }
12736          break;
12737
12738        case 'random':
12739          // Truly random RGB (any value 0–255 per channel)
12740          for (let i = 0; i < count; i++) {
12741            colors.push({
12742              r: rng.randomInt(0, 255),
12743              g: rng.randomInt(0, 255),
12744              b: rng.randomInt(0, 255)
12745            });
12746          }
12747          break;
12748
12749        case 'warm':
12750          // Reds, oranges, yellows (0-60°)
12751          for (let i = 0; i < count; i++) {
12752            const h = rng.random() * 60; // 0-60°
12753            const s = 60 + rng.random() * 40; // 60-100%
12754            const l = 40 + rng.random() * 50; // 40-90%
12755            colors.push(hslToRgb(h, s, l));
12756          }
12757          break;
12758
12759        case 'cool':
12760          // Blues, cyans, greens, purples (180-300°)
12761          for (let i = 0; i < count; i++) {
12762            const h = 180 + rng.random() * 120; // 180-300°
12763            const s = 60 + rng.random() * 40; // 60-100%
12764            const l = 40 + rng.random() * 50; // 40-90%
12765            colors.push(hslToRgb(h, s, l));
12766          }
12767          break;
12768
12769        case 'dark':
12770          // Low brightness, deep colors
12771          for (let i = 0; i < count; i++) {
12772            const h = rng.random() * 360; // Any hue
12773            const s = 15 + rng.random() * 15; // 15-30% (reduced saturation)
12774            const l = 6 + rng.random() * 18; // 6-24% (40% darker than before)
12775            colors.push(hslToRgb(h, s, l));
12776          }
12777          break;
12778
12779        case 'light':
12780          // High brightness, pastel-like
12781          for (let i = 0; i < count; i++) {
12782            const h = rng.random() * 360; // Any hue
12783            const s = 30 + rng.random() * 30; // 30-60%
12784            const l = 70 + rng.random() * 25; // 70-95%
12785            colors.push(hslToRgb(h, s, l));
12786          }
12787          break;
12788
12789        case 'complementary':
12790          // Opposite colors on color wheel
12791          const baseHue = rng.random() * 360;
12792          for (let i = 0; i < count; i++) {
12793            // Alternate between base and complement
12794            const h = (i % 2 === 0) ? baseHue : (baseHue + 180) % 360;
12795            const s = 60 + rng.random() * 40; // 60-100%
12796            const l = 40 + rng.random() * 50; // 40-90%
12797            colors.push(hslToRgb(h, s, l));
12798          }
12799          break;
12800
12801        case 'analogous':
12802          // Adjacent colors (30-60° apart)
12803          const baseHueAnalogous = rng.random() * 360;
12804          const spread = 30 + rng.random() * 30; // 30-60° spread
12805          for (let i = 0; i < count; i++) {
12806            const offset = (rng.random() - 0.5) * spread;
12807            const h = (baseHueAnalogous + offset + 360) % 360;
12808            const s = 60 + rng.random() * 40; // 60-100%
12809            const l = 40 + rng.random() * 50; // 40-90%
12810            colors.push(hslToRgb(h, s, l));
12811          }
12812          break;
12813
12814        case 'triadic':
12815          // Three evenly spaced colors (120° apart)
12816          const baseHueTriadic = rng.random() * 360;
12817          for (let i = 0; i < count; i++) {
12818            const offset = (i % 3) * 120;
12819            const h = (baseHueTriadic + offset) % 360;
12820            const s = 60 + rng.random() * 40; // 60-100%
12821            const l = 40 + rng.random() * 50; // 40-90%
12822            colors.push(hslToRgb(h, s, l));
12823          }
12824          break;
12825
12826        case 'monochromatic':
12827          // Single hue, varying saturation/brightness
12828          const baseHueMono = rng.random() * 360;
12829          for (let i = 0; i < count; i++) {
12830            const h = baseHueMono;
12831            const s = 30 + rng.random() * 70; // 30-100%
12832            const l = 20 + rng.random() * 70; // 20-90%
12833            colors.push(hslToRgb(h, s, l));
12834          }
12835          break;
12836
12837        default:
12838          // Fallback to vibrant (saturated)
12839          for (let i = 0; i < count; i++) {
12840            const h = rng.random() * 360;
12841            const s = 75 + rng.random() * 25;
12842            const l = 45 + rng.random() * 30;
12843            colors.push(hslToRgb(h, s, l));
12844          }
12845      }
12846
12847      return colors;
12848    }
12849
12850    // Randomize colors button handler with smooth transition
12851    randomizeColorsBtn.addEventListener('click', () => {
12852      saveHistory(); // Save state before randomizing colors
12853      
12854      // Store old colors for transition
12855      const oldColors = gradientStopsArray.map(stop => ({ ...stop.color }));
12856      const oldCount = gradientStopsArray.length;
12857      
12858      // Generate new colors
12859      const seed = Math.floor(Math.random() * 1000000);
12860      seededRNG.setSeed(seed);
12861      const count = parseInt(multiColorCount.value) || 5;
12862      const template = colorTemplateSelect.value || 'vibrant';
12863      
12864      // Sort by position first
12865      gradientStopsArray.sort((a, b) => a.position - b.position);
12866      
12867      // Adjust stop count to match requested count
12868      if (gradientStopsArray.length < count) {
12869        // Add stops evenly distributed
12870        while (gradientStopsArray.length < count) {
12871          const newIndex = gradientStopsArray.length;
12872          const position = newIndex / (count - 1);
12873          const col = getColorFromGradient(position);
12874          gradientStopsArray.push({ id: stopIdCounter++, position, color: col });
12875        }
12876      } else if (gradientStopsArray.length > count) {
12877        // Remove last stops until we reach the desired count
12878        while (gradientStopsArray.length > count) {
12879          if (gradientStopsArray.length > 1) {
12880            gradientStopsArray.pop();
12881          } else {
12882            break;
12883          }
12884        }
12885      }
12886      
12887      // Redistribute positions evenly and generate new colors based on template
12888      const newColors = generateColorsByTemplate(template, count, seededRNG);
12889      gradientStopsArray.forEach((stop, i) => {
12890        stop.position = i / (count - 1);
12891      });
12892      
12893      // Clear activePalette since we're using gradient stops
12894      activePalette = null;
12895      
12896      // Update selected stop if needed
12897      if (selectedStopIndex >= gradientStopsArray.length) {
12898        selectedStopIndex = 0;
12899        selectedStopId = gradientStopsArray[0]?.id || null;
12900      }
12901      
12902      // Fixed transition duration in milliseconds
12903      const duration = 340;
12904      const startTime = performance.now();
12905      
12906      function animateTransition(currentTime) {
12907        const elapsed = currentTime - startTime;
12908        const progress = Math.min(elapsed / duration, 1);
12909        
12910        // Ease-in-out for smooth transition
12911        const eased = progress < 0.5
12912          ? 2 * progress * progress
12913          : 1 - Math.pow(-2 * progress + 2, 2) / 2;
12914        
12915        // Interpolate colors
12916        gradientStopsArray.forEach((stop, i) => {
12917          const oldColor = i < oldColors.length ? oldColors[i] : { r: 128, g: 128, b: 128 };
12918          const newColor = newColors[i];
12919          
12920          stop.color = {
12921            r: Math.round(oldColor.r + (newColor.r - oldColor.r) * eased),
12922            g: Math.round(oldColor.g + (newColor.g - oldColor.g) * eased),
12923            b: Math.round(oldColor.b + (newColor.b - oldColor.b) * eased)
12924          };
12925          if (stop.oklch) delete stop.oklch;
12926        });
12927        
12928        // Update display
12929        renderGradientBar();
12930        updateColorPreviewFromSelectedStop();
12931        renderGradient();
12932        
12933        // Continue animation if not complete
12934        if (progress < 1) {
12935          requestAnimationFrame(animateTransition);
12936        } else {
12937          // Ensure final colors are set exactly
12938          gradientStopsArray.forEach((stop, i) => {
12939            stop.color = newColors[i];
12940            if (stop.oklch) delete stop.oklch;
12941          });
12942          renderGradientBar();
12943          updateColorPreviewFromSelectedStop();
12944          renderGradient();
12945        }
12946      }
12947      
12948      requestAnimationFrame(animateTransition);
12949    });
12950
12951    // Sync slider and number input for color count
12952    multiColorCountSlider.addEventListener('input', () => {
12953      multiColorCount.value = multiColorCountSlider.value;
12954      const count = parseInt(multiColorCountSlider.value) || 5;
12955      
12956      // Adjust stop count without randomizing colors
12957      const currentCount = gradientStopsArray.length;
12958      if (count !== currentCount) {
12959        saveHistory(); // Save state before changing color count
12960        // Sort by position first to ensure we keep the right stops
12961        gradientStopsArray.sort((a, b) => a.position - b.position);
12962        
12963        if (count < currentCount) {
12964          // Keep first 'count' stops, remove the rest
12965          gradientStopsArray.splice(count);
12966        } else if (count > currentCount) {
12967          // Add stops evenly distributed with interpolated colors
12968          while (gradientStopsArray.length < count) {
12969            const newIndex = gradientStopsArray.length;
12970            const position = newIndex / (count - 1);
12971            const col = getColorFromGradient(position);
12972            gradientStopsArray.push({ id: stopIdCounter++, position, color: col });
12973          }
12974        }
12975        
12976        // Update selected stop index if needed
12977        if (selectedStopIndex >= gradientStopsArray.length) {
12978          selectedStopIndex = Math.max(0, gradientStopsArray.length - 1);
12979        }
12980        
12981        // Redistribute positions evenly
12982        const finalCount = gradientStopsArray.length;
12983        if (finalCount > 1) {
12984          gradientStopsArray.forEach((stop, i) => {
12985            stop.position = i / (finalCount - 1);
12986          });
12987        } else if (finalCount === 1) {
12988          gradientStopsArray[0].position = 0;
12989        }
12990        
12991        // Force complete re-render
12992        renderGradientBar();
12993        renderGradient();
12994      }
12995    });
12996
12997    multiColorCount.addEventListener('input', () => {
12998      // Allow user to type freely, only sync slider if value is valid
12999      const numVal = parseInt(multiColorCount.value);
13000      if (!isNaN(numVal)) {
13001        const val = Math.min(20, Math.max(2, numVal));
13002        // Update slider only if value is within slider range (2-20)
13003        if (val >= 2 && val <= 20) {
13004          multiColorCountSlider.value = val;
13005        }
13006        
13007        const count = val;
13008        // Adjust stop count without randomizing colors
13009        const currentCount = gradientStopsArray.length;
13010        if (count !== currentCount) {
13011          saveHistory(); // Save state before changing color count
13012          // Sort by position first to ensure we keep the right stops
13013          gradientStopsArray.sort((a, b) => a.position - b.position);
13014          
13015          if (count < currentCount) {
13016            // Keep first 'count' stops, remove the rest
13017            gradientStopsArray.splice(count);
13018          } else if (count > currentCount) {
13019            // Add stops evenly distributed with interpolated colors
13020            while (gradientStopsArray.length < count) {
13021              const newIndex = gradientStopsArray.length;
13022              const position = newIndex / (count - 1);
13023              const col = getColorFromGradient(position);
13024              gradientStopsArray.push({ id: stopIdCounter++, position, color: col });
13025            }
13026          }
13027          
13028          // Update selected stop index if needed
13029          if (selectedStopIndex >= gradientStopsArray.length) {
13030            selectedStopIndex = Math.max(0, gradientStopsArray.length - 1);
13031          }
13032          
13033          // Redistribute positions evenly
13034          const finalCount = gradientStopsArray.length;
13035          if (finalCount > 1) {
13036            gradientStopsArray.forEach((stop, i) => {
13037              stop.position = i / (finalCount - 1);
13038            });
13039          } else if (finalCount === 1) {
13040            gradientStopsArray[0].position = 0;
13041          }
13042          
13043          // Force complete re-render
13044          renderGradientBar();
13045          renderGradient();
13046        }
13047      }
13048    });
13049    
13050    multiColorCount.addEventListener('blur', () => {
13051      // Validate and clamp on blur
13052      const val = Math.min(20, Math.max(2, parseInt(multiColorCount.value) || 5));
13053      multiColorCount.value = val;
13054      // Update slider only if value is within slider range (2-20)
13055      if (val >= 2 && val <= 20) {
13056        multiColorCountSlider.value = val;
13057      }
13058    });
13059    
13060    multiColorCount.addEventListener('keydown', (e) => {
13061      if (e.key === 'Enter') {
13062        e.preventDefault();
13063        multiColorCount.blur();
13064      }
13065    });
13066
13067    // Stop color picker (Figma-style dropdown)
13068    if (stopColorSwatch) {
13069      stopColorSwatch.addEventListener('click', (e) => {
13070        e.stopPropagation();
13071        if (stopPickerInteracting && stopColorDropdown && stopColorDropdown.classList.contains('visible')) {
13072          stopPickerInteracting = false;
13073          return;
13074        }
13075        if (!stopColorDropdown || !stopColorDropdown.classList.contains('visible')) {
13076          openStopColorDropdown();
13077        }
13078      });
13079    }
13080    if (stopColorDropdownClose) {
13081      stopColorDropdownClose.addEventListener('click', (e) => {
13082        e.stopPropagation();
13083        closeStopColorDropdown();
13084      });
13085    }
13086    if (stopColorDropdown) {
13087      stopColorDropdown.addEventListener('pointerdown', () => { stopPickerInteracting = true; });
13088    }
13089    window.addEventListener('pointerup', () => {
13090      if (stopPickerInteracting) stopPickerInteracting = false;
13091    });
13092    document.addEventListener('click', (e) => {
13093      if (!stopColorDropdown || !stopColorDropdown.classList.contains('visible')) return;
13094      if (stopColorRow && !stopColorRow.contains(e.target) && stopColorDropdown && !stopColorDropdown.contains(e.target)) {
13095        closeStopColorDropdown();
13096      }
13097    });
13098    document.addEventListener('keydown', (e) => {
13099      if (e.key === 'Escape' && stopColorDropdown && stopColorDropdown.classList.contains('visible')) {
13100        closeStopColorDropdown();
13101      }
13102    });
13103    if (stopSpectrumCanvas) {
13104      stopSpectrumCanvas.addEventListener('pointerdown', (e) => {
13105        e.stopPropagation();
13106        const rect = stopSpectrumCanvas.getBoundingClientRect();
13107      const update = (ev) => {
13108        const x = Math.max(0, Math.min(ev.clientX - rect.left, rect.width));
13109        const y = Math.max(0, Math.min(ev.clientY - rect.top, rect.height));
13110        currentSaturation = (x / rect.width) * 100;
13111        currentLightness = 100 - (y / rect.height) * 100;
13112        updateColorFromHsl();
13113          updateStopMarkers();
13114      };
13115      update(e);
13116        const onMove = (ev) => { update(ev); ev.stopPropagation(); };
13117      const onUp = () => {
13118        window.removeEventListener('pointermove', onMove);
13119        window.removeEventListener('pointerup', onUp);
13120      };
13121      window.addEventListener('pointermove', onMove);
13122      window.addEventListener('pointerup', onUp);
13123    });
13124    }
13125    if (stopHueCanvas) {
13126      stopHueCanvas.addEventListener('pointerdown', (e) => {
13127        e.stopPropagation();
13128        const rect = stopHueCanvas.getBoundingClientRect();
13129      const update = (ev) => {
13130          const y = Math.max(0, Math.min(ev.clientY - rect.top, rect.height));
13131          currentHue = (y / rect.height) * 360;
13132          drawStopSpectrum();
13133        updateColorFromHsl();
13134          updateStopMarkers();
13135      };
13136      update(e);
13137        const onMove = (ev) => { update(ev); ev.stopPropagation(); };
13138      const onUp = () => {
13139        window.removeEventListener('pointermove', onMove);
13140        window.removeEventListener('pointerup', onUp);
13141      };
13142      window.addEventListener('pointermove', onMove);
13143      window.addEventListener('pointerup', onUp);
13144    });
13145    }
13146    if (stopColorHexInput) {
13147      stopColorHexInput.addEventListener('change', () => {
13148        let val = stopColorHexInput.value;
13149        if (!val.startsWith('#')) val = '#' + val;
13150        if (/^#[0-9A-Fa-f]{6}$/.test(val)) updateColorFromHex(val);
13151      });
13152      stopColorHexInput.addEventListener('keydown', (e) => {
13153      if (e.key === 'Enter') {
13154        e.preventDefault();
13155          stopColorHexInput.blur();
13156      }
13157    });
13158    }
13159
13160    // Initial setup
13161    // Ensure curves are enabled by default
13162    if (curvesVisible) {
13163      document.getElementById('xCurveCanvas').classList.add('curves-enabled');
13164      document.getElementById('yCurveCanvas').classList.add('curves-enabled');
13165    }
13166    
13167    // Initialize curves with random values (3-5 points each)
13168    const randomSeed = Math.floor(Math.random() * 1000000);
13169    const tempRNG = new SeededRNG(randomSeed);
13170    const xComplexity = Math.floor(Math.random() * 3) + 3; // 3, 4, or 5
13171    const yComplexity = Math.floor(Math.random() * 3) + 3; // 3, 4, or 5
13172    xEditor.randomize(tempRNG, xComplexity);
13173    yEditor.randomize(tempRNG, yComplexity);
13174    xEditor.setMaxPoints(xComplexity);
13175    yEditor.setMaxPoints(yComplexity);
13176    renderGradientBar();
13177    // Initialize stop color swatch and hex input with first stop's color before selecting
13178    if (gradientStopsArray.length > 0 && stopColorSwatch && stopColorHexInput) {
13179      const firstStop = gradientStopsArray[0];
13180      const hex = rgbToHex(firstStop.color.r, firstStop.color.g, firstStop.color.b);
13181      stopColorHexInput.value = hex;
13182      stopColorSwatch.style.background = hex;
13183    }
13184    selectStop(0); // Select first stop by default
13185    graphics.start(); // Initialize after the editor has painted
13186    // Initialize canvas size based on screen, but allow user to change it
13187    setCanvasSize(); // This calls renderGradient() internally
13188    
13189    // Ensure gradient is rendered (in case setCanvasSize didn't trigger it)
13190    if (typeof renderGradient === 'function') {
13191      renderGradient();
13192    }
13193    
13194    // Save initial state to history so undo works after first user action
13195    saveHistory();
13196    
13197    // Initialize button states (both disabled until first user action)
13198    updateHistoryButtons();
13199    
13200    // Initialize Google Sign-In
13201    document.addEventListener('DOMContentLoaded', () => initAppAuth(), { once: true });
13202
13203  </script>
13203
13204
13205  
13205<script src="export-quota.js?v=website-release-1"></script>
13205
13206  
13206<script>
13207    const GOOGLE_CLIENT_ID = '439292391933-qv5adc300a42b9cvhcspjc6gfn66qj25.apps.googleusercontent.com';
13208
13209    function showPaddleSuccessModal() {
13210      const modal = document.getElementById('paddleSuccessModal');
13211      if (modal) modal.classList.add('visible');
13212    }
13213
13214    // Upgrade modal with copy adapted to what triggered it
13215    const UPGRADE_MODAL_MESSAGES = {
13216      export: "You've reached your free export limit. Upgrade to PRO for unlimited exports and commercial use.",
13217      beta: 'Beta features — chromatic aberration, directional flow, and paper texture — are available with Colir PRO.'
13218    };
13219
13220    let upgradeReturnFocus = null;
13221    function openUpgradeModal(context) {
13222      const msgEl = document.getElementById('upgradeModalMessage');
13223      if (msgEl) msgEl.textContent = UPGRADE_MODAL_MESSAGES[context] || UPGRADE_MODAL_MESSAGES.export;
13224      const modal = document.getElementById('upgradeModal');
13225      if (modal) {
13226        if (!modal.classList.contains('visible')) upgradeReturnFocus = document.activeElement;
13227        modal.classList.add('visible');
13228        document.getElementById('closeUpgradeModal').focus();
13229      }
13230    }
13231    function closeUpgradeModal() {
13232      document.getElementById('upgradeModal').classList.remove('visible');
13233      if (upgradeReturnFocus?.isConnected) upgradeReturnFocus.focus();
13234      upgradeReturnFocus = null;
13235    }
13236    document.addEventListener('keydown', e => {
13237      const modal = document.getElementById('upgradeModal');
13238      if (!modal.classList.contains('visible')) return;
13239      if (e.key === 'Escape') {e.preventDefault();e.stopImmediatePropagation();closeUpgradeModal();return;}
13240      if (e.key !== 'Tab') return;
13241      const items = [...modal.querySelectorAll('button:not([disabled]), a[href], [tabindex="0"]')].filter(el=>el.getClientRects().length);
13242      const first=items[0],last=items[items.length-1];
13243      if (e.shiftKey && (document.activeElement===first || !modal.contains(document.activeElement))) {e.preventDefault();last?.focus();}
13244      else if (!e.shiftKey && (document.activeElement===last || !modal.contains(document.activeElement))) {e.preventDefault();first?.focus();}
13245    }, true);
13246
13247    // After a login triggered by clicking the locked Beta panel: expand it
13248    // for PRO users, otherwise show the upgrade offer.
13249    function handlePendingBetaUnlock(isPro) {
13250      if (!window.pendingBetaUnlock) return;
13251      window.pendingBetaUnlock = false;
13252      setTimeout(() => {
13253        if (isPro) {
13254          if (typeof window.expandBetaPanel === 'function') window.expandBetaPanel();
13255        } else {
13256          openUpgradeModal('beta');
13257        }
13258      }, 300);
13259    }
13260
13261    // Check license status; resolves to whether the user is PRO
13262    function checkLicenseStatus(email) {
13263      return fetch('/api/verify-license.php', {
13264        method: 'POST',
13265        headers: { 'Content-Type': 'application/json' },
13266        body: JSON.stringify({ email: email })
13267      })
13268      .then(res => res.json())
13269      .then(data => {
13270        const prevPro = localStorage.getItem('colir_license_is_pro') === '1';
13271        const isPro = !!(data && data.licensed && data.type === 'commercial');
13272
13273        localStorage.setItem('colir_license', JSON.stringify(data));
13274        localStorage.setItem('colir_license_is_pro', isPro ? '1' : '0');
13275
13276        // For now, allow free access - restrictions can be added later
13277        console.log('License status:', data);
13278        // Show Pro tag if user has commercial license
13279        updateProTag(data);
13280
13281        // Show PRO welcome modal once when user transitions to PRO
13282        if (isPro && !prevPro) {
13283          showPaddleSuccessModal();
13284        }
13285        return isPro;
13286      })
13287      .catch(error => {
13288        console.error('License check failed:', error);
13289        // Allow access even if check fails (graceful degradation)
13290        return localStorage.getItem('colir_license_is_pro') === '1';
13291      });
13292    }
13293
13294    // Update Pro tag visibility based on license
13295    function updateProTag(licenseData) {
13296      const proTag = document.getElementById('appProTag');
13297      if (proTag && licenseData && licenseData.licensed && licenseData.type === 'commercial') {
13298        proTag.style.display = 'inline-block';
13299      } else if (proTag) {
13300        proTag.style.display = 'none';
13301      }
13302      if (typeof window.updateBetaPanelVisibility === 'function') window.updateBetaPanelVisibility();
13303    }
13304
13305    // Check if user is already logged in
13306    function checkAppAuthStatus() {
13307      // The plugin shell rehydrates identity over postMessage. Painting from
13308      // this frame's storage would resurrect a signed-out account (
13308and its
13309      // Pro flag) 500ms after load.
13310      if (/[?&]embed=figma/.test(location.search)) return;
13311      const userData = localStorage.getItem('colir_user') || sessionStorage.getItem('colir_user');
13312      const storedLicense = localStorage.getItem('colir_license');
13313      if (userData) {
13314        const user = JSON.parse(userData);
13315        showAppUserProfile(user);
13316        // Check stored license first (for immediate display)
13317        if (storedLicense) {
13318          try {
13319            const licenseData = JSON.parse(storedLicense);
13320            updateProTag(licenseData);
13321          } catch (e) {
13322            console.error('Error parsing stored license:', e);
13323          }
13324        }
13325        // Then check license status (will update if changed)
13326        checkLicenseStatus(user.email);
13327      } else {
13328        // Licence keys can linger from the Figma embed (same origin) after the
13329        // plugin signs out. A visitor with no web session is not Pro.
13330        showAppLoginButton();
13331        updateProTag({ licensed: false, type: null });
13332      }
13333    }
13334
13335    function getUserAvatarInitials(user) {
13336      if (!user) return '?';
13337      const name = (user.name || '').trim();
13338      if (name) {
13339        const parts = name.split(/\s+/).filter(Boolean);
13340        if (parts.length >= 2) {
13341          const a = parts[0][0] || '';
13342          const b = parts[parts.length - 1][0] || '';
13343          return (a + b).toUpperCase();
13344        }
13345        return name.slice(0, 2).toUpperCase();
13346      }
13347      const email = (user.email || '').trim();
13348      if (email) {
13349        const local = email.split('@')[0] || email;
13350        const alnum = local.replace(/[^a-zA-Z0-9]/g, '');
13351        if (alnum.length >= 2) return alnum.slice(0, 2).toUpperCase();
13352        if (alnum.length === 1) return alnum.toUpperCase();
13353        return email.slice(0, 2).toUpperCase();
13354      }
13355      return '?';
13356    }
13357
13358    function resetAppUserAvatarBadge() {
13359      const img = document.getElementById('appUserAvatar');
13360      const fallback = document.getElementById('appUserAvatarFallback');
13361      if (img) {
13362        img.onload = null;
13363        img.onerror = null;
13364        img.removeAttribute('src');
13365        img.style.display = 'none';
13366      }
13367      if (fallback) {
13368        fallback.textContent = '';
13369        fallback.style.display = 'none';
13370      }
13371    }
13372
13373    // Show user profile in app
13374    function showAppUserProfile(user) {
13375      document.getElementById('appLoginButton').style.display = 'none';
13376      document.getElementById('appUserProfile').style.display = 'flex';
13377      const img = document.getElementById('appUserAvatar');
13378      const fallback = document.getElementById('appUserAvatarFallback');
13379      const pic = (user && user.picture || '').trim();
13380      const initials = getUserAvatarInitials(user);
13381
13382      function showAvatarFallback() {
13383        img.onload = null;
13384        img.onerror = null;
13385        img.removeAttribute('src');
13386        img.style.display = 'none';
13387        fallback.textContent = initials;
13388        fallback.style.display = 'block';
13389      }
13390
13391      if (pic) {
13392        img.onload = function () {
13393          img.onload = null;
13394          img.onerror = null;
13395        };
13396        img.onerror = function () {
13397          img.onload = null;
13398          img.onerror = null;
13399          showAvatarFallback();
13400        };
13401        img.src = pic;
13402        img.style.display = 'block';
13403        fallback.style.display = 'none';
13404      } else {
13405        showAvatarFallback();
13406      }
13407    }
13408
13409    // Show login button in app
13410    function showAppLoginButton() {
13411      const btn = document.getElementById('appLoginButton');
13412      btn.style.display = 'inline-flex';
13413      btn.style.alignItems = 'center';
13414      btn.style.gap = '8px';
13415      document.getElementById('appUserProfile').style.display = 'none';
13416      resetAppUserAvatarBadge();
13417    }
13418
13419    // Handle Google Sign-In in app
13420    function handleAppGoogleSignIn() {
13421      google.accounts.oauth2.initTokenClient({
13422        client_id: GOOGLE_CLIENT_ID,
13423        scope: 'email profile',
13424        callback: (response) => {
13425          fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
13426            headers: {
13427              'Authorization': `Bearer ${response.access_token}`
13428            }
13429          })
13430          .then(res => res.json())
13431          .then(data => {
13432            const userData = {
13433              email: data.email,
13434              name: data.name,
13435              picture: data.picture,
13436              email_verified: true
13437            };
13438            
13439            // Create/update user in database
13440            fetch('/api/create-user.php', {
13441              method: 'POST',
13442              headers: { 'Content-Type': 'application/json' },
13443              body: JSON.stringify({ google_access_token: response.access_token })
13444            })
13445            .then(res => res.json())
13446            .then(result => {
13447              if (result.success) {
13448                localStorage.setItem('colir_user', JSON.stringify(userData));
13449                // Check license after creating user
13450                checkLicenseStatus(data.email).then((isPro) => {
13451                  handlePendingBetaUnlock(isPro);
13452                  handlePendingCheckout();
13453                });
13454              } else {
13455                console.error('Failed to create user:', result.error);
13456                alert(result.error || 'Failed to sign in. Please try again.');
13457                return;
13458              }
13459
13460              showAppUserProfile(userData);
13461            
13462              // Close login modal if open
13463              const loginModal = document.getElementById('loginModal');
13464              if (loginModal) {
13465                loginModal.classList.remove('visible');
13466              }
13467            
13468              // Restore gradient state if saved
13469              restoreGradientState();
13470            
13471              // If there was a pending export, show export modal
13472              if (window.pendingExportFormat) resumePendingExport();
13473            
13474              if (localStorage.getItem('colir_cookie_consent') === 'all') {
13475                gtag('event', 'login', {
13476                  method: 'Google',
13477                  location: 'app'
13478                });
13479              }
13480            })
13481            .catch(error => {
13482              console.error('Error creating user:', error);
13483              alert('Failed to sign in. Please try again.');
13484            });
13485          })
13486          .catch(error => {
13487            console.error('Error fetching user info:', error);
13488            alert('Failed to sign in. Please try again.');
13489          });
13490        }
13491      }).requestAccessToken();
13492    }
13493
13494    // Open login modal (from header or from export flow)
13495    function openAppLoginModal() {
13496      saveGradientState();
13497      const loginModal = document.getElementById('loginModal');
13498      const content = document.getElementById('loginModalContent');
13499      if (content) { content.style.left = ''; content.style.top = ''; }
13500      if (loginModal) loginModal.classList.add('visible');
13501      showAppLoginModalView('login');
13502    }
13503
13504    function showAppLoginModalView(view) {
13505      const isLogin = view === 'login';
13506      document.getElementById('appModalForgotView').style.display = isLogin ? 'none' : 'block';
13507      document.getElementById('appModalGoogleBtn').style.display = isLogin ? 'flex' : 'none';
13508      const divider = document.querySelector('#appLoginModalBody .divider');
13509      if (divider) divider.style.display = isLogin ? 'flex' : 'none';
13510      document.getElementById('appLoginForm').style.display = isLogin ? 'block' : 'none';
13511      const tr = document.getElementById('appModalToggleRow');
13512      if (tr) tr.style.display = isLogin ? 'block' : 'none';
13513      document.getElementById('appModalError').classList.remove('visible');
13514      document.getElementById('appModalSuccess').classList.remove('visible');
13515      if (!isLogin) {
13516        document.getElementById('appModalForgotError').classList.remove('visible');
13517        document.getElementById('appModalForgotSuccess').classList.remove('visible');
13518      }
13519    }
13520
13521    // Handle login from export modal (legacy: export modal used to have its own Google button; now both use same login modal)
13522    function handleExportLogin() {
13523      saveGradientState();
13524      openAppLoginModal();
13525    }
13526
13527    // Save gradient state to localStorage
13528    function saveGradientState() {
13529      try {
13530        const state = generateGradientJSON();
13531        state.canvasWidth = parseInt(widthInput.value) || 900;
13532        state.canvasHeight = parseInt(heightInput.value) || 520;
13533        localStorage.setItem('colir_pending_gradient', JSON.stringify(state));
13534      } catch (error) {
13535        console.error('Error saving gradient state:', error);
13536      }
13537    }
13538
13539    // Restore gradient state from localStorage
13540    function restoreGradientState() {
13541      try {
13542        const savedState = localStorage.getItem('colir_pending_gradient');
13543        if (!savedState) return;
13544        
13545        // Check if required variables are initialized (use try-catch to avoid ReferenceError)
13546        let varsReady = false;
13547        try {
13548          varsReady = (typeof widthInput !== 'undefined' && typeof heightInput !== 'undefined' &&
13549                      typeof xEditor !== 'undefined' && typeof yEditor !== 'undefined' &&
13550                      typeof gradientStopsArray !== 'undefined' && typeof blendMode !== 'undefined' &&
13551                      widthInput && heightInput && xEditor && yEditor && blendMode);
13552        } catch (e) {
13553          varsReady = false;
13554        }
13555        
13556        if (!varsReady) {
13557          console.warn('Gradient state restoration delayed - variables not initialized');
13558          // Retry after a short delay
13559          setTimeout(restoreGradientState, 100);
13560          return;
13561        }
13562        
13563        const state = JSON.parse(savedState);
13564        // Use the same restore path as share links / templates so beta (and
13565        // every other feature) is applied consistently — including PRO gating.
13566        if (typeof restoreGradientFromData === 'function') {
13567          restoreGradientFromData(state);
13568        }
13569        
13570        // Clear saved state
13571        localStorage.removeItem('colir_pending_gradient');
13572      } catch (error) {
13573        console.error('Error restoring gradient state:', error);
13574        // Clear corrupted state
13575        localStorage.removeItem('colir_pending_gradient');
13576      }
13577    }
13578
13579    // Check export limit
13580    async function checkExportLimit() {
13581      const userData = localStorage.getItem('colir_user') || sessionStorage.getItem('colir_user');
13582      if (!userData) {
13583        return { can_export: false, exports_today: 0, limit: 3, monthly_limit: 10, exports_month: 0, is_pro: false };
13584      }
13585      
13586      try {
13587        const user = JSON.parse(userData);
13588        const response = await fetch('/api/check-export-limit.php', {
13589          method: 'POST',
13590          signal: AbortSignal.timeout(15000),
13591          headers: { 'Content-Type': 'application/json' },
13592          body: JSON.stringify({ email: user.email })
13593        });
13594        const data = await response.json();
13595        if (!response.ok || typeof data.can_export !== 'boolean') return {can_export:false,reason:data.reason || 'service_unavailable',error:data.error || 'Could not check your export allowance. Please retry.'};
13596        return data;
13597      } catch (error) {
13598        console.error('Error checking export limit:', error);
13599        return { can_export: false, reason: 'service_unavailable', error: 'Could not check your export allowance. Please retry.' };
13600      }
13601    }
13602
13603    function exportAllowanceText(data) {
13604      if (data.reason === 'service_unavailable' || data.reason === '
13604invalid_request') return data.error || 'Allowance unavailable. Please retry.';
13605      if (data.reason === 'email_unverified') return 'Confirm your email to export';
13606      if (data.is_pro) return 'Unlimited (PRO)';
13607      return `${Math.max(0, data.limit - data.exports_today)} left today · ${Math.max(0, data.monthly_limit - data.exports_month)} left this month`;
13608    }
13609    function showExportProblem(data) {
13610      if (data.reason === 'daily_limit' || data.reason === 'monthly_limit') {
13611        openUpgradeModal('export');
13612        document.getElementById('upgradeModalMessage').textContent = window.ColirWebExport.problem(data).message;
13613      } else alert(data.error || 'Could not check your export allowance. Please retry.');
13614    }
13615
13616    function resetExportTermsModal() {
13617      const checkbox = document.getElementById('exportTermsCheckbox');
13618      const confirmBtn = document.getElementById('confirmExportBtn');
13619      if (checkbox) {
13620        checkbox.checked = false;
13621        checkbox.disabled = false;
13622      }
13623      if (confirmBtn) {
13624        confirmBtn.disabled = true;
13625      }
13626    }
13627
13628    // Show export modal
13629    async function showExportModal() {
13630      const exportModal = document.getElementById('exportModal');
13631      const checkbox = document.getElementById('exportTermsCheckbox');
13632      const confirmBtn = document.getElementById('confirmExportBtn');
13633      const exportCounter = document.getElementById('exportCounter');
13634      
13635      if (!exportModal) return;
13636      
13637      // Check export limit
13638      const limitData = await checkExportLimit();
13639      
13640      // Update counter
13641      if (exportCounter) {
13642        if (limitData.reason === 'email_unverified') {
13643          exportCounter.textContent = 'Confirm your email to export';
13644        } else if (limitData.is_pro) {
13645          exportCounter.textContent = 'Unlimited (PRO)';
13646        } else {
13647          exportCounter.textContent = exportAllowanceText(limitData);
13648        }
13649      }
13650      
13651      // Enable/disable checkbox and button based on limit
13652      if (limitData.can_export || limitData.is_pro) {
13653        checkbox.disabled = false;
13654        confirmBtn.disabled = true; // Will be enabled when checkbox is checked
13655      } else if (limitData.reason === 'email_unverified') {
13656        checkbox.disabled = true;
13657        confirmBtn.disabled = true;
13658        exportModal.classList.remove('visible');
13659        alert('Please confirm your email to activate exports. Check your inbox for the Colir confirmation link.');
13660        return;
13661      } else {
13662        checkbox.disabled = true;
13663        confirmBtn.disabled = true;
13664        // Show upgrade modal instead
13665        exportModal.classList.remove('visible');
13666        showExportProblem(limitData);
13667        return;
13668      }
13669      
13670      // Reset checkbox
13671      resetExportTermsModal();
13672      
13673      // Show modal
13674      exportModal.classList.add('visible');
13675    }
13676    
13677    // Position export count display below export button
13678    function positionExportCountDisplay() {
13679      const exportCountDisplay = document.getElementById('exportCountDisplay');
13680      const exportBtn = document.getElementById('exportBtn');
13681      const outputArea = document.querySelector('.output-area');
13682      
13683      if (!exportCountDisplay || !exportBtn) return;
13684      
13685      // Ensure element is visible before getting dimensions
13686      if (!exportCountDisplay.classList.contains('show')) {
13687        exportCountDisplay.classList.add('show');
13688      }
13689      
13690      const btnRect = exportBtn.getBoundingClientRect();
13691      const containerRect = outputArea ? outputArea.getBoundingClientRect() : { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight };
13692      const spacing = 12;
13693      // The transformed output area is the fixed-position containing block.
13694      // Set the available width before measuring the wrapped content's height.
13695      exportCountDisplay.style.width = 'max-content';
13696      exportCountDisplay.style.maxWidth = `${Math.max(0, containerRect.width - spacing * 2)}px`;
13697      const displayWidth = exportCountDisplay.offsetWidth;
13698      const displayHeight = exportCountDisplay.offsetHeight;
13699      let top = btnRect.bottom - containerRect.top + spacing;
13700      if (top + displayHeight > containerRect.height - spacing) {
13701        top = btnRect.top - containerRect.top - spacing - displayHeight;
13702      }
13703      top = Math.max(spacing, Math.min(top, containerRect.height - displayHeight - spacing));
13704      const left = Math.max(spacing, Math.min(
13705        btnRect.right - containerRect.left - displayWidth,
13706        containerRect.width - displayWidth - spacing
13707      ));
13708      exportCountDisplay.style.top = `${top}px`;
13709      exportCountDisplay.style.left = `${left}px`;
13710      exportCountDisplay.style.right = 'auto';
13711    }
13712    
13713    // Update export count display under export button
13714    async function updateExportCountDisplay() {
13715      const userData = localStorage.getItem('colir_user') || sessionStorage.getItem('colir_user');
13716      if (!userData) return;
13717      
13718      const limitData = await checkExportLimit();
13719      const exportCountDisplay = document.getElementById('exportCountDisplay');
13720      const exportCountText = document.getElementById('exportCountText');
13721      
13722      if (!exportCountDisplay || !exportCountText) return;
13723      
13724      if (limitData.is_pro) {
13725        exportCountDisplay.classList.remove('show');
13726        exportCountDisplay.style.display = 'none';
13727      } else if (limitData.reason === 'email_unverified') {
13728        exportCountText.textContent = 'Confirm your email to export';
13729        exportCountDisplay.classList.add('show');
13730        positionExportCountDisplay();
13731      } else {
13732        exportCountText.textContent = exportAllowanceText(limitData);
13733        exportCountDisplay.style.removeProperty('display');
13734        exportCountDisplay.classList.add('show');
13735        // Position it after display is shown
13736        positionExportCountDisplay();
13737      }
13738    }
13739    
13740    // Close button handler
13741    const closeExportCount = document.getElementById('closeExportCount');
13742    if (closeExportCount) {
13743      closeExportCount.addEventListener('click', () => {
13744        const exportCountDisplay = document.getElementById('exportCountDisplay');
13745        if (exportCountDisplay) {
13746          exportCountDisplay.classList.remove('show');
13747          exportCountDisplay.style.display = 'none';
13748        }
13749      });
13750    }
13751    
13752    // Reposition on window resize
13753    window.addEventListener('resize', () => {
13754      const exportCountDisplay = document.getElementById('exportCountDisplay');
13755      if (exportCountDisplay && exportCountDisplay.classList.contains('show')) {
13756        positionExportCountDisplay();
13757      }
13758    });
13759
13760    // Handle actual export
13761    let pendingExportFormat = null;
13762    let exportGateBusy = false;
13763    async function performExport(format) {
13764      if (exportGateBusy || window.ColirWebExport.busy) return;
13765      exportGateBusy = true;
13766      try { return await prepareWebsiteExport(format); }
13767      catch (error) { showExportProblem({reason:error.reason,error:error.message}); }
13768      finally { exportGateBusy = false; }
13769    }
13770    async function prepareWebsiteExport(format) {
13771      const userData = localStorage.getItem('colir_user') || sessionStorage.getItem('colir_user');
13772      if (!userData) {
13773        // Not logged in - save state and show login modal
13774        saveGradientState();
13775        window.pendingExport = true;
13776        window.pendingExportFormat = format; // Save the format for after login
13777        openAppLoginModal();
13778        return;
13779      }
13780      
13781      // Check export limit
13782      const limitData = await checkExportLimit();
13783      if (!limitData.can_export && !limitData.is_pro) {
13784        if (limitData.reason === 'email_unverified') {
13785          alert('Please confirm your email to activate exports. Check your inbox for the Colir confirmation link.');
13786          return;
13787        }
13788        showExportProblem(limitData);
13789        return;
13790      }
13791      
13792      // PRO users skip the modal and export immediately
13793      if (limitData.is_pro) {
13794        await proceedWithExport(format);
13795        return;
13796      }
13797      
13798      // Check if user has already acknowledged terms (from database)
13799      const user = JSON.parse(userData);
13800      
13801      let termsAcknowledged = false;
13802      try {
13803        const termsResponse = await fetch('/api/check-terms-acknowledged.php', {
13804          signal: AbortSignal.timeout(15000),
13805          method: 'POST',
13806          headers: { 'Content-Type': 'application/json' },
13807          body: JSON.stringify({ email: user.email })
13808        });
13809        const termsData = await termsResponse.json();
13810        termsAcknowledged = termsData.acknowledged === true;
13811      } catch (error) {
13812        console.error('Error checking terms acknowledgment:', error);
13813      }
13814      
13815      if (termsAcknowledged) {
13816        await proceedWithExport(format);
13817      } else {
13818        pendingExportFormat = format;
13819        await showExportModal();
13820      }
13821    }
13822    function resumePendingExport() {
13823      const format = window.pendingExportFormat;
13824      window.pendingExportFormat = null;
13825      window.pendingExport = false;
13826      if (format) setTimeout(() => performExport(format), 0);
13827    }
13828
13829    // Proceed with actual export after modal confirmation
13830    async function proceedWithExport(format) {
13831      const actualFormat = pendingExportFormat || format;
13832      
13833      // Mark terms as acknowledged if this is from the modal (store in database)
13834      const checkbox = document.getElementById('exportTermsCheckbox');
13835      if (checkbox && checkbox.checked && document.getElementById('exportModal')?.classList.contains('visible')) {
13836        const userData = localStorage.getItem('colir_user') || sessionStorage.getItem('colir_user');
13837        if (userData) {
13838          const user = JSON.parse(userData);
13839          
13840          try {
13841            const termsResult = await fetch('/api/acknowledge-terms.php', {
13842          signal: AbortSignal.timeout(15000),
13843              method: 'POST',
13844              headers: { 'Content-Type': 'application/json' },
13845              body: JSON.stringify({ email: user.email })
13846            });
13847            if (!termsResult.ok || !(await termsResult.json()).success) throw new Error('Could not save your terms acceptance. Please retry.');
13848          } catch (error) {
13849            showExportProblem({error:'Could not save your terms acceptance. Please retry.'});
13850            return;
13851          }
13852        }
13853      }
13854      
13855      pendingExportFormat = null;
13856      document.getElementById('exportModal')?.classList.remove('visible');
13857      if (actualFormat === 'mp4' || actualFormat === 'webm') {
13858        window.ColirAnimation?.openExport();
13859        return;
13860      }
13861      let attempt;
13862      try {
13863        attempt = await window.ColirWebExport.begin();
13864        if (actualFormat === 'json') {
13865          const blob = new Blob([JSON.stringify(generateGradientJSON(), null, 2)], {type:'application/json'});
13866          await window.ColirWebExport.deliver(attempt, blob, 'colir-gradient.json');
13867        } else {
13868          const file = await executeImageExport(actualFormat);
13869          await window.ColirWebExport.deliver(attempt, file.dataUrl, file.filename);
13870        }
13871      } catch(error) {
13872        if (error.name !== 'AbortError') showExportProblem({reason:error.reason,error:error.message});
13873      } finally { if(attempt)window.ColirWebExport.finish(); }
13874    }
13875
13876    // Execute image export (extracted from original handler)
13877    async function executeImageExport(format) {
13878      const animationFrame = window.ColirAnimation?.stillFrame() || null;
13879      await ColirSurfaceFilters.prepareForExport();
13880      return new Promise((resolve, reject) => {
13881      // Show loading state
13882      exportBtn.classList.add('loading');
13883      exportBtn.disabled = true;
13884      const originalIcon = exportBtnIcon.dataset.icon;
13885      const originalText = exportBtnText.textContent;
13886      ColirIcons.set(exportBtnIcon, 'hourglass_empty');
13887      exportBtnText.textContent = 'Exporting...';
13888      
13889      // Use requestAnimationFrame to allow UI to update before heavy processing
13890      requestAnimationFrame(() => {
13891        setTimeout(() => {
13892          try {
13893            const scale = parseInt(exportScale.value) || 1;
13894            const originalWidth = gradientCanvas.width;
13895            const originalHeight = gradientCanvas.height;
13896
13897            const exportCanvas = document.createElement('canvas');
13898            exportCanvas.width = originalWidth * scale;
13899            exportCanvas.height = originalHeight * scale;
13900            const exportCtx = exportCanvas.getContext('2d');
13901
13902            requireGraphics(exportCanvas.width, exportCanvas.height);
13903            const img = renderGradientWebGLAtSize(exportCanvas.width, exportCanvas.height, false, animationFrame);
13904            if (!img) throw new Error('Graphics rendering failed. Retry graphics.');
13905            exportCtx.putImageData(img, 0, 0);
13906
13907            // Build comprehensive filename
13908            const width = exportCanvas.width;
13909            const height = exportCanvas.height;
13910            const noiseVal = parseInt(noiseInput.value);
13911            const featherVal = parseInt(featherInput.value);
13912            
13913            // Get colors from gradient stops (sorted by position)
13914            const sortedStops = [...gradientStopsArray].sort((a, b) => a.position - b.position);
13915            const colors = sortedStops.map(stop => {
13916              const hex = rgbToHex(stop.color.r, stop.color.g, stop.color.b);
13917              return hex.substring(1); // Remove # prefix
13918            }).join('-');
13919            
13920            // Get X curve point positions (sorted by t)
13921            const xPoints = [...xEditor.points].sort((a, b) => a.t - b.t).map(p => p.t.toFixed(2)).join(',');
13922            
13923            // Get Y curve point positions (sorted by t)
13924            const yPoints = [...yEditor.points].sort((a, b) => a.t - b.t).map(p => p.t.toFixed(2)).join(',');
13925            
13926            // Determine file extension and MIME type
13927            const fileExt = format === 'webp' ? 'webp' : 'png';
13928            const mimeType = format === 'webp' ? 'image/webp' : 'image/png';
13929            const quality = format === 'webp' ? 0.92 : undefined;
13930            
13931            // Build filename
13932            const filename = `colir_${width}x${height}_noise-${noiseVal}_feather-${featherVal}_colors-${colors}_x-${xPoints}_y-${yPoints}.${fileExt}`;
13933
13934            // Export the image
13935            let dataUrl;
13936            try {
13937              if (format === 'webp') {
13938                dataUrl = exportCanvas.toDataURL(mimeType, quality);
13939              } else {
13940                dataUrl = exportCanvas.toDataURL(mimeType);
13941              }
13942            } catch (toDataUrlError) {
13943              console.error('toDataURL error:', toDataUrlError);
13944              throw new Error('Failed to convert canvas to data URL: ' + toDataUrlError.message);
13945            }
13946            
13947            if (!dataUrl || dataUrl === 'data:,') {
13948              throw new Error('toDataURL returned invalid data');
13949            }
13950
13951            resolve({dataUrl, filename});
13952
13953            // Remove loading state
13954            exportBtn.classList.remove('loading');
13955            exportBtn.disabled = window.ColirGraphics?.state !== 'ready';
13956            ColirIcons.set(exportBtnIcon, originalIcon);
13957            if (exportBtnText) {
13958              exportBtnText.textContent = originalText;
13959            }
13960          } catch (error) {
13961            console.error('Export error:', error);
13962            console.error('Error stack:', error.stack);
13963            reject(error);
13964            // Remove loading state on error
13965            exportBtn.classList.remove('loading');
13966            exportBtn.disabled = window.ColirGraphics?.state !== 'ready';
13967            ColirIcons.set(exportBtnIcon, originalIcon);
13968            if (exportBtnText) {
13969              exportBtnText.textContent = originalText;
13970            }
13971          }
13972        }, 50);
13973      });
13974      });
13975    }
13976
13977    // Handle logout in app
13978    function handleAppLogout() {
13979      if (confirm('Are you sure you want to sign out?')) {
13980        localStorage.removeItem('colir_user');
13981        sessionStorage.removeItem('colir_user');
13982        localStorage.removeItem('colir_license');
13983        localStorage.removeItem('colir_license_is_pro');
13984        // Hide PRO tag and re-lock the beta panel
13985        updateProTag({ licensed: false, type: null });
13986        showAppLoginButton();
13987        
13988        if (localStorage.getItem('colir_cookie_consent') === 'all') {
13989          gtag('event', 'logout', {
13990            location: 'app'
13991          });
13992        }
13993      }
13994    }
13995
13996    // Initialize auth on app load
13997    function initAppAuth() {
13998      if (document.documentElement.classList.contains('colir-figma') || window.__COLIR_LOCAL_DEV__) return;
13999      // Wait for Google Sign-In script to load
14000      if (typeof google !== 'undefined' && google.accounts) {
14001        checkAppAuthStatus();
14002      } else {
14003        // Retry after a short delay
14004        setTimeout(initAppAuth, 100);
14005      }
14006    }
14007
14008    function initCookieBanner() {
14009      var banner = document.getElementById('cookieBanner');
14010      var acceptBtn = document.getElementById('cookieAccept');
14011      var necessaryBtn = document.getElementById('cookieNecessary');
14012      if (!banner) return;
14013      if (localStorage.getItem('colir_cookie_consent')) {
14014        banner.classList.add('hidden');
14015        return;
14016      }
14017      if (acceptBtn) {
14018        acceptBtn.addEventListener('click', function() {
14019          localStorage.setItem('colir_cookie_consent', 'all');
14020          var s = document.createElement('script');
14021          s.async = true;
14022          s.src = 'https://www.googletagmanager.com/gtag/js?id=G-PGS3X1TPPZ';
14023          document.head.appendChild(s);
14024          gtag('js', new Date());
14025          gtag('config', 'G-PGS3X1TPPZ');
14026          banner.classList.add('hidden');
14027        });
14028      }
14029      if (necessaryBtn) {
14030        necessaryBtn.addEventListener('click', function() {
14031          localStorage.setItem('colir_cookie_consent', 'necessary');
14032          banner.classList.add('hidden');
14033        });
14034      }
14035    }
14036
14037    // Also check on page load
14038    window.addEventListener('load', function() {
14039      initCookieBanner();
14040      // Check for shared gradient in URL hash first (before restoring from localStorage)
14041      loadGradientFromHash();
14042      if (typeof updateRandomBtnGradient === 'function') updateRandomBtnGradient();
14043      
14044      setTimeout(() => {
14045        checkAppAuthStatus();
14046        // Restore gradient state if user is already logged in and no share link was loaded
14047        // Use longer delay to ensure all variables are initialized and initial render has happened
14048        const userData = localStorage.getItem('colir_user') || sessionStorage.getItem('colir_user');
14049        if (userData && !window.__shareGradientLoaded) {
14050          // Wait longer to ensure initial render has completed
14051          setTimeout(() => {
14052            try {
14053              restoreGradientState();
14054            } catch (error) {
14055              console.error('Error in restoreGradientState on load:', error);
14056              // Clear corrupted state
14057              localStorage.removeItem('colir_pending_gradient');
14058            }
14059          }, 1500);
14060        }
14061      }, 500);
14062    });
14063
14064    // Position login modal next to export button
14065    function positionLoginModal() {
14066      const loginModal = document.getElementById('loginModal');
14067      const loginModalContent = loginModal.querySelector('.help-modal-content');
14068      const exportBtn = document.getElementById('exportBtn');
14069      
14070      if (!loginModal || !loginModalContent || !exportBtn) return;
14071      
14072      const btnRect = exportBtn.getBoundingClientRect();
14073      const modalWidth = 320;
14074      const modalHeight = loginModalContent.offsetHeight || 200;
14075      const spacing = 12;
14076      
14077      // Position to the left of the button, vertically aligned
14078      let left = btnRect.left - modalWidth - spacing;
14079      let top = btnRect.top;
14080      
14081      // If modal would go off screen to the left, position to the right instead
14082      if (left < 20) {
14083        left = btnRect.right + spacing;
14084      }
14085      
14086      // If modal would go off screen to the right, position above
14087      if (left + modalWidth > window.innerWidth - 20) {
14088        left = btnRect.left;
14089        top = btnRect.top - modalHeight - spacing;
14090      }
14091      
14092      // Ensure modal doesn't go off top of screen
14093      if (top < 20) {
14094        top = btnRect.bottom + spacing;
14095      }
14096      
14097      loginModalContent.style.left = `${left}px`;
14098      loginModalContent.style.top = `${top}px`;
14099    }
14100
14101    // App login modal: register vs login mode
14102    let appLoginModalIsRegister = false;
14103
14104    document.getElementById('appModalGoogleBtn')?.addEventListener('click', function() {
14105      if (typeof google !== 'undefined' && google.accounts && google.accounts.oauth2) {
14106        handleAppGoogleSignIn();
14107      } else {
14108        const errEl = document.getElementById('appModalError');
14109        errEl.textContent = 'Google Sign-In is loading. Please try again in a moment.';
14110        errEl.classList.add('visible');
14111      }
14112    });
14113
14114    document.getElementById('appLoginForm')?.addEventListener('submit', async function(e) {
14115      e.preventDefault();
14116      const errEl = document.getElementById('appModalError');
14117      const okEl = document.getElementById('appModalSuccess');
14118      const email = document.getElementById('appModalEmail').value.trim();
14119      const password = document.getElementById('appModalPassword').value;
14120      const name = document.getElementById('appModalNameInput').value.trim() || null;
14121      errEl.classList.remove('visible');
14122      okEl.classList.remove('visible');
14123      if (!email || !password) return;
14124      if (password.length < 8) {
14125        errEl.textContent = 'Password must be at least 8 characters';
14126        errEl.classList.add('visible');
14127        return;
14128      }
14129      const submitBtn = document.getElementById('appModalSubmit');
14130      submitBtn.disabled = true;
14131      submitBtn.textContent = appLoginModalIsRegister ? 'Creating account...' : 'Logging in...';
14132      try {
14133        const endpoint = appLoginModalIsRegister ? '/api/register.php' : '/api/login.php';
14134        const body = appLoginModalIsRegister ? { email, password, name } : { email, password };
14135        const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
14136        const data = await res.json();
14137        if (data.success && data.needs_email_verification) {
14138          okEl.textContent = data.message || 'Please check your inbox to confirm your email and activate your Personal license.';
14139          okEl.classList.add('visible');
14140          submitBtn.disabled = false;
14141          submitBtn.textContent = appLoginModalIsRegister ? 'Sign up' : 'Log in';
14142          return;
14143        }
14144        if (data.success && data.user) {
14145          const remember = document.getElementById('appModalRemember').checked;
14146          const stored = JSON.stringify({ email: data.user.email, name: data.user.name, picture: data.user.picture, email_verified: data.user.email_verified === true });
14147          if (remember) localStorage.setItem('colir_user', stored);
14148          else sessionStorage.setItem('colir_user', stored);
14149          document.getElementById('loginModal').classList.remove('visible');
14150          window.pendingExport = false;
14151          showAppUserProfile({ email: data.user.email, name: data.user.name, picture: data.user.picture });
14152          if (typeof checkLicenseStatus === 'function') {
14153            checkLicenseStatus(data.user.email).then((isPro) => {
14154              handlePendingBetaUnlock(isPro);
14155              handlePendingCheckout();
14156            });
14157          }
14158          if (window.pendingExportFormat) resumePendingExport();
14159          if (localStorage.getItem('colir_cookie_consent') === 'all') {
14160            if (typeof gtag !== 'undefined') gtag('event', 'login', { method: 'Email', location: 'app' });
14161          }
14162        } else {
14163          errEl.textContent = data.error || 'Something went wrong';
14164          errEl.classList.add('visible');
14165        }
14166      } catch (err) {
14167        errEl.textContent = 'Network error. Please try again.';
14168        errEl.classList.add('visible');
14169      }
14170      submitBtn.disabled = false;
14171      submitBtn.textContent = appLoginModalIsRegister ? 'Sign up' : 'Log in';
14172    });
14173
14174    document.getElementById('appModalToggleRow')?.addEventListener('click', function(e) {
14175      if (!e.target.closest('a')) return;
14176      e.preventDefault();
14177      appLoginModalIsRegister = !appLoginModalIsRegister;
14178      document.getElementById('appModalError').classList.remove('visible');
14179      document.getElementById('appModalSuccess').classList.remove('visible');
14180      document.getElementById('appModalNameGroup').style.display = appLoginModalIsRegister ? 'block' : 'none';
14181      document.getElementById('appLoginModalTitle').textContent = appLoginModalIsRegister ? 'Create account' : 'Log in';
14182      document.getElementById('appModalSubmit').textContent = appLoginModalIsRegister ? 'Sign up' : 'Log in';
14183      document.getElementById('appModalToggleRow').innerHTML = appLoginModalIsRegister
14184        ? 'Already have an account? <a href="#" style="color: var(--primary); text-decoration: none; font-weight: 500;">Log in</a>'
14185        : 'Don\'t have an account? <a href="#" style="color: var(--primary); text-decoration: none; font-weight: 500;">Sign up</a>';
14186    });
14187
14188    document.getElementById('appModalShowForgot')?.addEventListener('click', function(e) {
14189      e.preventDefault();
14190      showAppLoginModalView('forgot');
14191    });
14192
14193    document.getElementById('appModalBackToLogin')?.addEventListener('click', function(e) {
14194      e.preventDefault();
14195      showAppLoginModalView('login');
14196    });
14197
14198    document.getElementById('appModalForgotForm')?.addEventListener('submit', async function(e) {
14199      e.preventDefault();
14200      const email = document.getElementById('appModalForgotEmail').value.trim();
14201      if (!email) return;
14202      const errEl = document.getElementById('appModalForgotError');
14203      const okEl = document.getElementById('appModalForgotSuccess');
14204      errEl.classList.remove('visible');
14205      okEl.classList.remove('visible');
14206      try {
14207        const res = await fetch('/api/request-password-reset.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
14208        const data = await res.json();
14209        if (data.success) {
14210          okEl.textContent = data.message || 'If an account exists, a reset link has been sent.';
14211          okEl.classList.add('visible');
14212        } else {
14213          errEl.textContent = data.error || 'Something went wrong';
14214          errEl.classList.add('visible');
14215        }
14216      } catch (err) {
14217        errEl.textContent = 'Network error. Please try again.';
14218        errEl.classList.add('visible');
14219      }
14220    });
14221
14222    // Modal handlers
14223    document.getElementById('closeLoginModal')?.addEventListener('click', () => {
14224      document.getElementById('loginModal').classList.remove('visible');
14225      window.pendingExport = false;
14226      window.pendingExportFormat = null;
14227      window.pendingBetaUnlock = false;
14228      window.pendingCheckoutPlan = null;
14229      showAppLoginModalView('login');
14230    });
14231
14232    document.getElementById('closeExportModal')?.addEventListener('click', () => {
14233      document.getElementById('exportModal').classList.remove('visible');
14234      pendingExportFormat = null;
14235    });
14236
14237    document.getElementById('closeUpgradeModal')?.addEventListener('click', closeUpgradeModal);
14238
14239    // Close modals when clicking outside
14240    document.getElementById('loginModal')?.addEventListener('click', (e) => {
14241      if (e.target.id === 'loginModal') {
14242        document.getElementById('loginModal').classList.remove('visible');
14243        window.pendingExport = false;
14244        window.pendingExportFormat = null;
14245        window.pendingBetaUnlock = false;
14246        window.pendingCheckoutPlan = null;
14247      }
14248    });
14249
14250    document.getElementById('exportModal')?.addEventListener('click', (e) => {
14251      if (e.target.id === 'exportModal') {
14252        document.getElementById('exportModal').classList.remove('visible');
14253        pendingExportFormat = null;
14254      }
14255    });
14256
14257    document.getElementById('upgradeModal')?.addEventListener('click', (e) => {
14258      if (e.target.id === 'upgradeModal') {
14259        closeUpgradeModal();
14260      }
14261    });
14262
14263    // Paddle success modal (shown after redirect from /pay successUrl)
14264    function maybeShowPaddleSuccess() {
14265      try {
14266        const params = new URLSearchParams(window.location.search);
14267        if (params.get('paddle') !== 'success') return;
14268        showPaddleSuccessModal();
14269
14270        // Clean the URL so refresh doesn't show it again
14271        params.delete('paddle');
14272        const newQs = params.toString();
14273        const newUrl = window.location.pathname + (newQs ? ('?' + newQs) : '') + window.location.hash;
14274        window.history.replaceState({}, '', newUrl);
14275      } catch (e) {}
14276    }
14277
14278    function closePaddleSuccessModal() {
14279      document.getElementById('paddleSuccessModal')?.classList.remove('visible');
14280    }
14281
14282    document.getElementById('closePaddleSuccessModal')?.addEventListener('click', closePaddleSuccessModal);
14283    document.getElementById('paddleSuccessOkBtn')?.addEventListener('click', closePaddleSuccessModal);
14284    document.getElementById('paddleSuccessModal')?.addEventListener('click', (e) => {
14285      if (e.target && e.target.id === 'paddleSuccessModal') closePaddleSuccessModal();
14286    });
14287
14288    maybeShowPaddleSuccess();
14289
14290    document.getElementById('exportTermsCheckbox')?.addEventListener('change', (e) => {
14291      const confirmBtn = document.getElementById('confirmExportBtn');
14292      if (confirmBtn) {
14293        confirmBtn.disabled = !e.target.checked;
14294      }
14295    });
14296
14297    let isExportConfirmRunning = false;
14298
14299    // Confirm export button handler
14300    document.getElementById('confirmExportBtn')?.addEventListener('click', async () => {
14301      if (isExportConfirmRunning) return;
14302      const checkbox = document.getElementById('exportTermsCheckbox');
14303      if (!checkbox || !checkbox.checked) {
14304        return; // Don't proceed if checkbox not checked
14305      }
14306      const confirmBtn = document.getElementById('confirmExportBtn');
14307      if (pendingExportFormat) {
14308        isExportConfirmRunning = true;
14309        if (confirmBtn) confirmBtn.disabled = true;
14310        try {
14311          await proceedWithExport(pendingExportFormat);
14312          // The shared download controller refreshes both counters after approval.
14313        } finally {
14314          isExportConfirmRunning = false;
14315          const exportModal = document.getElementById('exportModal');
14316          if (confirmBtn && exportModal && exportModal.classList.contains('visible')) {
14317            confirmBtn.disabled = !checkbox.checked;
14318          }
14319        }
14320      } else {
14321        console.error('Export format not set');
14322      }
14323    });
14324    
14325    // Export count display only updates when export button is clicked (see exportBtn click handler)
14326
14327    // Upgrade link handler (export modal → upgrade modal)
14328    document.getElementById('upgradeLink')?.addEventListener('click', (e) => {
14329      e.preventDefault();
14330      document.getElementById('exportModal').classList.remove('visible');
14331      openUpgradeModal('export');
14332    });
14333
14334    document.getElementById('upgradeKeepFree').addEventListener('click', closeUpgradeModal);
14335    let appCheckoutBusy = false;
14336    async function startPaddleCheckout(plan) {
14337      if (appCheckoutBusy) return;
14338      const userData = localStorage.getItem('colir_user') || sessionStorage.getItem('colir_user');
14339      if (!userData) {
14340        // Stay in the app: log in, then resume checkout for the chosen plan
14341        window.pendingCheckoutPlan = plan;
14342        closeUpgradeModal();
14343        openAppLoginModal();
14344        return;
14345      }
14346      let user = null;
14347      try { user = JSON.parse(userData); } catch (e) {}
14348      if (!user || !user.email) {
14349        window.pendingCheckoutPlan = plan;
14350        closeUpgradeModal();
14351        openAppLoginModal();
14352        return;
14353      }
14354
14355      const monthlyBtn = document.getElementById('purchaseProMonthlyBtn');
14356      const perpetualBtn = document.getElementById('purchaseProPerpetualBtn');
14357      const activeBtn = plan === 'perpetual' ? perpetualBtn : monthlyBtn;
14358      appCheckoutBusy = true;
14359      const prevLabel = activeBtn ? activeBtn.textContent : '';
14360      if (monthlyBtn) monthlyBtn.style.pointerEvents = 'none';
14361      if (perpetualBtn) perpetualBtn.style.pointerEvents = 'none';
14362      if (activeBtn) activeBtn.textContent = 'Opening checkout…';
14363
14364      try {
14365        const r = await fetch('/api/paddle/create-checkout.php', {
14366          method: 'POST',
14367          headers: { 'Content-Type': 'application/json' },
14368          body: JSON.stringify({ email: user.email, plan, pricing_version: 2 })
14369        });
14370        const res = await r.json();
14371        if (res && res.success && res.checkout_url) {
14372          window.location.href = res.checkout_url;
14373          return;
14374        }
14375        alert((res && res.error) ? res.error : 'Checkout is not available right now. Please try again.');
14376      } catch (e) {
14377        alert('Network error. Please try again.');
14378      } finally {
14379        appCheckoutBusy = false;
14380        if (monthlyBtn) monthlyBtn.style.pointerEvents = '';
14381        if (perpetualBtn) perpetualBtn.style.pointerEvents = '';
14382        if (activeBtn) activeBtn.textContent = prevLabel;
14383      }
14384    }
14385
14386    // After login that was triggered by an upgrade CTA, continue into Paddle
14387    function handlePendingCheckout() {
14388      const plan = window.pendingCheckoutPlan;
14389      if (plan !== 'monthly' && plan !== 'annual' && plan !== 'perpetual') return;
14390      window.pendingCheckoutPlan = null;
14391      setTimeout(() => startPaddleCheckout(plan), 300);
14392    }
14393
14394    document.getElementById('app-cp-toggle').addEventListener('click', (e) => {
14395      const button = e.target.closest('button[data-billing]');
14396      if (!button || appCheckoutBusy) return;
14397      const annual = button.dataset.billing === 'annual';
14398      e.currentTarget.dataset.billing = button.dataset.billing;
14399      e.currentTarget.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', String(b === button)));
14400      document.getElementById('app-cp-pro-price').textContent = annual ? '$59' : '$9';
14401      document.getElementById('app-cp-pro-per').textContent = annual ? '/yr' : '/mo';
14402      document.getElementById('purchaseProMonthlyBtn').textContent = annual ? 'Get PRO — $59/yr' : 'Get PRO — $9/mo';
14403    });
14404
14405    // Upgrade CTAs → Paddle checkout (never the landing pricing page)
14406    document.getElementById('purchaseProMonthlyBtn')?.addEventListener('click', (e) => {
14407      e.preventDefault();
14408      startPaddleCheckout(document.getElementById('app-cp-toggle').dataset.billing);
14409    });
14410    document.getElementById('purchaseProPerpetualBtn')?.addEventListener('click', (e) => {
14411      e.preventDefault();
14412      startPaddleCheckout('perpetual');
14413    });
14414
14415    window.addEventListener('colir-graphics-state', () => updateExportButtonText());
14416    updateExportButtonText();
14417
14418    // Startup is done and the first gradient has been drawn; hand off to the
14419    // preloader after the next paint so it reveals a fully rendered app.
14420    if(graphics.state==='ready')requestAnimationFrame(()=>window.__colirPreloader?.done());
14421  </script>
14421
14422
14423  
14423<script src="animation/animation.js?v=flow-travel-20260916"></script>
14423
14424</body>
14425
14426</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.