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`).join('') + - '

Prose

A link

'; + probe.innerHTML = '

Heading

Heading

Heading

Heading
Heading

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. -

+
+ +
+ The .NET Drip +

A little .NET in your inbox.

+

One useful tip, a few links worth your time, and the occasional war story. No inbox flood.

-
-
- - -

- -

- - - -
-

- Subscribe for free · - read the archive +

+ + +

+

-
-
+ +
+ + +
+

Free to join. Unsubscribe whenever.

+

Read the archive.

+ + diff --git a/src/components/FeedItem.astro b/src/components/FeedItem.astro index c9c1f86..782915b 100644 --- a/src/components/FeedItem.astro +++ b/src/components/FeedItem.astro @@ -25,6 +25,7 @@ interface Props { const { item, level = 3 } = Astro.props; const Title = `h${level}` as 'h3' | 'h4'; const opensYouTube = isVideoKind(item.kind) && item.offsite; +const dateLabel = item.date.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }); const stats: string[] = [item.length]; if (item.views !== null) { @@ -34,27 +35,28 @@ if (item.engagementLikes !== null) { const label = item.engagementLikes === 1 ? 'like' : 'likes'; stats.push(`${item.engagementLikes.toLocaleString('en-US')} ${label}`); } -if (item.kind === 'article' && item.engagementComments !== null) { - const label = item.engagementComments === 1 ? 'comment' : 'comments'; - stats.push(`${item.engagementComments.toLocaleString('en-US')} ${label}`); -} if (item.offsite) { stats.push('YouTube'); } --- - {itemKindLabel(item.kind)} - + + {isVideoKind(item.kind) && item.thumbnail && ( + + )} + + {itemKindLabel(item.kind)} / {item.topic} {item.title} {item.description &&

{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' } +]; --- -
-
- - - -
+ + + diff --git a/src/components/PageHero.astro b/src/components/PageHero.astro new file mode 100644 index 0000000..b7d41bc --- /dev/null +++ b/src/components/PageHero.astro @@ -0,0 +1,29 @@ +--- +interface Stat { + label: string; + value: string; +} + +interface Props { + ghost: string; + title: string; + accent?: string; + description: string; + stats?: Stat[]; +} + +const { ghost, title, accent, description, stats = [] } = Astro.props; +--- + +
+ +
+

{title} {accent && {accent}}

+
+ {stats.map((stat) => ( +
{stat.label}{stat.value}
+ ))} +

{description}

+
+
+
diff --git a/src/components/SiteFooter.astro b/src/components/SiteFooter.astro index d9b4536..327f7eb 100644 --- a/src/components/SiteFooter.astro +++ b/src/components/SiteFooter.astro @@ -1,49 +1,89 @@ --- -import Wordmark from './Wordmark.astro'; +import Mark from './Mark.astro'; import { SITE, EXTERNAL } from '../config/site'; + +interface FooterGroup { + label: string; + links: Array<{ + label: string; + href: string; + external?: boolean; + }>; +} + +const groups: FooterGroup[] = [ + { + label: 'Learn', + links: [ + { label: 'Videos', href: '/videos/' }, + { label: 'Articles', href: '/articles/' }, + { label: 'Topics', href: '/topics/' } + ] + }, + { + label: 'Michael', + links: [ + { label: 'About', href: '/about/' }, + { label: 'Uses', href: '/uses/' }, + { label: 'Search', href: '/search/' } + ] + }, + { + label: 'Elsewhere', + links: [ + { label: 'YouTube', href: EXTERNAL.youtube, external: true }, + { label: 'GitHub', href: EXTERNAL.github, external: true }, + { label: 'Discord', href: EXTERNAL.discord, external: true }, + { label: 'The .NET Drip', href: EXTERNAL.drip, external: true } + ] + }, + { + label: 'Housekeeping', + links: [ + { label: 'Conduct', href: '/conduct/' }, + { label: 'Privacy', href: '/privacy/' }, + { label: 'Terms', href: '/terms/' } + ] + } +]; --- -
- - - )} - - - - - diff --git a/src/pages/all.astro b/src/pages/all.astro index 429c9d2..e2b1d91 100644 --- a/src/pages/all.astro +++ b/src/pages/all.astro @@ -1,37 +1,24 @@ --- -/* - Every published article and video, newest first. - - Topic pages are intentionally narrow, while the two homepage archive links promise the - whole catalogue. Keeping this as a real page makes those links useful without pretending - one topic owns work filed somewhere else. -*/ import Base from '../layouts/Base.astro'; import FeedItem from '../components/FeedItem.astro'; +import PageHero from '../components/PageHero.astro'; import { allItems } from '../lib/content'; const items = await allItems(); --- - -
-
-
- all -

Everything I've made

-

- Every article, video and short in one place, newest first. -

-
- -
-

Every article, video and short

- {items.map((item) => )} -
-
+ +
+ +
+

Straight off the workbench.

Every format, one list.

+
{items.map((item) => )}
+
diff --git a/src/pages/api/comments.ts b/src/pages/api/comments.ts index c04b0dd..87c56ec 100644 --- a/src/pages/api/comments.ts +++ b/src/pages/api/comments.ts @@ -1,26 +1,8 @@ -/* - The comment endpoints. - - GET reads a thread. POST writes one, PATCH edits within the window, DELETE tombstones. - - Reading is open to anybody. Everything that writes needs a sign in, because decision 16 - hangs its whole moderation story on there being an account behind every comment: rate - limits, the new account hold, and the ban flag all need somebody to point at. -*/ +/* Older thread data stays readable for compatibility, but retired accounts cannot mutate it. */ import type { APIRoute } from 'astro'; -import { - readThread, - postComment, - editComment, - deleteComment, - BODY_MAX, - EDIT_WINDOW_MINUTES -} from '../../lib/comments'; +import { readThread, BODY_MAX, EDIT_WINDOW_MINUTES } from '../../lib/comments'; import { isTargetKey, isTargetKind } from '../../lib/reader'; -import { nextBadgeStep, FIRST_REPLY } from '../../lib/badges'; -import { isNewAccount } from '../../lib/auth'; -import { supabaseWritable } from '../../lib/supabase'; export const prerender = false; @@ -33,123 +15,24 @@ function json(body: unknown, status = 200) { const bad = (message: string, status = 400) => json({ error: message }, status); -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - export const GET: APIRoute = async (context) => { const kind = context.url.searchParams.get('kind'); const key = context.url.searchParams.get('key'); if (!isTargetKind(kind) || !isTargetKey(key)) return bad('Unknown target.'); - const profile = context.locals.profile; - - /* - readThread needs the whole byline rather than just the id, because a held comment - comes back from the view unnamed and its own author is the one reader entitled to see - who wrote it. is_private travels with it so that a held comment reads the way it will - read once it clears. - */ - const viewer = profile - ? { - id: profile.id, - handle: profile.handle, - name: profile.display_name, - avatar: profile.avatar_url, - isPrivate: profile.is_private - } - : null; - - const thread = await readThread(kind, key, viewer); - - /* - The badge nudge on an empty thread. Only when there is nothing to read and somebody - signed in is looking at it: that is the one moment the answer changes what they do, - and it keeps two extra queries off every thread that already has replies in it. - */ - const nudge = - viewer && thread.comments.length === 0 - ? await nextBadgeStep(viewer.id, FIRST_REPLY) - : null; + const thread = await readThread(kind, key, null); return json({ ...thread, - nudge, - /* The island needs to know what it is allowed to draw before it draws it. */ - viewer: viewer - ? { id: viewer.id, handle: viewer.handle, name: viewer.name, avatar: viewer.avatar } - : null, + nudge: null, + viewer: null, limits: { bodyMax: BODY_MAX, editWindowMinutes: EDIT_WINDOW_MINUTES } }); }; -export const POST: APIRoute = async (context) => { - if (!supabaseWritable) return bad('Comments are off right now.', 503); +const retired: APIRoute = async () => bad('Comments are no longer available.', 410); - const profile = context.locals.profile; - if (!profile) return bad('Sign in to comment.', 401); - - let payload: Record; - try { - payload = await context.request.json(); - } catch { - return bad('Send JSON.'); - } - - const { kind, key, parentId, body } = payload; - if (!isTargetKind(kind) || !isTargetKey(key)) return bad('Unknown target.'); - if (typeof body !== 'string') return bad('A comment needs some words in it.'); - if (parentId !== null && parentId !== undefined && (typeof parentId !== 'string' || !UUID_RE.test(parentId))) { - return bad('That reply has nowhere to go.'); - } - - /* - Decision 16's hold. A GitHub account made this week can comment, and the comment waits - for a look before anybody else sees it. The author sees it the whole time, with a note, - because the alternative is watching your own words vanish and writing them again. - */ - const hold = isNewAccount(profile.github_created_at, profile.created_at); - - const result = await postComment({ - kind, - key, - parentId: (parentId as string | undefined) ?? null, - body, - authorId: profile.id, - hold - }); - - if (!result.ok) return bad(result.error, result.status); - return json({ id: result.id, held: result.held }, 201); -}; - -export const PATCH: APIRoute = async (context) => { - const profile = context.locals.profile; - if (!profile) return bad('Sign in to edit.', 401); - - let payload: Record; - try { - payload = await context.request.json(); - } catch { - return bad('Send JSON.'); - } - - const { id, body } = payload; - if (typeof id !== 'string' || !UUID_RE.test(id)) return bad('Unknown comment.'); - if (typeof body !== 'string') return bad('A comment needs some words in it.'); - - const result = await editComment(id, profile.id, body); - if (!result.ok) return bad(result.error, result.status); - return json({ id: result.id }); -}; - -export const DELETE: APIRoute = async (context) => { - const profile = context.locals.profile; - if (!profile) return bad('Sign in to delete.', 401); - - const id = context.url.searchParams.get('id'); - if (typeof id !== 'string' || !UUID_RE.test(id)) return bad('Unknown comment.'); - - const result = await deleteComment(id, profile.id); - if (!result.ok) return bad(result.error, result.status); - return json({ id: result.id }); -}; +export const POST = retired; +export const PATCH = retired; +export const DELETE = retired; diff --git a/src/pages/api/disasters.ts b/src/pages/api/disasters.ts deleted file mode 100644 index ed15706..0000000 --- a/src/pages/api/disasters.ts +++ /dev/null @@ -1,167 +0,0 @@ -/* - Submitting a dev disaster. - - A plain form post, same as the report endpoint and for the same reason: the page has to - work with JavaScript off. Decision 7 makes disasters pre moderated, which is the - opposite of how comments work, so nothing here publishes anything. A submission lands - with status pending and moves by hand in Studio. - - Signing in is required. Decision 25 gives the person who sent a story a badge and the - ability to edit it until it publishes, and neither exists without an account to attach - them to. Publishing anonymously is a separate choice, so the person can keep their name - and handle off the published story. - - Decision 25 also promised an email when the story ran. Notifications were cut from v1, - so that promise is not made anywhere a person can read it. See docs/notifications.md. -*/ - -import type { APIRoute } from 'astro'; -import { serviceClient, supabaseWritable } from '../../lib/supabase'; -import { RESERVED_DISASTER_SLUGS, SITE } from '../../config/site'; -import { draftDisaster, uniqueSlug } from '../../lib/draft'; - -export const prerender = false; - -/* - Long enough for the story somebody has been carrying for ten years, short enough that - the box is not a way to fill a table. The mockup promises no maximum, and it is right to - in spirit: nobody will reach this by telling a story. -*/ -const BODY_MAX = 40_000; -const BODY_MIN = 120; - -/* Pre moderation means each one costs me a read. Three a day is generous for a person and - useless to a script. The host is exempt: the limit is there to keep the moderation queue - from being filled by somebody else, and I am the one emptying it. */ -const SUBMISSIONS_PER_DAY = 3; - -const back = (query: string) => new Response(null, { status: 303, headers: { location: `/submit/${query}` } }); - -export const POST: APIRoute = async (context) => { - if (!supabaseWritable) return back('?sent=off'); - - const profile = context.locals.profile; - if (!profile) return back('?sent=signin'); - - let form: FormData; - try { - form = await context.request.formData(); - } catch { - return back('?sent=bad'); - } - - const body = String(form.get('story') ?? '').trim(); - const anonymous = form.get('anonymous') !== null; - const consented = form.get('consent_scrubbed') !== null && form.get('consent_publish') !== null; - - if (!consented) return back('?sent=consent'); - if (body.length < BODY_MIN) return back('?sent=short'); - if (body.length > BODY_MAX) return back('?sent=long'); - - const db = serviceClient(); - - /* - Every read below checks its own error, which the three of them did not used to do. - - A failed Supabase call returns { data: null, error }, so destructuring only data - turns "I could not ask" into "the answer was no". That is harmless on a read whose - absence means nothing, and it is not harmless here, because all three are gates and - all three fail open. An unreachable database would wave a banned person through, - reset the daily count to zero, and hand uniqueSlug an empty set of taken slugs, and - none of it would show up anywhere. A read that cannot answer is a refusal now. - */ - - const { data: banned, error: bansError } = await db - .from('bans') - .select('reason') - .eq('profile_id', profile.id) - .maybeSingle(); - - if (bansError) { - console.error(`Could not check bans for ${profile.id}: ${bansError.message}`); - return back('?sent=failed'); - } - - /* Same as comments: told it did not send, not told why. */ - if (banned) return back('?sent=failed'); - - /* - Decision 7 makes every submission cost me a read in Studio, and the limit is what keeps - that queue from being filled by one person. I am the one emptying it, so the gate does - not apply to me and the count is skipped outright rather than counted and ignored: - there is no reason to ask the database a question whose answer cannot change anything. - */ - if (profile.id !== SITE.hostProfileId) { - const { count, error: countError } = await db - .from('disasters') - .select('id', { count: 'exact', head: true }) - .eq('author_id', profile.id) - .gte('submitted_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()); - - if (countError) { - console.error(`Could not count today's submissions for ${profile.id}: ${countError.message}`); - return back('?sent=failed'); - } - - if ((count ?? 0) >= SUBMISSIONS_PER_DAY) return back('?sent=slow'); - } - - /* - Decision 25. The draft is written before the insert so that a story arrives in Studio - already readable, rather than as an untitled wall of text I have to open to identify. - draftDisaster never throws, so a model outage costs a better title and nothing else. - */ - const draft = await draftDisaster(body); - - const { data: existing, error: slugsError } = await db - .from('disasters') - .select('slug') - .not('slug', 'is', null); - - if (slugsError) { - console.error(`Could not read taken disaster slugs: ${slugsError.message}`); - return back('?sent=failed'); - } - - /* - The reserved words go in with the taken slugs rather than being checked separately. - - /dev-disasters/[...filter] serves every severity and sort view from the same path - segment a story slug occupies, so a story slugged "newest" would sit on top of the - "all, newest" view of the archive. RESERVED_DISASTER_SLUGS has existed since the route - was written and its own docstring said this endpoint checked it. Nothing imported it. - It was declared, exported, documented, and consumed by no code at all. - - That went unnoticed because the wall was reading a committed seed file, and a test over - that file was the only thing enforcing the rule. The seed is gone, so this is now the - only place the rule can be enforced, and it is the right place: a slug is decided here. - - Seeding the set means uniqueSlug's own collision loop does the work, so a story that - drafts as "newest" becomes "newest-2" and nothing has to fail. - */ - const taken = new Set([ - ...RESERVED_DISASTER_SLUGS, - ...(existing ?? []).map((r) => r.slug!).filter(Boolean) - ]); - - const { error } = await db.from('disasters').insert({ - slug: uniqueSlug(draft.slug, taken), - title: draft.title, - line: draft.line, - severity: draft.severity, - body, - author_id: profile.id, - is_anonymous: anonymous, - status: 'pending', - /* So I can see at a glance which titles nobody has checked, and which ones were - written by a fallback that was never meant to be good. */ - moderation_note: draft.fromModel ? 'Title drafted by the model.' : 'Model unavailable, title drafted from the opening line.' - }); - - if (error) { - console.error(`Could not insert a dev disaster for ${profile.id}: ${error.message}`); - return back('?sent=failed'); - } - - return back('?sent=1'); -}; diff --git a/src/pages/api/like.ts b/src/pages/api/like.ts index b2ffd73..a07deaf 100644 --- a/src/pages/api/like.ts +++ b/src/pages/api/like.ts @@ -4,8 +4,8 @@ POST toggles, GET reads. Both answer with the same shape so the island has one code path for "what is the state" and "the state changed". - No sign in required, per decision 11. Signing in only changes whether the like gets - your name on it. + Likes are anonymous. A browser token and address hash stop one person from moving the + count over and over. */ import type { APIRoute } from 'astro'; @@ -89,6 +89,6 @@ export const POST: APIRoute = async (context) => { return bad('That is a lot of liking. Give it an hour.', 429); } - const state = await toggleLike(kind, key, token, ip, context.locals.profile?.id ?? null); + const state = await toggleLike(kind, key, token, ip, null); return ok(state); }; diff --git a/src/pages/api/report.ts b/src/pages/api/report.ts deleted file mode 100644 index 2398d7d..0000000 --- a/src/pages/api/report.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - The report endpoint. - - A plain form post, not JSON, because the report form has to work with JavaScript off. - That is not a nicety: the moment somebody needs this page is a bad moment, and a form - that quietly needs a working script bundle is a form that fails exactly then. - - Anybody can file one, signed in or not. Requiring an account to report somebody would - mean the people least able to make an account are the least able to ask for help. -*/ - -import type { APIRoute } from 'astro'; -import { serviceClient, supabaseWritable } from '../../lib/supabase'; -import { clientIp, hashIp } from '../../lib/reader'; - -export const prerender = false; - -/* The form's own vocabulary, mapped onto the kinds the table accepts. */ -const KIND_MAP: Record = { - comment: 'comment', - disaster: 'disaster', - profile: 'profile', - conduct: 'conduct', - offsite: 'other' -}; - -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -/* - Enough to stop somebody hiding a comment by filing the same report over and over, since - three of them auto hides it. Keyed by hashed address rather than account, because - reporting does not need an account and the limit has to hold for the anonymous path too. -*/ -const REPORTS_PER_HOUR = 5; - -const back = (query: string) => new Response(null, { status: 303, headers: { location: `/report/${query}` } }); - -export const POST: APIRoute = async (context) => { - if (!supabaseWritable) return back('?sent=off'); - - let form: FormData; - try { - form = await context.request.formData(); - } catch { - return back('?sent=bad'); - } - - const what = String(form.get('what') ?? ''); - const kind = KIND_MAP[what] ?? 'other'; - const why = String(form.get('why') ?? '').trim(); - const link = String(form.get('link') ?? '').trim(); - const ref = String(form.get('ref') ?? '').trim(); - const urgent = form.get('urgent') !== null; - - if (!why) return back('?sent=empty'); - - const db = serviceClient(); - const hashed = hashIp(clientIp(context)); - - const { count } = await db - .from('reports') - .select('id', { count: 'exact', head: true }) - .eq('reporter_hash', hashed) - .gte('created_at', new Date(Date.now() - 60 * 60 * 1000).toISOString()); - - if ((count ?? 0) >= REPORTS_PER_HOUR) return back('?sent=slow'); - - /* - target_ref is what the auto hide trigger reads, so for a comment it has to be the - comment id and nothing else. The human readable link goes in the details, where it - helps me and cannot be mistaken for something the database should act on. - */ - const targetRef = kind === 'comment' && UUID_RE.test(ref) ? ref : link || null; - - const details = [ - link ? `Link: ${link}` : null, - urgent ? 'Marked urgent by the reporter.' : null - ] - .filter(Boolean) - .join('\n'); - - const { error } = await db.from('reports').insert({ - kind, - target_ref: targetRef, - reason: why.slice(0, 5000), - details: details || null, - reporter_id: context.locals.profile?.id ?? null, - reporter_hash: hashed - }); - - if (error) return back('?sent=failed'); - return back('?sent=1'); -}; diff --git a/src/pages/articles.astro b/src/pages/articles.astro new file mode 100644 index 0000000..c4da908 --- /dev/null +++ b/src/pages/articles.astro @@ -0,0 +1,50 @@ +--- +import Base from '../layouts/Base.astro'; +import Drip from '../components/Drip.astro'; +import FeedItem from '../components/FeedItem.astro'; +import PageHero from '../components/PageHero.astro'; +import { allItems } from '../lib/content'; + +const articles = (await allItems()).filter((item) => item.kind === 'article'); +const featured = articles[0]; +--- + + +
+ +
+
+

