#!/usr/bin/env python3
"""Render a self-contained HTML report from agent-learning evidence.

Reads the same inputs as `distill_learning` (corpus, baseline, mode, optional
skill inputs, optional domain rules) and emits a single static HTML file with
hand-rolled SVG charts, stats, and per-section evaluation explanations.

The HTML is fully self-contained (no CDN, no external assets). The structured
payload used for the visuals is embedded in a `<script type="application/json"
id="report-payload">` element so downstream tools can read it without parsing
markdown.
"""

from __future__ import annotations

import argparse
import datetime as dt
import html
import json
import pathlib
import sys
from typing import Any

from distill_learning import (
    DEFAULT_DOMAIN_PRESET,
    assistant_lines,
    classify_events,
    clean_user_messages,
    evidence_label,
    event_level,
    event_source,
    find_preference_evidence,
    load_domain_rules,
    load_json,
    load_optional_json,
    meta_lines,
    packaged_domain_rules_path,
    prior_report_context,
    evergreen_proposals,
    quote,
    resolve_domain_rules,
    skill_map_from_baseline,
)


SECTION_EXPLAINERS: dict[str, str] = {
    "confirmed_current": (
        "Baseline facts derived from the current repo checkout — source-of-truth "
        "files, instruction rules, validation commands, skills. Every line "
        "must name a concrete file, command, or metadata source."
    ),
    "memory_derived": (
        "Repeated user-steering signals mined from session transcripts. Each "
        "row carries a domain, evidence count, the failure signal, and a "
        "verbatim quote. Treat as advisory until verified in the checkout."
    ),
    "needs_verification": (
        "Anything that should not be acted on without a live recheck. Assistant "
        "claims from transcripts are never current repo truth."
    ),
    "agent_compensation": (
        "Repeated failure signals converted to procedural gates the next agent "
        "must clear before recommending action. Levels follow the capability "
        "rubric: III = strong (≥4 matches or baseline sources), II = moderate, "
        "I = weak or single signal."
    ),
    "self_healing_loop": (
        "How a single signal becomes durable behaviour: failure_signal → "
        "gate_category → directive → next_session_load. The strongest current "
        "gate is loaded first in the next session."
    ),
    "skill_inventory": (
        "Skills available in the repo or runtime, sourced from "
        "map_active_skills.py (or fallback to the baseline list)."
    ),
    "skill_usage": (
        "Per-session evidence of skill loading and application, expected vs "
        "loaded vs applied. Source: extract_skill_usage.py."
    ),
    "skill_health": (
        "Alerts about invalid, missed, or loaded-but-not-applied skills. Each "
        "alert names the verify command that confirms or refutes it."
    ),
    "skill_compensation": (
        "Candidate skill adjustments from evaluate_skill_impact.py. Each entry "
        "includes a verify command — run it before changing the skill."
    ),
    "next_agent_brief": (
        "Compressed handoff. What the next agent should load and verify before "
        "taking action in this repo."
    ),
    "proposed_evergreen_diffs": (
        "Proposed edits to evergreen personal files. The skill never writes "
        "these directly; they are surfaced here for explicit operator approval."
    ),
}


LEVEL_LEGEND = [
    ("3", "≥4 matching lines or baseline sources", "strong"),
    ("2", "≥2 matching lines or baseline sources", "moderate"),
    ("1-2", "single match · weak signal", "weak"),
]


def _evergreen_section(personal: pathlib.Path | None, baseline: dict, user_lines: list[str]) -> list[str]:
    if personal is None:
        return []
    return evergreen_proposals(personal, baseline, user_lines)


def _muted_domains(personal: pathlib.Path) -> set[str]:
    """Read the dashboard's muted-domains.json. Safe on missing/invalid file."""
    path = personal / "actions" / "muted-domains.json"
    if not path.is_file():
        return set()
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return set()
    if not isinstance(data, list):
        return set()
    return {
        (item.get("domain") or "").strip()
        for item in data
        if isinstance(item, dict) and (item.get("domain") or "").strip()
    }


def build_report_payload(
    corpus: str,
    baseline: dict,
    mode: str,
    personal: pathlib.Path | None = None,
    skill_map: dict | None = None,
    skill_usage: dict | None = None,
    skill_impact: dict | None = None,
    domain_rules: list[dict] | None = None,
    domain_rules_source: pathlib.Path | None = None,
) -> dict:
    """Compute the structured payload used by the HTML renderer.

    Mirrors the data derivations in `distill_learning.render_report`. Pure
    function: same inputs produce the same payload.
    """
    domain_rules = domain_rules or load_domain_rules(packaged_domain_rules_path(DEFAULT_DOMAIN_PRESET))
    today = dt.date.today().isoformat()
    user_messages = clean_user_messages(corpus)
    u_lines = [message.text for message in user_messages]
    a_lines = assistant_lines(corpus)
    corpus_meta = meta_lines(corpus)
    evidence = find_preference_evidence(u_lines, domain_rules)
    source_evidence = baseline.get("source_evidence") or [
        {"fact": f"`{path}` exists as a repo source-of-truth file.", "source": f"{path}:1"}
        for path in baseline.get("source_files", [])
    ]
    instruction_evidence = baseline.get("instruction_evidence", [])
    skill_evidence = baseline.get("skill_evidence") or [
        {"path": path, "source": f"{path}:1"} for path in baseline.get("skills", [])
    ]
    validation_evidence = baseline.get("validation_evidence") or [
        {"command": command, "source": "package.json:1"} for command in baseline.get("validation_commands", [])
    ]
    map_rows = classify_events(baseline, user_messages, domain_rules)
    if personal:
        muted = _muted_domains(personal)
        if muted:
            map_rows = [row for row in map_rows if row.get("domain") not in muted]
        prior_report, level_changes = prior_report_context(personal, map_rows)
        proposed_evergreen = evergreen_proposals(personal, baseline, u_lines)
    else:
        prior_report = None
        level_changes = []
        proposed_evergreen = []

    skill_map = skill_map or skill_map_from_baseline(baseline)
    skill_usage = skill_usage or {}
    skill_impact = skill_impact or {}

    rows_serialized = []
    for row in map_rows:
        rows_serialized.append({
            "domain": row["domain"],
            "level": event_level(row),
            "count": row["count"],
            "source_count": row.get("source_count", 0),
            "session_refs": list(row.get("session_refs", [])),
            "failure_signal": row["failure_signal"],
            "gate": row["gate"],
            "gate_category": row["category"],
            "quote": row["quotes"][0] if row.get("quotes") else "",
            "evidence_label": evidence_label(row),
            "evidence_source": event_source(row),
        })

    valid_skills = [item for item in skill_map.get("skills", []) if item.get("valid", True)]
    invalid_skills = skill_map.get("invalid", [])
    missed = list(skill_usage.get("missed", []))
    failed = list(skill_usage.get("failed", []))
    expected = list(skill_usage.get("expected", []))
    loaded = list(skill_usage.get("loaded", []))
    applied = list(skill_usage.get("applied", []))
    impact_rows = [row for row in skill_impact.get("skills", []) if row.get("candidate_adjustment")]

    payload = {
        "date": today,
        "mode": mode,
        "repo": baseline.get("repo", "unknown"),
        "totals": {
            "user_lines": len(u_lines),
            "assistant_lines": len(a_lines),
            "corpus_meta": len(corpus_meta),
            "domain_rules": len(domain_rules),
            "gates": len(map_rows),
            "evidence_lines": sum(row["count"] for row in map_rows),
            "evidence_fallback": len(evidence),
            "skills_available": len(valid_skills),
            "skill_alerts": len(invalid_skills) + len(missed) + len(failed),
        },
        "corpus_meta": list(corpus_meta),
        "domain_rules": {
            "count": len(domain_rules),
            "source": str(domain_rules_source) if domain_rules_source else None,
        },
        "baseline_evidence": {
            "source": list(source_evidence),
            "purpose": list(baseline.get("purpose_evidence", [])),
            "entrypoint": list(baseline.get("entrypoint_evidence", [])),
            "planning": list(baseline.get("planning_evidence", [])),
            "stack": list(baseline.get("stack_evidence", [])),
            "gotcha": list(baseline.get("gotcha_evidence", [])),
            "instruction": list(instruction_evidence)[:30],
            "instruction_omitted": max(0, len(instruction_evidence) - 30),
            "skills": list(skill_evidence),
            "validation": list(validation_evidence),
        },
        "memory_derived": {
            "rows": rows_serialized,
            "level_changes": list(level_changes),
            "evidence_fallback": [quote(item) for item in evidence],
            "had_data": bool(map_rows),
        },
        "needs_verification": [
            "Assistant claims in transcripts are not treated as current repo truth without live checks."
            if a_lines
            else "No assistant claims found. Re-run extraction if this is unexpected."
        ],
        "agent_compensation": {
            "rows": rows_serialized,
            "default": "Use read-only exploration before recommendations." if not map_rows else None,
        },
        "self_healing_loop": {
            "top": rows_serialized[0] if rows_serialized else None,
            "fallback": None if rows_serialized else "No repeated failure signal found; keep dry-run report advisory.",
        },
        "skill_inventory": {
            "available_count": len(valid_skills),
            "invalid_count": len(invalid_skills),
            "rows": [
                {"name": item.get("name", "unknown"), "path": item.get("path", "")}
                for item in valid_skills[:12]
            ],
            "omitted": max(0, len(valid_skills) - 12),
        },
        "skill_usage": {
            "expected": expected,
            "loaded": loaded,
            "applied": applied,
        },
        "skill_health": {
            "invalid": [item.get("path") or item.get("name", "unknown") for item in invalid_skills],
            "missed": missed,
            "failed_to_apply": failed,
        },
        "skill_compensation": {
            "rows": [
                {
                    "skill": row.get("skill", "unknown"),
                    "signal": row.get("impact_signal", "needs_review"),
                    "gate": row.get("candidate_adjustment", ""),
                    "count": row.get("expected_sessions", row.get("missed_sessions", 1)),
                }
                for row in impact_rows
            ],
        },
        "next_agent_brief": _next_agent_brief(rows_serialized),
        "proposed_evergreen": list(proposed_evergreen),
        "prior_report_path": str(prior_report) if prior_report else None,
    }
    return payload


