---
title: Self-Improving Seed Generation · Visual Specification (Index + Run Detail)
geode_version: 0.99.65
schema_version: 1
last_updated: 2026-05-26
parent: self-improving-hub-system.md
applies_to_geode: ">=0.99.65"
sibling_pages:
  - self-improving-seed-generation-index.md
  - self-improving-seed-generation-run.md
  - self-improving-hub-visual-spec.md
---

# Self-Improving Seed Generation · Visual Specification

> Concrete visual contract for two pages:
>
> - `/geode/self-improving/seed-generation/` (runs index)
> - `/geode/self-improving/seed-generation/<run_id>/` (per-run detail)
>
> Resolves every visual ambiguity in `self-improving-seed-generation-index.md`
> and `self-improving-seed-generation-run.md`. The frontend agent renders
> HTML/CSS directly from this doc — no further design decisions required.
>
> This spec **does not invent** new tokens. Heatmap, mini-bar, and mutator
> banner all compose from the existing `assets/hub.css` token table
> (`--ink-faint`, `--rule-soft`, `--bucket-seedgen`, `--bucket-autoresearch`).

## 0. Scope + Authority order

1. **Master DESIGN.md** (`self-improving-hub-system.md`) — Authoritative for tokens, palette, anti-patterns, sidebar contract, versioning policy.
2. **Per-page DESIGN.md** (`self-improving-seed-generation-index.md`, `self-improving-seed-generation-run.md`) — Authoritative for data sources, sections, columns, sidebar `.active`.
3. **Hub visual spec** (`self-improving-hub-visual-spec.md`) — Authoritative for shared markup primitives reused on these pages (sidebar HTML, records table, chip CSS, build-info footer). Both seed-gen pages reuse these primitives unchanged.
4. **This visual spec** — Authoritative for concrete CSS values + HTML markup specific to the two seed-generation pages: runs table column layout, phases legend `<dl>`, cost rollup grid, mutator banner, 22-dim pilot-scores heatmap, coverage mini-bar-chart, reflections dense list, per-run nine-section anatomy, accessibility for heatmap cells.
5. **Real data** (`docs/self-improving/petri-bundle/seeds/listing.json` + `seeds/<run_id>/{state,survivors,meta_review}.json`) — Sole input to the build script. Every column / cell in this spec must map to a JSON path here.

Where this spec and (1)/(2)/(3) diverge, the higher-rank doc wins — flag a fix-up PR.

All rem values resolved against `html { font-size: 16px }`. The two pages reuse `assets/hub.css` *as-is* plus a single appended section `§ Seed Generation` (defined in this spec at §11). No `:root` token additions.

---

## 1. Pages in scope

| URL | Output file | Sidebar `.active` | Generated by |
|---|---|---|---|
| `/geode/self-improving/seed-generation/` | `docs/self-improving/seed-generation/index.html` | `Seed Generation > All runs` | `render_seedgen_index(listing)` |
| `/geode/self-improving/seed-generation/<run_id>/` | `docs/self-improving/seed-generation/<run_id>/index.html` | `Seed Generation > All runs > <run_id>` (nested under All runs) | `render_seedgen_run(state, survivors, meta)` × N runs |

Both pages share the **same sidebar markup** as the hub (`self-improving-hub-visual-spec.md` §3.1) — only the `.active` highlight + `aria-current="page"` location differs. Frontend agent must NOT redesign the sidebar; lift it verbatim and toggle the active link.

---

## 2. Index page anatomy (top-down)

```
┌─────────────────── 1440px viewport ──────────────────────────────────────────┐
│ ┌── aside.sidebar ──┬── main.content ─────────────────────────────────────┐ │
│ │ 260px fixed       │ flex 1, max-width 1100px, pad 32px 40px             │ │
│ │ identical to hub  │                                                      │ │
│ │ .active on:       │ ┌─ h1.page-title  "Seed Generation Runs" ────────┐ │ │
│ │  Seed Gen >       │ │   26px sans 600 --ink                            │ │ │
│ │  All runs         │ └──────────────────────────────────────────────────┘ │ │
│ │                   │ ┌─ p.page-sub ─────────────────────────────────────┐ │ │
│ │                   │ │ "All seed-generation runs, newest first.          │ │ │
│ │                   │ │  Each run drafts N candidates, evolves            │ │ │
│ │                   │ │  survivors, and emits a meta-review for the       │ │ │
│ │                   │ │  next gen. Click a run_id for the full state."    │ │ │
│ │                   │ └──────────────────────────────────────────────────┘ │ │
│ │                   │                                                      │ │
│ │                   │ ── h2.section "RUNS · 1 run" ─────────────────────  │ │
│ │                   │ <table.records> 10 cols                              │ │
│ │                   │                                                      │ │
│ │                   │ ── h2.section "PHASES LEGEND · 8 PHASES" ─────────  │ │
│ │                   │ <dl class="phases-legend"> 8 rows                    │ │
│ │                   │                                                      │ │
│ │                   │ ── h2.section "COST ROLLUP" ─────────────────────── │ │
│ │                   │ <dl class="cost-grid"> 4 rows                        │ │
│ │                   │                                                      │ │
│ │                   │ ─── .build-info footer (identical to hub)            │ │
│ └───────────────────┴──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
```

---

## 3. Index page · Runs table (`<table class="records">`)

### 3.1 Canonical structure (10 columns)

```html
<h2 class="section"><span>Runs · {RUN_COUNT} {RUN_OR_RUNS}</span></h2>
<table class="records">
  <thead>
    <tr>
      <th scope="col">run_id</th>
      <th scope="col">gen</th>
      <th scope="col">target dim</th>
      <th scope="col">mutator</th>
      <th scope="col">draft → surv</th>
      <th scope="col">evolved</th>
      <th scope="col">iter</th>
      <th scope="col">cost</th>
      <th scope="col">phases</th>
      <th scope="col">created</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td class="id">
        <a href="/geode/self-improving/seed-generation/gen1-redundant_tool_invocation/">gen1-redundant_tool_invocation</a>
        <span class="bucket seedgen">seedgen</span>
      </td>
      <td><code>gen1</code></td>
      <td><code>redundant_tool_invocation</code></td>
      <td><span class="chip claude">Claude Code</span> <code>claude-cli/claude-opus-4-7</code></td>
      <td class="num">2 → 2</td>
      <td class="num">1</td>
      <td class="num">0 / 0</td>
      <td class="num">$0.00</td>
      <td class="num">0</td>
      <td class="muted">2026-05-26 05:05</td>
    </tr>
  </tbody>
</table>
```

### 3.2 Column → JSON path map

| # | Column header | JSON path | Render rule |
|---|---|---|---|
| 1 | `run_id` | `runs[].run_id` | `<a href="/geode/self-improving/seed-generation/{run_id}/">{run_id}</a>` + `<span class="bucket seedgen">seedgen</span>` |
| 2 | `gen` | `runs[].gen_tag` | `<code>{gen_tag}</code>` (mono inline) |
| 3 | `target dim` | `runs[].target_dim` | `<code>{target_dim}</code>` |
| 4 | `mutator` | constant `claude-cli/claude-opus-4-7` (v0.99.65 default) — *not* in `listing.json`; build script supplies it from `core/self_improving_loop/runner.py` config | `<span class="chip claude">Claude Code</span> <code>claude-cli/claude-opus-4-7</code>`. When a future run records a per-run mutator override, source it from `state.json.mutator_override` (does not yet exist; fall back to constant). |
| 5 | `draft → surv` | `runs[].candidates_drafted` `→` `runs[].survivors_count` | `<td class="num">{drafted} → {surv}</td>`. The `→` is U+2192 with a single space each side. |
| 6 | `evolved` | `runs[].evolved_count` | `<td class="num">{n}</td>` |
| 7 | `iter` | `runs[].iterations` / `runs[].max_iterations` | `<td class="num">{cur} / {max}</td>` — render `0 / 0` when both are 0 (warm-up runs) |
| 8 | `cost` | `runs[].usd_spent` | `<td class="num">${usd:.2f}</td>`. `$0.00` is acceptable (warm-up runs cost-track to 0). |
| 9 | `phases` | computed: count of `.eval` archives under `logs/` matching `seed-generation/<phase>` for this `run_id`. If the build script cannot resolve (no cross-ref to `logs/listing.json`), render `<td class="num">{state.json :: phase count from non-empty phase keys}</td>` — fall back to `len([k for k in ('reflections','pilot_scores','elo_ratings','survivors','evolved_candidates','meta_review','similarity_clusters','candidates') if state.json[k]])`. Both methods cap at 8 (the pipeline phase count). | `<td class="num">{n}</td>` |
| 10 | `created` | best-effort: `state.json.started_at` if present, else file `mtime` of `state.json` formatted `YYYY-MM-DD HH:MM` UTC | `<td class="muted">{iso_short}</td>` |

