diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md
index e157e8b..a3939b2 100644
--- a/docs/reference/file-map.md
+++ b/docs/reference/file-map.md
@@ -279,6 +279,7 @@ python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone?
python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open section 51 checks
python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks
python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks
+python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks
```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js
index f95aec9..f922db1 100644
--- a/html/wp-creation-app.js
+++ b/html/wp-creation-app.js
@@ -2247,12 +2247,30 @@ function renderWpNav(){
: (open ? open + ' open' : (waiting ? 'waits on ' + waiting : 'ready'));
const active = (editingId && p.id === editingId) ? ' active' : '';
const title = (p.number || '') + ' — ' + (p.subject || '') + ' · ' + (p.status || '') + ' · ' + state;
+ // A6: the stand-up answer, inline. Status, priority, due date, P6 activity
+ // and the open count all on the row; a held package says WHY it is held,
+ // from the newest live hold entry - the modal captured it, so display it.
+ // The title attribute above keeps the hover summary, but hover is never
+ // the only path (C1: Field View runs on tablets).
+ const triage = esc(p.status || 'Draft') + ' · ' + esc(wpPriorityOf(p))
+ + ' · due ' + esc(p.due || '—') + ' · P6 ' + esc(p.p6Id || '—');
+ let holdLine = '';
+ if (p.status === 'Issue') {
+ const live = (p.holds || []).filter(h => h && !h.released);
+ const h = live[live.length - 1];
+ const why = h ? (h.constraint ? h.constraint + ': ' : '') + (h.details || '')
+ : 'no reason recorded — log it from the status control';
+ holdLine = '⛔ ' + (open ? open + ' open — ' : '')
+ + esc(why) + '';
+ }
return '';
diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css
index 5934fce..9a3a2fc 100644
--- a/html/wp-creation-styles.css
+++ b/html/wp-creation-styles.css
@@ -882,6 +882,18 @@
body { --nav-w: 288px; }
body.wp-nav-collapsed { --nav-w: 56px; }
+ /* A6: the triage line and the inline hold reason. These WRAP - an ellipsis
+ here would hide exactly the data the row exists to show. The hold reason is
+ clamped at three lines so one essay of a reason cannot swallow the panel. */
+ .wp-nav-item { align-items: flex-start; }
+ .wp-nav-item .wp-nav-badge { margin-top: 2px; }
+ .wp-nav-triage { display: block; font-size: 10.5px; line-height: 1.5;
+ color: var(--text-muted); white-space: normal; margin-top: 1px; }
+ .wp-nav-hold { display: -webkit-box; -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical; overflow: hidden;
+ font-size: 10.5px; line-height: 1.45; color: var(--red);
+ white-space: normal; margin-top: 2px; }
+
/* -- collapse toggle -- */
.wp-nav-top { display: flex; align-items: center; padding: 8px 10px 2px; }
.wp-nav-toggle {
diff --git a/tests/triage_check.py b/tests/triage_check.py
new file mode 100644
index 0000000..1ce1c8a
--- /dev/null
+++ b/tests/triage_check.py
@@ -0,0 +1,224 @@
+#!/usr/bin/env python3
+"""Can the sidebar answer the stand-up question? — A6, T7.5.
+
+The use case in the task: someone is asked in a stand-up why a package has not
+moved. They open it on a phone and need the answer WITHOUT scrolling or
+clicking — so the hold reason, captured by the hold modal since T7.3, has to be
+readable straight off the navigator row, along with status, priority, due date,
+P6 activity and the open-constraint count.
+
+Three package shapes cover the matrix:
+ A — on hold, reason recorded (the stand-up case)
+ B — a plain draft (no hold: the row must look sensible, not have empty slots)
+ C — on hold the legacy way, holds:[] (the row must say the reason is missing,
+ not render an empty red line)
+
+Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
+Exit 0 all passed, 1 a failure, 2 could not run.
+"""
+import json
+import os
+import re
+import sys
+import tempfile
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import cdp # noqa: E402
+from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
+from sections_check import set_sop # noqa: E402
+from stepper_check import dismiss_dialogs # noqa: E402
+
+REASON = "Boom lift recalled for inspection"
+
+
+def ascii_(v, n=300):
+ return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
+
+
+def settle(seconds=0.6):
+ time.sleep(seconds)
+
+
+def wait_creator(page, tries=40):
+ for _ in range(tries):
+ if page.eval("!!window.wpCreatorReady"):
+ return True
+ time.sleep(0.3)
+ return False
+
+
+def constraint_btn(page, index, which):
+ labels = {"open": "Open", "cleared": "Cleared", "na": "N/A"}
+ page.eval("""(() => {
+ const tr = document.querySelectorAll('#constraint-body tr')[%d];
+ [...tr.querySelectorAll('.cstatus button')]
+ .find(b => b.textContent.trim() === %s).click();
+ })()""" % (index, json.dumps(labels[which])))
+ settle(0.4)
+
+
+def click_status(page, val):
+ page.eval("document.querySelector('#status-group .radio-pill[data-val=%s]').click()"
+ % json.dumps(val))
+ settle(0.4)
+
+
+def set_field(page, fid, val):
+ page.eval("(() => { const el = document.getElementById(%s); el.value = %s; "
+ "el.dispatchEvent(new Event('input', {bubbles: true})); })()"
+ % (json.dumps(fid), json.dumps(val)))
+
+
+def nav_row(page, number):
+ """The navigator row for a package number, as data."""
+ return json.loads(page.eval("""JSON.stringify((() => {
+ const row = [...document.querySelectorAll('.wp-nav-item')]
+ .find(b => (b.querySelector('.wp-nav-num')||{textContent:''}).textContent === %s);
+ if (!row) return null;
+ const g = sel => { const e = row.querySelector(sel); return e ? e.textContent : null; };
+ const r = row.getBoundingClientRect();
+ return {triage: g('.wp-nav-triage'), hold: g('.wp-nav-hold'),
+ state: g('.wp-nav-state'), subj: g('.wp-nav-subj'),
+ h: r.height, scrollW: row.scrollWidth, clientW: row.clientWidth};
+ })())""" % json.dumps(number)))
+
+
+def main():
+ exe = cdp.find_browser()
+ if not exe:
+ print("no headless-capable browser found; set WP_BROWSER.")
+ return 2
+
+ tmpdir = tempfile.mkdtemp(prefix="wpsuite-triage-")
+ db_path = os.path.join(tmpdir, "check.db")
+ server = None
+ browser = None
+ try:
+ tok = seed(db_path)
+ set_sop(db_path, {})
+ port = cdp.free_port()
+ base = "http://127.0.0.1:%d" % port
+ server = start_server(port, db_path)
+
+ browser = cdp.Browser(exe)
+ page = browser.page()
+ page.clear_cookies()
+ page.set_cookie("wp_session", tok["root"])
+ page.viewport(1440, 900)
+ page.goto(base + "/wp-creation-index.html?project=projA")
+ dismiss_dialogs(page)
+ chk("the creator boots", wait_creator(page))
+ settle(1.5)
+ page.eval("window.prompt=()=>null; window.alert=()=>{}; window.confirm=()=>false;")
+
+ # ── build package A: on hold, with a reason ───────────────────────────
+ print("\n1. package A: held, reason recorded (the stand-up case)")
+ set_field(page, "wp_subject", "Horn strobe conduit")
+ set_field(page, "wp_type", "Conduit Install")
+ set_field(page, "wp_priority", "High")
+ set_field(page, "wp_due", "2026-09-01")
+ set_field(page, "wp_p6_id", "A-1040")
+ n = page.eval("pkgConstraints.length")
+ for i in range(n):
+ constraint_btn(page, i, "cleared")
+ click_status(page, "In Progress")
+ constraint_btn(page, 0, "open") # opens the hold modal
+ page.eval("document.getElementById('hold-details').value=%s" % json.dumps(REASON))
+ page.eval("submitHold()")
+ settle(0.4)
+ page.eval("savePackage(false)")
+ settle(0.8)
+ numA = page.eval("(savedPackages[savedPackages.length-1]||{}).number||''")
+ row = nav_row(page, numA)
+ chk("the held package renders a row in the sidebar", row is not None, numA)
+ chk("the hold reason is ON the row — no modal, no hover, no click",
+ row and REASON in (row["hold"] or ""), ascii_(row))
+ chk("...and the row names the constraint it hangs on",
+ row and "Construction Equipment" in (row["hold"] or "")
+ or (row and (row["hold"] or "").count(":") >= 1), ascii_(row and row["hold"]))
+ chk("no modal is open while all of that is readable",
+ page.eval("!document.querySelector('#hold-modal.open')"))
+ tri = (row or {}).get("triage") or ""
+ chk("status, priority, due date and P6 activity are all on the row together",
+ "Issue" in tri and "High" in tri and "2026-09-01" in tri and "A-1040" in tri,
+ ascii_(tri))
+ chk("...and the open-constraint count is on the row too",
+ row and re.search(r"1 open", (row["hold"] or "") + " " + (row["state"] or "")),
+ ascii_(row))
+
+ # ── package B: a plain draft ──────────────────────────────────────────
+ print("\n2. package B: nothing held, nothing weird")
+ page.eval("newPackage()")
+ settle(0.6)
+ set_field(page, "wp_subject", "Wire pull")
+ set_field(page, "wp_type", "Conduit Install")
+ page.eval("savePackage(false)")
+ settle(0.8)
+ numB = page.eval("(savedPackages[savedPackages.length-1]||{}).number||''")
+ row_b = nav_row(page, numB)
+ chk("the no-hold package has NO hold line at all — not an empty red slot",
+ row_b is not None and row_b["hold"] is None, ascii_(row_b))
+ chk("...its state still says something (ready / N open), never a blank",
+ row_b and (row_b["state"] or "").strip() != "", ascii_(row_b))
+ tri_b = (row_b or {}).get("triage") or ""
+ chk("...and its triage line shows placeholders, not gaps, for unset fields",
+ "Draft" in tri_b and "—" in tri_b and "Normal" in tri_b, ascii_(tri_b))
+
+ # ── package C: held the legacy way, holds:[] ──────────────────────────
+ print("\n3. package C: a legacy hold with no entry")
+ page.eval("""(() => {
+ const p = collectPackage();
+ p.id = 'wp_legacy_probe'; p.number = 'WP99-LEGACY';
+ p.subject = 'Legacy hold'; p.status = 'Issue'; p.holds = [];
+ p.constraints[0].status = 'open';
+ savedPackages.push(p); renderWpNav();
+ })()""")
+ settle(0.5)
+ row_c = nav_row(page, "WP99-LEGACY")
+ chk("a hold with no recorded entry says so instead of rendering nothing",
+ row_c and "no reason recorded" in (row_c["hold"] or ""), ascii_(row_c))
+
+ # ── 390px: the phone in the stand-up ──────────────────────────────────
+ print("\n4. 390px")
+ page.viewport(390, 900, mobile=True)
+ settle(0.8)
+ page.eval("openWpNav()")
+ settle(0.6)
+ row = nav_row(page, numA)
+ chk("390px: the held row renders in the opened panel", row is not None
+ and row["h"] > 0, ascii_(row))
+ chk("390px: the reason is still readable there", row
+ and REASON in (row["hold"] or ""), ascii_(row))
+ chk("390px: the row does not overflow its panel sideways",
+ row and row["scrollW"] <= row["clientW"] + 2, ascii_(row))
+ chk("390px: the row is a comfortable tap target (>= 44px tall)",
+ row and row["h"] >= 44, ascii_(row))
+
+ js_errors = [e for e in page.js_errors()]
+ chk("no JavaScript errors anywhere in this run", not js_errors,
+ ascii_(js_errors[:2]))
+
+ finally:
+ if browser is not None:
+ try:
+ browser.close()
+ except Exception:
+ pass
+ if server is not None:
+ try:
+ server.terminate()
+ except Exception:
+ pass
+
+ print("\n" + "-" * 54)
+ print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
+ for f in _FAIL:
+ print(" - " + f)
+ return 1 if _FAIL else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())