Fresh off the workbench.

+

Newest first. Pick the problem in front of you, or wander around until something useful falls out.

+
+ {featured && ( + +
{featured.topic}
ARTICLE
+
+ Newest / {featured.topic} / {featured.length} +

{featured.title}

+ {featured.description &&

{featured.description}

} + Read the article → +
+
+ )} +
+ {articles.slice(1).map((item) => )} +
+
+ +
+ diff --git a/src/pages/auth/callback.ts b/src/pages/auth/callback.ts deleted file mode 100644 index 2bf49f1..0000000 --- a/src/pages/auth/callback.ts +++ /dev/null @@ -1,187 +0,0 @@ -/* - Where the provider sends the reader back to. - - Exchanges the one time code for a session, sets the cookies, and returns them to - wherever they were when they decided to sign in. - - Both flows land here. A sign in arrives with no `linked` parameter, and a link started - from /auth/link// arrives with one naming the provider that was just attached. - They need telling apart, because app_metadata.provider keeps naming whichever provider - this person originally signed up with, so a Twitch link on a GitHub account would - otherwise refresh the GitHub columns and leave twitch_user_id null. - - The profile row is not created here. It is created by the on_auth_user_created trigger - in the database, in the same transaction as the auth user, so there is no window in - which somebody is signed in but has no profile. Doing it here would leave that window - open every time this route failed halfway. -*/ - -import type { APIRoute } from 'astro'; -import { serverClient, serviceClient, supabaseWritable } from '../../lib/supabase'; -import type { Database } from '../../lib/supabase/database.types'; -import { oauthOutcome, safeReturnPath } from '../../lib/auth'; -import { isProvider, providerLogin } from '../../lib/providers'; - -type ProfileUpdate = Database['public']['Tables']['profiles']['Update']; - -export const prerender = false; - -export const GET: APIRoute = async ({ url, cookies, request, redirect }) => { - if (!supabaseWritable) { - return redirect('/?auth=unavailable', 302); - } - - const code = url.searchParams.get('code'); - const next = safeReturnPath(url.searchParams.get('next')); - const linked = url.searchParams.get('linked') ?? ''; - const isLink = isProvider(linked); - - if (!code) { - /* - No code means the handshake ended somewhere other than success, and the parameters - alongside it say where. Reading all of them as a cancellation is what made an - approved Discord link come back claiming the reader had cancelled it, so the reason - is classified rather than assumed, and written to the log either way: the reader gets - the short version, and whoever has to fix it gets Supabase's own words. - */ - const { outcome, detail } = oauthOutcome(url.searchParams); - - if (detail) { - console.error( - `[auth/callback] the ${isLink ? `${linked} link` : 'sign in'} came back without a code`, - detail - ); - } - - if (isLink) { - return redirect(`/account/?link=${outcome}&provider=${linked}#connections`, 302); - } - - /* - A cancellation goes back where they were, because backing out of a sign in is a - decision, not a fault, and there is nothing to say about it. A failure goes to the - chooser with `next` still attached, so there is a page that can say something and a - button to try again on. It used to go to /?auth=failed, which nothing on the site - renders, so a sign in that broke looked exactly like one that never happened. - */ - if (outcome === 'cancelled') return redirect(next, 302); - - return redirect(`/signin/?auth=failed&next=${encodeURIComponent(next)}`, 302); - } - - const supabase = serverClient(cookies, request.headers); - const { data, error } = await supabase.auth.exchangeCodeForSession(code); - - if (error || !data.user) { - console.error('[auth/callback] the code could not be exchanged for a session', error); - return redirect( - isLink - ? `/account/?link=failed&provider=${linked}#connections` - : `/signin/?auth=failed&next=${encodeURIComponent(next)}`, - 302 - ); - } - - /* - Refresh the fields the provider owns on every sign in. An avatar, a display name and - a handle all change, and a profile that shows who somebody was two years ago is worse - than one that shows nothing. - - Only the columns belonging to the provider they just used get touched. Somebody who - signed in with GitHub last week and Discord today keeps their github_login, because - the alternative is a profile that forgets half of itself every time its owner picks a - different button. - - github_created_at is the one that matters most. It is what the new account hold in - decision 16 reads, and only GitHub sends it. Discord and Twitch accounts fall back to - the day the profile was made, which is the honest answer when the provider will not - tell us anything better. - */ - try { - const meta = data.user.user_metadata ?? {}; - const provider = isLink ? linked : (data.user.app_metadata?.provider ?? ''); - - /* - A link attaches an identity. It is not a fresh introduction, so it leaves the name - and the face alone: somebody who set a display name on /account/ should not lose it - for connecting Twitch. - */ - const patch: ProfileUpdate = isLink - ? {} - : { - display_name: (meta.full_name as string) ?? (meta.name as string) ?? null, - avatar_url: (meta.avatar_url as string) ?? null - }; - - if (isProvider(provider)) { - const identity = data.user.identities?.find((i) => i.provider === provider); - const identityData = (identity?.identity_data ?? {}) as Record; - - /* - The user_metadata fallbacks are for a sign in only. On a link, that object still - describes whoever this person signed up as, so falling back to it would copy a - GitHub username into twitch_login and call the account linked when it is not. - Better to write nothing and leave the row honest. - - Which keys hold the login is providerLogin's problem, because the answer is - different for all three of them. Asking every provider for user_name is how a - Discord link came to write a null login on every single attempt: Discord does not - have that key, has never had it, and the empty string fell straight through to null. - */ - const rawId = - (identityData.provider_id as string | number | undefined) ?? - (identityData.sub as string | undefined) ?? - (isLink ? undefined : (meta.provider_id as string | number | undefined)); - const providerId = rawId == null || rawId === '' ? null : String(rawId); - - const login = - providerLogin(provider, identityData) ?? - (isLink ? null : providerLogin(provider, meta as Record)); - - const createdAt = - (identityData.created_at as string | undefined) ?? - (isLink ? undefined : (meta.created_at as string | undefined)); - - if (provider === 'github') { - if (providerId) patch.github_id = Number(providerId); - patch.github_login = login; - if (createdAt) patch.github_created_at = createdAt; - } else if (provider === 'discord') { - if (providerId) patch.discord_id = providerId; - patch.discord_login = login; - } else { - /* - Signing in with Twitch is a link, so the badge backfill should see it. Without - this, somebody whose way in is Twitch would have to walk through - /auth/link/twitch/ to claim history they already proved they own. - */ - if (providerId) { - patch.twitch_user_id = providerId; - patch.twitch_linked_at = new Date().toISOString(); - } - patch.twitch_login = login; - } - } - - /* - An update with no columns is a request PostgREST rejects, and there is nothing to - say anyway. It happens when a link comes back for a provider whose identity is not - in the response yet. - */ - if (Object.keys(patch).length > 0) { - const { error: writeError } = await serviceClient() - .from('profiles') - .update(patch) - .eq('id', data.user.id); - - if (writeError) { - console.error('[auth/callback] profile refresh failed', writeError); - } - } - } catch (err) { - /* A stale avatar is not a reason to refuse somebody entry. */ - console.error('[auth/callback] profile refresh threw', err); - } - - return redirect(next, 302); -}; diff --git a/src/pages/auth/link/[provider].ts b/src/pages/auth/link/[provider].ts deleted file mode 100644 index 1b74300..0000000 --- a/src/pages/auth/link/[provider].ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - Attach another way in to the account you already have. - - /account/ offers this for every provider not yet connected, and until now every one of - those links 404ed because the route was never written. The config and the docs both - describe it, which is how it went unnoticed: everything except the code said it existed. - - This is linkIdentity, not signInWithOAuth. The difference matters. signInWithOAuth on a - browser that already holds a session replaces that session, so somebody who signed in - with GitHub and pressed "Link it" on Twitch would end up signed in as a second, empty - account rather than with two identities on one. linkIdentity adds the identity to the - user who is already here. - - It needs manual linking turned on in the Supabase project. It fails quietly without it, - so the failure lands on /account/?link=failed rather than as a stack trace nobody sees. -*/ - -import type { APIRoute } from 'astro'; -import { serverClient, supabaseWritable } from '../../../lib/supabase'; -import { safeReturnPath } from '../../../lib/auth'; -import { PROVIDER_SCOPES, isProvider } from '../../../lib/providers'; - -export const prerender = false; - -/* Where a link ends up when it works, naming the provider so the notice can say which. */ -const connections = (provider: string) => `/account/?link=ok&provider=${provider}#connections`; - -export const GET: APIRoute = async ({ params, url, cookies, request, redirect, locals }) => { - if (!supabaseWritable) { - return redirect('/?auth=unavailable', 302); - } - - const requested = params.provider ?? ''; - - if (!isProvider(requested)) { - return redirect('/account/?link=unknown#connections', 302); - } - - /* - Linking is something you do to an account, so there has to be one. Somebody who - followed this link with an expired session goes to the chooser and comes back here - afterwards rather than being told no. - */ - if (!locals.profile) { - const back = `/auth/link/${requested}/`; - return redirect(`/signin/?next=${encodeURIComponent(back)}`, 302); - } - - const next = safeReturnPath(url.searchParams.get('next') ?? connections(requested)); - - const supabase = serverClient(cookies, request.headers); - - /* - Back through the one callback rather than a per provider one. It already exchanges the - code and refreshes provider columns, and `linked` tells it which provider to write, - because app_metadata.provider still names the one this person originally signed in - with rather than the one they just attached. - */ - const redirectTo = new URL( - `/auth/callback/?next=${encodeURIComponent(next)}&linked=${requested}`, - url.origin - ).toString(); - - const { data, error } = await supabase.auth.linkIdentity({ - provider: requested, - options: { redirectTo, scopes: PROVIDER_SCOPES[requested] } - }); - - if (error || !data?.url) { - console.error(`[auth/link] could not start the ${requested} link`, error); - return redirect(`/account/?link=failed&provider=${requested}#connections`, 302); - } - - return redirect(data.url, 302); -}; diff --git a/src/pages/auth/signin.ts b/src/pages/auth/signin.ts deleted file mode 100644 index aac5e9f..0000000 --- a/src/pages/auth/signin.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - Sign in. - - A GET, not a POST, so it works as a plain link from anywhere including a page that - shipped no JavaScript. It hands off to the provider and comes back at /auth/callback/. - - `next` carries where the reader was, so signing in to leave a comment returns them to - the comment box rather than to the front page. It is checked rather than trusted: an - open redirect on a sign in route is how a phishing link borrows your domain. - - `provider` gets the same treatment. It is looked up in the table below rather than - passed through, because handing a user supplied string to signInWithOAuth is how you - end up supporting a provider you never configured. -*/ - -import type { APIRoute } from 'astro'; -import { serverClient, supabaseWritable } from '../../lib/supabase'; -import { safeReturnPath } from '../../lib/auth'; -import { PROVIDER_SCOPES, isProvider } from '../../lib/providers'; - -export const prerender = false; - -export const GET: APIRoute = async ({ url, cookies, request, redirect }) => { - if (!supabaseWritable) { - return redirect('/?auth=unavailable', 302); - } - - const next = safeReturnPath(url.searchParams.get('next')); - const requested = url.searchParams.get('provider') ?? ''; - - if (!isProvider(requested)) { - /* - Back to the chooser rather than quietly defaulting to GitHub. A missing provider - means somebody followed a link written before there was a choice, and sending them - somewhere they can pick is more use than signing them in as something they never - asked for. - */ - return redirect(`/signin/?next=${encodeURIComponent(next)}`, 302); - } - - const supabase = serverClient(cookies, request.headers); - - const { data, error } = await supabase.auth.signInWithOAuth({ - provider: requested, - options: { - redirectTo: new URL(`/auth/callback/?next=${encodeURIComponent(next)}`, url.origin).toString(), - scopes: PROVIDER_SCOPES[requested] - } - }); - - if (error || !data?.url) { - console.error(`[auth/signin] could not start the ${requested} handshake`, error); - return redirect(`/signin/?auth=failed&next=${encodeURIComponent(next)}`, 302); - } - - return redirect(data.url, 302); -}; diff --git a/src/pages/auth/signout.ts b/src/pages/auth/signout.ts deleted file mode 100644 index 7889edd..0000000 --- a/src/pages/auth/signout.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - Sign out. - - POST only. A GET would mean any image tag on any page could sign a reader out, which is - a small piece of griefing that costs nothing to prevent. The control that calls it is a - real form, so it works without JavaScript. -*/ - -import type { APIRoute } from 'astro'; -import { serverClient, supabaseConfigured } from '../../lib/supabase'; -import { clearSession, safeReturnPath } from '../../lib/auth'; - -export const prerender = false; - -export const POST: APIRoute = async (context) => { - const { request, cookies, redirect } = context; - const form = await request.formData().catch(() => null); - const next = safeReturnPath(form?.get('next')?.toString() ?? '/'); - - if (supabaseConfigured) { - try { - /* - Scope 'local' rather than the default 'global'. Signing out here should end this - browser's session, not every session on every device the same person is signed in - on, which is what pressing sign out on a shared laptop would otherwise do to their - phone. - */ - await serverClient(cookies, request.headers).auth.signOut({ scope: 'local' }); - } catch { - /* Fall through and clear the cookies anyway. */ - } - } - - /* - And clear them regardless. signOut writes the removals through the same cookie - adapter, so this is usually a no op, but it is a network call to the auth server and - network calls fail. A browser still holding an sb- cookie after pressing sign out - renders as signed in until the token expires, which is the single outcome this button - exists to prevent. - */ - clearSession(context); - - return redirect(next, 303); -}; diff --git a/src/pages/builders/[handle].astro b/src/pages/builders/[handle].astro deleted file mode 100644 index 7c053a4..0000000 --- a/src/pages/builders/[handle].astro +++ /dev/null @@ -1,205 +0,0 @@ ---- -/* - A builder profile. - - Rendered on demand rather than built, because it changes whenever the person it - describes does something, and because decision 14 keeps it out of the index anyway so - there is nothing to gain from a static copy. - - Private and deleted profiles 404 rather than saying "this profile is private". A page - that admits somebody exists but will not show them is still a page that confirms they - exist, which is not what going private means. -*/ -import Base from '../../layouts/Base.astro'; -import { sevClass, sevLabel } from '../../config/site'; -import { getBuilderProfile, initials, ownsProfile } from '../../lib/profiles'; -import { readOwnSubmissions } from '../../lib/account'; -import SubmissionList from '../../components/SubmissionList.astro'; - -export const prerender = false; - -const { handle } = Astro.params; -const profile = handle ? await getBuilderProfile(handle) : null; - -if (!profile) { - return new Response(null, { status: 404, statusText: 'Not found' }); -} - -/* - The submissions block is the owner's own view of their profile, so it is fetched only - when the person reading is the person described. Gating the fetch rather than the render - means a story waiting on a look never reaches the page for anybody else, so no later - markup change can leak one. -*/ -const isOwner = ownsProfile(Astro.locals.profile?.id, profile.id); -const submissions = isOwner ? await readOwnSubmissions(profile.id) : []; - -const joined = profile.joined.toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }); - -const earned = profile.badges.filter((b) => b.earned); -const locked = profile.badges.filter((b) => !b.earned); - -const counts = profile.disasters.reduce>((acc, d) => { - acc[d.severity] = (acc[d.severity] ?? 0) + 1; - return acc; -}, {}); - -const countLine = Object.entries(counts) - .map(([sev, n]) => `${n} ${sevLabel(sev as never).toLowerCase()}`) - .join(' \u00b7 '); - -const n = (v: number) => v.toLocaleString('en-GB'); ---- - - -
-
- -
- {profile.avatarUrl - ? - : } - -
-

