v1.1: harden recovery and control boundaries
WP-D and WP-E complete the planned v1.1 implementation packages. WP-D — recovery honesty: - backups verify the completed copy with PRAGMA integrity_check - corruption missed by quick_check is detected by the full check - existing good backups remain protected - oversized exports are still delivered but declare whether this version can import them, while the 20 MB import limit remains unchanged - backup was exercised through the real browser UI on both the normal campaign database and a campaign-shaped database over 100 MB WP-E — control-boundary contrast: - interactive control boundaries meet the WCAG 1.4.11 3:1 target - the contrast audit is now a failing gate rather than an advisory - rendered browser measurements pass for the composer, controls, tabs and nav - text contrast and focus visibility remain intact - owner reviewed and approved the before/after screenshots Reports: - planning/reports/v1.1/V1.1-WP-D-REPORT.md - planning/reports/v1.1/V1.1-WP-E-REPORT.md All planned v1.1 work packages A-E are now complete. Release validation has not yet begun.
This commit is contained in:
@@ -665,6 +665,228 @@ def check_accessibility(browser: Browser, site: Site, adv: int, checks: Checks):
|
||||
checks.record("A11y", "the story input takes keyboard focus", typed is True)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- WP-E boundaries
|
||||
|
||||
#: The controls whose edges WP-E measures, as the stylesheets actually draw them.
|
||||
#: `side` is the border the rule sets: .topnav paints only a bottom edge.
|
||||
BOUNDARY_TARGETS = [
|
||||
("E01", "the story composer", ".input-bar", "Top"),
|
||||
("E02", "a story control", ".story-controls button:not(:disabled)", "Top"),
|
||||
("E04", "the open panel tab", ".panel-tabs button.active", "Top"),
|
||||
]
|
||||
|
||||
#: `.topnav` is measured on the library route, because the play route does not
|
||||
#: render it — the play page has its own `.play-header`, which story.css keeps
|
||||
#: deliberately opaque. So the translucent case exists on exactly one screen,
|
||||
#: and it is the case token arithmetic cannot answer: --bg-panel-glass is
|
||||
#: rgba(...,0.82) over a gradient, so only the rendered page knows what is
|
||||
#: behind that edge.
|
||||
TRANSLUCENT_TARGET = ("E03", "the top navigation's edge", ".topnav", "Bottom")
|
||||
|
||||
#: Measured in the browser rather than from tokens, because two of these cannot
|
||||
#: be derived from tokens at all: .topnav sits on --bg-panel-glass, which is
|
||||
#: translucent, so its effective background is a composite of what is behind it;
|
||||
#: and a rendered edge can be changed by opacity, a transition mid-flight, or a
|
||||
#: rule the token file knows nothing about.
|
||||
#:
|
||||
#: A boundary is measured against **both** adjacent colours — the control's own
|
||||
#: fill inside it and the background outside it — and passes on the better of
|
||||
#: the two. An edge that matches its fill but contrasts with the page is still a
|
||||
#: visible outline, and vice versa; what 1.4.11 asks is that the component's
|
||||
#: extent be perceivable, not that every neighbouring surface differ from it.
|
||||
BOUNDARY_JS = """
|
||||
const parse = (c) => (c.match(/[\\d.]+/g) || []).map(Number);
|
||||
const alphaOf = (c) => {
|
||||
if (!c || c === 'transparent') return 0;
|
||||
const p = parse(c);
|
||||
return p.length > 3 ? p[3] : 1;
|
||||
};
|
||||
function lum(c) {
|
||||
const [r, g, b] = parse(c).slice(0, 3).map(v => v / 255)
|
||||
.map(v => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
function over(fg, bg) {
|
||||
const f = parse(fg), b = parse(bg), a = alphaOf(fg);
|
||||
return 'rgb(' + [0, 1, 2].map(i => Math.round(f[i] * a + b[i] * (1 - a))).join(', ') + ')';
|
||||
}
|
||||
function ratio(x, y) {
|
||||
const a = lum(x), b = lum(y);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
// Everything painted behind `el`, composited bottom-up, so a translucent
|
||||
// panel reports the colour a reader actually sees rather than its own rgba.
|
||||
function behind(el) {
|
||||
const layers = [];
|
||||
let node = el;
|
||||
while (node && node !== document.documentElement) {
|
||||
const c = getComputedStyle(node).backgroundColor;
|
||||
if (alphaOf(c) > 0) {
|
||||
layers.push(c);
|
||||
if (alphaOf(c) >= 1) break;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
let result = 'rgb(10, 10, 15)';
|
||||
const root = getComputedStyle(document.documentElement).backgroundColor;
|
||||
const body = getComputedStyle(document.body).backgroundColor;
|
||||
if (alphaOf(root) >= 1) result = root;
|
||||
else if (alphaOf(body) >= 1) result = body;
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
result = alphaOf(layers[i]) >= 1 ? layers[i] : over(layers[i], result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const [selector, side] = arguments;
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
const s = getComputedStyle(el);
|
||||
const edge = s['border' + side + 'Color'];
|
||||
const width = parseFloat(s['border' + side + 'Width']) || 0;
|
||||
const opacity = parseFloat(s.opacity);
|
||||
const inside = alphaOf(s.backgroundColor) >= 1
|
||||
? s.backgroundColor : over(s.backgroundColor, behind(el.parentElement || el));
|
||||
const outside = behind(el.parentElement || el);
|
||||
return {
|
||||
selector, edge, width, opacity, inside, outside,
|
||||
insideRatio: Math.round(ratio(edge, inside) * 100) / 100,
|
||||
outsideRatio: Math.round(ratio(edge, outside) * 100) / 100,
|
||||
outline: s.outlineStyle + ' ' + s.outlineWidth + ' ' + s.outlineColor,
|
||||
shadow: s.boxShadow,
|
||||
};
|
||||
"""
|
||||
|
||||
|
||||
def _settled(browser: Browser, selector: str, side: str, *, timeout: float = 5) -> None:
|
||||
"""Waits until the edge colour stops moving.
|
||||
|
||||
Every one of these controls carries `transition: border-color 0.15s`, so a
|
||||
measurement taken the instant after a hover or a focus reads a colour part
|
||||
way between the two states — a value no state actually has. The first run of
|
||||
this check did exactly that: it reported the hover edge as rgb(114,114,160),
|
||||
which is neither --border nor --border-bright but a frame between them.
|
||||
Polled rather than slept, per this harness's rule.
|
||||
"""
|
||||
script = ("(() => { const el = document.querySelector(%s);"
|
||||
" if (!el) return true;"
|
||||
" const c = getComputedStyle(el)['border%sColor'];"
|
||||
" const was = window.__wpeEdge; window.__wpeEdge = c;"
|
||||
" return was === c; })()" % (json.dumps(selector), side))
|
||||
# Cleared first, so the comparison starts from no previous reading rather
|
||||
# than from whatever the last measured control left behind.
|
||||
browser.js("window.__wpeEdge = undefined")
|
||||
browser.wait_js(script, timeout=timeout)
|
||||
|
||||
|
||||
def _boundary(browser: Browser, selector: str, side: str) -> dict | None:
|
||||
_settled(browser, selector, side)
|
||||
return browser.js(BOUNDARY_JS, selector, side)
|
||||
|
||||
|
||||
def _record_boundary(checks: Checks, test: str, what: str, row: dict | None) -> None:
|
||||
if row is None:
|
||||
checks.skip(test, what, "the control was not on the page")
|
||||
return
|
||||
best = max(row["insideRatio"], row["outsideRatio"])
|
||||
detail = (f"{best:.2f}:1 (edge {row['edge']} — {row['insideRatio']}:1 against its fill "
|
||||
f"{row['inside']}, {row['outsideRatio']}:1 against {row['outside']}), "
|
||||
f"{row['width']}px")
|
||||
checks.record("WCAG 1.4.11", what, best >= 3.0 and row["width"] > 0, detail)
|
||||
|
||||
|
||||
def check_control_boundaries(browser: Browser, site: Site, adv: int, checks: Checks,
|
||||
evidence: dict, out: Path) -> None:
|
||||
"""v1.1 WP-E §21: a control's edge is visible on its own, measured.
|
||||
|
||||
M11 measured text contrast on the rendered page and left boundaries to the
|
||||
token audit, which checked them against one background and reported a
|
||||
shortfall without failing. These are the rendered edges, at rest, on hover
|
||||
and while focused, against what is actually behind them.
|
||||
"""
|
||||
_open_play(browser, site, adv)
|
||||
measured: dict[str, dict] = {}
|
||||
|
||||
# A panel tab's edge is `transparent` until the panel is open, which makes
|
||||
# the open tab the one control here whose boundary is the only thing marking
|
||||
# it — exactly what 1.4.11 is about. So one is opened rather than skipped.
|
||||
if not _open_panel(browser, "State"):
|
||||
checks.skip("E04", "the open panel tab — resting edge", "the State panel did not open")
|
||||
|
||||
for test, what, selector, side in BOUNDARY_TARGETS:
|
||||
row = _boundary(browser, selector, side)
|
||||
_record_boundary(checks, test, f"{what} — resting edge", row)
|
||||
if row:
|
||||
measured[f"{what} (rest)"] = row
|
||||
|
||||
# Hover, with a real pointer: `:hover` follows the browser's pointer state,
|
||||
# so a synthetic mouseover would silently re-measure the resting edge.
|
||||
control = browser.find(".story-controls button:not(:disabled)", required=False)
|
||||
if control is None:
|
||||
checks.skip("E02", "a story control — hover edge", "no enabled control on the page")
|
||||
else:
|
||||
browser.hover(control)
|
||||
hovered = browser.js(
|
||||
"return document.querySelector('.story-controls button:not(:disabled)')"
|
||||
" .matches(':hover');")
|
||||
if hovered is not True:
|
||||
checks.skip("E02", "a story control — hover edge",
|
||||
"the pointer did not land on the control")
|
||||
else:
|
||||
row = _boundary(browser, ".story-controls button:not(:disabled)", "Top")
|
||||
_record_boundary(checks, "E02", "a story control — hover edge", row)
|
||||
if row:
|
||||
measured["a story control (hover)"] = row
|
||||
browser.unhover()
|
||||
|
||||
# Focus: the composer's edge changes colour while it holds focus, and that
|
||||
# edge is what tells a keyboard reader where they are.
|
||||
focused = browser.js("""
|
||||
const box = document.querySelector('.input-main textarea');
|
||||
if (!box) return false;
|
||||
box.focus();
|
||||
return document.activeElement === box;
|
||||
""")
|
||||
if focused is not True:
|
||||
checks.skip("E01", "the story composer — focused edge", "the composer did not take focus")
|
||||
else:
|
||||
row = _boundary(browser, ".input-bar", "Top")
|
||||
_record_boundary(checks, "E01", "the story composer — focused edge", row)
|
||||
if row:
|
||||
measured["the story composer (focus)"] = row
|
||||
# A focus ring that is only a colour change is not enough on its own;
|
||||
# M11 already asserts a visible focus indicator, and this says the
|
||||
# focused edge is also measurably distinct from the resting one.
|
||||
rest = measured.get("the story composer (rest)")
|
||||
if rest:
|
||||
checks.record("WCAG 1.4.11", "the focused composer edge differs from its resting edge",
|
||||
row["edge"] != rest["edge"],
|
||||
f"rest {rest['edge']} -> focus {row['edge']}")
|
||||
|
||||
shot = browser.screenshot(out / "control-boundaries.png")
|
||||
checks.record("WP-E", "a screenshot of the measured controls was captured",
|
||||
shot.exists() and shot.stat().st_size > 0, str(shot))
|
||||
|
||||
# The translucent edge, on the only screen that has it. Token arithmetic
|
||||
# cannot reach this one: --bg-panel-glass is rgba over a gradient, so what
|
||||
# is behind the nav's bottom edge is known only to the rendered page.
|
||||
test, what, selector, side = TRANSLUCENT_TARGET
|
||||
browser.go(site.url)
|
||||
browser.wait_for(".topnav", timeout=30)
|
||||
row = _boundary(browser, selector, side)
|
||||
_record_boundary(checks, test, f"{what} — over a translucent panel", row)
|
||||
if row:
|
||||
measured[f"{what} (translucent)"] = row
|
||||
checks.record("WP-E", "the nav's background really is translucent",
|
||||
row["outside"] != row["inside"],
|
||||
f"composited to {row['inside']} over {row['outside']}")
|
||||
nav_shot = browser.screenshot(out / "control-boundaries-nav.png")
|
||||
checks.record("WP-E", "a screenshot of the navigation edge was captured",
|
||||
nav_shot.exists() and nav_shot.stat().st_size > 0, str(nav_shot))
|
||||
|
||||
evidence["control_boundaries"] = {"measured": measured,
|
||||
"screenshots": [str(shot), str(nav_shot)]}
|
||||
|
||||
|
||||
# -------------------------------------------------------------- WP-C scenarios
|
||||
|
||||
def _take_count_is(count: str) -> str:
|
||||
@@ -1354,7 +1576,11 @@ def main() -> int:
|
||||
"C5", "failed generation and recovery"),
|
||||
("export", lambda: check_export_download(browser, site, adv, checks, evidence, out)),
|
||||
]
|
||||
for suite, scenarios in (("M11", m11), ("WP-C", wpc)):
|
||||
wpe = [
|
||||
("boundaries", lambda: check_control_boundaries(browser, site, adv, checks,
|
||||
evidence, out)),
|
||||
]
|
||||
for suite, scenarios in (("M11", m11), ("WP-C", wpc), ("WP-E", wpe)):
|
||||
checks.suite = suite
|
||||
for name, scenario in scenarios:
|
||||
if not wanted(name):
|
||||
@@ -1383,7 +1609,8 @@ def main() -> int:
|
||||
"kind": ("release regression" if narrated and not only else
|
||||
"partial (no narrator)" if not narrated else f"development (only {sorted(only)})"),
|
||||
"checks": checks.rows,
|
||||
"suites": {"M11": checks.counts("M11"), "WP-C": checks.counts("WP-C")},
|
||||
"suites": {"M11": checks.counts("M11"), "WP-C": checks.counts("WP-C"),
|
||||
"WP-E": checks.counts("WP-E")},
|
||||
"passed": checks.counts()["passed"],
|
||||
"failed": checks.counts()["failed"],
|
||||
"skipped": checks.counts()["skipped"],
|
||||
|
||||
Reference in New Issue
Block a user