"""The PRD Plugin UI: one page, embeddable or standalone (REQ-141).

Two delivery modes from one document:

- **static** — `build_html(root)` bakes a snapshot in and reaches nothing. Use
  it to look at state anywhere, including offline.
- **live** — `build_html(root, live=True)`, served by `prd_ui_serve.py`, adds
  the write path so the toggles are real switches.

It is styled to sit inside the ai-collab-v3 shell without looking like a guest:
an iframe is a separate document, so the host's stylesheet cannot cascade in
and the tokens are replicated here from `web/src/styles/theme.css` (teal accent,
8px radius, dark-first, `data-theme` override). Standalone it still stands up on
its own, following the viewer's OS theme when the host does not set one.

Every input is state something else already computes — prd_status,
message_check, prd_config, canonical requests — so this is a view, not a second
source of truth.
"""

import argparse
import html as html_mod
import json
import sys
from datetime import datetime, timezone
from pathlib import Path

SCRIPTS = Path(__file__).resolve().parent
if str(SCRIPTS) not in sys.path:
    sys.path.insert(0, str(SCRIPTS))

DEFAULT_OUTPUT = "prd-ui.html"

# What each family is for, in the operator's words rather than the code's.
FAMILY = {
    "hooks": "When the plugin acts on host events — session start, prompt, stop, pre-tool.",
    "automation": "How much the agent decides alone, and what it checks before committing.",
    "integrations": "The AI-Collab Substrate adapter: what it may read, project, and coordinate.",
    "verification": "How much gets re-run before work may claim it passes.",
    "reasoning_guard": "How visible evidence promises are reported or enforced before consequential claims and changes.",
    "workflows": "The deterministic engine that owns bounded mechanics instead of the model.",
    "reporting": "Session reports, and how much of them is delegated.",
    "journal": "The agent journal contract — off until deliberately switched on.",
    "fork": "Behaviour when this install is a fork rather than the published package.",
    "drift": "Watching for tracking, docs, and traceability drifting apart.",
    "reflection": "Stop-hook reflection questions.",
    "knowledge": "The LLM wiki: querying before re-deriving, ingesting on close.",
    "tracking": "Tracking-record lifecycle defaults.",
    "fabric": "Model-profile bindings and the evidence they require.",
}
FAMILY_ORDER = ["hooks", "automation", "workflows", "verification", "reasoning_guard", "integrations",
                "reporting", "journal", "drift", "reflection", "knowledge",
                "tracking", "fabric", "fork"]

# Human-purpose groups are a UI projection, not a replacement for the stable
# technical `category` field consumed by external apps. REQ-170.
UI_FAMILIES = {
    "agent_autonomy": {
        "label": "Agent autonomy",
        "description": "How independently the agent works, when it may stop, and the bounds on continuing.",
    },
    "safety_verification": {
        "label": "Safety & verification",
        "description": "Checks that prevent unsafe commits and prove changed behavior before completion.",
    },
    "host_lifecycle": {
        "label": "Host events & lifecycle",
        "description": "What runs when sessions start, prompts arrive, tools finish, or sessions stop.",
    },
    "logging_audit": {
        "label": "Logging & audit",
        "description": "Local evidence, journals, drift history, skill logs, and traceability refreshes.",
    },
    "reporting_reflection": {
        "label": "Reporting & reflection",
        "description": "Session summaries, delegated prose, and bounded questions asked at Stop.",
    },
    "deterministic_workflows": {
        "label": "Deterministic workflows",
        "description": "Code-owned lifecycle mechanics, receipts, retry bounds, and judgment fallbacks.",
    },
    "knowledge_memory": {
        "label": "Knowledge & memory",
        "description": "Durable wiki knowledge and query-before-re-derivation behavior.",
    },
    "external_integrations": {
        "label": "External integrations",
        "description": "Optional AI-Collab discovery, recall, coordination, and runtime boundaries.",
    },
    "collaboration_tracking": {
        "label": "Collaboration & tracking",
        "description": "How parallel work is isolated and promoted into canonical tracking state.",
    },
    "model_policy": {
        "label": "Model policy",
        "description": "Evidence-bound model behavior and fail-safe defaults.",
    },
    "updates_forks": {
        "label": "Updates & forks",
        "description": "Published-package and fork version checks, caching, and source locations.",
    },
}
UI_FAMILY_ORDER = list(UI_FAMILIES)

UI_LABELS = {
    "automation.autonomy_level": "Agent decision authority",
    "automation.autonomous_run_until_done": "Run autonomously until done",
    "automation.key_decision_continue_guard": "Continue planned work between key decisions",
    "automation.autonomous_continue_cap": "Autonomous continuation limit",
    "automation.stop_guard_goal_max_age_days": "Goal freshness limit",
    "automation.precommit_gate": "Enforce the commit gate",
    "automation.graph_auto_refresh": "Refresh the traceability graph on Stop",
    "automation.version_check.enabled": "Check for plugin updates",
    "hooks.stop_guard.enabled": "Enable the Stop guard",
    "hooks.nudge.on_user_prompt": "Show routing guidance on every prompt",
    "hooks.session_report.enabled": "Build a session report on Stop",
    "hooks.skill_log.enabled": "Log skill usage",
    "drift.monitoring.on_stop": "Check for drift on every Stop",
    "journal.enabled": "Enable the agent journal",
    "reflection.enabled": "Enable Stop reflections",
    "knowledge.llm_wiki.enabled": "Enable the LLM wiki workflow",
    "workflows.enabled": "Enable deterministic workflows",
    "integrations.substrate.enabled": "Enable the AI-Collab integration",
    "verification.test_scope.enabled": "Use impact-scoped verification",
    "reporting.delegation.enabled": "Delegate eligible reports",
    "reasoning_guard.mode": "Reasoning Guard mode",
    "reasoning_guard.backfill.mode": "Reasoning summary backfill",
    "reasoning_guard.backfill.limit": "Recent summary count",
    "reasoning_guard.categories.evidence_follow_through": "Evidence follow-through",
    "reasoning_guard.categories.causal_claims": "Causal claims",
    "reasoning_guard.categories.permanent_mutations": "Permanent mutations",
    "reasoning_guard.categories.completion_claims": "Completion claims",
}