{profile.displayName}

-

{profile.handle} · joined {joined}

- - {earned.length > 0 && ( -
- {earned.slice(0, 4).map((b) => ( - - {b.name}{b.numeral ? ` ${b.numeral}` : ''} - - ))} -
- )} -
- - {profile.githubLogin && ( - - GitHub → - - )} -
- - {profile.bio &&

{profile.bio}

} - - {profile.links.length > 0 && ( -

- {profile.links.map((l, i) => ( - <> - {i > 0 && ' \u00b7 '} - {l.label} - - ))} -

- )} - -
- {n(profile.stats.disasters)} dev disasters - {n(profile.stats.likesReceived)} likes received - {n(profile.stats.comments)} comments - {profile.twitchLinked && {n(profile.stats.streamsWatched)} streams watched live} -
- -
-

Badge shelf

-

- {earned.length === 0 - ? 'Nothing on the shelf yet. These are the ones within reach.' - : profile.twitchLinked - ? 'Some of these came from Twitch, from the stream history we have on record.' - : 'Some of these live on Twitch. Link your Twitch account and the old ones show up on their own.'} -

-
- {earned.map((b) => ( -
- {b.numeral && {b.numeral}} - {b.name} - {b.description} -
- ))} - {locked.map((b) => ( -
- {b.numeral && {b.numeral}} - {b.name} - {b.description} - {b.progress && ( - - - )} -
- ))} -
- - {/* - The shelf is empty on day one for everybody, so the empty state is the state that - ships. It says what fills it rather than leaving a grid of grey boxes to be read - as a broken feature. - */} - {earned.length === 0 && ( -

- Badges come from turning up. Watch a stream, say something in chat, file a dev - disaster, answer somebody in the comments. - {!profile.twitchLinked && ' Linking Twitch brings the older ones across.'} -

- )} -
- -

