diff --git a/runner/harness/card.py b/runner/harness/card.py
new file mode 100644
index 0000000..334c2b6
--- /dev/null
+++ b/runner/harness/card.py
@@ -0,0 +1,184 @@
+"""Phase 3 — summary card + level/status badge rendering (plan-phase3-results-ux.md §4.2, R3/R6/U2).
+
+Two render layers, both PURE string builders (unit-testable, deterministic) plus a thin best-effort
+Playwright PNG step:
+
+- `render_badge_svg(...)` → shields-style SVG: "cc-ci | level N" (or a status word), colour by level.
+- `render_card_html(data)` → an HTML results card (recipe+version, the level badge, a per-stage /
+ per-test ✔/✘ table, and the embedded app screenshot) from a results.json
+ dict. Deterministic inline CSS + a relative screenshot.png ref so it
+ renders offline (file://) with no external assets.
+- `render_card_png(...)` → screenshot the HTML card to PNG via the harness Playwright browser.
+ Best-effort: returns None on any failure (cosmetics never block, R7).
+
+The card REPORTS results.json verbatim — it must never present a run greener than its tests
+(cardinal guardrail, plan §6). The level + ✔/✘ shown are read straight from the data this module is
+handed; it computes nothing.
+"""
+
+from __future__ import annotations
+
+import html
+import os
+
+# Level → colour ramp (YunoHost-ish): red at the floor, climbing to green at the top.
+LEVEL_COLOR = {
+ 0: "#e5534b", # red — install failed
+ 1: "#e0823d", # orange
+ 2: "#e0823d",
+ 3: "#d9b343", # amber
+ 4: "#a0b93f", # yellow-green
+ 5: "#57ab5a", # green
+ 6: "#3fb950", # bright green — full climb
+}
+STATUS_MARK = {"pass": "✔", "fail": "✘", "skip": "–", "error": "✘", "na": "–"}
+STATUS_COLOR = {
+ "pass": "#3fb950",
+ "fail": "#f85149",
+ "error": "#f85149",
+ "skip": "#8b949e",
+ "na": "#8b949e",
+}
+
+
+def level_color(level: int) -> str:
+ return LEVEL_COLOR.get(int(level), "#8b949e")
+
+
+def _text_width(s: str) -> int:
+ """Rough px width for a Verdana-11 label (badge sizing); good enough for shields-style boxes."""
+ return 7 * len(s) + 10
+
+
+def render_badge_svg(label: str, message: str, color: str) -> str:
+ """A two-box shields-style SVG badge (left grey label, right coloured message)."""
+ lw = _text_width(label)
+ mw = _text_width(message)
+ w = lw + mw
+ return (
+ f''
+ )
+
+
+def level_badge_svg(level: int, cap_reason: str = "") -> str:
+ """Per-recipe/-run LEVEL badge: 'cc-ci | level N'. Colour by level (R6)."""
+ msg = f"level {int(level)}"
+ return render_badge_svg("cc-ci", msg, level_color(level))
+
+
+def _stage_rows(stages: list[dict]) -> str:
+ rows = []
+ for st in stages:
+ smark = STATUS_MARK.get(st.get("status", ""), "?")
+ scolor = STATUS_COLOR.get(st.get("status", ""), "#8b949e")
+ rows.append(
+ f'
{smark}'
+ f'{html.escape(st.get("name", "?"))}
'
+ f'
{html.escape(st.get("status", ""))}
'
+ )
+ for t in st.get("tests", []):
+ tmark = STATUS_MARK.get(t.get("status", ""), "?")
+ tcolor = STATUS_COLOR.get(t.get("status", ""), "#8b949e")
+ ms = t.get("ms", 0)
+ rows.append(
+ f'
{tmark}
'
+ f'
{html.escape(t.get("name", "?"))}
'
+ f'
{ms} ms
'
+ )
+ return "\n".join(rows) or '
no stages
'
+
+
+def render_card_html(data: dict, screenshot_rel: str | None = "screenshot.png") -> str:
+ """Build the summary-card HTML from a results.json dict. `screenshot_rel` is the relative path to
+ the screenshot PNG (same dir as the card) — omitted from the card if None / absent.
+
+ The card shows exactly what the data says: recipe + version, the level badge + cap reason, the
+ per-stage/per-test ✔/✘ table, the invariant flags, and the app screenshot. No computation here."""
+ recipe = html.escape(str(data.get("recipe", "?")))
+ version = html.escape(str(data.get("version") or data.get("ref") or ""))
+ level = int(data.get("level", 0))
+ cap = html.escape(str(data.get("level_cap_reason") or ""))
+ color = level_color(level)
+ flags = data.get("flags", {}) or {}
+ flag_bits = []
+ for key, lbl in (("clean_teardown", "clean teardown"), ("no_secret_leak", "no secret leak")):
+ ok = bool(flags.get(key))
+ flag_bits.append(
+ f''
+ f'{STATUS_MARK["pass"] if ok else STATUS_MARK["fail"]} {lbl}'
+ )
+ show_shot = bool(screenshot_rel) and bool(data.get("screenshot"))
+ shot_html = (
+ f'