def _ui_category(key):
    if (key.startswith("automation.autonomy")
            or key.startswith("automation.autonomous")
            or key.startswith("automation.key_decision")
            or key.startswith("automation.stop_guard")
            or key == "hooks.stop_guard.enabled"):
        return "agent_autonomy"
    if (key.startswith("verification.")
            or key.startswith("reasoning_guard.")
            or key in {"automation.precommit_gate",
                       "hooks.precommit_gate.enabled",
                       "hooks.test_scope_guard.enabled"}):
        return "safety_verification"
    if (key.startswith("journal.")
            or key.startswith("drift.")
            or key in {"hooks.skill_log.enabled",
                       "hooks.drift_check.enabled",
                       "automation.graph_auto_refresh"}):
        return "logging_audit"
    if (key.startswith("reporting.")
            or key.startswith("reflection.")
            or key.startswith("hooks.session_report.")
            or key == "hooks.reflection.enabled"):
        return "reporting_reflection"
    if key.startswith("workflows.") or key.startswith("hooks.workflow."):
        return "deterministic_workflows"
    if key.startswith("knowledge."):
        return "knowledge_memory"
    if key.startswith("integrations."):
        return "external_integrations"
    if key.startswith("tracking."):
        return "collaboration_tracking"
    if key.startswith("fabric."):
        return "model_policy"
    if key.startswith("fork.") or key.startswith("automation.version_check."):
        return "updates_forks"
    return "host_lifecycle"


def _ui_label(key):
    if key in UI_LABELS:
        return UI_LABELS[key]
    parts = key.split(".")[1:]
    if parts and parts[-1] == "enabled":
        parts.pop()
    if not parts:
        parts = key.split(".")
    return " ".join(parts).replace("_", " ").capitalize()


def _decision_guidance(key, values):
    reporting_timeout = values.get("reporting.delegation.timeout_seconds", 30)
    substrate_timeout = values.get("integrations.substrate.timeout_seconds", 5)
    substrate_cache = values.get("integrations.substrate.cache_ttl_seconds", 60)
    verification_timeout = values.get(
        "verification.test_scope.execution_timeout_seconds", 600)
    guard_metrics = values.get("__reasoning_guard_metrics__", {})
    if isinstance(guard_metrics, dict) and guard_metrics.get("samples_ms"):
        guard_latency = (
            f"Measured locally: p50 {guard_metrics.get('p50_ms', 0)} ms and "
            f"p95 {guard_metrics.get('p95_ms', 0)} ms per observed event."
        )
    else:
        guard_latency = (
            "Measured locally per visible host event; no samples exist yet. "
            "Report and on modes add bounded local parsing plus one atomic state write."
        )
    guidance = {
        "reasoning_guard.mode": {
            "use_when": "Use when visible promises to gather discriminating evidence should remain traceable across tool calls and completion boundaries.",
            "on": "On records the same report data and may block guarded claims or permanent mutations; report records findings without blocking and is the default.",
            "off": "Off is a hard master switch: no analysis, enforcement, or report write occurs.",
            "latency": guard_latency,
        },
        "reasoning_guard.backfill.mode": {
            "use_when": "Use when a session may contain visible summaries produced before Reason Guard first observed it. Recent is the default and starts with 20.",
            "on": "Recent backfills the configured count (20 by default); live reads only the newest existing summary; full scans all visible summary history before following new records.",
            "off": "Choose live for the lowest startup cost. Historical summaries before startup remain intentionally unprocessed.",
            "latency": "Startup latency is lowest in live mode. Recent scans once but classifies only the configured window; full latency and temporary memory grow with rollout size. After initialization, all modes read only newly appended records.",
        },
        "reasoning_guard.backfill.limit": {
            "use_when": "Use with recent backfill to bound how many historical visible summaries are processed; the default is 20.",
            "on": "A larger count gives broader recent coverage and more one-time startup parsing.",
            "off": "This value is ignored by live and full modes.",
            "latency": "The selected count bounds classification work, although locating the last X summaries still scans the trusted rollout once.",
        },
        "automation.autonomous_run_until_done": {
            "use_when": "Use when an accepted goal should be completed without routine permission pauses.",
            "on": "The Stop guard continues work while the session owns an active, fresh goal.",
            "off": "The agent may stop after an intermediate response even when tracked work remains.",
            "latency": "No extra operation is added; sessions can run longer because planned work continues instead of stopping.",
        },
        "hooks.nudge.on_user_prompt": {
            "use_when": "Use when every prompt should refresh routing and update guidance.",
            "on": "A lightweight routing nudge and cached update check run for every submitted prompt.",
            "off": "Prompt handling skips that repeated guidance; session-start guidance can still remain enabled.",
            "latency": "Runs on every prompt, so small per-event cost accumulates. Exact duration is environment-dependent and is not measured portably.",
        },
        "hooks.session_report.enabled": {
            "use_when": "Use when each Stop should leave a current summary and skill-usage view.",
            "on": "Session-report work runs at Stop and may regenerate enabled report components.",
            "off": "No automatic Stop report is built; explicit reporting tools still work.",
            "latency": "Adds Stop-time work. Cost depends on enabled report components and repository size; no portable duration is available.",
        },
        "hooks.skill_log.enabled": {
            "use_when": "Use when you need a local audit trail of which PRD skills agents used.",
            "on": "Post-tool events append bounded skill-usage records.",
            "off": "Skill execution continues, but automatic usage logging is skipped.",
            "latency": "Runs after relevant tool events. The write is local, but cumulative cost follows tool-call frequency and is environment-dependent.",
        },
        "journal.enabled": {
            "use_when": "Use when the connected runtime implements the complete journal contract and durable lifecycle audit is valuable.",
            "on": "Journal subfeatures may operate when their own switches and contract checks also permit them.",
            "off": "All journal operations remain inert, including automatic capture and prompt recall.",
            "latency": "The master switch alone adds no work; enabled capture or recall can add event-time or prompt-time work defined by the connected runtime.",
        },
        "drift.monitoring.on_stop": {
            "use_when": "Use when continuous drift visibility is worth checking the repository at every Stop.",
            "on": "A bounded validator set records and surfaces drift whenever Stop fires.",
            "off": "Drift checks run only when explicitly requested or through another configured workflow.",
            "latency": "Adds validator work at every Stop. Cost grows with repository state and documentation; no universal duration is safe to claim.",
        },
        "reflection.enabled": {
            "use_when": "Use when bounded reflection questions improve quality enough to justify extra Stop interaction.",
            "on": "Enabled reflection categories may contribute up to the configured question limit at Stop.",
            "off": "No reflection questions are selected or asked automatically.",
            "latency": "Selection is local, but answering questions adds interaction time. The total is intentionally bounded by the configured maximum.",
        },
        "knowledge.llm_wiki.enabled": {
            "use_when": "Use when durable repository knowledge should be queried before re-derivation and updated after meaningful work.",
            "on": "Agents follow the wiki query, ingest, and lint lifecycle where applicable.",
            "off": "Automatic wiki behavior is skipped; explicit wiki requests can still be handled.",
            "latency": "Adds work only when wiki query, ingest, or lint is applicable. Cost depends on article count and task scope and is not measured portably.",
        },
        "workflows.enabled": {
            "use_when": "Use when bounded lifecycle mechanics should be deterministic, replay-safe, and receipt-backed.",
            "on": "Enabled workflows may validate, sequence, and record configured lifecycle operations.",
            "off": "Automatic and manual workflow execution is refused; domain skills and direct validated tools remain available.",
            "latency": "Workflow validation and receipts add overhead when a workflow runs; the exact cost depends on its declared steps.",
        },
        "integrations.substrate.enabled": {
            "use_when": "Use when AI-Collab discovery, recall, or coordination benefits outweigh a local runtime dependency.",
            "on": "Only the configured mode, capability allowlist, and individual automation switches may call the runtime.",
            "off": "Every subordinate integration setting is inert and PRD Plugin follows its configured local fallback.",
            "latency": f"Runtime calls may wait up to {substrate_timeout} seconds each and discovery can be cached for {substrate_cache} seconds. Actual calls depend on mode and enabled automation.",
        },
        "verification.test_scope.enabled": {
            "use_when": "Use when impact evidence can avoid unrelated tests without weakening the full-suite fallback.",
            "on": "Changed files are mapped to a conservative focused plan; uncertainty still widens to the full suite.",
            "off": "The impact-scoped path is skipped and ordinary verification selection applies.",
            "latency": f"Impact lookup adds planning work, but may reduce total test time. Each selected command is bounded by the configured {verification_timeout}-second timeout.",
        },
        "reporting.delegation.enabled": {
            "use_when": "Use when eligible report prose can be delegated while deterministic sources and validation remain local.",
            "on": "Eligible tasks may call the configured executor and validate its source references before use.",
            "off": "Reporting stays on the configured local or deterministic path and makes no delegation call.",
            "latency": f"A delegated report may wait up to {reporting_timeout} seconds before the configured fallback. Successful calls may return sooner.",
        },
    }
    if key in guidance:
        return guidance[key]
    category_guidance = {
        "reasoning_guard.categories.evidence_follow_through": (
            "a promised bounded check remains open at a completion boundary",
            "unresolved evidence promises",
        ),
        "reasoning_guard.categories.causal_claims": (
            "a root-cause or causal statement could outrun the promised evidence",
            "premature causal claims",
        ),
        "reasoning_guard.categories.permanent_mutations": (
            "a permanent routing, fallback, policy, or configuration change could outrun the promised evidence",
            "premature permanent mutations",
        ),
        "reasoning_guard.categories.completion_claims": (
            "a done or resolved claim could outrun the promised evidence",
            "premature completion claims",
        ),
    }
    if key in category_guidance:
        use_case, subject = category_guidance[key]
        return {
            "use_when": f"Use this override when {use_case}.",
            "on": f"On may block {subject}; report records them without blocking; inherit follows the global mode.",
            "off": f"Off disables only this category while the other Reasoning Guard categories keep their effective modes.",
            "latency": guard_latency,
        }
    return None


