/* global React, ReactDOM, SNAPPY_DATA */
const { useState, useEffect, useMemo, useCallback } = React;

function App() {
  const data = window.SNAPPY_DATA;
  const S = window.useSnappyState();
  const [theme, setTheme] = window.useTheme();
  const { route, page, arg, setRoute } = window.useHashRoute();
  // `view` comes from the server — "home" when URL is /, "skills" when /skills.
  // It only gates the default landing when no hash route is active.
  const initialView = (typeof window !== "undefined" && window.SNAPPY_INITIAL_VIEW) || "home";
  const activeTab = useMemo(() => {
    if (page === "skill") return "Skills";
    if (page === "home") return "Home";
    // When there's no hash route, fall back to the server-sent view so the
    // tab highlighting matches the URL path (/ vs /skills).
    if (page === "skills" && !window.location.hash) return initialView === "skills" ? "Skills" : "Home";
    return ({home:"Home", skills:"Skills", files:"Files", ai:"AI", settings:"Settings", linter:"Linter", docs:"Docs"})[page] || "Skills";
  }, [page, initialView]);

  const [query, setQuery] = useState("");
  const [lens, setLens] = useState("Importance");
  const [activeCat, setActiveCat] = useState("all");
  const [collapsed, setCollapsed] = useState(() => {
    const s = {};
    data.categories.forEach(c => { s[c.id] = true; });
    return s;
  });
  const [expandedId, setExpandedId] = useState(null);
  const [tweaksOpen, setTweaksOpen] = useState(false);
  const [view, setView] = useState(() => localStorage.getItem("snappy_view") || "list");
  useEffect(() => { localStorage.setItem("snappy_view", view); }, [view]);
  const [bold, setBold] = useState(true);
  const [density, setDensity] = useState(228);

  // user-added skills come from SNAPPY_STATE (shared with Settings + AI)
  const extraSkills = S.installedSkills;
  const [dragOver, setDragOver] = useState(false);
  const [toast, setToast] = useState(null);

  // Tweaks protocol
  useEffect(() => {
    const onMsg = (e) => {
      if (!e.data || typeof e.data !== "object") return;
      if (e.data.type === "__activate_edit_mode") setTweaksOpen(true);
      if (e.data.type === "__deactivate_edit_mode") setTweaksOpen(false);
    };
    window.addEventListener("message", onMsg);
    try { window.parent.postMessage({ type: "__edit_mode_available" }, "*"); } catch(e){}
    return () => window.removeEventListener("message", onMsg);
  }, []);

  useEffect(() => { document.documentElement.classList.toggle("bold", bold); }, [bold]);
  useEffect(() => { document.documentElement.style.setProperty("--grid-min", density + "px"); }, [density]);

  // scroll to top on route change
  useEffect(() => { window.scrollTo({ top: 0 }); setExpandedId(null); }, [page, arg]);

  // merged skills = data + extras (respect freshMode — hide canonical skills)
  const canonicalSkills = useMemo(() => (
    S.freshMode
      ? data.skills.filter(s => ["bootstrap","gateway","settings","os","ops"].includes(s.id))
      : data.skills
  ), [data.skills, S.freshMode]);
  const allSkills = useMemo(() => [...canonicalSkills, ...extraSkills], [canonicalSkills, extraSkills]);

  // expose to page components via window (read live)
  window.SNAPPY_DATA.skills = allSkills;

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return allSkills;
    return allSkills.filter(s =>
      s.id.toLowerCase().includes(q) ||
      s.blurb.toLowerCase().includes(q) ||
      s.cat.toLowerCase().includes(q)
    );
  }, [query, allSkills]);

  const grouped = useMemo(() => {
    const m = {};
    filtered.forEach(s => { (m[s.cat] ||= []).push(s); });
    return m;
  }, [filtered]);

  const counts = useMemo(() => {
    const c = {};
    allSkills.forEach(s => { c[s.cat] = (c[s.cat]||0)+1; });
    return c;
  }, [allSkills]);

  const orderedCategories = useMemo(() => {
    const order = ["orchestrator","core","system","ops","integrations","knowledge","content","channels","clients","other","primitives"];
    return order.map(id => data.categories.find(c => c.id === id)).filter(Boolean);
  }, [data.categories]);

  const jumpToCat = useCallback((id) => {
    setActiveCat(id);
    if (id === "all") {
      window.scrollTo({ top: document.getElementById("catalog")?.offsetTop - 120 || 0, behavior: "smooth" });
    } else {
      const el = document.getElementById("cat-" + id);
      if (el) window.scrollTo({ top: el.offsetTop - 110, behavior: "smooth" });
    }
  }, []);

  const toggleCollapsed = useCallback((id) => {
    setCollapsed(s => ({ ...s, [id]: !s[id] }));
  }, []);

  // =====================================================================
  // Drag-drop: markdown files OR integration tile OR URL → install skill
  // =====================================================================
  useEffect(() => {
    const onDragOver = (e) => {
      e.preventDefault();
      // Integration tile drags have their own per-card drop target (.drop-hot);
      // the full-page .md overlay should only fire for actual file drags.
      const types = e.dataTransfer && e.dataTransfer.types;
      if (types && [].indexOf.call(types, "application/snappy-integration") >= 0) return;
      setDragOver(true);
    };
    const onDragLeave = (e) => {
      if (e.relatedTarget === null || e.clientX <= 0 || e.clientY <= 0) setDragOver(false);
    };
    const onDrop = async (e) => {
      e.preventDefault();
      setDragOver(false);
      if (page !== "skills" && page !== "files") return;

      // 1) integration tile from gallery
      const intId = e.dataTransfer.getData("application/snappy-integration");
      if (intId) {
        const r = S.installIntegration(intId);
        if (r.ok) {
          setToast({ kind:"add", msg: `Installed ${intId} · env keys added to Settings` });
          setTimeout(()=>setToast(null), 3500);
        } else {
          setToast({ kind:"warn", msg: `${intId} is already installed` });
          setTimeout(()=>setToast(null), 2500);
        }
        return;
      }

      // 2) markdown files
      const files = Array.from(e.dataTransfer.files || []).filter(f => /\.md$/i.test(f.name));
      if (files.length) {
        const added = [];
        for (const f of files) {
          const text = await f.text();
          const sk = S.parseMarkdownSkill(text, f.name);
          const r = S.installSkill(sk);
          if (r.ok) added.push(sk);
        }
        if (added.length) {
          const keys = added.flatMap(s => s.env_keys || []);
          const keyHint = keys.length ? ` · ${keys.length} env key${keys.length>1?"s":""} added to Settings` : "";
          setToast({ kind:"add", msg: `Added ${added.length} skill${added.length>1?"s":""}: ${added.map(a=>a.id).join(", ")}${keyHint}` });
          setTimeout(()=>setToast(null), 4000);
        }
        return;
      }

      // 3) URL / plain text (simulate AI conversion)
      const txt = e.dataTransfer.getData("text/plain") || "";
      if (/^https?:\/\//i.test(txt.trim())) {
        const url = txt.trim();
        const name = url.replace(/^https?:\/\//,"").split(/[\/?#]/)[0].replace(/\./g,"-");
        const stub = `---\ntrigger: ${name}\ncategory: integrations\nsource: url-import\nenv_keys: [${name.toUpperCase().replace(/-/g,"_")}_KEY]\n---\n# ${name}\n\nImported from ${url}. Edit this stub to wire real steps.\n`;
        const sk = S.parseMarkdownSkill(stub, name + ".md");
        sk.blurb = `AI-imported stub from ${url}`;
        S.installSkill(sk);
        setToast({ kind:"add", msg: `URL → skill stub: ${sk.id} · edit the markdown to finish wiring` });
        setTimeout(()=>setToast(null), 4500);
      }
    };
    window.addEventListener("dragover", onDragOver);
    window.addEventListener("dragleave", onDragLeave);
    window.addEventListener("drop", onDrop);
    return () => {
      window.removeEventListener("dragover", onDragOver);
      window.removeEventListener("dragleave", onDragLeave);
      window.removeEventListener("drop", onDrop);
    };
  }, [page, S]);

  const removeSkill = (id) => {
    if (!extraSkills.find(s => s.id === id)) {
      setToast({ kind:"warn", msg: `Canonical skill "${id}" cannot be removed from the prototype.` });
      setTimeout(()=>setToast(null), 3000);
      return;
    }
    S.uninstallSkill(id);
    setToast({ kind:"remove", msg: `Removed ${id}.md from the catalog.` });
    setTimeout(()=>setToast(null), 3000);
  };

  // =====================================================================
  // Render
  // =====================================================================
  // `showHomeOnly` and `showGrid` are hoisted so JSX below the renderPage
  // call can gate the IntegrationRail + drop overlay on the actual view.
  // Home must be chrome-light: no rail, no drop overlay — that's the whole
  // point of letting the home flow into /skills.
  const addSkill = (sk) => {
    S.installSkill(sk);
    setToast({kind:"add", msg:`Added ${sk.id} to catalog.`});
    setTimeout(()=>setToast(null), 3000);
  };
  const showHomeOnly = page === "home" || (page === "skills" && !window.location.hash && initialView === "home");
  const showGrid = page === "skills" && !showHomeOnly;
  const showFiles = page === "files";

  const renderPage = () => {
    if (page === "skill" && arg) {
      return <window.SkillDetailPage id={arg} setRoute={setRoute}/>;
    }
    if (page === "ai")       return <window.AIPage/>;
    if (page === "settings") return <window.SettingsPage/>;
    if (page === "linter")   return <window.LinterPage/>;
    if (page === "docs")     return <DocsCombined/>;
    if (page === "files")    return <FilesPage allSkills={allSkills} canonicalSkills={canonicalSkills} extraSkills={extraSkills} onRemove={removeSkill} setRoute={setRoute} query={query} onQuery={setQuery}/>;
    if (showHomeOnly) {
      return (
        <InstallHero totals={data.totals} extras={extraSkills.length} onAdd={addSkill} goToAI={() => setRoute("ai")} user={data.user}/>
      );
    }
    // skills — the grid. Reached via /skills (initialView=skills) or the Skills tab hash.
    return (
      <>
        <section className="section" id="catalog" style={{paddingTop: 28}}>
          <div className="container">
            <ActionHub onAdd={addSkill} onSearch={setQuery} allSkills={allSkills} goToAI={() => setRoute("ai")}/>
            <Controls query={query} onQuery={setQuery} lens={lens} onLens={setLens} view={view} onView={setView} totalCount={allSkills.length}/>
            <CategoryRail categories={orderedCategories} counts={counts} active={activeCat} onJump={jumpToCat} totals={{...data.totals, skills: allSkills.length}}/>
            <div className="legend" style={{marginBottom:18}}>
              <span className="legend-item"><span className="legend-dot" style={{background:"oklch(0.62 0.15 145)"}}/>Healthy</span>
              <span className="legend-item"><span className="legend-dot" style={{background:"oklch(0.72 0.14 70)"}}/>Stale</span>
              <span className="legend-item"><span className="legend-dot" style={{background:"oklch(0.60 0.20 28)"}}/>Issues</span>
              <span className="legend-item"><span className="legend-dot" style={{background:"var(--muted-foreground)", opacity:0.6}}/>No data</span>
              <span style={{marginLeft:"auto", fontSize:11.5, color:"var(--muted-foreground)"}}>
                {allSkills.length} skills · {query ? `${filtered.length} match "${query}"` : `drag a .md file anywhere to add`}
              </span>
            </div>
            {orderedCategories.map(cat => (
              grouped[cat.id] && grouped[cat.id].length ? (
                <CategorySectionWithExpand
                  key={cat.id} cat={cat} skills={grouped[cat.id]}
                  lens={lens}
                  view={view}
                  expandedId={expandedId}
                  onOpen={(id) => setExpandedId(prev => prev === id ? null : id)}
                  onOpenFull={(id) => setRoute("skill/" + id)}
                  onRemove={removeSkill}
                  allSkills={allSkills}
                  collapsed={query ? false : collapsed[cat.id]}
                  onToggle={toggleCollapsed}/>
              ) : null
            ))}
            {query && filtered.length === 0 && (
              <div style={{padding:"40px 0", textAlign:"center", color:"var(--muted-foreground)"}}>
                No skills match "<strong>{query}</strong>".
              </div>
            )}
          </div>
        </section>
        <LandingFooter/>
      </>
    );
  };

  return (
    <>
      <Nav theme={theme} onTheme={setTheme} activeTab={activeTab} onTab={(t) => setRoute(t.toLowerCase())} user={data.user}/>
      <main>
        {renderPage()}
        {page !== "skills" && page !== "files" && (
          <div className="footer">
            <div className="footer-meta">
              {allSkills.length} skills &nbsp;·&nbsp; <a className="footer-link" href="#/docs">Docs</a> &nbsp;·&nbsp; <a className="footer-link" href="https://github.com" target="_blank" rel="noreferrer">GitHub</a> &nbsp;·&nbsp; Built by Robert Boulos
            </div>
          </div>
        )}
      </main>

      {dragOver && (showGrid || showFiles) && <DropOverlay/>}
      {toast && <Toast t={toast}/>}

      {(showGrid || showFiles) && <window.IntegrationRail/>}

      <TweaksPanel open={tweaksOpen} bold={bold} setBold={setBold} density={density} setDensity={setDensity} lens={lens} setLens={setLens} theme={theme} setTheme={setTheme}/>
    </>
  );
}