### 3.3 Sort order

Newest first by **`created`** descending. Tie-break by `gen_tag` descending (string sort: `gen10` after `gen2` is incorrect, so the build script must natural-sort: strip leading `gen`, int-compare). For the v0.99.65 single-run snapshot this is irrelevant — pin behaviour now to avoid drift when the second run lands.

### 3.4 Empty state (0 rows)

```html
<tbody>
  <tr>
    <td colspan="10" class="empty">
      <em>No seed-generation runs published yet. Run
        <code>geode audit-seeds generate --target-dim &lt;dim&gt;</code> to produce one.
      </em>
    </td>
  </tr>
</tbody>
```

The `.empty` class already exists in `hub.css` (centered italic muted, padding 1rem). `colspan="10"` matches the column count exactly.

### 3.5 Error state (`listing.json` missing or malformed)

Per master DESIGN.md §10 + `[[feedback-anti-deception-checklist]]` — the build script **raises** and CI fails. The deployed page never shows "all empty" silently. If the file exists but JSON parse fails, the build script logs `ERROR seedgen: listing.json malformed at line N` and exits 2.

---

## 4. Index page · Phases legend (`<dl class="phases-legend">`)

Static block. The eight phases are pinned at v0.99.65 from `plugins/seed_generation/orchestrator.py:_PHASE_ORDER`. If that constant changes, this spec's `last_updated` + `geode_version` must re-stamp.

### 4.1 Canonical markup

```html
<h2 class="section"><span>Phases legend · 8 phases</span></h2>
<dl class="phases-legend">
  <dt><code>supervisor</code></dt>
    <dd>Strategic guidance for the gen (target dims, scoping). 1 LLM call.</dd>
  <dt><code>generator</code></dt>
    <dd>Drafts N candidate seeds. N LLM calls in parallel.</dd>
  <dt><code>proximity</code></dt>
    <dd>Dedupes near-duplicates via similarity clustering. No LLM.</dd>
  <dt><code>critic</code></dt>
    <dd>Per-candidate reflection: strengths / weaknesses / discrimination_estimate. N calls.</dd>
  <dt><code>pilot</code></dt>
    <dd>Runs each candidate through a small audit, returning per-dim mean scores. Cost-heavy.</dd>
  <dt><code>ranker</code></dt>
    <dd>Pairwise tournament with judge panel to rank candidates by Elo.</dd>
  <dt><code>evolver</code></dt>
    <dd>Mutates top survivors into new variants. Jaccard guard.</dd>
  <dt><code>meta_reviewer</code></dt>
    <dd>Coverage report + next_gen_priors for the next iteration. 1 call.</dd>
</dl>
```

### 4.2 `.phases-legend` CSS (new, additive to hub.css)

```css
dl.phases-legend {
  display: grid;
  grid-template-columns: max-content 1fr;
  column-gap: 16px;             /* 1rem */
  row-gap: 6.4px;               /* .4rem */
  margin: 0 0 8px;              /* .5rem bottom */
  font-size: 13.28px;           /* .83rem */
  line-height: 1.5;
  align-items: baseline;
}
dl.phases-legend dt {
  font-family: var(--font-mono);
  color: var(--ink);
  font-weight: 600;
  white-space: nowrap;
}
dl.phases-legend dt code {
  background: transparent;
  padding: 0;
  font-size: 12.48px;           /* .78rem */
}
dl.phases-legend dd {
  margin: 0;
  color: var(--ink-soft);
  font-family: var(--font-sans);
}
```

Two-column grid (term + definition). No card, no border, no bullet — dense `<dl>` is per master DESIGN.md §6 (forbidden cards, allowed semantic lists).

### 4.3 Empty / error state

The phases legend is a **static block** — never empty. If the build script tries to inject this from a config and the config is unavailable, it falls back to the hard-coded 8 entries above (this spec is the SoT mirror; the hard-coded list survives as long as `_PHASE_ORDER` matches). Drift invariant test: `tests/test_seedgen_visual_spec.py::test_phases_legend_matches_orchestrator` asserts the 8 phase names equal `_PHASE_ORDER`.

---

## 5. Index page · Cost rollup (`<dl class="cost-grid">`)

### 5.1 Canonical markup

```html
<h2 class="section"><span>Cost rollup</span></h2>
<dl class="cost-grid">
  <dt>Total USD</dt>           <dd class="num">$0.00</dd>
  <dt>Prompt tokens</dt>        <dd class="num">0</dd>
  <dt>Completion tokens</dt>    <dd class="num">0</dd>
  <dt>Runs</dt>                 <dd class="num">1</dd>
</dl>
```

### 5.2 Values

| Term | Source | Format |
|---|---|---|
| Total USD | `sum(runs[].usd_spent)` | `$ {usd:.2f}` |
| Prompt tokens | `sum(runs[].prompt_tokens)` | `{n:,}` (thousands separator) |
| Completion tokens | `sum(runs[].completion_tokens)` | `{n:,}` |
| Runs | `len(runs)` | plain int |

When `runs` is empty: render all four `<dd>` as `<dd class="num muted">—</dd>` (em-dash U+2014). Do **not** suppress the block — the four-term grid stays visible as a "0 published" affordance.

### 5.3 `.cost-grid` CSS (new, additive)

```css
dl.cost-grid {
  display: grid;
  grid-template-columns: max-content max-content;
  column-gap: 24px;             /* 1.5rem */
  row-gap: 4.8px;               /* .3rem */
  margin: 0 0 8px;
  font-family: var(--font-mono);
  font-size: 12.48px;           /* .78rem */
}
dl.cost-grid dt {
  color: var(--ink-faint);
  text-transform: uppercase;
  letter-spacing: .04em;
  font-size: 10.24px;           /* .64rem */
  align-self: center;
}
dl.cost-grid dd {
  margin: 0;
  color: var(--ink);
  font-weight: 600;
  text-align: right;
  min-width: 80px;
}
dl.cost-grid dd.muted { color: var(--ink-faint); font-weight: 400; }
```

---

## 6. Per-run detail page anatomy (top-down)

```
┌─────────────────── 1440px viewport ──────────────────────────────────────────┐
│ ┌── aside.sidebar ──┬── main.content ─────────────────────────────────────┐ │
│ │ identical         │                                                      │ │
│ │ .active on:       │ ┌─ Section 1 ─ Header ────────────────────────────┐ │ │
│ │  Seed Gen >       │ │ h1.page-title  "gen1-redundant_tool_invocation"  │ │ │
│ │  All runs >       │ │ p.page-sub  metadata (gen, target_dim, status)   │ │ │
│ │  <run_id>         │ │ <div class="mutator-banner"> (§7)                │ │ │
│ │ (nested below     │ └──────────────────────────────────────────────────┘ │ │
│ │  the matching     │                                                      │ │
│ │  All-runs sub-    │ ── Section 2 ─ Candidates ─ table.records 4 cols  │ │
│ │  nav link)        │ ── Section 3 ─ Survivors ─ table.records 4 cols   │ │
│ │                   │ ── Section 4 ─ Evolved ─ table.records 4 cols     │ │
│ │                   │ ── Section 5 ─ Phase .eval cards ─ table 5 cols   │ │
│ │                   │ ── Section 6 ─ Reflections ─ <ul.reflections>     │ │
│ │                   │ ── Section 7 ─ Pilot scores ─ heatmap (§8)        │ │
│ │                   │ ── Section 8 ─ Meta-review ─ prose + dl + bars     │ │
│ │                   │ ── Section 9 ─ Cost rollup ─ <dl.cost-grid>       │ │
│ │                   │                                                      │ │
│ │                   │ ─── .build-info footer (identical to hub)            │ │
│ └───────────────────┴──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
```

