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