#!/usr/bin/env python3
"""render.py — one self-contained HTML report for a discover run, regenerated from the corpus on demand.

Usage: render.py <run-dir> [--out <file>] [--image-width 560]

Reads only what the run wrote: 00-scope.md, evidence/capture-log.md and the saved images, model/ (records, index,
model files, census), 05-review.md and defects.jsonl. Writes <run-dir>/report/index.html: inline CSS, inline SVG
charts, images embedded as base64 (downscaled with `sips` when it exists), no network fetch. The report directory
is git-ignored; this script and the corpus are the source, so the page is rebuilt rather than versioned.
"""
import base64
import html
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile

HERE = os.path.dirname(os.path.abspath(__file__))
STATUS = ["walked", "partial", "blocked", "unwalked"]
VERDICTS = ["CONFIRMS", "EXTENDS", "DIFFERS"]          # hue slots 1–3; ABSENT is outlined, NOT CAPTURED hatched
ID = re.compile(r"\b(\d{2,3}-[a-z0-9]+(?:-[a-z0-9]+)*)\b")


# ---------- reading ----------

def read(path):
    try:
        with open(path, encoding="utf-8", errors="replace") as f:
            return f.read()
    except OSError:
        return ""


def frontmatter(text):
    if not text.startswith("---"):
        return {}, text
    end = text.find("\n---", 3)
    if end < 0:
        return {}, text
    fm = {}
    for line in text[3:end].splitlines():
        m = re.match(r"^([\w-]+):\s*(.*)$", line)
        if m:
            v = m.group(2).strip()
            if v.startswith("[") and v.endswith("]"):
                v = [x.strip().strip("'\"") for x in v[1:-1].split(",") if x.strip()]
            fm[m.group(1)] = v
    return fm, text[end + 4:].lstrip("\n")


def tables(body):
    out, section, header = {}, "", None
    for line in body.splitlines():
        h = re.match(r"^#{1,4}\s+(.*)", line)
        if h:
            section, header = h.group(1).strip(), None
            continue
        if line.strip().startswith("|"):
            cells = [c.strip() for c in line.strip().strip("|").split("|")]
            if header is None:
                header = [c.lower() for c in cells]
            elif all(re.fullmatch(r":?-{2,}:?", c or "--") for c in cells):
                continue
            else:
                out.setdefault(section, []).append(dict(zip(header, cells)))
        elif line.strip():
            header = None
    return out


def verdict_of(s):
    s = (s or "").upper().replace("*", "").strip()
    for v in ("NOT CAPTURED", "N/A", "CONFIRMS", "EXTENDS", "DIFFERS", "ABSENT"):
        if s.startswith(v):
            return v
    return "NOT CAPTURED" if not s else s.split()[0]


# ---------- markdown ----------

def inline(s, states):
    s = html.escape(s, quote=False)
    s = re.sub(r"`([^`]+)`", lambda m: f"<code>{m.group(1)}</code>", s)
    s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s)
    s = re.sub(r"(?<![\w*])\*([^*\n]+)\*(?!\w)", r"<em>\1</em>", s)
    s = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", lambda m: f'<a href="{html.escape(m.group(2))}">{m.group(1)}</a>' if m.group(2).startswith("http") else m.group(1), s)
    return ID.sub(lambda m: f'<a class="cite" data-state="{m.group(1)}">{m.group(1)}</a>' if m.group(1) in states else m.group(1), s)