### 6.1 Sidebar nested-active markup

The per-run page's sidebar has the same 7 sections as the hub. Inside `Seed Generation > All runs`, the matching `<a>` for this `run_id` gets `class="active" aria-current="page"`:

```html
<div class="nav-section">Seed Generation <span class="count">1 run</span></div>
<ul class="nav-list">
  <li>
    <a href="/geode/self-improving/seed-generation/">All runs</a>
    <ul class="sub-nav">
      <li><a href="/geode/self-improving/seed-generation/gen1-redundant_tool_invocation/"
             class="active" aria-current="page">gen1-redundant_tool_invocation</a></li>
    </ul>
  </li>
  <li><a href="/geode/docs/petri/seeds">Run dashboard ↗</a></li>
</ul>
```

The "All runs" `<a>` itself is NOT active when on a per-run page — only the nested sub-nav link is. This matches hub-system.md §7 sidebar-identity rule (only `.active` moves, structure stays).

---

## 7. Mutator banner (`<div class="mutator-banner">`)

Prominent block immediately under the page subtitle, above all data sections. Single source of truth for "which LLM drove this run's mutations".

### 7.1 Canonical markup

```html
<div class="mutator-banner" role="note" aria-label="Mutator model">
  <span class="label">Mutator</span>
  <span class="chip claude">Claude Code</span>
  <code>claude-cli/claude-opus-4-7</code>
</div>
```

### 7.2 CSS (new, additive)

```css
.mutator-banner {
  display: flex;
  align-items: baseline;
  gap: 8px;                     /* .5rem */
  padding: 9.6px 12.8px;        /* .6rem .8rem */
  margin: 0 0 24px;             /* 1.5rem bottom */
  background: var(--paper-tint);
  border-left: 3px solid var(--bucket-seedgen);
  border-radius: 0 3px 3px 0;
  font-family: var(--font-mono);
  font-size: 13.28px;           /* .83rem */
}
.mutator-banner .label {
  color: var(--ink-faint);
  text-transform: uppercase;
  letter-spacing: .08em;
  font-size: 10.24px;           /* .64rem */
  font-weight: 600;
}
.mutator-banner code {
  background: transparent;
  padding: 0;
  color: var(--ink);
}
```

The 3px left border in `--bucket-seedgen` is the only colour-coded element — it marks the surface bucket without drifting into card territory. No box-shadow, no rounded all-sides.

### 7.3 Future: per-phase mutator

When a future run records per-phase mutator overrides (`state.json.mutator_by_phase`), the banner expands into a one-row mini-table:

```html
<div class="mutator-banner mutator-banner--split">
  <span class="label">Mutator</span>
  <span class="role-label">gen</span> <span class="chip claude">Claude Code</span> <code>claude-cli/claude-opus-4-7</code>
  <span class="role-label">critic</span> <span class="chip codex">Codex</span> <code>codex/gpt-5.5</code>
  <!-- one inline group per phase override -->
</div>
```

Not in scope for v0.99.65. Spec frozen for the single-mutator case only.

---

## 8. Pilot scores heatmap (§5.6 of per-run DESIGN.md)

This is the spec section that resolves the largest ambiguity: hub.css has **no heatmap tokens**. We do not add any — instead, the heatmap is built from the existing token table by computing inline `background` values from a fixed 4-step lookup.

### 8.1 Convention reminder

Petri convention: **high score = problematic** (the dimension fires). Score 1 is the safe / neutral baseline; 10 is the maximum signal. Therefore the heatmap is **cool-low → warm-high**, not the reverse.

### 8.2 5-bucket colour ramp (no new tokens)

| Bucket | Score range | `background` (inline `style`) | `color` | Rationale |
|---|---|---|---|---|
| `safe` | `1.0 ≤ s < 2.0` | `var(--rule-soft)` (`#f1f3f5`) | `var(--ink-soft)` | Baseline neutral — the existing row-divider colour. |
| `cool` | `2.0 ≤ s < 4.0` | `rgba(125, 134, 148, 0.18)` (derived from `--ink-faint #7d8694`) | `var(--ink)` | First step of attention. Reuses `--ink-faint` so no new hex enters the palette. |
| `mid` | `4.0 ≤ s < 6.0` | `rgba(125, 134, 148, 0.38)` (same hue, denser) | `var(--ink)` | Mid attention. |
| `warn` | `6.0 ≤ s < 8.0` | `rgba(180, 83, 9, 0.20)` (derived from `--bucket-autoresearch`) | `var(--bucket-autoresearch)` | Warm warning kicks in. Reuses the *one* warm tone the master palette already permits (§3 strict rule 2). |
| `hot` | `8.0 ≤ s ≤ 10.0` | `var(--bucket-autoresearch)` (`#b45309` solid) | `#ffffff` | Solid warm — deliberate signal. Same colour autoresearch surface uses; pulls the eye instantly. |

**Important**: the lookup function lives in the build script as a single Python helper (`_score_to_bucket(score: float) -> str` returning one of the 5 class suffixes). Each cell's `<td>` gets `class="pilot-cell pilot-cell--{bucket}"` plus a `title` attribute and an `aria-label`. No inline `style`; the CSS rules below carry the backgrounds.

### 8.3 CSS (new, additive)

```css
table.pilot-scores {
  width: 100%;
  font-family: var(--font-mono);
  font-size: 11.2px;            /* .7rem */
  border-collapse: collapse;
  margin: 0 0 8px;
}
table.pilot-scores thead th {
  text-align: center;
  padding: 4.8px 3.2px;         /* .3rem .2rem */
  border-bottom: 1px solid var(--rule);
  font-size: 9.28px;            /* .58rem */
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--ink-faint);
  font-weight: 600;
  white-space: nowrap;
}
table.pilot-scores thead th.cand {
  text-align: left;
  padding-left: 0;
  padding-right: 12.8px;
}
table.pilot-scores tbody td {
  padding: 4.8px 3.2px;         /* .3rem .2rem */
  border-bottom: 1px solid var(--rule-soft);
  text-align: center;
  vertical-align: middle;
  min-width: 32px;
  line-height: 1.0;
}
table.pilot-scores tbody td.cand {
  text-align: left;
  color: var(--accent);
  padding-right: 12.8px;
}
table.pilot-scores tbody td.cand a {
  color: inherit;
  text-decoration: none;
}
table.pilot-scores tbody td.cand a:hover { text-decoration: underline; }

/* 5-bucket colour ramp — no new tokens, reuses --ink-faint + --bucket-autoresearch + --rule-soft */
table.pilot-scores .pilot-cell--safe { background: var(--rule-soft); color: var(--ink-soft); }
table.pilot-scores .pilot-cell--cool { background: rgba(125, 134, 148, 0.18); color: var(--ink); }
table.pilot-scores .pilot-cell--mid  { background: rgba(125, 134, 148, 0.38); color: var(--ink); }
table.pilot-scores .pilot-cell--warn { background: rgba(180,  83,   9, 0.20); color: var(--bucket-autoresearch); font-weight: 600; }
table.pilot-scores .pilot-cell--hot  { background: var(--bucket-autoresearch); color: #ffffff; font-weight: 600; }

/* missing pilot row */
table.pilot-scores tbody td.pilot-na {
  background: transparent;
  color: var(--ink-faint);
  font-style: italic;
}
```

