/* =====================================================================
   Shaders v2 — canvas-based animated backgrounds for project cards
   + DotTrace utility component
   ===================================================================== */
const { useEffect: useEfSh, useRef: useRefSh } = React;

/* Read a CSS-variable RGB triplet ("R G B") from <html>, with fallback. */
function readShaderColors() {
  const root = document.documentElement;
  const cs = getComputedStyle(root);
  const fg = (cs.getPropertyValue('--shader-rgb').trim() || '20 184 166').split(/\s+/).map(Number);
  const bg = (cs.getPropertyValue('--shader-bg-rgb').trim() || '10 10 11').split(/\s+/).map(Number);
  return { fg, bg };
}

function ShaderCanvas({ draw }) {
  const canvasRef = useRefSh(null);
  const rafRef = useRefSh(null);
  const visibleRef = useRefSh(false);
  const colorsRef = useRefSh({ fg:[20,184,166], bg:[10,10,11] });
  const targetRef = useRefSh({ fg:[20,184,166], bg:[10,10,11] });
  useEfSh(() => {
    const canvas = canvasRef.current; if (!canvas) return;
    const ctx = canvas.getContext('2d');
    let frame = 0;
    let lastTs = 0;
    const FRAME_MS = 1000/30; // cap at 30fps to ease main thread

    // Read TOD colors from CSS vars + watch <html data-tod=...> for changes
    const refreshTarget = () => { targetRef.current = readShaderColors(); };
    refreshTarget();
    colorsRef.current = { fg:[...targetRef.current.fg], bg:[...targetRef.current.bg] };
    const obs = new MutationObserver(refreshTarget);
    obs.observe(document.documentElement, { attributes:true, attributeFilter:['data-tod'] });

    const tick = (ts) => {
      rafRef.current = requestAnimationFrame(tick);
      if (!visibleRef.current) return;
      if (ts - lastTs < FRAME_MS) return;
      lastTs = ts;
      // Lerp current colors toward target (matches CSS theme transition feel)
      const cur = colorsRef.current, tgt = targetRef.current, k = 0.06;
      for (let i=0;i<3;i++){ cur.fg[i]+=(tgt.fg[i]-cur.fg[i])*k; cur.bg[i]+=(tgt.bg[i]-cur.bg[i])*k; }
      const w = canvas.offsetWidth, h = canvas.offsetHeight;
      if (w === 0 || h === 0) return;
      if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
      draw(ctx, w, h, frame++, cur);
    };

    const io = new IntersectionObserver(entries => {
      visibleRef.current = entries[0].isIntersecting;
    }, { threshold: 0.01 });
    io.observe(canvas);

    rafRef.current = requestAnimationFrame(tick);
    return () => { cancelAnimationFrame(rafRef.current); io.disconnect(); obs.disconnect(); };
  }, []);
  return <canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }}/>;
}

// Helpers — write rgba() strings using the lerped theme colors.
const fgRGBA = (c, a) => `rgba(${c.fg[0]|0},${c.fg[1]|0},${c.fg[2]|0},${a})`;
const bgRGB  = (c)    => `rgb(${c.bg[0]|0},${c.bg[1]|0},${c.bg[2]|0})`;
const bgRGBA = (c, a) => `rgba(${c.bg[0]|0},${c.bg[1]|0},${c.bg[2]|0},${a})`;

function GravityGrid({ density = 22 }) {
  return <ShaderCanvas draw={(ctx, w, h, f, c) => {
    ctx.fillStyle = bgRGB(c); ctx.fillRect(0, 0, w, h);
    const t = f * 0.016;
    const cols = Math.ceil(w / density) + 1, rows = Math.ceil(h / density) + 1;
    for (let i = 0; i < cols; i++) for (let j = 0; j < rows; j++) {
      const x = i * density, y = j * density;
      const d = Math.hypot(x - w/2, y - h/2);
      const v = Math.sin(d * 0.028 - t) * 0.5 + 0.5;
      ctx.beginPath();
      ctx.arc(x, y, Math.max(0.4, v * 2.2), 0, Math.PI * 2);
      ctx.fillStyle = fgRGBA(c, 0.12 + v * 0.32);
      ctx.fill();
    }
  }}/>;
}

