import * as THREE from "three";

const GLYPH = {
  "Web Apps & SaaS": 0,
  "Client Services": 1,
  "Creative & Media": 2,
  "AI Agents & LLM": 3,
  "Learning & Courses": 4,
  "Portfolio & Agency": 5,
  "Netlify & Creator Tools": 6,
  "Automation & Tools": 7,
  "Security Operations & OSINT": 8,
  "Games & Experiments": 9,
  "Python Tools": 10,
  "Crypto & Web3": 11,
  "Workspaces": 12,
  "Rust Projects": 13,
};

const CAT_COLOR = {
  "Web Apps & SaaS": 0x5ce1ff,
  "Client Services": 0xe879f9,
  "Creative & Media": 0x4ade80,
  "AI Agents & LLM": 0x7dd3fc,
  "Learning & Courses": 0xc4b5fd,
  "Portfolio & Agency": 0xf0abfc,
  "Netlify & Creator Tools": 0x22d3ee,
  "Automation & Tools": 0x67e8f9,
  "Security Operations & OSINT": 0x4ade80,
  "Games & Experiments": 0xf472b6,
  "Python Tools": 0xfacc15,
  "Crypto & Web3": 0xa5b4fc,
  "Workspaces": 0x94a3b8,
  "Rust Projects": 0xfb923c,
};

const $ = (id) => document.getElementById(id);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const inspect = $("inspect");
const tip = $("tip");
const isMobile = () => window.innerWidth < 860;

let all = [];
let filterCat = "all";
let query = "";
let selectedId = null;

const redact = (list) =>
  (list || []).map((t) => ({
    id: t.id || "",
    name: t.name || t.id || "Untitled",
    description: (t.description || "").slice(0, 240),
    category: t.category || "Other",
    relative_path: t.relative_path || "",
    runtimes: t.runtimes || [],
    tags: t.tags || [],
  }));

async function loadSnapshot() {
  const r = await fetch("/registry/tools.json", { cache: "force-cache" });
  const j = await r.json();
  all = j.tools || [];
  $("src").textContent = "snapshot";
  $("src").classList.remove("is-live");
}

async function probeLive() {
  try {
    const ctrl = new AbortController();
    const t = setTimeout(() => ctrl.abort(), 600);
    const r = await fetch("/api/tools", {
      cache: "no-store",
      signal: ctrl.signal,
    });
    clearTimeout(t);
    if (!r.ok) return;
    const j = await r.json();
    const tools = j.tools || j;
    if (!Array.isArray(tools) || !tools.length) return;
    all = redact(tools);
    $("src").textContent = "live deck";
    $("src").classList.add("is-live");
    renderCats();
    layoutWall();
  } catch {
    /* snapshot stands */
  }
}

function visibleList() {
  const q = query.trim().toLowerCase();
  return all.filter((t) => {
    if (filterCat !== "all" && t.category !== filterCat) return false;
    if (!q) return true;
    return `${t.name} ${t.description} ${t.category} ${(t.tags || []).join(" ")} ${t.id}`
      .toLowerCase()
      .includes(q);
  });
}

function wallList() {
  const list = visibleList();
  if (query.trim() || list.length <= 36) return list;
  const byCat = new Map();
  list.forEach((t) => {
    if (!byCat.has(t.category)) byCat.set(t.category, []);
    byCat.get(t.category).push(t);
  });
  const cats = [...byCat.keys()];
  const picked = [];
  let i = 0;
  while (picked.length < 36) {
    let added = false;
    for (const c of cats) {
      const t = byCat.get(c)[i];
      if (t) {
        picked.push(t);
        added = true;
      }
      if (picked.length >= 36) break;
    }
    if (!added) break;
    i += 1;
  }
  return picked;
}

function renderCats() {
  const counts = new Map();
  all.forEach((t) => counts.set(t.category, (counts.get(t.category) || 0) + 1));
  const names = [...counts.keys()].sort((a, b) => counts.get(b) - counts.get(a));
  $("cats").replaceChildren();
  const mk = (id, label) => {
    const b = document.createElement("button");
    b.type = "button";
    b.setAttribute("role", "tab");
    b.textContent = label;
    b.setAttribute("aria-selected", filterCat === id ? "true" : "false");
    b.classList.toggle("is-on", filterCat === id);
    b.addEventListener("click", () => {
      filterCat = id;
      renderCats();
      layoutWall();
    });
    return b;
  };
  $("cats").append(mk("all", `All ${all.length}`));
  names.forEach((n) => $("cats").append(mk(n, `${n.split("&")[0].trim()} ${counts.get(n)}`)));
}