def md(text, states, drop_h1=False):
    out, para, lst, i = [], [], None, 0
    lines = text.splitlines()

    def flush():
        nonlocal para, lst
        if para:
            out.append("<p>" + inline(" ".join(para), states) + "</p>")
            para = []
        if lst:
            out.append(f"</{lst}>")
            lst = None
    while i < len(lines):
        line = lines[i]
        if line.startswith("```"):
            flush()
            j = i + 1
            while j < len(lines) and not lines[j].startswith("```"):
                j += 1
            out.append("<pre><code>" + html.escape("\n".join(lines[i + 1:j])) + "</code></pre>")
            i = j + 1
            continue
        h = re.match(r"^(#{1,4})\s+(.*)", line)
        if h:
            flush()
            lvl = len(h.group(1))
            if not (drop_h1 and lvl == 1):
                out.append(f"<h{lvl + 2}>{inline(h.group(2), states)}</h{lvl + 2}>")
            i += 1
            continue
        if line.strip().startswith("|"):
            flush()
            rows = []
            while i < len(lines) and lines[i].strip().startswith("|"):
                rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
                i += 1
            rows = [r for r in rows if not all(re.fullmatch(r":?-{2,}:?", c or "--") for c in r)]
            if rows:
                head = "".join(f"<th>{inline(c, states)}</th>" for c in rows[0])
                body = "".join("<tr>" + "".join(f"<td>{inline(c, states)}</td>" for c in r) + "</tr>" for r in rows[1:])
                out.append(f'<div class="tw"><table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>')
            continue
        b = re.match(r"^\s*([-*]|\d+\.)\s+(.*)", line)
        if b:
            if para:
                out.append("<p>" + inline(" ".join(para), states) + "</p>")
                para = []
            kind = "ol" if b.group(1)[0].isdigit() else "ul"
            if lst != kind:
                if lst:
                    out.append(f"</{lst}>")
                out.append(f"<{kind}>")
                lst = kind
            out.append("<li>" + inline(b.group(2), states) + "</li>")
            i += 1
            continue
        if not line.strip():
            flush()
        else:
            if lst:
                out.append(f"</{lst}>")
                lst = None
            para.append(line.strip().lstrip("> "))
        i += 1
    flush()
    return "\n".join(out)


# ---------- images ----------

def image_b64(path, width, cache):
    if not path or not os.path.exists(path):
        return None
    if path in cache:
        return cache[path]
    data, mime = None, "image/jpeg"
    if shutil.which("sips"):
        fd, tmp = tempfile.mkstemp(suffix=".jpg")
        os.close(fd)
        r = subprocess.run(["sips", "-s", "format", "jpeg", "-s", "formatOptions", "62", "--resampleWidth", str(width), path, "--out", tmp],
                           capture_output=True)
        if r.returncode == 0:
            data = open(tmp, "rb").read()
        os.remove(tmp)
    if data is None:
        data = open(path, "rb").read()
        mime = "image/png" if path.lower().endswith(".png") else "image/jpeg"
    cache[path] = f"data:{mime};base64," + base64.b64encode(data).decode()
    return cache[path]


# ---------- charts (inline SVG) ----------

def hbar(items, color="var(--s1)", unit=""):
    """Horizontal bars, one series: [(label, value, tip)]. Labels in ink, value at the bar end."""
    if not items:
        return '<p class="muted">Nothing to chart.</p>'
    mx = max(v for _, v, _ in items) or 1
    rowh, labw, w = 24, 150, 300
    h = rowh * len(items) + 8
    out = [f'<svg class="chart" viewBox="0 0 {labw + w + 60} {h}" role="img" aria-label="bar chart">']
    for i, (lab, v, tip) in enumerate(items):
        y = 4 + i * rowh
        bw = max(2, (w - 10) * v / mx) if v else 0
        out.append(f'<text x="{labw - 8}" y="{y + 16}" text-anchor="end" class="axl">{html.escape(lab[:24])}</text>')
        out.append(f'<rect x="{labw}" y="{y + 4}" width="{bw:.1f}" height="16" rx="4" fill="{color}" data-tip="{html.escape(tip or f"{lab}: {v}{unit}")}"/>')
        out.append(f'<text x="{labw + bw + 6}" y="{y + 16}" class="val">{v}{unit}</text>')
    out.append(f'<line x1="{labw}" y1="2" x2="{labw}" y2="{h - 2}" class="base"/></svg>')
    return "".join(out)


def stacked(parts, total_label=""):
    """One 100% stacked bar of status parts: [(label, value, cssvar)] with a 2px surface gap between fills."""
    tot = sum(v for _, v, _ in parts) or 1
    w, x = 760, 0
    out = [f'<svg class="chart wide" viewBox="0 0 {w} 54" role="img" aria-label="{html.escape(total_label)}">']
    for lab, v, col in parts:
        if not v:
            continue
        bw = w * v / tot
        out.append(f'<rect x="{x:.1f}" y="4" width="{max(bw - 2, 1):.1f}" height="22" rx="4" fill="{col}" data-tip="{html.escape(lab)}: {v} of {tot}"/>')
        if bw > 70:
            out.append(f'<text x="{x + 8:.1f}" y="44" class="axl">{html.escape(lab)} · {v}</text>')
        x += bw
    out.append("</svg>")
    legend = "".join(f'<span class="lg"><i style="background:{c}"></i>{html.escape(l)} <b>{v}</b></span>' for l, v, c in parts)
    return "".join(out) + f'<div class="legend">{legend}</div>'


