/* project-detail.jsx — NodePrompt case study */

const { useState: useStatePD, useEffect: useEffectPD, useMemo: useMemoPD, useRef: useRefPD } = React;

/* ------------------------------------------------------------------
   NodepromptDecompose — preset-driven decomposition demo.

   Flow: select a preset → sentence appears in a textarea → press
   Decompose → 17+ typed nodes drift out one by one and edges fade
   in. Hover/click any node for a tooltip with type, weight, and
   description, mirroring NodePrompt's inspector.
------------------------------------------------------------------ */
const NP_TYPES = ["ens", "res", "unum", "aliquid", "verum", "bonum"];

const NP_TYPE_GLOSS = {
  ens:     { latin: "ens",     en: "Being",      kr: "존재",   q: "What does this prompt posit as existing?" },
  res:     { latin: "res",     en: "Essence",    kr: "본질",   q: "What is it, as a formal structure?" },
  unum:    { latin: "unum",    en: "Unity",      kr: "통일성", q: "What holds it together as one?" },
  aliquid: { latin: "aliquid", en: "Difference", kr: "차이",   q: "What distinguishes it from what it is not?" },
  verum:   { latin: "verum",   en: "Truth",      kr: "참",     q: "How is it true to a knower?" },
  bonum:   { latin: "bonum",   en: "Value",      kr: "선",     q: "How is it desirable to a will?" },
};

/* Each preset: a sentence + ~17 typed nodes (1 root + 6 register
   parents + 10 leaves). Each node carries a parent for edge
   generation, plus a one-line description for the inspector. */