function openInspect(t) {
  selectedId = t.id;
  $("inspect-cat").textContent = t.category;
  $("inspect-name").textContent = t.name;
  $("inspect-desc").textContent = t.description || "No description in the snapshot.";
  $("inspect-run").textContent = (t.runtimes || []).join(" · ") || "—";
  $("inspect-path").textContent = t.relative_path || t.id;
  $("inspect-tags").replaceChildren(
    ...(t.tags || []).slice(0, 12).map((tag) => {
      const s = document.createElement("span");
      s.textContent = tag;
      return s;
    })
  );
  inspect.hidden = false;
  uniforms.uSelectId.value = slots.find((s) => s.tool.id === t.id)?.i ?? -1;
}

function closeInspect() {
  selectedId = null;
  inspect.hidden = true;
  uniforms.uSelectId.value = -1;
}

$("inspect-close").addEventListener("click", closeInspect);
document.addEventListener("keydown", (e) => {
  if (e.key === "Escape") closeInspect();
  if (e.key === "/" && document.activeElement !== $("q")) {
    e.preventDefault();
    $("q").focus();
  }
  if (document.activeElement === $("q")) return;
  if (e.key === "ArrowRight" || e.key === "ArrowLeft" || e.key === "Enter") {
    stepSelect(e.key);
  }
});
$("q").addEventListener("input", (e) => {
  query = e.target.value;
  layoutWall();
});

function stepSelect(key) {
  if (!slots.length) return;
  const idx = Math.max(
    0,
    slots.findIndex((s) => s.tool.id === selectedId)
  );
  if (key === "Enter") {
    if (slots[idx]) openInspect(slots[idx].tool);
    return;
  }
  const next = key === "ArrowRight" ? (idx + 1) % slots.length : (idx - 1 + slots.length) % slots.length;
  openInspect(slots[next].tool);
}