def _read_json(path, default=None):
    try:
        return json.loads(Path(path).read_text(encoding="utf-8-sig"))
    except (OSError, ValueError):
        return default


def _safe(fn, default):
    """Every source is optional: a downstream repo may not have installed the
    helper or the state file. A missing input degrades the view, never the run.

    This deliberately swallows a wrong call as readily as a missing file — it
    already hid one typo during development — so each source has a test that
    asserts it is actually populated in a repo where that state exists.
    """
    try:
        return fn()
    except Exception:
        return default


def _repo_identity(root):
    version = ""
    config = _read_json(Path(root) / ".prd_plugin" / "config.json", {}) or {}
    plugin = config.get("plugin")
    if isinstance(plugin, dict):
        version = str(plugin.get("installed_version") or plugin.get("version") or "")
    repo_id = ""
    manifest = _read_json(Path(root) / ".prd_plugin" / "services.json", {}) or {}
    repository = manifest.get("repository")
    if isinstance(repository, dict):
        repo_id = str(repository.get("id") or "")
    return {"id": repo_id or Path(root).resolve().name,
            "version": version or "unknown"}


def _overview(root):
    def load():
        import prd_status
        s = prd_status.build_status(str(root))
        # repo_root is an absolute local path — never ship someone's drive
        # letters into a page that gets embedded elsewhere.
        s.pop("repo_root", None)
        return s
    return _safe(load, {})


def _requests(root):
    data = _read_json(Path(root) / ".prd_plugin" / "state" / "requests.json", {}) or {}
    rows = data.get("requests")
    if not isinstance(rows, list):
        return []
    out = []
    for r in rows:
        if not isinstance(r, dict):
            continue
        thread = r.get("thread") or {}
        messages = thread.get("messages") if isinstance(thread, dict) else []
        out.append({
            "id": r.get("id", ""),
            "summary": r.get("summary", ""),
            "status": r.get("status", ""),
            "severity": r.get("severity", ""),
            "type": r.get("request_type", ""),
            "origin_repo": r.get("origin_repo") or "",
            "target_repo": r.get("target_repo") or "",
            "messages": len(messages) if isinstance(messages, list) else 0,
        })
    return out


def _toggles(root):
    def load():
        import prd_config
        catalog = prd_config.build_catalog(str(root))
        data, _ = prd_config._load(str(root))
        flat = dict(prd_config._flatten(data))
        report = _read_json(
            Path(root) / ".prd_plugin/local/reason_guard.json", {}
        )
        if isinstance(report, dict) and isinstance(report.get("metrics"), dict):
            flat["__reasoning_guard_metrics__"] = report["metrics"]
        rows = []
        for t in prd_config.TOGGLES:
            key = t["key"]
            spec = catalog.get(key, {})
            default = spec.get("default", t.get("default"))
            rows.append({
                "key": key,
                "category": key.split(".")[0],
                "ui_category": _ui_category(key),
                "ui_label": _ui_label(key),
                "type": spec.get("type", t.get("type", "")),
                "allowed": spec.get("allowed") or t.get("allowed") or [],
                "default": default,
                "value": flat.get(key, default),
                "controls": t.get("controls", ""),
                "dependencies": spec.get("dependencies", []),
                "latency": spec.get("latency", "none"),
                "guidance": _decision_guidance(key, flat),
                "mutable": spec.get("mutable", True),
            })
        return rows
    return _safe(load, [])