const NP_PRESETS = [
  {
    id: "research",
    label: "Research proposal",
    sentence: "Write a research proposal on spintronics for a graduate fellowship, balancing rigor against the reader's limited attention.",
    nodes: [
      { id: "root", type: "ens", parent: null, ring: 0, ang: 0, w: 1.00, label: "spintronics proposal",
        desc: "The whole. What the prompt posits as a thing — the proposal itself, taken as one object." },

      { id: "res",     type: "res",     parent: "root", ring: 1, ang:  -90, w: 0.85, label: "research framework",
        desc: "The formal structure: thesis, claims, the architecture that holds the argument." },
      { id: "unum",    type: "unum",    parent: "root", ring: 1, ang:  -30, w: 0.75, label: "fellowship reader",
        desc: "The frame that holds it together — who reads this, under what evaluation rubric." },
      { id: "bonum",   type: "bonum",   parent: "root", ring: 1, ang:   30, w: 0.65, label: "career stake",
        desc: "The desirability axis: tone, urgency, what is at risk for the writer." },
      { id: "verum",   type: "verum",   parent: "root", ring: 1, ang:   90, w: 0.80, label: "scientific rigor",
        desc: "What it owes the intellect — methodological commitments, reproducibility, falsifiability." },
      { id: "aliquid", type: "aliquid", parent: "root", ring: 1, ang:  150, w: 0.70, label: "novelty vs prior art",
        desc: "Difference. What it is not — distinguishing this from the existing literature." },
      { id: "res2",    type: "res",     parent: "root", ring: 1, ang:  210, w: 0.55, label: "methods section",
        desc: "Secondary structural register — the experimental procedure read as form, not content." },

      { id: "l-res-aims",   type: "res",     parent: "res",     ring: 2, ang: -110, w: 0.40, label: "aims",
        desc: "A child of the framework — the deliverable claims the proposal commits to." },
      { id: "l-res-budget", type: "res",     parent: "res",     ring: 2, ang:  -70, w: 0.35, label: "budget arc",
        desc: "How resources map onto the structural skeleton over the funding period." },
      { id: "l-unum-rubric",type: "unum",    parent: "unum",    ring: 2, ang:  -50, w: 0.45, label: "evaluation rubric",
        desc: "The frame the reader applies — explicit and implicit scoring axes." },
      { id: "l-unum-tone",  type: "unum",    parent: "unum",    ring: 2, ang:  -10, w: 0.30, label: "register",
        desc: "The institutional voice the document must speak in to be heard at all." },
      { id: "l-bonum-stake",type: "bonum",   parent: "bonum",   ring: 2, ang:   45, w: 0.40, label: "stake",
        desc: "What the writer loses if the proposal fails — the affective center of gravity." },
      { id: "l-verum-rep",  type: "verum",   parent: "verum",   ring: 2, ang:   75, w: 0.55, label: "reproducibility",
        desc: "A specific commitment to a knower — code, data, methods exposed for audit." },
      { id: "l-verum-fals", type: "verum",   parent: "verum",   ring: 2, ang:  105, w: 0.45, label: "falsifiability",
        desc: "What would count as the proposal being wrong — required for it to count as knowledge." },
      { id: "l-aliq-prior", type: "aliquid", parent: "aliquid", ring: 2, ang:  140, w: 0.45, label: "prior literature",
        desc: "The not-it. What this proposal explicitly defines itself against." },
      { id: "l-aliq-gap",   type: "aliquid", parent: "aliquid", ring: 2, ang:  170, w: 0.40, label: "open gap",
        desc: "The seam between what is known and what this work intends to add." },
      { id: "l-res2-meas",  type: "res",     parent: "res2",    ring: 2, ang:  205, w: 0.35, label: "measurement chain",
        desc: "The instrument-to-claim path. Where rigor lives or fails, structurally." },
    ],
    crossEdges: [
      ["l-verum-rep", "l-res-aims"],
      ["l-aliq-gap",  "l-verum-fals"],
      ["l-bonum-stake","l-unum-rubric"],
    ],
  },

  {
    id: "quantum",
    label: "Quantum, for a 14-year-old",
    sentence: "Explain quantum entanglement to a high-school student in a way that builds wonder before it builds formalism.",
    nodes: [
      { id: "root", type: "ens", parent: null, ring: 0, ang: 0, w: 1.00, label: "entanglement, taught",
        desc: "The whole. The act of explanation as one object — not the topic alone, but the teaching of it." },

      { id: "res",     type: "res",     parent: "root", ring: 1, ang:  -90, w: 0.80, label: "quantum mechanics",
        desc: "The formal structure underneath the lesson. The thing whose surface is being shown." },
      { id: "unum",    type: "unum",    parent: "root", ring: 1, ang:  -30, w: 0.85, label: "high-school context",
        desc: "Pedagogical frame. What this listener already has — what the lesson can stand on." },
      { id: "bonum",   type: "bonum",   parent: "root", ring: 1, ang:   30, w: 0.80, label: "wonder before rigor",
        desc: "The affective stance the lesson is asked to take. The desirable shape of the encounter." },
      { id: "verum",   type: "verum",   parent: "root", ring: 1, ang:   90, w: 0.65, label: "what is physically true",
        desc: "The non-negotiable: claims that have to remain accurate even when simplified." },
      { id: "aliquid", type: "aliquid", parent: "root", ring: 1, ang:  150, w: 0.75, label: "vs classical correlation",
        desc: "Difference. The crucial not-it — entanglement is most clearly seen against what it is not." },
      { id: "res2",    type: "res",     parent: "root", ring: 1, ang:  210, w: 0.55, label: "explanatory ladder",
        desc: "Structural form of the explanation itself: where to start, where to stop, in what order." },

      { id: "l-res-bell",   type: "res",     parent: "res",     ring: 2, ang: -115, w: 0.45, label: "Bell pair",
        desc: "The minimal example. The simplest formal object that exhibits the phenomenon." },
      { id: "l-res-state",  type: "res",     parent: "res",     ring: 2, ang:  -65, w: 0.35, label: "state vector",
        desc: "The mathematical handle. Optional for a 14-year-old; load-bearing if invoked." },
      { id: "l-unum-prior", type: "unum",    parent: "unum",    ring: 2, ang:  -45, w: 0.45, label: "prior knowledge",
        desc: "What the listener already trusts — coins, dice, polarizers — anchors for new claims." },
      { id: "l-unum-met",   type: "unum",    parent: "unum",    ring: 2, ang:  -15, w: 0.50, label: "metaphor budget",
        desc: "How many analogies the lesson can spend before they start lying for it." },
      { id: "l-bonum-aha",  type: "bonum",   parent: "bonum",   ring: 2, ang:   45, w: 0.50, label: "aha-moment",
        desc: "The affective payoff the lesson is engineered around. The desirable peak." },
      { id: "l-verum-nosig",type: "verum",   parent: "verum",   ring: 2, ang:   85, w: 0.45, label: "no-signaling",
        desc: "A constraint that must survive any simplification — entanglement carries no usable signal." },
      { id: "l-verum-meas", type: "verum",   parent: "verum",   ring: 2, ang:  115, w: 0.40, label: "measurement role",
        desc: "What measurement means in this regime — different from the classical reading." },
      { id: "l-aliq-coin",  type: "aliquid", parent: "aliquid", ring: 2, ang:  140, w: 0.50, label: "shared-coin trap",
        desc: "The classical model that almost works. Best contrast for showing where it breaks." },
      { id: "l-aliq-bell",  type: "aliquid", parent: "aliquid", ring: 2, ang:  170, w: 0.45, label: "Bell inequality",
        desc: "The line that classical correlations cannot cross — the place difference becomes visible." },
      { id: "l-res2-step",  type: "res",     parent: "res2",    ring: 2, ang:  205, w: 0.35, label: "step ordering",
        desc: "The sequence of explanation. Wonder first, mechanism second, formalism only on demand." },
    ],
    crossEdges: [
      ["l-aliq-coin", "l-verum-nosig"],
      ["l-bonum-aha", "l-aliq-bell"],
      ["l-unum-met",  "l-res-bell"],
    ],
  },

  {
    id: "vinyl",
    label: "Vinyl-label logo",
    sentence: "Design a logo for an independent vinyl record label, refusing the shorthand of major-label marks while staying readable at 7-inch scale.",
    nodes: [
      { id: "root", type: "ens", parent: null, ring: 0, ang: 0, w: 1.00, label: "label mark",
        desc: "The whole. The mark itself, taken as a single object — not the brand system, the mark." },

      { id: "res",     type: "res",     parent: "root", ring: 1, ang:  -90, w: 0.80, label: "brand system",
        desc: "The formal scaffolding the mark belongs to: typography, palette, lockups." },
      { id: "unum",    type: "unum",    parent: "root", ring: 1, ang:  -30, w: 0.75, label: "vinyl culture",
        desc: "What holds the mark in place culturally — the listener it speaks to." },
      { id: "bonum",   type: "bonum",   parent: "root", ring: 1, ang:   30, w: 0.85, label: "tone, attitude",
        desc: "The affective register — independent, considered, slightly hostile to the obvious." },
      { id: "verum",   type: "verum",   parent: "root", ring: 1, ang:   90, w: 0.70, label: "design principles",
        desc: "Commitments to a knower — what would make a designer call this work honest." },
      { id: "aliquid", type: "aliquid", parent: "root", ring: 1, ang:  150, w: 0.75, label: "vs major-label marks",
        desc: "Difference. The not-it — the corporate marks the brief explicitly refuses." },
      { id: "res2",    type: "res",     parent: "root", ring: 1, ang:  210, w: 0.55, label: "press constraints",
        desc: "The other formal register: physical print, ink coverage, 7-inch legibility." },

      { id: "l-res-type",   type: "res",     parent: "res",     ring: 2, ang: -115, w: 0.45, label: "type system",
        desc: "Custom or licensed; condensed or wide. Carries more of the label's voice than the mark itself." },
      { id: "l-res-grid",   type: "res",     parent: "res",     ring: 2, ang:  -65, w: 0.35, label: "grid",
        desc: "The proportions the rest of the system has to obey." },
      { id: "l-unum-shop",  type: "unum",    parent: "unum",    ring: 2, ang:  -50, w: 0.40, label: "record-shop bin",
        desc: "Where the mark will actually first be seen — sideways, half-occluded, fluorescent light." },
      { id: "l-unum-dj",    type: "unum",    parent: "unum",    ring: 2, ang:  -10, w: 0.35, label: "DJ subculture",
        desc: "The community whose vocabulary the mark should already speak." },
      { id: "l-bonum-ind",  type: "bonum",   parent: "bonum",   ring: 2, ang:   45, w: 0.50, label: "indie posture",
        desc: "Refusal as aesthetic — a stance, not just a style." },
      { id: "l-verum-leg",  type: "verum",   parent: "verum",   ring: 2, ang:   75, w: 0.50, label: "legibility",
        desc: "Survives reduction. A non-negotiable for a print mark." },
      { id: "l-verum-honest",type: "verum",  parent: "verum",   ring: 2, ang:  105, w: 0.40, label: "honest construction",
        desc: "No false depth, no AI-render texture standing in for craft." },
      { id: "l-aliq-corp",  type: "aliquid", parent: "aliquid", ring: 2, ang:  140, w: 0.50, label: "corporate gloss",
        desc: "The aesthetic to refuse — gradients, fake bevels, inevitability." },
      { id: "l-aliq-trend", type: "aliquid", parent: "aliquid", ring: 2, ang:  170, w: 0.40, label: "trend cycles",
        desc: "Differentiation across time, not just across the shelf." },
      { id: "l-res2-ink",   type: "res",     parent: "res2",    ring: 2, ang:  205, w: 0.40, label: "ink coverage",
        desc: "The press's tolerance for thin lines and large solids — a real constraint, not a stylistic one." },
    ],
    crossEdges: [
      ["l-aliq-corp",   "l-bonum-ind"],
      ["l-verum-leg",   "l-res2-ink"],
      ["l-unum-shop",   "l-verum-leg"],
    ],
  },
];