def _next_agent_brief(rows: list[dict]) -> list[str]:
    items = [
        "Start by reading repo-local instruction files and skill inventory.",
        "Treat transcript-derived memories as advisory until verified in the current checkout.",
    ]
    if rows:
        for row in rows[:5]:
            items.append(f"{row['domain']}: {row['gate']}")
    else:
        items.append("Convert repeated corrections into gates, not personality claims.")
    items.append("Do not append durable personalization unless validation passes and --write was requested.")
    return items


# ---------- HTML rendering primitives ----------


_ROMAN = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]


def _esc(value: Any) -> str:
    return html.escape("" if value is None else str(value), quote=True)


def _esc_attr(value: Any) -> str:
    return html.escape("" if value is None else str(value), quote=True)


def _truncate(text: str, limit: int) -> str:
    if not text:
        return ""
    if len(text) <= limit:
        return text
    return text[: limit - 1].rstrip() + "…"


def _wrap(text: str, width: int, max_lines: int = 3) -> list[str]:
    """Word-wrap to a character width without flowing past max_lines."""
    if not text:
        return []
    words = text.split()
    lines: list[str] = []
    current = ""
    for word in words:
        if not current:
            current = word
        elif len(current) + 1 + len(word) <= width:
            current = f"{current} {word}"
        else:
            lines.append(current)
            current = word
            if len(lines) == max_lines - 1:
                # one line of budget left; pack the remainder
                remainder = " ".join([current] + words[words.index(word) + 1:])
                if len(remainder) > width:
                    remainder = remainder[: width - 1].rstrip() + "…"
                lines.append(remainder)
                return lines
    if current:
        lines.append(current)
    return lines[:max_lines]


def _level_class(level: str) -> str:
    if level == "3":
        return "lv lv-3"
    if level == "2":
        return "lv lv-2"
    return "lv lv-1"


def _level_roman(level: str) -> str:
    return {"3": "III", "2": "II"}.get(level, "I")


def _level_marks(level: str) -> str:
    filled = 3 if level == "3" else 2 if level == "2" else 1
    return (
        '<span class="lvl" aria-hidden="true">'
        + "".join(
            f'<i class="lvl__seg{" lvl__seg--on" if i < filled else ""}"></i>'
            for i in range(3)
        )
        + "</span>"
    )


def _section_num(idx: int) -> str:
    return f"{idx:02d}"


# ---------- charts ----------


def _editorial_bar_chart(rows: list[dict], *, caption: str | None = None) -> str:
    """A serious-looking bar chart with tick marks and tabular numerals."""
    if not rows:
        return '<p class="empty editorial">— No data for this chart yet.</p>'
    width = 760
    bar_h = 22
    gap = 16
    label_col = 280
    chart_left = label_col + 24
    chart_right = width - 64
    chart_w = chart_right - chart_left
    body_h = len(rows) * (bar_h + gap)
    height = body_h + 56
    max_value = max((r.get("value") or 0) for r in rows) or 1

    out = [
        f'<svg class="chart-svg" viewBox="0 0 {width} {height}" '
        f'xmlns="http://www.w3.org/2000/svg" role="img" aria-label="bar chart">'
    ]
    # top hairline
    out.append(f'<line x1="0" y1="0.5" x2="{width}" y2="0.5" class="rule-strong"/>')
    # vertical hairline before bars
    out.append(f'<line x1="{chart_left - 0.5}" y1="14" x2="{chart_left - 0.5}" y2="{body_h + 20}" class="rule"/>')
    # tick grid + labels
    ticks = [0.25, 0.5, 0.75, 1.0]
    for t in ticks:
        x = chart_left + t * chart_w
        tval = int(round(t * max_value))
        out.append(f'<line x1="{x:.1f}" y1="14" x2="{x:.1f}" y2="{body_h + 20}" class="tick-grid"/>')
        out.append(f'<line x1="{x:.1f}" y1="{body_h + 20}" x2="{x:.1f}" y2="{body_h + 28}" class="tick-mark"/>')
        out.append(f'<text x="{x:.1f}" y="{body_h + 44}" class="tick-text" text-anchor="middle">{tval}</text>')
    # bars
    for i, row in enumerate(rows):
        y = 18 + i * (bar_h + gap)
        value = row.get("value") or 0
        ratio = value / max_value if max_value else 0
        bw = max(2.0, ratio * chart_w)
        cls = _level_class(row.get("level", ""))
        label = _esc(row.get("label", ""))
        # label on the left, right-aligned
        out.append(
            f'<text x="{label_col}" y="{y + bar_h / 2 + 4:.1f}" '
            f'class="bar-label" text-anchor="end">{label}</text>'
        )
        # bar
        out.append(
            f'<rect x="{chart_left}" y="{y}" width="{bw:.1f}" height="{bar_h}" '
            f'class="bar {cls}"/>'
        )
        # value
        out.append(
            f'<text x="{chart_left + bw + 10:.1f}" y="{y + bar_h / 2 + 4:.1f}" '
            f'class="bar-value">{_esc(value)}</text>'
        )
    # bottom axis
    out.append(f'<line x1="{chart_left}" y1="{body_h + 20.5}" x2="{chart_right}" y2="{body_h + 20.5}" class="rule"/>')
    if caption:
        out.append(
            f'<text x="{width - 4}" y="{height - 6}" class="chart-caption" text-anchor="end">{_esc(caption)}</text>'
        )
    out.append("</svg>")
    return "".join(out)


def _flow_diagram(rows: list[dict]) -> str:
    """HTML 3-step flow: what we saw → pattern → what next agent does.

    Replaces the older SVG diagram. HTML reflows on narrow viewports and
    is far easier to read than text-in-SVG.
    """
    if not rows:
        return '<p class="empty editorial">— No repeated patterns found this run.</p>'
    blocks = []
    legend = (
        '<div class="flow-legend" role="presentation">'
        '<span class="flow-step-mark">1</span><span class="flow-step-name">What we saw</span>'
        '<span class="flow-arrow" aria-hidden="true">→</span>'
        '<span class="flow-step-mark">2</span><span class="flow-step-name">The pattern</span>'
        '<span class="flow-arrow" aria-hidden="true">→</span>'
        '<span class="flow-step-mark">3</span><span class="flow-step-name">What next agent does</span>'
        '</div>'
    )
    blocks.append(legend)
    blocks.append('<ol class="flow-rows">')
    for idx, row in enumerate(rows, start=1):
        level = row.get("level", "1-2")
        level_label, _ = _LEVEL_HUMAN.get(level, _LEVEL_HUMAN["1-2"])
        sessions = row.get("session_refs", [])
        signal = _esc(row.get("failure_signal", ""))
        domain = _esc(row.get("domain", ""))
        category = _esc(row.get("gate_category", ""))
        gate = _esc(row.get("gate", ""))
        count = _esc(row.get("count", 0))
        nsessions = _esc(len(sessions))
        blocks.append(
            f'<li class="flow-row {_level_class(level)}">'
            f'  <div class="flow-row__index" aria-hidden="true">{_section_num(idx)}</div>'
            f'  <div class="flow-step flow-step--signal">'
            f'    <div class="flow-step__kicker"><span>SIGNAL</span><em>topic · {domain}</em></div>'
            f'    <p class="flow-step__body">{signal}</p>'
            f'    <p class="flow-step__meta">{count}× across {nsessions} sessions</p>'
            f'  </div>'
            f'  <div class="flow-step__arrow" aria-hidden="true">'
            f'    <span class="flow-step__chevron">›</span>'
            f'    <span class="flow-step__verb">becomes</span>'
            f'  </div>'
            f'  <div class="flow-step flow-step--pattern">'
            f'    <div class="flow-step__kicker"><span>PATTERN</span><em>{level_label} · level {_level_roman(level)}</em></div>'
            f'    <p class="flow-step__body flow-step__body--strong">{category}</p>'
            f'    <p class="flow-step__meta">classifies under <code>{category}</code></p>'
            f'  </div>'
            f'  <div class="flow-step__arrow" aria-hidden="true">'
            f'    <span class="flow-step__chevron">›</span>'
            f'    <span class="flow-step__verb">tells the next agent to</span>'
            f'  </div>'
            f'  <div class="flow-step flow-step--directive">'
            f'    <div class="flow-step__kicker"><span>DIRECTIVE</span><em>load first next session</em></div>'
            f'    <p class="flow-step__body flow-step__body--strong">{gate}</p>'
            f'  </div>'
            f'</li>'
        )
    blocks.append('</ol>')
    return "".join(blocks)


def _skill_slat_chart(rows: list[dict]) -> str:
    if not rows or all(r.get("value", 0) == 0 for r in rows):
        return (
            '<p class="empty editorial">— No skill telemetry was supplied to this '
            "filing. Pass <code>--skill-map</code> / <code>--skill-usage</code> / "
            "<code>--skill-impact</code> JSON to populate this chart.</p>"
        )
    width = 760
    row_h = 24
    gap = 6
    label_col = 200
    chart_left = label_col + 24
    chart_w = width - chart_left - 80
    max_value = max((r.get("value") or 0) for r in rows) or 1
    height = len(rows) * (row_h + gap) + 20
    out = [
        f'<svg class="chart-svg" viewBox="0 0 {width} {height}" '
        f'xmlns="http://www.w3.org/2000/svg" role="img" aria-label="skill chart">'
    ]
    out.append(f'<line x1="0" y1="0.5" x2="{width}" y2="0.5" class="rule"/>')
    for i, row in enumerate(rows):
        y = 12 + i * (row_h + gap)
        value = row.get("value") or 0
        ratio = value / max_value
        bw = max(2.0, ratio * chart_w)
        cls = _level_class(row.get("level", ""))
        out.append(
            f'<text x="{label_col}" y="{y + row_h / 2 + 4:.1f}" class="bar-label" text-anchor="end">{_esc(row["label"]).upper()}</text>'
        )
        out.append(
            f'<rect x="{chart_left}" y="{y + 6}" width="{bw:.1f}" height="{row_h - 12}" class="bar bar-thin {cls}"/>'
        )
        out.append(
            f'<text x="{chart_left + bw + 10:.1f}" y="{y + row_h / 2 + 4:.1f}" class="bar-value">{_esc(value)}</text>'
        )
    out.append("</svg>")
    return "".join(out)


