diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md
index 9de4147..e157e8b 100644
--- a/docs/reference/file-map.md
+++ b/docs/reference/file-map.md
@@ -278,6 +278,7 @@ Wave 7 adds these:
python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone? 39 checks
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
```
**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 78e7ca8..f95aec9 100644
--- a/html/wp-creation-app.js
+++ b/html/wp-creation-app.js
@@ -1006,8 +1006,11 @@ function updateReleaseBanner(){
txt+=` `;
}
}
- b.innerHTML=`
${txt}
`;
- updateStickyStatus();
+ // A live region announces every rewrite, and this runs on saves and loads
+ // that change nothing - only touch the DOM when the message actually moved.
+ const html=`
${txt}
`;
+ if(b._rbLast!==html){ b._rbLast=html; b.innerHTML=html; }
+ updateConstraintBadge();
}
// Releasing with an unclosed predecessor is allowed but must be explained. The
// reason rides on the package (data.gateOverride) and the server writes it to the
@@ -1489,7 +1492,7 @@ function setFormChrome(on){
if(save) save.style.display = on ? 'flex' : 'none';
document.body.classList.toggle('has-sticky-save', !!on);
document.body.classList.toggle('has-sec-rail', !!on);
- if(on){ initSectionNavAutoHide(); buildSectionRail(); updateStickyStatus(); }
+ if(on){ initSectionNavAutoHide(); buildSectionRail(); updateConstraintBadge(); }
else positionSectionNav();
}
// ── SECTION STRUCTURE (F6 / D3) ───────────────────────────────────────────────
@@ -1653,6 +1656,7 @@ function buildSectionRail(){
const want = (typeof WPUrl !== 'undefined' && WPUrl.get && WPUrl.get('section')) || '';
const target = cards.some(c => c.id === want) ? want : (cards[0] && cards[0].id) || '';
secApplyOpenState(target);
+ updateConstraintBadge();
secBindSpy();
}
@@ -1766,13 +1770,24 @@ function initSectionNavAutoHide(){
try { setExpandAll(localStorage.getItem(SEC_EXPAND_KEY) === '1'); } catch(e){}
}
-function updateStickyStatus(){
- const el=document.getElementById('sticky-status'); if(!el) return;
- const r=readiness(); const st=getRadio('status');
- if(st==='Issue'){ el.className='sticky-status ss-hold'; el.textContent=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened`; }
- else if(r.ready){ el.className='sticky-status ss-ready'; el.textContent=`✓ Release-ready — all ${r.total} constraints cleared`; }
- else if(r.constraintsClear){ el.className='sticky-status ss-notready'; el.textContent=`⚠ Waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`; }
- else { el.className='sticky-status ss-notready'; el.textContent=`⚠ ${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`+(r.blocking.length?` · ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`:''); }
+// A2: the count badge on the Constraints rail entry. The rail is sticky at every
+// width, so this is the thing a user can see from ANY section - the top banner
+// is the warning, this is the pointer to it. It replaced updateStickyStatus(),
+// which wrote the same warning a second time into the sticky save bar - and
+// destroyed the B5 autosave indicator mounted in the same span every time it did.
+function updateConstraintBadge(){
+ const btn=document.querySelector('.sec-rail-item[data-sec="constraint-card"]');
+ if(!btn) return;
+ let badge=btn.querySelector('.sec-badge');
+ if(!badge){
+ badge=document.createElement('span');
+ badge.className='sec-badge';
+ btn.appendChild(badge);
+ }
+ const open=readiness().open;
+ badge.hidden = open===0;
+ badge.textContent = open || '';
+ btn.setAttribute('aria-label', 'Constraints'+(open?` — ${open} open`:''));
}
// ── LOCATION (CR-004 / T6.3) ─────────────────────────────────────────────────
diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html
index a4d41c1..29e5452 100644
--- a/html/wp-creation-index.html
+++ b/html/wp-creation-index.html
@@ -96,8 +96,12 @@
-
-
+
+
+
diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css
index 7ab05aa..5934fce 100644
--- a/html/wp-creation-styles.css
+++ b/html/wp-creation-styles.css
@@ -762,6 +762,12 @@
.sec-rail-item:hover { color: var(--accent); border-color: var(--border); }
/* Not colour alone: the current entry is bolder, keeps a left marker and is the
one carrying aria-current. */
+ /* A2: the open-constraint count. Red chip + a number - the number is the
+ content, so the state is never colour-only. Sized to stay legible at 390px. */
+ .sec-badge { display:inline-block; margin-left:8px; min-width:18px; padding:1px 6px;
+ border-radius:9px; background:var(--red); color:var(--cds-text-on-color);
+ font-size:12px; font-weight:700; line-height:16px; text-align:center; }
+ .sec-badge[hidden] { display:none; }
.sec-rail-item.is-current {
color: var(--accent); font-weight: 700;
background: var(--accent-dim);
@@ -1017,9 +1023,6 @@
border-top:1px solid var(--border-strong); box-shadow:var(--wp-shadow-sticky); }
.sticky-save .sticky-status{ font-size:13px; font-weight:600; }
.sticky-save .sticky-actions{ display:flex; gap:10px; }
- .ss-ready{ color:var(--accent-green); }
- .ss-notready{ color:var(--accent-amber); }
- .ss-hold{ color:var(--red); }
body.has-sticky-save .main{ padding-bottom:74px; }
/* Disciplines + per-discipline scope */
diff --git a/tests/warning_check.py b/tests/warning_check.py
new file mode 100644
index 0000000..b990407
--- /dev/null
+++ b/tests/warning_check.py
@@ -0,0 +1,244 @@
+#!/usr/bin/env python3
+"""Does the constraint warning appear once, and can you still tell? — A2, T7.4.
+
+`A2`: the same not-release-ready warning rendered three times — the top banner,
+the sticky save bar, and a static hint under the status control. The banner
+stays; the count moves to a badge on the Constraints rail entry, which is
+sticky at every width, so it is visible from any section without repeating the
+sentence anywhere.
+
+The sticky bar's copy was worse than noise: it was written with textContent
+into the SAME span the B5 autosave indicator mounts into, so every count
+change destroyed the indicator. Check 4 pins the survival.
+
+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
+
+
+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)
+
+
+# Every element whose OWN text says "not release-ready" (or the on-hold variant),
+# deduplicated to the outermost matches. This is the A2 count.
+WARN_COUNT_JS = """(() => {
+ const rx = /Not release-ready|On hold —/;
+ const hits = [...document.querySelectorAll('body *')].filter(el =>
+ rx.test(el.textContent || '') &&
+ ![...el.children].some(ch => rx.test(ch.textContent || '')));
+ return JSON.stringify(hits.map(el =>
+ el.tagName.toLowerCase() + (el.id ? '#'+el.id : '') +
+ (el.className && typeof el.className === 'string'
+ ? '.'+el.className.trim().split(/\\s+/)[0] : '')));
+})()"""
+
+
+def warn_sites(page):
+ return json.loads(page.eval(WARN_COUNT_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-warn-")
+ 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)
+ # Native dialogs block Runtime.evaluate outright - the release-ready offer
+ # fires when the last open constraint changes, so stub before touching one.
+ page.eval("""(() => {
+ window.prompt = () => null;
+ window.alert = () => {};
+ window.confirm = () => false;
+ })()""")
+
+ # ── 1. one warning ────────────────────────────────────────────────────
+ print("\n1. the warning appears once")
+ sites = warn_sites(page)
+ chk("with constraints open, the not-release-ready warning renders exactly once",
+ len(sites) == 1, ascii_(sites))
+ chk("...and it is the top banner",
+ len(sites) == 1 and sites[0].startswith("div.rb-inner"), ascii_(sites))
+ chk("the static hint under the status control is gone",
+ page.eval("""(() => ![...document.querySelectorAll('.field-hint')]
+ .some(h => /until all constraints are cleared/.test(h.textContent)))()"""))
+ chk("the sticky save bar carries no readiness text",
+ page.eval("""(() => !/release-ready|constraint/i.test(
+ (document.getElementById('sticky-status')||{textContent:''}).textContent))()"""))
+
+ # ── 2. the badge ─────────────────────────────────────────────────────
+ print("\n2. the badge on the Constraints rail entry")
+ open_now = page.eval("readiness().open")
+ badge = lambda: json.loads(page.eval("""JSON.stringify((() => {
+ const b = document.querySelector(
+ '.sec-rail-item[data-sec="constraint-card"] .sec-badge');
+ if (!b) return null;
+ const cs = getComputedStyle(b);
+ const r = b.getBoundingClientRect();
+ return {text: b.textContent, hidden: b.hidden, display: cs.display,
+ size: parseFloat(cs.fontSize), w: r.width, h: r.height,
+ top: r.top, bottom: r.bottom};
+ })())"""))
+ b = badge()
+ chk("the badge exists on the rail entry and shows the open count",
+ b is not None and b["text"] == str(open_now) and not b["hidden"], ascii_(b))
+ chk("...and the entry SAYS it for a screen reader",
+ "open" in (page.eval("""(document.querySelector(
+ '.sec-rail-item[data-sec="constraint-card"]')||{getAttribute:()=>''})
+ .getAttribute('aria-label')""") or ""))
+
+ constraint_btn(page, 0, "cleared")
+ b2 = badge()
+ chk("clearing a constraint moves the count", b2 and b2["text"] == str(open_now - 1),
+ ascii_(b2))
+ n = page.eval("pkgConstraints.length")
+ for i in range(n):
+ constraint_btn(page, i, "na")
+ dismiss_dialogs(page)
+ b3 = badge()
+ chk("at zero open the badge disappears instead of showing a calm-looking 0",
+ b3 is None or b3["hidden"] or b3["display"] == "none", ascii_(b3))
+ constraint_btn(page, 0, "open")
+
+ # ── 3. visible from any section, badge legible, both widths ──────────
+ print("\n3. out of sight is not out of mind")
+ page.eval("gotoSection('signoff-card')")
+ settle(0.8)
+ page.eval("window.scrollTo(0, document.documentElement.scrollHeight)")
+ settle(0.6)
+ # "Out of view" means the INFORMATION is out of view: the page is barely
+ # two screens at rest, so the section's collapsed HEADER is almost always
+ # somewhere on screen - but a collapsed header says nothing about open
+ # counts. What must be simultaneously true: the constraint table is not
+ # visible, the banner is not visible, and the badge is.
+ info_off = page.eval("""(() => {
+ const t = document.getElementById('constraint-body');
+ const tr = t ? t.getBoundingClientRect() : null;
+ const tableGone = !t || t.offsetParent === null || tr.height === 0
+ || tr.bottom < 0 || tr.top > innerHeight;
+ const w = document.querySelector('#release-banner .rb-inner');
+ const wr = w ? w.getBoundingClientRect() : null;
+ const bannerGone = !w || wr.bottom < 0 || wr.top > innerHeight;
+ return tableGone && bannerGone;
+ })()""")
+ b4 = badge()
+ vis = b4 and not b4["hidden"] and b4["top"] >= 0 and b4["bottom"] <= 900
+ chk("1440px: table and banner both out of view, the badge still on screen",
+ info_off and bool(vis), ascii_((info_off, b4)))
+
+ page.viewport(390, 900, mobile=True)
+ settle(0.8)
+ page.eval("window.scrollTo(0, document.documentElement.scrollHeight)")
+ settle(0.6)
+ b5 = badge()
+ chk("390px: the badge renders at a legible size (>= 12px text, >= 16px tall)",
+ b5 and not b5["hidden"] and b5["size"] >= 12 and b5["h"] >= 16, ascii_(b5))
+ chk("390px: scrolled to the bottom, the badge is still on screen (sticky rail)",
+ b5 and b5["top"] >= 0 and b5["bottom"] <= 900, ascii_(b5))
+ page.viewport(1440, 900)
+ settle(0.6)
+
+ # ── 4. the live region, and the indicator it used to destroy ─────────
+ print("\n4. announcements")
+ chk("the banner is a polite live region (role=status, the login.html pattern)",
+ page.eval("(document.getElementById('release-banner')||{}).getAttribute"
+ "&&document.getElementById('release-banner').getAttribute('role')")
+ == "status")
+ page.eval("document.querySelector('#release-banner .rb-inner').dataset.probe='x'")
+ page.eval("updateReleaseBanner()")
+ settle(0.3)
+ chk("a no-op refresh does NOT rewrite the region (no phantom announcements)",
+ page.eval("(document.querySelector('#release-banner .rb-inner')||{dataset:{}})"
+ ".dataset.probe") == "x")
+ before = page.eval("document.querySelector('#release-banner .rb-inner').textContent")
+ constraint_btn(page, 1, "open")
+ after = page.eval("document.querySelector('#release-banner .rb-inner').textContent")
+ chk("a count change rewrites the region, which is what announces it",
+ before != after and page.eval(
+ "(document.querySelector('#release-banner .rb-inner')||{dataset:{}})"
+ ".dataset.probe") != "x",
+ ascii_((before, after)))
+ chk("the B5 autosave indicator survived every one of those updates",
+ page.eval("!!document.querySelector('#sticky-status #wp-draft-status')"))
+
+ 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())