// Direction A — UTILITY-ROOM v2
// The current site's vibe, refined. Same warm-dark amber palette, but the
// boot theatrics are replaced with a quieter "session resumed" opener that
// gets out of the way faster. Wireframes are promoted from background
// distractions to deliberate ambient ornament. Adds availability + contact
// sections so the site actually pitches commission work.

(function () {
  const W = window.IM_WIRE;
  const C = window.IM_CONTENT;
  const { useState, useEffect, useRef } = React;

  // Short, dry boot lines. The original site's sequence was 21 jokes —
  // user noted it "gets in the way of content" — so this one keeps four
  // lines of plausible session-restore chatter and gets out of the way.
  const BOOT_LINES = [
    { txt: '[ok] session restored' },
    { txt: '[ok] tmux: 4 panes · 1 dirty buffer (sketchbook.md)' },
    { txt: '[ok] last seen 14m ago — chair by the bench' },
    { txt: '[ok] welcome back' },
  ];

  // ── three.js background scene ─────────────────────────────────
  // Recreates the original site's wireframe background: five rotating
  // platonic solids + a torus knot, with subtle mouse parallax. The
  // amber/green palette + low opacities keep it deep in the back.
  function BgScene() {
    const canvasRef = useRef(null);
    useEffect(() => {
      if (!canvasRef.current || !window.THREE) return;
      const THREE = window.THREE;
      const canvas = canvasRef.current;
      const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
      renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
      renderer.setSize(window.innerWidth, window.innerHeight);
      renderer.setClearColor(0x000000, 0);

      const scene = new THREE.Scene();
      const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 100);
      camera.position.z = 8;

      const mAmber = new THREE.LineBasicMaterial({ color: 0xe8a030, opacity: 0.14, transparent: true });
      const mGreen = new THREE.LineBasicMaterial({ color: 0x39d353, opacity: 0.07, transparent: true });
      const mDim   = new THREE.LineBasicMaterial({ color: 0xe8a030, opacity: 0.05, transparent: true });

      const wf = (geo, mat, x, y, z) => {
        const mesh = new THREE.LineSegments(new THREE.WireframeGeometry(geo), mat);
        mesh.position.set(x, y, z);
        scene.add(mesh);
        return mesh;
      };

      const ico   = wf(new THREE.IcosahedronGeometry(2.8, 1),          mAmber, -1.5,  0.5, -3);
      const tknot = wf(new THREE.TorusKnotGeometry(1.1, 0.3, 120, 16), mGreen,  4.5, -1.2, -4);
      const oct   = wf(new THREE.OctahedronGeometry(1.5, 2),           mDim,   -5.5, -2.5, -2);
      const dodec = wf(new THREE.DodecahedronGeometry(1.0, 0),         mAmber,  5.0,  2.8, -1.5);
      const tet   = wf(new THREE.TetrahedronGeometry(0.8, 2),          mGreen, -3.5,  3.0, -1);

      // Each body gets its own slow rotation rate + a slight positional
      // drift driven by sine waves so nothing reads as orbiting a single
      // axis. Rates picked low — like watching the storms of Jupiter
      // from a porthole, not a screensaver. Times are in radians/frame
      // at 60fps; ~0.0004 ≈ a full revolution every ~4 minutes.
      const bodies = [
        { m: ico,   rx: 0.00028, ry: 0.00041, rz: 0,        bob: [0.6, 0.0009, 0],     home: [-1.5, 0.5, -3] },
        { m: tknot, rx: 0.00044, ry: 0.00033, rz: 0.00018, bob: [0.4, 0.0011, 1.7],   home: [ 4.5,-1.2,-4] },
        { m: oct,   rx:-0.00031, ry: 0,        rz: 0.00038, bob: [0.5, 0.0008, 3.1],   home: [-5.5,-2.5,-2] },
        { m: dodec, rx: 0.00018, ry: 0.00046, rz: 0,        bob: [0.7, 0.00065,4.6],   home: [ 5.0, 2.8,-1.5] },
        { m: tet,   rx: 0.00036, ry: 0,        rz:-0.00041, bob: [0.55,0.00095,2.3],   home: [-3.5, 3.0,-1] },
      ];

      let mx = 0, my = 0;
      const onMove = (e) => {
        // Halve the parallax too — the original was punchy, this should
        // breathe.
        mx =  (e.clientX / window.innerWidth  - 0.5) * 0.35;
        my = -(e.clientY / window.innerHeight - 0.5) * 0.35;
      };
      const onResize = () => {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
      };
      document.addEventListener('mousemove', onMove);
      window.addEventListener('resize', onResize);

      const start = performance.now();
      let raf, alive = true;
      const tick = () => {
        if (!alive) return;
        raf = requestAnimationFrame(tick);
        const t = (performance.now() - start) / 1000;
        for (const b of bodies) {
          b.m.rotation.x += b.rx;
          b.m.rotation.y += b.ry;
          b.m.rotation.z += b.rz;
          // sine-wave drift around each body's home position — amplitude
          // ~0.4–0.7 world units, periods of 90–150s. Reads as floating.
          const [amp, freq, phase] = b.bob;
          const [hx, hy, hz] = b.home;
          b.m.position.x = hx + Math.sin(t * freq * Math.PI * 2 + phase) * amp;
          b.m.position.y = hy + Math.cos(t * freq * 0.8 * Math.PI * 2 + phase * 1.3) * amp * 0.6;
          b.m.position.z = hz + Math.sin(t * freq * 0.5 * Math.PI * 2 + phase * 0.7) * amp * 0.3;
        }
        camera.position.x += (mx - camera.position.x) * 0.02;
        camera.position.y += (my - camera.position.y) * 0.02;
        camera.lookAt(scene.position);
        renderer.render(scene, camera);
      };
      tick();

      return () => {
        alive = false;
        cancelAnimationFrame(raf);
        document.removeEventListener('mousemove', onMove);
        window.removeEventListener('resize', onResize);
        renderer.dispose();
      };
    }, []);
    return <canvas ref={canvasRef} className="ur-bg-canvas" aria-hidden="true" />;
  }

  // Font URL — any .ttf/.otf/.woff (NOT .woff2; opentype.js can't decode
  // that natively). VT323 is the same CRT-terminal face used in the hero
  // name, so the monogram reads as drawn by the same hand. Swap freely
  // to any TTF — typewriter (Special Elite, Cutive Mono), antique book
  // (Old Standard TT), or your own font hosted anywhere CORS-friendly.
  // raw.githubusercontent.com is used instead of jsdelivr's @gh proxy —
  // the google/fonts repo as a whole exceeds jsdelivr's 50MB package
  // size limit, so most files in it 403. raw.githubusercontent.com
  // serves the files directly with permissive CORS.
  const FONT_URL = 'https://raw.githubusercontent.com/google/fonts/main/ofl/russoone/RussoOne-Regular.ttf';
  let FONT_CACHE = null; // promise, reused across mounts

  // Convert opentype.js path commands to THREE.Shape objects via ShapePath.
  // opentype uses y-down (screen-style); THREE.Shape uses y-up — we negate
  // y as we go. The y flip also reverses winding direction, so we pass
  // isCCW=true to toShapes() so outer outlines (now CCW post-flip) are
  // treated as outer rather than as holes.
  function shapesFromOpentype(font, text, fontSize) {
    const otPath = font.getPath(text, 0, 0, fontSize);
    const sp = new THREE.ShapePath();
    for (const c of otPath.commands) {
      if      (c.type === 'M') sp.moveTo(c.x, -c.y);
      else if (c.type === 'L') sp.lineTo(c.x, -c.y);
      else if (c.type === 'Q') sp.quadraticCurveTo(c.x1, -c.y1, c.x, -c.y);
      else if (c.type === 'C') sp.bezierCurveTo(c.x1, -c.y1, c.x2, -c.y2, c.x, -c.y);
      // 'Z' (close) is implicit — the next 'M' starts a new sub-path.
    }
    return sp.toShapes(true);
  }

  // Small in-card Three.js scene for the hero wireframe slot.
  //
  // Builds "IM" by extruding glyph shapes from a real font (Helvetiker
  // Bold ≈ Arial Bold) — replaces the previous hand-traced polygons,
  // which got the M's diagonals tangled. Rendered as wireframe edges +
  // vertex dots to match the rest of the site's aesthetic.
  //
  // Interaction: idle sine wobble by default; pointer drag rotates the
  // monogram (Y from horizontal drag, X from vertical, clamped so it
  // can't flip upside-down). Idle smoothly fades out while dragging and
  // back in on release, so the user's pose isn't snapped away. Double-
  // click resets to the default orientation.
  function HeroWire() {
    const ref = useRef(null);
    useEffect(() => {
      if (!ref.current || !window.THREE) return;
      const THREE = window.THREE;
      const host = ref.current;
      let w = host.clientWidth, h = host.clientHeight;
      const canvas = document.createElement('canvas');
      host.innerHTML = '';
      host.appendChild(canvas);
      const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
      renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
      renderer.setSize(w, h);
      renderer.setClearColor(0x000000, 0);

      const scene = new THREE.Scene();
      const camera = new THREE.PerspectiveCamera(40, w / h, 0.1, 100);
      camera.position.z = 8;

      // Each edge is rendered as a thin cylinder rather than a line, so
      // we can control thickness (LineBasicMaterial.linewidth is capped
      // to 1px in WebGL on most browsers). MeshBasicMaterial keeps the
      // flat amber look — no shading on the tubes.
      const wireMat = new THREE.MeshBasicMaterial({ color: 0xe8a030, transparent: true, opacity: 0.75 });
      const pivot = new THREE.Group();
      scene.add(pivot);

      // Drag state — accumulated user rotation, blended with idle wobble.
      let dragging = false, lastX = 0, lastY = 0;
      let userRotY = 0, userRotX = 0;
      let idleAmt = 1; // 0..1, fades to 0 while dragging
      const X_CLAMP = 0.7; // ~40°, keeps the monogram from flipping

      // Fetch font (cached across mounts) then build geometry. Component
      // may unmount before fetch resolves; `alive` guards the add to scene.
      // opentype.parse needs the raw bytes — hence arrayBuffer, not json.
      let alive = true;
      if (!FONT_CACHE) {
        FONT_CACHE = fetch(FONT_URL)
          .then(r => r.arrayBuffer())
          .then(buf => window.opentype.parse(buf));
      }
      FONT_CACHE.then((font) => {
        if (!alive) return;
        // fontSize=100 keeps the path values in a comfortable numeric range;
        // we re-fit to the camera frustum after computing the bbox anyway.
        // fontSize=100 puts path coords in the ~0–100 range; the depth is
        // expressed in those same units (gets normalized by the fit-scale
        // below alongside the rest of the geometry).
        const shapes = shapesFromOpentype(font, 'IM', 100);
        const geo = new THREE.ExtrudeGeometry(shapes, {
          depth: 30, bevelEnabled: false, curveSegments: 12,
        });
        geo.computeBoundingBox();
        const bb = geo.boundingBox;
        // Center the monogram on the pivot's origin so rotation feels right.
        geo.translate(
          -(bb.max.x + bb.min.x) / 2,
          -(bb.max.y + bb.min.y) / 2,
          -(bb.max.z + bb.min.z) / 2,
        );
        // Fit width to the camera frustum at z=8. ~3.5 world units wide
        // reads well at the in-card scale.
        const targetW = 3.5;
        const fitScale = targetW / (bb.max.x - bb.min.x);
        geo.scale(fitScale, fitScale, fitScale);

        // Silhouette/hard edges only — no face triangulation.
        const edges = new THREE.EdgesGeometry(geo, 1);
        const posArr = edges.attributes.position.array;
        const edgeCount = posArr.length / 6;

        // Build a single unit cylinder, then instance it once per edge
        // with a per-edge matrix that positions + orients + stretches it
        // between the two endpoints. Radius is the line thickness — tune
        // here to taste; ~2% of letter width reads as a confident stroke.
        const radius = 0.028;
        const base = new THREE.CylinderGeometry(radius, radius, 1, 8, 1, false);
        base.translate(0, 0.5, 0); // origin at one end so scale.y = length
        const inst = new THREE.InstancedMesh(base, wireMat, edgeCount);
        const dummy = new THREE.Object3D();
        const up = new THREE.Vector3(0, 1, 0);
        const dir = new THREE.Vector3();
        const quat = new THREE.Quaternion();
        for (let i = 0; i < edgeCount; i++) {
          const o = i * 6;
          const ax = posArr[o],   ay = posArr[o+1], az = posArr[o+2];
          const bx = posArr[o+3], by = posArr[o+4], bz = posArr[o+5];
          dir.set(bx - ax, by - ay, bz - az);
          const len = dir.length();
          dir.normalize();
          quat.setFromUnitVectors(up, dir);
          dummy.position.set(ax, ay, az);
          dummy.quaternion.copy(quat);
          dummy.scale.set(1, len, 1);
          dummy.updateMatrix();
          inst.setMatrixAt(i, dummy.matrix);
        }
        inst.instanceMatrix.needsUpdate = true;
        pivot.add(inst);
      }).catch(() => { /* fetch failure — leave the stage empty */ });

      const start = performance.now();
      let raf = 0;
      const tick = () => {
        if (!alive) return;
        raf = requestAnimationFrame(tick);
        const t = (performance.now() - start) / 1000;
        // Ease idleAmt toward 0 when dragging, 1 otherwise — avoids
        // snapping the pose when a drag starts or ends.
        idleAmt += ((dragging ? 0 : 1) - idleAmt) * 0.08;
        const idleY = Math.sin(t * 0.28) * 0.55 * idleAmt;
        const idleX = Math.sin(t * 0.22 + 0.7) * 0.18 * idleAmt;
        const idleZ = Math.sin(t * 0.17 + 1.4) * 0.04 * idleAmt;
        pivot.rotation.y = userRotY + idleY;
        pivot.rotation.x = THREE.MathUtils.clamp(userRotX + idleX, -X_CLAMP, X_CLAMP);
        pivot.rotation.z = idleZ;
        renderer.render(scene, camera);
      };
      tick();

      // Pointer drag — single handler set covers mouse + touch + pen.
      // Capture on pointerdown so the drag survives the cursor leaving
      // the canvas; touch-action: none (set in CSS) suppresses scroll.
      const onDown = (e) => {
        dragging = true;
        lastX = e.clientX; lastY = e.clientY;
        host.classList.add('grabbing');
        try { host.setPointerCapture(e.pointerId); } catch {}
      };
      const onMove = (e) => {
        if (!dragging) return;
        const dx = e.clientX - lastX, dy = e.clientY - lastY;
        lastX = e.clientX; lastY = e.clientY;
        userRotY += dx * 0.01;
        userRotX = THREE.MathUtils.clamp(userRotX + dy * 0.01, -X_CLAMP, X_CLAMP);
      };
      const onUp = (e) => {
        dragging = false;
        host.classList.remove('grabbing');
        try { host.releasePointerCapture(e.pointerId); } catch {}
      };
      const onDouble = () => { userRotY = 0; userRotX = 0; };
      host.addEventListener('pointerdown', onDown);
      host.addEventListener('pointermove', onMove);
      host.addEventListener('pointerup', onUp);
      host.addEventListener('pointercancel', onUp);
      host.addEventListener('dblclick', onDouble);

      const onResize = () => {
        w = host.clientWidth; h = host.clientHeight;
        if (!w || !h) return;
        camera.aspect = w / h; camera.updateProjectionMatrix();
        renderer.setSize(w, h);
      };
      window.addEventListener('resize', onResize);

      return () => {
        alive = false;
        cancelAnimationFrame(raf);
        window.removeEventListener('resize', onResize);
        host.removeEventListener('pointerdown', onDown);
        host.removeEventListener('pointermove', onMove);
        host.removeEventListener('pointerup', onUp);
        host.removeEventListener('pointercancel', onUp);
        host.removeEventListener('dblclick', onDouble);
        renderer.dispose();
      };
    }, []);
    return <div className="ur-wire-stage" ref={ref} />;
  }

  // ── prompt fragment ────────────────────────────────────────────
  function Prompt({ cmd, blink = false }) {
    return (
      <span className="ur-prompt">
        <span className="ur-u">{C.identity.user}</span>
        <span className="ur-at">@</span>
        <span className="ur-h">{C.identity.host}</span>
        <span className="ur-ps">:~$ </span>
        {cmd && <span className="ur-cmd">{cmd}</span>}
        {blink && <span className="ur-cur" />}
      </span>
    );
  }

  // ── ambient gutter — slow log ticker ───────────────────────────
  function AmbientLog() {
    const [lines, setLines] = useState(() => C.ambient.slice(0, 5));
    useEffect(() => {
      let i = 5;
      const id = setInterval(() => {
        setLines((prev) => {
          const next = prev.slice(1);
          next.push(C.ambient[i % C.ambient.length]);
          i++;
          return next;
        });
      }, 4200);
      return () => clearInterval(id);
    }, []);
    return (
      <div className="ur-ambient">
        <div className="ur-ambient-label">/var/log/workshop</div>
        {lines.map((l, idx) => (
          <div key={idx + ':' + l} className="ur-ambient-line" style={{ opacity: 0.35 + idx * 0.13 }}>{l}</div>
        ))}
      </div>
    );
  }

  // ── quick nav (click target, key hint is secondary) ──────────
  // Sibling pills, each one a clear tap target. The key glyph is small,
  // tucked in the corner — a visual hint, not the primary affordance.
  // Real keyboard handlers live on the root component as an easter egg.
  function QuickNav({ onHelp }) {
    const items = [
      { key: 'a', label: 'about',    href: '#about',    sub: 'who I am' },
      { key: 'e', label: 'exhibits', href: '#exhibits', sub: 'selected work' },
      { key: 'w', label: 'work',     href: '#work',     sub: 'commission me' },
      { key: 'c', label: 'contact',  href: '#contact',  sub: 'say hello' },
    ];
    return (
      <nav className="ur-quicknav" aria-label="Quick navigation">
        {items.map((it) => (
          <a key={it.key} className="ur-jump" href={it.href}
             onClick={(e) => { e.preventDefault(); document.querySelector(it.href)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }}>
            <span className="ur-jump-arrow">›</span>
            <span className="ur-jump-body">
              <span className="ur-jump-label">{it.label}</span>
              <span className="ur-jump-sub">{it.sub}</span>
            </span>
            <span className="ur-jump-key" aria-hidden="true">{it.key}</span>
          </a>
        ))}
        <button type="button" className="ur-jump ur-jump-help" onClick={onHelp}>
          <span className="ur-jump-arrow">›</span>
          <span className="ur-jump-body">
            <span className="ur-jump-label">help</span>
            <span className="ur-jump-sub">all shortcuts</span>
          </span>
          <span className="ur-jump-key" aria-hidden="true">?</span>
        </button>
      </nav>
    );
  }

  // ── cheatsheet overlay — the easter egg, revealed by ? or help ─
  function Cheatsheet({ onClose }) {
    useEffect(() => {
      const onKey = (e) => { if (e.key === 'Escape') onClose(); };
      window.addEventListener('keydown', onKey);
      return () => window.removeEventListener('keydown', onKey);
    }, [onClose]);

    const rows = [
      ['a', 'jump to about'],
      ['e', 'jump to exhibits'],
      ['w', 'jump to commission work'],
      ['c', 'jump to contact'],
      ['g g', 'top of page'],
      ['G',   'bottom of page'],
      ['?',   'this help'],
      ['esc', 'dismiss this overlay'],
    ];
    return (
      <div className="ur-cheat-scrim" onClick={onClose}>
        <div className="ur-cheat" onClick={(e) => e.stopPropagation()} role="dialog" aria-label="Keyboard shortcuts">
          <div className="ur-cheat-bar">
            <div className="ur-cheat-dot" />
            <div className="ur-cheat-dot" />
            <div className="ur-cheat-dot" />
            <span className="ur-cheat-title">man utility-room — KEYBOARD(7)</span>
            <button className="ur-cheat-close" onClick={onClose} aria-label="Close">×</button>
          </div>
          <div className="ur-cheat-body">
            <Prompt cmd="man utility-room" />
            <div className="ur-cheat-section-head">NAME</div>
            <div className="ur-cheat-line">utility-room — navigate this portfolio</div>
            <div className="ur-cheat-section-head">SYNOPSIS</div>
            <div className="ur-cheat-line">press a key, or click any link. all shortcuts are optional.</div>
            <div className="ur-cheat-section-head">KEYS</div>
            <table className="ur-cheat-table"><tbody>
              {rows.map(([k, v]) => (
                <tr key={k}>
                  <td><kbd className="ur-kbd">{k}</kbd></td>
                  <td>{v}</td>
                </tr>
              ))}
            </tbody></table>
            <div className="ur-cheat-foot">// inspired by every well-loved terminal I’ve ever owned.</div>
          </div>
        </div>
      </div>
    );
  }

  // ── hero ───────────────────────────────────────────────────────
  // The boot animation got the user-feedback "in the way" + "a little
  // cheesy" treatment — so this one is tight: a typed `resume` + four
  // short status lines, ~1.4s total, and it skips on subsequent visits
  // (sessionStorage flag). Hero content fades in alongside, not after.
  function Hero({ onHelp }) {
    // Boot animation state. `cmd` is the typed-in command; `lines` are
    // the status rows; `done` flips the hero content into final position.
    // `?boot` query param forces a replay even when the session flag is
    // set — handy when sharing a link with "watch the open."
    const skip = (() => {
      try {
        if (location.search.includes('boot')) return false;
        return sessionStorage.getItem('ur-booted') === '1';
      } catch { return false; }
    })();
    const [cmd, setCmd]   = useState(skip ? 'resume' : '');
    const [lines, setLines] = useState(skip ? BOOT_LINES.map(l => l.txt) : []);
    const [done, setDone] = useState(skip);

    useEffect(() => {
      if (skip) return;
      const timers = [];
      // Type "resume" letter by letter.
      const target = 'resume';
      for (let i = 1; i <= target.length; i++) {
        timers.push(setTimeout(() => setCmd(target.slice(0, i)), 240 + i * 60));
      }
      // Then drop the status lines.
      BOOT_LINES.forEach((line, i) => {
        timers.push(setTimeout(() => {
          setLines((prev) => [...prev, line.txt]);
        }, 720 + i * 180));
      });
      // Finally, reveal the rest of the hero.
      timers.push(setTimeout(() => {
        setDone(true);
        try { sessionStorage.setItem('ur-booted', '1'); } catch {}
      }, 720 + BOOT_LINES.length * 180 + 240));
      return () => timers.forEach(clearTimeout);
    }, [skip]);

    return (
      <section className="ur-hero">
        <div className="ur-hero-inner">
          <div className="ur-hero-left">
            <div className="ur-resume-line">
              <Prompt cmd={cmd} blink={!done} />
              {done && <span className="ur-resume-tag">last seen 14m ago</span>}
            </div>

            <div className="ur-boot-lines">
              {lines.map((l, i) => {
                const m = l.match(/^(\[(ok|\.\.|warn)\])\s+(.*)$/);
                if (!m) return <div key={i} className="ur-boot-line dim">{l}</div>;
                const cls = m[2] === '..' ? 'dots' : m[2];
                return (
                  <div key={i} className={'ur-boot-line ' + cls}>
                    <span className="ur-boot-tag">{m[1]}</span>
                    <span className="ur-boot-rest">{m[3]}</span>
                  </div>
                );
              })}
            </div>

            {done && (
              <div className="ur-hero-body shown">
                <h1 className="ur-name">IAN MILLSPAUGH</h1>
                <div className="ur-role">{C.identity.role}</div>
                <p className="ur-pitch">{C.identity.pitch}</p>

                <div className="ur-quick-hint">
                  <span className="ur-quick-hint-lbl">jump to</span>
                  <span className="ur-quick-hint-sub">click any tile — or press the letter shown</span>
                </div>
                <QuickNav onHelp={onHelp} />
              </div>
            )}
          </div>

          {done && (
            <div className="ur-hero-right shown">
              <div className="ur-wire-frame">
                <HeroWire />
              </div>
              <div className="ur-wire-caption">
                ./schema/curiosity.icos
                <span className="ur-wire-sub">12 vertices · 30 edges · 20 faces</span>
              </div>
            </div>
          )}
        </div>
      </section>
    );
  }

  // ── about ──────────────────────────────────────────────────────
  function About() {
    return (
      <section id="about" className="ur-section">
        <header className="ur-section-head">
          <Prompt cmd="cat ./about.txt" />
        </header>
        <div className="ur-section-body">
          <div className="ur-about-prose">
            {C.about.paragraphs.map((p, i) => <p key={i}>{p}</p>)}
          </div>
          <div className="ur-disc">
            <div className="ur-disc-head"># disciplines</div>
            <table className="ur-disc-table"><tbody>
              {C.about.disciplines.map(([name, sub]) => (
                <tr key={name}>
                  <td className="ur-disc-name">{name}</td>
                  <td className="ur-disc-sub">{sub}</td>
                </tr>
              ))}
            </tbody></table>
          </div>
        </div>
      </section>
    );
  }

  // ── exhibits — ls -la style listing, expandable ────────────────
  function Exhibits() {
    const [open, setOpen] = useState(C.exhibits[0].id);
    return (
      <section id="exhibits" className="ur-section">
        <header className="ur-section-head">
          <Prompt cmd="ls -la ./exhibits/" />
        </header>
        <div className="ur-section-body">
          <div className="ur-ls-total">total {C.exhibits.length * 4}</div>
          <div className="ur-ls">
            {C.exhibits.map((ex) => (
              <ExhibitRow key={ex.id} ex={ex} open={open === ex.id} onToggle={() => setOpen(open === ex.id ? null : ex.id)} />
            ))}
          </div>
        </div>
      </section>
    );
  }

  function ExhibitRow({ ex, open, onToggle }) {
    const statusClass = ex.status === 'ON DISPLAY' ? 'ok' : ex.status === 'IN PROGRESS' ? 'warn' : 'dim';
    return (
      <div className={'ur-ex' + (open ? ' open' : '')}>
        <button className="ur-ex-row" onClick={onToggle} aria-expanded={open}>
          <span className="ur-ex-perm">{ex.perm}</span>
          <span className="ur-ex-owner">ian  ian</span>
          <span className="ur-ex-size">{ex.size}</span>
          <span className="ur-ex-date">{ex.mtime}</span>
          <span className={'ur-ex-name'}>
            <span className="ur-amber">{ex.id.toLowerCase()}/</span>
          </span>
          <span className={'ur-ex-status ' + statusClass}>· {ex.status}</span>
          <span className="ur-ex-caret">{open ? '▼' : '▶'}</span>
        </button>
        {open && (
          <div className="ur-ex-body">
            <div className="ur-ex-title">{ex.title}</div>
            <div className="ur-ex-tagline">{ex.tagline}</div>
            <p className="ur-ex-desc">{ex.description}</p>

            <div className="ur-ex-grid">
              <div>
                <div className="ur-ex-mini-head">stack</div>
                <ul className="ur-ex-list">{ex.stack.map(s => <li key={s}>{s}</li>)}</ul>
              </div>
              <div>
                <div className="ur-ex-mini-head">role</div>
                <div className="ur-ex-value">{ex.role}</div>
                <div className="ur-ex-mini-head" style={{ marginTop: '0.9rem' }}>year</div>
                <div className="ur-ex-value">{ex.year}</div>
              </div>
              <div className="ur-ex-shot">
                <div className="ur-shot-frame">
                  <div className="ur-shot-grid" />
                  <div className="ur-shot-cap">[photo · {ex.title}]</div>
                </div>
              </div>
            </div>

            <div className="ur-ex-tags">
              {ex.tags.map(t => <span key={t} className="ur-tag">{t}</span>)}
            </div>
          </div>
        )}
      </div>
    );
  }

  // ── availability — the commercial pitch ───────────────────────
  function Availability() {
    return (
      <section id="work" className="ur-section">
        <header className="ur-section-head">
          <Prompt cmd="systemctl status availability.service" />
        </header>
        <div className="ur-section-body">
          <div className="ur-avail-status">
            <span className="ur-avail-dot" />
            <span className="ur-amber">● {C.availability.statusLabel}</span>
            <span className="ur-avail-note">{C.availability.statusNote}</span>
          </div>

          <div className="ur-offers">
            {C.availability.offers.map((o) => (
              <div key={o.id} className="ur-offer">
                <div className="ur-offer-head">
                  <span className="ur-offer-id">{o.id}</span>
                  <span className="ur-offer-rate">{o.rate}</span>
                </div>
                <div className="ur-offer-title">{o.title}</div>
                <p className="ur-offer-body">{o.body}</p>
              </div>
            ))}
          </div>
        </div>
      </section>
    );
  }

  // ── contact ────────────────────────────────────────────────────
  function Contact() {
    return (
      <section id="contact" className="ur-section">
        <header className="ur-section-head">
          <Prompt cmd="mail -s 'lets build something' " blink />
        </header>
        <div className="ur-section-body ur-contact">
          <a className="ur-contact-email" href={'mailto:' + C.contact.email}>{C.contact.email}</a>
          <div className="ur-contact-row"><span className="ur-dim">at  </span>{C.contact.location}</div>
          <div className="ur-contact-row"><span className="ur-dim">eta </span>{C.contact.booking}</div>
          <div className="ur-contact-elsewhere">
            {C.contact.elsewhere.map(([k, v]) => (
              <div key={k} className="ur-elsewhere"><span className="ur-dim">{k.padEnd(10, '·')}</span>{v}</div>
            ))}
          </div>
        </div>
      </section>
    );
  }

  // ── footer ─────────────────────────────────────────────────────
  function Footer() {
    return (
      <footer className="ur-footer">
        <div><Prompt cmd="logout" /></div>
        <div className="ur-footer-meta">
          © 2026 · built by hand · <span className="ur-amber">utility-room</span> uptime 4h 12m
        </div>
      </footer>
    );
  }

  // ── nav ────────────────────────────────────────────────────────
  function Nav() {
    return (
      <nav className="ur-nav">
        <Prompt />
        <ul>
          <li><a href="#about">about</a></li>
          <li><a href="#exhibits">exhibits</a></li>
          <li><a href="#work">work</a></li>
          <li><a href="#contact">contact</a></li>
        </ul>
      </nav>
    );
  }

  // ── sys panel — fixed bottom-right ─────────────────────────────
  function SysPanel() {
    const [now, setNow] = useState(() => new Date());
    useEffect(() => {
      const id = setInterval(() => setNow(new Date()), 1000);
      return () => clearInterval(id);
    }, []);
    const fmt = (n) => String(n).padStart(2, '0');
    return (
      <div className="ur-sys">
        <div><span className="ur-sys-lbl">tz   </span>america/los_angeles</div>
        <div><span className="ur-sys-lbl">local</span>{fmt(now.getHours())}:{fmt(now.getMinutes())}:{fmt(now.getSeconds())}</div>
        <div><span className="ur-sys-lbl">load </span>0.31 0.42 0.38</div>
        <div><span className="ur-sys-lbl">coff </span><span className="ur-amber">warm</span></div>
      </div>
    );
  }

  // ── full direction ─────────────────────────────────────────────
  function UtilityRoom() {
    const [help, setHelp] = useState(false);

    // Keyboard easter egg. Listens at window level; ignores input/textarea
    // and any modifier-bearing key combos so the browser's own shortcuts
    // keep working. `g g` (two g's within 700ms) goes top; `G` goes bottom.
    useEffect(() => {
      let lastG = 0;
      const jump = (sel) => {
        const el = document.querySelector(sel);
        if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
      };
      const onKey = (e) => {
        if (e.metaKey || e.ctrlKey || e.altKey) return;
        const tag = (e.target?.tagName || '').toLowerCase();
        if (tag === 'input' || tag === 'textarea' || e.target?.isContentEditable) return;
        const k = e.key;
        if (k === '?') { e.preventDefault(); setHelp(true); return; }
        if (k === 'Escape') { setHelp(false); return; }
        if (k === 'a') { e.preventDefault(); jump('#about'); }
        else if (k === 'e') { e.preventDefault(); jump('#exhibits'); }
        else if (k === 'w') { e.preventDefault(); jump('#work'); }
        else if (k === 'c') { e.preventDefault(); jump('#contact'); }
        else if (k === 'G') { e.preventDefault(); window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); }
        else if (k === 'g') {
          const now = Date.now();
          if (now - lastG < 700) { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); lastG = 0; }
          else lastG = now;
        }
      };
      window.addEventListener('keydown', onKey);
      return () => window.removeEventListener('keydown', onKey);
    }, []);

    return (
      <div className="ur-root">
        <BgScene />
        <div className="ur-scanlines" />
        <div className="ur-vignette" />

        <Nav />
        <SysPanel />
        <AmbientLog />

        <main className="ur-main">
          <Hero onHelp={() => setHelp(true)} />
          <About />
          <Exhibits />
          <Availability />
          <Contact />
          <Footer />
        </main>

        {help && <Cheatsheet onClose={() => setHelp(false)} />}
      </div>
    );
  }

  window.UtilityRoom = UtilityRoom;
})();
