name: nf-metro-layout-triage
description: Build a self-contained HTML triage page for failing/xfailing layout-invariant tests in nf-metro (test_label_x_anchored_to_station_marker_on_horizontal_runs, test_visual_stack_station_xs_share_column, test_row_trunk_marker_cy_consistent, test_no_kink_at_section_boundary, test_symfan_pairs_share_y, test_lines_dont_cross_non_consumer_markers, test_topological_siblings_share_y_or_symmetric, test_section_bbox_has_bottom_padding, test_off_track_inputs_above_consumer). Each row pairs the rendered fixture SVG with a red-bbox overlay on the offending element, a plain-English "Supposed issue" + "What to check" explanation, and bug / not-a-bug / ambiguous triage buttons whose state is saved in localStorage and exported as JSON. Use when the user asks to "triage layout invariants", "review xfails", "review failing layout tests", "build the triage page", or generally wants to triage the layout-invariant test suite by eye.
when-to-use: The user wants to walk through every failing or xfailing case in tests/test_layout_invariants.py and classify each one as bug / not-a-bug / ambiguous - typically before deciding which invariants to fix in the engine vs. which to relax in the test. Trigger phrases include "triage layout invariants", "review xfails", "review failing layout tests", "triage tool", "build the triage page", or any mention of triaging the nine invariants listed in the description.
nf-metro layout-invariant triage
This skill packages the triage tool that produces a single self-contained HTML page (with embedded SVGs, red-bbox overlays, explanations, and localStorage triage state) for every failing or xfailing case in tests/test_layout_invariants.py. It grew out of the xfail-review session for PR #326.
What it covers
The tool produces one card per (fixture, invariant) pair for these nine invariants:
test_label_x_anchored_to_station_marker_on_horizontal_runstest_visual_stack_station_xs_share_columntest_row_trunk_marker_cy_consistenttest_no_kink_at_section_boundarytest_symfan_pairs_share_ytest_lines_dont_cross_non_consumer_markerstest_topological_siblings_share_y_or_symmetrictest_section_bbox_has_bottom_paddingtest_off_track_inputs_above_consumer
If new invariants are added to the suite, the script will still surface them in the page but without a tailored explanation block; add a new finder + explanation entry in build_review.py to give them a structured highlight.
Triaging ad-hoc / one-off checks
You do not have to add a committed (x)failing test plus a find_* finder
just to eyeball a candidate check. Two escape hatches drive the page from an
arbitrary provider:
--violations <file.json> (render cards straight from JSON)
Pass a JSON file shaped like:
[
{
"fixture": "rnaseq_sections.mmd",
"invariant": "label_vs_foreign_line",
"rects": [
{"x": 120, "y": 80, "w": 60, "h": 24, "note": "label grazes the qc line"}
],
"issue": "The label sits on top of a line it doesn't belong to.",
"check": "If the line strikes through the text it's a bug; a graze is Ambiguous."
}
]
python .claude/skills/nf-metro-layout-triage/build_review.py \
--worktree "$PWD" --output-dir "$OUT" \
--violations /tmp/my-candidate-check.json
Each rect becomes a red dashed overlay on the rendered fixture. The
optional issue / check strings (HTML allowed) become the "Supposed
issue" / "What to check" blocks. This path skips pytest discovery and the
INVARIANT_FINDERS registry entirely - the only engine work is rendering
the fixture SVG.
--finder-module <path-or-dotted-name> (register a finder at runtime)
Point the tool at a small Python module exposing a FINDERS dict
({invariant: callable(graph, engine) -> list[violator-dict]}) and/or an
EXPLANATIONS dict ({invariant: (issue_html, check_html)}):
# /tmp/my_finder.py
def find_offenders(graph, engine):
return [{"kind": "rect", "x": ..., "y": ..., "w": ..., "h": ..., "note": "..."}]
FINDERS = {"my_candidate_check": find_offenders}
EXPLANATIONS = {"my_candidate_check": ("Issue prose...", "What-to-check prose...")}
python .claude/skills/nf-metro-layout-triage/build_review.py \
--worktree "$PWD" --output-dir "$OUT" \
--fail-list /tmp/my-faillist.txt --finder-module /tmp/my_finder.py
These are merged over the built-in registries, so the nine built-in
invariants keep working unchanged. A violator-dict uses the same shape the
built-in finders emit (kind, x, y, w, h, note).
Generic explanation fallback
When an invariant has no tailored explanation (no *_info block on its
violators and no matching EXPLANATIONS entry), the card simply renders the
overlay and notes with no prose block - which is the graceful generic path.
Supply issue / check in the violations JSON, or an EXPLANATIONS entry
in the finder module, to add prose; otherwise the red overlay plus the
per-rect note strings are enough to classify by eye.
Recipe
Assume an nf-metro checkout at $PWD (or a worktree off it) and the nf-metro micromamba env is available.
Activate the env and pin
PYTHONPATHto the worktree'ssrc/(the script does not require apip install, just an importable engine):source ~/.local/bin/mm-activate nf-metro export PYTHONPATH="$PWD/src"Pick an output directory outside the repo to keep generated SVGs out of git, for example
/Users/jonathan.manning/projects/nf-metro-triageor/tmp/triage-<task>:OUT=/Users/jonathan.manning/projects/nf-metro-triage mkdir -p "$OUT"Run the build script. By default it invokes pytest itself to discover the FAILED/XFAIL set, then renders each fixture and writes
index.htmlplusrenders/:python .claude/skills/nf-metro-layout-triage/build_review.py \ --worktree "$PWD" \ --output-dir "$OUT"If you already have pytest output captured in a log file (e.g.
pytest tests/test_layout_invariants.py -rfX --tb=no -q > /tmp/inv.log), pass it via--fail-list /tmp/inv.logto skip re-running the suite.Serve the output. The HTML is self-contained (SVGs are inlined as base64) but a local server makes loading and JSON export reliable:
cd "$OUT" && python -m http.server 8765Then point the user at http://localhost:8765.
Triage in the browser. For each row pick Bug, Not a bug, or Ambiguous and optionally add a note. The state persists in
localStorageper browser. When done, click Export JSON in the page header - this downloadsxfail-review-tags-<timestamp>.jsoncontaining{key: {tag, notes}}keyed by<fixture>__<invariant>.Clean up when the triage is finished:
# Stop the http.server (Ctrl-C) and optionally rm -rf "$OUT"
How the script works (in brief)
--worktreeis added tosys.path, so the script imports the parser, layout engine, routing, labels, and SVG renderer directly from that checkout. No pip install required.- Each fixture is rendered once via the
nf-metroCLI (cached in<output-dir>/renders/<fixture>.svg) and laid out once viacompute_layout(). - For each invariant the script runs a finder that re-derives the offending geometry (port Y, trunk marker cy, fan column, label X, etc.) and emits a red dashed rectangle into a per-row annotated SVG (
<output-dir>/renders/annotated/<key>.svg). - The HTML embeds the annotated SVG as base64 data URI, so the page is fully portable - you can drop
index.htmlanywhere and it still works (although the page also referencesrenders/for debugging). - If pytest is rerun later, the rendered SVGs are cached - delete
<output-dir>/renders/to force a fresh render.
Output anatomy
<output-dir>/
index.html # the page to open
fail-list.txt # raw pytest output (only when --fail-list not passed)
renders/
<fixture>.svg # base render per fixture (cached)
annotated/
<fixture>__<invariant>.svg # base + red overlay per row
The exported triage JSON lands in the user's browser Downloads folder; it is not written by the script.
When the explanation is generic
If the invariant fires inside the test harness but the embedded finder cannot reproduce the offending element (e.g. the test relies on a slightly different layout-param path), the row shows a yellow "no offending element" note plus a generic invariant-level explanation. Treat those as "no red highlight, classify from the whole render".
See also
tests/test_layout_invariants.py- the actual assertions the page mirrors.tests/layout_validator.py- lower-level programmatic checks used by other test files.- PR #326 (feat/comprehensive-invariants) - the original session this tool was built for.