# ---------- composed blocks ----------


def _story_block(payload: dict) -> str:
    """A plain-English summary of what this filing contains, with three big
    numbers highlighted inline so a non-technical reader can read the report
    in one breath."""
    t = payload["totals"]
    gates = t["gates"]
    sessions = t["corpus_meta"]
    evidence = t["evidence_lines"]
    user_lines = t["user_lines"]
    if gates == 0:
        narrative = (
            f"Watched <strong>{user_lines}</strong> user messages across "
            f"<strong>{sessions}</strong> session roots. No patterns repeated "
            "often enough to become rules yet — widen the window or add more "
            "domain rules."
        )
        verdict = "QUIET RUN"
    else:
        rule_word = "rule" if gates == 1 else "rules"
        pattern_word = "pattern repeated" if gates == 1 else "patterns repeated"
        narrative = (
            f"We watched <strong>{user_lines}</strong> user messages across "
            f"<strong>{sessions}</strong> session roots. "
            f"<strong>{gates}</strong> {pattern_word} often enough to become "
            f"a {rule_word} the next agent must check. "
            f"<strong>{evidence}</strong> lines of feedback back them up."
        )
        verdict = f"{gates} new {rule_word.upper()}"
    return (
        f'<section class="story" aria-label="At a glance">'
        f'  <div class="story__tag"><span class="story__dot"></span><span>AT A GLANCE</span></div>'
        f'  <p class="story__line">{narrative}</p>'
        f'  <div class="story__verdict">{_esc(verdict)}</div>'
        f'</section>'
    )


def _abstract_strip(payload: dict) -> str:
    t = payload["totals"]
    # Plain-English labels with secondary explainer. Hide stats that are zero
    # to avoid noise.
    cells = [
        (t["gates"], "Rules", "patterns the next agent must clear"),
        (t["evidence_lines"], "Feedback lines", "user messages backing the rules"),
        (t["corpus_meta"], "Session roots", "transcripts mined this run"),
        (t["user_lines"], "User messages", "across all transcripts"),
        (t["domain_rules"], "Topics", "domain rules loaded"),
        (t["skills_available"], "Skills seen", "available in this repo"),
        (t["skill_alerts"], "Alerts", "skills missed or broken"),
    ]
    inner = []
    for value, label, sub in cells:
        if value == 0 and label in ("Skills seen", "Alerts"):
            continue
        inner.append(
            f'<div class="ab__stat">'
            f'<span class="ab__num">{_esc(value)}</span>'
            f'<span class="ab__label">{_esc(label)}</span>'
            f'<span class="ab__sub">{_esc(sub)}</span>'
            f'</div>'
        )
    return f'<div class="abstract" data-count="{len(inner)}">{"".join(inner)}</div>'


def _toc(sections: list[tuple[str, str, str]]) -> str:
    """sections: [(anchor_id, title, kicker)]"""
    lis = []
    for idx, (anchor, title, _kicker) in enumerate(sections, start=1):
        num = _section_num(idx)
        lis.append(
            f'<li><a href="#{anchor}">'
            f'<span class="toc__num">{num}</span>'
            f'<span class="toc__title">{_esc(title)}</span>'
            f"</a></li>"
        )
    return (
        '<nav class="toc" aria-label="contents">'
        '<span class="toc__label">Contents</span>'
        f'<ol>{"".join(lis)}</ol>'
        "</nav>"
    )


SECTION_LEDES: dict[str, str] = {
    "gates": "Things the next agent must double-check before doing anything in this repo.",
    "flow": "How one repeated mistake turns into a permanent rule.",
    "memory": "What topics kept tripping us up, and how often.",
    "skills": "Which tools the agent had, used, and missed.",
    "baseline": "What we know is true in the repo right now.",
    "needs-verify": "Things to recheck live — don't trust without verifying.",
    "brief": "Tell the next agent this, in order.",
}


def _section_open(idx: int, anchor: str, title: str, kicker: str, explainer_key: str) -> str:
    explainer = SECTION_EXPLAINERS.get(explainer_key, "")
    lede = SECTION_LEDES.get(anchor, "")
    lede_html = f'<p class="entry__lede">{_esc(lede)}</p>' if lede else ""
    return (
        f'<section class="entry" id="{anchor}" data-section="{_section_num(idx)}">'
        '<aside class="entry__rail">'
        f'<span class="entry__num">{_section_num(idx)}</span>'
        f'<span class="entry__kicker">{_esc(kicker).upper()}</span>'
        f'<p class="entry__note">{_esc(explainer)}</p>'
        "</aside>"
        '<div class="entry__body">'
        f'<h2 class="entry__title">{_esc(title)}</h2>'
        f'{lede_html}'
    )


def _section_close() -> str:
    return "</div></section>"


_LEVEL_HUMAN = {
    "3": ("HIGH",     "Saw this many times. Treat as a hard rule."),
    "2": ("MEDIUM",   "Repeated enough to matter. Treat as a working rule."),
    "1-2": ("LOW",    "Saw it once or twice. Worth a glance."),
}


def _gate_cards(rows: list[dict]) -> str:
    if not rows:
        return (
            '<p class="empty editorial">— No rules surfaced this filing. '
            "Widen the session window or check that domain rules match this repo.</p>"
        )
    cards = []
    for idx, row in enumerate(rows, start=1):
        sessions = row.get("session_refs", [])
        sessions_html = ""
        if sessions:
            tail = "" if len(sessions) <= 4 else f' <span class="muted">+{len(sessions) - 4} more</span>'
            chips = "".join(
                f'<span class="rc-session">{_esc(s)}</span>' for s in sessions[:4]
            )
            sessions_html = f'<div class="rc-sessions">{chips}{tail}</div>'
        quote_html = ""
        if row.get("quote"):
            quote_html = (
                f'<blockquote class="rc-quote">{_esc(row["quote"])}</blockquote>'
            )
        level = row.get("level", "1-2")
        level_label, level_blurb = _LEVEL_HUMAN.get(level, _LEVEL_HUMAN["1-2"])
        domain = row.get("domain", "")
        initials = "".join(part[0] for part in domain.replace("_", "-").split("-")[:2]).upper() or "?"
        cards.append(
            f'<article class="rule-card {_level_class(level)}">'
            f'  <header class="rc-head">'
            f'    <span class="rc-num">RULE {_section_num(idx)}</span>'
            f'    <span class="rc-dot" aria-hidden="true"></span>'
            f'    <span class="rc-level">{level_label}</span>'
            f'    <span class="rc-level-meta" aria-hidden="true">Level {_level_roman(level)}</span>'
            f'  </header>'
            f'  <div class="rc-body">'
            f'    <div class="rc-domain"><span class="rc-initials">{_esc(initials)}</span>'
            f'      <span class="rc-domain-name">{_esc(domain)}</span></div>'
            f'    <p class="rc-rule">{_esc(row.get("gate", ""))}</p>'
            f'    {quote_html}'
            f'  </div>'
            f'  <footer class="rc-foot">'
            f'    <span class="rc-stat"><strong>{_esc(row.get("count", 0))}×</strong> seen</span>'
            f'    <span class="rc-stat"><strong>{_esc(len(sessions))}</strong> sessions</span>'
            f'    <span class="rc-stat rc-stat-blurb">{_esc(level_blurb)}</span>'
            f'    <span class="rc-stat rc-stat-cat">category &middot; <code>{_esc(row.get("gate_category", ""))}</code></span>'
            f'  </footer>'
            f'  {sessions_html}'
            f'</article>'
        )
    return f'<div class="rule-cards">{"".join(cards)}</div>'


# Backward-compatible alias — referenced in render_html_report.
_gate_table = _gate_cards


def _gate_bar_data(rows: list[dict]) -> list[dict]:
    return [
        {
            "level": row.get("level", ""),
            "label": f'{row["domain"]} · {row["gate_category"]} · L{_level_roman(row["level"])}',
            "value": row.get("count", 0),
        }
        for row in rows
    ]


def _session_coverage_data(rows: list[dict]) -> list[dict]:
    return [
        {
            "level": row.get("level", ""),
            "label": f'{row["domain"]} · {len(row.get("session_refs", []))} sessions',
            "value": len(row.get("session_refs", [])),
        }
        for row in rows
    ]


def _skill_chart_data(payload: dict) -> list[dict]:
    sk = payload["skill_usage"]
    inv = payload["skill_inventory"]
    h = payload["skill_health"]
    return [
        {"label": "available", "value": inv.get("available_count", 0), "level": "3"},
        {"label": "expected", "value": len(sk.get("expected", [])), "level": "2"},
        {"label": "loaded", "value": len(sk.get("loaded", [])), "level": "2"},
        {"label": "applied", "value": len(sk.get("applied", [])), "level": "3"},
        {"label": "invalid", "value": len(h.get("invalid", [])), "level": "1-2"},
        {"label": "missed", "value": len(h.get("missed", [])), "level": "1-2"},
        {"label": "loaded · not applied", "value": len(h.get("failed_to_apply", [])), "level": "1-2"},
    ]


