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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
164
tests/sticky_bar_check.py
Normal file
164
tests/sticky_bar_check.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user