/* ---------- glyph atlas (no network) ---------- */
function makeGlyphAtlas() {
  const size = 512;
  const cell = 128;
  const c = document.createElement("canvas");
  c.width = size;
  c.height = size;
  const ctx = c.getContext("2d");
  ctx.clearRect(0, 0, size, size);
  const ink = (fn) => {
    ctx.strokeStyle = "#ffffff";
    ctx.fillStyle = "rgba(0,0,0,0)";
    ctx.lineWidth = 5;
    ctx.lineCap = "round";
    ctx.lineJoin = "round";
    ctx.shadowBlur = 0;
    fn();
  };
  const cellAt = (i, draw) => {
    const x = (i % 4) * cell;
    const y = Math.floor(i / 4) * cell;
    ctx.save();
    ctx.translate(x, y);
    ink(() => draw(ctx, cell));
    ctx.restore();
  };

  cellAt(0, (g) => {
    g.strokeRect(30, 40, 40, 40);
    g.strokeRect(58, 48, 40, 40);
  });
  cellAt(1, (g) => {
    g.beginPath();
    g.moveTo(34, 46);
    g.lineTo(56, 30);
    g.lineTo(78, 46);
    g.lineTo(56, 62);
    g.closePath();
    g.stroke();
    g.beginPath();
    g.moveTo(50, 70);
    g.lineTo(72, 54);
    g.lineTo(94, 70);
    g.lineTo(72, 86);
    g.closePath();
    g.stroke();
  });
  cellAt(2, (g) => {
    g.beginPath();
    g.moveTo(24, 64);
    g.bezierCurveTo(38, 34, 50, 94, 64, 64);
    g.bezierCurveTo(78, 34, 90, 94, 104, 64);
    g.stroke();
  });
  cellAt(3, (g) => {
    g.beginPath();
    g.moveTo(42, 42);
    g.lineTo(64, 58);
    g.lineTo(86, 42);
    g.moveTo(64, 58);
    g.lineTo(64, 86);
    g.stroke();
    [
      [42, 42],
      [86, 42],
      [64, 86],
    ].forEach(([x, y]) => {
      g.beginPath();
      g.arc(x, y, 9, 0, Math.PI * 2);
      g.stroke();
    });
  });
  cellAt(4, (g) => {
    g.strokeRect(32, 32, 64, 14);
    g.strokeRect(38, 56, 52, 14);
    g.strokeRect(44, 80, 40, 14);
  });
  cellAt(5, (g) => {
    g.beginPath();
    g.moveTo(64, 26);
    g.lineTo(98, 64);
    g.lineTo(64, 102);
    g.lineTo(30, 64);
    g.closePath();
    g.stroke();
  });
  cellAt(6, (g) => {
    g.beginPath();
    g.moveTo(34, 30);
    g.lineTo(94, 30);
    g.lineTo(72, 68);
    g.lineTo(72, 98);
    g.lineTo(56, 88);
    g.lineTo(56, 68);
    g.closePath();
    g.stroke();
  });
  cellAt(7, (g) => {
    g.beginPath();
    g.arc(40, 36, 9, 0, Math.PI * 2);
    g.stroke();
    g.beginPath();
    g.arc(88, 36, 9, 0, Math.PI * 2);
    g.stroke();
    g.beginPath();
    g.arc(64, 96, 9, 0, Math.PI * 2);
    g.stroke();
    g.beginPath();
    g.moveTo(40, 45);
    g.lineTo(64, 74);
    g.lineTo(88, 45);
    g.moveTo(64, 74);
    g.lineTo(64, 87);
    g.stroke();
  });
  cellAt(8, (g) => {
    g.beginPath();
    g.moveTo(64, 26);
    g.lineTo(98, 40);
    g.lineTo(94, 70);
    g.quadraticCurveTo(64, 108, 34, 70);
    g.lineTo(30, 40);
    g.closePath();
    g.stroke();
  });
  cellAt(9, (g) => {
    g.beginPath();
    g.moveTo(64, 24);
    g.lineTo(70, 54);
    g.lineTo(104, 64);
    g.lineTo(70, 74);
    g.lineTo(64, 104);
    g.lineTo(58, 74);
    g.lineTo(24, 64);
    g.lineTo(58, 54);
    g.closePath();
    g.stroke();
  });
  cellAt(10, (g) => {
    g.beginPath();
    for (let i = 0; i < 46; i++) {
      const a = i * 0.42;
      const r = 10 + i * 1.05;
      const x = 64 + Math.cos(a) * r;
      const y = 64 + Math.sin(a) * r;
      if (i === 0) g.moveTo(x, y);
      else g.lineTo(x, y);
    }
    g.stroke();
  });
  cellAt(11, (g) => {
    g.beginPath();
    for (let i = 0; i < 6; i++) {
      const a = (Math.PI / 3) * i - Math.PI / 6;
      const x = 64 + Math.cos(a) * 36;
      const y = 64 + Math.sin(a) * 36;
      if (i === 0) g.moveTo(x, y);
      else g.lineTo(x, y);
    }
    g.closePath();
    g.stroke();
  });
  cellAt(12, (g) => {
    g.strokeRect(30, 30, 26, 26);
    g.strokeRect(72, 30, 26, 26);
    g.strokeRect(30, 72, 26, 26);
    g.strokeRect(72, 72, 26, 26);
  });
  cellAt(13, (g) => {
    g.beginPath();
    g.moveTo(64, 28);
    g.lineTo(98, 48);
    g.lineTo(98, 82);
    g.lineTo(64, 102);
    g.lineTo(30, 82);
    g.lineTo(30, 48);
    g.closePath();
    g.stroke();
    g.beginPath();
    g.moveTo(64, 28);
    g.lineTo(64, 64);
    g.lineTo(30, 82);
    g.moveTo(64, 64);
    g.lineTo(98, 82);
    g.stroke();
  });

  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.NoColorSpace;
  tex.flipY = true;
  tex.generateMipmaps = false;
  tex.minFilter = THREE.LinearFilter;
  tex.magFilter = THREE.LinearFilter;
  tex.needsUpdate = true;
  return tex;
}

/* ---------- WebGL wall ---------- */
const canvas = $("gl");
const dprCap = isMobile() ? 1.15 : 1.5;
const renderer = new THREE.WebGLRenderer({
  canvas,
  antialias: !isMobile(),
  alpha: false,
  powerPreference: "high-performance",
  stencil: false,
  depth: true,
});
renderer.setClearColor(0x03050a, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, dprCap));
renderer.setSize(window.innerWidth || 1440, window.innerHeight || 900, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.NoToneMapping;

const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x03050a, 0.016);

const camera = new THREE.PerspectiveCamera(38, 1, 0.2, 80);
const camRest = new THREE.Vector3(-2.1, 0.28, 16.2);
const lookRest = new THREE.Vector3(0.55, -0.12, 0);
camera.position.copy(camRest);
camera.lookAt(lookRest);

