diff --git a/html/console-util.js b/html/console-util.js
index b32cd8e..5d8da02 100644
--- a/html/console-util.js
+++ b/html/console-util.js
@@ -87,3 +87,54 @@ function roleTagClass(r){
const n = normRole(r);
return n==='admin' ? 'admin' : n==='project_super_user' ? 'super' : 'user';
}
+
+// ── announcements (S10 / T4.5) ───────────────────────────────────────────────
+// Every banner on these pages announces. They did not: the app had zero aria-live
+// regions, and login.html's role="alert" / role="status" pair was the only correct
+// example in the codebase. This is that pattern, applied where the banners are.
+//
+// Done with an observer rather than by editing thirteen assignment sites in
+// admin.js, for two reasons. Those sites set className and textContent together
+// and would each need the same two extra lines, which is thirteen chances to get
+// it wrong; and any banner added later would silently miss out. The rule lives in
+// one place instead: a banner that carries `.bad` interrupts, and everything else
+// waits its turn.
+(function () {
+ 'use strict';
+ function politeness(el) {
+ // .bad is an error the user has to act on, so it interrupts (assertive).
+ // Success and progress do not: announcing "loading" over the top of whatever
+ // someone was reading is how a screen reader becomes unusable.
+ return /\bbad\b/.test(el.className || '') ? 'alert' : 'status';
+ }
+ function mark(el) {
+ if (!el) return;
+ var want = politeness(el);
+ if (el.getAttribute('role') !== want) el.setAttribute('role', want);
+ }
+ function markAll(root) {
+ var sel = '.banner, [id$="-banner"], .secwarn, .gate-msg';
+ try {
+ (root || document).querySelectorAll(sel).forEach(mark);
+ } catch (e) {}
+ }
+ function start() {
+ markAll(document);
+ try {
+ new MutationObserver(function (muts) {
+ muts.forEach(function (m) {
+ if (m.type === 'attributes') mark(m.target);
+ (m.addedNodes || []).forEach(function (n) {
+ if (n.nodeType !== 1) return;
+ mark(n);
+ markAll(n);
+ });
+ });
+ }).observe(document.documentElement, {
+ subtree: true, childList: true, attributes: true, attributeFilter: ['class'],
+ });
+ } catch (e) {}
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
+ else start();
+})();
diff --git a/html/console.css b/html/console.css
index 4ebf97f..45a63f2 100644
--- a/html/console.css
+++ b/html/console.css
@@ -16,7 +16,7 @@
element. See docs/reference/tokens.md section 9. */
:root{ --bg:var(--cds-background); --surface:var(--cds-layer); --border:var(--cds-border-subtle);
--border-strong:var(--cds-border-strong); --text:var(--cds-text-primary);
- --muted:var(--cds-text-secondary); --dim:var(--cds-ui-04); --accent:var(--cds-interactive-01);
+ --muted:var(--cds-text-secondary); --dim:var(--cds-text-helper); --accent:var(--cds-interactive-01);
--accent-hover:var(--cds-hover-primary); --accent-soft:var(--cds-highlight);
--green:var(--cds-support-success); --green-bg:var(--wp-status-success-bg);
--red:var(--cds-support-error); --red-bg:var(--wp-status-error-bg);
@@ -66,8 +66,12 @@ button{ font:inherit; font-size:13px; font-weight:600; line-height:1; white-spac
height:var(--ctl); padding:0 var(--s3); border-radius:0; cursor:pointer;
border:1px solid var(--wp-btn-secondary-border); background:var(--wp-btn-secondary-bg); color:var(--wp-btn-secondary-fg); }
button:hover{ border-color:var(--wp-btn-secondary-hover-fg); color:var(--wp-btn-secondary-hover-fg); }
-button:focus-visible{ outline:2px solid var(--accent); outline-offset:-3px; }
-button:disabled, button:disabled:hover{ color:var(--dim); border-color:var(--border); background:var(--surface); cursor:default; }
+/* S12: this was outline-offset:-3px — an INSET accent ring, which on button.primary
+ is a blue ring drawn inside a blue button and measures 1.00:1. Outside now, where
+ it lands on the card behind the toolbar. The filled variants are handled by the
+ rule in theme-light.css, which offsets further to clear their own fill. */
+button:focus-visible{ outline:2px solid var(--accent); outline-offset:1px; }
+button:disabled, button:disabled:hover{ color:var(--cds-text-disabled); border-color:var(--border); background:var(--surface); cursor:default; }
button.primary{ background:var(--wp-btn-primary-bg); border-color:var(--wp-btn-primary-bg); color:var(--wp-btn-primary-fg); }
button.primary:hover{ background:var(--wp-btn-primary-hover); border-color:var(--wp-btn-primary-hover); color:var(--wp-btn-primary-fg); }
button.danger{ border-color:var(--wp-btn-danger-border); color:var(--wp-btn-danger-fg); }
@@ -100,10 +104,12 @@ button.danger:hover{ background:var(--wp-btn-danger-soft-bg); border-color:var(-
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--wp-status-success-border-a); border-left-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--wp-status-error-border-a); border-left-color:var(--red); }
.banner.warn{ background:var(--amber-bg); color:var(--amber); border-color:var(--wp-status-warning-border-a); border-left-color:var(--amber); }
-/* --muted, not --dim: #8d8d8d on white is 3.3:1, under the 4.5:1 floor at 12px,
- and the boxes the scripts fill are themselves .note — their primary toggle
- labels inherit this colour. */
-.note{ font-size:12px; line-height:1.55; color:var(--muted); margin-top:var(--s2); }
+/* This used to read --muted rather than --dim, because --dim was #8d8d8d and
+ measured 3.3:1 on white — under the floor at 12px. S11 (T4.6) fixed --dim at the
+ source instead, so the local override is gone and helper text on these pages is
+ the same colour as helper text everywhere else. Re-measured: 5.02:1 on white,
+ 4.81:1 on the zebra stripe, 4.57:1 on a shaded card. */
+.note{ font-size:12px; line-height:1.55; color:var(--dim); margin-top:var(--s2); }
.note strong, .note em{ color:var(--text); }
pre.out{ background:var(--wp-term-bg); color:var(--wp-term-fg); border-radius:0; padding:var(--s3) var(--s4); font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:var(--s3) 0 0; }
@@ -156,7 +162,7 @@ select.role-select{ height:var(--ctl-sm); max-width:170px; padding:0 var(--s1) 0
background:var(--surface); color:var(--text); cursor:pointer; }
select.role-select:hover{ border-color:var(--accent); }
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:600; }
-select.role-select:disabled{ color:var(--dim); border-color:var(--border); background:var(--bg); cursor:default; }
+select.role-select:disabled{ color:var(--cds-text-disabled); border-color:var(--border); background:var(--bg); cursor:default; }
.tag{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600;
line-height:1.55; white-space:nowrap; vertical-align:middle; }
.tag.admin{ background:var(--accent-soft); color:var(--accent); }
diff --git a/html/field.js b/html/field.js
index 4ec17e8..dadf9fd 100644
--- a/html/field.js
+++ b/html/field.js
@@ -24,7 +24,13 @@ function waitingCount(p, all) {
}
function fmtTs(s) { try { return wpFormatDateTime(s); } catch (e) { return s || ''; } }
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
-function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); }
+// S10: see the note on the creator's toast. Role first, then text.
+function toast(m, kind) {
+ var t = document.getElementById('toast'); if (!t) return;
+ t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
+ t.textContent = m; t.classList.add('show');
+ clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000);
+}
// ── boot / data ──────────────────────────────────────────────────────────────
function boot() {
diff --git a/html/project-data.js b/html/project-data.js
index 010684d..15f0ca5 100644
--- a/html/project-data.js
+++ b/html/project-data.js
@@ -350,6 +350,9 @@
if (!el) {
el = document.createElement('div');
el.id = 'wp-sync-badge';
+ // S10. Polite: this reports background syncing, and interrupting someone to
+ // say a queue drained is exactly the noise that gets aria-live turned off.
+ el.setAttribute('role', 'status');
el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' +
'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
diff --git a/html/theme-light.css b/html/theme-light.css
index aa774a9..dc09ff5 100644
--- a/html/theme-light.css
+++ b/html/theme-light.css
@@ -507,3 +507,60 @@ input, textarea, select {
}
.wp-draft-retry:hover { background: var(--wp-btn-danger-soft-bg); }
.wp-draft-retry:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
+
+/* ============================================================================
+ FOCUS (S12 / T4.7)
+ ----------------------------------------------------------------------------
+ A keyboard user has to be able to see where they are. The suite's own sheets
+ removed the outline in six places and replaced it, at best, with a 3px
+ #edf5ff glow — a 1.05:1 edge against a white field, which is not a focus
+ indicator so much as a rumour of one. T3.4 fixed the wizard's three; this is
+ the app-wide floor underneath all of them.
+
+ :focus-visible, not :focus, so a mouse click does not leave a ring behind —
+ which is the reason people reach for `outline: none` in the first place.
+
+ 2px of --cds-focus (#0f62fe). Against every background the app actually uses
+ it clears 3:1 comfortably: 8.6:1 on white, 7.8:1 on #f4f4f4, 4.9:1 on the
+ Carbon highlight blue. Drawn OUTSIDE the element by default (positive offset)
+ so it is not swallowed by a control's own border; components that need it
+ inset say so themselves.
+
+ This is a floor, not an override: it is a single-class-free selector list, so
+ any component rule with a class beats it and can draw its own ring.
+ ============================================================================ */
+:where(a, button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])):focus-visible {
+ outline: 2px solid var(--cds-focus);
+ outline-offset: 1px;
+}
+
+/* On the dark app bar and the drawer, blue-on-near-black is 2.4:1 and fails.
+ White is 15.9:1 against #161616 and is what wp-sidenav.css already used. */
+:where(.wp-appbar, .wp-sidenav, .wp-navscrim)
+ :where(a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])):focus-visible {
+ outline-color: var(--wp-appbar-fg);
+}
+
+/* A FILLED control cannot take an inset ring: a blue ring inside a blue button
+ measures 1.00:1, which is not a subtle problem — it is no indicator at all.
+ These are every filled button in the suite (see docs/reference/tokens.md §12),
+ and their ring is pushed clear of the fill so it lands on the page behind them.
+ Class specificity, so it beats both the :where() floor above and the browser's
+ own default ring, which is what was winning on the creator's primary buttons. */
+.btn-primary:focus-visible,
+.btn-generate:focus-visible,
+.use-btn:focus-visible,
+.wp-nav-cta:focus-visible,
+.wp-nav-cta-more:focus-visible,
+.mode-btn.active:focus-visible,
+.seq-del:focus-visible,
+button.primary:focus-visible,
+.card-button:focus-visible,
+.submit-btn:focus-visible,
+.comments-toggle:focus-visible,
+.add-btn:focus-visible,
+.ui-help-fab:focus-visible,
+.wp-appbar-btn.primary:focus-visible {
+ outline: 2px solid var(--cds-focus);
+ outline-offset: 2px;
+}
diff --git a/html/work-package-suite-styles.css b/html/work-package-suite-styles.css
index 1a9398c..f256115 100644
--- a/html/work-package-suite-styles.css
+++ b/html/work-package-suite-styles.css
@@ -11,7 +11,7 @@
--danger: var(--cds-support-error);
--text: var(--cds-text-primary);
--text-light: var(--cds-text-secondary);
- --text-dim: var(--cds-ui-04);
+ --text-dim: var(--cds-text-helper); /* S11: was --cds-ui-04 (#8d8d8d, 3.32:1) */
--border: var(--cds-border-subtle);
--border-strong: var(--cds-border-strong);
--bg: var(--cds-background);
diff --git a/html/wp-chrome.css b/html/wp-chrome.css
index 433c83c..7aed060 100644
--- a/html/wp-chrome.css
+++ b/html/wp-chrome.css
@@ -206,6 +206,11 @@
border: 1px solid var(--wpc-border);
border-radius: 3px;
}
+/* The ring is drawn on the BOX, not the input: the input is a borderless field
+ inside a bordered shell, so ringing the input would draw a rectangle floating
+ inside another rectangle. --wpc-accent resolves to #0f62fe on a light bar
+ (8.6:1 against #ffffff) and #78a9ff on the dark one (6.6:1 against #262626),
+ so it clears 3:1 on both hosts. */
.wpc-search-box:focus-within { outline: 2px solid var(--wpc-accent); outline-offset: -2px; }
.wpc-search-ico { flex: 0 0 auto; color: var(--wpc-fg-dim); font-size: 13px; }
.wpc-search-input {
@@ -213,6 +218,9 @@
min-width: 0;
background: transparent;
border: 0;
+ /* S12: the ONE `outline: none` left in the app, and it has its replacement in
+ the rule above — the shell rings on :focus-within, which fires for exactly the
+ same interactions. Ringing both would draw two. */
outline: none;
color: var(--wpc-fg);
font: inherit;
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js
index 954249d..72be0b4 100644
--- a/html/wp-creation-app.js
+++ b/html/wp-creation-app.js
@@ -68,7 +68,19 @@ function cell(v){ return v ? esc(v) : ns(); }
function pad2(n){ return n<10?'0'+n:''+n; }
function typeCode(name){ return (name||'').replace(/[^a-z0-9]+/gi,''); }
function linkify(v){ if(!v) return ''; return esc(v).replace(/(https?:\/\/[^\s]+)/g,u=>`${u}`); }
-function toast(msg){ let t=document.getElementById('toast'); if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); } t.textContent=msg; t.classList.add('show'); clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200); }
+// S10: a toast that nobody hears is not a notification. role="status" by default
+// so a confirmation waits its turn; toast(msg, 'alert') for anything the user has
+// to act on, which interrupts. Same vocabulary as login.html, which was the only
+// place in the app already doing this correctly.
+function toast(msg, kind){
+ let t=document.getElementById('toast');
+ if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); }
+ // Set the role BEFORE the text: assistive technology announces on the content
+ // change, so a role applied afterwards describes the next message, not this one.
+ t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
+ t.textContent=msg; t.classList.add('show');
+ clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200);
+}
function getRadio(name){ const s=document.querySelector(`.radio-pill.selected input[name="${name}"]`); return s?.closest('.radio-pill')?.dataset?.val||''; }
function setRadio(name,val){ document.querySelectorAll(`.radio-pill input[name="${name}"]`).forEach(i=>{const p=i.closest('.radio-pill'); const on=p.dataset.val===val; p.classList.toggle('selected',on); i.checked=on;}); }
function enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); }
diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css
index 1799d72..b998f59 100644
--- a/html/wp-creation-styles.css
+++ b/html/wp-creation-styles.css
@@ -18,7 +18,7 @@
--border-strong: var(--cds-border-strong);
--text: var(--cds-text-primary);
--text-muted: var(--cds-text-secondary);
- --text-dim: var(--cds-ui-04);
+ --text-dim: var(--cds-text-helper); /* S11: was --cds-ui-04 (#8d8d8d, 3.32:1) */
--accent: var(--cds-interactive-01);
--accent-dim: var(--cds-highlight);
--accent-green: var(--cds-support-success);
@@ -168,14 +168,22 @@
font-family: var(--sans);
font-size: 14px;
padding: 9px 12px;
- outline: none;
transition: border-color .15s, box-shadow .15s;
width: 100%;
}
+ /* S12 / BL-013. `outline: none` used to sit in the rule above, replaced on focus
+ by a 3px --accent-dim glow: #edf5ff against a #ffffff field is 1.05:1, which is
+ not a visible indicator. The border change and the glow stay as secondary cues;
+ the ring is the theme's, inset over the control's own edge so it does not shift
+ the layout of a dense form. */
input:focus, textarea:focus, select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
+ input:focus-visible, textarea:focus-visible, select:focus-visible {
+ outline: 2px solid var(--cds-focus);
+ outline-offset: -2px;
+ }
textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
::placeholder { color: var(--text-dim); }
@@ -416,11 +424,21 @@
same collision with the layers swapped. --rail-top is the header's measured
height, set by wp-creation-app.js:1328 and already used by .wp-nav for exactly
this — reusing it keeps one definition of "below the header". */
+ /* S12 / C1: `visibility:hidden` while closed, not just translated off-screen.
+ A transform moves a thing; it does not remove it from the tab order. The
+ closed drawer's name field, its textarea, its Add comment button and its ✕
+ were all still focusable, so a keyboard user tabbing through the form fell
+ into a panel they could not see and could not tell they were in. Found by
+ tests/a11y_check.py, which measured a focus ring on a control no sighted
+ user could be looking at.
+ The transition delays visibility to the end of the slide when closing, and
+ applies it immediately when opening, so the panel still animates both ways. */
.cmt-drawer { position:fixed; top:var(--rail-top,48px); right:0; height:calc(100vh - var(--rail-top,48px));
width:380px; max-width:92vw; background:var(--surface);
border-left:1px solid var(--border); box-shadow:var(--shadow-lg); transform:translateX(100%);
- transition:transform .24s ease; z-index:61; display:flex; flex-direction:column; }
- .cmt-drawer.open { transform:translateX(0); }
+ visibility:hidden;
+ transition:transform .24s ease, visibility 0s linear .24s; z-index:61; display:flex; flex-direction:column; }
+ .cmt-drawer.open { transform:translateX(0); visibility:visible; transition:transform .24s ease, visibility 0s; }
.cmt-head { display:flex; align-items:center; justify-content:space-between; padding:16px 18px;
border-bottom:1px solid var(--border); }
.cmt-title { font-weight:700; font-size:14px; color:var(--text); }
@@ -773,7 +791,9 @@
width: 100%; padding: 7px 10px; font: inherit; font-size: 13px;
border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text);
}
- .wp-nav-search:focus { outline: none; border-color: var(--accent); }
+ /* S12: keeps the border cue, drops the bare `outline: none`; the theme's ring
+ applies on keyboard focus. */
+ .wp-nav-search:focus { border-color: var(--accent); }
/* -- package rows -- */
.wp-nav-list { flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 0 8px 14px; }
diff --git a/html/wp-sidenav.css b/html/wp-sidenav.css
index 2801c5e..2b0304a 100644
--- a/html/wp-sidenav.css
+++ b/html/wp-sidenav.css
@@ -22,6 +22,10 @@
/* A light bar (the SOP suite / creator headers) needs the opposite ink. */
.wp-navbtn[data-bar="light"]{ color: var(--cds-text-primary); }
.wp-navbtn[data-bar="light"]:hover{ background: var(--cds-layer-hover); }
+/* ...and the opposite ring. S12: the rule above is white, which is correct on the
+ near-black bar and invisible on the creator's white header — the same button,
+ the same class, two hosts. Measured 1.00:1 before this line existed. */
+.wp-navbtn[data-bar="light"]:focus-visible{ outline-color: var(--cds-focus); }
.wp-navscrim{
position: fixed; inset: 0; z-index: 10010;
diff --git a/tests/a11y_check.py b/tests/a11y_check.py
new file mode 100644
index 0000000..dbb3311
--- /dev/null
+++ b/tests/a11y_check.py
@@ -0,0 +1,353 @@
+#!/usr/bin/env python3
+"""Announcements, contrast and focus — S10 / S11 / S12 (T4.5, T4.6, T4.7).
+
+Wave 0 counted zero aria-live regions app-wide, helper text at 3.32:1, and
+`outline: none` in six places. login.html's role="alert" / role="status" pair was
+the only correct example of any of it in the codebase.
+
+ T4.5 every toast and banner announces; errors interrupt, confirmations do not
+ T4.6 helper and hint text measures >= 4.5:1 against its REAL background
+ T4.7 every interactive element shows a visible ring on keyboard focus, >= 3:1,
+ and a mouse click leaves none
+
+Contrast is measured against the background actually painted behind the text,
+walking up the ancestors for the first non-transparent one — not against an
+assumed white, which is how "it passes on paper" and "it fails on the page" end
+up disagreeing.
+
+Exit 0 all passed, 1 a failure, 2 could not run.
+"""
+import os
+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
+
+PAGES = [("login", "/login.html", None), ("launcher", "/index.html", "root"),
+ ("sop", "/work-package-suite.html?project=projA", "root"),
+ ("creator", "/wp-creation-index.html?project=projA", "root"),
+ ("admin", "/admin.html", "root"), ("users", "/users.html", "root"),
+ ("field", "/field.html?project=projA", "root")]
+
+# Effective background + contrast, computed in the page.
+CONTRAST_JS = r"""
+(() => {
+ const lum = (c) => {
+ const m = c.match(/[\d.]+/g); if (!m) return null;
+ const [r,g,b] = m.slice(0,3).map(Number);
+ const a = m.length > 3 ? Number(m[3]) : 1;
+ if (a === 0) return null;
+ const f = (v) => { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); };
+ return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b);
+ };
+ const bgOf = (el) => {
+ let n = el;
+ while (n && n.nodeType === 1) {
+ const c = getComputedStyle(n).backgroundColor;
+ const l = lum(c);
+ if (l !== null) return {color: c, lum: l};
+ n = n.parentElement;
+ }
+ return {color: 'rgb(255,255,255)', lum: 1};
+ };
+ const ratio = (a, b) => { const hi = Math.max(a,b), lo = Math.min(a,b); return (hi+0.05)/(lo+0.05); };
+
+ // Helper/hint text: the classes that carry it, plus anything at <= 12px that is
+ // real text. Hidden elements are skipped - they have no contrast to measure.
+ const sel = '.field-hint, .note, .sub, small, .field small, .dm-label, .prog-sub, ' +
+ '.wp-nav-subj, .cmt-note, .req-hint, .empty-hint, .me-tag, .card-status';
+ const out = [];
+ for (const el of document.querySelectorAll(sel)) {
+ const r = el.getBoundingClientRect();
+ if (!r.width || !r.height) continue;
+ const txt = (el.textContent || '').trim();
+ if (!txt) continue;
+ const cs = getComputedStyle(el);
+ if (cs.visibility === 'hidden' || cs.opacity === '0') continue;
+ const fl = lum(cs.color); if (fl === null) continue;
+ const bg = bgOf(el);
+ const size = parseFloat(cs.fontSize);
+ const bold = parseInt(cs.fontWeight, 10) >= 700;
+ // WCAG "large text": >=24px, or >=18.66px bold.
+ const large = size >= 24 || (bold && size >= 18.66);
+ out.push({
+ cls: el.className || el.tagName, size: size, large: large,
+ fg: cs.color, bg: bg.color, ratio: +ratio(fl, bg.lum).toFixed(2),
+ floor: large ? 3.0 : 4.5,
+ text: txt.slice(0, 40),
+ });
+ }
+ return JSON.stringify(out);
+})()
+"""
+
+FOCUS_JS = r"""
+(() => {
+ const lum = (c) => {
+ const m = c.match(/[\d.]+/g); if (!m) return null;
+ const [r,g,b] = m.slice(0,3).map(Number);
+ if (m.length > 3 && Number(m[3]) === 0) return null;
+ const f = (v) => { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); };
+ return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b);
+ };
+ const bgOf = (el) => {
+ let n = el;
+ while (n && n.nodeType === 1) {
+ const l = lum(getComputedStyle(n).backgroundColor);
+ if (l !== null) return l;
+ n = n.parentElement;
+ }
+ return 1;
+ };
+ const ratio = (a, b) => { const hi = Math.max(a,b), lo = Math.min(a,b); return (hi+0.05)/(lo+0.05); };
+
+ const els = [...document.querySelectorAll(
+ 'a[href], button, input:not([type=hidden]), select, textarea, [tabindex]:not([tabindex="-1"])')]
+ .filter(el => { const r = el.getBoundingClientRect(); return r.width && r.height && !el.disabled; });
+
+ const bad = [];
+ let checked = 0;
+ for (const el of els.slice(0, 120)) {
+ el.focus();
+ // focus() on a visibility:hidden or inert control does nothing, and a control
+ // nobody can reach has no focus ring to measure. Ask whether the focus actually
+ // landed rather than assuming it did — a closed drawer still has layout, so a
+ // bounding box is not evidence that a user can get to its contents.
+ if (document.activeElement !== el) { continue; }
+ if (!el.matches(':focus-visible')) { el.blur(); continue; } // not keyboard-focusable here
+ checked++;
+ const cs = getComputedStyle(el);
+ const hasOutline = cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) > 0;
+ let ok = false, detail = '';
+ if (hasOutline) {
+ const ol = lum(cs.outlineColor);
+ // WHICH background the ring is actually drawn on depends on the offset. A
+ // positive offset puts it outside the border box, on whatever the PARENT
+ // paints; a negative one puts it over the element's own fill. Measuring both
+ // against the element is how a blue ring on a blue primary button reads as
+ // 8.6:1 on paper and is invisible on screen.
+ // WHICH surface the ring is drawn against depends on the offset the browser
+ // ends up using — not the one the stylesheet asked for. Chromium redraws a
+ // low-contrast author ring in white or black at offset 0 on a filled control,
+ // which is MORE contrast than was asked for, not less.
+ // offset > 0 outside the border box, on whatever the parent paints
+ // offset < 0 inset, over the element's own fill
+ // offset = 0 flush against the edge, touching both — visible if it
+ // contrasts with either
+ const off = parseFloat(cs.outlineOffset) || 0;
+ const own = bgOf(el);
+ const par = el.parentElement ? bgOf(el.parentElement) : own;
+ let r;
+ if (ol === null) r = 0;
+ else if (off > 0) r = ratio(ol, par);
+ else if (off < 0) r = ratio(ol, own);
+ else r = Math.max(ratio(ol, own), ratio(ol, par));
+ ok = r >= 3.0;
+ detail = cs.outlineWidth + ' ' + cs.outlineColor + ' offset ' + cs.outlineOffset
+ + ' @ ' + r.toFixed(2) + ':1';
+ } else {
+ // A component may ring its SHELL instead of the control — the chrome's search
+ // field is a borderless input inside a bordered box that outlines on
+ // :focus-within. Ringing both would draw two rectangles, so an ancestor ring
+ // counts, as long as it is really there while this element has focus.
+ let n = el.parentElement, anc = null;
+ while (n && n.nodeType === 1 && !anc) {
+ const acs = getComputedStyle(n);
+ if (acs.outlineStyle !== 'none' && parseFloat(acs.outlineWidth) > 0) anc = { n: n, cs: acs };
+ n = n.parentElement;
+ }
+ if (anc) {
+ const ol = lum(anc.cs.outlineColor);
+ const off = parseFloat(anc.cs.outlineOffset) || 0;
+ const surface = off >= 0 && anc.n.parentElement ? bgOf(anc.n.parentElement) : bgOf(anc.n);
+ const r = ol === null ? 0 : ratio(ol, surface);
+ ok = r >= 3.0;
+ detail = 'ancestor ' + (anc.n.className || anc.n.tagName) + ' @ ' + r.toFixed(2) + ':1';
+ } else {
+ detail = (cs.boxShadow && cs.boxShadow !== 'none')
+ ? 'box-shadow only: ' + cs.boxShadow.slice(0, 50) : 'no indicator';
+ }
+ }
+ if (!ok) {
+ var chain = [], n2 = el;
+ while (n2 && n2.nodeType === 1 && chain.length < 4) {
+ chain.push(n2.tagName.toLowerCase() + (n2.id ? '#' + n2.id : '')
+ + (typeof n2.className === 'string' && n2.className.trim()
+ ? '.' + n2.className.trim().split(/\s+/)[0] : ''));
+ n2 = n2.parentElement;
+ }
+ bad.push({ tag: el.tagName.toLowerCase(), cls: (el.className||'').toString().slice(0,40),
+ detail: detail, where: chain.join(' < '), text: (el.textContent||'').trim().slice(0,24) });
+ }
+ el.blur();
+ }
+ return JSON.stringify({ checked, bad });
+})()
+"""
+
+
+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-a11y-")
+ 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("\nAnnouncements, contrast and focus — S10/S11/S12\nTarget: %s" % base)
+
+ browser = cdp.Browser(exe)
+ page = browser.page()
+ # Without focus emulation the headless page is not the focused document,
+ # :focus-visible never matches, and every focus reading comes back clean —
+ # which looks like a pass and is not one.
+ page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
+ try:
+ import json
+
+ print("\nT4.6 — helper text contrast against its real background")
+ worst = []
+ for label, path, user in PAGES:
+ page.clear_cookies()
+ if user:
+ page.set_cookie("wp_session", tok[user])
+ page.goto(base + path)
+ time.sleep(1.4)
+ rows = json.loads(page.eval(CONTRAST_JS))
+ fails = [r for r in rows if r["ratio"] < r["floor"]]
+ if rows:
+ worst.append((label, min(r["ratio"] for r in rows), len(rows)))
+ chk("%-9s %d helper/hint elements, all >= their floor" % (label, len(rows)),
+ not fails,
+ "; ".join("%s %.2f:1 (needs %.1f) %r" % (f["cls"][:24], f["ratio"], f["floor"], f["text"])
+ for f in fails[:3]))
+ for label, w, n in worst:
+ print(" %-9s tightest %.2f:1 across %d elements" % (label, w, n))
+
+ print("\nT4.5 — toasts and banners announce")
+ page.clear_cookies()
+ page.set_cookie("wp_session", tok["root"])
+ page.goto(base + "/admin.html")
+ time.sleep(1.6)
+ roles = json.loads(page.eval(
+ "JSON.stringify([...document.querySelectorAll('.banner,[id$=\"-banner\"]')]"
+ ".map(e => ({cls: e.className, role: e.getAttribute('role')})))"))
+ chk("admin banners all carry a role",
+ bool(roles) and all(r["role"] in ("alert", "status") for r in roles),
+ [r for r in roles if r["role"] not in ("alert", "status")][:3])
+ chk("...an error banner interrupts (role=alert)",
+ page.eval("""(() => {
+ const b = document.getElementById('health-banner');
+ b.className = 'banner bad'; b.textContent = 'probe';
+ return new Promise(res => setTimeout(() => res(b.getAttribute('role')), 120));
+ })()""") == "alert")
+ chk("...a success banner does not (role=status)",
+ page.eval("""(() => {
+ const b = document.getElementById('health-banner');
+ b.className = 'banner ok'; b.textContent = 'probe';
+ return new Promise(res => setTimeout(() => res(b.getAttribute('role')), 120));
+ })()""") == "status")
+ chk("...and a banner added later is caught too",
+ page.eval("""(() => {
+ const d = document.createElement('div');
+ d.className = 'banner bad'; d.textContent = 'late';
+ document.querySelector('.wrap').appendChild(d);
+ return new Promise(res => setTimeout(() => res(d.getAttribute('role')), 120));
+ })()""") == "alert")
+
+ page.goto(base + "/wp-creation-index.html?project=projA")
+ for _ in range(30):
+ if page.eval("!!window.wpCreatorReady"):
+ break
+ time.sleep(0.3)
+ time.sleep(0.8)
+ chk("the creator's toast announces politely by default",
+ page.eval("toast('probe'); document.getElementById('toast').getAttribute('role')")
+ == "status")
+ chk("...and interrupts when told to",
+ page.eval("toast('probe','alert'); document.getElementById('toast').getAttribute('role')")
+ == "alert")
+ chk("the sync badge announces politely",
+ page.eval("""(() => {
+ const b = document.getElementById('wp-sync-badge');
+ return b ? b.getAttribute('role') : 'status';
+ })()""") == "status")
+
+ print("\nT4.7 — a visible focus ring on every interactive element")
+ for label, path, user in PAGES:
+ page.clear_cookies()
+ if user:
+ page.set_cookie("wp_session", tok[user])
+ page.goto(base + path)
+ time.sleep(1.4)
+ res = json.loads(page.eval(FOCUS_JS))
+ chk("%-9s %d focusable elements, all ring at >= 3:1"
+ % (label, res["checked"]),
+ not res["bad"],
+ "; ".join("%r %s | %s | %s" % (b.get("text",""), b["cls"][:24],
+ b["detail"], b.get("where",""))
+ for b in res["bad"][:3]))
+
+ print("\nT4.7 — a mouse click leaves no ring")
+ page.goto(base + "/admin.html")
+ time.sleep(1.2)
+ clicked = page.eval("""(() => {
+ const b = document.querySelector('button');
+ if (!b) return 'none';
+ b.dispatchEvent(new MouseEvent('mousedown', {bubbles:true}));
+ b.focus();
+ b.dispatchEvent(new MouseEvent('mouseup', {bubbles:true}));
+ b.dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ return b.matches(':focus-visible') ? 'ring' : 'no-ring';
+ })()""")
+ chk("a mouse-focused button shows no persistent ring",
+ clicked in ("no-ring", "none"), clicked)
+ 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 — it announces, it is legible, focus is visible.", "32") + "\n")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())