diff --git a/CLAUDE.md b/CLAUDE.md
index 5706799..9741d23 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -94,7 +94,7 @@ The host **never executes build tools directly** — it only orchestrates VM lif
| `commands/report.py` | `report job` |
| `operations/vms.py`, `cache.py`, `host.py`, `audit.py`, `executions.py` | Pure logic layer shared by the CLI and the web UI — dataclasses only, zero `click`/`print`. `commands/*` and `web/routes.py` both call these; never duplicate logic in either caller. |
| `commands/web.py` | `web serve` — `ci web serve --host --port` launches the dashboard (`uvicorn`/`fastapi` imported lazily so the core CLI stays dep-light without the `[web]` extra) |
-| `web/app.py`, `auth.py`, `routes.py`, `security.py` | FastAPI dashboard: recent-executions/cache/disk/runner view, Gitea OIDC login (Authlib, fail-closed allowlist), CSRF-protected confirm-gated mutating actions (VM remove/cleanup, cache purge, template backup), JSONL audit trail. In-process only — imports `operations/` + `backends.load_backend` directly, never shells the CLI. See `docs/WEB-UI.md`. |
+| `web/app.py`, `auth.py`, `routes.py`, `security.py` | FastAPI dashboard: four info cards (Host with CPU/RAM/disk gauges, VMware modules, Runners, Cargo cache — no status-header tile row) + recent-executions view, Gitea OIDC login (Authlib, fail-closed allowlist), CSRF-protected confirm-gated mutating actions (VM remove/cleanup, cache purge, template backup), JSONL audit trail. In-process only — imports `operations/` + `backends.load_backend` directly, never shells the CLI. See `docs/WEB-UI.md`. |
**Key design rule**: Application code (especially `commands/job.py`) must import `backends.load_backend` only — never `WorkstationVmrunBackend` directly. This keeps the Phase D ESXi extension path open.
diff --git a/docs/WEB-UI.md b/docs/WEB-UI.md
index a6a3431..570f730 100644
--- a/docs/WEB-UI.md
+++ b/docs/WEB-UI.md
@@ -1,7 +1,7 @@
# Web UI — Management Dashboard
Server-rendered dashboard (`ci web serve`) for the local CI/CD system: recent
-CI executions with their live VMs (§8), cargo cache, disk, and act_runner
+CI executions with their live VMs (§9), cargo cache, disk, and act_runner
status, plus confirm-gated destructive actions (VM remove/cleanup, cache
purge, template backup) with an audit trail. In-process only — it imports
`operations/` and `backends.load_backend` directly and never shells out to
@@ -106,7 +106,7 @@ application **before** enabling `[web.oidc]`:
5. Add the Gitea username(s) allowed to log in to `[web].allowed_users`.
**The allowlist is fail-closed**: an empty list denies everyone, it
never means "allow all".
-6. For the Executions card (§8), add an API read scope to
+6. For the Executions card (§9), add an API read scope to
`[web.oidc].scopes` — `read:repository`, plus `read:admin` if the account
is a Gitea admin and you want runs from every repo on the instance. New
scopes take effect after one fresh login (Gitea shows the consent screen
@@ -152,10 +152,15 @@ reinstalls the `[web]` extra and restarts the service in one step (steps
address while `[web.oidc]` is absent — an unauthenticated dashboard that
can destroy VMs and purge caches must never be reachable from the LAN.
Configure `[web.oidc]` first, or bind `127.0.0.1`.
-- Every mutating route (VM remove/cleanup, cache purge, template backup) is
- behind a synchronizer-token CSRF check and a two-step confirm gate (a
- preview/dry-run, then an explicit "Confirm" that actually executes) —
- there is no one-click destroy.
+- Every mutating route (VM remove/cleanup, cache purge, template backup,
+ template delete, backup delete) is behind a synchronizer-token CSRF check
+ and a two-step confirm gate (a preview/dry-run, then an explicit "Confirm"
+ that actually executes) — there is no one-click destroy.
+- **Template delete has no backup-required safety rail.** It removes the
+ entire template directory — the golden image VM clones are made from, not
+ a single backup archive — behind the same confirm gate as everything else,
+ but nothing stops you from deleting a template with zero backups. Use the
+ adjacent Backup button first if you want a way back.
- Every mutating action appends exactly one entry to the audit log
(`[web].audit_log`, default `/var/lib/ci/logs/web-audit.jsonl`), viewable
at `/audit` in the dashboard.
@@ -280,13 +285,73 @@ on the live host with `sudo visudo -c` before relying on the panel.
---
-## 8. Executions card (recent Gitea Actions runs)
+## 8. Dashboard layout — one card per subject, no tile row
+
+The dashboard opens with four compact info cards, each the **only** place its
+subject is reported:
+
+| Card | Route | Refresh | Contents |
+|---|---|---|---|
+| **Host** | `GET /host` | 5s | `System` (hostname · kernel), `CPU` (model · cores), CPU / RAM / disk with gauges; uptime in the header bar, load average under the CPU gauge |
+| **VMware modules** | `GET /vmware` | 15s | `vmmon`, `vmnet`, `vmware-host.service` |
+| **Runners** | `GET /runners` | 5s | each `act-runner*` unit with its sub-state |
+| **Cargo cache** | `GET /cache` | 5s | root, total size, entries, oldest entry, retention |
+
+Then the wide tables: Executions (§9), Templates, Artifacts, Services,
+Maintenance.
+
+All four info cards render the same `
` grammar at the page's plain body
+size — one `- `/`
- ` pair per fact. VMware and Runners used to be a
+bordered `
` at a smaller font (`0.9rem` body text, uppercase `0.75rem`
+column headers), which read as a visibly different style sitting next to
+Host and Cargo cache's plain label/value lists. They carry the same two data
+points as before per row (a status badge, a sub-state) — just as `- {{
+unit }}
- {{ badge }} {{ sub_state }}
` instead of a table row —
+so no reading was dropped, and no card in this row uses `` any more.
+
+There is **no status-header tile row**. It used to sit above these cards
+showing VMs / Runners / CPU / RAM / Disk / Cache, which meant runner state was
+displayed in three places at once (tile, Runners card, Services card) and the
+core count, RAM total, cache size and entry count in two. The readouts that
+only the tiles had — CPU%, RAM used, disk used/free — moved into the Host card,
+where the rest of the host data already was. The VM count went away with the
+tiles; running VMs are listed in the Executions card, and the orphan sweep is
+in Maintenance.
+
+The Host card carries the most data of the four, so it is packed to match
+their height: the near-static facts pair up (`System`, `CPU`), uptime rides the
+header bar (spelled out — it has no `- ` to label it), and the load average
+is the CPU gauge's sub-line. Five `
- ` rows total. The CPU model is trimmed
+by the `cpu_short` filter (`Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz` →
+`Intel Core i9-13900K`); the untrimmed name is the `title` of that row.
+
+**Health does not depend on color.** Each gauge states its value as text
+directly above the bar (`42%`, `20.0 / 62 GB`, `40% · 60 GB free`), and the
+level word — `ok` / `elevated` / `critical` / `unknown` — is part of the
+gauge's `aria-label`, so a screen reader announces it. There is no visible
+level strip: the words repeated what the numbers already said on every row.
+Thresholds behind the fill colour and the announced word: CPU and RAM `> 75%`
+amber, `> 90%` red; disk `> 70%` amber, `> 85%` red; a source that could not
+be read is a neutral `unknown`, never a false green — and when the disk probe
+fails outright, `unknown` is printed as the row's value, since there is no
+gauge left to carry it.
+
+---
+
+## 9. Executions card (recent Gitea Actions runs)
The dashboard's top wide table is **Executions**: the most recent workflow
-runs, the live ones included, each with its status, workflow, branch/commit,
-trigger, duration, and the build VM(s) that run spawned (with their `Remove`
-button). It replaced the old read-only "Live VMs" table — a VM alone could
-never answer "did the last build pass?".
+runs, the live ones included, with the build VM(s) that run spawned (with
+their `Remove` button). It replaced the old read-only "Live VMs" table — a VM
+alone could never answer "did the last build pass?".
+
+Every fact is its own column — Run, Repo, Workflow, Branch, Commit, Trigger,
+Actor, Status, Duration, Started, VM, IP / Uptime, Actions. No cell stacks two
+facts on separate lines with `
`; a run's repo, the commit hash, who
+triggered it, and when it started each get their own column instead. The
+table is wide as a result — 13 columns — and scrolls horizontally inside the
+card's own `.table-wrap` rather than the page, same as the other wide tables
+(Templates, Services).
Runs come from Gitea's Actions API: `/api/v1/admin/actions/runs` (every repo
on the instance, admin token) with `/api/v1/user/actions/runs` as the
@@ -294,6 +359,15 @@ non-admin fallback. Results are cached for 10s, so the card's 5s refresh costs
Gitea at most one call per 10s window; the VM columns are re-read on every
refresh.
+The Status pill carries a check/✕ icon for the two outcomes that are a real
+verdict (`success`/`failure`, via `run_badge_icon`); `queued`/`running`/
+`cancelled`/`skipped` stay plain text since they are not a pass/fail on the
+run. Every pill in the column — outcome pills and the VM-only `orphan`/
+`running` pills on unmatched-VM rows — is forced to the same width
+(`#executions-card td:nth-child(8)` — Status is the 8th column, see above —
+sized to fit `cancelled`, the longest word) with left-aligned content, so the
+column reads as a clean list instead of ragged pill widths.
+
Rows are ordered **newest first** (a queued run, which has no start time yet,
sorts above everything; ties break on Gitea's run id). The row-count dropdown at
the right end of the card's header bar picks how many to list — 10 / 25 / 50 /
@@ -340,13 +414,12 @@ passes. Do not remove that header.
**Orphaned VMs are not handled in this card.** A powered-on VM whose run is no
longer in the window (or that no run ever claimed) keeps a row of its own
flagged `no run`, with its `Remove` button — but the orphan *sweep* lives in
-the **Maintenance** card ("Cleanup orphaned VMs") and the orphan *count* in
-the status-header VMs tile. Those two are the places to look at orphans; the
-Executions card only avoids hiding them.
+the **Maintenance** card ("Cleanup orphaned VMs"). That is the place to act on
+orphans; the Executions card only avoids hiding them.
---
-## 9. Troubleshooting
+## 10. Troubleshooting
**Login loop (bounces back to `/login` after authenticating with Gitea)**
: The registered Redirect URI in Gitea does not exactly match
diff --git a/docs/superpowers/specs/2026-07-25-card-consolidation.md b/docs/superpowers/specs/2026-07-25-card-consolidation.md
new file mode 100644
index 0000000..1492045
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-25-card-consolidation.md
@@ -0,0 +1,128 @@
+# Dashboard card consolidation — design (2026-07-25)
+
+Collapse the dashboard's redundant first two rows: six status-header tiles plus
+four compact cards that restated them. **The cards win; the tile row is
+deleted** and each card becomes the single owner of its subject.
+
+## Problem
+
+The rows said the same thing twice:
+
+| Datum | Tile | Card | Also |
+|---|---|---|---|
+| Runner up/down | `Runners 2/2` | `partials/runners.html` unit table | Services card, *with* start/stop/restart |
+| Cache size + entries | `Cache` | `partials/cache.html` | Maintenance card (purge) |
+| CPU cores | sub-line `/ N cores` | `partials/host.html` "CPU cores" | — |
+| RAM total | sub-line `/ N GB` | `partials/host.html` "Memory" | — |
+
+Runner state was in **three** places at once. Only three readouts were unique
+to the tiles — CPU%, RAM used, and the disk usage whose own card Stage E had
+already folded away — and only they needed a new home.
+
+## Decision
+
+One card per subject, no header:
+
+| Card | Route | Poll | Owns |
+|---|---|---|---|
+| Host | `/host` | 5s | hostname, kernel, full CPU model, cores, **CPU% / RAM / disk + gauges**, uptime, load avg |
+| VMware modules | `/vmware` | 15s | `vmmon`, `vmnet`, `vmware-host.service` |
+| Runners | `/runners` | 5s | each `act-runner*` unit + sub-state |
+| Cargo cache | `/cache` | 5s | root, size, entries, oldest, retention |
+
+Retired: `partials/status_header.html` and its `HealthSummary` /
+`_health_summary`. The VM count and orphan count went with them — running VMs
+are rows in the Executions card and the orphan sweep is a Maintenance action,
+so neither needed a numeric readout of its own.
+
+Rejected alternative: keep the tiles as the state layer and merge the four
+cards into one "Platform" detail card. It splits every subject across two
+places (is the runner state a tile or a card?), and forces the header to start
+polling to avoid going stale. One owner per subject is simpler to keep honest.
+
+## What survives from the tiles
+
+`_resource_levels(resources, disk) -> ResourceLevels` keeps the threshold logic
+and the `tile-*` class names, now driving the Host card's three gauges:
+
+- CPU / RAM: `> 75%` amber, `> 90%` red.
+- Disk: `> 70%` amber, `> 85%` red.
+- Any unreadable source: `unknown` (neutral), never a false green.
+
+**Health is never color-only** — the rule the tiles were built around. Every
+gauge is followed by `.resource-level`: the level word plus a check/alert icon,
+both keyed off the same class the gauge fill uses.
+
+## New data
+
+`HostResources` gains `cpu_model` / `kernel` / `hostname`, read through the
+existing `_read_proc` seam but via `_read_proc_optional` — a missing
+`/proc/cpuinfo` must cost the CPU *name*, never the CPU *percentage* that
+shares the live-metric `try`. Each degrades to `""`, never `None`, so templates
+print them unguarded. `cpu_model` is `""` on architectures that don't emit
+`model name` (arm64), which is a normal outcome.
+
+## Stage G — height parity across the four cards
+
+Folding the tiles in left the Host card at eleven ` ` rows against its
+neighbours' two-to-five, and `.dashboard-grid` uses `align-items: stretch`:
+VMware, Runners and Cargo cache were stretched to Host's height and padded with
+~200px of empty card. Same data, packed to five rows:
+
+| Was | Now |
+|---|---|
+| `Hostname`, `Kernel` (2 rows) | `System` — `ci-host · 7.0.0-28-generic` |
+| `CPU` (full model), `Cores` (2 rows) | `CPU` — `Intel Core i9-13900K · 16 cores` |
+| `Uptime` (1 row) | `.head-meta` at the right end of the card header |
+| `Load avg` (1 row) | dim sub-line under the CPU gauge |
+| level strip (word + check icon under each gauge) | the word moved into the gauge's `aria-label` |
+
+Nothing is dropped and no new grammar is introduced — the card is still a
+``, so it still reads like Cargo cache next to it.
+
+Three details the shape depends on:
+
+- **`.head-meta` must reset `text-transform`.** `.card h2` uppercases; an
+ unreset header meta renders `26H 03M`. `.head-select` (the Executions
+ row-count selector) already had to do this — same fix, same header slot.
+- **Uptime is spelled out** (`uptime 26h 03m`). Outside the `` it has no
+ `- `, and a bare duration in a header names nothing.
+- **Dropping the level strip does not make health color-only**, which is why
+ it could go. The rule the tiles were built around was written when a tile
+ showed a coloured bar and little else; the card states the number in text
+ above every bar, so `42%` versus `92%` is already readable without seeing a
+ colour. The word is kept for screen readers via `aria-label`, and the disk
+ row prints `levels.disk_label` as its value when the probe fails and there
+ is no gauge to attach it to.
+- **The CPU model is trimmed** through `cpu_short` — the raw ~40-char name
+ wraps to three lines in a quarter-width track, which is the height being
+ reclaimed. Full name on the row's `title`. This is the filter's first
+ template caller: it was written for a status tile that was retired before it
+ ever shipped.
+
+## Stage H — one grammar for all four cards
+
+Host and Cargo cache were always a `
` at the page's plain 15px body text.
+VMware and Runners were a bordered `` at `0.9rem` (13.5px) with
+uppercase `0.75rem` (11.25px) column headers — two font scales and two visual
+grammars in the same row of four cards. Converted VMware and Runners to the
+same ``: component/unit name as `- `, the status badge plus its
+sub-state sharing one `
- ` (the same "value + dim sub-text" shape Host's
+Disk row and Runners' own unit rows already used). No table, no header row,
+no per-row color tint (`tr.row-alert` dropped along with the `
` it
+targeted — the badge already carries the color, so the row no longer
+double-encodes it, matching how Host and Cargo cache never tinted a whole
+row either).
+
+## Tests
+
+`tests/python/test_web_cards.py`: the `_resource_levels` threshold matrix, each
+card's contents (including the three Stage F additions and the three readouts
+inherited from the tiles), the "every gauge has a text level" rule, and the
+absence of any status header. Stage G pins the packed shape: the paired
+`System` / `CPU` rows (and the absence of the four `- `s they replaced),
+`count("
- ") == 5`, uptime in `.head-meta` and not in a `
- `, the load
+average with no `
- ` of its own, the trimmed-model-plus-`title` pair, and the
+level word present in each gauge's `aria-label` while `resource-level` is
+absent from the markup — `id="status-header"` and `stat-tile` must not
+appear in the dashboard, and `/status-header` / `/platform` must 404.