def _messages(root):
    def load():
        import message_check
        report = message_check.build_message_check(str(root))
        totals = report.get("totals")
        return dict(totals) if isinstance(totals, dict) else {}
    return _safe(load, {})


def build_snapshot(root):
    """Everything the page renders, from sources that already compute it."""
    root = Path(root)
    return {
        "repo": _repo_identity(root),
        "generated_from": "canonical PRD Plugin state",
        "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
        "families": FAMILY,
        "family_order": FAMILY_ORDER,
        "ui_families": UI_FAMILIES,
        "ui_family_order": UI_FAMILY_ORDER,
        "overview": _overview(root),
        "requests": _requests(root),
        "toggles": _toggles(root),
        "messages": _messages(root),
    }


def _payload(snapshot):
    """Inline the snapshot safely: `</script>` inside any string would end the
    block early, and request text is written by other repos' agents."""
    raw = json.dumps(snapshot, ensure_ascii=False, sort_keys=True)
    return raw.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")


# Tokens replicated from ai-collab-v3 web/src/styles/theme.css so the page reads
# as part of that shell inside an iframe (where the host stylesheet cannot
# reach), and still stands on its own outside it.
STYLE = """
:root{
  --radius:8px;
  --bg:#101114; --surface:#17191d; --surface-raised:#1e2127; --surface-strong:#252934;
  --border:#323744; --border-strong:#465063;
  --text:#f4f7fb; --muted:#9aa3b2; --subtle:#6f7888;
  --accent:#20c7b5; --accent-strong:#0f8f83;
  --ok:#22c55e; --info:#60a5fa; --warn:#f59e0b; --danger:#ef4444;
  --input:#111318; --shadow:rgb(0 0 0 / .22);
  --sans:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
  --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  color-scheme:dark;
}
@media (prefers-color-scheme: light){
  :root{
    --bg:#f5f6f8; --surface:#fff; --surface-raised:#f8fafc; --surface-strong:#eef1f5;
    --border:#d8dee7; --border-strong:#b8c2d1;
    --text:#171a21; --muted:#5f6876; --subtle:#818b99;
    --accent:#0d9488; --accent-strong:#0f766e;
    --ok:#15803d; --info:#2563eb; --warn:#b45309; --danger:#dc2626;
    --input:#fff; --shadow:rgb(25 31 44 / .08);
    color-scheme:light;
  }
}
:root[data-theme="dark"]{
  --bg:#101114; --surface:#17191d; --surface-raised:#1e2127; --surface-strong:#252934;
  --border:#323744; --border-strong:#465063;
  --text:#f4f7fb; --muted:#9aa3b2; --subtle:#6f7888;
  --accent:#20c7b5; --accent-strong:#0f8f83;
  --ok:#22c55e; --info:#60a5fa; --warn:#f59e0b; --danger:#ef4444;
  --input:#111318; --shadow:rgb(0 0 0 / .22);
  color-scheme:dark;
}
:root[data-theme="light"]{
  --bg:#f5f6f8; --surface:#fff; --surface-raised:#f8fafc; --surface-strong:#eef1f5;
  --border:#d8dee7; --border-strong:#b8c2d1;
  --text:#171a21; --muted:#5f6876; --subtle:#818b99;
  --accent:#0d9488; --accent-strong:#0f766e;
  --ok:#15803d; --info:#2563eb; --warn:#b45309; --danger:#dc2626;
  --input:#fff; --shadow:rgb(25 31 44 / .08);
  color-scheme:light;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);
  line-height:1.4;font-synthesis:none;text-rendering:optimizeLegibility;
  -webkit-font-smoothing:antialiased}
.wrap{max-width:62rem;margin:0 auto;padding:16px 14px 48px;
  display:flex;flex-direction:column;gap:14px}
.head{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;
  justify-content:space-between}
.brand{display:flex;flex-direction:column;gap:2px}
.brand h1{margin:0;font-size:16px;font-weight:600;letter-spacing:-.01em}
.eyebrow{font-family:var(--mono);font-size:11px;color:var(--subtle);
  letter-spacing:.06em;text-transform:uppercase}
.stamp{font-family:var(--mono);font-size:11px;color:var(--subtle)}
.tabs{display:flex;flex-wrap:wrap;gap:4px;border-bottom:1px solid var(--border);
  padding-bottom:8px}
.tab{font-size:12px;font-family:inherit;padding:5px 10px;border-radius:var(--radius);
  border:1px solid var(--border);background:var(--surface);color:var(--muted);
  cursor:pointer;display:inline-flex;gap:6px;align-items:center}
.tab:hover{color:var(--text);border-color:var(--border-strong)}
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.tab__n{font-family:var(--mono);font-size:11px;opacity:.75;
  font-variant-numeric:tabular-nums}
.tab[aria-selected="true"]{background:var(--surface-strong);color:var(--text);
  border-color:var(--border-strong)}
.view{display:none;flex-direction:column;gap:12px}
.view.is-active{display:flex}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));gap:8px}
.card{background:var(--surface);border:1px solid var(--border);
  border-radius:var(--radius);padding:10px 12px;display:flex;flex-direction:column;gap:2px}
.card__n{font-family:var(--mono);font-size:20px;font-variant-numeric:tabular-nums;
  line-height:1.15}
.card__l{font-size:11px;color:var(--subtle)}
.card--warn .card__n{color:var(--warn)}
.card--ok .card__n{color:var(--ok)}
.card--accent .card__n{color:var(--accent)}
.filter{width:100%;padding:8px 10px;font-family:var(--mono);font-size:12px;
  color:var(--text);background:var(--input);border:1px solid var(--border);
  border-radius:var(--radius)}
.filter::placeholder{color:var(--subtle)}
.filter:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.bar .filter{flex:1 1 14rem;width:auto}
.linkish{font-size:12px;color:var(--muted);background:none;border:none;
  cursor:pointer;font-family:inherit;padding:4px 6px;border-radius:var(--radius)}
.linkish:hover{color:var(--accent)}
.linkish:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
details.grp{background:var(--surface);border:1px solid var(--border);
  border-radius:var(--radius);overflow:hidden}
details.grp+details.grp{margin-top:8px}
summary.grp__head{cursor:pointer;padding:10px 12px;display:flex;flex-wrap:wrap;
  gap:4px 10px;align-items:center;list-style:none;
  background:var(--surface-raised)}
summary.grp__head::-webkit-details-marker{display:none}
summary.grp__head:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.grp__caret{color:var(--subtle);font-size:11px;transition:transform .12s ease;
  font-family:var(--mono)}
details[open] .grp__caret{transform:rotate(90deg)}
.grp__name{font-weight:600;font-size:13px}
.grp__n{font-family:var(--mono);font-size:11px;color:var(--subtle);
  font-variant-numeric:tabular-nums}
.grp__on{font-family:var(--mono);font-size:11px;color:var(--accent)}
.grp__what{flex:1 1 100%;font-size:11.5px;color:var(--subtle);margin:0}
.grp__body{padding:2px 12px 4px}
.tgl{padding:11px 0;border-top:1px solid var(--border);
  display:flex;gap:12px;align-items:flex-start}
.tgl:first-child{border-top:none}
.tgl__main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:4px}
.tgl__label{font-size:13px;font-weight:600;color:var(--text)}
.tgl__key{font-family:var(--mono);font-size:10.5px;color:var(--subtle);
  word-break:break-word}
.tgl__why{margin:0;font-size:12.5px;color:var(--muted);max-width:68ch}
.tgl__meta{display:flex;flex-wrap:wrap;gap:4px 6px;align-items:center}
.tgl__details{margin-top:3px;border:1px solid var(--border);
  border-radius:calc(var(--radius) / 2);background:var(--surface-raised);
  max-width:68ch}
.tgl__details>summary{cursor:pointer;list-style:none;padding:5px 7px;
  color:var(--muted);font-size:11.5px;font-weight:600}
.tgl__details>summary::-webkit-details-marker{display:none}
.tgl__details>summary::before{content:"›";display:inline-block;margin-right:6px;
  color:var(--accent);transition:transform .12s ease}
.tgl__details[open]>summary::before{transform:rotate(90deg)}
.tgl__details>summary:focus-visible{outline:2px solid var(--accent);
  outline-offset:1px}
.tgl__guide{display:grid;grid-template-columns:minmax(5rem,auto) minmax(0,1fr);
  gap:5px 10px;margin:0;padding:2px 7px 8px;border-top:1px solid var(--border);
  font-size:11.5px}
.tgl__guide dt{font-weight:600;color:var(--subtle)}
.tgl__guide dd{margin:0;color:var(--muted)}
/* A dependency chip names every key it is inert without ("inert unless
   drift.monitoring.enabled - hooks.enabled - hooks.drift_check.enabled"), so it
   can be wider than a phone. nowrap made it run past the panel and clip, because
   the page itself never scrolls sideways. Short chips still sit on one line -
   they only ever break when they genuinely cannot fit. */
.tag{font-family:var(--mono);font-size:10.5px;padding:2px 6px;
  border-radius:calc(var(--radius) / 2);background:var(--surface-strong);
  color:var(--subtle);max-width:100%;overflow-wrap:anywhere}
.tag--warn{background:color-mix(in srgb,var(--warn) 16%,transparent);color:var(--warn)}
.tag--accent{background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent)}
.tag--changed{background:color-mix(in srgb,var(--info) 16%,transparent);color:var(--info)}
.tgl__ctl{flex:0 0 auto;display:flex;flex-direction:column;gap:4px;align-items:flex-end}
.switch{position:relative;width:38px;height:22px;border-radius:999px;
  border:1px solid var(--border-strong);background:var(--surface-strong);
  cursor:pointer;padding:0;transition:background .14s ease,border-color .14s ease}
.switch::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;
  border-radius:50%;background:var(--muted);transition:transform .14s ease,background .14s ease}
.switch[aria-checked="true"]{background:var(--accent);border-color:var(--accent)}
.switch[aria-checked="true"]::after{transform:translateX(16px);background:#fff}
.switch:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.switch:disabled{opacity:.45;cursor:not-allowed}
.switch.is-busy{opacity:.6}
.pick{font-family:var(--mono);font-size:11.5px;padding:4px 6px;
  background:var(--input);color:var(--text);border:1px solid var(--border);
  border-radius:calc(var(--radius) / 2)}
.pick:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.ro{font-family:var(--mono);font-size:11.5px;color:var(--subtle);
  padding:4px 6px;background:var(--surface-strong);
  border-radius:calc(var(--radius) / 2);max-width:12rem;overflow:hidden;
  text-overflow:ellipsis;white-space:nowrap}
.toasts{display:flex;flex-direction:column;gap:6px}
.toast{border:1px solid var(--border);border-left:3px solid var(--warn);
  background:var(--surface);border-radius:var(--radius);padding:8px 10px;
  font-size:12px;color:var(--muted);display:flex;gap:8px;align-items:flex-start}
.toast--err{border-left-color:var(--danger)}
.toast b{color:var(--text);font-weight:600}
.toast code{font-family:var(--mono);font-size:11.5px;color:var(--text)}
.scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
table{border-collapse:collapse;width:100%;font-size:12.5px;min-width:32rem}
th{text-align:left;font-family:var(--mono);font-size:10.5px;font-weight:500;
  text-transform:uppercase;letter-spacing:.06em;color:var(--subtle);
  border-bottom:1px solid var(--border);padding:7px 8px}
td{border-bottom:1px solid var(--border);padding:7px 8px;vertical-align:top}
td.id{font-family:var(--mono);white-space:nowrap}
.pill{font-family:var(--mono);font-size:10.5px;padding:2px 6px;
  border-radius:calc(var(--radius) / 2);background:var(--surface-strong);
  color:var(--muted);white-space:nowrap}
.pill--ok{background:color-mix(in srgb,var(--ok) 16%,transparent);color:var(--ok)}
.pill--warn{background:color-mix(in srgb,var(--warn) 16%,transparent);color:var(--warn)}
.note{font-size:12px;color:var(--muted);margin:0;max-width:70ch}
.empty{color:var(--subtle);font-family:var(--mono);font-size:12px;padding:12px 0}
footer{border-top:1px solid var(--border);padding-top:10px;font-size:11.5px;
  color:var(--subtle)}
footer code{font-family:var(--mono);color:var(--muted)}
.is-hidden{display:none !important}
@media (prefers-reduced-motion: reduce){*{transition:none !important}}
"""