def _memory_list(payload: dict) -> str:
    rows = payload["memory_derived"]["rows"]
    if not rows:
        fallback = payload["memory_derived"]["evidence_fallback"]
        if fallback:
            items = "".join(
                f'<li><blockquote class="pull">{_esc(q)}</blockquote></li>'
                for q in fallback[:10]
            )
            return f'<ul class="memory">{items}</ul>'
        return '<p class="empty editorial">— No reusable user-steering evidence in this corpus.</p>'
    items = []
    for idx, row in enumerate(rows, start=1):
        sessions = row.get("session_refs", [])
        sess_disp = ", ".join(sessions[:5])
        if len(sessions) > 5:
            sess_disp += f", +{len(sessions) - 5}"
        quote_html = (
            f'<blockquote class="pull">{_esc(row["quote"])}</blockquote>' if row.get("quote") else ""
        )
        items.append(
            f'<li class="memory__item">'
            f'<div class="memory__head">'
            f'<span class="memory__num">{_section_num(idx)}</span>'
            f'<span class="memory__domain">{_esc(row["domain"]).upper()}</span>'
            f'<span class="lv-chip {_level_class(row["level"])}">{_level_roman(row["level"])}</span>'
            f'<span class="memory__meta">{_esc(row["evidence_label"])}</span>'
            f'</div>'
            f'<p class="memory__signal">{_esc(row["failure_signal"])}</p>'
            f'{quote_html}'
            f'<p class="memory__sessions"><span class="kicker">SESSIONS</span> {_esc(sess_disp) or "—"}</p>'
            f'</li>'
        )
    return f'<ol class="memory">{"".join(items)}</ol>'


def _baseline_section(payload: dict) -> str:
    baseline = payload["baseline_evidence"]
    groups = [
        ("Source-of-truth files", baseline["source"], "fact"),
        ("Purpose", baseline["purpose"], "fact"),
        ("Entrypoints", baseline["entrypoint"], "fact"),
        ("Planning", baseline["planning"], "fact"),
        ("Stack", baseline["stack"], "fact"),
        ("Gotchas", baseline["gotcha"], "fact"),
    ]
    blocks: list[str] = []
    for label, items, key in groups:
        if not items:
            continue
        rows = "".join(
            f'<li><span class="fact-text">{_esc(item.get(key, ""))}</span>'
            f'<span class="fact-src">{_esc(item.get("source", ""))}</span></li>'
            for item in items
        )
        blocks.append(
            f'<details class="kvs" open><summary><span class="kvs__count">{len(items)}</span><span>{_esc(label).upper()}</span></summary>'
            f'<ul class="evidence">{rows}</ul></details>'
        )
    if baseline["instruction"]:
        rows = "".join(
            f'<li><span class="fact-text">{_esc(item.get("fact", ""))}</span>'
            f'<span class="fact-src">{_esc(item.get("source", ""))}</span></li>'
            for item in baseline["instruction"]
        )
        omitted = baseline["instruction_omitted"]
        omitted_msg = f' <span class="muted">(+{omitted} omitted)</span>' if omitted else ""
        blocks.append(
            f'<details class="kvs"><summary><span class="kvs__count">{len(baseline["instruction"])}</span><span>INSTRUCTION RULES{omitted_msg}</span></summary>'
            f'<ul class="evidence">{rows}</ul></details>'
        )
    if baseline["skills"]:
        rows = "".join(
            f'<li><code class="fact-text">{_esc(item.get("path", ""))}</code>'
            f'<span class="fact-src">{_esc(item.get("source", ""))}</span></li>'
            for item in baseline["skills"]
        )
        blocks.append(
            f'<details class="kvs"><summary><span class="kvs__count">{len(baseline["skills"])}</span><span>REPO-LOCAL SKILLS</span></summary>'
            f'<ul class="evidence">{rows}</ul></details>'
        )
    if baseline["validation"]:
        rows = "".join(
            f'<li><code class="fact-text">{_esc(item.get("command", ""))}</code>'
            f'<span class="fact-src">{_esc(item.get("source", ""))}</span></li>'
            for item in baseline["validation"]
        )
        blocks.append(
            f'<details class="kvs"><summary><span class="kvs__count">{len(baseline["validation"])}</span><span>VALIDATION COMMANDS</span></summary>'
            f'<ul class="evidence">{rows}</ul></details>'
        )
    if payload["corpus_meta"]:
        rows = "".join(
            f'<li><code class="fact-text">{_esc(item)}</code></li>'
            for item in payload["corpus_meta"]
        )
        blocks.append(
            f'<details class="kvs"><summary><span class="kvs__count">{len(payload["corpus_meta"])}</span><span>CORPUS METADATA</span></summary>'
            f'<ul class="evidence">{rows}</ul></details>'
        )
    if not blocks:
        blocks.append(
            '<p class="empty editorial">— No repo baseline sources were discovered. '
            "The pipeline ran against an empty baseline.</p>"
        )
    return "".join(blocks)


def _list_block(items: list[str], css: str = "linear", ordered: bool = False) -> str:
    if not items:
        return '<p class="empty editorial">—</p>'
    tag = "ol" if ordered else "ul"
    lis = "".join(f"<li>{_esc(item)}</li>" for item in items)
    return f'<{tag} class="{css}">{lis}</{tag}>'


def _skill_inventory_block(payload: dict) -> str:
    inv = payload["skill_inventory"]
    if inv["available_count"] == 0 and not inv["rows"]:
        return '<p class="empty editorial">— No skills discovered. Pass <code>--skill-map</code> JSON from <code>map_active_skills.py</code> to populate this.</p>'
    rows = "".join(
        f'<li><strong>{_esc(row["name"])}</strong> <code>{_esc(row["path"])}</code></li>'
        for row in inv["rows"]
    )
    omitted = inv.get("omitted", 0)
    omitted_msg = f' <span class="muted">(+{omitted} omitted)</span>' if omitted else ""
    return (
        f'<p class="lede">Available: <strong>{_esc(inv["available_count"])}</strong>{omitted_msg}'
        f' · Invalid: <strong>{_esc(inv["invalid_count"])}</strong></p>'
        f'<ul class="linear">{rows}</ul>'
    )


def _skill_health_block(payload: dict) -> str:
    h = payload["skill_health"]
    if not (h["invalid"] or h["missed"] or h["failed_to_apply"]):
        return '<p class="empty editorial">— No skill-health alerts.</p>'
    blocks = []
    if h["invalid"]:
        blocks.append('<h4 class="sub">INVALID</h4>' + _list_block(h["invalid"]))
    if h["missed"]:
        blocks.append('<h4 class="sub">MISSED EXPECTED SKILL</h4>' + _list_block(h["missed"]))
    if h["failed_to_apply"]:
        blocks.append('<h4 class="sub">LOADED · NOT APPLIED</h4>' + _list_block(h["failed_to_apply"]))
    return "".join(blocks)


def _skill_compensation_block(payload: dict) -> str:
    rows = payload["skill_compensation"]["rows"]
    if not rows:
        return '<p class="empty editorial">— No candidate skill adjustments.</p>'
    items = []
    for row in rows:
        items.append(
            f'<li><strong>{_esc(row["skill"])}</strong> '
            f'<span class="muted">— {_esc(row["signal"])} '
            f'({_esc(row["count"])} sessions)</span>'
            f'<div>{_esc(row["gate"])}</div></li>'
        )
    return f'<ul class="memory">{"".join(items)}</ul>'


def _brief_block(items: list[str]) -> str:
    if not items:
        return '<p class="empty editorial">—</p>'
    lis = "".join(
        f'<li><span class="brief__num">{_section_num(idx)}</span>'
        f'<span class="brief__text">{_esc(item)}</span></li>'
        for idx, item in enumerate(items, start=1)
    )
    return f'<ol class="brief">{lis}</ol>'


def _legend() -> str:
    parts = []
    for level, meaning, _name in LEVEL_LEGEND:
        parts.append(
            f'<span class="lg__item">'
            f'<span class="lv-chip {_level_class(level)}">{_level_roman(level)}</span>'
            f'<span class="lg__meaning">{_esc(meaning)}</span>'
            f'</span>'
        )
    return f'<div class="legend">{"".join(parts)}</div>'


# ---------- master render ----------


SECTIONS = [
    ("gates", "Approved gates", "Procedural memory", "agent_compensation"),
    ("flow", "Self-healing loop", "Signal → Directive", "self_healing_loop"),
    ("memory", "Memory derived", "Per domain", "memory_derived"),
    ("skills", "Skill health", "Inventory · Usage · Alerts", "skill_health"),
    ("baseline", "Confirmed current", "Live repo facts", "confirmed_current"),
    ("needs-verify", "Needs verification", "Recheck before use", "needs_verification"),
    ("brief", "Next agent brief", "Compressed handoff", "next_agent_brief"),
]