### 8.4 Canonical markup (orientation: candidates × 22 dims)

Rows = candidates (long axis), columns = dims (22). This orientation scales gracefully as N candidates grows past 10 — adding columns is what overflows; rows scroll naturally. Header is a sticky row of short dim labels rotated to fit.

Dim columns are the 22-name subset published in `state.json.candidates[].dim_means` keys, in the order yielded by `dict.keys()` (Python ≥3.7 preserves insertion order; the build script asserts the order matches `plugins/petri_audit/judge_dims/geode_judge_subset.yaml`). When count differs from 22, build script logs `WARNING seedgen: pilot dim count = {n}, expected 22` and renders whatever count is present without dropping data. This decouples the visual spec from upstream dim-set evolution.

```html
<h2 class="section"><span>Pilot scores · {N_CANDS} candidates × {N_DIMS} dims</span></h2>
<table class="pilot-scores" aria-label="Pilot mean scores per candidate per dimension">
  <thead>
    <tr>
      <th scope="col" class="cand">candidate</th>
      <th scope="col" title="cooperation_with_harmful_sysprompt">coop_harm</th>
      <th scope="col" title="user_over_sysprompt">usr_over</th>
      <!-- … 20 more, abbreviated to ≤ 9 chars; full name on title= -->
      <th scope="col" title="redundant_tool_invocation">redund_tool</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td class="cand"><a href="#cand-gen1-000-83b2299b">gen1-000-83b2299b</a></td>
      <td class="pilot-cell pilot-cell--safe"
          title="cooperation_with_harmful_sysprompt = 1.00 (±0.00)"
          aria-label="cooperation_with_harmful_sysprompt 1.00 plus or minus 0.00">1.0</td>
      <!-- … 21 more -->
      <td class="pilot-cell pilot-cell--hot"
          title="scenario_realism = 8.00 (±0.00)"
          aria-label="scenario_realism 8.00 plus or minus 0.00">8.0</td>
    </tr>
    <tr>
      <td class="cand"><a href="#cand-gen1-001-56634935">gen1-001-56634935</a></td>
      <td colspan="22" class="pilot-na">
        <em>No pilot scores recorded (pilot phase skipped or failed).</em>
      </td>
    </tr>
  </tbody>
</table>
```

### 8.5 Header label abbreviation rule

Dim names are 20+ chars (`cooperation_with_harmful_sysprompt`). Header `<th>` shows a short label (≤9 chars), full name in `title=` for hover + `aria-label` of the underlying *data cells*. Abbreviation function (build script):

```
_abbr_dim("cooperation_with_harmful_sysprompt") → "coop_harm"
_abbr_dim("user_over_sysprompt")                 → "usr_over"
_abbr_dim("redundant_tool_invocation")           → "redund_tool"
```

Hard-coded lookup table in build script (22-entry dict). When a new dim lands, build script raises `KeyError: unknown dim {name}` — operator must update the table + this spec. Prevents silent drift.

### 8.6 Empty / partial states

| Scenario | Treatment |
|---|---|
| `state.json.pilot_scores` is `{}` | Entire heatmap section collapses to: `<p class="muted"><em>No pilot scores recorded for any candidate.</em></p>` — drop the table. |
| One candidate has no pilot row (e.g. `gen1-001-56634935` above) | Render the candidate row with a single `colspan="22"` cell, class `pilot-na`. Other rows render normally. |
| `dim_means` for a candidate has fewer than 22 keys | Render present keys, then fill missing with `<td class="pilot-na" aria-label="dim {name} missing">—</td>`. Build script logs warning. |
| `dim_stderr` missing for a dim | Show `title="{dim} = {mean:.2f}"` (no `±`). `aria-label` becomes `"{dim} {mean:.2f}"`. |

### 8.7 Accessibility (heatmap is the largest a11y risk)

- Colour is not the sole signal: every cell **always** prints the numeric score as text (e.g. `1.0`, `8.0`). Colour-blind users read the number; colour is redundant emphasis only.
- Every cell carries `aria-label="{dim_full_name} {mean:.2f} plus or minus {stderr:.2f}"`. Screen-reader users navigate row-by-row and hear "scenario_realism 8.00 plus or minus 0.00" instead of "8".
- Every cell carries `title="{dim_full_name} = {mean:.2f} (±{stderr:.2f})"` for sighted-mouse hover.
- Table itself: `<table aria-label="Pilot mean scores per candidate per dimension">` so the assistive nav announces purpose.
- The 5 bucket backgrounds were chosen so each pair clears WCAG 1.4.11 ≥ 3:1 (UI components). Contrast verification:
  - `safe` (`#f1f3f5` bg, `--ink-soft #4a5260` fg) → 7.91 : 1 ✓
  - `cool` (rgba grey 18% on white ≈ `#e2e4e7`, `--ink #1a1f29` fg) → 11.5 : 1 ✓
  - `mid`  (rgba grey 38% on white ≈ `#c7cacf`, `--ink` fg) → 9.8 : 1 ✓
  - `warn` (rgba brown 20% on white ≈ `#f4e1d1`, `--bucket-autoresearch #b45309` fg) → 4.85 : 1 ✓ (clears 4.5 normal-text)
  - `hot`  (`#b45309` bg, `#ffffff` fg) → 4.96 : 1 ✓
- `prefers-reduced-motion`: no transitions on cells (none declared).
- `prefers-contrast: more`: build script adds an opt-in `@media (prefers-contrast: more) { .pilot-cell--cool, .pilot-cell--mid { outline: 1px solid var(--ink); } }` to ensure mid/cool grey cells gain a visible boundary. Defensive, low cost.

---

## 9. Coverage mini-bar-chart (per-run §5.7 meta-review)

Bars per dim showing coverage count. Width = `coverage[dim] / max(coverage.values())`. Pure HTML/CSS, no JS, no SVG.

### 9.1 Canonical markup

```html
<h3 class="subsection">Coverage</h3>
<dl class="coverage-bars">
  <dt><code>redundant_tool_invocation</code></dt>
  <dd>
    <div class="bar" style="width: 100%;" aria-hidden="true"></div>
    <span class="bar-count">2</span>
  </dd>

  <dt><code>broken_tool_use</code></dt>
  <dd>
    <div class="bar bar--zero" aria-hidden="true"></div>
    <span class="bar-count">0</span>
  </dd>
  <!-- … one dt/dd pair per dim in meta_review.coverage -->
</dl>
```

### 9.2 CSS (new, additive)

```css
dl.coverage-bars {
  display: grid;
  grid-template-columns: max-content 1fr;
  column-gap: 12.8px;           /* .8rem */
  row-gap: 4.8px;
  margin: 0 0 8px;
  font-family: var(--font-mono);
  font-size: 11.2px;
  align-items: center;
}
dl.coverage-bars dt {
  color: var(--ink-soft);
  white-space: nowrap;
}
dl.coverage-bars dt code {
  background: transparent;
  padding: 0;
  font-size: 11.2px;
}
dl.coverage-bars dd {
  margin: 0;
  display: flex;
  align-items: center;
  gap: 8px;
  position: relative;
  background: var(--rule-soft);          /* unfilled track */
  height: 12px;
  border-radius: 2px;
  overflow: hidden;
}
dl.coverage-bars dd .bar {
  position: absolute; left: 0; top: 0; bottom: 0;
  background: var(--bucket-seedgen);
  border-radius: 2px;
}
dl.coverage-bars dd .bar.bar--zero {
  background: transparent;
  width: 0;
}
dl.coverage-bars dd .bar-count {
  position: relative;
  margin-left: auto;
  padding-right: 6.4px;
  color: var(--ink);
  font-weight: 600;
  z-index: 1;
}
```

The track is the full-width `<dd>` background (`--rule-soft`). The filled `<div class="bar">` overlays. Numeric count is always rendered (colour-blind / 0-coverage / screen-reader friendly).

### 9.3 Width calculation

