/* ───────────────────────────────────────────────────────────────────────────
   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.14, 0.96, 0.00, 1.00],   // ‿ crescent
    surprised:  [1.34, 1.40, 0.00, 0.00],
    sleepy:     [1.00, 0.82, 0.60, 0.00],
    suspicious: [1.08, 0.44, 0.34, 0.00],
  };
  const STATE_EXPR = { idle: "neutral", listening: "alert", processing: "focused", speaking: "neutral" };

  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)

    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.295 * (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 mode: concentric bubble-shell rings around the orb. GREEN in
      // every state (v1.54: teal-green rings, matching ui/dot.js — no blue hologram).
      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 += vec3(0.45, 0.92, 0.80) * rg; alpha = max(alpha, rg);
        }
      }

      // ── 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 + the app
        // orb) — a RADIAL gloss gradient centred on the pinned upper-left highlight:
        // cream → lime → volt → green → olive. PINNED (never relights) so the shading
        // can't slosh. GREEN in every state (v1.54: synced from ui/dot.js).
        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)
        float hlD = length(uv - vec2(-0.30, 0.42));                 // distance from the pinned 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 (no blue hologram): a whisper of a cooler teal-green shimmer
        vec3 coolTint = mix(body, vec3(0.55, 0.95, 0.72), 0.32);
        body = mix(body, coolTint, u_holo * 0.35);

        // 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);
        eyeC *= min(1.0, 0.62 / max(length(eyeC), 1e-4));
        float ew = 0.135 * max(0.12, u_eye.x);
        float eh = 0.135 * max(0.04, u_eye.y * (1.0 - 0.92 * u_blink));
        vec2 ep = uv - eyeC;
        float eyeA = smoothstep(1.10, 0.55, length(vec2(ep.x / ew, ep.y / eh)));
        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
          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);
        }
        body = mix(body, vec3(1.0), eyeA * 0.95);

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

        // refined dark rim → keeps it crisp at any size (thinner than an ink line)
        float outW = max(1.5, R * 0.040);
        float inkA = smoothstep(R - outW, R - outW * 0.35, d);
        body = mix(body, INK, inkA * 0.92);

        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.75, R * 0.98, d);
        float gI = 0.10 + 0.10 * (0.5 + 0.5 * sin(t * 1.4)) + 0.22 * u_energy + 0.30 * u_level;
        vec3 auraCol = mix(VOLT, vec3(0.45, 0.92, 0.80), u_holo * 0.6);   // v1.54: green thinking (no blue)
        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,
    _holo: 0.0, _idleSince: 0.0, _dozing: false,
    _lookSeq: [], _lastVig: "",      // scripted idle glances + last vignette played
    _ttPrev: 0.0,                    // last physics tt (for dt)
    _minimal: false,           // bare-orb overlay: damp motion so it stays a
                               // clean circle that always overfills the clip

    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"),
        };
        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._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._eye = (EXPR[STATE_EXPR[this.state] || "neutral"] || EXPR.neutral).slice();
      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
    },

    // 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); }
    },

    // 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: 22, k: "glance" }, { w: 16, k: "happy" }, { w: 14, k: "perk" },
        { w: 12, k: "stretch" }, { w: 11, k: "curious" }, { w: 9, k: "wiggle" },
        { w: 8, k: "blinkx2" }, { w: 5, k: "suspicious" },
      ];
      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;
      }
    },

    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);

      // ── 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 (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;

      // blink: a quick wink of the catch-light every few seconds (+ on glances)
      this._blinkCd -= dt;
      if (this._blinkCd <= 0) { this._blinkCd = 2.6 + Math.random() * 4.2; this._blinking = true; this._blinkAge = 0.0; }
      if (this._blinking) {
        this._blinkAge += dt;
        const h = this._blinkAge / 0.15;                   // ~150ms 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._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._vigCd = 6.0 + Math.random() * 7.0;
        this._idleVignette();                              // a little "alive" moment
      }
      if (st === "idle" && !this._dozing && (tt - this._idleSince) > 18.0) {
        this._dozing = true; this._lookSeq.length = 0;
        this.react("sleepy", 4.0);                         // nods off when left idle
      }
      if (fresh) { this._idleSince = tt; if (this._dozing) { this._dozing = false; this._lookSeq.length = 0; this.react("surprised", 0.6); } }
      const tgt = EXPR[this._react || this._exprBase] || EXPR.neutral;
      for (let i = 0; i < 4; i++) this._eye[i] += (tgt[i] - this._eye[i]) * 0.18;

      // 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
      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 };
    },

    _loop() {
      const gl = this._gl;
      const tick = () => {
        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
        const par = this.canvas.parentElement;
        if (par) {
          par.style.setProperty("--dot-lx", ph.lookX.toFixed(3));
          par.style.setProperty("--dot-ly", ph.lookY.toFixed(3));
        }
        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.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);
        const g = ctx.createRadialGradient(-r * 0.3, -r * 0.3, r * 0.1, 0, 0, r);
        g.addColorStop(0, "#dff77a"); g.addColorStop(0.6, "#aee23a"); g.addColorStop(1, "#5e7a1d");
        ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill();
        ctx.lineWidth = Math.max(2, r * 0.06); ctx.strokeStyle = "#0b0b0a"; ctx.stroke();  // ink outline
        ctx.restore();
        this._raf = requestAnimationFrame(tick);
      };
      this._raf = requestAnimationFrame(tick);
    },
  };

  window.WivexxDot = Dot;
})();