const wall = new THREE.Group();
wall.rotation.y = 0.16;
wall.position.set(0.35, 0.08, 0);
scene.add(wall);

const dummy = new THREE.Object3D();
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(-10, -10);
let pointerNdc = { x: 0, y: 0 };
let hoverId = -1;

const uniforms = {
  uTime: { value: 0 },
  uGlyph: { value: makeGlyphAtlas() },
  uHoverId: { value: -1 },
  uSelectId: { value: -1 },
  uPulse: { value: reduce ? 0 : 1 },
};

const glassMat = new THREE.ShaderMaterial({
  uniforms,
  transparent: true,
  depthWrite: false,
  fog: false,
  side: THREE.FrontSide,
  vertexShader: /* glsl */ `
    attribute float instanceGlyph;
    attribute float instanceId;
    varying vec2 vUv;
    varying vec3 vColor;
    varying float vGlyph;
    varying float vId;
    void main() {
      vUv = uv;
      vColor = instanceColor;
      vGlyph = instanceGlyph;
      vId = instanceId;
      vec4 mv = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
      gl_Position = projectionMatrix * mv;
    }
  `,
  fragmentShader: /* glsl */ `
    uniform sampler2D uGlyph;
    uniform float uTime;
    uniform float uHoverId;
    uniform float uSelectId;
    uniform float uPulse;
    varying vec2 vUv;
    varying vec3 vColor;
    varying float vGlyph;
    varying float vId;

    float sdRoundBox(vec2 p, vec2 b, float r) {
      vec2 q = abs(p) - b + r;
      return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
    }

    void main() {
      vec2 p = vUv * 2.0 - 1.0;
      float d = sdRoundBox(p, vec2(0.78, 0.78), 0.07);
      if (d > 0.42) discard;

      float inside = 1.0 - smoothstep(-0.018, 0.004, d);
      float edge = 1.0 - smoothstep(0.0, 0.045, abs(d));
      float glow = exp(-max(d, 0.0) * 9.0) * 0.55;

      float hot = 0.0;
      hot += 1.0 - step(0.5, abs(vId - uHoverId));
      hot += 1.0 - step(0.5, abs(vId - uSelectId));
      hot = clamp(hot, 0.0, 1.0);

      vec3 frost = vec3(0.04, 0.11, 0.15);
      vec3 frame = vec3(0.28, 0.94, 1.0);
      vec3 col = frost + frame * 0.08 + vColor * 0.08;
      float scan = 0.9 + 0.1 * sin(vUv.y * 86.0 + uTime * 1.4 * uPulse);
      col *= scan;
      col += vec3(0.45, 0.92, 1.0) * (1.0 - vUv.y) * 0.12 * inside;

      vec2 gUv = (vUv - 0.5) / 0.46 + 0.5;
      float gx = mod(vGlyph, 4.0);
      float gy = 3.0 - floor(vGlyph / 4.0);
      vec2 atlas = (vec2(gx, gy) + clamp(gUv, 0.0, 1.0)) / 4.0;
      float inGlyph = step(0.0, gUv.x) * step(gUv.x, 1.0) * step(0.0, gUv.y) * step(gUv.y, 1.0);
      float glyph = texture2D(uGlyph, atlas).a * inGlyph * inside;

      col += vColor * glyph * (1.25 + 0.4 * hot);
      col += frame * edge * (1.2 + 0.65 * hot);
      col += vColor * glow * (0.25 + 0.35 * hot);
      col += frame * glow * 0.35;

      float flicker = 1.0 - 0.04 * uPulse * step(0.985, sin(uTime * 17.0 + vId * 3.1));
      col *= flicker;

      float alpha = inside * (0.46 + 0.12 * hot) + edge * 0.95 + glow * 0.38 + glyph * 0.6;
      alpha = clamp(alpha, 0.0, 0.94);
      gl_FragColor = vec4(col, alpha);
    }
  `,
});

const MAX = 320;
const glassGeo = new THREE.PlaneGeometry(1.12, 1.12);
const glyphAttr = new THREE.InstancedBufferAttribute(new Float32Array(MAX), 1);
const idArr = new Float32Array(MAX);
for (let i = 0; i < MAX; i++) idArr[i] = i;
glassGeo.setAttribute("instanceGlyph", glyphAttr);
glassGeo.setAttribute("instanceId", new THREE.InstancedBufferAttribute(idArr, 1));