/* Tree → polar coordinates. Root at center; ring 1 evenly spaced;
   ring 2 placed at given ang. Returns {x, y} in viewBox units. */
function npNodePos(node, W, H) {
  if (node.ring === 0) return { x: 0, y: 0 };
  const R1 = Math.min(W, H) * 0.22;
  const R2 = Math.min(W, H) * 0.40;
  const r = node.ring === 1 ? R1 : R2;
  const a = (node.ang * Math.PI) / 180;
  return { x: Math.cos(a) * r, y: Math.sin(a) * r };
}

/* Lombardi-style markers — pattern, never colour. */
function NpNodeMark({ type, r }) {
  const stroke = "var(--fg)";
  const sw = 1.25;
  switch (type) {
    case "ens":
      return <circle r={r} fill={stroke} stroke={stroke} strokeWidth={sw} />;
    case "res":
      return <circle r={r} fill="var(--bg)" stroke={stroke} strokeWidth={sw} />;
    case "unum":
      return (
        <g>
          <circle r={r} fill="var(--bg)" stroke={stroke} strokeWidth={sw} />
          <circle r={Math.max(2, r * 0.4)} fill={stroke} />
        </g>
      );
    case "aliquid":
      return (
        <g>
          <circle r={r} fill="var(--bg)" stroke={stroke} strokeWidth={sw} />
          <path d={`M 0 0 L ${r} 0 A ${r} ${r} 0 0 1 0 ${r} Z`} fill={stroke} />
        </g>
      );
    case "verum":
      return (
        <g>
          <circle r={r} fill="var(--bg)" stroke={stroke} strokeWidth={sw} />
          <line x1={-r} y1={0} x2={r} y2={0} stroke={stroke} strokeWidth={sw * 0.6} />
          <line x1={0} y1={-r} x2={0} y2={r} stroke={stroke} strokeWidth={sw * 0.6} />
        </g>
      );
    case "bonum":
      return <circle r={r} fill="var(--bg)" stroke={stroke} strokeWidth={sw} strokeDasharray="2 3" />;
    default:
      return null;
  }
}

