Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions scripts/ci/test-ui-defect-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,47 @@ const FIXTURES = {
<div style="background:#fff;color:#111;font:16px sans-serif;padding:24px">
<p style="text-align:center;max-width:420px;margin:0 auto">Every feature — health tracking, digital twin, vet network, marketplace, and adoption listings — is included free with no pet limits.</p>
</div>`,

// A bare wrapper anchor around a styled button. The anchor's own ink is
// never painted — the button paints the label — so measuring the anchor read
// 1:1 on surf-your-life's "Konto erstellen". The button itself passes.
wrapperAnchor: `
<div style="background:#fff;font:16px sans-serif;padding:24px">
<a href="#go" style="color:#fff;text-decoration:none"><button style="background:#0f766e;color:#fff;border:0;padding:12px 24px;font-size:18px">Konto erstellen</button></a>
</div>`,

// A disabled control is dim BECAUSE it is disabled — WCAG exempts it. Its
// enabled twin with the same colors is a real finding.
disabledControl: `
<div style="background:#fff;font:14px sans-serif;padding:24px">
<button disabled style="background:#f4f4f4;color:#9a9a9a;border:0;padding:8px 16px">Send</button>
<button style="background:#f4f4f4;color:#9a9a9a;border:0;padding:8px 16px">Send twin</button>
</div>`,

// A row of padded buttons in a text column: the BUTTON BOX is flush with the
// column and only the labels sit padding deeper — aoz-wohnen's hero read as
// ragged for exactly this.
buttonRow: `
<div style="background:#fff;color:#111;font:16px sans-serif;padding:24px">
<div style="display:flex;flex-direction:column;gap:12px;width:600px">
<h1 style="margin:0;font-size:24px">Gemeinsam wohnen</h1>
<p style="margin:0">Die Wohnung, auf die ihr euch einigen könnt.</p>
<div style="display:flex;gap:12px"><a href="#d" style="background:#ba222e;color:#fff;padding:10px 16px;text-decoration:none">Ausprobieren</a><a href="#l" style="border:1px solid #ccc;color:#111;padding:10px 16px;text-decoration:none">Anmelden</a></div>
<p style="margin:0">Kein Konto nötig.</p>
</div>
</div>`,

// The same hero with the button row genuinely off the column: the BOX edge
// returns 16px out and back, and the box edge is what must be measured.
buttonRowRagged: `
<div style="background:#fff;color:#111;font:16px sans-serif;padding:24px">
<div style="display:flex;flex-direction:column;gap:12px;width:600px">
<h1 style="margin:0;font-size:24px">Gemeinsam wohnen</h1>
<p style="margin:0">Die Wohnung, auf die ihr euch einigen könnt.</p>
<div style="display:flex;gap:12px;margin-left:16px"><a href="#d" style="background:#ba222e;color:#fff;padding:10px 16px;text-decoration:none">Ausprobieren</a><a href="#l" style="border:1px solid #ccc;color:#111;padding:10px 16px;text-decoration:none">Anmelden</a></div>
<p style="margin:0">Kein Konto nötig.</p>
</div>
</div>`,
};

function assert(cond, msg) {
Expand Down Expand Up @@ -177,6 +218,30 @@ async function main() {
);
});

await check("ignores a wrapper anchor whose label a nested button paints", async () => {
const r = await measure(FIXTURES.wrapperAnchor);
assert(!r.contrast.some((c) => c.tag === "a"), `the wrapper <a> must be skipped, got ${JSON.stringify(r.contrast)}`);
const btn = r.contrast.find((c) => c.tag === "button");
assert(btn && btn.value >= btn.floor, `the painted button passes on its own fill, got ${btn && btn.value}`);
});

await check("exempts a disabled control but reports its enabled twin", async () => {
const r = await measure(FIXTURES.disabledControl);
assert(!r.contrast.some((c) => c.text === "Send"), "the disabled button is exempt (WCAG inactive)");
const twin = r.contrast.find((c) => c.text === "Send twin");
assert(twin && twin.value < twin.floor, `the enabled twin is a real finding, got ${twin && twin.value}`);
});

await check("measures a padded button row by its box edge, not its label", async () => {
const r = await measure(FIXTURES.buttonRow);
assert(r.ragged.length === 0, `a flush button row is aligned, got ${JSON.stringify(r.ragged)}`);
});

await check("still catches a button row whose box is off the column", async () => {
const r = await measure(FIXTURES.buttonRowRagged);
assert(r.ragged.length >= 1, "a 16px box offset that returns must be reported");
});

await check("does NOT flag centered copy as a missing hanging indent", async () => {
const r = await measure(FIXTURES.centeredCopy);
assert(
Expand Down
42 changes: 35 additions & 7 deletions scripts/ci/ui-defect-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ export const MEASURE = String.raw`(() => {
var el = actions[i];
var text = (el.innerText || "").trim();
if (!text) continue;
// A wrapper whose visible label is painted by a nested action measures the
// wrapper's own never-painted ink: surf-your-life's bare <a> around a
// styled <button> read 1:1 against the button's fill. The nested control
// is in the scan in its own right, so the wrapper adds nothing but noise.
if (el.querySelector('a, button, [role="button"]')) continue;
// WCAG exempts inactive controls: a disabled Send button is dim BECAUSE it
// is disabled, and reporting it buries the real findings.
if (el.disabled || el.getAttribute("aria-disabled") === "true") continue;
if (el.closest("[disabled],[aria-disabled='true']")) continue;
var r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
var cs = getComputedStyle(el);
Expand Down Expand Up @@ -195,7 +204,7 @@ export const MEASURE = String.raw`(() => {
var rg = document.createRange();
rg.selectNodeContents(n);
var rects = Array.prototype.slice.call(rg.getClientRects());
if (rects.length) return rects;
if (rects.length) { rects.host = el; return rects; }
}
if (n.nodeType === 1) {
if (n.getAttribute && n.getAttribute("aria-hidden") === "true") continue;
Expand Down Expand Up @@ -236,16 +245,35 @@ export const MEASURE = String.raw`(() => {
// spans side by side on ONE line, and that is a peer line, not a group.
// Counting children alone exempted it and blinded the audit to the very
// stack it was written for.
var innerTops = {};
var innerCount = 0;
// With TOLERANCE, not exact tops: two buttons on one row measure ~1px
// apart when only one of them has a border, and exact comparison read
// that row as a two-line group — exempting the very misalignment under
// test. Real stacked lines sit at least a line-height (>8px) apart.
var innerTops = [];
for (var q = 0; q < kid.children.length; q++) {
var kr = lineRects(kid.children[q]);
if (!kr) continue;
var top = Math.round(kr[0].top);
if (!innerTops[top]) { innerTops[top] = 1; innerCount++; }
var top = kr[0].top;
var newTop = true;
for (var w = 0; w < innerTops.length; w++) {
if (Math.abs(innerTops[w] - top) < 8) { newTop = false; break; }
}
if (newTop) innerTops.push(top);
}
if (innerTops.length >= 2) continue;
// Where the row's PAINT starts. For plain text that is the first glyph;
// for a row led by an element that draws its own box — a button, a chip —
// it is that box's border edge, and the glyphs sit padding deeper by
// design. aoz's hero read as ragged because its CTA labels start 16px
// after the button edge that is actually flush with the column.
var edge = rects[0].left;
for (var h = rects.host; h && h !== box; h = h.parentElement) {
var hcs2 = getComputedStyle(h);
if (resolve(hcs2.backgroundColor)[3] > 0 || parseFloat(hcs2.borderLeftWidth) > 0) {
edge = h.getBoundingClientRect().left;
}
}
if (innerCount >= 2) continue;
kids.push({ el: kid, left: Math.round(rects[0].left), text: (kid.innerText || "").trim().slice(0, 40) });
kids.push({ el: kid, left: Math.round(edge), text: (kid.innerText || "").trim().slice(0, 40) });
}
if (kids.length < 3) continue;
seen++;
Expand Down