#!/usr/bin/env python3
"""
Render all Great Docs Gauntlet (GDG) package sites and serve them with a shared nav bar.

Usage:
    python test-packages/render_all.py              # build all + serve
    python test-packages/render_all.py --build      # build only
    python test-packages/render_all.py --serve      # serve previously built
    python test-packages/render_all.py --only gdtest_minimal gdtest_github_icon
    python test-packages/render_all.py --build --skip-ok   # resume interrupted build

The script:
  1. Generates each synthetic package into _rendered/<name>/
  2. Runs `great-docs init --force` + `great-docs build` on each
  3. Collects all _site/ outputs into the GDG directory
  4. Injects a top navigation bar into every HTML file
  5. Creates a GDG index page at _rendered/_hub/index.html
  6. Starts a local HTTP server on port 3333
"""

from __future__ import annotations

import argparse
import concurrent.futures
import html
import http.server
import json
import os
import re
import shutil
import socketserver
import subprocess
import sys
import textwrap
import threading
import time
from datetime import datetime
from pathlib import Path

from yaml12 import format_yaml

# ── Path setup ───────────────────────────────────────────────────────────────

_THIS_DIR = Path(__file__).resolve().parent
_PROJECT_ROOT = _THIS_DIR.parent

if str(_THIS_DIR) not in sys.path:
    sys.path.insert(0, str(_THIS_DIR))

from build_state import (
    is_stale,
    load_state,
    new_run_id,
    record_build,
    reset_for_full_rebuild,
    save_state,
    start_selective_run,
)
from catalog import (
    ALL_PACKAGES,
    AXIS_COLORS,
    DIMENSIONS,
    PACKAGE_DESCRIPTIONS,
    get_spec,
)
from synthetic.generator import generate_package

# ── Constants ────────────────────────────────────────────────────────────────

RENDERED_DIR = _THIS_DIR / "_rendered"
HUB_DIR = RENDERED_DIR / "_hub"
LOGS_DIR = RENDERED_DIR / "_logs"
STATE_FILE = RENDERED_DIR / "_build_state.json"
PORT = 3333

# ── Coverage levels tracked by test_gdg_rendered.py ─────────────────────────
# Each level maps to a parametrized test or dedicated test in the file.
# The score is "how many of these 21 levels does a package participate in?"

_COVERAGE_LEVELS = [
    "idx",
    "srch",
    "ref",
    "nodoc",
    "bigcl",
    "ug",
    "supp",
    "title",
    "badge",
    "sig",
    "desc",
    "param",
    "pmatch",
    "ret",
    "refidx",
    "sechdg",
    "sbar",
    "sbsec",
    "land",
    "hdg",
    "nav",
    "meta",
    "a11y",
    "DED",
]

# Package names explicitly referenced in test_gdg_rendered.py (dedicated tests)
_DEDICATED_PACKAGES: set[str] = set()
_DEDICATED_COUNTS: dict[str, int] = {}


def _load_dedicated_packages() -> set[str]:
    """Parse test_gdg_rendered.py once for explicitly named packages."""
    global _DEDICATED_PACKAGES  # noqa: PLW0603
    if _DEDICATED_PACKAGES:
        return _DEDICATED_PACKAGES
    test_file = _PROJECT_ROOT / "tests" / "test_gdg_rendered.py"
    if test_file.exists():
        _DEDICATED_PACKAGES = set(re.findall(r"gdtest_[a-z0-9_]+", test_file.read_text()))
    return _DEDICATED_PACKAGES


def _count_dedicated_tests(name: str) -> int:
    """Count dedicated test functions that reference a given package.

    Looks for functions decorated with ``@pytest.mark.dedicated`` and counts
    how many reference the package name in their decorator block + body.
    """
    global _DEDICATED_COUNTS  # noqa: PLW0603
    if not _DEDICATED_COUNTS:
        test_file = _PROJECT_ROOT / "tests" / "test_gdg_rendered.py"
        if not test_file.exists():
            return 0
        content = test_file.read_text()
        # Split into function blocks
        fn_positions = [(m.start(), m.group(1)) for m in re.finditer(r"\ndef (test_\w+)", content)]
        for idx, (pos, _fn_name) in enumerate(fn_positions):
            # Look for @pytest.mark.dedicated in the decorator block
            decorator_block = content[max(0, pos - 1500) : pos]
            if "@pytest.mark.dedicated" not in decorator_block:
                continue
            # Get function body
            end = fn_positions[idx + 1][0] if idx + 1 < len(fn_positions) else len(content)
            fn_body = content[pos:end]
            search_text = decorator_block + fn_body
            for pkg in set(re.findall(r"gdtest_[a-z0-9_]+", search_text)):
                _DEDICATED_COUNTS[pkg] = _DEDICATED_COUNTS.get(pkg, 0) + 1
    return _DEDICATED_COUNTS.get(name, 0)


def _compute_coverage(name: str) -> dict[str, bool]:
    """Compute test coverage levels for a single package.

    Returns a dict mapping each coverage level to True/False.
    """
    result = {level: False for level in _COVERAGE_LEVELS}

    try:
        spec = get_spec(name)
        exp = spec.get("expected", {})
    except Exception:
        # Not in catalog at all
        if name in _load_dedicated_packages():
            result["DED"] = True
        return result

    has_exports = bool(exp.get("export_names"))

    # All catalog packages get basic structural tests
    result["idx"] = True
    result["srch"] = True
    result["land"] = True

    if has_exports:
        result["ref"] = True
        result["title"] = True
        result["badge"] = True
        result["sig"] = True
        result["pmatch"] = True
        result["refidx"] = True

    if exp.get("nodoc_items"):
        result["nodoc"] = True
    if exp.get("big_class_name"):
        result["bigcl"] = True
    if exp.get("user_guide_files"):
        result["ug"] = True
    if any(
        exp.get(k)
        for k in (
            "has_license_page",
            "has_citation_page",
            "has_contributing_page",
            "has_code_of_conduct_page",
        )
    ):
        result["supp"] = True
    if exp.get("section_titles"):
        result["sechdg"] = True
        result["sbsec"] = True

    parser = exp.get("detected_parser", "numpy")
    if has_exports and name != "gdtest_nodocs" and parser in ("numpy", "google", "sphinx"):
        result["desc"] = True
    if has_exports and name != "gdtest_nodocs":
        result["param"] = True
        result["ret"] = True

    # Sidebar check
    ref = RENDERED_DIR / name / "great-docs" / "_site" / "reference"
    if ref.exists() and any(f.name != "index.html" for f in ref.glob("*.html")):
        result["sbar"] = True

    # Heading check — applies to all rendered packages with reference pages
    ref_exists = (RENDERED_DIR / name / "great-docs" / "_site" / "reference").exists()
    if ref_exists:
        result["hdg"] = True

    # Navbar, meta, and a11y — apply to all packages with an index.html
    site_index = RENDERED_DIR / name / "great-docs" / "_site" / "index.html"
    if site_index.exists():
        result["nav"] = True
        result["meta"] = True
        result["a11y"] = True

    if name in _load_dedicated_packages():
        result["DED"] = True

    return result


def _applicable_levels(name: str) -> set[str]:
    """Return the set of coverage levels that apply to a package.

    Respects `coverage_exclude` (opt-out list) and `coverage_include`
    (opt-in list) in the spec's `expected` dict.

    - If `coverage_include` is set, only those levels count.
    - If `coverage_exclude` is set, those levels are removed.
    - If neither is set, all levels apply (backwards-compatible).
    """
    try:
        spec = get_spec(name)
        exp = spec.get("expected", {})
    except Exception:
        return set(_COVERAGE_LEVELS)

    include = exp.get("coverage_include")
    exclude = exp.get("coverage_exclude")

    if include is not None:
        return set(include) & set(_COVERAGE_LEVELS)
    elif exclude is not None:
        return set(_COVERAGE_LEVELS) - set(exclude)
    else:
        return set(_COVERAGE_LEVELS)


def coverage_score(name: str) -> int:
    """Return the test coverage score for a package (passing / applicable)."""
    coverage = _compute_coverage(name)
    applicable = _applicable_levels(name)
    return sum(1 for level in applicable if coverage.get(level))


def coverage_max(name: str) -> int:
    """Return the max possible score for a package (number of applicable levels)."""
    return len(_applicable_levels(name))


def _coverage_badge_html(score: int, max_score: int | None = None) -> str:
    """Return an HTML badge with mini progress bar for test coverage."""
    if max_score is None:
        max_score = len(_COVERAGE_LEVELS)
    pct = score / max_score if max_score > 0 else 0
    pct_int = int(pct * 100)
    if pct >= 0.7:
        color = "#a6e3a1"  # green
    elif pct >= 0.4:
        color = "#f9e2af"  # yellow
    else:
        color = "#f38ba8"  # red
    return (
        f'<span class="cov-chip" style="'
        f"background:{color}15;color:{color};border:1px solid {color}40;"
        f'"title="Test coverage: {score}/{max_score} levels ({pct_int}%)">'
        f'<span class="cov-chip-bar" style="width:{pct_int}%;background:{color}"></span>'
        f"{pct_int}%</span>"
    )


# ── Helpers ──────────────────────────────────────────────────────────────────


def _spec_file_exists(name: str) -> bool:
    """Check whether the spec module exists on disk for *name*."""
    return (_THIS_DIR / "synthetic" / "specs" / f"{name}.py").exists()


# ── Build pipeline ───────────────────────────────────────────────────────────


def build_package(name: str) -> dict:
    """
    Build a single synthetic package site.

    Returns a result dict with status info.
    """
    spec = get_spec(name)
    pkg_build_dir = RENDERED_DIR / name
    site_dir = pkg_build_dir / "great-docs" / "_site"

    _common: dict = {
        "name": name,
        "description": spec.get("description", ""),
        "long_description": PACKAGE_DESCRIPTIONS.get(name, ""),
        "dimensions": spec.get("dimensions", []),
    }

    # Clean and regenerate from scratch
    if pkg_build_dir.exists():
        shutil.rmtree(pkg_build_dir)

    # Generate package files
    pkg_dir = generate_package(spec, RENDERED_DIR)

    # Enrich README.md with the long description from the catalog so it
    # appears on the landing page of the built site.
    long_desc = PACKAGE_DESCRIPTIONS.get(name, "")
    if long_desc:
        readme = pkg_dir / "README.md"
        if readme.exists():
            original = readme.read_text(encoding="utf-8")
            readme.write_text(
                original.rstrip() + "\n\n" + long_desc + "\n",
                encoding="utf-8",
            )

    # For packages without pyproject.toml (e.g., setup.cfg-only), create a
    # minimal pyproject.toml as a "root barrier" so that _find_package_root()
    # doesn't walk up into the great-docs repo itself.
    pyproject = pkg_dir / "pyproject.toml"
    if not pyproject.exists():
        detected_name = spec.get("expected", {}).get("detected_name", name)
        pyproject.write_text(
            f'[project]\nname = "{detected_name}"\nversion = "0.1.0"\n',
            encoding="utf-8",
        )

    # Add to sys.path for griffe
    if str(pkg_dir) not in sys.path:
        sys.path.insert(0, str(pkg_dir))
    for subdir in ("src", "python", "lib"):
        sub = pkg_dir / subdir
        if sub.is_dir() and str(sub) not in sys.path:
            sys.path.insert(0, str(sub))

    # Run great-docs init + build using subprocess with the installed CLI
    great_docs_cli = str(Path(sys.executable).parent / "great-docs")
    env = os.environ.copy()
    pythonpath_parts = [str(pkg_dir)]
    for subdir in ("src", "python", "lib"):
        sub = pkg_dir / subdir
        if sub.is_dir():
            pythonpath_parts.append(str(sub))
    existing = env.get("PYTHONPATH", "")
    if existing:
        pythonpath_parts.append(existing)
    env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)

    # Prepare log file
    LOGS_DIR.mkdir(parents=True, exist_ok=True)
    log_path = LOGS_DIR / f"{name}.log"
    log_lines: list[str] = []
    log_lines.append(f"{'=' * 70}")
    log_lines.append(f"Package: {name}")
    log_lines.append(f"Build dir: {pkg_dir}")
    log_lines.append(f"Timestamp: {time.strftime('%Y-%m-%d %H:%M:%S')}")
    log_lines.append(f"{'=' * 70}")

    try:
        # Init: only run when no great-docs.yml exists yet (specs that
        # provide their own config already have it written by
        # generate_package).  Running init on a pre-configured package
        # would overwrite the spec's custom settings.
        has_config = (pkg_dir / "great-docs.yml").exists()

        if not has_config:
            log_lines.append("\n--- great-docs init ---")
            init_result = subprocess.run(
                [great_docs_cli, "init", "--force", "--project-path", str(pkg_dir)],
                capture_output=True,
                text=True,
                timeout=120,
                env=env,
            )
            log_lines.append(f"exit code: {init_result.returncode}")
            if init_result.stdout:
                log_lines.append(f"stdout:\n{init_result.stdout}")
            if init_result.stderr:
                log_lines.append(f"stderr:\n{init_result.stderr}")

            if init_result.returncode != 0:
                log_lines.append("\nRESULT: init_failed")
                log_path.write_text("\n".join(log_lines), encoding="utf-8")
                return {
                    **_common,
                    "status": "init_failed",
                    "error": init_result.stderr or init_result.stdout,
                    "log": str(log_path),
                }
        else:
            log_lines.append("\n--- great-docs init SKIPPED (config exists) ---")

        # Build
        log_lines.append("\n--- great-docs build ---")
        build_result = subprocess.run(
            [great_docs_cli, "build", "--project-path", str(pkg_dir)],
            capture_output=True,
            text=True,
            timeout=300,
            env=env,
        )
        log_lines.append(f"exit code: {build_result.returncode}")
        if build_result.stdout:
            log_lines.append(f"stdout:\n{build_result.stdout}")
        if build_result.stderr:
            log_lines.append(f"stderr:\n{build_result.stderr}")

        if build_result.returncode != 0:
            log_lines.append("\nRESULT: build_failed")
            log_path.write_text("\n".join(log_lines), encoding="utf-8")
            return {
                **_common,
                "status": "build_failed",
                "error": build_result.stderr or build_result.stdout,
                "log": str(log_path),
            }

        # ----------------------------------------------------------
        # Enrich the rendered landing page (_site/index.html) AFTER
        # build. The build step regenerates index.qmd, so we must
        # inject directly into the final HTML output.
        # ----------------------------------------------------------
        site_index = site_dir / "index.html"
        gd_yml = pkg_dir / "great-docs.yml"
        if site_index.exists():
            extras: list[str] = []

            # Interactive file tree viewer
            try:
                tree_html = _build_file_tree_html(
                    spec,
                    config_path=gd_yml,
                )
                extras.append(
                    '<details style="margin-top:1em">\n'
                    '<summary style="cursor:pointer;font-weight:600;'
                    'font-size:14px">Source files</summary>\n'
                    f"{_FILE_TREE_CSS}\n"
                    f"{tree_html}\n"
                    "</details>\n"
                )
            except Exception:
                pass  # non-fatal

            if extras:
                extras_block = "\n".join(extras)
                site_html = site_index.read_text(encoding="utf-8")
                site_html = site_html.replace(
                    "</main>",
                    f"{extras_block}\n</main>",
                    1,
                )
                site_index.write_text(site_html, encoding="utf-8")

        log_lines.append("\nRESULT: ok")
        log_path.write_text("\n".join(log_lines), encoding="utf-8")
        return {
            **_common,
            "status": "ok",
            "site_dir": str(site_dir),
            "log": str(log_path),
        }

    except subprocess.TimeoutExpired:
        log_lines.append("\nRESULT: timeout")
        log_path.write_text("\n".join(log_lines), encoding="utf-8")
        return {**_common, "status": "timeout", "log": str(log_path)}
    except Exception as e:
        log_lines.append(f"\nRESULT: error\n{e}")
        log_path.write_text("\n".join(log_lines), encoding="utf-8")
        return {**_common, "status": "error", "error": str(e), "log": str(log_path)}