SCRIPT = r"""
(function () {
  var S = window.PRD_SNAPSHOT, LIVE = !!window.PRD_LIVE;
  window.PRD_RENDER = null;
  function esc(t) { var d = document.createElement('div'); d.textContent = t == null ? '' : String(t); return d.innerHTML; }
  function fmt(v) {
    if (typeof v === 'boolean') return v ? 'on' : 'off';
    if (v === null || v === undefined || v === '') return '—';
    if (Array.isArray(v)) return v.length ? v.join(', ') : '[]';
    return String(v);
  }
  function same(a, b) { return JSON.stringify(a) === JSON.stringify(b); }

  // The host shell tells the iframe which theme it is in: ?theme=dark|light
  var qs = new URLSearchParams(location.search);
  var theme = qs.get('theme');
  if (theme === 'dark' || theme === 'light') document.documentElement.setAttribute('data-theme', theme);

  // ---------- overview ----------
  var ov = S.overview || {}, msg = S.messages || {}, toggles = S.toggles || [];
  var byStatus = ov.requests_by_status || {};
  var open = (byStatus.proposed || 0) + (byStatus.in_review || 0) + (byStatus.needs_info || 0);
  var changed = toggles.filter(function (t) { return !same(t.value, t['default']); });
  var reasoningGuard = ov.reasoning_guard || {};
  var reasoningClearance = (reasoningGuard.clearance || {}).status || 'unavailable';
  var reasoningClassification = reasoningGuard.classification || {};
  var reasoningLatency = reasoningGuard.latency || {};
  var reasoningSessions = reasoningGuard.sessions || [];
  function card(n, l, cls) {
    return '<div class="card ' + (cls || '') + '"><span class="card__n">' + esc(n) +
      '</span><span class="card__l">' + esc(l) + '</span></div>';
  }
  document.getElementById('cards').innerHTML = [
    card(reasoningClearance, 'reasoning clearance',
      reasoningClearance === 'clear' ? 'card--ok' :
      (reasoningClearance === 'unavailable' ? '' : 'card--warn')),
    card((reasoningClassification.candidates || 0) + ' / ' +
      (reasoningClassification.summaries_seen || 0), 'summary candidates'),
    card(reasoningClassification.uncertain || 0, 'uncertain summaries',
      (reasoningClassification.uncertain || 0) ? 'card--warn' : ''),
    card((reasoningLatency.samples_ms || []).length ?
      (reasoningLatency.p95_ms + ' ms') : 'unmeasured', 'guard p95'),
    card(reasoningSessions.length, 'retained reasoning sessions'),
    card(ov.autonomy_level || '—', 'autonomy tier'),
    card(open, 'requests needing attention', open ? 'card--warn' : ''),
    card((ov.active_tracking || []).length, 'active goals'),
    card((ov.open_health || []).length, 'open health findings', (ov.open_health || []).length ? 'card--warn' : ''),
    card(ov.stale_count || 0, 'stale records'),
    card(msg.pending_outbound_replies || 0, 'replies pending delivery',
      (msg.pending_outbound_replies || 0) ? 'card--warn' : 'card--ok'),
    card(msg.unanswered_resolutions || 0, 'unanswered resolutions',
      (msg.unanswered_resolutions || 0) ? 'card--warn' : 'card--ok'),
    card(changed.length, 'toggles off default', 'card--accent')
  ].join('');
  document.getElementById('statusTable').innerHTML =
    Object.keys(byStatus).sort().map(function (k) {
      return '<tr><td class="id">' + esc(k) + '</td><td>' + esc(byStatus[k]) + '</td></tr>';
    }).join('') || '<tr><td colspan="2">no requests</td></tr>';
  document.getElementById('reasoningSessionsBody').innerHTML =
    reasoningSessions.map(function (s) {
      var clearanceClass = s.clearance === 'clear' ? 'pill--ok' :
        (s.clearance === 'unavailable' ? '' : 'pill--warn');
      return '<tr><td>' + esc(s.session_label || (s.host_name + ' session')) +
        (s.active ? ' <span class="pill pill--ok">active</span>' : '') + '</td>' +
        '<td>' + esc(s.surface_name || 'Surface unknown') + '</td>' +
        '<td>' + esc(s.model || 'unknown') + '</td>' +
        '<td>' + esc(s.effort_name || 'Not reported') + '</td>' +
        '<td><span class="pill ' + clearanceClass + '">' + esc(s.clearance) +
        '</span></td><td>' + esc(s.coverage) + '</td>' +
        '<td>' + esc(s.summaries_processed || 0) + '</td>' +
        '<td>' + esc(s.p95_ms || 0) + ' ms</td>' +
        '<td class="id">' + esc(s.session_id || 'unavailable') + '</td></tr>';
    }).join('') || '<tr><td colspan="9">no retained reasoning sessions</td></tr>';

  // ---------- requests ----------
  var OPEN = { proposed: 1, in_review: 1, needs_info: 1, accepted: 1 };
  document.getElementById('reqBody').innerHTML = (S.requests || []).map(function (r) {
    var cls = OPEN[r.status] ? 'pill--warn' : (r.status === 'implemented' ? 'pill--ok' : '');
    var route = r.origin_repo ? ('from ' + r.origin_repo) : (r.target_repo ? ('to ' + r.target_repo) : '');
    return '<tr data-s="' + esc((r.id + ' ' + r.summary + ' ' + r.status + ' ' + route).toLowerCase()) + '">' +
      '<td class="id">' + esc(r.id) + '</td>' +
      '<td><span class="pill ' + cls + '">' + esc(r.status) + '</span></td>' +
      '<td>' + esc(r.summary) + (route ? ' <span class="pill">' + esc(route) + '</span>' : '') + '</td>' +
      '<td class="id">' + esc(r.messages || '') + '</td></tr>';
  }).join('') || '<tr><td colspan="4">no requests</td></tr>';

  // ---------- config ----------
  function control(t) {
    var disabled = LIVE ? '' : ' disabled';
    if (t.type === 'bool') {
      return '<button class="switch" role="switch" type="button" aria-checked="' +
        (t.value ? 'true' : 'false') + '" data-key="' + esc(t.key) + '"' + disabled +
        ' aria-label="' + esc(t.ui_label || t.key) + '"></button>';
    }
    if (t.type === 'enum' && (t.allowed || []).length) {
      return '<select class="pick" data-key="' + esc(t.key) + '"' + disabled +
        ' aria-label="' + esc(t.ui_label || t.key) + '">' +
        t.allowed.map(function (a) {
          return '<option value="' + esc(a) + '"' + (a === t.value ? ' selected' : '') + '>' + esc(a) + '</option>';
        }).join('') + '</select>';
    }
    return '<span class="ro" title="' + esc(fmt(t.value)) + '">' + esc(fmt(t.value)) + '</span>';
  }

  function toggleRow(t) {
    var isChanged = !same(t.value, t['default']);
    var tags = ['<span class="tag">' + esc(t.type) + '</span>'];
    if (isChanged) tags.push('<span class="tag tag--changed">default ' + esc(fmt(t['default'])) + '</span>');
    if (t.apply_scope === 'session') tags.push('<span class="tag tag--warn">new session</span>');
    tags.push('<span class="tag ' + (t.latency === 'high' ? 'tag--warn' : '') +
      '">latency ' + esc(t.latency || 'none') + '</span>');
    if ((t.dependencies || []).length) {
      tags.push('<span class="tag">inert unless ' + esc(t.dependencies.join(' · ')) + '</span>');
    }
    var g = t.guidance || null;
    var guide = g ? '<details class="tgl__details"><summary>What changes?</summary>' +
      '<dl class="tgl__guide">' +
      '<dt>Use this when</dt><dd>' + esc(g.use_when) + '</dd>' +
      '<dt>When on</dt><dd>' + esc(g.on) + '</dd>' +
      '<dt>When off</dt><dd>' + esc(g.off) + '</dd>' +
      '<dt>Latency</dt><dd>' + esc(g.latency) + '</dd></dl></details>' : '';
    var guideSearch = g ? Object.keys(g).map(function (k) { return g[k]; }).join(' ') : '';
    return '<div class="tgl" data-s="' +
      esc((t.ui_label + ' ' + t.key + ' ' + t.controls + ' ' + guideSearch).toLowerCase()) + '">' +
      '<div class="tgl__main"><span class="tgl__label">' + esc(t.ui_label || t.key) + '</span>' +
      '<code class="tgl__key">' + esc(t.key) + '</code>' +
      '<p class="tgl__why">' + esc(t.controls) + '</p>' +
      '<div class="tgl__meta">' + tags.join('') + '</div>' + guide + '</div>' +
      '<div class="tgl__ctl">' + control(t) + '</div></div>';
  }

  function renderConfig() {
    var groups = {};
    toggles.forEach(function (t) {
      var category = t.ui_category || t.category;
      (groups[category] = groups[category] || []).push(t);
    });
    var order = (S.ui_family_order || S.family_order || []).filter(function (c) { return groups[c]; });
    Object.keys(groups).sort().forEach(function (c) { if (order.indexOf(c) === -1) order.push(c); });
    document.getElementById('cfg').innerHTML = order.map(function (cat) {
      var rows = groups[cat];
      var family = (S.ui_families || {})[cat] || {};
      var on = rows.filter(function (t) { return t.type === 'bool' && t.value; }).length;
      var bools = rows.filter(function (t) { return t.type === 'bool'; }).length;
      var off = rows.filter(function (t) { return !same(t.value, t['default']); }).length;
      return '<details class="grp" data-cat="' + esc(cat) + '">' +
        '<summary class="grp__head"><span class="grp__caret">›</span>' +
        '<span class="grp__name">' + esc(family.label || cat) + '</span>' +
        '<span class="grp__n">' + rows.length + (bools ? ' · ' + on + '/' + bools + ' on' : '') + '</span>' +
        (off ? '<span class="grp__on">' + off + ' off default</span>' : '') +
        '<p class="grp__what">' +
        esc(family.description || (S.families || {})[cat] || '') + '</p></summary>' +
        '<div class="grp__body">' + rows.map(toggleRow).join('') + '</div></details>';
    }).join('') || '<p class="empty">no toggles</p>';
  }
  renderConfig();

  // ---------- messages ----------
  document.getElementById('msgBody').innerHTML = Object.keys(msg).sort().map(function (k) {
    var v = msg[k], bad = /pending|unanswered|new_inbox/.test(k) && v;
    return '<tr><td>' + esc(k.replace(/_/g, ' ')) + '</td><td><span class="pill ' +
      (bad ? 'pill--warn' : (v === 0 ? 'pill--ok' : '')) + '">' + esc(v) + '</span></td></tr>';
  }).join('') || '<tr><td colspan="2">message state unavailable in this repo</td></tr>';

  // ---------- tabs ----------
  var tabs = [].slice.call(document.querySelectorAll('.tab'));
  tabs.forEach(function (tab) {
    tab.addEventListener('click', function () {
      tabs.forEach(function (t) {
        var on = t === tab;
        t.setAttribute('aria-selected', on ? 'true' : 'false');
        document.getElementById(t.dataset.view).classList.toggle('is-active', on);
      });
    });
  });

  // ---------- filtering + expand/collapse ----------
  [].slice.call(document.querySelectorAll('.filter')).forEach(function (input) {
    input.addEventListener('input', function () {
      var term = input.value.trim().toLowerCase();
      var scope = document.getElementById(input.dataset.scope);
      [].slice.call(scope.querySelectorAll('[data-s]')).forEach(function (el) {
        el.classList.toggle('is-hidden', !!term && el.dataset.s.indexOf(term) === -1);
      });
      [].slice.call(scope.querySelectorAll('details.grp')).forEach(function (g) {
        var any = g.querySelector('.tgl:not(.is-hidden)');
        g.classList.toggle('is-hidden', !any);
        if (term && any) g.open = true;   // searching should reveal, not hide
      });
    });
  });
  var expand = document.getElementById('expandAll');
  if (expand) {
    expand.addEventListener('click', function () {
      var groups = [].slice.call(document.querySelectorAll('details.grp'));
      var anyClosed = groups.some(function (g) { return !g.open; });
      groups.forEach(function (g) { g.open = anyClosed; });
      expand.textContent = anyClosed ? 'collapse all' : 'expand all';
    });
  }

  // The write path is a SEPARATE script block (the static export must contain
  // no network code at all), so hand it the two things it needs.
  window.PRD_UI = { toggles: toggles, render: renderConfig, fmt: fmt, esc: esc };
})();
"""