// =======================================================================
// INSTALL HERO — npx install + "how did you get here?" branching onboarding
// =======================================================================
function InstallHero({ totals, extras, onAdd, goToAI, user }) {
  const [cmd, setCmd] = useState("pull"); // pull | install | doctor
  const [copied, setCopied] = useState(false);
  const commands = {
    install: "npx snappy-os install",
    pull:    "npx snappy-os pull --auto",
    doctor:  "npx snappy-os doctor",
  };
  const copy = () => {
    navigator.clipboard?.writeText(commands[cmd]).catch(()=>{});
    setCopied(true); setTimeout(()=>setCopied(false), 1400);
  };
  // Which lane applies to the visitor right now? The "detected" card is
  // highlighted but every card always shows its full summary — no tabs.
  const currentLane = "installed"; // this session has hooks wired; preserved for styling

  return (
    <section className="install-hero">
      <div className="container">
        <div className="ih-top">
          <div className="ih-lede">
            <div className="eyebrow accent mono">SKILLS · {totals.skills + extras} CATALOGUED{user && user.name && user.name !== "Guest" ? " · SIGNED IN AS " + user.name.toUpperCase() : " · BROWSING AS GUEST"}</div>
            <h1 className="h1 ih-title">Treat markdown like code.</h1>
            <p className="lead ih-sub">
              A skill is one <code>.md</code> file with YAML frontmatter &mdash; the Anthropic spec. Optional
              siblings: <code>api.ts</code> for the library, <code>state/bin/</code> for scripts,
              <code>.agents.md</code> for the loader. Run one as-is on any compatible CLI, or take the full tree
              for self-healing loaders and a shared feedback ledger.
            </p>
          </div>

          <div className="ih-terminal">
            <div className="ih-term-tabs">
              {[
                { k:"install", label:"install"   },
                { k:"pull",    label:"pull"      },
                { k:"doctor",  label:"doctor"    },
              ].map(t => (
                <button key={t.k} className={"ih-term-tab"+(cmd===t.k?" active":"")} onClick={()=>setCmd(t.k)}>{t.label}</button>
              ))}
              <span className="ih-term-dot" title="macOS · linux · wsl"/>
              <span className="ih-term-caption mono">macOS · linux · wsl</span>
            </div>
            <div className="ih-term-row">
              <span className="ih-term-prompt mono">$</span>
              <span className="ih-term-cmd mono">{commands[cmd]}</span>
              <button className="ih-term-copy" onClick={copy} title="Copy">
                {copied ? <Icon name="check" size={14}/> : <Icon name="copy" size={14}/>}
              </button>
            </div>
            <div className="ih-term-foot mono">
              <span>no flags · picks up every runtime already on this machine</span>
              <a className="ih-term-link" href="#/docs">what does it do? →</a>
            </div>
          </div>
        </div>

      </div>
    </section>
  );
}

