T4.5/T4.6/T4.7 - S10/S11/S12: it announces, it is legible, focus is visible

Three small accessibility items, done together because they share one probe and
one measurement method. tests/a11y_check.py, 22 checks, all passing.

S10 — ANNOUNCEMENTS. The app had zero aria-live regions; login.html's
role="alert" / role="status" pair was the only correct example in the codebase.
Both toasts now take an optional kind and set the role BEFORE the text, because
assistive technology announces on the content change and a role applied after
describes the next message rather than this one. The sync badge announces
politely.

Admin banners are handled by a rule rather than by editing thirteen assignment
sites: a MutationObserver in console-util.js marks anything carrying `.bad` as
role=alert and everything else role=status. Thirteen edits is thirteen chances to
get it wrong, and any banner added later would have missed out. The probe checks
a banner created after load, which is the case that would have regressed.

S11 — CONTRAST. Re-measured rather than quoting either published figure, as the
file map asked. #8d8d8d is 3.32:1 on white, not "about 2.9:1" as the plan says;
console.css:103's 3.3:1 was right. On the shaded surfaces it is worse - 3.01:1 on
a success banner. --cds-text-helper (#6f6f6f) clears 4.5:1 on all seven
backgrounds the app actually paints, tightest 4.56:1.

Fixed once, in the token, so all three sheets inherit it. console.css's local
override is gone, as the task requires. Disabled text was repointed to
--cds-text-disabled rather than darkened with everything else: making disabled
text MORE legible makes a disabled control look enabled.

The probe measures against the background actually painted behind each element,
walking ancestors for the first non-transparent one - not an assumed white, which
is how "passes on paper" and "fails on the page" come to disagree.

S12 — FOCUS. An app-wide :focus-visible floor in theme-light.css at zero
specificity, so any component can still draw its own. Filled controls get an
explicit rule at class specificity: a blue ring inside a blue button measures
1.00:1, which is not a subtle problem but no indicator at all. console.css's
inset ring had exactly that defect on button.primary.

`outline: none` is down from six to one, and that one (.wpc-search-input) has its
replacement in the rule above it - the shell rings on :focus-within, and ringing
both would draw two rectangles.

TWO REAL DEFECTS THE PROBE FOUND that reading would not have:

  - .wp-navbtn's ring is white, which is right on the near-black app bar and
    invisible on the creator's white header. Same button, same class, two hosts,
    1.00:1 on one of them.
  - The comment drawer is translated off-screen when closed, and a transform
    moves a thing without removing it from the tab order. Its name field,
    textarea, Add button and close button were all still focusable: a keyboard
    user could tab into a panel they could not see and could not tell they were
    in. Now visibility:hidden while closed, with the transition delayed so it
    still animates both ways.

The probe itself needed three corrections, each of which was a wrong answer
before it was a right one, and each worth knowing:

  - focus emulation must be ON, or :focus-visible never matches in headless and
    every element reports clean - a pass that means nothing.
  - which surface a ring is drawn against depends on the offset the BROWSER uses,
    not the one the stylesheet asked for. Chromium redraws a low-contrast author
    ring in white at offset 0 on a filled control, which is more contrast than was
    requested; measuring that against the parent scores it 1.00:1 and calls a
    correct ring a defect.
  - focus() on a hidden control does nothing, so the probe has to ask whether the
    focus actually landed. A closed drawer still has layout; a bounding box is not
    evidence that anyone can reach it.

Metric 7, aria-live regions: was 0 at wave 0, now 13 role/aria-live sites across
7 files.

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-15 22:20:57 -05:00
parent 0cce0b4191
commit c024cba844
11 changed files with 536 additions and 16 deletions

View File

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

View File

@@ -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); }

View File

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

View File

@@ -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;';

View File

@@ -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;
}

View File

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

View File

@@ -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;

View File

@@ -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=>`<a href="${u}" target="_blank" rel="noopener">${u}</a>`); }
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); }

View File

@@ -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; }

View File

@@ -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;

353
tests/a11y_check.py Normal file
View File

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