CtrlK
BlogDocsLog inGet started
Tessl Logo

tessleng/skill-insights

Scan a directory or workspace for SKILL.md files across all agents and repos, capture supporting files (references, scripts, linked docs), dedupe vendored copies, enrich each Tessl tile with registry signals, and emit a canonical JSON inventory validated by JSON Schema. Then run four analytical phases in parallel against the inventory — staleness + git provenance (history, broken refs, contributors), quality (Tessl `skill review`), duplicates (similarity + LLM judgement), registry-search (per-standalone-skill registry suggestions, HTTP only) — and render a self-contained interactive HTML report with a top-of-report health overview, top-issues panel, recently-changed list, and per-tessl.json manifests view.

84

1.44x
Quality

90%

Does it follow best practices?

Impact

97%

1.44x

Average score across 2 eval scenarios

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

report-template.htmlreferences/

<!doctype html>
<html lang="en" class="dark">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Tessl | Skill Insights</title>
        <link
            rel="icon"
            type="image/svg+xml"
            href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOTIiIGhlaWdodD0iMTkyIiBmaWxsPSJub25lIiB2aWV3Qm94PSIwIDAgMTkyIDE5MiI+PHBhdGggZmlsbD0iI2ZhZmFmYSIgZD0iTTU3LjQ2IDgzLjc4MWMuMDAyLTEuNzI0IDEuODc0LTIuNzg2IDMuMzY4LTEuOTM3bDMwLjY0NCAxNy42MXYzOS42MTdjMCA2LjkzMSA3LjU4NyAxMS4yMTIgMTMuNTY2IDcuNjhsMjkuNDQtMTcuNDIydjQzLjMxM2wtMjkuNTI3IDE2Ljk2OWExOC4wMiAxOC4wMiAwIDAgMS0xNy45MzQgMEw1Ny40NiAxNzIuNjQyek0xODIgMTM1LjA5NWExNy44MyAxNy44MyAwIDAgMS04Ljk3MSAxNS40NTNsLTI5LjU0OSAxNi45Njl2LTQzLjUwMUwxODIgMTAxLjIwM3pNNDguNDk3IDEyMy4yNzRoLS4wMjR2NDQuMjJsLTI5LjUwMi0xNi45NDZBMTcuODMgMTcuODMgMCAwIDEgMTAgMTM1LjA5NVYxMDEuMTh6bTEyNC41MDgtODEuNzlhMTcuODMgMTcuODMgMCAwIDEgOC45NzIgMTUuNDUzVjkwLjgybC03OC4xNDMgNDYuMjY3Yy0xLjQ5My44OTctMy4zODItLjE5MS0zLjM4NC0xLjkxNVY5OS41MDhsMzcuNDI2LTIxLjUxNmM0LjQ4Mi0yLjU3NyA0LjUwNS04Ljk3Ny4wNDgtMTEuNTc4bC0zMy40MDUtMTkuNTMyIDM4LjkzNy0yMi4zNzV6bS00Ni4yNjMgMjguNzU4YzEuNDkzLjg1IDEuNDk0IDMuMDAyIDAgMy44NTFMOTYuMDEyIDkxLjc1IDU4LjYxNyA3MC4yNjVjLTQuNDg0LTIuNTc1LTEwLjA5Mi42MzgtMTAuMTIgNS43OXYzNi45MTRMMTAgOTAuODc1VjU2LjkzN2MwLTYuMzcyIDMuNDItMTIuMjY2IDguOTcxLTE1LjQ1M2wyOS41NS0xN3ptLTIyLjIyMy0yMy4zNi0uMDE2LjAxNnYtLjAyNHpNODcuMDE3IDIuMzg5YTE4LjAyIDE4LjAyIDAgMCAxIDE3LjkzNCAwbDI5LjUyNyAxNi45NDZMOTUuNjEgNDEuNjYzIDU3LjQ2IDE5LjM1OHoiLz48L3N2Zz4=" />
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
        <link
            rel="stylesheet"
            href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:wght@400..700&family=Atkinson+Hyperlegible+Next:wght@400..700&display=swap" />
        <style>
            *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
            html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
            body {
                font-family: "Atkinson Hyperlegible Next", system-ui, sans-serif;
                background-color: #0a0a0a;
                color: var(--fg);
                font-size: 14px;
                line-height: 1.625;
            }
            a { color: inherit; text-decoration: none; }
            button { font: inherit; cursor: pointer; border: none; background: none; color: inherit; }
            ul, ol { list-style: none; }
            code, .mono { font-family: "Atkinson Hyperlegible Mono", ui-monospace, monospace; font-size: 12px; }

            :root {
                --red: #f87171;
                --orange: #fb923c;
                --yellow: #fbbf24;
                --green: #4ade80;
                --blue: #60a5fa;
                --purple: #a78bfa;
                --fg: #f9fafb;
                --fg-muted: #9ca3af;
                --fg-subtle: #6b7280;
                --border: rgba(255, 255, 255, 0.04);
                --border-strong: rgba(255, 255, 255, 0.08);
                --surface: #111;
                --surface-raised: #161616;
                --bar-label-width: 140px;
            }

            /* Page header bar */
            .page-header {
                max-width: 1200px;
                margin: 0 auto;
                padding: 32px 32px 0;
                display: flex;
                justify-content: space-between;
                align-items: center;
            }
            .page-header-logo { display: flex; align-items: center; gap: 10px; }
            .page-header-link {
                font-size: 14px;
                color: var(--fg-subtle);
                transition: color 0.15s;
            }
            .page-header-link:hover { color: var(--fg-muted); }
            .alpha-tag {
                font-size: 12px;
                font-weight: 600;
                letter-spacing: 0.1em;
                padding: 2px 7px;
                border-radius: 4px;
                background: rgba(251, 191, 36, 0.14);
                color: var(--yellow);
                text-transform: uppercase;
                display: inline-block;
                line-height: 1.375;
            }

            /* Layout shell */
            .shell {
                min-height: 100vh;
                max-width: 1200px;
                margin: 0 auto;
                padding: 0 32px;
                display: grid;
                grid-template-columns: repeat(12, 1fr);
                column-gap: 32px;
            }
            .sidebar {
                grid-column: 1 / 4;
                position: sticky;
                top: 0;
                height: 100vh;
                background: transparent;
                overflow-y: auto;
                padding: 28px 0;
                display: flex;
                flex-direction: column;
            }
            .sidebar-meta { margin-bottom: 24px; }
            .sidebar-meta-item {
                font-size: 12px;
                color: var(--fg-subtle);
                padding: 3px 0;
            }
            .sidebar-meta-item strong {
                color: var(--fg-muted);
                font-weight: 500;
                margin-right: 6px;
            }
            .sidebar-divider {
                height: 1px;
                background: var(--border);
                margin: 4px 0 16px;
            }
            .sidebar-nav-label {
                font-size: 12px;
                font-weight: 600;
                text-transform: uppercase;
                letter-spacing: 0.1em;
                color: var(--fg-subtle);
                margin-bottom: 12px;
            }
            .sidebar-nav { flex: 1; }
            .sidebar-nav-item {
                display: block;
                padding: 5px 0;
                font-size: 14px;
                color: var(--fg-muted);
                cursor: pointer;
                transition: color 0.12s;
            }
            .sidebar-nav-item:hover { color: var(--fg); }
            .sidebar-nav-item.active { color: var(--fg); }
            .sidebar-nav-item .nav-count {
                font-size: 12px;
                color: var(--fg-subtle);
                background: rgba(255, 255, 255, 0.04);
                padding: 1px 6px;
                border-radius: 8px;
                margin-left: 6px;
            }
            .main { grid-column: 4 / 13; padding: 28px 0 80px; }

            /* Hero */
            .hero {
                background: rgba(255, 255, 255, 0.02);
                border: 1px solid var(--border);
                border-radius: 8px;
                padding: 24px;
                margin-bottom: 32px;
            }
            .hero h1 {
                font-size: 20px;
                font-weight: 600;
                color: var(--fg);
                margin-bottom: 6px;
                letter-spacing: -0.01em;
            }
            .hero-subtitle {
                font-size: 13px;
                color: var(--fg-muted);
                line-height: 1.625;
            }

            /* Stats row */
            .stats-row {
                display: grid;
                grid-auto-flow: column;
                grid-auto-columns: 1fr;
                gap: 16px;
                margin-bottom: 40px;
                padding: 20px 0;
                border-top: 1px solid var(--border);
                border-bottom: 1px solid var(--border);
            }
            .stat-item { text-align: center; display: flex; flex-direction: column; gap: 4px; align-items: center; }
            .stat-value { font-size: 24px; font-weight: 600; color: var(--fg); }
            .stat-label { font-size: 12px; color: var(--fg-subtle); text-transform: uppercase; letter-spacing: 0.05em; }

            /* Section dividers */
            .section-divider { margin: 48px 0 24px; scroll-margin-top: 20px; }
            .section-title {
                font-size: 14px;
                font-weight: 600;
                text-transform: uppercase;
                letter-spacing: 0.1em;
                color: var(--fg-subtle);
                margin-bottom: 8px;
            }
            .section-description { font-size: 13px; color: var(--fg-muted); margin-bottom: 16px; }

            /* Bar chart rows */
            .bar-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
            .bar-label {
                font-size: 12px;
                color: var(--fg-muted);
                white-space: nowrap;
                flex-shrink: 0;
                min-width: var(--bar-label-width);
            }
            .bar-container {
                flex: 1;
                height: 6px;
                background: rgba(255, 255, 255, 0.04);
                border-radius: 3px;
                overflow: hidden;
            }
            .bar-fill { height: 100%; background: var(--blue); }
            .bar-fill.green { background: var(--green); }
            .bar-fill.yellow { background: var(--yellow); }
            .bar-fill.purple { background: var(--purple); }
            .bar-fill.red { background: var(--red); }
            .bar-fill.orange { background: var(--orange); }
            .bar-count { font-size: 12px; color: var(--fg-subtle); width: 40px; text-align: right; flex-shrink: 0; }

            /* Top-of-report: Health overview, Top issues, Recently changed */
            .health-grid {
                display: grid;
                grid-template-columns: repeat(3, 1fr);
                gap: 16px;
                margin-bottom: 24px;
            }
            .health-card {
                background: rgba(255, 255, 255, 0.02);
                border: 1px solid var(--border);
                border-radius: 8px;
                padding: 18px 20px;
                display: flex;
                flex-direction: column;
                gap: 12px;
            }
            .health-card-header {
                display: flex;
                justify-content: space-between;
                align-items: baseline;
                gap: 8px;
            }
            .health-card-title {
                font-size: 11px;
                font-weight: 500;
                text-transform: uppercase;
                letter-spacing: 0.08em;
                color: var(--fg-subtle);
            }
            .health-card-headline { font-size: 22px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
            .health-card-headline-sub { font-size: 12px; color: var(--fg-subtle); margin-left: 4px; font-weight: 400; }
            .stacked-bar {
                display: flex;
                width: 100%;
                height: 8px;
                background: rgba(255, 255, 255, 0.04);
                border-radius: 4px;
                overflow: hidden;
            }
            .stacked-bar-segment { height: 100%; transition: opacity 0.1s; }
            .stacked-bar-segment.green { background: var(--green); }
            .stacked-bar-segment.blue { background: var(--blue); }
            .stacked-bar-segment.yellow { background: var(--yellow); }
            .stacked-bar-segment.orange { background: var(--orange); }
            .stacked-bar-segment.red { background: var(--red); }
            .stacked-bar-segment.muted { background: rgba(156, 163, 175, 0.25); }
            .stacked-bar-legend {
                display: flex;
                flex-wrap: wrap;
                gap: 12px;
                font-size: 11px;
                color: var(--fg-muted);
            }
            .stacked-bar-legend-item { display: inline-flex; align-items: center; gap: 6px; }
            .stacked-bar-swatch { width: 8px; height: 8px; border-radius: 2px; }
            .stacked-bar-swatch.green { background: var(--green); }
            .stacked-bar-swatch.blue { background: var(--blue); }
            .stacked-bar-swatch.yellow { background: var(--yellow); }
            .stacked-bar-swatch.orange { background: var(--orange); }
            .stacked-bar-swatch.red { background: var(--red); }
            .stacked-bar-swatch.muted { background: rgba(156, 163, 175, 0.5); }
            .health-mini-grid {
                display: grid;
                grid-template-columns: repeat(2, 1fr);
                gap: 8px 18px;
                font-size: 12px;
            }
            .health-mini-grid dt { color: var(--fg-subtle); }
            .health-mini-grid dd { color: var(--fg); font-variant-numeric: tabular-nums; text-align: right; font-weight: 500; }

            .top-issues-grid {
                display: grid;
                grid-template-columns: repeat(3, 1fr);
                gap: 16px;
                margin-bottom: 32px;
            }
            .issue-card {
                background: rgba(255, 255, 255, 0.02);
                border: 1px solid var(--border);
                border-radius: 8px;
                padding: 14px 16px;
                display: flex;
                flex-direction: column;
                gap: 10px;
            }
            .issue-card-header {
                display: flex;
                justify-content: space-between;
                align-items: baseline;
                gap: 8px;
                font-size: 11px;
                font-weight: 500;
                text-transform: uppercase;
                letter-spacing: 0.08em;
                color: var(--fg-subtle);
            }
            .issue-card-link {
                font-size: 11px;
                color: var(--fg-subtle);
                text-transform: none;
                letter-spacing: 0;
                cursor: pointer;
                text-decoration: none;
            }
            .issue-card-link:hover { color: var(--fg); }
            .issue-list { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; }
            .issue-list-item {
                display: flex;
                justify-content: space-between;
                align-items: baseline;
                gap: 8px;
                padding: 6px 0;
                font-size: 13px;
                cursor: pointer;
                border-bottom: 1px solid var(--border);
            }
            .issue-list-item:last-child { border-bottom: none; }
            .issue-list-item:hover .issue-name { color: var(--fg); }
            .issue-name { color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; }
            .issue-meta { font-size: 11px; color: var(--fg-subtle); font-variant-numeric: tabular-nums; flex-shrink: 0; }
            .issue-empty { color: var(--fg-subtle); font-size: 12px; padding: 6px 0; }

            .recent-changes {
                background: rgba(255, 255, 255, 0.02);
                border: 1px solid var(--border);
                border-radius: 8px;
                padding: 14px 18px;
                margin-bottom: 32px;
            }
            .recent-changes-header {
                display: flex;
                justify-content: space-between;
                align-items: baseline;
                font-size: 11px;
                font-weight: 500;
                text-transform: uppercase;
                letter-spacing: 0.08em;
                color: var(--fg-subtle);
                margin-bottom: 10px;
            }
            .recent-list { display: flex; flex-direction: column; }
            .recent-item {
                display: grid;
                grid-template-columns: 1fr auto auto;
                gap: 12px;
                align-items: baseline;
                padding: 8px 0;
                border-bottom: 1px solid var(--border);
                font-size: 13px;
                cursor: pointer;
            }
            .recent-item:last-child { border-bottom: none; }
            .recent-item:hover .recent-name { color: var(--fg); }
            .recent-name { color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
            .recent-author { color: var(--fg-subtle); font-size: 12px; }
            .recent-date { color: var(--fg-subtle); font-size: 12px; font-variant-numeric: tabular-nums; }

            /* Tile location grouping */
            .tile-location-group { margin-bottom: 20px; }
            .tile-location-group-header {
                font-size: 11px;
                text-transform: uppercase;
                letter-spacing: 0.08em;
                color: var(--fg-subtle);
                padding: 8px 0;
                border-bottom: 1px solid var(--border);
                margin-bottom: 0;
                display: flex;
                justify-content: space-between;
                align-items: baseline;
            }
            .tile-location-group-header-prefix { font-family: "Atkinson Hyperlegible Mono", monospace; font-size: 12px; color: var(--fg-muted); text-transform: none; letter-spacing: 0; }
            .tile-location-group-count { font-size: 11px; color: var(--fg-subtle); font-variant-numeric: tabular-nums; }

            /* Manifests section */
            .manifests-table { width: 100%; border-collapse: collapse; }
            .manifests-table th, .manifests-table td {
                padding: 8px 12px;
                font-size: 13px;
                border-bottom: 1px solid var(--border);
                text-align: left;
                vertical-align: top;
            }
            .manifests-table th {
                font-size: 11px;
                text-transform: uppercase;
                letter-spacing: 0.05em;
                color: var(--fg-subtle);
                font-weight: 500;
                white-space: nowrap;
            }
            .manifests-table td.mono { font-family: "Atkinson Hyperlegible Mono", monospace; font-size: 12px; color: var(--fg-muted); }
            .manifests-table .num-cell { text-align: right; font-variant-numeric: tabular-nums; }
            .manifests-table .dep-list { display: flex; flex-direction: column; gap: 4px; }
            .manifests-table .dep-row { font-size: 12px; color: var(--fg-muted); }
            .manifests-table .dep-row .dep-name { color: var(--fg); }
            .manifests-table .dep-row .dep-version { color: var(--fg-subtle); margin-left: 6px; }
            .manifests-table .dep-row.unresolved .dep-name { color: var(--orange); }

            /* Provenance block in skill drawer */
            .provenance-section { margin-top: 8px; }
            .provenance-author { display: inline-flex; align-items: center; gap: 6px; }
            .commit-list { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
            .commit-row {
                display: grid;
                grid-template-columns: 70px 70px 1fr;
                gap: 10px;
                font-size: 12px;
                color: var(--fg-muted);
                align-items: baseline;
            }
            .commit-sha { font-family: "Atkinson Hyperlegible Mono", monospace; font-size: 11px; color: var(--fg-subtle); }
            .commit-date { color: var(--fg-subtle); font-variant-numeric: tabular-nums; }
            .commit-subject { color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
            .contributors-list { display: flex; flex-wrap: wrap; gap: 6px; }

            /* Filter row */
            .filter-row {
                display: flex;
                gap: 12px;
                margin-bottom: 16px;
                flex-wrap: wrap;
            }
            .search-box { flex: 1; min-width: 220px; position: relative; }
            .search-box input {
                width: 100%;
                padding: 8px 12px;
                background: var(--surface);
                border: 1px solid var(--border);
                border-radius: 6px;
                color: var(--fg);
                font: inherit;
                font-size: 13px;
            }
            .search-box input:focus { outline: none; border-color: var(--border-strong); }
            .filter-chip {
                padding: 7px 12px;
                background: var(--surface);
                border: 1px solid var(--border);
                border-radius: 6px;
                font-size: 12px;
                color: var(--fg-muted);
                cursor: pointer;
                transition: all 0.1s;
                white-space: nowrap;
            }
            .filter-chip.active { background: var(--surface-raised); color: var(--fg); border-color: var(--border-strong); }
            .filter-chip:hover { color: var(--fg); }

            /* Skills table */
            .table-wrapper { overflow-x: auto; }
            table.data-table { width: 100%; border-collapse: collapse; }
            table.data-table th {
                text-align: left;
                font-size: 11px;
                text-transform: uppercase;
                letter-spacing: 0.05em;
                color: var(--fg-subtle);
                padding: 10px 12px;
                border-bottom: 1px solid var(--border);
                font-weight: 500;
                cursor: pointer;
                user-select: none;
                white-space: nowrap;
            }
            table.data-table th:hover { color: var(--fg); }
            table.data-table th.sorted { color: var(--fg); }
            table.data-table th.sorted::after { content: " ↓"; color: var(--blue); }
            table.data-table th.sorted.asc::after { content: " ↑"; }
            table.data-table td {
                padding: 10px 12px;
                border-bottom: 1px solid var(--border);
                font-size: 13px;
                color: var(--fg);
            }
            table.data-table tr { cursor: pointer; transition: background 0.1s; }
            table.data-table tr:hover td { background: rgba(255, 255, 255, 0.02); }
            .tile-cell { color: var(--fg-muted); font-family: "Atkinson Hyperlegible Mono", monospace; font-size: 12px; }
            .mono-cell { font-family: "Atkinson Hyperlegible Mono", monospace; font-size: 12px; color: var(--fg-muted); }
            .num-cell { font-variant-numeric: tabular-nums; text-align: right; width: 60px; }

            /* Badges */
            .badge {
                font-size: 11px;
                font-weight: 500;
                padding: 2px 8px;
                border-radius: 3px;
                letter-spacing: 0.025em;
                display: inline-block;
                white-space: nowrap;
                background: rgba(255, 255, 255, 0.05);
                color: var(--fg-subtle);
            }
            .badge.source-claude_skill { background: rgba(96, 165, 250, 0.12); color: var(--blue); }
            .badge.source-cursor_skill { background: rgba(167, 139, 250, 0.12); color: var(--purple); }
            .badge.source-agents_skill { background: rgba(74, 222, 128, 0.12); color: var(--green); }
            .badge.source-tessl_tile_skill { background: rgba(251, 191, 36, 0.10); color: var(--yellow); }
            .badge.source-standalone { background: rgba(156, 163, 175, 0.10); color: var(--fg-muted); }
            .badge-count { font-variant-numeric: tabular-nums; }

            /* Repo cards */
            .repo-card {
                background: rgba(255, 255, 255, 0.02);
                border: 1px solid var(--border);
                border-radius: 8px;
                padding: 16px 20px;
                margin-bottom: 12px;
            }
            .repo-card-header {
                display: flex;
                justify-content: space-between;
                align-items: baseline;
                margin-bottom: 8px;
                gap: 12px;
                flex-wrap: wrap;
            }
            .repo-card-name { font-size: 14px; font-weight: 600; color: var(--fg); }
            .repo-card-remote { font-size: 12px; color: var(--fg-subtle); font-family: "Atkinson Hyperlegible Mono", monospace; }
            .repo-card-meta { font-size: 12px; color: var(--fg-muted); display: flex; gap: 16px; flex-wrap: wrap; }
            .repo-card-meta strong { color: var(--fg-subtle); font-weight: 500; margin-right: 4px; }

            /* Warnings list */
            .warnings-list { margin-top: 8px; }
            .warning-item {
                padding: 10px 14px;
                border-bottom: 1px solid var(--border);
                font-size: 13px;
                color: var(--fg-muted);
                font-family: "Atkinson Hyperlegible Mono", monospace;
                font-size: 12px;
                line-height: 1.5;
            }
            .warning-item:last-child { border-bottom: none; }

            /* Side drill-down panel */
            .panel {
                position: fixed;
                top: 0;
                right: 0;
                width: 520px;
                max-width: 90vw;
                height: 100vh;
                background: var(--surface);
                border-left: 1px solid var(--border);
                overflow-y: auto;
                z-index: 50;
                transform: translateX(100%);
                transition: transform 0.25s ease;
            }
            .shell.panel-open .panel { transform: translateX(0); }
            .panel-inner { padding: 32px 28px; }
            .panel-close {
                position: absolute;
                top: 14px;
                right: 18px;
                color: var(--fg-subtle);
                font-size: 22px;
                cursor: pointer;
                background: none;
                border: none;
                line-height: 1;
            }
            .panel-close:hover { color: var(--fg-muted); }
            .panel-title {
                font-size: 18px;
                font-weight: 600;
                color: var(--fg);
                margin-bottom: 4px;
                padding-right: 32px;
                word-break: break-word;
            }
            .panel-subtitle {
                font-size: 12px;
                color: var(--fg-subtle);
                margin-bottom: 24px;
                font-family: "Atkinson Hyperlegible Mono", monospace;
                word-break: break-all;
            }
            .panel-section { margin-bottom: 24px; }
            .panel-section-label {
                font-size: 11px;
                font-weight: 600;
                text-transform: uppercase;
                letter-spacing: 0.06em;
                color: var(--fg-subtle);
                margin-bottom: 10px;
            }
            .panel-section p { font-size: 13px; color: var(--fg-muted); line-height: 1.6; }
            .kv-grid { display: grid; grid-template-columns: 140px 1fr; row-gap: 6px; column-gap: 12px; font-size: 13px; }
            .kv-grid dt { color: var(--fg-subtle); }
            .kv-grid dd { color: var(--fg); word-break: break-word; }
            .kv-grid dd.mono { font-family: "Atkinson Hyperlegible Mono", monospace; font-size: 12px; color: var(--fg-muted); }
            .chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
            .path-list { margin: 0; padding: 0; }
            .path-list li {
                font-family: "Atkinson Hyperlegible Mono", monospace;
                font-size: 12px;
                color: var(--fg-muted);
                padding: 3px 0;
                line-height: 1.5;
                word-break: break-all;
            }
            .body-preview {
                background: var(--surface-raised);
                border: 1px solid var(--border);
                border-radius: 6px;
                padding: 12px 14px;
                font-family: "Atkinson Hyperlegible Mono", monospace;
                font-size: 11.5px;
                color: var(--fg-muted);
                white-space: pre-wrap;
                line-height: 1.55;
                max-height: 240px;
                overflow-y: auto;
            }
            .supporting-files-list li {
                padding: 6px 0;
                border-bottom: 1px solid var(--border);
                font-size: 12px;
                color: var(--fg-muted);
            }
            .supporting-files-list li:last-child { border-bottom: none; }
            .supporting-files-list .sf-kind {
                display: inline-block;
                font-size: 10px;
                text-transform: uppercase;
                padding: 1px 6px;
                border-radius: 3px;
                background: rgba(255, 255, 255, 0.05);
                color: var(--fg-subtle);
                margin-right: 8px;
                letter-spacing: 0.04em;
            }
            .supporting-files-list .sf-path {
                font-family: "Atkinson Hyperlegible Mono", monospace;
                font-size: 11.5px;
                word-break: break-all;
            }

            /* Empty state */
            .empty { text-align: center; padding: 40px 20px; color: var(--fg-subtle); font-size: 13px; }

            /* Footer */
            .footer {
                max-width: 1200px;
                margin: 60px auto 20px;
                padding: 24px 32px;
                border-top: 1px solid var(--border);
                font-size: 12px;
                color: var(--fg-subtle);
                display: flex;
                justify-content: space-between;
            }

            @media (max-width: 900px) {
                .shell { grid-template-columns: 1fr; }
                .sidebar { position: static; height: auto; grid-column: 1; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
                .main { grid-column: 1; }
                .stats-row { grid-auto-flow: row; grid-template-columns: repeat(2, 1fr); grid-auto-columns: auto; }
                .health-grid { grid-template-columns: 1fr; }
                .top-issues-grid { grid-template-columns: 1fr; }
            }
        </style>
    </head>
    <body>
        <!-- Page header -->
        <header class="page-header">
            <div class="page-header-logo">
                <svg width="78" height="28" viewBox="0 0 78 28" fill="none" xmlns="http://www.w3.org/2000/svg">
                    <mask id="mask0_hdr" style="mask-type: luminance" maskUnits="userSpaceOnUse" x="30" y="6" width="48" height="16">
                        <path d="M77.3953 6.70508H30.6836V21.5867H77.3953V6.70508Z" fill="white" />
                    </mask>
                    <g mask="url(#mask0_hdr)">
                        <path d="M35.4451 21.2513V8.63531H30.748V6.7041H42.4032V8.63531H37.7063V21.2416H35.4451V21.2513Z" fill="white" />
                        <path d="M50.7885 16.4942H42.8113C42.8502 18.2216 43.8692 19.8326 45.9265 19.8326C47.7606 19.8326 48.469 18.6486 48.6535 17.9596H50.6915C50.1382 19.949 48.6146 21.4532 45.8876 21.4532C42.5881 21.4532 40.7637 19.1144 40.7637 15.941C40.7637 12.7676 42.7143 10.4482 45.8876 10.4482C49.061 10.4482 50.8079 12.5251 50.8079 15.7372C50.8079 16.0187 50.8079 16.3292 50.7885 16.4942ZM48.7311 14.922C48.7311 13.2917 47.5957 12.0786 45.8197 12.0786C44.1506 12.0786 42.9278 13.214 42.8113 14.922H48.7311Z" fill="white" />
                        <path d="M52.7969 17.688H54.8057C54.8833 18.8913 55.6209 19.823 57.5521 19.823C59.299 19.823 59.7647 19.0466 59.7647 18.2993C59.7647 16.999 58.377 16.8534 57.0378 16.572C55.223 16.1449 53.1559 15.6112 53.1559 13.4568C53.1559 11.6712 54.6019 10.4678 57.1057 10.4678C59.9492 10.4678 61.3174 11.9914 61.463 13.7867H59.4444C59.299 12.9909 58.872 12.0787 57.1446 12.0787C55.8053 12.0787 55.2327 12.6028 55.2327 13.3792C55.2327 14.4564 56.3875 14.5632 57.853 14.8834C59.7647 15.3298 61.8415 15.8829 61.8415 18.2023C61.8415 20.2112 60.2985 21.434 57.5716 21.434C54.4176 21.434 52.8551 19.8036 52.7871 17.688H52.7969Z" fill="white" />
                        <path d="M63.7149 17.688H65.7237C65.8013 18.8913 66.5388 19.823 68.4701 19.823C70.2169 19.823 70.6827 19.0466 70.6827 18.2993C70.6827 16.999 69.295 16.8534 67.9558 16.572C66.141 16.1449 64.0739 15.6112 64.0739 13.4568C64.0739 11.6712 65.5199 10.4678 68.0237 10.4678C70.8671 10.4678 72.2354 11.9914 72.381 13.7867H70.3624C70.2169 12.9909 69.7899 12.0787 68.0625 12.0787C66.7233 12.0787 66.1507 12.6028 66.1507 13.3792C66.1507 14.4564 67.3055 14.5632 68.7709 14.8834C70.6827 15.3298 72.7595 15.8829 72.7595 18.2023C72.7595 20.2112 71.2164 21.434 68.4895 21.434C65.3355 21.434 63.7731 19.8036 63.7051 17.688H63.7149Z" fill="white" />
                        <path d="M75.332 21.2513V6.7041H77.3215V21.2513H75.332Z" fill="white" />
                    </g>
                    <path d="M6.87137 12.2181C6.87168 11.9667 7.1427 11.8117 7.35901 11.9355L11.7957 14.5036V20.2812C11.7957 21.292 12.8941 21.9163 13.7598 21.4012L18.0221 18.8605V25.1769L13.7473 27.6516C12.9434 28.1161 11.9546 28.1161 11.1507 27.6516L6.87137 25.1769V12.2181ZM24.9025 19.7013C24.9024 20.6306 24.4075 21.4902 23.6037 21.9549L19.3256 24.4295V18.0857L24.9025 14.7588V19.7013ZM5.57365 17.9774H5.57023V24.4262L1.29885 21.9549C0.495145 21.4902 0.00016839 20.6305 0 19.7013V14.7554L5.57365 17.9774ZM23.6003 6.04969C24.404 6.51447 24.8992 7.37397 24.8992 8.3033V13.2446L13.5855 19.9918C13.3693 20.1227 13.0958 19.964 13.0956 19.7126V14.5115L18.5142 11.3739C19.1632 10.998 19.1664 10.0648 18.5211 9.68532L13.6846 6.83697L19.3221 3.5739L23.6003 6.04969ZM16.9021 10.2436C17.1183 10.3676 17.1184 10.6814 16.9021 10.8053L12.453 13.3802L7.03884 10.247C6.38961 9.8714 5.5777 10.3401 5.57365 11.0913V16.4746L0 13.2526V8.3033C0 7.37399 0.495181 6.51448 1.29885 6.04969L5.57707 3.57048L16.9021 10.2436ZM13.6846 6.83697L13.6823 6.83925V6.83583L13.6846 6.83697ZM11.1507 0.348424C11.9546 -0.116155 12.9434 -0.116127 13.7473 0.348424L18.0221 2.81966L12.3949 6.07589L6.87137 2.82307L11.1507 0.348424Z" fill="white" />
                </svg>
                <span class="alpha-tag">Alpha</span>
            </div>
            <a class="page-header-link" href="https://tessl.io/registry" target="_blank" rel="noopener">
                https://tessl.io/registry
            </a>
        </header>

        <div class="shell" id="shell">
            <!-- Sidebar -->
            <nav class="sidebar" id="sidebar">
                <div class="sidebar-meta" id="sidebarMeta"></div>
                <div class="sidebar-divider"></div>
                <div class="sidebar-nav-label">Table of Contents</div>
                <div class="sidebar-nav" id="sidebarNav">
                    <a class="sidebar-nav-item active" data-section="section-overview" onclick="scrollToSection('section-overview')">Overview</a>
                    <a class="sidebar-nav-item" data-section="section-top-issues" onclick="scrollToSection('section-top-issues')" id="navTopIssues">
                        Top issues <span class="nav-count" id="navTopIssuesCount">—</span>
                    </a>
                    <a class="sidebar-nav-item" data-section="section-recent" onclick="scrollToSection('section-recent')" id="navRecent">
                        Recently changed <span class="nav-count" id="navRecentCount">—</span>
                    </a>
                    <a class="sidebar-nav-item" data-section="section-tiles" onclick="scrollToSection('section-tiles')">
                        Tessl tiles <span class="nav-count" id="navTilesCount">0</span>
                    </a>
                    <a class="sidebar-nav-item" data-section="section-manifests" onclick="scrollToSection('section-manifests')">
                        Manifests <span class="nav-count" id="navManifestsCount">0</span>
                    </a>
                    <a class="sidebar-nav-item" data-section="section-skills" onclick="scrollToSection('section-skills')">
                        All skills <span class="nav-count" id="navSkillsCount">0</span>
                    </a>
                    <a class="sidebar-nav-item" data-section="section-repos" onclick="scrollToSection('section-repos')">
                        Repos <span class="nav-count" id="navReposCount">0</span>
                    </a>
                    <a class="sidebar-nav-item" data-section="section-quality" onclick="scrollToSection('section-quality')" id="navQuality">
                        Quality <span class="nav-count" id="navQualityCount">—</span>
                    </a>
                    <a class="sidebar-nav-item" data-section="section-staleness" onclick="scrollToSection('section-staleness')" id="navStaleness">
                        Staleness <span class="nav-count" id="navStalenessCount">—</span>
                    </a>
                    <a class="sidebar-nav-item" data-section="section-duplicates" onclick="scrollToSection('section-duplicates')" id="navDuplicates">
                        Duplicates <span class="nav-count" id="navDuplicatesCount">—</span>
                    </a>
                    <a class="sidebar-nav-item" data-section="section-registry-suggestions" onclick="scrollToSection('section-registry-suggestions')" id="navRegistrySuggestions">
                        Registry suggestions <span class="nav-count" id="navRegistrySuggestionsCount">—</span>
                    </a>
                    <a class="sidebar-nav-item" data-section="section-breakdown" onclick="scrollToSection('section-breakdown')">Breakdown</a>
                    <a class="sidebar-nav-item" data-section="section-warnings" onclick="scrollToSection('section-warnings')">
                        Warnings <span class="nav-count" id="navWarningsCount">0</span>
                    </a>
                    <a class="sidebar-nav-item" data-section="section-methodology" onclick="scrollToSection('section-methodology')">Methodology</a>
                </div>
            </nav>

            <!-- Main -->
            <div class="main">
                <div id="section-overview"></div>

                <div class="hero">
                    <h1>Skill Insights</h1>
                    <div class="hero-subtitle" id="heroSubtitle"></div>
                </div>

                <!-- Health overview: three stacked-bar cards (Quality / Staleness / Estate) -->
                <div class="health-grid" id="healthGrid"></div>

                <!-- Top issues: three columns of click-throughs to the worst offenders -->
                <div class="section-divider" id="section-top-issues">
                    <div class="section-title">Top issues</div>
                    <div class="section-description">
                        The worst offenders by staleness score, by review score, and the largest duplicate clusters.
                        Click any item to jump to its drawer or section.
                    </div>
                </div>
                <div class="top-issues-grid" id="topIssuesGrid"></div>

                <!-- Recently changed: latest commits across the estate -->
                <div class="section-divider" id="section-recent">
                    <div class="section-title">Recently changed</div>
                    <div class="section-description">
                        Skills sorted by their most recent commit (from the staleness phase's <code>git_provenance</code>).
                    </div>
                </div>
                <div class="recent-changes" id="recentChanges"></div>

                <!-- Tessl Tiles -->
                <div class="section-divider" id="section-tiles">
                    <div class="section-title">Tessl tiles</div>
                    <div class="section-description">
                        Skills grouped by their owning tile (<code>owning_package.kind === "tessl_tile"</code>).
                        Tiles are first-class because they have a <code>tile.json</code> manifest, support
                        <code>tessl eval run</code>, and can be versioned and pinned via <code>tessl.json</code>.
                        Grouped by the location prefix of the owning <code>tile.json</code>.
                    </div>
                </div>
                <div id="tilesContent"></div>

                <!-- Manifests: every tessl.json in the scan and what it consumes -->
                <div class="section-divider" id="section-manifests">
                    <div class="section-title">Manifests</div>
                    <div class="section-description">
                        Every <code>tessl.json</code> manifest the scan found, listed by repo and path. For each
                        manifest, the table shows total / resolved / unresolved dependency counts and the tiles it
                        declares.
                    </div>
                </div>
                <div id="manifestsContent"></div>

                <!-- All skills (tile + non-tile) -->
                <div class="section-divider" id="section-skills">
                    <div class="section-title">All skills</div>
                    <div class="section-description">
                        Every logical skill from the discovery scan. The "Tile" column shows the owning tile
                        (if <code>owning_package.kind === "tessl_tile"</code>). Vendored copies (same file
                        across agent harness paths) are collapsed into one logical skill.
                    </div>
                </div>

                <div class="filter-row">
                    <div class="search-box">
                        <input id="skillSearch" placeholder="Search skills by name, description, tile..." oninput="applySkillFilters()" />
                    </div>
                    <div id="sourceTypeFilters"></div>
                </div>

                <div class="table-wrapper">
                    <table class="data-table" id="skillsTable">
                        <thead>
                            <tr>
                                <th data-sort="name">Skill</th>
                                <th data-sort="tile">Tile</th>
                                <th data-sort="repo">Repo</th>
                                <th data-sort="source_type">Type</th>
                                <th data-sort="quality" class="num-cell">Quality</th>
                                <th data-sort="staleness" class="num-cell">Stale</th>
                                <th data-sort="dup">Dup</th>
                                <th data-sort="paths" class="num-cell">Paths</th>
                            </tr>
                        </thead>
                        <tbody id="skillsTbody"></tbody>
                    </table>
                </div>

                <!-- Repos -->
                <div class="section-divider" id="section-repos">
                    <div class="section-title">Repos</div>
                    <div class="section-description">
                        Each git repository the scan covered. Skills and supporting files are attributed back to their repo.
                    </div>
                </div>
                <div id="reposList"></div>

                <!-- Quality -->
                <div class="section-divider" id="section-quality">
                    <div class="section-title">Quality</div>
                    <div class="section-description" id="qualityDescription">
                        LLM rubric review across six dimensions per skill: description clarity, activation signals,
                        specificity, non-redundancy with model defaults, size fit, and structural integrity.
                    </div>
                </div>
                <div id="qualityContent"></div>

                <!-- Staleness -->
                <div class="section-divider" id="section-staleness">
                    <div class="section-title">Staleness</div>
                    <div class="section-description" id="stalenessDescription">
                        Per-skill age signals from <code>git log</code> plus broken-reference detection.
                        Score is deterministic — see schema for the weighted rules.
                    </div>
                </div>
                <div id="stalenessContent"></div>

                <!-- Duplicates -->
                <div class="section-divider" id="section-duplicates">
                    <div class="section-title">Duplicates</div>
                    <div class="section-description" id="duplicatesDescription">
                        LLM-confirmed duplicate skills, clustered transitively. Overlapping pairs (separate scope but
                        meaningful shared content) are listed below.
                    </div>
                </div>
                <div id="duplicatesContent"></div>

                <!-- Registry suggestions -->
                <div class="section-divider" id="section-registry-suggestions">
                    <div class="section-title">Registry suggestions</div>
                    <div class="section-description" id="registrySuggestionsDescription">
                        For every skill whose owning tile is not <code>published_to_registry</code>, the top hybrid-search
                        match against the Tessl registry's skill index and tile index. Eval / quality / impact / security
                        scores are surfaced verbatim — high-eval matches are candidates to replace the local skill.
                    </div>
                </div>
                <div id="registrySuggestionsContent"></div>

                <!-- Breakdown -->
                <div class="section-divider" id="section-breakdown">
                    <div class="section-title">Breakdown</div>
                    <div class="section-description">Aggregate distribution by source type, agent harness, and repo.</div>
                </div>
                <div id="breakdownContent"></div>

                <!-- Warnings -->
                <div class="section-divider" id="section-warnings">
                    <div class="section-title">Warnings</div>
                    <div class="section-description">
                        Non-fatal issues encountered during discovery — broken links, unrecognised paths, unparseable
                        frontmatter. The scan still produced a complete inventory.
                    </div>
                </div>
                <div id="warningsList"></div>

                <!-- Methodology -->
                <div class="section-divider" id="section-methodology">
                    <div class="section-title">Methodology</div>
                </div>
                <div style="color: var(--fg-muted); font-size: 13px; line-height: 1.7;">
                    <p style="margin-bottom: 12px;">
                        Skill Insights (discovery phase) scans the target directory for every <code>SKILL.md</code> file.
                        Each repository is detected by the presence of a <code>.git/</code> directory; if the scan root
                        itself is a repo it's treated as a single-repo scan, otherwise each immediate child that is a repo
                        is scanned in workspace mode.
                    </p>
                    <p style="margin-bottom: 12px;">
                        Within one repo, multiple <code>SKILL.md</code> files with identical content hashes (Tessl's
                        vendored install pattern — replicating a skill to <code>.claude/skills/</code>,
                        <code>.agents/skills/</code>, <code>.cursor/skills/</code>, <code>.tessl/tiles/</code>, etc.) are
                        deduped into one logical skill with all paths recorded. Cross-repo dedup is not performed.
                    </p>
                    <p>
                        Supporting files are captured from <code>references/</code>, <code>reference/</code>, and
                        <code>scripts/</code> directories adjacent to each skill, plus any file linked from the skill's
                        markdown body. Other sibling directories (e.g. <code>examples/</code>, <code>templates/</code>,
                        language-specific subdirs) are summarised as bundled directories with file counts.
                    </p>
                </div>
            </div>

            <!-- Right-side drill-down panel -->
            <aside class="panel" id="panel" aria-hidden="true">
                <div class="panel-inner" id="panelInner"></div>
            </aside>
        </div>

        <footer class="footer">
            <span>Generated by tessleng/skill-insights</span>
            <span id="footerMeta"></span>
        </footer>

        <script id="discovery-data" type="application/json"><!--@DISCOVERY_DATA@--></script>
        <script id="staleness-data" type="application/json"><!--@STALENESS_DATA@--></script>
        <script id="quality-data" type="application/json"><!--@QUALITY_DATA@--></script>
        <script id="duplicates-data" type="application/json"><!--@DUPLICATES_DATA@--></script>
        <script id="registry-search-data" type="application/json"><!--@REGISTRY_SEARCH_DATA@--></script>

        <script>
            // === Data loading ============================================================
            const $data = (() => {
                try { return JSON.parse(document.getElementById('discovery-data').textContent); }
                catch (e) { return null; }
            })();

            if (!$data || !$data.metadata) {
                document.body.innerHTML = '<div style="padding:40px;color:#f87171;font-family:monospace;">Discovery data not found or invalid JSON.</div>';
                throw new Error('Discovery data not loaded');
            }

            // Schema 1.2: flat skills[] PLUS top-level tiles[] with tier, registry,
            // outdated, and context_cost enrichment. We use discovery's tiles[] as the
            // source of truth and reshape into a per-tile lookup; the skills array gets
            // a synthetic _tile back-pointer for the per-skill table cells.
            const skills = (Array.isArray($data.skills) ? $data.skills : []).map(s => ({ ...s }));

            // Back-fill skill lists onto each tile + collate declared_in[] from skills
            const tilesRaw = Array.isArray($data.tiles) ? $data.tiles : [];
            const tesslTiles = tilesRaw.map(t => {
                // Find skills belonging to this tile
                const tileSkills = skills.filter(s => {
                    const pkg = s.owning_package;
                    if (!pkg || pkg.kind !== 'tessl_tile' || pkg.name !== t.name || s.repo !== t.repo) return false;
                    if (s.tile_id) return s.tile_id === t.tile_id;
                    return true;
                });
                // Aggregate declared_in across the tile's skills (deduped)
                const seen = new Set();
                const declared_in = [];
                for (const s of tileSkills) {
                    for (const d of (s.declared_in || [])) {
                        const k = `${d.manifest_path}|${d.dep_key}`;
                        if (!seen.has(k)) { seen.add(k); declared_in.push(d); }
                    }
                }
                return {
                    ...t,
                    display_name: t.name,
                    version: t.version_installed,
                    skills: tileSkills,
                    declared_in,
                };
            });

            const tileById = new Map(tesslTiles.map(t => [t.tile_id, t]));
            const tilesByRepoName = new Map();
            for (const t of tesslTiles) {
                const key = `${t.repo}::${t.name}`;
                const list = tilesByRepoName.get(key) || [];
                list.push(t);
                tilesByRepoName.set(key, list);
            }

            for (const s of skills) {
                const pkg = s.owning_package;
                if (pkg && pkg.kind === 'tessl_tile' && pkg.name) {
                    s._tile = (s.tile_id && tileById.get(s.tile_id)) || null;
                    if (!s._tile) {
                        const candidates = tilesByRepoName.get(`${s.repo}::${pkg.name}`) || [];
                        s._tile = candidates.length === 1 ? candidates[0] : null;
                    }
                } else {
                    s._tile = null;
                }
            }

            const repos = (($data.metadata || {}).repos) || [];
            const stats = $data.stats || {};
            const warnings = Array.isArray($data.warnings) ? $data.warnings : [];

            // Optional analytical phase data (null/missing if not run)
            const $loadOptional = (id) => {
                try {
                    const txt = document.getElementById(id).textContent.trim();
                    if (!txt || txt === 'null') return null;
                    return JSON.parse(txt);
                } catch (e) { return null; }
            };
            const $staleness = $loadOptional('staleness-data');
            const $quality   = $loadOptional('quality-data');
            const $duplicates = $loadOptional('duplicates-data');
            const $registrySearch = $loadOptional('registry-search-data');

            // Build per-skill lookup maps for the optional phases (skill_id → record)
            const skillById = new Map(skills.map(s => [s.skill_id, s]));
            // Map repo_id → display name (git origin "ws/name" when present, else repo_id basename)
            const repoNameById = new Map(repos.map(r => [r.repo_id, r.name || r.repo_id]));
            const repoLabel = (s) => repoNameById.get(s.repo) || s.repo || '';
            const stalenessById = (() => {
                if (!$staleness || !Array.isArray($staleness.per_skill)) return new Map();
                return new Map($staleness.per_skill.map(p => [p.skill_id, p]));
            })();
            const qualityById = (() => {
                if (!$quality || !Array.isArray($quality.per_skill)) return new Map();
                return new Map($quality.per_skill.map(p => [p.skill_id, p]));
            })();
            const dupClusterByIdSkill = (() => {
                if (!$duplicates || !Array.isArray($duplicates.clusters)) return new Map();
                const m = new Map();
                $duplicates.clusters.forEach(c => c.skill_ids.forEach(sid => m.set(sid, c)));
                return m;
            })();

            // === Utilities ===============================================================
            const escapeHTML = (s) => {
                if (s == null) return '';
                return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
            };
            const truncate = (s, n = 120) => { if (!s) return ''; s = String(s); return s.length > n ? s.slice(0, n) + '…' : s; };
            const humanBytes = (n) => {
                if (n == null) return '—';
                if (n < 1024) return n + ' B';
                if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
                return (n / 1024 / 1024).toFixed(2) + ' MB';
            };

            // === Sidebar meta ============================================================
            (() => {
                const meta = $data.metadata || {};
                const items = [];
                if (meta.scan_root) items.push(`<div class="sidebar-meta-item"><strong>Root</strong></div><div class="sidebar-meta-item" style="color: var(--fg-muted); margin-top: -2px">${escapeHTML(meta.scan_root)}</div>`);
                if (meta.scanned_at) items.push(`<div class="sidebar-meta-item" style="margin-top: 6px"><strong>Scanned</strong></div><div class="sidebar-meta-item" style="color: var(--fg-muted); margin-top: -2px">${new Date(meta.scanned_at).toLocaleString()}</div>`);
                if (meta.tool_version) items.push(`<div class="sidebar-meta-item" style="margin-top: 6px"><strong>Version</strong></div><div class="sidebar-meta-item" style="color: var(--fg-muted); margin-top: -2px">v${escapeHTML(meta.tool_version)}</div>`);
                document.getElementById('sidebarMeta').innerHTML = items.join('');
            })();

            // === Hero subtitle ===========================================================
            (() => {
                const nRepos = repos.length;
                const nSkills = stats.total_skills ?? skills.length;
                const nFiles = stats.total_skill_files ?? nSkills;
                const parts = [];
                if (nRepos === 1) parts.push(`<strong>${escapeHTML(repos[0]?.name || repos[0]?.repo_id || 'repo')}</strong>`);
                else if (nRepos > 1) parts.push(`<strong>${nRepos}</strong> repositories`);
                parts.push(`<strong>${nSkills}</strong> logical skill${nSkills === 1 ? '' : 's'}`);
                if (nFiles > nSkills) parts.push(`${nFiles} <code style="font-family:'Atkinson Hyperlegible Mono',monospace;font-size:12px;color:var(--fg-subtle)">SKILL.md</code> paths (${(nFiles / nSkills).toFixed(1)}× dedup ratio)`);
                document.getElementById('heroSubtitle').innerHTML = parts.join(' · ');
            })();

            // === Health overview =========================================================
            // Three cards stacked into one row: Quality, Staleness, Estate. Each shows a
            // stacked-bar distribution where applicable. Falls back to an "—" headline
            // when the corresponding analytical phase didn't run.
            const renderStackedBar = (segments, totalOverride) => {
                const total = totalOverride ?? segments.reduce((n, s) => n + (s.value || 0), 0);
                if (total <= 0) {
                    return `<div class="stacked-bar"><div class="stacked-bar-segment muted" style="width:100%"></div></div>`;
                }
                const segHtml = segments
                    .filter(s => (s.value || 0) > 0)
                    .map(s => `<div class="stacked-bar-segment ${escapeHTML(s.color || 'muted')}" style="width:${(s.value / total * 100).toFixed(2)}%" title="${escapeHTML(s.label)}: ${s.value}"></div>`)
                    .join('');
                return `<div class="stacked-bar">${segHtml}</div>`;
            };
            const renderStackedLegend = (segments) => {
                const total = segments.reduce((n, s) => n + (s.value || 0), 0);
                return `<div class="stacked-bar-legend">${segments.map(s => `
                    <span class="stacked-bar-legend-item">
                        <span class="stacked-bar-swatch ${escapeHTML(s.color || 'muted')}"></span>
                        ${escapeHTML(s.label)} <span style="color: var(--fg-subtle); font-variant-numeric: tabular-nums;">${s.value || 0}</span>
                    </span>
                `).join('')}${total === 0 ? '<span class="stacked-bar-legend-item" style="color: var(--fg-subtle);">no data</span>' : ''}</div>`;
            };

            (() => {
                const cards = [];

                // Quality card
                const q = $quality && $quality.estate_summary;
                if (q) {
                    const v = q.by_verdict || {};
                    const segments = [
                        { label: 'good', value: v.good || 0, color: 'green' },
                        { label: 'acceptable', value: v.acceptable || 0, color: 'blue' },
                        { label: 'needs work', value: v.needs_work || 0, color: 'yellow' },
                        { label: 'poor', value: v.poor || 0, color: 'red' },
                        { label: 'unknown', value: v.unknown || 0, color: 'muted' },
                    ];
                    cards.push(`
                        <div class="health-card" onclick="scrollToSection('section-quality')" style="cursor:pointer;">
                            <div class="health-card-header">
                                <span class="health-card-title">Quality</span>
                                <span class="health-card-headline">${q.avg_review_score ?? '—'}<span class="health-card-headline-sub">/100 avg</span></span>
                            </div>
                            ${renderStackedBar(segments)}
                            ${renderStackedLegend(segments)}
                        </div>
                    `);
                } else {
                    cards.push(`
                        <div class="health-card">
                            <div class="health-card-header">
                                <span class="health-card-title">Quality</span>
                                <span class="health-card-headline">—</span>
                            </div>
                            <div style="font-size:12px; color: var(--fg-subtle);">Quality phase did not run.</div>
                        </div>
                    `);
                }

                // Staleness card
                const st = $staleness && $staleness.estate_summary;
                if (st) {
                    const b = st.buckets || {};
                    const segments = [
                        { label: 'fresh', value: b.fresh || 0, color: 'green' },
                        { label: 'warm', value: b.warm || 0, color: 'blue' },
                        { label: 'stale', value: b.stale || 0, color: 'yellow' },
                        { label: 'ancient', value: b.ancient || 0, color: 'red' },
                        { label: 'unknown', value: b.unknown || 0, color: 'muted' },
                    ];
                    const median = st.median_days_since_modified;
                    cards.push(`
                        <div class="health-card" onclick="scrollToSection('section-staleness')" style="cursor:pointer;">
                            <div class="health-card-header">
                                <span class="health-card-title">Staleness</span>
                                <span class="health-card-headline">${median ?? '—'}<span class="health-card-headline-sub">d median</span></span>
                            </div>
                            ${renderStackedBar(segments)}
                            ${renderStackedLegend(segments)}
                        </div>
                    `);
                } else {
                    cards.push(`
                        <div class="health-card">
                            <div class="health-card-header">
                                <span class="health-card-title">Staleness</span>
                                <span class="health-card-headline">—</span>
                            </div>
                            <div style="font-size:12px; color: var(--fg-subtle);">Staleness phase did not run.</div>
                        </div>
                    `);
                }

                // Estate card: counts that don't fit into stacked bars
                const sourceCounts = stats.by_source_type || {};
                const tileSkillCount = sourceCounts.tessl_tile_skill ?? skills.filter(x => x._tile).length;
                const nonTileSkillCount = (stats.total_skills ?? skills.length) - tileSkillCount;
                const totalManifestCount = (() => {
                    let n = 0;
                    repos.forEach(r => { n += (r.tessl_manifests || []).length; });
                    return n;
                })();
                const dupClusters = ($duplicates && $duplicates.estate_summary && $duplicates.estate_summary.duplicate_clusters) || 0;
                const skillsWithBroken = ($staleness && $staleness.estate_summary && $staleness.estate_summary.skills_with_broken_refs) || 0;
                cards.push(`
                    <div class="health-card">
                        <div class="health-card-header">
                            <span class="health-card-title">Estate</span>
                            <span class="health-card-headline">${stats.total_skills ?? skills.length}<span class="health-card-headline-sub">skills</span></span>
                        </div>
                        <dl class="health-mini-grid">
                            <dt>Tessl tiles</dt><dd>${tesslTiles.length}</dd>
                            <dt>Tile skills</dt><dd>${tileSkillCount}</dd>
                            <dt>Non-tile skills</dt><dd>${nonTileSkillCount}</dd>
                            <dt>Repos</dt><dd>${stats.total_repos ?? repos.length}</dd>
                            <dt>Manifests</dt><dd>${totalManifestCount}</dd>
                            <dt>Dup clusters</dt><dd>${dupClusters}</dd>
                            <dt>Broken refs</dt><dd>${skillsWithBroken}</dd>
                            <dt>Warnings</dt><dd>${warnings.length}</dd>
                        </dl>
                    </div>
                `);

                document.getElementById('healthGrid').innerHTML = cards.join('');
            })();

            // === Top issues =============================================================
            // Three columns: top stale, lowest quality, largest dup clusters. Each entry
            // is clickable: skills open the skill drawer; cluster items jump to the
            // duplicates section.
            (() => {
                const cards = [];

                // Top stale: by staleness_score desc, ignore zero-score
                if ($staleness && Array.isArray($staleness.per_skill)) {
                    const top = [...$staleness.per_skill]
                        .filter(p => (p.staleness_score || 0) > 0)
                        .sort((a, b) => (b.staleness_score || 0) - (a.staleness_score || 0))
                        .slice(0, 5);
                    const items = top.length ? top.map(p => {
                        const skill = skills.find(s => s.skill_id === p.skill_id);
                        const name = skill?.name || p.skill_id.split('::').pop();
                        return `
                            <li class="issue-list-item" onclick="openSkillPanelById('${escapeHTML(p.skill_id)}')">
                                <span class="issue-name">${escapeHTML(name)}</span>
                                <span class="issue-meta">${p.staleness_score}/100${p.days_since_modified != null ? ' · ' + p.days_since_modified + 'd' : ''}</span>
                            </li>`;
                    }).join('') : `<div class="issue-empty">No stale skills.</div>`;
                    cards.push(`
                        <div class="issue-card">
                            <div class="issue-card-header">
                                <span>Top stale</span>
                                <a class="issue-card-link" onclick="scrollToSection('section-staleness')">View all →</a>
                            </div>
                            <ul class="issue-list">${items}</ul>
                        </div>
                    `);
                }

                // Lowest quality: by review_score asc (ignore null)
                if ($quality && Array.isArray($quality.per_skill)) {
                    const top = [...$quality.per_skill]
                        .filter(p => typeof p.review_score === 'number')
                        .sort((a, b) => (a.review_score || 0) - (b.review_score || 0))
                        .slice(0, 5);
                    const items = top.length ? top.map(p => `
                        <li class="issue-list-item" onclick="openSkillPanelById('${escapeHTML(p.skill_id)}')">
                            <span class="issue-name">${escapeHTML(p.name || p.skill_id.split('::').pop())}</span>
                            <span class="issue-meta">${p.review_score}/100 · ${escapeHTML(p.verdict || '')}</span>
                        </li>`).join('') : `<div class="issue-empty">No skills with review scores.</div>`;
                    cards.push(`
                        <div class="issue-card">
                            <div class="issue-card-header">
                                <span>Lowest quality</span>
                                <a class="issue-card-link" onclick="scrollToSection('section-quality')">View all →</a>
                            </div>
                            <ul class="issue-list">${items}</ul>
                        </div>
                    `);
                }

                // Largest duplicate clusters
                if ($duplicates && Array.isArray($duplicates.clusters)) {
                    const sorted = [...$duplicates.clusters]
                        .sort((a, b) => (b.skill_ids?.length || 0) - (a.skill_ids?.length || 0))
                        .slice(0, 5);
                    const items = sorted.length ? sorted.map(c => `
                        <li class="issue-list-item" onclick="scrollToSection('section-duplicates')">
                            <span class="issue-name">${escapeHTML(c.cluster_id)} · ${escapeHTML(c.dominant_skill_id?.split('::').pop() || '')}</span>
                            <span class="issue-meta">${c.skill_ids?.length || 0} skills · ${escapeHTML(c.severity || '')}</span>
                        </li>`).join('') : `<div class="issue-empty">No duplicate clusters.</div>`;
                    cards.push(`
                        <div class="issue-card">
                            <div class="issue-card-header">
                                <span>Duplicate clusters</span>
                                <a class="issue-card-link" onclick="scrollToSection('section-duplicates')">View all →</a>
                            </div>
                            <ul class="issue-list">${items}</ul>
                        </div>
                    `);
                }

                document.getElementById('topIssuesGrid').innerHTML = cards.join('');
                const totalIssues = (() => {
                    let n = 0;
                    if ($staleness?.estate_summary?.buckets) {
                        n += ($staleness.estate_summary.buckets.stale || 0) + ($staleness.estate_summary.buckets.ancient || 0);
                    }
                    if ($quality?.estate_summary?.by_verdict) {
                        n += ($quality.estate_summary.by_verdict.poor || 0);
                    }
                    if ($duplicates?.estate_summary?.duplicate_clusters) {
                        n += $duplicates.estate_summary.duplicate_clusters;
                    }
                    return n;
                })();
                document.getElementById('navTopIssuesCount').textContent = totalIssues;
            })();

            // === Recently changed =======================================================
            // Sorted by last_modified desc using the staleness phase's git_provenance.
            (() => {
                const container = document.getElementById('recentChanges');
                const navCount = document.getElementById('navRecentCount');
                if (!$staleness || !Array.isArray($staleness.per_skill)) {
                    container.innerHTML = '<div class="empty">No git history available — run the staleness phase to populate this list.</div>';
                    if (navCount) navCount.textContent = '—';
                    return;
                }
                const sorted = [...$staleness.per_skill]
                    .filter(p => p.last_modified)
                    .sort((a, b) => (b.last_modified || '').localeCompare(a.last_modified || ''))
                    .slice(0, 8);
                if (navCount) navCount.textContent = sorted.length;
                if (!sorted.length) {
                    container.innerHTML = '<div class="empty">No skills with git history.</div>';
                    return;
                }
                container.innerHTML = `
                    <div class="recent-changes-header">
                        <span>Last 8 modified</span>
                        <span style="text-transform:none; letter-spacing:0; color: var(--fg-subtle);">click to open</span>
                    </div>
                    <div class="recent-list">
                        ${sorted.map(p => {
                            const skill = skills.find(s => s.skill_id === p.skill_id);
                            const name = skill?.name || p.skill_id.split('::').pop();
                            const author = (p.git_provenance && p.git_provenance.last_modified_by && p.git_provenance.last_modified_by.name) || '';
                            const date = p.last_modified ? new Date(p.last_modified).toLocaleDateString() : '—';
                            const days = p.days_since_modified != null ? `${p.days_since_modified}d ago` : '';
                            return `
                                <div class="recent-item" onclick="openSkillPanelById('${escapeHTML(p.skill_id)}')">
                                    <span class="recent-name">${escapeHTML(name)}</span>
                                    <span class="recent-author">${escapeHTML(author)}</span>
                                    <span class="recent-date" title="${escapeHTML(date)}">${escapeHTML(days)}</span>
                                </div>`;
                        }).join('')}
                    </div>
                `;
            })();

            // === Tessl Tiles section =====================================================
            (() => {
                const container = document.getElementById('tilesContent');
                document.getElementById('navTilesCount').textContent = tesslTiles.length;
                if (!tesslTiles.length) {
                    container.innerHTML = '<div class="empty">No Tessl tiles in this scan.</div>';
                    return;
                }

                const bySource = stats.tiles_by_source || (() => {
                    const acc = { tessl_json: 0, filesystem: 0 };
                    for (const t of tesslTiles) if (t.source && acc[t.source] != null) acc[t.source]++;
                    return acc;
                })();
                const totalTileSkills = tesslTiles.reduce((n, t) => n + t.skills.length, 0);

                const summaryRow = `
                    <div style="display:grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px;">
                        <div class="stat-item"><div class="stat-value">${tesslTiles.length}</div><div class="stat-label">Tiles</div></div>
                        <div class="stat-item" title="Tiles surfaced from a tessl.json manifest"><div class="stat-value" style="color: var(--blue, #60a5fa);">${bySource.tessl_json || 0}</div><div class="stat-label">via tessl.json</div></div>
                        <div class="stat-item" title="Tiles found by walking the SKILL.md tree (authored, no tessl.json declaration)"><div class="stat-value" style="color: var(--fg-subtle);">${bySource.filesystem || 0}</div><div class="stat-label">via filesystem</div></div>
                        <div class="stat-item"><div class="stat-value">${totalTileSkills}</div><div class="stat-label">Tile skills</div></div>
                    </div>
                `;

                // Within each location group, declared tiles come first, then alphabetical.
                // Groups are rendered as section header rows in the table body.
                const groupOrder = ['tiles/', 'tile/', 'packages/', 'apps/', 'research/', 'monorepo/', '.tessl/tiles/', '.tessl/'];
                const tileLocationGroup = (t) => {
                    const p = t.manifest_path;
                    if (!p) return '(no manifest)';
                    if (p.startsWith('.tessl/tiles/')) return '.tessl/tiles/';
                    if (p.startsWith('.tessl/')) return '.tessl/';
                    const idx = p.indexOf('/');
                    if (idx < 0) return '(repo root)';
                    return p.slice(0, idx + 1);
                };
                const groupRank = (g) => {
                    const i = groupOrder.indexOf(g);
                    return i === -1 ? 99 : i;
                };
                const sorted = [...tesslTiles].sort((a, b) => {
                    const ag = tileLocationGroup(a);
                    const bg = tileLocationGroup(b);
                    const gr = groupRank(ag) - groupRank(bg);
                    if (gr !== 0) return gr;
                    if (ag !== bg) return ag.localeCompare(bg);
                    const aDecl = a.declared_in.length > 0 ? 0 : 1;
                    const bDecl = b.declared_in.length > 0 ? 0 : 1;
                    return aDecl - bDecl || a.display_name.localeCompare(b.display_name) || a.tile_id.localeCompare(b.tile_id);
                });

                const securityColor = (s) => s === 'CRITICAL' ? 'var(--red)' : s === 'HIGH' ? 'var(--red)' : s === 'MEDIUM' ? 'var(--yellow)' : s === 'LOW' ? 'var(--green)' : 'var(--fg-subtle)';

                let lastGroup = null;
                const rowChunks = [];
                for (const t of sorted) {
                    const tile = tileById.get(t.tile_id) || t;
                    const declCount = (tile.declared_in || []).length;
                    const declCell = declCount > 0
                        ? `<span class="badge" style="color:var(--green);">${declCount}× declared</span>`
                        : `<span style="color:var(--fg-subtle);">—</span>`;
                    const reg = (tile.registry || {});
                    const scores = (reg.scores || {});
                    const tierBadge = `<span class="badge" style="color:var(--fg-subtle);">${escapeHTML(tile.tier || '')}</span>`;
                    const sourceBadge = tile.source
                        ? `<span class="badge" title="Discovery source: ${tile.source === 'tessl_json' ? 'declared in a tessl.json manifest' : 'found by walking the SKILL.md tree'}" style="color:${tile.source === 'tessl_json' ? 'var(--blue, #60a5fa)' : 'var(--fg-subtle)'};">${escapeHTML(tile.source.replace('_', '.'))}</span>`
                        : '';
                    const securityCell = scores.security
                        ? `<span class="badge" style="color:${securityColor(scores.security)};">${escapeHTML(scores.security)}</span>`
                        : '<span style="color:var(--fg-subtle)">—</span>';
                    const uplift = reg.evalImprovementMultiplier;
                    const upliftCell = uplift != null
                        ? `<span style="color:var(--green); font-weight:600;">↑${uplift}×</span>`
                        : '<span style="color:var(--fg-subtle)">—</span>';
                    const out = (tile.outdated || {});
                    const outCell = out.update_available
                        ? `<span class="badge" style="color:var(--orange);">${escapeHTML(out.current || '')} → ${escapeHTML(out.latest || '')}</span>`
                        : '<span style="color:var(--fg-subtle)">—</span>';
                    const cc = (tile.context_cost || {});
                    const ccCell = cc.front_loaded_total != null
                        ? `<div style="font-variant-numeric:tabular-nums;"><strong>${cc.front_loaded_total}</strong><div style="font-size:10px; color:var(--fg-subtle);">${cc.on_demand_min_total}-${cc.on_demand_max_total} on-demand</div></div>`
                        : '<span style="color:var(--fg-subtle)">—</span>';

                    const group = tileLocationGroup(t);
                    if (group !== lastGroup) {
                        const groupTiles = sorted.filter(x => tileLocationGroup(x) === group);
                        rowChunks.push(`<tr><td colspan="8" style="padding: 14px 0 6px 0; border-bottom: 1px solid var(--border);">
                            <span style="font-size:11px; text-transform:uppercase; letter-spacing:.08em; color: var(--fg-subtle);">${escapeHTML(group)}</span>
                            <span style="margin-left: 10px; font-size: 11px; color: var(--fg-subtle); font-variant-numeric: tabular-nums;">${groupTiles.length} tile${groupTiles.length === 1 ? '' : 's'}</span>
                        </td></tr>`);
                        lastGroup = group;
                    }

                    rowChunks.push(`<tr data-tile-key="${escapeHTML(t.tile_id)}">
                        <td>
                            <div style="font-weight:500;">${escapeHTML(t.display_name)}</div>
                            <div style="font-size:11px; color:var(--fg-subtle); margin-top:2px;">${escapeHTML(t.manifest_path || t.tile_id)}</div>
                        </td>
                        <td><div style="display:flex; gap:4px; flex-wrap:wrap;">${tierBadge}${sourceBadge}</div></td>
                        <td>${escapeHTML(t.version || '—')}</td>
                        <td>${securityCell}</td>
                        <td>${upliftCell}</td>
                        <td>${outCell}</td>
                        <td>${ccCell}</td>
                        <td class="num-cell mono-cell">${t.skills.length}</td>
                    </tr>`);
                }
                const rows = rowChunks.join('');

                container.innerHTML = `
                    ${summaryRow}
                    <div class="table-wrapper">
                        <table class="data-table">
                            <thead><tr>
                                <th>Tile</th><th>Tier · Source</th><th>Version</th><th>Security</th>
                                <th>Uplift</th><th>Outdated</th><th>Context cost</th>
                                <th class="num-cell">Skills</th>
                            </tr></thead>
                            <tbody>${rows}</tbody>
                        </table>
                    </div>
                `;

                container.querySelectorAll('tr[data-tile-key]').forEach(tr => {
                    tr.addEventListener('click', () => openTilePanel(tileById.get(tr.dataset.tileKey)));
                });
            })();

            // === Manifests section ======================================================
            // For each repo, list every tessl.json the scan found, with dependency stats
            // and the tiles each manifest declares.
            (() => {
                const container = document.getElementById('manifestsContent');

                // Build inverse: for each (repo_id, manifest_path), list tiles whose
                // declared_in[] points to that manifest.
                const tilesByManifest = new Map();
                for (const t of tesslTiles) {
                    for (const d of (t.declared_in || [])) {
                        const key = `${t.repo}::${d.manifest_path}`;
                        const list = tilesByManifest.get(key) || [];
                        list.push({ tile: t, dep_key: d.dep_key, version: d.version });
                        tilesByManifest.set(key, list);
                    }
                }

                let totalManifests = 0;
                const blocks = [];
                for (const repo of repos) {
                    const manifests = repo.tessl_manifests || [];
                    if (!manifests.length) continue;
                    totalManifests += manifests.length;
                    const sortedM = [...manifests].sort((a, b) => a.path.localeCompare(b.path));
                    const rows = sortedM.map(m => {
                        const declList = tilesByManifest.get(`${repo.repo_id}::${m.path}`) || [];
                        const declHtml = declList.length
                            ? `<div class="dep-list">${declList.map(d => `
                                <div class="dep-row" data-tile-key="${escapeHTML(d.tile.tile_id)}" style="cursor:pointer;">
                                    <span class="dep-name">${escapeHTML(d.dep_key)}</span>
                                    ${d.version ? `<span class="dep-version">@ ${escapeHTML(d.version)}</span>` : ''}
                                </div>`).join('')}</div>`
                            : `<span style="color: var(--fg-subtle); font-size: 12px;">—</span>`;
                        const unresolvedClass = m.dependencies_unresolved > 0 ? ' style="color: var(--orange);"' : '';
                        return `<tr>
                            <td class="mono">${escapeHTML(m.path)}</td>
                            <td class="num-cell">${m.dependencies_total}</td>
                            <td class="num-cell" style="color: var(--green);">${m.dependencies_resolved}</td>
                            <td class="num-cell"${unresolvedClass}>${m.dependencies_unresolved}</td>
                            <td>${declHtml}</td>
                        </tr>`;
                    }).join('');
                    blocks.push(`
                        <div class="repo-card" style="padding: 14px 18px;">
                            <div class="repo-card-header" style="margin-bottom: 4px;">
                                <span class="repo-card-name">${escapeHTML(repo.name || repo.repo_id)}</span>
                                <span class="repo-card-remote">${manifests.length} manifest${manifests.length === 1 ? '' : 's'}</span>
                            </div>
                            <table class="manifests-table">
                                <thead><tr>
                                    <th>Path</th>
                                    <th class="num-cell">Total</th>
                                    <th class="num-cell">Resolved</th>
                                    <th class="num-cell">Unresolved</th>
                                    <th>Tiles declared (workspace/name @ version)</th>
                                </tr></thead>
                                <tbody>${rows}</tbody>
                            </table>
                        </div>`);
                }

                document.getElementById('navManifestsCount').textContent = totalManifests;
                container.innerHTML = blocks.length
                    ? blocks.join('')
                    : '<div class="empty">No tessl.json manifests found in this scan.</div>';

                container.querySelectorAll('.dep-row[data-tile-key]').forEach(el => {
                    el.addEventListener('click', (e) => {
                        e.stopPropagation();
                        openTilePanel(tileById.get(el.dataset.tileKey));
                    });
                });
            })();

            // Tile panel — full enriched tile data from discovery.tiles[]
            function openTilePanel(t) {
                if (!t) return;
                const inner = document.getElementById('panelInner');
                const skillList = (t.skills || []).map(s => `
                    <li><span class="sf-path" data-skill="${escapeHTML(s.skill_id)}" style="cursor:pointer;">${escapeHTML(s.name)}</span></li>
                `).join('');
                const declList = (t.declared_in || []).map(d => `
                    <li>
                        <span class="sf-kind">${escapeHTML(d.dep_key)}${d.version ? ' @ ' + escapeHTML(d.version) : ''}</span>
                        <span class="sf-path">${escapeHTML(d.manifest_path)}</span>
                    </li>
                `).join('');

                const reg = t.registry || {};
                const scores = reg.scores || {};
                const securityColor = (s) => s === 'CRITICAL' ? 'var(--red)' : s === 'HIGH' ? 'var(--red)' : s === 'MEDIUM' ? 'var(--yellow)' : s === 'LOW' ? 'var(--green)' : 'var(--fg-subtle)';

                const registryBlock = (t.published_to_registry === true)
                    ? `<div class="panel-section">
                        <div class="panel-section-label">Registry signals</div>
                        <dl class="kv-grid">
                            ${scores.aggregate != null ? `<dt>Aggregate</dt><dd><strong>${(scores.aggregate * 100).toFixed(0)}%</strong></dd>` : ''}
                            ${scores.quality != null ? `<dt>Quality</dt><dd>${(scores.quality * 100).toFixed(0)}%</dd>` : ''}
                            ${scores.impact != null ? `<dt>Impact</dt><dd>${(scores.impact * 100).toFixed(0)}%</dd>` : ''}
                            ${scores.security ? `<dt>Security</dt><dd><span class="badge" style="color:${securityColor(scores.security)};">${escapeHTML(scores.security)}</span></dd>` : ''}
                            ${reg.evalImprovementMultiplier != null ? `<dt>Uplift</dt><dd><span style="color:var(--green);">↑${reg.evalImprovementMultiplier}×</span> (eval ${reg.evalScore} vs baseline ${reg.evalBaselineScore}, ${scores.evalCount} scenarios)</dd>` : ''}
                            ${reg.moderationPassed != null ? `<dt>Moderation</dt><dd>${reg.moderationPassed ? '✓ passed' : '✗ failed'}${reg.moderationError ? ' — ' + escapeHTML(reg.moderationError) : ''}</dd>` : ''}
                            ${reg.archived ? `<dt>Archived</dt><dd style="color:var(--orange);">${escapeHTML(reg.archivedReason || '')}</dd>` : ''}
                            ${scores.lastScoredAt ? `<dt>Last scored</dt><dd>${new Date(scores.lastScoredAt).toLocaleDateString()}</dd>` : ''}
                            ${(scores.validationErrors || []).length ? `<dt>Issues</dt><dd>${scores.validationErrors.map(e => `<span class="badge" style="color:var(--red);">${escapeHTML(e)}</span>`).join(' ')}</dd>` : ''}
                        </dl>
                    </div>`
                    : (t.published_to_registry === false
                        ? `<div class="panel-section">
                            <div class="panel-section-label">Registry signals</div>
                            <p style="color:var(--fg-subtle);">Not published to the registry. Quality, security, uplift, and eval signals from the registry are unavailable.</p>
                        </div>`
                        : '');

                const out = t.outdated || {};
                const outdatedBlock = out.update_available
                    ? `<div class="panel-section">
                        <div class="panel-section-label">Update available</div>
                        <p><span class="badge" style="color:var(--orange);">${escapeHTML(out.current || '')}</span> → <span class="badge" style="color:var(--green);">${escapeHTML(out.latest || '')}</span>${out.update && out.update !== out.latest ? ' (recommended next: ' + escapeHTML(out.update) + ')' : ''}</p>
                    </div>`
                    : '';

                const cc = t.context_cost || {};
                const ccBlock = cc.front_loaded_total != null
                    ? `<div class="panel-section">
                        <div class="panel-section-label">Context cost (per <code>tessl tile lint</code>)</div>
                        <dl class="kv-grid">
                            <dt>Front-loaded</dt><dd><strong>${cc.front_loaded_total}</strong> tokens (always in agent context)</dd>
                            <dt>On-demand range</dt><dd>${cc.on_demand_min_total}-${cc.on_demand_max_total} tokens (loaded when a skill activates)</dd>
                            ${cc.content_tokens_total ? `<dt>Docs / content</dt><dd>${cc.content_tokens_total} tokens</dd>` : ''}
                            <dt>Lint valid</dt><dd>${cc.lint_valid ? '✓' : '✗'}</dd>
                        </dl>
                        ${Object.keys(cc.per_skill || {}).length ? `<div style="margin-top:10px; font-size:11px; color:var(--fg-subtle);">Per-skill breakdown:</div>
                            <ul class="supporting-files-list" style="margin-top:4px;">
                                ${Object.entries(cc.per_skill).map(([k, v]) => `<li><span class="sf-path">${escapeHTML(k)}</span> <span style="color:var(--fg-muted); font-size:11px;">${v.front_loaded} fl, ${v.on_demand_min}-${v.on_demand_max} od</span></li>`).join('')}
                            </ul>` : ''}
                    </div>`
                    : '';

                inner.innerHTML = `
                    <button class="panel-close" onclick="closePanel()">×</button>
                    <div class="panel-title">${escapeHTML(t.display_name || t.name || t.tile_id)}</div>
                    <div class="panel-subtitle mono">${escapeHTML(t.tile_id || '')}</div>
                    <div class="panel-subtitle">${escapeHTML(t.tier || 'tessl tile')}${t.source ? ` · via ${escapeHTML(t.source.replace('_', '.'))}` : ''}${t.published_to_registry ? ' · published' : t.published_to_registry === false ? ' · not in registry' : ''}</div>

                    <div class="panel-section">
                        <div class="panel-section-label">Tile metadata</div>
                        <dl class="kv-grid">
                            <dt>Repo</dt><dd>${escapeHTML(repoLabel(t))}</dd>
                            <dt>Version</dt><dd>${escapeHTML(t.version || '—')}</dd>
                            <dt>tile.json</dt><dd class="mono">${escapeHTML(t.manifest_path || '—')}</dd>
                            ${t.source ? `<dt>Discovered via</dt><dd>${escapeHTML(t.source.replace('_', '.'))} <span style="color:var(--fg-subtle); font-size:11px;">— ${t.source === 'tessl_json' ? 'declared in a tessl.json manifest' : 'found by walking the SKILL.md tree'}</span></dd>` : ''}
                            ${reg.summary ? `<dt>Summary</dt><dd>${escapeHTML(reg.summary)}</dd>` : ''}
                        </dl>
                    </div>

                    ${registryBlock}
                    ${outdatedBlock}
                    ${ccBlock}

                    ${declList
                        ? `<div class="panel-section">
                            <div class="panel-section-label">Declared in (${(t.declared_in || []).length})</div>
                            <ul class="supporting-files-list">${declList}</ul>
                        </div>`
                        : `<div class="panel-section">
                            <div class="panel-section-label">Declarations</div>
                            <p style="color:var(--fg-subtle);">No <code>tessl.json</code> in this scan declares this tile.</p>
                        </div>`}

                    <div class="panel-section">
                        <div class="panel-section-label">Skills (${(t.skills || []).length})</div>
                        <ul class="path-list">${skillList || '<li><em style="color:var(--fg-subtle);">no skills</em></li>'}</ul>
                    </div>
                `;
                document.getElementById('shell').classList.add('panel-open');
                document.getElementById('panel').setAttribute('aria-hidden', 'false');

                inner.querySelectorAll('[data-skill]').forEach(el => el.addEventListener('click', () => {
                    const s = skills.find(x => x.skill_id === el.dataset.skill);
                    if (s) openPanel(s);
                }));
            }
            window.openTilePanel = openTilePanel;

            // === Quality section =========================================================
            (() => {
                const container = document.getElementById('qualityContent');
                if (!$quality || !Array.isArray($quality.per_skill)) {
                    container.innerHTML = '<div class="empty">Quality phase not run for this scan.</div>';
                    document.getElementById('navQualityCount').textContent = '—';
                    return;
                }
                const ps = $quality.per_skill;
                const pt = $quality.per_tile || [];
                const summary = $quality.estate_summary || {};
                const meta = $quality.metadata || {};
                document.getElementById('navQualityCount').textContent = ps.length;

                const verdictColor = { good: 'var(--green)', acceptable: 'var(--blue)', needs_work: 'var(--yellow)', poor: 'var(--red)', unknown: 'var(--fg-subtle)' };

                const verdicts = summary.by_verdict || {};
                const verdictRow = ['good', 'acceptable', 'needs_work', 'poor', 'unknown'].map(v => `
                    <span style="color:${verdictColor[v]}; margin-right:14px;">
                        <strong style="font-variant-numeric:tabular-nums;">${verdicts[v] ?? 0}</strong>
                        <span style="color:var(--fg-subtle); font-size:11px; margin-left:4px;">${v}</span>
                    </span>
                `).join('');

                // Source attribution: how many scores came from where?
                const fromRegistry = meta.skill_count_passthrough || 0;
                const fromReview = meta.skill_count_reviewed || 0;
                const skipped = meta.skill_count_skipped || 0;
                const failed = meta.skill_count_failed || 0;

                // Lowest-scored skills (only those with a numeric review_score)
                const scored = ps.filter(p => Number.isFinite(p.review_score));
                const worst = [...scored].sort((a, b) => a.review_score - b.review_score).slice(0, 12);
                const skillTbody = worst.map(p => {
                    const sevColor = p.review_score < 50 ? 'var(--red)' : p.review_score < 70 ? 'var(--yellow)' : 'var(--green)';
                    const sugCount = ((p.description_judge?.suggestions || []).length) + ((p.content_judge?.suggestions || []).length);
                    const valBadge = p.validation && p.validation.error_count > 0
                        ? `<span class="badge" style="color:var(--red);">${p.validation.error_count} err</span>`
                        : (p.validation && p.validation.warning_count > 0
                            ? `<span class="badge" style="color:var(--yellow);">${p.validation.warning_count} warn</span>`
                            : '');
                    const sourceBadge = p._passthrough
                        ? `<span class="badge" style="color:var(--fg-subtle);">tile</span>`
                        : '';
                    return `<tr data-skill="${escapeHTML(p.skill_id)}">
                        <td class="name-cell"><strong>${escapeHTML(p.name)}</strong> ${sourceBadge}</td>
                        <td class="tile-cell">${escapeHTML(p.repo)}</td>
                        <td><span class="badge" style="color:${verdictColor[p.verdict] || 'var(--fg-muted)'};">${escapeHTML(p.verdict || 'unknown')}</span></td>
                        <td class="num-cell"><span style="color:${sevColor}; font-weight:600;">${p.review_score}</span></td>
                        <td>${valBadge}${sugCount > 0 ? `<span style="color:var(--fg-muted); font-size:11px; margin-left:6px;">${sugCount} suggestion${sugCount === 1 ? '' : 's'}</span>` : ''}</td>
                    </tr>`;
                }).join('');

                // Per-tile rollup
                const tileRows = [...pt].sort((a, b) => (a.score ?? 999) - (b.score ?? 999)).map(t => {
                    const score = t.score;
                    const sevColor = score == null ? 'var(--fg-subtle)' : score < 50 ? 'var(--red)' : score < 70 ? 'var(--yellow)' : 'var(--green)';
                    const sourceLabel = t.score_source === 'registry'
                        ? '<span class="badge" style="color:var(--fg-subtle);">registry</span>'
                        : t.score_source === 'computed_avg'
                            ? '<span class="badge" style="color:var(--fg-subtle);">avg</span>'
                            : '<span class="badge" style="color:var(--fg-subtle);">—</span>';
                    return `<tr data-tile-key="${escapeHTML(t.tile_id)}">
                        <td><strong>${escapeHTML(t.name)}</strong><div style="font-size:11px; color:var(--fg-subtle);">${escapeHTML(t.tile_id)}</div></td>
                        <td><span class="badge">${escapeHTML(t.tier || '—')}</span></td>
                        <td><span class="badge" style="color:${verdictColor[t.verdict] || 'var(--fg-muted)'};">${escapeHTML(t.verdict || 'unknown')}</span></td>
                        <td class="num-cell"><span style="color:${sevColor}; font-weight:600;">${score ?? '—'}</span></td>
                        <td>${sourceLabel}</td>
                        <td class="num-cell mono-cell">${t.skill_count ?? 0}</td>
                    </tr>`;
                }).join('');

                container.innerHTML = `
                    <div style="display:grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px;">
                        <div class="repo-card" style="margin-bottom:0;">
                            <div style="font-size:11px; color:var(--fg-subtle); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px;">Estate</div>
                            <div style="font-size:28px; font-weight:600; line-height:1;">${summary.avg_review_score ?? '—'}<span style="font-size:14px; color:var(--fg-subtle); margin-left:4px;">avg / 100</span></div>
                            <div style="margin-top:12px;">${verdictRow}</div>
                            <div style="margin-top:12px; font-size:11px; color:var(--fg-subtle);">${summary.skills_with_validation_failures ?? 0} skill${summary.skills_with_validation_failures === 1 ? '' : 's'} have validation failures</div>
                        </div>
                        <div class="repo-card" style="margin-bottom:0;">
                            <div style="font-size:11px; color:var(--fg-subtle); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px;">Source attribution</div>
                            <div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap:14px; margin-top:8px;">
                                <div><strong style="font-size:18px;">${fromReview}</strong><div style="font-size:11px; color:var(--fg-subtle);">reviewed</div></div>
                                <div><strong style="font-size:18px;">${fromRegistry}</strong><div style="font-size:11px; color:var(--fg-subtle);">passthrough</div></div>
                                <div><strong style="font-size:18px; color:${failed ? 'var(--red)' : 'var(--fg)'};">${failed}</strong><div style="font-size:11px; color:var(--fg-subtle);">failed${skipped ? ` · ${skipped} skipped` : ''}</div></div>
                            </div>
                            <div style="margin-top:10px; font-size:11px; color:var(--fg-subtle);">${summary.tiles_with_registry_score ?? 0} tile${summary.tiles_with_registry_score === 1 ? '' : 's'} scored from registry · ${summary.tiles_with_computed_avg ?? 0} from per-skill avg</div>
                        </div>
                    </div>

                    <div style="font-size:13px; color:var(--fg-subtle); text-transform:uppercase; letter-spacing:.05em; margin-bottom:8px;">Per tile</div>
                    <div class="table-wrapper" style="margin-bottom:24px;">
                        <table class="data-table">
                            <thead><tr>
                                <th>Tile</th><th>Tier</th><th>Verdict</th><th class="num-cell">Score</th><th>Source</th><th class="num-cell">Skills</th>
                            </tr></thead>
                            <tbody>${tileRows || '<tr><td colspan="6" class="empty">No tiles scored.</td></tr>'}</tbody>
                        </table>
                    </div>

                    <div style="font-size:13px; color:var(--fg-subtle); text-transform:uppercase; letter-spacing:.05em; margin-bottom:8px;">Lowest-scored skills</div>
                    <div class="table-wrapper">
                        <table class="data-table">
                            <thead><tr>
                                <th>Skill</th><th>Repo</th><th>Verdict</th><th class="num-cell">Score</th><th>Notes</th>
                            </tr></thead>
                            <tbody>${skillTbody || '<tr><td colspan="5" class="empty">No quality data.</td></tr>'}</tbody>
                        </table>
                    </div>
                `;

                container.querySelectorAll('tr[data-skill]').forEach(tr => tr.addEventListener('click', () => {
                    const s = skills.find(x => x.skill_id === tr.dataset.skill);
                    if (s) openPanel(s);
                }));
                container.querySelectorAll('tr[data-tile-key]').forEach(tr => tr.addEventListener('click', () => {
                    const t = tileById.get(tr.dataset.tileKey);
                    if (t) openTilePanel(t);
                }));
            })();

            // === Staleness section =======================================================
            (() => {
                const container = document.getElementById('stalenessContent');
                if (!$staleness || !Array.isArray($staleness.per_skill)) {
                    container.innerHTML = '<div class="empty">Staleness phase not run for this scan.</div>';
                    document.getElementById('navStalenessCount').textContent = '—';
                    return;
                }
                const ps = $staleness.per_skill;
                const summary = $staleness.estate_summary || {};
                const buckets = summary.buckets || {};
                const stale = (buckets.stale || 0) + (buckets.ancient || 0);
                document.getElementById('navStalenessCount').textContent = stale;

                const bucketColor = { fresh: 'var(--green)', warm: 'var(--blue)', stale: 'var(--yellow)', ancient: 'var(--red)', unknown: 'var(--fg-subtle)' };
                const bucketRow = ['fresh', 'warm', 'stale', 'ancient', 'unknown'].map(b => `
                    <span style="color:${bucketColor[b]}; margin-right:14px;">
                        <strong style="font-variant-numeric:tabular-nums;">${buckets[b] ?? 0}</strong>
                        <span style="color:var(--fg-subtle); font-size:11px; margin-left:4px;">${b}</span>
                    </span>
                `).join('');

                // Worst offenders sorted by score desc
                const offenders = (summary.top_offenders || []).slice(0, 10);
                const offTbody = offenders.map(o => {
                    const skill = skills.find(s => s.skill_id === o.skill_id);
                    const skillName = skill ? skill.name : o.skill_id;
                    const repo = skill ? skill.repo : '—';
                    const sevColor = o.staleness_score >= 70 ? 'var(--red)' : o.staleness_score >= 40 ? 'var(--yellow)' : 'var(--fg-muted)';
                    return `<tr data-skill="${escapeHTML(o.skill_id)}">
                        <td class="name-cell"><strong>${escapeHTML(skillName)}</strong></td>
                        <td class="tile-cell">${escapeHTML(repo)}</td>
                        <td class="num-cell"><span style="color:${sevColor}; font-weight:600;">${o.staleness_score}</span></td>
                        <td>${escapeHTML(o.reason || '')}</td>
                    </tr>`;
                }).join('');

                // Skills with broken refs
                const withBroken = ps.filter(p => p.broken_references && p.broken_references.length > 0)
                    .sort((a, b) => b.broken_references.length - a.broken_references.length)
                    .slice(0, 8);
                const brokenList = withBroken.map(p => {
                    const refs = (p.broken_references || []).slice(0, 5).map(r => `<li>${escapeHTML(r.target)}</li>`).join('');
                    return `<div class="repo-card" style="margin-bottom:8px;">
                        <div class="repo-card-name" style="cursor:pointer;" data-skill="${escapeHTML(p.skill_id)}">${escapeHTML(p.skill_id)}</div>
                        <ul class="path-list" style="margin-top:8px;">${refs}</ul>
                        ${p.broken_references.length > 5 ? `<div style="font-size:11px; color:var(--fg-subtle); margin-top:6px;">+ ${p.broken_references.length - 5} more</div>` : ''}
                    </div>`;
                }).join('');

                container.innerHTML = `
                    <div style="display:grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px;">
                        <div class="repo-card" style="margin-bottom:0;">
                            <div style="font-size:11px; color:var(--fg-subtle); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px;">Estate</div>
                            <div style="font-size:28px; font-weight:600; line-height:1;">${summary.median_days_since_modified ?? '—'}<span style="font-size:14px; color:var(--fg-subtle); margin-left:4px;">d median age</span></div>
                            <div style="margin-top:12px;">${bucketRow}</div>
                            ${summary.skills_with_broken_refs > 0 ? `<div style="margin-top:12px; font-size:12px; color:var(--orange);">${summary.skills_with_broken_refs} skills with broken references</div>` : ''}
                        </div>
                        <div class="repo-card" style="margin-bottom:0;">
                            <div style="font-size:11px; color:var(--fg-subtle); text-transform:uppercase; letter-spacing:.06em; margin-bottom:12px;">Top offenders</div>
                            ${offTbody ? `<table class="data-table"><tbody>${offTbody.replace(/<th[^>]*>.*?<\/th>/g, '')}</tbody></table>` : '<div class="empty" style="padding:20px;">No high-staleness skills.</div>'}
                        </div>
                    </div>

                    ${withBroken.length ? `
                    <div style="font-size:13px; color:var(--fg-subtle); text-transform:uppercase; letter-spacing:.05em; margin-bottom:8px;">Skills with broken references</div>
                    ${brokenList}
                    ` : ''}
                `;

                container.querySelectorAll('[data-skill]').forEach(el => el.addEventListener('click', () => {
                    const s = skills.find(x => x.skill_id === el.dataset.skill);
                    if (s) openPanel(s);
                }));
            })();

            // === Duplicates section ======================================================
            (() => {
                const container = document.getElementById('duplicatesContent');
                if (!$duplicates) {
                    container.innerHTML = '<div class="empty">Duplicates phase not run for this scan.</div>';
                    document.getElementById('navDuplicatesCount').textContent = '—';
                    return;
                }
                const clusters = Array.isArray($duplicates.clusters) ? $duplicates.clusters : [];
                const overlapping = Array.isArray($duplicates.overlapping_pairs) ? $duplicates.overlapping_pairs : [];
                const summary = $duplicates.estate_summary || {};
                document.getElementById('navDuplicatesCount').textContent = clusters.length;

                const sevColor = { critical: 'var(--red)', high: 'var(--orange)', medium: 'var(--yellow)', low: 'var(--fg-muted)' };

                const clusterCards = clusters.map(c => {
                    const memberRows = c.skill_ids.map(sid => {
                        const skill = skills.find(s => s.skill_id === sid);
                        const isDom = sid === c.dominant_skill_id;
                        return `<li><span class="sf-path" style="color:${isDom ? 'var(--green)' : 'var(--fg-muted)'};" data-skill="${escapeHTML(sid)}">${isDom ? '★ ' : ''}${escapeHTML(skill?.name || sid)}</span><span style="color:var(--fg-subtle); font-size:11px; margin-left:6px;">${escapeHTML(skill ? repoLabel(skill) : '')}</span></li>`;
                    }).join('');
                    return `<div class="repo-card">
                        <div class="repo-card-header">
                            <div class="repo-card-name">${escapeHTML(c.cluster_id)} · ${c.skill_ids.length} skills</div>
                            <span class="badge" style="color:${sevColor[c.severity] || 'var(--fg-muted)'};">${escapeHTML(c.severity || '')}</span>
                        </div>
                        <div class="repo-card-meta" style="font-size:13px; color:var(--fg-muted);">${escapeHTML(c.reason || '')}</div>
                        <ul class="path-list" style="margin-top:10px;">${memberRows}</ul>
                    </div>`;
                }).join('');

                const overlappingRows = overlapping.slice(0, 10).map(o => {
                    const a = skills.find(s => s.skill_id === o.skill_a);
                    const b = skills.find(s => s.skill_id === o.skill_b);
                    return `<tr>
                        <td class="name-cell" style="cursor:pointer;" data-skill="${escapeHTML(o.skill_a)}"><strong>${escapeHTML(a?.name || o.skill_a)}</strong></td>
                        <td class="name-cell" style="cursor:pointer;" data-skill="${escapeHTML(o.skill_b)}"><strong>${escapeHTML(b?.name || o.skill_b)}</strong></td>
                        <td><span class="badge" style="color:${sevColor[o.severity] || 'var(--fg-muted)'};">${escapeHTML(o.severity || '')}</span></td>
                        <td class="num-cell">${o.similarity_score?.toFixed(2) ?? '—'}</td>
                        <td>${escapeHTML(o.reason || '')}</td>
                    </tr>`;
                }).join('');

                container.innerHTML = `
                    <div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 24px;">
                        <div class="stat-item"><div class="stat-value">${summary.duplicate_clusters ?? 0}</div><div class="stat-label">Clusters</div></div>
                        <div class="stat-item"><div class="stat-value">${summary.skills_in_clusters ?? 0}</div><div class="stat-label">Skills in clusters</div></div>
                        <div class="stat-item"><div class="stat-value">${summary.estimated_skill_reduction_potential ?? 0}</div><div class="stat-label">Reduction potential</div></div>
                    </div>

                    ${clusters.length ? `
                        <div style="font-size:13px; color:var(--fg-subtle); text-transform:uppercase; letter-spacing:.05em; margin-bottom:8px;">Duplicate clusters</div>
                        ${clusterCards}
                    ` : '<div class="empty">No duplicate clusters confirmed.</div>'}

                    ${overlapping.length ? `
                        <div style="font-size:13px; color:var(--fg-subtle); text-transform:uppercase; letter-spacing:.05em; margin: 28px 0 8px;">Overlapping pairs</div>
                        <div class="section-description" style="margin-bottom:12px;">Pairs that share meaningful scope but are not full duplicates. Worth refactoring.</div>
                        <div class="table-wrapper">
                            <table class="data-table">
                                <thead><tr>
                                    <th>Skill A</th><th>Skill B</th><th>Severity</th><th class="num-cell">Similarity</th><th>Reason</th>
                                </tr></thead>
                                <tbody>${overlappingRows}</tbody>
                            </table>
                        </div>
                    ` : ''}
                `;

                container.querySelectorAll('[data-skill]').forEach(el => el.addEventListener('click', () => {
                    const s = skills.find(x => x.skill_id === el.dataset.skill);
                    if (s) openPanel(s);
                }));
            })();

            // === Registry suggestions section ============================================
            (() => {
                const container = document.getElementById('registrySuggestionsContent');
                const navCount = document.getElementById('navRegistrySuggestionsCount');

                if (!$registrySearch) {
                    container.innerHTML = '<div class="empty">Registry-search phase not run for this scan.</div>';
                    navCount.textContent = '—';
                    return;
                }

                const matches = Array.isArray($registrySearch.matches) ? $registrySearch.matches : [];
                const stats = $registrySearch.stats || {};
                const phaseWarnings = Array.isArray($registrySearch.warnings) ? $registrySearch.warnings : [];

                const totalSuggestions = matches.filter(m => m.best_match).length;
                navCount.textContent = totalSuggestions;

                if (!matches.length) {
                    const empty = phaseWarnings.length
                        ? `<div class="empty">${phaseWarnings.map(escapeHTML).join('<br>')}</div>`
                        : '<div class="empty">No skills to suggest replacements for — every discovered skill is either declared in a tessl.json or owned by a published tile.</div>';
                    container.innerHTML = empty;
                    return;
                }

                const fmtScore = (n) => (typeof n === 'number' ? n.toFixed(2) : '—');
                const fmtSecurity = (s) => s || '—';
                const securityColor = {
                    LOW: 'var(--green)',
                    MEDIUM: 'var(--yellow)',
                    HIGH: 'var(--orange)',
                    CRITICAL: 'var(--red)',
                };

                const renderMatchCell = (match) => {
                    if (!match) return '<td colspan="2"><span style="color:var(--fg-subtle);">no match</span></td>';
                    const scores = match.scores || {};
                    const isSkill = match.kind === 'skill';
                    const label = isSkill
                        ? escapeHTML(match.name || '')
                        : escapeHTML(match.full_name || match.name || '');
                    const description = isSkill
                        ? escapeHTML(truncate(match.description || '', 120))
                        : escapeHTML(truncate(match.describes || (match.latest_version && match.latest_version.summary) || '', 120));
                    const kindBadge = `<span class="badge" style="margin-right:6px; color:${isSkill ? 'var(--blue, var(--fg-muted))' : 'var(--purple, var(--fg-muted))'};">${isSkill ? 'skill' : 'tile'}</span>`;
                    const sec = fmtSecurity(scores.security);
                    const secStyle = scores.security ? `color:${securityColor[scores.security] || 'var(--fg-muted)'};` : '';
                    return `
                        <td class="name-cell">
                            <div>${kindBadge}<strong>${label}</strong></div>
                            <div style="font-size:11px; color:var(--fg-muted); margin-top:2px;">${description}</div>
                        </td>
                        <td class="num-cell" style="white-space:nowrap;">
                            <div>agg <strong>${fmtScore(scores.aggregate)}</strong></div>
                            <div style="font-size:11px; color:var(--fg-muted);">eval ${fmtScore(scores.evalAvg)} · qual ${fmtScore(scores.quality)}</div>
                            <div style="font-size:11px; ${secStyle}">sec ${escapeHTML(sec)}</div>
                        </td>
                    `;
                };

                const sortedMatches = matches.slice().sort((a, b) => {
                    const aBest = a.best_match?.scores?.aggregate ?? -1;
                    const bBest = b.best_match?.scores?.aggregate ?? -1;
                    return bBest - aBest;
                });

                const rows = sortedMatches.map(m => {
                    const sourceSkill = skillById.get(m.source_skill_id);
                    const sourceCell = sourceSkill
                        ? `<td class="name-cell" style="cursor:pointer;" data-skill="${escapeHTML(m.source_skill_id)}">
                                <strong>${escapeHTML(sourceSkill.name || m.source_skill_name)}</strong>
                                <div style="font-size:11px; color:var(--fg-muted);">${escapeHTML(m.source_tile_name || repoLabel(sourceSkill))}</div>
                            </td>`
                        : `<td class="name-cell">
                                <strong>${escapeHTML(m.source_skill_name || m.source_skill_id)}</strong>
                                <div style="font-size:11px; color:var(--fg-muted);">${escapeHTML(m.source_tile_name || '')}</div>
                            </td>`;
                    const errorNote = (m.search_errors || []).length
                        ? `<div style="font-size:11px; color:var(--orange);">${escapeHTML(m.search_errors.map(e => `${e.target}: ${e.message}`).join('; '))}</div>`
                        : '';
                    return `<tr>
                        ${sourceCell}
                        ${renderMatchCell(m.best_match)}
                        <td>${errorNote || '<span style="color:var(--fg-subtle);">—</span>'}</td>
                    </tr>`;
                }).join('');

                const phaseWarningsBlock = phaseWarnings.length
                    ? `<div class="empty" style="margin-bottom:16px;">${phaseWarnings.map(escapeHTML).join('<br>')}</div>`
                    : '';

                const matchKinds = stats.match_kinds || { skill: 0, tile: 0 };
                container.innerHTML = `
                    ${phaseWarningsBlock}
                    <div style="display:grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px;">
                        <div class="stat-item"><div class="stat-value">${stats.total_skills_searched ?? 0}</div><div class="stat-label">Skills searched</div></div>
                        <div class="stat-item"><div class="stat-value">${stats.skills_with_match ?? 0}</div><div class="stat-label">With match</div></div>
                        <div class="stat-item"><div class="stat-value">${matchKinds.skill ?? 0} / ${matchKinds.tile ?? 0}</div><div class="stat-label">skill / tile</div></div>
                        <div class="stat-item"><div class="stat-value">${stats.skills_with_no_match ?? 0}</div><div class="stat-label">No match</div></div>
                    </div>
                    <div class="table-wrapper">
                        <table class="data-table">
                            <thead><tr>
                                <th>Source skill</th>
                                <th>Best match</th>
                                <th class="num-cell">Scores</th>
                                <th>Notes</th>
                            </tr></thead>
                            <tbody>${rows}</tbody>
                        </table>
                    </div>
                `;

                container.querySelectorAll('[data-skill]').forEach(el => el.addEventListener('click', () => {
                    const s = skills.find(x => x.skill_id === el.dataset.skill);
                    if (s) openPanel(s);
                }));
            })();

            // === Skills table ============================================================
            const SOURCE_TYPES = ['tessl_tile_skill', 'claude_skill', 'agents_skill', 'cursor_skill', 'claude_plugin_skill', 'standalone'];
            const SOURCE_LABEL = {
                claude_skill: 'Claude',
                agents_skill: 'Agents',
                cursor_skill: 'Cursor',
                tessl_tile_skill: 'Tessl tile',
                claude_plugin_skill: 'Claude plugin',
                standalone: 'Standalone',
            };

            let filters = { source_type: null, search: '' };
            let sort = { key: 'name', asc: true };

            // Source-type filter chips
            (() => {
                const container = document.getElementById('sourceTypeFilters');
                const byType = {};
                skills.forEach(s => { byType[s.source_type] = (byType[s.source_type] || 0) + 1; });
                const chips = SOURCE_TYPES.filter(t => byType[t]).map(t =>
                    `<button class="filter-chip" data-source="${t}">${SOURCE_LABEL[t]} <span class="badge-count">${byType[t]}</span></button>`
                );
                container.innerHTML = chips.join('');
                container.querySelectorAll('.filter-chip').forEach(el => {
                    el.addEventListener('click', () => {
                        const t = el.dataset.source;
                        filters.source_type = filters.source_type === t ? null : t;
                        container.querySelectorAll('.filter-chip').forEach(c => c.classList.toggle('active', c.dataset.source === filters.source_type));
                        renderSkillsTable();
                    });
                });
            })();

            function getSortKey(s, k) {
                if (k === 'name') return (s.name || '').toLowerCase();
                if (k === 'repo') return s.repo || '';
                if (k === 'tile') return s._tile?.display_name || s._tile?.name || 'zzzz';
                if (k === 'source_type') return s.source_type || 'zzzz';
                if (k === 'harnesses') return (s.agent_harnesses || []).join(',');
                if (k === 'paths') return (s.all_paths || []).length;
                if (k === 'files') return (s.supporting_files || []).length;
                if (k === 'lines') return s.content?.line_count ?? 0;
                if (k === 'quality') return qualityById.get(s.skill_id)?.review_score ?? -1;
                if (k === 'staleness') return stalenessById.get(s.skill_id)?.staleness_score ?? -1;
                if (k === 'dup') return dupClusterByIdSkill.has(s.skill_id) ? 1 : 0;
                return s.name || '';
            }

            function renderSkillsTable() {
                const search = filters.search.toLowerCase();
                let list = skills.filter(s => {
                    if (filters.source_type && s.source_type !== filters.source_type) return false;
                    if (!search) return true;
                    return (s.name || '').toLowerCase().includes(search) ||
                           (s.description || '').toLowerCase().includes(search) ||
                           (s.repo || '').toLowerCase().includes(search) ||
                           repoLabel(s).toLowerCase().includes(search) ||
                           (s.owning_package?.name || '').toLowerCase().includes(search);
                });

                const dir = sort.asc ? 1 : -1;
                list.sort((a, b) => {
                    const av = getSortKey(a, sort.key), bv = getSortKey(b, sort.key);
                    if (av < bv) return -1 * dir;
                    if (av > bv) return 1 * dir;
                    return 0;
                });

                document.querySelectorAll('#skillsTable th').forEach(th => {
                    th.classList.remove('sorted', 'asc');
                    if (th.dataset.sort === sort.key) { th.classList.add('sorted'); if (sort.asc) th.classList.add('asc'); }
                });

                const tbody = document.getElementById('skillsTbody');
                if (!list.length) {
                    tbody.innerHTML = '<tr><td colspan="8" class="empty">No skills match the current filters.</td></tr>';
                    return;
                }

                const colorForScore = (n, kind) => {
                    if (n == null || n < 0) return 'var(--fg-subtle)';
                    if (kind === 'quality') {
                        return n >= 85 ? 'var(--green)' : n >= 70 ? 'var(--blue)' : n >= 50 ? 'var(--yellow)' : 'var(--red)';
                    }
                    return n >= 70 ? 'var(--red)' : n >= 40 ? 'var(--yellow)' : n >= 20 ? 'var(--blue)' : 'var(--green)';
                };

                tbody.innerHTML = list.map((s, i) => {
                    const qual = qualityById.get(s.skill_id);
                    const stale = stalenessById.get(s.skill_id);
                    const dup = dupClusterByIdSkill.get(s.skill_id);
                    const qualScore = qual?.review_score;
                    const qualCell = (qualScore != null)
                        ? `<span style="color:${colorForScore(qualScore, 'quality')}; font-weight:600;">${qualScore}</span>${qual._passthrough ? '<div style="font-size:10px; color:var(--fg-subtle);">tile</div>' : ''}`
                        : `<span style="color:var(--fg-subtle)">—</span>${qual?._status ? `<div style="font-size:10px; color:${qual._status === 'failed' ? 'var(--red)' : 'var(--fg-subtle)'};">${escapeHTML(qual._status.replaceAll('_', ' '))}</div>` : ''}`;
                    const staleCell = stale ? `<span style="color:${colorForScore(stale.staleness_score, 'staleness')}; font-weight:600;">${stale.staleness_score}</span>` : '<span style="color:var(--fg-subtle)">—</span>';
                    const dupCell = dup ? `<span class="badge" style="color:var(--purple);">${escapeHTML(dup.cluster_id)}</span>` : '<span style="color:var(--fg-subtle)">—</span>';
                    const tileCell = s._tile
                        ? `<span class="tile-cell" style="cursor:pointer;" data-tile-key="${escapeHTML(s._tile.tile_id)}">${escapeHTML(s._tile.display_name || s._tile.name)}</span>${s._tile.version ? `<div style="font-size:10px; color:var(--fg-subtle);">v${escapeHTML(s._tile.version)}</div>` : ''}`
                        : '<span style="color:var(--fg-subtle)">—</span>';
                    return `<tr data-idx="${skills.indexOf(s)}">
                        <td>
                            <div style="font-weight:500;">${escapeHTML(s.name || '')}</div>
                            <div style="font-size:12px; color:var(--fg-subtle); margin-top:2px;">${escapeHTML(truncate(s.description || '', 110))}</div>
                        </td>
                        <td>${tileCell}</td>
                        <td class="tile-cell">${escapeHTML(repoLabel(s))}</td>
                        <td><span class="badge source-${escapeHTML(s.source_type || 'standalone')}">${escapeHTML(SOURCE_LABEL[s.source_type] || s.source_type || '')}</span></td>
                        <td class="num-cell mono-cell">${qualCell}</td>
                        <td class="num-cell mono-cell">${staleCell}</td>
                        <td>${dupCell}</td>
                        <td class="num-cell mono-cell">${(s.all_paths || []).length}</td>
                    </tr>`;
                }).join('');

                // Make tile cells clickable
                tbody.querySelectorAll('[data-tile-key]').forEach(el => el.addEventListener('click', (e) => {
                    e.stopPropagation();
                    const t = tileById.get(el.dataset.tileKey);
                    if (t) openTilePanel(t);
                }));

                tbody.querySelectorAll('tr[data-idx]').forEach(tr => {
                    tr.addEventListener('click', () => openPanel(skills[Number(tr.dataset.idx)]));
                });
            }

            document.querySelectorAll('#skillsTable th').forEach(th => th.addEventListener('click', () => {
                const k = th.dataset.sort;
                if (sort.key === k) sort.asc = !sort.asc;
                else { sort.key = k; sort.asc = true; }
                renderSkillsTable();
            }));

            function applySkillFilters() {
                filters.search = document.getElementById('skillSearch').value;
                renderSkillsTable();
            }

            renderSkillsTable();

            // === Repos ===================================================================
            (() => {
                const list = document.getElementById('reposList');
                if (!repos.length) { list.innerHTML = '<div class="empty">No repos detected.</div>'; return; }

                const skillsByRepo = {};
                skills.forEach(s => { skillsByRepo[s.repo] = (skillsByRepo[s.repo] || 0) + 1; });

                list.innerHTML = repos.map(r => {
                    const n = skillsByRepo[r.repo_id] || 0;
                    return `<div class="repo-card">
                        <div class="repo-card-header">
                            <div>
                                <div class="repo-card-name">${escapeHTML(r.name || r.repo_id)}</div>
                                ${r.remote_url ? `<div class="repo-card-remote">${escapeHTML(r.remote_url)}</div>` : ''}
                            </div>
                            <div style="font-size:13px; color: var(--fg-muted);">${n} skill${n === 1 ? '' : 's'}</div>
                        </div>
                        <div class="repo-card-meta">
                            <span><strong>Path</strong>${escapeHTML(r.path || '')}</span>
                            ${r.head_branch ? `<span><strong>Branch</strong>${escapeHTML(r.head_branch)}</span>` : ''}
                            ${r.head_sha ? `<span><strong>HEAD</strong><code>${escapeHTML(r.head_sha.slice(0, 7))}</code></span>` : ''}
                            ${r.is_git_repo === false ? '<span style="color:var(--yellow)">not a git repo</span>' : ''}
                        </div>
                    </div>`;
                }).join('');
            })();

            // === Breakdown ===============================================================
            (() => {
                const content = document.getElementById('breakdownContent');
                const total = skills.length;
                if (total === 0) { content.innerHTML = '<div class="empty">No skills to break down.</div>'; return; }

                function bars(label, data, colorClass) {
                    const max = Math.max(...Object.values(data), 1);
                    const rows = Object.entries(data)
                        .filter(([, v]) => v > 0)
                        .sort((a, b) => b[1] - a[1])
                        .map(([k, v]) => `
                            <div class="bar-row">
                                <div class="bar-label">${escapeHTML(SOURCE_LABEL[k] || k)}</div>
                                <div class="bar-container"><div class="bar-fill ${colorClass || ''}" style="width:${(v / max * 100).toFixed(1)}%"></div></div>
                                <div class="bar-count">${v}</div>
                            </div>
                        `).join('');
                    return `<div style="margin-bottom: 28px;"><div style="font-size:12px; color:var(--fg-subtle); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:12px;">${escapeHTML(label)}</div>${rows}</div>`;
                }

                // Source type
                const sourceCounts = stats.by_source_type || {};
                // Harnesses (re-compute from skills[])
                const harnessCounts = {};
                skills.forEach(s => (s.agent_harnesses || []).forEach(h => { harnessCounts[h] = (harnessCounts[h] || 0) + 1; }));
                // Repo — relabel keys with the friendly repo name (git origin) when available
                const repoCounts = Object.fromEntries(
                    Object.entries(stats.by_repo || {}).map(([k, v]) => [repoNameById.get(k) || k, v])
                );

                let html = '';
                html += bars('Skills by source type', sourceCounts, 'purple');
                html += bars('Skills by agent harness', harnessCounts, 'green');
                if (repos.length > 1) html += bars('Skills by repo', repoCounts);
                content.innerHTML = html || '<div class="empty">No breakdown data.</div>';
            })();

            // === Warnings ================================================================
            (() => {
                const list = document.getElementById('warningsList');
                if (!warnings.length) { list.innerHTML = '<div class="empty">No warnings — clean scan.</div>'; return; }
                list.innerHTML = '<div class="warnings-list">' + warnings.map(w => `<div class="warning-item">${escapeHTML(w)}</div>`).join('') + '</div>';
            })();

            // === Sidebar counts + nav active state =======================================
            document.getElementById('navSkillsCount').textContent = skills.length;
            document.getElementById('navReposCount').textContent = repos.length;
            document.getElementById('navWarningsCount').textContent = warnings.length;

            function scrollToSection(id) {
                const el = document.getElementById(id);
                if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
                document.querySelectorAll('.sidebar-nav-item').forEach(a => a.classList.toggle('active', a.dataset.section === id));
            }
            window.scrollToSection = scrollToSection;

            const io = new IntersectionObserver((entries) => {
                entries.forEach(e => {
                    if (e.isIntersecting) {
                        document.querySelectorAll('.sidebar-nav-item').forEach(a => a.classList.toggle('active', a.dataset.section === e.target.id));
                    }
                });
            }, { rootMargin: '-20% 0px -75% 0px' });
            ['section-overview', 'section-top-issues', 'section-recent', 'section-tiles', 'section-manifests', 'section-skills', 'section-repos', 'section-quality', 'section-staleness', 'section-duplicates', 'section-registry-suggestions', 'section-breakdown', 'section-warnings', 'section-methodology']
                .forEach(id => { const el = document.getElementById(id); if (el) io.observe(el); });

            // === Drill-down panel ========================================================
            function openPanel(s) {
                const shell = document.getElementById('shell');
                const inner = document.getElementById('panelInner');
                const pkg = s.owning_package;
                const fm = s.frontmatter?.raw;
                const stale = stalenessById.get(s.skill_id);
                const qual = qualityById.get(s.skill_id);
                const dupCluster = dupClusterByIdSkill.get(s.skill_id);

                const supportingFiles = (s.supporting_files || []).map(sf => `
                    <li>
                        <span class="sf-kind">${escapeHTML(sf.kind || 'other')}</span>
                        <span class="sf-path">${escapeHTML(sf.path || '')}</span>
                        <span style="float:right; color:var(--fg-subtle); font-size:11px;">${humanBytes(sf.size_bytes)}</span>
                    </li>
                `).join('');

                const bundledDirs = (s.bundled_directories || []).map(bd => `
                    <li><span class="sf-kind">bundle</span><span class="sf-path">${escapeHTML(bd.path || '')}</span>
                    <span style="float:right; color:var(--fg-subtle); font-size:11px;">${bd.file_count} file${bd.file_count === 1 ? '' : 's'}</span></li>
                `).join('');

                inner.innerHTML = `
                    <button class="panel-close" onclick="closePanel()">×</button>
                    <div class="panel-title">${escapeHTML(s.name || '')}</div>
                    <div class="panel-subtitle">${escapeHTML(s.skill_id || '')}</div>

                    ${s.description ? `<div class="panel-section"><p>${escapeHTML(s.description)}</p></div>` : ''}

                    <div class="panel-section">
                        <div class="panel-section-label">Metadata</div>
                        <dl class="kv-grid">
                            <dt>Repo</dt><dd>${escapeHTML(repoLabel(s))}</dd>
                            <dt>Source type</dt><dd><span class="badge source-${escapeHTML(s.source_type || '')}">${escapeHTML(SOURCE_LABEL[s.source_type] || s.source_type || '')}</span></dd>
                            <dt>Harnesses</dt><dd class="chip-row">${(s.agent_harnesses || []).map(h => `<span class="badge">${escapeHTML(h)}</span>`).join('') || '—'}</dd>
                            ${pkg?.name ? `<dt>Package</dt><dd>${escapeHTML(pkg.kind || '')}: <code>${escapeHTML(pkg.name)}</code>${pkg.version ? ' @ ' + escapeHTML(pkg.version) : ''}</dd>` : ''}
                            ${pkg?.manifest_path ? `<dt>Manifest</dt><dd class="mono">${escapeHTML(pkg.manifest_path)}</dd>` : ''}
                            <dt>Content hash</dt><dd class="mono" style="font-size:11px;">${escapeHTML((s.content_hash || '').slice(0, 24))}…</dd>
                            <dt>Lines / words</dt><dd>${s.content?.line_count ?? '—'} / ${s.content?.word_count ?? '—'}</dd>
                            <dt>Has scripts/</dt><dd>${s.content?.has_scripts_dir ? '✓' : '—'}</dd>
                            <dt>Has references/</dt><dd>${s.content?.has_references_dir ? '✓' : '—'}</dd>
                        </dl>
                    </div>

                    ${(s.declared_in || []).length ? `<div class="panel-section">
                        <div class="panel-section-label">Declared in (${s.declared_in.length})</div>
                        <ul class="supporting-files-list">${s.declared_in.map(d => `
                            <li>
                                <span class="sf-kind">${escapeHTML(d.dep_key)}${d.version ? ' @ ' + escapeHTML(d.version) : ''}</span>
                                <span class="sf-path">${escapeHTML(d.manifest_path)}</span>
                            </li>`).join('')}</ul>
                    </div>` : ''}

                    <div class="panel-section">
                        <div class="panel-section-label">Paths (${(s.all_paths || []).length})</div>
                        <ul class="path-list">${(s.all_paths || []).map(p => `<li>${escapeHTML(p)}</li>`).join('')}</ul>
                    </div>

                    ${fm ? `<div class="panel-section">
                        <div class="panel-section-label">Frontmatter</div>
                        <div class="body-preview">${escapeHTML(JSON.stringify(fm, null, 2))}</div>
                    </div>` : ''}

                    ${s.content?.body_preview ? `<div class="panel-section">
                        <div class="panel-section-label">Body preview</div>
                        <div class="body-preview">${escapeHTML(s.content.body_preview)}</div>
                    </div>` : ''}

                    ${supportingFiles ? `<div class="panel-section">
                        <div class="panel-section-label">Supporting files (${(s.supporting_files || []).length})</div>
                        <ul class="supporting-files-list">${supportingFiles}</ul>
                    </div>` : ''}

                    ${bundledDirs ? `<div class="panel-section">
                        <div class="panel-section-label">Bundled directories</div>
                        <ul class="supporting-files-list">${bundledDirs}</ul>
                    </div>` : ''}

                    ${qual ? (() => {
                        const desc = qual.description_judge || {};
                        const cont = qual.content_judge || {};
                        const valid = qual.validation;
                        const dimRow = (label, scores) => Object.keys(scores || {}).length
                            ? `<dl class="kv-grid">${Object.entries(scores).map(([k, v]) => `<dt>${escapeHTML(k)}</dt><dd>${v.score}/3 <span style="color:var(--fg-subtle); font-size:11px;">${escapeHTML(v.reasoning || '').slice(0, 120)}</span></dd>`).join('')}</dl>`
                            : '';
                        const sugList = (sug) => (sug || []).length
                            ? `<ul class="supporting-files-list" style="margin-top:6px;">${sug.map(s => `<li><span style="color:var(--fg-muted);">${escapeHTML(s)}</span></li>`).join('')}</ul>`
                            : '';
                        const passthroughBadge = qual._passthrough
                            ? `<span class="badge" style="margin-left:8px; color:var(--fg-subtle);">tile-level</span>`
                            : '';
                        const validationBlock = valid && (valid.error_count > 0 || valid.warning_count > 0)
                            ? `<div style="margin-top:10px; font-size:12px;"><strong>Validation:</strong> ${valid.error_count} errors, ${valid.warning_count} warnings${(valid.failed_checks || []).length ? `<ul class="supporting-files-list" style="margin-top:6px;">${valid.failed_checks.map(c => `<li><span class="sf-kind" style="color:${c.status === 'error' ? 'var(--red)' : 'var(--yellow)'};">${escapeHTML(c.status)}</span> <span class="sf-path">${escapeHTML(c.name)}</span> <span style="color:var(--fg-muted);">${escapeHTML(c.message || '')}</span></li>`).join('')}</ul>` : ''}</div>`
                            : '';
                        return `<div class="panel-section">
                            <div class="panel-section-label">Quality · ${qual.review_score ?? '—'}/100 · ${escapeHTML(qual.verdict || 'unknown')}${passthroughBadge}</div>
                            ${qual._passthrough ? `<p style="color:var(--fg-subtle); font-size:12px;">Score is the tile-level registry score — per-skill review skipped (--skip-published-skills).</p>` : ''}
                            ${desc.scores ? `<div style="margin-top:8px;"><strong style="font-size:12px;">Description judge</strong> · ${desc.normalized_score != null ? Math.round(desc.normalized_score * 100) + '%' : '—'} <span style="color:var(--fg-subtle); font-size:11px;">${escapeHTML(desc.model || '')}</span>${dimRow('desc', desc.scores)}${sugList(desc.suggestions)}</div>` : ''}
                            ${cont.scores ? `<div style="margin-top:12px;"><strong style="font-size:12px;">Content judge</strong> · ${cont.normalized_score != null ? Math.round(cont.normalized_score * 100) + '%' : '—'}${dimRow('cont', cont.scores)}${sugList(cont.suggestions)}</div>` : ''}
                            ${validationBlock}
                        </div>`;
                    })() : ''}

                    ${stale ? `<div class="panel-section">
                        <div class="panel-section-label">Staleness · ${stale.staleness_score}/100 · ${escapeHTML(stale.staleness_bucket)}</div>
                        <dl class="kv-grid">
                            <dt>Days since modified</dt><dd>${stale.days_since_modified ?? '—'}</dd>
                            <dt>Last modified</dt><dd>${stale.last_modified ? new Date(stale.last_modified).toLocaleDateString() : '—'}</dd>
                            <dt>Commit count</dt><dd>${stale.commit_count}</dd>
                            ${stale.tracked_path ? `<dt>Tracked at</dt><dd class="mono">${escapeHTML(stale.tracked_path)}</dd>` : ''}
                            ${(stale.factors || []).length ? `<dt>Factors</dt><dd>${stale.factors.map(f => `<span class="badge">${escapeHTML(f)}</span>`).join(' ')}</dd>` : ''}
                        </dl>
                        ${(stale.broken_references || []).length ? `<div style="margin-top:8px;">
                            <div style="font-size:11px; color:var(--orange); text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;">Broken references</div>
                            <ul class="path-list">${stale.broken_references.map(r => `<li>${escapeHTML(r.target)} <span style="color:var(--fg-subtle); font-size:10px;">(${escapeHTML(r.kind)})</span></li>`).join('')}</ul>
                        </div>` : ''}
                    </div>` : ''}

                    ${stale && stale.git_provenance ? (() => {
                        const gp = stale.git_provenance;
                        const formatAuthor = (a) => a ? `${escapeHTML(a.name)}<span style="color:var(--fg-subtle); font-size:11px; margin-left:6px;">${escapeHTML(a.email)}</span>` : '—';
                        const contribs = (gp.contributors || []).map(c => `
                            <span class="badge" title="${escapeHTML(c.email)}">${escapeHTML(c.name)} · ${c.commits}</span>
                        `).join('');
                        const commits = (gp.recent_commits || []).map(c => `
                            <div class="commit-row">
                                <span class="commit-sha">${escapeHTML(c.sha)}</span>
                                <span class="commit-date">${c.date ? new Date(c.date).toLocaleDateString() : ''}</span>
                                <span class="commit-subject" title="${escapeHTML(c.author)}: ${escapeHTML(c.subject)}">${escapeHTML(c.author)}: ${escapeHTML(c.subject)}</span>
                            </div>`).join('');
                        return `<div class="panel-section">
                            <div class="panel-section-label">Provenance</div>
                            <dl class="kv-grid">
                                <dt>Created by</dt><dd>${formatAuthor(gp.created_by)}</dd>
                                <dt>Last modified by</dt><dd>${formatAuthor(gp.last_modified_by)}</dd>
                                ${(gp.contributors || []).length ? `<dt>Contributors (${gp.contributors.length})</dt><dd><div class="contributors-list">${contribs}</div></dd>` : ''}
                            </dl>
                            ${commits ? `<div class="provenance-section">
                                <div style="font-size:11px; color:var(--fg-subtle); text-transform:uppercase; letter-spacing:.05em; margin: 10px 0 6px 0;">Recent commits</div>
                                <div class="commit-list">${commits}</div>
                            </div>` : ''}
                        </div>`;
                    })() : ''}

                    ${dupCluster ? `<div class="panel-section">
                        <div class="panel-section-label">In duplicate cluster · ${escapeHTML(dupCluster.cluster_id)} · ${escapeHTML(dupCluster.severity)}</div>
                        <p style="font-size:13px; color:var(--fg-muted); margin-bottom:8px;">${escapeHTML(dupCluster.reason)}</p>
                        <ul class="path-list">${dupCluster.skill_ids.map(sid => sid === dupCluster.dominant_skill_id ? `<li style="color:var(--green);">★ ${escapeHTML(sid)}</li>` : sid === s.skill_id ? `<li style="color:var(--fg);">${escapeHTML(sid)} (this skill)</li>` : `<li>${escapeHTML(sid)}</li>`).join('')}</ul>
                    </div>` : ''}
                `;

                shell.classList.add('panel-open');
                document.getElementById('panel').setAttribute('aria-hidden', 'false');
            }
            // Convenience wrapper used by inline onclick handlers in the top-of-report
            // sections (top issues, recently changed). Looks up the skill by id and
            // delegates to openPanel.
            function openSkillPanelById(skillId) {
                const s = skillById.get(skillId);
                if (s) openPanel(s);
            }
            function closePanel() {
                document.getElementById('shell').classList.remove('panel-open');
                document.getElementById('panel').setAttribute('aria-hidden', 'true');
            }
            window.openPanel = openPanel;
            window.openSkillPanelById = openSkillPanelById;
            window.closePanel = closePanel;
            document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePanel(); });

            // Footer
            document.getElementById('footerMeta').textContent = `Schema v${($data.schema_version || '—')} · ${skills.length} skills`;
        </script>
    </body>
</html>

README.md

tile.json