/* ───────────────────────────────────────────────────────────────────────────
   The Wivexx DOT — the volt period from `wivexx.`, a glossy candy "toy" orb.
   Rendered as a FAUX-3D disc in a WebGL fragment shader: the depth is PAINTED
   (a fixed top→bottom volume gradient, a big plastic sheen, a bottom bounce
   light, a small catch-light glint) with the light PINNED — it never relights,
   so the shading can't slosh around. All the life is MOVEMENT, in a small JS
   physics layer: squash & stretch, idle float + breathe, a quick anticipation
   squash before it speaks, a gentle LEAN toward the cursor (gaze = a lean, not
   a relight), an occasional blink (the catch-light winks), and a springy bounce
   whenever its state changes. It reads alive because it MOVES like it's alive.
   Reacts to assistant state:
     idle      → calm float + slow breathe
     listening → perks up + expanding ripple rings
     processing→ a cartoon satellite orbits the orb (tilted)
     speaking  → squashes & pulses to the voice level
   Falls back to a 2D canvas dot if WebGL is unavailable.
   ─────────────────────────────────────────────────────────────────────────── */
(function () {
  "use strict";

  const STATE = { idle: 0, listening: 1, processing: 2, speaking: 3 };

  // eye expressions — the single white optic reshapes to emote: [w, h, lid, curve]
  // (mirrors the minimalist overlay's _EXPR). Eased toward the target each frame.
  const EXPR = {
    neutral:    [1.00, 1.00, 0.00, 0.00],
    alert:      [1.12, 1.20, 0.00, 0.00],   // listening
    focused:    [1.06, 0.50, 0.06, 0.00],   // processing
    happy:      [1.10, 1.02, 0.00, -1.00],  // the excited ∩ arch, smaller
    surprised:  [1.72, 1.72, 0.00, 0.00],   // big round dot
    sleepy:     [1.00, 0.82, 0.60, 0.00],
    suspicious: [1.08, 0.44, 0.34, 0.00],
    squint:     [1.20, 0.30, 0.40, 0.00],   // one-eye-shut viewfinder squint (recording)
    angry:      [1.24, 1.02, 0.00, 1.15],   // the (old love) ‿ crescent — red mood reads the anger
    sad:        [0.92, 0.74, 0.30, -0.45],  // narrow, tall, droopy, slight frown
    excited:    [1.46, 1.36, 0.00, -1.00],  // big ∩ arch (the old happy ‿, flipped upside down)
    love:       [1.20, 1.15, 0.00, 0.00],   // a HEART — shape via u_eyeShape; w/h size it
  };
  const HEART = { love: 1 };   // emotions whose optic is a heart (u_eyeShape → 1)
  const STATE_EXPR = { idle: "neutral", listening: "alert", processing: "focused", speaking: "neutral" };
  // EMOTION MOOD colours (export-only tint): {c:[r,g,b] 0..1, amt 0..1}. No entry = volt.
  const MOOD = {
    angry:   { c: [0.95, 0.16, 0.10], amt: 0.72 },
    sad:     { c: [0.28, 0.46, 0.96], amt: 0.60 },
    excited: { c: [1.00, 0.62, 0.14], amt: 0.50 },
    love:    { c: [1.00, 0.34, 0.62], amt: 0.66 },
  };
  // GAZE bias per emotion [x,y] (eyes look down=sad, up=adoring, aside=suspicious)
  const GAZE = { sad: [0.00, -0.50], love: [0.00, 0.05], suspicious: [0.45, -0.10], surprised: [0.0, 0.12] };

  const VERT = `
    attribute vec2 p;
    void main(){ gl_Position = vec4(p, 0.0, 1.0); }
  `;

  const FRAG = `
    precision highp float;
    uniform vec2  u_res;
    uniform float u_time;
    uniform float u_state;    // 0 idle 1 listening 2 processing 3 speaking
    uniform float u_energy;   // smoothed 0..1 overall liveliness
    uniform float u_level;    // mic level 0..1 (speaking)
    uniform vec2  u_squash;   // (sx, sy) volume-preserving stretch
    uniform vec2  u_off;      // body centre offset, physical px (bob + lean)
    uniform vec2  u_look;     // light/highlight shift (look direction)
    uniform float u_blink;    // 0 open .. 1 winked (briefly hides the glint)
    uniform float u_glitch;   // 0..1 digital-glitch intensity (rare blip)
    uniform float u_rings[4]; // speaking: per-ring progress 0..1 (2 = inactive)
    uniform vec4  u_eye;      // optic shape: (w, h, lid, curve) — expressions
    uniform float u_holo;     // 0 volt .. 1 blue hologram (thinking)
    uniform vec3  u_mood;     // emotion tint colour (red=angry, blue=sad, pink=love …)
    uniform float u_moodAmt;  // 0 none .. 1 full tint (0 in normal use → live orb unchanged)
    uniform float u_express;  // 1 only during studio capture (enables the eye-socket); 0 live
    uniform float u_eyeShape; // 0 = normal optic .. 1 = HEART (love); 0 on the live idle orb
    uniform vec4  u_recolor;  // studio colour picker: (r,g,b, amount); 0 amt = brand volt

    const vec3 VOLT   = vec3(0.757, 0.969, 0.227);  // brand volt
    const vec3 VOLT_HI= vec3(0.86,  1.0,   0.42 );  // bright lime top
    const vec3 INK    = vec3(0.043, 0.043, 0.039);  // rim near-black
    const vec3 HOLO   = vec3(0.30, 0.62, 1.00);     // hologram blue (thinking)
    const vec3 HOLO_HI= vec3(0.62, 0.86, 1.00);
    const vec3 HOLO_LO= vec3(0.05, 0.16, 0.40);

    // soft ring contribution around radius rr with thickness w
    float ring(float d, float rr, float w){
      return smoothstep(w, 0.0, abs(d - rr));
    }

    void main(){
      vec2 frag = gl_FragCoord.xy;                      // gl_FragCoord.y is UP
      vec2 c = u_res * 0.5 + vec2(u_off.x, u_off.y);    // +off.y = move up
      vec2 pp = frag - c;                               // pp.y up = top of ball
      float mn = min(u_res.x, u_res.y);

      float t = u_time;
      float isProc = (u_state > 1.5 && u_state < 2.5) ? 1.0 : 0.0;
      vec3  coolCol = vec3(0.45, 0.92, 0.80);           // teal "thinking" tint
      float pulse = (u_state > 2.5) ? (0.04 + 0.10 * u_level) : 0.0;
      float R = mn * 0.330 * (1.0 + pulse);

      // GLITCH: tear ~random horizontal bands sideways (zero when u_glitch == 0)
      float gband = floor(pp.y / (R * 0.14));
      float gh = fract(sin(gband * 91.17 + floor(t * 20.0) * 13.7) * 43758.5453);
      pp.x += step(0.65, gh) * (fract(gh * 53.0) - 0.5) * R * 0.6 * u_glitch;

      // squashed sample space (volume-preserving stretch → squash & stretch)
      vec2 sq = vec2(max(u_squash.x, 0.2), max(u_squash.y, 0.2));
      vec2 q = pp / sq;
      float d = length(q);

      vec3 col = vec3(0.0);
      float alpha = 0.0;

      // faint volt ground glow — SECONDARY ACTION: it widens as the orb
      // squashes down and fades as the orb hops up off the ground
      float gy = -R * 1.05;
      float gw = R * 1.5 * (0.5 + 0.5 / max(u_squash.y, 0.3));
      vec2 gp = vec2(q.x, q.y - gy);
      float gd = length(vec2(gp.x / gw, gp.y / (R * 0.30)));
      float lift = clamp(u_off.y / (R * 0.5), 0.0, 1.0);
      float ground = smoothstep(1.0, 0.0, gd) * 0.10 * (1.0 - 0.7 * lift);
      col += VOLT * ground; alpha = max(alpha, ground);

      // listening: calm expanding ripple rings (unchanged)
      if (u_state > 0.5 && u_state < 1.5) {
        for (int i = 0; i < 3; i++){
          float ph = mod(t * 0.6 + float(i) * 0.7, 2.1);
          float rr = R * (1.06 + ph * 0.5);
          float fade = smoothstep(2.1, 0.0, ph) * 0.45;
          float rg = ring(d, rr, 2.0) * fade;
          col += VOLT * rg; alpha = max(alpha, rg);
        }
      }

      // speaking: continuous expanding ripple rings — SAME as the minimalist
      // overlay (3 rings, a touch faster than listening)
      if (u_state > 2.5) {
        for (int i = 0; i < 3; i++){
          float ph = mod(t * 0.9 + float(i) * 0.7, 2.1);
          float rr = R * (1.04 + ph * 0.45);
          float fade = smoothstep(2.1, 0.0, ph) * 0.6;
          float rg = ring(d, rr, 2.5) * fade;
          col += VOLT * rg; alpha = max(alpha, rg);
        }
      }

      // processing = HOLOGRAM mode: concentric blue bubble-shell rings around the
      // orb (the orbiting satellite is gone; the orb itself goes blue below)
      if (u_holo > 0.01) {
        for (int i = 0; i < 2; i++){
          float rr = R * (1.12 + float(i) * 0.13);
          float hopulse = 0.8 + 0.2 * sin(t * 2.4 - float(i));
          float rg = ring(d, rr, 2.0) * u_holo * (1.0 - float(i) * 0.4) * hopulse * 0.8;
          col += coolCol * rg; alpha = max(alpha, rg);   // teal-green thinking rings (not blue)
        }
      }

      // ── the dot: a faux-3D GLOSSY TOY disc — depth is PAINTED with a PINNED
      // light (it never relights → the shading can't slosh); gaze is a lean only
      float edge = smoothstep(R, R - 1.6, d);          // anti-aliased silhouette
      if (edge > 0.001) {
        vec2 uv = q / R;            // -1..1 across the disc, y up (fixed frame)
        float rr = d / R;           // 0 centre .. 1 rim

        // ── website-matched candy shading (mirrors wivexx.com's WivexxOrb) — a RADIAL
        // gloss gradient centred on the upper-left highlight: cream → lime → volt →
        // green → olive, exactly like radial-gradient(circle at 36% 30%, …). PINNED
        // (never relights) so the shading can't slosh; gaze stays a lean only.
        vec3 CREAM = vec3(0.957, 1.000, 0.824);   // #f4ffd2
        vec3 LIME  = vec3(0.863, 0.984, 0.431);   // #dcfb6e
        vec3 GREEN = vec3(0.541, 0.808, 0.118);   // #8ace1e
        vec3 OLIVE = vec3(0.353, 0.549, 0.078);   // #5a8c14  (a THIN candy edge only)

        // ── bright, EVEN candy body (matches wivexx.com's WivexxOrb): mostly volt,
        // a cream/lime lift toward the pinned upper-left highlight, and a GENTLE green
        // darkening ONLY at the rim (radial → symmetric, so there's no lopsided
        // 'dirty' darker side). No heavy belly shadow — the site orb reads clean/even.
        float hlD = length(uv - vec2(-0.30, 0.42));                 // distance from the light
        vec3 body = VOLT;
        body = mix(body, LIME,  smoothstep(0.62, 0.16, hlD));       // lime lift near the light
        body = mix(body, CREAM, smoothstep(0.30, 0.00, hlD));       // soft cream hotspot
        body = mix(body, GREEN, smoothstep(0.60, 1.00, rr) * 0.75); // gentle rim green (symmetric)
        body = mix(body, OLIVE, smoothstep(0.92, 1.00, rr) * 0.55); // thin candy edge
        body *= 1.0 - 0.05 * smoothstep(0.10, -1.0, uv.y);          // a whisper of lower roundness
        // clean little specular glint (small + bright — NOT a grey sheen cloud)
        float glint = smoothstep(0.20, 0.0, length((uv - vec2(-0.30, 0.44)) * vec2(1.0, 1.06)));
        body = mix(body, vec3(1.0), glint * 0.5);

        // thinking → stay GREEN (matches wivexx.com, whose mascot is green in every
        // state): no blue hologram recolour — just a whisper of a cooler teal-green
        // shimmer + a faint scanline so "processing" still reads as busy
        float lum = clamp(dot(body, vec3(0.35, 0.5, 0.15)), 0.0, 1.0);
        vec3 coolTint = mix(body, vec3(0.55, 0.95, 0.72), 0.32);
        body = mix(body, coolTint, u_holo * 0.35);
        body *= 1.0 - 0.05 * u_holo * step(0.5, fract(pp.y * 0.10 + t * 1.5));

        // EMOTION MOOD tint — recolour the orb toward a mood (angry red, sad blue, love
        // pink …) while preserving the glossy luminance structure. 0 in normal use.
        if (u_moodAmt > 0.001) {
          body = mix(body, mix(u_mood * 0.20, mix(u_mood, vec3(1.0), 0.45), lum), u_moodAmt * 0.85);
        }

        // USER ORB COLOUR (studio colour picker) — recolour toward the chosen hue while
        // keeping the glossy structure. Applied last so the user's pick wins over any mood
        // tint. amount is 0 on the live orb → brand volt is untouched.
        if (u_recolor.a > 0.001) {
          float lum2 = clamp(dot(body, vec3(0.35, 0.5, 0.15)), 0.0, 1.0);
          body = mix(body, mix(u_recolor.rgb * 0.20, mix(u_recolor.rgb, vec3(1.0), 0.45), lum2), u_recolor.a);
        }

        // the optic / "eye" — a soft white shape that EMOTES (shaped by u_eye:
        // width, height, top eyelid, happy ‿ crescent), slides toward the gaze,
        // and blinks shut by squashing. Smaller than the old catch-light, to
        // match the minimalist overlay.
        vec2 eyeC = vec2(u_look.x * 1.7, 0.06 + u_look.y * 2.05);
        // EXPRESSION contrast (export only): a soft dark socket so the white optic reads
        // on the bright body. emoting is 0 on the neutral live orb (no change).
        float emoting = u_express;   // u_express carries the eased socket strength (0 live / neutral)
        if (emoting > 0.001) {
          float sock = smoothstep(0.70, 0.10, length(uv - vec2(u_look.x * 0.85, 0.12 + u_look.y)));
          body *= 1.0 - 0.34 * clamp(emoting, 0.0, 1.0) * sock;
        }
        eyeC *= min(1.0, 0.62 / max(length(eyeC), 1e-4));
        float ew = 0.150 * max(0.12, u_eye.x);
        float eh = 0.150 * max(0.04, u_eye.y * (1.0 - 0.92 * u_blink));
        vec2 ep = uv - eyeC;
        float de = length(vec2(ep.x / ew, ep.y / eh));
        float eyeA = smoothstep(1.10, 0.55, de);
        float eyeGlow = smoothstep(2.7, 0.9, de);    // soft white bloom (website eye's glow)
        if (u_eye.z > 0.001) {                       // top eyelid droops down
          float lidY = eh * (1.0 - 2.0 * u_eye.z);
          eyeA *= smoothstep(lidY + eh * 0.20, lidY - eh * 0.02, ep.y);
        }
        if (u_eye.w > 0.001) {                        // happy ‿ crescent (carve the bottom away)
          float ed2 = length(vec2(ep.x / ew, (ep.y - eh * 0.95) / eh));
          eyeA = mix(eyeA, clamp(eyeA - smoothstep(1.10, 0.55, ed2), 0.0, 1.0), u_eye.w);
        } else if (u_eye.w < -0.001) {                // frown ∩ (carve the top → downturned/sad/cross)
          float ed2 = length(vec2(ep.x / ew, (ep.y + eh * 0.95) / eh));
          eyeA = mix(eyeA, clamp(eyeA - smoothstep(1.10, 0.55, ed2), 0.0, 1.0), -u_eye.w);
        }
        // HEART optic (love): implicit heart  (x²+y²−1)³ − x²·y³ < 0  → lobes up, point down.
        if (u_eyeShape > 0.001) {
          vec2 hp = vec2(ep.x / (ew * 1.34), (ep.y - eh * 0.16) / (eh * 1.34));
          float ha = hp.x * hp.x + hp.y * hp.y - 1.0;
          float hf = ha * ha * ha - hp.x * hp.x * hp.y * hp.y * hp.y;
          float heartA = smoothstep(0.05, -0.05, hf);
          eyeA = mix(eyeA, heartA, u_eyeShape);
        }
        body = mix(body, vec3(1.0), eyeGlow * 0.15 * (1.0 - u_blink));  // halo…
        body = mix(body, vec3(1.0), eyeA * 0.98);                        // …then crisp optic

        body *= (0.95 + 0.12 * u_energy);                // liveliness lift

        // a THIN clean candy edge (no black rim, no heavy darkening — the body already
        // rounds the rim; this just crisps the silhouette a touch)
        float edgeDark = smoothstep(0.93, 1.0, rr);
        body = mix(body, GREEN * 0.88, edgeDark * 0.35);

        col = mix(col, body, edge);
        alpha = max(alpha, edge);
      } else {
        // GLOW: a breathing volt aura hugging the disc — pulses on its own and
        // swells with energy / voice level (teal while thinking)
        float aura = smoothstep(R * 1.52, R * 0.99, d);
        float gI = 0.30 + 0.11 * (0.5 + 0.5 * sin(t * 1.4)) + 0.22 * u_energy + 0.30 * u_level;
        vec3 auraCol = mix(mix(VOLT, coolCol, u_holo * 0.6), u_mood, u_moodAmt * 0.7);
        col += auraCol * aura * gI; alpha = max(alpha, aura * gI);
      }

      // GLITCH chromatic split + scanlines (zero when u_glitch == 0)
      if (u_glitch > 0.001) {
        float gx = u_glitch * R * 0.05;
        float eR = smoothstep(R, R - 2.0, length(vec2((pp.x - gx) / sq.x, pp.y / sq.y)));
        float eB = smoothstep(R, R - 2.0, length(vec2((pp.x + gx) / sq.x, pp.y / sq.y)));
        col.r += max(0.0, eR - edge) * 0.7;
        col.b += max(0.0, eB - edge) * 0.7;
        alpha = max(alpha, max(eR, eB) * 0.5 * u_glitch);
        col *= 1.0 - 0.20 * u_glitch * step(0.5, fract(pp.y * 0.22 + t * 25.0));
      }

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

  function compile(gl, type, src) {
    const s = gl.createShader(type);
    gl.shaderSource(s, src); gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
      throw new Error(gl.getShaderInfoLog(s) || "shader compile failed");
    return s;
  }

  const Dot = {
    state: "idle",
    _energy: 0.0,
    _level: 0.0,
    _levelTarget: 0.0,
    _spk: 0.0,                  // slow speaking-amplitude envelope (smooth pulse)
    _raf: 0,
    _gl: null,
    _fallback: null,
    // ── physics (squash & stretch, float, look) ──────────────────────────────
    _sq: 0.0, _sqV: 0.0,        // squash scalar (+ = taller) + velocity (spring)
    _by: 0.0, _byV: 0.0,        // bob spring offset (reaction bounce)
    _lookX: 0.0, _lookY: 0.0,   // smoothed look direction
    _prevLvl: 0.0,              // last voice level (for syllable-onset bounces)
    _prev: "idle",
    // gaze + blink (abstract "life" — no face; the catch-light is the eye)
    _ptr: { x: 0, y: 0, t: -1e9 },   // last pointer position + timestamp (ms)
    _ptrWired: false,
    _sacX: 0.0, _sacY: 0.04,         // current idle fixation target (saccade)
    _sacT: 0.0,                      // countdown to next saccade (s)
    _blink: 0.0,                     // 0 open .. 1 winked
    _blinkAge: 0.0, _blinkCd: 2.0,   // blink envelope age + cooldown (s)
    _blinking: false,
    // glitch effect (rare digital blip) + speaking ripple rings
    _glitch: 0.0, _glitchCd: 2.0, _glitchAge: 0.0, _glitching: false,
    _rings: [], _ringArr: new Float32Array(4),
    // expressions (eye shape) + hologram (thinking) + idle doze reactions
    _eye: [1.0, 1.0, 0.0, 0.0], _exprBase: "neutral",
    _react: null, _reactT: 0.0, _vigCd: 5.0, _gadgetExpr: null, _gadgetActive: false,
    _holo: 0.0, _idleSince: 0.0, _dozing: false,
    _lookSeq: [], _lastVig: "",      // scripted idle glances + last vignette played
    _ttPrev: 0.0,                    // last physics tt (for dt)
    // "alive" micro-layers (see idle-animation research: layered motion — breath
    // base + weight shift + eye tremor + rare sighs — is what defeats the statue)
    _sighCd: 18.0, _sigh: 0.0,       // occasional deeper breath (slow swell)
    _msacT: 0.0, _msacX: 0.0, _msacY: 0.0,   // eye micro-saccade tremor
    _blinkDur: 0.15,                 // blink length (longer, heavier while dozing)
    _pulseSeq: [],                   // timed squash beats (workout squats, etc.)
    _humT: 0.0, _humTick: 0.0,       // humming: rhythmic ripple rings while idle
    _dozeSince: 0.0,                 // when the doze began (deep-sleep breathing)
    _minimal: false,           // bare-orb overlay: damp motion so it stays a
                               // clean circle that always overfills the clip
    _exporting: false,         // mascot-studio capture: suppress random blink/glitch/
                               // wander/doze so a recorded clip is clean + loopable

    init(canvas) {
      this.canvas = canvas;
      this._wirePointer();
      this._resetPhysics();
      let gl = null;
      try { gl = canvas.getContext("webgl", { premultipliedAlpha: false, alpha: true, antialias: true })
                 || canvas.getContext("experimental-webgl", { alpha: true }); } catch (e) {}
      if (!gl) { this._initFallback(canvas); return; }
      try {
        const prog = gl.createProgram();
        gl.attachShader(prog, compile(gl, gl.VERTEX_SHADER, VERT));
        gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, FRAG));
        gl.linkProgram(prog);
        if (!gl.getProgramParameter(prog, gl.LINK_STATUS))
          throw new Error(gl.getProgramInfoLog(prog) || "link failed");
        gl.useProgram(prog);
        const buf = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buf);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
        const loc = gl.getAttribLocation(prog, "p");
        gl.enableVertexAttribArray(loc);
        gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
        gl.enable(gl.BLEND);
        gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
        this._gl = gl;
        this._u = {
          res: gl.getUniformLocation(prog, "u_res"),
          time: gl.getUniformLocation(prog, "u_time"),
          state: gl.getUniformLocation(prog, "u_state"),
          energy: gl.getUniformLocation(prog, "u_energy"),
          level: gl.getUniformLocation(prog, "u_level"),
          squash: gl.getUniformLocation(prog, "u_squash"),
          off: gl.getUniformLocation(prog, "u_off"),
          look: gl.getUniformLocation(prog, "u_look"),
          blink: gl.getUniformLocation(prog, "u_blink"),
          glitch: gl.getUniformLocation(prog, "u_glitch"),
          rings: gl.getUniformLocation(prog, "u_rings[0]"),
          eye: gl.getUniformLocation(prog, "u_eye"),
          holo: gl.getUniformLocation(prog, "u_holo"),
          mood: gl.getUniformLocation(prog, "u_mood"),
          moodAmt: gl.getUniformLocation(prog, "u_moodAmt"),
          express: gl.getUniformLocation(prog, "u_express"),
          eyeShape: gl.getUniformLocation(prog, "u_eyeShape"),
          recolor: gl.getUniformLocation(prog, "u_recolor"),
        };
        this._t0 = performance.now();
        this._loop();
      } catch (e) {
        console.warn("WivexxDot WebGL init failed, using 2D fallback:", e);
        this._initFallback(canvas);
      }
    },

    _resetPhysics() {
      this._sq = this._sqV = this._by = this._byV = 0.0;
      this._lookX = this._lookY = 0.0;
      this._shakeT = 0.0; this._shakeAmp = 0.0; this._shakeFreq = 22.0;   // transient body shake
      this._tiltT = 0.0; this._tilt = 0.0;                                // head-tilt lean (deg)
      this._sacX = 0.0; this._sacY = 0.04; this._sacT = 0.0;
      this._blink = 0.0; this._blinkAge = 0.0; this._blinkCd = 2.0; this._blinking = false;
      this._sighCd = 18.0; this._sigh = 0.0; this._blinkDur = 0.15;
      this._msacT = 0.0; this._msacX = 0.0; this._msacY = 0.0;
      this._pulseSeq = []; this._humT = 0.0; this._humTick = 0.0; this._dozeSince = 0.0;
      this._eye = (EXPR[STATE_EXPR[this.state] || "neutral"] || EXPR.neutral).slice();
      this._eyeShape = 0.0;   // 0 = normal optic, eases to 1 for a heart (love)
      this._exprBase = STATE_EXPR[this.state] || "neutral";
      this._react = null; this._reactT = 0.0; this._holo = 0.0; this._dozing = false;
      this._prev = this.state;
    },

    // track the pointer so the orb can actually LOOK at the cursor — gaze is the
    // strongest "someone's home" cue on a faceless shape. Falls back to idle
    // saccadic wander when the pointer hasn't moved for a couple of seconds.
    _wirePointer() {
      if (this._ptrWired) return;
      this._ptrWired = true;
      const on = (e) => { this._ptr.x = e.clientX; this._ptr.y = e.clientY; this._ptr.t = performance.now(); };
      window.addEventListener("mousemove", on, { passive: true });
      window.addEventListener("mousedown", on, { passive: true });
    },

    setState(s) {
      if (STATE[s] === undefined || s === this.state) return;
      const prev = this.state;
      this.state = s;
      // anticipation + reaction: a quick squash-down then a springy hop. Going
      // into "speaking" gets a small wind-up so it feels like it takes a breath.
      const hard = (s === "speaking") ? 0.8 : 0.6;
      this._sqV -= 0.42 * hard;      // squash down (anticipation)
      this._byV += 0.045 * hard;     // little upward hop (reaction bounce)
      this._exprBase = STATE_EXPR[s] || "neutral";
      this._dozing = false;
      this._lookSeq.length = 0;        // drop any idle glance-around in progress
      if (s === "listening" && (prev === "idle" || prev === "speaking"))
        this.react("surprised", 0.55);   // a startled "?" perk on wake
      else if (s === "speaking" && prev === "processing")
        this.react("happy", 0.7);        // "got it!" — brightens as it starts answering
      else if (s === "processing" && prev === "listening")
        this.react("focused", 0.6);      // settles in to think
    },

    // play a transient eye expression that overrides the state default briefly —
    // wire to events (task done → "happy", error → "suspicious", etc.)
    react(name, hold) {
      if (EXPR[name]) { this._react = name; this._reactT = Math.max(this._reactT, hold || 0.7); }
    },

    // give the orb a transient body shake — a sneeze, a shiver, a "no" wobble
    shake(amp, dur, freq) {
      this._shakeAmp = amp || 0.05; this._shakeT = Math.max(this._shakeT || 0, dur || 0.4);
      this._shakeFreq = freq || 22.0;
    },
    // ── public "reactions" the app can fire on events (feels alive) ──────────────
    emote(name, hold) { this.react(name, hold); },                 // any EXPR by name
    celebrate() { this.react("excited", 1.6); this._byV += 0.06; this._sqV -= 0.05; this.shake(0.02, 0.5, 16); },
    cheer() { this.react("happy", 1.2); this._byV += 0.045; },     // task done / success
    oops() { this.react("suspicious", 1.2); this.shake(0.03, 0.45, 24); },  // an error
    nodYes() { this.react("happy", 0.9); this._lookSeq = [{ x: 0, y: -0.16, t: 0.24 }, { x: 0, y: 0.12, t: 0.24 }, { x: 0, y: 0.02, t: 0.3 }]; },
    shakeNo() { this.react("suspicious", 1.0); this._lookSeq = [{ x: 0.34, y: 0, t: 0.2 }, { x: -0.34, y: 0, t: 0.2 }, { x: 0.2, y: 0, t: 0.2 }, { x: 0, y: 0.04, t: 0.3 }]; this.shake(0.02, 0.5, 26); },

    // a SUSTAINED "gadget" pose — recording (squints through a viewfinder),
    // clicker / playback (no eye override; the body shake lives in app.css). Held
    // until cleared, and it beats the per-state expression but yields to a react().
    gadget(name) {
      this._gadgetActive = !!name;
      this._gadgetExpr = (name === "recording") ? "squint"
        : (name === "playback") ? "focused" : null;
    },

    // ── MASCOT STUDIO: record an animation → array of TRANSPARENT PNG data-URLs ──
    // Renders the orb deterministically (export guards suppress the random life) at a fixed
    // timestep onto its own backbuffer, grabbing each frame WITH alpha via toDataURL in the
    // same synchronous task as the draw — so the GL buffer is still intact even though the
    // context has no preserveDrawingBuffer. opts: {state, expr, talk, fps, duration, size}.
    captureClip(opts) {
      opts = opts || {};
      const gl = this._gl;
      if (!gl || !this._u) return null;                  // 2D fallback can't export GL
      const fps = Math.max(1, opts.fps || 30);
      const size = Math.max(64, opts.size || 540);
      if (this._raf) { cancelAnimationFrame(this._raf); this._raf = 0; }   // pause the live loop
      const saved = { state: this.state, w: this.canvas.width, h: this.canvas.height, minimal: this._minimal };
      this._exporting = true;
      this.canvas.width = size; this.canvas.height = size;
      gl.viewport(0, 0, size, size);
      this._resetPhysics();
      // BEATS: a sequence (opts.beats=[{emo,motion,talk,dur}]) OR one beat from the single opts.
      const beats = (opts.beats && opts.beats.length) ? opts.beats.map((b) => ({
        emo: (b.emo && EXPR[b.emo]) ? b.emo : null, motion: b.motion || "none",
        talk: !!b.talk, dur: Math.max(0.2, b.dur || 1),
      })) : [{ emo: (opts.expr && EXPR[opts.expr]) ? opts.expr : null, motion: opts.motion || "none",
               talk: !!opts.talk, dur: Math.max(0.2, opts.duration || 3) }];
      const totalDur = beats.reduce((a, b) => a + b.dur, 0);
      const N = Math.max(1, Math.round(fps * totalDur));
      const cum = []; { let a = 0; for (let k = 0; k < beats.length; k++) { a += beats[k].dur; cum.push(a); } }
      const beatAt = (tt) => { for (let k = 0; k < beats.length; k++) if (tt < cum[k]) return beats[k]; return beats[beats.length - 1]; };
      const idleState = (opts.state && STATE[opts.state] !== undefined && opts.state !== "speaking") ? opts.state : "idle";
      if (beats[0].emo) this._eye = EXPR[beats[0].emo].slice();
      const mCol = [0.757, 0.969, 0.227]; let mAmt = 0.0;   // mood eases volt → the beat's target
      let gzX = 0, gzY = 0; let sockAmt = 0.0;               // emotional gaze + eased eye-socket strength
      // optional user orb colour (studio colour picker): opts.color = [r,g,b] in 0..1.
      const rc = (opts.color && opts.color.length === 3) ? [opts.color[0], opts.color[1], opts.color[2], 1.0] : [0, 0, 0, 0];
      // optional PROP "in hand": a user image composited in front of the orb, bobbing with it.
      // opts.prop = { img, x, y, scale, rot(deg), bob }. When present, frames are flattened via a
      // 2D canvas so the prop's own alpha is preserved alongside the transparent orb.
      const prop = (opts.prop && opts.prop.img) ? {
        img: opts.prop.img,
        x: (opts.prop.x != null ? opts.prop.x : 0.5),
        y: (opts.prop.y != null ? opts.prop.y : 0.72),
        scale: (opts.prop.scale != null ? opts.prop.scale : 0.46),
        rot: (opts.prop.rot || 0) * Math.PI / 180,
        bob: opts.prop.bob !== false,
      } : null;
      let comp = null, cctx = null;
      if (prop) { comp = document.createElement("canvas"); comp.width = size; comp.height = size; cctx = comp.getContext("2d"); }
      this._energy = 0.5; this._level = 0; this._ttPrev = 0;
      const frames = [];
      for (let i = 0; i < N; i++) {
        const t = i / fps, b = beatAt(t);
        if (b.emo) { this._react = b.emo; this._reactT = 1e9; }             // hold the beat's emotion (eye eases)
        else { this._react = null; this._reactT = 0; this._exprBase = STATE_EXPR[idleState] || "neutral"; }
        if (b.talk) { this.state = "speaking"; this._level = 0.30 + 0.40 * Math.abs(Math.sin(t * 7.3)) * (0.6 + 0.4 * Math.sin(t * 2.1)); }
        else { this.state = idleState; this._level = 0; }
        const tm = (b.emo && MOOD[b.emo]) ? MOOD[b.emo] : { c: [0.757, 0.969, 0.227], amt: 0.0 };
        mAmt += (tm.amt - mAmt) * 0.14;                                     // cross-fade the mood colour
        for (let k = 0; k < 3; k++) mCol[k] += (tm.c[k] - mCol[k]) * 0.14;
        const tg = (b.emo && GAZE[b.emo]) ? GAZE[b.emo] : [0, 0];           // emotional gaze
        gzX += (tg[0] - gzX) * 0.12; gzY += (tg[1] - gzY) * 0.12;
        sockAmt += (((b.emo && b.emo !== "neutral") ? 1.0 : 0.0) - sockAmt) * 0.14;  // socket on for any emotion
        const tgtE = (this.state === "idle") ? 0.18 : (this.state === "listening") ? 0.7
                   : (this.state === "processing") ? 0.55 : 0.55 + 0.45 * this._level;
        this._energy += (tgtE - this._energy) * 0.06;
        gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
        const ph = this._physics(t, size);
        const A = size, m = b.motion;                                      // body-motion presets
        if (m === "shake") ph.offX += Math.sin(t * 16) * A * 0.05;
        else if (m === "nod") ph.offY += Math.sin(t * 8) * A * 0.05;
        else if (m === "bounce") ph.offY += Math.abs(Math.sin(t * 3.4)) * A * 0.11;
        else if (m === "wiggle") { ph.offX += Math.sin(t * 10) * A * 0.03; ph.offY += Math.sin(t * 7 + 1) * A * 0.02; }
        else if (m === "lean") { ph.lookX = Math.sin(t * 1.7) * 0.45; ph.offX += ph.lookX * A * 0.05; }
        ph.lookX += gzX; ph.lookY += gzY;                                  // emotional gaze bias
        gl.uniform2f(this._u.res, size, size);
        gl.uniform1f(this._u.time, t);
        gl.uniform1f(this._u.state, STATE[this.state] || 0);
        gl.uniform1f(this._u.energy, this._energy);
        gl.uniform1f(this._u.level, this._level);
        gl.uniform2f(this._u.squash, ph.sx, ph.sy);
        gl.uniform2f(this._u.off, ph.offX, ph.offY);
        gl.uniform2f(this._u.look, ph.lookX, ph.lookY);
        gl.uniform1f(this._u.blink, ph.blink);
        gl.uniform1f(this._u.glitch, ph.glitch);
        gl.uniform1fv(this._u.rings, ph.rings);
        gl.uniform4f(this._u.eye, ph.eye[0] * 1.55, ph.eye[1] * 1.55, ph.eye[2], ph.eye[3]);  // bigger, expressive eye for export
        gl.uniform1f(this._u.holo, ph.holo);
        gl.uniform3f(this._u.mood, mCol[0], mCol[1], mCol[2]);
        gl.uniform1f(this._u.moodAmt, mAmt);
        gl.uniform1f(this._u.express, sockAmt);           // eased eye-socket contrast (0 on neutral)
        gl.uniform1f(this._u.eyeShape, this._eyeShape);   // heart for love (eased in _physics)
        gl.uniform4f(this._u.recolor, rc[0], rc[1], rc[2], rc[3]);  // user orb colour
        gl.drawArrays(gl.TRIANGLES, 0, 3);
        if (prop) {                                        // flatten orb + held prop onto a 2D canvas
          cctx.clearRect(0, 0, size, size);
          cctx.drawImage(this.canvas, 0, 0);               // same task → GL buffer still valid
          const iw = prop.img.width || prop.img.naturalWidth || 1;
          const ih = prop.img.height || prop.img.naturalHeight || 1;
          const pw = size * prop.scale, ph2 = pw * (ih / iw);
          const bobY = prop.bob ? (ph.offY * 0.85 + Math.sin(t * 2.4) * size * 0.012) : 0;
          const bobX = prop.bob ? ph.offX * 0.85 : 0;
          const swayR = prop.bob ? Math.sin(t * 1.7) * 0.045 : 0;
          cctx.save();
          cctx.translate(prop.x * size + bobX, prop.y * size + bobY);
          cctx.rotate(prop.rot + swayR);
          cctx.drawImage(prop.img, -pw / 2, -ph2 / 2, pw, ph2);
          cctx.restore();
          frames.push(comp.toDataURL("image/png"));
        } else {
          frames.push(this.canvas.toDataURL("image/png"));  // RGBA, same task → alpha intact
        }
      }
      // restore + resume the live orb
      this._exporting = false;
      this.canvas.width = saved.w; this.canvas.height = saved.h;
      this.state = saved.state; this._minimal = saved.minimal;
      this._resetPhysics();
      this._t0 = performance.now();
      this._loop();
      return { fps: fps, size: size, count: frames.length, frames: frames };
    },

    // a small "alive" moment while idle: an eye expression + a bit of physical
    // motion (hop / stretch / wiggle) and sometimes a scripted glance around the
    // room. Weighted, never the same one twice in a row, so it never reads as a
    // loop or a screensaver.
    _idleVignette() {
      const pool = [
        { w: 15, k: "glance" }, { w: 12, k: "happy" }, { w: 10, k: "perk" },
        { w: 8, k: "stretch" }, { w: 9, k: "curious" }, { w: 8, k: "wiggle" },
        { w: 6, k: "blinkx2" }, { w: 5, k: "suspicious" },
        // —— extra personality beats (more life + movement) ——
        { w: 11, k: "bounce" }, { w: 10, k: "tilt" }, { w: 9, k: "ponder" },
        { w: 8, k: "shimmy" }, { w: 7, k: "nod" },
        // —— NEW: "doing different things" while idle ——
        { w: 10, k: "lookaround" }, { w: 5, k: "sneeze" }, { w: 6, k: "hiccup" },
        { w: 8, k: "peek" }, { w: 6, k: "doubletake" }, { w: 7, k: "yawn" },
        { w: 9, k: "boing" }, { w: 5, k: "shiver" }, { w: 7, k: "gazeup" },
        { w: 8, k: "wobble" }, { w: 5, k: "dizzy" }, { w: 6, k: "sniff" },
        // —— NEW: little scenes with a beginning, middle and end ——
        { w: 7, k: "chase" },     // follows an imaginary fly, then pounces
        { w: 6, k: "hum" },       // hums to itself (ripple rings, no voice)
        { w: 5, k: "daydream" },  // drifts off gazing upward, heart-eyed
        { w: 5, k: "workout" },   // three quick squats, then stands proud
      ];
      // time-of-day mood: sleepy beats late at night, bouncy ones in the morning
      const hr = new Date().getHours();
      const late = (hr >= 22 || hr < 6), morning = (hr >= 6 && hr < 11);
      for (const p of pool) {
        if (late) {
          if (p.k === "yawn" || p.k === "stretch" || p.k === "blinkx2" || p.k === "daydream") p.w += 6;
          if (p.k === "bounce" || p.k === "boing" || p.k === "shimmy" || p.k === "workout") p.w = Math.max(1, p.w - 4);
        } else if (morning) {
          if (p.k === "bounce" || p.k === "boing" || p.k === "workout" || p.k === "hum") p.w += 4;
          if (p.k === "yawn" || p.k === "daydream") p.w = Math.max(1, p.w - 3);
        }
      }
      let total = 0;
      for (const p of pool) if (p.k !== this._lastVig) total += p.w;
      let x = Math.random() * total, pick = "happy";
      for (const p of pool) { if (p.k === this._lastVig) continue; x -= p.w; if (x <= 0) { pick = p.k; break; } }
      this._lastVig = pick;
      const side = Math.random() < 0.5 ? -1 : 1;
      switch (pick) {
        case "glance":                                          // look around the room
          this._lookSeq = [{ x: side * 0.32, y: 0.06, t: 0.9 },
                           { x: -side * 0.30, y: -0.04, t: 0.9 },
                           { x: 0.0, y: 0.04, t: 0.7 }];
          this.react("alert", 1.0); this._blinking = true; this._blinkAge = 0.0; break;
        case "happy": this.react("happy", 1.4); this._byV += 0.030; break;        // a little hop
        case "perk": this.react("alert", 1.0); this._byV += 0.022;                // ears-up, look up
          this._lookSeq = [{ x: 0, y: 0.20, t: 0.6 }, { x: 0, y: 0.04, t: 0.5 }]; break;
        case "stretch": this.react("sleepy", 1.6); this._sqV += 0.12; break;      // a slow yawn/stretch
        case "curious": this.react("surprised", 0.9); this._byV += 0.015;         // "huh?" toward a spot
          this._lookSeq = [{ x: side * 0.40, y: 0.12, t: 0.8 }, { x: 0, y: 0.04, t: 0.5 }]; break;
        case "wiggle": this.react("happy", 0.9); this._byV += 0.028; this._sqV -= 0.06; break; // quick squish+hop
        case "blinkx2": this._blinking = true; this._blinkAge = 0.0; this._blinkCd = 0.20; break; // double blink
        case "suspicious": this.react("suspicious", 1.3);                         // narrowed side-eye
          this._lookSeq = [{ x: side * 0.30, y: -0.04, t: 1.1 }, { x: 0, y: 0.04, t: 0.6 }]; break;
        case "bounce": this.react("excited", 1.1); this._byV += 0.052; this._sqV -= 0.05; break;  // energetic pop
        case "tilt": this.react("alert", 1.1); this._sqV += 0.04;                  // a curious lean-and-hold
          this._lookSeq = [{ x: side * 0.50, y: 0.06, t: 1.2 }, { x: side * 0.42, y: 0.03, t: 0.9 }, { x: 0, y: 0.04, t: 0.6 }]; break;
        case "ponder": this.react("focused", 1.7);                                // slow thoughtful look up & away
          this._lookSeq = [{ x: side * 0.22, y: 0.22, t: 1.3 }, { x: side * 0.16, y: 0.17, t: 1.1 }, { x: 0, y: 0.04, t: 0.7 }]; break;
        case "shimmy": this.react("happy", 1.0); this._sqV -= 0.04;               // playful quick side-to-side sway
          this._lookSeq = [{ x: side * 0.26, y: 0, t: 0.28 }, { x: -side * 0.26, y: 0, t: 0.28 },
                           { x: side * 0.18, y: 0, t: 0.26 }, { x: 0, y: 0.04, t: 0.4 }]; break;
        case "nod": this.react("happy", 0.9); this._byV += 0.020;                 // small affirmative nod
          this._lookSeq = [{ x: 0, y: -0.14, t: 0.3 }, { x: 0, y: 0.12, t: 0.3 }, { x: 0, y: 0.02, t: 0.4 }]; break;
        case "lookaround":                                        // sweeps the whole room
          this.react("alert", 1.5); this._blinking = true; this._blinkAge = 0.0;
          this._lookSeq = [{ x: side * 0.46, y: 0.05, t: 0.7 }, { x: -side * 0.46, y: 0.05, t: 0.8 },
                           { x: 0, y: 0.30, t: 0.6 }, { x: 0, y: 0.04, t: 0.5 }]; break;
        case "sneeze":                                            // a little "achoo" shudder + hop
          this.react("surprised", 0.7); this._lookSeq = [{ x: 0, y: 0.30, t: 0.35 }, { x: 0, y: 0.04, t: 0.4 }];
          this._byV += 0.032; this._sqV -= 0.06; this.shake(0.055, 0.4, 27); break;
        case "hiccup":                                            // two quick surprised hops
          this.react("surprised", 0.5); this._byV += 0.045; this._sqV -= 0.05; break;
        case "peek":                                              // leans FAR to one side, holds, curious
          this.react("suspicious", 1.4);
          this._lookSeq = [{ x: side * 0.55, y: 0.02, t: 1.3 }, { x: side * 0.5, y: 0.0, t: 0.7 }, { x: 0, y: 0.04, t: 0.5 }]; break;
        case "doubletake":                                        // glances away, snaps back startled
          this._lookSeq = [{ x: side * 0.4, y: -0.02, t: 0.5 }, { x: 0, y: 0.04, t: 0.14 }];
          this.react("surprised", 0.9); this._byV += 0.018; break;
        case "yawn":                                              // a big slow stretch + sleepy blink
          this.react("sleepy", 2.0); this._sqV += 0.16; this._byV += 0.01;
          this._lookSeq = [{ x: 0, y: 0.26, t: 0.9 }, { x: 0, y: 0.04, t: 0.7 }]; break;
        case "boing":                                             // an energetic springy pop
          this.react("excited", 1.2); this._byV += 0.062; this._sqV -= 0.07; break;
        case "shiver":                                            // a quick brr shiver
          this.react("alert", 0.7); this.shake(0.03, 0.55, 34); break;
        case "gazeup":                                            // stares up, wondering
          this.react("focused", 1.6); this._lookSeq = [{ x: side * 0.1, y: 0.34, t: 1.3 }, { x: 0, y: 0.30, t: 0.8 }, { x: 0, y: 0.04, t: 0.6 }]; break;
        case "wobble":                                            // sways side to side
          this.react("happy", 1.0);
          this._lookSeq = [{ x: side * 0.3, y: 0, t: 0.32 }, { x: -side * 0.3, y: 0, t: 0.32 },
                           { x: side * 0.2, y: 0, t: 0.3 }, { x: 0, y: 0.04, t: 0.4 }]; break;
        case "dizzy":                                             // spins its gaze in a circle
          this.react("surprised", 1.3); this.shake(0.02, 0.6, 18);
          this._lookSeq = [{ x: side * 0.4, y: 0.16, t: 0.32 }, { x: 0, y: 0.32, t: 0.3 },
                           { x: -side * 0.4, y: 0.16, t: 0.3 }, { x: -side * 0.2, y: -0.14, t: 0.3 },
                           { x: side * 0.3, y: 0.10, t: 0.3 }, { x: 0, y: 0.04, t: 0.5 }]; break;
        case "sniff":                                             // leans down, sniffs around, back up
          this.react("curious" in EXPR ? "curious" : "alert", 1.2);
          this._lookSeq = [{ x: side * 0.2, y: -0.28, t: 0.6 }, { x: -side * 0.2, y: -0.30, t: 0.6 }, { x: 0, y: 0.04, t: 0.5 }];
          this._byV -= 0.008; break;
        case "chase":                                             // tracks an imaginary fly…
          this.react("alert", 2.2);                               // quick darting gaze, then POUNCE
          this._lookSeq = [{ x: side * 0.38, y: 0.18, t: 0.30 }, { x: side * 0.10, y: 0.30, t: 0.28 },
                           { x: -side * 0.34, y: 0.22, t: 0.30 }, { x: -side * 0.12, y: 0.05, t: 0.26 },
                           { x: side * 0.24, y: 0.12, t: 0.28 }, { x: 0, y: 0.06, t: 0.4 }];
          this._pulseSeq = [1.55];                                // the pounce beat at the end
          break;
        case "hum":                                               // hums a little tune to itself
          this.react("happy", 1.9); this._humT = 1.7; this._humTick = 0.0;
          this._lookSeq = [{ x: side * 0.14, y: 0.10, t: 0.9 }, { x: -side * 0.12, y: 0.12, t: 0.9 }]; break;
        case "daydream":                                          // drifts off, heart-eyed, gazing up
          this.react("love", 2.4);
          this._lookSeq = [{ x: side * 0.16, y: 0.24, t: 1.4 }, { x: side * 0.10, y: 0.28, t: 1.2 }, { x: 0, y: 0.04, t: 0.7 }]; break;
        case "workout":                                           // three quick squats, then proud
          this.react("focused", 1.6); this._pulseSeq = [0.15, 0.55, 0.95];
          this._lookSeq = [{ x: 0, y: 0.02, t: 1.3 }, { x: 0, y: 0.14, t: 0.5 }]; break;
      }
    },

    setLevel(v) { this._levelTarget = Math.max(0, Math.min(1, v || 0)); },

    // bare-orb minimalist overlay: the window is clipped to a circle the ball
    // overfills, so we damp squash/bob/look here to guarantee the ball always
    // covers that circle (no dark sliver) while still reading as alive.
    setMinimal(on) { this._minimal = !!on; },

    _resize() {
      const cv = this.canvas;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const w = Math.round(cv.clientWidth * dpr), h = Math.round(cv.clientHeight * dpr);
      if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
      return [w, h];
    },

    // advance the little physics sim one frame → {sx, sy, offX, offY, lookX, lookY}
    _physics(tt, mn) {
      this._level += (this._levelTarget - this._level) * 0.25;
      const st = this.state;
      // slow amplitude envelope for SPEAKING body motion — eases toward the voice
      // level while speaking, back to 0 otherwise, so the body swells smoothly
      // instead of jittering up and down on every syllable
      this._spk += ((st === "speaking" ? this._level : 0.0) - this._spk) * 0.08;

      const dt = Math.min(Math.max(tt - this._ttPrev, 0), 0.05);
      this._ttPrev = tt;

      // idle breathing + float — two incommensurate sines so the period never
      // reads as an obvious loop / screensaver
      let sqTarget = 0.040 * Math.sin(tt * 1.6) + 0.012 * Math.sin(tt * 2.7 + 1.3);
      let bobBase  = 0.018 * Math.sin(tt * 1.1) + 0.006 * Math.sin(tt * 0.61 + 0.5);
      // deep-sleep breathing — while dozing the breath goes SLOWER and DEEPER
      // (relaxed ~15 breaths/min vs alert ~22), the strongest "it sleeps" cue
      if (this._dozing) {
        sqTarget = 0.058 * Math.sin(tt * 0.85) + 0.010 * Math.sin(tt * 1.45 + 0.7);
        bobBase  = 0.012 * Math.sin(tt * 0.55) - 0.010;      // settles a touch lower
      }
      // weight shift — a very slow lateral sway (two incommensurate ~20–50s sines):
      // real bodies never hold one spot, they drift around their balance point
      this._swayX = 0.011 * Math.sin(tt * 0.23) + 0.007 * Math.sin(tt * 0.111 + 2.0);
      // an occasional SIGH — one deeper, slower breath every ~25–60 s of idle
      if (st === "idle" && !this._dozing && !this._reduceMotion && !this._exporting) {
        this._sighCd -= dt;
        if (this._sighCd <= 0 && this._sigh <= 0) { this._sigh = 1.0; this._sighCd = 24 + Math.random() * 36; }
      }
      if (this._sigh > 0) {
        this._sigh = Math.max(0, this._sigh - dt / 2.4);      // ~2.4s swell in+out
        const amp = Math.sin((1 - this._sigh) * Math.PI);
        sqTarget += 0.050 * amp; bobBase += 0.012 * amp;
      }
      // timed squash beats queued by vignettes (workout squats, hiccup pairs…)
      if (this._pulseSeq.length) {
        this._pulseSeq[0] -= dt;
        if (this._pulseSeq[0] <= 0) { this._pulseSeq.shift(); this._sqV -= 0.075; this._byV += 0.024; }
      }
      // humming — rhythmic ripple rings (the "voice" visual) with no voice
      if (this._humT > 0) {
        this._humT -= dt; this._humTick -= dt;
        if (this._humTick <= 0 && this._rings.length < 4) { this._humTick = 0.42; this._rings.push({ p: 0.0 }); }
      }

      // ── gaze: look toward the cursor; otherwise wander with idle saccades ───
      let lookTX, lookTY;
      const fresh = (performance.now() - this._ptr.t) < 2200;
      if (fresh) {
        const r = this.canvas.getBoundingClientRect();
        if (r.width > 1) {
          const dx = (this._ptr.x - (r.left + r.width * 0.5)) / (r.width * 0.5);
          const dy = ((r.top + r.height * 0.5) - this._ptr.y) / (r.height * 0.5);
          lookTX = Math.max(-1, Math.min(1, dx)) * 0.42;
          lookTY = Math.max(-1, Math.min(1, dy)) * 0.34;
        }
      }
      if (this._exporting) {                               // capture: hold a calm forward gaze
        lookTX = 0.0; lookTY = 0.03;
      } else if (lookTX === undefined) {                   // no cursor → wander / glance
        if (st === "idle" && this._lookSeq.length) {       // scripted vignette glance
          const g = this._lookSeq[0];
          g.t -= dt;
          if (g.t <= 0) { this._lookSeq.shift(); if (Math.random() < 0.5) { this._blinking = true; this._blinkAge = 0.0; } }
          const cur = this._lookSeq[0] || g;
          this._sacX = cur.x; this._sacY = cur.y; this._sacT = 0.5;   // hold the wander
        } else {
          this._sacT -= dt;
          if (this._sacT <= 0) {
            this._sacT = 0.9 + Math.random() * 1.9;
            if (Math.random() < 0.4) { this._sacX = 0.0; this._sacY = 0.04; }
            else { this._sacX = (Math.random() * 2 - 1) * 0.34;
                   this._sacY = (Math.random() * 2 - 1) * 0.20; }
            if (Math.random() < 0.55) { this._blinking = true; this._blinkAge = 0.0; }  // blink on glance
          }
        }
        lookTX = this._sacX + 0.03 * Math.sin(tt * 0.7);
        lookTY = this._sacY + 0.02 * Math.sin(tt * 0.5);
      }

      if (st === "listening") {                           // perks up, looks ahead
        bobBase = 0.012 * Math.sin(tt * 2.0);
        sqTarget += 0.03; lookTY += 0.14;
      } else if (st === "processing") {                   // contemplative: the
        bobBase = 0.010 * Math.sin(tt * 1.4);             // shine SEARCHES around
        lookTX = 0.5 * Math.cos(tt * 0.9);                // in a slow lazy orbit
        lookTY = 0.35 * Math.sin(tt * 0.9);
      } else if (st === "speaking") {
        // SMOOTH voice pulse — the body swells gently with the slow amplitude
        // envelope; NO per-syllable hop/squish (that read as a fast jittery
        // bounce). Each syllable onset only EMITS a ripple ring (visual feedback,
        // aged below into u_rings) — that's what carries the "talking" rhythm.
        const rising = this._level - this._prevLvl;
        if (rising > 0.07 && this._rings.length < 4) this._rings.push({ p: 0.0 });
        sqTarget += this._spk * 0.11;                    // gentle sustained swell
        bobBase  += this._spk * 0.010;                   // slow rise, no bounce
      }
      this._prevLvl = this._level;

      // eye micro-saccade tremor — real eyes are never perfectly still: a tiny
      // fixation jump every ~0.4–1.6 s on top of whatever the gaze is doing
      if (!this._reduceMotion && !this._exporting) {
        this._msacT -= dt;
        if (this._msacT <= 0) {
          this._msacT = 0.4 + Math.random() * 1.2;
          this._msacX = (Math.random() * 2 - 1) * 0.022;
          this._msacY = (Math.random() * 2 - 1) * 0.016;
        }
        lookTX += this._msacX; lookTY += this._msacY;
      }
      if (this._dozing) lookTY -= 0.20;                    // asleep: the gaze sinks

      // blink: a quick wink of the catch-light every few seconds (+ on glances);
      // while dozing, blinks come rarely and HEAVY (slow lids ~0.45s)
      this._blinkCd -= dt;
      if (this._blinkCd <= 0 && !this._exporting) {
        this._blinkCd = this._dozing ? 5.0 + Math.random() * 4.0 : 2.6 + Math.random() * 4.2;
        this._blinkDur = this._dozing ? 0.45 : 0.15;
        this._blinking = true; this._blinkAge = 0.0;
      }
      if (this._blinking) {
        this._blinkAge += dt;
        const h = this._blinkAge / (this._blinkDur || 0.15);   // open→shut→open
        this._blink = h >= 1 ? (this._blinking = false, 0.0) : Math.sin(h * Math.PI);
      } else this._blink = 0.0;

      // glitch: a rare ~220ms digital blip every few seconds
      this._glitchCd -= dt;
      if (this._glitchCd <= 0 && !this._exporting) { this._glitchCd = 2.8 + Math.random() * 4.5; this._glitching = true; this._glitchAge = 0.0; }
      if (this._glitching) {
        this._glitchAge += dt;
        if (this._glitchAge >= 0.22) { this._glitching = false; this._glitch = 0.0; }
        else this._glitch = 0.6 + 0.4 * Math.random();   // flickery intensity
      } else this._glitch = 0.0;

      // hologram crossfade — blue while thinking, volt otherwise
      this._holo += ((st === "processing" ? 1.0 : 0.0) - this._holo) * 0.12;

      // expression — idle vignettes / doze + ease the eye toward the active target
      this._reactT = Math.max(0, this._reactT - dt);
      if (this._reactT <= 0) this._react = null;
      this._vigCd -= dt;
      if (st === "idle" && !this._react && !this._lookSeq.length && this._vigCd <= 0 && !this._gadgetActive && !this._exporting && !this._reduceMotion) {
        this._vigCd = 4.0 + Math.random() * 5.0;   // livelier cadence — a beat every ~4–9 s
        this._idleVignette();                              // a little "alive" moment (skipped in reduce-motion)
      }
      if (st === "idle" && !this._dozing && !this._gadgetActive && !this._exporting && (tt - this._idleSince) > 28.0) {
        this._dozing = true; this._lookSeq.length = 0; this._dozeSince = tt;
        this.react("sleepy", 4.0);                         // nods off when left idle
      }
      if (this._dozing && !this._react) this.react("sleepy", 5.0);   // STAY asleep (eyes heavy)
      if (fresh) {
        this._idleSince = tt;
        if (this._dozing) {                                // waking up: startle-blink,
          this._dozing = false; this._lookSeq.length = 0;  // then a big stretch if it
          this.react("surprised", 0.6);                    // was really out
          this._blinking = true; this._blinkAge = 0.0; this._blinkDur = 0.15;
          if (tt - this._dozeSince > 10) { this._sqV += 0.11; this._byV += 0.028; this._pulseSeq = [0.7]; }
        }
      }
      const emoNow = this._react || this._gadgetExpr || this._exprBase;
      const tgt = EXPR[emoNow] || EXPR.neutral;
      for (let i = 0; i < 4; i++) this._eye[i] += (tgt[i] - this._eye[i]) * 0.18;
      this._eyeShape += ((HEART[emoNow] ? 1.0 : 0.0) - this._eyeShape) * 0.18;  // morph to/from a heart

      // age the speaking ripple rings; pack into the fixed-length uniform array
      for (let i = this._rings.length - 1; i >= 0; i--) {
        this._rings[i].p += dt * 1.5;
        if (this._rings[i].p > 1.0) this._rings.splice(i, 1);
      }
      for (let i = 0; i < 4; i++) this._ringArr[i] = (i < this._rings.length) ? this._rings[i].p : 2.0;

      // squash spring toward target
      this._sqV += (sqTarget - this._sq) * 0.18;
      this._sqV *= 0.80;
      this._sq  += this._sqV;
      // keep it tasteful — and TIGHT in minimal mode so the orb never narrows
      // enough to expose the dark window corners through the circular clip
      const sqHi = this._minimal ? 0.12 : 0.40;
      const sqLo = this._minimal ? -0.12 : -0.34;
      this._sq = Math.max(sqLo, Math.min(sqHi, this._sq));

      // bob spring (settles the reaction hop back to 0; baseline added after).
      // a touch under-damped so the hops feel springy / bouncy.
      this._byV += (0 - this._by) * 0.16;     // springier → a clear overshoot...
      this._byV *= 0.90;                       // ...that settles with a 2nd bounce
      this._by  += this._byV;

      // smooth the look direction toward the target (snappier so a cursor move
      // or a saccade reads as a quick glance, then it fixates)
      this._lookX += (lookTX - this._lookX) * 0.09;
      this._lookY += (lookTY - this._lookY) * 0.09;

      // volume-preserving stretch: sy taller ⇒ sx narrower
      const sy = 1.0 + this._sq;
      const sx = 1.0 / sy;
      let offY = (bobBase + this._by) * mn + this._lookY * mn * 0.025;
      let offX = this._lookX * mn * 0.06           // gentle lean toward the gaze
               + (this._swayX || 0) * mn;          // + the slow weight-shift drift
      // transient shake (sneeze / shiver / a startled shudder) — decays away
      if (this._shakeT > 0) {
        offX += Math.sin(tt * this._shakeFreq) * this._shakeAmp * mn;
        this._shakeT -= dt;
      }
      if (this._minimal) { offY *= 0.35; offX *= 0.5; }   // stay centred in the clip
      return { sx, sy, offX, offY, lookX: this._lookX, lookY: this._lookY,
               blink: this._blink, glitch: this._glitch, rings: this._ringArr,
               eye: this._eye, holo: this._holo };
    },

    setReduceMotion(on) { this._reduceMotion = !!on; },   // Settings → reduce animations

    _loop() {
      const gl = this._gl;
      const tick = () => {
        // window hidden (tray / minimised): skip ALL GL work — WebView2 doesn't
        // always suspend rAF for a hidden host window, so guard explicitly
        if (document.hidden) { this._raf = requestAnimationFrame(tick); return; }
        // reduce-motion: cap the orb to ~24fps (idle vignettes are also skipped)
        if (this._reduceMotion) {
          const now = performance.now();
          if (now - (this._lastDraw || 0) < 42) { this._raf = requestAnimationFrame(tick); return; }
          this._lastDraw = now;
        }
        const [w, h] = this._resize();
        const mn = Math.min(w, h);
        gl.viewport(0, 0, w, h);
        gl.clearColor(0, 0, 0, 0);
        gl.clear(gl.COLOR_BUFFER_BIT);

        const tt = (performance.now() - this._t0) / 1000;

        // energy envelope per state
        const target = (this.state === "idle") ? 0.18
                      : (this.state === "listening") ? 0.7
                      : (this.state === "processing") ? 0.55
                      : 0.55 + 0.45 * this._level;
        this._energy += (target - this._energy) * 0.06;
        if (this.state === "speaking") this._levelTarget *= 0.92; // decay if not refreshed

        const ph = this._physics(tt, mn);

        gl.uniform2f(this._u.res, w, h);
        gl.uniform1f(this._u.time, tt);
        gl.uniform1f(this._u.state, STATE[this.state] || 0);
        gl.uniform1f(this._u.energy, this._energy);
        gl.uniform1f(this._u.level, this._level);
        gl.uniform2f(this._u.squash, ph.sx, ph.sy);
        gl.uniform2f(this._u.off, ph.offX, ph.offY);
        gl.uniform2f(this._u.look, ph.lookX, ph.lookY);
        // publish the gaze so DOM accessories (e.g. the coding glasses) can track it
        // (write ONLY on change — same-value style writes still dirty the styler)
        const par = this.canvas.parentElement;
        if (par) {
          const lxs = ph.lookX.toFixed(3), lys = ph.lookY.toFixed(3);
          if (lxs !== this._pubLx) { par.style.setProperty("--dot-lx", lxs); this._pubLx = lxs; }
          if (lys !== this._pubLy) { par.style.setProperty("--dot-ly", lys); this._pubLy = lys; }
        }
        gl.uniform1f(this._u.blink, ph.blink);
        gl.uniform1f(this._u.glitch, ph.glitch);
        gl.uniform1fv(this._u.rings, ph.rings);
        gl.uniform4f(this._u.eye, ph.eye[0], ph.eye[1], ph.eye[2], ph.eye[3]);
        gl.uniform1f(this._u.holo, ph.holo);
        gl.uniform1f(this._u.moodAmt, 0.0);              // live orb: never mood-tinted
        gl.uniform1f(this._u.express, 0.0);              // …and no eye-socket (live = untouched)
        gl.uniform1f(this._u.eyeShape, this._eyeShape);  // 0 at idle → heart only on a love reaction
        gl.uniform4f(this._u.recolor, 0.0, 0.0, 0.0, 0.0); // live orb keeps brand volt
        gl.drawArrays(gl.TRIANGLES, 0, 3);
        this._raf = requestAnimationFrame(tick);
      };
      this._raf = requestAnimationFrame(tick);
    },

    /* ── tiny 2D fallback (no WebGL) — still floats + squashes a little ── */
    _initFallback(canvas) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      let phase = 0;
      const tick = () => {
        const W = canvas.clientWidth, H = canvas.clientHeight;
        if (canvas.width !== W * dpr || canvas.height !== H * dpr) { canvas.width = W * dpr; canvas.height = H * dpr; }
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H);
        phase += 0.05;
        this._level += (this._levelTarget - this._level) * 0.25;
        const mn = Math.min(W, H);
        const cx = W / 2;
        const cy = H / 2 - mn * 0.02 * Math.sin(phase * 0.7);     // float
        const sq = 1 + 0.05 * Math.sin(phase) + (this.state === "speaking" ? this._level * 0.18 : 0);
        const r = mn * 0.26;
        const rx = r / sq, ry = r * sq;
        ctx.save(); ctx.translate(cx, cy); ctx.scale(rx / r, ry / r);
        // website-matched candy sphere: radial gloss from the upper-left highlight,
        // NO ink outline (soft olive edge), a volt glow, and one white eye dot
        const g = ctx.createRadialGradient(-r * 0.34, -r * 0.40, r * 0.05, -r * 0.08, -r * 0.08, r * 1.28);
        g.addColorStop(0, "#f4ffd2"); g.addColorStop(0.15, "#dcfb6e"); g.addColorStop(0.38, "#c1f73a");
        g.addColorStop(0.68, "#8ace1e"); g.addColorStop(1, "#5a8c14");
        ctx.shadowColor = "rgba(193,247,58,0.5)"; ctx.shadowBlur = r * 0.55;
        ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill();
        ctx.shadowBlur = 0;
        ctx.fillStyle = "#fff"; ctx.shadowColor = "rgba(255,255,255,0.7)"; ctx.shadowBlur = r * 0.14;
        ctx.beginPath(); ctx.arc(r * 0.02, -r * 0.16, r * 0.15, 0, Math.PI * 2); ctx.fill();  // eye
        ctx.restore();
        this._raf = requestAnimationFrame(tick);
      };
      this._raf = requestAnimationFrame(tick);
    },
  };

  window.WivexxDot = Dot;
})();
