diff --git a/runner/harness/card.py b/runner/harness/card.py
index 44cf4ae..6f44d2a 100644
--- a/runner/harness/card.py
+++ b/runner/harness/card.py
@@ -79,10 +79,44 @@ def render_badge_svg(label: str, message: str, color: str) -> str:
)
-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))
+# Third-segment colours for the level badge: amber = an UNINTENTIONAL skip (a rung skipped but not
+# in the recipe's intentional list — likely missing coverage) capped the climb; muted = an
+# INTENTIONAL skip (declared in recipe_meta.EXPECTED_NA — nothing to fix). Font-safe text labels
+# (no emoji) so the SVG renders anywhere.
+GAP_COLOR = "#d29922"
+EXPECT_COLOR = "#6e7681"
+
+
+def level_badge_svg(level: int, cap_reason: str = "", cap_skip: str = "") -> str:
+ """Per-recipe/-run LEVEL badge: 'cc-ci | level N' coloured by level (R6), with a THIRD segment
+ that differentiates *why* the climb stopped when a SKIP capped it (`cap_skip`):
+ - "unintentional" (a rung skipped but not in the recipe's intentional list): amber 'gap?'.
+ - "intentional" (a skip declared in recipe_meta.EXPECTED_NA): muted 'expected'.
+ - "" (clean cap / full climb / a real failure): no third segment (the level + card carry it).
+ The badge never inflates — it only annotates the cap the level already reflects."""
+ label, msg = "cc-ci", f"level {int(level)}"
+ lw, mw = _text_width(label), _text_width(msg)
+ third: tuple[str, str] | None = None
+ if cap_skip == "unintentional":
+ third = ("gap?", GAP_COLOR)
+ elif cap_skip == "intentional":
+ third = ("expected", EXPECT_COLOR)
+ if third is None:
+ return render_badge_svg(label, msg, level_color(level))
+ txt, tcolor = third
+ tw = _text_width(txt)
+ w = lw + mw + tw
+ return (
+ f''
+ )
def _stage_rows(stages: list[dict]) -> str:
@@ -107,6 +141,41 @@ def _stage_rows(stages: list[dict]) -> str:
return "\n".join(rows) or '
no stages
'
+# Friendly rung labels for the skip rows (the four essential rungs).
+RUNG_LABEL = {
+ "install": "install",
+ "upgrade": "upgrade",
+ "backup_restore": "backup/restore",
+ "functional": "functional",
+}
+SKIP_GREEN = "#57ab5a" # muted green — an intentional skip reads like a pass (but labelled, never inflating)
+
+
+def _skip_rows(skips: dict) -> str:
+ """Render SKIPPED rungs as stage-like rows. An intentional (declared) skip looks like a pass row
+ but its status says 'INTENTIONAL SKIP' (muted green) with the declared reason on the line below;
+ an unintentional skip is amber 'UNINTENTIONAL SKIP' with a prompt to add a test or declare it."""
+ rows = []
+ for rung, reason in (skips.get("intentional") or {}).items():
+ rows.append(
+ f'
⊘'
+ f'{html.escape(RUNG_LABEL.get(rung, rung))}
'
+ f'
intentional skip
'
+ )
+ rows.append(f'
{html.escape(reason)}
')
+ for rung in skips.get("unintentional") or []:
+ rows.append(
+ f'
⊘'
+ f'{html.escape(RUNG_LABEL.get(rung, rung))}
'
+ f'
unintentional skip
'
+ )
+ rows.append(
+ '
not declared in EXPECTED_NA — add the '
+ "missing test/label, or declare the skip with a reason
"
+ )
+ return "\n".join(rows)
+
+
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.
@@ -116,7 +185,9 @@ def render_card_html(data: dict, screenshot_rel: str | None = "screenshot.png")
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 ""))
+ cap_reason = str(data.get("level_cap_reason") or "")
+ cap = html.escape(cap_reason)
+ sk = data.get("skips", {}) or {}
color = level_color(level)
flags = data.get("flags", {}) or {}
flag_bits = []
@@ -132,7 +203,7 @@ def render_card_html(data: dict, screenshot_rel: str | None = "screenshot.png")
if show_shot
else '