/* ================================================================
   Substrate Capital — Graphics stack
   High-polish, original generative visuals built on Canvas 2D
   ================================================================ */

// ---------- shared utilities ----------
function setupCanvas(canvas, maxDpr = 2) {
  const ctx = canvas.getContext("2d");
  const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
  let W = 0, H = 0;
  const resize = () => {
    const rect = canvas.getBoundingClientRect();
    W = rect.width; H = rect.height;
    canvas.width = Math.max(1, W * dpr);
    canvas.height = Math.max(1, H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  };
  resize();
  // ResizeObserver — keeps the canvas in sync with its container size
  // (its parent may resize after fonts load or layout settles).
  let ro;
  try {
    ro = new ResizeObserver(() => resize());
    ro.observe(canvas);
  } catch (_) { /* old browsers */ }
  // also re-measure on next frame to catch post-mount layout
  requestAnimationFrame(() => resize());
  setTimeout(() => resize(), 200);
  const stop = () => { try { ro && ro.disconnect(); } catch (_) {} };
  return { ctx, dpr, get W() { return W; }, get H() { return H; }, resize, stop };
}

// rotate vec3 by yaw (Y) then pitch (X)
function rotate3(p, rotX, rotY) {
  const cy = Math.cos(rotY), sy = Math.sin(rotY);
  const x1 = p.x * cy + p.z * sy;
  const z1 = -p.x * sy + p.z * cy;
  const cx = Math.cos(rotX), sx = Math.sin(rotX);
  const y1 = p.y * cx - z1 * sx;
  const z2 = p.y * sx + z1 * cx;
  return { x: x1, y: y1, z: z2 };
}

const DEFAULT_SPLINE_SPHERE = {
  width: 427,
  height: 427,
  depth: 427,
  widthSegments: 128,
  heightSegments: 128,
  phiStart: 0,
  phiLength: Math.PI * 2,
  thetaStart: 0,
  thetaLength: Math.PI,
  type: "SphereGeometry",
};

function parseSplineSphereGeometry(buffer) {
  const bytes = new Uint8Array(buffer);
  const view = new DataView(buffer);
  const text = new TextDecoder();
  let offset = 0;
  while (
    offset < Math.min(8, bytes.length) &&
    !((bytes[offset] >= 0x80 && bytes[offset] <= 0x8f) || (bytes[offset] >= 0x90 && bytes[offset] <= 0x9f))
  ) {
    offset += 1;
  }
  if (offset >= bytes.length) return { ...DEFAULT_SPLINE_SPHERE };

  const readScalar = () => {
    const tag = bytes[offset++];
    if (tag <= 0x7f) return tag;
    if (tag >= 0xa0 && tag <= 0xbf) {
      const len = tag & 0x1f;
      const out = text.decode(bytes.slice(offset, offset + len));
      offset += len;
      return out;
    }
    if (tag === 0xcc) return bytes[offset++];
    if (tag === 0xcd) {
      const out = (bytes[offset] << 8) | bytes[offset + 1];
      offset += 2;
      return out;
    }
    if (tag === 0xcb) {
      const out = view.getFloat64(offset, false);
      offset += 8;
      return out;
    }
    throw new Error(`Unsupported splinegeometry tag 0x${tag.toString(16)}`);
  };

  const mapTag = bytes[offset++];
  if (!((mapTag >= 0x80 && mapTag <= 0x8f) || (mapTag >= 0x90 && mapTag <= 0x9f))) {
    return { ...DEFAULT_SPLINE_SPHERE };
  }
  const count = mapTag & 0x0f;
  const keys = new Array(count);
  for (let i = 0; i < count; i++) keys[i] = readScalar();
  const parsed = {};
  for (let i = 0; i < count; i++) parsed[keys[i]] = readScalar();
  const params = { ...DEFAULT_SPLINE_SPHERE, ...parsed };
  if (params.phiStart > Math.PI * 2 + 0.001) params.phiStart = params.phiStart * Math.PI / 180;
  if (params.phiLength > Math.PI * 2 + 0.001) params.phiLength = params.phiLength * Math.PI / 180;
  if (params.thetaStart > Math.PI * 2 + 0.001) params.thetaStart = params.thetaStart * Math.PI / 180;
  if (params.thetaLength > Math.PI * 2 + 0.001) params.thetaLength = params.thetaLength * Math.PI / 180;
  return params;
}

function buildSplineSphereCore(params) {
  const width = Math.max(1, Number(params.width) || DEFAULT_SPLINE_SPHERE.width);
  const height = Math.max(1, Number(params.height) || DEFAULT_SPLINE_SPHERE.height);
  const depth = Math.max(1, Number(params.depth) || DEFAULT_SPLINE_SPHERE.depth);
  const widthSegments = Math.max(12, Number(params.widthSegments) || DEFAULT_SPLINE_SPHERE.widthSegments);
  const heightSegments = Math.max(8, Number(params.heightSegments) || DEFAULT_SPLINE_SPHERE.heightSegments);
  const phiStart = Number.isFinite(params.phiStart) ? params.phiStart : DEFAULT_SPLINE_SPHERE.phiStart;
  const phiLength = Number.isFinite(params.phiLength) ? params.phiLength : DEFAULT_SPLINE_SPHERE.phiLength;
  const thetaStart = Number.isFinite(params.thetaStart) ? params.thetaStart : DEFAULT_SPLINE_SPHERE.thetaStart;
  const thetaLength = Number.isFinite(params.thetaLength) ? params.thetaLength : DEFAULT_SPLINE_SPHERE.thetaLength;
  const maxDim = Math.max(width, height, depth, 1);
  const rx = width / maxDim;
  const ry = height / maxDim;
  const rz = depth / maxDim;
  const ringCount = Math.max(7, Math.min(18, Math.round(heightSegments / 8)));
  const meridianCount = Math.max(10, Math.min(26, Math.round(widthSegments / 5)));
  const ringPoints = Math.max(20, Math.min(60, Math.round(widthSegments / 3)));
  const meridianPoints = Math.max(10, Math.min(30, Math.round(heightSegments / 4)));

  const pointAt = (u, v, seed = 0) => {
    const phi = phiStart + u * phiLength;
    const theta = thetaStart + v * thetaLength;
    const sinTheta = Math.sin(theta);
    return {
      x: -rx * Math.cos(phi) * sinTheta,
      y: ry * Math.cos(theta),
      z: rz * Math.sin(phi) * sinTheta,
      phase: seed * 1.618 + u * Math.PI * 2,
      lift: 0.35 + v * 0.65,
      accent: (Math.floor(u * 12) + Math.floor(v * 10)) % 3 === 0,
    };
  };

  const rings = [];
  for (let ri = 1; ri < ringCount; ri++) {
    const v = ri / ringCount;
    const ring = [];
    for (let si = 0; si <= ringPoints; si++) ring.push(pointAt(si / ringPoints, v, ri));
    rings.push(ring);
  }

  const meridians = [];
  for (let mi = 0; mi < meridianCount; mi++) {
    const u = mi / meridianCount;
    const meridian = [];
    for (let ti = 0; ti <= meridianPoints; ti++) meridian.push(pointAt(u, ti / meridianPoints, mi + 40));
    meridians.push(meridian);
  }

  const nodes = [];
  for (let ri = 1; ri < ringCount; ri += 2) {
    for (let mi = 0; mi < meridianCount; mi += 2) {
      nodes.push(pointAt(mi / meridianCount, ri / ringCount, ri * 100 + mi));
    }
  }

  const spokes = nodes.filter((_, i) => i % Math.max(1, Math.floor(nodes.length / 12)) === 0).slice(0, 12);
  return { rings, meridians, nodes, spokes };
}

function createParallaxCanvas(size, paint) {
  const canvas = document.createElement("canvas");
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext("2d");
  paint(ctx, size);
  return canvas;
}

function buildParallaxBackgroundAtlas(core, glow, compact = false) {
  const size = compact ? 360 : 520;
  const rgba = (c, a) => `rgba(${c.r|0}, ${c.g|0}, ${c.b|0}, ${a})`;
  const coreRgb = { r: core.r, g: core.g, b: core.b };
  const glowRgb = { r: glow.r, g: glow.g, b: glow.b };

  const softHalo = createParallaxCanvas(size, (ctx, s) => {
    const g = ctx.createRadialGradient(s * 0.5, s * 0.5, 0, s * 0.5, s * 0.5, s * 0.62);
    g.addColorStop(0.00, rgba(glowRgb, 0.085));
    g.addColorStop(0.35, rgba(coreRgb, 0.055));
    g.addColorStop(0.72, rgba(coreRgb, 0.014));
    g.addColorStop(1.00, "rgba(0,0,0,0)");
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, s, s);
  });

  const ambientWash = createParallaxCanvas(size, (ctx, s) => {
    ctx.globalCompositeOperation = "lighter";
    const g = ctx.createRadialGradient(s * 0.5, s * 0.5, s * 0.18, s * 0.5, s * 0.5, s * 0.55);
    g.addColorStop(0.00, rgba(glowRgb, 0.030));
    g.addColorStop(0.50, rgba(coreRgb, 0.018));
    g.addColorStop(1.00, "rgba(0,0,0,0)");
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, s, s);
  });

  return [
    { image: softHalo, scale: 1.65, alpha: 0.85, depthX: 6, depthY: 5, driftX: 0.020, driftY: 0.014, phase: 0.2 },
    { image: ambientWash, scale: 1.95, alpha: 0.70, depthX: -10, depthY: -7, driftX: 0.013, driftY: 0.018, phase: 2.1 },
  ];
}

function drawParallaxBackground(ctx, atlas, weight, W, H, t, mouse, drive, fade) {
  if (!atlas || weight <= 0.01 || W <= 0 || H <= 0 || fade <= 0.01) return;
  ctx.save();
  ctx.globalCompositeOperation = "screen";
  atlas.forEach((layer, i) => {
    const pulse = 1 + drive * (0.018 + i * 0.006);
    const drawW = W * layer.scale * pulse;
    const drawH = H * layer.scale * pulse;
    const driftX = Math.sin(t * layer.driftX + layer.phase) * W * (0.010 + i * 0.004);
    const driftY = Math.cos(t * layer.driftY + layer.phase * 1.31) * H * (0.008 + i * 0.003);
    const parallaxX = mouse.x * layer.depthX;
    const parallaxY = mouse.y * layer.depthY;
    ctx.globalAlpha = layer.alpha * weight * fade * (0.34 + drive * 0.16);
    ctx.drawImage(
      layer.image,
      (W - drawW) / 2 + driftX + parallaxX,
      (H - drawH) / 2 + driftY + parallaxY,
      drawW,
      drawH
    );
  });
  ctx.restore();
}