const glass = new THREE.InstancedMesh(glassGeo, glassMat, MAX);
glass.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
glass.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX * 3), 3);
glass.frustumCulled = false;
glass.count = 0;
wall.add(glass);

const slots = [];

function applySlotMatrix(s, lift = 0, scale = 1) {
  dummy.position.set(s.x, s.y, s.z + lift);
  dummy.scale.set(s.sx * scale, s.sy * scale, 1);
  dummy.rotation.set(0, 0, 0);
  dummy.updateMatrix();
  glass.setMatrixAt(s.i, dummy.matrix);
}

const hullMat = new THREE.MeshBasicMaterial({ color: 0x070b12 });
const hull = new THREE.Mesh(new THREE.PlaneGeometry(56, 32), hullMat);
hull.position.set(0.4, 0, -7.4);
hull.rotation.y = 0.08;
scene.add(hull);
new THREE.TextureLoader().load("/assets/media/section-registry.jpg", (tex) => {
  tex.colorSpace = THREE.SRGBColorSpace;
  tex.minFilter = THREE.LinearFilter;
  tex.generateMipmaps = false;
  renderer.initTexture(tex);
  hullMat.map = tex;
  hullMat.color.set(0x4d5a66);
  hullMat.needsUpdate = true;
  revealWall();
});

const plate = new THREE.Mesh(
  new THREE.PlaneGeometry(38, 22),
  new THREE.MeshBasicMaterial({
    color: 0x05080f,
    transparent: true,
    opacity: 0.18,
  })
);
plate.position.z = -2.6;
wall.add(plate);

const sparkN = 140;
const sparkGeo = new THREE.BufferGeometry();
const sparkPos = new Float32Array(sparkN * 3);
for (let i = 0; i < sparkN; i++) {
  sparkPos[i * 3] = (Math.random() - 0.5) * 26;
  sparkPos[i * 3 + 1] = (Math.random() - 0.5) * 15;
  sparkPos[i * 3 + 2] = Math.random() * 5 - 0.6;
}
sparkGeo.setAttribute("position", new THREE.BufferAttribute(sparkPos, 3));
const sparks = new THREE.Points(
  sparkGeo,
  new THREE.PointsMaterial({
    color: 0x7af0ff,
    size: 0.028,
    transparent: true,
    opacity: 0.5,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    sizeAttenuation: true,
  })
);
scene.add(sparks);

function resize() {
  const w = window.innerWidth;
  const h = window.innerHeight;
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, w < 800 ? 1.15 : 1.5));
  renderer.setSize(w, h, false);
  camera.aspect = w / Math.max(h, 1);
  camera.updateProjectionMatrix();
}
window.addEventListener("resize", resize, { passive: true });

function layoutWall() {
  const matched = visibleList();
  const list = wallList();
  $("count").textContent =
    list.length < matched.length ? `${list.length} of ${matched.length}` : `${matched.length} / ${all.length}`;
  slots.length = 0;
  const n = Math.min(list.length, MAX);
  const cinematic = n <= 40;
  const cols = Math.max(cinematic ? 5 : 8, Math.ceil(Math.sqrt(n * (cinematic ? 1.35 : 1.6))));
  const rows = Math.max(1, Math.ceil(n / cols));
  const gapX = cinematic ? 1.58 : 1.16;
  const gapY = cinematic ? 1.48 : 1.1;
  const originX = -((cols - 1) * gapX) / 2;
  const originY = ((rows - 1) * gapY) / 2;
  dummy.scale.set(0, 0, 0);
  dummy.updateMatrix();
  for (let i = 0; i < MAX; i++) glass.setMatrixAt(i, dummy.matrix);

  const c = new THREE.Color();
  for (let i = 0; i < n; i++) {
    const t = list[i];
    const col = i % cols;
    const row = Math.floor(i / cols);
    const featured = cinematic && (i % 9 === 0 || i % 13 === 2);
    const tall = cinematic && i % 17 === 5;
    const sx = (featured ? 1.62 : tall ? 1.08 : 1) * (cinematic ? 1.12 : 0.9);
    const sy = (featured ? 1.28 : tall ? 1.48 : 1) * (cinematic ? 1.08 : 0.9);
    const x = originX + col * gapX + (row % 2) * (cinematic ? 0.18 : 0.08);
    const y = originY - row * gapY;
    const z = ((i * 17) % 7) * 0.045 - 0.1;
    dummy.position.set(x, y, z);
    dummy.scale.set(sx, sy, 1);
    dummy.rotation.set(0, 0, 0);
    dummy.updateMatrix();
    glass.setMatrixAt(i, dummy.matrix);
    c.setHex(CAT_COLOR[t.category] || 0x5ce1ff);
    glass.setColorAt(i, c);
    glyphAttr.setX(i, GLYPH[t.category] ?? 0);
    slots.push({ i, tool: t, x, y, z, sx, sy });
  }
  glass.count = n;
  glass.instanceMatrix.needsUpdate = true;
  if (glass.instanceColor) glass.instanceColor.needsUpdate = true;
  glyphAttr.needsUpdate = true;

  const span = Math.max(cols * gapX, rows * gapY, 8);
  camRest.z = THREE.MathUtils.clamp(span * (cinematic ? 1.05 : 0.92), 11.5, 26);
  camRest.x = cinematic ? -2.1 : -1.2;
  lookRest.x = cinematic ? 0.55 : 0.2;
  uniforms.uSelectId.value = selectedId ? slots.find((s) => s.tool.id === selectedId)?.i ?? -1 : -1;
}

