/* ==========================================================================
   ScrollScene — scroll-driven 3D product sequence (Sht 01A)

   One canvas, one WebGL context, one scene, shared by all three product
   sections. Every animated value is a pure function of scroll progress
   t ∈ [0,1]; nothing is time-driven and nothing plays once. That is what
   makes the sequence run identically backwards when the visitor scrolls up.

   All three products now render real CAD. Each entry still carries a
   `placeholder` primitive sized to the product's true bounding box, which is
   what shows if a GLB fails to load. Callout anchors are expressed in
   normalised bounding-box coordinates, so they land in the same relative place
   on placeholder and real geometry alike.
   ========================================================================== */

const SS = (function () {
  const { useState, useEffect, useRef, useCallback } = React;

  // --- maths -------------------------------------------------------------

  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
  const lerp = (a, b, t) => a + (b - a) * t;

  // Normalised progress across [a,b], eased with a smoothstep. Used for every
  // beat, so entries and exits have no velocity discontinuity at the seams.
  function ramp(t, a, b) {
    const x = clamp((t - a) / (b - a), 0, 1);
    return x * x * (3 - 2 * x);
  }

  // --- sequence definition ----------------------------------------------

  // Anchors are normalised to the model's bounding box: [-1,1] on each axis,
  // where ±1 is the box face. They survive a swap to real geometry.
  //
  // Bubble copy: only facts already published elsewhere on this site are used.
  // Anything the owner has not supplied is marked `placeholder: true` and is
  // listed in the handover notes — nothing here is invented.
  const SEQUENCE = [
    {
      id: "scrubmarine",
      title: "ScrubMarine",
      doc: "DOC EW-CS-01",
      tag: "Marine robotics",
      side: -1,
      // Converted from cad/SM_Turtle.usdz (6.92M tris). Welded position-only to
      // get past the simplifier's topology lock, then creased normals baked
      // back in at build time so the browser never pays for toCreasedNormals.
      // Measured: 1,028,527 tris, 5.64 MB Draco.
      url: "models/scrubmarine.glb",
      // Reduced-poly variant for phones (brief §6.4): 138,277 tris / 835 KB.
      // On 4x-throttled mobile the full model's Draco decode was a single
      // ~360 ms task as the section came into view.
      urlMobile: "models/scrubmarine-mobile.glb",
      // The ROV is a flat 1.43 m cross — edge-on it reads as a blob, so it is
      // tilted to a 3/4 aerial view that the pose rotations then orbit.
      orient: [Math.PI / 2 - 0.55, 0, 0],
      placeholder: {
        kind: "capsule",
        // Measured from the shipped mesh.
        dims: [1391, 423, 1433],
        palette: "marine",
      },
      bubbles: [
        {
          k: "Business case",
          anchor: [-0.55, 0.35, 0.85],
          text: "Autonomous inspection and cleaning of marine vessels, designed and built from scratch.",
          placeholder: true,
        },
        {
          k: "Robotics & engineering",
          anchor: [0.7, -0.15, 0.5],
          text: "Full-stack hardware — mechanical design, custom electronics and software, all in-house.",
        },
        {
          k: "Budget · team · outcome",
          anchor: [-0.2, -0.7, 0.7],
          text: "£100,000 current phase · 4–6 engineers · Project lead, mechanical and electronics design. Currently in development.",
        },
      ],
    },
    {
      id: "ewatch",
      title: "EWatch",
      doc: "DOC EW-CS-02",
      tag: "Consumer electronics",
      side: 1,
      // Converted from cad/EWatch.usdz: 149,703 tris → 105,209, 259 KB Draco.
      // Exported face-up (thickness on Y). Material-coded test renders put the
      // display on the -Y face, so it is stood up screen-toward-camera.
      url: "models/ewatch.glb",
      orient: [-Math.PI / 2, 0, 0],
      dress: "ewatch",
      placeholder: {
        kind: "watch",
        dims: [34, 14, 51],
        palette: "watch",
      },
      bubbles: [
        {
          k: "Business case",
          anchor: [0.0, 0.0, 1.0],
          text: "An open, programmable smartwatch and ESP32-S3 dev board for hobbyists, makers and developers.",
        },
        {
          k: "Robotics & engineering",
          anchor: [-0.85, 0.2, 0.4],
          text: "Electronics-led: low power consumption and miniaturisation, with hand-written open-source drivers for display, touch, IMU, BLE, charging and sleep.",
        },
        {
          k: "Budget · team · outcome",
          anchor: [0.75, -0.55, 0.5],
          text: "Solo design and development. Three tiers — bare PCB £59, self-assembly kit £99, fully assembled £129. Currently out of stock.",
          placeholder: true,
        },
      ],
    },
    {
      id: "minicapper",
      title: "MiniCapper",
      doc: "DOC EW-CS-03",
      tag: "Lab automation",
      side: -1,
      // Tessellated from cad/ARADMINICAP_SHELL.STEP with OpenCASCADE's display
      // mesher: 4,984 B-rep faces -> 383,330 tris, 941 KB Draco (bounding box
      // verified against the source STEP to within 0.01 mm). STEP carries no
      // appearance data; step2glb.py bakes a placeholder grey PBR, but the
      // look that actually ships comes from dressMiniCapper below.
      url: "models/minicapper.glb",
      // The SolidWorks export is already Y-up (its long axis is Y), so no
      // rotation is applied - confirmed by rendering, not assumed.
      orient: [0, 0, 0],
      dress: "minicapper",
      poseTilt: 0.4,
      holdAtEnd: true,
      placeholder: {
        kind: "instrument",
        // Real envelope from the STEP bounding box, mm.
        dims: [180, 428, 180],
        palette: "instrument",
      },
      bubbles: [
        {
          k: "Business case",
          anchor: [0.0, 0.75, 0.6],
          text: "A standalone device that caps and decaps vials for use in laboratory automation.",
        },
        {
          k: "Robotics & engineering",
          anchor: [-0.8, 0.1, 0.6],
          text: "Concept through to a final version tested in a laboratory environment. Designed for manufacturability and ease of assembly, with all custom components machined from metal.",
        },
        {
          k: "Budget · team · outcome",
          anchor: [0.8, -0.6, 0.4],
          text: "£35,000 · Small team (2–3 engineers) · Project lead.",
        },
      ],
    },
  ];

  // Beat table — §6.2 of the brief. Every window is expressed in local t, so
  // the real pacing of a beat is (its width in t) × SECTION_VH.
  //
  // Timings below are tuned against the previous 300vh section: the entry pan
  // is ~1.9× slower, the exit pan ~1.15× slower, and each callout now holds at
  // full opacity for ~58vh of scroll instead of ~21vh (2.7×).
  const BEATS = {
    enterFrom: 0.0, enterTo: 0.18,
    exitFrom: 0.82, exitTo: 1.0,
    bubbles: [
      { in: [0.20, 0.26], out: [0.38, 0.44] },
      { in: [0.42, 0.48], out: [0.58, 0.64] },
      { in: [0.61, 0.67], out: [0.79, 0.84] },
    ],
  };

  // Camera-facing poses the model rotates through while each callout is up.
  // The Y span is widened in step with the longer section so the rotation rate
  // per scrolled pixel stays close to what it was — the pans slow down, the
  // spin does not become sluggish.
  const POSES = [
    [0.10, -0.82, 0.02],
    [-0.18, 0.41, -0.05],
    [0.22, 1.63, 0.06],
  ];

  const SECTION_VH = 480;   // scroll length per product
  const REDUCED_VH = 100;   // reduced-motion: one screen per product, no motion
  const STATIC_T = 0.5;     // composed frame used when motion is suppressed

  // World envelope every model is fitted into, and how far it sits above the
  // stage centre. The lift keeps the model clear of the title block in the
  // lower-left — without it a wide model (MiniCapper, ScrubMarine) overlaps the
  // product name.
  const FIT = 2.05;
  const LIFT = 0.42;
  // How far a section's entry reaches back into the previous one, in local t.
  const LEAD = 0.07;

  // --- capability gate ---------------------------------------------------

  // Phones and low-core machines get the reduced-poly variant where one exists.
  function wantsLightAssets() {
    try {
      const vw = window.innerWidth || document.documentElement.clientWidth || 0;
      const coarse = matchMedia("(pointer: coarse)").matches;
      const cores = navigator.hardwareConcurrency || 8;
      const mem = navigator.deviceMemory || 8;
      return (coarse && vw > 0 && vw < 900) || cores <= 4 || mem <= 4;
    } catch (e) {
      return false;
    }
  }

  const LIGHT_ASSETS = wantsLightAssets();
  const modelURL = (spec) =>
    (LIGHT_ASSETS && spec.urlMobile) ? spec.urlMobile : spec.url;

  // Devices that fail this render the crawlable DOM content only. There is no
  // pre-rendered image-sequence fallback yet — see handover notes.
  function canRender3D() {
    try {
      if (typeof WebGL2RenderingContext === "undefined") return false;
      const c = document.createElement("canvas");
      const gl = c.getContext("webgl2") || c.getContext("webgl");
      if (!gl) return false;
      // Fall back to the document box, and treat "unknown" as "fine" — a
      // zero-sized report (some embedded/headless viewports) must not be
      // mistaken for a small phone and disable the scene outright.
      const vw = window.innerWidth || document.documentElement.clientWidth || 0;
      const vh = window.innerHeight || document.documentElement.clientHeight || 0;
      const smallScreen = vw > 0 && vh > 0 && Math.min(vw, vh) < 380;
      const weak = (navigator.hardwareConcurrency || 4) <= 2 &&
        matchMedia("(pointer: coarse)").matches;
      if (gl.getExtension) gl.getExtension("WEBGL_lose_context")?.loseContext();
      return !smallScreen && !weak;
    } catch (e) {
      return false;
    }
  }

  // --- placeholder geometry ---------------------------------------------

  // Sized from each product's real bounding box so a GLB swap does not change
  // the composition. Sub-parts exist so anchors attach to distinct components,
  // which is what real exports will provide.
  function buildPlaceholder(THREE, spec) {
    const g = new THREE.Group();
    const [w, h, d] = spec.dims.map((v) => v / 1000); // mm → m

    const mats = {
      marine: {
        body: new THREE.MeshPhysicalMaterial({ color: 0x2b3138, metalness: 0.55, roughness: 0.42, clearcoat: 0.3 }),
        trim: new THREE.MeshPhysicalMaterial({ color: 0xc44a1a, metalness: 0.2, roughness: 0.5 }),
        glass: new THREE.MeshPhysicalMaterial({ color: 0x0d1418, metalness: 0.1, roughness: 0.08, transmission: 0.25, thickness: 0.4 }),
      },
      watch: {
        body: new THREE.MeshPhysicalMaterial({ color: 0x1c1c1e, metalness: 0.9, roughness: 0.28 }),
        trim: new THREE.MeshPhysicalMaterial({ color: 0x1a4a2e, metalness: 0.35, roughness: 0.55 }),
        glass: new THREE.MeshPhysicalMaterial({ color: 0x05070a, metalness: 0.05, roughness: 0.04, clearcoat: 1 }),
      },
      instrument: {
        body: new THREE.MeshPhysicalMaterial({ color: 0xd8d5cd, metalness: 0.75, roughness: 0.34 }),
        trim: new THREE.MeshPhysicalMaterial({ color: 0xc44a1a, metalness: 0.25, roughness: 0.45 }),
        glass: new THREE.MeshPhysicalMaterial({ color: 0x121212, metalness: 0.2, roughness: 0.15 }),
      },
    }[spec.palette];

    const rbox = (bw, bh, bd, r) => roundedBox(THREE, bw, bh, bd, r);

    if (spec.kind === "capsule") {
      const body = new THREE.Mesh(new THREE.CapsuleGeometry(w / 2, d - w, 12, 32), mats.body);
      body.rotation.x = Math.PI / 2;
      g.add(body);
      const fin = new THREE.Mesh(rbox(w * 1.5, h * 0.08, d * 0.28, 0.01), mats.trim);
      fin.position.set(0, -h * 0.34, -d * 0.22);
      g.add(fin);
      const dome = new THREE.Mesh(new THREE.SphereGeometry(w * 0.28, 24, 16), mats.glass);
      dome.position.set(0, h * 0.06, d * 0.42);
      g.add(dome);
    } else if (spec.kind === "watch") {
      const caseM = new THREE.Mesh(rbox(w, h, d, 0.006), mats.body);
      g.add(caseM);
      const screen = new THREE.Mesh(rbox(w * 0.86, h * 0.78, d * 0.12, 0.004), mats.glass);
      screen.position.z = d * 0.5;
      g.add(screen);
      const pcb = new THREE.Mesh(rbox(w * 0.8, h * 0.72, d * 0.1, 0.002), mats.trim);
      pcb.position.z = -d * 0.36;
      g.add(pcb);
      const strap = new THREE.Mesh(new THREE.CapsuleGeometry(d * 0.32, h * 0.9, 6, 16), mats.trim);
      strap.position.y = h * 0.92;
      g.add(strap);
      const strap2 = strap.clone();
      strap2.position.y = -h * 0.92;
      g.add(strap2);
    } else {
      const body = new THREE.Mesh(rbox(w, h * 0.72, d, 0.008), mats.body);
      body.position.y = -h * 0.14;
      g.add(body);
      const head = new THREE.Mesh(new THREE.CylinderGeometry(w * 0.22, w * 0.26, h * 0.34, 28), mats.body);
      head.position.y = h * 0.34;
      g.add(head);
      const collar = new THREE.Mesh(new THREE.CylinderGeometry(w * 0.3, w * 0.3, h * 0.05, 28), mats.trim);
      collar.position.y = h * 0.16;
      g.add(collar);
      const panel = new THREE.Mesh(rbox(w * 0.52, h * 0.2, d * 0.06, 0.003), mats.glass);
      panel.position.set(0, -h * 0.1, d * 0.5);
      g.add(panel);
    }
    return g;
  }

  // Minimal rounded box — three has no primitive for it and the drawing-package
  // language wants 2px-radius edges, not knife edges.
  function roundedBox(THREE, w, h, d, r) {
    r = Math.min(r, w / 2, h / 2, d / 2);
    const shape = new THREE.Shape();
    const x = -w / 2, y = -h / 2;
    shape.moveTo(x, y + r);
    shape.lineTo(x, y + h - r);
    shape.quadraticCurveTo(x, y + h, x + r, y + h);
    shape.lineTo(x + w - r, y + h);
    shape.quadraticCurveTo(x + w, y + h, x + w, y + h - r);
    shape.lineTo(x + w, y + r);
    shape.quadraticCurveTo(x + w, y, x + w - r, y);
    shape.lineTo(x + r, y);
    shape.quadraticCurveTo(x, y, x, y + r);
    const geo = new THREE.ExtrudeGeometry(shape, {
      depth: Math.max(d - r * 2, 0.001), bevelEnabled: true,
      bevelThickness: r, bevelSize: r, bevelSegments: 3, curveSegments: 6,
    });
    geo.center();
    return geo;
  }

  // Fit any object — placeholder primitive or loaded GLB — into the same world
  // envelope. `orient` is applied first, so the bounding box (and therefore the
  // callout anchors, which are expressed in normalised box coordinates) is
  // measured in the same frame the model is finally displayed in.
  function normalise(THREE, obj, anchors, target, orient) {
    const inner = new THREE.Group();
    inner.add(obj);
    if (orient) inner.rotation.set(orient[0], orient[1], orient[2]);
    inner.updateMatrixWorld(true);

    const box = new THREE.Box3().setFromObject(inner);
    const size = new THREE.Vector3();
    const centre = new THREE.Vector3();
    box.getSize(size);
    box.getCenter(centre);
    inner.position.sub(centre);

    const holder = new THREE.Group();
    holder.add(inner);
    const k = target / Math.max(size.x, size.y, size.z);
    holder.scale.setScalar(k);

    // Anchors are siblings of the model inside the holder, so they inherit the
    // scroll-driven rotation and scale but stay on the axis-aligned box frame.
    const pts = anchors.map(([ax, ay, az]) => {
      const o = new THREE.Object3D();
      o.position.set((size.x / 2) * ax, (size.y / 2) * ay, (size.z / 2) * az);
      holder.add(o);
      return o;
    });
    return { holder, anchors: pts, baseScale: k, fitWidth: size.x * k };
  }

  // --- EWatch dressing ---------------------------------------------------

  // The Fusion export is a closed case with flat Autodesk appearances on it.
  // Everything except the display becomes anodised aluminium; the display gets
  // glass and a digital watch face.
  //
  // The face is drawn to a canvas rather than shipped as a PNG so it can use
  // the site's own IBM Plex faces. The time is FIXED, never `new Date()` — a
  // live clock would make the same scroll offset render differently on the way
  // down and on the way back up, which is the one thing this scene guarantees.
  const WATCH_FACE = { time: "10:09", date: "MON 08", label: "EWATCH" };

  function watchFaceTexture(THREE) {
    const w = 512, h = 768;
    const c = document.createElement("canvas");
    c.width = w; c.height = h;
    const g = c.getContext("2d");

    // The display material covers the whole front plate, so the bezel has to
    // come from the texture: black surround, inset active area.
    g.fillStyle = "#000000";
    g.fillRect(0, 0, w, h);

    const mx = 52, my = 92;              // bezel
    const aw = w - mx * 2, ah = h - my * 2;
    const r = 56;
    g.beginPath();
    g.moveTo(mx + r, my);
    g.arcTo(mx + aw, my, mx + aw, my + ah, r);
    g.arcTo(mx + aw, my + ah, mx, my + ah, r);
    g.arcTo(mx, my + ah, mx, my, r);
    g.arcTo(mx, my, mx + aw, my, r);
    g.closePath();
    g.fillStyle = "#06080b";
    g.fill();
    g.save();
    g.clip();

    const accent = getComputedStyle(document.documentElement)
      .getPropertyValue("--accent").trim() || "#E86B2B";
    const cx = mx + aw / 2;

    g.textAlign = "center";
    g.textBaseline = "alphabetic";

    g.fillStyle = "#8C8578";
    g.font = "500 40px 'IBM Plex Mono', ui-monospace, monospace";
    g.fillText(WATCH_FACE.date, cx, my + 92);

    // Fit the time to the active width rather than trusting a fixed size —
    // at 210px "10:09" overflowed the canvas and lost its last digit.
    let size = 190;
    g.font = `600 ${size}px 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif`;
    const maxW = aw * 0.86;
    const measured = g.measureText(WATCH_FACE.time).width;
    if (measured > maxW) {
      size = Math.floor(size * (maxW / measured));
      g.font = `600 ${size}px 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif`;
    }
    g.fillStyle = "#EBE7DA";
    g.fillText(WATCH_FACE.time, cx, my + ah / 2 + size * 0.34);

    g.strokeStyle = accent;
    g.lineWidth = 7;
    g.beginPath();
    g.moveTo(cx - 92, my + ah / 2 + size * 0.34 + 54);
    g.lineTo(cx + 92, my + ah / 2 + size * 0.34 + 54);
    g.stroke();

    g.fillStyle = "#6F6A5E";
    g.font = "500 34px 'IBM Plex Mono', ui-monospace, monospace";
    g.fillText(WATCH_FACE.label, cx, my + ah - 78);
    g.restore();

    const tex = new THREE.CanvasTexture(c);
    tex.colorSpace = THREE.SRGBColorSpace;
    tex.anisotropy = 4;
    return tex;
  }

  // Flat rectangular display, so a planar projection is exact. X maps to u and
  // Z to v — with the model's `orient` rotation, that puts the face upright and
  // unmirrored to camera.
  function planarUV(THREE, geom) {
    geom.computeBoundingBox();
    const bb = geom.boundingBox;
    const pos = geom.getAttribute("position");
    const sx = bb.max.x - bb.min.x || 1;
    const sz = bb.max.z - bb.min.z || 1;
    const uv = new Float32Array(pos.count * 2);
    for (let i = 0; i < pos.count; i++) {
      uv[i * 2] = (pos.getX(i) - bb.min.x) / sx;
      uv[i * 2 + 1] = (pos.getZ(i) - bb.min.z) / sz;
    }
    geom.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
  }

  const SCREEN_MATERIAL = /Opaque616161/;

  function dressEWatch(THREE, root) {
    const alu = new THREE.MeshPhysicalMaterial({
      color: 0x2e3033, metalness: 1.0, roughness: 0.34,
      clearcoat: 0.25, clearcoatRoughness: 0.35,
    });

    let screenTex = null;
    root.traverse((o) => {
      if (!o.isMesh) return;
      const name = (o.material && o.material.name) || "";
      if (SCREEN_MATERIAL.test(name)) {
        if (!screenTex) screenTex = watchFaceTexture(THREE);
        planarUV(THREE, o.geometry);
        o.material = new THREE.MeshPhysicalMaterial({
          map: screenTex,
          emissive: 0xffffff, emissiveMap: screenTex, emissiveIntensity: 0.9,
          color: 0xffffff, metalness: 0.0, roughness: 0.05,
          clearcoat: 1.0, clearcoatRoughness: 0.03,
        });
      } else {
        // Interior bodies are never visible through the closed case, so a
        // single anodised material for everything else is both correct for the
        // parts that show and cheaper than keeping 12 Autodesk appearances.
        o.material = alu;
      }
    });
  }

  // --- MiniCapper dressing -----------------------------------------------

  // Fine directional grain for anodised aluminium. Written into the green
  // channel because that is the one three.js samples for roughnessMap.
  function anodisedGrain(THREE) {
    const n = 512;
    const c = document.createElement("canvas");
    c.width = c.height = n;
    const g = c.getContext("2d");
    const img = g.createImageData(n, n);
    // A deterministic PRNG, not Math.random: the same scroll offset has to
    // render identically on the way down and on the way back up.
    let seed = 0x2f6e2b1;
    const rnd = () => {
      seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
      return ((seed >>> 0) % 10000) / 10000;
    };
    for (let y = 0; y < n; y++) {
      // Streak along X so the grain reads as brushed, not as noise.
      const row = 0.86 + rnd() * 0.14;
      for (let x = 0; x < n; x++) {
        const v = Math.max(0, Math.min(1, row + (rnd() - 0.5) * 0.10));
        const b = Math.round(v * 255);
        const i = (y * n + x) * 4;
        img.data[i] = b; img.data[i + 1] = b; img.data[i + 2] = b; img.data[i + 3] = 255;
      }
    }
    g.putImageData(img, 0, 0);
    const tex = new THREE.CanvasTexture(c);
    tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
    tex.anisotropy = 8;
    return tex;
  }

  // Box projection. A STEP tessellation carries no UVs, and this body is far
  // too irregular for a single planar projection — but for a fine roughness
  // grain the seams between projection axes are invisible.
  function boxUV(THREE, geom, tileMetres) {
    const pos = geom.getAttribute("position");
    const nrm = geom.getAttribute("normal");
    if (!pos || !nrm) return;
    const uv = new Float32Array(pos.count * 2);
    for (let i = 0; i < pos.count; i++) {
      const nx = Math.abs(nrm.getX(i)), ny = Math.abs(nrm.getY(i)), nz = Math.abs(nrm.getZ(i));
      let u, v;
      if (nx >= ny && nx >= nz) { u = pos.getZ(i); v = pos.getY(i); }
      else if (ny >= nx && ny >= nz) { u = pos.getX(i); v = pos.getZ(i); }
      else { u = pos.getX(i); v = pos.getY(i); }
      uv[i * 2] = u / tileMetres;
      uv[i * 2 + 1] = v / tileMetres;
    }
    geom.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
  }

  function dressMiniCapper(THREE, root) {
    const grain = anodisedGrain(THREE);
    // Full metalness against the bright studio environment rendered as white
    // plastic. Anodised aluminium is a darker, satin grey that keeps its own
    // colour instead of mirroring the room.
    const alu = new THREE.MeshPhysicalMaterial({
      color: 0x8d9399,
      // Not fully metallic on purpose. A pure metal has no diffuse term, so
      // faces turned away from the key light went near-black and the part
      // stopped reading as grey aluminium at all. The anodised oxide layer
      // justifies a little diffuse, which holds the grey through every pose.
      metalness: 0.80,
      roughness: 0.42,
      roughnessMap: grain,
      clearcoat: 0.22,
      clearcoatRoughness: 0.42,
      envMapIntensity: 0.85,
    });
    root.traverse((o) => {
      if (!o.isMesh || !o.geometry) return;
      boxUV(THREE, o.geometry, 0.022);
      o.material = alu;
    });
  }

  const DRESSERS = { ewatch: dressEWatch, minicapper: dressMiniCapper };

  // Safety net for a model shipped without normals. Both current GLBs bake
  // theirs at build time, so this normally does nothing — but a drop-in swap of
  // new geometry should never render with smeared or missing shading.
  function rebuildNormals(THREE, root, utils) {
    root.traverse((o) => {
      if (!o.isMesh || !o.geometry) return;
      if (o.geometry.getAttribute("normal")) return;
      const next = utils.toCreasedNormals(o.geometry, (35 * Math.PI) / 180);
      o.geometry.dispose();
      o.geometry = next;
    });
  }

  // Soft contact shadow as a gradient sprite. Cheaper than a shadow map and
  // reads correctly on both the light and dark drafting grounds.
  function contactShadow(THREE) {
    const c = document.createElement("canvas");
    c.width = c.height = 128;
    const ctx = c.getContext("2d");
    const grd = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
    grd.addColorStop(0, "rgba(0,0,0,0.42)");
    grd.addColorStop(0.55, "rgba(0,0,0,0.14)");
    grd.addColorStop(1, "rgba(0,0,0,0)");
    ctx.fillStyle = grd;
    ctx.fillRect(0, 0, 128, 128);
    const tex = new THREE.CanvasTexture(c);
    const m = new THREE.Mesh(
      new THREE.PlaneGeometry(3.4, 3.4),
      new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false })
    );
    m.rotation.x = -Math.PI / 2;
    return m;
  }

  // --- component ---------------------------------------------------------

  function ScrollScene({ onSelect }) {
    const wrapRef = useRef(null);
    const canvasRef = useRef(null);
    const overlayRef = useRef(null);
    const titleRefs = useRef([]);
    const bubbleRefs = useRef([]);
    const [reduced, setReduced] = useState(
      () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
    );
    const [enabled] = useState(canRender3D);
    const [progress, setProgress] = useState(0);
    const [ready, setReady] = useState(false);
    const [skipped, setSkipped] = useState(false);
    const [failed, setFailed] = useState(false);

    useEffect(() => {
      if (typeof matchMedia !== "function") return;
      const mq = matchMedia("(prefers-reduced-motion: reduce)");
      const on = () => setReduced(mq.matches);
      mq.addEventListener("change", on);
      return () => mq.removeEventListener("change", on);
    }, []);

    const skip = useCallback(() => {
      setSkipped(true);
      const el = document.getElementById("work");
      if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
    }, []);

    useEffect(() => {
      if (!enabled) return;
      const wrap = wrapRef.current;
      const canvas = canvasRef.current;
      if (!wrap || !canvas || !window.ewLoadThree) return;

      let live = true;
      let raf = 0;
      let ctx = null;      // three.js scene bundle, once loaded
      let visible = false;
      let lastT = -1;
      // Bumped on resize so cached card dimensions are re-read once, not per frame.
      let measureEpoch = 0;
      const reducedNow = reduced;

      // Scroll progress is read, never stored as animation state — the frame
      // is always recomputed from the current scroll offset.
      const readProgress = () => {
        const r = wrap.getBoundingClientRect();
        const span = wrap.offsetHeight - window.innerHeight;
        return span <= 0 ? 0 : clamp(-r.top / span, 0, 1);
      };

      // The library is a fifth of the bar; the GLBs are the rest, reported from
      // real Content-Length bytes rather than a timer.
      const bytes = {};
      const withModels = SEQUENCE.filter((s) => s.url);

      const report = () => {
        if (!live) return;
        let loaded = 0, total = 0, known = 0;
        withModels.forEach((s) => {
          const b = bytes[modelURL(s)];
          if (b && b.total) { loaded += b.loaded; total += b.total; known++; }
        });
        const modelPart = known === withModels.length && total > 0 ? loaded / total : 0;
        setProgress(clamp(0.2 + 0.8 * modelPart, 0, 1));
      };

      // Nothing is fetched, parsed or decoded until the visitor is within about
      // a viewport of the stage. Starting on mount instead put Draco decode and
      // renderer setup inside the page-load window and pushed Total Blocking
      // Time from 38 ms to 1.5 s on desktop (6.9 s on throttled mobile).
      let started = false;
      function start() {
        if (started || !live) return;
        started = true;
        window.ewLoadThree(
          (p) => { if (live) setProgress(p * 0.2); }
        ).then((lib) => {
          if (!live) return;
          ctx = buildScene(lib, canvas);
          lastT = -1;
          kick();
          if (!withModels.length) { setReady(true); setProgress(1); return; }
          return loadModels(lib);
        }).catch(() => {
          // A model failure is not fatal — the placeholder rig stays on screen.
          if (live) { setReady(true); setProgress(1); }
        });
      }

      const preload = new IntersectionObserver((es) => {
        if (es.some((e) => e.isIntersecting)) { preload.disconnect(); start(); }
      }, { rootMargin: "900px 0px" });
      preload.observe(wrap);

      function loadModels(lib) {
        // Wait for the webfonts before any model is dressed — the EWatch face
        // is drawn to a canvas with IBM Plex, and drawing it with a fallback
        // and redrawing later would make identical scroll offsets render
        // differently on the way down versus the way back up.
        const fonts = (document.fonts && document.fonts.ready) || Promise.resolve();
        return Promise.all([window.ewLoadGLTF(), fonts]).then(([mod]) => {
          if (!live || !ctx) return;
          const { THREE } = lib;
          const draco = new mod.DRACOLoader();
          draco.setDecoderPath("/vendor/three/addons/libs/draco/gltf/");
          const loader = new mod.GLTFLoader();
          loader.setDRACOLoader(draco);

          const jobs = withModels.map((spec) => new Promise((resolve) => {
            loader.load(
              modelURL(spec),
              (gltf) => {
                if (!live || !ctx) return resolve();
                try {
                  rebuildNormals(THREE, gltf.scene, mod.BufferGeometryUtils);
                  swapIn(spec, gltf.scene);
                } catch (e) { /* keep the placeholder */ }
                resolve();
              },
              (ev) => {
                bytes[modelURL(spec)] = { loaded: ev.loaded, total: ev.total || 0 };
                report();
              },
              () => resolve()
            );
          }));

          return Promise.all(jobs).then(() => {
            draco.dispose();
            if (!live) return;
            setProgress(1);
            setReady(true);
            lastT = -1;
            kick();
          });
        });
      }

      // Replace a placeholder with real geometry in place, keeping the item's
      // position in the scene graph and re-deriving the callout anchors from
      // the new bounding box.
      function swapIn(spec, object) {
        const i = SEQUENCE.indexOf(spec);
        const it = ctx.items[i];
        if (!it) return;
        const { THREE } = ctx;

        it.root.remove(it.holder);
        it.holder.traverse((o) => {
          if (o.geometry) o.geometry.dispose();
          if (o.material) {
            (Array.isArray(o.material) ? o.material : [o.material]).forEach((m) => m.dispose());
          }
        });

        const built = normalise(
          THREE, object, spec.bubbles.map((b) => b.anchor), FIT, spec.orient
        );
        // Lift the imported materials against the studio environment first, so
        // a dresser's own envMapIntensity is the one that survives.
        object.traverse((o) => {
          if (o.isMesh && o.material) {
            const mats = Array.isArray(o.material) ? o.material : [o.material];
            mats.forEach((m) => { m.envMapIntensity = 1.15; });
          }
        });
        const dress = DRESSERS[spec.dress];
        if (dress) dress(ctx.THREE, object);
        it.holder = built.holder;
        it.anchors = built.anchors;
        it.baseScale = built.baseScale;
        it.halfW = built.fitWidth / 2;
        it.real = true;
        it.root.add(built.holder);
      }

      function buildScene(lib, cv) {
        const { THREE, RoomEnvironment } = lib;
        const renderer = new THREE.WebGLRenderer({
          canvas: cv, antialias: true, alpha: true, powerPreference: "high-performance",
        });
        renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
        renderer.toneMapping = THREE.ACESFilmicToneMapping;
        renderer.toneMappingExposure = 1.0;
        renderer.outputColorSpace = THREE.SRGBColorSpace;

        const scene = new THREE.Scene();
        const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100);
        camera.position.set(0, 0.15, 6.2);

        const pmrem = new THREE.PMREMGenerator(renderer);
        scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;

        const key = new THREE.DirectionalLight(0xfff2e6, 1.6);
        key.position.set(2.6, 3.4, 3.2);
        scene.add(key);
        const rim = new THREE.DirectionalLight(0x9fc4ff, 0.7);
        rim.position.set(-3, 1.2, -2.4);
        scene.add(rim);

        const items = SEQUENCE.map((spec) => {
          const raw = buildPlaceholder(THREE, spec.placeholder);
          const { holder, anchors, baseScale, fitWidth } = normalise(
            THREE, raw, spec.bubbles.map((b) => b.anchor), FIT, spec.orient
          );
          const shadow = contactShadow(THREE);
          shadow.position.y = LIFT - FIT / 2 - 0.18;
          const root = new THREE.Group();
          root.add(holder, shadow);
          root.visible = false;
          root.userData.id = spec.id;
          scene.add(root);
          return { spec, root, holder, anchors, shadow, baseScale,
                   halfW: fitWidth / 2, real: false };
        });

        const ray = new THREE.Raycaster();
        const ndc = new THREE.Vector2();
        const vec = new THREE.Vector3();

        const resize = () => {
          const w = cv.clientWidth, h = cv.clientHeight;
          if (!w || !h) return;
          renderer.setSize(w, h, false);
          camera.aspect = w / h;
          camera.updateProjectionMatrix();
        };
        resize();

        return { THREE, renderer, scene, camera, items, ray, ndc, vec, resize, pmrem };
      }

      // --- per-frame composition (pure function of t) ---------------------

      function compose(T) {
        const n = SEQUENCE.length;
        const { camera, items, vec } = ctx;
        const w = canvas.clientWidth, h = canvas.clientHeight;

        // How much world space the camera actually sees at the model plane.
        // A fixed world size overflowed narrow viewports — at 390px the visible
        // width is only ~1.75 units against a 2.05-unit model, so arms and lugs
        // ran off both edges.
        const seenH = 2 * camera.position.z * Math.tan((camera.fov * Math.PI / 180) / 2);
        const seenW = seenH * camera.aspect;
        const fitK = Math.min(1, (seenW * 0.80) / FIT, (seenH * 0.62) / FIT);
        // Travel is per-model and derived from its real fitted width, below.
        // Deriving it from FIT (the max dimension) overshot for tall narrow
        // products: the MiniCapper is 0.86 units wide against a 2.05 envelope,
        // so it sat off-stage far longer than it needed to.

        for (let i = 0; i < n; i++) {
          const it = items[i];
          const spec = it.spec;
          // tRaw can dip slightly below 0 so a section may begin sliding in
          // while the previous one is still sliding out. Without that overlap
          // both models sat off-stage at the hand-off and the viewport went
          // blank for ~100 px of scroll between products.
          const tRaw = T * n - i;
          let t = clamp(tRaw, 0, 1);
          if (reducedNow) t = STATIC_T;

          // A section is live while its own window is on screen. Outside it,
          // the whole group is skipped — no transforms, no projections.
          const live = reducedNow
            ? Math.round(clamp(T, 0, 0.999) * n - 0.5) === i || (T * n >= i && T * n < i + 1)
            : (T * n > i - LEAD - 0.02 && T * n < i + 1.02);
          it.root.visible = live;
          setSectionDom(i, live);
          if (!live) continue;

          const enter = reducedNow ? 1 : ramp(clamp(tRaw, -LEAD, 1), -LEAD, BEATS.enterTo);
          const exit = ramp(t, BEATS.exitFrom, BEATS.exitTo);
          const present = enter * (1 - exit);
          // The last product stays on stage instead of panning out into an
          // empty frame just before the Bill of materials section.
          const exitX = spec.holdAtEnd ? 0 : exit;

          // Translate in from the section's side, out to the other side.
          const travel = seenW / 2 + it.halfW * fitK + 0.3;
          const x = lerp(spec.side * travel, 0, enter) + lerp(0, -spec.side * travel, exitX);
          const scale = lerp(0.85, 1, enter) * lerp(1, 0.9, exit);
          it.root.position.set(reducedNow ? 0 : x, lerp(LIFT - 0.28, LIFT, enter), 0);
          it.holder.scale.setScalar(scale * it.baseScale * fitK);
          it.shadow.material.opacity = present * 0.9;

          // Rotation: settle on entry, then walk the callout poses.
          const spin = BEATS.exitFrom - BEATS.enterTo;
          const seg = clamp((t - BEATS.enterTo) / spin, 0, 1) * (POSES.length - 1);
          const a = Math.floor(seg), b = Math.min(a + 1, POSES.length - 1);
          const f = seg - a;
          const ease = f * f * (3 - 2 * f);
          const settle = (1 - enter) * spec.side * 0.5;
          // Tall, obviously-upright products get less pitch: at full tilt the
          // MiniCapper showed the underside of its base and read as toppling.
          const tilt = spec.poseTilt === undefined ? 1 : spec.poseTilt;
          it.holder.rotation.set(
            lerp(POSES[a][0], POSES[b][0], ease) * tilt,
            lerp(POSES[a][1], POSES[b][1], ease) + settle + exit * spec.side * -0.6,
            lerp(POSES[a][2], POSES[b][2], ease) * tilt
          );

          // Title wipe — same side as the model, driven by the same ramps.
          const title = titleRefs.current[i];
          if (title) {
            const tv = reducedNow ? 1 : enter * (1 - ramp(t, BEATS.exitFrom + 0.04, BEATS.exitTo));
            title.style.setProperty("--ss-wipe", tv.toFixed(4));
            title.style.setProperty("--ss-dir", String(spec.side));
          }

          // Callouts: project the anchor, then draw a CAD leader to the card.
          it.root.updateWorldMatrix(true, true);
          for (let bi = 0; bi < spec.bubbles.length; bi++) {
            const beat = BEATS.bubbles[bi];
            const amt = reducedNow
              ? 1
              : ramp(t, beat.in[0], beat.in[1]) * (1 - ramp(t, beat.out[0], beat.out[1]));
            const dom = bubbleRefs.current[i] && bubbleRefs.current[i][bi];
            if (!dom || !dom.el) continue;
            if (amt <= 0.001) { hideBubble(dom); continue; }
            dom.el.style.visibility = "visible";

            it.anchors[bi].getWorldPosition(vec);
            vec.project(camera);
            const px = (vec.x * 0.5 + 0.5) * w;
            const py = (-vec.y * 0.5 + 0.5) * h;
            const right = px < w * 0.52;
            const dx = right ? 1 : -1;
            const elbow = Math.min(56, w * 0.06);
            const runX = px + dx * (elbow + Math.min(120, w * 0.1));

            // Card size only changes at a breakpoint, so measure it once per
            // resize rather than reading layout every frame.
            if (dom.epoch !== measureEpoch) {
              dom.cw = dom.card.offsetWidth;
              dom.ch = dom.card.offsetHeight;
              dom.epoch = measureEpoch;
            }

            // Keep the whole card on stage. Unclamped, cards on the right half
            // at 390px ran past the viewport and truncated mid-word.
            const pad = 10;
            // The site's top nav is fixed and 56px tall (52px under 900px), so
            // a card clamped to 10px from the top slid underneath it and lost
            // its first line.
            const padTop = (w < 900 ? 52 : 56) + 12;
            const maxLeft = Math.max(pad, w - dom.cw - pad);
            const maxTop = Math.max(padTop, h - dom.ch - pad);
            let left, top;

            if (reducedNow) {
              // No motion means all three callouts are on screen at once, so
              // anchoring them each to their own point let them overlap each
              // other and the product title. Stack them in a fixed column
              // instead — every card readable, nothing colliding.
              // Stack cumulatively using each card's own measured height. A
              // single shared height overlapped them, because the three facts
              // are different lengths and so are their cards.
              const gap = 14;
              const row = bubbleRefs.current[i] || [];
              let stacked = 0;
              for (let k = 0; k < bi; k++) stacked += (row[k] && row[k].ch) || dom.ch;
              left = maxLeft;
              top = clamp(padTop + stacked + bi * gap, padTop, maxTop);
            } else {
              left = clamp(right ? runX + 8 : runX - 8 - dom.cw, pad, maxLeft);
              top = clamp(py - elbow - 14, padTop, maxTop);
            }

            // If clamping pulled the card back over its own anchor, a
            // horizontal leader would be drawn underneath an opaque card and
            // the dot would be hidden too — which is exactly what happened at
            // 390px. Move the card clear of the anchor and attach vertically.
            const coversAnchor = px > left - 8 && px < left + dom.cw + 8;
            if (coversAnchor) {
              // Park the card at the far edge rather than right next to the
              // anchor. Sitting it an elbow away covered most of the product on
              // a phone, and left the leader as a ~30px stub.
              const bottomLimit = Math.max(padTop, h - dom.ch - (w < 900 ? 168 : 132));
              top = py > h * 0.5
                ? padTop
                : clamp(bottomLimit, padTop, maxTop);
            }

            // Never fade the card. Any opacity below 1 lets the model read
            // through the panel and the text stops being legible — a faster
            // fade only narrowed the bad band, it did not remove it. The card
            // is always fully opaque and is revealed with a clip wipe instead,
            // which also suits the drawing-package language better.
            dom.el.style.opacity = "1";
            dom.el.style.setProperty("--ss-dot-o", clamp(amt * 3, 0, 1).toFixed(3));
            dom.el.style.setProperty("--ss-x", px.toFixed(1) + "px");
            dom.el.style.setProperty("--ss-y", py.toFixed(1) + "px");
            dom.card.style.transform =
              `translate(${left.toFixed(1)}px, ${top.toFixed(1)}px)`;

            // Reveal from whichever edge the leader arrives at.
            const wipe = clamp((amt - 0.02) / 0.30, 0, 1);
            const hidden = ((1 - wipe) * 100).toFixed(1);
            dom.card.style.clipPath = coversAnchor
              ? (top + dom.ch <= py
                  ? `inset(${hidden}% 0 0 0)`
                  : `inset(0 0 ${hidden}% 0)`)
              : (px < left
                  ? `inset(0 ${hidden}% 0 0)`
                  : `inset(0 0 0 ${hidden}%)`);

            // Terminate the leader on the card's actual edge, wherever clamping
            // put it, so the line always reads as attached.
            let d;
            if (coversAnchor) {
              const above = top + dom.ch <= py;
              const attachY = above ? top + dom.ch : top;
              const attachX = clamp(px, left + 16, left + dom.cw - 16);
              const step = above ? -elbow * 0.55 : elbow * 0.55;
              d = `M ${px.toFixed(1)} ${py.toFixed(1)} ` +
                  `L ${attachX.toFixed(1)} ${(py + step).toFixed(1)} ` +
                  `L ${attachX.toFixed(1)} ${attachY.toFixed(1)}`;
            } else {
              const endX = px < left ? left : left + dom.cw;
              const elbowY = clamp(py - elbow, top + 10, top + dom.ch - 10);
              const stepX = px < left ? px + elbow : px - elbow;
              d = `M ${px.toFixed(1)} ${py.toFixed(1)} ` +
                  `L ${stepX.toFixed(1)} ${elbowY.toFixed(1)} ` +
                  `L ${endX.toFixed(1)} ${elbowY.toFixed(1)}`;
            }
            dom.path.setAttribute("d", d);
            const len = dom.path.getTotalLength ? dom.path.getTotalLength() : 200;
            const draw = clamp(amt * 1.35, 0, 1);
            dom.path.style.strokeDasharray = String(len);
            dom.path.style.strokeDashoffset = String(len * (1 - draw));
          }
        }
      }

      // Leader paths live in one shared <svg>, not inside .ss-bubble, so
      // hiding the bubble does not hide its leader. Both have to be cleared
      // together or a stale line stays painted when the section scrolls away.
      function hideBubble(dom) {
        if (!dom) return;
        if (dom.el && dom.el.style.visibility !== "hidden") dom.el.style.visibility = "hidden";
        if (dom.path && dom.path.hasAttribute("d")) dom.path.removeAttribute("d");
      }

      function setSectionDom(i, on) {
        const title = titleRefs.current[i];
        if (title) title.style.visibility = on ? "visible" : "hidden";
        const row = bubbleRefs.current[i];
        if (!on && row) row.forEach(hideBubble);
      }

      function frame() {
        raf = 0;
        if (!live || !ctx) return;
        const T = readProgress();
        if (visible && (Math.abs(T - lastT) > 0.00002 || lastT < 0)) {
          lastT = T;
          ctx.resize();
          compose(T);
          ctx.renderer.render(ctx.scene, ctx.camera);
        }
        if (visible) raf = requestAnimationFrame(frame);
      }

      const kick = () => { if (visible && !raf) raf = requestAnimationFrame(frame); };

      // Render loop runs only while the stage is on screen (§6.4).
      const io = new IntersectionObserver((es) => {
        visible = es.some((e) => e.isIntersecting);
        if (visible) { lastT = -1; kick(); }
        else if (raf) { cancelAnimationFrame(raf); raf = 0; }
      }, { rootMargin: "120px 0px" });
      io.observe(wrap);

      const onScroll = () => kick();
      const onResize = () => { lastT = -1; measureEpoch++; kick(); };
      window.addEventListener("scroll", onScroll, { passive: true });
      window.addEventListener("resize", onResize);

      // Click a model → jump to its line in the bill of materials and open it.
      const onClick = (e) => {
        if (!ctx || !onSelect) return;
        const r = canvas.getBoundingClientRect();
        ctx.ndc.set(
          ((e.clientX - r.left) / r.width) * 2 - 1,
          -((e.clientY - r.top) / r.height) * 2 + 1
        );
        ctx.ray.setFromCamera(ctx.ndc, ctx.camera);
        const live = ctx.items.filter((it) => it.root.visible).map((it) => it.root);
        const hit = ctx.ray.intersectObjects(live, true)[0];
        if (!hit) return;
        let o = hit.object;
        while (o && !o.userData.id) o = o.parent;
        if (o && o.userData.id) onSelect(o.userData.id);
      };
      canvas.addEventListener("click", onClick);

      return () => {
        live = false;
        io.disconnect();
        preload.disconnect();
        window.removeEventListener("scroll", onScroll);
        window.removeEventListener("resize", onResize);
        canvas.removeEventListener("click", onClick);
        if (raf) cancelAnimationFrame(raf);
        if (ctx) {
          ctx.pmrem.dispose();
          ctx.scene.traverse((o) => {
            if (o.geometry) o.geometry.dispose();
            if (o.material) {
              (Array.isArray(o.material) ? o.material : [o.material])
                .forEach((m) => { if (m.map) m.map.dispose(); m.dispose(); });
            }
          });
          ctx.renderer.dispose();
        }
      };
    }, [enabled, reduced, onSelect]);

    // Devices that cannot render, or a hard failure, fall through to the
    // crawlable DOM content that already exists further down the page.
    if (!enabled || failed) return null;

    const pct = Math.round(progress * 100);

    return (
      <div
        className={`ss-wrap ${reduced ? "is-reduced" : ""}`}
        ref={wrapRef}
        style={{ height: `calc(${SEQUENCE.length} * ${reduced ? REDUCED_VH : SECTION_VH}vh)` }}
      >
        <div className="ss-stage">
          <div className="ss-frame" aria-hidden="true">
            <span className="reg-mark tl" />
            <span className="reg-mark tr" />
            <span className="reg-mark bl" />
            <span className="reg-mark br" />
          </div>

          <canvas className="ss-canvas" ref={canvasRef} aria-hidden="true" />

          <div className="ss-overlay" ref={overlayRef} aria-hidden="true">
            <svg className="ss-leaders">
              {SEQUENCE.map((s, i) =>
                s.bubbles.map((b, bi) => (
                  <path
                    key={`${s.id}-${bi}`}
                    className="ss-leader-path"
                    ref={(el) => {
                      bubbleRefs.current[i] = bubbleRefs.current[i] || [];
                      bubbleRefs.current[i][bi] = bubbleRefs.current[i][bi] || {};
                      bubbleRefs.current[i][bi].path = el;
                    }}
                  />
                ))
              )}
            </svg>

            {SEQUENCE.map((s, i) => (
              <div className="ss-section" key={s.id}>
                <div
                  className="ss-title"
                  ref={(el) => { titleRefs.current[i] = el; }}
                >
                  <span className="mono-tag ss-doc">{s.doc} · {s.tag}</span>
                  <span className="ss-title-mask"><span className="ss-title-text">{s.title}</span></span>
                </div>

                {s.bubbles.map((b, bi) => (
                  <div
                    className="ss-bubble"
                    key={`${s.id}-b${bi}`}
                    ref={(el) => {
                      bubbleRefs.current[i] = bubbleRefs.current[i] || [];
                      bubbleRefs.current[i][bi] = bubbleRefs.current[i][bi] || {};
                      bubbleRefs.current[i][bi].el = el;
                    }}
                  >
                    <span className="ss-dot" />
                    <div
                      className="ss-card"
                      ref={(el) => {
                        bubbleRefs.current[i] = bubbleRefs.current[i] || [];
                        bubbleRefs.current[i][bi] = bubbleRefs.current[i][bi] || {};
                        bubbleRefs.current[i][bi].card = el;
                      }}
                    >
                      <span className="mono-tag ss-card-k">
                        {String(bi + 1).padStart(2, "0")} — {b.k}
                      </span>
                      <p className="ss-card-v">{b.text}</p>
                    </div>
                  </div>
                ))}
              </div>
            ))}
          </div>

          {!ready && !skipped && (
            <div className="ss-loading" role="status" aria-live="polite">
              <span className="mono-tag">Loading assembly</span>
              <div className="ss-bar"><i style={{ width: `${pct}%` }} /></div>
              <span className="ss-pct mono-tag">{pct}%</span>
              <button className="ss-skip" onClick={skip}>Skip to work</button>
            </div>
          )}
        </div>
      </div>
    );
  }

  return { ScrollScene, SEQUENCE };
})();

window.ScrollScene = SS.ScrollScene;
window.SCROLL_SEQUENCE = SS.SEQUENCE;