Activity

- -
-
- Filed by {profile.handle} - {countLine && {countLine}} -
- - {profile.disasters.length === 0 ? ( -

- Nothing filed yet, or nothing filed under this name. Stories told anonymously - still earn the badge, they just do not show up here. -

- ) : ( - profile.disasters.map((d) => ( - - {sevLabel(d.severity)} - {d.line} - #{String(d.id).padStart(3, '0')} · ♡ {n(d.likes)} - - )) - )} -
- - {isOwner && ( -
- -

- Only you can see this block. Everybody else sees the published stories above and - nothing else. -

-
- )} - -
-

- Profiles are public but never indexed by search engines, and any link somebody - adds here is nofollowed. You can go private or delete the whole account from - settings, and deleting means deleting. -

-
- -
-
- diff --git a/src/pages/conduct.astro b/src/pages/conduct.astro index c5cf56c..7b20401 100644 --- a/src/pages/conduct.astro +++ b/src/pages/conduct.astro @@ -2,39 +2,32 @@ import Base from '../layouts/Base.astro'; import { SITE } from '../config/site'; -/* - The severity vocabulary earns its keep one more time here. The consequences really are - tiered, so the same four badges that classify a disaster classify what happens when - somebody breaks the rule. Note the class is sev-warn while the id is warning. -*/ const consequences = [ { sev: 'hint', label: 'Hint', - what: 'I edit or delete the comment', + what: 'I point it out', detail: - 'Usually a first offence, usually somebody having a bad day. You get a note explaining what I removed and why.' + 'Most problems are misunderstandings or somebody having a rough day. A clear note is usually enough.' }, { sev: 'info', label: 'Info', what: 'I ask you to stop', - detail: - 'A direct message, from me, not a bot. Nothing happens to your account. Most of these end here.' + detail: 'A direct message from me, not a bot. Most problems end here.' }, { sev: 'warn', label: 'Warning', - what: 'You lose commenting for a while', - detail: - 'Seven days, then thirty. You can still read everything and your stories stay up.' + what: 'I remove you from the space', + detail: 'You lose access to the place where it happened. I will tell you why when I can.' }, { sev: 'error', label: 'Error', - what: 'You are done here', + what: 'I take it further', detail: - 'Account closed, comments removed. For harassment, threats, or doxxing, this is the first step and not the last one.' + 'Threats, stalking, or publishing private information may go to platform moderators or law enforcement.' } ]; @@ -47,13 +40,13 @@ const lastChanged = new Date(SITE.conductUpdated).toLocaleDateString('en-GB', {
@@ -148,22 +136,22 @@ const lastChanged = new Date(SITE.conductUpdated).toLocaleDateString('en-GB', {

If I get it wrong

- Reply to the email. I've reversed moderation calls before and I'll do it again. - What I won't do is relitigate a removal in public comments, because that turns - somebody's bad week into a thread. + Email the conduct inbox. I have + reversed moderation calls before and I will do it again. I will not argue about + somebody's safety in public.

If it's about me

- I'm the one who runs this, which makes reporting me awkward. Do it anyway. - Email works, and if you'd rather not talk to me at all, say so in the report - and I'll have somebody I trust read it first. + I'm the one who runs this, which makes the conversation awkward. Email the conduct + inbox anyway. If you do not want me to handle it alone, say so and I will ask + somebody I trust to read it first.

diff --git a/src/pages/dev-disasters/[...filter].astro b/src/pages/dev-disasters/[...filter].astro index 938280a..1d835b2 100644 --- a/src/pages/dev-disasters/[...filter].astro +++ b/src/pages/dev-disasters/[...filter].astro @@ -76,6 +76,7 @@ const viewUrl = (s: string, o: string) => { const scopeLabel = scope === 'all' ? '' : sevLabel(scope); const title = scope === 'all' ? 'dev disasters' : `${scopeLabel} dev disasters`; +const visibleSorts = DISASTER_SORTS.filter((option) => option.id !== 'replies'); /* The empty wall, worded for the view it is on. @@ -103,7 +104,7 @@ const isDefault = scope === 'all' && sort === 'liked'; noindex={!isDefault} description="Real outages, real bad code, real 2am pages, told by the people who caused them." > -
+
-

Your turn

-
-

The .NET Drip

diff --git a/src/pages/dev-disasters/[slug].astro b/src/pages/dev-disasters/[slug].astro index cdcb71b..b8b4da6 100644 --- a/src/pages/dev-disasters/[slug].astro +++ b/src/pages/dev-disasters/[slug].astro @@ -1,19 +1,10 @@ --- -/* - One dev disaster. - - The comment thread and the like button are Preact islands that land in phase five, so - this page is currently the story and its counts and nothing that pretends to be - interactive. A dead control is worse than an absent one. -*/ import Teller from '../../components/Teller.astro'; import Base from '../../layouts/Base.astro'; import { sevClass, sevLabel } from '../../config/site'; import LikeButton from '../../components/islands/LikeButton'; -import CommentThread from '../../components/islands/CommentThread'; import ShareMenu from '../../components/ShareMenu.astro'; import { allDisasters, disasterBySlug, disasterCode, countsBySeverity } from '../../lib/disasters'; -import { bakedReplies } from '../../lib/comments'; export function getStaticPaths() { return allDisasters().map((d) => ({ params: { slug: d.slug } })); @@ -25,8 +16,6 @@ if (!d) throw new Error(`No dev disaster with slug "${slug}"`); const counts = countsBySeverity(); -const replies = await bakedReplies('disaster', String(d.id)); - const filed = d.date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', @@ -67,16 +56,11 @@ const filed = d.date.toLocaleDateString('en-GB', { */} - - Report this story - {/* One filter per element. See the note in [topic]/[slug].astro. */} - -
- - -
diff --git a/src/pages/index.astro b/src/pages/index.astro index 9ba8673..48ceb61 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,385 +1,277 @@ --- -/* - The front page. Decision 118, layout A, the workbench. - - The newest thing Michael made is the headline. Dev disasters keep their section and - their call to action, but they sit mid page as the Problems panel rather than owning - the H1. This is a hierarchy change only: every colour, size and radius below is a - token that was already in use. - - Zero JavaScript, same as before. Nothing here rotates, animates or fetches. -*/ -import Mark from '../components/Mark.astro'; -import { getCollection } from 'astro:content'; import Base from '../layouts/Base.astro'; import Drip from '../components/Drip.astro'; -import { SITE, VISIBLE_TOPICS, sevClass, sevLabel } from '../config/site'; -import { allItems, isVideoKind, itemKindLabel, itemsInTopic } from '../lib/content'; -import { starterItems, fillSpans } from '../lib/starters'; -import { freshnessKicker, postedOn } from '../lib/freshness'; -import { leadSentences } from '../lib/excerpt'; -import { imageUrlAtWidth } from '../lib/images'; -import { - allDisasters, - sortDisasters, - countsBySeverity, - problemSummary, - disasterCode -} from '../lib/disasters'; +import FeedItem from '../components/FeedItem.astro'; +import { SITE, SOCIAL_IMAGES, VISIBLE_TOPICS } from '../config/site'; +import { allItems, isVideoKind, itemKindLabel, itemsInTopic, type Item } from '../lib/content'; +import { starterItems } from '../lib/starters'; const items = await allItems(); - -/* - The hero is the newest item that carries a summary, not simply the newest item. - - Same rule as the Start here lead, and for the same measured reason: the layout gives - the excerpt a paragraph of height, so an item without one leaves a hole in the largest - element on the page. Skipping forward costs a few days of recency and never costs the - page its shape. -*/ -const hero = items.find((i) => i.description); -const alsoNew = items.filter((i) => i !== hero).slice(0, 3); - +const hero = items[0]; const starters = await starterItems(); -const fill = fillSpans(starters.length); +const starterVideo = starters.find((item) => isVideoKind(item.kind)); +const starterArticle = starters.find((item) => item.kind === 'article'); -const disasters = allDisasters(); -const topProblems = sortDisasters(disasters, 'liked').slice(0, 4); -const counts = countsBySeverity(); +const mostWatched = items + .filter((item) => isVideoKind(item.kind) && item.views !== null) + .sort((left, right) => (right.views ?? 0) - (left.views ?? 0)) + .slice(0, 5); -const drips = await getCollection('drips'); -drips.sort((a, b) => (a.data.publishDate < b.data.publishDate ? 1 : -1)); -const latestDrip = drips[0]; - -const dripDay = latestDrip - ? new Date(latestDrip.data.publishDate).toLocaleDateString('en-US', { weekday: 'short' }) - : ''; - -/* - Four rows across two columns, the drip taking the last slot when there is one. Split by - halves rather than by column index so an odd count leaves the shorter column second. -*/ -const alsoRows: Array<{ - href: string; - title: string; - kindLabel: string; - meta: string; - offsite: boolean; - opensYouTube: boolean; -}> = - alsoNew.map((i) => ({ - href: i.href, - title: i.title, - kindLabel: itemKindLabel(i.kind), - meta: i.length, - offsite: i.offsite, - opensYouTube: isVideoKind(i.kind) && i.offsite - })); - -if (latestDrip) { - alsoRows.push({ - href: 'https://dotnetdrip.com', - title: 'The links that were worth reading this week', - kindLabel: 'Drip', - meta: dripDay, - offsite: true, - opensYouTube: false - }); -} - -const alsoSplit = Math.ceil(alsoRows.length / 2); -const alsoColumns = [alsoRows.slice(0, alsoSplit), alsoRows.slice(alsoSplit)].filter( - (c) => c.length > 0 -); +const articles = items.filter((item) => item.kind === 'article'); +const worthReading = [...starters.filter((item) => item.kind === 'article'), ...articles] + .filter((item, index, all) => all.findIndex((candidate) => candidate.key === item.key) === index) + .slice(0, 4); const topicCounts = await Promise.all( - VISIBLE_TOPICS.map(async (t) => ({ topic: t, n: (await itemsInTopic(t.slug)).length })) + VISIBLE_TOPICS.map(async (topic) => ({ + topic, + count: (await itemsInTopic(topic.slug)).length + })) ); -/* - Views print only when there are some. A video showing "0 views" on the front page reads - as work nobody wanted, which is a different claim from a number we have not fetched. -*/ -const heroMeta = hero - ? [ - hero.views && hero.views > 0 ? `${hero.views.toLocaleString('en-US')} views` : null, - hero.engagementLikes !== null - ? `${hero.engagementLikes.toLocaleString('en-US')} ${ - hero.engagementLikes === 1 ? 'like' : 'likes' - }` - : null, - hero.kind === 'article' && hero.engagementComments !== null - ? `${hero.engagementComments.toLocaleString('en-US')} ${ - hero.engagementComments === 1 ? 'comment' : 'comments' - }` - : null, - `posted ${postedOn(hero.date)}`, - hero.thumbnail ? null : hero.length, - hero.offsite ? 'on YouTube' : null - ].filter(Boolean) - : []; - -const heroThumbnail = hero?.thumbnail; -const heroImage = heroThumbnail - ? (() => { - const src = imageUrlAtWidth(heroThumbnail, 640); - const responsive = src !== heroThumbnail; - return { - src, - srcset: responsive - ? [640, 1280] - .map((width) => `${imageUrlAtWidth(heroThumbnail, width)} ${width}w`) - .join(', ') - : undefined, - cloudinary: responsive - }; - })() - : null; ---- - - +const latest = items.slice(0, 6); +const opensYouTube = (item: Item) => isVideoKind(item.kind) && item.offsite; +const heroSizes = '(max-width: 48rem) 90vw, (max-width: 68rem) 68vw, 55vw'; +const heroPortraits = [ + { + src: '/images/type-stage/point.webp', + srcset: '/images/type-stage/point-800.webp 800w, /images/type-stage/point.webp 1600w' + }, + { + src: '/images/type-stage/think.webp', + srcset: '/images/type-stage/think-800.webp 800w, /images/type-stage/think.webp 1600w' + }, { - heroImage && ( - - {heroImage.cloudinary && } - - - ) + src: '/images/type-stage/shrug.webp', + srcset: '/images/type-stage/shrug-800.webp 800w, /images/type-stage/shrug.webp 1600w' + }, + { + src: '/images/type-stage/shirt.webp', + srcset: '/images/type-stage/shirt-800.webp 800w, /images/type-stage/shirt.webp 1600w' + }, + { + src: '/images/type-stage/laugh.webp', + srcset: '/images/type-stage/laugh-800.webp 800w, /images/type-stage/laugh.webp 1544w' } -
- { - hero && ( -
- { - /* - No still, no column for one. `image` is optional in the blog schema, so a - post without one is a commit away, and a two column headline with nothing - in the left column is half a screen of nothing. The length moves to the - meta line in that case, since the duration badge lives on the still. - */ - } - {heroImage && ( - - )} +]; +--- -
-
- - {hero.topic} - / - {hero.kind} - - - {freshnessKicker(hero.date)} -
+ + + + - {/* Whose site this is. Full bleed so it reads as the page speaking, not as a module. */} -
-
- -

- I'm Michael. Twenty years of C# and I still find new ways to embarrass - myself. I teach .NET and Windows development the way I learned it, out loud, on - camera, mistakes included. -

- More about me → +
+
+ +
+ Real code. Honest tradeoffs. +

Have some fun doing it.

+

.NET, Windows, developer tools, and mistakes included.

+ Find your starting point
-
+ Michael Jolley, the Bald Bearded Builder + + {hero && ( + + Newest thing / {itemKindLabel(hero.kind)} / {hero.topic} + {hero.title} +
{hero.length}{isVideoKind(hero.kind) ? 'Watch now' : 'Read now'} →
+
+ )} +
+ Michael Jolley + Building since 1999 + Learning out loud
+
-

Learning paths

-

- Pick a rabbit hole. Each one is everything I've made on that topic, in the order - that actually makes sense to read it. -

-
- { - topicCounts - .filter((t) => t.n > 0) - .map((t) => ( - - {t.topic.label} {t.n} - - )) - } +
+
+ New here? +

Start with these.

+

One video and one article. They show how I teach, what I care about, and why the answer is usually "it depends."

+
+ +
- -

Not mine. Yours. Other people's worst days, filed by how bad it got.

-
-
- Problems - {problemSummary()} + {mostWatched.length > 0 && ( +
+
+ The numbers have spoken +

Most watched.

+

What developers have watched most across the channel, ranked by real YouTube views.

+
+ - { - topProblems.map((d) => ( - - {sevLabel(d.severity)} - {d.line} - - #{disasterCode(d.id)} - {d.likes > 0 && · ♡ {d.likes.toLocaleString('en-US')}} - - - )) - } - { - topProblems.length === 0 && ( - - Info - No problems reported. Yours would be the first. - #000 + Every video → +
+ )} + + {worthReading.length > 0 && ( +
+
+ Keep these close +

Worth reading.

+

Practical guides I would hand you when the same problem shows up again.

+
+ + Every article → +
+ )} + +
+
+ Pick your problem +

Choose a rabbit hole.

+

Start with the thing you need to fix, understand, or finally stop searching for.

+
+
+ {topicCounts.filter(({ count }) => count > 0).map(({ topic, count }, index) => ( + + {String(index + 1).padStart(2, '0')} + {topic.title} + {topic.blurb} + {count} pieces → + + ))}
+
-
- Tell me yours - Post anonymously if you'd rather. No name is ever required. +
+
+ Straight off the workbench +

Latest.

+

Videos and articles together, newest first.

+
+
+ {latest.map((item) => )}
+ Everything I've made → +
-

The .NET Drip

- -
+
diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro index 7bc69f4..a46f6ce 100644 --- a/src/pages/privacy.astro +++ b/src/pages/privacy.astro @@ -2,16 +2,14 @@ /* Privacy. - Every claim on this page is checked against the code that makes it true, and where the - code does something less flattering than a policy would like to say, the page says the - less flattering thing. The two that matter: video thumbnails are served by Google, so - the browser does contact them on any page with a video on it, and the like dedupe does - involve an address even though it never stores one. + Every claim on this page is checked against the code that makes it true. The two details + that matter most are easy to miss: video thumbnails come from Google, and like dedupe + briefly uses a network address even though it never stores that address. If a claim here stops matching the code, the code wins and this page is wrong. The - places to check are src/lib/reader.ts for the like token and the address hash, - src/lib/account.ts for deletion, src/components/VideoEmbed.astro for the thumbnail, and - src/layouts/Base.astro for the fonts. + places to check are src/lib/likes.ts for the like token and address hash, + src/components/VideoEmbed.astro for YouTube, src/components/Drip.astro for the + newsletter, and src/layouts/Base.astro for the fonts. */ import Base from '../layouts/Base.astro'; import { SITE } from '../config/site'; @@ -42,32 +40,11 @@ const held = [ gone: 'It is never stored as an address and cannot be turned back into one. Rotating the key forgets it entirely' }, { - tag: 'Auth', - what: 'Basic profile details from the sign-in provider you choose', - when: 'You sign in', - why: 'To put a name on your comments and help review brand-new accounts', - gone: 'Delete your account and it goes with it' - }, - { - tag: 'Words', - what: 'What you wrote, and when you edited it', - when: 'You comment or send a story', - why: 'It is the thing you asked me to publish', - gone: 'Comments become a tombstone. Published stories stay published' - }, - { - tag: 'Email', - what: 'Your email address, as your auth source supplied it', - when: 'You sign in', - why: 'To send the story and direct reply notifications you control', - gone: 'Delete your account and it goes with it' - }, - { - tag: 'Twitch', - what: 'Your Twitch user id', - when: 'You link Twitch, which is optional', - why: 'To match you to stream history for badges, by id rather than by name', - gone: 'Unlink it, or delete your account' + tag: 'Legacy', + what: 'Records from retired accounts, comments, and story submissions', + when: 'You used one of those features while it was available', + why: 'They supported the feature you chose to use at the time', + gone: 'Email the privacy inbox and I will remove what belongs to you' } ]; --- @@ -94,9 +71,9 @@ const held = [ without telling me anything.

- Reading needs no account, no cookie banner and no consent dialog, because - reading sets nothing that needs consenting to. Everything below is about what - happens when you do something, and there is a short list of somethings. + Reading needs no account, cookie banner, or consent dialog. Everything below + covers the small amount of data the site still uses, plus records left by + features that have been retired.

@@ -104,8 +81,8 @@ const held = [
The short version

- I keep the least I can get away with, I never sell it, and every piece of it - has a way out that you control rather than one that requires emailing me. + I keep the least I can get away with, and I never sell it. If an old record + still belongs to you, email me and I will remove it.

@@ -154,77 +131,47 @@ const held = [

Fonts are served from this site, not from Google. There is no analytics script, no advertising network, no session recorder, and no tag manager. The site sets - no cookie at all until you like something or sign in. + no cookie at all until you like something.

The exception, and it is a real one: video thumbnails are served by Google from i.ytimg.com, so on any page showing a video your browser makes a - request to Google to fetch that image, and Google sees it. The player itself is - not loaded. A YouTube embed would pull roughly 870 KB of Google script onto the - page and start talking to them before you had asked for anything, so instead the - page shows a still and a play button, and clicking it takes you to YouTube, - where their rules apply and mine stop. + request to Google to fetch that image, and Google sees it. The player itself + does not load until you press play. At that point your browser connects to + youtube-nocookie.com, and YouTube's rules apply to the player.

- Signing in sends you to the auth provider you chose, and linking Twitch sends - you to Twitch. Both see that you came from here. The pages are hosted by - Netlify and the database is Supabase, both of whom process requests on my - behalf and neither of whom get anything to use for themselves. + The pages are hosted by Netlify and the database is Supabase. Both process + requests on my behalf, and neither gets that data to use for themselves.

-

Deleting your account

+

Records from retired features

- It is a button on your account page, it works - immediately, and it does not route through me. It removes your account, your - handle, your badges, your address and any Twitch link. -

-

- Two things survive it, and you should know which before you press it. Comments - become tombstones rather than vanishing, because deleting the middle of a - conversation makes nonsense of the replies around it, and those replies belong - to other people. Stories you have already had published stay published, because - they are articles on a public site by then. If you want a published story taken - down as well, email the privacy inbox - and I will take it down. + If you had an account, wrote a comment, or submitted a story before those + features were retired, an old record may still exist. + Email the privacy inbox and I will + remove what belongs to you.

-

Email

-

- This site can send three account notifications: when a dev disaster you sent is - published, when it reaches the front page, and when somebody replies directly to - one of your comments. All three start on. Each has its own switch on - your account page, and every message - has a link that turns off that type without asking you to sign in. -

-

- The delivery queue stores your account id and the details needed to write the - message, but not your email address. The address is read from your auth account - at send time and handed to Resend, the delivery provider. Delete the account and - its queued messages are deleted with it. The site sends no welcome email, digest - or marketing email from that account address. -

+

Newsletter

- The .NET Drip is the one exception and it is opt in. Its signup box sits on the - front page and a handful of others, and it is the only thing here that asks you - to type an address. If you use it, that address goes onto the Drip list, which - is a newsletter I send from somewhere else. It is not joined to your account, - signing in does not put you on it, having an account does not put you on it, - and every issue carries its own unsubscribe link. + The .NET Drip is opt in. Its signup box is the only place here that asks you to + type an email address. If you use it, that address goes to the Drip newsletter, + which I send from another service. Every issue carries its own unsubscribe link.

Where you live

If you are in the UK, the EU, or somewhere with similar law, you have rights to - see what I hold, correct it, take it elsewhere and have it deleted. Most of that - is a button here already. For anything a button does not cover, - email the privacy inbox and I will do + see what I hold, correct it, take it elsewhere and have it deleted. + Email the privacy inbox and I will do it by hand.

Children

- This is a site about professional software. It is not aimed at children - and accounts are not intended for anyone under 16. + This is a site about professional software. It is not aimed at children. The + newsletter is not intended for anyone under 16.

When this changes

@@ -236,7 +183,7 @@ const held = [ diff --git a/src/pages/report.astro b/src/pages/report.astro deleted file mode 100644 index edcb276..0000000 --- a/src/pages/report.astro +++ /dev/null @@ -1,187 +0,0 @@ ---- -/* - Report a problem. - - Rendered on demand rather than prerendered, because decision 12 says every Report - control deep links here with the target already filled in, and it never opens a modal. - Reading the query string on the server means the field arrives filled for somebody with - JavaScript switched off, which a prerendered page could not do. - - Low traffic page, so an on demand render costs nothing worth measuring. -*/ -export const prerender = false; - -import Base from '../layouts/Base.astro'; -import { SITE } from '../config/site'; - -const KINDS = [ - { id: 'comment', label: 'A comment' }, - { id: 'disaster', label: 'A dev disaster' }, - { id: 'profile', label: 'A profile' }, - { id: 'offsite', label: 'Something off site' } -]; - -const params = Astro.url.searchParams; -const requested = params.get('type') ?? ''; -const selected = KINDS.some((k) => k.id === requested) ? requested : 'comment'; - -/* - Only a path from this site is accepted as a prefill. Anything else would let a crafted - link put arbitrary text on the page in the shape of a URL, which is a phishing surface - on a page whose whole job is to be trusted. -*/ -const rawTarget = params.get('target') ?? ''; -const target = rawTarget.startsWith('/') && !rawTarget.startsWith('//') - ? `${SITE.url}${rawTarget}` - : ''; - -/* - The id of the thing being reported, kept separate from the human readable link. The auto - hide trigger acts on this and nothing else, so it has to be an id rather than a URL a - reader could have typed anything into. -*/ -const rawRef = params.get('ref') ?? ''; -const ref = /^[0-9a-z-]{1,64}$/i.test(rawRef) ? rawRef : ''; - -/* - Outcomes of the form post. The form works with JavaScript off, so the answer comes back - as a redirect with a word on the end of it rather than as a message an island drew. -*/ -const SENT: Record = { - '1': { tone: 'ok', text: "Sent. I've got it, and I read these usually within a day." }, - empty: { tone: 'bad', text: 'That came through with nothing in the box. Tell me what happened and send it again.' }, - slow: { tone: 'bad', text: "That's a lot of reports in one hour. If something urgent is happening, email me instead." }, - off: { tone: 'bad', text: "Reports aren't saving right now. Please email me instead." }, - bad: { tone: 'bad', text: "That didn't come through. Try again, or email me." }, - failed: { tone: 'bad', text: "That didn't save. Try again, or email me." } -}; - -const sent = SENT[params.get('sent') ?? ''] ?? null; ---- - - -
-
- - -
-
- report -

- Tell me what happened. - I'll handle it. -

-

- This goes to one inbox and I'm the only one who reads it. The person you're - reporting is never told who reported them, and never sees this. -

-
- -
- { - sent && ( -

- {sent.text} -

- ) - } - - - -
- -
- { - KINDS.map((k) => ( - - )) - } -
-
- -
- - -
- -
- -

- Plain language is fine. You don't have to quote the rule you think it broke, - that's my job. If it's part of a pattern, tell me that too, because one - comment on its own often looks like nothing. -

- -
- -
- -
- - - Threats, doxxing, or anything that needs looking at tonight rather than - tomorrow. This pages my phone. Please don't use it for a bad take. - -
-
- -

- Here's what happens next. I read it, usually within a day, and then I act on it - or I decide not to. What I can't promise is a follow up, because a report - doesn't come with a way to reach you. If you want to hear back, email me - instead and I'll answer. Either way, I never tell the other person who reported - them. -

- -
- - - Or email me directly if that's - easier. - -
- -

- If somebody is in immediate danger, please contact your local emergency - services first. I'm one guy with a website. -

-
-
-
-
- - - diff --git a/src/pages/search.astro b/src/pages/search.astro index a962c70..97e7cc6 100644 --- a/src/pages/search.astro +++ b/src/pages/search.astro @@ -10,16 +10,17 @@ import Base from '../layouts/Base.astro';
- search/index + Search / Local index

Find the one where something broke.

- Search the full text of every article, video transcript, and dev disaster. + Search the full text of every public article and video transcript.

@@ -44,7 +45,7 @@ import Base from '../layouts/Base.astro'; type="search" autocomplete="off" enterkeyhint="search" - placeholder="Try “Blazor”, “async”, or an error message" + placeholder="Try Blazor, async, or an error message" spellcheck="false" /> - - {signedIn ? 'You can edit it until I publish it.' : 'Sign in first and this wakes up.'} - -
- -

- The long version lives in the submission terms. It says - the same thing with more commas. The code of conduct - applies here too. -

- - - -
- - - diff --git a/src/pages/terms.astro b/src/pages/terms.astro index bf3a9ae..ad643de 100644 --- a/src/pages/terms.astro +++ b/src/pages/terms.astro @@ -2,14 +2,11 @@ /* Terms. - Written to match what the site actually does and what the submit page already promises, - because the two consent checkboxes on /submit/ are the real agreement and this page only - spells them out. If they ever disagree, the checkbox wins: it is the thing somebody - ticked. The wording to keep in step is in src/pages/submit.astro around the - consent_scrubbed and consent_publish inputs. + Written to match the site people can use today. Historical data belongs on the privacy + page rather than in the rules for reading this one. Not written by a lawyer. See the note at the foot, which is deliberately on the page - rather than only in a comment. + rather than only in the source. */ import Base from '../layouts/Base.astro'; import { SITE } from '../config/site'; @@ -23,14 +20,15 @@ const lastChanged = new Date(SITE.termsUpdated).toLocaleDateString('en-GB', {