// =================================================================
// HERO — AI Reasoning Orb
//   A Fibonacci sphere of ~1800 luminous particles. Drives a state
//   machine (idle → waking → generating → climax → calming) and
//   responds to a sentiment palette (neutral / positive / technical).
//   Exposes a global `window.orb` API for live coupling to Claude.
// =================================================================

function OrbCanvas() {
  const canvasRef = React.useRef(null);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    const compactScreen = Math.min(window.innerWidth || 9999, window.innerHeight || 9999) <= 520;
    const midScreen = Math.min(window.innerWidth || 9999, window.innerHeight || 9999) <= 820;
    const env = setupCanvas(canvas, compactScreen ? 1.0 : midScreen ? 1.2 : 1.5);
    const ctx = env.ctx;
    window.addEventListener("resize", env.resize);

    // Hero-контейнер для проброса плавно интерполированной палитры в CSS-фон,
    // чтобы фон менялся синхронно с шаром, а не скачком по data-orb-tone.
    const heroEl = canvas.closest && canvas.closest('.hero');

    const simplex = typeof SimplexNoise !== 'undefined' ? new SimplexNoise() : { noise3D: () => 0 };

    // Нормализованные вершины тетраэдра — для морфа и для тангенциального стягивания к вершинам.
    const TET_VERTICES = [
      [1, 1, 1], [1, -1, -1], [-1, 1, -1], [-1, -1, 1],
    ].map(v => { const l = Math.hypot(v[0], v[1], v[2]); return [v[0]/l, v[1]/l, v[2]/l]; });

    // Икосаэдр — для idle-формы шара (как на референсе пользователя).
    // 12 вершин, 20 треугольных граней. Частицы будут стягиваться к
    // ближайшей грани, формируя чёткие wireframe-плоскости.
    const ICOS_PHI = (1 + Math.sqrt(5)) / 2;
    const ICOS_VERTICES = [
      [ 0,  1,  ICOS_PHI], [ 0, -1,  ICOS_PHI], [ 0,  1, -ICOS_PHI], [ 0, -1, -ICOS_PHI],
      [ 1,  ICOS_PHI,  0], [-1,  ICOS_PHI,  0], [ 1, -ICOS_PHI,  0], [-1, -ICOS_PHI,  0],
      [ ICOS_PHI,  0,  1], [-ICOS_PHI,  0,  1], [ ICOS_PHI,  0, -1], [-ICOS_PHI,  0, -1],
    ].map(v => { const l = Math.hypot(v[0], v[1], v[2]); return [v[0]/l, v[1]/l, v[2]/l]; });
    const ICOS_FACES = [
      [0, 1, 8], [0, 8, 4], [0, 4, 5], [0, 5, 9], [0, 9, 1],
      [1, 9, 7], [1, 7, 6], [1, 6, 8], [8, 6, 10], [8, 10, 4],
      [4, 10, 2], [4, 2, 5], [5, 2, 11], [5, 11, 9], [9, 11, 7],
      [3, 7, 11], [3, 11, 2], [3, 2, 10], [3, 10, 6], [3, 6, 7],
    ];
    // Нормаль грани = нормализованный центроид трёх вершин (т.к. икосаэдр центрирован).
    const ICOS_FACE_NORMALS = ICOS_FACES.map(([a, b, c]) => {
      const A = ICOS_VERTICES[a], B = ICOS_VERTICES[b], C = ICOS_VERTICES[c];
      const nx = A[0] + B[0] + C[0];
      const ny = A[1] + B[1] + C[1];
      const nz = A[2] + B[2] + C[2];
      const l = Math.hypot(nx, ny, nz) || 1;
      return [nx / l, ny / l, nz / l];
    });
    // Inradius: расстояние от центра до плоскости грани (для R=1).
    const ICOS_INRADIUS = (() => {
      const f = ICOS_FACES[0];
      const cx = (ICOS_VERTICES[f[0]][0] + ICOS_VERTICES[f[1]][0] + ICOS_VERTICES[f[2]][0]) / 3;
      const cy = (ICOS_VERTICES[f[0]][1] + ICOS_VERTICES[f[1]][1] + ICOS_VERTICES[f[2]][1]) / 3;
      const cz = (ICOS_VERTICES[f[0]][2] + ICOS_VERTICES[f[1]][2] + ICOS_VERTICES[f[2]][2]) / 3;
      return Math.hypot(cx, cy, cz);
    })();
    // Уникальные рёбра икосаэдра (30 шт.) для отрисовки wireframe в idle.
    const ICOS_EDGES = (() => {
      const set = new Set();
      const edges = [];
      ICOS_FACES.forEach(([a, b, c]) => {
        [[a, b], [b, c], [c, a]].forEach(([i, j]) => {
          const key = i < j ? `${i}-${j}` : `${j}-${i}`;
          if (!set.has(key)) { set.add(key); edges.push([i, j]); }
        });
      });
      return edges;
    })();

    const seedDirection = (p) => {
      p.theta = Math.random() * 2 * Math.PI;
      p.phi = Math.acos(2 * Math.random() - 1);
    };

	    const particles = [];
	    const makeParticle = () => {
	      // ~50.4% частиц — статичный каркас, остальные — реактивные.
	      const isStatic = Math.random() < 0.504;
	      const p = {
        theta: 0,
        phi: 0,
        baseSpeed: 0.002 + Math.random() * 0.008,
        life: Math.random() * 150,
        maxLife: 50 + Math.random() * 150,
	        seed: Math.random() * 1000,
	        band: Math.random(),
	        stream: Math.random(),
	        lane: Math.floor(Math.random() * 9),
	        audioReactivity: isStatic ? (0.034 + Math.random() * 0.085) : (0.527 + Math.random() * 0.425),
	      };
	      seedDirection(p);
	      return p;
	    };
    const targetParticleCount = (w, h) => {
      const width = Math.max(1, w || window.innerWidth || 1);
      const height = Math.max(1, h || window.innerHeight || 1);
      const minSide = Math.min(width, height);
      const area = width * height;
      // 9% lower particle budget to reduce mobile CPU/GPU pressure.
      if (minSide <= 430 || area <= 390 * 900) return 1230;
      if (minSide <= 560 || area <= 620 * 960) return 1550;
      if (minSide <= 820 || area <= 900 * 1200) return 2230;
      return 3185;
    };
    const syncParticleBudget = () => {
      const target = targetParticleCount(env.W, env.H);
      while (particles.length < target) particles.push(makeParticle());
      if (particles.length > target) particles.length = target;
      window.darkyPerf = {
        ...(window.darkyPerf || {}),
        orbParticles: particles.length,
        orbDpr: env.dpr,
        compactScreen,
      };
    };
    syncParticleBudget();

    let state = "idle";
    let stateT = 0;
    let energyTarget = 0.22;
    let energy = 0.22;
    let rateTarget = 0;
    let rate = 0;
    let hoverTarget = 0;
    let hoverActive = 0;
    let shockAmp = 0;
    let shockT = -10;
    let linkBurstT = -10;
    const LINK_BURST_SECONDS = 1.0;
    let audioPulse = 0;
    let audioPulseFast = 0;
    let audioPrevDrive = 0;
    let audioWavePhase = 0;
    // Сглаженный фактор морфа сферы → тетраэдр. Растёт быстро при thinking/speaking,
    // плавно затухает после — чтобы возврат к сфере был мягким.
    let tetMorphSmooth = 0;
    // Сглаженный фактор морфа сферы → икосаэдр для idle (до активации сайта).
    // По умолчанию активен; обнуляется по событию `darky-activate`.
    let icosMorphTarget = 1;
    let icosMorphSmooth = 1;
	    const palettes = {
	      idle: { r: 255, g: 36, b: 52, glowR: 255, glowG: 64, glowB: 72 },
	      thinking: { r: 198, g: 78, b: 255, glowR: 220, glowG: 130, glowB: 255 },
	      answering: { r: 88, g: 208, b: 255, glowR: 188, glowG: 236, glowB: 255 },
	    };
	    const palette = { ...palettes.idle };
	    const parallaxAtlases = {
	      idle: buildParallaxBackgroundAtlas(
	        { r: palettes.idle.r, g: palettes.idle.g, b: palettes.idle.b },
	        { r: palettes.idle.glowR, g: palettes.idle.glowG, b: palettes.idle.glowB },
	        compactScreen
	      ),
	      thinking: buildParallaxBackgroundAtlas(
	        { r: palettes.thinking.r, g: palettes.thinking.g, b: palettes.thinking.b },
	        { r: palettes.thinking.glowR, g: palettes.thinking.glowG, b: palettes.thinking.glowB },
	        compactScreen
	      ),
	      answering: buildParallaxBackgroundAtlas(
	        { r: palettes.answering.r, g: palettes.answering.g, b: palettes.answering.b },
	        { r: palettes.answering.glowR, g: palettes.answering.glowG, b: palettes.answering.glowB },
	        compactScreen
	      ),
	    };
	    let parallaxThinkingWeight = 0;
	    let parallaxAnsweringWeight = 0;

    const notifyState = () => {
      window.dispatchEvent(new CustomEvent("orb-state", { detail: state }));
    };

    const triggerLinkBurst = () => {
      linkBurstT = 0;
      shockT = -10;
      state = "climax";
      stateT = 0;
      energyTarget = Math.max(energyTarget, 1.55);
      notifyState();
    };

    window.orb = {
      wake() {
        if (state === "idle" || state === "calming") {
          state = "waking"; stateT = 0; energyTarget = 1.0;
          notifyState();
        }
      },
      setRate(r) { rateTarget = Math.max(0, Math.min(1, r)); },
      type() {
        state = "typing"; stateT = 0; energyTarget = 1.12;
        notifyState();
      },
      speak() {
        state = "speaking"; stateT = 0; energyTarget = 1.18;
        notifyState();
      },
      setSentiment(s) {
        window.dispatchEvent(new CustomEvent("orb-sentiment", { detail: s }));
      },
      complete(textLen = 200) {
        shockAmp = Math.min(1.0, 0.4 + Math.log10(Math.max(10, textLen)) * 0.22);
        shockT = 0;
        state = "climax"; stateT = 0; energyTarget = 1.5;
        notifyState();
      },
      linkBurst: triggerLinkBurst,
      reset() {
        state = "calming"; stateT = 0; energyTarget = 0.22; rateTarget = 0;
        notifyState();
      },
      getState() { return state; },
    };

    const onOrbHover = (e) => {
      hoverTarget = e.detail && e.detail.active ? 1 : 0;
      if (hoverTarget) energyTarget = Math.max(energyTarget, 0.58);
    };
    const onOrbActivated = () => {
      energyTarget = Math.max(energyTarget, 1.05);
    };
    const onSiteActivate = () => { icosMorphTarget = 0; };
    const onSiteDeactivate = () => { icosMorphTarget = 1; };
    window.addEventListener("darky-orb-hover", onOrbHover);
    window.addEventListener("darky-orb-activated", onOrbActivated);
    window.addEventListener("darky-orb-link-burst", triggerLinkBurst);
    window.addEventListener("darky-activate", onSiteActivate);
    window.addEventListener("darky-deactivate", onSiteDeactivate);

    let raf;
    let last = performance.now();
    let lastFrame = 0;
    let lastBudgetCheck = 0;
    const minFrameMs = compactScreen ? 1000 / 48 : midScreen ? 1000 / 55 : 0;
    let time = 0;
    let mouse = { x: 0, y: 0, targetX: 0, targetY: 0 };
    let pointer = {
      x: -9999,
      y: -9999,
      targetX: -9999,
      targetY: -9999,
      speed: 0,
      targetStrength: 0,
      strength: 0,
      lastMove: -9999,
    };

    const onPointerMove = (e) => {
      const rect = canvas.getBoundingClientRect();
      const localX = e.clientX - rect.left;
      const localY = e.clientY - rect.top;
      mouse.targetX = (localX - env.W/2) * 0.006;
      mouse.targetY = (localY - env.H/2) * 0.006;
      const dx = localX - pointer.targetX;
      const dy = localY - pointer.targetY;
      pointer.speed = Math.min(90, Math.hypot(dx, dy));
      pointer.targetX = localX;
      pointer.targetY = localY;
      pointer.targetStrength = 1;
      pointer.lastMove = performance.now();
    };
    const onPointerLeave = () => {
      pointer.targetStrength = 0;
      pointer.lastMove = -9999;
    };
    window.addEventListener("pointermove", onPointerMove);
    window.addEventListener("pointerleave", onPointerLeave);
    window.addEventListener("blur", onPointerLeave);

    const draw = () => {
      const now = performance.now();
      if (minFrameMs && now - lastFrame < minFrameMs) {
        raf = requestAnimationFrame(draw);
        return;
      }
      lastFrame = now;
      if (now - lastBudgetCheck > 700) {
        syncParticleBudget();
        lastBudgetCheck = now;
      }
      const dtMs = now - last;
      const dt = Math.min(2.5, dtMs / 16.66);
      last = now;
      const seconds = dt / 60;
      
      stateT += seconds;
      shockT += seconds;
      if (linkBurstT >= 0) {
        linkBurstT += seconds;
        if (linkBurstT > LINK_BURST_SECONDS) linkBurstT = -10;
      }

      if (state === "waking") {
        energyTarget = 1.0;
        if (stateT > 0.32) {
          state = "generating"; stateT = 0; notifyState();
        }
      } else if (state === "generating") {
        energyTarget = 0.72 + rateTarget * 0.38;
      } else if (state === "climax") {
        energyTarget = 1.28;
        if (stateT > 0.55) {
          state = "calming"; stateT = 0; energyTarget = 0.22; notifyState();
        }
      } else if (state === "calming") {
        if (stateT > 1.6) {
          state = "idle"; stateT = 0; notifyState();
        }
      }

      energy += (energyTarget - energy) * 0.042 * dt;
      rate += (rateTarget - rate) * 0.028 * dt;
      hoverActive += (hoverTarget - hoverActive) * 0.10 * dt;
      
      let audioBass = 0;
      let audioMid = 0;
      let audioHigh = 0;
      let audioEnergy = 0;
      const audioSample = window.darkyAudio && window.darkyAudio.sample ? window.darkyAudio.sample() : window.darkyAudio;
      if (audioSample && audioSample.enabled) {
        audioBass = audioSample.bass || 0;
        audioMid = audioSample.mid || 0;
        audioHigh = audioSample.high || 0;
        audioEnergy = audioSample.energy || 0;
      }
      let voiceEnergy = 0;
      let voiceMid = 0;
      let voiceHigh = 0;
      const voiceSample = window.darkyVoiceAudio && window.darkyVoiceAudio.sample ? window.darkyVoiceAudio.sample() : window.darkyVoiceAudio;
      if (voiceSample && voiceSample.enabled) {
        // 2x voice reactivity — central figure responds harder to whisper FFT.
        voiceEnergy = Math.min(1, (voiceSample.energy || 0) * 2);
        voiceMid = Math.min(1, (voiceSample.mid || 0) * 2);
        voiceHigh = Math.min(1, (voiceSample.high || 0) * 2);
      }

      const musicDrive = Math.min(1, audioEnergy * 1.35 + audioBass * 0.82 + audioMid * 0.46 + audioHigh * 0.24);
      const musicTransient = Math.max(0, musicDrive - audioPrevDrive);
      audioPrevDrive += (musicDrive - audioPrevDrive) * Math.min(1, 0.08 * dt);
      const pulseTarget = Math.min(1, musicDrive * 1.20 + musicTransient * 3.3);
      const pulseFastTarget = Math.min(1, musicDrive * 1.36 + musicTransient * 5.2);
      audioPulse += (pulseTarget - audioPulse) * Math.min(1, 0.12 * dt);
      audioPulseFast += (pulseFastTarget - audioPulseFast) * Math.min(1, 0.23 * dt);
      const internalAudio = Math.min(0.86, audioPulse * 0.86 + audioPulseFast * 0.18 + voiceEnergy * 0.55);
      audioWavePhase += (0.010 + audioPulse * 0.074 + audioPulseFast * 0.036 + audioBass * 0.055 + voiceEnergy * 0.016) * dt;
      window.darkyPerf = {
        ...(window.darkyPerf || {}),
        audioPulse,
        audioPulseFast,
        internalAudio,
        audioBass,
        audioMid,
        audioHigh,
        orbState: state,
      };

      const thinking = state === "waking" ? 0.55 : state === "generating" ? 1 : 0;
      const typing = state === "typing" ? 1 : 0;
      const speaking = state === "speaking" ? 1 : 0;
      // Tetrahedron-морф работает и в thinking, и в speaking. Ease-in быстрый,
      // ease-out очень медленный — форма мягко возвращается к сфере.
      const tetMorphTarget = Math.max(thinking, speaking);
      const tetEaseRate = (tetMorphTarget > tetMorphSmooth ? 0.090 : 0.018) * dt;
      tetMorphSmooth += (tetMorphTarget - tetMorphSmooth) * Math.min(1, tetEaseRate);
      // Icosahedron morph: активен пока сайт не активирован (idle-форма шара).
      // Снимаем форму резко при активации (≈0.5s), восстанавливаем медленно.
      const icosEaseRate = (icosMorphTarget > icosMorphSmooth ? 0.030 : 0.080) * dt;
      icosMorphSmooth += (icosMorphTarget - icosMorphSmooth) * Math.min(1, icosEaseRate);
      const answering = typing > 0 || speaking > 0 || state === "climax";
      const settling = state === "calming" ? 1 : 0;
      const linkBurstActive = linkBurstT >= 0 && linkBurstT < LINK_BURST_SECONDS;
	      const linkBurstK = linkBurstActive ? Math.min(1, linkBurstT / LINK_BURST_SECONDS) : 0;
	      const linkBurstEase = linkBurstActive ? 1 - Math.pow(1 - linkBurstK, 3) : 0;
	      const linkBurstFade = linkBurstActive ? Math.pow(1 - linkBurstK, 0.55) : 0;
	      const visualEnergy = energy + audioPulse * 0.26 + audioPulseFast * 0.08 + audioMid * 0.08 + audioHigh * 0.05 + voiceEnergy * 0.55 + hoverActive * 0.2 + linkBurstFade * 0.70;
	      const parallaxThinkingTarget = answering ? 0 : thinking;
	      const parallaxAnsweringTarget = answering ? 1 : 0;
	      const parallaxEase = Math.min(1, 0.050 * dt);
	      parallaxThinkingWeight += (parallaxThinkingTarget - parallaxThinkingWeight) * parallaxEase;
	      parallaxAnsweringWeight += (parallaxAnsweringTarget - parallaxAnsweringWeight) * parallaxEase;
	      const targetPalette = answering ? palettes.answering : thinking > 0 ? palettes.thinking : palettes.idle;
	      const paletteEase = Math.min(1, (answering ? 0.105 : thinking > 0 ? 0.072 : 0.045) * dt);
      palette.r += (targetPalette.r - palette.r) * paletteEase;
      palette.g += (targetPalette.g - palette.g) * paletteEase;
      palette.b += (targetPalette.b - palette.b) * paletteEase;
      palette.glowR += (targetPalette.glowR - palette.glowR) * paletteEase;
      palette.glowG += (targetPalette.glowG - palette.glowG) * paletteEase;
      palette.glowB += (targetPalette.glowB - palette.glowB) * paletteEase;

      // Пробрасываем состояние шара в darkyPerf, чтобы планеты повторяли его реакции.
      window.darkyPerf = {
        ...(window.darkyPerf || {}),
        tetMorphSmooth,
        icosMorphSmooth,
        orbThinking: thinking,
        orbSpeaking: speaking,
        orbPaletteR: palette.r,
        orbPaletteG: palette.g,
        orbPaletteB: palette.b,
      };

      // Пробрасываем интерполированную палитру в CSS-переменные .hero,
      // чтобы фоновые градиенты менялись синхронно с шаром.
      if (heroEl) {
        const pr = palette.r|0, pg = palette.g|0, pb = palette.b|0;
        const gr = palette.glowR|0, gg = palette.glowG|0, gb = palette.glowB|0;
        const s = heroEl.style;
        s.setProperty('--orb-bg-core', `${pr}, ${pg}, ${pb}`);
        s.setProperty('--orb-bg-mid', `${pr}, ${pg}, ${pb}`);
        s.setProperty('--orb-bg-soft', `${gr}, ${gg}, ${gb}`);
        s.setProperty('--orb-bg-highlight', `${gr}, ${gg}, ${gb}`);
      }
      
      time += 0.0015 * dt * (1 + visualEnergy * 1.5 + rate * 2.0 + typing * 0.8 + speaking * 0.35);

      mouse.x += (mouse.targetX - mouse.x) * 0.05 * dt;
      mouse.y += (mouse.targetY - mouse.y) * 0.05 * dt;
      pointer.x += (pointer.targetX - pointer.x) * Math.min(1, 0.18 * dt);
      pointer.y += (pointer.targetY - pointer.y) * Math.min(1, 0.18 * dt);
      const pointerFresh = now - pointer.lastMove < 220 ? pointer.targetStrength : 0;
      const pointerTarget = pointerFresh * Math.min(1, 0.28 + pointer.speed / 70);
      pointer.strength += (pointerTarget - pointer.strength) * Math.min(1, 0.08 * dt);

      const W = env.W, H = env.H;
      
	      const trailAlpha = 0.105 + visualEnergy * 0.055 + typing * 0.025;
	      ctx.fillStyle = `rgba(5, 6, 8, ${trailAlpha})`;
	      ctx.fillRect(0, 0, W, H);
	      const parallaxIdleWeight = Math.max(0, 1 - parallaxThinkingWeight - parallaxAnsweringWeight);
	      const parallaxDrive = Math.min(1, visualEnergy * 0.34 + internalAudio * 0.32 + hoverActive * 0.18 + audioPulseFast * 0.12);
	      const parallaxFade = (0.14 + (1 - icosMorphSmooth) * (compactScreen ? 0.50 : 0.62)) * (linkBurstActive ? 0.72 : 1);
	      drawParallaxBackground(ctx, parallaxAtlases.idle, parallaxIdleWeight, W, H, time, mouse, parallaxDrive, parallaxFade);
	      drawParallaxBackground(ctx, parallaxAtlases.thinking, parallaxThinkingWeight, W, H, time, mouse, parallaxDrive, parallaxFade);
	      drawParallaxBackground(ctx, parallaxAtlases.answering, parallaxAnsweringWeight, W, H, time, mouse, parallaxDrive, parallaxFade);

	      const cx = Math.cos(mouse.y);
      const sx = Math.sin(mouse.y);
      const cy = Math.cos(mouse.x + time * 0.5);
      const sy = Math.sin(mouse.x + time * 0.5);

      const fov = 800;
      // В idle (icosMorphSmooth ≈ 1) сжимаем шар до точки ≈ 1 см на экране.
      // 1 cm ≈ 38 CSS px → радиус ≈ 19 px (диаметр ≈ 1 см при 96 dpi).
      const IDLE_RADIUS_PX = 19;
      const baseRadiusFull = Math.min(W, H) * 0.35;
      const baseRadius = baseRadiusFull * (1 - icosMorphSmooth) + IDLE_RADIUS_PX * icosMorphSmooth;
      const shapeEnergy = energy + thinking * 0.05 + speaking * 0.06 + hoverActive * 0.10;
      const currentRadius = baseRadius * (1 + shapeEnergy * 0.045 + audioBass * 0.006);
      const projectionDenom = Math.sqrt(Math.max(1, (fov + currentRadius * 2) ** 2 - currentRadius ** 2));
	      const sphereScreenRadius = currentRadius * fov / projectionDenom;
	      const sphereMaskRadius = sphereScreenRadius * (1.032 + audioBass * 0.004 + speaking * 0.008);
	      const glowRadius = sphereScreenRadius * (0.90 + internalAudio * 0.11 + speaking * 0.03);
	      const linkBurstCoreFade = linkBurstActive ? 0 : 1;

	      ctx.save();
      ctx.beginPath();
      ctx.arc(W / 2, H / 2, sphereMaskRadius, 0, Math.PI * 2);
      ctx.clip();
      ctx.globalCompositeOperation = "lighter";
      const glow = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, glowRadius);
      glow.addColorStop(0, `rgba(${palette.glowR|0}, ${palette.glowG|0}, ${palette.glowB|0}, ${(0.058 + visualEnergy * 0.030 + internalAudio * 0.024 + speaking * voiceEnergy * 0.08) * linkBurstCoreFade})`);
      glow.addColorStop(0.45, `rgba(${palette.r|0}, ${palette.g|0}, ${palette.b|0}, ${(0.028 + audioMid * 0.014 + audioBass * 0.010 + typing * 0.016) * linkBurstCoreFade})`);
      glow.addColorStop(1, "rgba(0, 0, 0, 0)");
      ctx.fillStyle = glow;
      ctx.fillRect(W / 2 - sphereMaskRadius, H / 2 - sphereMaskRadius, sphereMaskRadius * 2, sphereMaskRadius * 2);
      ctx.restore();

      let surfaceWave = 0;
      let surfaceWavePhase = 0;
      if (shockT >= 0 && shockT < 0.85) {
        const k = shockT / 0.85;
        surfaceWave = Math.sin(k * Math.PI) * shockAmp;
        surfaceWavePhase = k;
      }

      const visible = [];
      particles.forEach(p => {
        const x0 = Math.sin(p.phi) * Math.cos(p.theta);
        const y0 = Math.sin(p.phi) * Math.sin(p.theta);
        const z0 = Math.cos(p.phi);

        const noiseScale = 1.8 + visualEnergy * 0.26 + thinking * 0.45 + internalAudio * 0.42;
        const noiseX = simplex.noise3D(x0 * noiseScale, y0 * noiseScale, z0 * noiseScale + time + p.seed * 0.01);
        const noiseY = simplex.noise3D(x0 * noiseScale + 100, y0 * noiseScale + 100, z0 * noiseScale + time + p.seed * 0.006);
        
        const currentSpeed = p.baseSpeed * (1 + visualEnergy * 1.45 + internalAudio * 2.8 + audioPulseFast * 2.2 + thinking * 1.2 + typing * 2.1 + speaking * 0.55);
        // Сильно подавляем тангенциальный noise-drift — частицы больше не "летают как мухи".
        // В idle-икосаэдре дрейф почти выключен, чтобы грани оставались чёткими.
        const driftDampening = 1 - icosMorphSmooth * 0.92;
        p.theta += noiseX * currentSpeed * dt * 0.18 * driftDampening;
        p.phi += noiseY * currentSpeed * dt * 0.18 * driftDampening;

        if (internalAudio > 0.01) {
          // Реактивные частицы — движение строго радиальное, тангенциальный jitter подавлен.
          const lateral = Math.sin(audioWavePhase * 4.0 + p.seed * 0.011 + p.lane) * internalAudio * 0.00035 * dt * p.audioReactivity;
          p.theta += lateral * (0.8 + p.stream * 0.7);
          p.phi += Math.cos(audioWavePhase * 3.2 + p.seed * 0.017) * internalAudio * 0.00022 * dt * p.audioReactivity;
        }

        if (thinking > 0) {
          const bandTarget = 0.20 + ((p.lane + 0.5) / 9) * (Math.PI - 0.40);
          p.phi += (bandTarget - p.phi) * 0.012 * thinking * dt;
          p.theta += (0.0015 + p.stream * 0.0035) * thinking * dt;
        }
        if (typing > 0) {
          const laneTarget = 0.16 + ((p.lane + 0.5) / 9) * (Math.PI - 0.32);
          const typingRipple = Math.sin(time * 42 + p.stream * 24 + p.seed) * 0.045;
          p.phi += (laneTarget + typingRipple - p.phi) * 0.010 * dt;
          p.theta += (0.010 + rate * 0.018 + p.stream * 0.010) * dt;
        }
        if (speaking > 0) {
          p.theta += Math.sin(time * 11 + p.seed) * 0.0025 * (0.4 + voiceEnergy) * dt;
          p.phi += Math.cos(time * 8 + p.seed * 0.7) * 0.0018 * (0.3 + voiceMid) * dt;
        }

        if (p.phi < 0.01) p.phi = 0.01;
        if (p.phi > Math.PI - 0.01) p.phi = Math.PI - 0.01;

        p.life += dt * (1 + visualEnergy * 0.5);
        if (p.life > p.maxLife) {
          seedDirection(p);
          p.life = 0;
        }

        const thinkingBand = thinking * Math.sin(p.lane * 1.7 + time * 7.5) * baseRadius * 0.018;
        const typingPacket = typing * Math.max(0, Math.sin(time * 55 - p.stream * 36)) * baseRadius * 0.030;
        const speechWave = speaking * Math.sin(p.phi * 7.5 - time * 30 + p.stream * 4) * baseRadius * (0.012 + voiceEnergy * 0.055 + voiceHigh * 0.025);
        const wavePos = (p.band + audioWavePhase * 0.115 + p.stream * 0.075) % 1;
        const waveFront = Math.max(0, 1 - Math.abs(wavePos - 0.50) * 5.2);
        const audioWave = internalAudio * waveFront * waveFront * (3 - 2 * waveFront);
        const coreLayer = Math.max(0, 1 - p.band);
        // Реактивные частицы двигаются строго от центра наружу пропорционально FFT-энергии,
        // без волновых фаз и без втягивания к центру.
        const fftLinear = audioBass * 0.55 + audioMid * 0.30 + audioHigh * 0.15;
        const audioCorePull = 0;
        const audioRadialPush = fftLinear * baseRadius * 0.34 * p.audioReactivity;
        const audioRipple = 0;
        const finishWeave = surfaceWave * Math.sin(p.phi * 8.0 + p.theta * 2.4 - surfaceWavePhase * Math.PI * 5.2 + p.stream * 3.0) * baseRadius * 0.034;
        if (surfaceWave > 0) {
          p.theta += surfaceWave * (0.0018 + p.stream * 0.0025) * dt;
          p.phi += Math.sin(p.theta * 2.0 + p.seed) * surfaceWave * 0.0009 * dt;
          if (p.phi < 0.01) p.phi = 0.01;
          if (p.phi > Math.PI - 0.01) p.phi = Math.PI - 0.01;
        }
        // Линейный радиальный outflow по фазе жизни частицы: каждая частица плавно
        // движется от центра к краю шара, при перерождении начинает заново.
        // Разные частицы на разных фазах → волна "от центра наружу".
        const lifePhase01 = Math.max(0, Math.min(1, p.life / p.maxLife));
        const radialOutflow = baseRadius * (lifePhase01 - 0.5) * 0.24 * (1 - icosMorphSmooth * 0.95);
        const burstVariance = 0.78 + (0.5 + 0.5 * Math.sin(p.seed * 12.9898 + p.stream * 78.233)) * 0.74;
        const linkBurstPush = linkBurstEase * baseRadius * (1.08 + burstVariance * 0.88);
        const r = currentRadius + thinkingBand + typingPacket + speechWave + audioRadialPush + audioRipple + finishWeave + radialOutflow + linkBurstPush - audioCorePull - settling * baseRadius * 0.018;
        let px = Math.sin(p.phi) * Math.cos(p.theta) * r;
        let py = Math.sin(p.phi) * Math.sin(p.theta) * r;
        let pz = Math.cos(p.phi) * r;

        if (linkBurstActive) {
          const scatter = baseRadius * (0.13 + p.stream * 0.08);
          px += Math.cos(p.seed * 1.91) * scatter;
          py += Math.sin(p.seed * 2.17) * scatter;
          pz += Math.sin(p.seed * 2.71) * scatter * 0.45;
        }

        // Морфинг сферы → тетраэдр (для thinking и speaking, с плавным затуханием).
        // Тетраэдр вписан в исходную сферу: вершины на радиусе r, грани при inradius = r/3.
        // Дополнительно частицы тангенциально стягиваются к ближайшей вершине — это
        // создаёт плотные сгустки у вершин, и эффект пропадает вместе с tetMorphSmooth.
        if (tetMorphSmooth > 0.002) {
          let ux = Math.sin(p.phi) * Math.cos(p.theta);
          let uy = Math.sin(p.phi) * Math.sin(p.theta);
          let uz = Math.cos(p.phi);
          // Ближайшая вершина по dot-product
          let bestDot = -Infinity, bestI = 0;
          for (let i = 0; i < 4; i++) {
            const d = TET_VERTICES[i][0] * ux + TET_VERTICES[i][1] * uy + TET_VERTICES[i][2] * uz;
            if (d > bestDot) { bestDot = d; bestI = i; }
          }
          // Стягиваем направление к ближайшей вершине пропорционально tetMorphSmooth.
          const pullStrength = tetMorphSmooth * 0.48;
          const V = TET_VERTICES[bestI];
          ux += (V[0] - ux) * pullStrength;
          uy += (V[1] - uy) * pullStrength;
          uz += (V[2] - uz) * pullStrength;
          const ulen = Math.hypot(ux, uy, uz) || 1;
          ux /= ulen; uy /= ulen; uz /= ulen;
          // Проекция на грань тетраэдра
          let bestProj = 1e-6;
          for (let i = 0; i < 4; i++) {
            const d = -(TET_VERTICES[i][0] * ux + TET_VERTICES[i][1] * uy + TET_VERTICES[i][2] * uz);
            if (d > bestProj) bestProj = d;
          }
          const tetScale = r / (3 * bestProj);
          const tetX = ux * tetScale;
          const tetY = uy * tetScale;
          const tetZ = uz * tetScale;
          const k = Math.min(1, tetMorphSmooth);
          px += (tetX - px) * k;
          py += (tetY - py) * k;
          pz += (tetZ - pz) * k;
        }

        const x1 = px * cy + pz * sy;
        const z1 = -px * sy + pz * cy;
        const y2 = py * cx - z1 * sx;
        const z2 = py * sx + z1 * cx;

        if (z2 > -r * 0.9) {
          const scale = fov / (fov + z2 + r * 2);
          let screenX = W/2 + x1 * scale;
          let screenY = H/2 + y2 * scale;
          if (linkBurstActive) {
            const fallbackAngle = p.seed * 6.28318530718;
            const dirX = Math.abs(x1) + Math.abs(y2) > 0.001 ? x1 : Math.cos(fallbackAngle);
            const dirY = Math.abs(x1) + Math.abs(y2) > 0.001 ? y2 : Math.sin(fallbackAngle);
            const dirLen = Math.hypot(dirX, dirY) || 1;
            const exitDistance = Math.hypot(W, H) * (0.74 + burstVariance * 0.16);
            screenX = W / 2 + (dirX / dirLen) * exitDistance;
            screenY = H / 2 + (dirY / dirLen) * exitDistance;
          }
          
          const depthAlpha = Math.min(1, (z2 + r) / (r * 1.5));
          const lifeAlpha = Math.sin((p.life / p.maxLife) * Math.PI);
          const baseAlpha = depthAlpha * lifeAlpha * 0.7;
	          const typingPulse = typing * Math.max(0, Math.sin(time * 58 - p.stream * 40 + p.lane));
	          const thinkPulse = thinking * (0.5 + 0.5 * Math.sin(time * 10 + p.lane * 0.9));
	          let alpha = Math.min(1, baseAlpha * (1 + visualEnergy * 0.22 + audioWave * 0.42 + typingPulse * 0.75 + speaking * voiceEnergy * 1.05 + thinkPulse * 0.28));
	          if (linkBurstActive) {
	            alpha = Math.min(1, alpha * (0.45 + linkBurstFade * 1.18) + linkBurstFade * 0.50);
	          }

          let cursorPush = 0;
          if (pointer.strength > 0.001) {
            const dx = screenX - pointer.x;
            const dy = screenY - pointer.y;
            const d = Math.hypot(dx, dy);
            const fieldRadius = currentRadius * (0.26 + hoverActive * 0.06);
            if (d < fieldRadius) {
              const falloff = 1 - d / fieldRadius;
              cursorPush = falloff * falloff * pointer.strength;
              const inv = 1 / Math.max(1, d);
              const drift = fieldRadius * (0.16 + p.stream * 0.12) * cursorPush;
              screenX += dx * inv * drift + Math.sin(time * 18 + p.seed) * cursorPush * 8;
              screenY += dy * inv * drift + Math.cos(time * 14 + p.seed) * cursorPush * 8;
              alpha = Math.min(1, alpha + cursorPush * 0.24);
            }
          }

	          const bandFlash = surfaceWave * Math.max(0, Math.sin(p.theta * 3.0 + p.phi * 9.0 - surfaceWavePhase * Math.PI * 6.0));
	          const lift = audioBass * 18 + audioMid * 14 + audioHigh * 8 + audioWave * 34 + voiceEnergy * 48 + typingPulse * 30 + thinkPulse * 18 + cursorPush * 34 + bandFlash * 65 + linkBurstFade * 96;
	          const red = Math.min(255, palette.r + lift * (answering ? 0.20 : 0.55));
	          const green = Math.min(255, palette.g + lift * (answering ? 0.52 : 0.42) + speaking * voiceMid * 34);
	          const blue = Math.min(255, palette.b + lift * (answering ? 0.76 : 0.24) + audioHigh * 34 + speaking * voiceHigh * 48);
	          const dotSize = (0.70 + visualEnergy * 0.12 + audioWave * 0.22 + audioHigh * 0.06 + typingPulse * 0.35 + cursorPush * 0.8 + bandFlash * 0.24 + linkBurstFade * 1.05) * scale;
	          visible.push({ screenX, screenY, z: z2, alpha, dotSize, red, green, blue, cursorPush, typingPulse, speaking, bandFlash, audioWave });
	        }
      });

      visible.sort((a, b) => a.z - b.z);
      ctx.save();
      if (!linkBurstActive) {
        ctx.beginPath();
        ctx.arc(W / 2, H / 2, sphereMaskRadius * 1.012, 0, Math.PI * 2);
        ctx.clip();
      }
      ctx.globalCompositeOperation = "lighter";
      for (const p of visible) {
        if (p.cursorPush > 0.05 || p.typingPulse > 0.35 || p.speaking > 0.2 || p.bandFlash > 0.2 || p.audioWave > 0.18) {
          ctx.fillStyle = `rgba(${p.red|0}, ${p.green|0}, ${p.blue|0}, ${Math.min(0.18, p.alpha * (0.15 + p.audioWave * 0.06))})`;
          ctx.beginPath();
          ctx.arc(p.screenX, p.screenY, p.dotSize * (2.1 + p.cursorPush * 2.2 + p.bandFlash * 1.2 + p.audioWave * 0.60), 0, Math.PI * 2);
          ctx.fill();
        }
        ctx.fillStyle = `rgba(${p.red|0}, ${p.green|0}, ${p.blue|0}, ${p.alpha})`;
        ctx.beginPath();
        ctx.arc(p.screenX, p.screenY, p.dotSize, 0, Math.PI*2);
        ctx.fill();
      }
      ctx.restore();

      if (shockT >= 0 && shockT < 0.85) {
        const k = shockT / 0.85;
        const ribbonAlpha = shockAmp * (1 - k) * 0.42;
        ctx.save();
        ctx.beginPath();
        ctx.arc(W / 2, H / 2, sphereMaskRadius, 0, Math.PI * 2);
        ctx.clip();
        ctx.globalCompositeOperation = "lighter";
        for (let band = 0; band < 3; band++) {
          ctx.beginPath();
          const yLift = (band - 1) * sphereScreenRadius * 0.18 + Math.sin(k * Math.PI * 2 + band) * sphereScreenRadius * 0.05;
          for (let i = 0; i <= 96; i++) {
            const u = i / 96;
            const a = u * Math.PI * 2 + time * 1.2 + band * 2.12 + k * Math.PI * 1.35;
            const rr = sphereScreenRadius * (0.45 + 0.34 * Math.sin(u * Math.PI));
            const x = W / 2 + Math.cos(a) * rr;
            const y = H / 2 + yLift + Math.sin(a) * rr * (0.22 + band * 0.055);
            if (i === 0) ctx.moveTo(x, y);
            else ctx.lineTo(x, y);
          }
          ctx.strokeStyle = `rgba(156, 226, 255, ${ribbonAlpha * (0.75 + band * 0.16)})`;
          ctx.lineWidth = (compactScreen ? 0.8 : 1.1) + (1 - k) * 1.2;
          ctx.stroke();
        }
        ctx.restore();
      }

      raf = requestAnimationFrame(draw);
    };

    ctx.fillStyle = '#0e0e0e';
    ctx.fillRect(0, 0, env.W, env.H);
    draw();

    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("resize", env.resize);
      window.removeEventListener("pointermove", onPointerMove);
      window.removeEventListener("pointerleave", onPointerLeave);
      window.removeEventListener("blur", onPointerLeave);
      window.removeEventListener("darky-orb-hover", onOrbHover);
      window.removeEventListener("darky-orb-activated", onOrbActivated);
      window.removeEventListener("darky-orb-link-burst", triggerLinkBurst);
      window.removeEventListener("darky-activate", onSiteActivate);
      window.removeEventListener("darky-deactivate", onSiteDeactivate);
      try { delete window.orb; } catch (_) {}
      env.stop();
    };
  }, []);

  return <canvas ref={canvasRef} className="hero__canvas" />;
}
function AmbientCanvas() {
  const canvasRef = React.useRef(null);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    const env = setupCanvas(canvas);
    const ctx = env.ctx;
    window.addEventListener("resize", env.resize);

    // smoothed orb energy proxy (we don't have direct read, simulate via state)
    let arousal = 0.2;
    let target = 0.2;
    const onState = (e) => {
      const s = e.detail;
      target = s === "generating" ? 0.85
             : s === "climax" ? 1.2
             : s === "typing" ? 0.95
             : s === "speaking" ? 1.05
             : s === "waking" ? 0.7
             : s === "calming" ? 0.35
             : 0.2;
    };
    window.addEventListener("orb-state", onState);

    let sentColor = [180, 30, 45];
    const onSent = (e) => {
      const s = e.detail;
      sentColor =
        s === "positive" ? [220, 60, 75] :
        s === "technical" ? [120, 140, 170] :
        [180, 30, 45];
    };
    window.addEventListener("orb-sentiment", onSent);

    // ambient dust particles
    const N = 90;
    const dust = new Array(N);
    for (let i = 0; i < N; i++) {
      dust[i] = {
        x: Math.random(),
        y: Math.random(),
        vx: (Math.random() - 0.5) * 0.00025,
        vy: -0.00012 - Math.random() * 0.00018,
        r: 0.4 + Math.random() * 1.4,
        ph: Math.random() * Math.PI * 2,
        useAcc: Math.random() < 0.25,
      };
    }

    let raf;
    const t0 = performance.now();
    const draw = () => {
      const t = (performance.now() - t0) / 1000;
      const W = env.W, H = env.H;
      // smooth arousal
      arousal += (target - arousal) * 0.04;

      // ---- background gradient: deep crimson nebula radial ----
      ctx.clearRect(0, 0, W, H);
      const cx = W * 0.5, cy = H * 0.4;
      const farR = Math.max(W, H) * 1.4;
      const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, farR);
      const tint = sentColor;
      grad.addColorStop(0,    `rgba(${(tint[0]*0.30)|0},${(tint[1]*0.15)|0},${(tint[2]*0.18)|0},1)`);
      grad.addColorStop(0.25, `rgba(${(tint[0]*0.22)|0},${(tint[1]*0.10)|0},${(tint[2]*0.14)|0},1)`);
      grad.addColorStop(0.5,  `rgba(${(tint[0]*0.12)|0},${(tint[1]*0.05)|0},${(tint[2]*0.08)|0},1)`);
      grad.addColorStop(0.75, `rgba(${(tint[0]*0.05)|0},${(tint[1]*0.02)|0},${(tint[2]*0.03)|0},1)`);
      grad.addColorStop(1,    `rgba(6, 3, 4, 1)`);
      ctx.fillStyle = grad;
      ctx.fillRect(0, 0, W, H);

      // ---- wireframe grid: disabled ----

      // ---- distant stars ----
      ctx.save();
      ctx.globalCompositeOperation = "lighter";
      for (let i = 0; i < 80; i++) {
        const sx = ((Math.sin(i * 31.1) * 0.5 + 0.5) * W);
        const sy = ((Math.cos(i * 47.3) * 0.5 + 0.5) * H * 0.6);
        const flick = 0.4 + 0.6 * Math.abs(Math.sin(t * 0.5 + i));
        ctx.fillStyle = `rgba(255, 220, 220, ${0.18 * flick})`;
        ctx.beginPath();
        ctx.arc(sx, sy, 0.6, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.restore();

      // ---- floating dust motes ----
      ctx.save();
      ctx.globalCompositeOperation = "lighter";
      for (const d of dust) {
        d.x += d.vx; d.y += d.vy;
        if (d.y < -0.02) { d.y = 1.02; d.x = Math.random(); }
        if (d.x < -0.02) d.x = 1; else if (d.x > 1.02) d.x = 0;
        const flick = 0.5 + 0.5 * Math.sin(t * 1.2 + d.ph);
        const a = (0.10 + arousal * 0.18) * flick;
        const col = d.useAcc ? `255, 80, 100` : `255, 170, 175`;
        ctx.fillStyle = `rgba(${col},${a})`;
        ctx.beginPath();
        ctx.arc(d.x * W, d.y * H, d.r, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.restore();

      // ---- vignette ----
      const vg = ctx.createRadialGradient(W * 0.5, H * 0.5, Math.min(W, H) * 0.4,
                                          W * 0.5, H * 0.5, Math.max(W, H) * 0.75);
      vg.addColorStop(0, "rgba(0,0,0,0)");
      vg.addColorStop(1, "rgba(0,0,0,0.7)");
      ctx.fillStyle = vg;
      ctx.fillRect(0, 0, W, H);

      // ---- horizontal scanlines: disabled (was creating visible band) ----

      raf = requestAnimationFrame(draw);
    };
    draw();

    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("resize", env.resize);
      window.removeEventListener("orb-state", onState);
      window.removeEventListener("orb-sentiment", onSent);
    };
  }, []);

  return <canvas ref={canvasRef} className="ambient-canvas" />;
}