WRITE_SCRIPT = r"""
(function () {
  var api = window.PRD_UI || {};
  var toggles = api.toggles || [], renderConfig = api.render || function () {};
  var fmt = api.fmt, esc = api.esc;
  var toasts = document.getElementById('toasts');
  function say(html, isError) {
    var el = document.createElement('div');
    el.className = 'toast' + (isError ? ' toast--err' : '');
    el.innerHTML = html;
    toasts.appendChild(el);
    if (!isError) setTimeout(function () { el.remove(); }, 9000);
  }

  function send(key, value, revert, busyEl) {
    busyEl.classList.add('is-busy');
    var req = new XMLHttpRequest();
    req.open('POST', 'api/toggle', true);
    req.setRequestHeader('Content-Type', 'application/json');
    req.onreadystatechange = function () {
      if (req.readyState !== 4) return;
      busyEl.classList.remove('is-busy');
      var res = {};
      try { res = JSON.parse(req.responseText); } catch (e) { }
      if (req.status === 200 && res.ok) {
        var t = toggles.filter(function (x) { return x.key === key; })[0];
        if (t) t.value = res.value;
        renderConfig();
        if (res.notice) {
          say('<span><b>' + esc(key) + '</b> is now <code>' + esc(fmt(res.value)) +
            '</code>. ' + esc(res.notice) + '</span>');
        }
      } else {
        revert();
        say('<span><b>Could not change ' + esc(key) + '.</b> ' +
          esc(res.error || ('server returned ' + req.status)) + '</span>', true);
      }
    };
    req.send(JSON.stringify({ key: key, value: value }));
  }

  document.getElementById('cfg').addEventListener('click', function (e) {
    var sw = e.target.closest('.switch');
    if (!sw || sw.disabled) return;
    var key = sw.dataset.key, next = sw.getAttribute('aria-checked') !== 'true';
    sw.setAttribute('aria-checked', next ? 'true' : 'false');
    send(key, next, function () { sw.setAttribute('aria-checked', next ? 'false' : 'true'); }, sw);
  });
  document.getElementById('cfg').addEventListener('change', function (e) {
    var pick = e.target.closest('.pick');
    if (!pick || pick.disabled) return;
    var key = pick.dataset.key, prev = (toggles.filter(function (x) { return x.key === key; })[0] || {}).value;
    send(key, pick.value, function () { pick.value = prev; }, pick);
  });
})();
"""