function Voronoi() {
  return <ShaderCanvas draw={(ctx, w, h, f, c) => {
    ctx.fillStyle = bgRGB(c); ctx.fillRect(0, 0, w, h);
    const t = f * 0.005;
    const pts = Array.from({length: 7}, (_, i) => ({
      x: w/2 + Math.cos(t + i * Math.PI*2/7) * w*0.32,
      y: h/2 + Math.sin(t*0.8 + i * Math.PI*2/7) * h*0.32,
    }));
    const step = 4;
    for (let x = 0; x < w; x += step) for (let y = 0; y < h; y += step) {
      let d1 = Infinity, d2 = Infinity;
      for (const p of pts) { const d = Math.hypot(x-p.x, y-p.y); if (d<d1){d2=d1;d1=d;}else if(d<d2)d2=d; }
      const a = Math.max(0, Math.min(1, (d2-d1)/18)) * 0.45;
      ctx.fillStyle = fgRGBA(c, a); ctx.fillRect(x, y, step, step);
    }
  }}/>;
}

function DisplacementField() {
  return <ShaderCanvas draw={(ctx, w, h, f, c) => {
    ctx.fillStyle = bgRGB(c); ctx.fillRect(0, 0, w, h);
    const t = f * 0.011; const N = 14;
    for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) {
      const x = (i/N)*w, y = (j/N)*h;
      const angle = Math.atan2(j-N/2, i-N/2) + t*0.25 + Math.sin(x*0.018+t)*0.8;
      const len = 6 + Math.sin(x*0.02+t)*5 + Math.cos(y*0.02+t*0.7)*5;
      const a = 0.08 + Math.abs(Math.sin(angle+t))*0.22;
      ctx.beginPath(); ctx.moveTo(x,y);
      ctx.lineTo(x+Math.cos(angle)*len, y+Math.sin(angle)*len);
      ctx.strokeStyle = fgRGBA(c, a); ctx.lineWidth=1; ctx.stroke();
    }
  }}/>;
}

function LiquidMetal() {
  return <ShaderCanvas draw={(ctx, w, h, f, c) => {
    ctx.fillStyle = bgRGB(c); ctx.fillRect(0, 0, w, h);
    const t = f * 0.007;
    for (let l = 0; l < 10; l++) {
      const base = h*(l/10);
      ctx.beginPath(); ctx.moveTo(0, base);
      for (let x = 0; x <= w; x += 3) {
        const y = base + Math.sin(x*0.016+t+l*0.6)*10 + Math.sin(x*0.031+t*1.3+l)*5;
        ctx.lineTo(x, y);
      }
      ctx.strokeStyle = fgRGBA(c, 0.03 + (l/10)*0.09); ctx.lineWidth = 1.5; ctx.stroke();
    }
  }}/>;
}

function ParticleSwarm() {
  const ptsRef = useRefSh(null);
  if (!ptsRef.current) ptsRef.current = Array.from({length:38}, () => ({
    x:Math.random(), y:Math.random(), vx:(Math.random()-.5)*.003, vy:(Math.random()-.5)*.003
  }));
  return <ShaderCanvas draw={(ctx, w, h, _f, c) => {
    ctx.fillStyle = bgRGBA(c, 0.18); ctx.fillRect(0,0,w,h);
    const pts = ptsRef.current;
    pts.forEach(p => {
      p.x+=p.vx; p.y+=p.vy;
      if(p.x<0||p.x>1)p.vx*=-1; if(p.y<0||p.y>1)p.vy*=-1;
      ctx.beginPath(); ctx.arc(p.x*w,p.y*h,1.8,0,Math.PI*2);
      ctx.fillStyle = fgRGBA(c, 0.55); ctx.fill();
    });
    for(let i=0;i<pts.length;i++) for(let j=i+1;j<pts.length;j++){
      const dx=(pts[i].x-pts[j].x)*w, dy=(pts[i].y-pts[j].y)*h, d=Math.hypot(dx,dy);
      if(d<55){ctx.beginPath();ctx.moveTo(pts[i].x*w,pts[i].y*h);ctx.lineTo(pts[j].x*w,pts[j].y*h);
        ctx.strokeStyle = fgRGBA(c, (1-d/55)*0.18); ctx.lineWidth=0.5; ctx.stroke();}
    }
  }}/>;
}

