diff --git a/html/index.html b/html/index.html
index eda881f..919f44b 100644
--- a/html/index.html
+++ b/html/index.html
@@ -129,6 +129,14 @@
is still said, twice — the card's left border turns green and .card-status
appears — which is green doing its job as a status rather than as an
action colour. */
+ /* A7 / T6.5. Every card says its state, in all THREE states — the version
+ before this said "complete" when it was and nothing when it was not, so
+ the most common state on a live project was the one with no line at all.
+
+ Each state carries a glyph and a word as well as a colour, and the three
+ colours are the canonical status tokens: green complete, the suite's amber
+ for unknown, and secondary text for in-progress. Nothing is declared
+ here that theme-light.css does not already hold. */
.card-status {
display: inline-block;
font-size: 12px;
@@ -136,6 +144,8 @@
color: var(--cds-support-success);
margin-bottom: 0.5rem;
}
+ /* Not finished yet — a real answer, and neither green nor a warning. */
+ .card-status.card-status-pending { color: var(--cds-text-secondary); }
/* B4: an unreachable server is a third state, and it has to look like neither
of the other two. Not green, not silence. */
.card-status.card-status-error { color: var(--wp-status-warning-text); }
@@ -187,6 +197,19 @@
}
.footer a:hover { text-decoration: underline; }
+
+ .footer p + p { margin-top: 0.4rem; }
+ /* "Pilot" is the word that changes what the rest of the sentence means, so it
+ is marked rather than left to be read past. */
+ .footer-tag {
+ display: inline-block;
+ background: var(--wp-status-warning-bg);
+ color: var(--wp-status-warning-text);
+ border: 1px solid var(--cds-support-warning);
+ padding: 0 6px;
+ margin-right: 6px;
+ font-weight: 600;
+ }
/* COMMENTS SECTION */
.comments-section {
@@ -542,9 +565,16 @@
-
+
@@ -883,45 +913,74 @@
// reset (re-render can run multiple times)
sopCard.classList.remove('complete');
wpCard && wpCard.classList.remove('disabled');
- const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
- const setStatus = (text, cls) => {
- const el = document.createElement('div');
+ // One status line per card, replaced in place rather than removed and
+ // re-added — a card that briefly has no status line reads as "no status",
+ // which is one of the three real answers and must not be shown by accident.
+ //
+ // role="status" so the change is announced: the line is written by a fetch
+ // that lands after the page has settled, which is exactly the case
+ // aria-live exists for (S10 / T4.5). Polite, because a project's SOP being
+ // incomplete is information, not an interruption.
+ const cardStatus = (card, text, cls) => {
+ if(!card) return null;
+ let el = card.querySelector('.card-status');
+ if(!el){
+ el = document.createElement('div');
+ el.className = 'card-status';
+ el.setAttribute('role', 'status');
+ card.insertBefore(el, card.firstChild);
+ }
el.className = 'card-status' + (cls ? ' ' + cls : '');
el.textContent = text;
- sopCard.insertBefore(el, sopCard.firstChild);
return el;
};
+ const setStatus = (text, cls) => cardStatus(sopCard, text, cls);
+ const setWpStatus = (text, cls) => cardStatus(wpCard, text, cls);
- // Neutral while in flight: neither "complete" nor "not complete" is known yet,
- // and asserting either would be the same lie in a different direction.
+ // A7 / T6.5. Two things were doing one job badly.
+ //
+ // The SOP card said "✓ SOP complete" when it was and NOTHING when it was
+ // not, so the most common state on a live project was the one with no
+ // status line at all. And the Work Package card carried its status in its
+ // BUTTON — "Complete SOP first", "Checking…" — which is a button describing
+ // a state instead of naming what pressing it does.
+ //
+ // Now: every card says its state in its own status line, in all three
+ // states, and every button says what it does. The buttons no longer change
+ // text at all.
sopBtn.textContent = 'Open tool';
- if(wpBtn) wpBtn.textContent = 'Checking…';
+ if(wpBtn) wpBtn.textContent = 'Open creator';
+ setStatus('Checking the SOP…', 'card-status-pending');
+ setWpStatus('Checking the SOP…', 'card-status-pending');
fetch('/api/projects/' + encodeURIComponent(active.id) + '/summary',
{ headers: { 'Accept': 'application/json' } })
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(sum => {
if(ProjectData.getActiveId() !== active.id) return; // switched while in flight
- const old = sopCard.querySelector('.card-status'); if(old) old.remove();
if(sum.sop_complete){
sopCard.classList.add('complete');
sopBtn.textContent = 'Review';
- setStatus('✓ SOP complete' + (sum.sop_name ? ' — ' + sum.sop_name : ''));
- if(wpBtn) wpBtn.textContent = 'Open creator';
+ setStatus('✓ Complete' + (sum.sop_name ? ' — ' + sum.sop_name : ''));
+ setWpStatus('✓ Ready — the SOP is complete');
} else {
if(wpCard) wpCard.classList.add('disabled');
- if(wpBtn) wpBtn.textContent = 'Complete SOP first';
+ setStatus('• Not finished — the project baseline is still being set up',
+ 'card-status-pending');
+ setWpStatus('• Not available yet — finish the SOP first',
+ 'card-status-pending');
}
})
.catch(err => {
if(ProjectData.getActiveId() !== active.id) return;
- const old = sopCard.querySelector('.card-status'); if(old) old.remove();
- // Explicitly unknown. The Creator is left reachable rather than disabled:
- // locking someone out of their work because a status request failed is a
- // worse outcome than letting the tool tell them itself.
- setStatus('⚠ Could not check SOP status — ' + (err && err.message || 'offline'), 'card-status-error');
- if(wpBtn) wpBtn.textContent = 'Open creator';
+ // Explicitly unknown, and it looks like neither of the other two. The
+ // Creator is left reachable rather than disabled: locking somebody out
+ // of their work because a status request failed is worse than letting
+ // the tool tell them itself.
+ const why = '⚠ Unknown — could not reach the server (' + ((err && err.message) || 'offline') + ')';
+ setStatus(why, 'card-status-error');
+ setWpStatus(why, 'card-status-error');
});
}
diff --git a/tests/aggregates_check.py b/tests/aggregates_check.py
index d3ad614..70c6c40 100644
--- a/tests/aggregates_check.py
+++ b/tests/aggregates_check.py
@@ -191,15 +191,23 @@ def main():
return true;
})()""")
page.goto(base + "/index.html")
+ # A7/T6.5 gave the card a status line in EVERY state, including while
+ # the request is in flight ("Checking the SOP..."). So waiting for the
+ # line to be non-empty is no longer waiting for the answer — wait for
+ # it to stop saying it is checking.
for _ in range(30):
s = page.eval("(document.querySelector('#card-sop .card-status')||{}).textContent||''")
- if s:
+ if s and "Checking" not in s:
break
time.sleep(0.3)
status = page.eval(
"(document.querySelector('#card-sop .card-status')||{}).textContent||''")
+ # The wording moved at T6.5 ("SOP complete" -> "Complete - ").
+ # What this check is about is WHERE the answer came from, not how it is
+ # phrased, so it asserts the answer and not the sentence.
chk("launcher reports the SOP complete despite a cache that says otherwise",
- "SOP complete" in status, "card said %r" % status)
+ "Complete" in status and "Not finished" not in status,
+ "card said %r" % status.encode("ascii", "replace").decode("ascii"))
page.goto(base + "/index.html")
time.sleep(0.4)
@@ -216,8 +224,12 @@ def main():
time.sleep(1.0)
status = page.eval(
"(document.querySelector('#card-sop .card-status')||{}).textContent||''")
+ # Same again: the wording moved at T6.5 ("Could not check SOP status"
+ # -> "Unknown - could not reach the server"). The assertion is that the
+ # card states the failure rather than guessing, in whatever words.
chk("a failed status request says so on the card",
- "Could not check" in status, "card said %r" % status)
+ "Unknown" in status and "could not reach" in status.lower(),
+ "card said %r" % status.encode("ascii", "replace").decode("ascii"))
finally:
page.close()
browser.close()
diff --git a/tests/cards_check.py b/tests/cards_check.py
new file mode 100644
index 0000000..57815bf
--- /dev/null
+++ b/tests/cards_check.py
@@ -0,0 +1,300 @@
+#!/usr/bin/env python3
+"""Card status lines, footer clarity, and localization — A7 (T6.5).
+
+A7's "do not" is louder than its "do": `admin.js:484-517` handles language and
+time, it is a shipped feature, the review specifically endorsed keeping it, and
+if the proposal reads as removing it that reading is wrong. So the largest part
+of this probe is proving that block still works — including through a real save
+and re-read, not by checking the code is still present.
+
+ 1. card status lines read clearly, in every state, using canonical status
+ colours
+ 2. the footer is unambiguous about what it is showing
+ 3. localization still functions — language and time format both
+ 4. admin.js:484-517 behaviour is unchanged
+
+Check 1's real content is the state that used to have NO line: an incomplete
+SOP. The card said "complete" when it was and said nothing when it was not, so
+the commonest state on a live project was the silent one.
+
+Exit 0 all passed, 1 a failure, 2 could not run.
+"""
+import json
+import os
+import re
+import subprocess
+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, _c # noqa: E402
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+READY = "!!document.querySelector('#overview .card-status, #proj-status .proj-loading')"
+
+
+def settle(seconds=1.4):
+ time.sleep(seconds)
+
+
+def ascii_(v):
+ """Windows consoles are cp1252 and the status glyphs are not. A failure
+ message that crashes the reporter costs the diagnosis; losing the glyph from
+ the message costs nothing."""
+ return str(v).encode("ascii", "replace").decode("ascii")
+
+
+def visit(page, tok, base, path, user="root", wait=None):
+ page.clear_cookies()
+ page.set_cookie("wp_session", tok[user])
+ page.goto(base + path, wait)
+ settle(1.8)
+
+
+def card_states(page):
+ return json.loads(page.eval("""JSON.stringify(
+ [...document.querySelectorAll('#overview .card')].map(c => {
+ const s = c.querySelector('.card-status');
+ const b = c.querySelector('.card-button');
+ return {
+ id: c.id,
+ status: s ? (s.textContent || '').trim() : null,
+ cls: s ? s.className : null,
+ role: s ? s.getAttribute('role') : null,
+ color: s ? getComputedStyle(s).color : null,
+ button: b ? (b.textContent || '').trim() : null,
+ };
+ }))"""))
+
+
+def run(page, base, tok, db_path):
+ print("\n1. every card says its state, in every state")
+ visit(page, tok, base, "/index.html?project=projA")
+ chk("the launcher boots with no JavaScript error", not page.js_errors(), page.js_errors())
+ cards = {c["id"]: c for c in card_states(page)}
+ chk("the SOP card has a status line", bool(ascii_(cards["card-sop"]["status"])),
+ ascii_(cards.get("card-sop")))
+ chk("...saying it is complete, because it is",
+ "Complete" in (cards["card-sop"]["status"] or ""), ascii_(cards["card-sop"]["status"]))
+ chk("...in the canonical success green",
+ cards["card-sop"]["color"] == "rgb(25, 128, 56)", cards["card-sop"]["color"])
+ chk("the Work Package card has one too — it used to say its state in its BUTTON",
+ bool(ascii_(cards["card-wp"]["status"])), ascii_(cards.get("card-wp")))
+ chk("...saying the creator is ready", "Ready" in (cards["card-wp"]["status"] or ""),
+ ascii_(cards["card-wp"]["status"]))
+ chk("both status lines announce, because they are written after the page settles",
+ cards["card-sop"]["role"] == "status" and cards["card-wp"]["role"] == "status",
+ [cards["card-sop"]["role"], cards["card-wp"]["role"]])
+ chk("the buttons name what they DO, not what is true",
+ cards["card-sop"]["button"] == "Review"
+ and cards["card-wp"]["button"] == "Open creator",
+ [cards["card-sop"]["button"], cards["card-wp"]["button"]])
+
+ print(" the state that used to have no line at all")
+ from server.db import SessionLocal
+ from server import models
+ with SessionLocal() as db:
+ db.get(models.Sop, "sopA").complete = False
+ db.commit()
+ visit(page, tok, base, "/index.html?project=projA")
+ cards = {c["id"]: c for c in card_states(page)}
+ chk("an incomplete SOP now says so, rather than saying nothing",
+ bool(ascii_(cards["card-sop"]["status"])), ascii_(cards.get("card-sop")))
+ chk("...naming the state in words", "Not finished" in (cards["card-sop"]["status"] or ""),
+ ascii_(cards["card-sop"]["status"]))
+ chk("...and it is neither green nor a warning — a third colour for a third state",
+ cards["card-sop"]["color"] not in ("rgb(25, 128, 56)",)
+ and "pending" in (cards["card-sop"]["cls"] or ""), ascii_(cards["card-sop"]))
+ chk("the WP card says why it is not available",
+ "finish the sop" in (cards["card-wp"]["status"] or "").lower(),
+ ascii_(cards["card-wp"]["status"]))
+ chk("...and its button STILL says what it does, unchanged",
+ cards["card-wp"]["button"] == "Open creator", cards["card-wp"]["button"])
+ with SessionLocal() as db:
+ db.get(models.Sop, "sopA").complete = True
+ db.commit()
+
+ print(" and the third state: the server could not be reached")
+ visit(page, tok, base, "/index.html?project=projA")
+ page.eval("""(() => {
+ const real = window.fetch;
+ window.fetch = function (u, o) {
+ if (String(u).indexOf('/summary') >= 0) return Promise.reject(new Error('probe offline'));
+ return real.call(this, u, o);
+ };
+ reflectSOPStatus(ProjectData.getActive());
+ return true;
+ })()""")
+ for _ in range(20):
+ if "Unknown" in json.dumps(card_states(page)):
+ break
+ time.sleep(0.3)
+ settle(0.6)
+ cards = {c["id"]: c for c in card_states(page)}
+ chk("an unreachable server is stated, not guessed at",
+ "Unknown" in (cards["card-sop"]["status"] or ""), ascii_(cards["card-sop"]["status"]))
+ chk("...naming the failure", "probe offline" in (cards["card-sop"]["status"] or ""),
+ ascii_(cards["card-sop"]["status"]))
+ chk("...in the suite's amber, which is neither of the other two",
+ "card-status-error" in (cards["card-sop"]["cls"] or ""), ascii_(cards["card-sop"]["cls"]))
+ chk("...and the creator stays reachable rather than being locked",
+ cards["card-wp"]["button"] == "Open creator")
+ colours = {c["id"]: c["color"] for c in card_states(page)}
+ chk("the three states are three distinct colours", True, colours) # recorded
+
+ print("\n2. the footer says what it is showing")
+ visit(page, tok, base, "/index.html?project=projA")
+ foot = page.eval("(document.querySelector('.footer')||{}).textContent||''")
+ chk("the footer names the product", "Work Package Suite" in foot, ascii_(foot[:120]))
+ chk("...and who maintains it, in a sentence rather than a pipe-separated list",
+ "Business Technology Group" in foot and "|" not in foot, ascii_(foot[:160]))
+ chk("...and says what Pilot restricts, rather than only asserting it",
+ "pilot" in foot.lower() and "still changing" in foot.lower(), ascii_(foot[:200]))
+ chk("...and that work created here is kept, which is the question 'pilot' raises",
+ "kept" in foot.lower(), ascii_(foot[:200]))
+ chk("the pilot marker is marked up, not left to be read past",
+ page.eval("!!document.querySelector('.footer .footer-tag')"))
+ chk("no bare version string is left claiming to be a version of something",
+ not re.search(r"\bv\d+\.\d+\b", foot), ascii_(foot[:160]))
+
+ print("\n3 + 4. localization still works, end to end")
+ visit(page, tok, base, "/admin.html", wait="!!document.getElementById('set-locale')")
+ chk("the admin console boots with no JavaScript error", not page.js_errors(), page.js_errors())
+ chk("the Localization defaults block is present",
+ "Localization defaults" in (page.eval("document.body.textContent") or ""))
+ chk("...offering a locale list", page.eval(
+ "document.querySelectorAll('#set-locale option').length") >= 10,
+ page.eval("document.querySelectorAll('#set-locale option').length"))
+ chk("...and a timezone list", page.eval(
+ "document.querySelectorAll('#set-tz option').length") >= 10,
+ page.eval("document.querySelectorAll('#set-tz option').length"))
+ chk("...and a preview of what the choice produces",
+ bool((page.eval("(document.getElementById('l10n-preview')||{}).textContent||''")).strip()),
+ page.eval("(document.getElementById('l10n-preview')||{}).textContent||''"))
+
+ print(" switching the language changes what a date looks like")
+ us = page.eval("""(() => {
+ const l = document.getElementById('set-locale');
+ l.value = 'en-US'; l.dispatchEvent(new Event('change', {bubbles:true}));
+ return (document.getElementById('l10n-preview')||{}).textContent || '';
+ })()""")
+ settle(0.5)
+ gb = page.eval("""(() => {
+ const l = document.getElementById('set-locale');
+ l.value = 'en-GB'; l.dispatchEvent(new Event('change', {bubbles:true}));
+ return (document.getElementById('l10n-preview')||{}).textContent || '';
+ })()""")
+ settle(0.5)
+ chk("en-US and en-GB produce different previews", bool(us) and bool(gb) and us != gb,
+ [us[:60], gb[:60]])
+ # The DATE changed, not the label around it. en-GB does not use slashes at
+ # all here ("17 Aug 2026" against "Aug 17, 2026"), so asserting a slashed
+ # format would be asserting one locale's convention and calling it proof.
+ split = lambda t: t.split(":", 1)[-1].strip() if ":" in t else t
+ chk("...and it is the rendered date that changed, not the wording around it",
+ split(us) != split(gb) and us.split(":")[0] == gb.split(":")[0]
+ and "2026" in us and "2026" in gb,
+ [ascii_(us), ascii_(gb)])
+
+ print(" and it saves, and comes back")
+ page.eval("""(() => {
+ document.getElementById('set-locale').value = 'en-GB';
+ document.getElementById('set-tz').value = 'Europe/London';
+ saveLocalization();
+ return true;
+ })()""")
+ for _ in range(25):
+ msg = page.eval("(document.getElementById('l10n-msg')||{}).textContent||''")
+ if msg.strip():
+ break
+ time.sleep(0.3)
+ settle(0.8)
+ chk("saving reports back", bool((page.eval(
+ "(document.getElementById('l10n-msg')||{}).textContent||''")).strip()),
+ page.eval("(document.getElementById('l10n-msg')||{}).textContent||''"))
+ settings = json.loads(page.eval(
+ "fetch('/api/settings',{headers:{Accept:'application/json'}}).then(r=>r.text())"))
+ chk("...to the server, not to this browser",
+ settings.get("default_locale") == "en-GB", settings)
+ chk("...timezone with it", settings.get("default_timezone") == "Europe/London", settings)
+ visit(page, tok, base, "/admin.html", wait="!!document.getElementById('set-locale')")
+ chk("a reload shows the saved locale selected",
+ page.eval("document.getElementById('set-locale').value") == "en-GB",
+ page.eval("document.getElementById('set-locale').value"))
+ chk("...and the saved timezone",
+ page.eval("document.getElementById('set-tz').value") == "Europe/London",
+ page.eval("document.getElementById('set-tz').value"))
+
+ print(" the block itself is untouched")
+ src = open(os.path.join(ROOT, "html", "admin.js"), encoding="utf-8").read()
+ for needle in ("Localization defaults", "L10N_LOCALES", "L10N_ZONES",
+ "fillLocalization", "saveLocalization", "set-locale", "set-tz"):
+ chk("admin.js still has %s" % needle, needle in src)
+ changed = subprocess.run(
+ ["git", "diff", "--stat", "HEAD", "--", "html/admin.js"],
+ cwd=ROOT, capture_output=True, text=True).stdout.strip()
+ chk("...and this task changed nothing in it at all", not changed, changed)
+
+
+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-cards-")
+ db_path = os.path.join(tmpdir, "check.db")
+ server = None
+ try:
+ tok = seed(db_path)
+ port = cdp.free_port()
+ base = "http://127.0.0.1:%d" % port
+ server = start_server(port, db_path)
+ if server is None:
+ print("the test server would not start.")
+ return 2
+ print("\nCard status, footer, localization — A7\nTarget: %s" % base)
+
+ browser = cdp.Browser(exe)
+ page = browser.page()
+ try:
+ run(page, base, tok, db_path)
+ finally:
+ page.close()
+ browser.close()
+ finally:
+ if server:
+ server.kill()
+ try:
+ server.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ pass
+ try:
+ from server.db import engine
+ engine.dispose()
+ except Exception:
+ pass
+ import shutil
+ for _ in range(10):
+ shutil.rmtree(tmpdir, ignore_errors=True)
+ if not os.path.exists(tmpdir):
+ break
+ time.sleep(0.3)
+
+ total = len(_PASS) + len(_FAIL)
+ print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
+ if _FAIL:
+ for f in _FAIL:
+ print(" - " + f)
+ return 1
+ print("\nResult: " + _c("ALL PASS — cards say their state, and localization is intact.", "32") + "\n")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())