function npBezier(p1, p2, sweep = 1) {
  const mx = (p1.x + p2.x) / 2;
  const my = (p1.y + p2.y) / 2;
  const dx = p2.x - p1.x;
  const dy = p2.y - p1.y;
  const len = Math.hypot(dx, dy) || 1;
  const off = len * 0.16 * sweep;
  const cx = mx + (-dy / len) * off;
  const cy = my + ( dx / len) * off;
  return `M ${p1.x} ${p1.y} Q ${cx} ${cy} ${p2.x} ${p2.y}`;
}

function NodepromptDecompose() {
  useLang();
  const [activeId, setActiveId] = useStatePD(NP_PRESETS[0].id);
  const [draft, setDraft] = useStatePD(NP_PRESETS[0].sentence);
  const [stage, setStage] = useStatePD("idle"); // idle | mapping | mapped
  const [shown, setShown] = useStatePD(0);
  const [hover, setHover] = useStatePD(null);
  const [pinned, setPinned] = useStatePD(null);
  const containerRef = useRefPD(null);

  const preset = useMemoPD(() => NP_PRESETS.find(p => p.id === activeId), [activeId]);
  const totalNodes = preset.nodes.length;

  // changing preset resets draft + stage
  useEffectPD(() => {
    setDraft(preset.sentence);
    setStage("idle");
    setShown(0);
    setHover(null);
    setPinned(null);
  }, [activeId]);

  // animate node arrival when stage flips to mapping
  useEffectPD(() => {
    if (stage !== "mapping") return;
    setShown(0);
    let n = 0;
    const id = setInterval(() => {
      n += 1;
      setShown(n);
      if (n >= totalNodes) {
        clearInterval(id);
        setStage("mapped");
      }
    }, 90);
    return () => clearInterval(id);
  }, [stage, totalNodes]);

  const submit = () => {
    setPinned(null);
    setStage("mapping");
  };
  const reset = () => {
    setStage("idle");
    setShown(0);
    setPinned(null);
    setHover(null);
  };

  const W = 760, H = 520;
  const active = pinned || hover;
  const activeNode = active ? preset.nodes.find(n => n.id === active) : null;

  // gather edges: parent links + cross-edges
  const edges = useMemoPD(() => {
    const e = [];
    preset.nodes.forEach(n => { if (n.parent) e.push([n.parent, n.id]); });
    (preset.crossEdges || []).forEach(p => e.push(p));
    return e;
  }, [preset]);

  // map id → reveal index for staggered animation
  const orderIndex = useMemoPD(() => {
    const m = {};
    preset.nodes.forEach((n, i) => { m[n.id] = i; });
    return m;
  }, [preset]);

  return (
    <div style={{ border: "1px solid var(--line)", padding: 28, marginBottom: 100, background: "var(--bg)" }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 20, flexWrap: "wrap", gap: 12 }}>
        <div>
          <div className="micro" style={{ marginBottom: 6 }}>{t("Fig. 02 · Interactive demo")}</div>
          <div className="display-md" style={{ fontSize: 28 }}>{t("Type a prompt. Watch it map.")}</div>
        </div>
        <div className="micro" style={{ color: "var(--fg-muted)" }}>{t("ens · res · unum · aliquid · verum · bonum")}</div>
      </div>

      {/* Input panel — dropdown, textarea, submit. Simulates the
          NodePrompt entry surface. */}
      <div style={{
        display: "grid",
        gridTemplateColumns: "240px 1fr",
        gap: 12,
        marginBottom: 18,
        alignItems: "stretch",
      }} className="np-input-grid">
        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
          <label className="micro" style={{ color: "var(--fg-muted)" }}>{t("Preset")}</label>
          <select
            value={activeId}
            onChange={(e) => setActiveId(e.target.value)}
            data-cursor="link" data-cursor-label={t("Pick")}
            style={{
              padding: "10px 12px",
              border: "1px solid var(--line)",
              background: "var(--bg)",
              color: "var(--fg)",
              fontFamily: "var(--font-mono)",
              fontSize: 12,
              letterSpacing: "0.06em",
              cursor: "pointer",
              appearance: "none",
              backgroundImage: "linear-gradient(45deg, transparent 50%, var(--fg) 50%), linear-gradient(135deg, var(--fg) 50%, transparent 50%)",
              backgroundPosition: "calc(100% - 16px) 50%, calc(100% - 11px) 50%",
              backgroundSize: "5px 5px, 5px 5px",
              backgroundRepeat: "no-repeat",
            }}
          >
            {NP_PRESETS.map((p, i) => (
              <option key={p.id} value={p.id}>
                {String(i + 1).padStart(2, "0")} · {t(p.label)}
              </option>
            ))}
          </select>
          <div className="micro" style={{ color: "var(--fg-muted)", lineHeight: 1.5 }}>
            {t("Three pre-mapped sentences. Pick one, edit if you like, then decompose.")}
          </div>
        </div>

        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
          <label className="micro" style={{ color: "var(--fg-muted)" }}>{t("Prompt")}</label>
          <textarea
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            rows={3}
            data-cursor="text"
            style={{
              padding: 14,
              border: "1px solid var(--line)",
              background: "var(--bg)",
              color: "var(--fg)",
              fontFamily: "var(--font-display)",
              fontStyle: "italic",
              fontSize: "clamp(16px, 1.6vw, 19px)",
              lineHeight: 1.45,
              resize: "vertical",
              minHeight: 90,
              outline: "none",
            }}
          />
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
            <div className="micro" style={{ color: "var(--fg-muted)" }}>
              {stage === "idle"   && t("Ready. Press Decompose to map this prompt to its concept graph.")}
              {stage === "mapping"&& t("Mapping… {n} / {total} nodes").replace("{n}", shown).replace("{total}", totalNodes)}
              {stage === "mapped" && t("Mapped. Hover any node for the inspector. Click to pin.")}
            </div>
            <div style={{ display: "flex", gap: 8 }}>
              {stage !== "idle" && (
                <button
                  onClick={reset}
                  data-cursor="link" data-cursor-label={t("Reset")}
                  className="micro"
                  style={{
                    padding: "10px 16px",
                    border: "1px solid var(--line)",
                    background: "var(--bg)",
                    color: "var(--fg)",
                    cursor: "pointer",
                  }}
                >
                  {t("Reset")}
                </button>
              )}
              <button
                onClick={submit}
                disabled={stage === "mapping"}
                data-cursor="link" data-cursor-label={t("Submit")}
                className="micro"
                style={{
                  padding: "10px 18px",
                  border: "1px solid var(--fg)",
                  background: "var(--fg)",
                  color: "var(--bg)",
                  cursor: stage === "mapping" ? "wait" : "pointer",
                  opacity: stage === "mapping" ? 0.6 : 1,
                }}
              >
                {stage === "mapped" ? t("Re-map ↻") : t("Decompose ↓")}
              </button>
            </div>
          </div>
        </div>
      </div>

      {/* Graph + inspector */}
      <div ref={containerRef} style={{ position: "relative", width: "100%", aspectRatio: "760 / 520", margin: "0 auto" }}>
        <svg viewBox={`${-W/2} ${-H/2} ${W} ${H}`} style={{ width: "100%", height: "100%", overflow: "visible", display: "block" }}
             onClick={(e) => { if (e.target.tagName === "svg") setPinned(null); }}>
          {/* Edges */}
          <g>
            {edges.map(([a, b], i) => {
              const na = preset.nodes.find(n => n.id === a);
              const nb = preset.nodes.find(n => n.id === b);
              if (!na || !nb) return null;
              const pa = npNodePos(na, W, H);
              const pb = npNodePos(nb, W, H);
              const sweep = i % 2 === 0 ? 1 : -1;
              // edge revealed once both endpoints are out
              const ready = stage !== "idle" && shown > Math.max(orderIndex[a], orderIndex[b]);
              const dimmed = active && active !== a && active !== b;
              return (
                <path
                  key={i}
                  d={npBezier(pa, pb, sweep)}
                  fill="none"
                  stroke="var(--fg)"
                  strokeWidth={0.9}
                  opacity={ready ? (dimmed ? 0.10 : 0.65) : 0}
                  style={{ transition: "opacity 500ms var(--easing-default)" }}
                />
              );
            })}
          </g>

          {/* Nodes */}
          {preset.nodes.map((node, i) => {
            const tgt = npNodePos(node, W, H);
            const isOut = stage !== "idle" && shown > i;
            const cx = isOut ? tgt.x : 0;
            const cy = isOut ? tgt.y : 0;
            const radius = 6 + node.w * (node.ring === 0 ? 22 : 14);
            const dim = active && active !== node.id ? 0.30 : 1;
            const isPinned = pinned === node.id;

            // labels: ring 0 above; ring 1 inside outer arc; ring 2 outside
            let labelY;
            if (node.ring === 0) labelY = -(radius + 12);
            else {
              const outward = tgt.y >= 0 ? 1 : -1;
              labelY = outward * (radius + 12);
            }

            return (
              <g
                key={node.id}
                transform={`translate(${cx} ${cy})`}
                style={{
                  transition: "transform 700ms var(--easing-default), opacity 400ms var(--easing-default)",
                  opacity: isOut ? dim : 0,
                  cursor: "pointer",
                }}
                onMouseEnter={() => window.__hasHover && setHover(node.id)}
                onMouseLeave={() => window.__hasHover && setHover(null)}
                onClick={(e) => { e.stopPropagation(); setPinned(isPinned ? null : node.id); }}
              >
                {/* hit halo for easier hover on small nodes */}
                <circle r={Math.max(radius + 6, 14)} fill="transparent" />
                <NpNodeMark type={node.type} r={radius} />
                {isPinned && (
                  <circle r={radius + 4} fill="none" stroke="var(--fg)" strokeWidth={0.6} strokeDasharray="1 2" />
                )}
                {node.ring <= 1 && (
                  <text
                    textAnchor="middle"
                    y={labelY}
                    fontFamily="var(--font-mono)"
                    fontSize={10}
                    letterSpacing="0.12em"
                    fill="var(--fg)"
                    style={{ textTransform: "uppercase", pointerEvents: "none" }}
                  >
                    {node.type}
                  </text>
                )}
              </g>
            );
          })}
        </svg>

        {/* Inspector popover — follows hovered/pinned node */}
        {activeNode && stage !== "idle" && shown > orderIndex[activeNode.id] && (() => {
          const tgt = npNodePos(activeNode, W, H);
          // convert SVG coords (-W/2..W/2) → percentage of container
          const px = ((tgt.x + W/2) / W) * 100;
          const py = ((tgt.y + H/2) / H) * 100;
          const onLeft = px > 55;
          const onTop  = py > 55;
          const gloss = NP_TYPE_GLOSS[activeNode.type];
          return (
            <div
              style={{
                position: "absolute",
                left: `${px}%`,
                top: `${py}%`,
                transform: `translate(${onLeft ? "calc(-100% - 18px)" : "18px"}, ${onTop ? "calc(-100% - 18px)" : "18px"})`,
                width: 280,
                background: "var(--bg)",
                border: "1px solid var(--line)",
                padding: 14,
                pointerEvents: "none",
                boxShadow: "0 12px 28px rgba(10,10,10,0.08)",
                zIndex: 4,
              }}
            >
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 8 }}>
                <div className="micro">
                  <span style={{ textTransform: "uppercase" }}>{activeNode.type}</span>
                  <span style={{ color: "var(--fg-muted)" }}> · {t(gloss.en)}</span>
                </div>
                <div className="micro" style={{ color: "var(--fg-muted)" }}>w {activeNode.w.toFixed(2)}</div>
              </div>
              <div style={{ fontFamily: "var(--font-display)", fontStyle: "italic", fontSize: 18, lineHeight: 1.3, marginBottom: 8 }}>
                {t(activeNode.label)}
              </div>
              <div className="body-sm" style={{ color: "var(--fg-muted)", lineHeight: 1.5, marginBottom: 8 }}>
                {t(activeNode.desc)}
              </div>
              {/* weight bar */}
              <div style={{ height: 2, background: "var(--line-soft)", marginBottom: 8 }}>
                <div style={{ height: "100%", background: "var(--fg)", width: `${activeNode.w * 100}%` }} />
              </div>
              <div className="micro" style={{ color: "var(--fg-muted)", fontStyle: "italic" }}>
                {t(gloss.q)}
              </div>
            </div>
          );
        })()}
      </div>

      <div className="micro" style={{ marginTop: 16, color: "var(--fg-muted)", display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
        <span>{t("17 nodes · 1 root, 6 register parents, 10 leaves. Each node carries a type, a weight, and a one-line gloss in the inspector.")}</span>
        <span>{t("After Aquinas, De Veritate q.1 a.1")}</span>
      </div>

      <style>{`
        @media (max-width: 760px) {
          .np-input-grid { grid-template-columns: 1fr !important; }
        }
      `}</style>
    </div>
  );
}