# ---------- the page ----------

CSS = """
.viz-root{color-scheme:light;--surface-1:#fcfcfb;--page:#f9f9f7;--ink:#0b0b0b;--ink2:#52514e;--muted:#898781;--grid:#e1e0d9;--axis:#c3c2b7;--ring:rgba(11,11,11,.10);
--s1:#2a78d6;--s2:#eb6834;--s3:#1baf7a;--good:#0ca30c;--warning:#fab219;--serious:#ec835a;--critical:#d03b3b;--hatch:#c3c2b7}
@media (prefers-color-scheme:dark){:root:where(:not([data-theme="light"])) .viz-root{color-scheme:dark;--surface-1:#1a1a19;--page:#0d0d0d;--ink:#f0efec;--ink2:#c3c2b7;--grid:#2c2c2a;--axis:#383835;--ring:rgba(255,255,255,.10);--s1:#3987e5;--s2:#d95926;--s3:#199e70;--hatch:#52514e}}
:root[data-theme="dark"] .viz-root{color-scheme:dark;--surface-1:#1a1a19;--page:#0d0d0d;--ink:#f0efec;--ink2:#c3c2b7;--grid:#2c2c2a;--axis:#383835;--ring:rgba(255,255,255,.10);--s1:#3987e5;--s2:#d95926;--s3:#199e70;--hatch:#52514e}
*{box-sizing:border-box}body{margin:0}
.viz-root{font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--page);color:var(--ink);min-height:100vh}
.wrap{display:grid;grid-template-columns:230px minmax(0,1fr);max-width:1400px;margin:0 auto}
nav.toc{position:sticky;top:0;align-self:start;height:100vh;overflow:auto;padding:28px 18px;border-right:1px solid var(--grid)}
nav.toc a{display:block;color:var(--ink2);text-decoration:none;padding:4px 8px;border-radius:6px;font-size:14px}nav.toc a:hover{background:var(--grid);color:var(--ink)}
nav.toc .brand{font-weight:650;color:var(--ink);font-size:15px;margin-bottom:14px}
main{padding:32px 40px 80px}section{margin:0 0 56px}h1{font-size:30px;margin:0 0 6px;letter-spacing:-.01em}h2{font-size:22px;margin:0 0 6px}
h3{font-size:17px;margin:22px 0 8px}h4,h5,h6{font-size:15px;margin:16px 0 6px}.lede{color:var(--ink2);max-width:72ch;margin:0 0 18px}
.muted{color:var(--muted)}code{font:13px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--grid);padding:1px 5px;border-radius:4px}
pre{background:var(--surface-1);border:1px solid var(--ring);padding:12px;border-radius:8px;overflow:auto}
.card{background:var(--surface-1);border:1px solid var(--ring);border-radius:12px;padding:18px 20px}
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;margin:18px 0}
.tile{background:var(--surface-1);border:1px solid var(--ring);border-radius:12px;padding:14px 16px}.tile .n{font-size:30px;font-weight:650;line-height:1.1}.tile .l{color:var(--ink2);font-size:13px}
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}
svg.chart{width:100%;max-width:720px;height:auto;display:block}svg.chart.wide{max-width:none}svg .axl{fill:var(--ink2);font-size:12px}svg .val{fill:var(--ink);font-size:12px;font-variant-numeric:tabular-nums}svg .base{stroke:var(--axis);stroke-width:1}
svg rect[data-tip]:hover{opacity:.85}
.legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:6px;font-size:13px;color:var(--ink2)}.lg i{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:6px;vertical-align:-1px}
.tw{overflow:auto;margin:8px 0 14px}table{border-collapse:collapse;width:100%;font-size:13.5px;background:var(--surface-1)}
th,td{border-bottom:1px solid var(--grid);padding:6px 9px;text-align:left;vertical-align:top}th{color:var(--ink2);font-weight:600;position:sticky;top:0;background:var(--surface-1)}
td{font-variant-numeric:tabular-nums}a.cite{color:var(--s1);cursor:pointer;text-decoration:underline dotted}
.depth{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.depth .col{background:var(--surface-1);border:1px solid var(--ring);border-radius:12px;padding:12px}
.depth .col h4{margin:0 0 8px;color:var(--ink2);font-size:13px;text-transform:uppercase;letter-spacing:.04em}
.chip{display:flex;align-items:center;gap:8px;padding:5px 8px;margin:4px 0;border-radius:8px;border:1px solid var(--ring);font-size:13px;cursor:pointer;background:var(--page)}
.chip .dot{width:9px;height:9px;border-radius:50%;flex:none}.chip .arch{margin-left:auto;color:var(--muted);font-size:11.5px}
.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px}
.shot{background:var(--surface-1);border:1px solid var(--ring);border-radius:12px;overflow:hidden;cursor:pointer;transition:transform .12s}
.shot:hover{transform:translateY(-2px)}.shot img{width:100%;aspect-ratio:16/11;object-fit:cover;object-position:top left;display:block;border-bottom:1px solid var(--grid)}
.shot .meta{padding:10px 12px}.shot .t{font-weight:600;font-size:14px}.shot .s{color:var(--ink2);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
.badge{display:inline-flex;align-items:center;gap:5px;font-size:12px;padding:1px 7px;border-radius:999px;border:1px solid var(--ring);color:var(--ink2)}
.badge .dot{width:8px;height:8px;border-radius:50%}
.heat{display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:4px}
.cell{height:46px;border-radius:6px;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:11.5px;font-weight:600;color:#fff;cursor:default}
.cell small{font-weight:500;opacity:.92;font-size:10.5px}
.cell.c-CONFIRMS{background:var(--s1)}.cell.c-EXTENDS{background:var(--s2)}.cell.c-DIFFERS{background:var(--s3)}
.cell.c-ABSENT{background:var(--surface-1);color:var(--ink);border:2px solid var(--ink2)}
.cell.c-NA{background:repeating-linear-gradient(45deg,var(--surface-1) 0 5px,var(--hatch) 5px 6px);color:var(--ink2);border:1px solid var(--ring)}
.heatgroup{margin:0 0 16px}.heatgroup h4{margin:0 0 6px;font-size:13px;color:var(--ink2)}
details{background:var(--surface-1);border:1px solid var(--ring);border-radius:10px;padding:10px 14px;margin:10px 0}details summary{cursor:pointer;font-weight:600}
dialog{border:none;border-radius:14px;max-width:min(1100px,94vw);width:94vw;max-height:90vh;padding:0;background:var(--surface-1);color:var(--ink)}
dialog::backdrop{background:rgba(0,0,0,.55)}dialog .dh{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--grid);position:sticky;top:0;background:var(--surface-1)}
dialog .db{padding:10px 18px 24px;overflow:auto;max-height:calc(90vh - 60px)}dialog img{max-width:100%;border:1px solid var(--ring);border-radius:8px}
button{font:inherit;background:var(--grid);color:var(--ink);border:1px solid var(--ring);border-radius:8px;padding:5px 12px;cursor:pointer}
#tip{position:fixed;pointer-events:none;background:var(--ink);color:var(--surface-1);font-size:12.5px;padding:6px 9px;border-radius:7px;max-width:360px;opacity:0;transition:opacity .08s;z-index:9}
.strip{display:flex;gap:10px;overflow:auto;padding:6px 0}.strip figure{margin:0;flex:none;width:300px}.strip figcaption{font-size:12px;color:var(--ink2);margin-top:4px}
.prov{font-size:13px;color:var(--ink2)}
@media (max-width:900px){.wrap{grid-template-columns:1fr}nav.toc{display:none}main{padding:20px}.depth{grid-template-columns:1fr 1fr}}
"""