function AsciiField() {
  const chars = ['0','1','·','─','│','▲','◆','░','▒'];
  return <ShaderCanvas draw={(ctx, w, h, f, c) => {
    ctx.fillStyle = bgRGB(c); ctx.fillRect(0,0,w,h);
    const fs=11, t=f*0.035;
    ctx.font=`${fs}px 'JetBrains Mono',monospace`;
    const cols=Math.floor(w/fs)+1, rows=Math.floor(h/fs)+1;
    for(let i=0;i<cols;i++) for(let j=0;j<rows;j++){
      const v=Math.sin(i*0.28+t)*Math.cos(j*0.28+t*0.73);
      const a=Math.max(0,v)*0.32;
      if(a>0.015){
        const ci=Math.floor(Math.abs(v)*chars.length)%chars.length;
        ctx.fillStyle = fgRGBA(c, a);
        ctx.fillText(chars[ci], i*fs, j*fs+fs);
      }
    }
  }}/>;
}

/* =====================================================================
   SkyScene — TOD-aware hero background.
   The "dots" arrange themselves into a scene that matches time of day:
     night   → drifting starfield + slow constellation lines + a low moon
     sunrise → low rising sun (right) + warm dust motes + high cirrus dots
     day     → high sun + radiating dot rays + soft particle haze
     sunset  → low setting sun (left) + ember dots + magenta horizon band
   Reads <html data-tod=...> directly so it transitions independently of
   the lerped color (which still drives accent tone).
   ===================================================================== */
function SkyScene() {
  const todRef = useRefSh('night');
  const todPrevRef = useRefSh('night');
  const todMixRef = useRefSh(1); // 0..1 transition between todPrev → tod
  const starsRef = useRefSh(null);
  const motesRef = useRefSh(null);

  // Persistent star field + dust mote positions (don't rebuild each frame)
  if (!starsRef.current) {
    starsRef.current = Array.from({length: 140}, () => ({
      x: Math.random(),
      y: Math.random()*0.85,                  // mostly upper sky
      r: Math.random()*1.4 + 0.3,
      tw: Math.random()*Math.PI*2,            // twinkle phase
      twS: 0.02 + Math.random()*0.04,         // twinkle speed
      mag: 0.4 + Math.random()*0.6,           // brightness
    }));
  }
  if (!motesRef.current) {
    motesRef.current = Array.from({length: 70}, () => ({
      x: Math.random(),
      y: Math.random(),
      vx: (Math.random()*0.6 + 0.2) * 0.0005, // slow drift right
      vy: (Math.random() - 0.5) * 0.0002,
      r: Math.random()*1.6 + 0.4,
      ph: Math.random()*Math.PI*2,
    }));
  }

  useEfSh(() => {
    const root = document.documentElement;
    const sync = () => {
      const next = root.getAttribute('data-tod') || 'night';
      if (next !== todRef.current) {
        todPrevRef.current = todRef.current;
        todRef.current = next;
        todMixRef.current = 0;
      }
    };
    sync();
    const obs = new MutationObserver(sync);
    obs.observe(root, { attributes:true, attributeFilter:['data-tod'] });
    return () => obs.disconnect();
  }, []);

  return <ShaderCanvas draw={(ctx, w, h, f, c) => {
    // Advance TOD blend
    if (todMixRef.current < 1) todMixRef.current = Math.min(1, todMixRef.current + 0.012);
    const mix = todMixRef.current;

    // Vertical sky gradient — bg color at bottom, slight lift toward top
    const skyTop = `rgba(${(c.bg[0]*0.6)|0},${(c.bg[1]*0.6)|0},${(c.bg[2]*0.7)|0},1)`;
    const skyBot = bgRGB(c);
    const grad = ctx.createLinearGradient(0,0,0,h);
    grad.addColorStop(0, skyTop);
    grad.addColorStop(1, skyBot);
    ctx.fillStyle = grad; ctx.fillRect(0,0,w,h);

    // Render the two TOD scenes and crossfade between them
    drawScene(ctx, w, h, f, c, todPrevRef.current, starsRef.current, motesRef.current, 1 - mix);
    drawScene(ctx, w, h, f, c, todRef.current,    starsRef.current, motesRef.current, mix);
  }}/>;
}

