← back to Damask Kaleidoscope

index.html

542 lines

<!DOCTYPE html>
<!-- Designer Wallcoverings — Damask Kaleidoscope · v2.1.1 -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Designer Wallcoverings — Damask Kaleidoscope</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&display=swap" rel="stylesheet">
<style>
  :root {
    color-scheme: dark;
    --bar-h: 56px;
    --champagne: #EDE0C4;
    --accent: #B5335A;              /* palette secondary — drives the pill dot */
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; overflow: hidden; background: #0d0b07; }
  #c { display: block; position: fixed; inset: 0; z-index: 1; cursor: pointer; touch-action: none; }

  /* ---------- fixed 56px top bar, above the canvas ---------- */
  #topbar {
    position: fixed; top: 0; left: 0; right: 0; height: var(--bar-h);
    z-index: 10;
    display: flex; align-items: center; justify-content: space-between;
    padding: 0 24px;
    background: linear-gradient(180deg, rgba(9,7,4,.90) 0%, rgba(9,7,4,.55) 62%, rgba(9,7,4,0) 100%);
    -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
    pointer-events: none; user-select: none;
  }
  .wordmark {
    font-family: "Cormorant Garamond", "Didot", "Bodoni MT", Georgia, serif;
    color: #ffffff;
    text-transform: uppercase;
    letter-spacing: .18em;
    font-weight: 600;
    font-size: clamp(15px, 2vw, 22px);
    line-height: 1;
    white-space: nowrap;
    text-shadow: 0 1px 14px rgba(0,0,0,.7);
  }
  .wordmark .mono {
    display: inline-block;
    padding-right: .32em; margin-right: .18em;
    font-size: 1.14em; font-weight: 700;
    color: var(--champagne);
    border-right: 1px solid rgba(237,224,196,.30);
  }
  .right { display: flex; align-items: center; gap: 16px; }
  .hint {
    font-family: -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
    color: rgba(237,224,196,.50);
    font-size: 11px; letter-spacing: .16em; text-transform: uppercase;
    white-space: nowrap;
    transition: opacity 1.4s ease;
  }
  .hint::before { content: "↻  "; color: rgba(200,169,81,.8); }
  .pill {
    font-family: -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
    font-size: 12px; letter-spacing: .15em; text-transform: uppercase;
    color: #0d0b07;
    background: linear-gradient(180deg, #EDE0C4, #C8A951);
    padding: 6px 15px; border-radius: 999px;
    box-shadow: 0 0 20px rgba(200,169,81,.28), inset 0 1px 0 rgba(255,255,255,.45);
    display: flex; align-items: center; gap: 9px;
    white-space: nowrap;
  }
  .pill .dot {
    width: 8px; height: 8px; border-radius: 50%;
    background: var(--accent);
    box-shadow: 0 0 7px var(--accent);
    transition: background .5s ease, box-shadow .5s ease;
  }
  #palName { transition: opacity .35s ease; }
  .faded { opacity: 0 !important; }
  @media (max-width: 520px) { .hint { display: none; } }
</style>
</head>
<body>
<canvas id="c"></canvas>
<header id="topbar">
  <div class="wordmark"><span class="mono">DW</span>Designer Wallcoverings</div>
  <div class="right">
    <span class="hint" id="hint">Click to shift palette</span>
    <span class="pill"><span class="dot" id="pillDot"></span><span id="palName">Royal</span></span>
  </div>
</header>

<script>
"use strict";
(() => {
  const canvas = document.getElementById("c");
  const ctx = canvas.getContext("2d", { alpha: false });

  // Offscreen "tube contents" — the evolving damask that the kaleidoscope mirrors.
  const src = document.createElement("canvas");
  const sctx = src.getContext("2d");

  const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;

  /* ---------- palettes ----------
     Royal is MANDATORY + first: near-black ground, champagne highlight,
     crisp gold petals, carmine secondary bloom (60°+ off gold so it pops).
     Every palette keeps a gilt `gold` and a saturated `sec` bloom that is a
     clear hue-jump from the gold — never a muddy warm beige.                 */
  const PALETTES = [
    { name: "Royal",     deep:"#0d0b07", bg:"#191207", gold:"#C8A951", sec:"#B5335A", champ:"#EDE0C4" },
    { name: "Sapphire",  deep:"#060a14", bg:"#0d1626", gold:"#C8A951", sec:"#5E90CC", champ:"#DCE6F2" },
    { name: "Emerald",   deep:"#05100b", bg:"#0d1c14", gold:"#C8A951", sec:"#43A776", champ:"#E4EFE0" },
    { name: "Amethyst",  deep:"#0a0710", bg:"#170f22", gold:"#C8A951", sec:"#A277C6", champ:"#ECE3F2" },
  ];

  let palIndex = 0;
  let curPal = mixPalette(PALETTES[0], PALETTES[0], 0);
  let fromPal = PALETTES[0], toPal = PALETTES[0], palT = 1; // crossfade progress

  /* ---------- geometry / device ---------- */
  let W = 0, H = 0, cx = 0, cy = 0, R = 0, dpr = 1;
  const SIDES = 16;                 // 16 wedges (8 mirrored pairs) -> true 8-fold dihedral symmetry
  const SLICE = (Math.PI * 2) / SIDES;
  let S = 0;                        // source (offscreen) size in px

  function resize() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = window.innerWidth; H = window.innerHeight;
    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    canvas.style.width = W + "px";
    canvas.style.height = H + "px";
    cx = W / 2; cy = H / 2;
    R = Math.hypot(W, H) / 2 + 4;   // cover the whole screen
    S = Math.min(Math.round(R * 1.15), 900);   // source square, capped for 60fps
    src.width = S; src.height = S;
  }
  window.addEventListener("resize", resize);
  resize();

  /* ---------- pointer warp ---------- */
  let mx = 0, my = 0;               // normalized -1..1 from center (target)
  let wx = 0, wy = 0;               // smoothed
  let warp = 0, warpTarget = 0;     // 0..1+ distance-driven warp strength
  function pointer(px, py) {
    mx = (px - cx) / (W / 2);
    my = (py - cy) / (H / 2);
    warpTarget = Math.min(Math.hypot(mx, my), 1.4);
    fade(hint);
  }
  canvas.addEventListener("mousemove", e => pointer(e.clientX, e.clientY));
  canvas.addEventListener("touchmove", e => {
    if (e.touches[0]) pointer(e.touches[0].clientX, e.touches[0].clientY);
    e.preventDefault();
  }, { passive: false });

  /* ---------- palette cycling ---------- */
  const palName = document.getElementById("palName");   // pill dot colour is driven by the --accent CSS var
  const hint    = document.getElementById("hint");

  function applyPillChrome(pal) {
    document.documentElement.style.setProperty("--accent", pal.sec);
    document.documentElement.style.setProperty("--champagne", pal.champ);
    document.body.style.background = pal.deep;
  }
  function cyclePalette() {
    fromPal = curPal;
    palIndex = (palIndex + 1) % PALETTES.length;
    toPal = PALETTES[palIndex];
    palT = 0;
    // swap the pill label with a tiny fade so it reads as a deliberate shift
    palName.classList.add("faded");
    setTimeout(() => { palName.textContent = toPal.name; palName.classList.remove("faded"); }, 180);
    applyPillChrome(toPal);
  }
  canvas.addEventListener("click", cyclePalette);
  canvas.addEventListener("touchend", e => { cyclePalette(); e.preventDefault(); });

  function fade(el){ if (el && !el.classList.contains("faded")) el.classList.add("faded"); }

  /* ---------- color helpers ---------- */
  function hexToRgb(h){ const n = parseInt(h.slice(1),16); return [n>>16&255, n>>8&255, n&255]; }
  function lerp(a,b,t){ return a+(b-a)*t; }
  function mixHex(h1,h2,t){
    const a=hexToRgb(h1), b=hexToRgb(h2);
    return `rgb(${Math.round(lerp(a[0],b[0],t))},${Math.round(lerp(a[1],b[1],t))},${Math.round(lerp(a[2],b[2],t))})`;
  }
  function mixPalette(a,b,t){
    return { name:b.name,
             deep:mixHex(a.deep,b.deep,t), bg:mixHex(a.bg,b.bg,t),
             gold:mixHex(a.gold,b.gold,t), sec:mixHex(a.sec,b.sec,t), champ:mixHex(a.champ,b.champ,t) };
  }
  // parse an "rgb(...)" string back to [r,g,b] so we can build rgba() glow strings from mixed colors
  function toRgb(str){ const m=str.match(/\d+/g); return m?[+m[0],+m[1],+m[2]]:[0,0,0]; }
  function rgbaOf(str,al){ const a=toRgb(str); return `rgba(${a[0]},${a[1]},${a[2]},${al})`; }

  /* ---------- the evolving damask motif (drawn into the source canvas) ----------
     6-fold rotational armature of ogee vines + bezier leaves + a central bezier
     urn; the 16-way kaleidoscope then mirrors a wedge into a seamless 8-fold
     mandala. The kaleidoscope "oscillation" is applied to the bezier anchor
     points (leaves, urn, ogee tip) so the motif breathes and warps.           */
  function drawSource(t, pal, swirl, amp) {
    const g = sctx, rs = S / 2;
    // background: deep radial well (near-black ground)
    const bgGrad = g.createRadialGradient(rs, rs, rs*0.02, rs, rs, rs);
    bgGrad.addColorStop(0, pal.bg);
    bgGrad.addColorStop(0.5, pal.bg);
    bgGrad.addColorStop(1, pal.deep);
    g.setTransform(1,0,0,1,0,0);
    g.fillStyle = bgGrad;
    g.fillRect(0,0,S,S);

    // faint warm floor — lifts the inter-motif voids from dead black to deep
    // bronze so the radial negative space reads as ornament ground, not sunburst
    // gaps. The rim vignette re-darkens the edge, so the ground stays near-black.
    const floor = g.createRadialGradient(rs,rs,rs*0.04, rs,rs, rs*0.9);
    floor.addColorStop(0,   rgbaOf(pal.gold, 0.12));
    floor.addColorStop(0.55,rgbaOf(pal.gold, 0.06));
    floor.addColorStop(1,   rgbaOf(pal.gold, 0.0));
    g.globalCompositeOperation = "lighter";
    g.fillStyle = floor; g.fillRect(0,0,S,S);
    g.globalCompositeOperation = "source-over";

    g.translate(rs, rs);

    // faint guilloché ground — concentric gilt rings threaded across the whole
    // field. Being rotationally symmetric they stay clean rings through the
    // kaleidoscope and lace the inter-arm voids with filigree, so the mandala
    // reads as gilt lattice rather than hard black pie-slices.
    g.globalCompositeOperation = "lighter";
    g.lineWidth = Math.max(rs*0.0035, 0.5);
    for (let ri=0; ri<10; ri++){
      const rad = rs*(0.13 + ri*0.088) * (1 + 0.02*Math.sin(t*0.6 + ri));
      g.strokeStyle = rgbaOf(pal.gold, 0.16 - ri*0.006);
      g.beginPath(); g.arc(0,0, rad, 0, Math.PI*2); g.stroke();
    }
    g.globalCompositeOperation = "source-over";

    // central bezier urn (foot of the damask), drawn first so foliage overlaps it
    drawUrn(g, rs, t, pal, amp);

    const ARMS = 6;
    // main radiating ogee arms
    for (let k=0; k<ARMS; k++) {
      g.save();
      g.rotate((k/ARMS)*Math.PI*2 + swirl*0.35);
      drawFlourish(g, rs, t, pal, amp, k, 1);
      g.restore();
    }
    // shorter filler arms offset by half a step — full ogee stems that occupy the
    // angular gaps at every radius, so the mandala reads as a field, not spokes.
    for (let k=0; k<ARMS; k++) {
      g.save();
      g.rotate(((k+0.5)/ARMS)*Math.PI*2 + swirl*0.35);
      drawFlourish(g, rs, t, pal, amp, k+11, 0.66);
      g.restore();
    }

    // filler ring — small acanthus buds set BETWEEN the arms to close the dark
    // gaps and soften the spoke read; alternating gold/carmine, breathing.
    const FILL = 12;
    for (let k=0; k<FILL; k++){
      const a  = (k/FILL)*Math.PI*2 + Math.PI/FILL + swirl*0.25;
      const rr = rs*(0.50 + 0.06*Math.sin(t*1.2 + k));
      const bud = rs*0.13*(0.85 + 0.2*Math.sin(t*1.5 + k));
      drawLeaf(g, Math.cos(a)*rr, Math.sin(a)*rr, a + Math.PI/2, bud, (k%2?1:-1), pal, amp, t, k*0.9, k%3===0);
    }
    // a tight inner petal collar ringing the hub — kills the near-center black
    // pie by wrapping the medallion in dense overlapping acanthus petals.
    const FILL2 = 12;
    for (let k=0; k<FILL2; k++){
      const a  = (k/FILL2)*Math.PI*2 + swirl*0.18;
      const rr = rs*(0.22 + 0.02*Math.sin(t*1.6 + k));
      drawLeaf(g, Math.cos(a)*rr, Math.sin(a)*rr, a + Math.PI/2, rs*0.13, (k%2?-1:1), pal, amp, t, k*1.3+2, k%2===0);
    }

    // central medallion bloom (gilt sheen at the very center) — additive
    g.globalCompositeOperation = "lighter";
    const mr = rs * (0.11 + 0.025*Math.sin(t*0.9));
    const med = g.createRadialGradient(0,0,0,0,0,mr);
    med.addColorStop(0, rgbaOf(pal.champ, 0.6));
    med.addColorStop(0.4, rgbaOf(pal.gold, 0.38));
    med.addColorStop(1, rgbaOf(pal.gold, 0));
    g.fillStyle = med;
    g.beginPath(); g.arc(0,0,mr,0,Math.PI*2); g.fill();

    g.globalCompositeOperation = "source-over";
    g.setTransform(1,0,0,1,0,0);
  }

  // A central damask urn/vase silhouette built from bezierCurveTo. Its shoulder
  // and lip anchors oscillate with time + pointer warp (the kaleidoscope osc).
  function drawUrn(g, rs, t, pal, amp) {
    const o = Math.sin(t*1.1) * (0.5 + amp);      // anchor oscillator
    const s = rs * 0.42;                          // urn scale — enlarged so the form reads
    const shoulder = 1 + 0.10*o;                  // belly bulge
    const lip = 1 + 0.14*o;                       // flared lip

    g.save();
    g.beginPath();
    // start at the foot, sweep the right flank up through the belly to the flared lip
    g.moveTo(0, s*0.10);
    g.bezierCurveTo( s*0.34,           s*0.06,
                     s*0.52*shoulder, -s*0.30,
                     s*0.30,          -s*0.58);
    g.bezierCurveTo( s*0.16,          -s*0.74,
                     s*0.30*lip,      -s*0.86,
                     s*0.20*lip,      -s*0.98);
    // mirror the left flank back down to the foot
    g.bezierCurveTo(-s*0.30*lip,      -s*0.86,
                    -s*0.16,          -s*0.74,
                    -s*0.30,          -s*0.58);
    g.bezierCurveTo(-s*0.52*shoulder, -s*0.30,
                    -s*0.34,           s*0.06,
                     0,                s*0.10);
    g.closePath();

    const ug = g.createLinearGradient(0, s*0.10, 0, -s*0.98);
    ug.addColorStop(0,   rgbaOf(pal.gold,  0.30));
    ug.addColorStop(0.5, rgbaOf(pal.gold,  0.60));
    ug.addColorStop(1,   rgbaOf(pal.champ, 0.55));
    g.fillStyle = ug; g.fill();
    // champagne rim-light for crisp definition
    g.strokeStyle = rgbaOf(pal.champ, 0.45);
    g.lineWidth = Math.max(rs*0.006, 1);
    g.stroke();
    g.restore();
  }

  // one ornamental "arm": a baseline ogee (S-curve) vine + counter-scroll, strung
  // with damask leaves, buds and gilt dots. Curvy even at rest so it always reads
  // as ornament, not spokes; morphs & opens up with time + pointer warp (amp).
  function drawFlourish(g, rs, t, pal, amp, seed, reach) {
    reach = reach || 1;                          // 1 = full main arm, <1 = shorter filler arm
    const ph = seed * 1.7;
    const sway = Math.sin(t*0.8 + ph) * (0.18 + amp*0.5);
    const breathe = 0.9 + 0.1*Math.sin(t*1.1 + ph);
    const rr = rs * reach;                        // radial reach of THIS arm

    // Baseline ogee: an intrinsic S-curl that sweeps out and hooks back — the
    // signature damask ogee — independent of sway so it's ornate with no input.
    const p0 = { x: 0, y: -rr*0.14 };
    const p1 = { x: rr*(0.34 + 0.06*sway),  y: -rr*0.24 };            // curl out low
    const p2 = { x: rr*(0.20 - 0.10*sway),  y: -rr*(0.58*breathe) };  // sweep up & back in
    const p3 = { x: rr*(0.52 + 0.08*Math.sin(t*0.7+ph)),
                 y: -rr*(0.92*breathe) };                             // hook to the tip

    // gilt stem: soft outer glow pass, then a bright core pass
    g.lineCap = "round"; g.lineJoin = "round";
    for (let pass=0; pass<2; pass++) {
      g.beginPath();
      g.moveTo(p0.x,p0.y);
      g.bezierCurveTo(p1.x,p1.y,p2.x,p2.y,p3.x,p3.y);
      if (pass===0){ g.strokeStyle = rgbaOf(pal.gold,0.16); g.lineWidth = rs*0.058*(0.6+0.4*reach); }
      else         { g.strokeStyle = rgbaOf(pal.gold,0.72); g.lineWidth = rs*0.022*(0.6+0.4*reach); }
      g.stroke();
    }

    // counter-scroll: a smaller curl branching the other way for density
    const c0 = bez(p0,p1,p2,p3,0.42);
    const c1 = { x: c0.x - rr*(0.22+0.05*sway), y: c0.y - rr*0.06 };
    const c2 = { x: c0.x - rr*0.10,             y: c0.y - rr*0.30*breathe };
    const c3 = { x: c0.x + rr*0.06,             y: c0.y - rr*0.40*breathe };
    g.beginPath();
    g.moveTo(c0.x,c0.y);
    g.bezierCurveTo(c1.x,c1.y,c2.x,c2.y,c3.x,c3.y);
    g.strokeStyle = rgbaOf(pal.gold,0.45); g.lineWidth = rs*0.013*reach; g.stroke();

    // leaves / buds strung along the main stem (alternating gold + carmine bloom)
    const LEAVES = 5;
    for (let i=1;i<=LEAVES;i++){
      const u = i/(LEAVES+0.5);
      const b = bez(p0,p1,p2,p3,u);
      const tang = bezTan(p0,p1,p2,p3,u);
      const ang = Math.atan2(tang.y, tang.x);
      const side = (i%2===0)?1:-1;
      const scale = rr*(0.22*(1-u*0.4)) * (0.9+0.25*Math.sin(t*1.3+ph+i));
      const bloom = (i % 2 === 0);                       // even = carmine secondary bloom
      drawLeaf(g, b.x, b.y, ang + side*(1.2+0.2*Math.sin(t+i)), scale, side, pal, amp, t, i+ph, bloom);
    }
    // a leaf on the counter-scroll tip
    drawLeaf(g, c3.x, c3.y, Math.atan2(c3.y-c2.y, c3.x-c2.x)-0.6, rr*0.15*breathe, -1, pal, amp, t, ph, false);
    // carmine blossom at the ogee tip
    drawLeaf(g, p3.x, p3.y, Math.atan2(p3.y,p3.x), rr*0.16*(0.9+0.2*Math.sin(t*1.6+ph)), 1, pal, amp, t, ph, true);

    // gilt punctuation dots along the stem
    for (let i=0;i<4;i++){
      const u = 0.20 + i*0.2;
      const b = bez(p0,p1,p2,p3,u);
      const dr = rs*0.013*(1.4+Math.sin(t*2+i+ph));
      g.beginPath();
      g.fillStyle = rgbaOf(pal.champ, 0.55);
      g.arc(b.x,b.y,Math.max(dr,0.5),0,Math.PI*2); g.fill();
    }
  }

  // A filled damask leaf/petal built from bezierCurveTo, with a lobed acanthus
  // profile. Its flank control anchors OSCILLATE (osc) so the petal breathes as
  // the kaleidoscope warps. `bloom` -> carmine secondary petal, else gilt gold.
  function drawLeaf(g, x, y, ang, size, side, pal, amp, t, phase, bloom) {
    const osc = Math.sin(t*1.6 + phase) * (0.18 + amp*0.35);   // anchor oscillation
    const w = size*(bloom?0.72:0.60) * side;                   // half-width (mirrored by side)
    const l = size*(bloom?1.15:1.65);                          // length outward (−y)
    const belly = 1 + 0.28*osc;                                // widest-point bulge
    const curl  = w*0.22*osc;                                  // tip sway

    g.save();
    g.translate(x,y); g.rotate(ang);
    g.beginPath();
    g.moveTo(0,0);
    // right flank: base -> lobe -> pointed tip (acanthus ogee)
    g.bezierCurveTo( w*0.55,          -l*0.14,
                     w*1.02*belly,    -l*0.46,
                     curl,            -l);
    // left flank: tip -> lobe -> base (mirror)
    g.bezierCurveTo(-w*1.02*belly,    -l*0.46,
                    -w*0.55,          -l*0.14,
                     0,                0);
    g.closePath();

    const body = bloom ? pal.sec : pal.gold;
    const lg = g.createLinearGradient(0,0,0,-l);
    lg.addColorStop(0,   rgbaOf(body,      bloom ? 0.55 : 0.42));
    lg.addColorStop(0.55,rgbaOf(body,      bloom ? 0.40 : 0.30));
    lg.addColorStop(1,   rgbaOf(pal.champ, 0.10));
    g.fillStyle = lg; g.fill();

    // champagne crisp outline for petal-vs-ground definition (the "pop")
    g.strokeStyle = rgbaOf(pal.champ, bloom ? 0.28 : 0.40);
    g.lineWidth = Math.max(size*0.02, 0.5);
    g.stroke();

    // gilt central rib
    g.beginPath(); g.moveTo(0,0); g.lineTo(curl*0.5,-l*0.9);
    g.strokeStyle = rgbaOf(bloom ? pal.champ : pal.gold, 0.5);
    g.lineWidth = Math.max(size*0.028, 0.4); g.stroke();
    g.restore();
  }

  // cubic bezier point + tangent
  function bez(p0,p1,p2,p3,u){ const m=1-u;
    return { x: m*m*m*p0.x+3*m*m*u*p1.x+3*m*u*u*p2.x+u*u*u*p3.x,
             y: m*m*m*p0.y+3*m*m*u*p1.y+3*m*u*u*p2.y+u*u*u*p3.y }; }
  function bezTan(p0,p1,p2,p3,u){ const m=1-u;
    return { x: 3*m*m*(p1.x-p0.x)+6*m*u*(p2.x-p1.x)+3*u*u*(p3.x-p2.x),
             y: 3*m*m*(p1.y-p0.y)+6*m*u*(p2.y-p1.y)+3*u*u*(p3.y-p2.y) }; }

  /* ---------- the kaleidoscope compositor ---------- */
  function compose(rot, ox, oy) {
    ctx.setTransform(dpr,0,0,dpr,0,0);
    ctx.fillStyle = curPal.deep;
    ctx.fillRect(0,0,W,H);

    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(rot);

    // Sample the source OFF-CENTER (ox,oy) and slowly drifting — this is what
    // turns radial "spokes" into interlocking concentric mandala rings, i.e. a
    // real kaleidoscope read instead of a starburst. Draw the source oversized
    // (R*2.4) so the shifted sample still covers the full wedge radius.
    for (let i=0;i<SIDES;i++){
      ctx.save();
      ctx.rotate(i*SLICE);
      if (i % 2 === 1) ctx.scale(1,-1);        // mirror alternate wedges -> seamless 8-fold
      // clip a wedge spanning [0, SLICE]
      ctx.beginPath();
      ctx.moveTo(0,0);
      ctx.arc(0,0,R, -0.001, SLICE+0.001);
      ctx.closePath();
      ctx.clip();
      ctx.drawImage(src, -R*1.2 + ox, -R*1.2 + oy, R*2.4, R*2.4);
      ctx.restore();
    }
    ctx.restore();

    // center gold sheen (hub)
    const hub = ctx.createRadialGradient(cx,cy,0,cx,cy,R*0.10);
    hub.addColorStop(0, rgbaOf(curPal.champ,0.42));
    hub.addColorStop(0.45, rgbaOf(curPal.gold,0.14));
    hub.addColorStop(1, rgbaOf(curPal.gold,0));
    ctx.globalCompositeOperation = "lighter";
    ctx.fillStyle = hub;
    ctx.beginPath(); ctx.arc(cx,cy,R*0.11,0,Math.PI*2); ctx.fill();
    ctx.globalCompositeOperation = "source-over";

    // vignette overlay for depth
    const vg = ctx.createRadialGradient(cx,cy,R*0.34,cx,cy,R*0.98);
    vg.addColorStop(0,"rgba(0,0,0,0)");
    vg.addColorStop(1,"rgba(0,0,0,0.50)");
    ctx.fillStyle = vg;
    ctx.fillRect(0,0,W,H);
  }

  /* ---------- animation loop (time-based, 60fps) ---------- */
  let t = 0, last = performance.now(), spin = 0;
  function frame(now) {
    let dt = (now - last) / 1000;
    last = now;
    if (dt > 0.05) dt = 0.05;                  // clamp huge first/hidden-tab deltas
    const rate = reduceMotion ? 0.25 : 1;
    t += dt * rate;

    // smooth pointer + warp
    wx += (mx - wx) * Math.min(dt*6,1);
    wy += (my - wy) * Math.min(dt*6,1);
    warp += (warpTarget - warp) * Math.min(dt*4,1);

    // palette crossfade (smoothstep)
    if (palT < 1) { palT = Math.min(palT + dt/0.8, 1); }
    curPal = mixPalette(fromPal, toPal, palT<1 ? palT*palT*(3-2*palT) : 1);

    // master rotation: gentle idle drift + pointer-driven push
    spin += dt * rate * (0.05 + wx * 0.55);
    const rot = spin + wy * 0.4;

    // the source swirls & the motif amplitude opens up with pointer distance
    const swirl = t*0.15 + wx*1.6;
    const amp = warp * 0.9;

    // off-center kaleidoscope sample point: a slow orbital drift, nudged by the
    // pointer, kept within ±0.2R so the oversized source still covers the wedge.
    // NB: a large sample offset breaks the per-wedge mirror symmetry (odd wedges
    // are scale(1,-1)'d but the offset is not), which produces alternating black
    // pie-slices. Keep it a whisper — just enough to animate, not enough to split
    // the mirror. Density comes from the filler arms + warm floor, not the offset.
    const offR = 0.02 * R;
    const offA = t*0.05;
    const ox = Math.cos(offA)*offR;
    const oy = Math.sin(offA)*offR;

    drawSource(t, curPal, swirl, amp);
    compose(rot, ox, oy);

    requestAnimationFrame(frame);
  }

  applyPillChrome(PALETTES[0]);
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>