def build_html(root, live=False):
    """The whole UI: one document, snapshot inlined, nothing fetched unless live."""
    snapshot = build_snapshot(root)
    if live:
        import prd_ui_serve
        for toggle in snapshot.get("toggles", []):
            toggle["apply_scope"] = prd_ui_serve.apply_scope(toggle["key"])
    repo = snapshot["repo"]
    counts = {"requests": len(snapshot["requests"]), "toggles": len(snapshot["toggles"])}
    mode = "control surface" if live else "snapshot"
    title = f"PRD Plugin — {repo['id']}"
    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html_mod.escape(title)}</title>
<style>{STYLE}</style>
</head>
<body>
<div class="wrap">
  <header class="head">
    <div class="brand">
      <span class="eyebrow">PRD Plugin · {html_mod.escape(repo['version'])}</span>
      <h1>{html_mod.escape(repo['id'])}</h1>
    </div>
    <span class="stamp">{mode} · {html_mod.escape(snapshot['generated_at'])}</span>
  </header>

  <div class="tabs" role="tablist">
    <button class="tab" role="tab" data-view="v-overview" aria-selected="true"
            type="button">overview</button>
    <button class="tab" role="tab" data-view="v-config" aria-selected="false"
            type="button">config<span class="tab__n">{counts['toggles']}</span></button>
    <button class="tab" role="tab" data-view="v-requests" aria-selected="false"
            type="button">requests<span class="tab__n">{counts['requests']}</span></button>
    <button class="tab" role="tab" data-view="v-messages" aria-selected="false"
            type="button">messages</button>
  </div>

  <div class="toasts" id="toasts"></div>

  <section class="view is-active" id="v-overview">
    <div class="cards" id="cards"></div>
    <h2>Reasoning sessions</h2>
    <p class="note">Readable provider, surface, model, effort, and session history; internal session IDs stay secondary for audit and support.</p>
    <div class="scroll">
      <table><thead><tr><th>session</th><th>surface</th><th>model</th><th>effort</th><th>clearance</th><th>coverage</th><th>summaries</th><th>p95 latency</th><th>session ID</th></tr></thead>
      <tbody id="reasoningSessionsBody"></tbody></table>
    </div>
    <div class="scroll">
      <table><thead><tr><th>request status</th><th>count</th></tr></thead>
      <tbody id="statusTable"></tbody></table>
    </div>
  </section>

  <section class="view" id="v-config">
    <div class="bar">
      <input class="filter" type="search" data-scope="v-config" autocomplete="off"
             placeholder="filter toggles…" aria-label="Filter toggles">
      <button class="linkish" id="expandAll" type="button">expand all</button>
    </div>
    {'' if live else '<p class="note">Read-only snapshot. Serve it with <code>prd_ui_serve.py</code> to switch things on and off.</p>'}
    <div id="cfg"></div>
  </section>

  <section class="view" id="v-requests">
    <input class="filter" type="search" data-scope="v-requests" autocomplete="off"
           placeholder="filter requests…" aria-label="Filter requests">
    <div class="scroll">
      <table><thead><tr><th>id</th><th>status</th><th>summary</th><th>msgs</th></tr></thead>
      <tbody id="reqBody"></tbody></table>
    </div>
  </section>

  <section class="view" id="v-messages">
    <p class="note">Cross-repo request transport. Anything pending or unanswered
      has not reached the other repo yet.</p>
    <div class="scroll">
      <table><thead><tr><th>metric</th><th>count</th></tr></thead>
      <tbody id="msgBody"></tbody></table>
    </div>
  </section>

  <footer>Reads canonical state. A toggle marked <b>new session</b> only lands
    when a fresh session starts — guidance already in the running session's
    context stays there. <code>/prd-off</code> switches everything off in one
    reversible profile.</footer>
</div>
<script>window.PRD_SNAPSHOT = {_payload(snapshot)};{' window.PRD_LIVE = true;' if live else ''}</script>
<script>{SCRIPT}</script>
{('<script>' + WRITE_SCRIPT + '</script>') if live else ''}
</body>
</html>
"""


def export(root, output=None):
    out = Path(output) if output else Path(root) / DEFAULT_OUTPUT
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(build_html(root), encoding="utf-8", newline="\n")
    return str(out)


def main(argv=None):
    parser = argparse.ArgumentParser(
        description="Export one self-contained HTML view of PRD Plugin state.")
    parser.add_argument("--repo-root", default=".")
    parser.add_argument("--output", default=None,
                        help=f"where to write the page (default: <repo>/{DEFAULT_OUTPUT})")
    args = parser.parse_args(argv)
    print(json.dumps({"written": export(args.repo_root, args.output)}, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