def build_all(
    packages: list[str] | None = None,
    *,
    run_id: str | None = None,
    state: dict | None = None,
    skip_ok: bool = False,
    workers: int = 1,
) -> list[dict]:
    """Build all (or selected) synthetic packages.

    When *run_id* and *state* are supplied each result is recorded into the state dict (caller is
    responsible for saving to disk).

    When *skip_ok* is `True`, packages whose status in *state* is already `"ok"` are skipped. This
    lets an interrupted full build resume quickly.

    When *workers* > 1, packages are built in parallel using a thread pool. Each build runs as an
    independent subprocess so parallelism is genuine.
    """
    names = packages or ALL_PACKAGES
    results = []

    # Filter to specs that exist on disk
    available = []
    for name in names:
        if _spec_file_exists(name):
            available.append(name)
        else:
            print(f"  SKIP {name} (no spec file)")

    # Separate skippable packages from those that need building
    to_build: list[str] = []
    skipped = 0
    for name in available:
        if skip_ok and state is not None:
            pkg_state = state.get("packages", {}).get(name, {})
            if pkg_state.get("status") == "ok":
                if run_id is not None:
                    pkg_state["run_id"] = run_id
                skipped += 1
                continue
        to_build.append(name)

    total = len(available)
    total_build = len(to_build)
    workers = min(workers, max(total_build, 1))
    label = f" ({workers} workers)" if workers > 1 else ""
    print(f"\n{'=' * 70}")
    print(f"  Building {total} synthetic package sites{label}")
    print(f"{'=' * 70}\n")

    if skipped:
        print(f"  ({skipped} packages skipped — already ok)\n")

    if workers <= 1:
        # ── Sequential path (original behavior) ────────────────────────
        for i, name in enumerate(to_build, 1):
            print(f"[{i:3d}/{total_build}] {name} ... ", end="", flush=True)
            t0 = time.monotonic()
            result = build_package(name)
            elapsed = time.monotonic() - t0
            status = result["status"]
            result["elapsed_s"] = round(elapsed, 1)

            if status == "ok":
                print(f"OK ({elapsed:.1f}s)")
            else:
                print(f"FAILED ({status})")
                if "error" in result:
                    for line in result["error"].strip().splitlines()[:3]:
                        print(f"         {line}")

            if state is not None and run_id is not None:
                record_build(
                    state, name, run_id=run_id, status=status,
                    elapsed=elapsed, error=result.get("error"),
                )
            results.append(result)
    else:
        # ── Parallel path ───────────────────────────────────────────────
        print_lock = threading.Lock()
        completed = [0]  # mutable counter

        def _build_one(name: str) -> dict:
            t0 = time.monotonic()
            result = build_package(name)
            elapsed = time.monotonic() - t0
            result["elapsed_s"] = round(elapsed, 1)

            with print_lock:
                completed[0] += 1
                n = completed[0]
                status = result["status"]
                if status == "ok":
                    print(f"[{n:3d}/{total_build}] {name} ... OK ({elapsed:.1f}s)")
                else:
                    print(f"[{n:3d}/{total_build}] {name} ... FAILED ({status})")
                    if "error" in result:
                        for line in result["error"].strip().splitlines()[:3]:
                            print(f"         {line}")

            return result

        with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
            futures = {pool.submit(_build_one, name): name for name in to_build}
            for future in concurrent.futures.as_completed(futures):
                result = future.result()
                if state is not None and run_id is not None:
                    record_build(
                        state, result["name"], run_id=run_id,
                        status=result["status"], elapsed=result["elapsed_s"],
                        error=result.get("error"),
                    )
                results.append(result)

    logged = [r for r in results if "log" in r]
    if logged:
        print(f"\n  Build logs saved to: {LOGS_DIR}/")
        print(f"  ({len(logged)} log files written)")

    return results


# ── Nav bar injection ────────────────────────────────────────────────────────


def _dimension_badges(dims: list[str]) -> str:
    """Generate HTML badge spans for dimension codes."""
    badges = []
    for code in dims:
        meta = DIMENSIONS.get(code, {})
        axis = meta.get("axis", "")
        label = meta.get("label", code)
        color = AXIS_COLORS.get(axis, "#6b7280")
        badges.append(
            f'<span style="display:inline-block;padding:1px 6px;margin:1px;'
            f"border-radius:3px;font-size:11px;font-weight:500;"
            f"background:{color}18;color:{color};border:1px solid {color}40;"
            f'"title="{html.escape(label)}">{html.escape(code)}</span>'
        )
    return " ".join(badges)


# ── Hub navigation categories ────────────────────────────────────────────────

CATEGORIES = {
    # Suite A: Layout & structure (001–100)
    "Docstrings (001–005)": (0, 5),
    "Layouts (006–013)": (5, 13),
    "Exports (014–017)": (13, 17),
    "Object Types (018–027)": (17, 27),
    "Directives (028–032)": (27, 32),
    "User Guide (033–038)": (32, 38),
    "Landing Pages (039–043)": (38, 43),
    "Extras & Config (044–050)": (43, 50),
    "Cross-Dimension (051–065)": (50, 65),
    "API Patterns (066–077)": (65, 77),
    "Scale & Stress (078–082)": (77, 82),
    "Build Systems (083–088)": (82, 88),
    "Edge Cases (089–095)": (88, 95),
    "Config Matrix (096–100)": (95, 100),
    # Suite B: Config & docstring richness (101–200)
    "Config Options (101–125)": (100, 125),
    "Docstring Richness (126–150)": (125, 150),
    "UG Variations (151–165)": (150, 165),
    "Custom Sections (166–175)": (165, 175),
    "Reference Config (176–185)": (175, 185),
    "Site Theming (186–195)": (185, 195),
    "Stress Tests (196–200)": (195, 200),
}


