T7.4 - A2: one warning, said once, visible from anywhere

The same not-release-ready warning rendered three times on the creator:
1. the release banner under the context bar   - STAYS, and is now the only one
2. updateStickyStatus() in the sticky save bar - removed
3. a static field-hint under the status radios - removed

The count moved to a badge on the Constraints rail entry (D3's rail replaced
the tabs A2's "tab count badge" referred to). The rail is position:sticky at
BOTH widths, so the badge is on screen from any section at 390px and 1440px -
measured with the constraint table AND the banner both scrolled out of view.
The badge is a number, not a colour: the count is the content, and the rail
entry carries an aria-label saying it ("Constraints - 3 open").

The banner is now role="status" (the login.html aria-live pattern, per C1) and
only rewrites when its message actually changes - a live region that repaints
on every save announces on every save.

Duplicate 2 was not just noise. It wrote the warning with textContent into
the SAME span the B5 autosave indicator mounts into, destroying the indicator
on every count change. Removing the duplicate is what fixes that; the probe
pins the indicator's survival across banner updates.

Verification (each probe run alone): NEW tests/warning_check.py 17/17.
Regressions: hold_check 50/50, form_structure_check 50/51 (the standing F6
height gap, re-measured after T7.5 as recorded at T7.2).

Items: A2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:20:12 -07:00
parent 2b597e68d8
commit 60434d452c
5 changed files with 286 additions and 17 deletions

View File

@@ -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/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/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/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 **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live

View File

@@ -1006,8 +1006,11 @@ function updateReleaseBanner(){
txt+=` <button type="button" class="rb-act" onclick="urgentOverrideRelease()">Release now — audited override</button>`; txt+=` <button type="button" class="rb-act" onclick="urgentOverrideRelease()">Release now — audited override</button>`;
} }
} }
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`; // A live region announces every rewrite, and this runs on saves and loads
updateStickyStatus(); // that change nothing - only touch the DOM when the message actually moved.
const html=`<div class="rb-inner ${cls}">${txt}</div>`;
if(b._rbLast!==html){ b._rbLast=html; b.innerHTML=html; }
updateConstraintBadge();
} }
// Releasing with an unclosed predecessor is allowed but must be explained. The // 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 // 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'; if(save) save.style.display = on ? 'flex' : 'none';
document.body.classList.toggle('has-sticky-save', !!on); document.body.classList.toggle('has-sticky-save', !!on);
document.body.classList.toggle('has-sec-rail', !!on); document.body.classList.toggle('has-sec-rail', !!on);
if(on){ initSectionNavAutoHide(); buildSectionRail(); updateStickyStatus(); } if(on){ initSectionNavAutoHide(); buildSectionRail(); updateConstraintBadge(); }
else positionSectionNav(); else positionSectionNav();
} }
// ── SECTION STRUCTURE (F6 / D3) ─────────────────────────────────────────────── // ── SECTION STRUCTURE (F6 / D3) ───────────────────────────────────────────────
@@ -1653,6 +1656,7 @@ function buildSectionRail(){
const want = (typeof WPUrl !== 'undefined' && WPUrl.get && WPUrl.get('section')) || ''; const want = (typeof WPUrl !== 'undefined' && WPUrl.get && WPUrl.get('section')) || '';
const target = cards.some(c => c.id === want) ? want : (cards[0] && cards[0].id) || ''; const target = cards.some(c => c.id === want) ? want : (cards[0] && cards[0].id) || '';
secApplyOpenState(target); secApplyOpenState(target);
updateConstraintBadge();
secBindSpy(); secBindSpy();
} }
@@ -1766,13 +1770,24 @@ function initSectionNavAutoHide(){
try { setExpandAll(localStorage.getItem(SEC_EXPAND_KEY) === '1'); } catch(e){} try { setExpandAll(localStorage.getItem(SEC_EXPAND_KEY) === '1'); } catch(e){}
} }
function updateStickyStatus(){ // A2: the count badge on the Constraints rail entry. The rail is sticky at every
const el=document.getElementById('sticky-status'); if(!el) return; // width, so this is the thing a user can see from ANY section - the top banner
const r=readiness(); const st=getRadio('status'); // is the warning, this is the pointer to it. It replaced updateStickyStatus(),
if(st==='Issue'){ el.className='sticky-status ss-hold'; el.textContent=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened`; } // which wrote the same warning a second time into the sticky save bar - and
else if(r.ready){ el.className='sticky-status ss-ready'; el.textContent=`✓ Release-ready — all ${r.total} constraints cleared`; } // destroyed the B5 autosave indicator mounted in the same span every time it did.
else if(r.constraintsClear){ el.className='sticky-status ss-notready'; el.textContent=`⚠ Waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`; } function updateConstraintBadge(){
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'}`:''); } 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) ───────────────────────────────────────────────── // ── LOCATION (CR-004 / T6.3) ─────────────────────────────────────────────────

View File

@@ -96,8 +96,12 @@
<div class="ctx-bar" id="ctx-bar"></div> <div class="ctx-bar" id="ctx-bar"></div>
<!-- RELEASE READINESS BANNER --> <!-- RELEASE READINESS BANNER - the ONE readiness warning on the page (A2).
<div class="release-banner" id="release-banner"></div> role="status" is a polite live region, the login.html pattern: a change in
the open-constraint count is announced without stealing focus. The two
duplicates this page used to render (the sticky bar's copy and the static
hint under the status control) are gone; the rail badge carries the count. -->
<div class="release-banner" id="release-banner" role="status"></div>
<!-- F6/D3: the jump-link strip stood here. It was a row of `<span onclick>` chips <!-- F6/D3: the jump-link strip stood here. It was a row of `<span onclick>` chips
that scrolled you somewhere inside a 5,399px page and then told you nothing that scrolled you somewhere inside a 5,399px page and then told you nothing
@@ -194,7 +198,6 @@
<label class="radio-pill" data-val="QC"><input type="radio" name="status"><span class="dot"></span>QC</label> <label class="radio-pill" data-val="QC"><input type="radio" name="status"><span class="dot"></span>QC</label>
<label class="radio-pill" data-val="Closed"><input type="radio" name="status"><span class="dot"></span>Closed</label> <label class="radio-pill" data-val="Closed"><input type="radio" name="status"><span class="dot"></span>Closed</label>
</div> </div>
<div class="field-hint">Cannot move to <strong>Issued</strong> or beyond until all constraints are cleared.</div>
</div> </div>
</div> </div>
<div class="notice">WP number builds automatically from these scope fields + the WP type (per the SOP naming format):</div> <div class="notice">WP number builds automatically from these scope fields + the WP type (per the SOP naming format):</div>
@@ -523,7 +526,10 @@
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div> <input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
</aside> </aside>
<!-- STICKY SAVE BAR (always-visible save + release status) --> <!-- STICKY SAVE BAR. The status span belongs to the B5 autosave indicator and
nothing else: the readiness text that used to be written here was duplicate
two of the A2 warning, and writing it via textContent destroyed the mounted
indicator every time the count changed. -->
<div class="sticky-save" id="sticky-save" style="display:none"> <div class="sticky-save" id="sticky-save" style="display:none">
<span class="sticky-status" id="sticky-status"></span> <span class="sticky-status" id="sticky-status"></span>
<div class="sticky-actions"> <div class="sticky-actions">

View File

@@ -762,6 +762,12 @@
.sec-rail-item:hover { color: var(--accent); border-color: var(--border); } .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 /* Not colour alone: the current entry is bolder, keeps a left marker and is the
one carrying aria-current. */ 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 { .sec-rail-item.is-current {
color: var(--accent); font-weight: 700; color: var(--accent); font-weight: 700;
background: var(--accent-dim); background: var(--accent-dim);
@@ -1017,9 +1023,6 @@
border-top:1px solid var(--border-strong); box-shadow:var(--wp-shadow-sticky); } 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-status{ font-size:13px; font-weight:600; }
.sticky-save .sticky-actions{ display:flex; gap:10px; } .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; } body.has-sticky-save .main{ padding-bottom:74px; }
/* Disciplines + per-discipline scope */ /* Disciplines + per-discipline scope */

244
tests/warning_check.py Normal file
View File

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