// AUTO-GENERATED for the local app from tpm-workflow.jsx. Do not edit by hand.
const { useState, useEffect, useRef } = React;
const mammoth = window.mammoth;


// AI calls go straight to Anthropic inside the Claude artifact (handled for us),
// but to the local broker's /ai proxy when running as a standalone local app.
// The local index.html sets these globals before this script loads.
const AI_ENDPOINT = (typeof window !== "undefined" && window.__AI_ENDPOINT__) || "https://api.anthropic.com/v1/messages";
const BROKER_BASE = (typeof window !== "undefined" && window.__BROKER_URL__) || "";
const IS_LOCAL_APP = typeof window !== "undefined" && !!window.__LOCAL_APP__;

// All broker calls carry the session cookie (credentials: include). A 401 means
// the session is gone; broadcast it so the app drops back to the sign-in screen.
function api(path, { method = "GET", body } = {}) {
  const headers = { "Content-Type": "application/json" };
  return fetch(`${BROKER_BASE}${path}`, {
    method, headers, credentials: "include",
    body: body != null ? JSON.stringify(body) : undefined,
  }).then(async r => {
    const d = await r.json().catch(() => ({}));
    if (r.status === 401 && typeof window !== "undefined") window.dispatchEvent(new Event("cadenly:unauth"));
    if (!r.ok) throw new Error(d.error || `request failed (${r.status})`);
    return d;
  });
}

// ══════════════════════════════════════════════════════════════════════════════
// AIAD · TPM Daily Workflow  — sibling tool to the AIAD BA pipeline.
// Same design language. Daily loop (not a one-pass pipeline). Session-only state,
// persisted via manual Save / Load Session (JSON) — the Claude artifact sandbox
// blocks localStorage, so there is no auto-save. Jira is the cross-day memory:
// import the board each morning, push changes back out as a Jira-import CSV.
// All system boundaries are files: Jira CSV/JSON in, .ics out, Jira CSV out.
// ══════════════════════════════════════════════════════════════════════════════

// ── Cadenly brand logo ───────────────────────────────────────────────
// A four-bar cadence mark — a 4/4 delivery beat building to the downbeat (the
// ship) — next to the Syne wordmark. Pure SVG so it's crisp at any size and
// theme-aware: the bars track --accent and the word tracks --text, so it sits
// natively in light/dark and recolors automatically if the accent is changed.
// Pass mark={true} to render just the mark (favicon / compact spots).
function CadenlyLogo({ h = 22, mark = false, href = "https://cadenly.io" }) {
  const svg = React.createElement("svg",
    { height: h, width: h, viewBox: "0 0 28 28", fill: "none", "aria-label": "Cadenly",
      style: { display: "block", flex: "none" } },
    React.createElement("rect", { x: 1,  y: 6,  width: 4.6, height: 21, rx: 2.3, fill: "var(--accent)", opacity: .85 }),
    React.createElement("rect", { x: 8,  y: 16, width: 4.6, height: 11, rx: 2.3, fill: "var(--accent)", opacity: .5 }),
    React.createElement("rect", { x: 15, y: 11, width: 4.6, height: 16, rx: 2.3, fill: "var(--accent)", opacity: .68 }),
    React.createElement("rect", { x: 22, y: 2,  width: 4.6, height: 25, rx: 2.3, fill: "var(--accent)" })
  );
  if (mark) return svg;
  const inner = React.createElement("span",
    { style: { display: "inline-flex", alignItems: "center", gap: Math.round(h * 0.32), verticalAlign: "middle" } },
    svg,
    React.createElement("span",
      { style: { fontFamily: "var(--font-ui)", fontWeight: 800, fontSize: Math.round(h * 0.82), letterSpacing: "-.02em", color: "var(--text)", lineHeight: 1 } },
      "Cadenly")
  );
  if (!href) return inner;
  return React.createElement("a",
    { href, "aria-label": "Cadenly — back to cadenly.io",
      style: { textDecoration: "none", color: "inherit", display: "inline-flex", alignItems: "center" } },
    inner
  );
}

// ── Fonts ───────────────────────────────────────────────────────────────────
const FL = document.createElement("link");
FL.rel = "stylesheet";
FL.href = "https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;600;700;800&display=swap";
document.head.appendChild(FL);

// ── Global CSS (shared with AIAD) ─────────────────────────────────────────────
const GS = document.createElement("style");
GS.textContent = `
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
  :root{
    --bg:#f0f2f8;--surface:#ffffff;--panel:#ffffff;--border:#dde1ee;
    --accent:#C8001F;--ok:#00a371;--accent2:#0055cc;--warn:#d63354;--gold:#b07d00;
    --purple:#6d28d9;--text:#1a1d2e;--muted:#6b7280;--shadow:rgba(0,0,0,.07);
    --st1:#7fb0ff;--st2:#74d0e3;--st3:#7fd6c9;--st4:#86d99e;--st5:#bfe08a;--st6:#f0d27a;
    --st7:#f7b98a;--st8:#f5a3a3;--st9:#f3a8c8;--st10:#d3b0f5;--st11:#c4b6f5;--st12:#aeb6f0;
    --grid-line:rgba(200,0,31,.025);--header-bg:rgba(240,242,248,.95);
    --font-ui:'Syne',sans-serif;--font-mono:'Space Mono',monospace;
  }
  html[data-theme="dark"]{
    --bg:#07080d;--surface:#0e1017;--panel:#13161f;--border:#1e2333;
    --accent:#E4002B;--ok:#00e5a0;--accent2:#006aff;--warn:#ff4d6d;--gold:#f5c542;
    --purple:#a855f7;--text:#e8eaf2;--muted:#8b92ad;--shadow:rgba(0,0,0,.35);
    --st1:#7fb0ff;--st2:#74d0e3;--st3:#7fd6c9;--st4:#86d99e;--st5:#bfe08a;--st6:#f0d27a;
    --st7:#f7b98a;--st8:#f5a3a3;--st9:#f3a8c8;--st10:#d3b0f5;--st11:#c4b6f5;--st12:#aeb6f0;
    --grid-line:rgba(228,0,43,.02);--header-bg:rgba(7,8,13,.92);
    --font-ui:'Syne',sans-serif;--font-mono:'Space Mono',monospace;
  }
  html[data-theme="light"]{
    --bg:#f0f2f8;--surface:#ffffff;--panel:#ffffff;--border:#dde1ee;
    --accent:#C8001F;--ok:#00a371;--accent2:#0055cc;--warn:#d63354;--gold:#b07d00;
    --purple:#6d28d9;--text:#1a1d2e;--muted:#6b7280;--shadow:rgba(0,0,0,.07);
    --st1:#7fb0ff;--st2:#74d0e3;--st3:#7fd6c9;--st4:#86d99e;--st5:#bfe08a;--st6:#f0d27a;
    --st7:#f7b98a;--st8:#f5a3a3;--st9:#f3a8c8;--st10:#d3b0f5;--st11:#c4b6f5;--st12:#aeb6f0;
    --grid-line:rgba(200,0,31,.025);--header-bg:rgba(240,242,248,.95);
    --font-ui:'Syne',sans-serif;--font-mono:'Space Mono',monospace;
  }
  body{background:var(--bg);color:var(--text);font-family:var(--font-ui);overflow-x:hidden;
    transition:background .22s,color .22s}
  ::-webkit-scrollbar{width:4px;height:4px}
  ::-webkit-scrollbar-track{background:var(--bg)}
  ::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
  @keyframes fadeUp{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}
  @keyframes spin{to{transform:rotate(360deg)}}
  @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.7)}}
  @keyframes slideIn{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:translateX(0)}}
  .fade-up{animation:fadeUp .4s ease both}
  .slide-in{animation:slideIn .3s ease both}

  .tag{display:inline-flex;align-items:center;gap:5px;font-family:var(--font-mono);font-size:10px;
    letter-spacing:.08em;padding:3px 8px;border-radius:3px;text-transform:uppercase}
  .tag-green{background:rgba(0,229,160,.12);color:var(--ok);border:1px solid rgba(0,229,160,.25)}
  .tag-red{background:rgba(255,77,109,.12);color:var(--warn);border:1px solid rgba(255,77,109,.25)}
  .tag-blue{background:rgba(0,106,255,.12);color:var(--accent2);border:1px solid rgba(0,106,255,.25)}
  .tag-gold{background:rgba(245,197,66,.1);color:var(--gold);border:1px solid rgba(245,197,66,.25)}
  .tag-purple{background:rgba(168,85,247,.12);color:var(--purple);border:1px solid rgba(168,85,247,.25)}
  .tag-muted{background:rgba(90,96,128,.15);color:var(--muted);border:1px solid var(--border)}
  [data-theme="light"] .tag-green{background:rgba(0,163,113,.1);border-color:rgba(0,163,113,.3)}
  [data-theme="light"] .tag-red{background:rgba(214,51,84,.08);border-color:rgba(214,51,84,.25)}
  [data-theme="light"] .tag-blue{background:rgba(0,85,204,.08);border-color:rgba(0,85,204,.25)}
  [data-theme="light"] .tag-gold{background:rgba(176,125,0,.08);border-color:rgba(176,125,0,.25)}
  [data-theme="light"] .tag-purple{background:rgba(109,40,217,.08);border-color:rgba(109,40,217,.25)}
  [data-theme="light"] .tag-muted{background:rgba(107,114,128,.1)}

  .btn{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-ui);font-weight:700;
    font-size:12px;padding:9px 18px;border-radius:6px;border:none;cursor:pointer;
    transition:all .2s;letter-spacing:.04em;text-transform:uppercase}
  .btn-primary{background:var(--accent);color:#fff;box-shadow:0 0 20px rgba(228,0,43,.2)}
  [data-theme="dark"] .btn-primary{color:#fff}
  .btn-primary:hover{box-shadow:0 0 32px rgba(228,0,43,.35);transform:translateY(-1px)}
  .btn-primary:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none}
  .btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none;pointer-events:none}
  .btn-secondary{background:transparent;color:var(--text);border:1px solid var(--border)}
  .btn-secondary:hover{border-color:var(--accent);color:var(--accent)}
  .btn-ghost{background:rgba(200,0,31,.08);color:var(--accent);border:1px solid rgba(200,0,31,.3)}
  .btn-ghost:hover{background:rgba(200,0,31,.15)}
  .btn-warn{background:rgba(255,77,109,.12);color:var(--warn);border:1px solid rgba(255,77,109,.25)}
  .btn-warn:hover{background:rgba(255,77,109,.22)}
  .btn-sm{font-size:10px;padding:6px 12px}

  textarea,input[type=text],input[type=date],input[type=time],select{
    background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;
    font-family:var(--font-mono);font-size:12px;padding:10px 12px;width:100%;
    resize:vertical;outline:none;transition:border-color .2s,background .22s}
  textarea:focus,input:focus,select:focus{border-color:var(--accent)}
  select{resize:none;cursor:pointer}
  .card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:20px;
    transition:background .22s,border-color .22s;box-shadow:0 2px 8px var(--shadow)}
  .card-hd{display:flex;align-items:center;justify-content:space-between;
    margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid var(--border)}
  .card-title{font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
  .spinner{width:13px;height:13px;border-radius:50%;border:2px solid rgba(228,0,43,.2);
    border-top-color:var(--accent);animation:spin .7s linear infinite;flex-shrink:0}
  .dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
  .dot-green{background:var(--ok);animation:pulse 2s infinite}
  .dot-red{background:var(--warn);animation:pulse 1.5s infinite}
  .dot-gold{background:var(--gold);animation:pulse 2s infinite .3s}
  .dot-muted{background:var(--muted)}
  .empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;
    padding:60px 20px;text-align:center;gap:12px}

  .kcol{flex:0 0 186px;background:var(--bg);border:1px solid var(--border);border-radius:10px;
    padding:12px;display:flex;flex-direction:column;gap:8px}
  .kcol-hd{display:flex;align-items:center;justify-content:space-between;font-size:10px;font-weight:800;
    letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-family:var(--font-mono);
    padding-bottom:8px;border-bottom:1px solid var(--border)}
  .kcard{background:var(--panel);border:1px solid var(--border);border-radius:7px;padding:10px;
    cursor:default;transition:border-color .2s}
  .kcard:hover{border-color:var(--accent)}

  .theme-toggle{display:flex;align-items:center;gap:8px;padding:5px 11px;border-radius:20px;
    border:1px solid var(--border);background:var(--panel);cursor:pointer;transition:all .2s;
    font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.06em;
    text-transform:uppercase;color:var(--muted);user-select:none}
  .theme-toggle:hover{border-color:var(--accent);color:var(--accent)}
  .tt-track{width:30px;height:16px;border-radius:8px;position:relative;transition:background .22s;flex-shrink:0}
  .tt-thumb{position:absolute;top:2px;width:12px;height:12px;border-radius:50%;
    background:#fff;transition:left .22s;box-shadow:0 1px 3px rgba(0,0,0,.25)}
`;
document.head.appendChild(GS);

// ── Helpers ─────────────────────────────────────────────────────────────────
const uid = () => (typeof crypto !== "undefined" && crypto.randomUUID)
  ? crypto.randomUUID() : `i_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;

function download(filename, text, mime = "text/plain") {
  const blob = new Blob([text], { type: mime });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url; a.download = filename;
  document.body.appendChild(a); a.click();
  document.body.removeChild(a); URL.revokeObjectURL(url);
}

// ── Markdown renderer (dependency-free) ───────────────────────────────────────
// Handles the subset these docs use: headings, bold/italic/inline-code,
// fenced code blocks, bullet/numbered lists, tables, blockquotes, hr, paragraphs.
function mdInline(text, keyBase) {
  // Split on inline code first (so ** inside ` ` isn't treated as bold), then bold, then italic.
  const out = []; let rest = String(text ?? ""); let k = 0;
  const re = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|__[^_]+__|_[^_]+_)/;
  while (rest.length) {
    const m = rest.match(re);
    if (!m) { out.push(rest); break; }
    if (m.index > 0) out.push(rest.slice(0, m.index));
    const tok = m[0];
    if (tok.startsWith("`")) out.push(<code key={`${keyBase}-${k++}`} style={{ fontFamily: "var(--font-mono)", fontSize: ".92em", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 4, padding: "1px 5px" }}>{tok.slice(1, -1)}</code>);
    else if (tok.startsWith("**") || tok.startsWith("__")) out.push(<strong key={`${keyBase}-${k++}`}>{tok.slice(2, -2)}</strong>);
    else out.push(<em key={`${keyBase}-${k++}`}>{tok.slice(1, -1)}</em>);
    rest = rest.slice(m.index + tok.length);
  }
  return out;
}
function Markdown({ text }) {
  const lines = String(text ?? "").replace(/\r\n/g, "\n").split("\n");
  const blocks = []; let i = 0;
  const flushList = (items, ordered) => blocks.push(ordered
    ? <ol key={`b${blocks.length}`} style={{ margin: "6px 0", paddingLeft: 22, display: "flex", flexDirection: "column", gap: 3 }}>{items.map((it, j) => <li key={j} style={{ fontSize: 13, lineHeight: 1.55 }}>{mdInline(it, `li${blocks.length}-${j}`)}</li>)}</ol>
    : <ul key={`b${blocks.length}`} style={{ margin: "6px 0", paddingLeft: 20, display: "flex", flexDirection: "column", gap: 3 }}>{items.map((it, j) => <li key={j} style={{ fontSize: 13, lineHeight: 1.55 }}>{mdInline(it, `li${blocks.length}-${j}`)}</li>)}</ul>);
  while (i < lines.length) {
    let ln = lines[i];
    // fenced code block
    if (/^```/.test(ln.trim())) {
      const lang = ln.trim().replace(/^```/, "").trim().toLowerCase();
      const buf = []; i++;
      while (i < lines.length && !/^```/.test(lines[i].trim())) { buf.push(lines[i]); i++; }
      i++; // skip closing fence
      if (lang === "mermaid") {
        blocks.push(<div key={`b${blocks.length}`} style={{ margin: "10px 0" }}><MermaidDiagram code={buf.join("\n")} /></div>);
      } else {
        blocks.push(<pre key={`b${blocks.length}`} style={{ fontFamily: "var(--font-mono)", fontSize: 11.5, lineHeight: 1.5, background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 14px", overflowX: "auto", margin: "8px 0", whiteSpace: "pre" }}>{buf.join("\n")}</pre>);
      }
      continue;
    }
    // heading
    const h = ln.match(/^(#{1,6})\s+(.*)$/);
    if (h) {
      const lvl = h[1].length, sizes = [0, 19, 16, 14, 13, 12, 11];
      blocks.push(<div key={`b${blocks.length}`} style={{ fontSize: sizes[lvl], fontWeight: 800, lineHeight: 1.3, margin: lvl <= 2 ? "16px 0 6px" : "12px 0 4px", color: "var(--text)", borderBottom: lvl <= 2 ? "1px solid var(--border)" : "none", paddingBottom: lvl <= 2 ? 4 : 0 }}>{mdInline(h[2], `h${blocks.length}`)}</div>);
      i++; continue;
    }
    // horizontal rule
    if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(ln.trim())) { blocks.push(<hr key={`b${blocks.length}`} style={{ border: "none", borderTop: "1px solid var(--border)", margin: "12px 0" }} />); i++; continue; }
    // table (header row + separator)
    if (/\|/.test(ln) && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && /-/.test(lines[i + 1])) {
      const parseRow = r => r.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map(c => c.trim());
      const headers = parseRow(ln); i += 2; const rows = [];
      while (i < lines.length && /\|/.test(lines[i]) && lines[i].trim()) { rows.push(parseRow(lines[i])); i++; }
      blocks.push(
        <div key={`b${blocks.length}`} style={{ overflowX: "auto", margin: "8px 0" }}>
          <table style={{ borderCollapse: "collapse", width: "100%", fontSize: 12 }}>
            <thead><tr>{headers.map((hd, j) => <th key={j} style={{ textAlign: "left", padding: "6px 10px", borderBottom: "2px solid var(--border)", fontWeight: 700 }}>{mdInline(hd, `th${j}`)}</th>)}</tr></thead>
            <tbody>{rows.map((r, ri) => <tr key={ri}>{r.map((c, ci) => <td key={ci} style={{ padding: "6px 10px", borderBottom: "1px solid var(--border)", verticalAlign: "top" }}>{mdInline(c, `td${ri}-${ci}`)}</td>)}</tr>)}</tbody>
          </table>
        </div>);
      continue;
    }
    // blockquote
    if (/^>\s?/.test(ln)) {
      const buf = [];
      while (i < lines.length && /^>\s?/.test(lines[i])) { buf.push(lines[i].replace(/^>\s?/, "")); i++; }
      blocks.push(<div key={`b${blocks.length}`} style={{ borderLeft: "3px solid var(--border)", padding: "2px 0 2px 12px", margin: "8px 0", color: "var(--muted)", fontSize: 13 }}>{mdInline(buf.join(" "), `bq${blocks.length}`)}</div>);
      continue;
    }
    // unordered list
    if (/^\s*[-*+]\s+/.test(ln)) {
      const items = [];
      while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*+]\s+/, "")); i++; }
      flushList(items, false); continue;
    }
    // ordered list
    if (/^\s*\d+\.\s+/.test(ln)) {
      const items = [];
      while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+\.\s+/, "")); i++; }
      flushList(items, true); continue;
    }
    // blank line
    if (!ln.trim()) { i++; continue; }
    // paragraph (gather consecutive non-blank, non-special lines)
    const buf = [ln]; i++;
    while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|\s*[-*+]\s|\s*\d+\.\s|>\s?|(-{3,}|\*{3,}|_{3,})\s*$)/.test(lines[i]) && !/\|/.test(lines[i])) { buf.push(lines[i]); i++; }
    blocks.push(<p key={`b${blocks.length}`} style={{ fontSize: 13, lineHeight: 1.6, margin: "6px 0" }}>{mdInline(buf.join(" "), `p${blocks.length}`)}</p>);
  }
  return <div>{blocks}</div>;
}

// ── Mermaid diagram renderer ──────────────────────────────────────────────────
// Renders a Mermaid graph definition to SVG via the vendored window.mermaid.
// Falls back to showing the code if the library isn't loaded or render fails.
let __mermaidInit = false;
function MermaidDiagram({ code }) {
  const ref = useRef(null);
  const [err, setErr] = useState("");
  const idRef = useRef("mmd-" + Math.random().toString(36).slice(2));
  useEffect(() => {
    const M = window.mermaid;
    if (!M) { setErr("nolib"); return; }
    let cancelled = false;
    const theme = (document.documentElement.getAttribute("data-theme") === "dark") ? "dark" : "default";
    try {
      M.initialize({ startOnLoad: false, securityLevel: "loose", theme, flowchart: { useMaxWidth: true, htmlLabels: true, nodeSpacing: 45, rankSpacing: 65, curve: "basis" } });
      __mermaidInit = true;
      const clean = String(code || "").trim();
      if (!clean) { setErr("empty"); return; }
      M.render(idRef.current, clean).then(({ svg }) => {
        if (!cancelled && ref.current) { ref.current.innerHTML = svg; setErr(""); }
      }).catch(e => { if (!cancelled) setErr(e?.message || "render error"); });
    } catch (e) { setErr(e?.message || "render error"); }
    return () => { cancelled = true; };
  }, [code]);

  if (err === "nolib") {
    return (
      <div>
        <div style={{ fontSize: 11, color: "var(--gold)", marginBottom: 8 }}>Mermaid isn't loaded — showing the diagram source. Add <code>/vendor/mermaid.js</code> to index.html to render it.</div>
        <pre style={{ fontFamily: "var(--font-mono)", fontSize: 11.5, lineHeight: 1.5, background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 14px", overflowX: "auto", whiteSpace: "pre" }}>{code}</pre>
      </div>
    );
  }
  return (
    <div>
      <div ref={ref} style={{ width: "100%", overflowX: "auto", textAlign: "center" }} />
      {err && err !== "empty" && (
        <div>
          <div style={{ fontSize: 11, color: "var(--warn)", margin: "8px 0" }}>Couldn't render the diagram ({err}). Source below:</div>
          <pre style={{ fontFamily: "var(--font-mono)", fontSize: 11.5, lineHeight: 1.5, background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 14px", overflowX: "auto", whiteSpace: "pre" }}>{code}</pre>
        </div>
      )}
    </div>
  );
}

// ── Mermaid diagram export (SVG + PNG), dependency-free ───────────────────────
// Render the Mermaid source to a standalone SVG string (off-screen), then either
// download it directly (.svg) or rasterize it onto a canvas for a .png.
// Render Mermaid source to an SVG string. `forRaster` uses native SVG text
// labels (htmlLabels:false) so the result can be drawn onto a canvas without
// tainting it — <foreignObject>/HTML labels break canvas export in browsers.
async function renderMermaidToSVG(code, forRaster = false) {
  const M = window.mermaid;
  if (!M) throw new Error("Mermaid isn't loaded — add /vendor/mermaid.js to index.html.");
  const theme = (document.documentElement.getAttribute("data-theme") === "dark") ? "dark" : "default";
  M.initialize({ startOnLoad: false, securityLevel: "loose", theme, flowchart: { useMaxWidth: false, htmlLabels: !forRaster, nodeSpacing: 45, rankSpacing: 65, curve: "basis" } });
  const id = "mmd-export-" + Math.random().toString(36).slice(2);
  const { svg } = await M.render(id, String(code || "").trim());
  return svg;
}
// Pull width/height from the SVG (attrs first, then viewBox). Returns {w,h,svg}
// with explicit width/height attributes injected so <img> can size it.
function sizeSVG(svg) {
  let w = 0, h = 0;
  const vb = svg.match(/viewBox="\s*[\d.eE+-]+\s+[\d.eE+-]+\s+([\d.eE+-]+)\s+([\d.eE+-]+)"/);
  const wm = svg.match(/<svg[^>]*\swidth="([\d.]+)(?:px)?"/);
  const hm = svg.match(/<svg[^>]*\sheight="([\d.]+)(?:px)?"/);
  if (vb) { w = parseFloat(vb[1]); h = parseFloat(vb[2]); }
  if (wm) w = parseFloat(wm[1]); if (hm) h = parseFloat(hm[1]);
  if (!w || !h) { w = w || 1200; h = h || 800; }
  // Inject/normalize explicit pixel width+height on the root <svg>.
  let out = svg.replace(/(<svg[^>]*?)\swidth="[^"]*"/, "$1").replace(/(<svg[^>]*?)\sheight="[^"]*"/, "$1");
  out = out.replace(/<svg/, `<svg width="${w}" height="${h}"`);
  return { w, h, svg: out };
}
function withWhiteBg(svg) {
  return svg.replace(/(<svg[^>]*>)/, '$1<rect x="0" y="0" width="100%" height="100%" fill="#ffffff"/>');
}
function downloadSVGString(svg, filename) {
  download(filename, withWhiteBg(svg), "image/svg+xml");
}
async function exportDiagramSVG(code, filename) {
  const svg = await renderMermaidToSVG(code, false);   // crisp on-screen-style SVG is fine as a file
  downloadSVGString(svg, filename);
}
async function exportDiagramPNG(code, filename, scale = 2) {
  const raw = await renderMermaidToSVG(code, true);     // raster-safe: native text labels
  const { w, h, svg } = sizeSVG(raw);
  const svgWithBg = withWhiteBg(svg);
  // Encode as a base64 data URL (unicode-safe) — more reliable than blob URLs
  // for loading SVG into an <img> across browsers.
  const dataUrl = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgWithBg)));
  await new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      try {
        const canvas = document.createElement("canvas");
        canvas.width = Math.max(1, Math.ceil(w * scale));
        canvas.height = Math.max(1, Math.ceil(h * scale));
        const ctx = canvas.getContext("2d");
        ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
        canvas.toBlob(b => {
          if (!b) return reject(new Error("PNG encode failed (canvas may be tainted)."));
          const a = document.createElement("a");
          a.href = URL.createObjectURL(b); a.download = filename;
          document.body.appendChild(a); a.click(); a.remove();
          setTimeout(() => URL.revokeObjectURL(a.href), 1000);
          resolve();
        }, "image/png");
      } catch (e) { reject(new Error("PNG export failed: " + (e?.message || e))); }
    };
    img.onerror = () => reject(new Error("Couldn't rasterize the diagram SVG."));
    img.src = dataUrl;
  });
}

// ── Dependency-free PDF (single image page) ───────────────────────────────────
// Builds a real .pdf byte-by-byte with a JPEG-embedded diagram + a title line.
// No external library — ported from the AIAD flowchart export.
function pdfTranslit(s) {
  return String(s ?? "")
    .replace(/[—–]/g, "-").replace(/[•·]/g, "-").replace(/→/g, "->").replace(/←/g, "<-")
    .replace(/[“”]/g, '"').replace(/[‘’]/g, "'").replace(/…/g, "...")
    .replace(/✓/g, "[x]").replace(/✗/g, "[ ]").replace(/⚠/g, "!").replace(/≤/g, "<=").replace(/≥/g, ">=")
    .replace(/[^\x20-\x7E]/g, "");
}
function pdfCharW(ch) { return ch === " " ? 278 : "iIl.,:;'|!".includes(ch) ? 250 : "mwMW".includes(ch) ? 830 : 540; }
function pdfTextW(s, size) { let w = 0; for (const ch of s) w += pdfCharW(ch); return w / 1000 * size; }
function pdfWrap(s, size, maxW) {
  const words = pdfTranslit(s).split(/\s+/); const lines = []; let cur = "";
  for (const word of words) {
    const t = cur ? cur + " " + word : word;
    if (pdfTextW(t, size) > maxW && cur) { lines.push(cur); cur = word; }
    else cur = t;
  }
  if (cur) lines.push(cur);
  return lines.length ? lines : [""];
}
function pdfEsc(s) {
  return pdfTranslit(s).replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
}
function buildBinaryPDF(objects) {
  const enc = new TextEncoder();
  const chunks = []; let length = 0;
  const push = (x) => { const u = typeof x === "string" ? enc.encode(x) : x; chunks.push(u); length += u.length; };
  const offsets = [];
  push("%PDF-1.4\n");
  push(new Uint8Array([0x25, 0xE2, 0xE3, 0xCF, 0xD3, 0x0A])); // binary marker
  const sorted = [...objects].sort((a, b) => a.id - b.id);
  for (const o of sorted) {
    offsets[o.id] = length;
    push(`${o.id} 0 obj\n`);
    for (const p of o.parts) push(p);
    push("\nendobj\n");
  }
  const xref = length, count = sorted.length + 1;
  push(`xref\n0 ${count}\n0000000000 65535 f \n`);
  for (let i = 1; i < count; i++) push(String(offsets[i] || 0).padStart(10, "0") + " 00000 n \n");
  push(`trailer\n<< /Size ${count} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`);
  const out = new Uint8Array(length); let p = 0;
  for (const c of chunks) { out.set(c, p); p += c.length; }
  return out;
}
function genImagePDF({ title, subtitle, jpeg, pxW, pxH }) {
  const PT = 0.75, MAXD = 14000, TITLE = 42, MGN = 24;
  let cw = pxW * PT / 2, ch = pxH * PT / 2;       // raster is 2× → back to ~logical points
  const aspect = cw / ch, maxC = MAXD - 2 * MGN;
  if (cw > maxC || ch > maxC) { if (cw >= ch) { cw = maxC; ch = cw / aspect; } else { ch = maxC; cw = ch * aspect; } }
  const pageW = cw + 2 * MGN, pageH = ch + 2 * MGN + TITLE;
  const content =
    `q\n${cw.toFixed(2)} 0 0 ${ch.toFixed(2)} ${MGN} ${MGN} cm\n/Im0 Do\nQ\n` +
    `BT /FR 15 Tf ${MGN} ${(pageH - 27).toFixed(1)} Td (${pdfEsc(title)}) Tj ET\n` +
    `BT /FR 9 Tf ${MGN} ${(pageH - 39).toFixed(1)} Td (${pdfEsc(subtitle || "")}) Tj ET`;
  return buildBinaryPDF([
    { id: 1, parts: ["<< /Type /Catalog /Pages 2 0 R >>"] },
    { id: 2, parts: ["<< /Type /Pages /Kids [3 0 R] /Count 1 >>"] },
    { id: 3, parts: [`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageW.toFixed(2)} ${pageH.toFixed(2)}] /Resources << /XObject << /Im0 5 0 R >> /Font << /FR 6 0 R >> >> /Contents 4 0 R >>`] },
    { id: 4, parts: [`<< /Length ${content.length} >>\nstream\n${content}\nendstream`] },
    { id: 5, parts: [`<< /Type /XObject /Subtype /Image /Width ${pxW} /Height ${pxH} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.length} >>\nstream\n`, jpeg, "\nendstream"] },
    { id: 6, parts: ["<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"] },
  ]);
}

// ── Dependency-free TEXT PDF (multi-page: headings, text, key-value, tables) ───
function genPDF({ title, subtitle, blocks }) {
  const PW = 612, PH = 792, M = 48, maxW = PW - 2 * M;
  const pages = []; let ops = []; let y = PH - M;
  const newPage = () => { pages.push(ops); ops = []; y = PH - M; };
  const ensure = (h) => { if (y - h < M) newPage(); };
  const T = (t, x, yy, size, bold) => ops.push(`BT /${bold ? "FB" : "FR"} ${size} Tf ${x} ${yy} Td (${pdfEsc(t)}) Tj ET`);
  const L = (x1, y1, x2, y2) => ops.push(`${x1} ${y1.toFixed(1)} m ${x2} ${y2.toFixed(1)} l S`);
  T(title || "Export", M, y, 18, true); y -= 22;
  if (subtitle) { T(subtitle, M, y, 10, false); y -= 14; }
  L(M, y, PW - M, y); y -= 18;
  for (const b of blocks || []) {
    if (b.type === "heading") { ensure(28); y -= 4; T(b.text, M, y, 13, true); y -= 18; }
    else if (b.type === "subheading") { ensure(20); T(b.text, M, y, 11, true); y -= 15; }
    else if (b.type === "text") {
      for (const ln of pdfWrap(b.text, 10, maxW)) { ensure(14); T(ln, M, y, 10, false); y -= 13; } y -= 4;
    }
    else if (b.type === "kv") {
      const k = (b.key || "") + ":  "; const kw = pdfTextW(k, 10);
      const v = pdfWrap(b.value, 10, Math.max(60, maxW - kw));
      ensure(14); T(k, M, y, 10, true); T(v[0] || "", M + kw, y, 10, false); y -= 13;
      for (let i = 1; i < v.length; i++) { ensure(14); T(v[i], M + kw, y, 10, false); y -= 13; }
    }
    else if (b.type === "table") {
      const cols = b.columns, n = cols.length;
      const widths = b.widths || cols.map(() => maxW / n);
      const totalW = widths.reduce((a, c) => a + c, 0);
      const row = (cells, bold) => {
        const cl = cells.map((c, ci) => pdfWrap(c, 9, widths[ci] - 8));
        const rowH = Math.max(...cl.map(l => l.length)) * 11 + 6;
        ensure(rowH);
        let x = M;
        for (let ci = 0; ci < n; ci++) {
          let yy = y - 12;
          for (const ln of cl[ci]) { T(ln, x + 4, yy, 9, bold); yy -= 11; }
          x += widths[ci];
        }
        L(M, y - rowH, M + totalW, y - rowH); y -= rowH;
      };
      L(M, y, M + totalW, y);
      row(cols, true);
      for (const r of b.rows) row(r, false);
      y -= 8;
    }
    else if (b.type === "spacer") { y -= (b.h || 10); }
  }
  newPage();
  const objs = []; let id = 5; const pageIds = [];
  for (const pageOps of pages) {
    const stream = pageOps.join("\n");
    const cId = id++, pId = id++;
    objs.push({ id: cId, body: `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream` });
    objs.push({ id: pId, body: `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PW} ${PH}] /Resources << /Font << /FR 3 0 R /FB 4 0 R >> >> /Contents ${cId} 0 R >>` });
    pageIds.push(pId);
  }
  const all = [
    { id: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" },
    { id: 2, body: `<< /Type /Pages /Kids [${pageIds.map(p => p + " 0 R").join(" ")}] /Count ${pageIds.length} >>` },
    { id: 3, body: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>" },
    { id: 4, body: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>" },
    ...objs,
  ].sort((a, b) => a.id - b.id);
  let pdf = "%PDF-1.4\n"; const offsets = [];
  for (const o of all) { offsets[o.id] = pdf.length; pdf += `${o.id} 0 obj\n${o.body}\nendobj\n`; }
  const xref = pdf.length, count = all.length + 1;
  pdf += `xref\n0 ${count}\n0000000000 65535 f \n`;
  for (let i = 1; i < count; i++) pdf += String(offsets[i] || 0).padStart(10, "0") + " 00000 n \n";
  pdf += `trailer\n<< /Size ${count} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`;
  return pdf;
}

// Rasterize a Mermaid diagram to JPEG bytes for embedding in a PDF.
// Mirrors the proven flowchart raster path (raster-safe SVG → canvas → JPEG).
async function rasterizeMermaidToJPEG(code, scale = 2) {
  const raw = await renderMermaidToSVG(code, true);     // native text labels (canvas-safe)
  const { w, h, svg } = sizeSVG(raw);
  const svgBg = withWhiteBg(svg);
  const src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgBg)));
  const img = new Image(); img.decoding = "async";
  img.src = src;
  await (img.decode ? img.decode() : new Promise((res, rej) => { img.onload = res; img.onerror = rej; }));
  const canvas = document.createElement("canvas");
  canvas.width = Math.max(1, Math.round(w * scale));
  canvas.height = Math.max(1, Math.round(h * scale));
  const ctx = canvas.getContext("2d");
  ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
  const durl = canvas.toDataURL("image/jpeg", 0.92);
  if (!durl.startsWith("data:image/jpeg")) throw new Error("rasterization blocked");
  const b = atob(durl.split(",")[1]); const jpeg = new Uint8Array(b.length);
  for (let i = 0; i < b.length; i++) jpeg[i] = b.charCodeAt(i);
  return { jpeg, pxW: canvas.width, pxH: canvas.height };
}

// Like genPDF (same block types), but assembled as a BINARY pdf so it can carry
// an optional trailing image page (the architecture diagram). `image` is
// { jpeg, pxW, pxH, title } or null.
function genPDFBinary({ title, subtitle, blocks, image }) {
  const PW = 612, PH = 792, M = 48, maxW = PW - 2 * M;
  const pages = []; let ops = []; let y = PH - M;
  const newPage = () => { pages.push(ops); ops = []; y = PH - M; };
  const ensure = (h) => { if (y - h < M) newPage(); };
  const T = (t, x, yy, size, bold) => ops.push(`BT /${bold ? "FB" : "FR"} ${size} Tf ${x} ${yy} Td (${pdfEsc(t)}) Tj ET`);
  const L = (x1, y1, x2, y2) => ops.push(`${x1} ${y1.toFixed(1)} m ${x2} ${y2.toFixed(1)} l S`);
  T(title || "Export", M, y, 18, true); y -= 22;
  if (subtitle) { T(subtitle, M, y, 10, false); y -= 14; }
  L(M, y, PW - M, y); y -= 18;
  for (const b of blocks || []) {
    if (b.type === "heading") { ensure(28); y -= 4; T(b.text, M, y, 13, true); y -= 18; }
    else if (b.type === "subheading") { ensure(20); T(b.text, M, y, 11, true); y -= 15; }
    else if (b.type === "text") {
      for (const ln of pdfWrap(b.text, 10, maxW)) { ensure(14); T(ln, M, y, 10, false); y -= 13; } y -= 4;
    }
    else if (b.type === "kv") {
      const k = (b.key || "") + ":  "; const kw = pdfTextW(k, 10);
      const v = pdfWrap(b.value, 10, Math.max(60, maxW - kw));
      ensure(14); T(k, M, y, 10, true); T(v[0] || "", M + kw, y, 10, false); y -= 13;
      for (let i = 1; i < v.length; i++) { ensure(14); T(v[i], M + kw, y, 10, false); y -= 13; }
    }
    else if (b.type === "table") {
      const cols = b.columns, n = cols.length;
      const widths = b.widths || cols.map(() => maxW / n);
      const totalW = widths.reduce((a, c) => a + c, 0);
      const row = (cells, bold) => {
        const cl = cells.map((c, ci) => pdfWrap(c, 9, widths[ci] - 8));
        const rowH = Math.max(...cl.map(l => l.length)) * 11 + 6;
        ensure(rowH);
        let x = M;
        for (let ci = 0; ci < n; ci++) {
          let yy = y - 12;
          for (const ln of cl[ci]) { T(ln, x + 4, yy, 9, bold); yy -= 11; }
          x += widths[ci];
        }
        L(M, y - rowH, M + totalW, y - rowH); y -= rowH;
      };
      L(M, y, M + totalW, y);
      row(cols, true);
      for (const r of b.rows) row(r, false);
      y -= 8;
    }
    else if (b.type === "spacer") { y -= (b.h || 10); }
  }
  newPage();

  // Build objects. Fonts: FR=3, FB=4. Then text-page (content,page) pairs.
  const objs = []; let id = 5; const pageIds = [];
  for (const pageOps of pages) {
    const stream = pageOps.join("\n");
    const cId = id++, pId = id++;
    objs.push({ id: cId, parts: [`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`] });
    objs.push({ id: pId, parts: [`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PW} ${PH}] /Resources << /Font << /FR 3 0 R /FB 4 0 R >> >> /Contents ${cId} 0 R >>`] });
    pageIds.push(pId);
  }

  // Optional image page (architecture diagram), letter-size, scaled to fit.
  if (image && image.jpeg) {
    const imgId = id++, cId = id++, pId = id++;
    const capH = 22;
    const availW = PW - 2 * M, availH = PH - 2 * M - capH;
    const aspect = image.pxW / image.pxH;
    let dw = availW, dh = dw / aspect;
    if (dh > availH) { dh = availH; dw = dh * aspect; }
    const ix = M + (availW - dw) / 2, iy = M + (availH - dh) / 2;
    const cap = image.title || "Architecture Diagram";
    const content =
      `BT /FB 13 Tf ${M} ${(PH - M).toFixed(1)} Td (${pdfEsc(cap)}) Tj ET\n` +
      `q\n${dw.toFixed(2)} 0 0 ${dh.toFixed(2)} ${ix.toFixed(2)} ${iy.toFixed(2)} cm\n/Im0 Do\nQ`;
    objs.push({ id: imgId, parts: [`<< /Type /XObject /Subtype /Image /Width ${image.pxW} /Height ${image.pxH} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${image.jpeg.length} >>\nstream\n`, image.jpeg, "\nendstream"] });
    objs.push({ id: cId, parts: [`<< /Length ${content.length} >>\nstream\n${content}\nendstream`] });
    objs.push({ id: pId, parts: [`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PW} ${PH}] /Resources << /XObject << /Im0 ${imgId} 0 R >> /Font << /FR 3 0 R /FB 4 0 R >> >> /Contents ${cId} 0 R >>`] });
    pageIds.push(pId);
  }

  const all = [
    { id: 1, parts: ["<< /Type /Catalog /Pages 2 0 R >>"] },
    { id: 2, parts: [`<< /Type /Pages /Kids [${pageIds.map(p => p + " 0 R").join(" ")}] /Count ${pageIds.length} >>`] },
    { id: 3, parts: ["<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"] },
    { id: 4, parts: ["<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>"] },
    ...objs,
  ];
  return buildBinaryPDF(all);
}

// ── Jira import CSV (Issue Id + Parent linking; Epic Link deprecated 2024) ─────
function csvCell(val) { return `"${String(val ?? "").replace(/"/g, '""')}"`; }
function jiraLabel(label) { return (label ?? "").trim().replace(/\s+/g, "-"); }
function exportEpicsJiraCSV(epics, label, filename = "jira-import.csv") {
  const lbl = jiraLabel(label);
  const headers = ["IssueId", "IssueType", "Summary", "Description", "Priority", "StoryPoints", "Parent", "Labels", "AcceptanceCriteria"];
  const rows = [headers.map(csvCell).join(",")];
  let nextId = 1; const epicRows = [];
  epics.forEach(epic => {
    const id = nextId++; epicRows.push({ epic, id });
    rows.push([id, "Epic", epic.epic_title, epic.epic_description ?? "", "High", "", "", lbl, ""].map(csvCell).join(","));
  });
  epicRows.forEach(({ epic, id }) => {
    (epic.stories ?? []).forEach(story => {
      const description = `As a ${story.role}, I want to ${story.action}, so that ${story.value}\n\nAcceptance Criteria:\n`
        + (story.acceptance_criteria ?? []).map((ac, i) => `${i + 1}. ${ac}`).join("\n");
      rows.push([nextId++, "Story", story.title, description, story.priority ?? "Medium", story.points ?? "", id, lbl,
        (story.acceptance_criteria ?? []).join(" | ")].map(csvCell).join(","));
    });
  });
  download(filename, rows.join("\n"), "text/csv;charset=utf-8;");
}
// Build the readable epics/stories PDF (showPoints adds a sizing line per story).
function exportEpicsPDF(epics, { title, showPoints = false, filename = "epics.pdf" }) {
  const blocks = [];
  const totalPts = epics.reduce((n, e) => n + (e.stories || []).reduce((m, st) => m + (st.points || 0), 0), 0);
  epics.forEach(e => {
    blocks.push({ type: "heading", text: `${e.epic_id} · ${e.epic_title}` });
    if (e.epic_description) blocks.push({ type: "text", text: e.epic_description });
    (e.stories || []).forEach(st => {
      const head = showPoints && st.points != null ? `[${st.points} pts] ${st.story_id} — ${st.title}` : `${st.story_id} — ${st.title}`;
      blocks.push({ type: "subheading", text: head });
      blocks.push({ type: "text", text: `As a ${st.role}, I want to ${st.action}${st.value ? `, so that ${st.value}` : ""}.` });
      if (showPoints && st.sizing?.rationale) blocks.push({ type: "kv", key: "Sizing", value: `${st.points} pts — ${st.sizing.rationale}${st.sizing.recommend_split ? " (recommend split)" : ""}` });
      (st.acceptance_criteria || []).forEach((ac, i) => blocks.push({ type: "text", text: `   ${i + 1}. ${ac}` }));
      blocks.push({ type: "spacer", h: 6 });
    });
    blocks.push({ type: "spacer", h: 10 });
  });
  const sub = showPoints
    ? `${epics.length} epics · ${epics.reduce((n, e) => n + (e.stories?.length || 0), 0)} stories · ${totalPts} points · ${new Date().toLocaleDateString()}`
    : `${epics.length} epics · ${epics.reduce((n, e) => n + (e.stories?.length || 0), 0)} stories · ${new Date().toLocaleDateString()}`;
  download(filename, genPDF({ title: title || "Epics & Stories", subtitle: sub, blocks }), "application/pdf");
}

// ── Test-case exports (PDF · Jira CSV · Helix ALM CSV/XML) ─────────────────────
function exportTestCasesPDF(ts, projectName) {
  const totalTCs = ts.reduce((n, s) => n + (s.test_cases?.length || 0), 0);
  const blocks = [];
  for (const s of ts) {
    blocks.push({ type: "heading", text: `${s.story_id || ""} — ${s.story_title || s.title || "Story"}`.trim() });
    const tcs = s.test_cases || [];
    if (!tcs.length) { blocks.push({ type: "text", text: "No test cases." }); continue; }
    blocks.push({
      type: "table",
      columns: ["ID", "Test Case", "Steps", "Expected Result", "Type"],
      widths: [52, 110, 130, 130, 56],
      rows: tcs.map(tc => [
        tc.tc_id || "", tc.title || "",
        (tc.steps || []).map((st, i) => `${i + 1}. ${st}`).join("  "),
        tc.expected_result || "", tc.type || tc.priority || "",
      ]),
    });
  }
  download("test-cases.pdf", genPDF({
    title: (projectName ? projectName + " — " : "") + "Test Cases — QA Sign-off",
    subtitle: `${ts.length} stor${ts.length === 1 ? "y" : "ies"} · ${totalTCs} test cases · generated ${new Date().toLocaleDateString()}`,
    blocks,
  }), "application/pdf");
}
// Jira import CSV — test cases as issues (type "Test"), each linked to its story by name in Summary.
function exportTestCasesJiraCSV(ts, label) {
  const lbl = jiraLabel(label);
  const headers = ["IssueType", "Summary", "Description", "Priority", "Labels"];
  const rows = [headers.map(csvCell).join(",")];
  (ts || []).forEach(s => {
    (s.test_cases || []).forEach(tc => {
      const desc = `Story: ${s.story_id} — ${s.story_title || ""}\nType: ${tc.type || ""}\n`
        + (tc.preconditions?.length ? `Preconditions:\n${tc.preconditions.map(p => "- " + p).join("\n")}\n` : "")
        + `Steps:\n${(tc.steps || []).map((st, i) => `${i + 1}. ${st}`).join("\n")}\n`
        + `Expected: ${tc.expected_result || ""}` + (tc.test_data ? `\nTest data: ${tc.test_data}` : "");
      rows.push([ "Test", `[${tc.tc_id}] ${tc.title}`, desc, tc.priority || "Medium", lbl ].map(csvCell).join(","));
    });
  });
  download("testcases-jira-import.csv", rows.join("\n"), "text/csv;charset=utf-8;");
}
function exportHelixCSV(ts) {
  const head = "TestCaseID,StoryID,Name,Type,Priority,Preconditions,Steps,ExpectedResult,TestData";
  const lines = [head, ...ts.flatMap(s => (s.test_cases || []).map(tc => [
    tc.tc_id, s.story_id, csvCell(tc.title), tc.type || "", tc.priority || "Medium",
    csvCell((tc.preconditions || []).join(" | ")), csvCell((tc.steps || []).join(" | ")),
    csvCell(tc.expected_result || ""), csvCell(tc.test_data || ""),
  ].join(",")))];
  download("helix-testcases.csv", lines.join("\n"), "text/csv;charset=utf-8;");
}
function xmlEsc(v) { return String(v ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;"); }
function exportHelixXML(ts) {
  const lines = ['<?xml version="1.0" encoding="UTF-8"?>', `<TestCases tool="Cadenly" generated="${new Date().toISOString()}">`];
  (ts || []).forEach(s => {
    (s.test_cases || []).forEach(tc => {
      lines.push(`  <TestCase id="${xmlEsc(tc.tc_id)}" storyId="${xmlEsc(s.story_id)}">`);
      lines.push(`    <Name>${xmlEsc(tc.title)}</Name>`);
      lines.push(`    <Type>${xmlEsc(tc.type || "")}</Type>`);
      lines.push(`    <Priority>${xmlEsc(tc.priority || "Medium")}</Priority>`);
      if ((tc.preconditions || []).length) {
        lines.push(`    <Preconditions>`);
        (tc.preconditions || []).forEach(p => lines.push(`      <Precondition>${xmlEsc(p)}</Precondition>`));
        lines.push(`    </Preconditions>`);
      }
      lines.push(`    <Steps>`);
      (tc.steps || []).forEach((step, idx) => lines.push(`      <Step order="${idx + 1}">${xmlEsc(step)}</Step>`));
      lines.push(`    </Steps>`);
      lines.push(`    <ExpectedResult>${xmlEsc(tc.expected_result || "")}</ExpectedResult>`);
      if (tc.test_data) lines.push(`    <TestData>${xmlEsc(tc.test_data)}</TestData>`);
      lines.push(`  </TestCase>`);
    });
  });
  lines.push(`</TestCases>`);
  download("helix-testcases.xml", lines.join("\n"), "application/xml");
}


// Medium-safe mechanical cleanup of a raw meeting transcript. Removes only what
// is almost never signal: per-line timestamps, repeated consecutive speaker
// labels, standalone filler/backchannel lines, and excess blank lines. It never
// touches text inside a substantive sentence, so decisions/blockers survive.
// Applied at ingest (paste + upload) so every downstream stage sees the lighter text.
function cleanTranscript(raw) {
  if (!raw || typeof raw !== "string") return raw || "";
  // Lines that are ONLY filler/backchannel (optionally with trailing punctuation)
  // — removed entirely. Embedded filler inside a real sentence is left alone.
  const FILLER = /^(?:um+|uh+|erm+|hmm+|mm+(?:[ -]?hmm+)?|yeah|yep|yup|ok(?:ay)?|right|sure|got it|gotcha|cool|nice|thanks|thank you|sounds good|exactly|totally|i see|makes sense|agreed|true|wow|haha+|lol)[\s.!,]*$/i;
  // Leading per-line timestamps: 00:14, 00:14:23, [10:04:11], (1:02:33), 10:04 AM, 2026-06-12 10:04
  const LEADING_TS = /^\s*[\[(]?\s*(?:\d{4}-\d{2}-\d{2}\s+)?\d{1,2}:\d{2}(?::\d{2})?(?:\s*[ap]\.?m\.?)?\s*[\])]?\s*[-–—:]?\s*/i;
  // A "Speaker:" label at line start (short, no spaces-heavy sentence). Captures the name.
  const SPEAKER = /^([A-Z][\w.'-]*(?:\s+[A-Z][\w.'-]*){0,3})\s*:\s*/;

  const out = [];
  let lastSpeaker = null;
  let blanks = 0;
  for (let line of raw.split(/\r?\n/)) {
    let l = line.replace(LEADING_TS, "");           // strip leading timestamp
    const trimmed = l.trim();
    if (!trimmed) { if (++blanks <= 1) out.push(""); continue; }  // collapse blank runs
    blanks = 0;
    // Pull off a speaker label if present, to dedupe consecutive same-speaker lines.
    let speaker = null, rest = trimmed;
    const sm = trimmed.match(SPEAKER);
    if (sm && sm[1].length <= 40) { speaker = sm[1]; rest = trimmed.slice(sm[0].length).trim(); }
    // Drop a line that is purely filler/backchannel (after removing the label).
    if (FILLER.test(rest)) continue;
    if (speaker) {
      if (speaker === lastSpeaker) out.push(rest);            // same speaker → drop repeated label
      else { out.push(`${speaker}: ${rest}`); lastSpeaker = speaker; }
    } else {
      out.push(rest);
    }
  }
  // Final tidy: collapse any leftover 3+ newlines, trim.
  return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}

// Extract plain text from an uploaded transcript file (.docx via mammoth, or .txt).
async function extractFileText(file) {
  const lower = file.name.toLowerCase();
  if (lower.endsWith(".docx")) {
    const arrayBuffer = await file.arrayBuffer();
    const res = await mammoth.extractRawText({ arrayBuffer });
    const text = (res.value || "").trim();
    if (!text) throw new Error("No text found in the document");
    return text;
  }
  if (lower.endsWith(".doc")) throw new Error("Legacy .doc isn't supported — save as .docx, or paste the text.");
  return (await file.text()).trim();
}

// ── Claude API (shared pattern with AIAD) ─────────────────────────────────────
async function generateWithContinuation({ system, userContent, maxTokens = 8000, maxRounds = 5 }) {
  const call = (messages) => fetch(AI_ENDPOINT, {
    method: "POST", credentials: "include", headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ model: "claude-sonnet-4-20250514", max_tokens: maxTokens, ...(system ? { system } : {}), messages }),
  }).then(async r => {
    if (r.status === 402 || r.status === 401) {
      let code = "no_subscription"; try { code = (await r.clone().json()).code || code; } catch { /* ignore */ }
      if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("cadenly:need-billing", { detail: { code } }));
      throw new Error(code === "no_tokens" ? "You're out of tokens — buy more to continue." : "Start your free trial to use AI features.");
    }
    if (!r.ok) throw new Error(`API ${r.status}: ${(await r.text()).slice(0, 120)}`);
    return r.json();
  });

  let data = await call([{ role: "user", content: userContent }]);
  if (data.error) throw new Error(data.error.message || "API error");
  let full = data.content?.map(b => b.text || "").join("") || "";
  let rounds = 0;
  while (data.stop_reason === "max_tokens" && rounds < maxRounds) {
    rounds++;
    const base = Array.isArray(userContent) ? [...userContent] : [{ type: "text", text: String(userContent) }];
    const cont = [...base, { type: "text", text:
      `Your previous response was cut off by a length limit. Here is what you produced so far:\n\n${full}\n\nContinue from EXACTLY where it stops. Output ONLY the additional text — no repetition, no commentary, no code fences.` }];
    data = await call([{ role: "user", content: cont }]);
    if (data.error) break;
    const more = (data.content?.map(b => b.text || "").join("") || "").replace(/```(?:json)?/gi, "");
    if (!more) break;
    full += more;
  }
  return full.trim();
}

function escapeControlChars(s) {
  let out = "", inStr = false, esc = false;
  for (let i = 0; i < s.length; i++) {
    const ch = s[i], code = s.charCodeAt(i);
    if (inStr) {
      if (esc) { out += ch; esc = false; continue; }
      if (ch === "\\") { out += ch; esc = true; continue; }
      if (ch === '"') { out += ch; inStr = false; continue; }
      if (code < 0x20) { out += ch === "\n" ? "\\n" : ch === "\r" ? "\\r" : ch === "\t" ? "\\t" : "\\u" + code.toString(16).padStart(4, "0"); continue; }
      out += ch;
    } else { if (ch === '"') inStr = true; out += ch; }
  }
  return out;
}

// Escape stray double-quotes that appear INSIDE string values. A quote legitimately
// closes a string only if the next non-space char is one of : , } ] or end-of-input;
// otherwise the model emitted an unescaped quote inside the value (e.g. a ticket name),
// which breaks JSON.parse with "Expected ',' or '}'". This walks the text and escapes
// those inner quotes so the rest parses.
function repairQuotes(s) {
  let out = "", inStr = false, esc = false;
  for (let i = 0; i < s.length; i++) {
    const ch = s[i];
    if (!inStr) { out += ch; if (ch === '"') inStr = true; continue; }
    if (esc) { out += ch; esc = false; continue; }
    if (ch === "\\") { out += ch; esc = true; continue; }
    if (ch === '"') {
      let j = i + 1; while (j < s.length && /\s/.test(s[j])) j++;
      const nx = s[j];
      if (nx === undefined || nx === ":" || nx === "," || nx === "}" || nx === "]") { out += '"'; inStr = false; }
      else out += '\\"';
      continue;
    }
    out += ch;
  }
  return out;
}

function salvageJSON(raw) {
  let c = (raw || "").trim().replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```\s*$/i, "").trim();
  const oS = c.indexOf("{"), aS = c.indexOf("[");
  const useArr = aS !== -1 && (oS === -1 || aS < oS);
  const start = useArr ? aS : oS;
  if (start === -1) throw new Error("No JSON found in response");
  c = escapeControlChars(c.slice(start));
  try { return JSON.parse(c); } catch {}
  c = repairQuotes(c);                       // fix unescaped inner quotes
  try { return JSON.parse(c); } catch {}
  let inStr = false, esc = false, cut = -1; const stack = [];
  for (let i = 0; i < c.length; i++) {
    const ch = c[i];
    if (inStr) { if (esc) esc = false; else if (ch === "\\") esc = true; else if (ch === '"') inStr = false; continue; }
    if (ch === '"') { inStr = true; continue; }
    if (ch === "{" || ch === "[") stack.push(ch);
    else if (ch === "}" || ch === "]") { if (stack.length) { stack.pop(); cut = i; } }
    else if (ch === ",") { if (stack.length) cut = i; }
  }
  if (cut === -1) throw new Error("Could not salvage truncated JSON");
  let fixed = (c[cut] === "," ? c.slice(0, cut) : c.slice(0, cut + 1)).replace(/,\s*$/, "");
  const open = []; inStr = false; esc = false;
  for (let i = 0; i < fixed.length; i++) {
    const ch = fixed[i];
    if (inStr) { if (esc) esc = false; else if (ch === "\\") esc = true; else if (ch === '"') inStr = false; continue; }
    if (ch === '"') inStr = true;
    else if (ch === "{") open.push("}");
    else if (ch === "[") open.push("]");
    else if (ch === "}" || ch === "]") open.pop();
  }
  for (let i = open.length - 1; i >= 0; i--) fixed += open[i];
  return JSON.parse(fixed);
}

const JSON_RULES = " Output ONLY valid JSON, no prose or code fences. Inside string values use single quotes, never the double-quote character. Keep each text field concise (under ~240 characters).";

async function callClaudeJSON(system, user, maxTokens = 8000) {
  const text = await generateWithContinuation({ system: (system || "") + JSON_RULES, userContent: user, maxTokens });
  if (!text) throw new Error("Empty response from API");
  return salvageJSON(text);
}

// ── Jira import parsing ───────────────────────────────────────────────────────
// Tolerant CSV row splitter (handles quoted fields, escaped quotes, commas).
function splitCSV(text) {
  const rows = []; let row = [], cur = "", inStr = false;
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    if (inStr) {
      if (ch === '"') { if (text[i + 1] === '"') { cur += '"'; i++; } else inStr = false; }
      else cur += ch;
    } else {
      if (ch === '"') inStr = true;
      else if (ch === ",") { row.push(cur); cur = ""; }
      else if (ch === "\n" || ch === "\r") {
        if (ch === "\r" && text[i + 1] === "\n") i++;
        row.push(cur); rows.push(row); row = []; cur = "";
      } else cur += ch;
    }
  }
  if (cur.length || row.length) { row.push(cur); rows.push(row); }
  return rows.filter(r => r.some(c => c.trim() !== ""));
}

const find = (headers, ...names) => {
  const lower = headers.map(h => h.toLowerCase().trim());
  for (const n of names) { const i = lower.indexOf(n.toLowerCase()); if (i !== -1) return i; }
  for (const n of names) { const i = lower.findIndex(h => h.includes(n.toLowerCase())); if (i !== -1) return i; }
  return -1;
};

function parseJiraCSV(text) {
  const rows = splitCSV(text);
  if (rows.length < 2) throw new Error("CSV has no data rows");
  const h = rows[0];
  const cK = find(h, "issue key", "key");
  const cS = find(h, "summary", "title");
  const cSt = find(h, "status");
  const cA = find(h, "assignee");
  const cP = find(h, "priority");
  const cT = find(h, "issue type", "type");
  const cPt = find(h, "story points", "story point estimate", "points");
  const cParent = find(h, "parent", "parent key", "epic link");
  return rows.slice(1).map((r, i) => ({
    id: uid(),
    key: cK !== -1 ? r[cK]?.trim() : `ROW-${i + 1}`,
    summary: cS !== -1 ? r[cS]?.trim() : "(no summary)",
    status: cSt !== -1 ? (r[cSt]?.trim() || "To Do") : "To Do",
    assignee: cA !== -1 ? (r[cA]?.trim() || "Unassigned") : "Unassigned",
    priority: cP !== -1 ? (r[cP]?.trim() || "Medium") : "Medium",
    type: cT !== -1 ? (r[cT]?.trim() || "Task") : "Task",
    points: cPt !== -1 ? (parseFloat(r[cPt]) || 0) : 0,
    parent: cParent !== -1 ? (r[cParent]?.trim() || "") : "",
  })).filter(t => t.key);
}

function parseJiraJSON(text) {
  const obj = JSON.parse(text);
  // Tolerate a saved TPM session file dropped into the board importer.
  // Shapes: { _meta, session:{ board:{ tickets } } } (saved) or a bare session object.
  const sess = (obj && obj._meta && obj.session) ? obj.session : (obj && obj.projectName ? obj : null);
  if (sess) {
    const t = sess.board?.tickets;
    if (Array.isArray(t) && t.length) return t;  // already in internal shape
    throw new Error("That's a saved TPM session with no board in it. Use “Load Session” on the start screen, or import a Jira board export here instead.");
  }
  const issues = Array.isArray(obj) ? obj : (obj.issues || obj.tickets || []);
  if (!Array.isArray(issues) || !issues.length) throw new Error("No issues array found in JSON");
  return issues.map((it, i) => {
    const f = it.fields || it;
    const pick = (...vals) => { for (const v of vals) if (v != null && v !== "") return v; return undefined; };
    const nm = x => (x && typeof x === "object") ? (x.name || x.displayName || x.value) : x;
    return {
      id: uid(),
      key: pick(it.key, f.key, `ROW-${i + 1}`),
      summary: pick(nm(f.summary), f.title, "(no summary)"),
      status: pick(nm(f.status), "To Do"),
      assignee: pick(nm(f.assignee), "Unassigned"),
      priority: pick(nm(f.priority), "Medium"),
      type: pick(nm(f.issuetype), nm(f.type), "Task"),
      points: pick(f.storyPoints, f["Story Points"], f.customfield_10016, 0) || 0,
      parent: pick(nm(f.parent), f.epicLink, "") || "",
    };
  });
}

// Normalise free-form statuses into canonical kanban columns.
const COLUMNS = ["To Do", "In Progress", "In Review", "Blocked", "Done"];
function columnOf(status) {
  const s = (status || "").toLowerCase();
  if (/(done|closed|resolved|complete|shipped)/.test(s)) return "Done";
  if (/(block|impediment|on hold|waiting)/.test(s)) return "Blocked";
  if (/(review|qa|verify|test)/.test(s)) return "In Review";
  if (/(progress|develop|doing|in dev|started|active)/.test(s)) return "In Progress";
  return "To Do";
}

// ── .ics generation ───────────────────────────────────────────────────────────
function pad(n) { return String(n).padStart(2, "0"); }
function toICSDate(dateStr, timeStr) {
  // dateStr "YYYY-MM-DD", timeStr "HH:MM" → "YYYYMMDDTHHMMSS" (floating local time)
  const d = (dateStr || "").replace(/-/g, "");
  const t = (timeStr || "09:00").replace(/:/g, "") + "00";
  return `${d}T${t}`;
}
function buildICS({ title, description, date, time, durationMin = 30, attendees = [] }) {
  const dtStart = toICSDate(date, time);
  const endMin = (() => {
    const [h, m] = (time || "09:00").split(":").map(Number);
    const total = h * 60 + m + durationMin;
    return `${pad(Math.floor(total / 60) % 24)}:${pad(total % 60)}`;
  })();
  const dtEnd = toICSDate(date, endMin);
  const esc = s => (s || "").replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
  const lines = [
    "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//AIAD//TPM Workflow//EN",
    "BEGIN:VEVENT", `UID:${uid()}@aiad-tpm`,
    `DTSTAMP:${toICSDate(new Date().toISOString().slice(0, 10), "00:00")}`,
    `DTSTART:${dtStart}`, `DTEND:${dtEnd}`,
    `SUMMARY:${esc(title)}`, `DESCRIPTION:${esc(description)}`,
    ...attendees.filter(Boolean).map(a => `ATTENDEE;CN=${esc(a)}:mailto:${a.replace(/\s+/g, ".").toLowerCase()}@example.com`),
    "END:VEVENT", "END:VCALENDAR",
  ];
  return lines.join("\r\n");
}

// ── Jira-import CSV out (current Parent-based linking) ─────────────────────────
function buildJiraImportCSV(items) {
  const esc = v => { const s = String(v ?? ""); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
  const head = ["Summary", "Issue Type", "Priority", "Assignee", "Description", "Parent"];
  const rows = items.map(it => [it.summary, it.type || "Task", it.priority || "Medium", it.assignee || "", it.description || "", it.parent || ""]);
  return [head, ...rows].map(r => r.map(esc).join(",")).join("\n");
}

// ── Shared atoms ──────────────────────────────────────────────────────────────
function Spinner() { return <div className="spinner" />; }
// ── LIVE STEP ── single-line "what it's doing now" for generate buttons.
// Shows the most recent log message (the real current step), with leading
// ►/✔/✘/· markers stripped. Falls back to a given default when no log yet.
function lastStep(log, fallback) {
  if (!log || !log.length) return fallback;
  const m = log[log.length - 1]?.msg || "";
  return m.replace(/^[►✔✘⚠·\s]+/, "").trim() || fallback;
}
function LiveStep({ log, fallback }) {
  return <><Spinner /> {lastStep(log, fallback)}</>;
}
// ── IDENTITY ── static signed-in identity chip, shown in every header.
// Fetched once at the App level and passed down; no dropdown (static display).
function Identity({ email }) {
  if (!email) return null;
  return (
    <span style={{ display: "inline-flex", alignItems: "center", gap: 7, fontSize: 11,
      fontFamily: "var(--font-mono)", color: "var(--muted)", padding: "4px 10px",
      borderRadius: 20, border: "1px solid var(--border)", background: "var(--panel)" }}
      title={`Signed in as ${email}`}>
      <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--ok)", flexShrink: 0 }} />
      <span style={{ color: "var(--text)" }}>{email}</span>
    </span>
  );
}

// Round avatar with initials, colored deterministically from the user's email,
// so it's stable per person. Clicking it opens the account modal.
function Avatar({ user, size = 30, onClick }) {
  const email = (user && user.email) || "";
  const name = (user && user.name) || "";
  const initials = (() => {
    const n = name.trim();
    if (n) { const p = n.split(/\s+/); return ((p[0][0] || "") + (p[1] ? p[1][0] : "")).toUpperCase(); }
    return (email.split("@")[0] || "?").slice(0, 2).toUpperCase();
  })();
  let h = 0; const seed = email || name || "?";
  for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) % 360;
  return (
    <button onClick={onClick} title={`Account · ${email}`} aria-label="Account"
      style={{ width: size, height: size, borderRadius: "50%", border: "1px solid var(--border)",
        background: `hsl(${h}, 55%, 45%)`, color: "#fff", fontSize: Math.round(size * 0.4), fontWeight: 700,
        display: "inline-flex", alignItems: "center", justifyContent: "center", cursor: "pointer",
        padding: 0, lineHeight: 1, userSelect: "none", flexShrink: 0 }}>
      {initials}
    </button>
  );
}

function ThemeToggle({ theme, onToggle }) {
  const isDark = theme === "dark";
  return (
    <button className="theme-toggle" onClick={onToggle} title={`Switch to ${isDark ? "light" : "dark"} mode`}>
      <span style={{ fontSize: 13, lineHeight: 1 }}>{isDark ? "🌙" : "☀️"}</span>
      <div className="tt-track" style={{ background: isDark ? "var(--accent)" : "#c4c9d9" }}>
        <div className="tt-thumb" style={{ left: isDark ? "14px" : "2px" }} />
      </div>
      <span>{isDark ? "Dark" : "Light"}</span>
    </button>
  );
}
function EmptyState({ icon, title, sub }) {
  return (
    <div className="empty-state">
      <div style={{ fontSize: 36, opacity: .2 }}>{icon}</div>
      <div style={{ fontWeight: 700, color: "var(--muted)", fontSize: 13 }}>{title}</div>
      <div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)", maxWidth: 380 }}>{sub}</div>
    </div>
  );
}
function CardHead({ title, right }) {
  return <div className="card-hd"><span className="card-title">{title}</span>{right}</div>;
}
function ErrBox({ msg }) {
  if (!msg) return null;
  return <div style={{ marginTop: 12, padding: "10px 12px", borderRadius: 6, fontSize: 11,
    fontFamily: "var(--font-mono)", color: "var(--warn)", background: "rgba(255,77,109,.08)",
    border: "1px solid rgba(255,77,109,.25)" }}>⚠ {msg}</div>;
}
function CopyBtn({ text, label = "Copy" }) {
  const [done, setDone] = useState(false);
  const copy = async () => {
    try {
      if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text || "");
      else throw new Error("no clipboard api");
    } catch {
      try {
        const ta = document.createElement("textarea");
        ta.value = text || ""; ta.style.position = "fixed"; ta.style.top = "0"; ta.style.opacity = "0";
        document.body.appendChild(ta); ta.focus(); ta.select();
        document.execCommand("copy"); document.body.removeChild(ta);
      } catch { /* give up silently */ }
    }
    setDone(true); setTimeout(() => setDone(false), 1500);
  };
  return <button className="btn btn-secondary btn-sm" onClick={copy}>{done ? "✓ Copied" : label}</button>;
}
function priTag(p) {
  const s = (p || "").toLowerCase();
  if (/(highest|critical|block)/.test(s)) return "tag-red";
  if (/high/.test(s)) return "tag-gold";
  if (/low/.test(s)) return "tag-muted";
  return "tag-blue";
}
// ── PRIORITY ── numeric rank for sorting (lower = more important). Used to order
// reminders, discussion topics and meetings high → low.
function priRank(p) {
  const s = (p || "").toLowerCase();
  if (/(highest|critical|block)/.test(s)) return 0;
  if (/high/.test(s)) return 1;
  if (/med/.test(s)) return 2;
  if (/low/.test(s)) return 3;
  return 2; // unset → treat as medium
}

// ══════════════════════════════════════════════════════════════════════════════
// STAGES
// ══════════════════════════════════════════════════════════════════════════════
const STAGES = [
  { id: "board",       n: "01", label: "Board Status",   color: "var(--st1)",  ai: false },
  { id: "transcripts", n: "02", label: "Transcripts",    color: "var(--st2)",  ai: false },
  { id: "risks",       n: "03", label: "Risks & Deps",   color: "var(--st3)",  ai: true  },
  { id: "standup",     n: "04", label: "Daily Standup",  color: "var(--st4)",  ai: false },
  { id: "wip",         n: "05", label: "Update WIP",     color: "var(--st5)",  ai: false },
  { id: "reminders",   n: "06", label: "Reminders",      color: "var(--st6)",  ai: true  },
  { id: "topics",      n: "07", label: "Discussion",     color: "var(--st7)",  ai: true  },
  { id: "meetings",    n: "08", label: "Schedule",       color: "var(--st8)",  ai: true  },
  { id: "memo",        n: "09", label: "Meeting Memo",   color: "var(--st9)",  ai: true  },
  { id: "actions",     n: "10", label: "Action Items",   color: "var(--st10)", ai: false },
  { id: "jira",        n: "11", label: "Update Jira",    color: "var(--st11)", ai: false },
  { id: "status",      n: "12", label: "Weekly Status",  color: "var(--st12)", ai: true  },
];

function Stepper({ active, onSelect, doneSet, stages = STAGES }) {
  return (
    <div style={{ display: "flex", alignItems: "center", overflowX: "auto", padding: "0 24px",
      borderBottom: "1px solid var(--border)", background: "var(--surface)" }}>
      {stages.map((s, i) => {
        const isActive = s.id === active, isDone = doneSet.has(s.id);
        return (
          <button key={s.id} onClick={() => onSelect(s.id)} style={{
            display: "flex", alignItems: "center", gap: 8, padding: "13px 14px", background: "none",
            border: "none", borderBottom: isActive ? `2px solid ${s.color}` : "2px solid transparent",
            cursor: "pointer", whiteSpace: "nowrap", transition: "all .2s", marginBottom: -1 }}>
            <span style={{ width: 18, height: 18, borderRadius: "50%", display: "flex", alignItems: "center",
              justifyContent: "center", fontSize: 9, fontWeight: 800, fontFamily: "var(--font-mono)",
              background: isDone ? s.color : "var(--border)", border: isActive ? `1.5px solid ${s.color}` : "none",
              color: isDone ? "#07080d" : isActive ? s.color : "var(--muted)", flexShrink: 0 }}>
              {isDone ? "✓" : s.n}
            </span>
            <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase",
              color: isActive ? s.color : isDone ? "var(--text)" : "var(--muted)", fontFamily: "var(--font-mono)" }}>
              {s.label}{s.ai && <span style={{ marginLeft: 5, opacity: .6, fontSize: 8 }}>✦</span>}{s.optional && <span style={{ marginLeft: 5, fontSize: 8, opacity: .7, textTransform: "none", letterSpacing: 0 }}>(optional)</span>}
            </span>
            {i < stages.length - 1 && <span style={{ color: "var(--border)", marginLeft: 8, fontSize: 12 }}>›</span>}
          </button>
        );
      })}
    </div>
  );
}

// ── 01 · Board Status ─────────────────────────────────────────────────────────
function StageBoard({ s, set }) {
  const [raw, setRaw] = useState("");
  const [fmt, setFmt] = useState("auto");
  const [err, setErr] = useState("");
  const [openTicket, setOpenTicket] = useState(null);   // ── TICKET MODAL ── clicked kanban card
  const fileRef = useRef();

  // Live Jira pull (local app only — needs the broker).
  const [spaces, setSpaces] = useState([]);
  const [filters, setFilters] = useState([]);
  const [sel, setSel] = useState("");        // "filter:<id>" or "project:<key>"
  const [jql, setJql] = useState("");
  const [pulling, setPulling] = useState(false);
  const [loadingSrc, setLoadingSrc] = useState(false);

  const loadSources = () => {
    if (!IS_LOCAL_APP) return;
    setLoadingSrc(true);
    Promise.all([
      fetch(`${BROKER_BASE}/filters`, { credentials: "include" }).then(r => r.json()).catch(() => ({})),
      fetch(`${BROKER_BASE}/projects`, { credentials: "include" }).then(r => r.json()).catch(() => ({})),
    ])
      .then(([f, p]) => {
        setFilters(f.filters || []);
        setSpaces(p.projects || []);
        if (f.code === "no_jira" || p.code === "no_jira") setErr("");   // banner already prompts to connect
        else if (f.error || p.error) setErr(`Couldn't load from Jira: ${f.error || p.error}`);
        else setErr("");
      })
      .catch(e => setErr(`Can't reach the broker at ${BROKER_BASE || "this address"} — is it running? (${e.message})`))
      .finally(() => setLoadingSrc(false));
  };
  useEffect(() => {
    loadSources();
    const onJira = () => loadSources();
    if (typeof window !== "undefined") window.addEventListener("cadenly:jira-changed", onJira);
    return () => { if (typeof window !== "undefined") window.removeEventListener("cadenly:jira-changed", onJira); };
  }, []);

  // Picking a saved filter uses its JQL; picking a space fills a sensible default.
  const pick = (val) => {
    setSel(val);
    if (val.startsWith("filter:")) {
      const f = filters.find(x => String(x.id) === val.slice(7));
      setJql(f?.jql || "");
    } else if (val.startsWith("project:")) {
      setJql(`project = ${val.slice(8)} AND statusCategory != Done ORDER BY updated DESC`);
    } else setJql("");
  };

  const pullJira = async () => {
    if (!jql.trim()) return;
    setErr(""); setPulling(true);
    try {
      const r = await fetch(`${BROKER_BASE}/board?jql=${encodeURIComponent(jql)}`, { credentials: "include" });
      const d = await r.json();
      if (r.status === 409 || d.code === "no_jira") throw new Error("Connect your Jira account first (Settings → Jira, or the banner above).");
      if (!r.ok || d.error) throw new Error(d.error || `broker returned ${r.status}`);
      if (!d.tickets?.length) throw new Error("Jira returned 0 tickets for that filter.");
      set({ ...s, board: { tickets: d.tickets, importedAt: Date.now(), source: "jira-live" } });
    } catch (e) { setErr(`Pull failed: ${e.message}`); }
    finally { setPulling(false); }
  };

  const ingest = (text) => {
    setErr("");
    try {
      const isJSON = fmt === "json" || (fmt === "auto" && text.trim().startsWith("{")) || (fmt === "auto" && text.trim().startsWith("["));
      const tickets = isJSON ? parseJiraJSON(text) : parseJiraCSV(text);
      if (!tickets.length) throw new Error("Parsed 0 tickets — check the export format");
      set({ ...s, board: { tickets, importedAt: Date.now() } });
    } catch (e) { setErr(e.message); }
  };

  // ── RECONCILE WITH connected sources RELEASE STATUS ──────────────────────────────────────
  // Ask connected sources what's actually released vs. in-dev, match to board tickets, and
  // SUGGEST status changes (suggestion-only — never writes to Jira). Stored on
  // s.board.reco keyed by ticket key; surfaced as per-card badges + a summary.
  const [reconciling, setReconciling] = useState(false);
  const [recoMsg, setRecoMsg] = useState("");
  const reconcile = async () => {
    if (!tickets.length) return;
    setReconciling(true); setRecoMsg("⟳ Checking release status via connectors…"); setErr("");
    try {
      const ticketList = tickets.map(t => `${t.key} [${columnOf(t.status)}] ${t.summary}`).join("\n").slice(0, 6000);
      let grounding = "";
      if (IS_LOCAL_APP) {
        try {
          const out = await api("/api/connectors/analyze", { method: "POST", body: {
            question: `For project "${s.projectName}", determine which of the following Jira tickets correspond to work that is actually RELEASED/shipped, IN DEVELOPMENT/in progress, or NOT STARTED — based on real release, build, and program data. For each that clearly maps to delivered or in-progress work, say so and cite the evidence.\n\nTICKETS:\n${ticketList}`,
            hint: `Suggestion-only Jira status reconciliation. Route to deployment/release, build, and program/software-configuration data. Read-only.`,
            maxServers: 4, maxRounds: 4,
          }});
          grounding = out?.answer || "";
          setRecoMsg(grounding ? `✔ Grounded against connected sources — ${(out.servers || []).join(", ") || "connected sources"}. Matching tickets…` : "No connected sources returned release data.");
        } catch {
          setRecoMsg("⚠ Couldn't reach connected sources — can't reconcile without it.");
          setReconciling(false); return;
        }
      } else {
        setRecoMsg("Reconcile needs the local app + connected source.");
        setReconciling(false); return;
      }
      if (!grounding) { setReconciling(false); return; }

      // Have the model map the grounding to concrete per-ticket recommendations.
      const valid = COLUMNS.join(", ");
      const res = await callClaudeJSON(
        "You reconcile a Jira board against authoritative release evidence and recommend status changes. Suggestion-only. Be conservative: only recommend a change when the evidence clearly supports it. Respond ONLY with JSON.",
        `Board columns (valid statuses): ${valid}\n\nTickets (key, current column, summary):\n${ticketList}\n\nRELEASE EVIDENCE (authoritative):\n${grounding}\n\nFor each ticket whose CURRENT column disagrees with what the evidence shows, return a recommendation. Use confidence "high" only when the evidence clearly identifies that ticket's work as released/in-progress; otherwise "review". Do NOT recommend a change when current status already matches, or when there's no evidence for that ticket.\n\nReturn JSON: {"recommendations":[{"key":"KEY-1","recommended":"<one of the valid statuses>","confidence":"high|review","reason":"<short evidence-based reason>"}]}`,
        4000);
      const recoMap = {};
      (res.recommendations || []).forEach(r => {
        if (!r.key || !r.recommended) return;
        const t = tickets.find(x => x.key === r.key);
        if (!t) return;
        if (columnOf(t.status) === r.recommended) return;   // already matches — skip
        recoMap[r.key] = { recommended: r.recommended, confidence: r.confidence === "high" ? "high" : "review", reason: r.reason || "", at: Date.now() };
      });
      const n = Object.keys(recoMap).length;
      set({ ...s, board: { ...s.board, reco: recoMap, recoAt: Date.now() } });
      setRecoMsg(n ? `✔ ${n} ticket${n === 1 ? "" : "s"} have a recommended status change — see badges below.` : "✔ No status changes recommended — board looks current.");
    } catch (e) { setErr(`Reconcile failed: ${e.message}`); setRecoMsg(""); }
    finally { setReconciling(false); }
  };
  const clearReco = () => set({ ...s, board: { ...s.board, reco: {}, recoAt: null } });

  const onFile = (e) => {
    const f = e.target.files?.[0]; if (!f) return;
    const r = new FileReader();
    r.onload = () => { setRaw(String(r.result)); ingest(String(r.result)); };
    r.readAsText(f);
  };

  const tickets = s.board?.tickets || [];
  const grouped = COLUMNS.map(col => ({ col, items: tickets.filter(t => columnOf(t.status) === col) }));

  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 18 }}>
      {IS_LOCAL_APP && (
        <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
        <JiraConnect compact />
        <div className="card">
          <CardHead title="Pull from Jira (live)"
            right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>via broker</span>} />
          <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5, marginBottom: 10 }}>
            Pick one of your saved Jira filters (or a whole space). The query fills in automatically — edit it only if you want something different.
          </div>
          <div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
            <select value={sel} onChange={e => pick(e.target.value)} style={{ minWidth: 280 }}>
              <option value="">{loadingSrc ? "Loading…" : "Choose a saved filter or space…"}</option>
              {filters.length > 0 && (
                <optgroup label="Saved filters">
                  {filters.map(f => <option key={`f${f.id}`} value={`filter:${f.id}`}>{f.name}</option>)}
                </optgroup>
              )}
              {spaces.length > 0 && (
                <optgroup label="Spaces (whole project)">
                  {spaces.map(p => <option key={`p${p.key}`} value={`project:${p.key}`}>{p.key} — {p.name}</option>)}
                </optgroup>
              )}
            </select>
            <button className="btn btn-primary" disabled={pulling || !jql.trim()} onClick={pullJira}>
              {pulling ? <><Spinner /> Pulling…</> : "Pull from Jira"}</button>
          </div>
          {sel && <textarea rows={2} value={jql} onChange={e => setJql(e.target.value)}
            style={{ marginTop: 10, fontFamily: "var(--font-mono)", fontSize: 12 }} placeholder="JQL filter" />}
        </div>
        </div>
      )}
      <div className="card">
        <CardHead title="Import Jira Board Export"
          right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>CSV or JSON</span>} />
        <div style={{ display: "flex", gap: 10, marginBottom: 10, flexWrap: "wrap" }}>
          <select value={fmt} onChange={e => setFmt(e.target.value)} style={{ width: 160 }}>
            <option value="auto">Auto-detect</option>
            <option value="csv">Force CSV</option>
            <option value="json">Force JSON</option>
          </select>
          <button className="btn btn-secondary" onClick={() => fileRef.current?.click()}>Upload File</button>
          <input ref={fileRef} type="file" accept=".csv,.json,.txt" onChange={onFile} style={{ display: "none" }} />
        </div>
        <textarea rows={6} value={raw} onChange={e => setRaw(e.target.value)}
          placeholder="…or paste the raw CSV / JSON export here" />
        <div style={{ marginTop: 10 }}>
          <button className="btn btn-primary" disabled={!raw.trim()} onClick={() => ingest(raw)}>Parse Board</button>
        </div>
        <ErrBox msg={err} />
      </div>

      {tickets.length > 0 ? (
        <div className="card">
          <CardHead title={`Board · ${tickets.length} tickets`}
            right={<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
              {IS_LOCAL_APP && <button className="btn btn-secondary btn-sm" disabled={reconciling} onClick={reconcile} title="Compare ticket statuses against what your connected sources report as released (suggestion-only — does not write to Jira)">
                {reconciling ? <><Spinner /> Reconciling…</> : "Reconcile with release status"}</button>}
              <span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>imported {new Date(s.board.importedAt).toLocaleTimeString()}</span>
            </div>} />
          {recoMsg && <div style={{ fontSize: 11, marginBottom: 10, fontFamily: "var(--font-mono)", display: "flex", alignItems: "center", gap: 10, color: recoMsg.startsWith("⚠") ? "var(--gold)" : recoMsg.startsWith("✔") ? "var(--accent)" : "var(--muted)" }}>
            <span>{recoMsg}</span>
            {s.board?.reco && Object.keys(s.board.reco).length > 0 && <span onClick={clearReco} style={{ cursor: "pointer", color: "var(--muted)", textDecoration: "underline" }}>clear</span>}
          </div>}
          {s.board?.reco && Object.keys(s.board.reco).length > 0 && (
            <div style={{ fontSize: 10, color: "var(--muted)", marginBottom: 10, fontFamily: "var(--font-mono)" }}>
              Suggestions only — update statuses in Jira yourself. <span style={{ color: "var(--accent)" }}>▸ solid = high confidence</span> · <span style={{ color: "var(--gold)" }}>▸ amber = review</span>
            </div>
          )}
          <div style={{ display: "flex", gap: 10, overflowX: "auto", paddingBottom: 6 }}>
            {grouped.map(({ col, items }) => (
              <div key={col} className="kcol">
                <div className="kcol-hd"><span>{col}</span><span>{items.length}</span></div>
                {items.map(t => {
                  const rec = s.board?.reco?.[t.key];
                  return (
                  <div key={t.id} className="kcard" onClick={() => setOpenTicket(t)} style={{ cursor: "pointer", borderLeft: rec ? `3px solid ${rec.confidence === "high" ? "var(--accent)" : "var(--gold)"}` : undefined }}>
                    <div style={{ display: "flex", justifyContent: "space-between", gap: 6, marginBottom: 5 }}>
                      <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--accent2)" }}>{t.key}</span>
                      <span className={`tag ${priTag(t.priority)}`} style={{ padding: "1px 5px" }}>{t.priority}</span>
                    </div>
                    <div style={{ fontSize: 12, lineHeight: 1.4, marginBottom: 6 }}>{t.summary}</div>
                    {rec && (
                      <div title={rec.reason} style={{ display: "flex", alignItems: "center", gap: 4, marginBottom: 6, padding: "3px 6px", borderRadius: 4, fontSize: 9.5, fontFamily: "var(--font-mono)", background: rec.confidence === "high" ? "var(--accent-dim, rgba(127,176,255,.14))" : "rgba(240,210,122,.16)", color: rec.confidence === "high" ? "var(--accent)" : "var(--gold)" }}>
                        <span style={{ fontWeight: 700 }}>→ {rec.recommended}</span>
                        <span style={{ opacity: .8 }}>{rec.confidence === "high" ? "suggested" : "review"}</span>
                      </div>
                    )}
                    <div style={{ display: "flex", justifyContent: "space-between", fontSize: 9,
                      color: "var(--muted)", fontFamily: "var(--font-mono)" }}>
                      <span>{t.assignee}</span>{t.points ? <span>{t.points} pts</span> : null}
                    </div>
                  </div>
                  );
                })}
                {!items.length && <div style={{ fontSize: 10, color: "var(--muted)", textAlign: "center", padding: 10 }}>—</div>}
              </div>
            ))}
          </div>
        </div>
      ) : (
        <EmptyState icon="▦" title="No board imported yet"
          sub="Export your project board from Jira (CSV or JSON) and import it above. This becomes the source for every step today." />
      )}

      {/* ── TICKET MODAL ── click a kanban card to see its details */}
      {openTicket && <TicketModal ticket={openTicket} reco={s.board?.reco?.[openTicket.key]} onClose={() => setOpenTicket(null)} />}
    </div>
  );
}

// ── Ticket detail popup (modal) ───────────────────────────────────────────────
function TicketModal({ ticket: t, reco, onClose }) {
  const [desc, setDesc] = useState(undefined);   // undefined=loading, ""=none, string=loaded, null=error
  useEffect(() => {
    const onKey = e => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);
  // ── (a) live Jira fetch for the full description, local app only.
  useEffect(() => {
    if (!IS_LOCAL_APP || !t.key) { setDesc(""); return; }
    let cancelled = false;
    setDesc(undefined);
    api(`/board/issue/${encodeURIComponent(t.key)}`)
      .then(d => { if (!cancelled) setDesc(d.description || ""); })
      .catch(() => { if (!cancelled) setDesc(null); });
    return () => { cancelled = true; };
  }, [t.key]);
  const Row = ({ label, children }) => (
    <div style={{ display: "flex", gap: 12, padding: "9px 0", borderBottom: "1px solid var(--border)" }}>
      <span style={{ flex: "0 0 96px", fontSize: 10, textTransform: "uppercase", letterSpacing: ".06em", color: "var(--muted)", fontFamily: "var(--font-mono)", paddingTop: 2 }}>{label}</span>
      <span style={{ flex: 1, fontSize: 13, lineHeight: 1.5 }}>{children}</span>
    </div>
  );
  return ReactDOM.createPortal(
    <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,.45)",
      display: "flex", alignItems: "center", justifyContent: "center", padding: "5vh 16px", overflowY: "auto", backdropFilter: "blur(2px)" }}>
      <div onClick={e => e.stopPropagation()} style={{ width: "100%", maxWidth: 560, maxHeight: "90vh", overflowY: "auto", background: "var(--panel)",
        border: "1px solid var(--border)", borderRadius: 12, boxShadow: "0 12px 48px var(--shadow)" }}>
        <div style={{ position: "sticky", top: 0, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12,
          padding: "14px 18px", borderBottom: "1px solid var(--border)", background: "var(--header-bg)", backdropFilter: "blur(8px)" }}>
          <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
            <span style={{ fontFamily: "var(--font-mono)", fontSize: 13, fontWeight: 700, color: "var(--accent2)" }}>{t.key}</span>
            <span className={`tag ${priTag(t.priority)}`} style={{ fontSize: 9 }}>{t.priority}</span>
          </div>
          <button className="btn btn-ghost btn-sm" onClick={onClose}>✕</button>
        </div>
        <div style={{ padding: "16px 18px" }}>
          <div style={{ fontSize: 16, fontWeight: 700, lineHeight: 1.4, marginBottom: 14 }}>{t.summary}</div>
          <Row label="Status">{t.status}{reco && <span style={{ marginLeft: 8, padding: "1px 7px", borderRadius: 4, fontSize: 11, fontFamily: "var(--font-mono)", background: reco.confidence === "high" ? "rgba(127,176,255,.16)" : "rgba(240,210,122,.16)", color: reco.confidence === "high" ? "var(--accent)" : "var(--gold)" }}>Suggested → {reco.recommended} ({reco.confidence === "high" ? "high confidence" : "review"})</span>}</Row>
          {reco?.reason && <Row label="Why">{reco.reason} <span style={{ color: "var(--muted)", fontSize: 11 }}>— suggestion only; update in Jira manually.</span></Row>}
          <Row label="Assignee">{t.assignee || "Unassigned"}</Row>
          <Row label="Type">{t.type || "—"}</Row>
          <Row label="Priority">{t.priority || "—"}</Row>
          <Row label="Story pts">{t.points ? t.points : "—"}</Row>
          <Row label="Epic / parent">{t.parent || "—"}</Row>
          <div style={{ marginTop: 14 }}>
            <div style={{ fontSize: 10, textTransform: "uppercase", letterSpacing: ".06em", color: "var(--muted)", fontFamily: "var(--font-mono)", marginBottom: 6 }}>Description</div>
            {desc === undefined ? (
              <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--muted)" }}><Spinner /> Fetching from Jira…</div>
            ) : desc === null ? (
              <div style={{ fontSize: 12, color: "var(--muted)" }}>Couldn't load the description from Jira.</div>
            ) : desc === "" ? (
              <div style={{ fontSize: 12, color: "var(--muted)" }}>No description{IS_LOCAL_APP ? " on this ticket." : " (available only in the local app)."}</div>
            ) : (
              <div style={{ fontSize: 13, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{desc}</div>
            )}
          </div>
        </div>
      </div>
    </div>,
    document.body
  );
}

// ── AI stage shell ──────────────────────────────────────────────────────────
function boardContext(s) {
  const t = s.board?.tickets || [];
  return t.map(x => `${x.key} [${x.type}/${x.priority}] "${x.summary}" — ${x.status}, ${x.assignee}${x.points ? `, ${x.points}pts` : ""}${x.parent ? `, parent ${x.parent}` : ""}`).join("\n");
}

// Session-wide transcript context, injected into every AI stage alongside the board.
function transcriptContext(s) {
  const ts = s.transcripts || [];
  if (!ts.length) return "";
  // Parse a meeting date for ordering. Undated transcripts sort oldest (date 0)
  // so they never override dated ones.
  const dateVal = t => { const d = t.meta?.date ? new Date(t.meta.date) : null; return d && !isNaN(d) ? d.getTime() : 0; };
  const sorted = [...ts].sort((a, b) => dateVal(b) - dateVal(a));   // newest first
  const newest = dateVal(sorted[0]);
  const DAY = 86400000;
  const ageLabel = (t, i) => {
    const dv = dateVal(t);
    if (i === 0) return "MOST RECENT";
    if (!dv) return "UNDATED — treat as background only";
    const days = newest ? Math.round((newest - dv) / DAY) : 0;
    return days > 0 ? `${days} day(s) older than the most recent` : "same period as most recent";
  };
  const guidance =
    "\n\nMeeting transcripts are listed NEWEST FIRST. Apply recency reconciliation:\n" +
    "- NEWER transcripts OVERRIDE older ones. If a newer transcript resolves, closes, or supersedes an issue raised in an older one, treat it as resolved — do NOT surface it as current.\n" +
    "- Only CURRENT, ACTIVE issues (raised or still-open in the most recent transcripts) plus the latest board status should drive your output.\n" +
    "- An issue raised ONLY in older transcripts and not mentioned since is POSSIBLY STALE: flag it as 'possibly resolved — verify' or lower its priority / suggest backlog, rather than presenting it as an active concern.\n" +
    "- When unsure whether an old issue is resolved, ask for confirmation rather than asserting it's active. Silence in newer transcripts is not proof of resolution, but it lowers priority.\n";
  const body = sorted.map((t, i) => {
    const m = t.meta;
    const hdr = [`[${ageLabel(t, i)}]`, m?.topic || t.name, m?.date ? `date: ${m.date}` : "date: unknown", (m?.participants || []).length ? `participants: ${m.participants.join(", ")}` : null].filter(Boolean).join(" · ");
    return `--- ${hdr} ---\n${t.text}`;
  }).join("\n\n");
  return guidance + "\nTranscripts (newest first):\n" + body;
}

// ── 02 · Transcripts & Context ────────────────────────────────────────────────
// Session-wide meeting/standup context. Fed into every AI stage alongside the board.
// Extract meeting metadata (date, participants, topic) from a transcript's text.
// Best-effort: only what's actually present in the text; null fields when absent.
async function parseTranscriptMeta(text) {
  const head = String(text || "").slice(0, 4000);   // metadata lives near the top
  const tail = String(text || "").length > 6000 ? "\n...\n" + String(text).slice(-1500) : "";
  const raw = await callClaudeJSON(
    "You extract meeting metadata from a transcript. Use ONLY what is present in the text — never invent. Respond ONLY with JSON.",
    `From this meeting transcript, extract:\n- "date": the meeting date if stated (ISO YYYY-MM-DD if possible, else the date text as written; null if absent)\n- "participants": array of attendee names mentioned as participants/speakers (empty array if none clear)\n- "topic": a short (<8 words) title of what the meeting was about\n\nReturn exactly: {"date": "...", "participants": ["..."], "topic": "..."}\n\nTRANSCRIPT (start, and end if long):\n${head}${tail}`,
    800);
  return {
    date: raw.date || null,
    participants: Array.isArray(raw.participants) ? raw.participants.filter(Boolean) : [],
    topic: (raw.topic || "").trim() || null,
  };
}

function StageTranscripts({ s, set }) {
  const transcripts = s.transcripts || [];
  const [tName, setTName] = useState("");
  const [tText, setTText] = useState("");
  const [tErr, setTErr] = useState("");
  const [parsing, setParsing] = useState(false);
  const [metaBusy, setMetaBusy] = useState(null);   // transcript id being (re)parsed
  const [editId, setEditId] = useState(null);
  const [editMeta, setEditMeta] = useState({ date: "", participants: "", topic: "" });
  const tFileRef = useRef();

  // Merge a meta update against the LATEST state (functional updater) — the parse
  // is async, so the captured `s` is stale by the time it resolves. Writing through
  // the old `s` would drop the metadata (the bug where the card showed "no metadata").
  const mergeMeta = (id, meta) => set(prev => ({ ...prev, transcripts: (prev.transcripts || []).map(t => t.id === id ? { ...t, meta } : t) }));

  // Add a transcript, then parse its metadata in the background and merge it in.
  const addOne = async (rec) => {
    set(prev => ({ ...prev, transcripts: [...(prev.transcripts || []), rec] }));
    try {
      const meta = await parseTranscriptMeta(rec.text);
      mergeMeta(rec.id, meta);
    } catch { /* leave without meta; user can backfill */ }
  };

  const addTranscript = async (name, text) => {
    if (!text.trim()) return;
    setTName(""); setTText("");
    await addOne({ id: uid(), name: name.trim() || `Transcript ${transcripts.length + 1}`, text: cleanTranscript(text), addedAt: Date.now() });
  };

  const onTranscriptFile = async (e) => {
    const files = Array.from(e.target.files || []); if (!files.length) return;
    setTErr(""); setParsing(true);
    const added = [], failed = [];
    for (const f of files) {
      try {
        const text = await extractFileText(f);
        added.push({ id: uid(), name: f.name.replace(/\.(docx|txt)$/i, ""), text: cleanTranscript(text), addedAt: Date.now() });
      } catch (er) {
        failed.push(`${f.name}: ${er.message.includes("zip") || er.message.includes("Corrupted") ? "not a readable .docx" : er.message}`);
      }
    }
    if (added.length) {
      set(prev => ({ ...prev, transcripts: [...(prev.transcripts || []), ...added] }));
      // Parse metadata for each added file, merging each against latest state.
      for (const rec of added) {
        try { mergeMeta(rec.id, await parseTranscriptMeta(rec.text)); } catch { /* skip */ }
      }
    }
    if (failed.length) setTErr(`${failed.length} file${failed.length === 1 ? "" : "s"} skipped — ${failed.join("; ")}`);
    setParsing(false); e.target.value = "";
  };

  const reparse = async (t) => {
    setMetaBusy(t.id);
    try {
      const meta = await parseTranscriptMeta(t.text);
      mergeMeta(t.id, meta);
    } catch (e) { setTErr(`Couldn't read metadata: ${e.message}`); }
    finally { setMetaBusy(null); }
  };

  const startEdit = (t) => {
    setEditId(t.id);
    setEditMeta({ date: t.meta?.date || "", participants: (t.meta?.participants || []).join(", "), topic: t.meta?.topic || "" });
  };
  const saveEdit = (t) => {
    const meta = { date: editMeta.date.trim() || null, participants: editMeta.participants.split(",").map(x => x.trim()).filter(Boolean), topic: editMeta.topic.trim() || null };
    mergeMeta(t.id, meta);
    setEditId(null);
  };

  const delTranscript = id => set(prev => ({ ...prev, transcripts: (prev.transcripts || []).filter(t => t.id !== id) }));
  const missingMeta = transcripts.filter(t => !t.meta);

  const fmtDate = d => {
    if (!d) return null;
    const dt = new Date(d);
    return isNaN(dt) ? String(d) : dt.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
  };

  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 18 }}>
      <div className="card">
        <CardHead title="Meeting Transcripts & Context"
          right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>fed into all AI steps ✦</span>} />
        <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5, marginBottom: 12 }}>
          Add recent standup or meeting transcripts. These join the board as shared context for risk analysis, discussion topics, scheduling and the weekly status. Date, participants, and topic are read automatically from each transcript.
        </div>
        <div style={{ display: "flex", gap: 10, marginBottom: 10, flexWrap: "wrap", alignItems: "flex-end" }}>
          <div style={{ flex: "1 1 200px" }}>
            <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 5 }}>Label (optional)</div>
            <input type="text" value={tName} onChange={e => setTName(e.target.value)} placeholder="e.g. Mon standup" />
          </div>
          <button className="btn btn-secondary" disabled={parsing} onClick={() => tFileRef.current?.click()}>
            {parsing ? <><Spinner /> Reading…</> : "Upload .docx (multiple)"}</button>
          <input ref={tFileRef} type="file" accept=".docx,.txt" multiple onChange={onTranscriptFile} style={{ display: "none" }} />
        </div>
        <textarea rows={4} value={tText} onChange={e => setTText(e.target.value)} placeholder="…or paste transcript text here" />
        <div style={{ marginTop: 10 }}>
          <button className="btn btn-primary" disabled={!tText.trim()} onClick={() => addTranscript(tName, tText)}>Add Transcript</button>
        </div>
        <ErrBox msg={tErr} />
      </div>

      {transcripts.length > 0 ? (
        <div className="card">
          <CardHead title={`Added · ${transcripts.length}`} right={missingMeta.length > 0
            ? <button className="btn btn-secondary btn-sm" disabled={!!metaBusy} onClick={async () => { for (const t of missingMeta) { await reparse(t); } }}>Extract metadata for {missingMeta.length}</button>
            : null} />
          <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
            {transcripts.map(t => {
              const m = t.meta;
              const editing = editId === t.id;
              const dateStr = fmtDate(m?.date);
              const parts = m?.participants || [];
              return (
                <div key={t.id} style={{ padding: "10px 12px", borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg)" }}>
                  <div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
                    <span className="dot dot-green" style={{ marginTop: 5 }} />
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <div style={{ fontSize: 13, fontWeight: 700 }}>{m?.topic || t.name}</div>
                      {m?.topic && t.name && m.topic !== t.name && <div style={{ fontSize: 10, color: "var(--muted)" }}>{t.name}</div>}
                      <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 3, display: "flex", gap: 12, flexWrap: "wrap", alignItems: "center" }}>
                        {dateStr && <span title="Meeting date">🗓 {dateStr}</span>}
                        {parts.length > 0 && <span title={parts.join(", ")}>👥 {parts.length === 1 ? parts[0] : `${parts.length} participants`}</span>}
                        <span style={{ fontFamily: "var(--font-mono)" }}>{t.text.length.toLocaleString()} chars</span>
                        {!m && <span style={{ color: "var(--gold)", fontFamily: "var(--font-mono)" }}>no metadata</span>}
                      </div>
                      {parts.length > 1 && <div style={{ fontSize: 10, color: "var(--muted)", marginTop: 2 }}>{parts.join(", ")}</div>}
                    </div>
                    <div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
                      <button className="btn btn-ghost btn-sm" disabled={metaBusy === t.id} onClick={() => reparse(t)} title="Re-read date/participants/topic from the transcript">{metaBusy === t.id ? <Spinner /> : "↻"}</button>
                      <button className="btn btn-ghost btn-sm" onClick={() => editing ? setEditId(null) : startEdit(t)}>{editing ? "Cancel" : "Edit"}</button>
                      <button className="btn btn-warn btn-sm" onClick={() => delTranscript(t.id)}>✕</button>
                    </div>
                  </div>
                  {editing && (
                    <div style={{ marginTop: 10, paddingTop: 10, borderTop: "1px solid var(--border)", display: "flex", gap: 8, flexWrap: "wrap", alignItems: "flex-end" }}>
                      <div style={{ flex: "1 1 130px" }}>
                        <div style={{ fontSize: 9, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".08em", marginBottom: 3 }}>Date</div>
                        <input type="text" value={editMeta.date} onChange={e => setEditMeta(v => ({ ...v, date: e.target.value }))} placeholder="2026-06-12" />
                      </div>
                      <div style={{ flex: "2 1 220px" }}>
                        <div style={{ fontSize: 9, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".08em", marginBottom: 3 }}>Participants (comma-separated)</div>
                        <input type="text" value={editMeta.participants} onChange={e => setEditMeta(v => ({ ...v, participants: e.target.value }))} placeholder="Michael, Robert, Eric" />
                      </div>
                      <div style={{ flex: "2 1 220px" }}>
                        <div style={{ fontSize: 9, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".08em", marginBottom: 3 }}>Topic</div>
                        <input type="text" value={editMeta.topic} onChange={e => setEditMeta(v => ({ ...v, topic: e.target.value }))} placeholder="Migration grooming" />
                      </div>
                      <button className="btn btn-primary btn-sm" onClick={() => saveEdit(t)}>Save</button>
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      ) : (
        <EmptyState icon="✎" title="No transcripts yet"
          sub="Optional — paste or upload standup / meeting notes to give every AI step shared context. You can skip this and add them later." />
      )}
    </div>
  );
}

// ── 03 · Risks & Dependencies ─────────────────────────────────────────────────
// Risk record shape:
//   { id, title, severity:"high|medium|low", detail, relatedKeys:[],
//     stakeholders:[], status, flaggedAt, updates:[{at,text}], severityEdited? }
const RISK_STATUSES = ["Open", "Monitoring", "Escalated", "Mitigated", "Resolved"];
const riskSevTag = sv => sv === "high" ? "tag-red" : sv === "medium" ? "tag-gold" : "tag-muted";
const riskStatusTag = st => ({ Open: "tag-gold", Monitoring: "tag-blue", Escalated: "tag-red", Mitigated: "tag-purple", Resolved: "tag-green" }[st] || "tag-muted");
const riskLbl = { fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--muted)" };
const fmtWhen = ts => { if (!ts) return "—"; const d = new Date(ts); return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); };
const daysSince = ts => { if (!ts) return ""; const d = Math.floor((Date.now() - ts) / 86400000); return d <= 0 ? "today" : d === 1 ? "1 day ago" : `${d} days ago`; };
const normTitle = t => (t || "").trim().toLowerCase();

function deriveStakeholders(r, s) {
  const tickets = s.board?.tickets || [];
  const keys = new Set(r.relatedKeys || []);
  return [...new Set(tickets.filter(t => keys.has(t.key)).map(t => t.assignee).filter(a => a && a !== "Unassigned"))];
}
function newRisk(r, s) {
  return {
    id: uid(),
    title: r.title || "Untitled risk",
    severity: r.severity || "medium",
    detail: r.detail || "",
    relatedKeys: r.relatedKeys || [],
    stakeholders: (r.stakeholders && r.stakeholders.length) ? r.stakeholders : deriveStakeholders(r, s),
    status: r.status || "Open",
    recency: r.recency === "stale" ? "stale" : "active",   // stale = raised only in older transcripts, possibly resolved
    flaggedAt: r.flaggedAt || Date.now(),
    updates: r.updates || [],
  };
}
// Re-analysis merges fresh AI risks with existing ones (matched by title), preserving
// user-owned fields (status, stakeholders, flaggedAt, progress, edited priority) and
// keeping prior risks the AI no longer returns so resolved/historical items don't vanish.
function mergeRisks(existing, incoming, s) {
  const byTitle = new Map(existing.map(r => [normTitle(r.title), r]));
  const seen = new Set();
  const merged = incoming.map(nw => {
    const prev = byTitle.get(normTitle(nw.title));
    seen.add(normTitle(nw.title));
    if (!prev) return newRisk(nw, s);
    return {
      ...prev,
      detail: nw.detail || prev.detail,
      relatedKeys: nw.relatedKeys || prev.relatedKeys,
      severity: prev.severityEdited ? prev.severity : (nw.severity || prev.severity),
      recency: nw.recency === "stale" ? "stale" : "active",   // always take fresh recency from latest analysis
      stakeholders: (prev.stakeholders && prev.stakeholders.length) ? prev.stakeholders
        : ((nw.stakeholders && nw.stakeholders.length) ? nw.stakeholders : deriveStakeholders(nw, s)),
    };
  });
  // Keep prior risks the AI dropped ONLY if the user has worked them (status,
  // priority, stakeholders or progress). Pristine AI risks that are no longer
  // surfaced — e.g. ones sourced from a transcript that's since been removed —
  // are cleared so re-analysis reflects the current inputs.
  const dropped = existing.filter(r => !seen.has(normTitle(r.title)) && r.userModified);
  return [...merged, ...dropped];
}

function RiskCard({ risk: r, onUpdate, onDelete, onExplain, explaining, onSourceDive, sourcing, expanded, onToggle }) {
  const [sk, setSk] = useState("");
  const [upd, setUpd] = useState("");
  const addStakeholder = () => { const v = sk.trim(); if (!v) return; if (!(r.stakeholders || []).includes(v)) onUpdate(r.id, { stakeholders: [...(r.stakeholders || []), v] }); setSk(""); };
  const rmStakeholder = n => onUpdate(r.id, { stakeholders: (r.stakeholders || []).filter(x => x !== n) });
  const addUpdate = () => { const v = upd.trim(); if (!v) return; onUpdate(r.id, { updates: [...(r.updates || []), { at: Date.now(), text: v }] }); setUpd(""); };

  return (
    <div className="slide-in" style={{ borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg)", overflow: "hidden", opacity: r.status === "Resolved" ? 0.72 : 1 }}>
      <div onClick={onToggle} style={{ padding: 12, cursor: "pointer", display: "flex", flexDirection: "column", gap: 6 }}>
        <div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center" }}>
          <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
            <span style={{ fontSize: 11, color: "var(--muted)", display: "inline-block", transform: expanded ? "rotate(90deg)" : "none", transition: "transform .15s" }}>▸</span>
            <strong style={{ fontSize: 13 }}>{r.title}</strong>
          </div>
          <div style={{ display: "flex", gap: 5, flexShrink: 0 }}>
            {r.recency === "stale" && r.status !== "Resolved" && <span className="tag tag-gold" title="Raised only in older transcripts and not confirmed since — verify whether it's resolved, or move to backlog">⏳ verify / stale</span>}
            <span className={`tag ${riskSevTag(r.severity)}`}>{r.severity}</span>
            <span className={`tag ${riskStatusTag(r.status)}`}>{r.status}</span>
          </div>
        </div>
        <div style={{ display: "flex", gap: 12, fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)", flexWrap: "wrap" }}>
          <span>flagged {fmtWhen(r.flaggedAt)}{daysSince(r.flaggedAt) ? ` · ${daysSince(r.flaggedAt)}` : ""}</span>
          {(r.stakeholders || []).length > 0 && <span>{r.stakeholders.length} stakeholder{r.stakeholders.length === 1 ? "" : "s"}</span>}
          {(r.updates || []).length > 0 && <span>{r.updates.length} update{r.updates.length === 1 ? "" : "s"}</span>}
        </div>
        {!expanded && <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.detail}</div>}
      </div>

      {expanded && (
        <div style={{ padding: "0 12px 14px", display: "flex", flexDirection: "column", gap: 14 }} onClick={e => e.stopPropagation()}>
          <div>
            <div style={riskLbl}>Summary</div>
            <div style={{ fontSize: 12, color: "var(--text)", lineHeight: 1.6, marginTop: 5 }}>{r.detail}</div>
          </div>

          <div>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
              <span style={riskLbl}>In-depth analysis</span>
              {r.analysis && !explaining && (
                <span onClick={onExplain} style={{ fontSize: 10, color: "var(--accent)", cursor: "pointer", fontFamily: "var(--font-mono)" }}>↻ regenerate</span>
              )}
            </div>
            {explaining ? (
              <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--muted)", marginTop: 8 }}>
                <Spinner /> Analyzing this risk against the board and transcripts…
              </div>
            ) : r.analysis ? (
              <div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 8 }}>
                {r.analysis.impact && (
                  <div>
                    <div style={{ fontSize: 10, fontWeight: 700, color: "var(--muted)", marginBottom: 3 }}>Impact if unmitigated</div>
                    <div style={{ fontSize: 12, lineHeight: 1.6 }}>{r.analysis.impact}</div>
                  </div>
                )}
                {[["Contributing factors", r.analysis.factors], ["Early warning signs", r.analysis.signals], ["Recommended mitigation", r.analysis.mitigation]].map(([label, arr]) =>
                  (arr && arr.length) ? (
                    <div key={label}>
                      <div style={{ fontSize: 10, fontWeight: 700, color: "var(--muted)", marginBottom: 3 }}>{label}</div>
                      <ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 3 }}>
                        {arr.map((x, i) => <li key={i} style={{ fontSize: 12, lineHeight: 1.55 }}>{x}</li>)}
                      </ul>
                    </div>
                  ) : null
                )}
                {r.analysisAt && <div style={{ fontSize: 9, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>generated {fmtWhen(r.analysisAt)}</div>}
              </div>
            ) : (
              <div style={{ marginTop: 6 }}>
                <button className="btn btn-secondary btn-sm" onClick={onExplain}>Explain in depth</button>
                <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 6, lineHeight: 1.5 }}>Generates a fuller breakdown — impact, contributing factors, warning signs and mitigation — grounded in the board and any transcripts.</div>
              </div>
            )}
            <ErrBox msg={r.analysisError} />
          </div>

          {/* ── CONNECTORS ── cross-system deep-dive (local app only; needs the broker) */}
          {IS_LOCAL_APP && (
            <div>
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
                <span style={riskLbl}>Cross-system deep-dive</span>
                {r.sourceAnalysis && !sourcing && (
                  <span onClick={onSourceDive} style={{ fontSize: 10, color: "var(--accent)", cursor: "pointer", fontFamily: "var(--font-mono)" }}>↻ regenerate</span>
                )}
              </div>
              {sourcing ? (
                <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--muted)", marginTop: 8 }}>
                  <Spinner /> Pulling cross-system data from connected sources…
                </div>
              ) : r.sourceAnalysis ? (
                <div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}>
                  <div style={{ fontSize: 13, background: "var(--panel)", border: "1px solid var(--border)", borderRadius: 8, padding: "4px 14px", color: "var(--text)" }}><Markdown text={r.sourceAnalysis} /></div>
                  <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
                    {(r.sourceServers || []).map(sv => <span key={sv} className="tag tag-blue" style={{ fontSize: 9 }}>{sv}</span>)}
                    {r.sourceAt && <span style={{ fontSize: 9, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>via connectors · {fmtWhen(r.sourceAt)}</span>}
                  </div>
                </div>
              ) : (
                <div style={{ marginTop: 6 }}>
                  <button className="btn btn-secondary btn-sm" onClick={onSourceDive}>Deep-dive across connected sources</button>
                  <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 6, lineHeight: 1.5 }}>Pulls live cross-system context — fleet telemetry, program status, quality, deployments — that the board-only analysis can't see. Read-only.</div>
                </div>
              )}
              <ErrBox msg={r.sourceError} />
            </div>
          )}

          <div style={{ display: "flex", gap: 18, flexWrap: "wrap", alignItems: "flex-end" }}>
            <label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
              <span style={riskLbl}>Priority</span>
              <select value={r.severity} onChange={e => onUpdate(r.id, { severity: e.target.value, severityEdited: true })} style={{ width: 130 }}>
                <option value="high">high</option><option value="medium">medium</option><option value="low">low</option>
              </select>
            </label>
            <label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
              <span style={riskLbl}>Status</span>
              <select value={r.status} onChange={e => onUpdate(r.id, { status: e.target.value })} style={{ width: 150 }}>
                {RISK_STATUSES.map(st => <option key={st} value={st}>{st}</option>)}
              </select>
            </label>
            <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
              <span style={riskLbl}>First flagged</span>
              <span style={{ fontSize: 12, fontFamily: "var(--font-mono)" }}>{fmtWhen(r.flaggedAt)}</span>
            </div>
          </div>

          {r.relatedKeys?.length > 0 && (
            <div>
              <div style={riskLbl}>Related tickets</div>
              <div style={{ display: "flex", gap: 5, flexWrap: "wrap", marginTop: 5 }}>
                {r.relatedKeys.map(k => <span key={k} className="tag tag-blue">{k}</span>)}
              </div>
            </div>
          )}

          <div>
            <div style={riskLbl}>Stakeholders to follow up</div>
            <div style={{ display: "flex", gap: 5, flexWrap: "wrap", marginTop: 5, marginBottom: 6 }}>
              {(r.stakeholders || []).length ? r.stakeholders.map(n => (
                <span key={n} className="tag tag-purple" style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
                  {n}<span onClick={() => rmStakeholder(n)} style={{ cursor: "pointer", opacity: .7 }}>✕</span>
                </span>
              )) : <span style={{ fontSize: 11, color: "var(--muted)" }}>None identified — add the people you need to chase.</span>}
            </div>
            <div style={{ display: "flex", gap: 8 }}>
              <input type="text" value={sk} onChange={e => setSk(e.target.value)} onKeyDown={e => { if (e.key === "Enter") addStakeholder(); }} placeholder="Add stakeholder…" style={{ flex: 1 }} />
              <button className="btn btn-secondary btn-sm" onClick={addStakeholder} disabled={!sk.trim()}>Add</button>
            </div>
          </div>

          <div>
            <div style={riskLbl}>Progress</div>
            {(r.updates || []).length > 0 && (
              <div style={{ display: "flex", flexDirection: "column", gap: 6, margin: "6px 0 8px", borderLeft: "2px solid var(--border)", paddingLeft: 10 }}>
                {[...r.updates].sort((a, b) => b.at - a.at).map((u, i) => (
                  <div key={i} style={{ fontSize: 12, lineHeight: 1.5 }}>
                    <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--muted)", marginRight: 8 }}>{fmtWhen(u.at)}</span>{u.text}
                  </div>
                ))}
              </div>
            )}
            <div style={{ display: "flex", gap: 8, marginTop: 6 }}>
              <input type="text" value={upd} onChange={e => setUpd(e.target.value)} onKeyDown={e => { if (e.key === "Enter") addUpdate(); }} placeholder="Log a progress update…" style={{ flex: 1 }} />
              <button className="btn btn-secondary btn-sm" onClick={addUpdate} disabled={!upd.trim()}>Log</button>
            </div>
          </div>

          <div style={{ display: "flex", justifyContent: "flex-end" }}>
            <button className="btn btn-warn btn-sm" onClick={() => onDelete(r.id)}>Delete risk</button>
          </div>
        </div>
      )}
    </div>
  );
}

function StageRisks({ s, set }) {
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const [openId, setOpenId] = useState(null);

  // Migrate any legacy risks (loaded from an older session) to the full record shape.
  useEffect(() => {
    const rs = s.risks || [];
    if (rs.length && rs.some(r => !r.id)) set({ ...s, risks: rs.map(r => r.id ? r : newRisk(r, s)) });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [s.risks]);

  const [groundMsg, setGroundMsg] = useState("");
  const run = async () => {
    setBusy(true); setErr(""); setGroundMsg("");
    try {
      // Ground against release/dependency status: ask connected sources which capabilities
      // this board depends on are actually released vs. still in development, so an
      // unreleased dependency can be flagged as a delivery risk. Graceful degrade.
      let releaseGrounding = "";
      if (IS_LOCAL_APP) {
        setGroundMsg("⟳ Checking release/dependency status via connectors…");
        try {
          const keys = (s.board?.tickets || []).map(t => t.key).filter(Boolean).slice(0, 60).join(", ");
          const out = await api("/api/connectors/analyze", { method: "POST", body: {
            question: `For project "${s.projectName}", which features, services, or components that this board's work depends on are already RELEASED/available vs. still IN DEVELOPMENT or not yet delivered? Flag any dependency that is not yet released, since that is a delivery risk. Board tickets: ${keys || "(none)"}.`,
            hint: `TPM dependency-availability check. Route to deployment/release and issue-tracking data (releases, builds, software configurations, program status). Read-only.`,
            maxServers: 4, maxRounds: 4,
          }});
          if (out?.answer?.trim()) {
            releaseGrounding = out.answer;
            setGroundMsg(`✔ Grounded against release status — ${(out.servers || []).join(", ") || "connected sources"}.`);
          } else setGroundMsg("No connected sources returned release data — analyzing from board + transcripts only.");
        } catch {
          setGroundMsg("⚠ Couldn't reach connected sources — analyzing without release grounding.");
        }
      }
      const groundingBlock = releaseGrounding
        ? `\n\n=== RELEASE / DEPENDENCY STATUS (authoritative — what is actually released vs. in development) ===\n${releaseGrounding}\n\nUse this to flag dependencies that are NOT yet released as risks, and do not flag an as-released dependency as an availability risk.`
        : "";
      const out = await callClaudeJSON(
        "You are a senior technical project manager reviewing a Jira board and any meeting transcripts provided. Identify risks, blockers and cross-ticket dependencies from the CURRENT board state and what the transcripts reveal. If a RELEASE / DEPENDENCY STATUS section is provided, treat it as authoritative for what is actually shipped vs. in development, and flag any dependency that is not yet released as a delivery risk. For each risk, name the stakeholders (people) who should be followed up with — drawn from the assignees of related tickets and anyone named in the transcripts. Respond ONLY with JSON.",
        `Board:\n${boardContext(s)}${transcriptContext(s)}${groundingBlock}\n\nReturn JSON: {"risks":[{"title":"","severity":"high|medium|low","detail":"","relatedKeys":["KEY-1"],"stakeholders":["Name"],"recency":"active|stale"}],"dependencies":[{"from":"KEY-1","to":"KEY-2","detail":""}]}. Set "recency":"stale" for a risk raised ONLY in older transcripts and not confirmed as still-active in the most recent ones (possibly resolved — needs verification); otherwise "active". Prefer not to list clearly-resolved issues at all.`,
        6000);
      set({ ...s, risks: mergeRisks(s.risks || [], out.risks || [], s), dependencies: out.dependencies || [] });
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };

  const updateRisk = (id, patch) => set({ ...s, risks: (s.risks || []).map(r => r.id === id ? { ...r, ...patch, userModified: true } : r) });
  const deleteRisk = id => { setOpenId(null); set({ ...s, risks: (s.risks || []).filter(r => r.id !== id) }); };

  // On-demand deep explanation for one risk. Uses a plain set (no userModified
  // stamp) so generating analysis doesn't change re-analysis retention behavior.
  const [explainingId, setExplainingId] = useState(null);
  const explainRisk = async (id) => {
    const r = (s.risks || []).find(x => x.id === id);
    if (!r) return;
    setExplainingId(id);
    const patch = (p) => set({ ...s, risks: (s.risks || []).map(x => x.id === id ? { ...x, ...p } : x) });
    try {
      const out = await callClaudeJSON(
        "You are a senior technical project manager. Produce an in-depth analysis of ONE specific risk, grounded strictly in the provided board and meeting transcripts. Be concrete and reference ticket keys where relevant; do not invent facts not supported by the inputs. Respond ONLY with JSON.",
        `Board:\n${boardContext(s)}${transcriptContext(s)}\n\nRisk to analyze in depth:\nTitle: ${r.title}\nPriority: ${r.severity}\nCurrent summary: ${r.detail}\nRelated tickets: ${(r.relatedKeys || []).join(", ") || "none"}\n\nReturn JSON: {"impact":"2-4 sentences on what happens if this is left unmitigated and who/what it affects","factors":["specific contributing factor"],"signals":["early warning sign to watch for"],"mitigation":["concrete recommended action"]}`,
        3000);
      patch({ analysis: out, analysisAt: Date.now(), analysisError: "" });
    } catch (e) {
      patch({ analysisError: e.message });
    } finally { setExplainingId(null); }
  };

  // ── CONNECTORS ── cross-system deep-dive for one risk: ask the broker's /connectors/analyze,
  // which routes to the relevant connected sources and returns
  // a markdown analysis grounded in live enterprise data. Read-only.
  const [sourcingId, setSourcingId] = useState(null);
  const sourceDeepDive = async (id) => {
    const r = (s.risks || []).find(x => x.id === id);
    if (!r) return;
    setSourcingId(id);
    const patch = (p) => set({ ...s, risks: (s.risks || []).map(x => x.id === id ? { ...x, ...p } : x) });
    try {
      const keys = (r.relatedKeys || []).join(", ") || "none";
      const question = `Risk on project "${s.projectName}": ${r.title}. ${r.detail || ""} `
        + `Investigate this risk using live enterprise data. What does the fleet/program/quality/deployment data show that bears on it? `
        + `Quantify where possible and call out anything that raises or lowers the severity.`;
      const hint = `Severity: ${r.severity}. Related Jira tickets: ${keys}.`;
      const out = await api("/api/connectors/analyze", { method: "POST", body: { question, hint } });
      patch({ sourceAnalysis: out.answer || "(no answer returned)", sourceServers: out.servers || [], sourceAt: Date.now(), sourceError: "" });
    } catch (e) {
      patch({ sourceError: e.message });
    } finally { setSourcingId(null); }
  };

  const risks = s.risks || [], deps = s.dependencies || [];
  const openCount = risks.filter(r => r.status !== "Resolved").length;

  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 18 }}>
      <div className="card">
        <CardHead title="Risk & Dependency Analysis" right={
          <button className="btn btn-primary btn-sm" disabled={busy || !s.board} onClick={run}>
            {busy ? <><Spinner /> Analyzing the board for risks…</> : risks.length ? "Re-analyze" : "Analyze Board"}</button>} />
        {!s.board ? <EmptyState icon="⚠" title="Import a board first" sub="Risk analysis reads the current board from step 01." />
          : !risks.length && !busy ? <EmptyState icon="✦" title="No analysis yet" sub="Run analysis to surface risks, blockers and dependencies derived from today's board." />
          : risks.length ? <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5 }}>Click any risk to expand it — adjust priority and status, manage stakeholders to follow up with, and log progress. Re-analyzing refreshes findings while keeping your edits and history.</div>
          : null}
        {groundMsg && <div style={{ fontSize: 11, marginTop: 8, fontFamily: "var(--font-mono)", color: groundMsg.startsWith("⚠") ? "var(--gold)" : groundMsg.startsWith("✔") ? "var(--accent)" : "var(--muted)" }}>{groundMsg}</div>}
        <ErrBox msg={err} />
      </div>
      {risks.length > 0 && (
        <div className="card">
          <CardHead title={`Risks · ${risks.length}`} right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{openCount} open</span>} />
          <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
            {risks.map(r => (
              <RiskCard key={r.id} risk={r} expanded={openId === r.id}
                onToggle={() => setOpenId(openId === r.id ? null : r.id)}
                onUpdate={updateRisk} onDelete={deleteRisk}
                onExplain={() => explainRisk(r.id)} explaining={explainingId === r.id}
                onSourceDive={() => sourceDeepDive(r.id)} sourcing={sourcingId === r.id} />
            ))}
          </div>
        </div>
      )}
      {deps.length > 0 && (
        <div className="card">
          <CardHead title={`Dependencies · ${deps.length}`} />
          <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
            {deps.map((d, i) => (
              <div key={i} style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 12, fontFamily: "var(--font-mono)" }}>
                <span className="tag tag-blue">{d.from}</span><span style={{ color: "var(--muted)" }}>→</span>
                <span className="tag tag-purple">{d.to}</span><span style={{ color: "var(--muted)", fontFamily: "var(--font-ui)" }}>{d.detail}</span>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

// ── 03 · Daily Standup ──────────────────────────────────────────────────────
// Autofill defaults derived from the board: Yesterday = each person's active /
// completed tickets, Blockers = their blocked tickets. Today is left blank.
// Defaults are seeded into session state once per person so they stay editable.
function standupDefaults(theirs) {
  const line = t => `${t.key} — ${t.summary}`;
  const active = theirs.filter(t => ["In Progress", "In Review", "Done"].includes(columnOf(t.status)));
  const blocked = theirs.filter(t => columnOf(t.status) === "Blocked");
  return {
    yesterday: active.map(t => `${line(t)} [${columnOf(t.status)}]`).join("\n"),
    today: "",
    blockers: blocked.map(line).join("\n"),
  };
}

function StageStandup({ s, set }) {
  const tickets = s.board?.tickets || [];
  const people = [...new Set(tickets.map(t => t.assignee).filter(a => a && a !== "Unassigned"))];
  const standup = s.standup || {};
  const upd = (person, field, val) => set({ ...s, standup: { ...standup, [person]: { ...(standup[person] || {}), [field]: val } } });

  // Seed Yesterday / Blockers defaults for anyone not yet captured. Runs when the
  // board changes; once a person is seeded their entry is editable and persists.
  useEffect(() => {
    if (!s.board) return;
    const cur = s.standup || {};
    let changed = false;
    const next = { ...cur };
    people.forEach(p => {
      if (next[p] === undefined) {
        next[p] = standupDefaults(tickets.filter(t => t.assignee === p));
        changed = true;
      }
    });
    if (changed) set({ ...s, standup: next });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [s.board]);

  if (!s.board) return <EmptyState icon="⚠" title="Import a board first" sub="Standup is organised around the assignees on today's board." />;
  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div className="card">
        <CardHead title="Daily Standup" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{people.length} team members</span>} />
        <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5 }}>
          Yesterday and Blockers are prefilled from the board — edit as needed. Today starts blank. All three feed the WIP update (04) and discussion topics (06).
        </div>
      </div>
      {people.map(p => {
        const e = standup[p] || {};
        const theirs = tickets.filter(t => t.assignee === p);
        return (
          <div key={p} className="card">
            <CardHead title={p} right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{theirs.length} tickets</span>} />
            <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10 }}>
              {[["yesterday", "Yesterday"], ["today", "Today"], ["blockers", "Blockers"]].map(([f, lbl]) => (
                <div key={f}>
                  <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase",
                    color: f === "blockers" ? "var(--warn)" : "var(--muted)", marginBottom: 5 }}>{lbl}</div>
                  <textarea rows={3} value={e[f] || ""} onChange={ev => upd(p, f, ev.target.value)} placeholder="…" />
                </div>
              ))}
            </div>
          </div>
        );
      })}
      {!people.length && <EmptyState icon="◴" title="No assignees on the board" sub="Every ticket is unassigned — assign owners in Jira and re-import." />}
    </div>
  );
}

// ── 04 · Update WIP ───────────────────────────────────────────────────────────
function StageWIP({ s, set }) {
  const tickets = s.board?.tickets || [];
  if (!s.board) return <EmptyState icon="⚠" title="Import a board first" sub="WIP updates apply to today's imported tickets." />;
  const setStatus = (id, status) => set({ ...s, board: { ...s.board, tickets: tickets.map(t => t.id === id ? { ...t, status, _touched: true } : t) } });
  const touched = tickets.filter(t => t._touched).length;
  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div className="card">
        <CardHead title="Update Work In Progress" right={<span style={{ fontSize: 10, color: touched ? "var(--accent)" : "var(--muted)", fontFamily: "var(--font-mono)" }}>{touched} updated</span>} />
        <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5 }}>
          Reflect what changed in standup. Status changes here flow into the Jira update file (10) at end of session.
        </div>
      </div>
      <div className="card">
        <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
          {tickets.map(t => (
            <div key={t.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 4px", borderBottom: "1px solid var(--border)" }}>
              <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--accent2)", width: 70, flexShrink: 0 }}>{t.key}</span>
              <span style={{ flex: 1, fontSize: 12 }}>{t.summary}</span>
              <span style={{ fontSize: 10, color: "var(--muted)", width: 90, flexShrink: 0, fontFamily: "var(--font-mono)" }}>{t.assignee}</span>
              <select value={columnOf(t.status)} onChange={e => setStatus(t.id, e.target.value)} style={{ width: 130, flexShrink: 0 }}>
                {COLUMNS.map(c => <option key={c} value={c}>{c}</option>)}
              </select>
              {t._touched && <span className="dot dot-green" />}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

// ── 05 · Reminders & To-dos ─────────────────────────────────────────────────
function StageReminders({ s, set }) {
  const reminders = s.reminders || [];
  const [text, setText] = useState("");
  const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
  const [time, setTime] = useState("09:00");
  const [pri, setPri] = useState("Medium");   // ── PRIORITY ── manual-add picker
  const [busy, setBusy] = useState(false), [err, setErr] = useState("");
  const add = () => {
    if (!text.trim()) return;
    set({ ...s, reminders: [...reminders, { id: uid(), text: text.trim(), date, time, priority: pri, source: "you" }] });
    setText("");
  };
  const del = id => set({ ...s, reminders: reminders.filter(r => r.id !== id) });
  const ics = r => download(`reminder-${r.text.slice(0, 20).replace(/\W+/g, "-")}.ics`,
    buildICS({ title: r.text, description: `TPM reminder · ${s.projectName}`, date: r.date, time: r.time, durationMin: 15 }), "text/calendar");

  const today = new Date().toISOString().slice(0, 10);
  const standupText = Object.entries(s.standup || {}).map(([p, e]) =>
    `${p}: blockers=${e.blockers || "-"}; today=${e.today || "-"}`).join("\n") || "(none)";
  const suggest = async () => {
    setBusy(true); setErr("");
    try {
      const out = await callClaudeJSON(
        "You are a technical project manager. From the board, standup blockers and risks, propose concrete follow-up reminders/to-dos the TPM should action. Keep each to one actionable line. Assign each a priority of high, medium, or low by how urgent/important it is. Respond ONLY with JSON.",
        `Today is ${today}. Project: ${s.projectName}\n\nBoard:\n${boardContext(s)}\n\nStandup:\n${standupText}${transcriptContext(s)}\n\nRisks:\n${(s.risks || []).map(r => `${r.title} (${r.severity}, ${r.status || "Open"})`).join("; ") || "none"}\n\nReturn JSON: {"reminders":[{"text":"","date":"YYYY-MM-DD","time":"HH:MM","priority":"high|medium|low"}]}. Default date to ${today} unless the item clearly belongs later this week.`,
        5000);
      const gen = (out.reminders || []).map(r => ({
        id: uid(), text: r.text || "", date: r.date || today, time: r.time || "09:00", priority: r.priority || "medium", source: "ai" }))
        .filter(r => r.text.trim());
      set({ ...s, reminders: [...reminders, ...gen] });
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };

  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div className="card">
        <CardHead title="Suggested Reminders" right={
          <button className="btn btn-primary btn-sm" disabled={busy || !s.board} onClick={suggest}>
            {busy ? <><Spinner /> Reviewing the board for follow-ups…</> : "Suggest from Board"}</button>} />
        <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5 }}>
          {s.board ? "Generates follow-ups from standup blockers, risks and ticket state — then add your own below. Each is editable and exportable to your calendar."
            : "Import a board first to get AI suggestions, or add your own reminders below."}
        </div>
        <ErrBox msg={err} />
      </div>
      <div className="card">
        <CardHead title="Add Your Own" />
        <div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "flex-end" }}>
          <div style={{ flex: "1 1 280px" }}>
            <textarea rows={1} value={text} onChange={e => setText(e.target.value)} placeholder="Remind me to…"
              onKeyDown={e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); add(); } }} />
          </div>
          <input type="date" value={date} onChange={e => setDate(e.target.value)} style={{ width: 150 }} />
          <input type="time" value={time} onChange={e => setTime(e.target.value)} style={{ width: 110 }} />
          <select value={pri} onChange={e => setPri(e.target.value)} style={{ width: 120 }}>{/* ── PRIORITY ── */}
            {["High", "Medium", "Low"].map(p => <option key={p}>{p}</option>)}
          </select>
          <button className="btn btn-primary" onClick={add}>Add</button>
        </div>
      </div>
      <div className="card">
        <CardHead title={`Items · ${reminders.length}`} />
        {!reminders.length ? <EmptyState icon="◷" title="No reminders" sub="Generate suggestions from the board, or add your own. Each can be dropped into Outlook (or any calendar) as an .ics file." />
          : <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
            {[...reminders].sort((a, b) => priRank(a.priority) - priRank(b.priority) || (a.date || "").localeCompare(b.date || "")).map(r => (
              <div key={r.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg)" }}>
                <span className={`dot ${r.source === "ai" ? "dot-green" : "dot-gold"}`} />
                <div style={{ flex: 1 }}>
                  <div style={{ fontSize: 13 }}>{r.text}</div>
                  <div style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>
                    {r.date} · {r.time}{r.source === "ai" ? " · suggested" : ""}
                  </div>
                </div>
                {r.priority && <span className={`tag ${priTag(r.priority)}`} style={{ fontSize: 9 }}>{r.priority}</span>}{/* ── PRIORITY ── */}
                <button className="btn btn-ghost btn-sm" onClick={() => ics(r)}>+ Calendar</button>
                <button className="btn btn-warn btn-sm" onClick={() => del(r.id)}>✕</button>
              </div>
            ))}
          </div>}
      </div>
    </div>
  );
}

// ── 06 · Discussion Topics ────────────────────────────────────────────────────
function StageTopics({ s, set }) {
  const [busy, setBusy] = useState(false), [err, setErr] = useState("");
  const standupText = Object.entries(s.standup || {}).map(([p, e]) =>
    `${p}: yesterday=${e.yesterday || "-"}; today=${e.today || "-"}; blockers=${e.blockers || "-"}`).join("\n") || "(no standup captured)";
  const run = async () => {
    setBusy(true); setErr("");
    try {
      const out = await callClaudeJSON(
        "You are a technical project manager deciding which items need a dedicated discussion beyond standup. Assign each topic a priority of high, medium, or low by importance/urgency. Respond ONLY with JSON.",
        `Board:\n${boardContext(s)}\n\nStandup notes:\n${standupText}${transcriptContext(s)}\n\nRisks:\n${(s.risks || []).map(r => r.title).join("; ") || "none"}\n\nReturn JSON: {"topics":[{"title":"","why":"","priority":"high|medium|low","suggestedAttendees":["Name"],"relatedKeys":["KEY-1"]}]}`,
        6000);
      set({ ...s, topics: out.topics || [] });
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };
  const topics = s.topics || [];

  // ── TOPIC DETAIL ── click a topic to expand; first expand generates an AI briefing
  // (background, key questions, options, suggested decision) cached on the topic.
  const [openId, setOpenId] = useState(null);
  const [detailBusy, setDetailBusy] = useState(null);
  const patchTopic = (i, p) => set({ ...s, topics: (s.topics || []).map((x, j) => j === i ? { ...x, ...p } : x) });
  const loadDetail = async (i) => {
    const t = (s.topics || [])[i];
    if (!t || t.detail) return;            // already cached
    setDetailBusy(i);
    try {
      const out = await callClaudeJSON(
        "You are a technical program manager preparing a discussion topic for a meeting. Given the topic and project context, produce a focused briefing. Respond ONLY with JSON.",
        `Project: ${s.projectName}\n\nTopic: ${t.title}\nWhy it matters: ${t.why}\nSuggested attendees: ${(t.suggestedAttendees || []).join(", ") || "none"}\nRelated tickets: ${(t.relatedKeys || []).join(", ") || "none"}\n\nBoard:\n${boardContext(s)}${transcriptContext(s)}\n\nReturn JSON: {"background":"2-4 sentences of context","keyQuestions":["question to resolve"],"options":["option or consideration"],"suggestedDecision":"a concrete recommended decision or next step"}`,
        4000);
      patchTopic(i, { detail: out, detailAt: Date.now(), detailError: "" });
    } catch (e) { patchTopic(i, { detailError: e.message }); } finally { setDetailBusy(null); }
  };
  const toggle = (i) => { const next = openId === i ? null : i; setOpenId(next); if (next === i) loadDetail(i); };

  // ── CONNECTORS ── optional per-topic cross-system pull (local app only; needs the broker).
  // Separate button from the briefing: only worth it for data-grounded topics. Read-only.
  const [sourcingId, setSourcingId] = useState(null);
  const sourceTopic = async (i) => {
    const t = (s.topics || [])[i];
    if (!t) return;
    setSourcingId(i);
    try {
      const keys = (t.relatedKeys || []).join(", ") || "none";
      const question = `Discussion topic for project "${s.projectName}": ${t.title}. ${t.why || ""} `
        + `Pull live enterprise data that bears on this topic. What do the fleet/program/quality/deployment/test systems show? Quantify where possible.`;
      const hint = `Suggested attendees: ${(t.suggestedAttendees || []).join(", ") || "none"}. Related Jira tickets: ${keys}.`;
      const out = await api("/api/connectors/analyze", { method: "POST", body: { question, hint } });
      patchTopic(i, { sourceAnalysis: out.answer || "(no answer returned)", sourceServers: out.servers || [], sourceAt: Date.now(), sourceError: "" });
    } catch (e) { patchTopic(i, { sourceError: e.message }); } finally { setSourcingId(null); }
  };

  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div className="card">
        <CardHead title="Topics Needing Discussion" right={
          <button className="btn btn-primary btn-sm" disabled={busy || !s.board} onClick={run}>
            {busy ? <><Spinner /> Surfacing discussion topics…</> : topics.length ? "Re-run" : "Surface Topics"}</button>} />
        {!s.board ? <EmptyState icon="⚠" title="Import a board first" sub="Topics are derived from the board, standup notes and risks." />
          : !topics.length && !busy ? <EmptyState icon="✦" title="No topics yet" sub="Surface items that warrant a meeting rather than a standup mention." /> : null}
        <ErrBox msg={err} />
      </div>
      {topics.length > 0 && <div className="card"><CardHead title={`Topics · ${topics.length}`} />
        <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
          {topics.map((t, i) => ({ t, i }))
            .sort((a, b) => priRank(a.t.priority) - priRank(b.t.priority))
            .map(({ t, i }) => {
            const open = openId === i;
            return (
            <div key={i} className="slide-in" style={{ borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg)", overflow: "hidden" }}>
              <div onClick={() => toggle(i)} style={{ padding: 12, cursor: "pointer" }}>
                <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                  <span style={{ fontSize: 11, color: "var(--muted)", display: "inline-block", transform: open ? "rotate(90deg)" : "none", transition: "transform .15s" }}>▸</span>
                  <strong style={{ fontSize: 13 }}>{t.title}</strong>
                  {t.priority && <span className={`tag ${priTag(t.priority)}`} style={{ fontSize: 9 }}>{t.priority}</span>}{/* ── PRIORITY ── */}
                </div>
                <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.5, margin: "5px 0 5px 19px" }}>{t.why}</div>
                <div style={{ display: "flex", gap: 5, flexWrap: "wrap", marginLeft: 19 }}>
                  {(t.suggestedAttendees || []).map(a => <span key={a} className="tag tag-purple">{a}</span>)}
                  {(t.relatedKeys || []).map(k => <span key={k} className="tag tag-blue">{k}</span>)}
                </div>
              </div>
              {open && (
                <div style={{ padding: "0 12px 12px 31px", borderTop: "1px solid var(--border)" }}>
                  {/* Topic briefing (local reasoning) */}
                  {detailBusy === i ? (
                    <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--muted)", padding: "12px 0" }}>
                      <Spinner /> Preparing a briefing for this topic…
                    </div>
                  ) : t.detail ? (
                    <div style={{ display: "flex", flexDirection: "column", gap: 12, paddingTop: 12 }}>
                      {t.detail.background && <div>
                        <div style={topicLbl}>Background</div>
                        <div style={{ fontSize: 12.5, lineHeight: 1.6, color: "var(--text)" }}>{t.detail.background}</div>
                      </div>}
                      {(t.detail.keyQuestions || []).length > 0 && <div>
                        <div style={topicLbl}>Key questions to resolve</div>
                        <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12.5, lineHeight: 1.7, color: "var(--text)" }}>
                          {t.detail.keyQuestions.map((q, k) => <li key={k}>{q}</li>)}
                        </ul>
                      </div>}
                      {(t.detail.options || []).length > 0 && <div>
                        <div style={topicLbl}>Options &amp; considerations</div>
                        <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12.5, lineHeight: 1.7, color: "var(--text)" }}>
                          {t.detail.options.map((o, k) => <li key={k}>{o}</li>)}
                        </ul>
                      </div>}
                      {t.detail.suggestedDecision && <div>
                        <div style={topicLbl}>Suggested decision / next step</div>
                        <div style={{ fontSize: 12.5, lineHeight: 1.6, color: "var(--text)" }}>{t.detail.suggestedDecision}</div>
                      </div>}
                      <div>
                        <span onClick={() => { patchTopic(i, { detail: null }); loadDetail(i); }}
                          style={{ fontSize: 10, color: "var(--accent)", cursor: "pointer", fontFamily: "var(--font-mono)" }}>↻ regenerate briefing</span>
                      </div>
                    </div>
                  ) : t.detailError ? <ErrBox msg={t.detailError} /> : null}

                  {/* ── CONNECTORS ── optional live cross-system pull (local app only) */}
                  {IS_LOCAL_APP && (
                    <div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px dashed var(--border)" }}>
                      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
                        <span style={topicLbl}>Live data</span>
                        {t.sourceAnalysis && sourcingId !== i && (
                          <span onClick={() => sourceTopic(i)} style={{ fontSize: 10, color: "var(--accent)", cursor: "pointer", fontFamily: "var(--font-mono)" }}>↻ regenerate</span>
                        )}
                      </div>
                      {sourcingId === i ? (
                        <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--muted)", marginTop: 8 }}>
                          <Spinner /> Pulling cross-system data from connected sources…
                        </div>
                      ) : t.sourceAnalysis ? (
                        <div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}>
                          <div style={{ fontSize: 13, background: "var(--panel)", border: "1px solid var(--border)", borderRadius: 8, padding: "4px 14px", color: "var(--text)" }}><Markdown text={t.sourceAnalysis} /></div>
                          <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
                            {(t.sourceServers || []).map(sv => <span key={sv} className="tag tag-blue" style={{ fontSize: 9 }}>{sv}</span>)}
                            {t.sourceAt && <span style={{ fontSize: 9, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>via connectors · {fmtWhen(t.sourceAt)}</span>}
                          </div>
                        </div>
                      ) : (
                        <div style={{ marginTop: 6 }}>
                          <button className="btn btn-secondary btn-sm" onClick={() => sourceTopic(i)}>Pull live data</button>
                          <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 6, lineHeight: 1.5 }}>Optional — best for data-grounded topics. Pulls live fleet/program/quality/test context. Read-only.</div>
                        </div>
                      )}
                      <ErrBox msg={t.sourceError} />
                    </div>
                  )}
                </div>
              )}
            </div>
            );
          })}
        </div></div>}
    </div>
  );
}
const topicLbl = { fontSize: 10, textTransform: "uppercase", letterSpacing: ".06em", color: "var(--muted)", fontFamily: "var(--font-mono)", marginBottom: 5 };

// ── 07 · Schedule Meetings ────────────────────────────────────────────────────
function StageMeetings({ s, set }) {
  const [busy, setBusy] = useState(false), [err, setErr] = useState("");
  const topicsText = (s.topics || []).map(t => `${t.title} — ${t.why} (attendees: ${(t.suggestedAttendees || []).join(", ")})`).join("\n") || "(no topics — derive from board/risks)";
  const run = async () => {
    setBusy(true); setErr("");
    try {
      const out = await callClaudeJSON(
        "You are a technical project manager turning discussion topics into concrete meetings. For each, propose attendees, a one-line topic, and a 3–5 bullet agenda. Assign each meeting a priority of high, medium, or low by importance/urgency. Respond ONLY with JSON.",
        `Project: ${s.projectName}\n\nDiscussion topics:\n${topicsText}${transcriptContext(s)}\n\nRisks:\n${(s.risks || []).map(r => r.title).join("; ") || "none"}\n\nReturn JSON: {"meetings":[{"title":"","topic":"","priority":"high|medium|low","attendees":["Name"],"agenda":["bullet"],"durationMin":30}]}`,
        6000);
      const today = new Date().toISOString().slice(0, 10);
      set({ ...s, meetings: (out.meetings || []).map(m => ({ id: uid(), date: today, time: "10:00", priority: "medium", ...m })) });
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };
  const meetings = s.meetings || [];
  const updM = (id, field, val) => set({ ...s, meetings: meetings.map(m => m.id === id ? { ...m, [field]: val } : m) });
  const ics = m => download(`meeting-${(m.title || "meeting").slice(0, 24).replace(/\W+/g, "-")}.ics`,
    buildICS({ title: m.title, description: `${m.topic}\n\nAgenda:\n${(m.agenda || []).map(a => "• " + a).join("\n")}`,
      date: m.date, time: m.time, durationMin: m.durationMin || 30, attendees: m.attendees || [] }), "text/calendar");
  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div className="card">
        <CardHead title="Meetings to Schedule" right={
          <button className="btn btn-primary btn-sm" disabled={busy} onClick={run}>
            {busy ? <><Spinner /> Drafting meetings from topics &amp; risks…</> : meetings.length ? "Re-draft" : "Draft Meetings"}</button>} />
        {!meetings.length && !busy && <EmptyState icon="✦" title="No meetings drafted" sub="Generate a meeting list from the discussion topics — each with attendees, topic and agenda, exportable as .ics." />}
        <ErrBox msg={err} />
      </div>
      {[...meetings].sort((a, b) => priRank(a.priority) - priRank(b.priority)).map(m => (
        <div key={m.id} className="card">
          <CardHead title={m.title} right={
            <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
              {m.priority && <span className={`tag ${priTag(m.priority)}`} style={{ fontSize: 9 }}>{m.priority}</span>}{/* ── PRIORITY ── */}
              <input type="date" value={m.date} onChange={e => updM(m.id, "date", e.target.value)} style={{ width: 140 }} />
              <input type="time" value={m.time} onChange={e => updM(m.id, "time", e.target.value)} style={{ width: 100 }} />
              <button className="btn btn-ghost btn-sm" onClick={() => ics(m)}>+ Calendar</button>
            </div>} />
          <div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 8 }}>{m.topic}</div>
          <div style={{ display: "flex", gap: 5, flexWrap: "wrap", marginBottom: 10 }}>
            {(m.attendees || []).map(a => <span key={a} className="tag tag-purple">{a}</span>)}
          </div>
          <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 5 }}>Agenda</div>
          <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12, lineHeight: 1.7 }}>
            {(m.agenda || []).map((a, i) => <li key={i}>{a}</li>)}
          </ul>
        </div>
      ))}
    </div>
  );
}

// ── 08 · Meeting Memo (transcript → memo + action items) ──────────────────────
function StageMemo({ s, set }) {
  const meetings = s.meetings || [];
  const memos = s.memos || [];
  const [busyId, setBusyId] = useState(null);
  const [parsingId, setParsingId] = useState(null);
  const [err, setErr] = useState("");
  const [batch, setBatch] = useState(null);   // ── BATCH ── {done, total} while bulk-generating
  const fileRefs = useRef({});
  const batchRef = useRef();

  const addMemo = () => set({ ...s, memos: [...memos, { id: uid(), transcript: "", memo: "", memoTitle: "", meetingId: "", createdAt: Date.now() }] });
  const updMemo = (id, patch) => set({ ...s, memos: (s.memos || []).map(x => x.id === id ? { ...x, ...patch } : x) });
  const delMemo = (id) => set({ ...s, memos: (s.memos || []).filter(x => x.id !== id) });

  const onFile = async (id, e) => {
    const f = e.target.files?.[0]; if (!f) return;
    setErr(""); setParsingId(id);
    try { updMemo(id, { transcript: await extractFileText(f) }); }
    catch (er) { setErr(er.message.includes("Corrupted") || er.message.includes("zip") ? "Couldn't read that file as a .docx." : er.message); }
    finally { setParsingId(null); e.target.value = ""; }
  };

  // ── Generate a memo from a given transcript and write it onto an existing memo id.
  // Returns the updated memos+actionItems so callers can chain (used by batch). When
  // `base` is provided we build on it instead of stale `s` (keeps sequential writes intact).
  const genFor = async (id, transcript, base) => {
    const src = base || s;
    const mm = (src.memos || []).find(x => x.id === id) || {};
    const out = await callClaudeJSON(
      "You are a technical project manager writing a concise meeting memo from a raw transcript, plus extracting action items for the meeting's participants. Base the memo ENTIRELY on the transcript. Derive the memo's title from the transcript's actual subject — ignore any external or scheduled meeting name. For each action item, name the participant who owns it (use the name as spoken in the transcript). Respond ONLY with JSON.",
      `Transcript:\n${transcript}\n\nReturn JSON: {"title":"a concise title derived from the transcript's subject","memo":"markdown summary that opens with a '## <title>' heading, then Decisions and Discussion sections","actionItems":[{"summary":"","owner":"participant name","priority":"High|Medium|Low"}]}`,
      7000);
    const linked = meetings.find(x => x.id === mm.meetingId);
    const memoActions = (out.actionItems || []).map(a => ({ id: uid(), ...a }));   // ── participant actions, scoped to THIS memo
    return {
      ...src,
      memos: (src.memos || []).map(x => x.id === id ? { ...x, transcript, memo: out.memo || "", memoTitle: out.title || "", actionItems: memoActions } : x),
    };
  };

  const run = async (id) => {
    const mm = (s.memos || []).find(x => x.id === id);
    if (!mm?.transcript?.trim()) { setErr("Paste a transcript first."); return; }
    setBusyId(id); setErr("");
    try { set(await genFor(id, mm.transcript)); }
    catch (e) { setErr(e.message); } finally { setBusyId(null); }
  };

  // ── BATCH ── upload many transcripts at once → one memo each → generate sequentially.
  const onBatch = async (e) => {
    const files = Array.from(e.target.files || []); e.target.value = "";
    if (!files.length) return;
    setErr("");
    // 1) read all files into new standalone memos (filename shown until generated)
    const created = [];
    let working = { ...s, memos: [...(s.memos || [])] };
    for (const f of files) {
      try {
        const text = await extractFileText(f);
        const id = uid();
        const name = f.name.replace(/\.(docx|txt)$/i, "");
        const memo = { id, transcript: text, memo: "", memoTitle: name, meetingId: "", createdAt: Date.now() };
        working = { ...working, memos: [...working.memos, memo] };
        created.push({ id, transcript: text });
      } catch (er) { /* skip unreadable file, keep going */ }
    }
    if (!created.length) { setErr("Couldn't read any of those files (.docx or .txt expected)."); return; }
    set(working);                 // show all the new memos immediately
    // 2) generate each sequentially, threading the growing state so none clobber others
    setBatch({ done: 0, total: created.length });
    for (let i = 0; i < created.length; i++) {
      setBatch({ done: i, total: created.length });
      setBusyId(created[i].id);
      try { working = await genFor(created[i].id, created[i].transcript, working); set(working); }
      catch (er) { setErr(`Stopped on file ${i + 1}: ${er.message}`); break; }
    }
    setBusyId(null); setBatch(null);
  };

  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div className="card">
        <CardHead title="Meeting Memos" right={
          <div style={{ display: "flex", gap: 8 }}>
            <button className="btn btn-secondary btn-sm" disabled={!!batch} onClick={() => batchRef.current?.click()}>
              {batch ? `Generating ${batch.done + 1} of ${batch.total}…` : "Upload transcripts"}</button>
            <button className="btn btn-primary btn-sm" disabled={!!batch} onClick={addMemo}>+ New memo</button>
            <input ref={batchRef} type="file" accept=".docx,.txt" multiple onChange={onBatch} style={{ display: "none" }} />
          </div>} />
        <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5 }}>
          Capture any meeting transcript and turn it into a memo + action items. Use <strong>Upload transcripts</strong> to drop in several .docx/.txt files at once — a memo is generated for each automatically. Each memo stands on its own; optionally link it to a scheduled meeting using “Relates to”.
        </div>
        {batch && <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--muted)", marginTop: 8 }}>
          <Spinner /> Generating memos — {batch.done + 1} of {batch.total}. You can watch them fill in below.
        </div>}
        <ErrBox msg={err} />
      </div>

      {!memos.length && <EmptyState icon="✦" title="No memos yet" sub="Upload one or more transcripts, or click “+ New memo” to paste one — a memo with action items is generated for each. No scheduled meeting required." />}

      {memos.map(mm => {
        const linked = meetings.find(x => x.id === mm.meetingId);
        return (
        <div key={mm.id} className="card">
          <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, marginBottom: 10, flexWrap: "wrap" }}>
            <strong style={{ fontSize: 14 }}>{mm.memoTitle || "Untitled memo"}</strong>
            <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
              <span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Relates to</span>
              <select value={mm.meetingId || ""} onChange={e => updMemo(mm.id, { meetingId: e.target.value })} style={{ minWidth: 260, maxWidth: 360 }}>
                <option value="">Standalone — not tied to a meeting</option>
                {meetings.map(x => <option key={x.id} value={x.id}>{x.title}</option>)}
              </select>
              <button className="btn btn-warn btn-sm" onClick={() => delMemo(mm.id)}>✕</button>
            </div>
          </div>
          {linked && <div style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)", marginBottom: 8 }}>linked to: {linked.title}</div>}
          <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 5 }}>
            <span style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--muted)" }}>Transcript</span>
            <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
              {parsingId === mm.id && <Spinner />}
              <button className="btn btn-secondary btn-sm" disabled={parsingId === mm.id} onClick={() => fileRefs.current[mm.id]?.click()}>
                {parsingId === mm.id ? "Reading…" : "Upload .docx"}</button>
              <input ref={el => fileRefs.current[mm.id] = el} type="file" accept=".docx,.txt" onChange={e => onFile(mm.id, e)} style={{ display: "none" }} />
            </div>
          </div>
          <textarea rows={6} value={mm.transcript || ""} onChange={e => updMemo(mm.id, { transcript: e.target.value })} placeholder="Paste the meeting transcript, or upload a .docx above…" />
          <div style={{ marginTop: 10 }}>
            <button className="btn btn-primary" disabled={busyId === mm.id} onClick={() => run(mm.id)}>{busyId === mm.id ? <><Spinner /> Generating…</> : "Generate Memo + Actions"}</button>
          </div>
          {mm.memo && <div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px solid var(--border)" }}>
            <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
              <span style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--muted)" }}>Memo</span>
              <div style={{ display: "flex", gap: 8 }}>
                <CopyBtn text={mm.memo} label="Copy" />
                <button className="btn btn-secondary btn-sm" onClick={() => download(`memo-${(mm.memoTitle || "memo").slice(0, 24).replace(/\W+/g, "-")}.md`, mm.memo, "text/markdown")}>Export .md</button>
              </div>
            </div>
            <div style={{ fontSize: 13, lineHeight: 1.65, whiteSpace: "pre-wrap", fontFamily: "var(--font-ui)" }}>{mm.memo}</div>
            {(mm.actionItems || []).length > 0 && <div style={{ marginTop: 14 }}>
              <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 6 }}>Action items for participants</div>
              <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                {(mm.actionItems || []).map(a => (
                  <div key={a.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", borderRadius: 7, border: "1px solid var(--border)", background: "var(--bg)" }}>
                    <span style={{ flex: 1, fontSize: 12.5 }}>{a.summary}</span>
                    {a.owner && <span className="tag tag-purple" style={{ fontSize: 9 }}>{a.owner}</span>}
                    {a.priority && <span className={`tag ${priTag(a.priority)}`} style={{ fontSize: 9 }}>{a.priority}</span>}
                  </div>
                ))}
              </div>
            </div>}
          </div>}
        </div>
        );
      })}
    </div>
  );
}


// ── 10 · My Action Items ──────────────────────────────────────────────────────
// Personal to-do list. AI synthesizes what's MINE from across the workflow —
// memo action items owned by me, meetings I must run/prep, risk mitigations I
// drive, and my reminders. Click an item to expand its detail.
function StageActions({ s, set, me }) {
  const actions = s.actionItems || [];
  const tickets = s.board?.tickets || [];
  const epics = [...new Set(tickets.map(t => t.parent).filter(Boolean))];
  const [busy, setBusy] = useState(false), [err, setErr] = useState("");
  const [openId, setOpenId] = useState(null);

  const upd = (id, field, val) => set({ ...s, actionItems: actions.map(a => a.id === id ? { ...a, [field]: val } : a) });
  const del = id => set({ ...s, actionItems: actions.filter(a => a.id !== id) });
  const add = () => set({ ...s, actionItems: [...actions, { id: uid(), summary: "", owner: (me && (me.name || me.email)) || "Me", priority: "Medium", type: "Task", parent: "", source: "manual", mine: true }] });

  // Gather context from the prior stages for the AI to reason over.
  const generate = async () => {
    setBusy(true); setErr("");
    try {
      const meName = (me && (me.name || me.email)) || "me";
      const memoActions = (s.memos || []).flatMap(m => (m.actionItems || []).map(a => `[memo: ${m.memoTitle || "untitled"}] ${a.summary} (owner: ${a.owner || "?"}, ${a.priority || "Medium"})`)).join("\n") || "(none)";
      const mtgs = (s.meetings || []).map(m => `[meeting] ${m.title} — ${m.topic || ""} (priority ${m.priority || "medium"}, attendees: ${(m.attendees || []).join(", ") || "?"})`).join("\n") || "(none)";
      const risksTxt = (s.risks || []).map(r => `[risk] ${r.title} (severity ${r.severity}, status ${r.status || "Open"})${r.detail ? " — " + r.detail : ""}`).join("\n") || "(none)";
      const remTxt = (s.reminders || []).map(r => `[reminder] ${r.text} (due ${r.date || "?"}, ${r.priority || "medium"})`).join("\n") || "(none)";
      const out = await callClaudeJSON(
        `You are a chief-of-staff AI building a PERSONAL action list for ${meName}, a technical program manager. From the workflow context, determine what ${meName} personally needs to do, follow up on, or drive — NOT tasks owned by other people. Pull from: meeting-memo action items where ${meName} is the owner or clearly responsible; scheduled meetings ${meName} must run, prep, or schedule; risks whose mitigation ${meName} drives; and ${meName}'s reminders. For each, write a clear action, a short rationale of why it's theirs and what to do, a priority, and where it came from. Respond ONLY with JSON.`,
        `Project: ${s.projectName}\nMe: ${meName}\n\nMemo action items:\n${memoActions}\n\nScheduled meetings:\n${mtgs}\n\nRisks:\n${risksTxt}\n\nReminders:\n${remTxt}\n\nReturn JSON: {"actions":[{"summary":"what I need to do","detail":"why this is mine and what to do about it","priority":"High|Medium|Low","origin":"Memo: X | Meeting: Y | Risk: Z | Reminder: W"}]}`,
        7000);
      const manual = actions.filter(a => a.source === "manual");   // keep my hand-added items
      const gen = (out.actions || []).map(a => ({
        id: uid(), summary: a.summary || "", detail: a.detail || "", owner: meName,
        priority: a.priority || "Medium", type: "Task", parent: "", source: a.origin || "synthesized", mine: true }));
      set({ ...s, actionItems: [...gen, ...manual] });
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };

  const sorted = [...actions].sort((a, b) => priRank(a.priority) - priRank(b.priority));
  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div className="card">
        <CardHead title="My Action Items" right={
          <div style={{ display: "flex", gap: 8 }}>
            <button className="btn btn-primary btn-sm" disabled={busy} onClick={generate}>{busy ? <><Spinner /> Synthesizing your action list…</> : actions.some(a => a.source !== "manual") ? "Re-generate" : "Generate my action list"}</button>
            <button className="btn btn-ghost btn-sm" disabled={busy} onClick={add}>+ Add</button>
          </div>} />
        <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5 }}>
          Your personal list — what <strong>you</strong> need to do, drive, or follow up on. Generated by analyzing your meeting-memo items, scheduled meetings, risks you own, and your reminders. Click an item for details. These become your new Jira tickets in the next step.
        </div>
        <ErrBox msg={err} />
      </div>
      {!actions.length ? <EmptyState icon="◇" title="No action items yet" sub="Click “Generate my action list” to pull together what's yours from across the workflow, or add items manually." />
        : sorted.map(a => {
          const open = openId === a.id;
          return (
          <div key={a.id} className="card" style={{ padding: 0, overflow: "hidden" }}>
            <div onClick={() => setOpenId(open ? null : a.id)} style={{ padding: "12px 14px", cursor: "pointer", display: "flex", alignItems: "flex-start", gap: 10 }}>
              <span style={{ fontSize: 11, color: "var(--muted)", flexShrink: 0, marginTop: 2, transform: open ? "rotate(90deg)" : "none", transition: "transform .15s" }}>▸</span>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 10 }}>
                  <span style={{ fontSize: 13, lineHeight: 1.45 }}>{a.summary || <span style={{ color: "var(--muted)" }}>Untitled action</span>}</span>
                  {a.priority && <span className={`tag ${priTag(a.priority)}`} style={{ fontSize: 9, flexShrink: 0 }}>{a.priority}</span>}
                </div>
                {a.source && a.source !== "manual" && <div style={{ fontSize: 9, color: "var(--muted)", fontFamily: "var(--font-mono)", lineHeight: 1.5, marginTop: 5 }}>{a.source}</div>}
              </div>
            </div>
            {open && (
              <div style={{ padding: "0 14px 14px 31px", borderTop: "1px solid var(--border)" }}>
                {a.detail && <div style={{ fontSize: 12.5, lineHeight: 1.6, color: "var(--text)", margin: "12px 0" }}>{a.detail}</div>}
                <textarea rows={2} value={a.summary} onChange={e => upd(a.id, "summary", e.target.value)} placeholder="Action summary" style={{ marginBottom: 8 }} />
                <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: 8 }}>
                  <input type="text" value={a.owner} onChange={e => upd(a.id, "owner", e.target.value)} placeholder="Owner" />
                  <select value={a.priority} onChange={e => upd(a.id, "priority", e.target.value)}>
                    {["Highest", "High", "Medium", "Low"].map(p => <option key={p}>{p}</option>)}
                  </select>
                  <select value={a.type} onChange={e => upd(a.id, "type", e.target.value)}>
                    {["Task", "Story", "Bug", "Sub-task"].map(t => <option key={t}>{t}</option>)}
                  </select>
                  <select value={a.parent || ""} onChange={e => upd(a.id, "parent", e.target.value)}>
                    <option value="">No parent</option>
                    {epics.map(ep => <option key={ep} value={ep}>{ep}</option>)}
                  </select>
                </div>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10 }}>
                  <span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{a.source && a.source !== "manual" ? `from: ${a.source}` : "added manually"}</span>
                  <button className="btn btn-warn btn-sm" onClick={() => del(a.id)}>✕ Remove</button>
                </div>
              </div>
            )}
          </div>
          );
        })}
    </div>
  );
}

// ── 10 · Update Jira ──────────────────────────────────────────────────────────
function StageJira({ s }) {
  const actions = (s.actionItems || []).filter(a => a.summary?.trim());
  const csv = buildJiraImportCSV(actions.map(a => ({ summary: a.summary, type: a.type, priority: a.priority, assignee: a.owner, parent: a.parent, description: `Created from ${a.source} · ${s.projectName}` })));
  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div className="card">
        <CardHead title="Jira Update File" right={
          <button className="btn btn-primary btn-sm" disabled={!actions.length}
            onClick={() => download(`jira-import-${s.projectName.replace(/\W+/g, "-")}.csv`, csv, "text/csv")}>Export Jira CSV</button>} />
        <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5 }}>
          {actions.length} new ticket{actions.length === 1 ? "" : "s"} ready. Uses current Parent-based linking (External System Import in Jira). WIP status changes from step 04 stay on the board view; this file covers new tickets only.
        </div>
      </div>
      {!actions.length ? <EmptyState icon="⤓" title="Nothing to export" sub="Add or generate action items in step 09 first." />
        : <div className="card"><CardHead title="Preview" />
          <pre style={{ fontFamily: "var(--font-mono)", fontSize: 11, lineHeight: 1.6, overflowX: "auto", color: "var(--text)", whiteSpace: "pre" }}>{csv}</pre>
        </div>}
    </div>
  );
}

// ── 11 · Weekly Status ────────────────────────────────────────────────────────
function StageStatus({ s, set, me }) {
  const [busy, setBusy] = useState(false), [err, setErr] = useState(""), [groundMsg, setGroundMsg] = useState("");
  const run = async () => {
    setBusy(true); setErr("");
    try {
      const tickets = s.board?.tickets || [];
      const counts = COLUMNS.map(c => `${c}: ${tickets.filter(t => columnOf(t.status) === c).length}`).join(", ");

      // ── Weekly window: start of last week → now (covers this week + last week). ──
      const now = new Date();
      const windowStart = new Date(now.getTime() - 14 * 86400000);
      const fmt = d => d.toISOString().slice(0, 10);
      const inWindow = t => {
        const d = t.meta?.date ? new Date(t.meta.date) : null;
        return d && !isNaN(d) && d >= windowStart;   // only dated transcripts within the window
      };
      const allTs = s.transcripts || [];
      const recentTs = [...allTs].filter(inWindow).sort((a, b) => new Date(b.meta.date) - new Date(a.meta.date));
      const undatedCount = allTs.filter(t => !(t.meta?.date && !isNaN(new Date(t.meta.date)))).length;

      // Build a window-scoped transcript context (NOT the full history).
      const weeklyTranscripts = recentTs.length
        ? "\n\nMeeting transcripts from THIS WEEK and LAST WEEK only (newest first):\n" + recentTs.map(t => {
            const m = t.meta;
            const hdr = [m?.topic || t.name, `date: ${m.date}`, (m?.participants || []).length ? `participants: ${m.participants.join(", ")}` : null].filter(Boolean).join(" · ");
            return `--- ${hdr} ---\n${t.text}`;
          }).join("\n\n")
        : "\n\n(No meeting transcripts dated within the last two weeks.)";

      // Only ACTIVE risks — exclude resolved and possibly-stale ones from a weekly update.
      const activeRisks = (s.risks || []).filter(r => r.status !== "Resolved" && r.recency !== "stale");
      const risksLine = activeRisks.map(r => `${r.title} (${r.severity})`).join("; ") || "none currently active";

      // Ground the weekly against connected sources: what actually shipped/released in this window?
      // Lets Highlights cite real delivered work, not just standup chatter. Degrades.
      let releaseEvidence = "";
      if (IS_LOCAL_APP) {
        setGroundMsg("⟳ Checking what shipped this period across connected sources…");
        try {
          const out = await api("/api/connectors/analyze", { method: "POST", body: {
            question: `For project "${s.projectName}", what features, releases, builds, or software configurations actually SHIPPED or were delivered between ${fmt(windowStart)} and ${fmt(now)} (the last two weeks)? List only what was genuinely released or delivered in that window.`,
            hint: `Weekly status release evidence. Route to deployment/release data (recent releases, builds, software configurations) and program status. Read-only.`,
            maxServers: 4, maxRounds: 4,
          }});
          if (out?.answer?.trim()) {
            releaseEvidence = out.answer;
            setGroundMsg(`✔ Grounded against connected sources — ${(out.servers || []).join(", ") || "connected sources"}.`);
          } else setGroundMsg("No connected sources returned release data — using board + transcripts only.");
        } catch {
          setGroundMsg("⚠ Couldn't reach connected sources — writing status without release grounding.");
        }
      }
      const releaseBlock = releaseEvidence
        ? `\n\n=== RELEASE EVIDENCE (authoritative — what actually shipped in this window) ===\n${releaseEvidence}\n\nUse this to ground Highlights in real delivered work. Only cite items genuinely shipped in the window.`
        : "";

      const out = await callClaudeJSON(
        "You are a technical project manager writing the WEEKLY status update delivered to management. It must cover ONLY this week and last week — the latest state. Be factual and current. If a RELEASE EVIDENCE section is provided, use it as authoritative for what actually shipped. Respond ONLY with JSON.",
        `Write the weekly status for ${s.projectName}. Today is ${fmt(now)}. The reporting window is ${fmt(windowStart)} to ${fmt(now)} (this week + last week).\n\n` +
        `STRICT RULES:\n` +
        `- Report ONLY what is current or happened within the last two weeks. Do NOT mention old, stale, or already-resolved issues, tasks, or risks. If something was resolved or is no longer active, leave it out entirely.\n` +
        `- Use the latest board status, the recent transcripts, and any release evidence below as the source of truth. The transcripts provided are already filtered to this/last week.\n` +
        `- "Highlights" = what actually progressed or completed in the last two weeks (prefer release evidence where available). "Next Week" = the immediate upcoming focus. Keep it to what management needs now.\n\n` +
        `Latest board status — ${counts}\n` +
        `Active risks/blockers (current only): ${risksLine}\n` +
        `New action items recently: ${(s.actionItems || []).length}` +
        (undatedCount ? `\n(Note: ${undatedCount} undated transcript(s) were excluded from this weekly window.)` : "") +
        releaseBlock +
        weeklyTranscripts +
        `\n\nReturn JSON with TWO keys:\n` +
        `1. "execSummary": an object with two arrays for the executive summary (skimmable, management-pitched, covering only this/last week):\n` +
        `   - "statusSummary": 5-8 bullet strings. Each is a SHORT, scannable fragment — roughly 6-12 words, no more than 14. State the fact directly, no lead-ins. Prefer noun/verb fragments over full sentences. Good: "Repository inventory completed across 88 Bitbucket projects". Bad: "The team has completed the repository inventory across all 88 of the Bitbucket projects this week." No sub-bullets.\n` +
        `   - "risks": the currently-active risks/blockers as SHORT bullet fragments (roughly 6-12 words each, max 14). State the risk plainly. Good: "Artifactory connectivity issues delaying binary migration validation". No sub-bullets.\n` +
        `2. "status": a COMPLETE markdown document with ALL of these sections, each a "## " header with current content (2-5 bullets), never blank:\n\n## Overall Status\n(One RAG line: 🔴 RED / 🟡 AMBER / 🟢 GREEN — one-sentence current summary.)\n\n## Highlights\n(What progressed/completed THIS week and last week.)\n\n## Risks & Blockers\n(Only currently-active risks and blockers.)\n\n## Next Week\n(Immediate planned focus.)\n\n## Asks\n(Help/decisions needed from management now. If none, say "None this week.")\n\nReturn exactly: {"execSummary":{"statusSummary":["..."],"risks":["..."]},"status":"<the full markdown document>"}`,
        6000);
      const st = out.status;
      let statusStr;
      if (typeof st === "string") statusStr = st;
      else if (st == null) statusStr = "";
      else if (typeof st === "object") {
        // Model returned structured sections — assemble into markdown so it renders fully.
        const titleize = k => k.replace(/[_-]+/g, " ").replace(/\b\w/g, c => c.toUpperCase());
        const sectionToMd = v => Array.isArray(v) ? v.map(x => `- ${typeof x === "string" ? x : JSON.stringify(x)}`).join("\n") : (typeof v === "string" ? v : JSON.stringify(v, null, 2));
        statusStr = Object.entries(st).map(([k, v]) => `## ${titleize(k)}\n\n${sectionToMd(v)}`).join("\n\n");
      } else statusStr = String(st);

      // Executive summary: prefer the model's structured object; fall back to
      // pulling Highlights / Risks bullets out of the markdown if it was omitted.
      const toBullets = v => Array.isArray(v)
        ? v.map(x => String(x)
            .replace(/^[-•*]\s*/, "")
            .replace(/^(the team has|the team|we have|we've|we |this week,?\s*|currently,?\s*)/i, "")
            .replace(/\.\s*$/, "")
            .trim())
           .map(x => x.charAt(0).toUpperCase() + x.slice(1))
           .filter(Boolean)
        : [];
      const sectionBullets = (md, header) => {
        const re = new RegExp(`##\\s*${header}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, "i");
        const m = String(md).match(re);
        if (!m) return [];
        return m[1].split("\n").map(l => l.trim()).filter(l => /^[-•*]\s+/.test(l)).map(l => l.replace(/^[-•*]\s+/, "").trim());
      };
      let exec = (out.execSummary && typeof out.execSummary === "object")
        ? { statusSummary: toBullets(out.execSummary.statusSummary), risks: toBullets(out.execSummary.risks) }
        : { statusSummary: [], risks: [] };
      if (!exec.statusSummary.length) exec.statusSummary = sectionBullets(statusStr, "Highlights");
      if (!exec.risks.length) exec.risks = sectionBullets(statusStr, "Risks & Blockers");

      set({ ...s, weeklyStatus: statusStr, weeklyExec: exec });
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };
  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div className="card">
        <CardHead title="Weekly Status for Management" right={
          <button className="btn btn-primary btn-sm" disabled={busy || !s.board} onClick={run}>
            {busy ? <><Spinner /> Writing the weekly status…</> : s.weeklyStatus ? "Regenerate" : "Generate Status"}</button>} />
        {!s.board ? <EmptyState icon="⚠" title="Import a board first" sub="The status update draws on the board, risks and action items." />
          : !s.weeklyStatus && !busy ? <EmptyState icon="✦" title="No status yet" sub="Generate a management-ready weekly update covering this week and last week." /> : null}
        {s.board && <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5, marginTop: 4, fontFamily: "var(--font-mono)" }}>Covers the last two weeks (this + last week). Uses the latest board, active risks, and transcripts dated in that window — old or resolved items are excluded.</div>}
        {groundMsg && <div style={{ fontSize: 11, marginTop: 8, fontFamily: "var(--font-mono)", color: groundMsg.startsWith("⚠") ? "var(--gold)" : groundMsg.startsWith("✔") ? "var(--accent)" : "var(--muted)" }}>{groundMsg}</div>}
        <ErrBox msg={err} />
      </div>
      {s.weeklyStatus && (() => {
        const exec = s.weeklyExec || { statusSummary: [], risks: [] };
        const ragMatch = String(s.weeklyStatus).match(/##\s*Overall Status[^\n]*\n+\s*([^\n]+)/i);
        const rag = ragMatch ? ragMatch[1].replace(/^[-•*]\s*/, "").trim() : "";
        const today = new Date().toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
        const execMd = `## Executive Summary\n\n**Status Summary**\n${exec.statusSummary.map(b => `- ${b}`).join("\n")}\n\n**Risks**\n${exec.risks.map(b => `- ${b}`).join("\n")}`;
        const fullExport = `# ${s.projectName || "Project"} — Weekly Status\n\n_Reporter: ${(me && (me.name || me.email)) || ""}  ·  Last updated: ${today}_\n\n${execMd}\n\n---\n\n${s.weeklyStatus}`;
        const col = (title, bullets, accent) => (
          <div style={{ flex: "1 1 280px", minWidth: 0, border: "1px solid var(--border)", borderRadius: 10, background: "var(--bg)", overflow: "hidden" }}>
            <div style={{ padding: "9px 14px", borderBottom: `2px solid ${accent}`, fontSize: 13, fontWeight: 800, letterSpacing: ".02em" }}>{title}</div>
            <ul style={{ margin: 0, padding: "10px 14px 12px 28px", display: "flex", flexDirection: "column", gap: 6 }}>
              {bullets.length ? bullets.map((b, i) => <li key={i} style={{ fontSize: 12.5, lineHeight: 1.5 }}>{b}</li>)
                : <li style={{ fontSize: 12, color: "var(--muted)", listStyle: "none", marginLeft: -14 }}>None this period.</li>}
            </ul>
          </div>
        );
        return (
          <div className="card">
            <CardHead title="Executive Summary" right={
              <button className="btn btn-secondary btn-sm" onClick={() => download(`weekly-status-${(s.projectName || "project").replace(/\W+/g, "-")}.md`, fullExport, "text/markdown")}>Export .md</button>} />
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", flexWrap: "wrap", gap: 8, marginBottom: 12, fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>
              <span>{(me && (me.name || me.email)) ? `Reporter: ${me.name || me.email}` : ""}</span>
              <span>Last updated: {today}</span>
            </div>
            {rag && <div style={{ fontSize: 14, fontWeight: 700, lineHeight: 1.5, marginBottom: 14, padding: "10px 14px", borderRadius: 8, background: "var(--panel)", border: "1px solid var(--border)" }}>{rag}</div>}
            <div style={{ display: "flex", gap: 14, flexWrap: "wrap" }}>
              {col("Status Summary", exec.statusSummary, "var(--accent)")}
              {col("Risks", exec.risks, "var(--warn)")}
            </div>
          </div>
        );
      })()}
      {s.weeklyStatus && <div className="card"><CardHead title="Detailed Status" right={
        <button className="btn btn-secondary btn-sm" onClick={() => download(`weekly-status-detailed-${(s.projectName || "project").replace(/\W+/g, "-")}.md`, `# ${s.projectName || "Project"} — Weekly Status\n\n${s.weeklyStatus}`, "text/markdown")}>Export .md</button>} />
        <div style={{ fontSize: 13, lineHeight: 1.65, color: "var(--text)" }}><Markdown text={typeof s.weeklyStatus === "string" ? s.weeklyStatus : String(s.weeklyStatus ?? "")} /></div>
      </div>}
    </div>
  );
}

// ══════════════════════════════════════════════════════════════════════════════
// SESSION SETUP (no project yet)
// ══════════════════════════════════════════════════════════════════════════════
function Setup({ onStart, onLoad, theme, onToggleTheme }) {
  const [name, setName] = useState("");
  const [loadErr, setLoadErr] = useState("");
  const fileRef = useRef();
  const load = e => {
    const f = e.target.files?.[0]; if (!f) return;
    setLoadErr("");
    const r = new FileReader();
    r.onload = () => { try { onLoad(JSON.parse(String(r.result))); } catch { setLoadErr("Not a valid TPM session file."); } };
    r.readAsText(f);
  };
  return (
    <div style={{ minHeight: "100vh", background: "var(--bg)", display: "flex", flexDirection: "column" }}>
      <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 28px", borderBottom: "1px solid var(--border)", background: "var(--surface)" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <span style={{ fontFamily: "var(--font-mono)", fontWeight: 700, fontSize: 13, color: "var(--accent)" }}>AIAD</span>
          <span style={{ fontSize: 12, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>TPM Daily Workflow</span>
        </div>
        <ThemeToggle theme={theme} onToggle={onToggleTheme} />
      </header>
      <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
        <div className="card fade-up" style={{ width: "100%", maxWidth: 460 }}>
          <div style={{ fontSize: 20, fontWeight: 800, marginBottom: 6 }}>Start a daily session</div>
          <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.55, marginBottom: 18 }}>
            One project per session. State is held in-memory and saved via <strong>Save Session</strong> — Jira stays the source of truth across days.
          </div>
          <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 5 }}>Project name</div>
          <input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Mobile Onboarding Revamp"
            onKeyDown={e => { if (e.key === "Enter" && name.trim()) onStart(name.trim()); }} />
          <div style={{ display: "flex", gap: 10, marginTop: 16 }}>
            <button className="btn btn-primary" disabled={!name.trim()} onClick={() => onStart(name.trim())} style={{ flex: 1, justifyContent: "center" }}>New Session</button>
            <button className="btn btn-secondary" onClick={() => fileRef.current?.click()}>Load Session</button>
            <input ref={fileRef} type="file" accept=".json" onChange={load} style={{ display: "none" }} />
          </div>
          <ErrBox msg={loadErr} />
        </div>
      </div>
    </div>
  );
}

// ══════════════════════════════════════════════════════════════════════════════
// VERSION HISTORY PANEL (local app)
// ══════════════════════════════════════════════════════════════════════════════
function VersionPanel({ projectId, currentState, canEdit, viewingId, onView, onRestore, onDelete, onClose, onSaved }) {
  const [revs, setRevs] = useState([]);
  const [label, setLabel] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const [confirmDel, setConfirmDel] = useState(null);
  const fmt = ts => new Date(ts).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
  const load = () => api(`/api/projects/${projectId}/revisions`).then(d => setRevs(d.revisions || [])).catch(e => setErr(e.message));
  useEffect(() => { load(); /* eslint-disable-next-line */ }, [projectId]);
  const saveVersion = async () => {
    setBusy(true); setErr("");
    const name = label.trim() || fmt(Date.now());   // default the name to a timestamp
    try { await api(`/api/projects/${projectId}/revisions`, { method: "POST", body: { label: name, state: currentState } }); setLabel(""); onSaved && onSaved(); await load(); }
    catch (e) { setErr(e.message); } finally { setBusy(false); }
  };
  const del = async (id) => {
    setErr("");
    try { await api(`/api/projects/${projectId}/revisions/${id}`, { method: "DELETE" }); setConfirmDel(null); onDelete && onDelete(id); await load(); }
    catch (e) { setErr(e.message); }
  };
  return (
    <div className="card fade-up" style={{ marginBottom: 18 }}>
      <CardHead title="Versions" right={<button className="btn btn-ghost btn-sm" onClick={onClose}>Close</button>} />
      {canEdit && (
        <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
          <input type="text" value={label} onChange={e => setLabel(e.target.value)} placeholder="Name (defaults to date & time)"
            style={{ flex: 1 }} onKeyDown={e => { if (e.key === "Enter") saveVersion(); }} />
          <button className="btn btn-primary btn-sm" disabled={busy} onClick={saveVersion}>{busy ? <><Spinner /> Saving…</> : "Save version"}</button>
        </div>
      )}
      <ErrBox msg={err} />
      {!revs.length ? <div style={{ fontSize: 12, color: "var(--muted)" }}>No saved versions yet — save one to start a history.</div> : (
        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
          {revs.map(r => (
            <div key={r.id} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, padding: "8px 10px", borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg)" }}>
              <div style={{ minWidth: 0 }}>
                <div style={{ fontSize: 13, fontWeight: 600 }}>{r.label || fmt(r.created_at)}</div>
                <div style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{fmt(r.created_at)}{r.created_by ? ` · ${r.created_by}` : ""}</div>
              </div>
              <div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
                <button className="btn btn-secondary btn-sm" onClick={() => onView(r)}>View</button>
                {canEdit && <button className="btn btn-secondary btn-sm" onClick={() => onRestore(r)}>Restore</button>}
                {canEdit && (confirmDel === r.id
                  ? <><button className="btn btn-warn btn-sm" onClick={() => del(r.id)}>Delete?</button>
                      <button className="btn btn-secondary btn-sm" onClick={() => setConfirmDel(null)}>No</button></>
                  : <button className="btn btn-ghost btn-sm" onClick={() => setConfirmDel(r.id)}>Delete</button>)}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ══════════════════════════════════════════════════════════════════════════════
// SPEC WORKFLOW — ported from AIAD platform onto the shared session/autosave model
// ══════════════════════════════════════════════════════════════════════════════
const sleepMs = ms => new Promise(r => setTimeout(r, ms));
function isolateJSON(text) {
  let cleaned = (text || "").replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```\s*$/i, "").trim();
  const objStart = cleaned.indexOf("{"), arrStart = cleaned.indexOf("[");
  const useArr = arrStart !== -1 && (objStart === -1 || arrStart < objStart);
  if (useArr) { const end = cleaned.lastIndexOf("]"); if (end !== -1) cleaned = cleaned.slice(arrStart, end + 1); }
  else if (objStart !== -1) { const end = cleaned.lastIndexOf("}"); if (end !== -1) cleaned = cleaned.slice(objStart, end + 1); }
  return escapeControlChars(cleaned);
}
function extractSpecJSON(raw) {
  if (!raw) throw new Error("Empty response from API");
  let c = raw.trim().replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```\s*$/i, "").trim();
  const s = c.indexOf("{"), e = c.lastIndexOf("}");
  if (s !== -1 && e !== -1) c = c.slice(s, e + 1);
  try { return JSON.parse(escapeControlChars(c)); } catch { return salvageJSON(raw); }
}
// Raw-text Claude call (multi-round) routed through the shared AI endpoint.
async function callClaudeText(systemPrompt, userContent, maxTokens = 8000) {
  const text = await generateWithContinuation({ system: systemPrompt, userContent, maxTokens });
  if (!text) throw new Error("Empty response from API");
  return text;
}

const SWIMLANE_SYSTEM = `You are a Business Analysis AI that converts a Product Requirements Document (PRD) into a structured, actor-aware swimlane process flow.

A PRD describes features and requirements but rarely states the end-to-end flow or which tier owns each step. Reconstruct the flow and assign every step to a lane, being explicit about what the PRD states vs. what you infer.

Produce:
1. Swimlane lanes — one per actor or system tier that performs work. Tier is one of: "frontend", "backend", "platform", "cloud", or "actor".
2. Ordered process steps, each in exactly ONE lane, action described in <=10 words.
3. Decision points with their branches.
4. A "group" on EVERY step — the functional module / feature area it belongs to. Emit one module's steps contiguously, reusing the exact same group string.

For EVERY lane and step include "inferred" (true if you inferred the owner/tier), "confidence" (0.0–1.0), and "sourceRef" (a short PRD anchor or "inferred").

Respond ONLY with valid JSON — no markdown:
{"title":"flow title","summary":"2-3 sentence summary","lanes":[{"id":"L1","name":"Lane name","tier":"frontend|backend|platform|cloud|actor","inferred":false,"confidence":0.9,"sourceRef":"section 10.2"}],"steps":[{"id":"S1","lane":"L1","group":"Module name","action":"short action","type":"start|process|decision|end","next":"S2","branches":[{"label":"Yes","to":"S3"}],"inferred":false,"confidence":0.9,"sourceRef":"section 9"}],"systems":["SystemA"],"entities":["Entity1"]}
Rules: first step type "start", last "end". Decision steps use "branches" (omit "next"); others use "next". Every step.lane MUST equal one lanes[].id.`;

const LANE_TIERS = {
  frontend: { label: "Frontend", color: "var(--accent2)" },
  backend: { label: "Backend", color: "var(--accent)" },
  platform: { label: "Platform", color: "var(--purple)" },
  cloud: { label: "Cloud", color: "var(--gold)" },
  actor: { label: "Actor", color: "var(--muted)" },
};
const laneColor = tier => (LANE_TIERS[tier] && LANE_TIERS[tier].color) || "var(--muted)";

const SPEC_STAGES = [
  { id: "flowchart",   n: "01", label: "PRD → Flow",    color: "var(--st1)",  ai: true },
  { id: "feasibility", n: "02", label: "Feasibility",   color: "var(--st2)",  ai: true },
  { id: "gap",         n: "03", label: "Gap Analysis",  color: "var(--st3)",  ai: true },
  { id: "epics",       n: "04", label: "Epics",         color: "var(--st4)",  ai: true },
  { id: "sizing",      n: "05", label: "Story Sizing",  color: "var(--st6)",  ai: true },
  { id: "jiracompare", n: "06", label: "Jira Compare",  color: "var(--st7)",  ai: true, optional: true },
  { id: "testcases",   n: "07", label: "Test Cases",    color: "var(--st8)",  ai: true },
  { id: "techstack",   n: "08", label: "Tech Stack",    color: "var(--st9)",  ai: true, optional: true },
  { id: "architecture",n: "09", label: "Architecture",  color: "var(--st10)", ai: true, optional: true },
  { id: "handoff",     n: "10", label: "Handoff",       color: "var(--st12)", ai: false },
];

// Normalize swimlane IR: flatten lane names onto each step, fill `next`.
function normalizeFlow(raw) {
  const lanes = raw.lanes || [];
  const laneName = id => { const l = lanes.find(x => x.id === id); return l ? l.name : id; };
  const steps = (raw.steps || []).map(s => ({ ...s, actor: laneName(s.lane), next: s.next || (s.branches && s.branches[0] && s.branches[0].to) || null }));
  return { ...raw, actors: lanes.map(l => l.name), systems: raw.systems || [], entities: raw.entities || [], steps };
}

// ── Spec 01 · PRD → Flow ──────────────────────────────────────────────────────
// Connected swimlane flow: lanes as columns, one step per row. Nodes grow to fit
// their text; edges follow each step's `next` / decision `branches` (incl.
// cross-lane handoffs). Click a node to reassign its lane; inferred / low-
// confidence steps are flagged. Module groups render as collapsible bands.
function SwimlaneView({ ir, onReassign }) {
  const [sel, setSel] = useState(null);
  const [exportMsg, setExportMsg] = useState("");
  const [collapsed, setCollapsed] = useState(() => new Set());
  const lanes = ir.lanes || [];
  const steps = ir.steps || [];
  const svgRef = useRef(null);
  if (!lanes.length || !steps.length) return null;

  // Layout constants — sized for breathing room when one lane owns many steps.
  const LEFT = 34, LANE_W = 252, NODE_W = 214, HEAD = 44, TOP = 6, GAP = 50, CH = 14;
  const PADV = 11, LINEH = 16, NODE_BUDGET = 30, HEAD_BUDGET = 28;
  const n = lanes.length;
  const W = LEFT + n * LANE_W + 16;

  const laneIdx = id => { const i = lanes.findIndex(l => l.id === id); return i < 0 ? 0 : i; };
  const cx = i => LEFT + i * LANE_W + LANE_W / 2;

  // Wrap text to as many lines as needed — NO truncation. Long single words split.
  const wrap = (txt, budget) => {
    const out = [];
    String(txt || "").split(/\s+/).filter(Boolean).forEach(w => {
      while (w.length > budget) { out.push(w.slice(0, budget)); w = w.slice(budget); }
      out.push(w);
    });
    const lines = []; let cur = "";
    for (const w of out) {
      if (!cur) cur = w;
      else if ((cur + " " + w).length > budget) { lines.push(cur); cur = w; }
      else cur += " " + w;
    }
    if (cur) lines.push(cur);
    return lines.length ? lines : [""];
  };

  // Lane-header band grows to fit the tallest wrapped lane name.
  const HEAD_LINEH = 12;
  const laneLines = lanes.map(l => wrap(l.name, HEAD_BUDGET));
  const headLines = Math.max(1, ...laneLines.map(a => a.length));
  const headerH = Math.max(HEAD - 6, PADV + headLines * HEAD_LINEH + 6);

  // Module grouping: a run of consecutive same-group steps renders as a labeled,
  // collapsible band. Collapsing hides that module's nodes/edges and reflows.
  const groupOf = s => String(s.group || s.phase || "").trim();
  const hasGroups = steps.some(s => groupOf(s));
  const allGroups = hasGroups ? [...new Set(steps.map(groupOf).filter(Boolean))] : [];
  const toggleGroup = (g) => setCollapsed(prev => { const m = new Set(prev); m.has(g) ? m.delete(g) : m.add(g); return m; });
  const allCollapsed = allGroups.length > 0 && allGroups.every(g => collapsed.has(g));
  const toggleAll = () => setCollapsed(allCollapsed ? new Set() : new Set(allGroups));
  const GROUP_GAP = 28;
  const GROUP_HEAD = hasGroups ? 30 : 0;

  // Per-step geometry with variable height + cumulative y.
  const startY = TOP + headerH + 16;
  const geo = {};
  const bands = [];
  let y = startY, prevGroup = null, curBand = null;
  steps.forEach((s, r) => {
    const g = groupOf(s);
    if (hasGroups && g !== prevGroup) {
      if (bands.length) y += GROUP_GAP;
      curBand = { group: g || "Other", collapsed: collapsed.has(g), count: 0, yTop: y, yBottom: y };
      bands.push(curBand);
      y += GROUP_HEAD;
      curBand.yBottom = y;
      if (curBand.collapsed) y += 8;
    }
    if (curBand) curBand.count++;
    const hidden = hasGroups && curBand && curBand.collapsed;
    if (!hidden) {
      const li = laneIdx(s.lane);
      const lines = wrap(s.action, NODE_BUDGET);
      const h = PADV * 2 + lines.length * LINEH;
      geo[s.id] = { row: r, li, x: cx(li) - NODE_W / 2, y, h, cxv: cx(li), cy: y + h / 2, bottom: y + h, lines, step: s };
      y += h + GAP;
      if (curBand) curBand.yBottom = y - GAP;
    }
    prevGroup = g;
  });
  const H = y - (hasGroups ? 0 : GAP) + 16;

  const STY = {
    frontend: { fill: "rgba(168,85,247,.12)", stroke: "var(--purple)" },
    backend:  { fill: "rgba(228,0,43,.10)",  stroke: "var(--accent)" },
    platform: { fill: "rgba(0,106,255,.10)",  stroke: "var(--accent2)" },
    cloud:    { fill: "rgba(245,197,66,.12)",  stroke: "var(--gold)" },
    actor:    { fill: "rgba(90,96,128,.14)",   stroke: "var(--muted)" },
  };
  const sty = t => STY[t] || STY.actor;
  const laneTier = id => (lanes.find(l => l.id === id)?.tier);

  // Edges from next / branches.
  const edges = [];
  steps.forEach(s => {
    const g = geo[s.id]; if (!g) return;
    if (s.type === "decision" && (s.branches || []).length) {
      s.branches.forEach(b => { if (geo[b.to]) edges.push({ from: s.id, to: b.to, label: b.label || "" }); });
    } else if (s.next && geo[s.next]) {
      edges.push({ from: s.id, to: s.next, label: "" });
    }
  });

  const edgePath = (e) => {
    const a = geo[e.from], b = geo[e.to];
    if (b.row === a.row + 1) {
      if (a.li === b.li) return `M ${a.cxv} ${a.bottom} L ${b.cxv} ${b.y}`;
      const midY = (a.bottom + b.y) / 2;
      return `M ${a.cxv} ${a.bottom} L ${a.cxv} ${midY} L ${b.cxv} ${midY} L ${b.cxv} ${b.y}`;
    }
    return `M ${a.x} ${a.cy} L ${CH} ${a.cy} L ${CH} ${b.cy} L ${b.x} ${b.cy}`;
  };

  const labelPos = (e) => {
    const a = geo[e.from], b = geo[e.to];
    if (b.row === a.row + 1 && a.li === b.li) return { x: a.cxv + 8, y: (a.bottom + b.y) / 2, anchor: "start" };
    return { x: b.x - 7, y: b.cy, anchor: "end" };
  };

  // Build a standalone SVG string: inline referenced CSS variables + a background
  // so the file renders correctly outside the app.
  const buildStandaloneSVG = () => {
    const src = svgRef.current;
    if (!src) return null;
    const clone = src.cloneNode(true);
    clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
    clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
    clone.setAttribute("width", W);
    clone.setAttribute("height", H);
    clone.setAttribute("viewBox", `0 0 ${W} ${H}`);
    let markup = new XMLSerializer().serializeToString(clone);
    const cs = getComputedStyle(document.documentElement);
    const vars = new Set(); let m; const re = /var\(\s*(--[\w-]+)\s*\)/g;
    while ((m = re.exec(markup))) vars.add(m[1]);
    const decls = [...vars].map(v => `${v}:${cs.getPropertyValue(v).trim()};`).join("");
    const bg = (cs.getPropertyValue("--surface").trim() || "#ffffff");
    const inject = `<style>svg{${decls}}</style><rect x="0" y="0" width="${W}" height="${H}" fill="${bg}"/>`;
    markup = markup.replace(/(<svg[^>]*>)/, `$1${inject}`);
    return { markup, bg };
  };

  const exportSVG = () => {
    const r = buildStandaloneSVG(); if (!r) { setExportMsg("Nothing to export yet."); return; }
    download("spec-flowchart.svg", `<?xml version="1.0" encoding="UTF-8"?>\n${r.markup}`, "image/svg+xml");
  };

  const exportPNG = async () => {
    const r = buildStandaloneSVG(); if (!r) { setExportMsg("Nothing to export yet."); return; }
    setExportMsg("");
    const scale = 2;
    const src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(r.markup);
    const img = new Image(); img.decoding = "async";
    try {
      img.src = src;
      await (img.decode ? img.decode() : new Promise((res, rej) => { img.onload = res; img.onerror = rej; }));
      const canvas = document.createElement("canvas");
      canvas.width = Math.max(1, Math.round(W * scale));
      canvas.height = Math.max(1, Math.round(H * scale));
      const ctx = canvas.getContext("2d");
      ctx.fillStyle = r.bg; ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.setTransform(scale, 0, 0, scale, 0, 0);
      ctx.drawImage(img, 0, 0);
      const save = (blob) => {
        if (!blob) { setExportMsg("PNG blocked by the sandbox — the SVG export works and converts cleanly."); return; }
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url; a.download = "spec-flowchart.png";
        document.body.appendChild(a); a.click(); document.body.removeChild(a);
        URL.revokeObjectURL(url);
      };
      if (canvas.toBlob) canvas.toBlob(save, "image/png");
      else {
        const durl = canvas.toDataURL("image/png");
        const bin = atob(durl.split(",")[1]); const bytes = new Uint8Array(bin.length);
        for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
        save(new Blob([bytes], { type: "image/png" }));
      }
    } catch (e) {
      setExportMsg(`Couldn't rasterize to PNG (${e?.message || "load/security error"}). Use the SVG export instead.`);
    }
  };

  const exportPDF = async () => {
    const r = buildStandaloneSVG(); if (!r) { setExportMsg("Nothing to export yet."); return; }
    setExportMsg("");
    // Dependency-free: rasterize the SVG to a JPEG on a canvas, embed it in a
    // hand-built single-page PDF. No external libraries (same approach as the
    // original AIAD flowchart export).
    const scale = 2;
    const src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(r.markup);
    const img = new Image(); img.decoding = "async";
    try {
      img.src = src;
      await (img.decode ? img.decode() : new Promise((res, rej) => { img.onload = res; img.onerror = rej; }));
      const canvas = document.createElement("canvas");
      canvas.width = Math.max(1, Math.round(W * scale));
      canvas.height = Math.max(1, Math.round(H * scale));
      const ctx = canvas.getContext("2d");
      ctx.fillStyle = r.bg; ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.setTransform(scale, 0, 0, scale, 0, 0);
      ctx.drawImage(img, 0, 0);
      const durl = canvas.toDataURL("image/jpeg", 0.92);
      if (!durl.startsWith("data:image/jpeg")) throw new Error("rasterization blocked");
      const b = atob(durl.split(",")[1]); const jpeg = new Uint8Array(b.length);
      for (let i = 0; i < b.length; i++) jpeg[i] = b.charCodeAt(i);
      const pdf = genImagePDF({
        title: ir?.title || "Swimlane Flowchart",
        subtitle: `${(ir?.steps || []).length} steps · ${(ir?.lanes || []).length} lanes · generated ${new Date().toLocaleDateString()}`,
        jpeg, pxW: canvas.width, pxH: canvas.height,
      });
      download("spec-flowchart.pdf", pdf, "application/pdf");
    } catch (e) {
      setExportMsg(`Couldn't build PDF (${e?.message || "raster/security error"}). The SVG export works and converts to PDF in any viewer.`);
    }
  };

  return (
    <div>
      {/* Reassign toolbar */}
      <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12, minHeight: 30,
        position: "sticky", top: 0, zIndex: 5, background: "var(--surface)", padding: "8px 0",
        borderBottom: sel && geo[sel] ? "1px solid var(--border)" : "none" }}>
        {sel && geo[sel] ? (
          <>
            <span style={{ fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--muted)" }}>{sel}</span>
            <span style={{ fontSize: 12, fontWeight: 600, maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{geo[sel].step.action}</span>
            <span style={{ fontSize: 11, color: "var(--muted)" }}>→ lane</span>
            <select value={geo[sel].step.lane} onChange={e => onReassign(sel, e.target.value)} style={{ width: "auto", minWidth: 150, fontSize: 11, padding: "5px 8px" }}>
              {lanes.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
            </select>
            {geo[sel].step.inferred && <span className="tag tag-gold" style={{ fontSize: 9 }}>inferred</span>}
            {geo[sel].step.improvised && <span className="tag tag-ok" style={{ fontSize: 9 }}>✦ resolved (added to close a gap)</span>}
            {typeof geo[sel].step.confidence === "number" && <span className="tag tag-muted" style={{ fontSize: 9 }}>{Math.round(geo[sel].step.confidence * 100)}%</span>}
            <button className="btn btn-secondary" style={{ fontSize: 10, padding: "4px 9px" }} onClick={() => setSel(null)}>Done</button>
          </>
        ) : (
          <span style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Click any step to reassign its lane · dashed lane header = inferred tier</span>
        )}
        <div style={{ marginLeft: "auto", display: "flex", gap: 8, alignItems: "center" }}>
          {exportMsg && <span style={{ fontSize: 10, color: "var(--warn)", maxWidth: 320, lineHeight: 1.3 }}>{exportMsg}</span>}
          {hasGroups && <button className="btn btn-secondary" style={{ fontSize: 10, padding: "4px 9px" }} title="Collapse or expand every module section" onClick={toggleAll}>{allCollapsed ? "▾ Expand all" : "▸ Collapse all"}</button>}
          <button className="btn btn-secondary" style={{ fontSize: 10, padding: "4px 9px" }} title="Download as PNG" onClick={exportPNG}>⤓ PNG</button>
          <button className="btn btn-secondary" style={{ fontSize: 10, padding: "4px 9px" }} title="Download as scalable SVG" onClick={exportSVG}>⤓ SVG</button>
          <button className="btn btn-secondary" style={{ fontSize: 10, padding: "4px 9px" }} title="Save as PDF (opens print dialog — choose Save as PDF)" onClick={exportPDF}>⤓ PDF</button>
        </div>
      </div>

      <div style={{ overflowX: "auto", paddingBottom: 6 }}>
        <svg ref={svgRef} width={W} height={H} style={{ display: "block" }}>
          <defs>
            <marker id="swArrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
              <path d="M2 1L8 5L2 9" fill="none" stroke="var(--muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
            </marker>
          </defs>

          {/* Lane dividers */}
          {lanes.map((l, i) => i > 0 && (
            <line key={`d${l.id}`} x1={LEFT + i * LANE_W} y1={TOP} x2={LEFT + i * LANE_W} y2={H - 8} stroke="var(--border)" strokeWidth="1" strokeDasharray="3 4" />
          ))}

          {/* Lane headers */}
          {lanes.map((l, i) => {
            const st = sty(l.tier);
            const hl = laneLines[i];
            const midY = TOP + headerH / 2;
            return (
              <g key={`h${l.id}`}>
                <clipPath id={`hclip-${l.id}`}>
                  <rect x={cx(i) - NODE_W / 2 + 3} y={TOP} width={NODE_W - 6} height={headerH} />
                </clipPath>
                <rect x={cx(i) - NODE_W / 2} y={TOP} width={NODE_W} height={headerH} rx="7" fill={st.fill} stroke={st.stroke} strokeWidth="1" strokeDasharray={l.inferred ? "4 3" : "0"} />
                <g clipPath={`url(#hclip-${l.id})`}>
                  {hl.map((ln, k) => (
                    <text key={k} x={cx(i)} y={midY + (k - (hl.length - 1) / 2) * HEAD_LINEH} textAnchor="middle" dominantBaseline="central" fill={st.stroke} fontSize="10" fontWeight="700" fontFamily="var(--font-ui)">{ln}</text>
                  ))}
                </g>
              </g>
            );
          })}

          {/* Module group bands — click a header to collapse/expand (behind nodes) */}
          {bands.map((b, i) => (
            <g key={`band${i}`} onClick={() => toggleGroup(b.group)} style={{ cursor: "pointer" }}>
              <rect x={8} y={b.yTop} width={W - 16} height={Math.max(GROUP_HEAD, b.yBottom - b.yTop)} rx="10" fill="var(--accent)" opacity={b.collapsed ? 0.09 : 0.05} stroke="var(--border)" strokeWidth="1" />
              <text x={20} y={b.yTop + 19} fill="var(--muted)" fontSize="11" fontWeight="700" fontFamily="var(--font-mono)" style={{ letterSpacing: ".06em" }}>
                {(b.collapsed ? "▸ " : "▾ ") + String(b.group).toUpperCase()}
                <tspan fill="var(--border)" fontWeight="600">{`  ·  ${b.count} step${b.count === 1 ? "" : "s"}`}</tspan>
              </text>
            </g>
          ))}

          {/* Edges */}
          {edges.map((e, i) => (
            <path key={`e${i}`} d={edgePath(e)} fill="none" stroke="var(--muted)" strokeWidth="1.4" markerEnd="url(#swArrow)" opacity="0.85" />
          ))}
          {/* Branch labels */}
          {edges.filter(e => e.label).map((e, i) => {
            const p = labelPos(e);
            return (
              <text key={`l${i}`} x={p.x} y={p.y} textAnchor={p.anchor} dominantBaseline="central" fill="var(--muted)" fontSize="10" fontFamily="var(--font-mono)" style={{ paintOrder: "stroke", stroke: "var(--bg)", strokeWidth: 3 }}>{e.label}</text>
            );
          })}

          {/* Nodes */}
          {steps.map((s) => {
            const g = geo[s.id]; if (!g) return null;
            const st = sty(laneTier(s.lane));
            const isSel = sel === s.id;
            const flag = s.inferred || (typeof s.confidence === "number" && s.confidence < 0.6);
            const isDecision = s.type === "decision";
            const stroke = isSel ? "var(--text)" : s.improvised ? "var(--ok)" : st.stroke;
            return (
              <g key={s.id} onClick={() => setSel(s.id)} style={{ cursor: "pointer" }}>
                <rect x={g.x} y={g.y} width={NODE_W} height={g.h} rx={isDecision ? 12 : 8} fill={st.fill} stroke={stroke} strokeWidth={isSel ? 2 : s.improvised ? 2 : 1} strokeDasharray={s.improvised ? "5 3" : "0"} />
                {g.lines.map((ln, li) => (
                  <text key={li} x={g.cxv} y={g.cy + (li - (g.lines.length - 1) / 2) * LINEH} textAnchor="middle" dominantBaseline="central" fill={st.stroke} fontSize="11" fontWeight="600" fontFamily="var(--font-ui)">{ln}</text>
                ))}
                <text x={g.x + 7} y={g.y + 9} textAnchor="start" dominantBaseline="central" fill="var(--muted)" fontSize="8" fontFamily="var(--font-mono)" opacity="0.7">{s.id}</text>
                {s.improvised && <text x={g.x + NODE_W - 7} y={g.y + 9} textAnchor="end" dominantBaseline="central" fill="var(--ok)" fontSize="7.5" fontWeight="700" fontFamily="var(--font-mono)">✦ RESOLVED</text>}
                {flag && !s.improvised && <circle cx={g.x + NODE_W - 9} cy={g.y + 9} r="4" fill={s.inferred ? "var(--gold)" : "var(--warn)"} />}
              </g>
            );
          })}
        </svg>
      </div>
    </div>
  );
}

function SpecStagePRD({ s, set }) {
  const flow = s.flowchart || null;
  const [input, setInput] = useState(s.prdText || "");
  const [docName, setDocName] = useState(s.prdDocName || null);
  const [phase, setPhase] = useState(flow ? "done" : "idle");
  const [log, setLog] = useState([]);
  const [err, setErr] = useState("");
  const fileRef = useRef();
  const docRef = useRef(null);
  const appendLog = (msg, type = "info") => setLog(l => [...l, { msg, type }]);

  const handleFile = (e) => {
    const f = e.target.files[0]; if (!f) return;
    const isPdf = (f.type || "").includes("pdf") || f.name.toLowerCase().endsWith(".pdf");
    const reader = new FileReader();
    if (isPdf) { reader.onload = ev => { docRef.current = { data: ev.target.result.split(",")[1] }; setDocName(f.name); }; reader.readAsDataURL(f); }
    else { reader.onload = ev => { docRef.current = null; setDocName(f.name); setInput(String(ev.target.result || "")); }; reader.readAsText(f); }
  };

  const run = async () => {
    setPhase("running"); setLog([]); setErr("");
    appendLog("► Reading PRD…"); await sleepMs(200);
    const doc = docRef.current;
    appendLog(doc ? "  Detected PDF — sending for analysis…" : "  Parsing PRD text…");
    appendLog("  Reconstructing flow & assigning actor lanes…");
    try {
      let raw;
      if (doc) {
        raw = await callClaudeText(SWIMLANE_SYSTEM, [
          { type: "document", source: { type: "base64", media_type: "application/pdf", data: doc.data } },
          { type: "text", text: "Generate the swimlane flow from this PRD." },
        ], 8000);
      } else {
        raw = await callClaudeText(SWIMLANE_SYSTEM, `Generate the swimlane flow from this PRD:\n\n${input}`, 8000);
      }
      const parsed = normalizeFlow(extractSpecJSON(isolateJSON(raw)));
      appendLog(`  Found ${parsed.lanes?.length ?? 0} lanes, ${parsed.steps?.length ?? 0} steps`);
      appendLog("✔ Flow generated — review below", "success");
      // Persist the PRD text (when pasted/uploaded as text) so the flow can be
      // regenerated later. Regenerating from the PRD intentionally discards any
      // gap-resolution patches and resets gap decisions — a clean start-over.
      const next = { ...s, flowchart: parsed };
      if (!doc && input.trim()) { next.prdText = input; next.prdDocName = docName || null; }
      if (s.gapAnalysis?.decisions && Object.keys(s.gapAnalysis.decisions).length) {
        next.gapAnalysis = { ...s.gapAnalysis, decisions: {} };
        appendLog("  (gap resolutions reset — flow regenerated from the PRD)", "warn");
      }
      set(next);
      setPhase("done");
    } catch (e) { appendLog(`✘ ${e.message}`, "error"); setErr(e.message); setPhase("error"); }
  };

  // Reassign a step to a different lane (updates lane + flattened actor, clears
  // inferred), then persist to session — mirrors the AIAD behavior.
  const reassign = (stepId, laneId) => {
    if (!flow) return;
    const lane = (flow.lanes || []).find(l => l.id === laneId);
    const name = lane ? lane.name : laneId;
    const steps = (flow.steps || []).map(st => st.id === stepId ? { ...st, lane: laneId, actor: name, inferred: false } : st);
    set({ ...s, flowchart: { ...flow, steps } });
  };

  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      <div className="card">
        <CardHead title="PRD Ingestion" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Stage 01</span>} />
        <div style={{ display: "flex", gap: 10, marginBottom: 12, alignItems: "center", padding: "10px 12px", background: "var(--bg)", borderRadius: 8, border: "1px dashed var(--border)" }}>
          <div style={{ fontSize: 18, opacity: .5 }}>📄</div>
          <div style={{ flex: 1 }}>
            <div style={{ fontSize: 12, fontWeight: 600 }}>Upload PRD</div>
            <div style={{ fontSize: 11, color: "var(--muted)" }}>PDF (parsed natively) · TXT / MD as text</div>
          </div>
          <button className="btn btn-secondary btn-sm" onClick={() => fileRef.current.click()}>{docName ? `✔ ${docName}` : "Browse"}</button>
          <input ref={fileRef} type="file" accept=".pdf,.txt,.md,application/pdf,text/plain,text/markdown" style={{ display: "none" }} onChange={handleFile} />
        </div>
        <textarea rows={8} value={input} onChange={e => setInput(e.target.value)} placeholder="…or paste PRD text here" />
        <div style={{ marginTop: 10, display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
          <button className="btn btn-primary" disabled={phase === "running" || (!input.trim() && !docRef.current)} onClick={run}>
            {phase === "running" ? <LiveStep log={log} fallback="Generating…" /> : flow ? "Regenerate flow from PRD" : "Generate flow"}</button>
          {flow && !input.trim() && !docRef.current && (
            <span style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Paste or upload the PRD again to regenerate (the source isn't stored for uploaded PDFs).</span>
          )}
          {flow && (input.trim() || docRef.current) && (s.gapAnalysis?.decisions && Object.keys(s.gapAnalysis.decisions).length > 0) && (
            <span style={{ fontSize: 11, color: "var(--gold)", fontFamily: "var(--font-mono)" }}>⚠ Regenerating rebuilds the flow from the PRD and discards gap resolutions.</span>
          )}
        </div>
        <ErrBox msg={err} />
        {log.length > 0 && (
          <div style={{ marginTop: 12, fontFamily: "var(--font-mono)", fontSize: 11, lineHeight: 1.7 }}>
            {log.map((l, i) => <div key={i} style={{ color: l.type === "error" ? "var(--warn)" : l.type === "success" ? "var(--accent)" : l.type === "warn" ? "var(--gold)" : "var(--muted)" }}>{l.msg}</div>)}
          </div>
        )}
      </div>

      {flow && (
        <div className="card">
          <CardHead title={flow.title || "Swimlane Flow — Review"} right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{flow.steps?.length || 0} steps · {flow.lanes?.length || 0} lanes</span>} />
          {flow.summary && <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6, marginBottom: 12 }}>{flow.summary}</div>}
          <SwimlaneView ir={flow} onReassign={reassign} />
        </div>
      )}
    </div>
  );
}

// ── Spec 02 · Gap Analysis ────────────────────────────────────────────────────
const FEASIBILITY_SYSTEM = `You are a principal engineer doing a FIRST-PASS technical feasibility review of a product idea, BEFORE detailed specification work begins. The PRD came from a non-technical team, so your job is to catch infeasible, redundant, or high-risk ideas early — a shift-left gate, not a sign-off.

You are given a process flow derived from the PRD (and optionally a GROUNDING section describing what your connected systems have).

Assess and produce a concise advisory in MARKDOWN with these sections:
## Verdict
One of: **Buildable with existing capability** / **Needs new work (feasible)** / **High-risk / needs investigation** / **Likely infeasible as written** — with one or two sentences of rationale.
## Reuse — what already exists
Existing connected sources services, components, or capabilities this could build on instead of from scratch (cite specifics from the grounding where available). If none known, say so.
## New work required
The genuinely new pieces that would have to be built.
## Conflicts & duplication
Anything that overlaps with, duplicates, or collides with existing systems/programs.
## Risks & constraints
Technical risks, platform/hardware/runtime limits (especially if on-wing/embedded), data availability, integration unknowns, and anything that could block delivery.
## Open questions for engineering
The specific things a human architect must confirm before this proceeds.

Rules:
- If a GROUNDING section is provided, treat it as authoritative for what exists today; ground every reuse/conflict claim in it rather than guessing.
- Be honest and specific. Do not invent your systems that aren't in the grounding.
- End with one line: "_First-pass AI review — validate with an architect before treating as a go/no-go._"
- MARKDOWN only, no preamble.`;

const GAP_SYSTEM = `You are a senior product analyst performing a GAP ANALYSIS on a process flow (optionally derived from a PRD).

Identify what a COMPLETE specification for a product of this kind SHOULD contain but is MISSING or underspecified. Use domain knowledge about comparable products to surface gaps a PM would otherwise discover late in delivery.

Categories:
- "actor": roles / systems the product clearly needs but no work is assigned to.
- "flow": end-to-end processes a product of this type needs but are omitted (refund, cancellation, connectivity loss, onboarding, recovery).
- "business_rule": decision logic / constraints left unstated.
- "edge_case": failure or boundary conditions not handled.
- "nfr": non-functional requirements not specified (performance, security, accessibility, localization, scale).
- "data": data entities or fields implied but never defined.

Only report GENUINE gaps — never list something already covered. Be specific and concrete.

If a "FEASIBILITY REVIEW" section is provided, use it as context only — your job is still completeness, not feasibility. Specifically: do NOT re-raise concerns the feasibility review already covers (reuse, conflicts, technical risk), and if the review flags part of the product as infeasible or likely-to-be-cut, do not generate completeness gaps for that part (note it's moot in your summary instead). Never turn a feasibility concern into a gap — gaps are about what a complete spec is missing, not whether it can be built.

If a "PRIOR DECISIONS" section is provided, it lists gaps the user already handled in an earlier pass — each was either IMPROVISED (the fix was added to the flow) or DISMISSED (intentionally not addressed). Do NOT re-raise an improvised gap if the flow now covers it. Do NOT re-raise a dismissed gap at all. Only surface genuinely NEW gaps plus any improvised one the flow still doesn't actually satisfy.

Respond ONLY with valid JSON — no markdown:
{"summary":"2-3 sentence assessment","completeness":0,"gaps":[{"id":"G1","category":"actor|flow|business_rule|edge_case|nfr|data","title":"short title","detail":"why it matters / what is missing","severity":"high|medium|low","suggestion":"what to add","evidence":"flow anchor or 'absent'"}]}
"completeness" is 0–100. Order gaps by severity, highest first.`;

// Improvise: given ONE gap and the current flow, produce the minimal flow change
// that closes it — one or more new steps (and a new actor/lane if truly needed),
// each tagged improvised so the flowchart can highlight them.
const IMPROVISE_SYSTEM = `You patch a swimlane process flow to close ONE identified completeness gap, with the SMALLEST sensible change.

You get the current flow (lanes + steps, each with its "group" — the phase band it belongs to) and a single gap. Produce the new step(s) that close the gap, slotting them into the existing flow.

Rules:
- Add 1–3 new steps maximum — only what the gap genuinely requires. Reuse existing lanes when possible; only add a new lane if the gap is a missing actor with no existing home.
- Each new step needs: a unique "id" (prefix "IMP-" + short suffix, e.g. "IMP-a1"), "action" (under 10 words), "lane" (an existing lane id, or a new one you define), "type" ("process" unless it's clearly a decision/start/end), "after" (the id of the existing step it should follow, or null to append at the end), and "group".
- "group" MUST be set: prefer the EXACT name of an existing group/phase the step logically belongs to (match the wording of the step it follows or a related phase). Only invent a NEW group name if the step genuinely starts a new phase no existing group covers — and then give it a clear, descriptive Title Case name (e.g. "Compliance & Governance", "Deprecation Management"). NEVER use "Other" or leave it blank.
- If you must add a lane, include it in "newLanes" with id + name + tier (frontend|backend|platform|cloud|actor).
- Do not restate or modify existing steps. Only return the additions.

Respond ONLY with valid JSON — no markdown:
{"newLanes":[{"id":"lane_id","name":"Lane Name","tier":"backend"}],"newSteps":[{"id":"IMP-a1","action":"short action","lane":"lane_id","type":"process","after":"S4","group":"Existing Phase Name"}],"note":"one line on what was added"}`;

const GAP_CATS = {
  actor: { label: "Missing actors", color: "var(--accent2)" },
  flow: { label: "Missing flows", color: "var(--purple)" },
  business_rule: { label: "Missing business rules", color: "var(--gold)" },
  edge_case: { label: "Unhandled edge cases", color: "var(--warn)" },
  nfr: { label: "Missing non-functionals", color: "var(--accent)" },
  data: { label: "Undefined data entities", color: "var(--muted)" },
};
const GAP_CAT_ORDER = ["actor", "flow", "business_rule", "edge_case", "nfr", "data"];
const gapSevTag = sev => sev === "high" ? "tag-red" : sev === "medium" ? "tag-gold" : "tag-blue";
const gapScoreColor = n => n >= 75 ? "var(--accent)" : n >= 50 ? "var(--gold)" : "var(--warn)";

function SpecStageFeasibility({ s, set }) {
  const flow = s.flowchart || null;
  const existing = s.feasibility || null;   // { markdown, generatedAt, edited, grounding }
  const [phase, setPhase] = useState(existing ? "done" : "idle");
  const [err, setErr] = useState("");
  const [groundMsg, setGroundMsg] = useState("");
  const [genStep, setGenStep] = useState("");
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(existing?.markdown || "");

  const ready = flow ? { ok: true, msg: "Reviewing the PRD flow for feasibility" } : { ok: false, msg: "Generate the PRD → Flow in Stage 01 first." };

  const run = async () => {
    if (!ready.ok) return;
    setPhase("running"); setErr(""); setGroundMsg(""); setGenStep("Reading the PRD flow…");
    try {
      const flowJSON = JSON.stringify(flow, null, 2);
      // Ground against what your connected systems have (best-effort; degrade gracefully).
      let grounding = "", groundingMeta = null;
      if (IS_LOCAL_APP) {
        setGenStep("Checking against your systems via connectors…");
        setGroundMsg("⟳ Checking feasibility against your systems via connectors…");
        try {
          const out = await api("/api/connectors/analyze", { method: "POST", body: {
            question: `For a proposed new product described by this process flow, what EXISTING capabilities, services, or components already provide similar functionality (so it could be reused rather than built)? What existing systems would it conflict with or duplicate? Are there platform, hardware, runtime, or data constraints that affect feasibility — especially if it is embedded or resource-constrained? Has anything similar been attempted before?\n\nFLOW:\n${flowJSON}`,
            hint: `First-pass technical feasibility review of a new product "${s.projectName || "untitled"}". Looking for reuse, conflicts, and real constraints.`,
            maxServers: 4, maxRounds: 4,
          }});
          if (out && out.answer && out.answer.trim()) {
            grounding = out.answer;
            groundingMeta = { servers: out.servers || [], at: Date.now() };
            setGroundMsg(`✔ Grounded against connected sources — ${(out.servers || []).join(", ") || "connected sources"}.`);
          } else {
            setGroundMsg("No connected sources returned grounding — reviewing from the flow alone.");
          }
        } catch (e) {
          setGroundMsg("⚠ Couldn't reach connected sources — reviewing without grounding.");
        }
      }
      setGenStep(grounding ? "Writing the feasibility review from grounding…" : "Writing the feasibility review…");
      const userContent = `Perform a first-pass technical feasibility review for this product.\n\nPROJECT: ${s.projectName || "Untitled"}\n\nFLOW (from the PRD):\n${flowJSON}`
        + (grounding ? `\n\n=== GROUNDING (authoritative — what your connected systems have today) ===\n${grounding}` : "");
      const md = await callClaudeText(FEASIBILITY_SYSTEM, userContent, 6000);
      const clean = md.replace(/^```(?:markdown|md)?\n?/i, "").replace(/```$/, "").trim();
      setDraft(clean);
      set({ ...s, feasibility: { markdown: clean, generatedAt: Date.now(), grounding: groundingMeta } });
      setPhase("done"); setGenStep("");
    } catch (e) { setErr(e.message); setPhase("error"); setGenStep(""); }
  };
  const save = () => { set({ ...s, feasibility: { markdown: draft, generatedAt: existing?.generatedAt || Date.now(), edited: true, grounding: existing?.grounding } }); setEditing(false); };

  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      <div className="card">
        <CardHead title="Technical Feasibility" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Stage 02</span>} />
        <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6, marginBottom: 12 }}>
          A first-pass feasibility check on the PRD before detailed spec work — grounded against what your connected systems have via connectors. Catches infeasible, redundant, or high-risk ideas early. Advisory only; validate with an architect.
        </div>
        {ready.ok
          ? <div style={{ fontSize: 11, color: "var(--accent)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>✔ {ready.msg}</div>
          : <div style={{ fontSize: 11, color: "var(--warn)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>{ready.msg}</div>}
        <button className="btn btn-primary" disabled={phase === "running" || !ready.ok} onClick={run}>
          {phase === "running" ? <><Spinner /> {genStep || "Reviewing…"}</> : existing ? "Re-review" : "Review feasibility"}</button>
        {groundMsg && <div style={{ fontSize: 11, marginTop: 10, fontFamily: "var(--font-mono)", color: groundMsg.startsWith("⚠") ? "var(--gold)" : groundMsg.startsWith("✔") ? "var(--accent)" : "var(--muted)" }}>{groundMsg}</div>}
        <ErrBox msg={err} />
      </div>

      {existing && (
        <div className="card">
          <CardHead title="Feasibility Review" right={
            <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
              {existing.grounding?.servers?.length > 0 && <span className="tag tag-blue" style={{ fontSize: 9 }} title={`Grounded via ${existing.grounding.servers.join(", ")}`}>Grounded</span>}
              {existing.edited && <span className="tag tag-gold" style={{ fontSize: 9 }}>edited</span>}
              <CopyBtn text={existing.markdown} label="Copy" />
              <button className="btn btn-secondary btn-sm" onClick={() => download("feasibility.md", existing.markdown, "text/markdown")}>⤓ MD</button>
              <button className="btn btn-secondary btn-sm" onClick={() => { setDraft(existing.markdown); setEditing(e => !e); }}>{editing ? "Preview" : "Edit"}</button>
            </div>} />
          {editing
            ? <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                <textarea value={draft} onChange={e => setDraft(e.target.value)} style={{ width: "100%", minHeight: 360, fontFamily: "var(--font-mono)", fontSize: 12, lineHeight: 1.5 }} />
                <div><button className="btn btn-primary btn-sm" onClick={save}>Save</button></div>
              </div>
            : <div style={{ color: "var(--text)" }}><Markdown text={existing.markdown} /></div>}
        </div>
      )}
    </div>
  );
}

function SpecStageGap({ s, set, goTo }) {
  const flow = s.flowchart || null;
  const result = s.gapAnalysis || null;
  const hasFlow = !!(flow && (flow.steps || []).length);
  const [phase, setPhase] = useState(result ? "done" : "idle");
  const [log, setLog] = useState([]);
  const [err, setErr] = useState("");
  const [busyGap, setBusyGap] = useState(null);   // gap id currently being improvised
  const [rerunStep, setRerunStep] = useState(""); // live label during apply & re-run
  const appendLog = (msg, type = "info") => setLog(l => [...l, { msg, type }]);

  // Decisions persist in saved state so they survive navigation and feed re-runs.
  const decisions = result?.decisions || {};   // { [gapId]: { kind:"improvised"|"dismissed", note?, at } }

  const analyze = async (st) => {
    const cur = st || s;
    const f = cur.flowchart;
    const flowJSON = JSON.stringify({ title: f.title, actors: f.actors || f.lanes, steps: (f.steps || []).map(x => ({ id: x.id, action: x.action, actor: x.actor, type: x.type, improvised: x.improvised || undefined })) }).slice(0, 6000);
    const feas = cur.feasibility?.markdown ? `\n\nFEASIBILITY REVIEW (context — already covers reuse/conflicts/risk; don't re-raise these, and skip gaps for anything it flags as infeasible):\n${cur.feasibility.markdown.slice(0, 4000)}` : "";
    const prior = cur.gapAnalysis?.decisions && Object.keys(cur.gapAnalysis.decisions).length
      ? "\n\nPRIOR DECISIONS:\n" + Object.entries(cur.gapAnalysis.decisions).map(([id, d]) => {
          const g = (cur.gapAnalysis.gaps || []).find(x => x.id === id);
          return `- [${d.kind.toUpperCase()}] ${g ? g.title : id}${d.kind === "improvised" && d.note ? ` (added: ${d.note})` : ""}`;
        }).join("\n")
      : "";
    const raw = await callClaudeText(GAP_SYSTEM, `Perform a gap analysis on this process flow. Identify what a COMPLETE system of this type needs that the flow is missing.\n\nFLOW:\n${flowJSON}${feas}${prior}`);
    return extractSpecJSON(isolateJSON(raw));
  };

  const run = async () => {
    if (!hasFlow) return;
    setPhase("running"); setLog([]); setErr("");
    appendLog("► Reading the process flow…"); await sleepMs(200);
    appendLog("  Comparing against a complete system of this type…");
    appendLog("  Looking for missing actors, flows, rules, edge cases…");
    if (s.feasibility?.markdown) appendLog("  Using the feasibility review to skip already-covered items…");
    try {
      const parsed = await analyze(s);
      const gaps = parsed.gaps || [];
      appendLog(`  ${gaps.length} gap(s) across ${new Set(gaps.map(g => g.category)).size} categories`);
      const high = gaps.filter(g => g.severity === "high").length;
      if (high) appendLog(`  ${high} high-severity gap(s)`, "warn");
      appendLog("✔ Gap analysis complete", "success");
      // Fresh analysis clears prior decisions (they were for the old gap set).
      set({ ...s, gapAnalysis: { ...parsed, decisions: {} } }); setPhase("done");
    } catch (e) { appendLog(`✘ ${e.message}`, "error"); setErr(e.message); setPhase("error"); }
  };

  // Improvise: AI patches the flow to close one gap; tag new steps improvised;
  // record the decision. The flow becomes the source of truth (no PRD regen).
  const improvise = async (g) => {
    if (!flow) return;
    setBusyGap(g.id); setErr("");
    try {
      const flowCtx = JSON.stringify({ lanes: flow.lanes, steps: (flow.steps || []).map(x => ({ id: x.id, action: x.action, lane: x.lane, type: x.type, group: x.group || x.phase || "" })) }).slice(0, 6000);
      const raw = await callClaudeText(IMPROVISE_SYSTEM, `Close this gap by patching the flow.\n\nGAP: ${g.title}\nDETAIL: ${g.detail}\nSUGGESTION: ${g.suggestion || "(none)"}\n\nCURRENT FLOW:\n${flowCtx}`, 2000);
      const patch = extractSpecJSON(isolateJSON(raw)) || {};
      const lanes = [...(flow.lanes || [])];
      (patch.newLanes || []).forEach(nl => { if (!lanes.find(l => l.id === nl.id)) lanes.push({ ...nl }); });
      const laneName = id => (lanes.find(l => l.id === id) || {}).name || id;
      const steps = (flow.steps || []).map(x => ({ ...x }));   // shallow clones — safe to rewire `next`
      (patch.newSteps || []).forEach(ns => {
        const afterIdx = ns.after ? steps.findIndex(x => x.id === ns.after) : -1;
        // Group: use the AI's group, else inherit the followed step's group, else
        // the last step's group — never fall through to the generic "Other" band.
        const inheritGroup = afterIdx >= 0 ? (steps[afterIdx].group || steps[afterIdx].phase) : (steps.length ? (steps[steps.length - 1].group || steps[steps.length - 1].phase) : "");
        const group = (ns.group && ns.group.trim() && ns.group.trim().toLowerCase() !== "other") ? ns.group.trim() : (inheritGroup || "");
        const step = { id: ns.id, action: ns.action, lane: ns.lane, actor: laneName(ns.lane), type: ns.type || "process", group, next: null, improvised: true, fromGap: g.id };
        if (afterIdx >= 0) {
          const prev = steps[afterIdx];
          if (prev.type !== "decision") {
            step.next = prev.next || null;
            prev.next = step.id;
          }
          steps.splice(afterIdx + 1, 0, step);
        } else {
          const tail = [...steps].reverse().find(x => x.type !== "end" && x.next == null && (x.branches || []).length === 0);
          if (tail) tail.next = step.id;
          steps.push(step);
        }
      });
      const newFlow = { ...flow, lanes, steps };
      const newDecisions = { ...decisions, [g.id]: { kind: "improvised", note: patch.note || (patch.newSteps || []).map(x => x.action).join("; "), at: Date.now() } };
      set({ ...s, flowchart: newFlow, gapAnalysis: { ...result, decisions: newDecisions } });
    } catch (e) { setErr(`Improvise failed: ${e.message}`); }
    finally { setBusyGap(null); }
  };

  const dismiss = (g) => {
    const newDecisions = { ...decisions };
    if (newDecisions[g.id]?.kind === "dismissed") delete newDecisions[g.id];
    else newDecisions[g.id] = { kind: "dismissed", at: Date.now() };
    set({ ...s, gapAnalysis: { ...result, decisions: newDecisions } });
  };

  // Apply & re-run: re-run feasibility (if present) then gap, against the PATCHED
  // flow. No PRD flow regeneration — improvised steps are preserved.
  const applyRerun = async () => {
    setPhase("running"); setLog([]); setErr("");
    try {
      let working = s;
      if (s.feasibility?.markdown && IS_LOCAL_APP) {
        setRerunStep("Re-checking feasibility against the updated flow…");
        appendLog("► Re-running feasibility on the patched flow…");
        try {
          const flowJSON = JSON.stringify(working.flowchart, null, 2);
          const out = await api("/api/connectors/analyze", { method: "POST", body: {
            question: `Re-assess feasibility for this UPDATED product flow (it was patched to close earlier gaps). What existing capabilities apply, what conflicts arise, and what constraints matter?\n\nFLOW:\n${flowJSON}`,
            hint: `Feasibility re-review after gap improvisation for "${s.projectName || "untitled"}".`, maxServers: 4, maxRounds: 4,
          }});
          if (out?.answer?.trim()) { working = { ...working, feasibility: { markdown: out.answer, generatedAt: Date.now(), grounding: { servers: out.servers || [], at: Date.now() } } }; appendLog("  feasibility updated"); }
        } catch { appendLog("  (couldn't reach connected sources — keeping prior feasibility)", "warn"); }
      }
      setRerunStep("Re-running gap analysis…");
      appendLog("► Re-running gap analysis on the patched flow…");
      const parsed = await analyze(working);
      const carried = working.gapAnalysis?.decisions || {};
      const gaps = parsed.gaps || [];
      appendLog(`  ${gaps.length} open gap(s) remain`);
      appendLog("✔ Re-run complete", "success");
      set({ ...working, gapAnalysis: { ...parsed, decisions: carried } });
      setPhase("done");
    } catch (e) { appendLog(`✘ ${e.message}`, "error"); setErr(e.message); setPhase("error"); }
    finally { setRerunStep(""); }
  };

  const gaps = result?.gaps || [];
  const score = result?.completeness ?? null;
  const decidedCount = Object.keys(decisions).length;
  const improvisedCount = Object.values(decisions).filter(d => d.kind === "improvised").length;
  const openCount = gaps.filter(g => !decisions[g.id]).length;
  const decisionTag = (g) => {
    const d = decisions[g.id];
    if (!d) return null;
    if (d.kind === "improvised") return <span className="tag tag-ok" style={{ fontSize: 9 }} title={d.note || ""}>✓ resolved</span>;
    return <span className="tag tag-muted" style={{ fontSize: 9 }}>dismissed</span>;
  };

  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      <div className="card">
        <CardHead title="Gap Analysis" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Stage 03</span>} />
        <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6, marginBottom: 12 }}>
          Analyzes your flow for what a complete system of this type needs but is missing. For each gap you can <strong>Resolve</strong> (let AI patch the flow to close it) or <strong>Dismiss</strong> it. Then re-run to see the updated picture before generating requirements.
        </div>
        {hasFlow
          ? <div style={{ fontSize: 11, color: "var(--accent)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>✔ Using your flow ({flow.steps.length} steps, {(flow.lanes || flow.actors || []).length} lanes)</div>
          : <div style={{ fontSize: 11, color: "var(--warn)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>No flow yet — generate one in Stage 01 first.</div>}
        <button className="btn btn-primary" disabled={phase === "running" || !hasFlow} onClick={run}>
          {phase === "running" ? <LiveStep log={log} fallback={rerunStep || "Analyzing…"} /> : result ? "Re-analyze flow" : "Analyze for gaps"}</button>
        <ErrBox msg={err} />
        {log.length > 0 && <div style={{ marginTop: 12, fontFamily: "var(--font-mono)", fontSize: 11, lineHeight: 1.7 }}>{log.map((l, i) => <div key={i} style={{ color: l.type === "error" ? "var(--warn)" : l.type === "success" ? "var(--accent)" : l.type === "warn" ? "var(--gold)" : "var(--muted)" }}>{l.msg}</div>)}</div>}
      </div>

      {result && (
        <div className="card">
          <CardHead title="Gap Report" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{openCount} open · {decidedCount} handled</span>} />
          <div style={{ display: "flex", gap: 16, alignItems: "center", background: "var(--bg)", borderRadius: 8, padding: 16, marginBottom: 16 }}>
            <div style={{ textAlign: "center", flexShrink: 0, width: 92 }}>
              <div style={{ fontSize: 30, fontWeight: 800, lineHeight: 1, color: gapScoreColor(score ?? 0) }}>{score ?? "—"}<span style={{ fontSize: 13, color: "var(--muted)" }}>/100</span></div>
              <div style={{ fontSize: 9, color: "var(--muted)", letterSpacing: ".1em", textTransform: "uppercase", marginTop: 4, fontFamily: "var(--font-mono)" }}>Completeness</div>
              <div style={{ height: 4, borderRadius: 2, background: "var(--border)", marginTop: 8, overflow: "hidden" }}><div style={{ height: "100%", width: `${score ?? 0}%`, background: gapScoreColor(score ?? 0) }} /></div>
            </div>
            <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6 }}>{result.summary}</div>
          </div>

          <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
            {gaps.map(g => {
              const d = decisions[g.id];
              const busy = busyGap === g.id;
              return (
                <div key={g.id} style={{ padding: 12, borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg)", opacity: d?.kind === "dismissed" ? .5 : 1 }}>
                  <div style={{ display: "flex", justifyContent: "space-between", gap: 8, marginBottom: 4 }}>
                    <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
                      <span style={{ width: 8, height: 8, borderRadius: 2, background: (GAP_CATS[g.category] || {}).color || "var(--muted)" }} />
                      <strong style={{ fontSize: 13 }}>{g.title}</strong>
                      <span className={`tag ${gapSevTag(g.severity)}`}>{g.severity}</span>
                      {decisionTag(g)}
                    </div>
                    <div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
                      {d?.kind === "improvised"
                        ? <span style={{ fontSize: 10, color: "var(--ok)", fontFamily: "var(--font-mono)", alignSelf: "center" }}>added to flow</span>
                        : <button className="btn btn-secondary btn-sm" disabled={busy || phase === "running"} onClick={() => improvise(g)}>{busy ? <><Spinner /> Resolving…</> : "Resolve"}</button>}
                      <button className="btn btn-ghost btn-sm" disabled={busy || phase === "running"} onClick={() => dismiss(g)}>{d?.kind === "dismissed" ? "Restore" : "Dismiss"}</button>
                    </div>
                  </div>
                  <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.5 }}>{g.detail}</div>
                  {g.suggestion && <div style={{ fontSize: 12, lineHeight: 1.5, marginTop: 4 }}><span style={{ color: "var(--accent)" }}>Suggestion:</span> {g.suggestion}</div>}
                  {d?.kind === "improvised" && d.note && <div style={{ fontSize: 11, color: "var(--ok)", fontFamily: "var(--font-mono)", marginTop: 4 }}>↳ added to flow: {d.note}</div>}
                </div>
              );
            })}
          </div>

          {improvisedCount > 0 && (
            <div style={{ marginTop: 16, padding: 14, borderRadius: 8, background: "var(--bg)", border: "1px solid var(--border)" }}>
              <div style={{ fontSize: 12, color: "var(--text)", marginBottom: 10, lineHeight: 1.6 }}>
                You've resolved <strong>{improvisedCount}</strong> gap{improvisedCount > 1 ? "s" : ""} into the flow. Re-run feasibility + gap analysis against the updated flow to see what's left — resolved steps are highlighted in Stage 01.
              </div>
              <button className="btn btn-primary btn-sm" disabled={phase === "running"} onClick={applyRerun}>
                {phase === "running" ? <><Spinner /> {rerunStep || "Re-running…"}</> : "↻ Apply & re-run feasibility + gap"}</button>
            </div>
          )}

          <div style={{ marginTop: 16, paddingTop: 14, borderTop: "1px solid var(--border)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
            <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5 }}>
              {openCount === 0 ? "All gaps handled — ready to generate requirements." : `${openCount} gap${openCount > 1 ? "s" : ""} still open. Resolve or dismiss them, or proceed anyway.`}
            </div>
            <button className="btn btn-secondary btn-sm" style={{ flexShrink: 0 }} onClick={() => goTo && goTo("epics")}>Proceed to Requirements →</button>
          </div>
        </div>
      )}
    </div>
  );
}

// ── Spec 03 · Epics ───────────────────────────────────────────────────────────
const GROUPING_SYSTEM = `You are a senior Business Analyst. Given a list of flowchart process steps, group them into logical epic domains.

Rules:
- Every step MUST be assigned to exactly one epic.
- Create as many epics as needed — do not merge unrelated steps.
- Epic titles reflect the business domain; keep under 6 words.

Respond ONLY with valid JSON — no markdown:
[{"epic_id":"EP-01","epic_title":"Short domain title","epic_description":"One sentence","step_ids":["S1","S2"]}]`;

const STORY_SYSTEM = `You are a senior Business Analyst. Given an epic and its flowchart steps, generate one atomic User Story per step.

Rules:
- ONE user story per step — do not combine or skip steps.
- Decision steps become stories about handling the branches.
- Format: "As a [role], I want to [action], so that [value]".
- 2–6 acceptance criteria, each a single testable condition in plain business language, under 20 words.
- Title under 8 words. Priority High/Medium/Low by business impact. Do NOT assign story points.

Respond ONLY with valid JSON — no markdown:
{"epic_id":"EP-01","epic_title":"title","epic_description":"description","stories":[{"story_id":"US-01","step_id":"S1","title":"Short title","role":"actor","action":"atomic action","value":"business value","priority":"High","acceptance_criteria":["...","..."]}]}`;

const priTagSpec = p => /high/i.test(p) ? "tag-red" : /low/i.test(p) ? "tag-blue" : "tag-gold";

function SpecStageEpics({ s, set }) {
  const flow = s.flowchart || null;
  const hasFlow = !!(flow && (flow.steps || []).length);
  const epics = s.epics || [];
  const [phase, setPhase] = useState(epics.length ? "done" : "idle");
  const [log, setLog] = useState([]);
  const [err, setErr] = useState("");
  const [openEpic, setOpenEpic] = useState(epics[0]?.epic_id || null);
  const appendLog = (msg, type = "info") => setLog(l => [...l, { msg, type }]);

  const run = async () => {
    if (!hasFlow) return;
    setPhase("running"); setLog([]); setErr("");
    const steps = flow.steps || [];
    appendLog(`► Analysing ${steps.length} flow steps…`); await sleepMs(250);
    try {
      appendLog("  Pass 1: grouping steps into epic domains…");
      const stepList = steps.map(x => ({ id: x.id, actor: x.actor, action: x.action, type: x.type }));
      const groupRaw = await callClaudeText(GROUPING_SYSTEM, `Group these ${steps.length} steps into epic domains. Every step must be assigned.\n\nSteps:\n${JSON.stringify(stepList, null, 2)}`, 4000);
      const groups = JSON.parse(isolateJSON(groupRaw));
      appendLog(`  ${groups.length} epic domains identified`);
      const allEpics = []; let counter = 1;
      for (let i = 0; i < groups.length; i++) {
        const g = groups[i];
        const epicSteps = steps.filter(x => (g.step_ids || []).includes(x.id));
        if (!epicSteps.length) continue;
        appendLog(`  [${i + 1}/${groups.length}] ${g.epic_title} — ${epicSteps.length} steps…`);
        try {
          const storyRaw = await callClaudeText(STORY_SYSTEM, `Generate one atomic user story per step for this epic.\n\nEPIC: ${g.epic_id} — ${g.epic_title}\nDESCRIPTION: ${g.epic_description}\n\nSTEPS:\n${JSON.stringify(epicSteps.map(x => ({ id: x.id, actor: x.actor, action: x.action, type: x.type })), null, 2)}\n\nSTART story_id numbering from US-${String(counter).padStart(2, "0")}`, 5000);
          const er = extractSpecJSON(isolateJSON(storyRaw));
          const renum = { ...er, epic_id: g.epic_id, stories: (er.stories || []).map((st, j) => ({ ...st, story_id: `US-${String(counter + j).padStart(2, "0")}` })) };
          counter += renum.stories.length;
          allEpics.push(renum);
          set({ ...s, epics: [...allEpics] });
          setOpenEpic(p => p || renum.epic_id);
          appendLog(`    ✔ ${renum.stories.length} stories`, "success");
        } catch (e) { appendLog(`    ⚠ ${g.epic_title} failed: ${e.message}`, "warn"); }
        if (i < groups.length - 1) await sleepMs(800);
      }
      const total = allEpics.reduce((n, e) => n + (e.stories?.length || 0), 0);
      appendLog(`✔ ${allEpics.length} epics, ${total} stories`, "success");
      setPhase("done");
    } catch (e) { appendLog(`✘ ${e.message}`, "error"); setErr(e.message); setPhase("error"); }
  };

  const total = epics.reduce((n, e) => n + (e.stories?.length || 0), 0);
  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      <div className="card">
        <CardHead title="Epics & Stories" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Stage 03</span>} />
        {hasFlow
          ? <div style={{ fontSize: 11, color: "var(--accent)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>✔ From your flow ({flow.steps.length} steps) — one story per step</div>
          : <div style={{ fontSize: 11, color: "var(--warn)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>No flow yet — generate one in Stage 01 first.</div>}
        <button className="btn btn-primary" disabled={phase === "running" || !hasFlow} onClick={run}>
          {phase === "running" ? <LiveStep log={log} fallback="Generating…" /> : epics.length ? "Regenerate epics" : "Generate epics"}</button>
        <ErrBox msg={err} />
        {log.length > 0 && <div style={{ marginTop: 12, fontFamily: "var(--font-mono)", fontSize: 11, lineHeight: 1.7 }}>{log.map((l, i) => <div key={i} style={{ color: l.type === "error" ? "var(--warn)" : l.type === "success" ? "var(--accent)" : l.type === "warn" ? "var(--gold)" : "var(--muted)" }}>{l.msg}</div>)}</div>}
      </div>

      {epics.length > 0 && (
        <div className="card">
          <CardHead title={`${epics.length} epics · ${total} stories`} right={
            <div style={{ display: "flex", gap: 8 }}>
              <button className="btn btn-secondary btn-sm" title="Download epics & stories as PDF" onClick={() => exportEpicsPDF(epics, { title: (s.projectName || "Spec") + " — Epics", filename: "epics.pdf" })}>⤓ PDF</button>
              <button className="btn btn-secondary btn-sm" title="Download a Jira-import CSV (epics + stories, Parent-linked)" onClick={() => exportEpicsJiraCSV(epics, s.projectName, "epics-jira-import.csv")}>⤓ Jira CSV</button>
            </div>} />
          <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
            {epics.map(e => {
              const open = openEpic === e.epic_id;
              return (
                <div key={e.epic_id} style={{ borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg)", overflow: "hidden" }}>
                  <div onClick={() => setOpenEpic(open ? null : e.epic_id)} style={{ padding: 12, cursor: "pointer", display: "flex", alignItems: "center", gap: 8 }}>
                    <span style={{ fontSize: 11, color: "var(--muted)", transform: open ? "rotate(90deg)" : "none", transition: "transform .15s" }}>▸</span>
                    <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--purple)" }}>{e.epic_id}</span>
                    <strong style={{ fontSize: 13 }}>{e.epic_title}</strong>
                    <span style={{ marginLeft: "auto", fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{e.stories?.length || 0} stories</span>
                  </div>
                  {open && (
                    <div style={{ padding: "0 12px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
                      {e.epic_description && <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.5 }}>{e.epic_description}</div>}
                      {(e.stories || []).map(st => (
                        <div key={st.story_id} style={{ padding: 10, borderRadius: 6, border: "1px solid var(--border)" }}>
                          <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
                            <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--muted)" }}>{st.story_id}</span>
                            <strong style={{ fontSize: 12.5 }}>{st.title}</strong>
                            <span className={`tag ${priTagSpec(st.priority)}`} style={{ fontSize: 9 }}>{st.priority}</span>
                          </div>
                          <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.5 }}>As a <strong style={{ color: "var(--text)" }}>{st.role}</strong>, I want to {st.action}{st.value ? <>, so that {st.value}</> : null}.</div>
                          {(st.acceptance_criteria || []).length > 0 && (
                            <ul style={{ margin: "6px 0 0", paddingLeft: 18, display: "flex", flexDirection: "column", gap: 2 }}>
                              {st.acceptance_criteria.map((ac, i) => <li key={i} style={{ fontSize: 11.5, lineHeight: 1.45 }}>{ac}</li>)}
                            </ul>
                          )}
                        </div>
                      ))}
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

// ── Spec 04 · Story Sizing ────────────────────────────────────────────────────
const SIZING_SYSTEM = `You are a senior engineering lead estimating user stories in Fibonacci story points (1, 2, 3, 5, 8, 13).

Story points measure RELATIVE effort and complexity, not hours. Judge each story on four factors: uncertainty, dependencies, integration, testing.
Scale: 1–2 trivial · 3–5 moderate · 8 large · 13 very large or too uncertain.
Rules: if too big/uncertain to estimate confidently set "recommend_split": true. Be CONSISTENT. Base the estimate on action + acceptance criteria, not the title alone.

Respond ONLY with valid JSON — no markdown:
{"sized":[{"story_id":"US-01","points":3,"confidence":0.0,"recommend_split":false,"rationale":"<=20 words on the drivers","factors":{"uncertainty":"low|medium|high","dependencies":"low|medium|high","integration":"low|medium|high","testing":"low|medium|high"}}]}
Use ONLY 1,2,3,5,8,13. Size every story_id provided.`;

function SpecStageSizing({ s, set }) {
  const epics = s.epics || [];
  const hasEpics = epics.some(e => (e.stories || []).length);
  const sized = epics.some(e => (e.stories || []).some(st => st.points != null));
  const [phase, setPhase] = useState(sized ? "done" : "idle");
  const [log, setLog] = useState([]);
  const [err, setErr] = useState("");
  const [openStory, setOpenStory] = useState(null);   // ── SIZING DETAIL ── click a story to see rationale
  const appendLog = (msg, type = "info") => setLog(l => [...l, { msg, type }]);

  const run = async () => {
    if (!hasEpics) return;
    setPhase("running"); setLog([]); setErr("");
    appendLog("► Sizing stories from technical detail…"); await sleepMs(250);
    const fresh = epics.map(e => ({ ...e, stories: (e.stories || []).map(st => ({ ...st })) }));
    try {
      for (let i = 0; i < fresh.length; i++) {
        const epic = fresh[i]; const stories = epic.stories || [];
        if (!stories.length) continue;
        appendLog(`  Epic ${epic.epic_id}: estimating ${stories.length} stories…`);
        const payload = stories.map(st => ({ story_id: st.story_id, title: st.title, role: st.role, action: st.action, acceptance_criteria: st.acceptance_criteria }));
        const raw = await callClaudeText(SIZING_SYSTEM, `Estimate Fibonacci story points for these stories. Use the acceptance criteria as the technical detail:\n\n${JSON.stringify(payload, null, 2)}`);
        const list = (extractSpecJSON(isolateJSON(raw)).sized) || [];
        const byId = Object.fromEntries(list.map(z => [z.story_id, z]));
        epic.stories = stories.map(st => { const z = byId[st.story_id]; return z ? { ...st, points: z.points, sizing: { confidence: z.confidence, recommend_split: z.recommend_split, rationale: z.rationale, factors: z.factors || {} } } : st; });
        const splits = epic.stories.filter(st => st.sizing?.recommend_split).length;
        appendLog(`    ✔ sized${splits ? ` · ${splits} flagged to split` : ""}`, "success");
        await sleepMs(120);
      }
      const flat = fresh.flatMap(e => (e.stories || []).map(st => ({ ...st, epic_id: e.epic_id, epic_title: e.epic_title })));
      const total = flat.reduce((n, st) => n + (st.points || 0), 0);
      appendLog(`✔ Sizing complete — ${total} points total`, "success");
      set({ ...s, epics: fresh, aiadStories: flat });
      setPhase("done");
    } catch (e) { appendLog(`✘ ${e.message}`, "error"); setErr(e.message); setPhase("error"); }
  };

  const totalPts = epics.reduce((n, e) => n + (e.stories || []).reduce((m, st) => m + (st.points || 0), 0), 0);
  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      <div className="card">
        <CardHead title="Story Sizing" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Stage 04</span>} />
        {hasEpics
          ? <div style={{ fontSize: 11, color: "var(--accent)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>✔ {epics.reduce((n, e) => n + (e.stories?.length || 0), 0)} stories across {epics.length} epics{sized ? ` · ${totalPts} pts` : ""}</div>
          : <div style={{ fontSize: 11, color: "var(--warn)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>No epics yet — generate them in Stage 03 first.</div>}
        <button className="btn btn-primary" disabled={phase === "running" || !hasEpics} onClick={run}>
          {phase === "running" ? <LiveStep log={log} fallback="Sizing…" /> : sized ? "Re-size stories" : "Size stories"}</button>
        <ErrBox msg={err} />
        {log.length > 0 && <div style={{ marginTop: 12, fontFamily: "var(--font-mono)", fontSize: 11, lineHeight: 1.7 }}>{log.map((l, i) => <div key={i} style={{ color: l.type === "error" ? "var(--warn)" : l.type === "success" ? "var(--accent)" : l.type === "warn" ? "var(--gold)" : "var(--muted)" }}>{l.msg}</div>)}</div>}
      </div>

      {sized && (
        <div className="card">
          <CardHead title="Sized Stories" right={
            <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
              <span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{totalPts} points</span>
              <button className="btn btn-secondary btn-sm" title="Download sized stories as PDF (with sizing rationale)" onClick={() => exportEpicsPDF(epics, { title: (s.projectName || "Spec") + " — Sized Stories", showPoints: true, filename: "sized-stories.pdf" })}>⤓ PDF</button>
              <button className="btn btn-secondary btn-sm" title="Download a Jira-import CSV (with story points)" onClick={() => exportEpicsJiraCSV(epics, s.projectName, "sized-jira-import.csv")}>⤓ Jira CSV</button>
            </div>} />
          {epics.map(e => (
            <div key={e.epic_id} style={{ marginBottom: 12 }}>
              <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 6 }}>{e.epic_id} · {e.epic_title}</div>
              <div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
                {(e.stories || []).map(st => {
                  const sopen = openStory === st.story_id;
                  const hasDetail = !!st.sizing;
                  return (
                  <div key={st.story_id} style={{ borderRadius: 6, border: "1px solid " + (sopen ? "var(--border)" : "transparent"), background: sopen ? "var(--bg)" : "transparent" }}>
                    <div onClick={() => hasDetail && setOpenStory(sopen ? null : st.story_id)}
                      style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 12, padding: "5px 8px", cursor: hasDetail ? "pointer" : "default" }}>
                      {hasDetail && <span style={{ fontSize: 10, color: "var(--muted)", flexShrink: 0, transform: sopen ? "rotate(90deg)" : "none", transition: "transform .15s" }}>▸</span>}
                      <span style={{ flexShrink: 0, width: 26, height: 22, borderRadius: 5, background: st.sizing?.recommend_split ? "var(--warn)" : "var(--accent)", color: "#fff", fontWeight: 800, fontSize: 11, display: "flex", alignItems: "center", justifyContent: "center" }}>{st.points ?? "–"}</span>
                      <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--muted)" }}>{st.story_id}</span>
                      <span style={{ flex: 1, minWidth: 0 }}>{st.title}</span>
                      {st.sizing?.recommend_split && <span className="tag tag-red" style={{ fontSize: 9 }}>split</span>}
                    </div>
                    {sopen && hasDetail && (
                      <div style={{ padding: "2px 12px 12px 44px", display: "flex", flexDirection: "column", gap: 8 }}>
                        {st.sizing.rationale && <div style={{ fontSize: 12.5, lineHeight: 1.55, color: "var(--text)" }}>{st.sizing.rationale}</div>}
                        <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
                          {typeof st.sizing.confidence === "number" && <span className="tag tag-muted" style={{ fontSize: 9 }}>confidence {Math.round(st.sizing.confidence * 100)}%</span>}
                          {Object.entries(st.sizing.factors || {}).map(([k, v]) => (
                            <span key={k} className={`tag ${v === "high" ? "tag-red" : v === "medium" ? "tag-gold" : "tag-muted"}`} style={{ fontSize: 9 }}>{k}: {v}</span>
                          ))}
                        </div>
                        {st.sizing.recommend_split && <div style={{ fontSize: 11, color: "var(--warn)" }}>⚠ Recommend splitting this story before committing the estimate.</div>}
                      </div>
                    )}
                  </div>
                  );
                })}
              </div>
            </div>
          ))}
          <div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Points-flagged 13s suggest splitting before sizing is trusted. Manual point override is coming with the editor port.</div>
        </div>
      )}
    </div>
  );
}

// ── Spec 05 · Jira Compare ────────────────────────────────────────────────────
const JIRA_COMPARE_SYSTEM = `You are a senior Business Analyst comparing two sets of product requirements:
(A) AIAD-GENERATED requirements for the CURRENT project (epics + user stories + acceptance criteria).
(B) EXISTING requirements imported from another Jira project (issues with summaries / descriptions / acceptance criteria).

Compare at the REQUIREMENT level. Match by intent and behaviour, NOT wording or ID. The projects may legitimately differ in scope.

Classify each meaningful comparison: "matched", "missing_in_aiad" (Jira has it, AIAD doesn't), "missing_in_jira" (AIAD has it, Jira doesn't), "conflict" (same requirement specified differently). Only report MEANINGFUL items.

Respond ONLY with valid JSON — no markdown:
{"summary":"2-4 sentence assessment","alignment_score":0,"comparisons":[{"id":"C1","category":"matched|missing_in_aiad|missing_in_jira|conflict","title":"short title","aiad_ref":"US-03 — title or null","jira_ref":"PROJ-12 — summary or null","detail":"what is same/different/missing and why","severity":"high|medium|low","recommendation":"concrete next action"}]}
"alignment_score" 0–100. Order by severity, highest first.`;

const CMP_CATS = {
  conflict: { label: "Conflicts", color: "var(--gold)" },
  missing_in_aiad: { label: "Missing in AIAD", color: "var(--warn)" },
  missing_in_jira: { label: "New vs Jira", color: "var(--accent2)" },
  matched: { label: "Matched", color: "var(--accent)" },
};
const CMP_CAT_ORDER = ["conflict", "missing_in_aiad", "missing_in_jira", "matched"];

function parseCSVRows(text) {
  const out = []; let row = [], cur = "", q = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (q) { if (c === '"' && text[i + 1] === '"') { cur += '"'; i++; } else if (c === '"') q = false; else cur += c; }
    else if (c === '"') q = true;
    else if (c === ",") { row.push(cur); cur = ""; }
    else if (c === "\n" || c === "\r") { if (cur !== "" || row.length) { row.push(cur); out.push(row); row = []; cur = ""; } if (c === "\r" && text[i + 1] === "\n") i++; }
    else cur += c;
  }
  if (cur !== "" || row.length) { row.push(cur); out.push(row); }
  return out.filter(r => r.some(c => c.trim() !== ""));
}

function SpecStageJiraCompare({ s, set }) {
  const epics = s.epics || [];
  const saved = s.jiraCompare || null;
  const [existing, setExisting] = useState(saved?.existing || null);
  const [result, setResult] = useState(saved?.result || null);
  const [phase, setPhase] = useState(saved?.result ? "done" : "idle");
  const [log, setLog] = useState([]);
  const [err, setErr] = useState("");
  const [jql, setJql] = useState("");
  const [pulling, setPulling] = useState(false);
  const fileRef = useRef();
  const appendLog = (msg, type = "info") => setLog(l => [...l, { msg, type }]);

  const ingestCSV = (text, name) => {
    const rows = parseCSVRows(text); if (!rows.length) return;
    const header = rows[0].map(h => h.trim().toLowerCase());
    const find = (...keys) => header.findIndex(h => keys.some(k => h.includes(k)));
    const idI = find("issue key", "key", "id"), typeI = find("issue type", "type"), sumI = find("summary", "title", "name"), descI = find("description", "detail"), acI = find("acceptance");
    const data = rows.slice(1).map((r, i) => ({ id: idI >= 0 ? (r[idI] || "").trim() : `ROW-${i + 1}`, type: typeI >= 0 ? (r[typeI] || "").trim() : "", summary: sumI >= 0 ? (r[sumI] || "").trim() : "", description: descI >= 0 ? (r[descI] || "").trim() : "", ac: acI >= 0 ? (r[acI] || "").trim() : "" })).filter(x => x.summary || x.description);
    setExisting({ name, count: data.length, rows: data }); setResult(null); setPhase("idle");
  };
  const handleFile = (e) => { const f = e.target.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = ev => ingestCSV(String(ev.target.result || ""), f.name); rd.readAsText(f); };
  const pullJira = async () => {
    if (!jql.trim()) return;
    setPulling(true); setErr("");
    try {
      const r = await fetch(`${BROKER_BASE}/board?jql=${encodeURIComponent(jql)}`, { credentials: "include" });
      const d = await r.json();
      if (r.status === 409 || d.code === "no_jira") throw new Error("Connect your Jira account first (Settings → Jira).");
      if (!r.ok || d.error) throw new Error(d.error || `broker ${r.status}`);
      const rows = (d.tickets || []).map(t => ({ id: t.key, type: t.type, summary: t.summary, description: "", ac: "" }));
      setExisting({ name: `Jira: ${jql}`, count: rows.length, rows }); setResult(null); setPhase("idle");
    } catch (e) { setErr(`Pull failed: ${e.message}`); } finally { setPulling(false); }
  };

  const aiadReqs = epics.flatMap(ep => (ep.stories || []).map(st => ({ id: st.story_id, epic: ep.epic_title, title: st.title, story: `As a ${st.role}, I want to ${st.action}, so that ${st.value}`, acceptance_criteria: st.acceptance_criteria || [], points: st.points ?? null })));

  const run = async () => {
    if (!existing || !aiadReqs.length) return;
    setPhase("running"); setLog([]); setErr("");
    appendLog(`► ${aiadReqs.length} AIAD stories vs ${existing.count} existing Jira issues…`); await sleepMs(200);
    appendLog("  Matching by intent, surfacing gaps & conflicts…");
    try {
      const raw = await callClaudeText(JIRA_COMPARE_SYSTEM, `(A) AIAD-GENERATED requirements:\n${JSON.stringify(aiadReqs, null, 2).slice(0, 8000)}\n\n(B) EXISTING Jira issues:\n${JSON.stringify(existing.rows, null, 2).slice(0, 8000)}`);
      const parsed = extractSpecJSON(isolateJSON(raw));
      appendLog(`✔ ${(parsed.comparisons || []).length} comparisons · alignment ${parsed.alignment_score ?? "—"}/100`, "success");
      setResult(parsed); setPhase("done");
      set({ ...s, jiraCompare: { existing, result: parsed } });
    } catch (e) { appendLog(`✘ ${e.message}`, "error"); setErr(e.message); setPhase("error"); }
  };

  const cmps = result?.comparisons || [];
  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      <div className="card">
        <CardHead title="Jira Compare" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Stage 05 · Optional</span>} />
        <div style={{ fontSize: 11, color: "var(--accent2)", background: "color-mix(in srgb, var(--accent2) 8%, transparent)", border: "1px solid color-mix(in srgb, var(--accent2) 30%, transparent)", borderRadius: 8, padding: "8px 12px", lineHeight: 1.55, marginBottom: 12 }}>
          Optional step — only useful if a Jira project <strong>already exists</strong> and you want to compare your generated requirements against it. For a brand-new spec, skip ahead to Test Cases.
        </div>
        <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6, marginBottom: 12 }}>Diff the generated stories against an existing Jira project — pull issues live, or upload a CSV export.</div>
        {IS_LOCAL_APP && (
          <div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
            <input type="text" value={jql} onChange={e => setJql(e.target.value)} placeholder="JQL, e.g. project = RP AND statusCategory != Done" style={{ flex: 1, fontFamily: "var(--font-mono)", fontSize: 12 }} />
            <button className="btn btn-secondary btn-sm" disabled={pulling || !jql.trim()} onClick={pullJira}>{pulling ? <><Spinner /> Pulling…</> : "Pull from Jira"}</button>
          </div>
        )}
        <div style={{ display: "flex", gap: 10, alignItems: "center", marginBottom: 12 }}>
          <button className="btn btn-secondary btn-sm" onClick={() => fileRef.current.click()}>Upload CSV</button>
          <input ref={fileRef} type="file" accept=".csv,text/csv" style={{ display: "none" }} onChange={handleFile} />
          {existing && <span style={{ fontSize: 11, color: "var(--accent)", fontFamily: "var(--font-mono)" }}>✔ {existing.name} · {existing.count} issues</span>}
        </div>
        <button className="btn btn-primary" disabled={phase === "running" || !existing || !aiadReqs.length} onClick={run}>
          {phase === "running" ? <LiveStep log={log} fallback="Comparing…" /> : result ? "Re-compare" : "Compare"}</button>
        <ErrBox msg={err} />
        {log.length > 0 && <div style={{ marginTop: 12, fontFamily: "var(--font-mono)", fontSize: 11, lineHeight: 1.7 }}>{log.map((l, i) => <div key={i} style={{ color: l.type === "error" ? "var(--warn)" : l.type === "success" ? "var(--accent)" : "var(--muted)" }}>{l.msg}</div>)}</div>}
      </div>

      {result && (
        <div className="card">
          <CardHead title="Comparison" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>alignment {result.alignment_score ?? "—"}/100</span>} />
          {result.summary && <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6, marginBottom: 12 }}>{result.summary}</div>}
          <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 14 }}>
            {CMP_CAT_ORDER.map(cat => { const n = cmps.filter(c => c.category === cat).length; if (!n) return null; return <div key={cat} style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 10px", borderRadius: 6, background: "var(--bg)", fontSize: 11 }}><span style={{ width: 8, height: 8, borderRadius: 2, background: CMP_CATS[cat].color }} />{CMP_CATS[cat].label}<strong>{n}</strong></div>; })}
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
            {cmps.map(c => (
              <div key={c.id} style={{ padding: 12, borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg)" }}>
                <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
                  <span style={{ width: 8, height: 8, borderRadius: 2, background: (CMP_CATS[c.category] || {}).color || "var(--muted)" }} />
                  <strong style={{ fontSize: 13 }}>{c.title}</strong>
                  <span className={`tag ${gapSevTag(c.severity)}`} style={{ fontSize: 9 }}>{c.severity}</span>
                  <span style={{ marginLeft: "auto", fontSize: 9, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{(CMP_CATS[c.category] || {}).label}</span>
                </div>
                <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.5 }}>{c.detail}</div>
                <div style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)", marginTop: 4 }}>{c.aiad_ref ? `AIAD: ${c.aiad_ref}` : ""}{c.aiad_ref && c.jira_ref ? "  ·  " : ""}{c.jira_ref ? `Jira: ${c.jira_ref}` : ""}</div>
                {c.recommendation && <div style={{ fontSize: 12, marginTop: 4 }}><span style={{ color: "var(--accent)" }}>Do:</span> {c.recommendation}</div>}
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

// ── Spec 06 · Test Cases ──────────────────────────────────────────────────────
const TESTCASE_SYSTEM = `You are a QA Lead. Generate functional test cases from user stories for QA sign-off.

For each story generate exactly 2-3 test cases: 1 happy path + 1-2 negative/edge cases.
Use as many steps as the test actually needs — as few as 1-2 for a simple check, more for a multi-step or branching flow. Don't pad to a fixed count. Each step is a single concrete action.
Keep all text concise — steps under 10 words each, expected_result under 15 words.

Respond ONLY with valid JSON array — no markdown:
[
  {
    "story_id": "US-01",
    "story_title": "title",
    "test_cases": [
      {
        "tc_id": "TC-001",
        "title": "Test case title",
        "type": "happy_path|negative|edge_case|boundary",
        "priority": "Critical|High|Medium|Low",
        "preconditions": ["condition1"],
        "steps": ["Step 1: ...", "Step 2: ..."],
        "expected_result": "What should happen",
        "test_data": "Specific data values"
      }
    ]
  }
]`;

const TC_TYPE_TAG = { happy_path: "tag-green", negative: "tag-red", edge_case: "tag-gold", boundary: "tag-blue" };

function SpecStageTestCases({ s, set }) {
  const epics = s.epics || [];
  const allStories = epics.flatMap(e => (e.stories || []).map(st => ({ ...st, epicTitle: e.epic_title, epic_id: e.epic_id })));
  const hasStories = allStories.length > 0;
  const suites = s.testSuites || [];
  const hasSuites = suites.length > 0;

  const [phase, setPhase] = useState(hasSuites ? "done" : "idle");
  const [log, setLog] = useState([]);
  const [err, setErr] = useState("");
  const [selId, setSelId] = useState(hasSuites ? suites[0].story_id : null);
  const [expTC, setExpTC] = useState(null);
  const appendLog = (msg, type = "info") => setLog(l => [...l, { msg, type }]);

  const genBatch = async (batch) => {
    const raw = await callClaudeText(TESTCASE_SYSTEM,
      `Generate test cases for these user stories:\n\n${JSON.stringify(
        batch.map(st => ({ story_id: st.story_id, title: st.title, role: st.role, action: st.action, value: st.value, acceptance_criteria: st.acceptance_criteria })),
        null, 2)}`, 6000);
    const parsed = JSON.parse(isolateJSON(raw));
    return Array.isArray(parsed) ? parsed : [];
  };

  const run = async () => {
    if (!hasStories) return;
    setPhase("running"); setLog([]); setErr(""); setExpTC(null);
    appendLog("► Analysing acceptance criteria…"); await sleepMs(250);
    appendLog(`  ${allStories.length} user stories → generating test cases…`);
    const batchSize = 5;
    const all = [];
    let hadError = false;
    try {
      for (let i = 0; i < allStories.length; i += batchSize) {
        const batch = allStories.slice(i, i + batchSize);
        const end = Math.min(i + batchSize, allStories.length);
        appendLog(`  Processing stories ${i + 1}–${end} of ${allStories.length}…`);
        try {
          const parsed = await genBatch(batch);
          all.push(...parsed);
          set({ ...s, testSuites: [...all] });
          appendLog(`    ✔ ${i + 1}–${end} done (${parsed.reduce((n, x) => n + (x.test_cases?.length || 0), 0)} TCs)`, "success");
        } catch (e) {
          // Fall back to one story at a time so a single bad story doesn't drop the batch.
          appendLog(`    ⚠ batch failed — retrying individually…`, "warn");
          for (const st of batch) {
            try {
              const one = await genBatch([st]);
              all.push(...one);
              set({ ...s, testSuites: [...all] });
              appendLog(`      ✔ ${st.story_id || st.title} recovered`, "success");
            } catch (e2) {
              appendLog(`      ✗ ${st.story_id || st.title} skipped: ${e2.message}`, "warn");
              hadError = true;
            }
            await sleepMs(400);
          }
        }
        if (i + batchSize < allStories.length) await sleepMs(800);
      }
      const totalTCs = all.reduce((n, x) => n + (x.test_cases?.length || 0), 0);
      if (all.length) {
        appendLog(`✔ ${totalTCs} test cases across ${all.length} stories${hadError ? " (some skipped)" : ""}`, "success");
        set({ ...s, testSuites: all });
        setSelId(all[0].story_id);
        setPhase("done");
      } else {
        appendLog("✘ All batches failed — please retry", "error");
        setPhase("error");
      }
    } catch (e) { appendLog(`✘ ${e.message}`, "error"); setErr(e.message); setPhase("error"); }
  };

  const totalTCs = suites.reduce((n, x) => n + (x.test_cases?.length || 0), 0);
  const criticalTCs = suites.reduce((n, x) => n + (x.test_cases?.filter(t => t.priority === "Critical").length || 0), 0);
  const selected = suites.find(x => x.story_id === selId) || null;

  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      <div className="card">
        <CardHead title="Test Cases" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Stage 06</span>} />
        {hasStories
          ? <div style={{ fontSize: 11, color: "var(--accent)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>✔ {allStories.length} stories across {epics.length} epics{hasSuites ? ` · ${totalTCs} test cases${criticalTCs ? ` · ${criticalTCs} critical` : ""}` : ""}</div>
          : <div style={{ fontSize: 11, color: "var(--warn)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>No stories yet — generate epics in Stage 03 first.</div>}
        <button className="btn btn-primary" disabled={phase === "running" || !hasStories} onClick={run}>
          {phase === "running" ? <LiveStep log={log} fallback="Generating…" /> : hasSuites ? "Regenerate test cases" : "Generate test cases"}</button>
        <ErrBox msg={err} />
        {log.length > 0 && <div style={{ marginTop: 12, fontFamily: "var(--font-mono)", fontSize: 11, lineHeight: 1.7 }}>{log.map((l, i) => <div key={i} style={{ color: l.type === "error" ? "var(--warn)" : l.type === "success" ? "var(--accent)" : l.type === "warn" ? "var(--gold)" : "var(--muted)" }}>{l.msg}</div>)}</div>}
      </div>

      {hasSuites && (
        <div className="card" style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
          <span style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--muted)" }}>Export</span>
          <button className="btn btn-secondary btn-sm" title="Readable PDF of all test cases" onClick={() => exportTestCasesPDF(suites, s.projectName)}>⤓ PDF</button>
          <button className="btn btn-secondary btn-sm" title="Jira import CSV — test cases as Test-type issues" onClick={() => exportTestCasesJiraCSV(suites, s.projectName)}>⤓ Jira CSV</button>
          <span style={{ width: 1, height: 18, background: "var(--border)" }} />
          <span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Helix ALM:</span>
          <button className="btn btn-secondary btn-sm" title="Helix ALM CSV import (flat fields)" onClick={() => exportHelixCSV(suites)}>⤓ CSV</button>
          <button className="btn btn-secondary btn-sm" title="Helix ALM XML import — preserves step structure, preconditions, test data" onClick={() => exportHelixXML(suites)}>⤓ XML</button>
        </div>
      )}

      {hasSuites && (
        <div className="card" style={{ display: "grid", gridTemplateColumns: "240px 1fr", gap: 16 }}>
          {/* Story list */}
          <div style={{ display: "flex", flexDirection: "column", gap: 6, maxHeight: 520, overflowY: "auto" }}>
            <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 2 }}>Stories</div>
            {suites.map(suite => (
              <div key={suite.story_id} onClick={() => { setSelId(suite.story_id); setExpTC(null); }} style={{
                padding: "9px 11px", borderRadius: 7, cursor: "pointer",
                background: selId === suite.story_id ? "rgba(245,197,66,.06)" : "var(--panel)",
                border: `1px solid ${selId === suite.story_id ? "rgba(245,197,66,.35)" : "var(--border)"}`,
              }}>
                <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 2 }}>
                  <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--gold)", fontWeight: 700 }}>{suite.story_id}</span>
                  <span className="tag tag-muted" style={{ fontSize: 9 }}>{suite.test_cases?.length || 0} TCs</span>
                </div>
                <div style={{ fontSize: 11, fontWeight: 600, lineHeight: 1.3 }}>{suite.story_title}</div>
              </div>
            ))}
          </div>

          {/* Test cases for the selected story */}
          <div style={{ minWidth: 0 }}>
            <CardHead title="Test Suite" right={selected && <span className="tag tag-gold">{selected.story_id}</span>} />
            {!selected ? <EmptyState icon="⬡" title="Select a story" sub="Pick a story to view its test cases." />
              : (
                <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                  {(selected.test_cases || []).map(tc => (
                    <div key={tc.tc_id} style={{ borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg)", overflow: "hidden" }}>
                      <div onClick={() => setExpTC(expTC === tc.tc_id ? null : tc.tc_id)} style={{ padding: "10px 13px", cursor: "pointer", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
                        <div style={{ display: "flex", gap: 8, alignItems: "center", minWidth: 0 }}>
                          <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--muted)", flexShrink: 0 }}>{tc.tc_id}</span>
                          <span style={{ fontSize: 12, fontWeight: 600 }}>{tc.title}</span>
                        </div>
                        <div style={{ display: "flex", gap: 6, alignItems: "center", flexShrink: 0 }}>
                          <span className={`tag ${TC_TYPE_TAG[tc.type] || "tag-muted"}`} style={{ fontSize: 9 }}>{(tc.type || "").replace("_", " ")}</span>
                          <span className={`tag ${tc.priority === "Critical" ? "tag-red" : tc.priority === "High" ? "tag-gold" : "tag-muted"}`} style={{ fontSize: 9 }}>{tc.priority}</span>
                          <span style={{ color: "var(--muted)", fontSize: 11 }}>{expTC === tc.tc_id ? "▲" : "▼"}</span>
                        </div>
                      </div>
                      {expTC === tc.tc_id && (
                        <div style={{ padding: "0 13px 13px", borderTop: "1px solid var(--border)" }}>
                          {tc.preconditions?.length > 0 && (
                            <div style={{ marginTop: 10 }}>
                              <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 4 }}>Preconditions</div>
                              {tc.preconditions.map((p, j) => <div key={j} style={{ fontSize: 11, color: "var(--muted)", marginBottom: 3, paddingLeft: 10, borderLeft: "2px solid var(--accent2)" }}>· {p}</div>)}
                            </div>
                          )}
                          <div style={{ marginTop: 10 }}>
                            <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 4 }}>Steps</div>
                            {(tc.steps || []).map((step, j) => <div key={j} style={{ fontSize: 11, color: "var(--text)", marginBottom: 4, padding: "5px 10px", background: "var(--panel)", borderRadius: 5 }}>{step}</div>)}
                          </div>
                          <div style={{ marginTop: 10 }}>
                            <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 4 }}>Expected Result</div>
                            <div style={{ fontSize: 11, color: "var(--accent)", padding: "6px 10px", background: "rgba(228,0,43,.05)", borderRadius: 5, border: "1px solid rgba(228,0,43,.15)" }}>{tc.expected_result}</div>
                          </div>
                          {tc.test_data && (
                            <div style={{ marginTop: 10 }}>
                              <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 4 }}>Test Data</div>
                              <div style={{ fontSize: 11, color: "var(--gold)", fontFamily: "var(--font-mono)", padding: "6px 10px", background: "rgba(245,197,66,.04)", borderRadius: 5 }}>{tc.test_data}</div>
                            </div>
                          )}
                        </div>
                      )}
                    </div>
                  ))}
                </div>
              )}
          </div>
        </div>
      )}
    </div>
  );
}

// ── Spec · Foundation stages (Tech Stack → Architecture → Repo) ───────────────
// These three AI stages define the technical foundation for a from-zero build,
// so the Compiler generates packages grounded in a real stack, architecture and
// file layout instead of inventing them. Each produces an editable markdown doc.

function projectBrief(s) {
  const flow = s.flowchart || {};
  const epics = s.epics || [];
  const stories = epics.flatMap(e => (e.stories || []));
  return `PROJECT: ${s.projectName || "Untitled"}\n`
    + (flow.title ? `FLOW: ${flow.title}\n` : "")
    + `SCOPE: ${epics.length} epics, ${stories.length} user stories.\n`
    + `EPICS:\n${epics.map(e => `- ${e.epic_id} ${e.epic_title}: ${e.epic_description || ""}`).join("\n") || "(none)"}\n`
    + `STORIES:\n${stories.slice(0, 60).map(st => `- ${st.story_id} ${st.title} — as a ${st.role}, ${st.action}`).join("\n") || "(none)"}`;
}

const TECHSTACK_SYSTEM = `You are a pragmatic principal engineer choosing a technology stack for a NEW product built from zero, primarily by AI coding agents.
Recommend a concrete, modern, mainstream stack — favor well-documented, widely-adopted choices an AI agent can implement reliably. Avoid exotic tech.
Cover: language(s) & runtime, frontend framework, backend framework, database, ORM/data layer, auth approach, API style, hosting/deploy target, testing tools, and key libraries. Give a one-line justification per choice and note one alternative where it matters.
If a "GROUNDING" section is provided, treat it as AUTHORITATIVE: conform the stack to your existing standards, frameworks, and platform conventions rather than generic defaults. Where you follow an existing standard, say so briefly (e.g. "matches existing services"). Only deviate from your stack with an explicit reason.
Respond in clean GitHub-flavored MARKDOWN only (no preamble). Use ## sections and bullet lists. Keep it tight and decision-oriented.`;

const ARCH_SYSTEM = `You are a principal software architect designing a NEW product from zero, to be implemented primarily by AI coding agents.
Given the product scope and the chosen tech stack, produce a clear architecture document covering: high-level architecture & components, the data model (each entity with key fields, types, and relationships), API surface (key endpoints with method, path, purpose), cross-cutting concerns (auth, validation, error handling, logging), and the most important architecture decisions with rationale.
Be concrete and unambiguous — an AI agent must be able to build from this without guessing. Define real entity/field names and real endpoint paths.
If a "GROUNDING" section is provided, treat it as AUTHORITATIVE: design the product to fit your existing services, APIs, data patterns, auth, and integration points described there — name the real systems it integrates with, and follow your architecture conventions rather than inventing generic ones.

CRITICAL FORMATTING RULES:
- DO NOT draw ASCII-art or text diagrams (no boxes made of |, -, +, arrows, or block characters). They render poorly and add no value.
- Represent components, layers, and relationships as MARKDOWN TABLES and BULLET LISTS instead. For example, a component inventory table (Component | Tech | Responsibility | Depends on), a data-model table per entity (Field | Type | Notes), and an endpoint table (Method | Path | Purpose | Auth).
- Describe data/request flows as concise numbered steps in prose, not as drawn diagrams.
- Use fenced code blocks ONLY for actual code, config, or JSON examples — never for diagrams.

Respond in clean GitHub-flavored MARKDOWN only (no preamble). Use ## sections, bullet lists, and tables.`;

// Emits a swimlane IR (same shape as the step-1 flow engine) for the architecture's
// runtime request/data flow — lanes are architectural components/zones, steps are
// the sequential operations. Rendered by the existing SwimlaneView engine (no new deps).
const ARCH_DIAGRAM_SYSTEM = `You convert a software architecture document into a CLEAN, LEGIBLE Mermaid architecture diagram. The diagram is a high-level OVERVIEW, not an exhaustive wiring map. The full detail lives in the document — the diagram must stay readable above all else. A cluttered diagram with crossing lines is a FAILURE.

Hard constraints (follow strictly):
- Output a Mermaid "graph TD" (top-down) definition. Layer the system into vertical tiers.
- NODE BUDGET: 7-11 nodes MAXIMUM. Collapse detail to stay under budget:
  - Combine multiple similar services into ONE node (e.g., several "… Worker" services → one "Async Workers" node; multiple small APIs → one "Core Services" node).
  - Represent the data tier as AT MOST 2 nodes (e.g., one "Postgres" and one "Cache/Queue"), not five separate stores.
  - Only show external systems that are genuinely central.
- EDGE BUDGET: 12 edges MAXIMUM. This is the most important rule. To stay under it:
  - Draw only PRIMARY relationships — the main request path and the most important dependencies.
  - DO NOT connect every service to every datastore. Draw ONE representative edge from the service tier to the data tier, not one per service. Omit routine reads/writes that clutter.
  - Prefer edges that flow in ONE direction down the tiers (Client → Gateway → Services → Data). Avoid long edges that span across the whole diagram.
- Group nodes into 3-4 subgraph tiers stacked top to bottom: e.g., subgraph Client / subgraph Edge / subgraph Services / subgraph Data. Keep each tier to 1-4 nodes.
- LABELS: label an edge ONLY when the label adds real meaning (e.g., "OAuth", "publishes events"). Leave routine edges unlabeled. Never label more than ~half the edges.
- Use short node IDs (alphanumeric, no spaces) and short bracket labels, e.g. gw[API Gateway].
- Output ONLY the raw Mermaid definition starting with "graph TD". No fences, no comments, no classDef/styling, no preamble.

Good shape to emulate (clean, tiered, sparse edges — do not copy content):
graph TD
  subgraph Client
    web[Web Portal]
    cli[CLI Tool]
  end
  subgraph Edge
    gw[API Gateway]
  end
  subgraph Services
    core[Core Services]
    workers[Async Workers]
  end
  subgraph Data
    db[(Postgres)]
    cache[(Cache / Queue)]
  end
  web --> gw
  cli --> gw
  gw -->|OAuth| core
  core --> workers
  core --> db
  workers -->|jobs| cache`;



// Generic foundation-stage component (markdown output, editable).
function FoundationStage({ s, set, cfg }) {
  const existing = s[cfg.key] || null;
  const [phase, setPhase] = useState(existing ? "done" : "idle");
  const [err, setErr] = useState("");
  const [draft, setDraft] = useState(existing?.markdown || "");
  const [editing, setEditing] = useState(false);
  const [groundMsg, setGroundMsg] = useState("");   // grounding status
  const [genStep, setGenStep] = useState("");       // live step shown on the button
  const ready = cfg.ready(s);

  const run = async () => {
    if (!ready.ok) return;
    setPhase("running"); setErr(""); setGroundMsg(""); setGenStep("Starting…");
    try {
      // ── Ground against connected sources (best-effort; degrade gracefully if the
      // broker isn't reachable, so the stage never breaks off-VPN). ──
      let grounding = "", groundingMeta = null;
      if (IS_LOCAL_APP && cfg.sourceQuestion) {
        setGenStep("Grounding against connected sources…");
        setGroundMsg("⟳ Grounding against connected sources standards via connectors…");
        try {
          const out = await api("/api/connectors/analyze", { method: "POST", body: {
            question: cfg.sourceQuestion(s),
            hint: cfg.sourceHint ? cfg.sourceHint(s) : "",
            maxServers: 4, maxRounds: 4,
          }});
          if (out && out.answer && out.answer.trim()) {
            grounding = out.answer;
            groundingMeta = { servers: out.servers || [], at: Date.now() };
            setGroundMsg(`✔ Grounded against connected sources — ${(out.servers || []).join(", ") || "connected sources"}.`);
          } else {
            setGroundMsg("No connected sources returned grounding — generating from project scope.");
          }
        } catch (e) {
          setGroundMsg("⚠ Couldn't reach connected sources — generating without grounding.");
        }
      }
      setGenStep(grounding ? "Writing the document from grounding…" : "Writing the document…");
      const userContent = grounding
        ? `${cfg.user(s)}\n\n=== GROUNDING (authoritative — conform to how you actually build: existing standards, stack, systems) ===\n${grounding}`
        : cfg.user(s);
      const md = await callClaudeText(cfg.system, userContent, 8000);
      const clean = md.replace(/^```(?:markdown|md)?\n?/i, "").replace(/```$/, "").trim();
      setDraft(clean);
      const next = { ...s, [cfg.key]: { markdown: clean, generatedAt: Date.now(), grounding: groundingMeta } };
      set(next);
      setPhase("done"); setGenStep("");
      if (cfg.onGenerated) cfg.onGenerated(clean, next);
    } catch (e) { setErr(e.message); setPhase("error"); setGenStep(""); }
  };
  const save = () => { set({ ...s, [cfg.key]: { markdown: draft, generatedAt: (existing?.generatedAt) || Date.now(), edited: true, grounding: existing?.grounding } }); setEditing(false); };

  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      <div className="card">
        <CardHead title={cfg.title} right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{cfg.stageLabel}</span>} />
        <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6, marginBottom: 12 }}>{cfg.blurb}</div>
        {ready.ok
          ? <div style={{ fontSize: 11, color: "var(--accent)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>✔ {ready.msg}</div>
          : <div style={{ fontSize: 11, color: "var(--warn)", marginBottom: 12, fontFamily: "var(--font-mono)" }}>{ready.msg}</div>}
        <button className="btn btn-primary" disabled={phase === "running" || !ready.ok} onClick={run}>
          {phase === "running" ? <><Spinner /> {genStep || "Generating…"}</> : existing ? "Regenerate" : cfg.cta}</button>
        {groundMsg && <div style={{ fontSize: 11, marginTop: 10, fontFamily: "var(--font-mono)", color: groundMsg.startsWith("⚠") ? "var(--gold)" : groundMsg.startsWith("✔") ? "var(--accent)" : "var(--muted)" }}>{groundMsg}</div>}
        <ErrBox msg={err} />
      </div>

      {cfg.middle}

      {existing && (
        <div className="card">
          <CardHead title={cfg.outTitle} right={
            <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
              {existing.grounding?.servers?.length > 0 && <span className="tag tag-blue" style={{ fontSize: 9 }} title={`Grounded via ${existing.grounding.servers.join(", ")}`}>Grounded</span>}
              {existing.edited && <span className="tag tag-gold" style={{ fontSize: 9 }}>edited</span>}
              <CopyBtn text={existing.markdown} label="Copy" />
              <button className="btn btn-secondary btn-sm" onClick={() => download(cfg.file, existing.markdown, "text/markdown")}>⤓ MD</button>
              <button className="btn btn-secondary btn-sm" onClick={() => { setDraft(existing.markdown); setEditing(e => !e); }}>{editing ? "Preview" : "Edit"}</button>
            </div>} />
          {editing ? (
            <div>
              <textarea rows={22} value={draft} onChange={e => setDraft(e.target.value)} style={{ fontFamily: "var(--font-mono)", fontSize: 12, lineHeight: 1.55 }} />
              <div style={{ marginTop: 10, display: "flex", gap: 8 }}>
                <button className="btn btn-primary btn-sm" onClick={save}>Save changes</button>
                <button className="btn btn-ghost btn-sm" onClick={() => setEditing(false)}>Cancel</button>
              </div>
            </div>
          ) : (
            <div style={{ color: "var(--text)" }}><Markdown text={existing.markdown} /></div>
          )}
        </div>
      )}
    </div>
  );
}

function SpecStageTechStack({ s, set }) {
  return <FoundationStage s={s} set={set} cfg={{
    key: "techStack", title: "Tech Stack", stageLabel: "Stage 07", outTitle: "Recommended Stack",
    blurb: "Proposes a technology stack for this product — grounded against how you actually build (existing frameworks, standards) via connectors when the broker is reachable. Edit to lock in your decisions before architecture.",
    cta: "Propose a stack", file: "tech-stack.md",
    ready: (st) => (st.epics || []).length ? { ok: true, msg: `Using ${st.epics.length} epics as scope` } : { ok: false, msg: "Generate epics in Stage 03 first." },
    system: TECHSTACK_SYSTEM,
    user: (st) => `Choose a tech stack for this product.\n\n${projectBrief(st)}`,
    sourceQuestion: (st) => `What technology stack do existing platform services use — programming languages, runtimes, frontend and backend frameworks, databases, ORMs, auth approach, API style, and CI/CD? What are your standard or approved technology choices for building a new platform service? Be specific with names and versions where known.`,
    sourceHint: (st) => `New product "${st.projectName || "untitled"}". Want the standard stack to conform to, not generic choices.`,
  }} />;
}

function SpecStageArchitecture({ s, set }) {
  const [diagPhase, setDiagPhase] = useState("idle");
  const [diagErr, setDiagErr] = useState("");
  const archDiagram = s.archDiagram || null;   // { mermaid: "graph TD ...", generatedAt }

  // Generate the Mermaid diagram FROM the architecture doc. `md`/`base` let us
  // chain straight off a fresh doc generation without waiting for state to settle.
  const genDiagram = async (md, base) => {
    const doc = md || s.architecture?.markdown;
    const st = base || s;
    if (!doc) return;
    setDiagPhase("running"); setDiagErr("");
    try {
      let code = await callClaudeText(ARCH_DIAGRAM_SYSTEM,
        `Produce a Mermaid architecture diagram for this system, derived from the architecture document.\n\nARCHITECTURE DOCUMENT:\n${doc}`, 4000);
      // Strip any stray fences/preamble; keep from the first "graph"/"flowchart".
      code = code.replace(/```(?:mermaid)?/gi, "").trim();
      const m = code.match(/\b(graph|flowchart)\b[\s\S]*/i);
      if (m) code = m[0].trim();
      set({ ...st, archDiagram: { mermaid: code, generatedAt: Date.now() } });
      setDiagPhase("done");
    } catch (e) { setDiagErr(e.message); setDiagPhase("error"); }
  };

  const diagramCard = (s.architecture?.markdown || archDiagram) ? (
    <div className="card fade-up">
      <CardHead title="Architecture Diagram" right={
        <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
          <span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>components & connections</span>
          {archDiagram?.mermaid && <>
            <button className="btn btn-secondary btn-sm" title="Download as scalable vector (SVG)" onClick={() => exportDiagramSVG(archDiagram.mermaid, "architecture-diagram.svg").catch(e => setDiagErr(e.message))}>⤓ SVG</button>
            <button className="btn btn-secondary btn-sm" title="Download as image (PNG)" onClick={() => exportDiagramPNG(archDiagram.mermaid, "architecture-diagram.png").catch(e => setDiagErr(e.message))}>⤓ PNG</button>
          </>}
          <button className="btn btn-secondary btn-sm" disabled={diagPhase === "running" || !s.architecture?.markdown} onClick={() => genDiagram()}>
            {diagPhase === "running" ? <><Spinner /> Drawing…</> : archDiagram ? "Regenerate" : "Generate diagram"}</button>
        </div>} />
      <ErrBox msg={diagErr} />
      {archDiagram?.mermaid
        ? <MermaidDiagram code={archDiagram.mermaid} />
        : diagPhase === "running"
          ? <div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)", display: "flex", alignItems: "center", gap: 8 }}><Spinner /> Drawing the architecture diagram…</div>
          : <div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>The diagram draws automatically after the architecture document is generated.</div>}
    </div>
  ) : null;

  return (
    <FoundationStage s={s} set={set} cfg={{
      key: "architecture", title: "Architecture", stageLabel: "Stage 08", outTitle: "Architecture Document",
      blurb: "System design built on your chosen stack — grounded against existing services, APIs, and integration points via connectors when the broker is reachable. The diagram below is drawn from this document.",
      cta: "Design architecture", file: "architecture.md",
      ready: (st) => (st.epics || []).some(e => (e.stories || []).length)
        ? { ok: true, msg: st.techStack?.markdown ? "Using your tech stack + scope" : "Using scope (no tech stack set — that's fine)" }
        : { ok: false, msg: "Generate epics in Stage 03 first." },
      system: ARCH_SYSTEM,
      user: (st) => `Design the architecture for this product.\n\n${projectBrief(st)}\n\n${st.techStack?.markdown ? `CHOSEN TECH STACK:\n${st.techStack.markdown}` : "No tech stack was specified — choose a sensible modern stack as part of the architecture."}`,
      sourceQuestion: (st) => `What existing services, APIs, data stores, and integration points would a new platform product need to fit into? What are your architecture conventions for platform services — service patterns, authentication/identity, API style, messaging, and shared data models? Name the real systems and patterns concisely.`,
      sourceHint: (st) => `New product "${st.projectName || "untitled"}". Designing its architecture to fit existing systems and conventions.`,
      onGenerated: (md, next) => { genDiagram(md, next); },
      middle: diagramCard,
    }} />
  );
}

// ── Spec · Compiler (removed) ─────────────────────────────────────────────────


// Spec workflow shell: its own stepper over SPEC_STAGES, sharing the project's
// session + autosave (every set() bubbles to App's autosave/versions).
// ── Spec 11 · Handoff ─────────────────────────────────────────────────────────
// Bundles everything generated (foundation docs + build packages + tests) into a
// single deliverable for an AI build team. Generate-only — no execution.
function buildHandoffMarkdown(s) {
  const epics = s.epics || [];
  const stories = epics.flatMap(e => (e.stories || []));
  const suites = s.testSuites || [];
  const suiteFor = id => (suites.find(x => x.story_id === id)?.test_cases) || [];
  const totalTCs = suites.reduce((n, x) => n + (x.test_cases?.length || 0), 0);
  const L = [];
  L.push(`# ${s.projectName || "Product"} — Build Handoff Package`);
  L.push(`_Generated ${new Date().toLocaleString()} · ${epics.length} epics · ${stories.length} stories · ${totalTCs} test cases_`);
  L.push(`\nThis is the specification an AI build team needs to build this product: the requirements (epics, stories, acceptance criteria), the chosen tech stack, the architecture, and the acceptance test cases. The build team scaffolds the repo, stands up the environment, and runs the build — grounding their coding agent on the real codebase as it takes shape.\n`);
  L.push(`## 1. Tech Stack\n\n${s.techStack?.markdown || "_Not defined._"}`);
  L.push(`## 2. Architecture\n\n${s.architecture?.markdown || "_Not defined._"}`);
  if (s.archDiagram?.mermaid) {
    L.push(`### 2.1 Architecture Diagram\n`);
    L.push("```mermaid");
    L.push(s.archDiagram.mermaid);
    L.push("```\n");
  }
  L.push(`## 3. Requirements\n`);
  if (!epics.length) L.push("_No epics generated yet._");
  epics.forEach(e => {
    L.push(`### ${e.epic_id} — ${e.epic_title}`);
    if (e.epic_description) L.push(e.epic_description);
    (e.stories || []).forEach(st => {
      const pts = st.points != null ? ` _(${st.points} pts)_` : "";
      L.push(`\n**${st.story_id} — ${st.title}**${pts}`);
      L.push(`As a ${st.role}, I want to ${st.action}${st.value ? `, so that ${st.value}` : ""}.`);
      if ((st.acceptance_criteria || []).length) {
        L.push(`Acceptance criteria:`);
        st.acceptance_criteria.forEach((ac, i) => L.push(`  ${i + 1}. ${ac}`));
      }
      const tcs = suiteFor(st.story_id);
      if (tcs.length) {
        L.push(`Test cases:`);
        tcs.forEach(tc => L.push(`  - \`${tc.tc_id}\` (${tc.type || "test"}): ${tc.title || ""} → _${tc.expected_result || ""}_`));
      }
    });
    L.push("");
  });
  return L.join("\n");
}
function buildHandoffJSON(s) {
  const epics = s.epics || [];
  const suites = s.testSuites || [];
  const suiteFor = id => (suites.find(x => x.story_id === id)?.test_cases) || [];
  return {
    project: s.projectName || "Product",
    generatedAt: new Date().toISOString(),
    techStack: s.techStack?.markdown || null,
    architecture: s.architecture?.markdown || null,
    architectureDiagram: s.archDiagram || null,
    requirements: epics.map(e => ({
      epic_id: e.epic_id, title: e.epic_title, description: e.epic_description,
      stories: (e.stories || []).map(st => ({
        story_id: st.story_id, title: st.title, role: st.role, action: st.action, value: st.value,
        points: st.points ?? null, acceptance_criteria: st.acceptance_criteria || [],
        test_cases: suiteFor(st.story_id),
      })),
    })),
  };
}
// Flatten a markdown string into genPDF blocks (headings + text). Handles #
// headings, bullet/numbered lists, and strips inline markdown emphasis. Mermaid
// code fences are dropped (the diagram ships separately as SVG/PNG).
function mdToPdfBlocks(md) {
  const out = [];
  const lines = String(md || "").split("\n");
  let inFence = false;
  for (const raw of lines) {
    const ln = raw.replace(/\t/g, "  ");
    if (/^\s*```/.test(ln)) { inFence = !inFence; continue; }
    if (inFence) continue;                       // skip code/mermaid blocks
    const t = ln.trim();
    if (!t) { out.push({ type: "spacer", h: 5 }); continue; }
    const strip = x => x.replace(/\*\*(.*?)\*\*/g, "$1").replace(/\*(.*?)\*/g, "$1").replace(/`(.*?)`/g, "$1").replace(/^#+\s*/, "").trim();
    const h = t.match(/^(#{1,6})\s+(.*)/);
    if (h) {
      const level = h[1].length;
      out.push(level <= 2 ? { type: "heading", text: strip(h[2]) } : { type: "subheading", text: strip(h[2]) });
    } else if (/^[-*]\s+/.test(t)) {
      out.push({ type: "text", text: "• " + strip(t.replace(/^[-*]\s+/, "")) });
    } else if (/^\d+\.\s+/.test(t)) {
      out.push({ type: "text", text: strip(t) });
    } else if (/^\|.*\|$/.test(t)) {
      // markdown table row → flatten cells to a simple line (skip separator rows)
      if (!/^\|[\s|:-]+\|$/.test(t)) out.push({ type: "text", text: strip(t.replace(/^\||\|$/g, "").split("|").map(c => c.trim()).join("   ")) });
    } else {
      out.push({ type: "text", text: strip(t) });
    }
  }
  return out;
}
async function buildHandoffPDF(s) {
  const epics = s.epics || [];
  const stories = epics.flatMap(e => (e.stories || []));
  const suites = s.testSuites || [];
  const suiteFor = id => (suites.find(x => x.story_id === id)?.test_cases) || [];
  const totalTCs = suites.reduce((n, x) => n + (x.test_cases?.length || 0), 0);
  const blocks = [];

  blocks.push({ type: "text", text: "This is the specification an AI build team needs to build this product: the requirements (epics, stories, acceptance criteria), the chosen tech stack, the architecture, and the acceptance test cases." });
  blocks.push({ type: "spacer", h: 6 });

  // 1. Tech Stack
  blocks.push({ type: "heading", text: "1. Tech Stack" });
  if (s.techStack?.markdown) blocks.push(...mdToPdfBlocks(s.techStack.markdown));
  else blocks.push({ type: "text", text: "Not defined." });
  blocks.push({ type: "spacer", h: 8 });

  // 2. Architecture
  blocks.push({ type: "heading", text: "2. Architecture" });
  if (s.architecture?.markdown) blocks.push(...mdToPdfBlocks(s.architecture.markdown));
  else blocks.push({ type: "text", text: "Not defined." });
  if (s.archDiagram?.mermaid) blocks.push({ type: "text", text: "(Architecture diagram on the following page.)" });
  blocks.push({ type: "spacer", h: 8 });

  // 3. Requirements
  blocks.push({ type: "heading", text: "3. Requirements" });
  if (!epics.length) blocks.push({ type: "text", text: "No epics generated yet." });
  epics.forEach(e => {
    blocks.push({ type: "subheading", text: `${e.epic_id} — ${e.epic_title}` });
    if (e.epic_description) blocks.push({ type: "text", text: e.epic_description });
    (e.stories || []).forEach(st => {
      const pts = st.points != null ? ` (${st.points} pts)` : "";
      blocks.push({ type: "kv", key: st.story_id, value: `${st.title}${pts}` });
      blocks.push({ type: "text", text: `As a ${st.role}, I want to ${st.action}${st.value ? `, so that ${st.value}` : ""}.` });
      (st.acceptance_criteria || []).forEach((ac, i) => blocks.push({ type: "text", text: `   ${i + 1}. ${ac}` }));
      const tcs = suiteFor(st.story_id);
      if (tcs.length) {
        blocks.push({ type: "text", text: "Test cases:" });
        tcs.forEach(tc => blocks.push({ type: "text", text: `   • ${tc.tc_id} (${tc.type || "test"}): ${tc.title || ""} → ${tc.expected_result || ""}` }));
      }
      blocks.push({ type: "spacer", h: 4 });
    });
    blocks.push({ type: "spacer", h: 6 });
  });

  // Rasterize the architecture diagram for its own page (best-effort — if it
  // fails, the PDF is still produced as text-only with a note).
  let image = null;
  if (s.archDiagram?.mermaid) {
    try {
      const r = await rasterizeMermaidToJPEG(s.archDiagram.mermaid, 2);
      image = { ...r, title: "Architecture Diagram" };
    } catch (e) { image = null; }
  }

  return genPDFBinary({
    title: `${s.projectName || "Product"} — Build Handoff Package`,
    subtitle: `Generated ${new Date().toLocaleString()} · ${epics.length} epics · ${stories.length} stories · ${totalTCs} test cases`,
    blocks,
    image,
  });
}
function SpecStageHandoff({ s, set }) {
  const hasReqs = (s.epics || []).some(e => (e.stories || []).length);
  const ready = hasReqs;
  const slug = (s.projectName || "product").replace(/\W+/g, "-").toLowerCase();
  const [pdfBusy, setPdfBusy] = useState(false);
  const [pdfErr, setPdfErr] = useState("");
  const makeBundlePDF = async () => {
    setPdfBusy(true); setPdfErr("");
    try {
      const bytes = await buildHandoffPDF(s);
      download(`${slug}-handoff.pdf`, bytes, "application/pdf");
    } catch (e) { setPdfErr(e?.message || "PDF build failed"); }
    finally { setPdfBusy(false); }
  };
  const md = ready ? buildHandoffMarkdown(s) : "";
  const checklist = [
    ["Requirements (epics & stories)", hasReqs],
    ["Test Cases", (s.testSuites || []).length > 0],
    ["Tech Stack", !!s.techStack?.markdown],
    ["Architecture", !!s.architecture?.markdown],
    ["Architecture diagram", !!s.archDiagram?.mermaid],
  ];
  const complete = checklist.every(([, ok]) => ok);
  return (
    <div className="fade-up" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      <div className="card">
        <CardHead title="Handoff Package" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Stage 09</span>} />
        <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6, marginBottom: 12 }}>
          The deliverable for an AI build team — requirements (epics, stories, acceptance criteria), the tech stack, the architecture, and the acceptance test cases, bundled into one document. The build team scaffolds the repo, sets up the environment, and runs the build from here, letting their coding agent ground itself on the real codebase.
        </div>
        <div style={{ display: "flex", flexDirection: "column", gap: 5, marginBottom: 14 }}>
          {checklist.map(([label, ok]) => (
            <div key={label} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12 }}>
              <span style={{ width: 16, height: 16, borderRadius: "50%", flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 800, background: ok ? "var(--ok)" : "var(--border)", color: ok ? "#fff" : "var(--muted)" }}>{ok ? "✓" : "–"}</span>
              <span style={{ color: ok ? "var(--text)" : "var(--muted)" }}>{label}</span>
            </div>
          ))}
        </div>
        {!ready
          ? <div style={{ fontSize: 11, color: "var(--warn)", fontFamily: "var(--font-mono)" }}>Generate epics &amp; stories (Stage 03) first — the handoff needs the requirements.</div>
          : complete
            ? <div style={{ fontSize: 11, color: "var(--accent)", fontFamily: "var(--font-mono)" }}>✔ Complete — ready to hand off.</div>
            : <div style={{ fontSize: 11, color: "var(--gold)", fontFamily: "var(--font-mono)" }}>⚠ Some sections aren't complete yet — the handoff will include what's been generated.</div>}
        {ready && (
          <div style={{ marginTop: 14 }}>
            <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 8 }}>Consolidated bundle</div>
            <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
              <button className="btn btn-primary btn-sm" title="One document: requirements, stack, architecture, tests" onClick={() => download(`${slug}-handoff.md`, buildHandoffMarkdown(s), "text/markdown")}>⤓ Bundle .md</button>
              <button className="btn btn-secondary btn-sm" disabled={pdfBusy} title="Readable PDF of the whole bundle, diagram included — opens anywhere, for non-developers" onClick={makeBundlePDF}>{pdfBusy ? <><Spinner /> Building PDF…</> : "⤓ Bundle PDF"}</button>
              <button className="btn btn-secondary btn-sm" title="Same content, structured for an agent harness" onClick={() => download(`${slug}-handoff.json`, JSON.stringify(buildHandoffJSON(s), null, 2), "application/json")}>⤓ Bundle .json</button>
              <CopyBtn text={md} label="Copy markdown" />
            </div>
            {pdfErr && <div style={{ fontSize: 11, color: "var(--warn)", fontFamily: "var(--font-mono)", marginBottom: 12 }}>⚠ {pdfErr}</div>}
            <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 8 }}>Individual artifacts — in importable form</div>
            <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
              {/* Requirements → Jira import */}
              <span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Requirements:</span>
              <button className="btn btn-secondary btn-sm" title="Epics + stories as a Jira-import CSV (Parent-linked)" onClick={() => exportEpicsJiraCSV(s.epics || [], s.projectName, `${slug}-requirements-jira.csv`)}>⤓ Jira CSV</button>
              <button className="btn btn-secondary btn-sm" title="Epics + stories as a readable PDF" onClick={() => exportEpicsPDF(s.epics || [], { title: (s.projectName || "Spec") + " — Requirements", filename: `${slug}-requirements.pdf` })}>⤓ PDF</button>
              <span style={{ width: 1, height: 18, background: "var(--border)" }} />
              {/* Test cases */}
              <span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Tests:</span>
              {(s.testSuites || []).length ? <>
                <button className="btn btn-secondary btn-sm" title="Test cases as Jira Test-type issues" onClick={() => exportTestCasesJiraCSV(s.testSuites, s.projectName)}>⤓ Jira CSV</button>
                <button className="btn btn-secondary btn-sm" title="Helix ALM XML import (preserves step structure)" onClick={() => exportHelixXML(s.testSuites)}>⤓ Helix XML</button>
              </> : <span style={{ fontSize: 10, color: "var(--muted)" }}>— none generated</span>}
              <span style={{ width: 1, height: 18, background: "var(--border)" }} />
              {/* Architecture */}
              <span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Architecture:</span>
              {s.architecture?.markdown
                ? <button className="btn btn-secondary btn-sm" title="Architecture document (markdown)" onClick={() => download(`${slug}-architecture.md`, s.architecture.markdown, "text/markdown")}>⤓ MD</button>
                : <span style={{ fontSize: 10, color: "var(--muted)" }}>— none</span>}
              {s.archDiagram?.mermaid && <>
                <span style={{ width: 1, height: 18, background: "var(--border)" }} />
                <span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Diagram:</span>
                <button className="btn btn-secondary btn-sm" title="Diagram as scalable vector (SVG) — best for docs/slides" onClick={() => exportDiagramSVG(s.archDiagram.mermaid, `${slug}-architecture-diagram.svg`).catch(e => alert(e.message))}>⤓ SVG</button>
                <button className="btn btn-secondary btn-sm" title="Diagram as image (PNG) — best for email/paste" onClick={() => exportDiagramPNG(s.archDiagram.mermaid, `${slug}-architecture-diagram.png`).catch(e => alert(e.message))}>⤓ PNG</button>
                <button className="btn btn-secondary btn-sm" title="Diagram as Mermaid source (.mmd) — editable, renders in GitHub/VS Code" onClick={() => download(`${slug}-architecture.mmd`, s.archDiagram.mermaid, "text/plain")}>⤓ .mmd</button>
              </>}
              {s.techStack?.markdown && <>
                <span style={{ width: 1, height: 18, background: "var(--border)" }} />
                <span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>Stack:</span>
                <button className="btn btn-secondary btn-sm" title="Tech stack document (markdown)" onClick={() => download(`${slug}-tech-stack.md`, s.techStack.markdown, "text/markdown")}>⤓ MD</button>
              </>}
            </div>
          </div>
        )}
      </div>

      {ready && (
        <div className="card">
          <CardHead title="Preview" right={<span style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{(s.epics || []).flatMap(e => e.stories || []).length} stories</span>} />
          <div style={{ maxHeight: 600, overflowY: "auto", color: "var(--text)" }}><Markdown text={md} /></div>
        </div>
      )}
    </div>
  );
}

function SpecShell({ s, set }) {
  const [active, setActive] = useState("flowchart");
  const done = new Set();
  if (s.flowchart) done.add("flowchart");
  if (s.feasibility?.markdown) done.add("feasibility");
  if (s.gapAnalysis) done.add("gap");
  if (s.epics?.length) done.add("epics");
  if (s.aiadStories?.length) done.add("sizing");
  if (s.jiraCompare) done.add("jiracompare");
  if (s.testSuites?.length) done.add("testcases");
  if (s.techStack?.markdown) done.add("techstack");
  if (s.architecture?.markdown) done.add("architecture");
  if ((s.epics || []).some(e => (e.stories || []).length)) done.add("handoff");
  const idx = SPEC_STAGES.findIndex(x => x.id === active);
  const body = {
    flowchart: <SpecStagePRD s={s} set={set} />,
    feasibility: <SpecStageFeasibility s={s} set={set} />,
    gap: <SpecStageGap s={s} set={set} goTo={setActive} />,
    epics: <SpecStageEpics s={s} set={set} />,
    sizing: <SpecStageSizing s={s} set={set} />,
    jiracompare: <SpecStageJiraCompare s={s} set={set} />,
    testcases: <SpecStageTestCases s={s} set={set} />,
    techstack: <SpecStageTechStack s={s} set={set} />,
    architecture: <SpecStageArchitecture s={s} set={set} />,
    handoff: <SpecStageHandoff s={s} set={set} />,
  }[active];
  return (
    <>
      <Stepper active={active} onSelect={setActive} doneSet={done} stages={SPEC_STAGES} />
      <main style={{ maxWidth: 1080, margin: "0 auto", padding: "24px 28px 80px" }}>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 18 }}>
          <div>
            <div style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)", letterSpacing: ".1em" }}>STEP {SPEC_STAGES[idx].n} / {SPEC_STAGES.length}{SPEC_STAGES[idx].ai ? " · AI-ASSISTED" : ""}{SPEC_STAGES[idx].optional ? " · OPTIONAL" : ""}</div>
            <h1 style={{ fontSize: 22, fontWeight: 800 }}>{SPEC_STAGES[idx].label}</h1>
          </div>
          <div style={{ display: "flex", gap: 8 }}>
            <button className="btn btn-secondary btn-sm" disabled={idx === 0} onClick={() => setActive(SPEC_STAGES[idx - 1].id)}>← Prev</button>
            <button className="btn btn-secondary btn-sm" disabled={idx === SPEC_STAGES.length - 1} onClick={() => setActive(SPEC_STAGES[idx + 1].id)}>Next →</button>
          </div>
        </div>
        {body}
      </main>
    </>
  );
}

// ══════════════════════════════════════════════════════════════════════════════
// ── Auth screen (sign in / register / forgot / reset) ────────────────────────
// Shown by App when the broker reports no session. On success it calls onAuthed,
// which re-fetches /api/me and drops the user into the app.
function AuthScreen({ onAuthed, theme, onToggleTheme }) {
  const resetToken = (() => { try { return new URLSearchParams(window.location.search).get("reset"); } catch { return null; } })();
  const [mode, setMode] = useState(resetToken ? "reset" : "login");
  const [email, setEmail] = useState("");
  const [name, setName] = useState("");
  const [password, setPassword] = useState("");
  const [confirm, setConfirm] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const [notice, setNotice] = useState("");

  const clearReset = () => { try { const u = new URL(window.location.href); u.searchParams.delete("reset"); window.history.replaceState({}, "", u.toString()); } catch { /* ignore */ } };
  const go = (m) => { setMode(m); setErr(""); setNotice(""); setPassword(""); setConfirm(""); };

  const submit = async () => {
    setErr(""); setNotice(""); setBusy(true);
    try {
      if (mode === "login") { await api("/api/auth/login", { method: "POST", body: { email, password } }); onAuthed(); }
      else if (mode === "register") {
        if (password !== confirm) throw new Error("Passwords don't match.");
        await api("/api/auth/register", { method: "POST", body: { email, name, password } }); onAuthed();
      } else if (mode === "forgot") {
        await api("/api/auth/forgot", { method: "POST", body: { email } });
        setNotice("If an account exists for that email, a reset link is on its way. (In dev, check the broker console for the link.)");
      } else if (mode === "reset") {
        if (password !== confirm) throw new Error("Passwords don't match.");
        await api("/api/auth/reset", { method: "POST", body: { token: resetToken, password } });
        clearReset(); onAuthed();
      }
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };

  const inp = { width: "100%", fontSize: 13, padding: "9px 11px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 8, color: "var(--text)", boxSizing: "border-box" };
  const lbl = { fontSize: 10, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--muted)", fontFamily: "var(--font-mono)", marginBottom: 4, display: "block" };
  const link = { color: "var(--accent)", cursor: "pointer" };
  const titles = { login: "Sign in", register: "Create your account", forgot: "Reset your password", reset: "Set a new password" };
  const subs = { login: "Welcome back to Cadenly.", register: "Start grounding your delivery work.", forgot: "We'll email you a reset link.", reset: "Choose a new password for your account." };
  const cta = { login: "Sign in", register: "Create account", forgot: "Send reset link", reset: "Set password" };

  return (
    <div style={{ minHeight: "100vh", background: "var(--bg)", display: "flex", flexDirection: "column" }}>
      <div style={{ position: "fixed", inset: 0, pointerEvents: "none", zIndex: 0, backgroundImage: "linear-gradient(var(--grid-line) 1px,transparent 1px),linear-gradient(90deg,var(--grid-line) 1px,transparent 1px)", backgroundSize: "48px 48px" }} />
      <header style={{ display: "flex", alignItems: "center", padding: "0 32px", height: 56, position: "relative", zIndex: 1 }}>
        <CadenlyLogo />
      </header>
      <main style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: "24px 16px", position: "relative", zIndex: 1 }}>
        <div style={{ width: "100%", maxWidth: 380, background: "var(--panel)", border: "1px solid var(--border)", borderRadius: 14, padding: 28, boxShadow: "0 4px 24px var(--shadow)" }}>
          <h1 style={{ fontSize: 20, fontWeight: 800, margin: "0 0 4px" }}>{titles[mode]}</h1>
          <p style={{ fontSize: 12, color: "var(--muted)", margin: "0 0 20px" }}>{subs[mode]}</p>
          {err && <ErrBox msg={err} />}
          {notice && <div style={{ fontSize: 12, color: "var(--text)", background: "var(--panel)", border: "1px solid var(--ok)", borderRadius: 8, padding: "8px 12px", marginBottom: 12 }}>{notice}</div>}

          {mode !== "reset" && <div style={{ marginBottom: 12 }}><label style={lbl}>Email</label><input style={inp} type="email" value={email} onChange={e => setEmail(e.target.value)} autoFocus onKeyDown={e => e.key === "Enter" && submit()} /></div>}
          {mode === "register" && <div style={{ marginBottom: 12 }}><label style={lbl}>Name</label><input style={inp} value={name} onChange={e => setName(e.target.value)} placeholder="optional" onKeyDown={e => e.key === "Enter" && submit()} /></div>}
          {(mode === "login" || mode === "register" || mode === "reset") && <div style={{ marginBottom: 12 }}><label style={lbl}>{mode === "reset" ? "New password" : "Password"}</label><input style={inp} type="password" value={password} onChange={e => setPassword(e.target.value)} onKeyDown={e => e.key === "Enter" && submit()} autoComplete={mode === "login" ? "current-password" : "new-password"} /></div>}
          {(mode === "register" || mode === "reset") && <div style={{ marginBottom: 12 }}><label style={lbl}>Confirm password</label><input style={inp} type="password" value={confirm} onChange={e => setConfirm(e.target.value)} onKeyDown={e => e.key === "Enter" && submit()} autoComplete="new-password" /></div>}

          <button className="btn btn-primary" style={{ width: "100%", marginTop: 4, justifyContent: "center" }} disabled={busy} onClick={submit}>{busy ? <><Spinner /> Working…</> : cta[mode]}</button>

          <div style={{ marginTop: 16, fontSize: 12, color: "var(--muted)", display: "flex", flexDirection: "column", gap: 6 }}>
            {mode === "login" && <><span>No account? <span onClick={() => go("register")} style={link}>Create one</span></span><span onClick={() => go("forgot")} style={link}>Forgot password?</span></>}
            {mode === "register" && <span>Already have an account? <span onClick={() => go("login")} style={link}>Sign in</span></span>}
            {mode === "forgot" && <span onClick={() => go("login")} style={link}>Back to sign in</span>}
            {mode === "reset" && <span onClick={() => { clearReset(); go("login"); }} style={link}>Back to sign in</span>}
          </div>
        </div>
      </main>
    </div>
  );
}

// ── Account / profile modal ──────────────────────────────────────────────────
function ProfileModal({ me, onClose, onUpdated, onLoggedOut }) {
  const [name, setName] = useState((me && me.name) || "");
  const [savingName, setSavingName] = useState(false);
  const [nameMsg, setNameMsg] = useState("");
  const [err, setErr] = useState("");
  const [cur, setCur] = useState(""); const [nw, setNw] = useState(""); const [cf, setCf] = useState("");
  const [savingPw, setSavingPw] = useState(false); const [pwMsg, setPwMsg] = useState(""); const [pwErr, setPwErr] = useState("");

  const saveName = async () => { setSavingName(true); setNameMsg(""); setErr(""); try { const d = await api("/api/profile", { method: "PUT", body: { name } }); onUpdated && onUpdated(d.user); setNameMsg("Saved."); } catch (e) { setErr(e.message); } finally { setSavingName(false); } };
  const changePw = async () => {
    setPwErr(""); setPwMsg("");
    if (nw !== cf) { setPwErr("New passwords don't match."); return; }
    setSavingPw(true);
    try { await api("/api/profile/password", { method: "POST", body: { currentPassword: cur, newPassword: nw } }); setPwMsg("Password updated."); setCur(""); setNw(""); setCf(""); }
    catch (e) { setPwErr(e.message); } finally { setSavingPw(false); }
  };
  const logout = async () => { try { await api("/api/auth/logout", { method: "POST" }); } catch { /* ignore */ } onLoggedOut && onLoggedOut(); };

  const inp = { width: "100%", fontSize: 13, padding: "8px 10px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 8, color: "var(--text)", boxSizing: "border-box" };
  const lbl = { fontSize: 10, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--muted)", fontFamily: "var(--font-mono)", marginBottom: 4, display: "block" };
  const rule = { borderTop: "1px solid var(--border)", margin: "18px 0" };

  return ReactDOM.createPortal(
    <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1200, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "6vh 16px", overflowY: "auto", backdropFilter: "blur(2px)" }}>
      <div onClick={e => e.stopPropagation()} style={{ width: "100%", maxWidth: 460, background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 14, boxShadow: "0 12px 48px rgba(0,0,0,.4)" }}>
        <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
          <div style={{ fontWeight: 800, fontSize: 15 }}>Account</div>
          <span onClick={onClose} style={{ cursor: "pointer", color: "var(--muted)", fontSize: 20, lineHeight: 1 }}>×</span>
        </header>
        <div style={{ padding: 20 }}>
          {err && <ErrBox msg={err} />}
          <label style={lbl}>Email</label>
          <div style={{ fontSize: 13, fontFamily: "var(--font-mono)", marginBottom: 18 }}>{me && me.email}</div>

          <label style={lbl}>Name</label>
          <div style={{ display: "flex", gap: 8 }}>
            <input style={{ ...inp, flex: 1 }} value={name} onChange={e => setName(e.target.value)} />
            <button className="btn btn-secondary btn-sm" disabled={savingName} onClick={saveName}>{savingName ? "Saving…" : "Save"}</button>
          </div>
          {nameMsg && <div style={{ fontSize: 11, color: "var(--ok)", marginTop: 6 }}>{nameMsg}</div>}

          <div style={rule} />
          <div style={{ fontSize: 13, fontWeight: 700, marginBottom: 10 }}>Change password</div>
          {pwErr && <ErrBox msg={pwErr} />}
          {pwMsg && <div style={{ fontSize: 11, color: "var(--ok)", marginBottom: 8 }}>{pwMsg}</div>}
          <div style={{ marginBottom: 8 }}><label style={lbl}>Current password</label><input type="password" style={inp} value={cur} onChange={e => setCur(e.target.value)} autoComplete="current-password" /></div>
          <div style={{ marginBottom: 8 }}><label style={lbl}>New password</label><input type="password" style={inp} value={nw} onChange={e => setNw(e.target.value)} autoComplete="new-password" /></div>
          <div style={{ marginBottom: 10 }}><label style={lbl}>Confirm new password</label><input type="password" style={inp} value={cf} onChange={e => setCf(e.target.value)} autoComplete="new-password" /></div>
          <button className="btn btn-secondary btn-sm" disabled={savingPw || !cur || !nw} onClick={changePw}>{savingPw ? "Updating…" : "Update password"}</button>

          <div style={rule} />
          <button className="btn btn-warn btn-sm" onClick={logout}>Sign out</button>
        </div>
      </div>
    </div>,
    document.body
  );
}

// ── Settings modal ───────────────────────────────────────────────────────────
// ── Jira connection (per-user) ────────────────────────────────────────────────
// Lets each signed-in user connect their own Jira Cloud site. The API token is
// sent to the broker once, verified against Jira, and stored encrypted; it is
// never returned. Surfaced in Settings and on the TPM Board Status stage.
function JiraConnect({ compact = false }) {
  const [conn, setConn] = useState(null);
  const [open, setOpen] = useState(false);

  const refresh = () => api("/api/jira/connection").then(setConn).catch(() => setConn({ connected: false, source: "none" }));
  useEffect(() => { refresh(); }, []);

  const own = conn && conn.source === "user";
  const shared = conn && conn.connected && conn.source === "shared";
  const label = !conn ? "Checking…"
    : own ? conn.baseUrl.replace(/^https?:\/\//, "")
    : shared ? "Using shared Jira" : "Not connected";
  const dot = !conn ? "var(--muted)" : own ? "var(--ok)" : shared ? "var(--gold)" : "var(--muted)";

  const wrap = compact
    ? { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "9px 12px", border: "1px solid var(--border)", borderRadius: 10, background: "var(--surface)" }
    : { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 };

  return (
    <>
      <div style={wrap}>
        <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
          <span style={{ width: 8, height: 8, borderRadius: "50%", background: dot, flex: "none" }} />
          <span style={{ fontSize: 12.5, fontWeight: 700, color: "var(--text)", flex: "none" }}>Jira</span>
          <span style={{ fontSize: 11.5, color: "var(--muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
        </div>
        <button className="btn btn-secondary btn-sm" style={{ flex: "none" }} onClick={() => setOpen(true)}>
          {own ? "Manage" : "Connect"}
        </button>
      </div>
      {open && <JiraConnectModal conn={conn} onClose={() => setOpen(false)}
        onChanged={() => { setOpen(false); refresh(); if (typeof window !== "undefined") window.dispatchEvent(new Event("cadenly:jira-changed")); }} />}
    </>
  );
}

function JiraConnectModal({ conn, onClose, onChanged }) {
  const own = conn && conn.source === "user";
  const [baseUrl, setBaseUrl] = useState(own ? conn.baseUrl : "");
  const [email, setEmail] = useState(own ? conn.email : "");
  const [token, setToken] = useState("");
  const [pf, setPf] = useState(own ? (conn.pointsField || "") : "");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const [ok, setOk] = useState("");

  const inp = { width: "100%", padding: "9px 11px", borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg)", color: "var(--text)", fontSize: 13 };
  const lbl = { fontSize: 11, fontWeight: 700, color: "var(--muted)", marginBottom: 5, display: "block", letterSpacing: ".02em" };

  const save = async () => {
    setErr(""); setOk(""); setBusy(true);
    try {
      const r = await api("/api/jira/connection", { method: "POST", body: { baseUrl, email, token, pointsField: pf } });
      setOk(`Connected as ${r.account || email}.`);
      setTimeout(onChanged, 750);
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };
  const disconnect = async () => {
    setErr(""); setBusy(true);
    try { await api("/api/jira/connection", { method: "DELETE" }); onChanged(); }
    catch (e) { setErr(e.message); setBusy(false); }
  };

  return ReactDOM.createPortal(
    <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1300, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "6vh 16px", overflowY: "auto", backdropFilter: "blur(2px)" }}>
      <div onClick={e => e.stopPropagation()} style={{ width: "100%", maxWidth: 460, background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 14, boxShadow: "0 12px 48px rgba(0,0,0,.4)" }}>
        <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
          <div style={{ fontWeight: 800, fontSize: 15 }}>{own ? "Manage Jira connection" : "Connect Jira"}</div>
          <span onClick={onClose} style={{ cursor: "pointer", color: "var(--muted)", fontSize: 20, lineHeight: 1 }}>×</span>
        </header>
        <div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 14 }}>
          <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.5 }}>
            Connect your own Jira Cloud site. Your API token is verified, stored encrypted, and never shown again.
          </div>
          <div>
            <label style={lbl}>Jira site URL</label>
            <input style={inp} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} placeholder="https://yourteam.atlassian.net" />
          </div>
          <div>
            <label style={lbl}>Account email</label>
            <input style={inp} value={email} onChange={e => setEmail(e.target.value)} placeholder="you@company.com" autoComplete="off" />
          </div>
          <div>
            <label style={lbl}>API token {own && <span style={{ fontWeight: 400, textTransform: "none" }}>· leave blank to keep current</span>}</label>
            <input style={inp} type="password" value={token} onChange={e => setToken(e.target.value)} placeholder={own ? "•••••••• (unchanged)" : "Paste your Jira API token"} autoComplete="new-password" />
            <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 5 }}>
              Create one at <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noopener noreferrer" style={{ color: "var(--accent)" }}>id.atlassian.com → API tokens</a>.
            </div>
          </div>
          <div>
            <label style={lbl}>Story points field <span style={{ fontWeight: 400, textTransform: "none" }}>· optional</span></label>
            <input style={inp} value={pf} onChange={e => setPf(e.target.value)} placeholder="customfield_10016" />
          </div>
          {err && <div style={{ fontSize: 12, color: "var(--warn)", background: "rgba(214,51,84,.08)", border: "1px solid rgba(214,51,84,.3)", borderRadius: 8, padding: "8px 11px" }}>{err}</div>}
          {ok && <div style={{ fontSize: 12, color: "var(--ok)", background: "rgba(0,163,113,.08)", border: "1px solid rgba(0,163,113,.3)", borderRadius: 8, padding: "8px 11px" }}>{ok}</div>}
          <div style={{ display: "flex", gap: 10, justifyContent: "space-between", alignItems: "center" }}>
            {own
              ? <button className="btn btn-warn btn-sm" disabled={busy} onClick={disconnect}>Disconnect</button>
              : <span />}
            <button className="btn btn-primary" disabled={busy || !baseUrl.trim() || !email.trim() || (!own && !token.trim())} onClick={save}>
              {busy ? <><Spinner /> Verifying…</> : own ? "Save changes" : "Connect Jira"}
            </button>
          </div>
        </div>
      </div>
    </div>,
    document.body
  );
}

// ── Billing (Stripe subscription + token packs) ───────────────────────────────
// Paywall + top-up surface. AI calls that hit the broker's token gate dispatch
// "cadenly:need-billing"; BillingGate (mounted once at the root) opens this modal.
function fmtTokens(n) {
  n = Number(n || 0);
  if (n >= 1e6) return (n / 1e6).toFixed(n % 1e6 ? 1 : 0) + "M";
  if (n >= 1e3) return Math.round(n / 1e3) + "K";
  return String(n);
}

function BillingModal({ reason, onClose }) {
  const [st, setSt] = useState(null);
  const [busy, setBusy] = useState("");
  const [err, setErr] = useState("");
  useEffect(() => { api("/api/billing/status").then(setSt).catch(e => setErr(e.message)); }, []);

  const go = async (path, body) => {
    setErr(""); setBusy(path + (body?.pack || ""));
    try { const r = await api(path, { method: "POST", body }); if (r.url) window.location.href = r.url; else setBusy(""); }
    catch (e) { setErr(e.message); setBusy(""); }
  };

  const active = st && (st.status === "active" || st.status === "trialing");
  const trialing = st && st.status === "trialing";
  const total = st ? st.tokens.total : 0;
  const base = st ? st.tokens.base : 0;
  const pct = base > 0 ? Math.max(0, Math.min(100, Math.round((total / base) * 100))) : 0;
  const title = reason === "no_tokens" ? "You're out of tokens"
    : reason === "no_subscription" ? "Start your free trial" : "Billing";

  return ReactDOM.createPortal(
    <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1400, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "6vh 16px", overflowY: "auto", backdropFilter: "blur(2px)" }}>
      <div onClick={e => e.stopPropagation()} style={{ width: "100%", maxWidth: 480, background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 14, boxShadow: "0 12px 48px rgba(0,0,0,.4)" }}>
        <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
          <div style={{ fontWeight: 800, fontSize: 15 }}>{title}</div>
          <span onClick={onClose} style={{ cursor: "pointer", color: "var(--muted)", fontSize: 20, lineHeight: 1 }}>×</span>
        </header>
        <div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 16 }}>
          {!st ? <div style={{ color: "var(--muted)", fontSize: 13 }}>Loading…</div>
          : st.enabled === false ? <div style={{ fontSize: 13, color: "var(--muted)" }}>Billing isn't configured on this server.</div>
          : <>
            <div style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 16, background: "var(--bg)" }}>
              <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
                <div style={{ fontWeight: 700, fontSize: 14 }}>Cadenly · $20/mo</div>
                <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, textTransform: "uppercase", letterSpacing: ".08em", padding: "3px 8px", borderRadius: 999,
                  background: active ? "rgba(0,163,113,.12)" : "rgba(107,114,128,.12)", color: active ? "var(--ok)" : "var(--muted)", border: `1px solid ${active ? "rgba(0,163,113,.35)" : "var(--border)"}` }}>
                  {st.status === "trialing" ? "Free trial" : st.status === "active" ? "Active" : st.status === "past_due" ? "Past due" : st.status === "canceled" ? "Canceled" : "Not started"}
                </span>
              </div>
              <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 6 }}>
                <span style={{ fontSize: 12, color: "var(--muted)" }}>Tokens remaining</span>
                <span style={{ fontWeight: 800, fontSize: 14 }}>{fmtTokens(total)}{base ? <span style={{ color: "var(--muted)", fontWeight: 400 }}> / {fmtTokens(base)} base</span> : null}</span>
              </div>
              <div style={{ height: 6, borderRadius: 999, background: "var(--border)", overflow: "hidden" }}>
                <div style={{ width: pct + "%", height: "100%", background: total > 0 ? "var(--accent)" : "var(--warn)" }} />
              </div>
              {st.tokens.extra > 0 && <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 6 }}>Includes {fmtTokens(st.tokens.extra)} purchased tokens (roll over).</div>}
              {trialing && st.trialEnd && <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 6 }}>Trial ends {new Date(st.trialEnd).toLocaleDateString()}.</div>}
              {st.status === "active" && st.currentPeriodEnd && <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 6 }}>Renews {new Date(st.currentPeriodEnd).toLocaleDateString()}.</div>}
            </div>

            {!active
              ? <button className="btn btn-primary" disabled={!!busy} onClick={() => go("/api/billing/subscribe")}>
                  {busy === "/api/billing/subscribe" ? <><Spinner /> Opening checkout…</> : `Start ${st.trialDays}-day free trial`}
                </button>
              : <button className="btn btn-secondary" disabled={!!busy} onClick={() => go("/api/billing/portal")}>
                  {busy === "/api/billing/portal" ? <><Spinner /> Opening…</> : "Manage subscription"}
                </button>}
            {!active && <div style={{ fontSize: 11, color: "var(--muted)", textAlign: "center", marginTop: -8 }}>$20/mo after the trial · cancel anytime</div>}

            {st.packs && st.packs.length > 0 && <>
              <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--muted)", fontFamily: "var(--font-mono)", marginTop: 4 }}>Buy more tokens</div>
              <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                {st.packs.map(p => (
                  <div key={p.id} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, border: "1px solid var(--border)", borderRadius: 10, padding: "10px 13px" }}>
                    <div style={{ fontSize: 13, fontWeight: 600 }}>{p.label}</div>
                    <button className="btn btn-secondary btn-sm" disabled={!!busy} onClick={() => go("/api/billing/buy-tokens", { pack: p.id })}>
                      {busy === "/api/billing/buy-tokens" + p.id ? <Spinner /> : "Buy"}
                    </button>
                  </div>
                ))}
              </div>
            </>}
          </>}
          {err && <div style={{ fontSize: 12, color: "var(--warn)", background: "rgba(214,51,84,.08)", border: "1px solid rgba(214,51,84,.3)", borderRadius: 8, padding: "8px 11px" }}>{err}</div>}
        </div>
      </div>
    </div>,
    document.body
  );
}

// Mounted once at the root. Opens BillingModal on demand and after returning
// from Stripe Checkout (?billing=…).
function BillingGate() {
  const [open, setOpen] = useState(false);
  const [reason, setReason] = useState(null);
  useEffect(() => {
    const openEvt = () => { setReason(null); setOpen(true); };
    const need = (e) => { setReason((e && e.detail && e.detail.code) || "no_tokens"); setOpen(true); };
    window.addEventListener("cadenly:open-billing", openEvt);
    window.addEventListener("cadenly:need-billing", need);
    try {
      const q = new URLSearchParams(window.location.search);
      const flag = q.get("billing");
      if (flag === "success" || flag === "tokens") { setReason(null); setOpen(true); window.dispatchEvent(new Event("cadenly:billing-changed")); }
      if (flag) { const u = new URL(window.location.href); u.searchParams.delete("billing"); window.history.replaceState({}, "", u.toString()); }
    } catch { /* ignore */ }
    return () => { window.removeEventListener("cadenly:open-billing", openEvt); window.removeEventListener("cadenly:need-billing", need); };
  }, []);
  if (!open) return null;
  return <BillingModal reason={reason} onClose={() => setOpen(false)} />;
}

// Compact header pill showing trial/balance; opens billing on click.
function BillingPill() {
  const [st, setSt] = useState(null);
  useEffect(() => {
    const load = () => api("/api/billing/status").then(setSt).catch(() => setSt(null));
    load();
    const h = () => load();
    window.addEventListener("cadenly:billing-changed", h);
    return () => window.removeEventListener("cadenly:billing-changed", h);
  }, []);
  if (!st || st.enabled === false) return null;
  const active = st.status === "active" || st.status === "trialing";
  const label = st.status === "trialing" ? `Trial · ${fmtTokens(st.tokens.total)} left`
    : st.status === "active" ? `${fmtTokens(st.tokens.total)} tokens` : "Upgrade";
  return (
    <button className="btn btn-secondary btn-sm" title="Billing & tokens"
      onClick={() => window.dispatchEvent(new Event("cadenly:open-billing"))}
      style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
      <span style={{ width: 7, height: 7, borderRadius: "50%", background: active ? "var(--ok)" : "var(--warn)" }} />
      {label}
    </button>
  );
}

// Consolidates appearance (theme) and Connections behind one entry point.
function SettingsModal({ me, theme, onToggleTheme, onOpenConnections, onClose }) {
  const section = { fontSize: 10, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--muted)", fontFamily: "var(--font-mono)", margin: "0 0 4px" };
  const row = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, padding: "10px 0" };
  const rule = { borderTop: "1px solid var(--border)", margin: "14px 0" };
  return ReactDOM.createPortal(
    <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1200, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "6vh 16px", overflowY: "auto", backdropFilter: "blur(2px)" }}>
      <div onClick={e => e.stopPropagation()} style={{ width: "100%", maxWidth: 460, background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 14, boxShadow: "0 12px 48px rgba(0,0,0,.4)" }}>
        <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
          <div style={{ fontWeight: 800, fontSize: 15 }}>Settings</div>
          <span onClick={onClose} style={{ cursor: "pointer", color: "var(--muted)", fontSize: 20, lineHeight: 1 }}>×</span>
        </header>
        <div style={{ padding: 20 }}>
          <div style={section}>Appearance</div>
          <div style={row}>
            <div>
              <div style={{ fontSize: 13, fontWeight: 600 }}>Theme</div>
              <div style={{ fontSize: 11, color: "var(--muted)" }}>Light or dark interface.</div>
            </div>
            <ThemeToggle theme={theme} onToggle={onToggleTheme} />
          </div>

          <div style={rule} />

          <div style={section}>Account</div>
          {me && <div style={{ fontSize: 13, fontFamily: "var(--font-mono)", color: "var(--text)", marginBottom: 14 }}>{me.email}</div>}
          <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: ".04em", textTransform: "uppercase", color: "var(--muted)", fontFamily: "var(--font-mono)", margin: "0 0 8px" }}>Team</div>
          <TeamManager me={me} />

          <div style={rule} />

          <div style={section}>Jira</div>
          <div style={{ padding: "8px 0 2px" }}>
            <JiraConnect />
            <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 8, lineHeight: 1.5 }}>
              Connect your own Jira Cloud site to pull live boards and filters. Stored encrypted, used only by your account.
            </div>
          </div>

          <div style={rule} />

          <div style={section}>Billing</div>
          <div style={row}>
            <div style={{ minWidth: 0 }}>
              <div style={{ fontSize: 13, fontWeight: 600 }}>Subscription &amp; tokens</div>
              <div style={{ fontSize: 11, color: "var(--muted)", maxWidth: 300 }}>Manage your $20/mo plan, free trial, and token top-ups.</div>
            </div>
            <button className="btn btn-secondary btn-sm" onClick={() => { onClose(); window.dispatchEvent(new Event("cadenly:open-billing")); }} style={{ flexShrink: 0 }}>Manage</button>
          </div>

          <div style={rule} />

          <div style={section}>Connections</div>
          <div style={row}>
            <div style={{ minWidth: 0 }}>
              <div style={{ fontSize: 13, fontWeight: 600 }}>MCP connections</div>
              <div style={{ fontSize: 11, color: "var(--muted)", maxWidth: 300 }}>Connect your tools so analysis can ground in your own systems.</div>
            </div>
            <button className="btn btn-secondary btn-sm" onClick={onOpenConnections} style={{ flexShrink: 0 }}>Manage</button>
          </div>
        </div>
      </div>
    </div>,
    document.body
  );
}

// ── Connections manager ──────────────────────────────────────────────────────
// Add/test/remove MCP connections from the UI (no code changes). Talks to the
// /api/connections CRUD routes; the grounding stages then ground against whatever
// read connections exist. Secrets are sent over the wire once and stored encrypted;
// they are never returned, so existing connections show "secret set" not the value.
function ConnectionsModal({ onClose }) {
  const blank = { name: "", url: "", access: "read", authType: "none", token: "", header: "", headerValue: "" };
  const [list, setList] = useState([]);
  const [loading, setLoading] = useState(true);
  const [err, setErr] = useState("");
  const [showAdd, setShowAdd] = useState(false);
  const [form, setForm] = useState(blank);
  const [testing, setTesting] = useState(false);
  const [testResult, setTestResult] = useState(null);
  const [saving, setSaving] = useState(false);
  const [rowStatus, setRowStatus] = useState({});

  const load = () => { setLoading(true); api("/api/connections").then(d => { setList(d.connections || []); setErr(""); }).catch(e => setErr(e.message)).finally(() => setLoading(false)); };
  useEffect(() => { load(); }, []);
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));

  const buildBody = () => {
    const b = { name: form.name.trim(), url: form.url.trim(), access: form.access, authType: form.authType };
    if (form.authType === "bearer") b.secret = form.token;
    if (form.authType === "header") { b.header = form.header.trim(); b.secret = form.headerValue; }
    return b;
  };
  const test = async () => {
    setTesting(true); setTestResult(null);
    try { setTestResult(await api("/api/connections/test", { method: "POST", body: buildBody() })); }
    catch (e) { setTestResult({ ok: false, error: e.message }); }
    finally { setTesting(false); }
  };
  const save = async () => {
    setSaving(true); setErr("");
    try { await api("/api/connections", { method: "POST", body: buildBody() }); setForm(blank); setShowAdd(false); setTestResult(null); load(); }
    catch (e) { setErr(e.message); }
    finally { setSaving(false); }
  };
  const toggle = async (c) => { try { await api(`/api/connections/${c.id}`, { method: "PUT", body: { enabled: !c.enabled } }); load(); } catch (e) { setErr(e.message); } };
  const del = async (id) => { try { await api(`/api/connections/${id}`, { method: "DELETE" }); load(); } catch (e) { setErr(e.message); } };
  const testRow = async (id) => {
    setRowStatus(s => ({ ...s, [id]: { state: "testing" } }));
    try { const r = await api(`/api/connections/${id}/test`, { method: "POST" }); setRowStatus(s => ({ ...s, [id]: { state: r.ok ? "ok" : "fail", msg: r.ok ? `${r.tools} tools (${r.readTools} read)` : r.error } })); }
    catch (e) { setRowStatus(s => ({ ...s, [id]: { state: "fail", msg: e.message } })); }
  };

  const inp = { width: "100%", fontFamily: "var(--font-mono)", fontSize: 12, padding: "8px 10px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 8, color: "var(--text)", boxSizing: "border-box" };
  const lbl = { fontSize: 10, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--muted)", fontFamily: "var(--font-mono)", marginBottom: 4, display: "block" };

  return ReactDOM.createPortal(
    <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1200, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "6vh 16px", overflowY: "auto", backdropFilter: "blur(2px)" }}>
      <div onClick={e => e.stopPropagation()} style={{ width: "100%", maxWidth: 640, background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 14, boxShadow: "0 12px 48px rgba(0,0,0,.4)" }}>
        <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
          <div style={{ fontWeight: 800, fontSize: 15 }}>Connections</div>
          <span onClick={onClose} style={{ cursor: "pointer", color: "var(--muted)", fontSize: 20, lineHeight: 1 }}>×</span>
        </header>
        <div style={{ padding: 20 }}>
          <p style={{ fontSize: 12, color: "var(--muted)", marginTop: 0, marginBottom: 16, lineHeight: 1.6 }}>
            Connect an MCP server so Cadenly can ground its analysis in your own systems. Paste the server URL and credentials — secrets are encrypted at rest and never shown again.
          </p>
          {err && <ErrBox msg={err} />}
          {loading ? <div style={{ fontSize: 12, color: "var(--muted)" }}><Spinner /> Loading…</div> : (
            <>
              {list.length === 0 && !showAdd && <div style={{ fontSize: 12, color: "var(--muted)", padding: "8px 0 4px" }}>No connections yet. Add one to ground analysis in your tools.</div>}
              {list.map(c => {
                const rs = rowStatus[c.id];
                return (
                  <div key={c.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 0", borderBottom: "1px solid var(--border)" }}>
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <div style={{ fontSize: 13, fontWeight: 700, display: "flex", alignItems: "center", gap: 6 }}>
                        {c.name}
                        <span className={`tag ${c.access === "write" ? "tag-gold" : "tag-blue"}`} style={{ fontSize: 9 }}>{c.access}</span>
                        {!c.enabled && <span className="tag tag-muted" style={{ fontSize: 9 }}>disabled</span>}
                      </div>
                      <div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.url} · {c.authType}</div>
                      {rs && <div style={{ fontSize: 10, marginTop: 2, fontFamily: "var(--font-mono)", color: rs.state === "ok" ? "var(--ok)" : rs.state === "fail" ? "var(--warn)" : "var(--muted)" }}>{rs.state === "testing" ? "testing…" : rs.state === "ok" ? `✔ ${rs.msg}` : `✕ ${rs.msg}`}</div>}
                    </div>
                    <button className="btn btn-secondary btn-sm" onClick={() => testRow(c.id)}>Test</button>
                    <button className="btn btn-secondary btn-sm" onClick={() => toggle(c)}>{c.enabled ? "Disable" : "Enable"}</button>
                    <button className="btn btn-warn btn-sm" onClick={() => del(c.id)}>Delete</button>
                  </div>
                );
              })}

              {!showAdd ? (
                <button className="btn btn-primary btn-sm" style={{ marginTop: 16 }} onClick={() => { setShowAdd(true); setTestResult(null); }}>+ Add connection</button>
              ) : (
                <div style={{ marginTop: 16, border: "1px solid var(--border)", borderRadius: 10, padding: 16, background: "var(--panel)" }}>
                  <div style={{ marginBottom: 10 }}><label style={lbl}>Name</label><input style={inp} value={form.name} onChange={e => set("name", e.target.value)} placeholder="e.g. Linear" /></div>
                  <div style={{ marginBottom: 10 }}><label style={lbl}>MCP server URL</label><input style={inp} value={form.url} onChange={e => set("url", e.target.value)} placeholder="https://mcp.example.com/mcp" /></div>
                  <div style={{ display: "flex", gap: 12, marginBottom: 10 }}>
                    <div style={{ flex: 1 }}><label style={lbl}>Access</label>
                      <select style={inp} value={form.access} onChange={e => set("access", e.target.value)}>
                        <option value="read">read (grounding)</option>
                        <option value="write">write (act)</option>
                      </select></div>
                    <div style={{ flex: 1 }}><label style={lbl}>Auth</label>
                      <select style={inp} value={form.authType} onChange={e => { set("authType", e.target.value); setTestResult(null); }}>
                        <option value="none">none</option>
                        <option value="bearer">API key / token</option>
                        <option value="header">custom header</option>
                        <option value="oauth" disabled>OAuth (coming soon)</option>
                      </select></div>
                  </div>
                  {form.authType === "bearer" && <div style={{ marginBottom: 10 }}><label style={lbl}>API key / token</label><input type="password" style={inp} value={form.token} onChange={e => set("token", e.target.value)} placeholder="paste the key" autoComplete="off" /></div>}
                  {form.authType === "header" && <div style={{ display: "flex", gap: 12, marginBottom: 10 }}>
                    <div style={{ flex: 1 }}><label style={lbl}>Header name</label><input style={inp} value={form.header} onChange={e => set("header", e.target.value)} placeholder="X-API-Key" /></div>
                    <div style={{ flex: 1 }}><label style={lbl}>Header value</label><input type="password" style={inp} value={form.headerValue} onChange={e => set("headerValue", e.target.value)} autoComplete="off" /></div>
                  </div>}
                  {testResult && <div style={{ fontSize: 11, marginBottom: 10, fontFamily: "var(--font-mono)", color: testResult.ok ? "var(--ok)" : "var(--warn)" }}>{testResult.ok ? `✔ Connected — ${testResult.tools} tools (${testResult.readTools} read-only)` : `✕ ${testResult.error}`}</div>}
                  <div style={{ display: "flex", gap: 8, marginTop: 4 }}>
                    <button className="btn btn-secondary btn-sm" disabled={testing || !form.url} onClick={test}>{testing ? <><Spinner /> Testing…</> : "Test connection"}</button>
                    <button className="btn btn-primary btn-sm" disabled={saving || !form.name || !form.url} onClick={save}>{saving ? "Saving…" : "Save"}</button>
                    <button className="btn btn-ghost btn-sm" onClick={() => { setShowAdd(false); setForm(blank); setTestResult(null); }}>Cancel</button>
                  </div>
                </div>
              )}
            </>
          )}
        </div>
      </div>
    </div>,
    document.body
  );
}

// Cadenly — workflow picker (front door) + workflow metadata
// ══════════════════════════════════════════════════════════════════════════════
const WORKFLOWS = [
  { id: "tpm", name: "TPM Workflow", icon: "◫",
    desc: "Your daily program-management loop — board status, risks, standup, meetings, and a management-ready weekly status." },
  { id: "spec", name: "Spec Workflow", icon: "❏",
    desc: "Turn a PRD or flowchart into delivery-ready specs — gap analysis, epics, story sizing, test cases, and Jira-ready packages." },
];
// Spec workflow stage chips for the dashboard use the canonical SPEC_STAGES (below).

// Shared account controls for every signed-in screen: billing pill, avatar
// (profile), and Settings. Keeps the picker and every workflow screen identical,
// so email/team/theme live in Settings rather than cluttering each header.
function HeaderControls({ me, theme, onToggleTheme, onProfileUpdated, onLoggedOut }) {
  const [profileOpen, setProfileOpen] = useState(false);
  const [settingsOpen, setSettingsOpen] = useState(false);
  const [connOpen, setConnOpen] = useState(false);
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
      <BillingPill />
      <Avatar user={me} onClick={() => setProfileOpen(true)} />
      <button className="btn btn-secondary btn-sm" onClick={() => setSettingsOpen(true)} title="Settings" aria-label="Settings">⚙ Settings</button>
      {settingsOpen && <SettingsModal me={me} theme={theme} onToggleTheme={onToggleTheme} onOpenConnections={() => { setSettingsOpen(false); setConnOpen(true); }} onClose={() => setSettingsOpen(false)} />}
      {connOpen && <ConnectionsModal onClose={() => setConnOpen(false)} />}
      {profileOpen && <ProfileModal me={me} onClose={() => setProfileOpen(false)} onUpdated={onProfileUpdated} onLoggedOut={() => { setProfileOpen(false); onLoggedOut && onLoggedOut(); }} />}
    </div>
  );
}

// Team roster management (moved out of the workflow header into Settings).
// Teammates appear in the project share picker.
function TeamManager({ me }) {
  const [users, setUsers] = useState([]);
  const [email, setEmail] = useState("");
  const [name, setName] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const load = () => api("/api/users").then(d => setUsers(d.users || [])).catch(() => {});
  useEffect(() => { load(); }, []);
  const add = async () => {
    const e = email.trim(); if (!e) return;
    setBusy(true); setErr("");
    try { const out = await api("/api/users", { method: "POST", body: { email: e, name: name.trim() || undefined } }); setUsers(out.users || []); setEmail(""); setName(""); window.dispatchEvent(new Event("cadenly:team-changed")); }
    catch (ex) { setErr(ex.message); } finally { setBusy(false); }
  };
  const remove = async (e) => {
    setBusy(true); setErr("");
    try { const out = await api(`/api/users/${encodeURIComponent(e)}`, { method: "DELETE" }); setUsers(out.users || []); window.dispatchEvent(new Event("cadenly:team-changed")); }
    catch (ex) { setErr(ex.message); } finally { setBusy(false); }
  };
  const inp = { padding: "8px 10px", borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg)", color: "var(--text)", fontSize: 13 };
  return (
    <div>
      <div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.5, marginBottom: 10 }}>
        Teammates appear in the project share picker, so you can grant them read-only access.
      </div>
      <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center", marginBottom: 8 }}>
        <input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="teammate@example.com" onKeyDown={e => e.key === "Enter" && add()} style={{ ...inp, flex: 2, minWidth: 180 }} />
        <input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="Name (optional)" onKeyDown={e => e.key === "Enter" && add()} style={{ ...inp, flex: 1, minWidth: 110 }} />
        <button className="btn btn-primary btn-sm" disabled={busy || !email.trim()} onClick={add}>{busy ? <Spinner /> : "Add"}</button>
      </div>
      {err && <div style={{ fontSize: 11, color: "var(--warn)", fontFamily: "var(--font-mono)", marginBottom: 8 }}>⚠ {err}</div>}
      <div style={{ display: "flex", flexDirection: "column", gap: 6, maxHeight: 220, overflowY: "auto" }}>
        {users.length === 0 && <div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>No teammates yet.</div>}
        {users.map(u => {
          const isMe = me && u.email === me.email;
          return (
            <div key={u.id || u.email} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, padding: "7px 11px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 8 }}>
              <div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
                <span style={{ fontWeight: 700, fontSize: 13 }}>{u.name || u.email.split("@")[0]}</span>
                <span style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis" }}>{u.email}</span>
                {isMe && <span className="tag tag-muted" style={{ fontSize: 9 }}>you</span>}
              </div>
              {!isMe && <button className="btn btn-ghost btn-sm" disabled={busy} onClick={() => remove(u.email)} style={{ color: "var(--warn)" }}>Remove</button>}
            </div>
          );
        })}
      </div>
    </div>
  );
}

function WorkflowPicker({ onPick, theme, onToggleTheme, me, onProfileUpdated, onLoggedOut }) {
  const [connOpen, setConnOpen] = useState(false);
  const [profileOpen, setProfileOpen] = useState(false);
  const [settingsOpen, setSettingsOpen] = useState(false);
  return (
    <div style={{ minHeight: "100vh", background: "var(--bg)" }}>
      <div style={{ position: "fixed", inset: 0, pointerEvents: "none", zIndex: 0,
        backgroundImage: "linear-gradient(var(--grid-line) 1px,transparent 1px),linear-gradient(90deg,var(--grid-line) 1px,transparent 1px)", backgroundSize: "48px 48px" }} />
      <div style={{ position: "relative", zIndex: 1 }}>
        <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 32px", height: 56, background: "var(--header-bg)", backdropFilter: "blur(12px)", borderBottom: "1px solid var(--border)" }}>
          <span style={{ fontWeight: 800, fontSize: 15, letterSpacing: ".02em" }}><CadenlyLogo /></span>
          <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
            <Avatar user={me} onClick={() => setProfileOpen(true)} />
            <BillingPill />
            <button className="btn btn-secondary btn-sm" onClick={() => setSettingsOpen(true)} title="Settings" aria-label="Settings">⚙ Settings</button>
          </div>
        </header>
        {settingsOpen && <SettingsModal me={me} theme={theme} onToggleTheme={onToggleTheme} onOpenConnections={() => { setSettingsOpen(false); setConnOpen(true); }} onClose={() => setSettingsOpen(false)} />}
        {connOpen && <ConnectionsModal onClose={() => setConnOpen(false)} />}
        {profileOpen && <ProfileModal me={me} onClose={() => setProfileOpen(false)} onUpdated={onProfileUpdated} onLoggedOut={() => { setProfileOpen(false); onLoggedOut(); }} />}
        <main style={{ maxWidth: 880, margin: "0 auto", padding: "56px 32px" }}>
          <h1 style={{ fontSize: 28, fontWeight: 800, letterSpacing: "-.02em", marginBottom: 8 }}>Choose a workflow</h1>
          <p style={{ fontSize: 13, color: "var(--muted)", marginBottom: 36, maxWidth: 520 }}>Each workflow has its own projects. Pick one to see and manage its projects.</p>
          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(300px,1fr))", gap: 18 }}>
            {WORKFLOWS.map(w => (
              <div key={w.id} onClick={() => onPick(w.id)} style={{ cursor: "pointer", background: "var(--panel)", border: "1px solid var(--border)", borderRadius: 12, padding: 24, boxShadow: "0 2px 8px var(--shadow)", transition: "border-color .2s, box-shadow .2s" }}
                onMouseEnter={e => { e.currentTarget.style.borderColor = "var(--accent)"; e.currentTarget.style.boxShadow = "0 4px 20px var(--shadow)"; }}
                onMouseLeave={e => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.boxShadow = "0 2px 8px var(--shadow)"; }}>
                <div style={{ fontSize: 28, marginBottom: 12 }}>{w.icon}</div>
                <div style={{ fontSize: 18, fontWeight: 800, marginBottom: 8 }}>{w.name}</div>
                <div style={{ fontSize: 12.5, color: "var(--muted)", lineHeight: 1.6, marginBottom: 16 }}>{w.desc}</div>
                <span style={{ fontSize: 11, fontFamily: "var(--font-mono)", fontWeight: 700, color: "var(--accent)", letterSpacing: ".04em" }}>OPEN →</span>
              </div>
            ))}
          </div>
        </main>
      </div>
    </div>
  );
}

// ══════════════════════════════════════════════════════════════════════════════
// DASHBOARD (local app — projects list, create, open, share, delete)
// ══════════════════════════════════════════════════════════════════════════════
function Dashboard({ workflow, onPick, onOpen, onBackToWorkflows, theme, onToggleTheme, me, onProfileUpdated, onLoggedOut }) {
  const [user, setUser] = useState(null);
  const [users, setUsers] = useState([]);
  const [projects, setProjects] = useState([]);
  const [as, setAs] = useState("");          // dev-login: act-as email ("" = server default)
  const [creating, setCreating] = useState(false);
  const [newName, setNewName] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const [shareFor, setShareFor] = useState(null);  // project id we're sharing
  const [shareEmail, setShareEmail] = useState("");
  const [shareRole] = useState("viewer");   // non-owners are always read-only
  const [confirmDel, setConfirmDel] = useState(null);
  const [renameFor, setRenameFor] = useState(null);
  const [renameVal, setRenameVal] = useState("");
  const [teamOpen, setTeamOpen] = useState(false);   // Team settings panel
  const [teamEmail, setTeamEmail] = useState("");
  const [teamName, setTeamName] = useState("");
  const [teamErr, setTeamErr] = useState("");
  const [teamBusy, setTeamBusy] = useState(false);

  const addMember = async () => {
    const email = teamEmail.trim(); if (!email) return;
    setTeamBusy(true); setTeamErr("");
    try {
      const out = await api("/api/users", { method: "POST", body: { email, name: teamName.trim() || undefined } });
      setUsers(out.users || []); setTeamEmail(""); setTeamName("");
    } catch (e) { setTeamErr(e.message); }
    finally { setTeamBusy(false); }
  };
  const removeMember = async (email) => {
    setTeamBusy(true); setTeamErr("");
    try {
      const out = await api(`/api/users/${encodeURIComponent(email)}`, { method: "DELETE" });
      setUsers(out.users || []);
    } catch (e) { setTeamErr(e.message); }
    finally { setTeamBusy(false); }
  };

  const refresh = async () => {
    setBusy(true); setErr("");
    try {
      const [me, ps] = await Promise.all([api("/api/me"), api(`/api/projects?kind=${workflow}`)]);
      setUser(me.user); setProjects(ps.projects || []);
      try { const us = await api("/api/users"); setUsers(us.users || []); } catch { setUsers([]); }
    } catch (e) { setErr(`Can't reach the broker — is it running? (${e.message})`); }
    finally { setBusy(false); }
  };
  useEffect(() => { refresh(); /* eslint-disable-next-line */ }, [as, workflow]);
  useEffect(() => {
    const h = () => { api("/api/users").then(us => setUsers(us.users || [])).catch(() => {}); };
    window.addEventListener("cadenly:team-changed", h);
    return () => window.removeEventListener("cadenly:team-changed", h);
  }, []);

  const create = async () => {
    const name = newName.trim(); if (!name) return;
    try { const { project } = await api("/api/projects", { method: "POST", body: { name, kind: workflow } }); setNewName(""); setCreating(false); await refresh(); onOpen(project); }
    catch (e) { setErr(e.message); }
  };
  const del = async (id) => { try { await api(`/api/projects/${id}`, { method: "DELETE" }); setConfirmDel(null); refresh(); } catch (e) { setErr(e.message); } };
  const share = async (id) => {
    if (!shareEmail.trim()) return;
    try { await api(`/api/projects/${id}/share`, { method: "POST", body: { email: shareEmail.trim(), role: shareRole } }); setShareEmail(""); setShareFor(null); refresh(); }
    catch (e) { setErr(e.message); }
  };
  const rename = async (id) => {
    const name = renameVal.trim(); if (!name) return;
    try { await api(`/api/projects/${id}`, { method: "PUT", body: { name } }); setRenameFor(null); refresh(); }
    catch (e) { setErr(e.message); }
  };

  const fmt = ts => ts ? new Date(ts).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "—";
  const timeAgo = ts => {
    if (!ts) return "";
    const m = Math.floor((Date.now() - ts) / 60000);
    if (m < 1) return "just now"; if (m < 60) return `${m}m ago`;
    const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`;
    const d = Math.floor(h / 24); return `${d}d ago`;
  };
  const roleTag = r => r === "owner" ? "tag-green" : r === "editor" ? "tag-blue" : "tag-muted";
  const COMPLETABLE = workflow === "spec" ? SPEC_STAGES : STAGES.filter(s => s.id !== "jira");
  const wfName = (WORKFLOWS.find(w => w.id === workflow) || {}).name || "Workflow";

  return (
    <div style={{ minHeight: "100vh", background: "var(--bg)" }}>
      <div style={{ position: "fixed", inset: 0, pointerEvents: "none", zIndex: 0,
        backgroundImage: "linear-gradient(var(--grid-line) 1px,transparent 1px),linear-gradient(90deg,var(--grid-line) 1px,transparent 1px)",
        backgroundSize: "48px 48px" }} />
      <div style={{ position: "relative", zIndex: 1 }}>
        <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 32px", height: 56, background: "var(--header-bg)", backdropFilter: "blur(12px)", borderBottom: "1px solid var(--border)" }}>
          <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
            <button className="btn btn-secondary btn-sm" onClick={onBackToWorkflows}>← Workflows</button>
            <span style={{ fontWeight: 800, fontSize: 14, letterSpacing: ".02em" }}><CadenlyLogo /></span>
            <span style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>· {wfName}</span>
          </div>
          <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
            <HeaderControls me={me || user} theme={theme} onToggleTheme={onToggleTheme} onProfileUpdated={onProfileUpdated} onLoggedOut={onLoggedOut} />
          </div>
        </header>

        <main style={{ maxWidth: 1100, margin: "0 auto", padding: "40px 32px" }}>
          <div style={{ marginBottom: 32 }}>
            <h1 style={{ fontSize: 28, fontWeight: 800, letterSpacing: "-.02em", marginBottom: 8 }}>{wfName} · Projects</h1>
            <p style={{ fontSize: 13, color: "var(--muted)", maxWidth: 560 }}>
              Open a project to pick up where you left off; saved versions track its history. Projects here belong to the {wfName}.
            </p>
          </div>

          {teamOpen && null}

          {!creating ? (
            <div style={{ display: "flex", gap: 10, marginBottom: 28, alignItems: "center" }}>
              <button className="btn btn-primary" onClick={() => setCreating(true)}>+ New project</button>
              <ErrBox msg={err} />
            </div>
          ) : (
            <div className="card" style={{ marginBottom: 28, maxWidth: 540 }}>
              <CardHead title="New project" right={<button className="btn btn-secondary btn-sm" onClick={() => { setCreating(false); setNewName(""); }}>Cancel</button>} />
              <div style={{ display: "flex", gap: 10 }}>
                <input type="text" value={newName} onChange={e => setNewName(e.target.value)} placeholder="e.g. Mobile Onboarding Revamp" autoFocus
                  onKeyDown={e => { if (e.key === "Enter") create(); }} style={{ flex: 1 }} />
                <button className="btn btn-primary" disabled={!newName.trim()} onClick={create}>Create</button>
              </div>
              <ErrBox msg={err} />
            </div>
          )}

          {busy && !projects.length ? <EmptyState icon="…" title="Loading…" sub="" />
            : !projects.length ? <EmptyState icon="✦" title="No projects yet" sub="Create your first project to get started." />
            : (
              <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill,minmax(320px,1fr))", gap: 16 }}>
                {projects.map(p => {
                  const done = p.done || [];
                  const pct = Math.round(done.length / COMPLETABLE.length * 100);
                  const lastStage = COMPLETABLE.filter(s => done.includes(s.id)).pop();
                  const lastColor = lastStage ? lastStage.color : "var(--border)";
                  const isOwner = p.my_role === "owner";
                  return (
                    <div key={p.id} style={{ background: "var(--panel)", borderRadius: 12, border: "1px solid var(--border)", boxShadow: "0 2px 8px var(--shadow)", overflow: "hidden", transition: "border-color .2s, box-shadow .2s" }}
                      onMouseEnter={e => { e.currentTarget.style.borderColor = "var(--accent)"; e.currentTarget.style.boxShadow = "0 4px 20px var(--shadow)"; }}
                      onMouseLeave={e => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.boxShadow = "0 2px 8px var(--shadow)"; }}>

                      <div style={{ height: 3, background: "var(--border)" }}>
                        <div style={{ height: "100%", width: `${pct}%`, background: `linear-gradient(90deg,var(--accent2),${lastColor})`, transition: "width .4s ease" }} />
                      </div>

                      <div style={{ padding: "18px 20px", cursor: "pointer" }} onClick={() => onOpen(p)}>
                        {renameFor === p.id ? (
                          <input type="text" value={renameVal} autoFocus onClick={e => e.stopPropagation()}
                            onChange={e => setRenameVal(e.target.value)}
                            onKeyDown={e => { if (e.key === "Enter") rename(p.id); if (e.key === "Escape") setRenameFor(null); }}
                            onBlur={() => rename(p.id)} style={{ marginBottom: 10, fontWeight: 700 }} />
                        ) : (
                          <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4, flexWrap: "wrap" }}>
                            <span style={{ fontWeight: 800, fontSize: 15, lineHeight: 1.3 }}>{p.name}</span>
                            <span className={`tag ${roleTag(p.my_role)}`} style={{ fontSize: 9 }}>{p.my_role}</span>
                          </div>
                        )}
                        <div style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)", marginBottom: 12 }}>owner {p.owner_email}</div>

                        <div style={{ display: "flex", gap: 5, flexWrap: "wrap", marginBottom: 14 }}>
                          {COMPLETABLE.map(s => {
                            const d = done.includes(s.id);
                            return <div key={s.id} style={{ fontSize: 9, fontFamily: "var(--font-mono)", fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", padding: "2px 7px", borderRadius: 3, background: d ? s.color : "var(--border)", color: d ? "#07080d" : "var(--muted)", transition: "all .2s" }}>{s.label}</div>;
                          })}
                        </div>

                        <div style={{ display: "flex", gap: 16, alignItems: "center" }}>
                          {p.tickets > 0 && <div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}><span style={{ color: "var(--accent)", fontWeight: 700 }}>{p.tickets}</span> tickets</div>}
                          {p.risks > 0 && <div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}><span style={{ color: "var(--warn)", fontWeight: 700 }}>{p.risks}</span> risks</div>}
                          {p.versions > 0 && <div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}><span style={{ color: "var(--accent2)", fontWeight: 700 }}>{p.versions}</span> versions</div>}
                          <div style={{ marginLeft: "auto", fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>{timeAgo(p.updated_at)}</div>
                        </div>
                      </div>

                      {shareFor === p.id && (
                        <div style={{ padding: "14px 20px 16px", borderTop: "1px solid var(--border)" }} onClick={e => e.stopPropagation()}>
                          <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
                            <select value={shareEmail} onChange={e => setShareEmail(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
                              <option value="">Choose a teammate…</option>
                              {users.filter(u => u.email !== (user && user.email)).map(u => <option key={u.id} value={u.email}>{u.email}</option>)}
                            </select>
                            <span className="tag tag-muted" style={{ fontSize: 10 }}>read-only</span>
                            <button className="btn btn-primary btn-sm" disabled={!shareEmail} onClick={() => share(p.id)}>Add</button>
                          </div>
                          <div style={{ fontSize: 10, color: "var(--muted)", marginTop: 6, fontFamily: "var(--font-mono)" }}>Shared teammates get read-only access. Only you, the owner, can edit this project.</div>
                          {p.members && <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 10 }}>{p.members.map(m => <span key={m.email} className={`tag ${roleTag(m.role)}`}>{m.email} · {m.role}</span>)}</div>}
                        </div>
                      )}

                      <div style={{ display: "flex", borderTop: "1px solid var(--border)" }}>
                        {[
                          { label: "OPEN →", color: "var(--accent)", on: () => onOpen(p), show: true },
                          { label: "SHARE", color: "var(--muted)", on: () => { setShareFor(shareFor === p.id ? null : p.id); setShareEmail(""); }, show: isOwner },
                          { label: "RENAME", color: "var(--muted)", on: () => { setRenameFor(p.id); setRenameVal(p.name); }, show: isOwner },
                          { label: "DELETE", color: "var(--warn)", on: () => setConfirmDel(p.id), show: isOwner },
                        ].filter(b => b.show).map((b, i, arr) => (
                          <button key={b.label} onClick={e => { e.stopPropagation(); b.on(); }} style={{ flex: 1, padding: "9px 0", background: "none", border: "none", cursor: "pointer", fontSize: 11, fontFamily: "var(--font-mono)", fontWeight: 700, color: b.color, letterSpacing: ".04em", borderRight: i < arr.length - 1 ? "1px solid var(--border)" : "none", transition: "background .15s" }}
                            onMouseEnter={e => e.currentTarget.style.background = "rgba(90,96,128,.08)"}
                            onMouseLeave={e => e.currentTarget.style.background = "none"}>{b.label}</button>
                        ))}
                      </div>
                    </div>
                  );
                })}
              </div>
            )}
        </main>
      </div>

      {confirmDel && (
        <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.6)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center" }} onClick={() => setConfirmDel(null)}>
          <div className="card" style={{ width: 380 }} onClick={e => e.stopPropagation()}>
            <div style={{ fontWeight: 800, fontSize: 15, marginBottom: 10 }}>Delete project?</div>
            <div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 20, lineHeight: 1.6 }}>
              This permanently deletes the project, its saved state, and all version history. This cannot be undone.
            </div>
            <div style={{ display: "flex", gap: 10 }}>
              <button className="btn btn-warn" style={{ flex: 1 }} onClick={() => del(confirmDel)}>Delete permanently</button>
              <button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setConfirmDel(null)}>Cancel</button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

// ══════════════════════════════════════════════════════════════════════════════
// ROOT
// ══════════════════════════════════════════════════════════════════════════════
function App() {
  const [theme, setTheme] = useState("light");
  const [workflow, setWorkflow] = useState(null);   // "tpm" | "spec" | null (picker)
  const [session, setSession] = useState(null);
  const [active, setActive] = useState("board");
  const [confirmEnd, setConfirmEnd] = useState(false);
  // Local-app project binding
  const [projectId, setProjectId] = useState(null);
  const [role, setRole] = useState(null);
  const [saveState, setSaveState] = useState("idle"); // idle | saving | saved | error
  const [revOpen, setRevOpen] = useState(false);
  const [viewingRev, setViewingRev] = useState(null);  // revision being viewed (read-only)
  const saveTimer = useRef();
  const justLoaded = useRef(false);
  const dirtyRef = useRef(false);   // did the user change anything since the last version?

  useEffect(() => { document.documentElement.setAttribute("data-theme", theme); }, [theme]);
  const toggleTheme = () => setTheme(t => t === "dark" ? "light" : "dark");

  // ── IDENTITY ── the signed-in user (null until checked). When null after the
  // check, App shows the AuthScreen. A 401 from any call drops the session.
  const [me, setMe] = useState(null);
  const [authChecked, setAuthChecked] = useState(!IS_LOCAL_APP);
  const [profileOpen, setProfileOpen] = useState(false);
  const reloadMe = () => api("/api/me").then(d => { setMe(d.user || null); return d.user; }).catch(() => { setMe(null); });
  useEffect(() => {
    if (!IS_LOCAL_APP) return;
    let cancelled = false;
    api("/api/me")
      .then(d => { if (!cancelled) setMe(d.user || null); })
      .catch(() => { if (!cancelled) setMe(null); })
      .finally(() => { if (!cancelled) setAuthChecked(true); });
    const onUnauth = () => setMe(null);
    window.addEventListener("cadenly:unauth", onUnauth);
    return () => { cancelled = true; window.removeEventListener("cadenly:unauth", onUnauth); };
  }, []);

  const start = name => { setSession({ projectName: name, startedAt: Date.now() }); setActive("board"); };
  const load = obj => { setSession(obj.session || obj); setActive("board"); };
  const save = () => download(`tpm-session-${(session.projectName || "project").replace(/\W+/g, "-")}.json`,
    JSON.stringify({ _meta: { tool: "AIAD TPM Workflow", savedAt: Date.now() }, session }, null, 2), "application/json");

  // Open a project from the dashboard: fetch its FULL record (the list row has no
  // state) and load the saved working state into the workflow.
  const openProject = async (p) => {
    let project = p, r = p.my_role;
    try { const d = await api(`/api/projects/${p.id}`); project = d.project; r = d.role; } catch { /* fall back to list row */ }
    let st = {};
    try { st = JSON.parse(project.state || "{}"); } catch { st = {}; }
    if (!st.projectName) st.projectName = project.name;
    justLoaded.current = true;
    dirtyRef.current = false;
    setRole(r || "owner");
    setProjectId(p.id);
    setViewingRev(null);
    setRevOpen(false);
    setSession(st);
    setActive("board");
    setSaveState("saved");
  };
  // Persist the working state immediately (used when leaving so the debounce
  // timer can't drop an in-flight change).
  const saveNow = async () => {
    if (!IS_LOCAL_APP || !projectId || role === "viewer" || viewingRev || !session) return;
    clearTimeout(saveTimer.current);
    try { await api(`/api/projects/${projectId}`, { method: "PUT", body: { name: session.projectName, state: session } }); setSaveState("saved"); }
    catch { setSaveState("error"); }
  };

  // Auto-snapshot a version when leaving, but only if something changed this visit.
  const snapshotIfDirty = async () => {
    if (!IS_LOCAL_APP || !projectId || role === "viewer" || viewingRev || !session || !dirtyRef.current) return;
    const stamp = new Date().toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
    try { await api(`/api/projects/${projectId}/revisions`, { method: "POST", body: { label: `Session — ${stamp}`, state: session } }); dirtyRef.current = false; } catch { /* ignore */ }
  };

  const backToDashboard = async () => { await saveNow(); await snapshotIfDirty(); setSession(null); setProjectId(null); setRole(null); setViewingRev(null); setRevOpen(false); setSaveState("idle"); };

  // Load a project's current working state (the latest), e.g. after restore or to exit a view.
  const reloadWorking = async () => {
    try {
      const { project, role: r } = await api(`/api/projects/${projectId}`);
      let st = {}; try { st = JSON.parse(project.state || "{}"); } catch { st = {}; }
      if (!st.projectName) st.projectName = project.name;
      justLoaded.current = true; setViewingRev(null); setRole(r); setSession(st); setSaveState("saved");
    } catch { /* ignore */ }
  };
  // View an older version, read-only (autosave is suspended while viewing).
  const viewRevision = async (rev) => {
    await saveNow();   // flush current work before swapping in the old state
    try {
      const { revision } = await api(`/api/projects/${projectId}/revisions/${rev.id}`);
      let st = {}; try { st = JSON.parse(revision.state || "{}"); } catch { st = {}; }
      if (!st.projectName) st.projectName = session.projectName;
      justLoaded.current = true; setViewingRev(rev); setRevOpen(false); setActive("board"); setSession(st);
    } catch { /* ignore */ }
  };
  const restoreRevision = async (rev) => {
    try { await api(`/api/projects/${projectId}/revisions/${rev.id}/restore`, { method: "POST" }); setRevOpen(false); await reloadWorking(); }
    catch { /* ignore */ }
  };
  // Delete a version. If we're currently viewing it, drop back to the working copy.
  const deleteRevision = async (rev) => {
    try {
      await api(`/api/projects/${projectId}/revisions/${rev.id}`, { method: "DELETE" });
      if (viewingRev && viewingRev.id === rev.id) await reloadWorking();
    } catch { /* ignore */ }
  };

  // Autosave the workflow state back to the project (local app, not for viewers, not while viewing history).
  useEffect(() => {
    if (!IS_LOCAL_APP || !projectId || role === "viewer" || viewingRev || !session) return;
    if (justLoaded.current) { justLoaded.current = false; return; }
    dirtyRef.current = true;
    setSaveState("saving");
    clearTimeout(saveTimer.current);
    saveTimer.current = setTimeout(async () => {
      try { await api(`/api/projects/${projectId}`, { method: "PUT", body: { name: session.projectName, state: session } }); setSaveState("saved"); }
      catch { setSaveState("error"); }
    }, 800);
    return () => clearTimeout(saveTimer.current);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [session]);

  // Best-effort flush if the tab is closed mid-edit (keepalive lets it complete).
  const liveRef = useRef({});
  liveRef.current = { projectId, role, viewingRev, session };
  useEffect(() => {
    if (!IS_LOCAL_APP) return;
    const h = () => {
      const v = liveRef.current;
      if (!v.projectId || v.role === "viewer" || v.viewingRev || !v.session) return;
      try {
        fetch(`${BROKER_BASE}/api/projects/${v.projectId}`, {
          method: "PUT", keepalive: true, credentials: "include",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ name: v.session.projectName, state: v.session }),
        });
      } catch { /* ignore */ }
    };
    window.addEventListener("beforeunload", h);
    return () => window.removeEventListener("beforeunload", h);
  }, []);

  // Auth gate (local app only): wait for the session check, then require sign-in.
  if (IS_LOCAL_APP && !authChecked) return <div style={{ minHeight: "100vh", background: "var(--bg)" }} />;
  if (IS_LOCAL_APP && !me) return <AuthScreen onAuthed={reloadMe} theme={theme} onToggleTheme={toggleTheme} />;

  if (!session) {
    if (IS_LOCAL_APP) {
      if (!workflow) return <WorkflowPicker onPick={setWorkflow} theme={theme} onToggleTheme={toggleTheme} me={me} onProfileUpdated={setMe} onLoggedOut={() => setMe(null)} />;
      return <Dashboard workflow={workflow} onOpen={openProject} onBackToWorkflows={() => setWorkflow(null)} theme={theme} onToggleTheme={toggleTheme} me={me} onProfileUpdated={setMe} onLoggedOut={() => setMe(null)} />;
    }
    return <Setup onStart={start} onLoad={load} theme={theme} onToggleTheme={toggleTheme} />;
  }

  // Spec workflow — its own stepper/stages, sharing the project's session + autosave + versions.
  if (IS_LOCAL_APP && workflow === "spec") {
    return (
      <div style={{ minHeight: "100vh", background: "var(--bg)" }}>
        <header style={{ position: "sticky", top: 0, zIndex: 10, display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 28px", borderBottom: "1px solid var(--border)", background: "var(--header-bg)", backdropFilter: "blur(8px)" }}>
          <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
            <button className="btn btn-secondary btn-sm" onClick={backToDashboard}>← Projects</button>
            <span style={{ fontWeight: 800, fontSize: 14 }}><CadenlyLogo /></span>
            <span style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>· Spec Workflow ·</span>
            <strong style={{ fontSize: 14 }}>{session.projectName}</strong>
            {role === "viewer" && <span className="tag tag-muted">view only</span>}
          </div>
          <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
            <span style={{ fontSize: 10, color: viewingRev ? "var(--gold)" : "var(--muted)", fontFamily: "var(--font-mono)" }}>
              {viewingRev ? "viewing older version" : role === "viewer" ? "changes not saved" : saveState === "saving" ? "saving…" : saveState === "error" ? "save failed" : "saved"}
            </span>
            {!viewingRev && <button className="btn btn-secondary btn-sm" onClick={() => setRevOpen(o => !o)}>Versions</button>}
            <HeaderControls me={me} theme={theme} onToggleTheme={toggleTheme} onProfileUpdated={setMe} onLoggedOut={() => setMe(null)} />
          </div>
        </header>
        {viewingRev && (
          <div style={{ background: "rgba(245,200,66,.12)", borderBottom: "1px solid var(--gold)", padding: "10px 28px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
            <span style={{ fontSize: 12 }}>You're viewing a saved version{viewingRev.label ? ` — "${viewingRev.label}"` : ""}. Read-only.</span>
            <div style={{ display: "flex", gap: 8 }}>
              {role !== "viewer" && <button className="btn btn-primary btn-sm" onClick={() => restoreRevision(viewingRev)}>Restore this version</button>}
              {role !== "viewer" && <button className="btn btn-warn btn-sm" onClick={() => deleteRevision(viewingRev)}>Delete this version</button>}
              <button className="btn btn-secondary btn-sm" onClick={reloadWorking}>Back to current</button>
            </div>
          </div>
        )}
        {revOpen && !viewingRev && (
          <div style={{ maxWidth: 1080, margin: "0 auto", padding: "24px 28px 0" }}>
            <VersionPanel projectId={projectId} currentState={session} canEdit={role !== "viewer"}
              onView={viewRevision} onRestore={restoreRevision} onDelete={() => {}} onClose={() => setRevOpen(false)} onSaved={() => { dirtyRef.current = false; }} />
          </div>
        )}
        <SpecShell s={session} set={setSession} />
      </div>
    );
  }

  // completion heuristics for the stepper ticks
  const doneSet = new Set();
  if (session.board?.tickets?.length) doneSet.add("board");
  if (session.transcripts?.length) doneSet.add("transcripts");
  if (session.risks?.length || session.dependencies?.length) doneSet.add("risks");
  if (Object.keys(session.standup || {}).length) doneSet.add("standup");
  if (session.board?.tickets?.some(t => t._touched)) doneSet.add("wip");
  if (session.reminders?.length) doneSet.add("reminders");
  if (session.topics?.length) doneSet.add("topics");
  if (session.meetings?.length) doneSet.add("meetings");
  if (session.meetings?.some(m => m.memo)) doneSet.add("memo");
  if (session.actionItems?.length) doneSet.add("actions");
  if (session.weeklyStatus) doneSet.add("status");

  const props = { s: session, set: setSession, me };
  const stage = {
    board: <StageBoard {...props} />, transcripts: <StageTranscripts {...props} />, risks: <StageRisks {...props} />, standup: <StageStandup {...props} />,
    wip: <StageWIP {...props} />, reminders: <StageReminders {...props} />, topics: <StageTopics {...props} />,
    meetings: <StageMeetings {...props} />, memo: <StageMemo {...props} />, actions: <StageActions {...props} />,
    jira: <StageJira s={session} />, status: <StageStatus {...props} />,
  }[active];

  const idx = STAGES.findIndex(x => x.id === active);

  return (
    <div style={{ minHeight: "100vh", background: "var(--bg)" }}>
      <header style={{ position: "sticky", top: 0, zIndex: 10, display: "flex", alignItems: "center",
        justifyContent: "space-between", padding: "12px 28px", borderBottom: "1px solid var(--border)",
        background: "var(--header-bg)", backdropFilter: "blur(8px)" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
          {IS_LOCAL_APP && <button className="btn btn-secondary btn-sm" onClick={backToDashboard}>← Projects</button>}
          <span style={{ fontWeight: 800, fontSize: 14, letterSpacing: ".02em" }}>{IS_LOCAL_APP ? <><CadenlyLogo /></> : <span style={{ fontFamily: "var(--font-mono)", color: "var(--accent)" }}>AIAD</span>}</span>
          <span style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>· {IS_LOCAL_APP ? "TPM Workflow" : "TPM"} ·</span>
          <strong style={{ fontSize: 14 }}>{session.projectName}</strong>
          {IS_LOCAL_APP && role === "viewer" && <span className="tag tag-muted">view only</span>}
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
          {IS_LOCAL_APP ? (
            <>
              <span style={{ fontSize: 10, color: viewingRev ? "var(--gold)" : "var(--muted)", fontFamily: "var(--font-mono)" }}>
                {viewingRev ? "viewing older version" : role === "viewer" ? "changes not saved" : saveState === "saving" ? "saving…" : saveState === "error" ? "save failed" : "saved"}
              </span>
              {!viewingRev && <button className="btn btn-secondary btn-sm" onClick={() => setRevOpen(o => !o)}>Versions</button>}
            </>
          ) : (
            <>
              <button className="btn btn-ghost btn-sm" onClick={save}>Save Session</button>
              {confirmEnd ? (
                <>
                  <button className="btn btn-warn btn-sm" onClick={() => { setConfirmEnd(false); setSession(null); }}>End — confirm</button>
                  <button className="btn btn-secondary btn-sm" onClick={() => setConfirmEnd(false)}>Cancel</button>
                </>
              ) : (
                <button className="btn btn-secondary btn-sm" onClick={() => setConfirmEnd(true)}>End</button>
              )}
            </>
          )}
          <HeaderControls me={me} theme={theme} onToggleTheme={toggleTheme} onProfileUpdated={setMe} onLoggedOut={() => setMe(null)} />
        </div>
      </header>

      <Stepper active={active} onSelect={setActive} doneSet={doneSet} />

      {IS_LOCAL_APP && viewingRev && (
        <div style={{ background: "rgba(245,200,66,.12)", borderBottom: "1px solid var(--gold)", padding: "10px 28px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
          <span style={{ fontSize: 12, color: "var(--text)" }}>
            You're viewing a saved version{viewingRev.label ? ` — "${viewingRev.label}"` : ""}. This is read-only; edits won't be saved.
          </span>
          <div style={{ display: "flex", gap: 8 }}>
            {role !== "viewer" && <button className="btn btn-primary btn-sm" onClick={() => restoreRevision(viewingRev)}>Restore this version</button>}
            {role !== "viewer" && <button className="btn btn-warn btn-sm" onClick={() => deleteRevision(viewingRev)}>Delete this version</button>}
            <button className="btn btn-secondary btn-sm" onClick={reloadWorking}>Back to current</button>
          </div>
        </div>
      )}

      <main style={{ maxWidth: 1080, margin: "0 auto", padding: "24px 28px 80px" }}>
        {IS_LOCAL_APP && revOpen && !viewingRev && (
          <VersionPanel projectId={projectId} currentState={session} canEdit={role !== "viewer"}
            onView={viewRevision} onRestore={restoreRevision} onDelete={() => {}} onClose={() => setRevOpen(false)} onSaved={() => { dirtyRef.current = false; }} />
        )}
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 18 }}>
          <div>
            <div style={{ fontSize: 10, color: "var(--muted)", fontFamily: "var(--font-mono)", letterSpacing: ".1em" }}>
              STEP {STAGES[idx].n} / {STAGES.length}{STAGES[idx].ai ? " · AI-ASSISTED" : ""}
            </div>
            <h1 style={{ fontSize: 22, fontWeight: 800 }}>{STAGES[idx].label}</h1>
          </div>
          <div style={{ display: "flex", gap: 8 }}>
            <button className="btn btn-secondary btn-sm" disabled={idx === 0} onClick={() => setActive(STAGES[idx - 1].id)}>← Prev</button>
            <button className="btn btn-secondary btn-sm" disabled={idx === STAGES.length - 1} onClick={() => setActive(STAGES[idx + 1].id)}>Next →</button>
          </div>
        </div>
        {stage}
      </main>
    </div>
  );
}


ReactDOM.createRoot(document.getElementById('root')).render(
  React.createElement(React.Fragment, null, React.createElement(App), React.createElement(BillingGate))
);
