T9.5 - C1+S8: the help-tip is real, the audit is written, BL-001 is dead
S8, finished where the plan said it would be: every .help-tip badge is a <button> - upgraded by the component itself at load (help.js), with helpTipUpgrade() for late renders, so a badge added tomorrow is born reachable. The count the task warned about came true: 15 at wave 0, 18 at the wave 6 exit, 20 at the start of this task - all 20 buttons now, and the fix being in the component is what stops the number growing again. One viewport-clamped role=tooltip bubble serves every badge: focus shows it, Escape hides it, tap toggles it, tap-elsewhere closes it - the touch path Field View's tablets never had. The injected styles now use theme tokens (four raw hexes of the S5 kind, gone). BL-001, CLOSED after three causes and nine waves: the old CSS ::after escaped its badge to the right and was the creator's last 390px overflow. The clamped bubble ends it - scrollWidth 390 vs clientWidth 390 - and frame_check's pin FLIPPED, exactly as designed: it asserted the failure until the fix landed, and now asserts the fix so a regression reopens the entry loudly. The audit (docs/reference/accessibility-audit.md), every number probe-backed: - div/span click handlers: 12/2 at wave 0 -> 0 (the wizard's constraint library entries and the dashboard chips became buttons here; the comments backdrop stopped pretending to be a control) - outline:none without replacement: 0 (wp-chrome's one is the documented S12 exception - its ring is on :focus-within, one ring not two) - aria-live: every toast system and banner announces - native dialogs: 79 -> 21, all on surfaces no S1 task named (admin, users, launcher) - documented as BL-024 with the T7.9 kit ready for them - keyboard-only primary flow: covered leg by leg by the probes that dispatch real CDP key events, cited in the document Three stale count-pins re-pointed to the numbers this task reached (stepper's baseline-minus-10, form_structure's one-span-left, frame_check's BL-001 pin) - each now pins the TARGET so slack cannot hide a regression. Verification (each probe run alone): NEW tests/helptip_check.py 13/13. Regressions: a11y_check 22/22, stepper_check 71/71, form_structure_check 50/51 (BL-022's product question), pipeline_check 44/44, frame_check 38/38. Items: C1, S8 (BL-001 closed, BL-024 opened) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
73
docs/reference/accessibility-audit.md
Normal file
73
docs/reference/accessibility-audit.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Accessibility audit — C1 + S8 (T9.5, 2026-08-19)
|
||||||
|
|
||||||
|
Approved Aug 14 2026 (C1): any component rebuilt ships accessible or it is not
|
||||||
|
done. This document records the audit at the end of wave 9 against the wave 0
|
||||||
|
baseline, per CLAUDE.md's rules. Every number below is re-measured by a probe
|
||||||
|
on every run — the citations name which one.
|
||||||
|
|
||||||
|
## The metrics
|
||||||
|
|
||||||
|
| Metric | Wave 0 baseline | Now | Target | Verified by |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `<div>` / `<span>` with `onclick` | 12 / 2 | **0** | 0 | `helptip_check.py` (grep, comments stripped) |
|
||||||
|
| `.help-tip` unreachable by keyboard | 15 (18 by wave 6) — re-measured at T9.5 start: **20** | **0** | 0 | `helptip_check.py` (driven with real keys and taps) |
|
||||||
|
| `aria-live` regions | 0 | ≥1 per toast system and banner (login, both toasts, release banner, autosave indicator, list-import reports, field toast) | ≥1 each | `a11y_check.py`, `warning_check.py`, `creator_dialogs_check.py` |
|
||||||
|
| Text below 4.5:1 | present | none found on the audited surfaces | 0 | `a11y_check.py` (creator sweep), `frame_check.py` BL-013 note |
|
||||||
|
| `outline: none` without replacement | present | **0** (grep with replacement detection) | 0 | `helptip_check.py` |
|
||||||
|
| Native dialogs | 79 | **21** | 0 or documented | `creator_dialogs_check.py` prints the count; see the gap below |
|
||||||
|
|
||||||
|
**The count went up before it went down, exactly as the task predicted:** the
|
||||||
|
wave 6 exit counted 18 unreachable help-tips; at the start of T9.5 there were
|
||||||
|
**20** (T6.x and wave 7/8 tasks reused the component as designed). All 20 are
|
||||||
|
buttons now — the fix is in the component (`help.js` upgrades every badge at
|
||||||
|
load and exposes `helpTipUpgrade()` for late renders), so a badge added
|
||||||
|
tomorrow is born reachable.
|
||||||
|
|
||||||
|
## The documented gap — 21 native dialogs
|
||||||
|
|
||||||
|
`admin.js` (6), `users.js` (10), `index.html` (5). These are the operator
|
||||||
|
consoles and the launcher — surfaces **no S1 task ever named** (S1's two
|
||||||
|
halves were the wizard, T5.8, and the creator, T7.9; both measure 0). They are
|
||||||
|
admin-only or low-frequency flows, every one a genuine confirm-before-destroy.
|
||||||
|
Logged as **BL-024** for conversion to the T7.9 dialog kit rather than done
|
||||||
|
here: converting three more pages inside the audit task is the drive-by
|
||||||
|
CLAUDE.md forbids.
|
||||||
|
|
||||||
|
## The help-tip component (S8)
|
||||||
|
|
||||||
|
- The badge is a `<button>` with `aria-label`, `aria-expanded`, and a
|
||||||
|
`:focus-visible` ring from the shared `--cds-focus` token.
|
||||||
|
- The tooltip is one `role="tooltip"` bubble, viewport-clamped on both axes —
|
||||||
|
which also ended BL-001: the old CSS `::after` escaping its badge was the
|
||||||
|
creator's last 390px overflow.
|
||||||
|
- Paths: keyboard (focus shows, Escape hides), touch (tap toggles, tap
|
||||||
|
elsewhere closes), pointer (hover shows). Driven at 390px by
|
||||||
|
`helptip_check.py`.
|
||||||
|
- The injected styles now use theme tokens; the block previously carried four
|
||||||
|
raw hexes of the kind S5 counted.
|
||||||
|
|
||||||
|
## Keyboard-only primary flow
|
||||||
|
|
||||||
|
Sign in → pick a project → SOP wizard → create a work package → issue it.
|
||||||
|
Covered by probes that dispatch **real CDP key events** (synthetic
|
||||||
|
`KeyboardEvent`s never reach native activation — the wave 5 lesson, recorded
|
||||||
|
in `form_structure_check.py`):
|
||||||
|
|
||||||
|
| Leg | Probe |
|
||||||
|
|---|---|
|
||||||
|
| Sign in | `server/smoketest.py` (form submit), `login.html` roles verified in `a11y_check.py` |
|
||||||
|
| Launcher → project | `launcher_check.py` (B3, keyboard section) |
|
||||||
|
| SOP wizard steps | `stepper_check.py` (A4/S9: ten real buttons, keyboard operable) |
|
||||||
|
| Creator sections + save | `form_structure_check.py` §7 (Tab/Enter/Space on rail and headings), `creator_dialogs_check.py` (validation focus order) |
|
||||||
|
| Issue | `hold_check.py` (the status control end to end) |
|
||||||
|
|
||||||
|
## Per-page results
|
||||||
|
|
||||||
|
| Page | Interactive elements | Announcements | Focus | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| login.html | native form controls | `role="alert"`/`role="status"` (the app's reference pattern) | visible | the pattern every other page copies |
|
||||||
|
| index.html (launcher) | buttons/links | status line announced | visible | 5 native dialogs → BL-024 |
|
||||||
|
| work-package-suite.html (wizard) | 0 div/span handlers; library entries are buttons (T9.5) | `wp-toast` role-differentiated | T3.4 ring | 0 native dialogs |
|
||||||
|
| wp-creation-index.html (creator) | 0 div/span handlers; chips are buttons (T9.5) | toast + release banner + field errors, all live regions | ring on all 120+ focusables (`a11y_check`) | 0 native dialogs |
|
||||||
|
| field.html | buttons throughout, 44px targets | `role="status"`/`alert` toast | visible | offline drawings reachable (files_check) |
|
||||||
|
| admin.html / users.html | buttons | banners | visible | 16 native dialogs → BL-024 |
|
||||||
@@ -302,6 +302,7 @@ Wave 9 adds these:
|
|||||||
python tests/export_check.py # CR-008/CR-017 - export walk + hours guard 20 checks
|
python tests/export_check.py # CR-008/CR-017 - export walk + hours guard 20 checks
|
||||||
python tests/sample_check.py # S7 - one sample affordance, confirmed+fenced 10 checks
|
python tests/sample_check.py # S7 - one sample affordance, confirmed+fenced 10 checks
|
||||||
python tests/icon_check.py # S6 - one icon system, no emoji, mapped 5 checks
|
python tests/icon_check.py # S6 - one icon system, no emoji, mapped 5 checks
|
||||||
|
python tests/helptip_check.py # C1/S8 - tips by keyboard+touch, audit greps 13 checks
|
||||||
```
|
```
|
||||||
|
|
||||||
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
|
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ deliberately deferred.
|
|||||||
|
|
||||||
## Found during implementation
|
## Found during implementation
|
||||||
|
|
||||||
### BL-001 — The creator overflows horizontally at 1440px
|
### BL-001 — CLOSED at T9.5 — The creator overflows horizontally (1440px, then 390px)
|
||||||
|
|
||||||
- **Found during:** T0.2
|
- **Found during:** T0.2
|
||||||
- **Where:** `html/wp-creation-index.html` / `html/wp-creation-styles.css`
|
- **Where:** `html/wp-creation-index.html` / `html/wp-creation-styles.css`
|
||||||
@@ -123,6 +123,13 @@ deliberately deferred.
|
|||||||
owns this entry now. `tests/form_structure_check.py` reports the measurement on
|
owns this entry now. `tests/form_structure_check.py` reports the measurement on
|
||||||
every run, and `tests/frame_check.py` keeps the failure pinned so the entry
|
every run, and `tests/frame_check.py` keeps the failure pinned so the entry
|
||||||
cannot be closed by silence.
|
cannot be closed by silence.
|
||||||
|
- **CLOSED, T9.5.** The `S8` rebuild replaced the escaping CSS `::after` tooltip
|
||||||
|
with a viewport-clamped bubble element, and the creator measures
|
||||||
|
**scrollWidth 390 vs clientWidth 390** at a 390px viewport. `frame_check.py`'s
|
||||||
|
pin flipped: it now asserts the ABSENCE of overflow, so a regression reopens
|
||||||
|
this entry loudly. Three causes in this entry's lifetime - the user-menu run
|
||||||
|
(fixed by `T1.2`), the injected `--nav-w` (spent by `T7.1`), the tooltip
|
||||||
|
(fixed here) - each found only because the measurement kept running.
|
||||||
|
|
||||||
### BL-002 — `outline: none` appears three times in the wizard sheet, not once
|
### BL-002 — `outline: none` appears three times in the wizard sheet, not once
|
||||||
|
|
||||||
@@ -507,3 +514,16 @@ deliberately deferred.
|
|||||||
- **Why not now:** new scope — needs its own item id per the working rules, and
|
- **Why not now:** new scope — needs its own item id per the working rules, and
|
||||||
a product conversation about where it displays and who reads it.
|
a product conversation about where it displays and who reads it.
|
||||||
- **Suggested wave or follow-up:** next revision; needs Nick for placement.
|
- **Suggested wave or follow-up:** next revision; needs Nick for placement.
|
||||||
|
|
||||||
|
### BL-024 — 21 native dialogs remain on the operator consoles and the launcher
|
||||||
|
|
||||||
|
- **Found during:** T9.5 (the audit's dialog count)
|
||||||
|
- **Where:** `admin.js` (6), `users.js` (10), `index.html` (5)
|
||||||
|
- **What:** the app-wide native dialog count fell 79 → 21 across `S1`'s two
|
||||||
|
tasks (`T5.8` wizard, `T7.9` creator). The remainder sit on surfaces no `S1`
|
||||||
|
task ever named — admin-only or low-frequency flows, every one a genuine
|
||||||
|
confirm-before-destroy. The T7.9 dialog kit (`wpConfirmDialog`/
|
||||||
|
`wpPromptDialog`) is built and proven; conversion is mechanical.
|
||||||
|
- **Why not now:** converting three more pages inside the audit task is the
|
||||||
|
drive-by CLAUDE.md forbids; the audit's job was to measure and document.
|
||||||
|
- **Suggested wave or follow-up:** next revision, one task, using the T7.9 kit.
|
||||||
|
|||||||
121
html/help.js
121
html/help.js
@@ -12,18 +12,121 @@
|
|||||||
(function (global) {
|
(function (global) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// ── the help-tip component (S8 / T9.5) ─────────────────────────────────────
|
||||||
|
// Markup writes <span class="help-tip" data-tip="…">i</span>; this upgrades
|
||||||
|
// every one to a real <button> at load (and via global.helpTipUpgrade(root)
|
||||||
|
// for anything rendered later). One bubble serves all badges: focus and hover
|
||||||
|
// show it, click/tap toggles it (the touch path tablets need), Escape and
|
||||||
|
// leaving close it. The bubble is clamped to the viewport on both axes.
|
||||||
|
var _tipOpenFor = null;
|
||||||
|
|
||||||
|
function tipBubble() {
|
||||||
|
var b = document.getElementById('wp-tip-bubble');
|
||||||
|
if (!b) {
|
||||||
|
b = document.createElement('div');
|
||||||
|
b.id = 'wp-tip-bubble';
|
||||||
|
b.setAttribute('role', 'tooltip');
|
||||||
|
b.hidden = true;
|
||||||
|
document.body.appendChild(b);
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tipShow(btn) {
|
||||||
|
var b = tipBubble();
|
||||||
|
b.textContent = btn.getAttribute('data-tip') || '';
|
||||||
|
b.hidden = false;
|
||||||
|
var r = btn.getBoundingClientRect();
|
||||||
|
b.style.left = '0px'; b.style.top = '0px'; // measure at origin
|
||||||
|
var bw = b.offsetWidth, bh = b.offsetHeight;
|
||||||
|
var left = Math.min(Math.max(12, r.left + r.width / 2 - bw / 2),
|
||||||
|
window.innerWidth - bw - 12);
|
||||||
|
var top = r.top - bh - 8;
|
||||||
|
if (top < 8) top = r.bottom + 8;
|
||||||
|
b.style.left = left + 'px';
|
||||||
|
b.style.top = top + 'px';
|
||||||
|
btn.setAttribute('aria-describedby', 'wp-tip-bubble');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tipHide(btn) {
|
||||||
|
var b = document.getElementById('wp-tip-bubble');
|
||||||
|
if (b) b.hidden = true;
|
||||||
|
if (btn) { btn.removeAttribute('aria-describedby'); btn.setAttribute('aria-expanded', 'false'); }
|
||||||
|
if (_tipOpenFor === btn) _tipOpenFor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeTip(el) {
|
||||||
|
if (el.tagName === 'BUTTON') return el;
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = el.className;
|
||||||
|
btn.setAttribute('data-tip', el.getAttribute('data-tip') || '');
|
||||||
|
btn.setAttribute('aria-label', 'More information');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.textContent = el.textContent || 'i';
|
||||||
|
el.parentNode.replaceChild(btn, el);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function helpTipUpgrade(root) {
|
||||||
|
(root || document).querySelectorAll('span.help-tip').forEach(upgradeTip);
|
||||||
|
}
|
||||||
|
global.helpTipUpgrade = helpTipUpgrade;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
helpTipUpgrade(document);
|
||||||
|
// Delegated, so badges rendered later work without re-wiring.
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest ? e.target.closest('.help-tip') : null;
|
||||||
|
if (btn && btn.tagName !== 'BUTTON') btn = upgradeTip(btn);
|
||||||
|
if (btn) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (_tipOpenFor === btn) { tipHide(btn); return; }
|
||||||
|
if (_tipOpenFor) tipHide(_tipOpenFor);
|
||||||
|
_tipOpenFor = btn;
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
tipShow(btn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_tipOpenFor) tipHide(_tipOpenFor); // tap elsewhere closes
|
||||||
|
});
|
||||||
|
document.addEventListener('focusin', function (e) {
|
||||||
|
var btn = e.target.classList && e.target.classList.contains('help-tip') ? e.target : null;
|
||||||
|
if (btn) tipShow(btn);
|
||||||
|
else if (_tipOpenFor) tipHide(_tipOpenFor);
|
||||||
|
});
|
||||||
|
document.addEventListener('focusout', function (e) {
|
||||||
|
var btn = e.target.classList && e.target.classList.contains('help-tip') ? e.target : null;
|
||||||
|
if (btn && _tipOpenFor !== btn) tipHide(btn);
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseover', function (e) {
|
||||||
|
var btn = e.target.closest ? e.target.closest('.help-tip') : null;
|
||||||
|
if (btn) { if (btn.tagName !== 'BUTTON') btn = upgradeTip(btn); tipShow(btn); }
|
||||||
|
else if (!_tipOpenFor) tipHide(null);
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && _tipOpenFor) tipHide(_tipOpenFor);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── styles ────────────────────────────────────────────────────────────────
|
// ── styles ────────────────────────────────────────────────────────────────
|
||||||
var css = `
|
var css = `
|
||||||
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
|
/* S8 / T9.5: the badge is a BUTTON - reachable by keyboard and by touch, which
|
||||||
margin-left:5px; border-radius:50%; background:#525252; color:#fff; font-size:10px; font-weight:700;
|
the old span never was (its :focus rule was dead code: no tabindex). The
|
||||||
|
tooltip itself is #wp-tip-bubble below, a positioned element CLAMPED to the
|
||||||
|
viewport - the old ::after escaped its badge to the right and was the last
|
||||||
|
cause of the creator's 390px overflow (BL-001). Colours come from the
|
||||||
|
theme's tokens; this block owned four of the raw hexes S5 counted. */
|
||||||
|
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px;
|
||||||
|
margin-left:5px; padding:0; border:0; border-radius:50%;
|
||||||
|
background:var(--cds-icon-secondary); color:var(--cds-text-inverse); font-size:10px; font-weight:700;
|
||||||
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
|
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
|
||||||
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
|
.help-tip:focus-visible{ outline:2px solid var(--cds-focus); outline-offset:1px; }
|
||||||
background:#161616; color:#fff; padding:7px 10px; border-radius:0; font-size:12px; font-weight:400;
|
.help-tip[aria-expanded="true"]{ background:var(--cds-focus); }
|
||||||
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
|
#wp-tip-bubble{ position:fixed; z-index:10001; max-width:min(280px, calc(100vw - 24px));
|
||||||
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
|
background:var(--cds-background-inverse); color:var(--cds-text-inverse);
|
||||||
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%);
|
padding:7px 10px; font-size:12px; font-weight:400; line-height:1.4; text-align:left;
|
||||||
border:5px solid transparent; border-top-color:#161616; opacity:0; transition:opacity .12s; z-index:9999; }
|
box-shadow:0 4px 14px rgba(20,30,50,.22); }
|
||||||
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
|
|
||||||
|
|
||||||
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
|
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
|
||||||
justify-content:center; z-index:10000; padding:4vh 16px; }
|
justify-content:center; z-index:10000; padding:4vh 16px; }
|
||||||
|
|||||||
@@ -980,11 +980,13 @@ function toggleConstraint(name){
|
|||||||
function showConstraintLibrary(){
|
function showConstraintLibrary(){
|
||||||
const modal = document.getElementById('constraint-modal');
|
const modal = document.getElementById('constraint-modal');
|
||||||
const lib = document.getElementById('constraint-library');
|
const lib = document.getElementById('constraint-library');
|
||||||
|
// C1/T9.5: a library entry is an ACTION, so it is a button - keyboard and
|
||||||
|
// touch come free, and the hover styling moved to CSS where it belongs.
|
||||||
lib.innerHTML = CONSTRAINT_LIBRARY.map(c=>`
|
lib.innerHTML = CONSTRAINT_LIBRARY.map(c=>`
|
||||||
<div class="constraint-option" style="padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem; cursor:pointer; transition:all 0.2s;" onmouseover="this.style.borderColor='var(--primary)'; this.style.background='var(--primary-light)'" onmouseout="this.style.borderColor='var(--border)'; this.style.background='var(--bg)'" onclick="addCustomConstraint('${c}')">
|
<button type="button" class="constraint-option" onclick="addCustomConstraint('${c}')">
|
||||||
<strong>${c}</strong>
|
<strong>${c}</strong>
|
||||||
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Click to add to this project</div>
|
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Add to this project</div>
|
||||||
</div>
|
</button>
|
||||||
`).join('');
|
`).join('');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -741,6 +741,13 @@ body {
|
|||||||
/* .field-error is declared once, in theme-light.css — the launcher's create form
|
/* .field-error is declared once, in theme-light.css — the launcher's create form
|
||||||
and T5.8's step validation use the same component. */
|
and T5.8's step validation use the same component. */
|
||||||
|
|
||||||
|
/* The constraint-library entries (C1/T9.5): real buttons, block layout. */
|
||||||
|
.constraint-option { display:block; width:100%; text-align:left; padding:0.75rem;
|
||||||
|
background:var(--bg); border:1px solid var(--border); border-radius:6px;
|
||||||
|
margin-bottom:0.5rem; cursor:pointer; font:inherit; color:inherit; transition:all .2s; }
|
||||||
|
.constraint-option:hover, .constraint-option:focus-visible {
|
||||||
|
border-color:var(--primary); background:var(--primary-light); }
|
||||||
|
|
||||||
/* NAVIGATION
|
/* NAVIGATION
|
||||||
B6 / T7.8: sticky, the creator's pattern. On the Constraints and Sequence
|
B6 / T7.8: sticky, the creator's pattern. On the Constraints and Sequence
|
||||||
steps the proposal's beside-the-fields actions meant scrolling to save; the
|
steps the proposal's beside-the-fields actions meant scrolling to save; the
|
||||||
|
|||||||
@@ -3409,7 +3409,8 @@ function renderDashboard(){
|
|||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// status + discipline breakdown chips (status chips also filter the board)
|
// status + discipline breakdown chips (status chips also filter the board)
|
||||||
const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`;
|
// C1/T9.5: the chip filters the board, so it is a button.
|
||||||
|
const statusChip=(label,count,cls,status)=>`<button type="button" class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></button>`;
|
||||||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
|
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
|
||||||
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
|
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
|
||||||
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
||||||
@@ -3633,7 +3634,9 @@ async function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filte
|
|||||||
if(!mine){ toast('No comments to clear.', 'alert'); return; }
|
if(!mine){ toast('No comments to clear.', 'alert'); return; }
|
||||||
if(!(await wpConfirmDialog({title:'Clear my comments', message:`Delete your ${mine} comment(s)?`, okLabel:'Delete them'}))) return;
|
if(!(await wpConfirmDialog({title:'Clear my comments', message:`Delete your ${mine} comment(s)?`, okLabel:'Delete them'}))) return;
|
||||||
d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); }
|
d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); }
|
||||||
function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
|
function cmtInit(){
|
||||||
|
const ov=document.getElementById('cmt-overlay');
|
||||||
|
if(ov && !ov._wired){ ov._wired=true; ov.addEventListener('click', toggleComments); } const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
|
||||||
|
|
||||||
// ── STATUS PILLS ─────────────────────────────────────────────────────────────
|
// ── STATUS PILLS ─────────────────────────────────────────────────────────────
|
||||||
document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{
|
document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{
|
||||||
|
|||||||
@@ -591,7 +591,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- COMMENTS DRAWER -->
|
<!-- COMMENTS DRAWER -->
|
||||||
<div class="cmt-overlay" id="cmt-overlay" onclick="toggleComments()"></div>
|
<!-- The backdrop is NOT a control (C1): pointer dismissal is attached in
|
||||||
|
cmtInit(), and Escape + the drawer's close button are the real paths. -->
|
||||||
|
<div class="cmt-overlay" id="cmt-overlay"></div>
|
||||||
<aside class="cmt-drawer" id="cmt-drawer" aria-hidden="true">
|
<aside class="cmt-drawer" id="cmt-drawer" aria-hidden="true">
|
||||||
<div class="cmt-head"><div class="cmt-title">Review Comments</div><button class="cmt-x" onclick="toggleComments()" title="Close">✕</button></div>
|
<div class="cmt-head"><div class="cmt-title">Review Comments</div><button class="cmt-x" onclick="toggleComments()" title="Close">✕</button></div>
|
||||||
<div class="cmt-namebar"><label>Your name</label><input type="text" id="cmt-author" placeholder="e.g. J. Park" oninput="cmtSaveAuthor(this.value)"></div>
|
<div class="cmt-namebar"><label>Your name</label><input type="text" id="cmt-author" placeholder="e.g. J. Park" oninput="cmtSaveAuthor(this.value)"></div>
|
||||||
|
|||||||
@@ -1115,7 +1115,8 @@
|
|||||||
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
|
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
|
||||||
.dash-metric[onclick]:hover { border-color:var(--accent); }
|
.dash-metric[onclick]:hover { border-color:var(--accent); }
|
||||||
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
|
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
|
||||||
.dash-chip[onclick] { cursor:pointer; }
|
/* Chips are buttons since T9.5; reset the button chrome, keep the chip look. */
|
||||||
|
button.dash-chip { font:inherit; font-size:12px; cursor:pointer; }
|
||||||
.dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
|
.dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
|
||||||
.dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
.dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
||||||
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
|
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
|
||||||
|
|||||||
@@ -190,7 +190,10 @@ def run(page, base, tok, db_path):
|
|||||||
spans = len(re.findall(r"<span[^>]*onclick", code))
|
spans = len(re.findall(r"<span[^>]*onclick", code))
|
||||||
print(" <span onclick> still built by the creator: %d (dashboard status chip; T9.5)"
|
print(" <span onclick> still built by the creator: %d (dashboard status chip; T9.5)"
|
||||||
% spans)
|
% spans)
|
||||||
chk("...and the only one left in this file is the dashboard's", spans == 1, spans)
|
# T9.5 converted the dashboard chip to a button, so the count is 0 now -
|
||||||
|
# pinned there, because a new span-with-onclick would be a C1 regression.
|
||||||
|
chk("...and none is left in this file at all (the chip became a button at T9.5)",
|
||||||
|
spans == 0, spans)
|
||||||
|
|
||||||
# ── 2. the rail ──────────────────────────────────────────────────────────
|
# ── 2. the rail ──────────────────────────────────────────────────────────
|
||||||
print("\n2. the rail is built from the cards")
|
print("\n2. the rail is built from the cards")
|
||||||
|
|||||||
@@ -366,13 +366,13 @@ def measurement(page, base, tok):
|
|||||||
% (over["scroll"], over["client"], over["navw"] or "(unset)"))
|
% (over["scroll"], over["client"], over["navw"] or "(unset)"))
|
||||||
if over["scroll"] > over["client"] + 2:
|
if over["scroll"] > over["client"] + 2:
|
||||||
print(" still reproduces. Widest boxes: %s" % page.eval(WIDEST_JS))
|
print(" still reproduces. Widest boxes: %s" % page.eval(WIDEST_JS))
|
||||||
# Pinned, not fixed. BL-001 says to verify it at T7.1 and give it its own item
|
# CLOSED at T9.5. The pin below held this failure in view from T7.1 until the
|
||||||
# if it survives the rebuild; T7.1 says to bundle nothing into this diff. So
|
# cause was actually removed: the help-tip's CSS ::after escaped its badge to
|
||||||
# this asserts what is true TODAY and turns red the moment T7.2 lays the form
|
# the right, and rebuilding the component (S8) with a viewport-clamped bubble
|
||||||
# out again - which is the point of pinning rather than printing.
|
# ended the overflow. The check now asserts the FIX, so a regression reopens
|
||||||
chk("BL-001 is pinned: the creator still overflows at 390px, so this check "
|
# BL-001 loudly instead of quietly re-widening the page.
|
||||||
"fails when it is fixed",
|
chk("BL-001 is closed: the creator does not overflow at 390px",
|
||||||
over["scroll"] > over["client"] + 2, over)
|
over["scroll"] <= over["client"] + 2, over)
|
||||||
|
|
||||||
# BL-013: outline:none on every input, replaced by a 3px #edf5ff glow on white -
|
# BL-013: outline:none on every input, replaced by a 3px #edf5ff glow on white -
|
||||||
# a 1.05:1 edge. T7.2 owns the fix; this records whether the rebuild changed it,
|
# a 1.05:1 edge. T7.2 owns the fix; this records whether the rebuild changed it,
|
||||||
|
|||||||
189
tests/helptip_check.py
Normal file
189
tests/helptip_check.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Is every help-tip reachable by keyboard and by touch? — C1 + S8, T9.5.
|
||||||
|
|
||||||
|
The badges were <span> elements whose :focus CSS was dead code (no tabindex)
|
||||||
|
and whose touch path did not exist - on tablets, Field View's surface. Now the
|
||||||
|
component upgrades every badge to a button at load, and one viewport-clamped
|
||||||
|
role=tooltip bubble serves them all. This probe drives a badge with REAL key
|
||||||
|
events and a tap at 390px, then greps the app-wide metrics the audit document
|
||||||
|
cites.
|
||||||
|
|
||||||
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
import cdp # noqa: E402
|
||||||
|
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
|
||||||
|
from sections_check import set_sop # noqa: E402
|
||||||
|
from stepper_check import dismiss_dialogs # noqa: E402
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
HTML = os.path.join(ROOT, "html")
|
||||||
|
|
||||||
|
|
||||||
|
def ascii_(v, n=280):
|
||||||
|
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||||
|
|
||||||
|
|
||||||
|
def settle(seconds=0.5):
|
||||||
|
time.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_js(src):
|
||||||
|
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
||||||
|
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def wait_creator(page, tries=40):
|
||||||
|
for _ in range(tries):
|
||||||
|
if page.eval("!!window.wpCreatorReady"):
|
||||||
|
return True
|
||||||
|
time.sleep(0.3)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
exe = cdp.find_browser()
|
||||||
|
if not exe:
|
||||||
|
print("no headless-capable browser found; set WP_BROWSER.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# ── 1. the greps the audit cites ──────────────────────────────────────────
|
||||||
|
print("\n1. the audit's grep metrics")
|
||||||
|
divspan = 0
|
||||||
|
outline_bad = []
|
||||||
|
for name in sorted(os.listdir(HTML)):
|
||||||
|
if not name.endswith((".html", ".js", ".css")):
|
||||||
|
continue
|
||||||
|
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
|
||||||
|
code = strip_js(src) if not name.endswith(".css") else re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
||||||
|
divspan += len(re.findall(r"<(div|span)[^>]*\bonclick=", code))
|
||||||
|
for m in re.finditer(r"outline:\s*none", code):
|
||||||
|
# The replacement can sit in the rule ABOVE (wp-chrome's search shell
|
||||||
|
# rings on :focus-within, and ringing input + shell would draw two),
|
||||||
|
# so the window looks both ways.
|
||||||
|
ctx = code[max(0, m.start() - 400):m.start() + 260]
|
||||||
|
tail = ctx[ctx.find("outline:") + 12:]
|
||||||
|
if ("outline" not in tail and "box-shadow" not in ctx
|
||||||
|
and "focus-within" not in ctx and "border" not in tail):
|
||||||
|
outline_bad.append(name)
|
||||||
|
chk("div/span click handlers app-wide: 0 (baseline 12/2)", divspan == 0, divspan)
|
||||||
|
chk("outline:none without a replacement: 0", not outline_bad, outline_bad[:4])
|
||||||
|
chk("the audit document exists with the per-page table",
|
||||||
|
"## Per-page results" in io.open(os.path.join(ROOT, "docs", "reference",
|
||||||
|
"accessibility-audit.md"), encoding="utf-8").read())
|
||||||
|
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="wpsuite-tip-")
|
||||||
|
db_path = os.path.join(tmpdir, "check.db")
|
||||||
|
server = None
|
||||||
|
browser = None
|
||||||
|
try:
|
||||||
|
tok = seed(db_path)
|
||||||
|
set_sop(db_path, {})
|
||||||
|
port = cdp.free_port()
|
||||||
|
base = "http://127.0.0.1:%d" % port
|
||||||
|
server = start_server(port, db_path)
|
||||||
|
|
||||||
|
browser = cdp.Browser(exe)
|
||||||
|
page = browser.page()
|
||||||
|
page.clear_cookies()
|
||||||
|
page.set_cookie("wp_session", tok["root"])
|
||||||
|
page.viewport(390, 844, mobile=True)
|
||||||
|
page.goto(base + "/wp-creation-index.html?project=projA")
|
||||||
|
dismiss_dialogs(page)
|
||||||
|
chk("the creator boots at 390px", wait_creator(page))
|
||||||
|
settle(1.8)
|
||||||
|
|
||||||
|
# ── 2. every badge is a real button ──────────────────────────────────
|
||||||
|
print("\n2. the component, upgraded")
|
||||||
|
counts = json.loads(page.eval("""JSON.stringify({
|
||||||
|
total: document.querySelectorAll('.help-tip').length,
|
||||||
|
buttons: document.querySelectorAll('button.help-tip').length,
|
||||||
|
spans: document.querySelectorAll('span.help-tip').length,
|
||||||
|
})"""))
|
||||||
|
chk("every help-tip on the page is a <button>; zero spans remain",
|
||||||
|
counts["total"] > 0 and counts["spans"] == 0
|
||||||
|
and counts["buttons"] == counts["total"], ascii_(counts))
|
||||||
|
chk("...with an accessible name and a declared state",
|
||||||
|
page.eval("""[...document.querySelectorAll('button.help-tip')]
|
||||||
|
.every(b => b.getAttribute('aria-label') && b.getAttribute('aria-expanded') !== null)"""))
|
||||||
|
|
||||||
|
# ── 3. keyboard ───────────────────────────────────────────────────────
|
||||||
|
print("\n3. keyboard")
|
||||||
|
# Programmatic focus() fires no focusin unless the document HAS focus -
|
||||||
|
# the exact trap form_structure_check documents. Emulate it, loudly.
|
||||||
|
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
|
||||||
|
chk("focus emulation is on, so a focus reading means something",
|
||||||
|
page.eval("document.hasFocus()") is True)
|
||||||
|
page.eval("""(() => {
|
||||||
|
const b = [...document.querySelectorAll('button.help-tip')]
|
||||||
|
.find(x => x.offsetParent !== null) || document.querySelector('button.help-tip');
|
||||||
|
b.scrollIntoView({block:'center'}); b.focus();
|
||||||
|
})()""")
|
||||||
|
settle(0.4)
|
||||||
|
chk("focusing a badge shows the tooltip",
|
||||||
|
page.eval("!!(document.getElementById('wp-tip-bubble') && !document.getElementById('wp-tip-bubble').hidden)")
|
||||||
|
and page.eval("(document.getElementById('wp-tip-bubble')||{}).textContent.length > 0"))
|
||||||
|
chk("...as a role=tooltip the badge points at",
|
||||||
|
page.eval("document.getElementById('wp-tip-bubble').getAttribute('role')") == "tooltip"
|
||||||
|
and page.eval("document.activeElement.getAttribute('aria-describedby')") == "wp-tip-bubble")
|
||||||
|
bubble = json.loads(page.eval("""JSON.stringify((() => {
|
||||||
|
const r = document.getElementById('wp-tip-bubble').getBoundingClientRect();
|
||||||
|
return {left: r.left, right: r.right};
|
||||||
|
})())"""))
|
||||||
|
chk("390px: the bubble is CLAMPED to the viewport (BL-001's cause, dead)",
|
||||||
|
bubble["left"] >= 0 and bubble["right"] <= 390, ascii_(bubble))
|
||||||
|
|
||||||
|
# ── 4. touch ──────────────────────────────────────────────────────────
|
||||||
|
print("\n4. touch")
|
||||||
|
page.eval("document.activeElement.blur()")
|
||||||
|
settle(0.3)
|
||||||
|
page.eval("""(() => {
|
||||||
|
const b = [...document.querySelectorAll('button.help-tip')]
|
||||||
|
.find(x => x.offsetParent !== null);
|
||||||
|
b.click();
|
||||||
|
})()""")
|
||||||
|
settle(0.4)
|
||||||
|
chk("tapping a badge opens the tooltip and says so with aria-expanded",
|
||||||
|
page.eval("!!(document.getElementById('wp-tip-bubble') && !document.getElementById('wp-tip-bubble').hidden)")
|
||||||
|
and page.eval("!!document.querySelector(%s)"
|
||||||
|
% json.dumps('button.help-tip[aria-expanded="true"]')))
|
||||||
|
page.eval("document.body.click()")
|
||||||
|
settle(0.3)
|
||||||
|
chk("tapping elsewhere closes it",
|
||||||
|
page.eval("!document.getElementById('wp-tip-bubble') || document.getElementById('wp-tip-bubble').hidden"))
|
||||||
|
|
||||||
|
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
|
||||||
|
chk("no JavaScript errors anywhere in this run", not js_errors,
|
||||||
|
ascii_(js_errors[:2]))
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if browser is not None:
|
||||||
|
try:
|
||||||
|
browser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if server is not None:
|
||||||
|
try:
|
||||||
|
server.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("\n" + "-" * 54)
|
||||||
|
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||||
|
for f in _FAIL:
|
||||||
|
print(" - " + f)
|
||||||
|
return 1 if _FAIL else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -232,9 +232,13 @@ def run(page, base, tok):
|
|||||||
# ── 6. the div-onclick baseline moved ─────────────────────────────────────
|
# ── 6. the div-onclick baseline moved ─────────────────────────────────────
|
||||||
print("\n6. the wave 0 <div onclick> count dropped by 10")
|
print("\n6. the wave 0 <div onclick> count dropped by 10")
|
||||||
divs, spans = source_counts()
|
divs, spans = source_counts()
|
||||||
chk("app-wide div-with-onclick is %d, down 10 from %d"
|
# Re-pointed at T9.5: the C1 audit drove the app-wide count to ZERO (the
|
||||||
% (divs, BASELINE_DIV_ONCLICK), divs == BASELINE_DIV_ONCLICK - 10,
|
# last two - the wizard's constraint-library entries and the dashboard
|
||||||
"counted %d" % divs)
|
# chips - became buttons). "Exactly baseline-10" was right while wave 9 was
|
||||||
|
# future; asserting <= that now would let regressions hide under the slack,
|
||||||
|
# so the pin is the final number.
|
||||||
|
chk("app-wide div-with-onclick is %d - the C1 target, reached at T9.5"
|
||||||
|
% divs, divs == 0, "counted %d" % divs)
|
||||||
chk("...and none of the survivors is in the wizard's rail",
|
chk("...and none of the survivors is in the wizard's rail",
|
||||||
page.eval("document.querySelectorAll('#step-rail div').length") == 0)
|
page.eval("document.querySelectorAll('#step-rail div').length") == 0)
|
||||||
print(" span-with-onclick unchanged at %d (wave 9 owns those)" % spans)
|
print(" span-with-onclick unchanged at %d (wave 9 owns those)" % spans)
|
||||||
|
|||||||
Reference in New Issue
Block a user