From e3de3c7c0081754235ab63c8756d37a99a086afa Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Wed, 19 Aug 2026 11:14:33 -0700 Subject: [PATCH] T7.8 - B6: the wizard's actions ride the viewport, not the page On the Constraints and Sequence steps the proposal's beside-the-fields actions meant scrolling to save. The wizard's .step-navigation bar is now position:sticky at the viewport bottom - the creator's sticky-bar pattern, adapted rather than duplicated: sticky (not the creator's fixed) because the bar lives inside the wizard's grid column, keeps its slot in the flow, and therefore CANNOT obscure a field at any width - no padding arithmetic to get wrong. Opaque background, top border and the shared --wp-shadow-sticky token so content scrolling beneath it reads as beneath it. The T4.4/B5 save-state indicator already mounted in this bar; it now rides the viewport with the buttons, which is the "shows the save state" criterion. Verification (each probe run alone): NEW tests/sticky_bar_check.py 12/12 - a primary action inside the viewport on all 12 steps unscrolled at a 700px viewport (short on purpose: both named steps genuinely overflow, asserted), still visible fully scrolled, nothing obscured at 390px. Regression: stepper_check ALL PASS. Items: B6 Co-Authored-By: Claude Fable 5 --- docs/reference/file-map.md | 1 + html/work-package-suite-styles.css | 16 ++- tests/sticky_bar_check.py | 164 +++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 tests/sticky_bar_check.py diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index 860f3eb..88452c5 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -282,6 +282,7 @@ python tests/warning_check.py # A2 - one warning, a badge from anywhere python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 40 checks python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks +python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks ``` **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live diff --git a/html/work-package-suite-styles.css b/html/work-package-suite-styles.css index 08e8732..16dfbfd 100644 --- a/html/work-package-suite-styles.css +++ b/html/work-package-suite-styles.css @@ -741,15 +741,29 @@ body { /* .field-error is declared once, in theme-light.css — the launcher's create form and T5.8's step validation use the same component. */ -/* NAVIGATION */ +/* NAVIGATION + B6 / T7.8: sticky, the creator's pattern. On the Constraints and Sequence + steps the proposal's beside-the-fields actions meant scrolling to save; the + bar now rides the bottom of the viewport while a tall step scrolls, and sits + in its natural place on short ones. position:sticky (not the creator's + fixed) because the bar lives inside the wizard's grid column: sticky keeps + its slot in the flow, so it can never obscure a field at any width - there + is nothing to pad around. The T4.4 save-state indicator already mounts in + this bar (B5) and rides along. */ .step-navigation { grid-column: 2; grid-row: 2; + position: sticky; + bottom: 0; + z-index: 30; display: flex; gap: 1rem; justify-content: space-between; + align-items: center; padding: 1.5rem; background: var(--bg-card); + border-top: 1px solid var(--border); + box-shadow: var(--wp-shadow-sticky); border-radius: 0; box-shadow: var(--shadow); } diff --git a/tests/sticky_bar_check.py b/tests/sticky_bar_check.py new file mode 100644 index 0000000..337142a --- /dev/null +++ b/tests/sticky_bar_check.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Is Save reachable without scrolling, on every step? — B6, T7.8. + +The proposal put actions beside the fields; on the Constraints and Sequence +steps that meant scrolling to save. The wizard's step-navigation bar is now +position:sticky at the viewport bottom - the creator's pattern, adapted to the +wizard's grid (sticky keeps its slot in the flow, so it cannot obscure a field +at any width). The T4.4 indicator already lives in the bar and rides along. + +Driven at a deliberately short viewport (700px) so the tall steps genuinely +overflow - a bar that is only reachable because the page happens to fit would +pass a taller run and prove nothing. + +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 stepper_check import dismiss_dialogs # noqa: E402 + + +def ascii_(v, n=280): + return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n] + + +def settle(seconds=0.5): + time.sleep(seconds) + + +BAR_JS = """(() => JSON.stringify((() => { + const bar = document.querySelector('.step-navigation'); + if (!bar) return null; + const r = bar.getBoundingClientRect(); + const cs = getComputedStyle(bar); + const btn = [...bar.querySelectorAll('button')].find(b => b.offsetParent !== null); + const br = btn ? btn.getBoundingClientRect() : null; + return {pos: cs.position, top: r.top, bottom: r.bottom, + btnVisible: !!br && br.top >= 0 && br.bottom <= innerHeight, + indicator: !!bar.querySelector('#wp-draft-status-sop')}; +})()))()""" + + +def bar(page): + return json.loads(page.eval(BAR_JS)) + + +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-bar-") + db_path = os.path.join(tmpdir, "check.db") + server = None + browser = None + try: + tok = seed(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(1280, 700) + page.goto(base + "/work-package-suite.html?tab=sop") + dismiss_dialogs(page) + settle(2.5) + chk("the wizard boots", page.eval("typeof goToStep === 'function'")) + + print("\n1. every step, top of page, no scrolling") + last = page.eval("LAST_STEP") + all_ok, detail = True, [] + for n in range(1, int(last) + 1): + # Forward movement is gated on real input; the check is about layout, + # so walk the steps the way the rail allows after marking them visited. + page.eval("currentStep = %d; updateStepUI(); window.scrollTo(0,0)" % n) + settle(0.25) + b = bar(page) + ok = bool(b) and b["btnVisible"] + all_ok = all_ok and ok + if not ok: + detail.append((n, b)) + chk("a primary action is inside the viewport on all %s steps, unscrolled" % last, + all_ok, ascii_(detail)) + + print("\n2. the two steps that caused B6") + for name, n in (("Sequence", 8), ("Constraints", 9)): + page.eval("currentStep = %d; updateStepUI(); window.scrollTo(0,0)" % n) + settle(0.3) + tall = page.eval("document.documentElement.scrollHeight > innerHeight") + b = bar(page) + chk("%s: the step overflows a 700px viewport, so the check means something" % name, + bool(tall)) + chk("%s: the bar is stuck to the viewport bottom with its button visible" % name, + b and b["pos"] == "sticky" and b["btnVisible"], ascii_(b)) + page.eval("window.scrollTo(0, document.documentElement.scrollHeight)") + settle(0.3) + b2 = bar(page) + chk("%s: still there at the bottom of the scroll" % name, + b2 and b2["btnVisible"], ascii_(b2)) + + chk("the T4.4 save-state indicator lives in the bar", bar(page)["indicator"]) + + print("\n3. 390px") + page.viewport(390, 844, mobile=True) + settle(0.6) + page.eval("currentStep = 9; updateStepUI(); window.scrollTo(0,0)") + settle(0.4) + b = bar(page) + chk("390px: the bar is present and its button reachable without scrolling", + b and b["btnVisible"], ascii_(b)) + page.eval("window.scrollTo(0, document.documentElement.scrollHeight)") + settle(0.4) + clear = json.loads(page.eval("""JSON.stringify((() => { + const bar = document.querySelector('.step-navigation').getBoundingClientRect(); + const fields = [...document.querySelectorAll( + '.step-content input, .step-content textarea, .step-content select, .step-content button')] + .filter(el => el.offsetParent !== null); + const lastEl = fields[fields.length - 1]; + const r = lastEl ? lastEl.getBoundingClientRect() : null; + return {barTop: bar.top, lastBottom: r ? r.bottom : null}; + })())""")) + chk("390px: fully scrolled, the last control sits ABOVE the bar - nothing is obscured", + clear["lastBottom"] is not None and clear["lastBottom"] <= clear["barTop"] + 1, + ascii_(clear)) + + 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())