// =================================================================
// LIQUID VOLUME — a separate background layer for slow caustic light.
// It sits behind the original orb and does not alter OrbCanvas.
// =================================================================
function LiquidVolumeCanvas() {
  return null;
/*
  const canvasRef = React.useRef(null);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    const env = setupCanvas(canvas);
    const ctx = env.ctx;
    window.addEventListener("resize", env.resize);

    const prefersReduced = window.matchMedia &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const lsdColors = [
      "255,70,92",
      "255,158,126",
      "235,68,190",
      "152,86,255",
      "70,210,255",
      "255,236,170",
    ];
    const bands = new Array(36).fill(0).map((_, i) => ({
      phase: i * 0.73 + Math.random() * Math.PI * 2,
      amp: 0.18 + Math.random() * 0.35,
      width: 0.55 + Math.random() * 1.45,
      lift: Math.random(),
      drift: 0.08 + Math.random() * 0.26,
      color: lsdColors[i % lsdColors.length],
    }));
    const cells = new Array(18).fill(0).map((_, i) => ({
      x: 0.06 + ((i * 0.173) % 0.88),
      y: 0.08 + ((i * 0.319) % 0.70),
      r: 0.11 + Math.random() * 0.18,
      phase: Math.random() * Math.PI * 2,
      drift: 0.035 + Math.random() * 0.055,
      wobble: 0.10 + Math.random() * 0.18,
      colorA: lsdColors[i % lsdColors.length],
      colorB: lsdColors[(i + 2) % lsdColors.length],
    }));

    let raf;
    const t0 = performance.now();
    const drawRibbon = (band, index, W, H, t) => {
      const yBase = H * (0.08 + band.lift * 0.68);
      const xStart = -W * 0.12;
      const xEnd = W * 1.12;
      const steps = 92;
      const wobble = H * (0.014 + band.amp * 0.026);
      const sweep = Math.sin(t * band.drift + band.phase) * W * 0.055;
      const alpha = 0.014 + band.amp * 0.013;
      const color = band.color;

      ctx.beginPath();
      for (let s = 0; s <= steps; s++) {
        const u = s / steps;
        const x = xStart + (xEnd - xStart) * u + sweep;
        const y =
          yBase +
          Math.sin(u * Math.PI * 2.0 + t * 0.34 + band.phase) * wobble +
          Math.sin(u * Math.PI * 5.4 - t * 0.19 + index) * wobble * 0.52 +
          Math.sin(u * Math.PI * 11.0 + t * 0.11 + band.phase) * wobble * 0.18;
        if (s === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      }
      ctx.strokeStyle = `rgba(${color},${alpha})`;
      ctx.lineWidth = band.width;
      ctx.stroke();

      ctx.strokeStyle = `rgba(255,235,220,${alpha * 0.32})`;
      ctx.lineWidth = Math.max(0.35, band.width * 0.35);
      ctx.stroke();
    };

    const drawCell = (cell, index, W, H, t) => {
      const cx = W * (cell.x + Math.sin(t * cell.drift + cell.phase) * 0.035);
      const cy = H * (cell.y + Math.cos(t * cell.drift * 0.8 + cell.phase) * 0.030);
      const r = Math.min(W, H) * cell.r;
      const steps = 76;

      const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, r * 1.35);
      glow.addColorStop(0.00, `rgba(${cell.colorA},0.026)`);
      glow.addColorStop(0.44, `rgba(${cell.colorB},0.014)`);
      glow.addColorStop(1.00, "rgba(0,0,0,0)");
      ctx.fillStyle = glow;
      ctx.fillRect(cx - r * 1.35, cy - r * 1.35, r * 2.7, r * 2.7);

      for (let ring = 0; ring < 3; ring++) {
        const rr = r * (0.54 + ring * 0.22 + 0.025 * Math.sin(t * 0.11 + index + ring));
        ctx.beginPath();
        for (let s = 0; s <= steps; s++) {
          const u = s / steps;
          const a = u * Math.PI * 2;
          const warp =
            1 +
            Math.sin(a * 3 + t * 0.18 + cell.phase + ring) * cell.wobble +
            Math.sin(a * 7 - t * 0.13 + index) * cell.wobble * 0.42;
          const x = cx + Math.cos(a) * rr * warp;
          const y = cy + Math.sin(a) * rr * warp * (0.62 + ring * 0.07);
          if (s === 0) ctx.moveTo(x, y);
          else ctx.lineTo(x, y);
        }
        ctx.strokeStyle = `rgba(${ring % 2 ? cell.colorB : cell.colorA},${0.018 - ring * 0.003})`;
        ctx.lineWidth = 0.55 + ring * 0.28;
        ctx.stroke();
      }
    };

    const draw = () => {
      const tRaw = (performance.now() - t0) / 1000;
      const t = prefersReduced ? 0 : tRaw;
      const W = env.W, H = env.H;
      ctx.clearRect(0, 0, W, H);

      // soft volumetric pools, separate from the orb bloom
      ctx.save();
      ctx.globalCompositeOperation = "screen";
      const pool = ctx.createRadialGradient(W * 0.50, H * 0.38, 0, W * 0.50, H * 0.38, Math.max(W, H) * 0.72);
      pool.addColorStop(0.00, "rgba(255,95,110,0.060)");
      pool.addColorStop(0.34, "rgba(190,28,46,0.040)");
      pool.addColorStop(0.72, "rgba(68,8,20,0.020)");
      pool.addColorStop(1.00, "rgba(0,0,0,0)");
      ctx.fillStyle = pool;
      ctx.fillRect(0, 0, W, H);

      const side = ctx.createRadialGradient(W * 0.18, H * 0.18, 0, W * 0.18, H * 0.18, Math.max(W, H) * 0.62);
      side.addColorStop(0.00, "rgba(255,210,185,0.045)");
      side.addColorStop(0.42, "rgba(255,70,92,0.028)");
      side.addColorStop(1.00, "rgba(0,0,0,0)");
      ctx.fillStyle = side;
      ctx.fillRect(0, 0, W, H);
      ctx.restore();

      // slow LSD caustic cells: psychedelic volume without touching the orb
      ctx.save();
      ctx.globalCompositeOperation = "screen";
      ctx.shadowBlur = 24;
      ctx.shadowColor = "rgba(255,70,140,0.18)";
      ctx.lineCap = "round";
      ctx.lineJoin = "round";
      for (let i = 0; i < cells.length; i++) drawCell(cells[i], i, W, H, t);
      ctx.restore();

      // liquid caustic ribbons
      ctx.save();
      ctx.globalCompositeOperation = "lighter";
      ctx.shadowColor = "rgba(255,60,120,0.28)";
      ctx.shadowBlur = 16;
      ctx.lineCap = "round";
      ctx.lineJoin = "round";
      for (let i = 0; i < bands.length; i++) drawRibbon(bands[i], i, W, H, t);
      ctx.restore();

      // faint vertical volume shafts, like light passing through fluid
      ctx.save();
      ctx.globalCompositeOperation = "screen";
      for (let i = 0; i < 7; i++) {
        const u = (i + 0.5) / 7;
        const x = W * (0.08 + u * 0.84 + Math.sin(t * 0.12 + i) * 0.025);
        const shaftW = W * (0.08 + 0.03 * Math.sin(i));
        const g = ctx.createLinearGradient(x - shaftW, 0, x + shaftW, H);
        const a = 0.012 + 0.010 * Math.sin(t * 0.21 + i * 1.7);
        g.addColorStop(0.00, "rgba(255,220,205,0)");
        g.addColorStop(0.34, `rgba(255,160,150,${a})`);
        g.addColorStop(0.62, `rgba(255,44,64,${a * 0.55})`);
        g.addColorStop(1.00, "rgba(255,44,64,0)");
        ctx.fillStyle = g;
        ctx.fillRect(x - shaftW, 0, shaftW * 2, H);
      }
      ctx.restore();

      raf = requestAnimationFrame(draw);
    };

    draw();
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("resize", env.resize);
      env.stop();
    };
  }, []);

  return <canvas ref={canvasRef} className="hero__liquid-canvas" />;
*/
}