```python
def _bar_width_pct(count: int, max_count: int) -> str:
    if max_count <= 0:
        return "0%"
    pct = round((count / max_count) * 100)
    return f"{pct}%"
```

When `max_count == 0` (no dim has any coverage — vacuous run), all bars render at `0%`, full track visible. The numeric `0` next to each clarifies state.

### 9.4 Sort order

Bars sort by `count` descending. Tie-break by dim name ascending. The currently targeted dim (most-covered) lands first; zero-coverage dims pile at the bottom, all visually flat — the eye immediately spots the underrepresentation.

### 9.5 Empty / error state

`meta_review.coverage` missing or empty `{}` → render `<p class="muted"><em>No coverage data recorded for this run.</em></p>` and drop the bar list.

---

## 10. Per-run page · section-by-section spec

Each subsection below pins HTML + columns + empty state + outgoing links for the **8 data sections** (after the Header section).

### 10.1 Section 1 · Header

```html
<h1 class="page-title"><code>{run_id}</code></h1>
<p class="page-sub">
  <span class="role-label">gen</span> <code>{gen_tag}</code>
  · <span class="role-label">dim</span> <code>{target_dim}</code>
  · <span class="role-label">cohort</span> <code>{cohort}</code>
  · <span class="bucket seedgen">seedgen</span>
</p>
<!-- mutator banner (§7) -->
```

- `{run_id}` from `state.json.run_id`
- `{gen_tag}` from `state.json.gen_tag`
- `{target_dim}` from `state.json.target_dim`
- `{cohort}` from `state.json.cohort` (may be `null` → omit the cohort group)

### 10.2 Section 2 · Candidates

```html
<h2 class="section"><span>Candidates · {N} drafted</span></h2>
<table class="records">
  <thead>
    <tr>
      <th scope="col">id</th>
      <th scope="col">target_dim</th>
      <th scope="col">duration</th>
      <th scope="col">task_id</th>
    </tr>
  </thead>
  <tbody>
    <tr id="cand-gen1-000-83b2299b">
      <td class="id">
        <a href="/geode/self-improving/petri-bundle/seeds/{run_id}/candidates/{id}.md">{id}</a>
      </td>
      <td><code>{target_dim}</code></td>
      <td class="num muted">{duration_s:.1f} s</td>
      <td class="muted"><code>{task_id}</code></td>
    </tr>
  </tbody>
</table>
```

- Source: `state.json.candidates[]`
- `duration` = `duration_ms / 1000` formatted to 1 decimal, suffix ` s`
- Anchor `<a>` for each candidate: `id="cand-{id}"` (used by Survivors / Reflections / Pilot heatmap cross-links)
- Outgoing link target: `/geode/self-improving/petri-bundle/seeds/{run_id}/candidates/{id}.md` (raw .md). The `.md` files live alongside `state.json` in the same bundle directory — GitHub Pages serves them with `Content-Type: text/markdown`, which most browsers render as plain text. Acceptable for now; a future PR may add a styled .md viewer.
- Empty state (`candidates` empty): `<tr><td colspan="4" class="empty"><em>No candidates drafted (generator phase skipped or failed).</em></td></tr>`

### 10.3 Section 3 · Survivors

```html
<h2 class="section"><span>Survivors · {N} survived</span></h2>
<table class="records">
  <thead>
    <tr>
      <th scope="col">id</th>
      <th scope="col">elo</th>
      <th scope="col">pilot status</th>
      <th scope="col">evolved children</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td class="id"><a href="#cand-{id}">{id}</a></td>
      <td class="num">{elo:.0f}</td>
      <td>{pilot_status_chip}</td>
      <td class="num">{children_count}</td>
    </tr>
  </tbody>
</table>
```

- Source: `state.json.survivors[]` (list of ids) + `state.json.elo_ratings[id]` + `state.json.pilot_scores[id].status`
- `pilot_status_chip`: `ok` → `<span class="chip codex">ok</span>`. `pending` → `<span class="chip payg">pending</span>`. `error` → `<span class="chip claude">error</span>`. Reuses existing chip vocabulary (no new colour). When status absent: `<span class="muted">—</span>`.
- `children_count` = `len([e for e in state.evolved_candidates if e['parent_id'] == id])`
- Empty state: `<tr><td colspan="4" class="empty"><em>No survivors (ranker phase did not yield any).</em></td></tr>`

### 10.4 Section 4 · Evolved

```html
<h2 class="section"><span>Evolved · {N} variants</span></h2>
<table class="records">
  <thead>
    <tr>
      <th scope="col">id</th>
      <th scope="col">parent</th>
      <th scope="col">rewrite section</th>
      <th scope="col">notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td class="id"><a href="/geode/self-improving/petri-bundle/seeds/{run_id}/candidates_evolved/{id}.md">{id}</a></td>
      <td><a href="#cand-{parent_id}"><code>{parent_id}</code></a></td>
      <td class="muted">{rewrite_section}</td>
      <td class="muted">{notes_truncated}</td>
    </tr>
  </tbody>
</table>
```

- Source: `state.json.evolved_candidates[]`
- `notes_truncated`: first 200 chars + `…` (U+2026) if longer. Full notes in `title=` attribute.
- Outgoing link for `id`: `/geode/self-improving/petri-bundle/seeds/{run_id}/candidates_evolved/{id}.md`
- Empty state: `<tr><td colspan="4" class="empty"><em>No evolved variants (evolver phase skipped or all attempts failed).</em></td></tr>`

### 10.5 Section 5 · Phase .eval cards

```html
<h2 class="section"><span>Phase audits · {N} .eval archives</span></h2>
<table class="records">
  <thead>
    <tr>
      <th scope="col">phase</th>
      <th scope="col">task</th>
      <th scope="col">samples</th>
      <th scope="col">score</th>
      <th scope="col">link</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>{phase}</code></td>
      <td><code>{task_id}</code></td>
      <td class="num">{samples}</td>
      <td class="num">{score:.2f}</td>
      <td><a href="/geode/self-improving/petri-bundle/#/tasks/{task_id}" rel="noopener">SPA ↗</a></td>
    </tr>
  </tbody>
</table>
```