function hitTest(clientX, clientY) {
  const rect = canvas.getBoundingClientRect();
  pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
  pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
  raycaster.setFromCamera(pointer, camera);
  const hits = raycaster.intersectObject(glass, false);
  if (!hits.length) return null;
  const id = hits[0].instanceId;
  return slots[id] || null;
}

window.addEventListener(
  "pointermove",
  (e) => {
    if (e.target.closest(".hud, .inspect")) {
      tip.hidden = true;
      hoverId = -1;
      uniforms.uHoverId.value = -1;
      return;
    }
    pointerNdc.x = e.clientX / window.innerWidth - 0.5;
    pointerNdc.y = e.clientY / window.innerHeight - 0.5;
    const hit = hitTest(e.clientX, e.clientY);
    if (hoverId >= 0 && (!hit || hit.i !== hoverId) && slots[hoverId]) {
      applySlotMatrix(slots[hoverId], 0, 1);
      glass.instanceMatrix.needsUpdate = true;
    }
    if (hit) {
      hoverId = hit.i;
      uniforms.uHoverId.value = hit.i;
      tip.hidden = false;
      tip.innerHTML = `<span>${hit.tool.category}</span><b>${hit.tool.name}</b>`;
      tip.style.left = `${e.clientX}px`;
      tip.style.top = `${e.clientY}px`;
      canvas.style.cursor = "pointer";
    } else {
      hoverId = -1;
      uniforms.uHoverId.value = -1;
      tip.hidden = true;
      canvas.style.cursor = "default";
    }
  },
  { passive: true }
);

window.addEventListener("click", (e) => {
  if (e.target.closest(".hud, .inspect")) return;
  const hit = hitTest(e.clientX, e.clientY);
  if (hit) openInspect(hit.tool);
  else closeInspect();
});

let t0 = performance.now();
function tick(now) {
  const t = (now - t0) / 1000;
  uniforms.uTime.value = t;
  if (!reduce) {
    camera.position.x += (camRest.x + pointerNdc.x * 1.35 - camera.position.x) * 0.045;
    camera.position.y += (camRest.y - pointerNdc.y * 0.95 - camera.position.y) * 0.045;
    camera.position.z += (camRest.z - camera.position.z) * 0.06;
    camera.lookAt(lookRest);
    sparks.rotation.z = t * 0.018;
    if (hoverId >= 0 && slots[hoverId]) {
      applySlotMatrix(slots[hoverId], 0.18, 1.07);
      glass.instanceMatrix.needsUpdate = true;
    }
  }
  renderer.render(scene, camera);
  requestAnimationFrame(tick);
}

let revealed = false;
function revealWall() {
  if (revealed) return;
  revealed = true;
  requestAnimationFrame(() => {
    renderer.render(scene, camera);
    requestAnimationFrame(() => $("veil")?.classList.add("is-off"));
  });
}

async function boot() {
  resize();
  camera.lookAt(lookRest);
  renderer.render(scene, camera);
  try {
    await loadSnapshot();
    renderCats();
    layoutWall();
  } catch (err) {
    $("count").textContent = "unavailable";
    console.error(err);
  }
  requestAnimationFrame(tick);
  setTimeout(revealWall, 320);
  probeLive();
}

boot();