// alias kept for compatibility
const HeroCanvas = OrbCanvas;

// =================================================================
// MANIFESTO — flowing topographic field
// =================================================================
function ContourCanvas() {
  const canvasRef = React.useRef(null);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    const env = setupCanvas(canvas);
    const ctx = env.ctx;
    window.addEventListener("resize", env.resize);

    let raf;
    const t0 = performance.now();
    const draw = () => {
      const t = (performance.now() - t0) / 1000;
      const W = env.W, H = env.H;
      ctx.clearRect(0, 0, W, H);

      const vg = ctx.createLinearGradient(0, 0, W, 0);
      vg.addColorStop(0, "rgba(11,12,14,1)");
      vg.addColorStop(0.1, "rgba(11,12,14,0)");
      vg.addColorStop(0.9, "rgba(11,12,14,0)");
      vg.addColorStop(1, "rgba(11,12,14,1)");

      const lines = 32;
      for (let i = 0; i < lines; i++) {
        const u = i / (lines - 1);
        const yBase = u * H;
        const phase = u * Math.PI * 2;

        ctx.beginPath();
        for (let x = 0; x <= W; x += 3) {
          const ux = x / W;
          const wave =
            Math.sin(ux * 7 + t * 0.45 + phase * 1.2) * 10 +
            Math.sin(ux * 2.3 - t * 0.3 + phase * 0.6) * 24 +
            Math.cos(ux * 13 + t * 0.18 + i) * 4 +
            Math.sin(ux * 1.1 + t * 0.7) * 6;
          const y = yBase + wave * (0.6 + Math.sin(u * Math.PI) * 0.7);
          if (x === 0) ctx.moveTo(x, y);
          else ctx.lineTo(x, y);
        }
        const dist = Math.abs(u - 0.5) * 2;
        const isHero = i === Math.floor(lines / 2);
        const alpha = (1 - dist * 0.85) * 0.28;
        ctx.strokeStyle = isHero
          ? `rgba(212, 240, 80, 0.55)`
          : `rgba(232, 228, 220, ${Math.max(0.04, alpha)})`;
        ctx.lineWidth = isHero ? 1.25 : 0.7;
        ctx.stroke();
      }

      ctx.fillStyle = vg;
      ctx.fillRect(0, 0, W, H);

      raf = requestAnimationFrame(draw);
    };
    draw();
    return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", env.resize); };
  }, []);
  return <canvas ref={canvasRef} style={{ width: "100%", height: "100%", display: "block" }} />;
}