def render_html_report(payload: dict, *, page_title: str | None = None) -> str:
    title = page_title or f"Agent Learning Report — {payload['date']}"
    totals = payload["totals"]
    gates_rows = payload["agent_compensation"]["rows"]

    # accent rule width is proportional to the share of strong gates
    strong = sum(1 for r in gates_rows if r.get("level") == "3")
    if gates_rows:
        accent_ratio = max(0.18, strong / len(gates_rows))
    else:
        accent_ratio = 0.18

    domain_rules_src = payload["domain_rules"]["source"] or "—"
    prior = payload.get("prior_report_path") or "—"

    payload_json = html.escape(
        json.dumps(payload, indent=2, ensure_ascii=False), quote=False
    )

    # Build sections
    gate_caption = f"n = {totals['evidence_lines']} matching lines · {len(gates_rows)} gates"
    coverage_caption = "distinct sessions per domain"
    sec_html: list[str] = []
    for idx, (anchor, sec_title, kicker, explainer_key) in enumerate(SECTIONS, start=1):
        sec_html.append(_section_open(idx, anchor, sec_title, kicker, explainer_key))
        if anchor == "gates":
            sec_html.append(_gate_table(gates_rows))
            chart_svg = _editorial_bar_chart(_gate_bar_data(gates_rows), caption=gate_caption)
            sec_html.append(
                f'<figure class="chart">{chart_svg}'
                f'<figcaption>Fig. 1 · Matching user-line count per gate, by level.</figcaption>'
                f'</figure>'
            )
        elif anchor == "flow":
            sec_html.append(
                f'<figure class="chart chart--wide">'
                f'{_flow_diagram(gates_rows)}'
                f'<figcaption>Fig. 2 · Failure signal → gate category → directive. Level marks indicate evidence strength.</figcaption>'
                f'</figure>'
            )
        elif anchor == "memory":
            cov_svg = _editorial_bar_chart(_session_coverage_data(gates_rows), caption=coverage_caption)
            sec_html.append(
                f'<figure class="chart">{cov_svg}'
                f'<figcaption>Fig. 3 · Distinct session count per domain.</figcaption>'
                f'</figure>'
            )
            sec_html.append(_memory_list(payload))
        elif anchor == "skills":
            sec_html.append(
                f'<figure class="chart">'
                f'{_skill_slat_chart(_skill_chart_data(payload))}'
                f'<figcaption>Fig. 4 · Skill telemetry — available, expected, loaded, applied, alerts.</figcaption>'
                f'</figure>'
            )
            sec_html.append('<h3 class="sub">INVENTORY</h3>' + _skill_inventory_block(payload))
            sec_html.append('<h3 class="sub">USAGE</h3>')
            sec_html.append(
                f'<p class="lede">Expected <strong>{_esc(len(payload["skill_usage"]["expected"]))}</strong>'
                f' &middot; Loaded <strong>{_esc(len(payload["skill_usage"]["loaded"]))}</strong>'
                f' &middot; Applied <strong>{_esc(len(payload["skill_usage"]["applied"]))}</strong></p>'
            )
            sec_html.append('<h3 class="sub">ALERTS</h3>' + _skill_health_block(payload))
            sec_html.append('<h3 class="sub">COMPENSATION CANDIDATES</h3>' + _skill_compensation_block(payload))
        elif anchor == "baseline":
            sec_html.append(_baseline_section(payload))
        elif anchor == "needs-verify":
            sec_html.append(_list_block(payload["needs_verification"], css="linear"))
        elif anchor == "brief":
            sec_html.append(_brief_block(payload["next_agent_brief"]))
            if payload["proposed_evergreen"]:
                sec_html.append('<h3 class="sub">PROPOSED EVERGREEN DIFFS</h3>')
                sec_html.append(
                    f'<p class="lede note">{_esc(SECTION_EXPLAINERS["proposed_evergreen_diffs"])}</p>'
                )
                sec_html.append(_list_block(payload["proposed_evergreen"], css="linear"))
        sec_html.append(_section_close())

    body = f"""
<article class="page">
  <div class="page__shoulder" aria-hidden="true">
    <span class="filenum">Field report</span>
    <span class="filenum filenum--mid">{_esc(payload['date'])} · {_esc(payload['mode']).upper()}</span>
    <span class="filenum">Volume I</span>
  </div>

  <button class="theme-toggle" type="button" aria-label="Toggle dark mode" data-action="toggle-theme">
    <span class="theme-toggle__dot" aria-hidden="true"></span>
    <span class="theme-toggle__label">Theme</span>
  </button>

  <header class="masthead">
    <div class="masthead__overline">Agent-learning · evidence-backed gates</div>
    <h1 class="masthead__title">Agent <em>Learning</em> Report</h1>
    <p class="masthead__sub">Distilled procedural memory · self-healing loop · next-session brief.</p>
    <div class="masthead__rule"></div>
    <div class="masthead__rule masthead__rule--accent" style="--w:{accent_ratio*100:.0f}%"></div>
    <dl class="masthead__meta">
      <div><dt>Filed</dt><dd>{_esc(payload['date'])}</dd></div>
      <div><dt>Mode</dt><dd>{_esc(payload['mode']).upper()}</dd></div>
      <div><dt>Repository</dt><dd>{_esc(payload['repo'])}</dd></div>
      <div><dt>Domain rules</dt><dd>{_esc(payload['domain_rules']['count'])}<span class="muted"> &middot; {_esc(pathlib.Path(domain_rules_src).name) if domain_rules_src != '—' else '—'}</span></dd></div>
      <div><dt>Prior dossier</dt><dd><code class="long">{_esc(pathlib.Path(prior).name) if prior != '—' else '—'}</code></dd></div>
    </dl>
  </header>

  {_story_block(payload)}
  {_abstract_strip(payload)}
  {_legend()}
  {_toc([(a, t, k) for (a, t, k, _ex) in SECTIONS])}

  <main class="entries">
    {''.join(sec_html)}
  </main>

  <footer class="colophon">
    <div class="col-row">
      <span><em>Set in</em> system serif &amp; monospace · hand-rolled SVG · zero external assets</span>
      <span>Generated by <code>agent-learning-compounder</code></span>
    </div>
    <div class="col-row col-row--sub">
      <span>Filed {_esc(payload['date'])} · {_esc(payload['mode']).upper()}</span>
      <span>Structured payload embedded as <code>application/json</code> below.</span>
    </div>
  </footer>
</article>

<script type="application/json" id="report-payload">{payload_json}</script>
<script>
(function () {{
  var KEY = 'alc-theme';
  var root = document.documentElement;
  function apply(theme) {{
    if (theme === 'dark' || theme === 'light') {{
      root.setAttribute('data-theme', theme);
    }} else {{
      root.removeAttribute('data-theme');
    }}
  }}
  try {{ apply(localStorage.getItem(KEY)); }} catch (_) {{}}
  document.addEventListener('click', function (event) {{
    var btn = event.target.closest('[data-action="toggle-theme"]');
    if (!btn) return;
    var current = root.getAttribute('data-theme');
    var media = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
    var next;
    if (!current) {{ next = media ? 'light' : 'dark'; }}
    else if (current === 'dark') {{ next = 'light'; }}
    else {{ next = 'dark'; }}
    apply(next);
    try {{ localStorage.setItem(KEY, next); }} catch (_) {{}}
  }});
}})();
</script>
"""

    css = _stylesheet()

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{_esc(title)}</title>
<style>{css}</style>
</head>
<body>
{body}
</body>
</html>
"""


def _stylesheet() -> str:
    """Editorial dossier — newsprint in light, phosphor on graphite in dark."""
    return """
:root {
  --paper:        #f0ebde;
  --paper-2:      #e7e0cd;
  --paper-edge:   #d9d1b8;
  --ink:          #181612;
  --ink-soft:     #3b3530;
  --muted:        #7c7669;
  --rule:         #c8bfa6;
  --rule-soft:    #ddd5bb;
  --rule-strong:  #1d1a16;
  --accent:       #9a3a0c;
  --accent-soft:  rgba(154,58,12,0.10);
  --lv3:          #9a3a0c;
  --lv3-soft:     rgba(154,58,12,0.10);
  --lv2:          #7a6217;
  --lv2-soft:     rgba(122,98,23,0.10);
  --lv1:          #9a948a;
  --lv1-soft:     rgba(154,148,138,0.10);
  --highlight:    #f6e5b2;
  --shadow:       0 1px 0 var(--paper-edge), 0 26px 60px -28px rgba(20,16,8,0.32);
  --serif:  "Iowan Old Style", "Source Serif 4", "Source Serif Pro", "Charter", "Cambria", "Constantia", Georgia, "Liberation Serif", serif;
  --display:"Iowan Old Style", "Source Serif 4", "Source Serif Pro", "Charter", "Cambria", "Constantia", Georgia, "Liberation Serif", serif;
  --mono:  "Berkeley Mono", "JetBrains Mono", "IBM Plex Mono", "SF Mono", "Cascadia Code", "Liberation Mono", ui-monospace, monospace;
}
[data-theme="dark"], html[data-theme="dark"] {
  --paper:        #0e0f0b;
  --paper-2:      #15160f;
  --paper-edge:   #232319;
  --ink:          #ece7d6;
  --ink-soft:     #b6b0a0;
  --muted:        #7f796d;
  --rule:         #2b2922;
  --rule-soft:    #1c1a15;
  --rule-strong:  #ece7d6;
  --accent:       #ff8a3a;
  --accent-soft:  rgba(255,138,58,0.14);
  --lv3:          #ff8a3a;
  --lv3-soft:     rgba(255,138,58,0.14);
  --lv2:          #d6b25a;
  --lv2-soft:     rgba(214,178,90,0.14);
  --lv1:          #6c6759;
  --lv1-soft:     rgba(108,103,89,0.18);
  --highlight:    #3a2d10;
  --shadow:       0 1px 0 #000, 0 30px 80px -36px rgba(0,0,0,0.7);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --paper:        #0e0f0b;
    --paper-2:      #15160f;
    --paper-edge:   #232319;
    --ink:          #ece7d6;
    --ink-soft:     #b6b0a0;
    --muted:        #7f796d;
    --rule:         #2b2922;
    --rule-soft:    #1c1a15;
    --rule-strong:  #ece7d6;
    --accent:       #ff8a3a;
    --accent-soft:  rgba(255,138,58,0.14);
    --lv3:          #ff8a3a;
    --lv3-soft:     rgba(255,138,58,0.14);
    --lv2:          #d6b25a;
    --lv2-soft:     rgba(214,178,90,0.14);
    --lv1:          #6c6759;
    --lv1-soft:     rgba(108,103,89,0.18);
    --highlight:    #3a2d10;
    --shadow:       0 1px 0 #000, 0 30px 80px -36px rgba(0,0,0,0.7);
  }
}
* { box-sizing: border-box; }
html { background: var(--paper-2); }
body {
  margin: 0;
  color: var(--ink);
  font-family: var(--serif);
  font-size: 16px;
  line-height: 1.55;
  font-feature-settings: "kern" 1, "liga" 1, "onum" 1, "pnum" 1;
  background:
    radial-gradient(at 12% 6%, var(--accent-soft) 0, transparent 38%),
    radial-gradient(at 88% 92%, rgba(0,0,0,0.06) 0, transparent 40%),
    var(--paper-2);
  min-height: 100vh;
  padding: 48px 20px 80px;
  -webkit-font-smoothing: antialiased;
}
[data-theme="dark"] body,
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) body {
    background:
      radial-gradient(at 8% 4%, rgba(255,138,58,0.06) 0, transparent 45%),
      radial-gradient(at 92% 100%, rgba(255,255,255,0.02) 0, transparent 40%),
      var(--paper-2);
  }
}
.page {
  position: relative;
  max-width: 1100px;
  margin: 0 auto;
  padding: 56px 64px 96px;
  background: var(--paper);
  border: 1px solid var(--paper-edge);
  box-shadow: var(--shadow);
}
.page::before {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(180deg, transparent 0, transparent 60%, rgba(0,0,0,0.02) 100%);
  pointer-events: none;
}

