diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py
index 705bdc4..831f254 100644
--- a/dashboard/dashboard.py
+++ b/dashboard/dashboard.py
@@ -326,16 +326,31 @@ def render_history(recipe, rows):
return _page(f"{recipe} — cc-ci history", inner)
-def render_badge(recipe, status):
- color = _COLORS.get(status, "#8b949e")
- label, msg = "cc-ci", status
- lw, mw = 44, max(40, 7 * len(msg) + 10)
+def _badge_svg(label, msg, color):
+ """Two-box shields-style SVG (grey label | coloured message). Stdlib-only, deterministic sizing."""
+ lw = max(44, 7 * len(label) + 12)
+ mw = max(40, 7 * len(msg) + 12)
w = lw + mw
- return f""""""
+ return (
+ f''
+ )
+
+
+def render_badge(recipe, status):
+ """Status fallback badge (used when a recipe has no results.json level yet)."""
+ return _badge_svg("cc-ci", status, _COLORS.get(status, "#8b949e"))
+
+
+def render_level_badge(recipe, level):
+ """Per-recipe latest-LEVEL badge (R6): 'cc-ci: | level N', coloured by level —
+ embeddable in a recipe README (`/badge/.svg`) and shown on the dashboard."""
+ return _badge_svg(f"cc-ci: {recipe}", f"level {int(level)}", level_color(level))
def serve_run_file(run_id, fname):
@@ -363,8 +378,11 @@ class Handler(BaseHTTPRequestHandler):
if path.startswith("/badge/") and path.endswith(".svg"):
recipe = path[len("/badge/") : -len(".svg")]
row = next((r for r in recipes_cached() if r["recipe"] == recipe), None)
- status = row["status"] if row else "unknown"
- return 200, render_badge(recipe, status), "image/svg+xml"
+ # R6: per-recipe LATEST-LEVEL badge (from results.json). Fall back to a status badge when
+ # the recipe has no level yet (never ran / failed before emitting results.json).
+ if row and row.get("level") is not None:
+ return 200, render_level_badge(recipe, row["level"]), "image/svg+xml"
+ return 200, render_badge(recipe, row["status"] if row else "unknown"), "image/svg+xml"
if path.startswith("/runs/"):
# /runs// — stable URL for a run's results.json / summary.png / screenshot /
# badge (R3/R6). Whitelisted + traversal-guarded by serve_run_file.
diff --git a/docs/results-ux.md b/docs/results-ux.md
index 6feaf6a..270daba 100644
--- a/docs/results-ux.md
+++ b/docs/results-ux.md
@@ -97,20 +97,64 @@ run's exit code (cosmetics never block the pipeline, R7).
## 3. Summary card + app screenshot (R3/R4)
-
+**App screenshot** (`runner/harness/screenshot.py`). After the app deploys and passes health/readiness
+and **before any tier mutates state or teardown runs**, the harness captures a real Playwright
+screenshot of the live app and writes `screenshot.png` to the run dir. It is **secret-safe by
+default**: it shoots the **landing page** (login/setup forms show input *fields*, not secret values),
+viewport-only (`full_page=False`, no scroll into a secrets panel), and the harness never auto-fills an
+install wizard. A recipe whose landing page is uninformative may opt into a post-login view via an
+optional `SCREENSHOT` hook in `tests//recipe_meta.py` — **that hook owns the no-credential-page
+guarantee**. Capture is **best-effort**: any error returns `None`, writes no file, and never blocks the
+run (R7); `results.json.screenshot` is set only when a file was actually produced.
+
+**Summary card** (`runner/harness/card.py`). After `results.json` is written, the harness builds an
+HTML results card — recipe + version, the level badge, a per-stage/per-test ✔/✘ table with timings,
+the embedded app screenshot (base64 data-URI so the PNG is self-contained), and the invariant flags —
+and screenshots that HTML to `summary.png` via the harness Playwright browser. The card **reports
+`results.json` verbatim — it computes nothing**, so it can never show a run greener than its tests
+(cardinal guardrail). Rendering is best-effort (returns `None` on failure → no card, run unaffected).
+
+**Stable URLs.** The dashboard serves the run artifact dir read-only at:
+
+```
+https://ci.commoninternet.net/runs//summary.png # the card
+https://ci.commoninternet.net/runs//screenshot.png # the app screenshot
+https://ci.commoninternet.net/runs//badge.svg # the per-run level badge
+https://ci.commoninternet.net/runs//results.json # the raw data
+```
+
+`` is the Drone build number. The route is whitelist + traversal-guarded (filenames from a
+fixed set; `run_id` charset-restricted; realpath must stay inside the runs dir) and read-only.
## 4. PR comment (R2)
-
+On a `!testme` run the comment-bridge (`bridge/bridge.py`) maintains **one comment per PR, updated in
+place** (it carries a hidden `` marker so re-`!testme` finds and refreshes the
+same comment rather than stacking new ones):
+
+1. **On start** — a 🌻 + ⏳ placeholder: `testing @ ` + a live-logs link, "level pending".
+2. **On completion** — the same comment is edited to the YunoHost-shaped result: 🌻 + a **level badge**
+ image + the **summary card** image, **both linking to the run**, plus full-logs/dashboard links.
+
+If the rendered card isn't served (render failed, build didn't finish), the comment **falls back to a
+compact text verdict** with the run link (the bridge checks artifact availability with a cheap HEAD
+request) — R7: a cosmetics failure degrades to text, never a broken image, never affecting the verdict.
## 5. Badges (R6) + how to embed one
-
+Two SVG badge endpoints, both shields-style and coloured by level (`level_color`):
+
+- **Per-recipe latest-level** (for a recipe README): `https://ci.commoninternet.net/badge/.svg`
+ → `cc-ci: | level N` for that recipe's most recent run (falls back to a status badge if the
+ recipe has no level yet). Re-rendered live from the latest `results.json`.
+- **Per-run** (pinned to one run, e.g. in the PR comment):
+ `https://ci.commoninternet.net/runs//badge.svg`.
+
+Embed the per-recipe badge in a recipe README (Markdown), linking to the cc-ci dashboard:
+
+```markdown
+[](https://ci.commoninternet.net/recipe/)
+```
+
+The link target `…/recipe/` is that recipe's run-history page (level/version/status per run,
+with a link to each run's summary card).
diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_dashboard.py
index 1c7ff49..8d50d73 100644
--- a/tests/unit/test_dashboard.py
+++ b/tests/unit/test_dashboard.py
@@ -108,6 +108,22 @@ def test_build_row_degrades_without_results(monkeypatch):
assert "level —" in dashboard.render_overview([r])
+def test_level_badge_shows_level_coloured(monkeypatch):
+ svg = dashboard.render_level_badge("custom-html", 4)
+ assert "custom-html" in svg and "level 4" in svg
+ assert dashboard.level_color(4) in svg # coloured by level
+ assert svg.startswith("