// =================================================================
// PILLAR MINI-GRAPHICS
// =================================================================
function PillarRadar() {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const c = ref.current;
    const env = setupCanvas(c);
    const ctx = env.ctx;
    window.addEventListener("resize", env.resize);
    let raf, t0 = performance.now();
    const draw = () => {
      const t = (performance.now() - t0) / 1000;
      const W = env.W, H = env.H;
      ctx.clearRect(0, 0, W, H);
      const cx = W / 2, cy = H / 2;
      const maxR = Math.min(W, H) * 0.46;

      for (let i = 1; i <= 5; i++) {
        const r = (i / 5) * maxR;
        ctx.strokeStyle = `rgba(232,228,220,${0.08 + (i === 3 ? 0.1 : 0)})`;
        ctx.lineWidth = i === 3 ? 1 : 0.6;
        ctx.beginPath();
        ctx.arc(cx, cy, r, 0, Math.PI * 2);
        ctx.stroke();
      }
      ctx.strokeStyle = "rgba(232,228,220,0.08)";
      ctx.beginPath();
      ctx.moveTo(cx - maxR, cy); ctx.lineTo(cx + maxR, cy);
      ctx.moveTo(cx, cy - maxR); ctx.lineTo(cx, cy + maxR);
      ctx.stroke();

      const sweepAngle = (t * 0.7) % (Math.PI * 2);
      const grad = ctx.createConicGradient
        ? ctx.createConicGradient(sweepAngle, cx, cy)
        : null;
      if (grad) {
        grad.addColorStop(0, "rgba(212,240,80,0.35)");
        grad.addColorStop(0.08, "rgba(212,240,80,0.0)");
        grad.addColorStop(1, "rgba(212,240,80,0)");
        ctx.fillStyle = grad;
        ctx.beginPath();
        ctx.arc(cx, cy, maxR, 0, Math.PI * 2);
        ctx.fill();
      }

      ctx.strokeStyle = "rgba(212,240,80,0.85)";
      ctx.lineWidth = 1.2;
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.lineTo(cx + Math.cos(sweepAngle) * maxR, cy + Math.sin(sweepAngle) * maxR);
      ctx.stroke();

      const pings = [{ a: 0.7, r: 0.55 }, { a: 2.1, r: 0.32 }, { a: 4.0, r: 0.78 }];
      pings.forEach((p, i) => {
        const ang = p.a;
        const px = cx + Math.cos(ang) * maxR * p.r;
        const py = cy + Math.sin(ang) * maxR * p.r;
        const phase = (t * 0.7 + i * 0.4) % 2;
        const lit = phase < 0.3;
        ctx.fillStyle = lit ? "rgba(212,240,80,0.95)" : "rgba(232,228,220,0.5)";
        ctx.beginPath();
        ctx.arc(px, py, 2.2, 0, Math.PI * 2);
        ctx.fill();
        if (lit) {
          ctx.strokeStyle = `rgba(212,240,80,${1 - phase / 0.3})`;
          ctx.beginPath();
          ctx.arc(px, py, 2.2 + phase * 18, 0, Math.PI * 2);
          ctx.stroke();
        }
      });

      raf = requestAnimationFrame(draw);
    };
    draw();
    return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", env.resize); };
  }, []);
  return <canvas ref={ref} className="pillar__canvas" />;
}