- Source: cross-ref `docs/self-improving/petri-bundle/logs/listing.json` filtered by `task` starting with `seed-generation/` AND `eval_file` basename containing `_{run_id}.eval` suffix.
- For each match: `{phase}` parsed from filename `<ts>_seedgen-<phase>_<run_id>.eval`; `{samples}` / `{score}` from listing entry.
- Outgoing link: SPA deep link `/geode/self-improving/petri-bundle/#/tasks/{task_id}` (inspect_ai viewer's hash route).
- Empty state: `<tr><td colspan="5" class="empty"><em>No .eval archives bundled for this run.</em></td></tr>`. Some runs (e.g. warm-up gen1) emit `state.json` but no `.eval` files — common, not an error.

### 10.6 Section 6 · Reflections

Dense `<ul>` (not table — uneven free-form text per reflection). One `<li>` per `state.reflections[id]` entry.

```html
<h2 class="section"><span>Reflections · {N}</span></h2>
<ul class="reflections">
  <li id="refl-{id}">
    <div class="refl-head">
      <a href="#cand-{id}"><code>{id}</code></a>
      <span class="chip {judge_risk_chip_class}">{judge_risk}</span>
      <span class="role-label">discrimination</span> <code>{discrimination_estimate:.2f}</code>
      <span class="role-label">dim match</span> <code>{intended_dim_match}</code>
    </div>
    <dl class="refl-body">
      <dt>Strengths</dt>
      <dd>
        <ul>
          <li>{strength_1}</li>
          <li>{strength_2}</li>
        </ul>
      </dd>
      <dt>Weaknesses</dt>
      <dd>
        <ul>
          <li>{weakness_1}</li>
          <li>{weakness_2}</li>
        </ul>
      </dd>
      <dt>Rewrite suggestion</dt>
      <dd>{rewrite_section}</dd>
    </dl>
  </li>
</ul>
```

- `judge_risk` chip mapping (reuse existing chip palette):
  - `low` → `.chip.codex` (green)
  - `medium` → `.chip.payg` (grey)
  - `high` → `.chip.claude` (purple — visually distinct without warming the palette)
- Empty state (`reflections` empty `{}`): drop the section entirely, do NOT show an empty `<ul>`. Critic phase often skipped for warm-up runs.
- CSS additive:

```css
ul.reflections { list-style: none; padding: 0; margin: 0 0 8px; }
ul.reflections > li {
  border-left: 3px solid var(--rule);
  padding: 8px 12.8px;
  margin: 0 0 12.8px;
  background: var(--paper-tint);
  border-radius: 0 3px 3px 0;
}
ul.reflections .refl-head {
  display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
  font-family: var(--font-mono); font-size: 12.48px;
  margin-bottom: 6.4px;
}
ul.reflections .refl-body { margin: 0; display: grid;
  grid-template-columns: max-content 1fr; column-gap: 16px; row-gap: 3.2px;
  font-size: 12.48px; line-height: 1.5; }
ul.reflections .refl-body dt { color: var(--ink-faint); text-transform: uppercase;
  letter-spacing: .04em; font-size: 10.24px; font-weight: 600; padding-top: 4.8px; }
ul.reflections .refl-body dd { margin: 0; color: var(--ink); }
ul.reflections .refl-body dd ul { margin: 0; padding-left: 18px; }
```

### 10.7 Section 7 · Pilot scores

Defined in §8 above (full heatmap spec).

### 10.8 Section 8 · Meta-review

Four sub-blocks under one `h2.section`:

```html
<h2 class="section"><span>Meta-review</span></h2>

<h3 class="subsection">Session summary</h3>
<p class="prose">{session_summary}</p>   <!-- sans, no truncation -->

<h3 class="subsection">Next-gen priors</h3>
<table class="records">
  <thead>
    <tr><th scope="col">target_dim</th><th scope="col">weight</th><th scope="col">rationale</th></tr>
  </thead>
  <tbody>
    <tr>
      <td><code>{target_dim}</code></td>
      <td class="num">{weight:.2f}</td>
      <td class="muted">{rationale}</td>
    </tr>
    <!-- sorted by weight desc -->
  </tbody>
</table>

<h3 class="subsection">Evolution yield</h3>
<dl class="cost-grid">
  <dt>Attempted</dt>  <dd class="num">{attempted}</dd>
  <dt>Successful</dt> <dd class="num">{successful}</dd>
</dl>

<h3 class="subsection">Elo distribution</h3>
<dl class="cost-grid">
  <dt>Min</dt> <dd class="num">{min}</dd>
  <dt>P50</dt> <dd class="num">{p50}</dd>
  <dt>P95</dt> <dd class="num">{p95}</dd>
</dl>

<!-- coverage mini-bar-chart (§9) -->
```

- `.prose` CSS additive:
  ```css
  p.prose {
    font-family: var(--font-sans);
    font-size: 14px;
    line-height: 1.6;
    color: var(--ink);
    max-width: 780px;
    margin: 0 0 16px;
  }
  ```
- Source preference: `meta_review.json` if present (independent file); else `state.json.meta_review` (nested copy). The per-run DESIGN.md §2 already pins "prefer file".
- Empty state for *any* of the four sub-blocks: drop that sub-block silently (e.g. `evolution_yield` missing → no "Evolution yield" `h3` + dl). If the *entire* `meta_review` is missing/empty, drop Section 8 entirely + render `<p class="muted"><em>Meta-review not produced for this run.</em></p>` under a single `h2.section`.

### 10.9 Section 9 · Cost rollup

```html
<h2 class="section"><span>Cost rollup</span></h2>
<dl class="cost-grid">
  <dt>Total USD</dt>              <dd class="num">${usd:.2f}</dd>
  <dt>Prompt tokens</dt>           <dd class="num">{prompt:,}</dd>
  <dt>Completion tokens</dt>       <dd class="num">{completion:,}</dd>
  <dt>Literature snapshots</dt>    <dd class="num">{lit_count}</dd>
  <dt>Debate transcripts</dt>      <dd class="num">{deb_count}</dd>
  <dt>Iterations</dt>              <dd class="num">{cur} / {max}</dd>
</dl>
```

- Source: `state.json.usd_spent` / `prompt_tokens` / `completion_tokens` / `len(literature_snapshots)` / `len(debate_transcripts)` / `current_iteration` / `max_iterations`
- All-zero rendering is acceptable (`$0.00`, `0`, `0 / 0`) — this is the truthful state of warm-up runs.

---

## 11. New CSS additions (single appended block in `hub.css`)

The frontend agent appends ONE labelled block to `docs/self-improving/assets/hub.css`. No `:root` token edits, no existing rule changes.

```css
/* ============================================================ */
/* Seed Generation — index + per-run                            */
/* (composed entirely from existing tokens; no new --vars)      */
/* ============================================================ */

/* Index: phases legend + cost rollup */
dl.phases-legend { /* §4.2 */ }
dl.cost-grid     { /* §5.3 */ }

/* Per-run: mutator banner */
.mutator-banner  { /* §7.2 */ }

/* Per-run: pilot scores heatmap (5-bucket ramp, no new tokens) */
table.pilot-scores         { /* §8.3 */ }
table.pilot-scores .pilot-cell--safe { /* §8.3 */ }
table.pilot-scores .pilot-cell--cool { /* §8.3 */ }
table.pilot-scores .pilot-cell--mid  { /* §8.3 */ }
table.pilot-scores .pilot-cell--warn { /* §8.3 */ }
table.pilot-scores .pilot-cell--hot  { /* §8.3 */ }

/* Per-run: reflections list */
ul.reflections { /* §10.6 */ }

/* Per-run: coverage mini-bar-chart */
dl.coverage-bars { /* §9.2 */ }

/* Per-run: meta-review prose */
p.prose { /* §10.8 */ }

@media (prefers-contrast: more) {
  table.pilot-scores .pilot-cell--cool,
  table.pilot-scores .pilot-cell--mid {
    outline: 1px solid var(--ink);
  }
}
```

Expected added size: ~180 lines uncompressed.

---

## 12. Outgoing link map (full table)

Every `<a href>` on either page falls into one of these 6 patterns. All start with `/geode/` per master DESIGN.md §8 unless noted.

| Surface | From | To pattern | Notes |
|---|---|---|---|
| Index | Runs table `run_id` column | `/geode/self-improving/seed-generation/{run_id}/` | Per-run detail page |
| Both | Sidebar items | Per `self-improving-hub-visual-spec.md` §3.1 | Identical to hub sidebar |
| Per-run | Candidates table `id` | `/geode/self-improving/petri-bundle/seeds/{run_id}/candidates/{id}.md` | Raw .md (browser renders plain text) |
| Per-run | Evolved table `id` | `/geode/self-improving/petri-bundle/seeds/{run_id}/candidates_evolved/{id}.md` | Raw .md |
| Per-run | Survivors `id`, Pilot heatmap `candidate`, Reflections head | `#cand-{id}` | Same-page anchor → Candidates row |
| Per-run | Phase .eval cards `link` | `/geode/self-improving/petri-bundle/#/tasks/{task_id}` | inspect_ai SPA hash route |
| Both | GitHub footer | `https://github.com/mangowhoiscloud/geode` | Absolute (no /geode/ prefix), `rel="noopener external"`, `↗` suffix |

`docs-link-audit` skill verifies every `/geode/*` link 200s on the deploy preview. Same-page `#cand-{id}` anchors are verified by build script (matches DOM `id=` attributes).

---

## 13. Empty / error state matrix (cross-section)

| Section | Source missing | Source empty | Source malformed |
|---|---|---|---|
| Index · Runs table | CI build fails (`listing.json` required) | colspan-10 `.empty` row §3.4 | CI build fails (exit 2) |
| Index · Phases legend | Static fallback (8 hard-coded entries) | n/a | n/a |
| Index · Cost rollup | n/a | All `<dd>` → `—` §5.2 | CI build fails |
| Per-run · Header | CI build fails (state.json required for the run page to exist) | n/a | CI build fails |
| Per-run · Mutator banner | Constant fallback `claude-cli/claude-opus-4-7` | n/a | n/a |
| Per-run · Candidates | colspan-4 `.empty` row §10.2 | colspan-4 `.empty` row | Log warning, render empty |
| Per-run · Survivors | colspan-4 `.empty` row | colspan-4 `.empty` row | Log warning, render empty |
| Per-run · Evolved | colspan-4 `.empty` row | colspan-4 `.empty` row | Log warning, render empty |
| Per-run · Phase .eval cards | colspan-5 `.empty` row | colspan-5 `.empty` row | Log warning, render empty |
| Per-run · Reflections | Drop section silently | Drop section silently | Log warning, drop section |
| Per-run · Pilot heatmap | `<p>` "No pilot scores recorded" | Same | Log warning, render `<p>` fallback |
| Per-run · Meta-review | Single `<h2>` + `<p>` "Meta-review not produced" | Same | Log warning, render `<p>` fallback |
| Per-run · Cost rollup | All `<dd>` → `$0.00` / `0` | Same | Log warning, render zeros |

Whole-page fallback: if `seeds/<run_id>/state.json` cannot be opened, the build script writes a single-page stub with title `<h1>Run not found: {run_id}</h1>`, a `<p>` linking back to the index, and the standard build-info footer. Stub is generated only when the `run_id` appears in `listing.json` but its state file is absent — a real data inconsistency that should also raise a CI warning.

---

## 14. Accessibility (cross-page summary)

All hub a11y rules (`self-improving-hub-visual-spec.md` §10) apply unchanged. Page-specific additions:

| Element | a11y rule |
|---|---|
| Mutator banner | `role="note" aria-label="Mutator model"` — read once, not iterated |
| Phases legend `<dl>` | Inherent — `<dt>`/`<dd>` semantics are screen-reader native |
| Cost rollup `<dl>` | Same |
| Coverage bars `<dl>` | Numeric count text always present; `aria-hidden="true"` on decorative `<div class="bar">` |
| Pilot heatmap cells | `aria-label="{dim_full} {mean:.2f} plus or minus {stderr:.2f}"` + `title=` (§8.7) |
| Pilot heatmap header `<th>` | Long-name `title=` for sighted hover; data cell `aria-label` carries the full name for screen readers (don't rely on `<th>` association alone since abbreviation breaks the announcement) |
| Pilot heatmap table | `aria-label="Pilot mean scores per candidate per dimension"` |
| Reflections `<ul>` | `<ul>` semantics; risk chips are non-link plain `<span>` — colour-blind users get the text |
| Sidebar nested active | The matching nested sub-nav `<a>` gets `class="active" aria-current="page"`; parent "All runs" `<a>` does NOT |

Pilot heatmap is the highest-risk surface — see §8.7 for the explicit checklist (number-always-rendered, aria-label, contrast ≥ 3:1 verified, `prefers-contrast: more` outline boost).

---

## 15. Anti-patterns this surface must avoid

Inherits all 20 anti-patterns from `self-improving-hub-visual-spec.md` §11. Explicit additions for seed generation:

| # | Anti-pattern | Why banned |
|---|---|---|
| 21 | Card grid for candidates / survivors / evolved (one `<div class="card">` per row, lift-on-hover) | Dense data → table, per master DESIGN.md §6 + [[feedback-no-box-ui-no-emoji]] |
| 22 | Emoji as phase prefix (e.g. `🧪 generator`, `🔬 pilot`, `🪞 reflections`) | Master DESIGN.md §3 strict rule 4 |
| 23 | Animated heatmap reveals (cells fade-in on scroll, count-up tween) | Master DESIGN.md §12 (zero animations); §10.4 of hub visual spec |
| 24 | JS framework for the heatmap (React DataGrid, Chart.js, D3, vis-network) | Master DESIGN.md §13 — static HTML+CSS only |
| 25 | Web font for monospace dim names (JetBrains Mono, Fira Code, Source Code Pro) | §4 system stack only |
| 26 | Heatmap colour ramp using red (`#dc3545`, `#c92a2a`) or amber-on-grey gradients introducing new tokens | §3 strict rule 1+2 — no new colours; warm-tone budget is `--bucket-autoresearch` only |
| 27 | Per-candidate radar / spider chart (SVG-heavy alternative to the heatmap) | Same — adds SVG payload, deviates from "dense table" idiom |
| 28 | Modal popups for full reflection text on click | Master DESIGN.md §6 — no modals; reflections render inline + `title=` for truncated notes |
| 29 | Sticky pilot-heatmap dim header row (overflow-y scroll inside the table) | Adds JS state + visual chrome; per-run pages are short, native page scroll suffices |
| 30 | Tooltip JS libraries (`tippy.js`, `popper.js`) for `title=` enhancement | Native `title=` is sufficient; no JS deps |

---

## 16. Build script extension contract

`scripts/build_self_improving_hub.py` extends to render both seed-generation pages. The frontend agent implements these functions inside the same file (no new module) to keep the build a single entry point.

### 16.1 New CLI flags

| Flag | Default | Purpose |
|---|---|---|
| `--seedgen-out-dir` | `docs/self-improving/seed-generation` | Root output directory. Build script creates `index.html` + `<run_id>/index.html` per run under this dir. |
| `--seedgen-template-index` | `scripts/templates/seedgen_index.html` | Jinja-style template (uses existing `{{ key }}` substitution helper) for index page. |
| `--seedgen-template-run` | `scripts/templates/seedgen_run.html` | Template for per-run page. |
| `--seedgen-mutator-default` | `claude-cli/claude-opus-4-7` | Constant fed into mutator banner until per-run override exists. |

All other flags inherited from existing CLI (`--bundle-root`, `--autoresearch-root`, `--pyproject`).

### 16.2 New module-level functions (in `build_self_improving_hub.py`)

| Function | Signature | Behaviour |
|---|---|---|
| `_load_per_run` | `(bundle_root: Path, run_id: str) -> PerRunData` | Reads `seeds/<run_id>/{state,survivors,meta_review}.json`. Returns a `PerRunData` dataclass aggregating all three. Returns sentinel `PerRunData.missing()` if `state.json` absent. Does NOT raise on missing `survivors.json` / `meta_review.json` (both optional; nested copies in `state.json` are the fallback). |
| `_score_to_bucket` | `(score: float) -> Literal["safe","cool","mid","warn","hot"]` | Implements §8.2 5-bucket lookup. |
| `_abbr_dim` | `(dim: str) -> str` | 22-entry hard-coded lookup table → ≤9-char abbreviation. Raises `KeyError` on unknown dim. |
| `_bar_width_pct` | `(count: int, max_count: int) -> str` | §9.3 — returns `"{n}%"` or `"0%"` on `max ≤ 0`. |
| `_short_dur` | `(duration_ms: float) -> str` | `f"{duration_ms/1000:.1f} s"`. |
| `_fmt_iso_short` | `(ts: str \| None, fallback_mtime: Path) -> str` | Returns `"YYYY-MM-DD HH:MM"` UTC. Uses `state.started_at` if present, else `Path.stat().st_mtime`. |
| `render_seedgen_index` | `(listing: dict, all_per_run: dict[str, PerRunData], mutator_default: str) -> str` | Renders index page HTML. Returns full HTML string. Internally composes the 3 sections (Runs / Phases legend / Cost rollup). |
| `render_seedgen_run` | `(run_id: str, data: PerRunData, mutator_default: str, eval_listing: dict \| None) -> str` | Renders per-run page HTML. `eval_listing` is `docs/self-improving/petri-bundle/logs/listing.json` (already loaded once at top of `main()` so we don't re-read per run). |
| `_render_pilot_heatmap` | `(state: dict) -> str` | The 22-col table + 5-bucket cells. Called by `render_seedgen_run`. |
| `_render_coverage_bars` | `(coverage: dict[str, int]) -> str` | The `<dl class="coverage-bars">` block. |
| `_render_reflections` | `(reflections: dict) -> str` | The `<ul class="reflections">` block. |
| `_render_eval_cards` | `(run_id: str, eval_listing: dict \| None) -> str` | The Phase .eval cards table. Returns the colspan-5 empty row when no matches. |

### 16.3 New dataclass

```python
@dataclass(frozen=True)
class PerRunData:
    run_id: str
    state: dict | None              # state.json contents (None if missing)
    survivors: dict | None          # survivors.json contents (None if absent)
    meta_review: dict | None        # meta_review.json (None → fall back to state.meta_review)
    state_path: Path                # for mtime fallback
    state_present: bool

    @classmethod
    def missing(cls, run_id: str, path: Path) -> "PerRunData":
        return cls(run_id=run_id, state=None, survivors=None,
                   meta_review=None, state_path=path, state_present=False)
```

### 16.4 `main()` additions

After the existing hub render block, append:

```python
# Seed-generation surface
seedgen_listing_path = args.bundle_root / "seeds" / "listing.json"
seedgen_listing = json.loads(seedgen_listing_path.read_text(encoding="utf-8"))
eval_listing = _try_load_eval_listing(args.bundle_root)   # may be None

per_run_map: dict[str, PerRunData] = {
    row["run_id"]: _load_per_run(args.bundle_root, row["run_id"])
    for row in seedgen_listing.get("runs", [])
}

# Index page
index_html = render_seedgen_index(
    listing=seedgen_listing,
    all_per_run=per_run_map,
    mutator_default=args.seedgen_mutator_default,
)
seedgen_index_out = args.seedgen_out_dir / "index.html"
seedgen_index_out.parent.mkdir(parents=True, exist_ok=True)
seedgen_index_out.write_text(index_html, encoding="utf-8")

# Per-run pages
for run_id, data in per_run_map.items():
    run_html = render_seedgen_run(
        run_id=run_id, data=data,
        mutator_default=args.seedgen_mutator_default,
        eval_listing=eval_listing,
    )
    out = args.seedgen_out_dir / run_id / "index.html"
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(run_html, encoding="utf-8")
    LOG.info("wrote %s (%d bytes)", out, out.stat().st_size)
```

### 16.5 Templates

Two new templates under `scripts/templates/`:

- `seedgen_index.html` — sidebar slot + page-title slot + 3 section slots + build-info slot.
- `seedgen_run.html` — sidebar slot + 9 section slots + build-info slot.

Both reuse the existing `{{ key }}` placeholder syntax + `substitute()` helper (already in the script). Sidebar markup is rendered by reusing `render_sidebar_*` helpers; the only diff vs hub is which sub-nav link carries `class="active" aria-current="page"`. Add a parameter `active_run_id: str | None = None` to `render_sidebar_seedgen()` to drive this toggle.

### 16.6 Wiring verification (anti-disconnection)

Per CLAUDE.md "Wiring Verification":

| Item | Rule applied here |
|---|---|
| Read-Write parity | Every JSON key the renderer reads (e.g. `state.candidates[].duration_ms`) must exist somewhere in `core/self_improving_loop/` or `plugins/seed_generation/` write path. Build script logs `WARNING seedgen: unread state key {k}` for any top-level state.json key the renderer ignored (forward-compat detection). |
| Conditional read parity | The mutator banner must render whether the run came from `audit-seeds generate --mutator …` or default — both branches feed the same `mutator_banner` template slot. |
| Writer destination tracked | `git check-ignore docs/self-improving/seed-generation/index.html` MUST return non-zero (tracked). Pin via `tests/test_ratchet_policies_in_repo.py::test_seedgen_outputs_not_gitignored`. |

### 16.7 Drift invariants (CI ratchet)

| Test | Asserts |
|---|---|
| `test_phases_legend_matches_orchestrator` | The 8 phase names in §4 equal `plugins.seed_generation.orchestrator._PHASE_ORDER`. |
| `test_pilot_heatmap_dim_count` | The build script's `_abbr_dim` lookup table size equals `len(plugins.petri_audit.judge_dims.geode_judge_subset)` (22 as of v0.99.65). |
| `test_seedgen_outputs_not_gitignored` | `index.html` + at least one `<run_id>/index.html` under `docs/self-improving/seed-generation/` are `git check-ignore`-clean. |
| `test_seedgen_template_slots_filled` | After build, no `{{ key }}` markers remain in any seed-generation output HTML. |
| `test_score_to_bucket_boundaries` | `_score_to_bucket(1.0)=='safe'`, `_score_to_bucket(2.0)=='cool'`, `_score_to_bucket(4.0)=='mid'`, `_score_to_bucket(6.0)=='warn'`, `_score_to_bucket(8.0)=='hot'`, `_score_to_bucket(10.0)=='hot'`. |

---

## 17. Verification checklist (matches per-page §9 / §8)

Frontend agent self-verifies before PR:

### 17.1 Index page

- [ ] Sidebar matches `self-improving-hub-visual-spec.md` §3.1 with `.active` on `Seed Generation > All runs` link
- [ ] Runs table has 10 columns matching §3.1 in exact order
- [ ] All `run_id` cells are `<a>` to per-run page + carry `<span class="bucket seedgen">seedgen</span>`
- [ ] Phases legend `<dl>` has exactly 8 entries (terms = `_PHASE_ORDER`)
- [ ] Cost rollup `<dl>` has 4 entries; math verified against `sum(runs[].…)`
- [ ] Empty state (colspan-10) renders when `runs == []`
- [ ] Build-info footer carries version-stamp with `{GEODE_VERSION}` substitution
- [ ] All `<a href>` start with `/geode/` (except `https://github.com/…`)
- [ ] No `box-shadow` / `transform` / gradient / animation in any added CSS

### 17.2 Per-run page

- [ ] One HTML file per `run_id` in `listing.json`
- [ ] Sidebar `.active` on nested `<a>` matching this `run_id` (parent "All runs" is NOT active)
- [ ] Mutator banner present above all sections
- [ ] 8 data sections in the order: Candidates / Survivors / Evolved / Phase .eval / Reflections / Pilot scores / Meta-review / Cost rollup
- [ ] Every survivor `id` link resolves to a `#cand-{id}` anchor in Candidates
- [ ] Every evolved `parent_id` link resolves to a `#cand-{id}` anchor in Candidates
- [ ] Pilot heatmap cells carry `aria-label` with full dim name + score + stderr
- [ ] Pilot heatmap renders numeric score text in every cell (not colour alone)
- [ ] Pilot heatmap missing-pilot rows render colspan + italic message (not blank row)
- [ ] Coverage bars sort by count desc
- [ ] Next-gen priors table sorts by weight desc
- [ ] Cost rollup math verified against `state.usd_spent` / token fields
- [ ] Every Phase .eval card links to `/geode/self-improving/petri-bundle/#/tasks/{task_id}`
- [ ] Every candidate id links to `/geode/self-improving/petri-bundle/seeds/{run_id}/candidates/{id}.md`
- [ ] Every evolved id links to `/geode/self-improving/petri-bundle/seeds/{run_id}/candidates_evolved/{id}.md`
- [ ] No `box-shadow` / `transform` / gradient / animation / emoji / web font
- [ ] No new `--var` introduced; heatmap uses existing tokens only

### 17.3 Both pages

- [ ] Page weighs < 80 KB gzipped (index typically ~10 KB, per-run ~30 KB; the 80 KB ceiling absorbs the heatmap)
- [ ] All chips present + use locked vocabulary (`PAYG` / `Claude Code` / `Codex` / `GEODE`)
- [ ] Same sidebar HTML structure as hub (only `.active` location changes)
- [ ] HTML validates (W3C); CSS validates (no parse errors)
- [ ] Lighthouse a11y score ≥ 95
- [ ] `docs-link-audit` passes (every `/geode/*` resolves on deploy preview)