/* shoulder file numbers */
.page__shoulder {
  display: flex; justify-content: space-between; gap: 24px;
  font-family: var(--mono);
  font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
  color: var(--muted);
  padding-bottom: 18px;
  border-bottom: 1px solid var(--rule);
  margin-bottom: 44px;
}
.filenum--mid { color: var(--ink-soft); }

/* theme toggle */
.theme-toggle {
  position: absolute; top: 26px; right: 32px;
  display: inline-flex; align-items: center; gap: 8px;
  background: transparent;
  border: 1px solid var(--rule);
  color: var(--muted);
  font-family: var(--mono); font-size: 10px;
  letter-spacing: 0.2em; text-transform: uppercase;
  padding: 6px 10px 6px 8px;
  cursor: pointer;
  transition: color .15s ease, border-color .15s ease;
}
.theme-toggle:hover { color: var(--ink); border-color: var(--ink-soft); }
.theme-toggle__dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 1px var(--accent-soft);
}

/* masthead */
.masthead { margin: 0 0 56px; }
.masthead__overline {
  font-family: var(--mono);
  font-size: 11px; letter-spacing: 0.24em; text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 28px;
}
.masthead__title {
  font-family: var(--display);
  font-weight: 600;
  font-size: clamp(56px, 9vw, 104px);
  line-height: 0.92;
  letter-spacing: -0.028em;
  margin: 0 0 16px;
  font-feature-settings: "lnum" 1, "kern" 1, "liga" 1;
}
.masthead__title em {
  font-style: italic;
  font-weight: 400;
  color: var(--accent);
}
.masthead__sub {
  font-size: 19px;
  font-style: italic;
  color: var(--ink-soft);
  margin: 0 0 28px;
  max-width: 52ch;
  line-height: 1.45;
}
.masthead__rule { height: 1px; background: var(--rule-strong); }
.masthead__rule--accent { height: 4px; background: var(--accent); width: var(--w, 24%); margin-top: 4px; }
.masthead__meta {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 18px 36px;
  margin: 28px 0 0;
  padding: 18px 0 0;
  border-top: 1px solid var(--rule);
}
.masthead__meta > div { min-width: 0; }
.masthead__meta dt {
  font-family: var(--mono);
  font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 4px;
}
.masthead__meta dd {
  margin: 0;
  font-size: 16px;
  font-feature-settings: "tnum" 1, "lnum" 1;
  word-break: break-word;
}
.masthead__meta dd .muted { color: var(--muted); }
code.long { font-family: var(--mono); font-size: 13px; }

/* abstract — numerical strip */
.abstract {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  border-top: 1px solid var(--rule-strong);
  border-bottom: 1px solid var(--rule);
  margin: 0 0 32px;
  background:
    repeating-linear-gradient(90deg, transparent 0 calc(100%/7), var(--rule) calc(100%/7) calc(100%/7 + 1px));
}
.ab__stat {
  padding: 18px 14px 22px;
  position: relative;
  display: flex; flex-direction: column; gap: 10px;
}
.ab__num {
  font-family: var(--display);
  font-size: 44px;
  line-height: 1;
  font-feature-settings: "tnum" 1, "lnum" 1;
  color: var(--ink);
}
.ab__label {
  font-family: var(--mono);
  font-size: 10px; letter-spacing: 0.18em;
  color: var(--muted);
}
@media (max-width: 880px) {
  .abstract { grid-template-columns: repeat(3, 1fr); background: none; }
  .ab__stat { border-right: 1px solid var(--rule); }
}

/* legend */
.legend {
  display: flex; gap: 28px; flex-wrap: wrap;
  font-family: var(--mono);
  font-size: 11px; letter-spacing: 0.05em;
  color: var(--ink-soft);
  margin: 0 0 40px;
  padding-bottom: 18px;
  border-bottom: 1px solid var(--rule);
}
.lg__item { display: inline-flex; align-items: center; gap: 10px; }
.lg__meaning { letter-spacing: 0.04em; }

/* table of contents */
.toc {
  margin: 0 0 56px;
  padding: 18px 0;
  border-top: 1px solid var(--rule);
  border-bottom: 1px solid var(--rule);
  display: grid; grid-template-columns: 120px 1fr;
  gap: 18px 36px;
}
.toc__label {
  font-family: var(--mono);
  font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
  color: var(--muted);
  padding-top: 4px;
}
.toc ol {
  list-style: none; padding: 0; margin: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 8px 24px;
}
.toc a {
  display: flex; align-items: baseline; gap: 12px;
  color: var(--ink); text-decoration: none;
  border-bottom: 1px dotted var(--rule);
  padding: 6px 0 6px;
  transition: color .15s ease;
}
.toc a:hover { color: var(--accent); }
.toc__num {
  font-family: var(--mono);
  color: var(--muted);
  font-size: 11px; letter-spacing: 0.12em;
}
.toc__title { font-style: italic; font-size: 17px; }

/* entries */
.entries { margin: 0; }
.entry {
  display: grid;
  grid-template-columns: 220px 1fr;
  gap: 64px;
  padding: 56px 0;
  border-top: 1px solid var(--rule);
}
.entry:first-child { border-top: none; padding-top: 24px; }
.entry__rail {
  position: sticky; top: 24px;
  align-self: start;
  display: flex; flex-direction: column; gap: 14px;
}
.entry__num {
  font-family: var(--display);
  font-size: 96px;
  line-height: 0.86;
  font-weight: 500;
  color: var(--accent);
  letter-spacing: -0.04em;
  font-feature-settings: "lnum" 1;
}
.entry__kicker {
  font-family: var(--mono);
  font-size: 10px; letter-spacing: 0.22em;
  color: var(--ink-soft);
}
.entry__note {
  margin: 0;
  font-style: italic;
  color: var(--ink-soft);
  font-size: 13.5px;
  line-height: 1.55;
  max-width: 30ch;
}
.entry__title {
  margin: 4px 0 28px;
  font-family: var(--display);
  font-weight: 500;
  font-style: italic;
  font-size: clamp(30px, 4vw, 40px);
  letter-spacing: -0.015em;
  line-height: 1.05;
}
.entry__title::before {
  content: "§ ";
  font-style: normal;
  color: var(--accent);
}

@media (max-width: 820px) {
  .entry { grid-template-columns: 1fr; gap: 12px; padding: 36px 0; }
  .entry__rail { position: static; flex-direction: row; align-items: center; gap: 20px; flex-wrap: wrap; }
  .entry__num { font-size: 56px; }
  .entry__note { max-width: 100%; }
}

/* data tables */
.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
  margin: 16px 0 28px;
  font-feature-settings: "tnum" 1, "lnum" 1;
}
.data-table th, .data-table td {
  text-align: left;
  padding: 14px 14px;
  border-bottom: 1px solid var(--rule);
  vertical-align: top;
}
.data-table th {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--muted);
  border-bottom-color: var(--rule-strong);
  font-weight: 600;
  padding-top: 8px;
  padding-bottom: 10px;
}
.data-table td.t-num, .data-table th.t-num {
  text-align: right;
  font-variant-numeric: tabular-nums lining-nums;
  white-space: nowrap;
}
.data-table td.t-idx { color: var(--muted); font-family: var(--mono); font-size: 12px; }
.data-table td.t-cat code { font-size: 12px; }
.data-table td .t-sub { display: block; color: var(--muted); font-size: 11px; margin-top: 4px; font-family: var(--mono); }
.data-table td.t-gate { font-style: italic; max-width: 380px; line-height: 1.5; }
.t-quote {
  margin: 8px 0 0;
  padding: 6px 0 6px 12px;
  border-left: 2px solid var(--accent);
  font-style: italic;
  font-size: 13px;
  color: var(--ink-soft);
}
.domain-tag {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink);
}

/* level chip */
.lv-chip {
  display: inline-block;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.18em;
  padding: 3px 8px 4px;
  border: 1px solid var(--rule-strong);
  border-radius: 2px;
  background: transparent;
}
.lv-chip.lv-3 { color: var(--paper); background: var(--lv3); border-color: var(--lv3); }
.lv-chip.lv-2 { color: var(--paper); background: var(--lv2); border-color: var(--lv2); }
.lv-chip.lv-1 { color: var(--ink-soft); background: transparent; border-color: var(--rule-strong); }

[data-theme="dark"] .lv-chip.lv-3,
[data-theme="dark"] .lv-chip.lv-2 { color: #1a1a14; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .lv-chip.lv-3,
  :root:not([data-theme="light"]) .lv-chip.lv-2 { color: #1a1a14; }
}

/* charts */
.chart {
  margin: 16px 0 20px;
  padding: 16px 0 4px;
  border-top: 1px solid var(--rule-strong);
  border-bottom: 1px solid var(--rule);
}
.chart--wide { padding: 18px 0 6px; }
.chart > figcaption {
  font-family: var(--mono);
  font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;
  color: var(--muted);
  margin-top: 12px;
}
.chart-svg, .flow-svg { width: 100%; height: auto; display: block; overflow: visible; }
.bar { rx: 0; }
.bar.lv-3 { fill: var(--lv3); }
.bar.lv-2 { fill: var(--lv2); }
.bar.lv-1 { fill: var(--lv1); }
.bar-thin {}
.bar-label {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.06em;
  fill: var(--ink-soft);
  text-transform: uppercase;
}
.bar-value {
  font-family: var(--mono);
  font-size: 12px;
  font-variant-numeric: tabular-nums lining-nums;
  fill: var(--ink);
}
.tick-mark { stroke: var(--rule-strong); stroke-width: 1; }
.tick-grid { stroke: var(--rule); stroke-width: 1; stroke-dasharray: 1 4; }
.tick-text {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.08em;
  fill: var(--muted);
}
.rule { stroke: var(--rule); stroke-width: 1; }
.rule-strong { stroke: var(--rule-strong); stroke-width: 1; }
.chart-caption {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  fill: var(--muted);
}

/* flow diagram */
.col-head {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.22em;
  fill: var(--muted);
}
.kicker {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--muted);
  fill: var(--muted);
}
.kicker-num { fill: var(--accent); font-weight: 700; }
.cell-frame { fill: none; stroke: var(--rule-strong); stroke-width: 1; }
.cell-frame--filled { fill: var(--paper-2); }
[data-theme="dark"] .cell-frame--filled { fill: var(--paper-2); }
.cell.lv-3 .cell-frame { stroke: var(--lv3); }
.cell.lv-2 .cell-frame { stroke: var(--lv2); }
.cell.lv-1 .cell-frame { stroke: var(--rule); }
.cell.lv-3 .cell-frame--filled { fill: var(--lv3-soft); }
.cell.lv-2 .cell-frame--filled { fill: var(--lv2-soft); }
.cell-text {
  font-family: var(--serif);
  font-size: 14px;
  fill: var(--ink);
}
.cat-text {
  font-family: var(--mono);
  font-size: 15px;
  letter-spacing: 0.04em;
  fill: var(--ink);
}
.leader {
  stroke: var(--rule-strong);
  stroke-width: 1;
  stroke-dasharray: 1 4;
}
.arrow { fill: var(--rule-strong); }
.mark { fill: none; stroke: var(--rule-strong); stroke-width: 1; }
.mark--on { fill: var(--accent); stroke: var(--accent); }