function PillarNetwork() {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const c = ref.current;
    const env = setupCanvas(c);
    const ctx = env.ctx;
    window.addEventListener("resize", env.resize);

    const N = 22;
    const nodes = Array.from({ length: N }, () => ({
      x: Math.random(), y: Math.random(),
      vx: (Math.random() - 0.5) * 0.0006,
      vy: (Math.random() - 0.5) * 0.0006,
      r: 1.6 + Math.random() * 1.4,
      hi: Math.random() < 0.15,
    }));

    let raf;
    const draw = () => {
      const W = env.W, H = env.H;
      ctx.clearRect(0, 0, W, H);

      for (const n of nodes) {
        n.x += n.vx; n.y += n.vy;
        if (n.x < 0.05 || n.x > 0.95) n.vx *= -1;
        if (n.y < 0.05 || n.y > 0.95) n.vy *= -1;
      }
      for (let i = 0; i < N; i++) {
        for (let j = i + 1; j < N; j++) {
          const a = nodes[i], b = nodes[j];
          const dx = (a.x - b.x) * W, dy = (a.y - b.y) * H;
          const d = Math.hypot(dx, dy);
          const max = Math.min(W, H) * 0.32;
          if (d > max) continue;
          const alpha = (1 - d / max) * 0.4;
          ctx.strokeStyle = (a.hi || b.hi)
            ? `rgba(212,240,80,${alpha * 1.1})`
            : `rgba(232,228,220,${alpha * 0.5})`;
          ctx.lineWidth = 0.7;
          ctx.beginPath();
          ctx.moveTo(a.x * W, a.y * H);
          ctx.lineTo(b.x * W, b.y * H);
          ctx.stroke();
        }
      }
      for (const n of nodes) {
        ctx.fillStyle = n.hi ? "rgba(212,240,80,1)" : "rgba(232,228,220,0.85)";
        ctx.beginPath();
        ctx.arc(n.x * W, n.y * H, n.r, 0, Math.PI * 2);
        ctx.fill();
      }
      raf = requestAnimationFrame(draw);
    };
    draw();
    return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", env.resize); };
  }, []);
  return <canvas ref={ref} className="pillar__canvas" />;
}