function drawScene(ctx, w, h, f, c, tod, stars, motes, alpha) {
  if (alpha <= 0) return;
  ctx.save();
  ctx.globalAlpha = alpha;
  if (tod === 'night')   drawNight(ctx, w, h, f, c, stars);
  else if (tod === 'sunrise') drawSunrise(ctx, w, h, f, c, motes);
  else if (tod === 'day')     drawDay(ctx, w, h, f, c);
  else if (tod === 'sunset')  drawSunset(ctx, w, h, f, c, motes);
  ctx.restore();
}

/* ── NIGHT ─────────────────────────────────────────────────────
   Drifting starfield, twinkling dots, soft constellation lines,
   low crescent moon glow on the right.
*/
function drawNight(ctx, w, h, f, c, stars) {
  const t = f * 0.016;

  // Moon — soft luminous disc. Position is configurable via data-moon-pos
  // on <html> (top-right is original; alts let us avoid status-bar overlap).
  const moonPos = document.documentElement.getAttribute('data-moon-pos') || 'top-right';
  const moonCoords = {
    'top-right':   [0.78, 0.32],
    'top-center':  [0.50, 0.24],
    'top-left':    [0.22, 0.30],
    'mid-left':    [0.18, 0.46],
    'mid-right':   [0.82, 0.50],
    'low-right':   [0.78, 0.62],
    'low-left':    [0.22, 0.66],
  }[moonPos] || [0.78, 0.32];
  const mx = w * moonCoords[0], my = h * moonCoords[1], mr = Math.min(w,h) * 0.07;
  const moonGlow = ctx.createRadialGradient(mx, my, 0, mx, my, mr*4);
  moonGlow.addColorStop(0, fgRGBA(c, 0.32));
  moonGlow.addColorStop(0.4, fgRGBA(c, 0.08));
  moonGlow.addColorStop(1, fgRGBA(c, 0));
  ctx.fillStyle = moonGlow;
  ctx.beginPath(); ctx.arc(mx, my, mr*4, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = fgRGBA(c, 0.85);
  ctx.beginPath(); ctx.arc(mx, my, mr, 0, Math.PI*2); ctx.fill();
  // Crescent shadow — clip with a slightly offset bg disc
  ctx.fillStyle = bgRGBA(c, 0.95);
  ctx.beginPath(); ctx.arc(mx + mr*0.42, my - mr*0.05, mr*0.92, 0, Math.PI*2); ctx.fill();

  // Stars — twinkling dots, sized by mag
  for (const s of stars) {
    const tw = 0.55 + 0.45 * Math.sin(s.tw + t * s.twS * 60);
    const a = s.mag * tw;
    const r = s.r * (0.85 + tw * 0.35);
    const x = s.x * w, y = s.y * h;
    // Soft halo
    ctx.fillStyle = fgRGBA(c, a * 0.18);
    ctx.beginPath(); ctx.arc(x, y, r * 3.2, 0, Math.PI*2); ctx.fill();
    // Core
    ctx.fillStyle = fgRGBA(c, a);
    ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); ctx.fill();
  }

  // Constellation — connect 6 brightest stars in a slow drift
  const bright = stars.slice(0, 6);
  ctx.strokeStyle = fgRGBA(c, 0.10);
  ctx.lineWidth = 0.6;
  ctx.beginPath();
  for (let i=0; i<bright.length; i++) {
    const s = bright[i];
    const x = s.x*w, y = s.y*h;
    if (i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
  }
  ctx.stroke();
}

/* ── SUNRISE ───────────────────────────────────────────────────
   Sun rising low-right, warm horizon band, dust motes drifting up.
*/
function drawSunrise(ctx, w, h, f, c, motes) {
  const t = f * 0.012;

  // Horizon glow band low on screen
  const horizon = h * 0.78;
  const bandGrad = ctx.createLinearGradient(0, horizon - h*0.18, 0, horizon + h*0.05);
  bandGrad.addColorStop(0, fgRGBA(c, 0));
  bandGrad.addColorStop(0.6, fgRGBA(c, 0.12));
  bandGrad.addColorStop(1, fgRGBA(c, 0.22));
  ctx.fillStyle = bandGrad;
  ctx.fillRect(0, horizon - h*0.18, w, h*0.23);

  // Rising sun — disc + halo, low-right (just clearing horizon)
  const sx = w * 0.78, sy = horizon - h*0.04, sr = Math.min(w,h) * 0.08;
  const halo = ctx.createRadialGradient(sx, sy, 0, sx, sy, sr*5);
  halo.addColorStop(0, fgRGBA(c, 0.38));
  halo.addColorStop(0.35, fgRGBA(c, 0.14));
  halo.addColorStop(1, fgRGBA(c, 0));
  ctx.fillStyle = halo;
  ctx.beginPath(); ctx.arc(sx, sy, sr*5, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = fgRGBA(c, 0.95);
  ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI*2); ctx.fill();

  // Dust motes — slow rising particles, bigger near horizon
  for (const m of motes) {
    m.x += m.vx; m.y -= Math.abs(m.vy) * 1.6;
    if (m.x > 1.05) m.x = -0.05;
    if (m.y < -0.05) m.y = 1.05;
    const x = m.x*w, y = m.y*h;
    const lift = Math.max(0, (y / h) - 0.1); // brighter near horizon
    const a = (0.18 + 0.42 * lift) * (0.5 + 0.5 * Math.sin(m.ph + t));
    ctx.fillStyle = fgRGBA(c, a);
    ctx.beginPath(); ctx.arc(x, y, m.r, 0, Math.PI*2); ctx.fill();
  }

  // High cirrus dots — small bright pinpoints up top, slow shimmer
  const N = 30;
  for (let i=0;i<N;i++){
    const x = (i/N)*w + Math.sin(t+i)*8;
    const y = h*0.18 + Math.sin(i*1.7+t*0.5) * h*0.06;
    const a = 0.18 + 0.18 * Math.sin(t*1.2 + i);
    ctx.fillStyle = fgRGBA(c, a);
    ctx.beginPath(); ctx.arc(x,y,0.9,0,Math.PI*2); ctx.fill();
  }
}

/* ── DAY ───────────────────────────────────────────────────────
   High sun (off to the upper-right so it doesn't fight headline copy),
   radiating dot rays, soft particle haze. Halo intentionally subdued.
*/
function drawDay(ctx, w, h, f, c) {
  const t = f * 0.010;
  // Pushed up + further right so it sits above/outside the headline area
  const sx = w * 0.82, sy = h * 0.18, sr = Math.min(w,h) * 0.075;

  // Sun halo — softer + smaller spread to avoid washing the hero text
  const halo = ctx.createRadialGradient(sx, sy, 0, sx, sy, sr*4.5);
  halo.addColorStop(0, fgRGBA(c, 0.22));
  halo.addColorStop(0.35, fgRGBA(c, 0.07));
  halo.addColorStop(1, fgRGBA(c, 0));
  ctx.fillStyle = halo;
  ctx.beginPath(); ctx.arc(sx, sy, sr*4.5, 0, Math.PI*2); ctx.fill();

  // Sun disc
  ctx.fillStyle = fgRGBA(c, 0.88);
  ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI*2); ctx.fill();

  // Radiating dot rays — concentric rings of dots emanating from sun.
  // Reduced opacity overall + skip rings that overlap headline area on the left.
  const rings = 9;
  for (let r=1; r<=rings; r++){
    const dist = sr*1.6 + r*sr*0.55;
    const dotsOnRing = Math.floor(8 + r*3);
    for (let i=0;i<dotsOnRing;i++){
      const ang = (i/dotsOnRing) * Math.PI*2 + t*0.15 + r*0.08;
      const x = sx + Math.cos(ang)*dist;
      const y = sy + Math.sin(ang)*dist;
      if (x<-10||x>w+10||y<-10||y>h+10) continue;
      // Fade dots that drift left into the headline column (left 55% of canvas)
      const leftFade = x < w*0.55 ? Math.max(0, (x - w*0.30) / (w*0.25)) : 1;
      const pulse = 0.5 + 0.5 * Math.sin(t*2 + r*0.7 + i*0.3);
      const a = (0.30 - r*0.025) * pulse * leftFade;
      const sz = Math.max(0.4, 1.4 - r*0.09) * (0.7 + pulse*0.55);
      if (a <= 0) continue;
      ctx.fillStyle = fgRGBA(c, Math.max(0, a));
      ctx.beginPath(); ctx.arc(x, y, sz, 0, Math.PI*2); ctx.fill();
    }
  }

  // Faint particle haze — random sparkles in lower 2/3, kept away from text column
  const N = 50;
  for (let i=0;i<N;i++){
    const x = ((i*73.71 + t*30) % w + w) % w;
    const y = h*0.55 + ((i*131.3) % (h*0.45));
    // Avoid the hero text column on the left
    if (x < w*0.45 && y < h*0.85) continue;
    const a = 0.08 + 0.08 * Math.sin(t*1.5 + i*0.4);
    ctx.fillStyle = fgRGBA(c, a);
    ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI*2); ctx.fill();
  }
}

