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
4 changes: 1 addition & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,7 @@ jobs:
run: pnpm check:dist

# In this job rather than the browser one because it needs no browser, and it reads
# source as well as dist. /report/ is prerender = false, so it writes no file and
# every gate that works from the built output is blind to it. a11y is the exception,
# since it starts a dev server for exactly that reason.
# source as well as dist so server handlers and conditional states are covered too.
- name: Published addresses
run: pnpm check:emails

Expand Down
6 changes: 1 addition & 5 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,7 @@ import { rehypeDemoteHeadings } from './src/lib/rehype-demote-headings.mjs';

noindexRoutes covers what serialize cannot see. On demand pages write no file, so there
is no markup to read, and they reach the sitemap purely as routes. That is how
/unsubscribe/ stayed listed after being parked, and how /account/ was listed at all.

This file used to assert that on demand pages are invisible to the sitemap and that
report was therefore excluded. Both halves were false. Nothing surfaced it because a
sitemap is generated and never read by a person.
/unsubscribe/ stayed listed after being parked.
*/
function noindexRoutes() {
const root = fileURLToPath(new URL('./src/pages/', import.meta.url));
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@
"@astrojs/preact": "^4.1.3",
"@astrojs/rss": "^4.0.15",
"@astrojs/sitemap": "^3.7.0",
"@fontsource-variable/atkinson-hyperlegible-next": "^5.3.0",
"@fontsource-variable/fira-code": "^5.3.0",
"@fontsource-variable/fraunces": "^5.3.0",
"@fontsource-variable/anybody": "^5.3.0",
"@fontsource-variable/chivo": "^5.3.0",
"@fontsource-variable/martian-mono": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@supabase/ssr": "^0.12.4",
"@supabase/supabase-js": "^2.111.0",
"astro": "^5.17.1",
Expand Down
32 changes: 20 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added public/images/type-stage/laugh-800.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/type-stage/laugh.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/type-stage/point-800.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/type-stage/point.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/type-stage/shirt-800.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/type-stage/shirt.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/type-stage/shrug-800.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/type-stage/shrug.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/type-stage/think-800.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/type-stage/think.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
176 changes: 66 additions & 110 deletions scripts/a11y.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
Accessibility gate.

WCAG 2.2 AA is a decision, not an aspiration, so it is checked by a script that fails
the build rather than by remembering to look. Every page archetype is audited, under a
dark theme and a light theme, at a phone width and a desktop width, because contrast
and reflow failures only show up in some of those combinations.
the build rather than by remembering to look. Every page archetype is audited in the
fixed Type Stage palette at phone and desktop widths.

Run it against a built dist. `pnpm a11y` serves dist and does the rest.
*/
Expand All @@ -20,7 +19,10 @@ import { provenanceSuffix } from './lib/provenance.mjs';
a line here, which is the point. */
const PAGES = [
['home', '/'],
['all content', '/all/'],
['articles', '/articles/'],
['videos', '/videos/'],
['topics', '/topics/'],
['topic index', '/csharp/'],
['topic filtered', '/csharp/articles/'],
/*
Expand Down Expand Up @@ -71,17 +73,9 @@ if (!PAGES.some(([label]) => label === 'article')) {

/*
Pages that are rendered on demand, so they are not in dist and a static audit cannot see
them. The report page reads its prefill on the server, which is what keeps it working
with JavaScript off, and that is exactly why it must not be the one page nobody checks.
them. Unsubscribe reads and validates its token on the server, so it needs a real render.
*/
const ON_DEMAND = [
['report', '/report/'],
['report prefilled', '/report/?type=comment&ref=00000000-0000-4000-8000-000000000000&target=%2Fcsharp%2F'],
['report sent', '/report/?sent=1'],
['report refused', '/report/?sent=slow'],
['submit', '/submit/'],
['submit sent', '/submit/?sent=1'],
['submit refused', '/submit/?sent=consent'],
['unsubscribe missing token', '/unsubscribe/'],
[
'unsubscribe confirmation',
Expand All @@ -94,9 +88,7 @@ const VIEWPORTS = [
['desktop', { width: 1280, height: 900 }]
];

/* One dark and one light. hotdog-stand is the harshest palette the generator emits, so
it is the one most likely to expose a contrast rule the guard missed. */
const THEMES = ['bbb-dark', 'bbb-light', 'hotdog-stand'];
const THEMES = ['type-stage'];

/*
The 31 July unexplained failure, now explained.
Expand Down Expand Up @@ -167,6 +159,40 @@ for (const [vpName, viewport] of VIEWPORTS) {
violations nobody changed anything to cause. */
await page.evaluate(() => document.fonts.ready);

/*
Vite can briefly show its own error overlay while the on demand route finishes
compiling. Give that one clean retry, then report Vite's message instead of asking
axe to grade the overlay as if it were the site.
*/
const viteError = () =>
page.evaluate(() => {
const root = document.querySelector('vite-error-overlay')?.shadowRoot;
if (!root) return null;

const message = root.getElementById('message-content')?.textContent?.trim();
const stack = root.getElementById('stack-content')?.textContent?.trim();
return [message, stack?.split('\n').slice(0, 8).join('\n')]
.filter(Boolean)
.join('\n');
});

let devError = await viteError();
if (devError) {
const retry = await page.reload({ waitUntil: 'load' });
if (!retry || retry.status() !== 200) {
failures.push(`${label} ${url} returned ${retry ? retry.status() : 'no response'} on retry`);
continue;
}

await page.waitForTimeout(600);
await page.evaluate(() => document.fonts.ready);
devError = await viteError();
if (devError) {
failures.push(`${label} [${vpName}] Vite failed to render the route after a retry:\n${devError}`);
continue;
}
}

/*
Find every disclosure on the page and tag it, so its contents can be audited.

Expand Down Expand Up @@ -369,103 +395,34 @@ for (const [vpName, viewport] of VIEWPORTS) {
failures.push(`${label} [${vpName}] scrolls sideways by ${overflow}px`);
}

/*
Focus rings on the two controls that are not boxes, WCAG 2.2 success criterion 2.4.7.

Here rather than in a script of its own because these two pages are prerender = false,
so they write no file and this is the only gate with a dev server that can reach them.
It is also the same subject: axe has no rule for focus appearance, so a ring that is
drawn on the wrong element, or not drawn at all, passes every audit above.

The defect it was written for shipped on both pages. `.field input:focus` had no
exclusion for radios and checkboxes, so clicking an option drew a 2px square around a
13px dot floating inside a much larger rounded chip, and it fired on :focus rather
than :focus-visible so a mouse click drew it. It was doing the same to the consent
checkboxes on submit, on top of the correct .consent rule fifteen lines away, which
nobody had noticed because nobody was looking at that control.

Removing the ring outright was the literal request and would have failed 2.4.7, so
what is asserted is that it moved rather than that it went away.

Transitions are already settled here: every context is opened with reducedMotion
'reduce' and app.css collapses transition-duration under it, so a computed style read
straight after a state change is the settled one. That is measured in check:headings
rather than assumed here.

Clicks assert a transition rather than a state. Asserting "checked" after a click
makes the answer depend on whether the fixture ships pre-ticked, and "no ring" only
means something if the click landed at all.
*/
/* Axe does not measure focus appearance, so prove keyboard focus remains visible. */
const ringed = (el) => {
const s = getComputedStyle(el);
return s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0;
};

if (await page.locator('.pick input').first().count()) {
focusChecked.add(label);

const atRest = await page.evaluate((fn) => {
const test = new Function('el', `return (${fn})(el)`);
return [...document.querySelectorAll('.pick')].filter(
(chip) => test(chip) || test(chip.querySelector('input'))
).length;
}, ringed.toString());
if (atRest > 0) failures.push(`${label} [${vpName}] draws a focus ring on ${atRest} option(s) at rest`);

/* An option that is not already selected, so the click has somewhere to move to. */
const target = await page.evaluate(() => {
const chips = [...document.querySelectorAll('.pick')];
const chip = chips.find((c) => !c.querySelector('input').checked) ?? chips[0];
chip.setAttribute('data-focus-probe', '');
return chips.indexOf(chip);
});
if (target < 0) failures.push(`${label} [${vpName}] has options but none could be probed`);

await page.locator('[data-focus-probe]').click();
const onMouse = await page.evaluate((fn) => {
const test = new Function('el', `return (${fn})(el)`);
const chip = document.querySelector('[data-focus-probe]');
const input = chip.querySelector('input');
return { checked: input.checked, chip: test(chip), dot: test(input) };
}, ringed.toString());

if (!onMouse.checked) failures.push(`${label} [${vpName}] clicking an option did not select it`);
if (onMouse.dot) failures.push(`${label} [${vpName}] a mouse click rings the radio dot`);
if (onMouse.chip) failures.push(`${label} [${vpName}] a mouse click rings the option chip`);

/*
Keyboard next. A real key press first, because :focus-visible follows the modality of
the last interaction, and the click above has just set that to mouse. Focusing after
the press is what a reader arrowing through the group ends up in.
*/
await page.keyboard.press('Tab');
await page.evaluate(() => document.querySelector('[data-focus-probe] input').focus());
const onKeys = await page.evaluate((fn) => {
const test = new Function('el', `return (${fn})(el)`);
const chip = document.querySelector('[data-focus-probe]');
return { chip: test(chip), dot: test(chip.querySelector('input')) };
}, ringed.toString());

if (!onKeys.chip) failures.push(`${label} [${vpName}] a keyboard focused option draws no ring at all`);
if (onKeys.dot) failures.push(`${label} [${vpName}] a keyboard focused option rings the dot as well as the chip`);

await page.evaluate(() => document.querySelector('[data-focus-probe]')?.removeAttribute('data-focus-probe'));
}

if (await page.locator('.consent input').first().count()) {
focusChecked.add(label);

const before = await page.locator('.consent input').first().isChecked();
await page.locator('.consent input').first().click();
const consent = await page.evaluate((fn) => {
const test = new Function('el', `return (${fn})(el)`);
const input = document.querySelector('.consent input');
return { checked: input.checked, ring: test(input) };
}, ringed.toString());
await page.evaluate(() => {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
});
await page.keyboard.press('Tab');

const onKeys = await page.evaluate((fn) => {
const test = new Function('el', `return (${fn})(el)`);
const active = document.activeElement;
if (!(active instanceof HTMLElement)) return { skip: false, ring: false, visible: false };

const rect = active.getBoundingClientRect();
return {
skip: active.matches('.skip'),
ring: test(active),
visible: rect.width > 0 && rect.height > 0
};
}, ringed.toString());
focusChecked.add(label);

if (consent.checked === before) failures.push(`${label} [${vpName}] clicking a consent box did not toggle it`);
if (consent.ring) failures.push(`${label} [${vpName}] a mouse click rings the consent checkbox`);
}
if (!onKeys.skip) failures.push(`${label} [${vpName}] does not focus the skip link first`);
if (!onKeys.ring) failures.push(`${label} [${vpName}] keyboard focus draws no ring`);
if (!onKeys.visible) failures.push(`${label} [${vpName}] keyboard focus is not visible`);
}

await context.close();
Expand All @@ -491,11 +448,10 @@ if (disclosuresOpened === 0) {
process.exit(1);
}

if (focusChecked.size < 2) {
if (focusChecked.size < 1) {
/*
Fail closed, same reasoning as the disclosures above. Both /report/ and /submit/ carry
these controls, so anything under two means a selector stopped matching and the focus
checks quietly measured nothing while still reporting clean.
Fail closed, same reasoning as the disclosures above. Zero means a selector stopped
matching and the focus checks quietly measured nothing while still reporting clean.
*/
console.error(
`focus rings were only checked on ${focusChecked.size} page(s): ${[...focusChecked].join(', ') || 'none'}.`
Expand Down
4 changes: 3 additions & 1 deletion scripts/check-dist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ const DIST = 'dist';
Route segments that must not ship. Add to this when a feature is parked, and delete the
entry in the same commit that unparks it.
*/
const PARKED = [];
const PARKED = [
{ route: 'report', why: 'Problem reports are retired.' }
];

const problems = [];

Expand Down
27 changes: 6 additions & 21 deletions scripts/check-emails.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,8 @@ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
/*
Source files worth scanning as well as dist.

Not decoration. /report/ is prerender = false, so it renders on demand and never writes
a file, which means every gate in this repo that reads dist is blind to the page the
whole reporting flow ends on. That is the page this decision exists for. A dist only
version of this gate would have reported clean on the one page it most needed to read.

a11y.mjs is the exception and is worth naming rather than leaving implied: it starts a
dev server and audits seven on demand targets, /report/ among them. So the page is not
unaudited, it is unreadable to anything that works from the built output, which is this
gate and four others.
Dist proves which addresses reach readers in the common path. Source catches addresses
in server handlers and conditional states that the static output may not render.
*/
const SRC_DIRS = ['src/pages', 'src/components', 'src/config', 'src/lib'];

Expand Down Expand Up @@ -139,24 +132,16 @@ for (const file of [...distFiles, ...srcFiles]) {
}

/*
Fail closed, and the two halves are not the same assertion.

privacy@ is checked in dist because /privacy/ is a static page, so this follows the
address all the way to the markup a reader is handed.

coc@ can only be checked in source, because /report/ renders on demand and writes no
file. Stating that rather than quietly checking the easy half, since a gate that cannot
see its most important page should say so where somebody will read it.
Fail closed. Both role addresses belong on static pages, so the built output proves they
reach the markup a reader receives.
*/
const distText = distFiles.map((f) => fs.readFileSync(f, 'utf8')).join('\n');
const reportPage = path.join(ROOT, 'src', 'pages', 'report.astro');
const reportText = fs.existsSync(reportPage) ? fs.readFileSync(reportPage, 'utf8') : '';

if (!distText.includes('privacy@baldbeardedbuilder.com')) {
failures.push('dist publishes no privacy address, so this gate is not reading a real page');
}
if (!reportText.includes('SITE.conductEmail')) {
failures.push('report.astro no longer names SITE.conductEmail, so the conduct inbox is unrouted');
if (!distText.includes('coc@baldbeardedbuilder.com')) {
failures.push('dist publishes no conduct address, so the conduct inbox is unrouted');
}

if (failures.length) {
Expand Down
Loading