function PillarMeter() {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const c = ref.current;
    const env = setupCanvas(c);
    const ctx = env.ctx;
    window.addEventListener("resize", env.resize);

    let raf, t0 = performance.now();
    const draw = () => {
      const t = (performance.now() - t0) / 1000;
      const W = env.W, H = env.H;
      ctx.clearRect(0, 0, W, H);

      const bars = 36;
      const padding = 6;
      const usable = W - padding * 2;
      const barW = usable / bars * 0.55;
      const gap = usable / bars * 0.45;

      for (let i = 0; i < bars; i++) {
        const u = i / (bars - 1);
        const v = 0.5 + 0.42 * Math.sin(u * 5 + t * 2.1) * (0.6 + 0.4 * Math.sin(u * 3.1 - t * 1.1));
        const h = Math.abs(v - 0.5) * H * 1.1 + 4;
        const x = padding + i * (barW + gap);
        const y = H / 2 - h / 2;
        const hi = i % 6 === 0;
        ctx.fillStyle = hi ? "rgba(212,240,80,0.95)" : "rgba(232,228,220,0.55)";
        ctx.fillRect(x, y, barW, h);
      }

      ctx.strokeStyle = "rgba(232,228,220,0.12)";
      ctx.lineWidth = 0.5;
      ctx.beginPath();
      ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2);
      ctx.stroke();

      raf = requestAnimationFrame(draw);
    };
    draw();
    return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", env.resize); };
  }, []);
  return <canvas ref={ref} className="pillar__canvas" />;
}