/* ------------------------------------------------------------------ */

function ProjectDetail() {
  useLang();
  const { go } = useRouter();
  const videoRef = useRefPD(null);

  return (
    <div className="page" style={{ paddingTop: 140 }}>
      <div className="page-eyebrow">
        <button onClick={() => go("projects")} data-cursor="link" data-cursor-label={t("Back")} className="micro" style={{ display: "flex", alignItems: "center", gap: 8 }}>
          {t("← Index 03 / Projects")}
        </button>
        <span style={{ width: 24, height: 1, background: "var(--fg)" }} />
        <span className="micro micro-fg">{t("Case study · NodePrompt")}</span>
      </div>

      <div className="responsive-stack" style={{ display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 64, marginBottom: 100, alignItems: "end" }}>
        <h1 className="display-xxl">Node<span style={{ fontStyle: "italic" }}>Prompt</span></h1>
        <div>
          <div className="micro" style={{ marginBottom: 12 }}>{t("2025 — present · Co-built")}</div>
          <p className="body-lg" style={{ color: "var(--fg-muted)" }}>
            {t("A canvas environment for prompting LLMs in graphs, not chats. Each node is a step; each edge a dependency. Inspired by Mark Lombardi's diagrams of power.")}
          </p>
        </div>
      </div>

      <div className="four-col" style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 32, marginBottom: 100 }}>
        {[
          { l: "Problem", v: "Linear chat hides the shape of an investigation. Branches, retries, and dead-ends collapse into a single scroll." },
          { l: "Approach", v: "Treat prompts as nodes. Edges encode dependency. Graph state is the artifact, not the chat log." },
          { l: "Result", v: "Used internally for research planning, writing decomposition, paper-outline generation. Closed beta with a small group." },
          { l: "Stack", v: "Next.js 15 · React Three Fiber · Postgres · Lenis · Vercel. Custom shader for the node-glow." },
        ].map((s, i) => (
          <div key={i}>
            <div className="micro" style={{ marginBottom: 12 }}>{String(i + 1).padStart(2, "0")} · {t(s.l)}</div>
            <p className="body" style={{ color: "var(--fg-muted)" }}>{t(s.v)}</p>
          </div>
        ))}
      </div>

      <NodepromptDecompose />

      <div className="responsive-stack" style={{ display: "grid", gridTemplateColumns: "1fr 2fr", gap: 64, marginBottom: 60 }}>
        <div className="micro">{t("§ 01 — Why a graph")}</div>
        <div>
          <p className="body-lg" style={{ marginBottom: 20 }}>
            {t("The chat metaphor was a brilliant onboarding choice and a terrible long-term tool. It rewards ephemerality: each turn is one branch, the rest are gone. But real research is parallel.")}
          </p>
          <p className="body-lg" style={{ color: "var(--fg-muted)" }}>
            {t("On a canvas you keep the dead ends. You see, materially, where you spent attention. The prompt becomes an object you can rearrange, not a transcript you scroll past.")}
          </p>
        </div>
      </div>

      <figure style={{ margin: "0 0 100px", border: "1px solid var(--line)" }}>
        <img
          src="nodeprompt-media/sphere.png"
          alt={t("NodePrompt sphere mode — concept nodes distributed on a 3D sphere via Fibonacci lattice, connected by Bezier edges in Mark Lombardi black-and-white network aesthetic.")}
          style={{ display: "block", width: "100%", height: "auto" }}
        />
        <figcaption className="micro" style={{ display: "flex", justifyContent: "space-between", padding: "12px 16px", borderTop: "1px solid var(--line)", color: "var(--fg-muted)" }}>
          <span>{t("Fig. 03 · Sphere mode — 50+ extracted nodes laid out on a Fibonacci lattice, edges arced in Lombardi sweeps. Orbit, zoom, click to drop into a node.")}</span>
          <span>{t("Build · NODEPROMPT 2026.04")}</span>
        </figcaption>
      </figure>

      <figure style={{ margin: "0 0 100px", border: "1px solid var(--line)" }}>
        <video
          ref={videoRef}
          src="nodeprompt-media/nodeprompt-demo.mp4"
          autoPlay
          loop
          muted
          playsInline
          preload="metadata"
          style={{ display: "block", width: "100%", height: "auto", aspectRatio: "1344 / 774", background: "#000" }}
        />
        <figcaption className="micro" style={{ display: "flex", justifyContent: "space-between", padding: "12px 16px", borderTop: "1px solid var(--line)", color: "var(--fg-muted)" }}>
          <span>{t("Fig. 04 · 0:42 walkthrough — prompt enters, sphere assembles, mode switches to radial for editing, then inside the sphere via interior fisheye. Sound off.")}</span>
          <span>{t("muted, looping")}</span>
        </figcaption>
      </figure>

      <div className="responsive-stack" style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 24, marginBottom: 100 }}>
        <figure style={{ margin: 0, border: "1px solid var(--line)" }}>
          <img
            src="nodeprompt-media/radial.png"
            alt={t("NodePrompt radial mode — concept nodes laid out in concentric hierarchical rings around a central theme.")}
            style={{ display: "block", width: "100%", height: "auto" }}
          />
          <figcaption className="micro" style={{ padding: "12px 16px", borderTop: "1px solid var(--line)", color: "var(--fg-muted)" }}>
            {t("Fig. 05 · Radial mode — 2D editing surface. Concentric rings encode hierarchy depth; drag to reposition, scroll to reweight, shift-click to wire a new edge.")}
          </figcaption>
        </figure>
        <figure style={{ margin: 0, border: "1px solid var(--line)" }}>
          <img
            src="nodeprompt-media/radial-inspector.png"
            alt={t("NodePrompt inspector — node weights, types, and relationships exposed in a sidebar.")}
            style={{ display: "block", width: "100%", height: "auto" }}
          />
          <figcaption className="micro" style={{ padding: "12px 16px", borderTop: "1px solid var(--line)", color: "var(--fg-muted)" }}>
            {t("Fig. 06 · Inspector — type, weight, parent, and relations exposed for a single node. The same surface the demo above borrows from.")}
          </figcaption>
        </figure>
      </div>

      <div className="responsive-stack" style={{ display: "grid", gridTemplateColumns: "1fr 2fr", gap: 64, marginBottom: 100 }}>
        <div className="micro">{t("§ 02 — On Mark Lombardi")}</div>
        <div>
          <p className="body-lg" style={{ marginBottom: 20 }}>
            {t("Mark Lombardi drew financial conspiracies in pencil — flows of money and influence as node-and-edge diagrams. Beautiful, illegible at full scale, undeniable up close.")}
          </p>
          <p className="body-lg" style={{ color: "var(--fg-muted)" }}>
            {t("NodePrompt borrows this aesthetic and the political sensibility behind it: that information has a topology, that the topology matters, and that drawing it is a form of argument.")}
          </p>
        </div>
      </div>

      <div className="responsive-stack" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24, marginBottom: 80 }}>
        <button onClick={() => go("projects")} data-cursor="link" data-cursor-label={t("Back")} style={{ padding: "32px", border: "1px solid var(--line)", textAlign: "left" }}>
          <div className="micro" style={{ marginBottom: 8, color: "var(--fg-muted)" }}>{t("← Previous")}</div>
          <div className="display-md">{t("Index of all projects")}</div>
        </button>
        <button onClick={() => go("research")} data-cursor="link" data-cursor-label={t("Next")} style={{ padding: "32px", border: "1px solid var(--line)", textAlign: "right", background: "var(--fg)", color: "var(--bg)" }}>
          <div className="micro" style={{ marginBottom: 8, color: "rgba(255,255,255,0.5)" }}>{t("Next →")}</div>
          <div className="display-md">{t("Research · Spintronics")}</div>
        </button>
      </div>
    </div>
  );
}

Object.assign(window, { ProjectDetail, NodepromptDecompose });
