diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3c1210..504da2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/astro.config.mjs b/astro.config.mjs index eea8cf7..268fc73 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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)); diff --git a/package.json b/package.json index 9ff5bd7..3728458 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d88445..7774cad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,13 +20,16 @@ importers: '@astrojs/sitemap': specifier: ^3.7.0 version: 3.7.0 - '@fontsource-variable/atkinson-hyperlegible-next': + '@fontsource-variable/anybody': specifier: ^5.3.0 version: 5.3.0 - '@fontsource-variable/fira-code': + '@fontsource-variable/chivo': specifier: ^5.3.0 version: 5.3.0 - '@fontsource-variable/fraunces': + '@fontsource-variable/martian-mono': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/archivo-black': specifier: ^5.3.0 version: 5.3.0 '@supabase/ssr': @@ -663,14 +666,17 @@ packages: '@fastify/busboy@3.2.0': resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} - '@fontsource-variable/atkinson-hyperlegible-next@5.3.0': - resolution: {integrity: sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==} + '@fontsource-variable/anybody@5.3.0': + resolution: {integrity: sha512-3u/FSV3mGv9xW3x+CdK9lAZYT1Y3NaRA1bexeoIuk9s5QE8sD8OOTcofvpPx0Z6M0Fq1WAJ06ZAw8hivdRQWHQ==} - '@fontsource-variable/fira-code@5.3.0': - resolution: {integrity: sha512-ZhvHeknlicraKVSJBa1NrJnXDVByUkC407zwgELYfmnx3p9g8vwIPfQCxx8YD3K7dAeCdLuHJOHlpMfRsIcApQ==} + '@fontsource-variable/chivo@5.3.0': + resolution: {integrity: sha512-lbYs65CyAE3RDvZjiFSt2HYF/cPl/2wB3RSBzSlRqPxYw5uqA/Ux/Wa/BuDEKo72NXmiR9F5ba8FXqJnooRhSA==} - '@fontsource-variable/fraunces@5.3.0': - resolution: {integrity: sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA==} + '@fontsource-variable/martian-mono@5.3.0': + resolution: {integrity: sha512-0wVODx9ILwNnaYC4XCuSaFRYtj1Prd89MRVaSuv2ng6zLiQkS4SepaWEPefs7vqCZS8sbEOA8jb/4F5KNMnGdg==} + + '@fontsource/archivo-black@5.3.0': + resolution: {integrity: sha512-7EsTIQ+Blf96ktpzTUpI7msi+u9/AJxM/GZxOSkEubVd1jSTrFSTNxMgfpX/8rQsjZ+19a+qvRbtcTEp5J/72g==} '@formatjs/ecma402-abstract@2.3.6': resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} @@ -5383,11 +5389,13 @@ snapshots: '@fastify/busboy@3.2.0': {} - '@fontsource-variable/atkinson-hyperlegible-next@5.3.0': {} + '@fontsource-variable/anybody@5.3.0': {} + + '@fontsource-variable/chivo@5.3.0': {} - '@fontsource-variable/fira-code@5.3.0': {} + '@fontsource-variable/martian-mono@5.3.0': {} - '@fontsource-variable/fraunces@5.3.0': {} + '@fontsource/archivo-black@5.3.0': {} '@formatjs/ecma402-abstract@2.3.6': dependencies: diff --git a/public/images/type-stage/laugh-800.webp b/public/images/type-stage/laugh-800.webp new file mode 100644 index 0000000..7a80d98 Binary files /dev/null and b/public/images/type-stage/laugh-800.webp differ diff --git a/public/images/type-stage/laugh.webp b/public/images/type-stage/laugh.webp new file mode 100644 index 0000000..2b10819 Binary files /dev/null and b/public/images/type-stage/laugh.webp differ diff --git a/public/images/type-stage/point-800.webp b/public/images/type-stage/point-800.webp new file mode 100644 index 0000000..7d23b56 Binary files /dev/null and b/public/images/type-stage/point-800.webp differ diff --git a/public/images/type-stage/point.webp b/public/images/type-stage/point.webp new file mode 100644 index 0000000..99ca0de Binary files /dev/null and b/public/images/type-stage/point.webp differ diff --git a/public/images/type-stage/shirt-800.webp b/public/images/type-stage/shirt-800.webp new file mode 100644 index 0000000..df10e2c Binary files /dev/null and b/public/images/type-stage/shirt-800.webp differ diff --git a/public/images/type-stage/shirt.webp b/public/images/type-stage/shirt.webp new file mode 100644 index 0000000..2be6d68 Binary files /dev/null and b/public/images/type-stage/shirt.webp differ diff --git a/public/images/type-stage/shrug-800.webp b/public/images/type-stage/shrug-800.webp new file mode 100644 index 0000000..0f1d759 Binary files /dev/null and b/public/images/type-stage/shrug-800.webp differ diff --git a/public/images/type-stage/shrug.webp b/public/images/type-stage/shrug.webp new file mode 100644 index 0000000..9c0a5c2 Binary files /dev/null and b/public/images/type-stage/shrug.webp differ diff --git a/public/images/type-stage/think-800.webp b/public/images/type-stage/think-800.webp new file mode 100644 index 0000000..fc0da87 Binary files /dev/null and b/public/images/type-stage/think-800.webp differ diff --git a/public/images/type-stage/think.webp b/public/images/type-stage/think.webp new file mode 100644 index 0000000..de0d4c1 Binary files /dev/null and b/public/images/type-stage/think.webp differ diff --git a/scripts/a11y.mjs b/scripts/a11y.mjs index e8bb290..6ed8fbc 100644 --- a/scripts/a11y.mjs +++ b/scripts/a11y.mjs @@ -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. */ @@ -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/'], /* @@ -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', @@ -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. @@ -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. @@ -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(); @@ -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'}.` diff --git a/scripts/check-dist.mjs b/scripts/check-dist.mjs index 98b14c8..5bbe4d2 100644 --- a/scripts/check-dist.mjs +++ b/scripts/check-dist.mjs @@ -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 = []; diff --git a/scripts/check-emails.mjs b/scripts/check-emails.mjs index 0b18b30..35bf693 100644 --- a/scripts/check-emails.mjs +++ b/scripts/check-emails.mjs @@ -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']; @@ -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) { diff --git a/scripts/check-headings.mjs b/scripts/check-headings.mjs index 6a442d1..74a8e9c 100644 --- a/scripts/check-headings.mjs +++ b/scripts/check-headings.mjs @@ -1,303 +1,109 @@ /* - Prose heading colour, measured in a browser rather than read back from the file. - - Decision 130. h2, h3 and h4 each take a colour, resolved per theme by gen-themes.mjs - because CSS cannot ask whether two colours are far apart. Sixteen themes multiplied by - three levels is 48 answers, none of which a person is going to check by eye, and the - failure mode is a heading that reads as body text or as a link rather than one that - looks broken. - - Three things this gate does deliberately. - - IT MEASURES COMPUTED COLOUR, NOT THE GENERATED FILE. Reading themes.css back and - checking the numbers agree with themselves is not verification, it is the generator - marking its own homework. What matters is the colour that wins the cascade on a real - page, which is a question about app.css and themes.css together, and only a browser - knows the answer. - - IT KILLS TRANSITIONS RATHER THAN WAITING OUT. app.css puts a .18s colour transition on - body, so a naive gate that switches theme and reads immediately samples a blend of two - themes and reports a colour that exists nowhere. A longer wait is not the fix: a wait is - a race that passes on a fast laptop and fails under load in CI. The stylesheet injected - below removes transitions outright, so the measurement is of the settled state, which is - the only state a reader is ever in. - - The obvious alternative was to open the context with reducedMotion reduce, which - app.css already collapses to .01ms, and which is how a11y.mjs avoids this. That would - work today and is the wrong mechanism, because it makes this gate's correctness depend - on a rule inside the file it is auditing. Deleting the reduced motion block would then - silently turn this into a gate that measures mid transition colours and still passes. - - IT IS HELD TO A WRITTEN LIST OF FALLBACKS. Six of the 48 pairs, in this run ten, cannot - be given a colour and land on --fg-strong. In the output a justified fallback and a lazy - one are byte identical, so without a record the gate can only assert that every heading - is some colour the generator emitted, which it always is. gen-themes.mjs writes down - which pairs it could not colour, and this holds the CSS to exactly that list: a theme - edit that opens up a colour turns this red until the CSS is regenerated. - - IT INJECTS THE HEADINGS. Real content today has h2 and h3 and no h4, h5 or h6 at all, - measured across the built output. A gate that looked for real elements would cover two - of the five levels and would go red the day somebody edits a post, which decision 117 - forbids. The elements are synthetic and everything else is real: a real page, a real - .prose, the real cascade, the real theme block. - - Run against a built dist, after pnpm build. + The Type Stage reading surface uses one fixed palette. This gate checks the real cascade + on an article, including contrast and the heading size hierarchy. */ import { chromium } from 'playwright'; -import fs from 'node:fs'; -import path from 'node:path'; import { serveDist } from './lib/serve-dist.mjs'; import { provenanceSuffix } from './lib/provenance.mjs'; import { firstArticlePage } from './lib/archetypes.mjs'; -import { THEMES } from '../src/lib/themes.generated.ts'; - -const record = JSON.parse( - fs.readFileSync(path.resolve('src/lib/heading-fallbacks.generated.json'), 'utf8') -); -const { thresholds, fallbacks } = record; - -const LEVELS = ['h2', 'h3', 'h4']; -const NEUTRAL_LEVELS = ['h5', 'h6']; - -/* ---------- colour maths, the same shape the generator uses ---------- */ const parse = (css) => { - const m = css.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i); - if (!m) throw new Error(`cannot read colour from "${css}"`); - return [Number(m[1]), Number(m[2]), Number(m[3])]; + const match = css.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i); + if (!match) throw new Error(`cannot read colour from "${css}"`); + return [Number(match[1]), Number(match[2]), Number(match[3])]; }; -const chan = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; -const lum = (c) => { const [r, g, b] = c.map(chan); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; -const ratio = (a, b) => { - const l1 = lum(a), l2 = lum(b); - return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +const channel = (value) => { + const normalized = value / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; }; -const linearize = (v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; -const D65 = [0.95047, 1, 1.08883]; -const lab = (c) => { - const [r, g, b] = c.map(linearize); - const xyz = [ - r * 0.4124564 + g * 0.3575761 + b * 0.1804375, - r * 0.2126729 + g * 0.7151522 + b * 0.0721750, - r * 0.0193339 + g * 0.1191920 + b * 0.9503041 - ]; - const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29); - const [x, y, z] = xyz.map((v, i) => f(v / D65[i])); - return [116 * y - 16, 500 * (x - y), 200 * (y - z)]; +const luminance = (color) => { + const [red, green, blue] = color.map(channel); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; }; -const deltaE = (a, b) => { - const [l1, a1, b1] = lab(a); - const [l2, a2, b2] = lab(b); - return Math.sqrt((l1 - l2) ** 2 + (a1 - a2) ** 2 + (b1 - b2) ** 2); +const contrast = (left, right) => { + const a = luminance(left); + const b = luminance(right); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); }; -const same = (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; -const show = (c) => `rgb(${c.join(',')})`; -/* ---------- run ---------- */ - -/* Returns an array of [label, url] pairs so a11y can spread it. Take the first pair. */ -const [[articleLabel, article] = []] = firstArticlePage(); -if (!article) { - console.error('check:headings found no article page in dist to measure against.'); +const [[articleLabel, articlePath] = []] = firstArticlePage(); +if (!articlePath) { + console.error('check:headings found no article page in dist.'); process.exit(1); } const { server, base } = await serveDist(); const browser = await chromium.launch(); -const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); -const page = await context.newPage(); - +const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); const failures = []; -let measured = 0; -let fellBack = 0; try { - const res = await page.goto(base + article, { waitUntil: 'load' }); - if (!res || res.status() !== 200) { - throw new Error(`${articleLabel} ${article} returned ${res ? res.status() : 'no response'}`); + const response = await page.goto(base + articlePath, { waitUntil: 'load' }); + if (!response || response.status() !== 200) { + throw new Error(`${articleLabel} returned ${response ? response.status() : 'no response'}`); } - - const hasProse = await page.locator('.prose').count(); - if (!hasProse) throw new Error(`${articleLabel} ${article} has no .prose to measure in`); - - /* - Transitions off, and the sample elements in. Both are injected once and survive the - theme loop, because switching theme is an attribute change on documentElement and - neither the stylesheet nor the nodes are touched by it. - */ - await page.addStyleTag({ - content: '*, *::before, *::after { transition: none !important; animation: none !important; }' - }); - await page.evaluate((levels) => { + await page.evaluate(() => document.fonts.ready); + await page.evaluate(() => { const prose = document.querySelector('.prose'); const probe = document.createElement('div'); probe.id = 'heading-probe'; - probe.innerHTML = - levels.map((l) => `<${l} data-probe="${l}">Heading${l}>`).join('') + - '
Prose
'; + probe.innerHTML = 'Prose
'; prose.appendChild(probe); - }, [...LEVELS, ...NEUTRAL_LEVELS]); - - for (const theme of THEMES) { - await page.evaluate((id) => document.documentElement.setAttribute('data-theme', id), theme.id); - - const read = await page.evaluate(() => { - const out = {}; - for (const el of document.querySelectorAll('[data-probe]')) { - out[el.dataset.probe] = getComputedStyle(el).color; - } - const root = getComputedStyle(document.documentElement); - out.__bg = getComputedStyle(document.body).backgroundColor; - out.__strong = root.getPropertyValue('--fg-strong').trim(); - out.__fg = root.getPropertyValue('--fg').trim(); - /* - The same level as it renders on real content, so the injected probe is held to - something rather than only to itself. A probe that is a grandchild of .prose and - a real heading that is a child of it match the same selector, and asserting that - rather than assuming it is the difference between measuring the page and - measuring the measurement. - */ - const real = document.querySelector('.prose > h2'); - out.__realH2 = real ? getComputedStyle(real).color : null; - return out; - }); - - const at = (k) => parse(read[k]); - const bg = at('__bg'); - const prose = at('p'); - const link = at('a'); - const strong = parse( - read.__strong.startsWith('#') - ? `rgb(${[1, 3, 5].map((i) => parseInt(read.__strong.slice(i, i + 2), 16)).join(',')})` - : read.__strong - ); - const fg = parse( - read.__fg.startsWith('#') - ? `rgb(${[1, 3, 5].map((i) => parseInt(read.__fg.slice(i, i + 2), 16)).join(',')})` - : read.__fg - ); - - const expectedFallbacks = fallbacks[theme.id]; - if (!expectedFallbacks) { - failures.push(`${theme.id}: no fallback record, so nothing can be held to anything`); - continue; - } - - /* - The clamp. --fg-strong is the more extreme of the seed and --fg, never the weaker, - because --fg is already at the end of the scale in most themes and is literally - #000000 in hotdog-stand, where a fixed dark grey would be lighter than the prose it - is meant to outrank. - */ - const strongOk = theme.scheme === 'dark' ? lum(strong) >= lum(fg) : lum(strong) <= lum(fg); - if (!strongOk) { - failures.push( - `${theme.id}: --fg-strong ${show(strong)} is weaker than --fg ${show(fg)} in a ` + - `${theme.scheme} theme, so a fallback heading reads as dimmer than its own paragraph` - ); - } - - if (!read.__realH2) { - failures.push( - `${theme.id}: ${articleLabel} carries no .prose > h2, so the probe is only being ` + - `held to itself. Point this gate at a page with prose headings on it.` - ); - } else { - const realH2 = parse(read.__realH2); - measured++; - if (!same(realH2, at('h2'))) { - failures.push( - `${theme.id}: a real h2 computes ${show(realH2)} but the probe h2 computes ` + - `${show(at('h2'))}, so the rule does not reach real content` - ); - } - } + }); - for (const level of NEUTRAL_LEVELS) { const c = at(level); - measured++; - if (!same(c, strong)) { - failures.push(`${theme.id} ${level}: ${show(c)} is not --fg-strong ${show(strong)}`); - } + const result = await page.evaluate(() => { + const values = {}; + for (const level of ['h2', 'h3', 'h4', 'h5', 'h6', 'p']) { + const style = getComputedStyle(document.querySelector(`#heading-probe ${level}`)); + values[level] = { + color: style.color, + size: parseFloat(style.fontSize), + weight: Number(style.fontWeight), + family: style.fontFamily + }; } + const surface = getComputedStyle(document.querySelector('.detail-shell')).backgroundColor; + const real = document.querySelector('.prose > h2'); + return { + values, + surface, + realH2: real ? getComputedStyle(real).color : null + }; + }); - const assigned = []; - for (const level of LEVELS) { - const c = at(level); - measured++; const shouldFallBack = expectedFallbacks.includes(level); - - if (shouldFallBack) { - fellBack++; - if (!same(c, strong)) { - failures.push( - `${theme.id} ${level}: recorded as a fallback but computed ${show(c)}, ` + - `not --fg-strong ${show(strong)}` - ); - } - continue; - } + const background = parse(result.surface); + for (const level of ['h2', 'h3', 'h4', 'h5', 'h6']) { + const value = result.values[level]; + const ratio = contrast(parse(value.color), background); + if (ratio < 4.5) failures.push(`${level} contrast is ${ratio.toFixed(2)}:1 on the reading surface.`); + if (!value.family.includes('Anybody')) failures.push(`${level} does not use the Type Stage supporting display face.`); + } - if (same(c, strong)) { - failures.push( - `${theme.id} ${level}: fell back to --fg-strong and is not on the recorded list. ` + - `Either it should have taken a colour, or gen-themes.mjs needs rerunning.` - ); - continue; - } + if (!result.realH2) { + failures.push(`${articleLabel} has no real prose h2 to compare with the probe.`); + } else if (result.realH2 !== result.values.h2.color) { + failures.push('the real prose h2 does not match the checked heading rule.'); + } - const dProse = deltaE(c, prose); - const dLink = deltaE(c, link); - const onBg = ratio(c, bg); - if (dProse < thresholds.prose) { - failures.push( - `${theme.id} ${level}: ${show(c)} is deltaE ${dProse.toFixed(1)} from prose, ` + - `under ${thresholds.prose}, so it reads as body text` - ); - } - if (dLink < thresholds.link) { - failures.push( - `${theme.id} ${level}: ${show(c)} is deltaE ${dLink.toFixed(1)} from the link colour, ` + - `under ${thresholds.link}, so it reads as a link` - ); - } - if (onBg < 4.5) { - failures.push( - `${theme.id} ${level}: ${show(c)} is ${onBg.toFixed(2)}:1 on the page background, under AA` - ); - } - for (const [other, oc] of assigned) { - const d = deltaE(c, oc); - if (d < thresholds.level) { - failures.push( - `${theme.id} ${level}: ${show(c)} is deltaE ${d.toFixed(1)} from ${other}, ` + - `under ${thresholds.level}, so the levels collapse into one colour` - ); - } - } - assigned.push([level, c]); + const sizes = ['h2', 'h3', 'h4', 'h5', 'h6'].map((level) => result.values[level].size); + for (let index = 1; index < sizes.length; index += 1) { + if (sizes[index] > sizes[index - 1]) { + failures.push(`h${index + 2} is larger than h${index + 1} on the reading surface.`); } } + if (result.values.h2.weight < 700 || result.values.h3.weight < 700) { + failures.push('the main prose headings are not visually stronger than the body.'); + } } finally { await browser.close(); server.close(); } -if (measured === 0) { - console.error('check:headings measured nothing. Refusing to report clean.'); - process.exit(1); -} - if (failures.length) { - console.error('check:headings found prose headings that do not hold:\n'); - for (const line of failures) console.error(` ${line}`); - console.error(`\n${failures.length} problem(s) across ${measured} measurements.${provenanceSuffix()}`); + console.error('\ncheck:headings found Type Stage heading problems:\n'); + failures.forEach((failure) => console.error(` ${failure}`)); process.exit(1); } -const coloured = THEMES.length * LEVELS.length - fellBack; -console.log( - `check:headings clean across ${measured} computed colours in ${THEMES.length} themes. ` + - `${coloured} of ${THEMES.length * LEVELS.length} theme and level pairs carry a colour, ` + - `${fellBack} fall back to --fg-strong and every one of them is on the recorded list.` + - provenanceSuffix() -); +console.log(`Type Stage prose headings are clean on ${articleLabel}.${provenanceSuffix()}`); diff --git a/scripts/check-layout.mjs b/scripts/check-layout.mjs index 7acb4ad..775bf17 100644 --- a/scripts/check-layout.mjs +++ b/scripts/check-layout.mjs @@ -1,562 +1,161 @@ /* - Layout gate. The things only a browser can see. + Browser layout gate for the Type Stage shell. - Every automated gate in this repo so far checks data, markup or output files. That has - repeatedly missed a whole class of bug where the data is right and the rendering is - wrong: a progress bar drawn in the divider colour, a reply count correct about the - database and wrong about the reader, two status markers resolving to the same colour, a - linked title drawn identically to an unlinked one. None of those were reachable by a - test that never laid out a page. - - This one measures geometry against intent. - - THUMBNAIL CROPPING. .card .thumb declares aspect-ratio 16/9 and .card .thumb img uses - object-fit cover, so the moment the box stops being 16/9 the image gets cut. The wide - lead card is the exposed one: .card.wide is a grid with align-content stretch, and its - thumb drops the base max-height, so on the face of it the row height should drive the - thumb height and crop the picture. - - It does not, and the reason is worth writing down because it is load bearing and easy to - delete by accident. A definite inline size plus a specified aspect-ratio gives the box a - definite block size, and align-self stretch does not apply to an item that already has - one. So the row stretches, .body fills it, and the thumb keeps its ratio. Forcing the - neighbouring card to 2000px tall leaves the thumb at exactly its 16/9 box. - - That is a CSS subtlety, not a promise. Setting an explicit height on .card .thumb, or - removing its aspect-ratio, would silently start cropping every thumbnail on the site, - and nothing else here would notice. - - Run against a built dist, after pnpm build. + It measures the desktop and phone layouts that carry the most risk: the portrait stage, + mobile navigation, editorial archive rows, and the article reading grid. */ import { chromium } from 'playwright'; import { serveDist } from './lib/serve-dist.mjs'; import { provenanceSuffix } from './lib/provenance.mjs'; - -/* Pages that carry thumbnails, one per layout that can produce them. */ -const PAGES = [ - ['home', '/'], - ['videos', '/videos/'], - ['topic index', '/csharp/'] -]; - -const WIDTHS = [1200, 860, 390]; - -/* - A one pixel border on a 135 pixel box is most of a percent on its own, so the floor has - to clear rounding without letting a real crop through. Anything genuinely stretched - lands far above this: the failure mode being guarded costs about 27 percent. -*/ -const MAX_CROP_PERCENT = 3; +import { firstArticlePage } from './lib/archetypes.mjs'; const { server, base } = await serveDist(); const browser = await chromium.launch(); - const failures = []; -const cropHint = - 'Check whether .card .thumb still has its aspect-ratio and no explicit height. A box ' + - 'with both a width and a height ignores aspect-ratio, and object-fit cover then cuts ' + - 'the picture to fit.'; -let cropped = false; -let measured = 0; - -for (const [name, url] of PAGES) { - for (const width of WIDTHS) { - const page = await browser.newPage({ viewport: { width, height: 1200 } }); - await page.goto(base + url, { waitUntil: 'networkidle' }); - - const thumbs = await page.evaluate(() => { - const out = []; - for (const thumb of document.querySelectorAll('.thumb')) { - const img = thumb.querySelector('img'); - if (!img || !img.naturalWidth || !img.naturalHeight) continue; - - const box = thumb.getBoundingClientRect(); - if (!box.width || !box.height) continue; - - out.push({ - boxRatio: box.width / box.height, - srcRatio: img.naturalWidth / img.naturalHeight, - box: `${Math.round(box.width)}x${Math.round(box.height)}`, - wide: Boolean(thumb.closest('.card.wide')), - alt: (img.getAttribute('alt') || '').slice(0, 40) - }); - } - return out; - }); - - for (const t of thumbs) { - measured += 1; - - /* - cover scales to fill the box, so whichever axis is relatively short gets cut. - Report the proportion of the source lost, whichever way round it is. - */ - const lost = - t.boxRatio < t.srcRatio - ? 1 - t.boxRatio / t.srcRatio - : 1 - t.srcRatio / t.boxRatio; - const percent = lost * 100; +let measurements = 0; - if (percent > MAX_CROP_PERCENT) { - cropped = true; - failures.push( - `${name} at ${width}px: ${t.wide ? 'wide ' : ''}thumb ${t.box} loses ` + - `${percent.toFixed(1)}% of "${t.alt}" ` + - `(box ${t.boxRatio.toFixed(3)} against source ${t.srcRatio.toFixed(3)})` - ); - } - } - - await page.close(); +const insideViewport = (box, width, label) => { + if (box.left < -1 || box.right > width + 1) { + failures.push(`${label} leaves the ${width}px viewport (${box.left.toFixed(1)} to ${box.right.toFixed(1)}).`); } -} +}; -/* - The measurements above only prove today's layout is fine. The risk is that a future - change makes the thumb's height come from the row, and the row's height comes from - whatever card sits beside it. So force that: make the neighbour absurdly tall and check - the thumb has not moved. If aspect-ratio ever stops winning, this is what says so, and - it says so without waiting for a summary long enough to trigger it naturally. -*/ -const stress = await (async () => { - const page = await browser.newPage({ viewport: { width: 1200, height: 1000 } }); +for (const width of [1280, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); await page.goto(`${base}/`, { waitUntil: 'networkidle' }); - - const result = await page.evaluate(() => { - const card = document.querySelector('.card.wide'); - if (!card) return { skipped: 'no wide card on the home page' }; - - const thumb = card.querySelector('.thumb'); - const sibling = [...card.parentElement.children].find((el) => el !== card); - if (!thumb || !sibling) return { skipped: 'no thumb or no neighbour to stretch' }; - - const before = thumb.getBoundingClientRect(); - sibling.style.minHeight = '2000px'; - void card.offsetHeight; - const after = thumb.getBoundingClientRect(); - const cardHeight = card.getBoundingClientRect().height; - sibling.style.minHeight = ''; - - return { - cardGrewTo: Math.round(cardHeight), - before: `${Math.round(before.width)}x${Math.round(before.height)}`, - after: `${Math.round(after.width)}x${Math.round(after.height)}`, - grewBy: Math.round(after.height - before.height) + await page.evaluate(() => document.fonts.ready); + + const home = await page.evaluate(() => { + const box = (selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height }; }; - }); - - await page.close(); - return result; -})(); - -if (stress.skipped) { - failures.push(`could not run the stretch check: ${stress.skipped}`); -} else if (stress.grewBy > 1) { - cropped = true; - failures.push( - `the wide card's thumb stretched with its neighbour: ${stress.before} became ` + - `${stress.after} when the card grew to ${stress.cardGrewTo}px. The row height is ` + - `driving the thumb, so object-fit cover will crop the image.` - ); -} - -/* - DEAD SPACE IN THE LEAD CARD. The wide card sits in a grid row whose height is set by - whichever card beside it had the most to say. If the lead has less, the difference has to - go somewhere, and it shows up either as a hole between the title and the foot line or as a - band of nothing under the whole row. Both read as a broken card rather than a lead. - - MEASURE INK, NOT BOXES. The first version of this compared element rectangles, and it - scored a card 24px clean while 64px of dead space sat inside it, because a stray padding - shorthand had inflated the foot element itself. Every box was flush against its - neighbour and the card still had a hole in it. What a person sees is where the last - pixel of text or picture is, so that is what gets measured: walk the text nodes, take - the lowest one, and compare it to the card's content edge. -*/ -/* - The limit is 12px, and it is tight on purpose. Clean measures 1.8px, because the lead's - foot sits on the same baseline as its neighbours'. The regression this is really guarding - is align-content going back to start on .card.wide, which measures 26.1px and reads as a - visible hole under the lead. A limit of 48 passed that happily, so it was a gate that - could not fail for the one bug it was written after. -*/ -const DEAD_SPACE_LIMIT = 12; - -const deadSpace = await (async () => { - const page = await browser.newPage({ viewport: { width: 1200, height: 1200 } }); - await page.goto(`${base}/`, { waitUntil: 'networkidle' }); - - const result = await page.evaluate(() => { - const card = document.querySelector('.card.wide'); - if (!card) return { skipped: 'no wide card on the home page' }; - - const inkBottom = (root) => { - let lowest = -Infinity; - const range = document.createRange(); - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - for (let n = walker.nextNode(); n; n = walker.nextNode()) { - if (!n.textContent.trim()) continue; - range.selectNodeContents(n); - const r = range.getBoundingClientRect(); - if (r.height) lowest = Math.max(lowest, r.bottom); - } - for (const img of root.querySelectorAll('img')) { - const r = img.getBoundingClientRect(); - if (r.height) lowest = Math.max(lowest, r.bottom); - } - return lowest; - }; - - const style = getComputedStyle(card); - const box = card.getBoundingClientRect(); - const contentBottom = box.bottom - parseFloat(style.paddingBottom); - const ink = inkBottom(card); - if (!Number.isFinite(ink)) return { skipped: 'the wide card drew no text or images' }; - - const body = card.querySelector('.body'); + const doc = document.documentElement; + const portrait = document.querySelector('.hero-person'); + const portraitStyle = portrait ? getComputedStyle(portrait) : null; return { - trailing: Math.round(contentBottom - ink), - hasSummary: Boolean(body?.querySelector('p')?.textContent?.trim()), - cardHeight: Math.round(box.height) + overflow: doc.scrollWidth - doc.clientWidth, + hero: box('.hero'), + portrait: box('.hero-person'), + latest: box('.hero-latest'), + start: box('.start-grid'), + topics: box('.topic-list'), + footer: box('.site-footer'), + menuDisplay: getComputedStyle(document.querySelector('.menu-button')).display, + navDisplay: getComputedStyle(document.querySelector('.site-nav')).display, + portraitVisible: portraitStyle?.visibility !== 'hidden' && portraitStyle?.display !== 'none' }; }); - await page.close(); - return result; -})(); - -if (deadSpace.skipped) { - failures.push(`could not measure the lead card: ${deadSpace.skipped}`); -} else if (deadSpace.trailing > DEAD_SPACE_LIMIT) { - failures.push( - `the wide lead card has ${deadSpace.trailing}px of nothing below its last text, ` + - `in a card ${deadSpace.cardHeight}px tall` + - (deadSpace.hasSummary - ? '. Two known causes, in the order they are worth checking: align-content on ' + - '.card.wide is start rather than stretch, so .body never fills the card and the ' + - "foot's margin-top: auto has no free space to push into. Or a padding or margin " + - 'shorthand is reaching the foot from another rule, which is how the site footer ' + - 'once leaked 4rem into every card.' - : ', and it has no summary. A video has no description in the collection, so the ' + - 'lead needs a blurb in START_HERE in src/config/site.ts.') - ); -} - -/* - EMPTY CELLS AND SHORT CARDS. Decision 106. - - .grid is display:grid with gap 1px and background var(--line), and every card paints - var(--bg) over its own cell. The seams between cards are not borders, they are the grid's - background showing through one pixel gaps. That makes the divider system elegant and it - makes one thing dangerous: anything that does not cover its cell shows up as divider - colour, at full strength, in the shape of whatever is missing. - - Two shapes of the same defect, and both are measured here. - - A SHORT CARD leaves a horizontal band. That is what a lead card with align-content start - produced: the card stopped at its content while the row carried on, and the difference - drew as a coloured bar. - - AN EMPTY CELL is worse and is the one nothing on this branch could see. The lead spans two - columns and the rest span one, so the number of cards that tiles cleanly is fixed by the - column count. Miss it and a whole cell of var(--line) lands on the page as a solid block. - Adding one curated pick to a config file is enough to cause it, and no data test, no - markup test and no accessibility gate can see a rectangle of the wrong colour. - - Measured per row rather than per grid, because a hole in the middle of a grid and a hole - at the end are the same defect and only the last row is intuitive. - - THE PAGE LIST IS DERIVED, NOT WRITTEN DOWN. The thumbnail checks above run against three - hand picked layouts, which is right for them: they exist to cover one page per layout that - can produce a thumbnail. Reusing that list here would have left the check guarding the - pages somebody remembered rather than the pages that have a grid on them. - - So this walks the built output and visits every page that actually contains one. Today - that is exactly one page. The point is the day it is not, when somebody puts a grid on the - about page and nothing in this file has to be edited for it to be measured. Adding a grid - is the same shape of edit as adding a curated pick: it looks like content and it can paint - a coloured rectangle. -*/ -let cellsMeasured = 0; -const cellFailures = []; - -/** Every built page carrying a .grid, as urls, found by reading dist rather than by memory. */ -async function pagesWithGrids() { - const { readdir, readFile } = await import('node:fs/promises'); - const found = []; - - const walk = async (dir, prefix) => { - for (const entry of await readdir(dir, { withFileTypes: true })) { - const full = `${dir}/${entry.name}`; - if (entry.isDirectory()) { - await walk(full, `${prefix}${entry.name}/`); - continue; - } - if (entry.name !== 'index.html') continue; - const html = await readFile(full, 'utf8'); - /* - A class list and not a substring. `class="grid"` alone would miss `class="grid tight"`, - and a loose /\bgrid\b/ would match `foot-grid`, because a hyphen is a word boundary. - foot-grid has no painted background so it cannot hole, and pulling it in here would - make this check report on a grid it has nothing to say about. - */ - const hasGrid = [...html.matchAll(/class="([^"]*)"/g)].some((m) => - m[1].split(/\s+/).includes('grid') - ); - if (hasGrid) { - found.push([prefix === '' ? 'home' : prefix.replace(/\/$/, ''), `/${prefix}`]); - } + if (home.overflow > 1) failures.push(`home scrolls sideways by ${home.overflow}px at ${width}px.`); + for (const [name, box] of Object.entries({ + hero: home.hero, + latest: home.latest, + start: home.start, + topics: home.topics, + footer: home.footer + })) { + if (!box || box.width <= 0 || box.height <= 0) { + failures.push(`home ${name} has no measurable box at ${width}px.`); + continue; } - }; - - await walk('dist', ''); - return found; -} - -const GRID_PAGES = await pagesWithGrids(); - -for (const [name, url] of GRID_PAGES) { - for (const width of WIDTHS) { - const page = await browser.newPage({ viewport: { width, height: 1200 } }); - await page.goto(base + url, { waitUntil: 'networkidle' }); - - const grids = await page.evaluate(() => { - const out = []; - - for (const grid of document.querySelectorAll('.grid')) { - const cs = getComputedStyle(grid); - const tracks = cs.gridTemplateColumns.split(' ').filter(Boolean); - const cols = tracks.length; - const colW = parseFloat(tracks[0]) || 0; - const gap = parseFloat(cs.columnGap) || 0; - if (!cols || !colW) continue; - - const cards = [...grid.children] - .map((el) => { - const r = el.getBoundingClientRect(); - return { - top: r.top, - bottom: r.bottom, - height: r.height, - span: Math.max(1, Math.round((r.width + gap) / (colW + gap))), - label: (el.querySelector('h3')?.textContent || el.className || '').trim().slice(0, 44) - }; - }) - .filter((c) => c.height > 0); - if (!cards.length) continue; - - /* Group into rows by top edge, with a couple of pixels of tolerance for rounding. */ - const rows = []; - for (const c of [...cards].sort((a, b) => a.top - b.top)) { - const row = rows.find((r) => Math.abs(r.top - c.top) <= 2); - if (row) row.cards.push(c); - else rows.push({ top: c.top, cards: [c] }); - } - - const holes = []; - const short = []; - for (const [i, row] of rows.entries()) { - const spans = row.cards.reduce((n, c) => n + c.span, 0); - if (spans < cols) holes.push({ row: i + 1, missing: cols - spans, spans }); - - const rowBottom = Math.max(...row.cards.map((c) => c.bottom)); - for (const c of row.cards) { - const below = rowBottom - c.bottom; - if (below > 1) { - short.push({ row: i + 1, label: c.label, below: Math.round(below) }); - } - } - } - - out.push({ cols, cards: cards.length, rows: rows.length, holes, short }); - } - - return out; - }); - - for (const g of grids) { - cellsMeasured += g.cards; - - for (const h of g.holes) { - cellFailures.push( - `${name} at ${width}px: row ${h.row} of a ${g.cols} column grid fills only ` + - `${h.spans} of ${g.cols} columns, so ${h.missing} cell` + - `${h.missing === 1 ? '' : 's'} of divider colour ` + - `${h.missing === 1 ? 'is' : 'are'} drawn as a solid block. The grid paints ` + - `var(--line) behind cards that paint var(--bg), so an unoccupied cell is not ` + - `empty space, it is a coloured rectangle.` - ); - } - - for (const s of g.short) { - cellFailures.push( - `${name} at ${width}px: the card "${s.label}" ends ${s.below}px above the bottom ` + - `of row ${s.row}, so a band of divider colour shows under it. A card has to ` + - `cover its own cell, because the seams are grid background rather than borders.` - ); - } + insideViewport(box, width, `home ${name}`); + measurements++; + } + if (!home.portraitVisible || !home.portrait) { + failures.push(`the Michael portrait is hidden at ${width}px.`); + } else { + const visibleWidth = Math.min(home.portrait.right, width) - Math.max(home.portrait.left, 0); + if (visibleWidth / home.portrait.width < 0.75) { + failures.push(`less than 75% of the Michael portrait remains visible at ${width}px.`); } - - await page.close(); + measurements++; } -} - -failures.push(...cellFailures); - -/* - The topic nav keeps one row and reveals fewer destinations as the viewport narrows. - Dev disasters stays visible at the end rather than being sacrificed with the - lower-priority topics. Measure the geometry so clipping, scrolling, or wrapping - cannot make a nominally visible topic unreachable. -*/ -const NAV_WIDTHS = [1024, 640, 560, 480, 420, 390, 360]; -let navMeasured = 0; -let previousVisibleCount; -for (const width of NAV_WIDTHS) { - const page = await browser.newPage({ viewport: { width, height: 844 } }); - await page.goto(`${base}/`, { waitUntil: 'networkidle' }); - - const nav = await page.evaluate(() => { - const list = document.querySelector('.nav'); - if (!list) return null; - const box = list.getBoundingClientRect(); - const items = [...list.querySelectorAll('li')] - .filter((li) => li.getClientRects().length > 0) - .map((li) => { - const r = li.getBoundingClientRect(); - return { label: li.textContent.trim(), right: r.right, left: r.left, top: r.top }; + if (width === 1280) { + if (home.menuDisplay !== 'none') failures.push('the mobile menu button is visible on desktop.'); + if (home.navDisplay === 'none') failures.push('the primary navigation is hidden on desktop.'); + } else { + if (home.menuDisplay === 'none') failures.push('the mobile menu button is hidden at 390px.'); + await page.locator('.menu-button').click(); + const menu = await page.locator('.site-nav').evaluate((nav) => { + const rect = nav.getBoundingClientRect(); + const links = [...nav.querySelectorAll('a')].map((link) => { + const box = link.getBoundingClientRect(); + return { text: link.textContent.trim(), height: box.height }; }); - return { - count: items.length, - labels: items.map((i) => i.label), - /* One pixel of tolerance, because a subpixel layout can round a flush edge over. */ - clipped: items.filter((i) => i.right > box.right + 1 || i.left < box.left - 1).map((i) => i.label), - scrollable: list.scrollWidth > list.clientWidth + 1, - rows: new Set(items.map((i) => Math.round(i.top))).size - }; - }); - - await page.close(); - - if (!nav) { - failures.push(`the front page at ${width}px has no .nav, so the topic reach check proved nothing.`); - continue; - } - - navMeasured += nav.count; - - if (previousVisibleCount !== undefined && nav.count > previousVisibleCount) { - failures.push( - `front page at ${width}px shows ${nav.count} topics after the wider viewport showed ` + - `${previousVisibleCount}. Topic visibility must only decrease as space tightens.` - ); - } - previousVisibleCount = nav.count; - - if (nav.labels.at(-1) !== 'dev disasters') { - failures.push( - `front page at ${width}px ends its visible topic nav with "${nav.labels.at(-1)}" ` + - `instead of dev disasters.` - ); - } - - if (nav.rows !== 1) { - failures.push(`front page at ${width}px wraps its visible topics across ${nav.rows} rows.`); - } - - if (nav.clipped.length) { - failures.push( - `front page at ${width}px: ${nav.clipped.length} topic` + - `${nav.clipped.length === 1 ? '' : 's'} sit outside the nav box (${nav.clipped.join(', ')}). ` + - `A topic a phone cannot reach is a section of the site that does not exist on a phone.` - ); - } - - if (nav.scrollable) { - failures.push( - `front page at ${width}px: the nav scrolls horizontally instead of showing fewer topics.` - ); + return { display: getComputedStyle(nav).display, left: rect.left, right: rect.right, links }; + }); + if (menu.display === 'none') failures.push('the mobile menu did not open at 390px.'); + insideViewport(menu, width, 'mobile menu'); + for (const link of menu.links) { + if (link.height < 44) failures.push(`mobile navigation link "${link.text}" is only ${link.height.toFixed(1)}px tall.`); + measurements++; + } } -} -if (previousVisibleCount === undefined || previousVisibleCount >= 8) { - failures.push('the narrowest viewport did not reduce the number of visible topics.'); + await page.close(); } -/* - The reading layout is also a shell, so it has to keep the shell's inline gutter. - - A padding shorthand on .read erased .shell's padding-inline at every width above the - phone breakpoint. At 1200px, which is also what a zoomed desktop commonly resolves to, - the metadata rail touched the viewport edge. At the 1080px stack breakpoint the article - did the same. Measure the geometry rather than the declaration so another selector can - never recreate the defect unnoticed. -*/ -const READ_WIDTHS = [1200, 1080, 720, 390]; -let readLayoutsMeasured = 0; - -for (const width of READ_WIDTHS) { - const page = await browser.newPage({ viewport: { width, height: 1000 } }); - await page.goto( - `${base}/aspnetcore/tame-configuration-in-aspnet-core-with-ivalidateoptions/`, - { waitUntil: 'networkidle' } - ); - - const reading = await page.evaluate(() => { - const shell = document.querySelector('.shell.read'); - const rail = shell?.querySelector(':scope > .rail'); - const article = shell?.querySelector(':scope > article'); - if (!shell || !rail || !article) return null; - - const shellBox = shell.getBoundingClientRect(); - const railBox = rail.getBoundingClientRect(); - const articleBox = article.getBoundingClientRect(); - const style = getComputedStyle(shell); - - return { - paddingLeft: parseFloat(style.paddingLeft), - paddingRight: parseFloat(style.paddingRight), - contentLeft: shellBox.left + parseFloat(style.paddingLeft), - contentRight: shellBox.right - parseFloat(style.paddingRight), - railLeft: railBox.left, - railRight: railBox.right, - articleLeft: articleBox.left, - articleRight: articleBox.right - }; +for (const [label, path] of [ + ['articles', '/articles/'], + ['videos', '/videos/'], + ['topics', '/topics/'], + ['topic', '/csharp/'] +]) { + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await page.goto(base + path, { waitUntil: 'networkidle' }); + const result = await page.evaluate(() => { + const doc = document.documentElement; + const rows = [...document.querySelectorAll('.archive-row, .topic-directory a')].map((row) => { + const box = row.getBoundingClientRect(); + return { left: box.left, right: box.right, width: box.width, height: box.height }; + }); + return { overflow: doc.scrollWidth - doc.clientWidth, rows }; }); - - await page.close(); - - if (!reading) { - failures.push( - `article at ${width}px has no .shell.read with direct rail and article children, ` + - `so the reading gutter check proved nothing.` - ); - continue; - } - - readLayoutsMeasured += 1; - if (reading.paddingLeft < 20 || reading.paddingRight < 20) { - failures.push( - `article at ${width}px has ${reading.paddingLeft}px left and ` + - `${reading.paddingRight}px right shell padding. Reading pages need at least a ` + - `20px gutter so the rail or article never touches the viewport edge.` - ); + if (result.overflow > 1) failures.push(`${label} scrolls sideways by ${result.overflow}px at 390px.`); + if (result.rows.length === 0) failures.push(`${label} has no archive or directory rows to measure.`); + for (const row of result.rows) { + insideViewport(row, 390, `${label} row`); + if (row.width <= 0 || row.height < 44) failures.push(`${label} has a collapsed row at 390px.`); + measurements++; } + await page.close(); +} - const tolerance = 1; - const outside = - reading.railLeft < reading.contentLeft - tolerance || - reading.railRight > reading.contentRight + tolerance || - reading.articleLeft < reading.contentLeft - tolerance || - reading.articleRight > reading.contentRight + tolerance; - if (outside) { - failures.push( - `article at ${width}px places the rail or article outside the shell's padded content ` + - `box (${Math.round(reading.contentLeft)}-${Math.round(reading.contentRight)}px).` - ); +const [[, articlePath] = []] = firstArticlePage(); +if (!articlePath) { + failures.push('no article page was found for the reading layout check.'); +} else { + for (const width of [1200, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + await page.goto(base + articlePath, { waitUntil: 'networkidle' }); + const reading = await page.evaluate(() => { + const doc = document.documentElement; + const selectors = ['.story-head-inner', '.detail-shell', '.detail-rail', '.story']; + return { + overflow: doc.scrollWidth - doc.clientWidth, + boxes: selectors.map((selector) => { + const element = document.querySelector(selector); + if (!element) return { selector, box: null }; + const box = element.getBoundingClientRect(); + return { selector, box: { left: box.left, right: box.right, width: box.width, height: box.height } }; + }) + }; + }); + if (reading.overflow > 1) failures.push(`article scrolls sideways by ${reading.overflow}px at ${width}px.`); + for (const { selector, box } of reading.boxes) { + if (!box || box.width <= 0 || box.height <= 0) { + failures.push(`article ${selector} has no measurable box at ${width}px.`); + continue; + } + insideViewport(box, width, `article ${selector}`); + measurements++; + } + await page.close(); } } @@ -564,50 +163,9 @@ await browser.close(); server.close(); if (failures.length) { - /* - The heading has to cover both halves of this gate. It said "thumbnails are being - cropped" while reporting a dead space failure, which sent the reader looking at - aspect-ratio for a problem that was a missing summary. - */ - console.error('layout problems:\n' + failures.map((f) => ` ${f}`).join('\n')); - if (cropped) console.error('\n' + cropHint); - process.exit(1); -} - -if (measured === 0) { - console.error('no thumbnails were measured, so this gate proved nothing. Check the selectors.'); - process.exit(1); -} - -if (cellsMeasured === 0) { - /* - Fail closed. This now covers two ways of proving nothing: the .grid selector no longer - matching anything on a page, and the discovery walk finding no pages to visit. Both end - with a gate that reports clean over a site it never looked at. - */ - console.error( - `no grid cells were measured, so the cell fill check proved nothing. ` + - `The walk over dist found ${GRID_PAGES.length} page(s) carrying a grid. ` + - `Check the .grid selector and the class list match in pagesWithGrids.` - ); - process.exit(1); -} - -if (navMeasured === 0) { - console.error('no nav topics were measured, so the topic reach check proved nothing.'); - process.exit(1); -} - -if (readLayoutsMeasured === 0) { - console.error('no reading layouts were measured, so the article gutter check proved nothing.'); + console.error('\nlayout problems:\n'); + failures.forEach((failure) => console.error(` ${failure}`)); process.exit(1); } -console.log( - `layout is clean across ${measured} thumbnail measurements, ${cellsMeasured} grid ` + - `cells on ${GRID_PAGES.length} page(s) found by walking dist and ${navMeasured} visible ` + - `topic placements across responsive widths, ${readLayoutsMeasured} reading layouts, the wide card's thumb ` + - `held its ratio with a 2000px neighbour, and the lead card has ` + - `${deadSpace.trailing}px below its last text.` + - provenanceSuffix() -); +console.log(`layout clean across ${measurements} Type Stage measurements.${provenanceSuffix()}`); diff --git a/scripts/gen-fonts.mjs b/scripts/gen-fonts.mjs index f1a7b6b..62d1084 100644 --- a/scripts/gen-fonts.mjs +++ b/scripts/gen-fonts.mjs @@ -1,19 +1,5 @@ /* - Self hosted fonts. - - The design system asks for three families by their real names, so the generated CSS uses - those names rather than the "Fraunces Variable" style names Fontsource ships. Nothing in - app.css has to know where the files came from. - - This exists because the alternative, a Google Fonts stylesheet in the head, is a render - blocking request to a third party before a single word can paint, and it tells Google - about every reader. Self hosting removes both. The files land in public/fonts, which is - gitignored, because Fontsource is the source of truth and a copied binary in the repo - would only ever drift away from it. - - Only the latin and latin-ext subsets are shipped. The site is written in English, and a - reader who needs Cyrillic or Vietnamese gets the fallback stack, which is what they would - have got from an unsubsetted webfont on a slow connection anyway. + Self hosted fonts. Only the latin and latin-ext subsets ship. */ import { mkdir, readFile, writeFile, copyFile, rm } from 'node:fs/promises'; @@ -26,28 +12,25 @@ const OUT_CSS = join(ROOT, 'src', 'styles', 'fonts.generated.css'); const SUBSETS = ['latin', 'latin-ext']; -/* - Fraunces ships one file per combination of variable axes. The design sets SOFT and WONK - on every heading, and only the "full" build carries both, so the cheaper single axis - builds are not an option here. -*/ const FAMILIES = [ { - family: 'Fraunces', - pkg: '@fontsource-variable/fraunces', - /* No italic. The only italics in the design system are on the mono face, and a - synthesized oblique on the rare emphasised heading is not worth 150 KB. */ - css: ['full.css'] + family: 'Archivo Black', + pkg: '@fontsource/archivo-black', + css: ['index.css'] + }, + { + family: 'Anybody', + pkg: '@fontsource-variable/anybody', + css: ['index.css'] }, { - family: 'Atkinson Hyperlegible Next', - pkg: '@fontsource-variable/atkinson-hyperlegible-next', - /* Body copy, so real italics matter. Fontsource keeps them in a separate stylesheet. */ + family: 'Chivo', + pkg: '@fontsource-variable/chivo', css: ['index.css', 'wght-italic.css'] }, { - family: 'Fira Code', - pkg: '@fontsource-variable/fira-code', + family: 'Martian Mono', + pkg: '@fontsource-variable/martian-mono', css: ['index.css'] } ]; @@ -101,6 +84,7 @@ for (const { family, pkg, css } of FAMILIES) { await copyFile(join(pkgDir, 'files', face.file), join(OUT_DIR, face.file)); copied++; + const format = face.weight.includes(' ') ? 'woff2-variations' : 'woff2'; blocks.push( [ `@font-face {`, @@ -110,7 +94,7 @@ for (const { family, pkg, css } of FAMILIES) { /* swap rather than optional, because the design leans on Fraunces hard enough that a permanent fallback would not be the same site. */ ` font-display: swap;`, - ` src: url(/fonts/${face.file}) format('woff2-variations');`, + ` src: url(/fonts/${face.file}) format('${format}');`, face.unicodeRange ? ` unicode-range: ${face.unicodeRange};` : null, `}` ] diff --git a/scripts/gen-taxonomy.mjs b/scripts/gen-taxonomy.mjs index e2c3495..6fef188 100644 --- a/scripts/gen-taxonomy.mjs +++ b/scripts/gen-taxonomy.mjs @@ -150,7 +150,7 @@ const ALSO_FILED_RATIO = 0.5; */ const RESERVED = new Set([ '404', 'about', 'blog', 'builders', 'conduct', 'dev-disasters', 'images', - 'kitchen-sink', 'privacy', 'report', 'rss.xml', 'search', 'settings', + 'kitchen-sink', 'privacy', 'rss.xml', 'search', 'settings', 'sitemap-index.xml', 'submit', 'terms', 'uses', 'videos' ]); diff --git a/scripts/lib/serve-dev.mjs b/scripts/lib/serve-dev.mjs index cd5c8e3..5ecdfdb 100644 --- a/scripts/lib/serve-dev.mjs +++ b/scripts/lib/serve-dev.mjs @@ -2,12 +2,8 @@ A dev server, for the gates that need to see a page dist does not contain. Most of the site is prerendered and gets audited out of dist, which is both faster and - closer to what Netlify serves. The report page is not prerendered, because it reads a - target out of the query string on the server so the field arrives filled for somebody - with JavaScript off. That makes it invisible to a dist audit, and it is the last page on - the site that should go unchecked: it is the one people reach on a bad day. - - So it gets checked here instead, against a real render. + closer to what Netlify serves. Server rendered utility pages are checked here against a + real render instead. */ import { spawn } from 'node:child_process'; diff --git a/src/components/Drip.astro b/src/components/Drip.astro index 8b139fa..3cf4377 100644 --- a/src/components/Drip.astro +++ b/src/components/Drip.astro @@ -24,36 +24,32 @@ const { uid } = Astro.props; const inputId = `drip-${uid}`; --- -Every Sunday. One .NET tip, and the links that were actually worth reading.
-- No fluff, no "10 tips to 10x your productivity." One thing worth knowing, plus - whatever I hit that week that made me stop and read it twice. Unsubscribe whenever, - no hard feelings. -
+One useful tip, a few links worth your time, and the occasional war story. No inbox flood.
- Subscribe for free · - read the archive +
{item.description}
} - + {stats.map((s) => {s})} + → diff --git a/src/components/Masthead.astro b/src/components/Masthead.astro index 6d12b77..7c293c0 100644 --- a/src/components/Masthead.astro +++ b/src/components/Masthead.astro @@ -1,45 +1,64 @@ --- -import Wordmark from './Wordmark.astro'; -import ThemePicker from './ThemePicker.astro'; +import Mark from './Mark.astro'; import { VISIBLE_TOPICS } from '../config/site'; interface Props { - /** Topic slug, or "dd" for dev disasters. Drives the current nav marker. */ active?: string; } const { active } = Astro.props; +const topicActive = active === 'topics' || VISIBLE_TOPICS.some((topic) => topic.slug === active); +const links = [ + { href: '/#start', label: 'Start here', on: active === 'start' }, + { href: '/videos/', label: 'Videos', on: active === 'videos' }, + { href: '/articles/', label: 'Articles', on: active === 'articles' || active === 'all' }, + { href: '/topics/', label: 'Topics', on: topicActive }, + { href: '/about/', label: 'About', on: active === 'about' } +]; --- -{description}
+