// =================================================================
// SCAN GRID
// =================================================================
function ScanGrid() {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const c = ref.current;
    const env = setupCanvas(c);
    const ctx = env.ctx;
    window.addEventListener("resize", env.resize);

    let raf, t0 = performance.now();
    const draw = () => {
      const t = (performance.now() - t0) / 1000;
      const W = env.W, H = env.H;
      ctx.clearRect(0, 0, W, H);

      const cell = 80;
      ctx.strokeStyle = "rgba(232,228,220,0.04)";
      ctx.lineWidth = 0.5;
      for (let x = 0; x < W; x += cell) {
        ctx.beginPath();
        ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
      }
      for (let y = 0; y < H; y += cell) {
        ctx.beginPath();
        ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
      }
      const sy = (Math.sin(t * 0.4) * 0.5 + 0.5) * H;
      const sg = ctx.createLinearGradient(0, sy - 60, 0, sy + 60);
      sg.addColorStop(0, "rgba(212,240,80,0)");
      sg.addColorStop(0.5, "rgba(212,240,80,0.08)");
      sg.addColorStop(1, "rgba(212,240,80,0)");
      ctx.fillStyle = sg;
      ctx.fillRect(0, sy - 60, W, 120);

      raf = requestAnimationFrame(draw);
    };
    draw();
    return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", env.resize); };
  }, []);
  return <canvas ref={ref} className="scan-grid" />;
}

// =================================================================
// TURING BACKGROUND — reaction-diffusion field, recoloured to match
//   the orb's red accent + the hero's blue nebula. Renders below the
//   orb with mix-blend-mode: lighten so it adds atmospheric accents
//   without flattening the existing gradient.
// =================================================================
function TuringBackground() {
  const canvasRef = React.useRef(null);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const gl = canvas.getContext("webgl", { antialias: false, premultipliedAlpha: false });
    if (!gl) return;

    const VERT = `attribute vec2 aPos; void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }`;
    const FRAG = `
precision highp float;
uniform vec2 uRes; uniform float uTime; uniform vec2 uMouse;

float hash(vec2 p){ return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }
float noise(vec2 p){ vec2 i=floor(p), f=fract(p);
  float a=hash(i), b=hash(i+vec2(1,0)), c=hash(i+vec2(0,1)), d=hash(i+vec2(1,1));
  vec2 u=f*f*(3.0-2.0*f); return mix(mix(a,b,u.x), mix(c,d,u.x), u.y); }
float fbm(vec2 p){ float v=0.,a=0.5; for(int i=0;i<6;i++){v+=a*noise(p);p=mat2(1.6,1.2,-1.2,1.6)*p;a*=0.55;} return v; }

// procedural star field — grid cells with random pos/size/twinkle
float starLayer(vec2 frag, float scale, float thresh, float sizeBase){
  vec2 g = floor(frag * scale);
  vec2 f = fract(frag * scale);
  float h = hash(g);
  float prob = step(thresh, h);
  vec2 sp = vec2(hash(g + vec2(1.13, 0.0)), hash(g + vec2(0.0, 7.21)));
  float d = length(f - sp);
  float sz = sizeBase * (0.6 + 0.4 * hash(g + vec2(3.7, 1.1)));
  float core = exp(-pow(d/sz, 2.0));
  float tw = 0.40 + 0.60 * pow(0.5 + 0.5 * sin(uTime * (0.6 + hash(g + vec2(11.0, 2.0)) * 1.8) + h * 6.2832), 2.0);
  return core * prob * tw;
}

void main(){
  vec2 frag = gl_FragCoord.xy;
  vec2 uv = frag / uRes.xy;

  // planet center & radius (matches graphics.jsx orb's cy = H*0.43 and baseR)
  vec2 orbCenter = vec2(uRes.x * 0.5, uRes.y * 0.57);
  float orbR = min(uRes.x, uRes.y) * 0.36;

  // normalised sphere coords inside the planet circle
  float dx = (frag.x - orbCenter.x) / orbR;
  float dy = (frag.y - orbCenter.y) / orbR;
  float r2 = dx*dx + dy*dy;
  float r = sqrt(r2);
  float planetMask = 1.0 - smoothstep(0.96, 1.04, r);

  // --- planet surface: sphere-mapped clouds + ocean/land, rotating ---
  vec3 planetCol = vec3(0.0);
  if (r2 < 1.20) {
    float dz = sqrt(max(0.0, 1.0 - r2));
    // slow planet spin (yaw) + fixed tilt (pitch)
    float yaw = uTime * 0.028;
    float ca = cos(yaw), sa = sin(yaw);
    vec3 sph = vec3(dx*ca + dz*sa, dy, -dx*sa + dz*ca);
    float pitch = 0.20;
    float cp = cos(pitch), sp = sin(pitch);
    sph = vec3(sph.x, sph.y*cp - sph.z*sp, sph.y*sp + sph.z*cp);

    // lat/lon mapping onto sphere (wraps cleanly around)
    vec2 puv = vec2(atan(sph.x, sph.z) * 0.42, sph.y * 1.20);

    // continents (lower-frequency)
    float surf = fbm(puv * 1.4 + vec2(0.5, 1.7));
    float landMask = smoothstep(0.40, 0.60, surf);

    // CLOUDS — three octaves, dense + saturated
    float cl1 = fbm(puv * 2.2 + vec2(uTime*0.018, 0.0));
    float cl2 = fbm(puv * 5.0 + cl1 * 1.6 + vec2(uTime*0.010, uTime*0.006));
    float cl3 = fbm(puv * 11.0 - cl2 * 0.9);
    float clouds = cl1 * 0.50 + cl2 * 0.55 + cl3 * 0.28;
    clouds = smoothstep(0.30, 0.75, clouds);

    // palette: deep blue ocean / deep red land / warm cloud tops
    vec3 oceanCol = vec3(0.045, 0.085, 0.235);
    vec3 landCol  = vec3(0.48, 0.09, 0.16);
    vec3 cloudCol = vec3(1.00, 0.65, 0.58);
    vec3 surface = mix(oceanCol, landCol, landMask);
    planetCol = mix(surface, cloudCol, clouds * 0.88);

    // limb darkening — makes the disc read as a sphere
    float limb = pow(dz, 0.55);
    planetCol *= 0.32 + 0.68 * limb;

    // atmospheric blue rim halo
    float halo = exp(-(1.0 - dz) * 2.6) * smoothstep(0.82, 1.0, r);
    planetCol += vec3(0.27, 0.58, 1.00) * halo * 0.65;

    planetCol = planetCol / (1.0 + planetCol * 0.35);
  }

  // --- star field (visible outside the planet) ---
  float s1 = starLayer(frag, 0.028, 0.992, 0.13);
  float s2 = starLayer(frag, 0.014, 0.992, 0.16);
  float s3 = starLayer(frag, 0.008, 0.994, 0.20);
  vec3 colCold = vec3(0.78, 0.86, 1.00);
  vec3 colWarm = vec3(1.00, 0.92, 0.78);
  vec3 colPink = vec3(1.00, 0.80, 0.85);
  vec3 stars = colCold * s1 * 0.85 + colWarm * s2 * 1.10 + colPink * s3 * 1.50;

  // --- composite: stars on black outside, planet inside ---
  vec3 col = mix(stars, planetCol, planetMask);

  // mild grain
  float gn = fract(sin(dot(uv*uRes, vec2(12.9898, 78.233))) * 43758.5453);
  col += (gn - 0.5) * 0.008;

  gl_FragColor = vec4(col, 1.0);
}`;

    const compile = (type, src) => {
      const sh = gl.createShader(type);
      gl.shaderSource(sh, src); gl.compileShader(sh);
      if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
        console.warn("turing shader compile fail", gl.getShaderInfoLog(sh));
        gl.deleteShader(sh);
        return null;
      }
      return sh;
    };
    const vs = compile(gl.VERTEX_SHADER, VERT);
    const fs = compile(gl.FRAGMENT_SHADER, FRAG);
    if (!vs || !fs) return;
    const prog = gl.createProgram();
    gl.attachShader(prog, vs); gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      console.warn("turing link fail", gl.getProgramInfoLog(prog));
      return;
    }

    const buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
    const attr = gl.getAttribLocation(prog, "aPos");
    const uRes = gl.getUniformLocation(prog, "uRes");
    const uTime = gl.getUniformLocation(prog, "uTime");
    const uMouse = gl.getUniformLocation(prog, "uMouse");

    const resize = () => {
      // background — cap DPR at 1 to save GPU; mix-blend hides aliasing
      const dpr = Math.min(window.devicePixelRatio || 1, 1.0);
      const w = Math.floor(window.innerWidth * dpr);
      const h = Math.floor(window.innerHeight * dpr);
      if (canvas.width !== w || canvas.height !== h) {
        canvas.width = w; canvas.height = h;
        gl.viewport(0, 0, w, h);
      }
    };
    window.addEventListener("resize", resize);
    resize();

    const mouse = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5 };
    const onMove = (e) => {
      const r = canvas.getBoundingClientRect();
      mouse.tx = (e.clientX - r.left) / r.width;
      mouse.ty = 1.0 - (e.clientY - r.top) / r.height;
    };
    window.addEventListener("mousemove", onMove);

    let raf;
    const t0 = performance.now() / 1000;
    let last = t0;
    const tick = () => {
      const now = performance.now() / 1000;
      const dt = Math.min(now - last, 1 / 30);
      last = now;
      const k = 1 - Math.exp(-dt * 6);
      mouse.x += (mouse.tx - mouse.x) * k;
      mouse.y += (mouse.ty - mouse.y) * k;

      gl.useProgram(prog);
      gl.bindBuffer(gl.ARRAY_BUFFER, buf);
      gl.enableVertexAttribArray(attr);
      gl.vertexAttribPointer(attr, 2, gl.FLOAT, false, 0, 0);
      gl.uniform2f(uRes, canvas.width, canvas.height);
      gl.uniform1f(uTime, now - t0);
      gl.uniform2f(uMouse, mouse.x, mouse.y);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);

    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("resize", resize);
      window.removeEventListener("mousemove", onMove);
      try {
        gl.deleteProgram(prog);
        gl.deleteBuffer(buf);
        gl.deleteShader(vs);
        gl.deleteShader(fs);
      } catch (_) {}
    };
  }, []);

  return <canvas ref={canvasRef} className="hero__turing" />;
}

Object.assign(window, {
  OrbCanvas, HeroCanvas, AmbientCanvas, LiquidVolumeCanvas, ContourCanvas,
  PillarRadar, PillarNetwork, PillarMeter,
  ScanGrid, TuringBackground,
});