// =======================================================================
// ACTION HUB — sits at top of catalog. "Add OR select." Drop / paste URL / paste markdown.
// =======================================================================
function ActionHub({ onAdd, onSearch, allSkills, goToAI }) {
  const [mode, setMode] = useState("drop");       // drop | url | paste | manual
  const [url, setUrl] = useState("");
  const [text, setText] = useState("");
  const [state, setState] = useState("idle");     // idle | fetching | converting | parsed | error
  const [parsed, setParsed] = useState(null);
  const [aiLog, setAiLog] = useState([]);
  const [dragOver, setDragOver] = useState(false);
  const fileRef = useRef(null);

  const reset = () => { setState("idle"); setParsed(null); setAiLog([]); setUrl(""); setText(""); };

  const parseMd = (raw, filename="pasted.md") => {
    const fm = /^---\s*([\s\S]*?)---/.exec(raw);
    const meta = {};
    if (fm) fm[1].split("\n").forEach(l => {
      const m = /^\s*([\w-]+)\s*:\s*(.+)$/.exec(l);
      if (m) meta[m[1]] = m[2].replace(/^["']|["']$/g,"").trim();
    });
    const body = fm ? raw.slice(fm[0].length) : raw;
    const title = /^#\s+(.+)$/m.exec(body);
    const firstPara = body.replace(/^\s*#[^\n]*\n+/, "").split("\n\n")[0].replace(/\n/g," ").slice(0, 220);
    const id = (meta.trigger || meta.name || (title && title[1]) || filename.replace(/\.(agents\.)?md$/i,"")).toLowerCase().replace(/[^a-z0-9-]/g,"-").slice(0,40);
    const cat = meta.category || "other";
    const desc = meta.description || firstPara;
    const chips = {
      slugValid: /^[a-z][a-z0-9-]{1,38}[a-z0-9]$/.test(id),
      slugFree:  !allSkills.some(s => s.id === id),
      hasTitle:  !!(meta.name || (title && title[1]) || id),
      hasDesc:   !!desc,
      bodyOk:    body.length >= 50,
    };
    return { id, cat, desc, body, raw, trigger: meta.trigger || id.replace(/-/g," "),
             preview: firstPara, chips,
             rationale: `closest siblings in ${cat}: ${allSkills.filter(s => s.cat === cat).slice(0,3).map(s => s.id).join(", ") || "none yet"}` };
  };

  const convertWithAI = async (sourceText, sourceLabel) => {
    setState("converting");
    setAiLog([
      { t: `Fetched ${sourceLabel}` },
      { t: `Reading framework: program.md + SKILL.md schema` },
      { t: `Extracting intent + distilling to markdown…` },
    ]);
    // Stream-like delay for visual plausibility
    await new Promise(r => setTimeout(r, 500));
    setAiLog(a => [...a, { t: `Generating frontmatter (trigger, category, depends_on)` }]);
    await new Promise(r => setTimeout(r, 500));
    setAiLog(a => [...a, { t: `Writing Steps + Gotchas + Eval sections` }]);

    // Try real Claude if available; otherwise synth a plausible stub.
    let generated;
    try {
      if (window.claude?.complete) {
        const short = sourceText.slice(0, 2000);
        generated = await window.claude.complete(
`You are converting a source document into a Snappy Skill markdown file.
The source: """${short}"""

Output a valid SKILL.md with frontmatter. Schema:
---
trigger: <2-3 word trigger phrase>
category: <orchestrator|core|system|ops|integrations|knowledge|content|channels|clients>
description: <one sentence>
depends_on: [settings]
---
# <slug>

<one-paragraph summary>

## Steps
1. ...
2. ...

## Gotchas
- ...

## Eval
- shape: {status, ms, score}
- threshold: 0.8

Respond with ONLY the markdown. No preamble.`);
      }
    } catch (e) { /* fall through to stub */ }

    if (!generated || !generated.startsWith("---")) {
      const slug = (sourceLabel.split("/").pop() || "new-skill").replace(/\W+/g,"-").toLowerCase().slice(0,30);
      generated = `---
trigger: ${slug.replace(/-/g," ").slice(0,20)}
category: other
description: Imported from ${sourceLabel}
depends_on: [settings]
---
# ${slug}

Generated from ${sourceLabel}. Review the steps below — the AI distilled the source into the framework shape; you're the final editor.

## Steps
1. Parse the inbound payload.
2. Call the provider API with env("KEY").
3. Emit a receipt row.

## Gotchas
- Actor ≠ auditor. Pair a different model for grading.
- Timeouts default to 30s; bump if the provider is slow.

## Eval
- shape: { status, ms, score }
- threshold: 0.8
`;
    }
    setAiLog(a => [...a, { t: `✓ Generated ${generated.length} chars · shape-checked` }]);
    setParsed(parseMd(generated, sourceLabel));
    setState("parsed");
  };

  const onFile = async (f) => {
    if (!f) return;
    const raw = await f.text();
    const p = parseMd(raw, f.name);
    const allGreen = Object.values(p.chips).every(Boolean);
    if (allGreen) {
      setParsed(p); setState("parsed");
      setAiLog([{ t: `Parsed ${f.name}` }, { t: `✓ Frontmatter + body pass the framework` }]);
    } else {
      // AI repair path
      setAiLog([{ t: `Parsed ${f.name} — doesn't fully fit the framework` },
                { t: `Offering AI repair…` }]);
      setState("converting");
      await convertWithAI(raw, f.name);
    }
  };

  const onUrl = async () => {
    if (!url) return;
    setState("fetching");
    setAiLog([{ t: `Fetching ${url}…` }]);
    // Can't actually fetch cross-origin; synthesize a realistic source snippet
    await new Promise(r => setTimeout(r, 600));
    const host = (url.match(/\/\/([^/]+)/)||[])[1] || "unknown";
    const source = `Page at ${url}\n\nContent from ${host} (simulated fetch in prototype). Key concepts extracted would include: authentication, rate limits, primary endpoints, example payloads, error semantics.`;
    await convertWithAI(source, url);
  };

  const onPaste = async () => {
    if (!text.trim()) return;
    // If it already looks like a skill file, parse directly; otherwise convert.
    if (/^---/.test(text.trim())) {
      const p = parseMd(text, "pasted.md");
      const allGreen = Object.values(p.chips).every(Boolean);
      if (allGreen) { setParsed(p); setState("parsed"); setAiLog([{t:"Parsed pasted markdown · fits framework"}]); return; }
    }
    await convertWithAI(text, "pasted text");
  };

  const publish = () => {
    if (!parsed) return;
    onAdd({
      id: parsed.id, cat: parsed.cat,
      blurb: parsed.desc,
      health: "green", age: "today",
      size: (parsed.raw.length/1024).toFixed(1)+" KB",
      evals: null, fanout:0, fanin:0, imp:55,
      icon: "✚", userAdded: true,
    });
    reset();
  };

  const allGreen = parsed && Object.values(parsed.chips).every(Boolean);

  return (
    <div className="hub">
      <div className="hub-head">
        <div className="hub-title-row">
          <div>
            <div className="eyebrow accent mono">ACTION HUB</div>
            <h2 className="h2 hub-title">Add a skill, or pick one below.</h2>
          </div>
          <div className="hub-count mono">
            <span className="hub-count-n">{allSkills.length}</span>
            <span className="hub-count-l">in catalog</span>
          </div>
        </div>
        <div className="hub-modes">
          {[
            { k:"drop",   icon:"▤", label:"Drop a .md",     sub:"file or folder" },
            { k:"url",    icon:"◎", label:"Paste a URL",    sub:"docs page, repo, article" },
            { k:"paste",  icon:"▥", label:"Paste markdown", sub:"from clipboard" },
            { k:"manual", icon:"✚", label:"Write from scratch", sub:"empty template" },
          ].map(m => (
            <button key={m.k} className={"hub-mode"+(mode===m.k?" active":"")} onClick={()=>{ setMode(m.k); reset(); }}>
              <span className="hub-mode-icon mono">{m.icon}</span>
              <div className="hub-mode-body">
                <div className="hub-mode-l">{m.label}</div>
                <div className="hub-mode-s">{m.sub}</div>
              </div>
            </button>
          ))}
        </div>
      </div>

      {state === "idle" && (
        <div className="hub-surface">
          {mode === "drop" && (
            <div className={"hub-drop"+(dragOver?" over":"")}
                 onDragOver={e => { e.preventDefault(); setDragOver(true); }}
                 onDragLeave={() => setDragOver(false)}
                 onDrop={e => { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files[0]; if (f) onFile(f); }}>
              <div className="hub-drop-inner">
                <div className="hub-drop-glyph" aria-hidden="true">
                  <svg width="56" height="56" viewBox="0 0 56 56" fill="none">
                    <rect x="10" y="8" width="28" height="36" rx="3" stroke="currentColor" strokeWidth="1.5"/>
                    <path d="M38 8l8 8v28a3 3 0 0 1-3 3H22" stroke="currentColor" strokeWidth="1.5"/>
                    <path d="M17 20h14M17 26h14M17 32h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
                  </svg>
                </div>
                <div className="hub-drop-title">Drop a <code>.md</code> file here</div>
                <div className="hub-drop-sub">Or <button className="hub-link" onClick={()=>fileRef.current?.click()}>browse</button> — frontmatter is parsed; if it doesn't fit the framework, the AI offers a rewrite.</div>
                <input ref={fileRef} type="file" accept=".md,text/markdown" hidden onChange={e => onFile(e.target.files[0])}/>
              </div>
              <div className="hub-drop-footnote mono">
                <span>accepts: SKILL.md · AGENTS.md · any .md with or without frontmatter</span>
                <span className="muted">max 64 KB · stays in your browser until Publish</span>
              </div>
            </div>
          )}
          {mode === "url" && (
            <div className="hub-form">
              <div className="hub-form-row">
                <span className="hub-form-prefix mono">https://</span>
                <input className="hub-form-input mono" placeholder="stripe.com/docs/api · raw.githubusercontent.com/… · linkedin.com/pulse/…"
                       value={url} onChange={e=>setUrl(e.target.value.replace(/^https?:\/\//,""))}/>
                <button className="btn btn-primary btn-sm" onClick={onUrl} disabled={!url}>
                  Fetch & convert <Icon name="arrow-right" size={12}/>
                </button>
              </div>
              <div className="hub-form-hint">
                Any markdown source works. The AI (your OpenRouter key) reads the page and writes a <code>SKILL.md</code>
                that matches the framework. You review before it lands.
              </div>
              <div className="hub-examples">
                <span className="muted mono">try:</span>
                {[
                  "stripe.com/docs/payments/quickstart",
                  "raw.githubusercontent.com/anthropics/anthropic-sdk-typescript/main/README.md",
                  "platform.openai.com/docs/guides/prompt-engineering",
                ].map(ex => (
                  <button key={ex} className="hub-example" onClick={()=>setUrl(ex)}>{ex}</button>
                ))}
              </div>
            </div>
          )}
          {mode === "paste" && (
            <div className="hub-form">
              <textarea className="hub-textarea mono" rows={6} placeholder={"---\nname: my-skill\ndescription: ...\n---\n\n# my-skill\n\nBody..."} value={text} onChange={e=>setText(e.target.value)}/>
              <div className="hub-form-actions">
                <span className="muted" style={{fontSize:12}}>
                  If it's already a SKILL.md, it lands as-is. If not, the AI converts it first.
                </span>
                <button className="btn btn-primary btn-sm" onClick={onPaste} disabled={!text.trim()}>
                  Parse / convert <Icon name="arrow-right" size={12}/>
                </button>
              </div>
            </div>
          )}
          {mode === "manual" && (
            <div className="hub-form">
              <div className="hub-form-hint" style={{marginBottom:10}}>
                Scaffolds the minimum viable SKILL.md. You edit inline, then publish.
              </div>
              <button className="btn btn-primary btn-sm" onClick={() => {
                setText(`---
trigger: new skill
category: other
description: Replace me.
depends_on: [settings]
---
# new-skill

One-paragraph summary of what this skill does and when it triggers.

## Steps
1.
2.

## Gotchas
-

## Eval
- shape: { status, ms, score }
- threshold: 0.8
`);
                setMode("paste");
              }}>Scaffold an empty SKILL.md</button>
            </div>
          )}
        </div>
      )}

      {(state === "fetching" || state === "converting") && (
        <div className="hub-surface hub-working">
          <div className="hub-working-head">
            <span className="ai-pulse"/>
            <div>
              <div className="hub-working-t">AI is shaping this into a skill.</div>
              <div className="hub-working-s mono">claude-sonnet-4.6 · your OpenRouter key · read-only on source</div>
            </div>
          </div>
          <div className="hub-working-log">
            {aiLog.map((l, i) => <div key={i} className="ail mono">→ {l.t}</div>)}
          </div>
        </div>
      )}

      {state === "parsed" && parsed && (
        <div className="hub-surface hub-parsed">
          <div className="hub-parsed-grid">
            <div className="hub-parsed-preview">
              <div className="mono" style={{fontSize:13, color:"var(--muted-foreground)", marginBottom:6}}>PROPOSED SKILL</div>
              <div style={{display:"flex", alignItems:"center", gap:10, flexWrap:"wrap"}}>
                <span className="skill-icon" style={{fontSize:15}}>✚</span>
                <span className="mono" style={{fontSize:16, fontWeight:600}}>{parsed.id}</span>
                <span className={"cat-chip cat-"+parsed.cat}>{parsed.cat}</span>
                <span className="trigger-chip"><span className="mono" style={{fontSize:10, color:"var(--muted-foreground)", marginRight:6}}>TRIGGER</span><span className="mono">{parsed.trigger}</span></span>
              </div>
              <p style={{margin:"10px 0 0", fontSize:13.5, color:"var(--muted-foreground)", lineHeight:1.55}}>{parsed.desc}</p>
              <pre className="hub-parsed-body">{parsed.raw.slice(0, 600)}{parsed.raw.length>600?"\n…":""}</pre>
            </div>
            <div className="hub-parsed-gates">
              <div className="mono" style={{fontSize:12, color:"var(--muted-foreground)", marginBottom:8}}>FRAMEWORK FIT</div>
              {[
                { k:"slugValid", lbl:"slug valid",       fix:"a-z, digits, hyphens; 3-40 chars" },
                { k:"slugFree",  lbl:"slug free",        fix:"that slug is already in the catalog" },
                { k:"hasTitle",  lbl:"has title",        fix:"add # H1 or frontmatter.name" },
                { k:"hasDesc",   lbl:"has description",  fix:"add frontmatter.description" },
                { k:"bodyOk",    lbl:"body ≥ 50 chars",  fix:"body is too short to splice" },
              ].map(g => {
                const ok = parsed.chips[g.k];
                return (
                  <div key={g.k} className={"hub-gate "+(ok?"g-ok":"g-fail")}>
                    <span className="hub-gate-mark">{ok?"✓":"✕"}</span>
                    <span className="hub-gate-lbl">{g.lbl}</span>
                    {!ok && <span className="hub-gate-fix">— {g.fix}</span>}
                  </div>
                );
              })}
              <div className="hub-parsed-actions">
                <button className="btn btn-outline btn-sm" onClick={reset}>Start over</button>
                <button className="btn btn-primary btn-sm" onClick={publish} disabled={!allGreen}>
                  Publish to catalog
                </button>
              </div>
              <div className="muted" style={{fontSize:11, marginTop:8, lineHeight:1.5}}>{parsed.rationale}</div>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

// =======================================================================
// Membrane drop zone — full-width, bottom of landing
// =======================================================================
function MembraneDropZone({ onAdd }) {
  const [state, setState] = useState("idle"); // idle | parsing | parsed
  const [mode, setMode] = useState("file");   // file | paste | url
  const [text, setText] = useState("");
  const [url, setUrl] = useState("");
  const [parsed, setParsed] = useState(null);
  const [overrideCat, setOverrideCat] = useState(null);
  const fileRef = useRef(null);

  const parseMd = (raw, filename="pasted.md") => {
    setState("parsing");
    setTimeout(() => {
      const fm = /^---\s*([\s\S]*?)---/.exec(raw);
      const meta = {};
      if (fm) fm[1].split("\n").forEach(l => {
        const m = /^\s*([\w-]+)\s*:\s*(.+)$/.exec(l);
        if (m) meta[m[1]] = m[2].replace(/^["']|["']$/g,"").trim();
      });
      const body = fm ? raw.slice(fm[0].length) : raw;
      const title = /^#\s+(.+)$/m.exec(body);
      const firstPara = body.replace(/^\s*#[^\n]*\n+/, "").split("\n\n")[0].replace(/\n/g," ").slice(0, 200);
      const id = (meta.trigger || meta.name || (title && title[1]) || filename.replace(/\.(agents\.)?md$/i,"")).toLowerCase().replace(/[^a-z0-9-]/g,"-").slice(0,40);
      const cat = meta.category || "other";
      const desc = meta.description || firstPara;
      setParsed({
        id, cat, desc, body, raw,
        trigger: meta.trigger || id.replace(/-/g," "),
        preview: firstPara,
        chips: {
          slugValid: /^[a-z][a-z0-9-]{1,38}[a-z0-9]$/.test(id),
          slugFree:  !window.SNAPPY_DATA.skills.some(s => s.id === id),
          hasTitle:  !!(meta.name || (title && title[1]) || id),
          hasDesc:   !!desc,
          bodyOk:    body.length >= 50,
        },
        rationale: `closest siblings: ${window.SNAPPY_DATA.skills.filter(s => s.cat === cat).slice(0,3).map(s => s.id).join(", ") || "none in that band"}`,
      });
      setState("parsed");
    }, 350);
  };

  const onFile = async (f) => {
    if (!f) return;
    const t = await f.text();
    parseMd(t, f.name);
  };

  const allGreen = parsed && Object.values(parsed.chips).every(Boolean);
  const reset = () => { setState("idle"); setParsed(null); setText(""); setUrl(""); setOverrideCat(null); };

  return (
    <section className="section" id="membrane">
      <div className="container">
        <div className="eyebrow accent mono">MEMBRANE</div>
        <h2 className="h2" style={{margin:"6px 0 14px", fontSize:22}}>Add a skill.</h2>
        <div className={"membrane " + (state !== "idle" ? "populated" : "")}
             onDragOver={e => { e.preventDefault(); }}
             onDrop={e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) onFile(f); }}>
          {state === "idle" && (
            <div className="membrane-idle">
              <div className="membrane-prompt">
                Drop a <code>.md</code> file, paste markdown, or paste a URL.
              </div>
              <div className="membrane-modes">
                <button className={"lens-btn"+(mode==="file"?" active":"")}   onClick={()=>setMode("file")}>file</button>
                <button className={"lens-btn"+(mode==="paste"?" active":"")}  onClick={()=>setMode("paste")}>paste markdown</button>
                <button className={"lens-btn"+(mode==="url"?" active":"")}    onClick={()=>setMode("url")}>paste a URL</button>
              </div>
              {mode === "file" && (
                <>
                  <input ref={fileRef} type="file" accept=".md,text/markdown" style={{display:"none"}} onChange={e => onFile(e.target.files[0])}/>
                  <button className="btn btn-outline" onClick={() => fileRef.current.click()}>Pick a .md file</button>
                </>
              )}
              {mode === "paste" && (
                <div style={{width:"min(720px, 100%)", display:"flex", flexDirection:"column", gap:8}}>
                  <textarea className="input" rows={5} placeholder="---&#10;name: my-skill&#10;description: ...&#10;---" value={text} onChange={e=>setText(e.target.value)}/>
                  <button className="btn btn-primary btn-sm" onClick={() => parseMd(text || "")} disabled={!text.trim()} style={{alignSelf:"flex-end"}}>Parse</button>
                </div>
              )}
              {mode === "url" && (
                <div style={{display:"flex", gap:8, alignItems:"center"}}>
                  <input className="input" style={{width:380}} placeholder="https://raw.githubusercontent.com/…" value={url} onChange={e=>setUrl(e.target.value)}/>
                  <button className="btn btn-primary btn-sm" onClick={() => parseMd(`---\nname: ${url.split("/").pop().replace(".md","")}\ndescription: Imported from URL\n---\n# ${url.split("/").pop()}\n\nImported body placeholder for demo.`)}>Fetch</button>
                </div>
              )}
              <a className="footer-link" style={{marginTop:10, fontSize:12}} href="#" onClick={e=>{e.preventDefault(); setMode("paste");}}>Type manually instead</a>
            </div>
          )}
          {state === "parsing" && (
            <div className="membrane-parsing">
              <div className="skeleton" style={{height:18, width:220, marginBottom:10}}/>
              <div className="skeleton" style={{height:14, width:380, marginBottom:8}}/>
              <div className="skeleton" style={{height:14, width:320}}/>
            </div>
          )}
          {state === "parsed" && parsed && (
            <div className="membrane-parsed">
              <div className="mp-preview">
                <div className="mono" style={{fontSize:14, fontWeight:600}}>{parsed.id}</div>
                <div className="muted" style={{fontSize:13, marginTop:4}}>{parsed.desc}</div>
                <div style={{marginTop:10, display:"flex", gap:8, alignItems:"center", flexWrap:"wrap"}}>
                  <span className="trigger-chip"><span className="mono" style={{fontSize:10, color:"var(--muted-foreground)", marginRight:6}}>TRIGGER</span><span className="mono">{parsed.trigger}</span></span>
                  <span className="muted mono" style={{fontSize:11}}>first 200 chars:</span>
                </div>
                <div className="membrane-body-preview">{parsed.preview || <em>(empty body)</em>}</div>
              </div>
              <div className="mp-class">
                <div className="eyebrow mono" style={{fontSize:10}}>PROPOSED CATEGORY</div>
                <select className="input btn-sm" style={{marginTop:4, width:"100%", height:32}} value={overrideCat || parsed.cat} onChange={e=>setOverrideCat(e.target.value)}>
                  {window.SNAPPY_DATA.categories.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
                </select>
                <div className="muted" style={{fontSize:11.5, marginTop:6, lineHeight:1.5}}>{parsed.rationale}</div>
                <div className="mp-gates">
                  {[
                    { k:"slugValid", lbl:"slug valid",       fix:"a-z, digits, hyphens; 3-40 chars" },
                    { k:"slugFree",  lbl:"slug free",        fix:"that slug is already in the catalog" },
                    { k:"hasTitle",  lbl:"has title",        fix:"add a # H1 or frontmatter.name" },
                    { k:"hasDesc",   lbl:"has description",  fix:"add frontmatter.description" },
                    { k:"bodyOk",    lbl:"body ≥ 50 chars",  fix:"body is too short to splice" },
                  ].map(g => {
                    const ok = parsed.chips[g.k];
                    return (
                      <div key={g.k} className={"mp-gate " + (ok?"g-ok":"g-fail")}>
                        <span className="mp-gate-mark">{ok?"✓":"✕"}</span>
                        <span className="mp-gate-lbl">{g.lbl}</span>
                        {!ok && <span className="mp-gate-fix">— {g.fix}</span>}
                      </div>
                    );
                  })}
                </div>
                <div style={{display:"flex", gap:8, marginTop:12}}>
                  <button className="btn btn-outline btn-sm" onClick={reset}>Cancel</button>
                  <button className="btn btn-primary btn-sm" disabled={!allGreen}
                          onClick={() => {
                            onAdd({
                              id: parsed.id,
                              cat: overrideCat || parsed.cat,
                              blurb: parsed.desc,
                              health: "green", age: "today",
                              size: (parsed.raw.length/1024).toFixed(1)+" KB",
                              evals: null, fanout:0, fanin:0, imp:50,
                              icon:"✚", userAdded: true,
                            });
                            reset();
                          }}>
                    Publish to catalog
                  </button>
                </div>
              </div>
            </div>
          )}
        </div>
      </div>
    </section>
  );
}

// =======================================================================
// Try-it simulator — preview of /ai without running a model
// =======================================================================
function TryItSimulator({ allSkills, onRun }) {
  const [prompt, setPrompt] = useState("generate an image of a whale and post to notion");
  const matches = useMemo(() => {
    const t = prompt.toLowerCase();
    return allSkills.filter(s =>
      t.includes(s.id.replace(/-/g," ")) || t.includes(s.id) ||
      s.id.split("-").some(w => w.length > 3 && t.includes(w))
    ).slice(0, 5);
  }, [prompt, allSkills]);
  return (
    <section className="section" style={{paddingTop:8}}>
      <div className="container">
        <div className="eyebrow accent mono">TRY IT</div>
        <h2 className="h2" style={{margin:"6px 0 4px", fontSize:22}}>Type a prompt. See which loaders would fire.</h2>
        <p className="muted" style={{fontSize:13, margin:"0 0 14px"}}>No model runs — this is a dry preview of the splice step. Click <b>Run it</b> to open the AI lens with this prompt pre-filled.</p>
        <div className="tryit">
          <textarea className="input" rows={2} value={prompt} onChange={e=>setPrompt(e.target.value)} style={{fontFamily:"var(--font-mono)", fontSize:13}}/>
          <div className="tryit-matches">
            {matches.length === 0
              ? <span className="muted" style={{fontSize:12.5}}>No trigger phrases matched. Nothing would be spliced.</span>
              : matches.map(s => (
                <span key={s.id} className="tryit-match">
                  <span style={{fontSize:12}}>{s.icon}</span>
                  <span className="mono" style={{fontSize:12}}>{s.id}.agents.md</span>
                </span>
              ))
            }
            <button className="btn btn-primary btn-sm" style={{marginLeft:"auto"}} onClick={() => onRun(prompt)}>
              <Icon name="play" size={12}/> Run it in /ai
            </button>
          </div>
        </div>
      </div>
    </section>
  );
}

// =======================================================================
// Landing footer — 3 columns
// =======================================================================
function LandingFooter() {
  return (
    <footer className="landing-footer">
      <div className="container landing-footer-grid">
        <div>
          <div className="eyebrow mono">CANONICAL FILES</div>
          <ul className="lf-list">
            <li><a className="footer-link" href="#/docs">program.md</a> <span className="muted" style={{fontSize:11}}>— schema + PID contract</span></li>
            <li><a className="footer-link" href="#/docs">state/index.md</a> <span className="muted" style={{fontSize:11}}>— catalog pointer</span></li>
            <li><a className="footer-link" href="#/docs">CLAUDE.md</a> <span className="muted" style={{fontSize:11}}>— repo wiring</span></li>
          </ul>
        </div>
        <div>
          <div className="eyebrow mono">GATEWAY</div>
          <ul className="lf-list mono" style={{fontSize:12}}>
            <li>GET /.well-known/skills/index.json</li>
            <li>GET /skills/&lt;slug&gt;.json</li>
            <li>POST /_push · POST /_patch-catalog</li>
            <li>POST /ai/analyze · POST /ai/run</li>
          </ul>
        </div>
        <div>
          <div className="nav-brand" style={{marginBottom:10}}>
            <span className="nav-brand-mark">s</span>
            <span className="nav-brand-word">snappy</span>
          </div>
          <p className="muted" style={{fontSize:12, lineHeight:1.55, margin:0, maxWidth:280}}>
            A distribution layer for LLM system-prompt fragments. Blob-backed, CDN-cached, audited by a different model than the one that wrote it.
          </p>
        </div>
      </div>
    </footer>
  );
}

// =======================================================================
// Category section with inline-expand
// =======================================================================
function CategorySectionWithExpand({ cat, skills, lens, view, expandedId, onOpen, onOpenFull, onRemove, allSkills, collapsed, onToggle }) {
  const expandedSkill = skills.find(s => s.id === expandedId);
  const sameCat = expandedSkill ? allSkills.filter(s => s.cat === expandedSkill.cat && s.id !== expandedSkill.id) : [];
  const sorted = useMemo(() => [...skills].sort((a,b) => {
    if (lens === "Importance") return b.imp - a.imp;
    if (lens === "Maturity")   return (b.evals?1:0) - (a.evals?1:0);
    return a.id.localeCompare(b.id);
  }), [skills, lens]);
  const isList = view === "list";
  // Preview 6 rows/cards when collapsed so rows are never empty.
  const previewN = isList ? 5 : 6;
  const visible = collapsed ? sorted.slice(0, previewN) : sorted;
  const hiddenCount = collapsed ? Math.max(0, sorted.length - visible.length) : 0;

  return (
    <section id={"cat-" + cat.id} className={"cat-section" + (isList ? " cat-section-list" : "")}>
      <div className="cat-header">
        <button className="cat-toggle" onClick={() => onToggle(cat.id)} aria-label="Toggle section">
          <Icon name={collapsed ? "chevron-right" : "chevron-down"} size={14}/>
        </button>
        <span className="cat-label mono">{cat.label.toUpperCase()}</span>
        <span className="cat-blurb">{cat.blurb}</span>
        <span className="cat-count mono">{sorted.length}</span>
      </div>

      {isList ? (
        <div className="cat-list">
          <div className="cat-list-headers mono">
            <span className="cl-h-name">NAME</span>
            <span className="cl-h-blurb">WHAT IT DOES</span>
            <span className="cl-h-metric">{lens.toUpperCase()}</span>
            <span className="cl-h-evals">EVALS</span>
            <span className="cl-h-size">SIZE</span>
            <span className="cl-h-age">UPDATED</span>
          </div>
          {visible.map((s) => (
            <React.Fragment key={s.id}>
              <SkillListRow s={s} lens={lens}
                            active={expandedId === s.id}
                            onOpen={() => onOpen(s.id)}
                            onRemove={s.userAdded ? () => onRemove(s.id) : null}/>
              {expandedId === s.id && (
                <div className="cat-list-expanded">
                  <window.SkillExpanded skill={expandedSkill}
                                        sameCategory={sameCat}
                                        onClose={() => onOpen(s.id)}
                                        onOpenFull={onOpenFull}/>
                </div>
              )}
            </React.Fragment>
          ))}
          {hiddenCount > 0 && (
            <button className="cat-list-more mono" onClick={() => onToggle(cat.id)}>
              <span>+ {hiddenCount} more</span>
              <span className="cat-list-more-label">show all {cat.label.toLowerCase()}</span>
            </button>
          )}
        </div>
      ) : (
        <div className="grid">
          {visible.map((s) => (
            <React.Fragment key={s.id}>
              <SkillCard s={s} lens={lens}
                         active={expandedId === s.id}
                         onOpen={() => onOpen(s.id)}
                         onRemove={s.userAdded ? () => onRemove(s.id) : null}/>
              {expandedId === s.id && (
                <div className="grid-expanded-slot">
                  <window.SkillExpanded skill={expandedSkill}
                                       sameCategory={sameCat}
                                       onClose={() => onOpen(s.id)}
                                       onOpenFull={onOpenFull}/>
                </div>
              )}
            </React.Fragment>
          ))}
          {hiddenCount > 0 && (
            <button className="skill skill-showmore" onClick={() => onToggle(cat.id)} aria-label={`Show all ${sorted.length} ${cat.label}`}>
              <div className="showmore-n mono">+{hiddenCount}</div>
              <div className="showmore-l mono">show all<br/>{cat.label.toLowerCase()}</div>
            </button>
          )}
        </div>
      )}
    </section>
  );
}

// =======================================================================
// Skill list row — dense terminal-like row used in "list" view
// =======================================================================
function SkillListRow({ s, lens, onOpen, active, onRemove }) {
  const metric = lens === "Importance" ? s.imp : lens === "Maturity" ? (s.evals ? 85 : 45) : (50 + (s.id.length * 3) % 50);
  const evalsClass = s.evals ? (s.evals.startsWith("0") ? "warn" : "ok") : "";
  return (
    <div className={"cl-row" + (active ? " cl-row-active" : "") + (s.userAdded ? " cl-row-user" : "")}
         data-cat={s.cat} data-skill-id={s.id}
         onClick={onOpen} role="button" tabIndex={0}
         onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(); } }}>
      <div className="cl-name-cell">
        <span className={"cl-health h-" + s.health}/>
        <span className="cl-icon mono">{s.icon}</span>
        <span className="cl-name mono">{s.id}</span>
        {s.userAdded && <span className="cl-tag">NEW</span>}
      </div>
      <div className="cl-blurb">{s.blurb}</div>
      <div className="cl-metric-cell">
        <div className="cl-metric-bar"><div className="cl-metric-fill" style={{ width: metric + "%" }}/></div>
        <span className="cl-metric-num mono">{metric}</span>
      </div>
      <div className="cl-evals-cell">
        {s.evals ? <span className={"cl-evals " + evalsClass}>{s.evals}</span> : <span className="cl-muted mono">—</span>}
      </div>
      <div className="cl-size-cell mono">{s.size}</div>
      <div className="cl-age-cell mono">{s.age}</div>
      {onRemove && (
        <button className="cl-remove" onClick={(e) => { e.stopPropagation(); onRemove(); }} title="Remove" aria-label="Remove">✕</button>
      )}
    </div>
  );
}

// =======================================================================
// Drop overlay (when dragging md files over page)
// =======================================================================
function DropOverlay() {
  return (
    <div className="drop-overlay">
      <div className="drop-card">
        <div style={{fontSize:40, marginBottom:10}}>📄</div>
        <div className="h2" style={{margin:0, fontSize:22}}>Drop a <code>.md</code> skill file</div>
        <div className="muted" style={{marginTop:6, fontSize:13}}>
          Frontmatter is parsed into the catalog. This is how the system adds a plugin — it's just a file.
        </div>
      </div>
    </div>
  );
}
function Toast({ t }) {
  return <div className={"toast toast-" + t.kind}>{t.msg}</div>;
}

// =======================================================================
// Docs combined (pillars + wiring + analyzer + three files)
// =======================================================================
function DocsCombined() {
  const data = window.SNAPPY_DATA;
  return (
    <>
      <section className="section section-tight" style={{paddingTop:32}}>
        <div className="container">
          <div className="eyebrow accent mono">DOCS</div>
          <h1 className="h1" style={{margin:"6px 0 10px", fontSize:"32px"}}>The three files every agent reads.</h1>
          <p className="lead" style={{maxWidth:720, margin:0, fontSize:14}}>
            Schema, catalog, runtime — in that order. Everything else is files, HTTP, and CLI.
          </p>
        </div>
      </section>
      <section className="section section-tight"><div className="container"><Pillars/></div></section>
      <section className="section section-tight">
        <div className="container">
          <div className="section-header">
            <span className="eyebrow">Per-runtime wiring</span>
            <p className="lead">Same install, different wiring per runtime.</p>
          </div>
          <Wiring runtimes={data.runtimes}/>
        </div>
      </section>
      <section className="section"><div className="container"><ThreeFiles/></div></section>
      <section className="section">
        <div className="container">
          <div className="section-header center">
            <span className="eyebrow">Try it on something of yours</span>
            <h2>Configure for a repo, or drop a skill file.</h2>
          </div>
          <Analyzer/>
        </div>
      </section>
      <section className="section">
        <div className="container">
          <div className="section-header center">
            <span className="eyebrow">Try it</span>
            <h2>Type a verb. See the trace.</h2>
          </div>
          <Simulator/>
        </div>
      </section>
    </>
  );
}

// =======================================================================
// TweaksPanel (unchanged)
// =======================================================================
function TweaksPanel({ open, bold, setBold, density, setDensity, lens, setLens, theme, setTheme }) {
  return (
    <div className={"tweaks" + (open?" open":"")}>
      <h4>Tweaks</h4>
      <div className="tweaks-row">
        <span>Variant</span>
        <select className="input btn-sm" style={{height:28, width:120}} value={bold?"bold":"refined"} onChange={e => setBold(e.target.value === "bold")}>
          <option value="refined">Safe refinement</option>
          <option value="bold">Bold reimagining</option>
        </select>
      </div>
      <div className="tweaks-row">
        <span>Theme</span>
        <select className="input btn-sm" style={{height:28, width:120}} value={theme} onChange={e => setTheme(e.target.value)}>
          <option value="dark">Dark</option>
          <option value="light">Light</option>
        </select>
      </div>
      <div className="tweaks-row">
        <span>Default lens</span>
        <select className="input btn-sm" style={{height:28, width:120}} value={lens} onChange={e => setLens(e.target.value)}>
          <option>Importance</option>
          <option>Maturity</option>
          <option>Usage</option>
        </select>
      </div>
      <div className="tweaks-row">
        <span>Card min width</span>
        <input type="range" min="180" max="300" step="4" value={density} onChange={e => setDensity(+e.target.value)}/>
      </div>
      <div style={{fontSize:11, color:"var(--muted-foreground)", marginTop:6, lineHeight:1.45}}>
        Every page is a view onto the same markdown-file reality. Drag a <code>.md</code> file into the Skills tab to add a plugin.
      </div>
    </div>
  );
}

// =======================================================================
// FilesPage — same catalog as Skills, rendered as the on-disk file tree.
// Each skill = 4 files (skill.md, .agents.md, lib/<slug>.ts, bin/<slug>/).
// Around the tree: the harness — eval log, drain script, UserPromptSubmit
// hook — so the operator can see what's actually on disk, not a taxonomy.
// =======================================================================
function FilesPage({ allSkills, canonicalSkills, extraSkills, onRemove, setRoute, query, onQuery }) {
  const [collapsed, setCollapsed] = useState({
    skills: false,      // the whole skills/ dir open by default
    lib: true,
    bin: true,
    log: false,
    regen: true,
    lint: true,
    hooks: false,
    docs: true,
  });
  const [selected, setSelected] = useState(null);
  const toggle = (k) => setCollapsed(s => ({ ...s, [k]: !s[k] }));

  const q = (query || "").trim().toLowerCase();
  const match = (s) => !q || s.id.toLowerCase().includes(q) || (s.blurb||"").toLowerCase().includes(q);
  const visibleSkills = useMemo(() => allSkills.filter(match), [allSkills, q]);

  // Rank: core/orchestrator first, then by importance. Keeps the tree
  // scannable — the spine of the system is near the top, user-added
  // integrations sit below their canonical siblings.
  const sortSkills = (arr) => {
    const coreOrder = ["ops","bootstrap","gateway","os","settings","sync"];
    return [...arr].sort((a, b) => {
      const ia = coreOrder.indexOf(a.id); const ib = coreOrder.indexOf(b.id);
      if (ia >= 0 && ib >= 0) return ia - ib;
      if (ia >= 0) return -1; if (ib >= 0) return 1;
      return (b.imp || 0) - (a.imp || 0);
    });
  };
  const ranked = useMemo(() => sortSkills(visibleSkills), [visibleSkills]);

  const extraIds = new Set(extraSkills.map(s => s.id));
  const isExtra = (id) => extraIds.has(id);

  const onFile = (kind, skill) => {
    setSelected({ kind, skill });
    if (kind === "skill-md" || kind === "agents-md") {
      // click the prose/loader → detail page
      // single-click selects in the right panel; double-click opens.
    }
  };

  const openDetail = (slug) => setRoute("skill/" + slug);

  const clearSelection = () => setSelected(null);

  const harnessCallouts = [
    { tag:"PROSE",  path:"state/skills/<slug>.md",        note:"The human-written brief the LLM reads. One per skill." },
    { tag:"LOADER", path:"state/skills/<slug>.agents.md", note:"Trigger phrases. UserPromptSubmit splices this into the turn." },
    { tag:"api.ts", path:"state/lib/<slug>.ts",           note:"The TypeScript entry point for pure-code skills." },
    { tag:"SCRIPTS",path:"state/bin/<slug>/",             note:"Shell scripts and helpers a skill shells out to." },
    { tag:"EVAL",   path:"state/log/evals.ndjson",        note:"Every run's row. actor_session_id ≠ auditor_session_id." },
    { tag:"REGEN",  path:"state/regen/drain.sh",          note:"Stop hook runs this. Fresh session rewrites broken loaders." },
    { tag:"HOOK",   path:"~/.claude/hooks/snappy-os-inject.sh", note:"The UserPromptSubmit hook that splices prose into every turn." },
  ];

  const extraCount = extraSkills.length;

  return (
    <>
      <section className="section section-tight" style={{paddingTop:32, paddingBottom:0}}>
        <div className="container">
          <div className="eyebrow accent mono">FILES</div>
          <h1 className="h1" style={{margin:"6px 0 6px", fontSize:"28px"}}>The same catalog — as the files it actually is.</h1>
          <p className="lead" style={{maxWidth:760, margin:"0 0 8px", fontSize:13.5, lineHeight:1.55}}>
            Every skill is four files on disk. Drop a <code>.md</code> anywhere to add one — it lands in the tree as the files it becomes. The harness around the tree is what makes the PID loop work.
          </p>
        </div>
      </section>

      <section className="section section-tight" style={{paddingTop:12}}>
        <div className="container">
          <div className="files-toolbar">
            <input className="input" placeholder="filter files…" value={query || ""} onChange={e => onQuery(e.target.value)} style={{maxWidth:280}}/>
            <span className="muted mono" style={{fontSize:11.5, marginLeft:12}}>
              {allSkills.length} skills · {canonicalSkills.length} canonical · {extraCount} user-added{q ? ` · ${visibleSkills.length} match "${q}"` : ""}
            </span>
          </div>

          <div className="files-layout">
            <div className="files-tree" role="tree" aria-label="snappy-os on-disk tree">
              <div className="ft-root mono">~/projects/snappy-os/</div>

              <FileLeaf kind="schema" label="program.md" note="schema — read first"
                onClick={() => onFile("schema", null)} active={selected?.kind === "schema"}/>
              <FileLeaf kind="schema" label="CLAUDE.md" note="runtime wiring"
                onClick={() => onFile("claude", null)} active={selected?.kind === "claude"}/>

              <FileDir label="state/" open={true}>
                <FileLeaf kind="schema" label="index.md" note="catalog pointer"
                  onClick={() => onFile("index", null)} active={selected?.kind === "index"}/>

                <FileDir label={`skills/   (${ranked.length * 2} files — prose + loader per skill)`} open={!collapsed.skills} onToggle={() => toggle("skills")}>
                  {ranked.map(s => (
                    <React.Fragment key={s.id}>
                      <FileLeaf kind="skill-md" label={`${s.id}.md`} note={s.blurb}
                        tag={isExtra(s.id) ? "dropped" : null}
                        onClick={() => onFile("skill-md", s)}
                        onDoubleClick={() => openDetail(s.id)}
                        active={selected?.kind === "skill-md" && selected?.skill?.id === s.id}/>
                      <FileLeaf kind="agents-md" label={`${s.id}.agents.md`} note="loader — trigger phrases"
                        tag={isExtra(s.id) ? "dropped" : null}
                        onClick={() => onFile("agents-md", s)}
                        onDoubleClick={() => openDetail(s.id)}
                        active={selected?.kind === "agents-md" && selected?.skill?.id === s.id}/>
                    </React.Fragment>
                  ))}
                  {q && ranked.length === 0 && (
                    <div className="ft-empty muted">No files match "{q}".</div>
                  )}
                </FileDir>

                <FileDir label={`lib/      (api.ts per skill + harness libs)`} open={!collapsed.lib} onToggle={() => toggle("lib")}>
                  <FileLeaf kind="harness" label="eval.ts" note="sessionId() guard — actor ≠ auditor enforced here"
                    onClick={() => onFile("harness", { path:"state/lib/eval.ts" })} active={selected?.skill?.path === "state/lib/eval.ts"}/>
                  <FileLeaf kind="harness" label="health-score.ts" note="rolls 7-day eval mean into a color"
                    onClick={() => onFile("harness", { path:"state/lib/health-score.ts" })} active={selected?.skill?.path === "state/lib/health-score.ts"}/>
                  {ranked.slice(0, 24).map(s => (
                    <FileLeaf key={s.id} kind="lib-ts" label={`${s.id}.ts`} note="api.ts — pure TS entry"
                      muted={true}
                      onClick={() => onFile("lib-ts", s)}
                      active={selected?.kind === "lib-ts" && selected?.skill?.id === s.id}/>
                  ))}
                  {ranked.length > 24 && <div className="ft-more muted">…and {ranked.length - 24} more</div>}
                </FileDir>

                <FileDir label={`bin/      (scripts per skill)`} open={!collapsed.bin} onToggle={() => toggle("bin")}>
                  {ranked.slice(0, 12).map(s => (
                    <FileLeaf key={s.id} kind="bin-dir" label={`${s.id}/`} note="scripts a skill shells out to"
                      muted={true}
                      onClick={() => onFile("bin-dir", s)}
                      active={selected?.kind === "bin-dir" && selected?.skill?.id === s.id}/>
                  ))}
                  {ranked.length > 12 && <div className="ft-more muted">…and more</div>}
                </FileDir>

                <FileDir label="log/      (append-only truth)" open={!collapsed.log} onToggle={() => toggle("log")}>
                  <FileLeaf kind="harness-log" label="evals.ndjson" note="every run's row · actor_session_id ≠ auditor_session_id"
                    onClick={() => onFile("harness", { path:"state/log/evals.ndjson" })} active={selected?.skill?.path === "state/log/evals.ndjson"}/>
                  <FileLeaf kind="harness-log" label="frictions.ndjson" note="P0/P1 raised by lints and probes"
                    onClick={() => onFile("harness", { path:"state/log/frictions.ndjson" })} active={selected?.skill?.path === "state/log/frictions.ndjson"}/>
                  <FileLeaf kind="harness-log" label="regen-pending.txt" note="queue drained on Stop"
                    onClick={() => onFile("harness", { path:"state/log/regen-pending.txt" })} active={selected?.skill?.path === "state/log/regen-pending.txt"}/>
                  <FileLeaf kind="harness-log" label="sync-manifest.json" note="sha+size per file — gateway round-trip check"
                    muted={true}
                    onClick={() => onFile("harness", { path:"state/log/sync-manifest.json" })} active={selected?.skill?.path === "state/log/sync-manifest.json"}/>
                </FileDir>

                <FileDir label="regen/    (drain — closes the loop)" open={!collapsed.regen} onToggle={() => toggle("regen")}>
                  <FileLeaf kind="harness" label="drain.sh" note="Stop hook fires this. Fresh session rewrites broken loaders."
                    onClick={() => onFile("harness", { path:"state/regen/drain.sh" })} active={selected?.skill?.path === "state/regen/drain.sh"}/>
                </FileDir>

                <FileDir label="lint/     (46 structural rules)" open={!collapsed.lint} onToggle={() => toggle("lint")}>
                  <FileLeaf kind="harness" label="check.ts" note="master lint — runs all the others"
                    onClick={() => onFile("harness", { path:"state/lint/check.ts" })} active={selected?.skill?.path === "state/lint/check.ts"}/>
                  <FileLeaf kind="harness" label="prose-sidecar-drift.ts" note="catch skills that grew commands in prose without a sidecar"
                    muted={true}
                    onClick={() => onFile("harness", { path:"state/lint/prose-sidecar-drift.ts" })} active={selected?.skill?.path === "state/lint/prose-sidecar-drift.ts"}/>
                  <div className="ft-more muted">…44 more rules — see <a className="footer-link" href="/linter">/linter</a></div>
                </FileDir>
              </FileDir>

              <FileDir label="~/.claude/hooks/" open={!collapsed.hooks} onToggle={() => toggle("hooks")}>
                <FileLeaf kind="harness" label="snappy-os-inject.sh" note="UserPromptSubmit hook · splices prose into every turn"
                  onClick={() => onFile("harness", { path:"~/.claude/hooks/snappy-os-inject.sh" })} active={selected?.skill?.path === "~/.claude/hooks/snappy-os-inject.sh"}/>
              </FileDir>
            </div>

            <aside className="files-side">
              {selected ? (
                <FileDetailPanel selected={selected} onClose={clearSelection} onRemove={onRemove} openDetail={openDetail} isExtra={isExtra}/>
              ) : (
                <div className="files-harness">
                  <div className="eyebrow mono">THE HARNESS AROUND THE TREE</div>
                  <p className="muted" style={{fontSize:12.5, margin:"6px 0 14px", lineHeight:1.55}}>
                    The tree is just files. The PID loop is how they fit together. Click any file on the left to see what it is. The callouts below point at the ones that make the system self-healing.
                  </p>
                  <ul className="harness-list">
                    {harnessCallouts.map(h => (
                      <li key={h.tag} className="harness-item">
                        <span className="harness-tag mono">{h.tag}</span>
                        <div className="harness-body">
                          <div className="mono harness-path">{h.path}</div>
                          <div className="muted harness-note">{h.note}</div>
                        </div>
                      </li>
                    ))}
                  </ul>
                  <div className="files-drop-hint">
                    <div className="mono" style={{fontSize:11.5, letterSpacing:".06em"}}>DROP A .md ANYWHERE →</div>
                    <div className="muted" style={{fontSize:12, marginTop:4, lineHeight:1.5}}>
                      It lands in <code>state/skills/</code> as <code>&lt;slug&gt;.md</code> + <code>&lt;slug&gt;.agents.md</code>. That's all "adding a plugin" means here.
                    </div>
                  </div>
                </div>
              )}
            </aside>
          </div>
        </div>
      </section>
      <LandingFooter/>
    </>
  );
}

function FileDir({ label, open, onToggle, children }) {
  return (
    <div className={"ft-dir" + (open ? " ft-open" : "")}>
      <button className="ft-dir-head" onClick={onToggle} aria-expanded={open}>
        <span className="ft-caret mono">{open ? "▾" : "▸"}</span>
        <span className="ft-dir-label mono">{label}</span>
      </button>
      {open && <div className="ft-dir-body">{children}</div>}
    </div>
  );
}

function FileLeaf({ kind, label, note, tag, muted, active, onClick, onDoubleClick }) {
  return (
    <div className={"ft-leaf ft-" + kind + (active ? " ft-active" : "") + (muted ? " ft-muted" : "")}
         role="treeitem"
         onClick={onClick}
         onDoubleClick={onDoubleClick}>
      <span className="ft-icon mono" aria-hidden>·</span>
      <span className="ft-name mono">{label}</span>
      {tag && <span className="ft-tag mono">{tag}</span>}
      {note && <span className="ft-note muted">{note}</span>}
    </div>
  );
}

function FileDetailPanel({ selected, onClose, onRemove, openDetail, isExtra }) {
  const { kind, skill } = selected;
  // Harness files (eval.ts, drain.sh, logs, hook): synthesized detail.
  if (kind === "harness" || kind === "harness-log") {
    const path = skill?.path || "";
    const copy = HARNESS_DETAIL[path] || { title: path, body: "A file that lives on disk. Read the source for the full story." };
    return (
      <div className="files-panel">
        <div className="files-panel-head">
          <span className="eyebrow mono">HARNESS FILE</span>
          <button className="btn btn-ghost-icon" onClick={onClose} aria-label="Close"><Icon name="x" size={14}/></button>
        </div>
        <div className="mono" style={{fontSize:13, marginTop:6, wordBreak:"break-all"}}>{path}</div>
        <h3 className="h3" style={{margin:"10px 0 6px", fontSize:15}}>{copy.title}</h3>
        <p className="muted" style={{fontSize:12.5, lineHeight:1.55, margin:0}}>{copy.body}</p>
      </div>
    );
  }
  if (kind === "schema" || kind === "claude" || kind === "index") {
    const m = {
      schema: { title:"program.md", body:"The schema. Read this first — it defines the PID contract, the verb shape, the 5-part harness, and how every file in the tree earns its place." },
      claude: { title:"CLAUDE.md", body:"Per-machine wiring. Lives outside the repo so each runtime's hooks, paths, and env can diverge without polluting shared code." },
      index:  { title:"state/index.md", body:"The catalog pointer. Lists every skill under state/skills/ and the one-line description that decides whether the LLM splices it." },
    };
    const c = m[kind];
    return (
      <div className="files-panel">
        <div className="files-panel-head">
          <span className="eyebrow mono">CANONICAL</span>
          <button className="btn btn-ghost-icon" onClick={onClose} aria-label="Close"><Icon name="x" size={14}/></button>
        </div>
        <h3 className="h3" style={{margin:"10px 0 6px", fontSize:16}}>{c.title}</h3>
        <p className="muted" style={{fontSize:12.5, lineHeight:1.55, margin:0}}>{c.body}</p>
      </div>
    );
  }
  // Skill-attached files: skill-md, agents-md, lib-ts, bin-dir
  if (!skill) return null;
  const partName = {
    "skill-md":  "PROSE",
    "agents-md": "LOADER",
    "lib-ts":    "api.ts",
    "bin-dir":   "SCRIPTS",
  }[kind] || kind;
  const pathOf = {
    "skill-md":  `state/skills/${skill.id}.md`,
    "agents-md": `state/skills/${skill.id}.agents.md`,
    "lib-ts":    `state/lib/${skill.id}.ts`,
    "bin-dir":   `state/bin/${skill.id}/`,
  }[kind];
  const roleBlurb = {
    "skill-md":  "The human-written brief. If the slug appears in the catalog and this file has a heading + description, the skill is real.",
    "agents-md": "Trigger phrases — when a user message hits one of these, UserPromptSubmit splices this file into the turn.",
    "lib-ts":    "Pure-TS entry point. Libraries that a verb or another skill imports. Not all skills have one.",
    "bin-dir":   "Shell scripts and helpers this skill shells out to. Optional — only present when the skill does something OS-level.",
  }[kind];
  return (
    <div className="files-panel">
      <div className="files-panel-head">
        <span className="eyebrow mono">{partName}</span>
        <button className="btn btn-ghost-icon" onClick={onClose} aria-label="Close"><Icon name="x" size={14}/></button>
      </div>
      <div className="mono" style={{fontSize:13, marginTop:6, wordBreak:"break-all"}}>{pathOf}</div>
      <h3 className="h3" style={{margin:"10px 0 4px", fontSize:16}}>{skill.id}</h3>
      <p className="muted" style={{fontSize:12.5, lineHeight:1.55, margin:"0 0 10px"}}>{skill.blurb}</p>
      <p className="muted" style={{fontSize:12, lineHeight:1.55, margin:"0 0 14px"}}>{roleBlurb}</p>
      <div style={{display:"flex", gap:8, flexWrap:"wrap"}}>
        <button className="btn btn-primary btn-sm" onClick={() => openDetail(skill.id)}>
          Open full detail <Icon name="arrow-right" size={12}/>
        </button>
        {isExtra(skill.id) && (
          <button className="btn btn-outline btn-sm" onClick={() => onRemove(skill.id)}>
            Remove from catalog
          </button>
        )}
      </div>
      <div className="muted" style={{fontSize:11, marginTop:12, lineHeight:1.5}}>
        Double-click any file in the tree to jump to the skill's detail page.
      </div>
    </div>
  );
}

const HARNESS_DETAIL = {
  "state/lib/eval.ts": {
    title: "sessionId() — the actor ≠ auditor guarantee",
    body:  "A new process ID stamped on every run. When a verb appends its eval row, the actor's session is already written. The auditor runs later, in a different session, and fills in the score. Same process can't be both — that invariant is what makes the number believable.",
  },
  "state/lib/health-score.ts": {
    title: "health-score.ts — 7-day rolling eval mean → color",
    body:  "Turns ~N eval rows into green/amber/red per skill. Weight was redistributed away from the retired `graduation_ratio` on 2026-04-20.",
  },
  "state/log/evals.ndjson": {
    title: "Every run's row",
    body:  "Append-only. One line per verb run. Contains actor_session_id, auditor_session_id, skill, score, primary_issue. The auditor line lands seconds-to-minutes after the actor line — they're the same row filled in across two sessions.",
  },
  "state/log/frictions.ndjson": {
    title: "P0/P1 raised",
    body:  "Every lint failure and probe miss appends here. The PID loop reads this to decide what to enqueue for regen.",
  },
  "state/log/regen-pending.txt": {
    title: "The queue, drained on Stop",
    body:  "When a lint catches drift, the slug lands here. On Stop, drain.sh pops the queue and runs a fresh session to rewrite the broken loader. Closes the loop.",
  },
  "state/log/sync-manifest.json": {
    title: "sha + size per file",
    body:  "Written by cli.js push. gateway-integrity reads this to confirm the Worker, DO Spaces, and local disk agree on bytes. Drift here is what the 4 silent-drop incidents on 2026-04-18 were.",
  },
  "state/regen/drain.sh": {
    title: "Stop-hook drain — the healing arrow",
    body:  "Stop hook fires → this script pops regen-pending.txt → spawns a fresh session → rewrites the broken loader. Fresh session matters because actor ≠ auditor — the thing that failed can't grade its own fix.",
  },
  "state/lint/check.ts": {
    title: "The master lint",
    body:  "Runs all 45 rules. Exit 1 on any breach. Shape invariants, sync integrity, runtime parity, skill-file wellformedness — all enforced here.",
  },
  "state/lint/prose-sidecar-drift.ts": {
    title: "Catch skills that grew commands in prose",
    body:  "If a skill's markdown contains ≥3 executable commands and has no sidecar at state/bin/<slug>/ or state/lib/<slug>.ts, it's flagged. Self-healing: the slug enters the regen queue.",
  },
  "~/.claude/hooks/snappy-os-inject.sh": {
    title: "UserPromptSubmit — the splice",
    body:  "Fires on every user message. Reads trigger phrases out of state/skills/*.agents.md, finds the matches, splices them into the system prompt before the LLM responds. This is how the prose becomes behavior.",
  },
};

ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
