diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index 1a75f03..ad0250d 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -305,6 +305,7 @@ python tests/icon_check.py # S6 - one icon system, no emoji, mapped python tests/helptip_check.py # C1/S8 - tips by keyboard+touch, audit greps 13 checks python tests/mobile_check.py # C2 - all 7 pages at 390px, targets + fit 24 checks python tests/archived_check.py # D7 - archived projects, admins only, frozen 15 checks +python tests/color_check.py # C4 - zero literals outside theme-light 4 checks ``` **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live diff --git a/docs/waves/backlog.md b/docs/waves/backlog.md index cd493dd..86c53e1 100644 --- a/docs/waves/backlog.md +++ b/docs/waves/backlog.md @@ -157,7 +157,7 @@ deliberately deferred. - **Suggested wave or follow-up:** `T2.2` should ship the drawer with adequate targets; `C1`'s audit at `T9.5` confirms it app-wide. -### BL-004 — `help.js` ships a 52-colour palette in a different design language +### BL-004 — CLOSED at T9.9 — `help.js` ships a 52-colour palette in a different design language - **Found during:** T3.1 - **Where:** `html/help.js:79` (the injected `${c}`); + // C4/T9.9 (and BL-008): the popup document has no stylesheet, so the theme's + // values are read from the live page and inlined - including THE blue where + // the second brand blue (#2563d6) used to be. + const tok=(name)=>getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + w.document.write(`Work Package${c}`); w.document.close(); w.print(); } @@ -2594,12 +2598,12 @@ const WP_NAV_CRITICAL_CSS = ` body{--nav-w:288px;} body.wp-nav-collapsed{--nav-w:56px;} .wp-nav{position:fixed;top:var(--rail-top,48px);left:0;bottom:0;width:var(--nav-w); - z-index:120;display:flex;flex-direction:column;overflow:hidden;background:#fbfbfc; - border-right:1px solid #e0e0e0;} + z-index:120;display:flex;flex-direction:column;overflow:hidden;background:var(--cds-layer-01); + border-right:1px solid var(--cds-border-subtle-01);} .wp-nav-list{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;} .wp-nav-item,.wp-nav-link{display:flex;align-items:center;gap:11px;width:100%; background:none;border:0;text-align:left;cursor:pointer;font:inherit;} - .wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:#fff; + .wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:var(--cds-text-on-color); display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;} .wp-nav-body{min-width:0;flex:1 1 auto;} .wp-nav-num,.wp-nav-subj{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} @@ -2691,8 +2695,19 @@ function setWpNavView(view){ // A stable colour per package so the same one is always the same swatch — the cue // Planner gets from its plan avatars. Hashed from the WP number, not its index, so // it doesn't shuffle when a package is added or deleted. -const WP_BADGE_COLORS = ['#0f62fe','#8a3ffc','#007d79','#d02670','#ba4e00', - '#1192e8','#198038','#a56eff','#9f1853','#005d5d']; +const WP_BADGE_COLORS = (() => { + // C4/T9.9: the values live in theme-light.css (--wp-chart-1..10), the ONE + // place a colour may exist; this reads them at boot. The fallback array is + // token NAMES, not values - if the theme fails to load there are no colours + // anywhere, which is the correct failure. + const cs = getComputedStyle(document.documentElement); + const out = []; + for(let k = 1; k <= 10; k++){ + const v = (cs.getPropertyValue('--wp-chart-' + k) || '').trim(); + out.push(v || 'var(--wp-chart-' + k + ')'); + } + return out; +})(); function wpBadgeColor(p){ const key = (p.number || p.id || '') + ''; let h = 0; @@ -2920,7 +2935,18 @@ function loadPackageIntoForm(p){ onClashChange(); applyKind(); buildTypePicker(); document.getElementById('wp_type').value=p.type||''; - buildCostCodes(); document.getElementById('wp_cost').value=p.cost||''; + buildCostCodes(); + // BL-019 (fixed at T9.9): setting a ' + '
Times (MIMO windows, history, notifications) are shown in this zone. ' + 'Calendar dates like a due date are never shifted.
' + - '
' + + '
' + '' + - '
' + - '' + - '' + + '
' + + '' + + '' + '
' + '
'; function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); } function msg(text, ok) { var e = document.getElementById('wp-prefs-msg'); e.style.display = 'block'; e.textContent = text; - e.style.background = ok ? '#defbe6' : '#fff1f1'; - e.style.color = ok ? '#0e6027' : '#da1e28'; + e.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; + e.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)'; } ov.addEventListener('click', function (e) { if (e.target === ov) close(); }); document.body.appendChild(ov); diff --git a/tests/baseline_shots.py b/tests/baseline_shots.py index ac96450..fbbf176 100644 --- a/tests/baseline_shots.py +++ b/tests/baseline_shots.py @@ -178,6 +178,24 @@ def main(): print(f"\n {len(pages)} page(s) x {len(widths)} width(s) -> {args.out}\n") browser = cdp.Browser() page = browser.page() + # BL-012 (fixed at T9.9): admin's captured height varied ~600px between + # runs and the creator at 1440px shifted, because live timestamps and + # relative times re-render per run. Freezing Date (and Math.random) in + # every new document makes a capture comparable with the last one. + page.ws.call("Page.addScriptToEvaluateOnNewDocument", {"source": ( + "(function(){" + "var FIXED = 1755600000000;" # 2026-08-19T10:40Z + "var RealDate = Date;" + "function FrozenDate(){ return new RealDate(FIXED); }" + "FrozenDate.now = function(){ return FIXED; };" + "FrozenDate.parse = RealDate.parse; FrozenDate.UTC = RealDate.UTC;" + "FrozenDate.prototype = RealDate.prototype;" + "window.Date = FrozenDate;" + "var seed = 42;" + "Math.random = function(){ seed = (seed * 9301 + 49297) % 233280;" + " return seed / 233280; };" + "})();" + )}) for name, filename, user, wait_for in pages: capture(page, base, tok, name, filename, user, wait_for, widths, args.out, args.label) diff --git a/tests/browser_check.py b/tests/browser_check.py index 11ff3fd..6870898 100644 --- a/tests/browser_check.py +++ b/tests/browser_check.py @@ -109,9 +109,34 @@ def seed(db_path): # Job A gets a complete SOP and two packages. Without a SOP the field view's # GET /api/sops/latest correctly answers 404 ("No SOP found") and the browser # logs it as an error — a false alarm in a page-boot check. + # BL-018 (fixed at T9.9): the production shape is {sop, state}, as + # ProjectData.pushSOP writes it. The old {"governance": ...} blob was a + # shape no code path produces, and it sent four probes' creators to the + # SOP gate until each imported set_sop() to overwrite it. db.add(models.Sop(id="sopA", project_id="projA", name="Job A SOP", number="A-1", complete=True, - data={"governance": {"disciplines": ["Mechanical", "Electrical"]}})) + data={"sop": {"meta": {"tool": "Work Package Configuration", "sample": False}, + "project": {"name": "Job A", "number": "A-1", "client": "Internal QA"}, + "governance": {"disciplines": ["Mechanical", "Electrical"], + "woFormat": "WP##-[TYPE]"}, + "woTypes": [{"name": "Conduit Install", "enabled": True}], + "sections": {}}, + "state": {"project": {"name": "Job A", "number": "A-1", "client": "Internal QA", + "division": "Internal", "site": "QA Lab"}, + "team": {"pm": "", "apm": "", "cm": "", "qm": ""}, + "teamIds": {"pm": "", "apm": "", "cm": "", "qm": ""}, + "teamMembers": [], "sections": {}, + "signoffRoles": [{"role": "Superintendent", "name": ""}, + {"role": "Foreman", "name": ""}], + "wpTypes": [{"name": "Conduit Install", "enabled": True}], + "governance": {"woformat": "WP##-[TYPE]", "wosize": "", "issuance": [], + "disciplines": ["Mechanical", "Electrical"], + "discMode": "choice", "instanceSuffix": "letter", + "sizeHoursMax": ""}, + "quality": {"qcreq": "Yes", "photo": "", "hold": ""}, + "platforms": {"tracking": "CxAlloy", "commissioning": "CxAlloy", + "trackingUrl": "", "commissioningUrl": ""}, + "constraints": [], "sequence": [], "sources": []}})) db.flush() for wid, num, subj, status in (("wpA1", "WP01-COND", "1P horn/strobe conduit", "Issued"), ("wpA2", "WP02-WIRE", "1P wire pull", "In Progress")): diff --git a/tests/color_check.py b/tests/color_check.py new file mode 100644 index 0000000..6a6e691 --- /dev/null +++ b/tests/color_check.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Is theme-light.css the only place a colour exists? — C4, T9.9. + +The token rule, finally enforceable everywhere: after this sweep no colour +literal survives outside theme-light.css - not in page stylesheets, not in the +help centre's injected styles (BL-004), not in the JS-built dialogs (BL-005), +not in the print popup. One accent blue (BL-008 - the second brand blue is +gone, .sop-inherited tints with THE blue) and one warning amber (BL-009 - the +alt token is deleted). Comments are stripped first: quoting a hex while +explaining it is not declaring one (the BL-017 lesson). + +The exceptions, in full: (a meta attribute cannot +resolve a CSS var), and rgba() shadow/overlay alphas, which are opacity +recipes, not palette entries. + +Static sweep - no browser needed. Exit 0 all passed, 1 a failure. +""" +import io +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from browser_check import chk, _PASS, _FAIL # noqa: E402 + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HTML = os.path.join(ROOT, "html") + + +def strip_comments(src, is_css): + src = re.sub(r"/\*.*?\*/", "", src, flags=re.S) + if not is_css: + src = "\n".join(re.sub(r"(?", "", src, flags=re.S) + return src + + +def main(): + print("\n1. hex literals outside theme-light.css") + offenders = [] + for name in sorted(os.listdir(HTML)): + if not name.endswith((".js", ".html", ".css")) or name == "theme-light.css": + continue + src = strip_comments(io.open(os.path.join(HTML, name), encoding="utf-8").read(), + name.endswith(".css")) + # the one exception: the browser-chrome hint, which cannot use var() + src = re.sub(r'', "", src) + for m in re.finditer(r"#[0-9a-fA-F]{3}\b|#[0-9a-fA-F]{6}\b", src): + offenders.append("%s: %s" % (name, m.group(0))) + chk("no hex colour literal outside theme-light.css; grep confirms", + not offenders, offenders[:8]) + + print("\n2. one blue, one amber") + theme = io.open(os.path.join(HTML, "theme-light.css"), encoding="utf-8").read() + code = strip_comments(theme, True) + chk("the second brand blue (#2563d6) is gone from the theme itself", + "2563d6" not in code and "37, 99, 214" not in code) + chk("the ninth amber (--wp-status-warning-text-alt) is deleted", + "--wp-status-warning-text-alt" not in code) + others = [] + for name in sorted(os.listdir(HTML)): + if name == "theme-light.css" or not name.endswith((".js", ".css", ".html")): + continue + src = strip_comments(io.open(os.path.join(HTML, name), encoding="utf-8").read(), + name.endswith(".css")) + if "warning-text-alt" in src or "2563d6" in src: + others.append(name) + chk("...and no consumer still references either", not others, others) + + 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()) diff --git a/tests/hold_check.py b/tests/hold_check.py index 5f4eab8..92162c0 100644 --- a/tests/hold_check.py +++ b/tests/hold_check.py @@ -460,8 +460,12 @@ def main(): tree = ast.parse(open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read()) bad = [] for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]: + # wp.status specifically - T8.3's coalescer sets a NOTIFICATION + # row's .status (held.status = "pending"), which is outbox state, + # not a release transition. assigns = [n for n in ast.walk(fn) if isinstance(n, ast.Assign) and any(isinstance(t, ast.Attribute) and t.attr == "status" + and isinstance(t.value, ast.Name) and t.value.id == "wp" for t in n.targets)] if not assigns: continue diff --git a/tests/stepper_check.py b/tests/stepper_check.py index cef6b0c..9c0dfcf 100644 --- a/tests/stepper_check.py +++ b/tests/stepper_check.py @@ -186,7 +186,11 @@ def source_counts(): def run(page, base, tok): - def open_wizard(query="?project=projA"): + # BL-018's fixture fix (T9.9) gave projA a PRODUCTION-shape completed SOP, + # so the wizard on projA now legitimately restores a finished configuration. + # This file's premise is a wizard someone is STARTING - projB has no SOP, + # which is that premise, honestly. + def open_wizard(query="?project=projB"): # Leave the outgoing page clean first: the unsaved-work guard from T4.3 is # doing its job, and a "Leave site?" prompt would stall the navigation. try: @@ -252,7 +256,7 @@ def run(page, base, tok): chk("...and it is the step being shown", cur and cur[0]["step"] == 1, cur) chk("...which also says so in words", cur and cur[0]["state"] == "Current step", cur) - # projA's fixture project has no division or site, so step 1 is incomplete on + # projB has no SOP at all, so step 1 is incomplete on # a fresh load and everything ahead of it is genuinely out of reach. locked = [r for r in rows if r["ariaDisabled"] == "true"] chk("with step 1 incomplete, steps 2-10 are unavailable", @@ -410,14 +414,14 @@ def run(page, base, tok): settle(page, 1.1) chk("...and Back returns to the previous step", page.eval("currentStep") == 2, page.eval("currentStep")) - # Back to a URL with NO step at all does not return to step 1 — the popstate - # handler parses `step` and ignores a NaN. That is T4.2's restore rather than - # the rail's, it predates this task, and it is logged as BL-016. - chk("...and the known step-1 gap is still exactly that, and no wider", + # BL-016, FIXED at T9.9: a step-less wizard URL is step 1. This check pinned + # the WRONG behaviour until the fix landed, and flipped with it - which was + # the plan recorded on the entry. + chk("...and Back continues to work", page.eval("(() => { history.back(); return true; })()") is True) settle(page, 1.1) - chk("...(BL-016) Back to a step-less URL leaves the step where it was", - page.eval("currentStep") == 2 and "step=" not in page.eval("location.search"), + chk("...(BL-016) Back to a step-less URL returns to step 1", + page.eval("currentStep") == 1 and "step=" not in page.eval("location.search"), [page.eval("currentStep"), page.eval("location.search")]) # ── narrow width: the gloved-hands surface ──────────────────────────────── @@ -484,7 +488,10 @@ def run(page, base, tok): chk("the page does not scroll sideways at 1440px", page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"), page.eval("[document.documentElement.scrollWidth, window.innerWidth]")) - chk("the wizard still boots without a JavaScript error", not page.js_errors(), + # projB has no SOP, so /api/sops/latest answering 404 is the CORRECT answer + # being logged by the browser, not an error in the page. + chk("the wizard still boots without a JavaScript error", + not [e for e in page.js_errors() if "sops/latest" not in e], page.js_errors())