JS = """
const tip=document.getElementById('tip');
document.addEventListener('mousemove',e=>{const t=e.target.closest('[data-tip]');if(!t){tip.style.opacity=0;return}
tip.textContent=t.dataset.tip;tip.style.opacity=1;const x=Math.min(e.clientX+14,innerWidth-380);tip.style.left=x+'px';tip.style.top=(e.clientY+16)+'px'});
const dlg=document.getElementById('dlg'),dt=document.getElementById('dlg-title'),db=document.getElementById('dlg-body');
function openRecord(id){const r=RECORDS[id];if(!r)return openState(id);dt.textContent=r.title;db.innerHTML=r.html;fillImgs(db);dlg.showModal();bindCites(db)}
function openState(id){const s=STATES[id];if(!s)return;dt.textContent=id+(s.shows?' — '+s.shows:'');
db.innerHTML=(s.img?'<img src="'+s.img+'" alt="'+id+'">':'<p class="muted">No image saved for this state.</p>')+'<p class="muted">'+(s.reached||'')+'</p>';dlg.showModal()}
function fillImgs(root){root.querySelectorAll('img[data-state-img]').forEach(i=>{const s=STATES[i.dataset.stateImg];if(s&&s.img)i.src=s.img})}
fillImgs(document);
function bindCites(root){root.querySelectorAll('a.cite').forEach(a=>a.onclick=e=>{e.preventDefault();openState(a.dataset.state)})}
document.querySelectorAll('[data-record]').forEach(el=>el.onclick=()=>openRecord(el.dataset.record));
bindCites(document);document.getElementById('dlg-close').onclick=()=>dlg.close();
document.getElementById('theme').onclick=()=>{const r=document.documentElement;r.dataset.theme=(r.dataset.theme==='dark'||(!r.dataset.theme&&matchMedia('(prefers-color-scheme: dark)').matches))?'light':'dark'};
"""