/* lists & memory items */
ul.linear, ol.linear { padding-left: 18px; margin: 8px 0; }
ul.linear li, ol.linear li { margin: 4px 0; }
ul.linear code, ol.linear code { font-size: 12px; }

ul.memory, ol.memory { list-style: none; padding: 0; margin: 8px 0; counter-reset: m; }
.memory__item {
  display: grid; gap: 8px;
  padding: 18px 0;
  border-top: 1px solid var(--rule);
}
.memory__item:first-child { border-top: 1px solid var(--rule-strong); }
.memory__head {
  display: flex; align-items: center; gap: 14px;
  flex-wrap: wrap;
}
.memory__num {
  font-family: var(--mono);
  color: var(--accent);
  font-size: 12px;
  letter-spacing: 0.18em;
}
.memory__domain {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.18em;
  color: var(--ink);
}
.memory__meta {
  font-family: var(--mono);
  font-size: 11px;
  color: var(--muted);
  margin-left: auto;
}
.memory__signal {
  margin: 4px 0 0;
  font-size: 16px;
  font-style: italic;
  color: var(--ink-soft);
  max-width: 70ch;
  line-height: 1.5;
}
.memory__sessions {
  margin: 4px 0 0;
  font-family: var(--mono);
  font-size: 11px;
  color: var(--muted);
}
.memory__sessions .kicker {
  font-family: var(--mono);
  color: var(--accent);
  font-size: 10px;
  letter-spacing: 0.2em;
  margin-right: 8px;
}

blockquote.pull {
  margin: 6px 0 0;
  padding: 10px 16px;
  border-left: 3px solid var(--accent);
  background: var(--accent-soft);
  font-style: italic;
  font-size: 15px;
  color: var(--ink);
  line-height: 1.5;
  text-indent: -0.4em;
}
blockquote.pull::before { content: "“"; color: var(--accent); padding-right: 0.1em; }
blockquote.pull::after { content: "”"; color: var(--accent); padding-left: 0.05em; }

/* details / kvs */
.kvs {
  margin: 8px 0 0;
  border: none;
  border-top: 1px solid var(--rule);
  padding: 0;
}
.kvs:last-of-type { border-bottom: 1px solid var(--rule); }
.kvs > summary {
  display: flex; align-items: baseline; gap: 14px;
  cursor: pointer;
  padding: 14px 0;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.18em;
  color: var(--ink);
  list-style: none;
}
.kvs > summary::-webkit-details-marker { display: none; }
.kvs > summary::after {
  content: "+";
  margin-left: auto;
  font-family: var(--mono);
  font-size: 14px;
  color: var(--muted);
}
.kvs[open] > summary::after { content: "−"; }
.kvs__count {
  font-family: var(--mono);
  background: var(--ink);
  color: var(--paper);
  font-size: 10px;
  padding: 3px 7px;
  letter-spacing: 0.1em;
  min-width: 28px;
  text-align: center;
}
[data-theme="dark"] .kvs__count { background: var(--ink); color: var(--paper); }

ul.evidence {
  list-style: none; padding: 0 0 16px 0; margin: 0;
}
ul.evidence li {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 8px 16px;
  padding: 6px 0;
  border-bottom: 1px dotted var(--rule);
  font-size: 13px;
  line-height: 1.5;
}
ul.evidence li:last-child { border-bottom: none; }
.fact-text { color: var(--ink); }
.fact-src {
  color: var(--muted);
  font-family: var(--mono);
  font-size: 11px;
}

/* brief */
ol.brief { list-style: none; padding: 0; margin: 12px 0 0; counter-reset: b; }
ol.brief li {
  display: grid;
  grid-template-columns: 64px 1fr;
  gap: 18px;
  padding: 14px 0;
  border-top: 1px solid var(--rule);
}
ol.brief li:first-child { border-top: 1px solid var(--rule-strong); }
ol.brief li:last-child  { border-bottom: 1px solid var(--rule); }
.brief__num {
  font-family: var(--display);
  font-size: 28px;
  color: var(--accent);
  font-feature-settings: "lnum" 1;
  letter-spacing: -0.02em;
  line-height: 1;
}
.brief__text {
  font-family: var(--serif);
  font-size: 18px;
  font-style: italic;
  color: var(--ink);
  line-height: 1.5;
}

/* sub headings within a section */
h3.sub, h4.sub {
  margin: 28px 0 12px;
  font-family: var(--mono);
  font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase;
  color: var(--ink-soft);
  font-weight: 600;
}
h4.sub { font-size: 10px; margin: 18px 0 6px; }

p.lede {
  font-size: 16px;
  color: var(--ink);
  margin: 8px 0 12px;
}
p.lede.note {
  color: var(--ink-soft);
  font-style: italic;
  max-width: 64ch;
}

/* empty states */
.empty.editorial {
  color: var(--muted);
  font-style: italic;
  font-size: 15px;
  border-left: 2px solid var(--rule);
  padding: 6px 0 6px 14px;
  margin: 16px 0;
}
.empty.editorial code { font-size: 12px; }

/* colophon */
.colophon {
  margin: 56px 0 0;
  padding: 28px 0 0;
  border-top: 1px solid var(--rule-strong);
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.06em;
  color: var(--muted);
}
.col-row { display: flex; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
.col-row + .col-row { margin-top: 8px; }
.col-row em { font-style: italic; color: var(--ink-soft); }
.col-row code { font-family: var(--mono); font-size: 11px; }

.muted { color: var(--muted); font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; }
code {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--ink);
}

/* page-load reveal */
.page > * { opacity: 0; transform: translateY(6px); animation: rise 600ms cubic-bezier(.2,.7,.2,1) forwards; }
.page > .page__shoulder { animation-delay:  60ms; }
.page > .theme-toggle   { animation-delay:  80ms; }
.page > .masthead       { animation-delay: 120ms; }
.page > .abstract       { animation-delay: 220ms; }
.page > .legend         { animation-delay: 300ms; }
.page > .toc            { animation-delay: 360ms; }
.page > .entries        { animation-delay: 420ms; }
.page > .colophon       { animation-delay: 540ms; }
@keyframes rise {
  to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .page > * { animation: none; opacity: 1; transform: none; }
}

/* ---------- overflow safety ---------- */
.entry__body { min-width: 0; }
.masthead__meta dd { overflow-wrap: anywhere; word-break: break-word; }
code, code.long { overflow-wrap: anywhere; word-break: break-word; }
.fact-src, .fact-text { overflow-wrap: anywhere; word-break: break-word; }

/* ---------- at-a-glance story ---------- */
.story {
  margin: 0 0 28px;
  padding: 24px 0 26px;
  border-top: 1px solid var(--rule-strong);
  border-bottom: 1px solid var(--rule);
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 24px 32px;
  align-items: end;
}
.story__tag {
  display: inline-flex; gap: 10px; align-items: center;
  font-family: var(--mono);
  font-size: 10px; letter-spacing: 0.24em; text-transform: uppercase;
  color: var(--muted);
  grid-column: 1 / -1;
  margin-bottom: -8px;
}
.story__dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 1px var(--accent-soft);
}
.story__line {
  margin: 0;
  font-family: var(--display);
  font-style: italic;
  font-weight: 500;
  font-size: clamp(20px, 2.4vw, 28px);
  line-height: 1.35;
  letter-spacing: -0.01em;
  color: var(--ink);
  max-width: 64ch;
}
.story__line strong {
  font-style: normal;
  font-weight: 700;
  color: var(--accent);
  font-feature-settings: "lnum" 1, "tnum" 1;
  padding: 0 0.05em;
}
.story__verdict {
  font-family: var(--mono);
  font-size: 11px; letter-spacing: 0.22em;
  color: var(--ink);
  border: 1px solid var(--ink);
  padding: 8px 12px 9px;
  align-self: end;
  justify-self: end;
  white-space: nowrap;
}
@media (max-width: 720px) {
  .story { grid-template-columns: 1fr; }
  .story__verdict { justify-self: start; }
}

/* ---------- abstract strip extension ---------- */
.abstract { grid-template-columns: repeat(var(--cols, 7), minmax(0, 1fr)); }
.abstract[data-count="3"] { --cols: 3; }
.abstract[data-count="4"] { --cols: 4; }
.abstract[data-count="5"] { --cols: 5; }
.abstract[data-count="6"] { --cols: 6; }
.abstract[data-count="7"] { --cols: 7; }
.ab__sub {
  font-family: var(--serif);
  font-style: italic;
  font-size: 11.5px;
  color: var(--ink-soft);
  margin-top: 2px;
  letter-spacing: 0;
}

/* ---------- section lede ---------- */
.entry__lede {
  margin: -14px 0 24px;
  font-family: var(--display);
  font-style: italic;
  font-size: clamp(17px, 1.6vw, 20px);
  line-height: 1.45;
  color: var(--ink-soft);
  max-width: 62ch;
}

