/* Chrome shared by every page. Exposes window.FX. Loaded after content.js. */
(function () {
  const DS = window.GlacierDesignSystem_b5cdbc;
  const { GrainField, Button, IconButton, Icon, Label, Divider, MetaRow, ImagePlate, NavBar, Badge, TabSwitch } = DS;
  const F = window.FRAGMENT;

  const PAGES = {
    Home: "index.html",
    Work: "work.html",
    // Studio + CV / Blog became one About page (2026-10-02); the old URLs
    // redirect to it (vercel.json).
    About: "about.html",
    // Fragment link hidden from the nav for now (2026-10-02).
  };

  const go = (u) => window.open(u, "_blank", "noopener");

  function Sec({ children, style, id, label, className }) {
    return (
      <section className={"gx-sec" + (className ? " " + className : "")} style={style} id={id} aria-label={label}>
        {children}
      </section>
    );
  }

  /* Eyebrow row + display heading. `tone="dark"` on light (rime) sections. */
  function SectionHead({ eyebrow, caption, title, tone, id, maxWidth = "22ch", style }) {
    const light = tone === "dark";
    return (
      <React.Fragment>
        <div className="gx-eyebrow">
          <Label tone={light ? "dark" : undefined} wide>{eyebrow}</Label>
          {caption && (
            <span style={{ font: "var(--type-caption)", color: light ? "var(--text-muted-on-light)" : "var(--text-muted)" }}>
              {caption}
            </span>
          )}
        </div>
        {title && (
          <h2
            id={id}
            style={{
              font: "var(--type-display-3)", letterSpacing: "var(--tracking-display)",
              color: light ? "var(--text-on-light)" : "var(--text-primary)",
              margin: "0 0 var(--space-10)", maxWidth, textWrap: "balance", ...style,
            }}
          >
            {title}
          </h2>
        )}
      </React.Fragment>
    );
  }

  /* ── Responsive switch ────────────────────────────────────────────────
     The Glacier bundle styles NavBar with INLINE styles, which a media
     query cannot reach without a wall of !important. So below 768px we
     don't fight it — we render a different component entirely. Desktop
     keeps the design-system capsule untouched. */
  function useMediaQuery(q) {
    const [hit, setHit] = React.useState(() => window.matchMedia(q).matches);
    React.useEffect(() => {
      const mq = window.matchMedia(q);
      const on = (e) => setHit(e.matches);
      mq.addEventListener("change", on);
      setHit(mq.matches);
      return () => mq.removeEventListener("change", on);
    }, [q]);
    return hit;
  }

  /* Phone navigation: a 48px glass bar that opens a full-height sheet.
     Four destinations and a CTA cannot sit on one 375px line — so they
     don't try. The bar carries identity and one control; the sheet
     carries the choice, at a type size worth tapping. */
  function MobileNav({ active }) {
    const items = Object.keys(PAGES);
    const [open, setOpen] = React.useState(false);
    const toggle = React.useRef(null);
    const sheet = React.useRef(null);

    const close = React.useCallback(() => {
      setOpen(false);
      if (toggle.current) toggle.current.focus();
    }, []);

    React.useEffect(() => {
      // `inert` rather than `hidden`: the sheet stays mounted so it can
      // transition, but never takes focus or reads out while closed.
      if (sheet.current) {
        if (open) sheet.current.removeAttribute("inert");
        else sheet.current.setAttribute("inert", "");
      }
      if (!open) return;
      const prev = document.body.style.overflow;
      const prevHtml = document.documentElement.style.overflow;
      document.body.style.overflow = "hidden";
      document.documentElement.style.overflow = "hidden";
      const onKey = (e) => { if (e.key === "Escape") close(); };
      document.addEventListener("keydown", onKey);
      return () => {
        document.body.style.overflow = prev;
        document.documentElement.style.overflow = prevHtml;
        document.removeEventListener("keydown", onKey);
      };
    }, [open, close]);

    /* The bar is fixed, so it survives a 16,000px page — but it tucks away
       while the visitor is reading downward and returns the moment they
       reverse. Never hidden while the sheet is open. */
    const [tucked, setTucked] = React.useState(false);
    React.useEffect(() => {
      let last = window.scrollY;
      let frame = 0;
      const onScroll = () => {
        if (frame) return;
        frame = requestAnimationFrame(() => {
          frame = 0;
          const y = window.scrollY;
          const dy = y - last;
          if (Math.abs(dy) > 6) {
            setTucked(y > 160 && dy > 0);
            last = y;
          }
        });
      };
      window.addEventListener("scroll", onScroll, { passive: true });
      return () => { window.removeEventListener("scroll", onScroll); cancelAnimationFrame(frame); };
    }, []);

    const pad = (i) => String(i + 1).padStart(2, "0");

    return (
      <React.Fragment>
        <div className="gx-mnav-spacer" aria-hidden="true" />
        <nav className={"gx-mnav" + (tucked && !open ? " is-tucked" : "")} aria-label="Primary">
          <a className="gx-mnav-brand" href={PAGES.Home} aria-label={`${F.nav.brand} — home`}><span className="gx-brand">{F.nav.brand}</span></a>
          <button
            ref={toggle}
            type="button"
            className="gx-mnav-toggle"
            aria-expanded={open}
            aria-controls="gx-mnav-sheet"
            onClick={() => setOpen((o) => !o)}
          >
            <span className="gx-sr">{open ? "Close menu" : "Open menu"}</span>
            <span className="gx-mnav-bars" aria-hidden="true"><i /><i /></span>
          </button>
        </nav>

        <div
          id="gx-mnav-sheet"
          ref={sheet}
          className={"gx-mnav-sheet" + (open ? " is-open" : "")}
          onClick={(e) => { if (e.target === e.currentTarget) close(); }}
        >
          <ul className="gx-mnav-list">
            {items.map((it, i) => (
              <li key={it}>
                <a
                  className={"gx-mnav-item" + (it === active ? " is-active" : "")}
                  href={PAGES[it]}
                  target={it === "Fragment" ? "_blank" : undefined}
                  rel={it === "Fragment" ? "noopener noreferrer" : undefined}
                  aria-current={it === active ? "page" : undefined}
                  onClick={(e) => { if (it === active) { e.preventDefault(); close(); } }}
                >
                  <span className="gx-mnav-idx">{pad(i)}</span>
                  <span>{it}</span>
                  <span className="gx-mnav-go" aria-hidden="true">&#8599;</span>
                </a>
              </li>
            ))}
          </ul>

          <div className="gx-mnav-foot">
            <div className="gx-mnav-meta">
              <span>{F.identity.email}</span>
              <span>{F.identity.timezone}</span>
            </div>
          </div>
        </div>
      </React.Fragment>
    );
  }

  /* Sound orb — the site's music control, on every page (it rides with
     Nav). A glass sphere like the ones in the orbit artwork, with the record
     turning inside it while the slow-jazz track plays.

     Across pages: the position and on/off state are kept in sessionStorage,
     so the next page picks the track up where it left off. Autoplay is
     attempted (unless the visitor switched it off); browsers usually refuse
     until the page has been interacted with, and then the orb pulses "Tap to
     play". Only this control starts sound (WCAG 1.4.2). */
  function SoundOrb() {
    const audio = F.hero && F.hero.audio;
    const ref = React.useRef(null);
    const [playing, setPlaying] = React.useState(false);
    const [ask, setAsk] = React.useState(false);
    const [missing, setMissing] = React.useState(false);
    const KEY = "gx-audio-pos";

    React.useEffect(() => {
      const a = ref.current;
      if (!a) return undefined;
      a.volume = 0.6;
      let off = false, pos = null;
      try { off = window.localStorage.getItem("gx-audio") === "off"; pos = JSON.parse(window.sessionStorage.getItem(KEY) || "null"); } catch (e) { /* storage blocked */ }
      const resume = () => { if (pos && pos.t) { try { a.currentTime = pos.t; } catch (e) { /* not seekable yet */ } } };
      if (a.readyState >= 1) resume(); else a.addEventListener("loadedmetadata", resume, { once: true });
      if (!off) { const p = a.play(); if (p && p.catch) p.catch(() => setAsk(true)); }
      const save = () => { try { window.sessionStorage.setItem(KEY, JSON.stringify({ t: a.currentTime })); } catch (e) { /* storage blocked */ } };
      const iv = setInterval(save, 2000);
      window.addEventListener("pagehide", save);
      return () => { clearInterval(iv); window.removeEventListener("pagehide", save); };
    }, []);

    // The orbit (home only) turns a little faster with the music.
    React.useEffect(() => {
      const sync = () => { if (window.__gxOrbit) window.__gxOrbit.params.speed = playing ? 1.25 : 1; };
      sync();
      window.addEventListener("gx-orbit:live", sync);
      return () => window.removeEventListener("gx-orbit:live", sync);
    }, [playing]);

    if (!audio || !audio.src || missing) return null;
    const toggle = () => {
      const a = ref.current;
      if (!a) return;
      if (a.paused) a.play().catch(() => setAsk(true)); else a.pause();
      try { window.localStorage.setItem("gx-audio", a.paused ? "on" : "off"); } catch (e) { /* storage blocked */ }
    };
    // Portalled to <body>: Nav sits inside a GrainField, which is its own
    // stacking context, so a fixed child there slid under later sections.
    return ReactDOM.createPortal((
      <React.Fragment>
        <audio ref={ref} src={audio.src} loop preload="auto"
          onPlay={() => { setPlaying(true); setAsk(false); }} onPause={() => setPlaying(false)} onError={() => setMissing(true)} />
        <span className={"gx-orb-tip" + (ask && !playing ? " is-shown" : "")} aria-hidden="true">Tap to play</span>
        <button type="button" className={"gx-orb" + (playing ? " is-playing" : "") + (ask && !playing ? " is-asking" : "")}
          onClick={toggle} aria-pressed={playing} aria-label={playing ? "Pause music" : "Play music"} title={playing ? "Pause music" : "Play music"}>
          <GlassOrb playing={playing} />
          {/* Without WebGL 2 the CSS record below stands in. */}
          <span className="gx-orb-disc" aria-hidden="true" />
        </button>
      </React.Fragment>
    ), document.body);
  }

  function Nav({ active }) {
    return (
      <React.Fragment>
        <NavInner active={active} />
        <SoundOrb />
      </React.Fragment>
    );
  }

  function NavInner({ active }) {
    const items = Object.keys(PAGES);
    const phone = useMediaQuery("(max-width:767px)");
    if (phone) return <MobileNav active={active} />;
    return (
      <NavBar
        brand={<span className="gx-brand">{F.nav.brand}</span>}
        items={items}
        active={active}
        onSelect={(it) => { if (it === "Fragment") go(PAGES[it]); else if (it !== active) window.location.href = PAGES[it]; }}
        action={null}   /* no CTA in the menu (2026-10-02) */
      />
    );
  }

  /* The address used to be a 64px display headline linking straight to
     Gmail's compose URL. Two problems: it shouted louder than any project
     title, and it assumed the visitor's mail lives at google.com — in a
     Teams/Outlook shop that link is a dead end.

     Now it is a line of mono that copies on click, says so, and offers the
     OS mail handler as the alternative. Clipboard access throws on insecure
     origins and inside some in-app browsers, so there is a execCommand
     fallback and, failing both, the text is selected for a manual copy. */
  function CopyEmail() {
    const [status, setStatus] = React.useState(null); // null | "ok" | "manual"
    const timer = React.useRef(0);
    const textRef = React.useRef(null);

    React.useEffect(() => () => clearTimeout(timer.current), []);

    const announce = (next) => {
      setStatus(next);
      clearTimeout(timer.current);
      timer.current = setTimeout(() => setStatus(null), 2600);
    };

    const copy = async () => {
      const value = F.identity.email;
      try {
        await navigator.clipboard.writeText(value);
        announce("ok");
        return;
      } catch (e) { /* insecure origin, denied, or no API */ }
      try {
        const ta = document.createElement("textarea");
        ta.value = value;
        ta.setAttribute("readonly", "");
        ta.style.cssText = "position:fixed;top:-1000px;opacity:0";
        document.body.appendChild(ta);
        ta.select();
        const ok = document.execCommand("copy");
        document.body.removeChild(ta);
        announce(ok ? "ok" : "manual");
      } catch (e) {
        announce("manual");
      }
      if (status === "manual" && textRef.current) {
        const r = document.createRange();
        r.selectNodeContents(textRef.current);
        const sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(r);
      }
    };

    return (
      <div className="gx-addr">
        <button type="button" className="gx-mail" onClick={copy}>
          <span className="gx-mail-text" ref={textRef}>{F.identity.email}</span>
          <span className="gx-mail-act" aria-hidden="true">Copy</span>
        </button>
        <a className="gx-mail-alt" href={"mailto:" + F.identity.email}>
          or open in your mail app
        </a>
        {/* role=status + aria-live announces without stealing focus. */}
        <div className={"gx-toast" + (status ? " is-up" : "")} role="status" aria-live="polite">
          {status === "ok" ? "Email copied" : status === "manual" ? "Press \u2318C / Ctrl+C to copy" : ""}
        </div>
      </div>
    );
  }

  /* ── Chrome, drawn by hand ─────────────────────────────────────────────
     Two small WebGL pieces share the hero artwork's look: the same analytic
     photo studio (a dark room, hard softboxes, a light line below the view
     axis) reflected in polished metal, with the same warm spectral split at
     light/dark edges. Plain WebGL 2, no three.js — they live on every page. */
  const STUDIO_GLSL = `
  float box(vec2 q, vec2 c, vec2 hw, float soft) {
    vec2 dq = q - c; dq.x = mod(dq.x + 3.14159265, 6.2831853) - 3.14159265;
    vec2 d = abs(dq) - hw; return 1.0 - smoothstep(-soft, soft, max(d.x, d.y));
  }
  vec3 env(vec3 r) {
    float y = r.y, az = atan(r.x, r.z); vec2 q = vec2(az, y);
    vec3 c = vec3(0.010, 0.011, 0.013);
    c += vec3(0.16) * smoothstep(-0.95, -0.4, y) * step(y, -0.36);
    c += vec3(0.05) * smoothstep(0.0, 1.0, y);
    c += vec3(2.4) * exp(-pow((y + 0.34) / 0.03, 2.0));
    c += vec3(1.2) * exp(-pow((y - 0.30) / 0.02, 2.0));
    c += vec3(3.2) * box(q, vec2(0.0, 0.80), vec2(3.3, 0.17), 0.10);
    c += vec3(7.0) * box(q, vec2(-0.95, 0.18), vec2(0.07, 0.70), 0.012);
    c += vec3(2.6) * box(q, vec2(1.20, 0.08), vec2(0.34, 0.52), 0.16);
    c += vec3(5.0) * box(q, vec2(-0.30, 0.44), vec2(0.20, 0.07), 0.025);
    c += vec3(1.4) * box(q, vec2(0.38, -0.40), vec2(0.42, 0.06), 0.06);
    c += vec3(3.2) * box(q, vec2(2.65, 0.25), vec2(0.40, 0.65), 0.22);
    c += vec3(2.8, 2.6, 2.3) * box(q, vec2(-2.70, -0.05), vec2(0.35, 0.55), 0.22);
    return c;
  }
  // Warm-biased spectral split of the reflection (red, orange, yellow, green).
  vec3 chrome(vec3 n, float k) {
    vec3 V = vec3(0.0, 0.0, -1.0), c = vec3(0.0);
    c.r = env(reflect(V, normalize(vec3(n.xy * (1.0 + k), n.z)))).r;
    c.g = env(reflect(V, n)).g;
    c.b = env(reflect(V, normalize(vec3(n.xy * (1.0 - k), n.z)))).b;
    return c;
  }
  vec3 aces(vec3 x) { return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); }
  vec3 display(vec3 c) { return pow(aces(c), vec3(1.0 / 2.2)); }
  `;

  // A full-screen triangle with one fragment shader. Returns null without WebGL 2.
  function glQuad(canvas, frag) {
    const gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true, antialias: true });
    if (!gl) return null;
    const sh = (type, src) => { const o = gl.createShader(type); gl.shaderSource(o, src); gl.compileShader(o); return o; };
    const prog = gl.createProgram();
    gl.attachShader(prog, sh(gl.VERTEX_SHADER, "#version 300 es\nin vec2 a;out vec2 vP;void main(){vP=a;gl_Position=vec4(a,0.,1.);}"));
    gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, frag));
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
    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, "a"); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
    const u = {}; const U = (n) => (u[n] !== undefined ? u[n] : (u[n] = gl.getUniformLocation(prog, n)));
    return {
      gl, U,
      fit() {
        const d = Math.min(window.devicePixelRatio || 1, 2), w = Math.round(canvas.clientWidth * d), h = Math.round(canvas.clientHeight * d);
        if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); }
      },
      draw() { gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 3); },
      dispose() { const x = gl.getExtension("WEBGL_lose_context"); if (x) x.loseContext(); },
    };
  }

  /* Liquid chrome CTA — one polished-metal icon that melts from mail into a
     message bubble into a phone, and round again. Each icon is a stroke
     (a distance field to its centre line) drawn as a round chrome tube; the
     morph blends one field into the next with a ripple, so the metal flows.
     A press acts on whatever it shows: email, a message (Instagram DM), or
     booking a call. The cursor is a lamp reflected in the tube. */
  const CTA_ACTIONS = [
    { key: "mail", label: "Email me", href: () => F.identity.mailto || ("mailto:" + F.identity.email), external: false },
    { key: "message", label: "Message me", href: () => F.links.instagram, external: true },
    { key: "phone", label: "Text Me", href: () => F.links.call, external: true },
  ];
  const CTA_FRAG = `#version 300 es
  precision highp float;
  in vec2 vP; out vec4 o;
  uniform float uFrom, uTo, uMix, uT; uniform vec2 uLamp; uniform float uLampOn;
  ${STUDIO_GLSL}
  float sdBox(vec2 p, vec2 b, float r) { vec2 q = abs(p) - b + r; return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; }
  float sdSeg(vec2 p, vec2 a, vec2 b) { vec2 pa = p - a, ba = b - a; float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); return length(pa - ba * h); }
  // Distance to each icon's centre line.
  float mailD(vec2 p) {
    float frame = abs(sdBox(p, vec2(0.62, 0.42), 0.10));
    float flap = min(sdSeg(p, vec2(-0.50, 0.30), vec2(0.0, -0.04)), sdSeg(p, vec2(0.50, 0.30), vec2(0.0, -0.04)));
    return min(frame, flap);
  }
  float msgD(vec2 p) {
    float body = abs(sdBox(p - vec2(0.0, 0.10), vec2(0.62, 0.40), 0.26));
    float tail = min(sdSeg(p, vec2(-0.30, -0.30), vec2(-0.44, -0.56)), sdSeg(p, vec2(-0.44, -0.56), vec2(-0.06, -0.30)));
    float dots = min(min(length(p - vec2(-0.24, 0.10)), length(p - vec2(0.0, 0.10))), length(p - vec2(0.24, 0.10)));   // three round beads
    return min(min(body, tail), dots);
  }
  float phoneD(vec2 p) {
    float frame = abs(sdBox(p, vec2(0.36, 0.64), 0.15));
    float spk = sdSeg(p, vec2(-0.10, 0.48), vec2(0.10, 0.48));
    float bar = sdSeg(p, vec2(-0.12, -0.48), vec2(0.12, -0.48));
    return min(frame, min(spk, bar));
  }
  float iconD(float i, vec2 p) { return i < 0.5 ? mailD(p) : (i < 1.5 ? msgD(p) : phoneD(p)); }
  float field(vec2 p) {
    float a = iconD(uFrom, p), b = iconD(uTo, p);
    float k = uMix;
    // Mid-morph the metal ripples, so it flows rather than cross-fades.
    float ripple = sin(k * 3.14159) * 0.05 * sin(p.x * 9.0 + uT * 3.0) * sin(p.y * 7.0 - uT * 2.3);
    float bulge = sin(k * 3.14159) * 0.035;   // the tube swells a little while it moves
    return mix(a, b, smoothstep(0.0, 1.0, k)) + ripple - bulge;
  }
  void main() {
    vec2 p = vP * 1.05;
    float R = 0.075;                                   // tube radius
    float d = field(p);
    float e = 0.004;
    vec2 g = vec2(field(p + vec2(e, 0.0)) - field(p - vec2(e, 0.0)), field(p + vec2(0.0, e)) - field(p - vec2(0.0, e))) / (2.0 * e);
    float gl = max(length(g), 1e-4);
    float sd = d / gl;                                 // distance to the centre line, corrected
    float aa = fwidth(sd) * 1.2;
    float m = 1.0 - smoothstep(R - aa, R + aa, sd);
    // Soft contact shadow on the white page, down and to the right.
    vec2 ps = p - vec2(0.03, -0.06);
    float ds = field(ps);
    float sh = (1.0 - smoothstep(R * 0.6, R * 3.2, ds)) * 0.16;
    if (m <= 0.0) { o = vec4(0.0, 0.0, 0.0, sh); return; }
    float s = clamp(sd / R, 0.0, 0.999);
    vec2 dir = g / gl;
    vec3 n = vec3(dir * s, sqrt(1.0 - s * s));          // a round tube
    vec3 col = chrome(n, 0.10) * 1.1;
    float F = 0.92 + 0.08 * pow(1.0 - n.z, 5.0);
    col *= F;
    // Cursor lamp: a hard glint where the tube faces it.
    vec3 L = normalize(vec3(uLamp - p, 0.9));
    vec3 Rf = reflect(vec3(0.0, 0.0, -1.0), n);
    float rl = max(dot(Rf, L), 0.0);
    col += (pow(rl, 400.0) * 8.0 + pow(rl, 40.0) * 0.4) * uLampOn;
    col = display(col);
    o = vec4(col * m, m + sh * (1.0 - m));
  }`;

  function ChromeCTA() {
    const cv = React.useRef(null);
    const [step, setStep] = React.useState(0);
    React.useEffect(() => {
      const canvas = cv.current;
      if (!canvas) return undefined;
      const q = glQuad(canvas, CTA_FRAG);
      if (!q) { canvas.parentElement.classList.add("is-static"); return undefined; }
      const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const HOLD = 2.0, MORPH = 1.1, CYCLE = HOLD + MORPH;
      let raf = 0, t0 = performance.now(), visible = true, last = -1;
      const lamp = { x: 0.6, y: 0.6, on: 0, want: 0 };
      const move = (e) => {
        const b = canvas.getBoundingClientRect();
        lamp.x = ((e.clientX - b.left) / b.width * 2 - 1) * 1.05; lamp.y = -((e.clientY - b.top) / b.height * 2 - 1) * 1.05; lamp.want = 1;
      };
      const leave = () => { lamp.want = 0; };
      window.addEventListener("pointermove", move, { passive: true });
      document.documentElement.addEventListener("pointerleave", leave);
      const frame = (now) => {
        raf = requestAnimationFrame(frame);
        q.fit();
        const t = (now - t0) / 1000;
        const cycle = reduced ? 0 : Math.floor(t / CYCLE), phase = reduced ? 0 : t - cycle * CYCLE;
        const from = cycle % 3, to = (from + 1) % 3;
        const mix = phase < HOLD ? 0 : (phase - HOLD) / MORPH;
        const shown = mix > 0.5 ? to : from;
        if (shown !== last) { last = shown; setStep(shown); }
        lamp.on += ((lamp.want ? 1 : 0.35) - lamp.on) * 0.08;
        const { gl, U } = q;
        gl.uniform1f(U("uFrom"), from); gl.uniform1f(U("uTo"), to); gl.uniform1f(U("uMix"), mix); gl.uniform1f(U("uT"), t);
        gl.uniform2f(U("uLamp"), lamp.want ? lamp.x : 0.6 + 0.3 * Math.sin(t * 0.7), lamp.want ? lamp.y : 0.7);
        gl.uniform1f(U("uLampOn"), lamp.on);
        q.draw();
      };
      const io = new IntersectionObserver((en) => {
        visible = en[0].isIntersecting;
        if (visible && !raf) raf = requestAnimationFrame(frame);
        else if (!visible && raf) { cancelAnimationFrame(raf); raf = 0; }
      });
      io.observe(canvas);
      raf = requestAnimationFrame(frame);
      return () => {
        cancelAnimationFrame(raf); io.disconnect(); q.dispose();
        window.removeEventListener("pointermove", move); document.documentElement.removeEventListener("pointerleave", leave);
      };
    }, []);
    const a = CTA_ACTIONS[step];
    return (
      <a className="gx-chrome-cta" href={a.href()} target={a.external ? "_blank" : undefined} rel={a.external ? "noopener" : undefined} aria-label={a.label}>
        <canvas ref={cv} className="gx-chrome-cta-art" aria-hidden="true" />
        <span className="gx-chrome-cta-label" aria-hidden="true">
          {CTA_ACTIONS.map((x, i) => <span key={x.key} className={i === step ? "is-on" : ""}>{x.label}</span>)}
        </span>
      </a>
    );
  }

  /* Liquid CTA — the contact section's object (liquid-icon.js, approved in
     cta-lab.html). Real liquid metal in the shape of mail → message → phone:
     it flows from one to the next, parts under the cursor, and splashes into
     mercury drops on a press, which roll home. A press makes the splash; the
     label under it is the link and follows the icon. Loads only when the
     section comes near, pauses off screen; without WebGL 2 the drawn chrome
     icon (ChromeCTA) stands in. */
  function LiquidCTA() {
    const stageRef = React.useRef(null);
    const [state, setState] = React.useState("loading"); // loading | live | failed
    const [icon, setIcon] = React.useState(0);
    React.useEffect(() => {
      const host = stageRef.current;
      if (!host) return undefined;
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setState("failed"); return undefined; }
      let h = null, io = null, cancelled = false;
      const start = async () => {
        h = await window.LiquidIcon.mount(host, { onIcon: (i) => setIcon(i), onFail: () => {} }).catch(() => null);
        if (cancelled) { if (h) h.destroy(); return; }
        if (!h) { setState("failed"); return; }
        io = new IntersectionObserver((en) => h.setActive(en[0].isIntersecting), { rootMargin: "100px" });
        io.observe(host);
        setState("live");
      };
      const near = new IntersectionObserver((en) => {
        if (!en[0].isIntersecting) return;
        near.disconnect();
        if (window.LiquidIcon) start(); else window.addEventListener("liquidicon:ready", start, { once: true });
      }, { rootMargin: "400px" });
      near.observe(host);
      return () => {
        cancelled = true; near.disconnect(); if (io) io.disconnect(); if (h) h.destroy();
        window.removeEventListener("liquidicon:ready", start);
      };
    }, []);
    // The liquid IS the link: a clean press opens what it shows; a drag
    // (more than a few px) only splashes and is not followed.
    const press = React.useRef(null);
    if (state === "failed") return <ChromeCTA />;
    const a = CTA_ACTIONS[icon];
    return (
      <a className={"gx-liquid-cta is-" + state} href={a.href()}
        target={a.external ? "_blank" : undefined} rel={a.external ? "noopener" : undefined}
        aria-label={a.label} title={a.label}
        onPointerDown={(e) => { press.current = { x: e.clientX, y: e.clientY }; }}
        onClick={(e) => {
          const p0 = press.current;
          if (p0 && Math.hypot(e.clientX - p0.x, e.clientY - p0.y) > 8) e.preventDefault();
          press.current = null;
        }}>
        <div ref={stageRef} className="gx-liquid-cta-stage" aria-hidden="true" />
      </a>
    );
  }

  /* Glass orb — the music control's skin. A real glass ball like the ones
     in the hero artwork: the record inside is seen THROUGH it (magnified in
     the middle, bending away toward the rim, each colour bending a little
     differently), the studio is reflected on its surface at Fresnel
     strength, and past the record the page behind shows through (blurred by
     the button's backdrop filter). No painted colour rim. */
  const ORB_FRAG = `#version 300 es
  precision highp float;
  in vec2 vP; out vec4 o;
  uniform sampler2D uLabel; uniform float uAngle, uHasLabel;
  ${STUDIO_GLSL}
  // The record, seen flat: grooves, a sheen that turns with it, the label, the spindle.
  vec4 record(vec2 q) {
    float c = cos(uAngle), s = sin(uAngle);
    vec2 r = mat2(c, -s, s, c) * q;
    float rr = length(r);
    if (rr > 0.50) return vec4(0.0);
    vec3 col;
    if (rr < 0.19) {
      // The portrait's face, cropped like the old vinyl label.
      vec2 uv = vec2(0.5, 0.27) + vec2(r.x, -r.y) / 0.19 * 0.22;
      vec3 lab = uHasLabel > 0.5 ? texture(uLabel, uv).rgb : vec3(0.85);
      col = lab;                                                   // the portrait's original cobalt
      if (rr < 0.025) col = vec3(0.92);
    } else {
      float ang = atan(r.y, r.x);
      col = vec3(0.045 + 0.02 * sin(rr * 260.0));
      col += 0.22 * pow(abs(cos(ang * 2.0 + 0.9)), 10.0) * smoothstep(0.20, 0.25, rr);   // groove sheen
    }
    float a = 1.0 - smoothstep(0.495, 0.505, rr);
    return vec4(col * a, a);
  }
  void main() {
    vec2 p = vP;
    float r = length(p);
    float edge = fwidth(r) * 1.5;
    float cover = 1.0 - smoothstep(1.0 - edge, 1.0, r);
    if (cover <= 0.0) { o = vec4(0.0); return; }
    float rc = min(r, 0.999);
    vec3 n = vec3(p, sqrt(1.0 - rc * rc));
    // Ball lens: magnified in the middle, pulled outward toward the rim.
    float bend = 0.78 + 0.9 * pow(rc, 2.2);
    vec4 cR = record(p * bend * 1.012), cG = record(p * bend), cB = record(p * bend * 0.988);
    vec4 inside = vec4(cR.r, cG.g, cB.b, cG.a);
    // Clear glass on a white page: reflections only where it grazes, and
    // only their bright and dark lines — the body itself stays see-through.
    float F = 0.03 + 0.97 * pow(1.0 - n.z, 5.0);
    vec3 refl = display(chrome(n, 0.06) * 0.9);
    float body = 0.015 + 0.08 * pow(rc, 4.0);
    float rA = F * 0.55;
    vec3 col = inside.rgb * (1.0 - rA) + refl * rA;
    float alpha = inside.a * (1.0 - rA) + rA + body * (1.0 - inside.a);
    col += vec3(1.0) * body * (1.0 - inside.a);
    // The sharp window highlight and a thin bright inner edge.
    vec3 Rf = reflect(vec3(0.0, 0.0, -1.0), n);
    col += vec3(pow(max(dot(Rf, normalize(vec3(-0.45, 0.62, 0.64))), 0.0), 220.0) * 1.4);
    col += vec3(smoothstep(0.93, 0.995, rc) * 0.25);
    alpha = clamp(alpha + smoothstep(0.93, 0.995, rc) * 0.25, 0.0, 1.0);
    o = vec4(col * cover, alpha * cover);
  }`;

  function GlassOrb({ playing }) {
    const cv = React.useRef(null);
    const playRef = React.useRef(playing);
    playRef.current = playing;
    React.useEffect(() => {
      const canvas = cv.current;
      if (!canvas) return undefined;
      const q = glQuad(canvas, ORB_FRAG);
      if (!q) { canvas.parentElement.classList.add("is-static"); return undefined; }
      const { gl, U } = q;
      const tex = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([200, 200, 200, 255]));
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
      let hasLabel = 0;
      const img = new Image();
      img.onload = () => { gl.bindTexture(gl.TEXTURE_2D, tex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); hasLabel = 1; };
      img.src = "assets/work/portrait.png";
      let raf = 0, prev = performance.now(), angle = 0, speed = 0;
      const frame = (now) => {
        raf = requestAnimationFrame(frame);
        const dt = Math.min(0.05, (now - prev) / 1000); prev = now;
        // The record spins up and down like a real platter.
        speed += ((playRef.current ? 1.9 : 0) - speed) * Math.min(1, dt * 2.5);
        angle -= speed * dt;
        q.fit();
        gl.uniform1i(U("uLabel"), 0); gl.uniform1f(U("uAngle"), angle); gl.uniform1f(U("uHasLabel"), hasLabel);
        q.draw();
      };
      raf = requestAnimationFrame(frame);
      const onVis = () => { if (document.hidden) { cancelAnimationFrame(raf); raf = 0; } else if (!raf) { prev = performance.now(); raf = requestAnimationFrame(frame); } };
      document.addEventListener("visibilitychange", onVis);
      return () => { cancelAnimationFrame(raf); document.removeEventListener("visibilitychange", onVis); q.dispose(); };
    }, []);
    return <canvas ref={cv} className="gx-orb-art" aria-hidden="true" />;
  }

  /* Contact + footer share one abyss field so no two identical atmospheres sit adjacent. */
  function ContactFooter() {
    const c = F.contact;
    return (
      <GrainField atmosphere="abyss" shaft>
        <Sec id="contact" style={{ paddingBottom: "var(--space-11)" }} label="Contact">
          <div className="gx-contact">
            <div>
              <Label tone="accent" wide>{(c.lines.join(" ") + " " + c.word).replace(/\s+/g, " ").trim()}</Label>
              <CopyEmail />
              <div className="gx-ctarow" style={{ display: "flex", gap: "var(--space-5)", flexWrap: "wrap" }}>
                <Button variant="primary" size="lg" onClick={() => go(F.links.call)}>{c.cta}</Button>
                <Button variant="ghost" size="lg" iconRight={<Icon name="arrow-up-right" size={13} />} onClick={() => go(F.links.instagram)}>Instagram</Button>
              </div>
              <MetaRow tone="muted" items={c.meta} style={{ marginTop: "var(--space-9)" }} />
            </div>
            <LiquidCTA />
          </div>
        </Sec>

        <footer>
          <Sec style={{ paddingTop: 0, paddingBottom: "var(--space-9)" }} label="Footer">
            <Divider style={{ marginBottom: "var(--space-9)" }} />
            <div className="gx-footgrid">
              <div className="gx-foot-brand">
                <div style={{ font: "var(--type-subheading)", letterSpacing: "var(--tracking-tight)", color: "var(--text-primary)" }}>
                  {F.identity.brand}
                </div>
                <p style={{ font: "var(--type-body-sm)", color: "var(--text-body)", margin: "var(--space-4) 0 0", maxWidth: "30ch", textWrap: "pretty" }}>
                  {F.identity.description.split(".")[0]}.
                </p>
              </div>
              {[["Site", F.footer.site], ["Sites", F.footer.elsewhere]].map(([h, links]) => (
                <div key={h} className="gx-foot-links">
                  <Label className="gx-foot-head">{h}</Label>
                  <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)", marginTop: "var(--space-6)" }}>
                    {links.map(([t, u]) => (
                      <a key={t} className="gx-ftlink" href={u} target={u.startsWith("http") ? "_blank" : undefined} rel="noopener">{t}</a>
                    ))}
                  </div>
                </div>
              ))}
            </div>
            <Divider style={{ margin: "var(--space-9) 0 var(--space-6)" }} />
            <MetaRow tone="muted" items={[F.identity.copyright, F.identity.person, F.identity.timezone]} />
          </Sec>
        </footer>
      </GrainField>
    );
  }


  /* Embedded build in a scaled device frame. The iframe renders at true device
     pixels and is scaled to fit, so the build never sees a fake viewport width.
     `isBooted` lets each build say what a healthy render looks like; if it never
     boots, the frame falls back to a still capture instead of showing wreckage. */
  function DeviceDemo({ src, fallback, title, alt, devices, isBooted, note }) {
    const DEVICES = devices || [
      { name: "Phone", w: 430, h: 932 },
      { name: "Large phone", w: 500, h: 1000 },
      { name: "Tablet", w: 834, h: 1050 },
    ];
    const [device, setDevice] = React.useState(DEVICES[0].name);
    const [nonce, setNonce] = React.useState(0);
    const [state, setState] = React.useState("loading");
    const stage = React.useRef(null);
    const [scale, setScale] = React.useState(1);
    const d = DEVICES.find((x) => x.name === device) || DEVICES[0];

    React.useEffect(() => {
      const fit = () => {
        if (!stage.current) return;
        setScale(Math.min(1, (stage.current.clientWidth - 24) / d.w));
      };
      fit();
      const ro = new ResizeObserver(fit);
      if (stage.current) ro.observe(stage.current);
      window.addEventListener("resize", fit);
      return () => { ro.disconnect(); window.removeEventListener("resize", fit); };
    }, [d.w]);

    React.useEffect(() => { setState("loading"); }, [nonce, device]);

    // These bundles boot asynchronously, so probe after load rather than on it.
    const probe = (el) => {
      setTimeout(() => {
        try {
          const doc = el.contentDocument;
          setState(isBooted(doc, el.contentWindow) ? "live" : "still");
        } catch (e) {
          setState("live"); // cross-origin: nothing to inspect, assume it runs
        }
      }, 2500);
    };

    return (
      <React.Fragment>
        <div className="gx-eyebrow">
          <Label wide>{state === "still" ? "The build" : "Live build"}</Label>
          <TabSwitch items={DEVICES.map((x) => x.name)} active={device} onSelect={setDevice} />
        </div>

        <div className="gx-stage" ref={stage}>
          <div className="gx-device" style={{ width: d.w * scale, height: d.h * scale }}>
            {state !== "still" && (
              <iframe key={device + "-" + nonce} className="gx-device-screen" src={src} title={title}
                loading="lazy" onLoad={(e) => probe(e.currentTarget)}
                /* allow-scripts + allow-same-origin so the build runs and the probe can
                   read it; top-navigation stays denied so an embed can never move the
                   page out from under the visitor. */
                sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
                style={{ width: d.w, height: d.h, transform: `scale(${scale})` }} />
            )}
            {state === "still" && <img className="gx-device-still" src={fallback} alt={alt} />}
            {state === "loading" && <div className="gx-device-loading" aria-hidden="true">Loading the build</div>}
          </div>
        </div>

        <div className="gx-ring-ctrls" style={{ marginTop: "var(--space-8)", justifyContent: "space-between" }}>
          <MetaRow tone="faint" items={[`${d.w} × ${d.h}`, `${Math.round(scale * 100)}% SCALE`, state === "still" ? "SCREEN CAPTURE" : (note || "SELF-CONTAINED HTML")]} />
          <div style={{ display: "flex", gap: "var(--space-4)", flexWrap: "wrap" }}>
            <IconButton name="refresh-cw" size={44} label="Reload the build" onClick={() => setNonce((n) => n + 1)} />
            <Button variant="ghost" size="sm" href={src} target="_blank" rel="noopener"
              iconRight={<Icon name="arrow-up-right" size={12} />}>Open full screen</Button>
          </div>
        </div>
      </React.Fragment>
    );
  }

  /* The Refillable CAN, live. Mounts bottle-scene.js — lazily, once the
     host is within about a screen of the viewport — and keeps the poster
     visible until the first real frame is drawn, so there is never an empty
     box. If WebGL is missing or the model fails, the poster simply stays.
     `target` is the element that receives pointer input; when the stage is
     wrapped in a link, that link is the target, so hover and drag work
     across the whole stage and not just the canvas. */
  function Bottle3D({ S, preset = "spot", view, className, autoRotate, interactive, onHandle }) {
    const host = React.useRef(null);
    const [state, setState] = React.useState("poster"); // poster | live | failed

    React.useEffect(() => {
      const el = host.current;
      if (!el) return undefined;
      let handle = null, cancelled = false;
      const start = () => {
        const run = () => {
          window.BottleScene.mount(el, {
            src: S.model, preset, view, autoRotate, interactive,
            target: el.closest("a") || el,
            onReady: () => { if (!cancelled) setState("live"); },
            onFail: () => { if (!cancelled) setState("failed"); },
          }).then((h) => {
            if (!h) return;
            if (cancelled) { h.destroy(); return; }
            handle = h;
            if (onHandle) onHandle(h);
          });
        };
        if (window.BottleScene) run();
        else window.addEventListener("bottlescene:ready", run, { once: true });
      };
      const io = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) { io.disconnect(); start(); }
      }, { rootMargin: "600px 0px" });
      io.observe(el);
      return () => { cancelled = true; io.disconnect(); if (handle) handle.destroy(); };
    }, [S.model, preset, view]);

    return (
      <div ref={host} className={"gx-3d is-" + state + (className ? " " + className : "")}>
        {preset === "spot" && S.poster && <img className="gx-3d-poster" src={S.poster.src} alt="" />}
      </div>
    );
  }

  function SkipLink() {
    return <a className="gx-skip" href="#main">Skip to content</a>;
  }

  /* One full-bleed photographic frame. Renders OUTSIDE <Sec> on purpose — see
     .gx-bleed in glacier.css for why that is what makes it run edge to edge.
     `tone` is the photograph's own tone (drives the floating ink); `field` is
     the tone of the section it sits in (drives the stacked ink on mobile). */
  function Bleed({ shot, gallery, tone = "onDark", field = "fieldDark", center, children }) {
    const cls = ["gx-bleed", "gx-bleed-" + tone, "gx-bleed-" + field];
    if (gallery) cls.push("gx-bleed-gallery");
    if (center) cls.push("gx-bleed-center");
    cls.push("gx-reveal");
    return (
      <div className={cls.join(" ")}>
        <img src={shot.src} alt={shot.alt} loading="lazy" />
        {children && <div className="gx-bleed-text">{children}</div>}
      </div>
    );
  }

  /* The project index — every project in content.js as a row (number,
     thumbnail, title, scope, discipline, year), filterable by year. One
     component for the Work page and the home page, so the two never drift.
     Order comes from content.js: Stiled, Australian Avocado, the CAN and
     Re-Soil lead, then the rest newest-first (2026-10-02). */
  const WORK = F.work;
  const YEARS = ["All", ...[...new Set(WORK.map((w) => w.year))].sort().reverse()];

  function WorkRow({ w, i }) {
    return (
      <a className="gx-row gx-reveal" href={w.href}
        target={w.internal ? undefined : "_blank"} rel={w.internal ? undefined : "noopener"}>
        <Label className="gx-row-idx">{String(i + 1).padStart(2, "0")}</Label>
        <div className={"gx-rowthumb" + (w.previewFit === "contain" ? " gx-rowthumb--contain" : "")}>
          <ImagePlate src={w.src} ratio="16 / 10" radius="md" grade="none" placeholder={w.title} />
        </div>
        <div className="gx-row-body">
          <h3 style={{ font: "var(--type-heading)", letterSpacing: "var(--tracking-tight)", color: "var(--text-primary)", margin: "0 0 var(--space-3)", textWrap: "balance" }}>{w.title}</h3>
          <p style={{ font: "var(--type-body-sm)", color: "var(--text-body)", margin: 0, maxWidth: "var(--measure-prose)", textWrap: "pretty" }}>{w.scope}</p>
        </div>
        <MetaRow className="gx-row-meta" tone="faint" items={[w.discipline]} />
        <span className="gx-arrow gx-row-year">{w.year} ↗</span>
      </a>
    );
  }

  function WorkIndex({ eyebrow = "Index" }) {
    const [year, setYear] = React.useState("All");
    const shown = year === "All" ? WORK : WORK.filter((w) => w.year === year);
    return (
      <React.Fragment>
        <div className="gx-eyebrow gx-reveal">
          <Label wide>{eyebrow}</Label>
          <TabSwitch items={YEARS} active={year} onSelect={setYear} />
        </div>
        <div>{shown.map((w, i) => <WorkRow key={w.slug} w={w} i={i} />)}</div>
      </React.Fragment>
    );
  }

  window.FX = { go, Sec, SectionHead, Nav, useMediaQuery, ContactFooter, SkipLink, DeviceDemo, Bleed, Bottle3D, PAGES, F, WorkIndex, WorkRow };
})();