def _build_nav_html(
    results: list[dict],
    current_name: str,
    hub_prefix: str = "..",
    state: dict | None = None,
) -> str:
    """Build the top navigation bar HTML to inject into every page."""

    categories: dict[str, list[dict]] = {k: [] for k in CATEGORIES}
    cat_keys = list(categories.keys())
    cat_ranges = list(CATEGORIES.values())

    for r in results:
        name = r["name"]
        idx = ALL_PACKAGES.index(name) if name in ALL_PACKAGES else -1
        placed = False
        for ci, (lo, hi) in enumerate(cat_ranges):
            if lo <= idx < hi:
                categories[cat_keys[ci]].append(r)
                placed = True
                break
        if not placed:
            categories[cat_keys[-1]].append(r)

    # Build dropdown items
    dropdown_items = []
    for cat_name, cat_results in categories.items():
        if not cat_results:
            continue
        dropdown_items.append(f'<div class="gd-nav-category">{html.escape(cat_name)}</div>')
        for r in cat_results:
            name = r["name"]
            status = r["status"]
            is_current = name == current_name
            stale = is_stale(state, name) if state else False
            num = ALL_PACKAGES.index(name) + 1 if name in ALL_PACKAGES else 0
            desc = r.get("description", "")

            stale_badge = '<span class="gd-nav-badge-stale">STALE</span>' if stale else ""

            if status == "ok":
                href = f"{hub_prefix}/{name}/index.html"
                cls = "gd-nav-item" + (" gd-nav-current" if is_current else "")
                icon = "●" if is_current else ""
                dropdown_items.append(
                    f'<a href="{href}" class="{cls}" title="{html.escape(desc)}">'
                    f'<span class="gd-nav-num">#{num:03d}</span> '
                    f"{html.escape(name)}"
                    f"{'<span class=gd-nav-dot>' + icon + '</span>' if icon else ''}"
                    f"{stale_badge}"
                    f"</a>"
                )
            else:
                dropdown_items.append(
                    f'<div class="gd-nav-item gd-nav-disabled" title="{html.escape(status)}">'
                    f'<span class="gd-nav-num">#{num:03d}</span> '
                    f"{html.escape(name)} "
                    f'<span class="gd-nav-badge-fail">FAIL</span>'
                    f"{stale_badge}"
                    f"</div>"
                )

    dropdown_html = "\n".join(dropdown_items)
    current_num = ALL_PACKAGES.index(current_name) + 1 if current_name in ALL_PACKAGES else 0
    ok_count = sum(1 for r in results if r["status"] == "ok")
    stale_count = sum(1 for r in results if is_stale(state, r["name"])) if state else 0
    current_is_stale = is_stale(state, current_name) if state else False

    # Elapsed build time for the current package
    current_result = next((r for r in results if r["name"] == current_name), None)
    elapsed_s = current_result.get("elapsed_s") if current_result else None
    if elapsed_s is not None:
        if elapsed_s >= 60:
            mins = int(elapsed_s // 60)
            secs = elapsed_s % 60
            elapsed_str = f"{mins}m\u2009{secs:.0f}s"
        else:
            elapsed_str = f"{elapsed_s:.1f}s"
        elapsed_html = f'<span class="gd-nav-elapsed">\u23f1 {elapsed_str}</span>'
    else:
        elapsed_html = ""

    # Test coverage score for the current package
    cov = coverage_score(current_name)
    max_cov = coverage_max(current_name)
    cov_pct = cov / max_cov if max_cov > 0 else 0
    cov_color = "#a6e3a1" if cov_pct >= 0.7 else "#f9e2af" if cov_pct >= 0.4 else "#f38ba8"
    coverage_html = (
        f'<span class="gd-nav-coverage" style="color:{cov_color}"'
        f' title="Test coverage: {cov}/{max_cov} applicable levels">'
        f"\U0001f9ea {cov}/{max_cov}</span>"
    )

    # Prev/Next navigation
    ok_names = [r["name"] for r in results if r["status"] == "ok"]
    if current_name in ok_names:
        ci = ok_names.index(current_name)
        if ci > 0:
            prev_name = ok_names[ci - 1]
            prev_link = (
                f'<a href="{hub_prefix}/{prev_name}/index.html" '
                f'class="gd-nav-arrow" title="{prev_name}">&#9664;</a>'
            )
        else:
            prev_link = '<span class="gd-nav-arrow-disabled" title="First package">&#9664;</span>'
        if ci < len(ok_names) - 1:
            next_name = ok_names[ci + 1]
            next_link = (
                f'<a href="{hub_prefix}/{next_name}/index.html" '
                f'class="gd-nav-arrow" title="{next_name}">&#9654;</a>'
            )
        else:
            next_link = '<span class="gd-nav-arrow-disabled" title="Last package">&#9654;</span>'
    else:
        prev_link = '<span class="gd-nav-arrow-disabled">&#9664;</span>'
        next_link = '<span class="gd-nav-arrow-disabled">&#9654;</span>'

    stale_trigger_badge = (
        ' <span class="gd-nav-badge-stale" style="margin-left:4px">STALE</span>'
        if current_is_stale
        else ""
    )

    stale_stat = (
        f'<span class="gd-nav-stale-stat">{stale_count} stale</span>' if stale_count else ""
    )

    return textwrap.dedent(f"""\
    <!-- GD-NAV-START -->
    <style>
    .gd-topnav {{
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        z-index: 99999;
        height: 40px;
        background: #1e1e2e;
        color: #cdd6f4;
        display: flex;
        align-items: center;
        padding: 0 16px;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
        font-size: 13px;
        box-shadow: 0 1px 3px rgba(0,0,0,.3);
        gap: 8px;
    }}
    .gd-topnav a {{ color: #89b4fa; text-decoration: none; }}
    .gd-topnav a:hover {{ color: #b4d0fb; text-decoration: underline; }}
    .gd-nav-hub {{ font-weight: 600; font-size: 14px; color: #cba6f7; margin-right: 8px; }}
    .gd-nav-hub:hover {{ color: #d8bff8 !important; }}
    .gd-nav-sep {{ color: #585b70; margin: 0 4px; }}
    .gd-nav-dropdown {{ position: relative; }}
    .gd-nav-trigger {{
        background: #313244;
        border: 1px solid #45475a;
        border-radius: 6px;
        color: #cdd6f4;
        padding: 4px 28px 4px 10px;
        font-size: 13px;
        cursor: pointer;
        min-width: 520px;
        text-align: left;
        position: relative;
    }}
    .gd-nav-trigger::after {{
        content: "▾";
        position: absolute;
        right: 10px;
        top: 50%;
        transform: translateY(-50%);
        font-size: 11px;
        color: #a6adc8;
    }}
    .gd-nav-trigger:hover {{ background: #45475a; border-color: #585b70; }}
    .gd-nav-panel {{
        display: none;
        position: absolute;
        top: 100%;
        left: 0;
        margin-top: 4px;
        background: #1e1e2e;
        border: 1px solid #45475a;
        border-radius: 8px;
        width: 400px;
        max-height: 80vh;
        overflow-y: auto;
        box-shadow: 0 8px 24px rgba(0,0,0,.4);
        padding: 6px 0;
    }}
    .gd-nav-dropdown.open .gd-nav-panel {{ display: block; }}
    .gd-nav-category {{
        padding: 6px 12px 3px;
        font-size: 11px;
        font-weight: 600;
        color: #a6adc8;
        text-transform: uppercase;
        letter-spacing: .5px;
        border-top: 1px solid #313244;
        margin-top: 2px;
    }}
    .gd-nav-category:first-child {{ border-top: none; margin-top: 0; }}
    .gd-nav-item {{
        display: flex;
        align-items: center;
        padding: 5px 12px;
        color: #cdd6f4;
        font-size: 12px;
        font-family: "SF Mono", "Fira Code", monospace;
        gap: 6px;
        text-decoration: none !important;
    }}
    a.gd-nav-item:hover {{ background: #313244; color: #cdd6f4; text-decoration: none !important; }}
    .gd-nav-current {{ background: #313244; font-weight: 600; }}
    .gd-nav-disabled {{ opacity: .45; cursor: default; }}
    .gd-nav-num {{ color: #6c7086; font-size: 10px; min-width: 32px; }}
    .gd-nav-dot {{ color: #89b4fa; margin-left: auto; font-size: 10px; }}
    .gd-nav-badge-fail {{
        font-size: 9px;
        background: #f38ba8;
        color: #1e1e2e;
        padding: 0 4px;
        border-radius: 3px;
        font-weight: 700;
        margin-left: auto;
    }}
    .gd-nav-badge-stale {{
        font-size: 9px;
        background: #f9e2af;
        color: #1e1e2e;
        padding: 0 4px;
        border-radius: 3px;
        font-weight: 700;
        margin-left: 4px;
    }}
    .gd-nav-arrow {{
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 28px;
        height: 28px;
        border-radius: 6px;
        background: #313244;
        color: #cdd6f4 !important;
        font-size: 12px;
        text-decoration: none !important;
        border: 1px solid #45475a;
    }}
    .gd-nav-arrow:hover {{ background: #45475a; }}
    .gd-nav-arrow-disabled {{
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 28px;
        height: 28px;
        border-radius: 6px;
        background: #313244;
        color: #585b70 !important;
        font-size: 12px;
        border: 1px solid #45475a;
        cursor: default;
        opacity: 0.4;
    }}
    .gd-nav-stats {{
        margin-left: auto;
        font-size: 11px;
        color: #a6adc8;
        display: flex;
        gap: 8px;
    }}
    .gd-nav-stale-stat {{
        color: #f9e2af;
    }}
    .gd-nav-elapsed {{
        color: #94e2d5;
        font-family: "SF Mono", "Fira Code", monospace;
        font-size: 11px;
    }}
    .gd-nav-coverage {{
        font-family: "SF Mono", "Fira Code", monospace;
        font-size: 11px;
    }}
    body {{ margin-top: 40px !important; }}
    #quarto-header {{ top: 40px !important; }}
    /* When a gd-lightbox is open it sets body.gd-lightbox-open. The hub nav is
       fixed at z-index 99999 and would otherwise cover the lightbox's top
       toolbar (copy/download/link/close), so hide it while the lightbox is up. */
    body.gd-lightbox-open .gd-topnav {{ display: none; }}
    </style>

    <div class="gd-topnav" id="gd-topnav">
        <a href="{hub_prefix}/index.html" class="gd-nav-hub">&#9776; GDG</a>
        <span class="gd-nav-sep">/</span>

        {prev_link}

        <div class="gd-nav-dropdown" id="gd-nav-dropdown">
            <button class="gd-nav-trigger" id="gd-nav-trigger">
                #{current_num:03d} {html.escape(current_name)}{stale_trigger_badge}
            </button>
            <div class="gd-nav-panel" id="gd-nav-panel">
                {dropdown_html}
            </div>
        </div>

        {next_link}

        <span class="gd-nav-stats">
            <span>{ok_count}/{len(results)} built</span>
            {stale_stat}
            {elapsed_html}
            {coverage_html}
        </span>
    </div>

    <script>
    (function() {{
        const dd = document.getElementById('gd-nav-dropdown');
        const trigger = document.getElementById('gd-nav-trigger');
        const panel = document.getElementById('gd-nav-panel');

        trigger.addEventListener('click', function(e) {{
            e.stopPropagation();
            dd.classList.toggle('open');
            if (dd.classList.contains('open')) {{
                const cur = panel.querySelector('.gd-nav-current');
                if (cur) cur.scrollIntoView({{ block: 'center' }});
            }}
        }});

        document.addEventListener('click', function(e) {{
            if (!dd.contains(e.target)) dd.classList.remove('open');
        }});

        document.addEventListener('keydown', function(e) {{
            if (e.key === 'Escape') dd.classList.remove('open');
        }});
    }})();
    </script>
    <!-- GD-NAV-END -->
    """)


def inject_nav_into_html(html_path: Path, nav_html: str) -> None:
    """Inject the navigation bar HTML right after <body...>.

    If the file already contains a `<!-- GD-NAV-START -->` block it is
    stripped first so that re-injection is idempotent.
    """
    content = html_path.read_text(encoding="utf-8")

    # Strip any previously-injected nav block
    content = re.sub(
        r"\n?<!-- GD-NAV-START -->.*?<!-- GD-NAV-END -->\n?",
        "",
        content,
        flags=re.DOTALL,
    )

    body_match = re.search(r"(<body[^>]*>)", content)
    if body_match:
        insert_pos = body_match.end()
        content = content[:insert_pos] + "\n" + nav_html + "\n" + content[insert_pos:]
        html_path.write_text(content, encoding="utf-8")


# ── Hub pages ────────────────────────────────────────────────────────────────


def _create_log_page(name: str, log_path: str | None) -> str:
    """Create an HTML page that displays the build log for a package."""
    log_content = ""
    if log_path and Path(log_path).exists():
        log_content = html.escape(Path(log_path).read_text(encoding="utf-8"))
    else:
        log_content = "(no build log available)"

    return textwrap.dedent(f"""\
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Build Log — {html.escape(name)}</title>
        <style>
            * {{ margin: 0; padding: 0; box-sizing: border-box; }}
            body {{
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
                background: #0d1117; color: #e6edf3; min-height: 100vh;
            }}
            .topbar {{
                background: #1e1e2e; border-bottom: 1px solid #30363d;
                padding: 12px 24px; display: flex; align-items: center; gap: 12px;
            }}
            .topbar a {{ color: #89b4fa; text-decoration: none; font-size: 13px; }}
            .topbar a:hover {{ text-decoration: underline; }}
            .topbar .sep {{ color: #585b70; }}
            .topbar h1 {{ font-size: 15px; font-weight: 600; color: #cba6f7; }}
            .log-container {{ max-width: 1100px; margin: 24px auto; padding: 0 24px; }}
            .log-pre {{
                background: #161b22; border: 1px solid #30363d; border-radius: 8px;
                padding: 20px 24px; font-family: "SF Mono", "Fira Code", monospace;
                font-size: 12px; line-height: 1.5; color: #c9d1d9;
                overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;
            }}
        </style>
    </head>
    <body>
        <div class="topbar">
            <a href="index.html">&larr; GDG</a>
            <span class="sep">/</span>
            <a href="_detail_{name}.html">{html.escape(name)}</a>
            <span class="sep">/</span>
            <h1>Build Log</h1>
        </div>
        <div class="log-container">
            <pre class="log-pre">{log_content}</pre>
        </div>
    </body>
    </html>
    """)


# ── Coverage-level descriptions (for the test coverage page) ──────────────

_COVERAGE_LEVEL_INFO: dict[str, tuple[str, str]] = {
    # level: (test function name, description)
    "idx": ("test_site_index_exists", "Site has an index.html landing page"),
    "srch": ("test_search_json_exists", "Site has a search.json for site search"),
    "ref": ("test_reference_index_exists", "Reference index.html exists"),
    "nodoc": ("test_nodoc_items_excluded", "Items marked %nodoc are excluded"),
    "bigcl": ("test_big_class_has_method_pages", "Big class has separate method pages"),
    "ug": ("test_user_guide_pages_exist", "User guide pages exist"),
    "supp": ("test_supporting_pages_exist", "Supporting pages (license, etc.) exist"),
    "title": ("test_reference_pages_have_title", "Reference pages have an <h1> title"),
    "badge": ("test_reference_pages_have_type_badge", "Reference pages show a type badge"),
    "sig": ("test_function_pages_have_signature", "Function pages show a call signature"),
    "desc": ("test_pages_have_doc_description", "Pages include a docstring description"),
    "param": ("test_parameters_section_renders", "Parameters section is rendered"),
    "pmatch": ("test_parameter_names_match_signature", "Param names match the signature"),
    "ret": ("test_returns_section_renders", "Returns section is rendered"),
    "refidx": ("test_reference_index_lists_exports", "Reference index lists all exports"),
    "sechdg": (
        "test_reference_index_has_section_headings",
        "Reference index has section headings",
    ),
    "sbar": ("test_sidebar_has_reference_section", "Sidebar has a Reference section"),
    "sbsec": ("test_sidebar_lists_section_titles", "Sidebar lists section titles"),
    "land": ("test_landing_page_has_title", "Landing page has a package title"),
    "hdg": ("test_no_broken_heading_attributes", "No broken heading attributes"),
    "nav": ("test_navbar_renders_with_links", "Navbar renders with navigation links"),
    "meta": ("test_page_has_meta_description", "Page has meta description and OG tags"),
    "a11y": ("test_heading_hierarchy_no_skips", "Heading hierarchy has no level skips"),
    "DED": ("(dedicated tests)", "Has one or more dedicated/feature-specific tests"),
}


def _create_test_coverage_page(name: str) -> str:
    """Create an HTML page showing test coverage details for a single package."""
    coverage = _compute_coverage(name)
    applicable = _applicable_levels(name)
    score = sum(1 for level in applicable if coverage.get(level))
    max_score = len(applicable)
    pct = score / max_score if max_score > 0 else 0
    num = ALL_PACKAGES.index(name) + 1 if name in ALL_PACKAGES else 0

    if pct >= 0.7:
        color = "#a6e3a1"
    elif pct >= 0.4:
        color = "#f9e2af"
    else:
        color = "#f38ba8"

    # Build the coverage table rows
    ded_count = _count_dedicated_tests(name)
    rows = []
    for level in _COVERAGE_LEVELS:
        covered = coverage.get(level, False)
        excluded = level not in applicable
        info = _COVERAGE_LEVEL_INFO.get(level, (level, ""))
        test_fn, desc = info
        if excluded:
            icon = "⊘"
            row_class = "cov-excluded"
        elif covered:
            icon = "✅"
            row_class = "cov-pass"
        else:
            icon = "❌"
            row_class = "cov-miss"
        # For DED, show the count badge
        level_display = html.escape(level)
        if level == "DED" and ded_count > 0:
            level_display = f'{html.escape(level)} <span class="ded-count">×{ded_count}</span>'
            desc = f"{ded_count} dedicated test{'s' if ded_count != 1 else ''} for this package"
        rows.append(
            f'<tr class="{row_class}">'
            f"<td>{icon}</td>"
            f'<td class="cov-level">{level_display}</td>'
            f'<td class="cov-fn">{html.escape(test_fn)}</td>'
            f'<td class="cov-desc">{html.escape(desc)}</td>'
            f"</tr>"
        )
    table_rows = "\n            ".join(rows)
    covered_count = score
    missing_count = max_score - score
    excluded_count = len(_COVERAGE_LEVELS) - max_score

    # Suggestions for improving coverage (only for applicable levels)
    suggestions = []
    if "nodoc" in applicable and not coverage.get("nodoc"):
        suggestions.append(
            "Add <code>nodoc_items</code> to the spec's <code>expected</code> dict "
            "to enable %nodoc exclusion testing."
        )
    if "bigcl" in applicable and not coverage.get("bigcl"):
        suggestions.append(
            "Add <code>big_class_name</code> and <code>big_class_method_count</code> "
            "to the spec to enable big-class method page testing."
        )
    if "ug" in applicable and not coverage.get("ug"):
        suggestions.append(
            "Add <code>user_guide_files</code> to the spec's <code>expected</code> dict "
            "to enable user guide page existence testing."
        )
    if "supp" in applicable and not coverage.get("supp"):
        suggestions.append(
            "Set <code>has_license_page</code>, <code>has_citation_page</code>, etc. in the "
            "spec to enable supporting page tests."
        )
    if "sechdg" in applicable and not coverage.get("sechdg"):
        suggestions.append(
            "Add <code>section_titles</code> to the spec's <code>expected</code> dict "
            "to enable section heading and sidebar section tests."
        )
    if "DED" in applicable and not coverage.get("DED"):
        suggestions.append(
            "Write a dedicated test in <code>test_gdg_rendered.py</code> that asserts "
            "a feature specific to this package (e.g., config behavior, badge presence, "
            "decorator handling). See the <code>gdg-add-tests</code> skill."
        )
    if "hdg" in applicable and not coverage.get("hdg"):
        suggestions.append(
            "This package is not in the first 20 by catalog order, so heading attribute "
            "checks don't run on it. This is by design (performance)."
        )

    suggestions_html = ""
    if suggestions:
        items = "\n".join(f"<li>{s}</li>" for s in suggestions)
        suggestions_html = f"""\
        <div class="section">
            <h2>How to Improve Coverage</h2>
            <ul class="suggestions">{items}</ul>
        </div>"""

    return textwrap.dedent(f"""\
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Test Coverage — {html.escape(name)}</title>
        <style>
            * {{ margin: 0; padding: 0; box-sizing: border-box; }}
            body {{
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
                background: #0d1117; color: #e6edf3; min-height: 100vh;
            }}
            .topbar {{
                background: #1e1e2e; border-bottom: 1px solid #30363d;
                padding: 12px 24px; display: flex; align-items: center; gap: 12px;
            }}
            .topbar a {{ color: #89b4fa; text-decoration: none; font-size: 13px; }}
            .topbar a:hover {{ text-decoration: underline; }}
            .topbar .sep {{ color: #585b70; }}
            .topbar h1 {{ font-size: 15px; font-weight: 600; color: #cba6f7; }}
            .content {{ max-width: 900px; margin: 32px auto; padding: 0 24px; }}
            .score-header {{
                display: flex; align-items: center; gap: 16px;
                margin-bottom: 24px; padding: 16px 20px;
                background: #161b22; border: 1px solid #30363d; border-radius: 8px;
            }}
            .score-num {{
                font-size: 36px; font-weight: 800; color: {color};
                font-family: "SF Mono", monospace;
            }}
            .score-detail {{ font-size: 14px; color: #a6adc8; }}
            .score-bar {{
                height: 8px; background: #21262d; border-radius: 4px;
                margin-top: 8px; overflow: hidden; width: 200px;
            }}
            .score-fill {{
                height: 100%; background: {color}; border-radius: 4px;
                width: {pct * 100:.0f}%;
            }}
            .section {{ margin-bottom: 28px; }}
            .section h2 {{
                font-size: 16px; font-weight: 600; color: #cba6f7;
                margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #30363d;
            }}
            table {{ width: 100%; border-collapse: collapse; }}
            th {{
                text-align: left; padding: 8px 10px; font-size: 12px;
                color: #6e7681; border-bottom: 1px solid #30363d; text-transform: uppercase;
            }}
            td {{ padding: 6px 10px; font-size: 13px; border-bottom: 1px solid #161b2280; }}
            .cov-pass td {{ color: #c9d1d9; }}
            .cov-miss td {{ color: #6e7681; }}
            .cov-excluded td {{ color: #484e58; font-style: italic; text-decoration: line-through; text-decoration-color: #484e5880; }}
            .cov-level {{ font-family: "SF Mono", monospace; font-weight: 600; }}
            .cov-fn {{ font-family: "SF Mono", monospace; font-size: 11px; color: #89b4fa; }}
            .cov-miss .cov-fn {{ color: #484e58; }}
            .cov-excluded .cov-fn {{ color: #484e58; }}
            .cov-desc {{ max-width: 300px; }}
            .ded-count {{
                display: inline-block; padding: 1px 6px; margin-left: 4px;
                border-radius: 10px; font-size: 10px; font-weight: 700;
                background: #a6e3a120; color: #a6e3a1; border: 1px solid #a6e3a140;
            }}
            .counter {{ display: flex; gap: 16px; margin-bottom: 16px; }}
            .counter-item {{
                padding: 4px 12px; border-radius: 4px; font-size: 13px; font-weight: 600;
            }}
            .counter-pass {{ background: #a6e3a122; color: #a6e3a1; border: 1px solid #a6e3a140; }}
            .counter-miss {{ background: #f38ba822; color: #f38ba8; border: 1px solid #f38ba840; }}
            .counter-excluded {{ background: #6e768122; color: #6e7681; border: 1px solid #6e768140; }}
            .suggestions {{ list-style: none; }}
            .suggestions li {{
                padding: 8px 12px; margin-bottom: 6px; font-size: 13px;
                background: #161b22; border: 1px solid #30363d; border-radius: 6px;
                color: #c9d1d9; line-height: 1.5;
            }}
            .suggestions code {{
                background: #21262d; padding: 1px 5px; border-radius: 3px;
                font-family: "SF Mono", monospace; font-size: 11px; color: #f9e2af;
            }}
        </style>
    </head>
    <body>
        <div class="topbar">
            <a href="index.html">&larr; GDG</a>
            <span class="sep">/</span>
            <a href="_detail_{name}.html">{html.escape(name)}</a>
            <span class="sep">/</span>
            <h1>Test Coverage</h1>
        </div>
        <div class="content">
            <div class="score-header">
                <div>
                    <div class="score-num">{score}/{max_score}</div>
                    <div class="score-bar"><div class="score-fill"></div></div>
                </div>
                <div class="score-detail">
                    <strong>#{num:03d} {html.escape(name)}</strong><br>
                    {covered_count} of {max_score} applicable levels covered{f", {excluded_count} excluded" if excluded_count else ""}
                </div>
            </div>

            <div class="section">
                <h2>Coverage Levels</h2>
                <div class="counter">
                    <span class="counter-item counter-pass">✅ {covered_count} covered</span>
                    <span class="counter-item counter-miss">❌ {missing_count} missing</span>
                    {"" if excluded_count == 0 else f'<span class="counter-item counter-excluded">⊘ {excluded_count} excluded</span>'}
                </div>
                <table>
                    <thead>
                        <tr>
                            <th></th><th>Level</th><th>Test</th><th>Description</th>
                        </tr>
                    </thead>
                    <tbody>
                        {table_rows}
                    </tbody>
                </table>
            </div>

            {suggestions_html}
        </div>
    </body>
    </html>
    """)


def _create_dimensions_page(results: list[dict]) -> str:
    """Create an HTML page documenting all dimension axes and codes."""
    from collections import defaultdict

    # Group dimensions by axis
    axes: dict[str, list[tuple[str, str]]] = defaultdict(list)
    for code, meta in DIMENSIONS.items():
        axes[meta["axis"]].append((code, meta["label"]))

    # Sort codes within each axis naturally
    for axis in axes:
        axes[axis].sort(key=lambda x: (x[0][0], int(x[0][1:]) if x[0][1:].isdigit() else 0))

    # Count packages per dimension code
    dim_counts: dict[str, int] = {}
    for r in results:
        for code in r.get("dimensions", []):
            dim_counts[code] = dim_counts.get(code, 0) + 1

    # Build per-axis sections
    axis_sections = []
    axis_order = [
        "layout",
        "exports",
        "objects",
        "docstrings",
        "directives",
        "user_guide",
        "landing",
        "extras",
        "config",
        "docstring",
        "sections",
        "reference",
        "theme",
        "skill",
    ]

    for axis in axis_order:
        if axis not in axes:
            continue
        color = AXIS_COLORS.get(axis, "#6b7280")
        codes = axes[axis]

        rows = []
        for code, label in codes:
            count = dim_counts.get(code, 0)
            # Get package links
            pkg_links = []
            for r in results:
                if code in r.get("dimensions", []):
                    pkg_links.append(
                        f'<a href="_detail_{r["name"]}.html">{html.escape(r["name"])}</a>'
                    )
            pkgs_html = ", ".join(pkg_links) if pkg_links else '<span class="dim-none">—</span>'
            rows.append(
                f"<tr>"
                f'<td class="dim-code" style="color:{color}">{html.escape(code)}</td>'
                f'<td class="dim-label">{html.escape(label)}</td>'
                f'<td class="dim-count">{count}</td>'
                f'<td class="dim-pkgs">{pkgs_html}</td>'
                f"</tr>"
            )
        rows_html = "\n                ".join(rows)

        axis_sections.append(
            f'<div class="axis-section">'
            f'<h3 style="color:{color}">'
            f'<span class="axis-dot" style="background:{color}"></span>'
            f"{html.escape(axis.upper().replace('_', ' '))}"
            f'<span class="axis-count">{len(codes)} codes, '
            f"{sum(dim_counts.get(c, 0) for c, _ in codes)} uses</span></h3>"
            f"<table>"
            f"<thead><tr><th>Code</th><th>Label</th><th>Packages</th><th>Used By</th></tr></thead>"
            f"<tbody>{rows_html}</tbody>"
            f"</table></div>"
        )

    sections_html = "\n        ".join(axis_sections)
    total_codes = len(DIMENSIONS)
    total_axes = len(axes)
    used_codes = sum(1 for c in DIMENSIONS if dim_counts.get(c, 0) > 0)

    return textwrap.dedent(f"""\
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Dimensions Index — GDG</title>
        <style>
            * {{ margin: 0; padding: 0; box-sizing: border-box; }}
            body {{
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
                background: #0d1117; color: #e6edf3; min-height: 100vh;
            }}
            .topbar {{
                background: #1e1e2e; border-bottom: 1px solid #30363d;
                padding: 12px 24px; display: flex; align-items: center; gap: 12px;
            }}
            .topbar a {{ color: #89b4fa; text-decoration: none; font-size: 13px; }}
            .topbar a:hover {{ text-decoration: underline; }}
            .topbar h1 {{ font-size: 15px; font-weight: 600; color: #cba6f7; }}
            .content {{ max-width: 1200px; margin: 32px auto; padding: 0 24px; }}
            .intro {{
                margin-bottom: 28px; padding: 16px 20px;
                background: #161b22; border: 1px solid #30363d; border-radius: 8px;
                line-height: 1.6;
            }}
            .intro p {{ font-size: 14px; color: #a6adc8; margin-bottom: 8px; }}
            .intro p:last-child {{ margin-bottom: 0; }}
            .summary-header {{
                display: flex; gap: 20px; flex-wrap: wrap;
                margin-bottom: 28px;
            }}
            .sum-card {{
                background: #161b22; border: 1px solid #30363d; border-radius: 8px;
                padding: 16px 24px; min-width: 130px; text-align: center;
            }}
            .sum-num {{ font-size: 28px; font-weight: 800; font-family: "SF Mono", monospace; }}
            .sum-label {{ font-size: 12px; color: #6e7681; margin-top: 4px; text-transform: uppercase; }}
            .axis-section {{ margin-bottom: 28px; }}
            .axis-section h3 {{
                font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px;
                margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #30363d;
            }}
            .axis-dot {{
                width: 10px; height: 10px; border-radius: 50%; display: inline-block;
            }}
            .axis-count {{
                font-size: 11px; font-weight: 400; color: #6e7681; margin-left: auto;
            }}
            table {{ width: 100%; border-collapse: collapse; margin-bottom: 8px; }}
            th {{
                text-align: left; padding: 6px 10px; font-size: 11px;
                color: #6e7681; border-bottom: 1px solid #30363d; text-transform: uppercase;
            }}
            td {{ padding: 5px 10px; font-size: 13px; border-bottom: 1px solid #161b2280; }}
            .dim-code {{
                font-family: "SF Mono", monospace; font-weight: 700; font-size: 13px;
            }}
            .dim-label {{ color: #cdd6f4; }}
            .dim-count {{
                font-family: "SF Mono", monospace; font-size: 12px; color: #a6adc8;
                text-align: center;
            }}
            .dim-pkgs {{ font-size: 11px; line-height: 1.6; }}
            .dim-pkgs a {{ color: #58a6ff; text-decoration: none; }}
            .dim-pkgs a:hover {{ text-decoration: underline; }}
            .dim-none {{ color: #484e58; }}
        </style>
    </head>
    <body>
        <div class="topbar">
            <a href="index.html">&larr; GDG Hub</a>
            <span style="color:#585b70">|</span>
            <h1>\U0001f4d0 Dimensions Index</h1>
        </div>
        <div class="content">
            <div class="intro">
                <p><strong>Dimensions</strong> describe the structural features of each GDG test
                package. Each package is tagged with a set of dimension codes that classify its
                layout, export style, object types, docstring format, and more.</p>
                <p>Dimensions are orthogonal to test coverage levels — they describe <em>what</em>
                a package is, not <em>how well</em> it's tested. Use them to find packages that
                exercise a specific feature combination.</p>
            </div>

            <div class="summary-header">
                <div class="sum-card">
                    <div class="sum-num" style="color:#cba6f7">{total_axes}</div>
                    <div class="sum-label">Axes</div>
                </div>
                <div class="sum-card">
                    <div class="sum-num" style="color:#89b4fa">{total_codes}</div>
                    <div class="sum-label">Dimension Codes</div>
                </div>
                <div class="sum-card">
                    <div class="sum-num" style="color:#a6e3a1">{used_codes}</div>
                    <div class="sum-label">Codes In Use</div>
                </div>
                <div class="sum-card">
                    <div class="sum-num" style="color:#f9e2af">{total_codes - used_codes}</div>
                    <div class="sum-label">Unused Codes</div>
                </div>
            </div>

            {sections_html}
        </div>
    </body>
    </html>
    """)


def _create_test_coverage_summary_page(results: list[dict]) -> str:
    """Create an HTML page showing test coverage summary for all packages."""
    from collections import Counter

    # Build per-package data with proper per-package scoring
    pkg_data = []
    for r in results:
        name = r["name"]
        cov = _compute_coverage(name)
        applicable = _applicable_levels(name)
        score = sum(1 for level in applicable if cov.get(level))
        max_s = len(applicable)
        pct = score / max_s if max_s > 0 else 0
        excluded = set(_COVERAGE_LEVELS) - applicable
        pkg_data.append(
            {
                "name": name,
                "score": score,
                "max": max_s,
                "pct": pct,
                "excluded": len(excluded),
                "coverage": cov,
                "applicable": applicable,
                "status": r["status"],
            }
        )

    total_pkgs = len(pkg_data)
    avg_pct = sum(p["pct"] for p in pkg_data) / max(total_pkgs, 1)
    full_cov = sum(1 for p in pkg_data if p["pct"] == 1.0)
    low_cov = sum(1 for p in pkg_data if p["pct"] < 0.6)
    with_ded = sum(1 for p in pkg_data if p["coverage"].get("DED"))

    # Per-level stats (count applicable + passing)
    level_rows = []
    for level in _COVERAGE_LEVELS:
        info = _COVERAGE_LEVEL_INFO.get(level, (level, ""))
        test_fn, desc = info
        applicable_count = sum(1 for p in pkg_data if level in p["applicable"])
        passing_count = sum(1 for p in pkg_data if p["coverage"].get(level))
        excluded_count = total_pkgs - applicable_count
        lvl_pct = passing_count / max(applicable_count, 1) * 100
        bar_w = lvl_pct
        level_rows.append(
            f"<tr>"
            f'<td class="lvl-code">{html.escape(level)}</td>'
            f'<td class="lvl-desc">{html.escape(desc)}</td>'
            f"<td>{passing_count}/{applicable_count}</td>"
            f'<td class="lvl-excl">{excluded_count}</td>'
            f'<td><div class="lvl-bar"><div class="lvl-fill" style="width:{bar_w:.0f}%"></div>'
            f"</div></td>"
            f"<td>{lvl_pct:.0f}%</td>"
            f"</tr>"
        )
    level_table = "\n            ".join(level_rows)

    # Per-package rows sorted by catalog number (default)
    pkg_data_sorted = sorted(
        pkg_data,
        key=lambda p: ALL_PACKAGES.index(p["name"]) if p["name"] in ALL_PACKAGES else 9999,
    )
    pkg_rows = []
    for p in pkg_data_sorted:
        name = p["name"]
        pct = p["pct"]
        if pct >= 0.7:
            color = "#a6e3a1"
        elif pct >= 0.4:
            color = "#f9e2af"
        else:
            color = "#f38ba8"
        num = ALL_PACKAGES.index(name) + 1 if name in ALL_PACKAGES else 0
        ded_n = _count_dedicated_tests(name)
        ded_cell = f'<span class="ded-badge">{ded_n}</span>' if ded_n > 0 else "—"

        # Level dots (pass/miss/excluded)
        dots = []
        for level in _COVERAGE_LEVELS:
            if level not in p["applicable"]:
                dots.append('<span class="dot dot-excl">●</span>')
            elif p["coverage"].get(level):
                dots.append('<span class="dot dot-pass">●</span>')
            else:
                dots.append('<span class="dot dot-miss">○</span>')
        dots_html = "".join(dots)

        pkg_rows.append(
            f"<tr>"
            f'<td class="pkg-num">#{num:03d}</td>'
            f'<td class="pkg-lnk"><a href="_tests_{name}.html">{html.escape(name)}</a></td>'
            f'<td style="color:{color};font-weight:700;font-family:SF Mono,monospace">'
            f"{pct * 100:.0f}%</td>"
            f'<td class="pkg-score">{p["score"]}/{p["max"]}</td>'
            f'<td class="pkg-excl">{p["excluded"]}</td>'
            f'<td class="dot-row">{dots_html}</td>'
            f'<td class="ded-icon">{ded_cell}</td>'
            f"</tr>"
        )
    pkg_table = "\n            ".join(pkg_rows)

    # Score distribution histogram (by percentage bucket)
    pct_buckets = Counter()
    for p in pkg_data:
        bucket = int(p["pct"] * 10) * 10  # 0, 10, 20, ..., 100
        if bucket > 100:
            bucket = 100
        pct_buckets[bucket] += 1

    hist_max = max(pct_buckets.values()) if pct_buckets else 1
    hist_bars = []
    for bucket in range(0, 110, 10):
        cnt = pct_buckets.get(bucket, 0)
        bar_h = cnt / hist_max * 100 if cnt > 0 else 0
        if bucket >= 70:
            color = "#a6e3a1"
        elif bucket >= 40:
            color = "#f9e2af"
        else:
            color = "#f38ba8"
        cnt_label = f'<div class="hist-cnt">{cnt}</div>' if cnt > 0 else ""
        hist_bars.append(
            f'<div class="hist-col" title="{bucket}%: {cnt} packages">'
            f"{cnt_label}"
            f'<div class="hist-bar-area">'
            f'<div class="hist-bar" style="height:{bar_h:.0f}%;background:{color}"></div>'
            f"</div>"
            f'<div class="hist-label">{bucket}%</div>'
            f"</div>"
        )
    hist_html = "\n            ".join(hist_bars)

    return textwrap.dedent(f"""\
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Test Coverage Summary — GDG</title>
        <style>
            * {{ margin: 0; padding: 0; box-sizing: border-box; }}
            body {{
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
                background: #0d1117; color: #e6edf3; min-height: 100vh;
            }}
            .topbar {{
                background: #1e1e2e; border-bottom: 1px solid #30363d;
                padding: 12px 24px; display: flex; align-items: center; gap: 12px;
            }}
            .topbar a {{ color: #89b4fa; text-decoration: none; font-size: 13px; }}
            .topbar a:hover {{ text-decoration: underline; }}
            .topbar h1 {{ font-size: 15px; font-weight: 600; color: #cba6f7; }}
            .content {{ max-width: 1200px; margin: 32px auto; padding: 0 24px; }}
            .summary-header {{
                display: flex; gap: 20px; flex-wrap: wrap;
                margin-bottom: 28px;
            }}
            .sum-card {{
                background: #161b22; border: 1px solid #30363d; border-radius: 8px;
                padding: 16px 24px; min-width: 140px; text-align: center;
            }}
            .sum-num {{ font-size: 28px; font-weight: 800; font-family: "SF Mono", monospace; }}
            .sum-label {{ font-size: 12px; color: #6e7681; margin-top: 4px; text-transform: uppercase; }}
            .section {{ margin-bottom: 32px; }}
            .section h2 {{
                font-size: 18px; font-weight: 600; color: #cba6f7;
                margin-bottom: 14px; padding-bottom: 6px; border-bottom: 1px solid #30363d;
            }}
            table {{ width: 100%; border-collapse: collapse; }}
            th {{
                text-align: left; padding: 8px 10px; font-size: 11px;
                color: #6e7681; border-bottom: 1px solid #30363d; text-transform: uppercase;
                cursor: pointer; user-select: none; white-space: nowrap;
            }}
            th:hover {{ color: #89b4fa; }}
            th .sort-arrow {{ font-size: 9px; margin-left: 4px; }}
            td {{ padding: 5px 10px; font-size: 13px; border-bottom: 1px solid #161b2280; }}
            .lvl-code {{ font-family: "SF Mono", monospace; font-weight: 600; color: #cba6f7; }}
            .lvl-desc {{ font-size: 12px; color: #a6adc8; }}
            .lvl-excl {{ font-size: 12px; color: #6e7681; }}
            .lvl-bar {{
                height: 10px; background: #21262d; border-radius: 3px;
                overflow: hidden; width: 120px; display: inline-block;
            }}
            .lvl-fill {{ height: 100%; background: #89b4fa; border-radius: 3px; }}
            .pkg-num {{ font-family: "SF Mono", monospace; color: #6e7681; font-size: 12px; }}
            .pkg-lnk a {{ color: #58a6ff; text-decoration: none; }}
            .pkg-lnk a:hover {{ text-decoration: underline; }}
            .pkg-score {{ font-family: "SF Mono", monospace; font-size: 12px; color: #a6adc8; }}
            .pkg-excl {{ font-family: "SF Mono", monospace; font-size: 12px; color: #6e7681; }}
            .dot-row {{ font-family: "SF Mono", monospace; letter-spacing: 2px; font-size: 10px; }}
            .dot-pass {{ color: #a6e3a1; }}
            .dot-miss {{ color: #f38ba8; }}
            .dot-excl {{ color: #30363d; }}
            .ded-icon {{ text-align: center; }}
            .ded-badge {{
                display: inline-block; padding: 2px 7px; border-radius: 10px;
                font-size: 11px; font-weight: 700; font-family: "SF Mono", monospace;
                background: #a6e3a118; color: #a6e3a1; border: 1px solid #a6e3a140;
            }}
            .hist-container {{
                display: flex; align-items: flex-end; gap: 6px;
                padding: 0 4px;
            }}
            .hist-col {{ display: flex; flex-direction: column; align-items: center; flex: 1; justify-content: flex-end; }}
            .hist-bar-area {{ height: 120px; width: 100%; display: flex; align-items: flex-end; }}
            .hist-bar {{ width: 100%; min-width: 12px; border-radius: 3px 3px 0 0; transition: height .3s; }}
            .hist-cnt {{ font-size: 11px; color: #ffffff; margin-bottom: 2px; font-family: "SF Mono", monospace; font-weight: 600; }}
            .hist-label {{ font-size: 11px; color: #a6adc8; margin-top: 4px; font-family: "SF Mono", monospace; }}
            .filter-row {{
                display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; align-items: center;
            }}
            .filter-row input {{
                background: #161b22; border: 1px solid #30363d; border-radius: 6px;
                color: #e6edf3; padding: 6px 12px; font-size: 13px; width: 240px;
            }}
            .filter-row input:focus {{ outline: none; border-color: #89b4fa; }}
            .filter-btn {{
                background: #21262d; border: 1px solid #30363d; border-radius: 6px;
                color: #a6adc8; padding: 5px 12px; font-size: 12px; cursor: pointer;
            }}
            .filter-btn:hover {{ border-color: #89b4fa; color: #89b4fa; }}
            .filter-btn.active {{ background: #89b4fa22; border-color: #89b4fa; color: #89b4fa; }}
        </style>
    </head>
    <body>
        <div class="topbar">
            <a href="index.html">&larr; GDG Hub</a>
            <span class="sep" style="color:#585b70">|</span>
            <h1>\U0001f9ea Test Coverage Summary</h1>
        </div>
        <div class="content">
            <div class="summary-header">
                <div class="sum-card">
                    <div class="sum-num" style="color:#cdd6f4">{total_pkgs}</div>
                    <div class="sum-label">Packages</div>
                </div>
                <div class="sum-card">
                    <div class="sum-num" style="color:#89b4fa">{avg_pct * 100:.0f}%</div>
                    <div class="sum-label">Avg Coverage</div>
                </div>
                <div class="sum-card">
                    <div class="sum-num" style="color:#a6e3a1">{full_cov}</div>
                    <div class="sum-label">100% Coverage</div>
                </div>
                <div class="sum-card">
                    <div class="sum-num" style="color:#f38ba8">{low_cov}</div>
                    <div class="sum-label">&lt;60% Coverage</div>
                </div>
                <div class="sum-card">
                    <div class="sum-num" style="color:#a6e3a1">{with_ded}</div>
                    <div class="sum-label">Dedicated Tests</div>
                </div>
            </div>

            <div class="section">
                <h2>Coverage Distribution</h2>
                <div class="hist-container">
                    {hist_html}
                </div>
            </div>

            <div class="section">
                <h2>Coverage by Level</h2>
                <table>
                    <thead><tr>
                        <th>Level</th><th>Description</th><th>Pass / Applicable</th><th>Excluded</th><th>Bar</th><th>%</th>
                    </tr></thead>
                    <tbody>
                        {level_table}
                    </tbody>
                </table>
            </div>

            <div class="section">
                <h2>Per-Package Coverage</h2>
                <div class="filter-row">
                    <input type="text" id="pkg-filter" placeholder="Filter packages..." oninput="filterPkgs()">
                    <button class="filter-btn sort-btn" data-key="pct" onclick="cycleSortPkgs('pct', this)">Sort: Coverage</button>
                    <button class="filter-btn sort-btn" data-key="name" onclick="cycleSortPkgs('name', this)">Sort: Name</button>
                    <button class="filter-btn sort-btn" data-key="num" onclick="cycleSortPkgs('num', this)">Sort: Catalog #</button>
                    <button class="filter-btn" id="btn-missing" onclick="filterMissing()">Show Missing Only</button>
                    <button class="filter-btn reset-btn" onclick="resetAll()" style="margin-left:auto">Reset</button>
                </div>
                <table id="pkg-table">
                    <thead><tr>
                        <th>#</th><th>Package</th><th>Coverage</th><th>Score</th><th>Excluded</th><th>Levels</th><th>DED</th>
                    </tr></thead>
                    <tbody id="pkg-tbody">
                        {pkg_table}
                    </tbody>
                </table>
            </div>
        </div>

        <script>
        (function() {{
            const tbody = document.getElementById('pkg-tbody');
            const rows = Array.from(tbody.querySelectorAll('tr'));
            const originalOrder = rows.slice();
            const input = document.getElementById('pkg-filter');
            const btnMissing = document.getElementById('btn-missing');
            let showMissingOnly = false;

            // Track sort state per key: null -> 'asc' -> 'desc' -> null
            const sortState = {{ pct: null, name: null, num: null }};

            window.filterPkgs = function() {{
                const q = input.value.toLowerCase();
                rows.forEach(r => {{
                    const name = r.cells[1].textContent.toLowerCase();
                    const pct = parseFloat(r.cells[2].textContent);
                    const matchesFilter = !q || name.includes(q);
                    const matchesMissing = !showMissingOnly || pct < 100;
                    r.style.display = (matchesFilter && matchesMissing) ? '' : 'none';
                }});
            }};

            window.filterMissing = function() {{
                showMissingOnly = !showMissingOnly;
                btnMissing.classList.toggle('active');
                filterPkgs();
            }};

            function clearSortButtons() {{
                document.querySelectorAll('.sort-btn').forEach(b => {{
                    b.classList.remove('active');
                    b.textContent = b.textContent.replace(/ [▲▼]$/, '');
                }});
            }}

            window.cycleSortPkgs = function(key, btn) {{
                // Cycle: null -> asc -> desc -> null
                const prev = sortState[key];
                // Reset all sort states
                Object.keys(sortState).forEach(k => sortState[k] = null);
                clearSortButtons();

                if (prev === null) {{
                    sortState[key] = 'asc';
                }} else if (prev === 'asc') {{
                    sortState[key] = 'desc';
                }} else {{
                    sortState[key] = null;
                }}

                const dir = sortState[key];
                if (dir === null) {{
                    // Reset to original order
                    originalOrder.forEach(r => tbody.appendChild(r));
                    return;
                }}

                btn.classList.add('active');
                btn.textContent = btn.textContent.replace(/ [▲▼]$/, '') + (dir === 'asc' ? ' ▲' : ' ▼');
                const mult = dir === 'asc' ? 1 : -1;

                rows.sort((a, b) => {{
                    if (key === 'pct') {{
                        return mult * (parseFloat(a.cells[2].textContent) - parseFloat(b.cells[2].textContent));
                    }} else if (key === 'name') {{
                        return mult * a.cells[1].textContent.localeCompare(b.cells[1].textContent);
                    }} else {{
                        return mult * (parseInt(a.cells[0].textContent.replace('#','')) -
                               parseInt(b.cells[0].textContent.replace('#','')));
                    }}
                }});
                rows.forEach(r => tbody.appendChild(r));
            }};

            window.resetAll = function() {{
                // Reset filter
                input.value = '';
                // Reset missing toggle
                showMissingOnly = false;
                btnMissing.classList.remove('active');
                // Reset sort
                Object.keys(sortState).forEach(k => sortState[k] = null);
                clearSortButtons();
                // Restore original order and show all
                originalOrder.forEach(r => {{ r.style.display = ''; tbody.appendChild(r); }});
            }};
        }})();
        </script>
    </body>
    </html>
    """)


# ── File-tree CSS (shared between landing pages and detail pages) ─────────

_FILE_TREE_CSS = """\
<style>
.file-tree { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; padding-top: 7px; }
.file-tree details { margin-left: 8px; }
.file-tree > details, .file-tree > .tree-file { margin-left: 0; }
.file-tree summary {
    cursor: pointer; border-radius: 4px;
    color: #c9d1d9; list-style: none; user-select: none;
    padding: 0; margin: 0; line-height: 1.4;
}
.file-tree summary::-webkit-details-marker { display: none; }
.file-tree summary::before {
    content: "▸ "; color: #6e7681; font-size: 10px; margin-right: 2px;
}
.file-tree details[open] > summary::before { content: "▾ "; }
.file-tree summary:hover { background: #161b22; }
.tree-icon { margin-right: 4px; font-size: 13px; }
.tree-dir > summary { color: #79c0ff; }
.tree-file > summary { color: #c9d1d9; }
.tree-children { border-left: 1px solid #21262d; margin-left: 9px; padding-left: 4px; }
.tree-code {
    background: #161b22; border: 1px solid #30363d; border-radius: 6px;
    padding: 12px 16px; margin: 4px 0 8px 0; font-size: 11px;
    line-height: 1.5; color: #c9d1d9; overflow-x: auto;
    white-space: pre-wrap; word-wrap: break-word; max-height: 500px;
}
</style>"""


def _build_file_tree_html(spec: dict, config_path: Path | None = None) -> str:
    """Build an interactive file-tree viewer from a package spec.

    Returns an HTML fragment with `<details>` elements for progressive
    disclosure of directory structure and file contents.

    Parameters
    ----------
    spec
        The package spec dict.
    config_path
        Optional path to the actual `great-docs.yml` on disk. If provided
        and the file exists, its contents are used (capturing any defaults
        that `great-docs init` created). Falls back to the spec's `config` dict.
    """
    all_files: dict[str, str] = {}

    # Source files from the spec
    for path, content in spec.get("files", {}).items():
        all_files[path] = textwrap.dedent(content)

    # great-docs.yml: prefer the on-disk version (includes init defaults)
    # Track whether the config was generated by `great-docs init` (not in spec)
    _config_is_generated = False
    if config_path and config_path.exists():
        all_files["great-docs.yml"] = config_path.read_text(encoding="utf-8")
        if "config" not in spec:
            _config_is_generated = True
    elif "config" in spec:
        all_files["great-docs.yml"] = format_yaml(
            spec["config"], default_flow_style=False, sort_keys=False
        )

    if not all_files:
        return "<p>No source files in spec.</p>"

    # Build a nested tree structure: each dict key is a segment name,
    # leaf values are file-content strings, branch values are sub-dicts.
    tree: dict = {}
    for path in sorted(all_files.keys()):
        parts = path.split("/")
        node = tree
        for part in parts[:-1]:
            if part not in node or not isinstance(node.get(part), dict):
                node[part] = {}
            node = node[part]
        node[parts[-1]] = all_files[path]

    # Language hint for syntax highlighting (cosmetic class)
    ext_lang = {
        "py": "python",
        "yml": "yaml",
        "yaml": "yaml",
        "toml": "toml",
        "md": "markdown",
        "cfg": "ini",
        "txt": "text",
        "json": "json",
        "js": "javascript",
        "css": "css",
        "html": "html",
        "qmd": "quarto",
    }

    def _render(name: str, value, depth: int = 0) -> str:
        indent = "  " * depth
        if isinstance(value, dict):
            children = "\n".join(
                _render(k, v, depth + 1)
                for k, v in sorted(
                    value.items(), key=lambda kv: (not isinstance(kv[1], dict), kv[0])
                )
            )
            return (
                f'{indent}<details class="tree-dir">'
                f'<summary><span class="tree-icon">\U0001f4c1</span> {html.escape(name)}/</summary>'
                f'<div class="tree-children">\n{children}\n{indent}</div>'
                f"</details>"
            )
        else:
            ext = name.rsplit(".", 1)[-1] if "." in name else ""
            lang = ext_lang.get(ext, "")
            lang_attr = f' data-lang="{lang}"' if lang else ""
            content = html.escape(str(value).rstrip())
            # Add "generated" badge for great-docs.yml when it was produced by init
            badge = ""
            if name == "great-docs.yml" and _config_is_generated:
                badge = (
                    ' <span style="font-size:9px;background:#94e2d5;color:#1e1e2e;'
                    "padding:1px 5px;border-radius:3px;font-weight:700;"
                    'vertical-align:middle;margin-left:4px">generated</span>'
                )
            return (
                f'{indent}<details class="tree-file">'
                f'<summary><span class="tree-icon">\U0001f4c4</span> {html.escape(name)}{badge}</summary>'
                f'<pre class="tree-code"{lang_attr}>{content}</pre>'
                f"</details>"
            )

    # Render top-level entries (dirs first, then files)
    items = "\n".join(
        _render(k, v)
        for k, v in sorted(tree.items(), key=lambda kv: (not isinstance(kv[1], dict), kv[0]))
    )
    return f'<div class="file-tree">\n{items}\n</div>'


def _create_detail_page(r: dict, results: list[dict]) -> str:
    """Create a detail/info page for a single package."""
    name = r["name"]
    num = ALL_PACKAGES.index(name) + 1 if name in ALL_PACKAGES else 0
    desc = html.escape(r.get("description", ""))
    long_desc = html.escape(r.get("long_description", ""))
    status = r["status"]
    dims = r.get("dimensions", [])
    badges = _dimension_badges(dims)

    # Build the file tree from the spec
    try:
        spec = get_spec(name)
        file_tree_html = _build_file_tree_html(
            spec,
            config_path=RENDERED_DIR / name / "great-docs.yml",
        )
        is_init_pkg = not bool(spec.get("config"))
    except Exception:
        file_tree_html = "<p>Spec not available.</p>"
        is_init_pkg = False

    if status == "ok":
        status_badge = (
            '<span style="background:#a6e3a1;color:#1e1e2e;padding:2px 8px;'
            'border-radius:4px;font-size:12px;font-weight:600;">OK</span>'
        )
        site_link = (
            f'<a href="{name}/index.html" class="action-btn action-primary">View Site &rarr;</a>'
        )
    else:
        status_badge = (
            f'<span style="background:#f38ba8;color:#1e1e2e;padding:2px 8px;'
            f'border-radius:4px;font-size:12px;font-weight:600;">'
            f"FAILED ({html.escape(status)})</span>"
        )
        site_link = ""

    log_link = f'<a href="_log_{name}.html" class="action-btn action-secondary">Build Log</a>'
    tests_link = f'<a href="_tests_{name}.html" class="action-btn action-secondary">\U0001f9ea Test Coverage</a>'

    # Init/config badge
    if is_init_pkg:
        init_badge = (
            '<span style="background:#94e2d5;color:#1e1e2e;padding:2px 8px;'
            'border-radius:4px;font-size:12px;font-weight:600;margin-left:6px;">INIT</span>'
        )
        init_section = """\
        <div class="section">
            <h2>Build Mode</h2>
            <div class="build-mode-info">
                <span class="build-mode-badge build-mode-init">&#9675; No great-docs.yml</span>
                <p class="build-mode-desc">This package has <strong>no pre-supplied config</strong>.
                It tests the full <code>great-docs init</code> &rarr; <code>great-docs build</code>
                pipeline from scratch, relying entirely on auto-detection of the package
                layout, docstring style, and exports.</p>
            </div>
        </div>"""
    else:
        init_badge = (
            '<span style="background:#cba6f7;color:#1e1e2e;padding:2px 8px;'
            'border-radius:4px;font-size:12px;font-weight:600;margin-left:6px;">CONFIG</span>'
        )
        init_section = """\
        <div class="section">
            <h2>Build Mode</h2>
            <div class="build-mode-info">
                <span class="build-mode-badge build-mode-config">&#9679; Has great-docs.yml</span>
                <p class="build-mode-desc">This package ships a <strong>pre-supplied config</strong>.
                The <code>great-docs init</code> step is skipped and
                <code>great-docs build</code> uses the spec-defined configuration directly.
                Tests specific config options and their rendered output.</p>
            </div>
        </div>"""

    error_section = ""
    if "error" in r:
        error_text = html.escape(r["error"][:2000])
        error_section = f"""\
        <div class="section">
            <h2>Error Output</h2>
            <pre class="error-pre">{error_text}</pre>
        </div>"""

    dim_details = []
    for code in dims:
        meta = DIMENSIONS.get(code, {})
        axis = meta.get("axis", "")
        label = meta.get("label", code)
        color = AXIS_COLORS.get(axis, "#6b7280")
        dim_details.append(
            f'<div class="dim-row">'
            f'<span class="dim-code" style="color:{color}">{html.escape(code)}</span>'
            f'<span class="dim-label">{html.escape(label)}</span>'
            f'<span class="dim-axis">{html.escape(axis)}</span>'
            f"</div>"
        )
    dim_html = "\n".join(dim_details) if dim_details else "<p>None</p>"

    return textwrap.dedent(f"""\
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>{html.escape(name)} — Synthetic Package Details</title>
        <style>
            * {{ margin: 0; padding: 0; box-sizing: border-box; }}
            body {{
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
                background: #0d1117; color: #e6edf3; min-height: 100vh;
            }}
            .topbar {{
                background: #1e1e2e; border-bottom: 1px solid #30363d;
                padding: 12px 24px; display: flex; align-items: center; gap: 12px;
            }}
            .topbar a {{ color: #89b4fa; text-decoration: none; font-size: 13px; }}
            .topbar a:hover {{ text-decoration: underline; }}
            .topbar .sep {{ color: #585b70; }}
            .topbar h1 {{ font-size: 15px; font-weight: 600; color: #cba6f7; }}
            .content {{ max-width: 800px; margin: 32px auto; padding: 0 24px; }}
            .pkg-header {{ display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }}
            .pkg-num {{ font-size: 14px; color: #6e7681; font-family: "SF Mono", monospace; }}
            .pkg-name {{ font-size: 24px; font-weight: 700; font-family: "SF Mono", monospace; color: #58a6ff; }}
            .pkg-short {{ font-size: 15px; color: #a6adc8; margin-bottom: 16px; font-style: italic; }}
            .pkg-long {{ font-size: 14px; color: #c9d1d9; line-height: 1.65; margin-bottom: 24px; }}
            .actions {{ display: flex; gap: 10px; margin-bottom: 28px; }}
            .action-btn {{
                display: inline-block; padding: 8px 18px; border-radius: 6px;
                font-size: 13px; font-weight: 600; text-decoration: none !important;
                transition: all .15s;
            }}
            .action-primary {{ background: #238636; color: #fff !important; border: 1px solid #2ea043; }}
            .action-primary:hover {{ background: #2ea043; }}
            .action-secondary {{ background: #21262d; color: #c9d1d9 !important; border: 1px solid #30363d; }}
            .action-secondary:hover {{ background: #30363d; }}
            .section {{ margin-bottom: 24px; }}
            .section h2 {{
                font-size: 16px; font-weight: 600; color: #cba6f7;
                margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #30363d;
            }}
            .dim-row {{
                display: flex; gap: 12px; padding: 5px 0; font-size: 13px;
                border-bottom: 1px solid #161b2280;
            }}
            .dim-code {{ font-family: "SF Mono", monospace; font-weight: 600; min-width: 40px; }}
            .dim-label {{ color: #c9d1d9; flex: 1; }}
            .dim-axis {{ color: #6e7681; font-size: 11px; text-transform: uppercase; }}
            .error-pre {{
                background: #1c0d0d; border: 1px solid #f38ba830; border-radius: 6px;
                padding: 14px 18px; font-family: "SF Mono", monospace; font-size: 12px;
                color: #f38ba8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;
            }}
            .badges-row {{ line-height: 1.8; }}
            .build-mode-info {{ padding: 12px 16px; border-radius: 8px; background: #161b22; border: 1px solid #30363d; }}
            .build-mode-badge {{
                display: inline-block; font-size: 12px; font-weight: 700;
                padding: 3px 10px; border-radius: 4px; margin-bottom: 8px;
            }}
            .build-mode-init {{ background: #94e2d520; color: #94e2d5; border: 1px solid #94e2d540; }}
            .build-mode-config {{ background: #cba6f720; color: #cba6f7; border: 1px solid #cba6f740; }}
            .build-mode-desc {{ font-size: 13px; color: #8b949e; line-height: 1.5; margin-top: 6px; }}
            .build-mode-desc code {{ background: #21262d; padding: 1px 5px; border-radius: 3px; font-size: 12px; color: #c9d1d9; }}
            .file-tree {{ font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; }}
            .file-tree details {{ margin-left: 8px; }}
            .file-tree > details, .file-tree > .tree-file {{ margin-left: 0; }}
            .file-tree summary {{
                cursor: pointer; border-radius: 4px;
                color: #c9d1d9; list-style: none; user-select: none;
                padding: 0; margin: 0; line-height: 1.4;
            }}
            .file-tree summary::-webkit-details-marker {{ display: none; }}
            .file-tree summary::before {{
                content: "▸ "; color: #6e7681; font-size: 10px; margin-right: 2px;
            }}
            .file-tree details[open] > summary::before {{ content: "▾ "; }}
            .file-tree summary:hover {{ background: #161b22; }}
            .tree-icon {{ margin-right: 4px; font-size: 13px; }}
            .tree-dir > summary {{ color: #79c0ff; }}
            .tree-file > summary {{ color: #c9d1d9; }}
            .tree-children {{ border-left: 1px solid #21262d; margin-left: 9px; padding-left: 4px; }}
            .tree-code {{
                background: #161b22; border: 1px solid #30363d; border-radius: 6px;
                padding: 12px 16px; margin: 4px 0 8px 0; font-size: 11px;
                line-height: 1.5; color: #c9d1d9; overflow-x: auto;
                white-space: pre-wrap; word-wrap: break-word; max-height: 500px;
            }}
        </style>
    </head>
    <body>
        <div class="topbar">
            <a href="index.html">&larr; GDG</a>
            <span class="sep">/</span>
            <h1>#{num:03d} {html.escape(name)}</h1>
        </div>
        <div class="content">
            <div class="pkg-header">
                <span class="pkg-num">#{num:03d}</span>
                <span class="pkg-name">{html.escape(name)}</span>
                {status_badge}
                {init_badge}
            </div>
            <div class="pkg-short">{desc}</div>
            <div class="pkg-long">{long_desc}</div>
            <div class="actions">
                {site_link}
                {log_link}
                {tests_link}
            </div>
            {error_section}
            {init_section}
            <div class="section">
                <h2>Dimensions</h2>
                <div class="badges-row">{badges}</div>
                <div style="margin-top:12px">{dim_html}</div>
            </div>
            <div class="section">
                <h2>Source Files</h2>
                {file_tree_html}
            </div>
        </div>
    </body>
    </html>
    """)


def create_hub_page(results: list[dict]) -> None:
    """Create the hub index page with cards for each package."""
    HUB_DIR.mkdir(parents=True, exist_ok=True)

    ok_results = [r for r in results if r["status"] == "ok"]
    fail_results = [r for r in results if r["status"] != "ok"]

    cards_html = []
    for r in results:
        name = r["name"]
        num = ALL_PACKAGES.index(name) + 1 if name in ALL_PACKAGES else 0
        desc = html.escape(r.get("description", ""))
        long_desc = html.escape(r.get("long_description", ""))
        dims = r.get("dimensions", [])
        badges = _dimension_badges(dims)
        status = r["status"]

        # Determine if this package tests pure init (no great-docs.yml)
        try:
            _spec = get_spec(name)
            is_init_pkg = not bool(_spec.get("config"))
        except Exception:
            is_init_pkg = False

        card_long = long_desc[:140] + "..." if len(long_desc) > 140 else long_desc

        cov_score = coverage_score(name)
        cov_max = coverage_max(name)
        cov_badge = _coverage_badge_html(cov_score, cov_max)

        links = (
            f'<div class="card-links">'
            f'<a href="_detail_{name}.html" class="card-link" '
            f'onclick="event.stopPropagation()">Details</a>'
            f'<a href="_log_{name}.html" class="card-link" '
            f'onclick="event.stopPropagation()">Build Log</a>'
            f'<a href="_tests_{name}.html" class="card-link" '
            f'onclick="event.stopPropagation()">Tests</a>'
            f"{cov_badge}"
            f"</div>"
        )

        init_cls = " card-init" if is_init_pkg else ""
        init_badge = '<span class="card-init-badge">INIT</span>' if is_init_pkg else ""

        if status == "ok":
            cards_html.append(f"""\
            <div class="card card-ok{init_cls}" data-href="{name}/index.html" data-init="{str(is_init_pkg).lower()}">
                <div class="card-header">
                    <span class="card-num">#{num:03d}</span>
                    <span class="card-name">{html.escape(name)}</span>
                    {init_badge}
                </div>
                <div class="card-desc">{desc}</div>
                <div class="card-long">{card_long}</div>
                <div class="card-dims">{badges}</div>
                {links}
            </div>""")
        else:
            error = html.escape(r.get("error", status)[:120])
            cards_html.append(f"""\
            <div class="card card-fail{init_cls}" data-init="{str(is_init_pkg).lower()}">
                <div class="card-header">
                    <span class="card-num">#{num:03d}</span>
                    <span class="card-name">{html.escape(name)}</span>
                    {init_badge}
                    <span class="card-status">FAILED</span>
                </div>
                <div class="card-desc">{desc}</div>
                <div class="card-long">{card_long}</div>
                <div class="card-error">{error}</div>
                <div class="card-dims">{badges}</div>
                {links}
            </div>""")

    # Collect all unique axis names for filter buttons
    all_axes = sorted(set(AXIS_COLORS.keys()))
    filter_buttons = ['<button class="filter-btn active" data-filter="all">All</button>']
    axis_labels = {
        "layout": "Layout",
        "exports": "Exports",
        "objects": "Objects",
        "docstrings": "Doc Format",
        "directives": "Directives",
        "user_guide": "User Guide",
        "landing": "Landing",
        "extras": "Extras",
        "config": "Config",
        "docstring": "Doc Richness",
        "sections": "Sections",
        "reference": "Reference",
        "theme": "Theming",
    }
    for axis in all_axes:
        label = axis_labels.get(axis, axis.replace("_", " ").title())
        filter_buttons.append(f'<button class="filter-btn" data-filter="{axis}">{label}</button>')
    filter_buttons.append('<button class="filter-btn" data-filter="init">Init (no config)</button>')
    filter_buttons.append('<button class="filter-btn" data-filter="config">Has Config</button>')
    filter_buttons.append('<button class="filter-btn" data-filter="failed">Failed</button>')
    filter_html = "\n            ".join(filter_buttons)

    # Count init (no-config) vs config packages
    init_count = 0
    for r in results:
        try:
            _s = get_spec(r["name"])
            if not _s.get("config"):
                init_count += 1
        except Exception:
            pass
    config_count = len(results) - init_count

    hub_html = textwrap.dedent(f"""\
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Great Docs Gauntlet</title>
        <style>
            * {{ margin: 0; padding: 0; box-sizing: border-box; }}
            body {{
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
                background: #0d1117; color: #e6edf3; min-height: 100vh;
            }}
            .header {{
                background: linear-gradient(135deg, #1e1e2e 0%, #181825 100%);
                border-bottom: 1px solid #30363d;
                padding: 32px 24px; text-align: center;
            }}
            .header h1 {{
                font-size: 36px; font-weight: 800; margin-bottom: 8px;
                background: linear-gradient(90deg, #f97316, #ef4444, #f59e0b, #ef4444, #f97316);
                background-size: 200% auto;
                -webkit-background-clip: text;
                -webkit-text-fill-color: transparent;
                background-clip: text;
                animation: fire-shift 3s ease-in-out infinite;
                position: relative;
                isolation: isolate;
            }}
            .header h1::before {{
                content: '';
                position: absolute;
                inset: -8px -16px;
                background: radial-gradient(ellipse at center, rgba(249,115,22,0.4) 0%, rgba(239,68,68,0.25) 40%, transparent 70%);
                filter: blur(18px);
                z-index: -1;
                animation: glow-pulse 3s ease-in-out infinite;
            }}
            @keyframes fire-shift {{
                0%, 100% {{ background-position: 0% center; }}
                50% {{ background-position: 100% center; }}
            }}
            @keyframes glow-pulse {{
                0%, 100% {{ opacity: 0.6; }}
                50% {{ opacity: 0.9; }}
            }}
            .header p {{ color: #8b949e; font-size: 15px; max-width: 700px; margin: 0 auto; }}
            .stats {{ display: flex; gap: 12px 20px; justify-content: center; margin-top: 16px; flex-wrap: wrap; padding: 0 16px; }}
            .stat {{ display: flex; align-items: center; gap: 6px; font-size: 14px; white-space: nowrap; }}
            .stat-num {{ font-size: 22px; font-weight: 700; }}
            .stat-ok .stat-num {{ color: #a6e3a1; }}
            .stat-fail .stat-num {{ color: #f38ba8; }}
            .stat-total .stat-num {{ color: #89b4fa; }}
            .stat-init .stat-num {{ color: #94e2d5; }}
            .stat-config .stat-num {{ color: #cba6f7; }}
            .stat-cov {{
                background: #1e3a3a; border: 1px solid #94e2d5; border-radius: 20px;
                padding: 4px 14px; color: #fff; transition: background 0.2s;
                white-space: nowrap;
            }}
            .stat-cov:hover {{ background: #264d4d; }}
            .stat-cov .stat-num {{ color: #94e2d5; }}
            .stat-cov .chevron {{ margin-left: 2px; font-size: 16px; color: #94e2d5; }}

            .stat-dim {{
                background: #1e2a3a; border: 1px solid #89b4fa; border-radius: 20px;
                padding: 4px 14px; color: #fff; transition: background 0.2s;
                white-space: nowrap;
            }}
            .stat-dim:hover {{ background: #263d5a; }}
            .stat-dim .stat-num {{ color: #89b4fa; }}
            .stat-dim .chevron {{ margin-left: 2px; font-size: 16px; color: #89b4fa; }}

            @media (max-width: 480px) {{
                .stats {{ gap: 8px 12px; padding: 0 8px; }}
                .stat-num {{ font-size: 18px; }}
                .stat {{ font-size: 12px; gap: 4px; }}
                .stat-cov, .stat-dim {{ padding: 3px 10px; font-size: 12px; }}
            }}

            .filter-bar {{
                max-width: 1200px; margin: 20px auto 0; padding: 0 24px;
                display: flex; gap: 8px; flex-wrap: wrap;
            }}
            .filter-btn {{
                background: #21262d; border: 1px solid #30363d; border-radius: 20px;
                color: #8b949e; padding: 5px 14px; font-size: 12px;
                cursor: pointer; transition: all .15s;
            }}
            .filter-btn:hover {{ background: #30363d; color: #e6edf3; }}
            .filter-btn.active {{ background: #1f6feb22; border-color: #1f6feb; color: #58a6ff; }}

            .grid {{
                max-width: 1200px; margin: 20px auto; padding: 0 24px 40px;
                display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px;
            }}
            .card {{
                background: #161b22; border: 1px solid #30363d; border-radius: 8px;
                padding: 14px 16px; display: flex; flex-direction: column; gap: 6px;
                transition: border-color .15s, box-shadow .15s;
                text-decoration: none !important; color: inherit !important;
            }}
            .card-ok {{ cursor: pointer; }}
            .card-ok:hover {{ border-color: #58a6ff; box-shadow: 0 0 0 1px #58a6ff40; }}
            .card-init {{ border-left: 3px solid #94e2d5; }}
            .card-init:hover {{ border-left-color: #94e2d5; }}
            .card-init-badge {{
                font-size: 9px;
                background: #94e2d5;
                color: #1e1e2e;
                padding: 1px 5px;
                border-radius: 3px;
                font-weight: 700;
                letter-spacing: .3px;
            }}
            .card-fail {{ opacity: .65; }}
            .card-header {{ display: flex; align-items: center; gap: 8px; }}
            .card-num {{ font-size: 11px; color: #6e7681; font-family: "SF Mono", monospace; min-width: 32px; }}
            .card-name {{ font-size: 14px; font-weight: 600; font-family: "SF Mono", monospace; color: #58a6ff; }}
            .card-fail .card-name {{ color: #f38ba8; }}
            .card-status {{
                margin-left: auto; font-size: 10px; background: #f38ba8; color: #1e1e2e;
                padding: 1px 6px; border-radius: 3px; font-weight: 700;
            }}
            .card-desc {{ font-size: 12px; color: #8b949e; line-height: 1.4; }}
            .card-long {{ font-size: 12px; color: #6e7681; line-height: 1.45; margin-top: 2px; }}
            .card-error {{
                font-size: 11px; color: #f38ba8; font-family: monospace;
                white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
            }}
            .card-dims {{ margin-top: 4px; line-height: 1.6; }}
            .card-links {{
                display: flex; gap: 12px; margin-top: 6px; padding-top: 6px;
                border-top: 1px solid #21262d; align-items: center;
            }}
            .card-link {{ font-size: 11px; color: #58a6ff !important; text-decoration: none !important; }}
            .card-link:hover {{ text-decoration: underline !important; }}
            .cov-chip {{
                display: inline-flex; align-items: center; position: relative;
                padding: 1px 7px; margin-left: auto; border-radius: 3px;
                font-size: 10px; font-weight: 700; font-family: "SF Mono", monospace;
                overflow: hidden; white-space: nowrap;
            }}
            .cov-chip-bar {{
                position: absolute; left: 0; top: 0; bottom: 0;
                opacity: 0.15; border-radius: 3px;
            }}
            .hub-footer {{
                margin-top: 64px;
                border-top: 1px solid #30363d;
                padding: 32px 24px;
                text-align: center;
                color: #8b949e;
                font-size: 13px;
            }}
            .hub-footer-inner {{
                max-width: 600px;
                margin: 0 auto;
            }}
            .hub-footer-links {{
                margin-bottom: 12px;
            }}
            .hub-footer-links a {{
                color: #58a6ff;
                text-decoration: none;
            }}
            .hub-footer-links a:hover {{
                text-decoration: underline;
            }}
            .hub-footer-sep {{
                margin: 0 8px;
                color: #484f58;
            }}
            .hub-footer-copy {{
                color: #6e7681;
            }}
        </style>
    </head>
    <body>
        <div class="header">
            <h1>Great Docs Gauntlet</h1>
            <p>
                Rendered documentation sites for {len(results)} synthetic test packages,
                covering layouts, config options, docstring patterns, user guides, and so much more.
            </p>
            <div class="stats">
                <div class="stat stat-ok"><span class="stat-num">{len(ok_results)}</span> built</div>
                <div class="stat stat-fail"><span class="stat-num">{len(fail_results)}</span> failed</div>
                <div class="stat stat-total"><span class="stat-num">{len(results)}</span> total</div>
                <div class="stat stat-init"><span class="stat-num">{init_count}</span> init</div>
                <div class="stat stat-config"><span class="stat-num">{config_count}</span> config</div>
                <a href="_tests_summary.html" class="stat stat-cov" style="text-decoration:none;cursor:pointer"><span class="stat-num">{sum(coverage_score(r["name"]) / max(coverage_max(r["name"]), 1) for r in results) / max(len(results), 1) * 100:.0f}%</span> avg cov<span class="chevron">&#8250;</span></a>
                <a href="_dimensions.html" class="stat stat-dim" style="text-decoration:none;cursor:pointer"><span class="stat-num">{len(DIMENSIONS)}</span> dims<span class="chevron">&#8250;</span></a>
            </div>
        </div>

        <div class="filter-bar">
            {filter_html}
        </div>

        <div class="grid" id="card-grid">
            {"".join(cards_html)}
        </div>

        <script>
        (function() {{
            const btns = document.querySelectorAll('.filter-btn');
            const cards = document.querySelectorAll('.card');
            const dimData = {json.dumps({r["name"]: r.get("dimensions", []) for r in results})};
            const dimMeta = {json.dumps(DIMENSIONS)};
            const statuses = {json.dumps({r["name"]: r["status"] for r in results})};

            btns.forEach(btn => {{
                btn.addEventListener('click', () => {{
                    btns.forEach(b => b.classList.remove('active'));
                    btn.classList.add('active');
                    const filter = btn.dataset.filter;

                    cards.forEach(card => {{
                        const name = card.querySelector('.card-name')?.textContent || '';
                        if (filter === 'all') {{
                            card.style.display = '';
                        }} else if (filter === 'failed') {{
                            card.style.display = (statuses[name] !== 'ok') ? '' : 'none';
                        }} else if (filter === 'init') {{
                            card.style.display = (card.dataset.init === 'true') ? '' : 'none';
                        }} else if (filter === 'config') {{
                            card.style.display = (card.dataset.init === 'false') ? '' : 'none';
                        }} else {{
                            const dims = dimData[name] || [];
                            const match = dims.some(d => {{
                                const meta = dimMeta[d];
                                return meta && meta.axis === filter;
                            }});
                            card.style.display = match ? '' : 'none';
                        }}
                    }});
                }});
            }});

            document.querySelectorAll('.card[data-href]').forEach(card => {{
                card.addEventListener('click', (e) => {{
                    if (e.target.closest('.card-link')) return;
                    window.location.href = card.dataset.href;
                }});
            }});
        }})();
        </script>
    <footer class="hub-footer">
        <div class="hub-footer-inner">
            <div class="hub-footer-links">
                <a href="https://github.com/posit-dev/gdg" target="_blank" rel="noopener">GDG on GitHub</a>
                <span class="hub-footer-sep">&middot;</span>
                <a href="https://github.com/posit-dev/great-docs" target="_blank" rel="noopener">Great Docs on GitHub</a>
            </div>
            <div class="hub-footer-copy">
                &copy; {datetime.now().year} Posit Software, PBC. All rights reserved.
            </div>
        </div>
    </footer>
    </body>
    </html>
    """)

    (HUB_DIR / "index.html").write_text(hub_html, encoding="utf-8")
    print(f"\n  Hub page: {HUB_DIR / 'index.html'}")


# ── Assemble hub ─────────────────────────────────────────────────────────────


def assemble_hub(
    results: list[dict],
    *,
    state: dict | None = None,
    rebuilt_names: set[str] | None = None,
) -> None:
    """Copy built _site/ dirs into _hub/<name>/ and inject nav bars.

    Parameters
    ----------
    results
        Result dicts for **all** packages (freshly-built + historical).
    state
        Build-state dict, used to mark stale packages in the nav bar.
    rebuilt_names
        When given (selective rebuild), only these packages' `_site/` dirs
        are re-copied. `None` means copy everything (full rebuild).
    """
    print(f"\n{'=' * 70}")
    print("  Assembling hub")
    print(f"{'=' * 70}\n")

    HUB_DIR.mkdir(parents=True, exist_ok=True)

    ok_results = [r for r in results if r["status"] == "ok"]

    # Copy _site/ dirs → GDG/<name>/
    for r in ok_results:
        name = r["name"]
        site_dir = Path(r["site_dir"]) if r.get("site_dir") else None

        # For selective rebuilds, only copy freshly-built packages
        if rebuilt_names is not None and name not in rebuilt_names:
            if (HUB_DIR / name).exists():
                continue  # already present from a previous build
            if site_dir and site_dir.exists():
                pass  # fall through to copy
            else:
                print(f"  SKIP {name} (no _site/ dir)")
                continue

        target = HUB_DIR / name
        if target.exists():
            shutil.rmtree(target)

        if site_dir and site_dir.exists():
            shutil.copytree(site_dir, target)
            print(f"  Copied {name}")

    # Inject nav bar into ALL packages in GDG
    print("\n  Injecting navigation bar ...")
    nav_count = 0
    for r in ok_results:
        name = r["name"]
        target = HUB_DIR / name
        if not target.exists():
            continue

        for html_file in target.rglob("*.html"):
            depth = len(html_file.relative_to(target).parts)
            hub_prefix = "/".join([".."] * depth)
            nav_html = _build_nav_html(results, name, hub_prefix=hub_prefix, state=state)
            inject_nav_into_html(html_file, nav_html)
            nav_count += 1

    print(f"  Injected nav into {nav_count} pages")

    create_hub_page(results)

    # Per-package detail & log pages
    print("\n  Creating detail and log pages ...")
    for r in results:
        name = r["name"]
        log_page = _create_log_page(name, r.get("log"))
        (HUB_DIR / f"_log_{name}.html").write_text(log_page, encoding="utf-8")
        detail_page = _create_detail_page(r, results)
        (HUB_DIR / f"_detail_{name}.html").write_text(detail_page, encoding="utf-8")
        tests_page = _create_test_coverage_page(name)
        (HUB_DIR / f"_tests_{name}.html").write_text(tests_page, encoding="utf-8")
    print(f"  Created {len(results)} detail + log + test coverage pages")

    # Test coverage summary page
    summary_page = _create_test_coverage_summary_page(results)
    (HUB_DIR / "_tests_summary.html").write_text(summary_page, encoding="utf-8")
    print("  Created test coverage summary page")

    # Dimensions index page
    dims_page = _create_dimensions_page(results)
    (HUB_DIR / "_dimensions.html").write_text(dims_page, encoding="utf-8")
    print("  Created dimensions index page")

    # Save results manifest
    manifest = {
        r["name"]: {
            "status": r["status"],
            "description": r.get("description", ""),
            "long_description": r.get("long_description", ""),
            "dimensions": r.get("dimensions", []),
        }
        for r in results
    }
    (HUB_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")


# ── Server ───────────────────────────────────────────────────────────────────


def serve(port: int = PORT) -> None:
    """Serve the hub directory with a simple HTTP server."""
    if not HUB_DIR.exists():
        print(f"ERROR: Hub directory not found at {HUB_DIR}")
        print("  Run with --build first.")
        sys.exit(1)

    os.chdir(HUB_DIR)

    handler = http.server.SimpleHTTPRequestHandler
    handler.extensions_map.update(
        {
            ".js": "application/javascript",
            ".css": "text/css",
            ".json": "application/json",
        }
    )

    with socketserver.TCPServer(("", port), handler) as httpd:
        httpd.allow_reuse_address = True
        url = f"http://localhost:{port}"
        print(f"\n{'=' * 70}")
        print(f"  Serving synthetic package hub at {url}")
        print("  Press Ctrl+C to stop")
        print(f"{'=' * 70}\n")

        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            print("\n  Server stopped.")


# ── CLI ──────────────────────────────────────────────────────────────────────


def _result_from_state(name: str, pkg_state: dict) -> dict:
    """Reconstruct a result dict from persisted state + catalog data."""
    spec = get_spec(name)
    site_dir = RENDERED_DIR / name / "great-docs" / "_site"
    status = pkg_state.get("status", "unknown")
    result: dict = {
        "name": name,
        "description": spec.get("description", ""),
        "long_description": PACKAGE_DESCRIPTIONS.get(name, ""),
        "dimensions": spec.get("dimensions", []),
        "status": status,
    }
    if status == "ok" and site_dir.exists():
        result["site_dir"] = str(site_dir)
    err = pkg_state.get("error")
    if err:
        result["error"] = err
    log_path = LOGS_DIR / f"{name}.log"
    if log_path.exists():
        result["log"] = str(log_path)
    elapsed = pkg_state.get("elapsed_s")
    if elapsed is not None:
        result["elapsed_s"] = elapsed
    return result


def main():
    parser = argparse.ArgumentParser(
        description="Render all Great Docs Gauntlet (GDG) package sites and serve via the GDG.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=textwrap.dedent("""\
            examples:
              python render_all.py                     # full build + serve
              python render_all.py --build             # full build only
              python render_all.py --build --skip-ok   # resume interrupted build
              python render_all.py --build --workers 8 # parallel build (8 workers)
              python render_all.py --serve             # serve previously built hub
              python render_all.py --only gdtest_minimal gdtest_github_icon
        """),
    )
    parser.add_argument("--build", action="store_true", help="Build only (don't start server)")
    parser.add_argument("--serve", action="store_true", help="Serve previously built hub only")
    parser.add_argument(
        "--only",
        nargs="+",
        metavar="NAME",
        help="Selective rebuild of these packages",
    )
    parser.add_argument("--port", type=int, default=PORT, help=f"Server port (default: {PORT})")
    parser.add_argument("--no-serve", action="store_true", help="Alias for --build")
    parser.add_argument(
        "--skip-ok",
        action="store_true",
        help="Skip packages already built successfully (resume interrupted build)",
    )
    parser.add_argument(
        "--workers",
        type=int,
        default=1,
        metavar="N",
        help="Number of parallel build workers (default: 1 = sequential)",
    )
    args = parser.parse_args()

    if args.serve:
        serve(args.port)
        return

    # ── Load / initialise build state ────────────────────────────────────
    state = load_state(STATE_FILE)
    rid = new_run_id()

    if args.only:
        # ── Selective rebuild ────────────────────────────────────────────
        start_selective_run(state, rid)
        fresh_results = build_all(packages=args.only, run_id=rid, state=state, workers=args.workers)
        save_state(STATE_FILE, state)

        # Merge: fresh results + historical results from state
        rebuilt_set = {r["name"] for r in fresh_results}
        fresh_by_name = {r["name"]: r for r in fresh_results}

        combined: list[dict] = []
        for name in ALL_PACKAGES:
            if name in fresh_by_name:
                combined.append(fresh_by_name[name])
            elif name in state.get("packages", {}):
                combined.append(_result_from_state(name, state["packages"][name]))
            # else: package never built, skip

        assemble_hub(combined, state=state, rebuilt_names=rebuilt_set)

        ok = sum(1 for r in fresh_results if r["status"] == "ok")
        fail = sum(1 for r in fresh_results if r["status"] != "ok")
        stale_n = sum(1 for n in state.get("packages", {}) if is_stale(state, n))
        print(f"\n  Selective rebuild: {ok} OK, {fail} failed")
        print(f"  State: {len(state.get('packages', {}))} tracked, {stale_n} stale")

    else:
        # ── Full rebuild ─────────────────────────────────────────────────
        if args.skip_ok and state.get("packages"):
            # Resume mode: keep existing state, only rebuild non-ok packages
            start_selective_run(state, rid)
            results = build_all(run_id=rid, state=state, skip_ok=True, workers=args.workers)
            save_state(STATE_FILE, state)

            # Combine fresh results with historical ok results
            built_set = {r["name"] for r in results}
            combined: list[dict] = []
            for name in ALL_PACKAGES:
                if name in built_set:
                    combined.append(next(r for r in results if r["name"] == name))
                elif name in state.get("packages", {}):
                    combined.append(_result_from_state(name, state["packages"][name]))

            assemble_hub(combined, state=state)

            total_ok = sum(1 for r in combined if r["status"] == "ok")
            total_fail = sum(1 for r in combined if r["status"] != "ok")
            freshly_built = len(results)
            skipped_n = len(combined) - freshly_built
            print(f"\n  Summary: {total_ok} OK, {total_fail} failed out of {len(combined)}")
            print(f"  ({skipped_n} skipped, {freshly_built} built)")
        else:
            reset_for_full_rebuild(state, rid)
            results = build_all(run_id=rid, state=state, workers=args.workers)
            save_state(STATE_FILE, state)

            assemble_hub(results, state=state)

            ok = sum(1 for r in results if r["status"] == "ok")
            fail = sum(1 for r in results if r["status"] != "ok")
            print(f"\n  Summary: {ok} OK, {fail} failed out of {len(results)}")

    print(f"  State saved: {STATE_FILE}")

    if not args.build and not args.no_serve:
        serve(args.port)


if __name__ == "__main__":
    main()