/* ---------- rule cards ---------- */
.rule-cards {
  display: grid;
  gap: 18px;
  margin: 8px 0 24px;
}
.rule-card {
  position: relative;
  border: 1px solid var(--rule-strong);
  background: var(--paper);
  padding: 22px 24px 18px;
  display: grid;
  gap: 14px;
  min-width: 0;
}
.rule-card::before {
  content: "";
  position: absolute; left: 0; top: 0; bottom: 0;
  width: 4px;
}
.rule-card.lv-3::before { background: var(--lv3); }
.rule-card.lv-2::before { background: var(--lv2); }
.rule-card.lv-1::before { background: var(--lv1); }
.rule-card .rc-head {
  display: flex; align-items: center; gap: 12px;
  flex-wrap: wrap;
}
.rc-num {
  font-family: var(--mono);
  font-size: 11px; letter-spacing: 0.22em;
  color: var(--muted);
}
.rc-dot {
  width: 12px; height: 12px; border-radius: 50%;
  border: 1px solid var(--ink-soft);
  display: inline-block;
}
.rule-card.lv-3 .rc-dot { background: var(--lv3); border-color: var(--lv3); }
.rule-card.lv-2 .rc-dot { background: var(--lv2); border-color: var(--lv2); }
.rule-card.lv-1 .rc-dot { background: transparent; border-color: var(--muted); }
.rc-level {
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: 0.18em;
  color: var(--ink);
}
.rc-level-meta {
  margin-left: auto;
  font-family: var(--mono);
  font-size: 10px; letter-spacing: 0.2em;
  color: var(--muted);
}
.rc-body { display: grid; gap: 10px; }
.rc-domain { display: flex; align-items: center; gap: 12px; }
.rc-initials {
  width: 36px; height: 36px;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 50%;
  font-family: var(--mono);
  font-size: 13px;
  letter-spacing: 0.04em;
  background: var(--ink);
  color: var(--paper);
}
.rule-card.lv-3 .rc-initials { background: var(--lv3); color: var(--paper); }
.rule-card.lv-2 .rc-initials { background: var(--lv2); color: var(--paper); }
.rc-domain-name {
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink);
}
.rc-rule {
  margin: 4px 0 0;
  font-family: var(--display);
  font-size: clamp(18px, 1.7vw, 21px);
  line-height: 1.4;
  color: var(--ink);
  font-style: italic;
  font-weight: 500;
  letter-spacing: -0.005em;
  max-width: 62ch;
}
.rc-quote {
  margin: 4px 0 0;
  padding: 8px 14px;
  border-left: 3px solid var(--accent);
  background: var(--accent-soft);
  font-style: italic;
  color: var(--ink);
  font-size: 14px;
  line-height: 1.5;
}
.rc-quote::before { content: "“"; color: var(--accent); padding-right: 0.1em; }
.rc-quote::after { content: "”"; color: var(--accent); padding-left: 0.05em; }
.rc-foot {
  display: flex; gap: 10px 22px;
  flex-wrap: wrap;
  border-top: 1px solid var(--rule);
  padding-top: 12px;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.08em;
  color: var(--ink-soft);
}
.rc-stat strong {
  font-family: var(--display);
  font-style: normal;
  font-size: 16px;
  font-feature-settings: "lnum" 1, "tnum" 1;
  color: var(--ink);
  margin-right: 4px;
}
.rule-card.lv-3 .rc-stat strong { color: var(--lv3); }
.rule-card.lv-2 .rc-stat strong { color: var(--lv2); }
.rc-stat-blurb { font-style: italic; font-family: var(--serif); letter-spacing: 0; color: var(--ink-soft); }
.rc-stat-cat code { font-size: 11px; }
.rc-sessions {
  display: flex; gap: 6px; flex-wrap: wrap;
  font-family: var(--mono);
  font-size: 11px;
  color: var(--muted);
}
.rc-session {
  border: 1px solid var(--rule);
  padding: 2px 8px 3px;
  border-radius: 2px;
  background: var(--paper-2);
  overflow-wrap: anywhere;
  max-width: 100%;
}

/* ---------- flow steps (HTML) ---------- */
.flow-legend {
  display: flex; align-items: center; gap: 10px;
  flex-wrap: wrap;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.16em;
  color: var(--ink-soft);
  margin: 0 0 18px;
}
.flow-step-mark {
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px;
  border: 1px solid var(--ink);
  font-size: 11px; letter-spacing: 0;
}
.flow-step-name { letter-spacing: 0.04em; text-transform: none; font-family: var(--serif); font-style: italic; font-size: 15px; color: var(--ink); }
.flow-arrow { color: var(--muted); font-size: 16px; }

.flow-rows {
  list-style: none;
  padding: 0;
  margin: 0;
  display: grid;
  gap: 18px;
}
.flow-row {
  display: grid;
  grid-template-columns: 56px 1fr 80px 1fr 80px 1fr;
  align-items: stretch;
  border: 1px solid var(--rule-strong);
  background: var(--paper);
  min-width: 0;
  position: relative;
}
.flow-row::before {
  content: "";
  position: absolute; left: 0; top: 0; bottom: 0;
  width: 4px;
}
.flow-row.lv-3::before { background: var(--lv3); }
.flow-row.lv-2::before { background: var(--lv2); }
.flow-row.lv-1::before { background: var(--lv1); }
.flow-row__index {
  display: flex; align-items: center; justify-content: center;
  font-family: var(--display);
  font-size: 28px;
  color: var(--accent);
  border-right: 1px solid var(--rule);
  font-feature-settings: "lnum" 1;
}
.flow-step {
  padding: 16px 18px;
  display: grid;
  gap: 6px;
  min-width: 0;
}
.flow-step__kicker {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.2em;
  color: var(--muted);
  display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline;
}
.flow-step__kicker em {
  font-family: var(--serif);
  font-style: italic;
  letter-spacing: 0;
  color: var(--ink-soft);
  font-size: 12px;
}
.flow-step__body {
  margin: 0;
  font-family: var(--serif);
  font-size: 14.5px;
  line-height: 1.45;
  color: var(--ink);
  max-width: 38ch;
}
.flow-step__body--strong {
  font-family: var(--display);
  font-style: italic;
  font-size: clamp(16px, 1.5vw, 18px);
  font-weight: 500;
  color: var(--ink);
}
.flow-step__meta {
  margin: 2px 0 0;
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.06em;
  color: var(--muted);
}
.flow-step__meta code { font-size: 11px; }
.flow-step--pattern { background: var(--paper-2); border-left: 1px solid var(--rule); border-right: 1px solid var(--rule); }
.flow-row.lv-3 .flow-step--pattern { background: var(--lv3-soft); }
.flow-row.lv-2 .flow-step--pattern { background: var(--lv2-soft); }
.flow-step__arrow {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 4px;
  background: var(--paper-2);
  color: var(--muted);
  padding: 8px 4px;
}
.flow-step__chevron {
  font-family: var(--display);
  font-size: 30px;
  line-height: 1;
  color: var(--accent);
}
.flow-step__verb {
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.18em;
  text-align: center;
  color: var(--muted);
  text-transform: uppercase;
}
@media (max-width: 920px) {
  .flow-row {
    grid-template-columns: 56px 1fr;
  }
  .flow-step { border-top: 1px solid var(--rule); }
  .flow-step:not(.flow-step--signal) { grid-column: 2 / -1; }
  .flow-step__arrow {
    grid-column: 2 / -1;
    flex-direction: row;
    gap: 14px;
    padding: 6px 18px;
  }
  .flow-step__chevron { font-size: 20px; transform: rotate(90deg); }
}

/* ---------- reveal stagger ---------- */
.page > .story { animation-delay: 200ms; }

/* print */
@media print {
  html, body { background: white; }
  .theme-toggle { display: none; }
  .page { box-shadow: none; border: none; padding: 24px 16px; max-width: none; }
}

/* small screens */
@media (max-width: 720px) {
  .page { padding: 32px 22px 56px; }
  .masthead__title { font-size: clamp(40px, 14vw, 64px); }
  .toc { grid-template-columns: 1fr; }
  .data-table { font-size: 12px; }
  .data-table td.t-gate { max-width: 100%; }
  .theme-toggle { top: 16px; right: 16px; }
  .entry { padding: 28px 0; }
  .entry__num { font-size: 56px; }
  .rule-card { padding: 18px 18px 16px 22px; }
  .flow-row__index { font-size: 22px; }
}
"""


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--corpus", required=True)
    parser.add_argument("--baseline", required=True)
    parser.add_argument("--output", required=True, help="HTML output path")
    parser.add_argument("--mode", default="all")
    parser.add_argument("--personal", help="Personal memory root (used to look up prior report).")
    parser.add_argument("--skill-map")
    parser.add_argument("--skill-usage")
    parser.add_argument("--skill-impact")
    parser.add_argument("--domain-rules")
    parser.add_argument("--domain-preset", default=DEFAULT_DOMAIN_PRESET)
    parser.add_argument("--payload-json", help="Optional path to also dump the structured payload as JSON.")
    args = parser.parse_args(argv)

    corpus = pathlib.Path(args.corpus).read_text(encoding="utf-8")
    baseline = load_json(pathlib.Path(args.baseline))
    skill_map = load_optional_json(args.skill_map) or skill_map_from_baseline(baseline)
    skill_usage = load_optional_json(args.skill_usage)
    skill_impact = load_optional_json(args.skill_impact)
    try:
        domain_rules, domain_rules_source = resolve_domain_rules(baseline, args.domain_rules, args.domain_preset)
    except ValueError as error:
        print(str(error), file=sys.stderr)
        return 1
    personal = pathlib.Path(args.personal).expanduser() if args.personal else None

    payload = build_report_payload(
        corpus,
        baseline,
        args.mode,
        personal,
        skill_map,
        skill_usage,
        skill_impact,
        domain_rules,
        domain_rules_source,
    )
    html_text = render_html_report(payload)
    out = pathlib.Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(html_text, encoding="utf-8")
    if args.payload_json:
        pathlib.Path(args.payload_json).write_text(
            json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8"
        )
    print(f"html report written: {out}")
    return 0


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