T7.5 - A6: the sidebar answers the stand-up question

Use case from 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. The navigator row now carries:

- a triage line: status - priority - due date - P6 activity, with an em dash
  for anything unset (a placeholder is information; a gap is a question)
- the open-constraint count (already in the state chip; on a held row it moves
  into the hold line so it is never displaced by "on hold")
- the hold reason INLINE, from the newest live entry in data.holds - the modal
  captured it at T7.3, so this is display work, exactly as the task said. A
  held package with no recorded entry (legacy data) says "no reason recorded -
  log it from the status control" rather than rendering an empty red slot.

The row's title attribute keeps its hover summary, but hover stops being the
only path to any of this (C1 - Field View runs on tablets). Triage and reason
lines WRAP instead of ellipsizing - an ellipsis would hide exactly the data
the row exists to show; the reason clamps at three lines so one essay cannot
swallow the panel. Rows align flex-start to take the extra height.

At 390px the panel is the existing overlay drawer; the row fits it with no
sideways overflow and stays a >= 44px tap target.

Verification (each probe run alone): NEW tests/triage_check.py 16/16 covering
the held/plain/legacy row matrix at 1440px and 390px. Regression:
frame_check 39/39.

Items: A6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:28:02 -07:00
parent 60434d452c
commit 5d27a1e086
4 changed files with 255 additions and 0 deletions

View File

@@ -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

View File

@@ -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 = '<span class="wp-nav-hold">⛔ ' + (open ? open + ' open — ' : '')
+ esc(why) + '</span>';
}
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
' title="' + esc(title) + '">' +
'<span class="wp-nav-badge" style="background:' + wpBadgeColor(p) + '">' + esc(wpBadgeText(p)) + '</span>' +
'<span class="wp-nav-body">' +
'<span class="wp-nav-num">' + esc(p.number || '(unnumbered)') + '</span>' +
'<span class="wp-nav-subj">' + esc(p.subject || 'untitled') + '</span>' +
'<span class="wp-nav-triage">' + triage + '</span>' +
holdLine +
'</span>' +
'<span class="wp-nav-state"><span class="wp-nav-dot ' + dot + '"></span>' + esc(state) + '</span>' +
'</button>';

View File

@@ -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 {

224
tests/triage_check.py Normal file
View File

@@ -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())