← 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>