/* ── SUNSET ────────────────────────────────────────────────────
   Sun setting low-left, ember dots rising, magenta horizon band.
*/
function drawSunset(ctx, w, h, f, c, motes) {
  const t = f * 0.011;

  // Horizon band — wider, more saturated than sunrise
  const horizon = h * 0.74;
  const bandGrad = ctx.createLinearGradient(0, horizon - h*0.22, 0, horizon + h*0.10);
  bandGrad.addColorStop(0, fgRGBA(c, 0));
  bandGrad.addColorStop(0.55, fgRGBA(c, 0.16));
  bandGrad.addColorStop(1, fgRGBA(c, 0.28));
  ctx.fillStyle = bandGrad;
  ctx.fillRect(0, horizon - h*0.22, w, h*0.32);

  // Setting sun — bigger, lower than sunrise, on the left
  const sx = w * 0.20, sy = horizon - h*0.02, sr = Math.min(w,h) * 0.10;
  const halo = ctx.createRadialGradient(sx, sy, 0, sx, sy, sr*6);
  halo.addColorStop(0, fgRGBA(c, 0.45));
  halo.addColorStop(0.3, fgRGBA(c, 0.16));
  halo.addColorStop(1, fgRGBA(c, 0));
  ctx.fillStyle = halo;
  ctx.beginPath(); ctx.arc(sx, sy, sr*6, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = fgRGBA(c, 0.95);
  ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI*2); ctx.fill();

  // Embers — warm dots rising from the sun, flickering
  for (const m of motes) {
    m.x += m.vx*0.6; m.y -= Math.abs(m.vy)*2.0;
    if (m.x > 1.05) m.x = -0.05;
    if (m.y < -0.05) { m.y = 1.05; m.x = 0.05 + Math.random()*0.5; }
    const x = m.x*w, y = m.y*h;
    // Embers brighter when they're closer to the sun horizontally
    const dx = (x - sx) / w;
    const proximity = Math.max(0, 1 - Math.abs(dx)*1.6);
    const flicker = 0.5 + 0.5 * Math.sin(m.ph + t*2.5);
    const a = (0.18 + 0.55 * proximity) * flicker;
    ctx.fillStyle = fgRGBA(c, a);
    ctx.beginPath(); ctx.arc(x, y, m.r * (0.8 + proximity*0.6), 0, Math.PI*2); ctx.fill();
  }

  // First-evening stars — sparse, only in upper-right where sky is darkest
  const N = 24;
  for (let i=0;i<N;i++){
    const seed = i*7919;
    const x = ((seed*0.6) % 1) * w;
    const y = ((seed*0.31) % 1) * h * 0.45;
    if (x < w*0.45) continue; // only on the right side away from sun
    const tw = 0.4 + 0.6 * Math.sin(t*1.3 + i);
    const a = 0.22 * tw;
    ctx.fillStyle = fgRGBA(c, a);
    ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI*2); ctx.fill();
  }
}

function DotTrace({ color = 'var(--fg-3)', size = 4 }) {
  const [s, setS] = React.useState(0);
  useEfSh(() => { const t=setInterval(()=>setS(x=>(x+1)%3),320); return ()=>clearInterval(t); }, []);
  return (
    <span style={{display:'inline-flex',gap:3,alignItems:'center'}}>
      {[0,1,2].map(i=>(
        <span key={i} style={{width:size,height:size,borderRadius:'50%',background:color,opacity:i===s?1:0.2,transition:'opacity .25s'}}/>
      ))}
    </span>
  );
}

Object.assign(window, { GravityGrid, Voronoi, DisplacementField, LiquidMetal, ParticleSwarm, AsciiField, DotTrace, ShaderCanvas, SkyScene });
