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.