def critic_line(crit):
    """'{questions: 30, agree: 28, …}' → '30 questions: 28 agree, …'."""
    pairs = dict(re.findall(r"(\w+):\s*(\d+)", str(crit)))
    if not pairs:
        return str(crit)
    q = pairs.pop("questions", None)
    rest = ", ".join(f"{v} {k}" for k, v in pairs.items())
    return f"{q} questions: {rest}" if q else rest


def status_color(s):
    return {"walked": "var(--good)", "partial": "var(--warning)", "blocked": "var(--serious)", "unwalked": "var(--critical)"}.get(s, "var(--muted)")


def main(argv):
    if len(argv) < 2:
        print(__doc__)
        return 2
    run = os.path.abspath(argv[1])
    out = argv[argv.index("--out") + 1] if "--out" in argv else os.path.join(run, "report", "index.html")
    width = int(argv[argv.index("--image-width") + 1]) if "--image-width" in argv else 560
    cache = {}
    scope_fm, scope_body = frontmatter(read(os.path.join(run, "00-scope.md")))
    target = scope_fm.get("target") or os.path.basename(os.path.dirname(os.path.dirname(run)))
    run_id = scope_fm.get("run_id") or os.path.basename(run)

    # states
    screens = os.path.join(run, "evidence", "screens")
    files = {os.path.splitext(f)[0]: os.path.join(screens, f) for f in (os.listdir(screens) if os.path.isdir(screens) else [])}
    log_rows = [r for rows in tables(read(os.path.join(run, "evidence", "capture-log.md"))).values() for r in rows if r.get("state")]
    states = {}
    for r in log_rows:
        sid = r["state"].strip("` ")
        states[sid] = {"shows": r.get("shows", ""), "surface": r.get("surface", ""), "reached": f"{r.get('route', '')} · reached by {r.get('reached by', '')}"}
    for sid in files:
        states.setdefault(sid, {"shows": "", "surface": "", "reached": ""})
    sids = set(states)

    # records and index
    sdir = os.path.join(run, "model", "surfaces")
    recs = []
    for f in sorted(os.listdir(sdir)) if os.path.isdir(sdir) else []:
        if f.endswith(".md") and not f.startswith("_"):
            fm, body = frontmatter(read(os.path.join(sdir, f)))
            t = tables(body)
            recs.append({"id": f[:-3], "fm": fm, "body": body, "elements": sum(len(v) for k, v in t.items() if k.lower() == "elements"),
                         "evidence": [(r.get("evidence") or "?").split(":")[0].strip() for k, v in t.items() if k.lower() == "elements" for r in v]})
    idx_rows = [r for rows in tables(read(os.path.join(sdir, "_index.md"))).values() for r in rows if r.get("surface")]
    status_counts = {s: 0 for s in STATUS}
    for r in idx_rows:
        s = (r.get("status") or "").split(":")[0].strip()
        status_counts[s] = status_counts.get(s, 0) + 1

    # census
    census_fm, census_body = frontmatter(read(os.path.join(run, "model", "census.md")))
    groups = []
    for sec, rows in tables(census_body).items():
        cells = [r for r in rows if re.fullmatch(r"\*?[A-Z]{1,2}\d{1,2}\*?", (r.get("row") or "").strip())]
        if cells:
            groups.append((sec, cells))
    if len(groups) == 1:   # one flat table: group the canon rows by their letter prefix
        by = {}
        for r in groups[0][1]:
            by.setdefault(re.match(r"\*?([A-Z]+)", r["row"].strip()).group(1), []).append(r)
        groups = [(f"{k} rows", v) for k, v in by.items()]
    vcount = {}
    for _, cells in groups:
        for r in cells:
            v = verdict_of(r.get("verdict"))
            vcount[v] = vcount.get(v, 0) + 1

    # review and defects
    review_fm, review_body = frontmatter(read(os.path.join(run, "05-review.md")))
    defects = []
    for line in read(os.path.join(run, "defects.jsonl")).splitlines():
        try:
            defects.append(json.loads(line))
        except ValueError:
            pass

    # ---- sections ----
    ev = {}
    for r in recs:
        for e in r["evidence"]:
            ev[e] = ev.get(e, 0) + 1
    n_el = sum(ev.values())
    observed = round(100 * (ev.get("seen", 0) + ev.get("walked", 0)) / n_el) if n_el else 0
    census_rows = sum(len(c) for _, c in groups)
    open_defects = sum(1 for d in defects if not d.get("fixed_in_run"))
    tiles = [(len(idx_rows) or len(recs), "surfaces known"), (status_counts.get("walked", 0) + status_counts.get("partial", 0), "surfaces recorded"),
             (len(states), "states saved"), (n_el, "elements recorded"), (f"{observed}%", "elements observed"),
             (census_rows, "canon rows scored"), (len(defects), f"defects ({open_defects} open)")]
    S = []
    S.append(f'<section id="top"><h1>{html.escape(target.title())} — what the product shows</h1>'
             f'<p class="lede">A discover run models a product as a person meets it: every surface it shows, what each one contains, how the surfaces connect, and how the design measures against the B2B product-UX canon. This page is rendered from run <code>{html.escape(run_id)}</code>; every number on it is computed from the corpus files, and every citation opens the saved state behind it.</p>'
             '<div class="tiles">' + "".join(f'<div class="tile"><div class="n">{v}</div><div class="l">{l}</div></div>' for v, l in tiles) + "</div></section>")

    # coverage
    arch = {}
    for r in recs:
        a = r["fm"].get("archetype", "?")
        arch[a] = arch.get(a, 0) + 1
    S.append('<section id="coverage"><h2>Coverage</h2><p class="lede"><strong>Status:</strong> a surface is walked when its resting state and its archetype\'s probes were saved, partial when probes are missing, blocked when it was never reached.</p>'
             '<div class="card">' + stacked([(s, status_counts.get(s, 0), status_color(s)) for s in STATUS if status_counts.get(s, 0)], "surface status") + "</div>"
             '<div class="grid2" style="margin-top:16px"><div class="card"><h3 style="margin-top:0">Surfaces by archetype</h3>' + hbar(sorted([(a, n, f"{a}: {n} surfaces") for a, n in arch.items()], key=lambda x: -x[1])) + "</div>"
             '<div class="card"><h3 style="margin-top:0">Elements by evidence type</h3>' + hbar(sorted([(e, n, f"{e}: {n} element rows") for e, n in ev.items()], key=lambda x: -x[1])) +
             '<p class="muted" style="margin:6px 0 0">Observed evidence is <code>seen</code> or <code>walked</code>; the rest came from source, docs or marketing.</p></div></div></section>')

    # depth map
    cols = {"D0": [], "D1": [], "D2": [], "D3+": []}
    for r in idx_rows:
        d = (r.get("depth") or "").split(",")[0].strip()
        d = d if d in cols else ("D3+" if d.startswith("D3") else "D1")
        rec = (r.get("record") or "").replace(".md", "").strip()
        s = (r.get("status") or "").split(":")[0].strip()
        tip = f"{r.get('route', '')} · {r.get('status', '')} · known from {r.get('known from', '')}"
        attr = f' data-record="{html.escape(rec)}"' if rec and rec != "—" else ""
        cols[d].append(f'<div class="chip"{attr} data-tip="{html.escape(tip)}"><span class="dot" style="background:{status_color(s)}"></span>{html.escape(r.get("surface", ""))}<span class="arch">{html.escape(r.get("archetype", ""))}</span></div>')
    S.append('<section id="map"><h2>Surface map</h2><p class="lede"><strong>Depth:</strong> clicks from the landing surface. Each chip is one surface, coloured by status; click a chip to open its record.</p><div class="depth">' +
             "".join(f'<div class="col"><h4>{d} · {len(v)}</h4>{"".join(v) or "<p class=muted>none</p>"}</div>' for d, v in cols.items()) + "</div>"
             '<div class="legend">' + "".join(f'<span class="lg"><i style="background:{status_color(s)}"></i>{s}</span>' for s in STATUS) + "</div></section>")

    # gallery
    cards, records_js = [], {}
    for r in recs:
        fm = r["fm"]
        st_list = fm.get("states") if isinstance(fm.get("states"), list) else []
        first = next((s for s in st_list if s in files), None)
        img = first if first and image_b64(files.get(first), width, cache) else None
        s = (fm.get("status") or "").split(":")[0].strip()
        cards.append(f'<div class="shot" data-record="{r["id"]}">' + (f'<img data-state-img="{img}" alt="{html.escape(fm.get("title", r["id"]))}">' if img else "") +
                     f'<div class="meta"><div class="t">{html.escape(fm.get("title", r["id"]))}</div><div class="s"><span class="badge"><span class="dot" style="background:{status_color(s)}"></span>{html.escape(s or "?")}</span>'
                     f'<span>{html.escape(fm.get("archetype", ""))}</span><span>{r["elements"]} elements</span><span>{len(st_list)} states</span></div></div></div>')
        strip = "".join(f'<figure><img data-state-img="{x}" alt="{x}"><figcaption>{html.escape(x)} — {html.escape(states.get(x, {}).get("shows", "")[:120])}</figcaption></figure>' for x in st_list if x in files)
        meta = " · ".join(html.escape(f"{k}: {v if not isinstance(v, list) else ', '.join(v)}") for k, v in fm.items() if k in ("archetype", "object", "route", "depth", "containers", "status", "blocked_by"))
        records_js[r["id"]] = {"title": fm.get("title", r["id"]), "html": f'<p class="muted">{meta}</p><div class="strip">{strip}</div>' + md(r["body"], sids, drop_h1=True)}
    S.append('<section id="surfaces"><h2>Surfaces</h2><p class="lede"><strong>Record:</strong> one per page, panel and dialog, compiled from saved states; each row cites the state that shows it. Click a card for the record and every state behind it.</p>'
             f'<div class="gallery">{"".join(cards)}</div></section>')

    # census
    if groups:
        heat = []
        for sec, cells in groups:
            cs = []
            for r in cells:
                v = verdict_of(r.get("verdict"))
                cls = v if v in VERDICTS or v == "ABSENT" else "NA"
                short = {"CONFIRMS": "conf", "EXTENDS": "ext", "DIFFERS": "diff", "ABSENT": "absent"}.get(v, "n/c")
                tip = f"{r.get('row')} · {v} · {r.get('evidence', '')} · {re.sub(r'[*`]', '', r.get('reason', ''))[:220]}"
                cs.append(f'<div class="cell c-{cls}" data-tip="{html.escape(tip)}">{html.escape(r.get("row", "").strip("*"))}<small>{short}</small></div>')
            heat.append(f'<div class="heatgroup"><h4>{html.escape(sec)}</h4><div class="heat">{"".join(cs)}</div></div>')
        legend = [("CONFIRMS", "var(--s1)"), ("EXTENDS", "var(--s2)"), ("DIFFERS", "var(--s3)")]
        lg = "".join(f'<span class="lg"><i style="background:{c}"></i>{l} <b>{vcount.get(l, 0)}</b></span>' for l, c in legend)
        lg += f'<span class="lg"><i style="border:2px solid var(--ink2)"></i>ABSENT <b>{vcount.get("ABSENT", 0)}</b></span>'
        lg += f'<span class="lg"><i style="background:repeating-linear-gradient(45deg,var(--surface-1) 0 3px,var(--hatch) 3px 4px)"></i>not captured <b>{vcount.get("NOT CAPTURED", 0) + vcount.get("N/A", 0)}</b></span>'
        S.append('<section id="census"><h2>Canon census</h2><p class="lede"><strong>Verdicts:</strong> CONFIRMS — the product implements the canon row; EXTENDS — it goes further; DIFFERS — it solves the job another way; ABSENT — a saved state shows it lacks it; not captured — the evidence cannot tell. Hover a cell for its reason.</p>'
                 f'<div class="card">{"".join(heat)}<div class="legend">{lg}</div></div>'
                 f'<details><summary>Census as a table</summary>{md(census_body, sids)}</details></section>')

    # model
    model_parts = []
    for name, label in (("objects", "Objects"), ("navigation", "Navigation"), ("verbs", "Verbs"), ("journeys", "Journeys"), ("ai", "AI layer"), ("settings", "Settings"), ("claims", "Claims")):
        text = read(os.path.join(run, "model", f"{name}.md"))
        if text:
            _, body = frontmatter(text)
            model_parts.append(f'<details{" open" if name == "objects" else ""}><summary>{label}</summary>{md(body, sids, drop_h1=True)}</details>')
    if model_parts:
        S.append('<section id="model"><h2>Product model</h2><p class="lede"><strong>Model:</strong> the records compiled into objects, navigation, verbs, journeys, the AI layer, settings and the claims located against the product.</p>' + "".join(model_parts) + "</section>")

    # review
    rv = []
    crit = review_fm.get("critic")
    if review_body:
        rv.append(f'<details><summary>Review</summary>{md(review_body, sids, drop_h1=True)}</details>')
    if defects:
        by = {}
        for d in defects:
            by[d.get("class", "?")] = by.get(d.get("class", "?"), 0) + 1
        rows = "".join(f'<tr><td>{html.escape(str(d.get("id", "")))}</td><td>{html.escape(str(d.get("class", "")))}</td><td>{html.escape(str(d.get("step", "")))}</td><td>{inline(str(d.get("what", "")), sids)}</td><td>{html.escape(str(d.get("caught_by", "")))}</td><td>{"fixed" if d.get("fixed_in_run") else "open"}</td></tr>' for d in defects)
        rv.insert(0, '<div class="card"><h3 style="margin-top:0">Defects by class</h3>' + hbar(sorted([(k, v, f"{k}: {v} defects") for k, v in by.items()], key=lambda x: -x[1])) + "</div>"
                  f'<details><summary>Every defect ({len(defects)})</summary><div class="tw"><table><thead><tr><th>Id</th><th>Class</th><th>Step</th><th>What</th><th>Caught by</th><th>State</th></tr></thead><tbody>{rows}</tbody></table></div></details>')
    if rv:
        S.append('<section id="review"><h2>Review and defects</h2><p class="lede"><strong>Review:</strong> coverage computed by script, a blind critic on a sealed question set, a redaction audit, and the defects the run logged for the improvement harness.' +
                 (f" Critic: {html.escape(critic_line(crit))}." if crit else "") + "</p>" + "".join(rv) + "</section>")

    S.append(f'<section id="provenance"><h2>Provenance</h2><p class="prov">Rendered by <code>.claude/skills/report/scripts/render.py</code> from <code>{html.escape(os.path.relpath(run))}</code>. '
             'The page is git-ignored; run the script again to rebuild it from the corpus. Images are the run\'s saved states, redacted before they were saved, downscaled for the page.</p></section>')

    toc = [("top", "Summary"), ("coverage", "Coverage"), ("map", "Surface map"), ("surfaces", "Surfaces"), ("census", "Canon census"), ("model", "Product model"), ("review", "Review and defects"), ("provenance", "Provenance")]
    present = {m for m in re.findall(r'<section id="([\w-]+)"', "".join(S))}
    toc_html = "".join(f'<a href="#{i}">{l}</a>' for i, l in toc if i in present)
    states_js = {k: {"shows": v["shows"], "reached": v["reached"], "img": image_b64(files.get(k), width, cache)} for k, v in states.items()}
    page = (f'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
            f'<title>{html.escape(target)} · discover {html.escape(run_id)}</title><style>{CSS}</style></head><body class="viz-root">'
            f'<div class="wrap"><nav class="toc"><div class="brand">{html.escape(target)}<br><span class="muted" style="font-weight:400;font-size:12.5px">discover · {html.escape(run_id)}</span></div>{toc_html}'
            '<p style="margin-top:18px"><button id="theme">Light / dark</button></p></nav>'
            f'<main>{"".join(S)}</main></div><div id="tip"></div>'
            '<dialog id="dlg"><div class="dh"><strong id="dlg-title"></strong><button id="dlg-close">Close</button></div><div class="db" id="dlg-body"></div></dialog>'
            f'<script>const RECORDS={json.dumps(records_js)};const STATES={json.dumps(states_js)};{JS}</script></body></html>')
    os.makedirs(os.path.dirname(out), exist_ok=True)
    with open(out, "w") as f:
        f.write(page)
    print(f"report → {os.path.relpath(out)} ({len(page) // 1024} KB, {len(recs)} records, {len(states)} states, {census_rows} census rows)")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
