From 8f271bd8f15b040a6ac38412699b7f74febd9c67 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Fri, 9 Oct 2026 21:21:27 -0500 Subject: [PATCH 01/17] chore(deps): vue-router 5.4.0 package.json, package-lock.json: vue-router ^5.3.1 -> ^5.4.0, after two days with no issue filed against it. The lockfile also gains the router's own build-time parser deps (ast-walker-scope's babel 8 and ast-kit). 5.4 restores hashes and the top of the page by default and invalidates obsolete scroll positions. Checked in headless Chromium against 5.3.1's build side by side (README anchors, back/forward, tab and cross-project navigation, a cold fragment load): the same positions on both, no console output. Nuxt's own scrollBehavior governs these navigations. ROADMAP.md: ticked. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 18 ++++--- package-lock.json | 119 ++++++++++++++++++++++++++++++++++++---------- package.json | 2 +- 3 files changed, 107 insertions(+), 32 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 83da53d..924eb86 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -439,12 +439,18 @@ record. There is no run log. `^24.15.0`; the build image has 24.21. -- [ ] vue-router 5.4.0 (a direct dependency, published 2026-10-07), once it - has soaked. It changes client navigation defaults this site relies on: - the router now restores hashes and the top of the page by default, and - history invalidates obsolete scroll positions. Land it with a browser - check of in-page README anchors and back/forward between project tabs, - not just the server gates, because none of that runs on the server. +- [x] vue-router 5.4.0 (a direct dependency, published 2026-10-07), landed + 2026-10-09 after two days with no issue filed against it. It changes + client navigation defaults this site relies on: the router now restores + hashes and the top of the page by default, and history invalidates + obsolete scroll positions. Typecheck, 246 tests, build, `check`, `a11y`, + `a11y:browser` and `audit:runtime` clean, all 43 routes as on 5.3.1. In + headless Chromium, side by side with 5.3.1's build on onyums and + Skidbladnir: README anchor clicks, back and forward between anchors, + about → blog → back → forward, a cross-project hop and back, and a cold + load with a fragment land at the same positions on both, with no console + output. Nuxt's own `scrollBehavior` governs all of it, so the new + defaults change nothing a visitor sees here. - [x] Silenced Nitro's own `[request error]` stack-trace block on a 404, without replacing the error handler. Nitro logs it when the error is `fatal`, and diff --git a/package-lock.json b/package-lock.json index 5d11a10..d149554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "nuxt": "^4.6.0", "shiki": "^4.4.3", "vue": "^3.5.43", - "vue-router": "^5.3.1" + "vue-router": "^5.4.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -5351,22 +5351,94 @@ } }, "node_modules/ast-walker-scope": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", - "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.10.0.tgz", + "integrity": "sha512-KzvoxDfXNz2Gm8KYLh47K3qcY3Zza3WKOuh2xoqa7LBu6MDKWAZLovJtU9DuAd/Er+rhQHCRn619lHS5U+qmhw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.2", - "@babel/types": "^7.29.0", - "ast-kit": "^2.2.0" + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "ast-kit": "^3.0.0" }, "engines": { - "node": ">=20.19.0" + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/helper-string-parser": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz", + "integrity": "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/parser": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz", + "integrity": "sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/ast-kit": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0.tgz", + "integrity": "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "estree-walker": "^3.0.3", + "pathe": "^2.0.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" }, "funding": { "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/ast-walker-scope/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -13404,26 +13476,26 @@ "license": "MIT" }, "node_modules/vue-router": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.3.1.tgz", - "integrity": "sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.4.0.tgz", + "integrity": "sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw==", "license": "MIT", "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", - "ast-walker-scope": "^0.9.0", + "ast-walker-scope": "^0.10.0", "chokidar": "^5.0.0", - "confbox": "^0.2.4", + "confbox": "^0.3.1", "local-pkg": "^1.2.1", - "magic-string": "^0.30.21", + "magic-string": "^1.4.3", "mlly": "^1.8.2", "muggle-string": "^0.4.1", - "nostics": "^1.1.4", + "nostics": "^1.3.0", "pathe": "^2.0.3", - "picomatch": "^4.0.5", + "picomatch": "^4.0.7", "scule": "^1.3.0", "tinyglobby": "^0.2.17", - "unplugin": "^3.3.0", + "unplugin": "^3.4.0", "unplugin-utils": "^0.3.2" }, "funding": { @@ -13451,14 +13523,11 @@ } } }, - "node_modules/vue-router/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } + "node_modules/vue-router/node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "license": "MIT" }, "node_modules/vue-tsc": { "version": "3.3.12", diff --git a/package.json b/package.json index 2e66636..d48bf49 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "nuxt": "^4.6.0", "shiki": "^4.4.3", "vue": "^3.5.43", - "vue-router": "^5.3.1" + "vue-router": "^5.4.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", From a843361e16356034ceaa22230a00db51dee47e1b Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Fri, 9 Oct 2026 21:35:58 -0500 Subject: [PATCH 02/17] fix(about): Back returns a reader to the README they were reading The about page folds its README, and a page reached by Back is rendered afresh, folded, so the scroll position vue-router saved no longer existed: a visitor 6,000 px down onyums' README landed at 2,700, the bottom of the folded page. app/composables/useReadmeFold.ts keeps the fold in history.state beside vue-router's own keys, so Back and Forward reopen it before Nuxt's scrollBehavior runs. A visit by link starts folded; one folded again stays folded, even with a fragment in the URL. A client navigation to a fragment inside the README opens it (shared/markdown/anchor.ts, test/anchor.test.ts). Never while hydrating, so the server's markup is unchanged; on a reload onMounted reopens it and scrolls back to where the visitor was, which is kept in sessionStorage on pagehide because Chromium drops a replaceState made while the page unloads. app/pages/projects/[slug]/about.vue: binds it. README.md, ROADMAP.md: say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- ROADMAP.md | 17 +++++++ app/composables/useReadmeFold.ts | 79 +++++++++++++++++++++++++++++ app/pages/projects/[slug]/about.vue | 3 +- shared/markdown/anchor.ts | 34 +++++++++++++ test/anchor.test.ts | 39 ++++++++++++++ 6 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 app/composables/useReadmeFold.ts create mode 100644 shared/markdown/anchor.ts create mode 100644 test/anchor.test.ts diff --git a/README.md b/README.md index eec30bc..e16701c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ it is flat. | --- | --- | | `/` | The pitch, live project stats, and the catalogue | | `/projects` | Every public project | -| `/projects//about` | A marketing page per project: hero, why it exists, features, a worked example, the recent releases, and the repo's README folded away underneath. A desktop app's page also gets a download section: a direct link per platform to the newest release's installers. `/projects/` redirects here | +| `/projects//about` | A marketing page per project: hero, why it exists, features, a worked example, the recent releases, and the repo's README folded away underneath — once opened, it stays open for Back, Forward and a reload, at the place the visitor was reading. A desktop app's page also gets a download section: a direct link per platform to the newest release's installers. `/projects/` redirects here | | `/projects//blog` | That project's news, and `/projects//blog/` for one post | | `/news` | The organization's own news, for what is about Basic Automation rather than one tool | | `/admin` | The post editor — behind a login the app checks itself, and absent over Tor (below) | diff --git a/ROADMAP.md b/ROADMAP.md index 924eb86..66554fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -799,6 +799,23 @@ record. There is no run log. selected; copy it with your keyboard." Checked over CDP with the permission denied: the selection is exactly the command, without the `$` sigil, and both buttons reset after 4 s. +- [x] Back returns a reader to the README they were reading. The about page + folds its README, and a page reached by Back is rendered afresh, so it + came back folded: the scroll position vue-router saved no longer + existed, and a visitor 6,000 px down onyums' README landed at 2,700, the + bottom of the folded page. `useReadmeFold` + (`app/composables/useReadmeFold.ts`) keeps the fold in the history + entry beside vue-router's own state, so Back and Forward reopen it + before the page scrolls; a fresh visit by link still starts folded, and + one folded again stays folded. A client navigation to a fragment inside + the README opens it first (`hasAnchor`, `shared/markdown/anchor.ts`), as + a browser does on a real one. A reload reopens it too, at the place the + visitor was: Chromium drops a `replaceState` made while the page + unloads, so that position goes to session storage on `pagehide`. Found + by the vue-router 5.4 browser check; the same on 5.3.1. Proved in + headless Chromium on onyums and Nanna, before and after: Back 2,700 → + 6,000, reload 2,700 → 6,000, a pushed `#heading` lands at the heading; + the server's markup is unchanged. - [ ] Run axe's `color-contrast` in the browser pass too, once the contrast item below is decided. Left out deliberately: it would be red today on the shortfalls already waiting on that decision. diff --git a/app/composables/useReadmeFold.ts b/app/composables/useReadmeFold.ts new file mode 100644 index 0000000..22cd1d8 --- /dev/null +++ b/app/composables/useReadmeFold.ts @@ -0,0 +1,79 @@ +import { hasAnchor } from '~~/shared/markdown/anchor' + +/** The key this keeps in `history.state`, beside vue-router's own. */ +const KEY = 'readmeOpen' + +/** + * Whether the about page's folded README is open — kept in the history entry. + * + * A visitor who unfolds a README, reads down it and moves to the blog tab + * comes Back to a page rendered afresh, folded. vue-router hands Nuxt the + * scroll position it saved for that entry, but on the folded page it no + * longer exists, so the visitor landed at the bottom of a short page, + * thousands of pixels from where they were reading (13,201 px down onyums' + * README, restored to 2,700). Recording the fold in `history.state` makes + * it part of the entry, as the scroll position is: Back and Forward reopen + * it before the page scrolls, while a fresh visit by link starts folded, as + * the page is designed to. + * + * A fragment that names a README heading opens it too, on a client-side + * navigation; a browser does that itself on a real one. + * + * Never while hydrating: the element must match the server's markup, which + * is folded. `onMounted` catches up — that is a reload, where the entry's + * state survives. + */ +export function useReadmeFold(readmeHtml: Readonly>) { + const route = useRoute() + const nuxtApp = useNuxtApp() + + const wanted = (): boolean => { + if (!import.meta.client) return false + // What the visitor last did with this entry's fold wins, even over a + // fragment: one who folded it and came Back meant it. + const kept: unknown = history.state?.[KEY] + if (typeof kept === 'boolean') return kept + return !!route.hash && hasAnchor(readmeHtml.value, route.hash) + } + + const open = ref(!nuxtApp.isHydrating && wanted()) + // A reload: the browser restores the scroll position against the page as + // the server sent it, folded, so a visitor reading the README lands short + // of where they were. Neither it nor vue-router keeps the position across + // a reload (`history.state.scroll` is `false` then), and Chromium drops a + // `replaceState` made while the page unloads, so it goes to session + // storage, keyed by the history entry. A convenience: if storage is + // refused, a reload just lands where it did before. + const entry = () => `readme-top:${history.state?.position ?? ''}:${route.path}` + const remember = () => { + try { + if (open.value) sessionStorage.setItem(entry(), String(Math.round(window.scrollY))) + } + catch {} + } + onMounted(async () => { + window.addEventListener('pagehide', remember) + if (open.value || !wanted()) return + open.value = true + let top = Number.NaN + try { + top = Number(sessionStorage.getItem(entry()) ?? Number.NaN) + } + catch {} + if (!Number.isFinite(top)) return + await nextTick() + window.scrollTo({ top, behavior: 'instant' }) + }) + onBeforeUnmount(() => window.removeEventListener('pagehide', remember)) + + /** The `
`'s own `toggle`, from a click or from the binding. */ + function onToggle(event: Event) { + const now = (event.target as HTMLDetailsElement).open + open.value = now + // vue-router merges `history.state` into the entry when it next writes + // it, so the key survives navigating away; a new entry starts without it. + if (history.state?.[KEY] !== now) history.replaceState({ ...history.state, [KEY]: now }, '') + } + + return { open, onToggle } +} diff --git a/app/pages/projects/[slug]/about.vue b/app/pages/projects/[slug]/about.vue index 706087a..6c3838b 100644 --- a/app/pages/projects/[slug]/about.vue +++ b/app/pages/projects/[slug]/about.vue @@ -9,6 +9,7 @@ const route = useRoute() const slug = computed(() => String(route.params.slug)) const { project, error, readmeHtml, hasReadme } = await useProject(slug) +const readmeFold = useReadmeFold(readmeHtml) if (error.value || !project.value) { // Client-only `fatal`, for the reason spelled out in `app/pages/[...slug].vue`: @@ -545,7 +546,7 @@ useSeoMeta({
-
+
diff --git a/shared/markdown/anchor.ts b/shared/markdown/anchor.ts new file mode 100644 index 0000000..79b8242 --- /dev/null +++ b/shared/markdown/anchor.ts @@ -0,0 +1,34 @@ +/** + * Whether rendered HTML holds the element a URL fragment names. + * + * The about page folds its README into a `
`, and a fragment that + * points inside it — a README heading, linked from its own contents list or + * from anywhere else — names an element with no box until the fold is open. + * A browser opens it on a real fragment navigation; a client-side one has to + * know to, before it scrolls. + * + * `hash` is a location hash with or without its `#`, percent-encoded or not: + * GitHub's slugs keep emoji and other non-ASCII letters (`#⚡-quick-start`), + * which a URL carries encoded. + */ +export function hasAnchor(html: string | null | undefined, hash: string): boolean { + if (!html) return false + const raw = hash.replace(/^#/, '') + if (!raw) return false + const ids = new Set([raw]) + try { + ids.add(decodeURIComponent(raw)) + } + catch { + // A lone `%`: only the raw form can match. + } + for (const id of ids) { + if (html.includes(` id="${escapeAttribute(id)}"`)) return true + } + return false +} + +/** The escaping a serializer applies inside a double-quoted attribute. */ +function escapeAttribute(value: string): string { + return value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} diff --git a/test/anchor.test.ts b/test/anchor.test.ts new file mode 100644 index 0000000..bc26d59 --- /dev/null +++ b/test/anchor.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { hasAnchor } from '~~/shared/markdown/anchor' +import { renderMarkdown } from '~~/shared/markdown/render' + +describe('hasAnchor', () => { + const html = '

Install

x

⚡ Quick start

' + + it('finds an id, with or without the #', () => { + expect(hasAnchor(html, '#install')).toBe(true) + expect(hasAnchor(html, 'install')).toBe(true) + }) + + it('decodes a percent-encoded fragment, as a URL carries an emoji slug', () => { + expect(hasAnchor(html, `#${encodeURIComponent('⚡-quick-start-5-minutes')}`)).toBe(true) + expect(hasAnchor(html, '#⚡-quick-start-5-minutes')).toBe(true) + }) + + it('matches an id the serializer escaped', () => { + expect(hasAnchor(html, '#a&b')).toBe(true) + }) + + it('is false for an id that is not there, a prefix of one, or nothing at all', () => { + expect(hasAnchor(html, '#usage')).toBe(false) + expect(hasAnchor(html, '#inst')).toBe(false) + expect(hasAnchor(html, '#')).toBe(false) + expect(hasAnchor(html, '')).toBe(false) + expect(hasAnchor(null, '#install')).toBe(false) + }) + + it('survives a fragment that is not valid percent-encoding', () => { + expect(hasAnchor('

x

', '#50%')).toBe(true) + }) + + it('finds the ids the README renderer actually mints', async () => { + const rendered = await renderMarkdown('# Nanna\n\n## First launch & troubleshooting\n\n## ⚡ Quick start\n') + expect(hasAnchor(rendered, '#first-launch--troubleshooting')).toBe(true) + expect(hasAnchor(rendered, `#${encodeURIComponent('⚡-quick-start')}`)).toBe(true) + }) +}) From 800df8781d8222a2bf91391bd0d05d83880064a3 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Fri, 9 Oct 2026 21:45:39 -0500 Subject: [PATCH 03/17] fix(about): a link to a README heading lands on it in Firefox Chromium opens a closed
for a fragment by itself; Firefox does not, and Tor Browser is Firefox. A link to /projects/onyums/about#how-onyums-compares left a Firefox visitor at the bottom of the folded page with the heading hidden inside it. app/composables/useReadmeFold.ts: once mounted, look the fragment up in the document (the README is markup, not a string, while hydrating), open the fold and scroll the heading in, unless the visitor already chose a fold state for this history entry. Checked in headless Firefox 155 over WebDriver BiDi and in Chromium. ROADMAP.md: recorded. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 11 +++++++++++ app/composables/useReadmeFold.ts | 31 ++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 66554fc..b6e1e39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -816,6 +816,17 @@ record. There is no run log. headless Chromium on onyums and Nanna, before and after: Back 2,700 → 6,000, reload 2,700 → 6,000, a pushed `#heading` lands at the heading; the server's markup is unchanged. +- [x] …and a link to a README heading lands on it in Firefox. Chromium opens + a closed `
` for a fragment by itself; Firefox does not, and Tor + Browser is Firefox. So `/projects/onyums/about#how-onyums-compares`, + followed from anywhere, left a Firefox visitor at the bottom of the + folded page (y 2,701) with the heading hidden. `useReadmeFold` now looks + the fragment up in the document once mounted, opens the fold and scrolls + the heading in, unless the visitor already folded it in that entry. + Proved in headless Firefox 155 over WebDriver BiDi on onyums and Nanna: + the heading lands 80 px from the top (its scroll margin), Back and + reload as in Chromium, no console output; Chromium unchanged, an + encoded emoji slug included. - [ ] Run axe's `color-contrast` in the browser pass too, once the contrast item below is decided. Left out deliberately: it would be red today on the shortfalls already waiting on that decision. diff --git a/app/composables/useReadmeFold.ts b/app/composables/useReadmeFold.ts index 22cd1d8..77397f1 100644 --- a/app/composables/useReadmeFold.ts +++ b/app/composables/useReadmeFold.ts @@ -16,8 +16,12 @@ const KEY = 'readmeOpen' * it before the page scrolls, while a fresh visit by link starts folded, as * the page is designed to. * - * A fragment that names a README heading opens it too, on a client-side - * navigation; a browser does that itself on a real one. + * A fragment that names a README heading opens it too — on a client-side + * navigation, and on arrival from a link elsewhere, because only Chromium + * opens a closed `
` for a fragment itself. Firefox, and so Tor + * Browser, which is how the onion service is reached, left a link to + * `/projects/onyums/about#how-onyums-compares` at the bottom of the folded + * page with the heading hidden inside it. * * Never while hydrating: the element must match the server's markup, which * is folded. `onMounted` catches up — that is a reload, where the entry's @@ -53,16 +57,20 @@ export function useReadmeFold(readmeHtml: Readonly>) { } onMounted(async () => { window.addEventListener('pagehide', remember) - if (open.value || !wanted()) return + if (open.value) return + // While hydrating the README is markup, not a string, so a fragment is + // looked up in the document. Not when the visitor already chose. + const heading = typeof history.state?.[KEY] === 'boolean' ? null : readmeHeading(route.hash) + if (!heading && !wanted()) return open.value = true let top = Number.NaN try { top = Number(sessionStorage.getItem(entry()) ?? Number.NaN) } catch {} - if (!Number.isFinite(top)) return await nextTick() - window.scrollTo({ top, behavior: 'instant' }) + if (Number.isFinite(top)) window.scrollTo({ top, behavior: 'instant' }) + else heading?.scrollIntoView({ block: 'start', behavior: 'instant' }) }) onBeforeUnmount(() => window.removeEventListener('pagehide', remember)) @@ -77,3 +85,16 @@ export function useReadmeFold(readmeHtml: Readonly>) { return { open, onToggle } } + +/** The README element a location hash names, if it names one. */ +function readmeHeading(hash: string): HTMLElement | null { + const raw = hash.replace(/^#/, '') + if (!raw) return null + let id = raw + try { + id = decodeURIComponent(raw) + } + catch {} + const el = document.getElementById(id) + return el?.closest('.readme') ? el : null +} From 4033b440796a3f02af43d4d6d50e720704e0bebd Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Fri, 9 Oct 2026 21:57:27 -0500 Subject: [PATCH 04/17] feat(checks): run the browser accessibility pass in Firefox too Every browser check here was Chromium, and the onion service is reached in Tor Browser, which is Firefox. The README-heading bug fixed in the previous commit was visible only there. scripts/lib/bidi.mjs: headless Firefox over WebDriver BiDi with Node's own WebSocket, on a port Firefox picks and records in the profile. scripts/lib/browser.mjs: one page interface (open, goto, evaluate, run, takeErrors, close) over cdp.mjs and bidi.mjs. scripts/check-a11y-browser.mjs: written against it; --firefox selects the engine. package.json: npm run a11y:firefox. ci.yml: runs it after a11y:browser. Clean on 23 pages at both widths in both engines. Proved on a stub server that a console error, an uncaught exception, a CSP refusal and undersized buttons each fail it in both. README.md, ROADMAP.md: documented. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 5 ++ README.md | 6 +- ROADMAP.md | 14 ++++ package.json | 1 + scripts/check-a11y-browser.mjs | 103 +++++++++-------------- scripts/lib/bidi.mjs | 121 +++++++++++++++++++++++++++ scripts/lib/browser.mjs | 144 +++++++++++++++++++++++++++++++++ 7 files changed, 330 insertions(+), 64 deletions(-) create mode 100644 scripts/lib/bidi.mjs create mode 100644 scripts/lib/browser.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 150f53f..b7bc6e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,11 @@ jobs: # access to scroll regions, the viewport meta — in the Google Chrome # the Ubuntu runner already ships, at desktop and phone widths. npm run a11y:browser + # …and the same pass in Firefox, which the runner also ships, over + # WebDriver BiDi. The onion service is reached in Tor Browser, which + # is Firefox, and the engines differ: a link to a README heading + # worked in Chromium and not in Firefox. + npm run a11y:firefox onion: name: onion gateway diff --git a/README.md b/README.md index e16701c..ce45eb4 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ npm run check # against http://127.0.0.1:3000 npm run check -- --external # also follow links off the site npm run a11y # axe-core over every page, through jsdom npm run a11y:browser # the layout rules, in headless Chromium +npm run a11y:firefox # …and the same, in headless Firefox npm run vitals # LCP and CLS, phone and desktop, median of 3 npm run font # re-cut the preloaded core of Fira Code ``` @@ -237,7 +238,10 @@ whether each control's visible label is part of its accessible name — in headless Chromium at a desktop and a phone width (set `CHROME_PATH` if Chromium is not on `PATH`), reading each page to the bottom so lazy content loads, and fails on any console error, uncaught exception or CSP refusal the -page produces along the way. Both walk every page the sitemap lists, so a new +page produces along the way. `npm run a11y:firefox` is the same pass in +headless Firefox, driven over WebDriver BiDi (set `FIREFOX_PATH` if it is not on +`PATH`): the onion service is reached in Tor Browser, which is Firefox, and the +two engines do differ. All three walk every page the sitemap lists, so a new project is audited the moment it is published. Colour contrast is measured separately and more directly by `npm run contrast`. diff --git a/ROADMAP.md b/ROADMAP.md index b6e1e39..e84b87f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -827,6 +827,20 @@ record. There is no run log. the heading lands 80 px from the top (its scroll margin), Back and reload as in Chromium, no console output; Chromium unchanged, an encoded emoji slug included. +- [x] Run the browser pass in Firefox too — `npm run a11y:firefox`, in CI + beside `a11y:browser`. Every browser check here was Chromium, and the + onion service is reached in Tor Browser, which is Firefox; the bug + above was visible only there. `scripts/lib/bidi.mjs` drives headless + Firefox over WebDriver BiDi with Node's own WebSocket, the way + `lib/cdp.mjs` drives Chromium, and `scripts/lib/browser.mjs` puts one + page interface over both, so `check-a11y-browser.mjs` runs the same + axe rules, the same read-to-the-bottom and the same console watch in + either. First run: no violations and no console errors on 23 pages at + both widths. Proved it bites on a stub server, in both engines: a + console error, an uncaught exception, a CSP-refused inline script and + two 8 px buttons each fail it; a clean page passes. Firefox reports a + CSP refusal as a `javascript` log entry, so no listener is needed. + - [ ] Run axe's `color-contrast` in the browser pass too, once the contrast item below is decided. Left out deliberately: it would be red today on the shortfalls already waiting on that decision. diff --git a/package.json b/package.json index d48bf49..3967b35 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "sizes:check": "node scripts/build-asset-sizes.mjs --check", "a11y": "node scripts/check-a11y.mjs", "a11y:browser": "node scripts/check-a11y-browser.mjs", + "a11y:firefox": "node scripts/check-a11y-browser.mjs --firefox", "vitals": "node scripts/measure-vitals.mjs", "font": "node scripts/build-font-subset.mjs", "font:check": "node scripts/build-font-subset.mjs --check" diff --git a/scripts/check-a11y-browser.mjs b/scripts/check-a11y-browser.mjs index bda80ee..292a7e1 100644 --- a/scripts/check-a11y-browser.mjs +++ b/scripts/check-a11y-browser.mjs @@ -34,7 +34,7 @@ import { readFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { NOT_FOUND_PATH, sitePages } from './lib/pages.mjs' -import { startBrowser } from './lib/cdp.mjs' +import { ENGINES, startDriver } from './lib/browser.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -44,6 +44,10 @@ const BASE = (args.find((a) => !a.startsWith('--')) ?? 'http://127.0.0.1:3000').replace(/\/$/, '') /** The layout rules `check-a11y.mjs` has to skip, less the two contrast ones. */ +/** `--firefox` runs the same pass in Firefox; Chromium is the default. */ +const ENGINE = args.includes('--firefox') ? 'firefox' : 'chromium' +if (!ENGINES.includes(ENGINE)) throw new Error(`no engine ${ENGINE}`) + const RULES = [ 'target-size', 'scrollable-region-focusable', @@ -62,91 +66,64 @@ const VIEWPORTS = [ const PAGES = await sitePages(BASE) const axeSource = await readFile(resolve(ROOT, 'node_modules/axe-core/axe.min.js'), 'utf8') -const { cdp, chrome, stop } = await startBrowser() +const browser = await startDriver(ENGINE) -console.log(`axe-core (${RULES.join(', ')}) over ${PAGES.length} pages × ${VIEWPORTS.length} widths in ${chrome}\n`) +console.log(`axe-core (${RULES.join(', ')}) over ${PAGES.length} pages × ${VIEWPORTS.length} widths in ${browser.name} (${browser.binary})\n`) let violations = 0 let consoleErrors = 0 const seen = new Map() /** - * What the page says went wrong while it loaded, until `stop()`. The 404 page - * is expected to log its own 404 for the document — that one line is not - * a fault, anything else on it is. + * What the page said went wrong while it loaded. The 404 page is expected to + * log its own 404 for the document — that one line is not a fault, anything + * else on it is. */ -function listenForErrors(sessionId) { - const errors = [] - const off = cdp.on((msg) => { - if (msg.sessionId !== sessionId) return - if (msg.method === 'Runtime.exceptionThrown') { - errors.push(`exception: ${msg.params.exceptionDetails.exception?.description?.split('\n')[0] ?? msg.params.exceptionDetails.text}`) - } - else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') { - errors.push(msg.params.args.map((a) => a.value ?? a.description ?? '').join(' ').slice(0, 200)) - } - else if (msg.method === 'Log.entryAdded' && msg.params.entry.level === 'error') { - const { text, url } = msg.params.entry - if (/status of 404/.test(text) && url?.endsWith(NOT_FOUND_PATH)) return - errors.push(text.slice(0, 200)) - } - }) - return { stop: () => { off(); return errors } } +function faults(page) { + return page.takeErrors() + .filter(({ text, url }) => !(/status of 404/.test(text) && url?.endsWith(NOT_FOUND_PATH))) + .map(({ text }) => text.slice(0, 200)) } try { for (const vp of VIEWPORTS) { - const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }) - const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }) - await cdp.send('Page.enable', {}, sessionId) - await cdp.send('Runtime.enable', {}, sessionId) - await cdp.send('Log.enable', {}, sessionId) const { name, ...metrics } = vp - await cdp.send('Emulation.setDeviceMetricsOverride', metrics, sessionId) + const page = await browser.open(metrics) for (const path of PAGES) { - const noise = listenForErrors(sessionId) - const loaded = cdp.once('Page.loadEventFired', sessionId) - const nav = await cdp.send('Page.navigate', { url: BASE + path }, sessionId) - if (nav.errorText) throw new Error(`${path}: ${nav.errorText}`) - await loaded + page.takeErrors() + await page.goto(BASE + path) // Let hydration settle, so what is measured is what a visitor gets. - await cdp.send('Runtime.evaluate', { - expression: 'new Promise((r) => requestIdleCallback(() => r(), { timeout: 2000 }))', - awaitPromise: true, - }, sessionId) + await page.evaluate('new Promise((r) => requestIdleCallback(() => r(), { timeout: 2000 }))') // Then read to the bottom, a screen at a time, as a visitor would. The // onion frame and the README's images are lazy: unscrolled, they never // load, and whatever they would log is never seen. - await cdp.send('Runtime.evaluate', { - expression: `(async () => { - for (let y = 0; y < document.documentElement.scrollHeight; y += innerHeight) { - scrollTo(0, y) - await new Promise((r) => setTimeout(r, 150)) - } - await new Promise((r) => setTimeout(r, 1000)) - scrollTo(0, 0) - })()`, - awaitPromise: true, - }, sessionId) + await page.evaluate(`(async () => { + for (let y = 0; y < document.documentElement.scrollHeight; y += innerHeight) { + scrollTo(0, y) + await new Promise((r) => setTimeout(r, 150)) + } + await new Promise((r) => setTimeout(r, 1000)) + scrollTo(0, 0) + })()`) const expected = path === NOT_FOUND_PATH ? 'error page' : 'page' - await cdp.send('Runtime.evaluate', { expression: axeSource }, sessionId) - const { result, exceptionDetails } = await cdp.send('Runtime.evaluate', { - expression: `axe.run(document, { + await page.run(axeSource) + let bad + try { + bad = await page.evaluate(`axe.run(document, { runOnly: { type: 'rule', values: ${JSON.stringify(RULES)} }, resultTypes: ['violations'], }).then((r) => r.violations.map((v) => ({ id: v.id, impact: v.impact, help: v.help, helpUrl: v.helpUrl, nodes: v.nodes.map((n) => ({ target: n.target.join(' '), html: n.html, summary: n.failureSummary })), - })))`, - awaitPromise: true, - returnByValue: true, - }, sessionId) - if (exceptionDetails) throw new Error(`${path}: axe threw — ${exceptionDetails.exception?.description ?? exceptionDetails.text}`) - - const bad = result.value - const errors = noise.stop() + })))`) + } + catch (e) { + throw new Error(`${path}: axe threw — ${e.message}`) + } + + const errors = faults(page) violations += bad.length consoleErrors += errors.length console.log(` ${bad.length || errors.length ? '✗' : '·'} ${name.padEnd(7)} ${path} (${expected}) — ${bad.length} violation(s), ${errors.length} console error(s)`) @@ -165,11 +142,11 @@ try { } } - await cdp.send('Target.closeTarget', { targetId }) + await page.close() } } finally { - await stop() + await browser.stop() } console.log('\nNot run: color-contrast, color-contrast-enhanced — `npm run contrast` measures the palette.') @@ -180,4 +157,4 @@ if (violations || consoleErrors) { process.exit(1) } -console.log(`\n✓ No violations and no console errors across ${PAGES.length} pages at ${VIEWPORTS.map((v) => `${v.width}px`).join(' and ')}.`) +console.log(`\n✓ No violations and no console errors across ${PAGES.length} pages at ${VIEWPORTS.map((v) => `${v.width}px`).join(' and ')} in ${browser.name}.`) diff --git a/scripts/lib/bidi.mjs b/scripts/lib/bidi.mjs new file mode 100644 index 0000000..6947641 --- /dev/null +++ b/scripts/lib/bidi.mjs @@ -0,0 +1,121 @@ +/** + * Headless Firefox over WebDriver BiDi, with Node's own WebSocket — the + * Firefox half of `lib/cdp.mjs`, for the same reason that is not Puppeteer. + * + * Why Firefox at all: the onion service is reached in Tor Browser, which is + * Firefox, and the two engines differ in ways this site has met — Chromium + * opens a closed `
` for a URL fragment and Firefox does not, so a + * link to a README heading worked in one and not the other. + * + * Firefox is found on PATH (`firefox`, `firefox-esr`) or named by + * `FIREFOX_PATH`. GitHub's Ubuntu runners ship Firefox. + * + */ + +import { spawn, execFileSync } from 'node:child_process' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +export function findFirefox() { + if (process.env.FIREFOX_PATH) return process.env.FIREFOX_PATH + for (const name of ['firefox', 'firefox-esr']) { + try { + return execFileSync('which', [name], { encoding: 'utf8' }).trim() + } + catch { + // not this one + } + } + console.error('✗ No Firefox found. Install one, or set FIREFOX_PATH.') + process.exit(1) +} + +/** + * Start Firefox on a free port and resolve with its BiDi WebSocket URL. + * With `--remote-debugging-port 0` it picks the port and writes it to + * `WebDriverBiDiServer.json` in the profile once it is listening — the + * counterpart of Chromium's `DevToolsActivePort`, polled the same way. + */ +async function launch(firefox, profile) { + const proc = spawn(firefox, [ + '--headless', + '--no-remote', + '--profile', profile, + '--remote-debugging-port', '0', + 'about:blank', + ], { stdio: ['ignore', 'ignore', 'pipe'] }) + + let err = '' + let exited = null + proc.stderr.on('data', (chunk) => { err += chunk }) + proc.on('exit', (code) => { exited = code }) + + const deadline = Date.now() + 60_000 + for (;;) { + if (exited !== null) throw new Error(`Firefox exited ${exited}:\n${err}`) + if (Date.now() > deadline) { + proc.kill() + throw new Error(`Firefox did not start within 60 s:\n${err}`) + } + const server = await readFile(join(profile, 'WebDriverBiDiServer.json'), 'utf8').catch(() => null) + const { ws_host: host, ws_port: port } = server ? JSON.parse(server) : {} + if (host && port) return { proc, url: `ws://${host}:${port}/session` } + await new Promise((r) => setTimeout(r, 100)) + } +} + +/** Commands by id, events to listeners — the same shape as `cdp.mjs`'s client. */ +async function connect(url) { + const ws = new WebSocket(url) + await new Promise((res, rej) => { + ws.onopen = res + ws.onerror = () => rej(new Error(`could not connect to ${url}`)) + }) + let id = 0 + const pending = new Map() + const listeners = new Set() + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + if (msg.id !== undefined && pending.has(msg.id)) { + const { res, rej } = pending.get(msg.id) + pending.delete(msg.id) + if (msg.type === 'error') rej(new Error(`${msg.error}: ${msg.message}`)) + else res(msg.result) + return + } + if (msg.type === 'event') for (const l of listeners) l(msg) + } + return { + send(method, params = {}) { + const msgId = ++id + ws.send(JSON.stringify({ id: msgId, method, params })) + return new Promise((res, rej) => pending.set(msgId, { res, rej })) + }, + on(listener) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + close: () => ws.close(), + } +} + +/** Launch a throwaway Firefox and open a BiDi session. `stop()` cleans up. */ +export async function startFirefox() { + const firefox = findFirefox() + const profile = await mkdtemp(join(tmpdir(), 'ba-bidi-')) + const { proc, url } = await launch(firefox, profile) + const bidi = await connect(url) + await bidi.send('session.new', { capabilities: {} }) + return { + bidi, + firefox, + async stop() { + await bidi.send('session.end').catch(() => {}) + bidi.close() + proc.kill() + await new Promise((r) => proc.exitCode !== null ? r() : proc.once('exit', r)) + await rm(profile, { recursive: true, force: true }).catch(() => {}) + }, + } +} diff --git a/scripts/lib/browser.mjs b/scripts/lib/browser.mjs new file mode 100644 index 0000000..7c0c7ed --- /dev/null +++ b/scripts/lib/browser.mjs @@ -0,0 +1,144 @@ +/** + * One page-driving interface over two engines: Chromium through `cdp.mjs`, + * Firefox through `bidi.mjs`. Only as much as `check-a11y-browser.mjs` needs — + * open a page at a size, load a URL, evaluate in it, and hear what it logged. + * + * const browser = await startDriver('firefox') + * const page = await browser.open({ width: 390, height: 844, deviceScaleFactor: 3, mobile: true }) + * await page.goto(url); await page.evaluate('document.title'); page.takeErrors() + * + * `evaluate` takes an expression and resolves with its value (awaited, as + * JSON); `run` executes a whole script, such as a library's source, and + * resolves with nothing. + * + * `takeErrors()` returns, and forgets, `{ text, url }` for every console + * error, uncaught exception and refused load the page reported. + */ + +import { startBrowser } from './cdp.mjs' +import { startFirefox } from './bidi.mjs' + +export const ENGINES = ['chromium', 'firefox'] + +export async function startDriver(engine) { + if (engine === 'firefox') return firefoxDriver() + if (engine === 'chromium') return chromiumDriver() + throw new Error(`unknown engine ${engine}; expected one of ${ENGINES.join(', ')}`) +} + +async function chromiumDriver() { + const { cdp, chrome, stop } = await startBrowser() + return { + name: 'Chromium', + binary: chrome, + stop, + async open(viewport) { + const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }) + const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }) + await cdp.send('Page.enable', {}, sessionId) + await cdp.send('Runtime.enable', {}, sessionId) + await cdp.send('Log.enable', {}, sessionId) + await cdp.send('Emulation.setDeviceMetricsOverride', viewport, sessionId) + let errors = [] + const off = cdp.on((msg) => { + if (msg.sessionId !== sessionId) return + if (msg.method === 'Runtime.exceptionThrown') { + const d = msg.params.exceptionDetails + errors.push({ text: `exception: ${d.exception?.description?.split('\n')[0] ?? d.text}` }) + } + else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') { + errors.push({ text: msg.params.args.map((a) => a.value ?? a.description ?? '').join(' ').slice(0, 200) }) + } + else if (msg.method === 'Log.entryAdded' && msg.params.entry.level === 'error') { + // Refused loads, CSP refusals and the like arrive here. + errors.push({ text: msg.params.entry.text, url: msg.params.entry.url }) + } + }) + return { + async goto(url) { + const loaded = cdp.once('Page.loadEventFired', sessionId) + const nav = await cdp.send('Page.navigate', { url }, sessionId) + if (nav.errorText) throw new Error(`${url}: ${nav.errorText}`) + await loaded + }, + async run(source) { + const { exceptionDetails } = await cdp.send('Runtime.evaluate', { expression: source }, sessionId) + if (exceptionDetails) throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text) + }, + async evaluate(expression) { + const { result, exceptionDetails } = await cdp.send('Runtime.evaluate', { + expression, awaitPromise: true, returnByValue: true, + }, sessionId) + if (exceptionDetails) throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text) + return result.value + }, + takeErrors() { + const out = errors + errors = [] + return out + }, + async close() { + off() + await cdp.send('Target.closeTarget', { targetId }) + }, + } + }, + } +} + +async function firefoxDriver() { + const { bidi, firefox, stop } = await startFirefox() + return { + name: 'Firefox', + binary: firefox, + stop, + async open({ width, height, deviceScaleFactor = 1 }) { + const { context } = await bidi.send('browsingContext.create', { type: 'tab' }) + await bidi.send('browsingContext.setViewport', { + context, viewport: { width, height }, devicePixelRatio: deviceScaleFactor, + }) + await bidi.send('session.subscribe', { events: ['log.entryAdded'], contexts: [context] }) + let errors = [] + const off = bidi.on((msg) => { + if (msg.method !== 'log.entryAdded' || msg.params.source?.context !== context) return + if (msg.params.level !== 'error') return + // `javascript` entries are uncaught errors and, in Firefox, the + // browser's own refusals too — a CSP-blocked script arrives here. + const prefix = msg.params.type === 'javascript' ? 'exception: ' : '' + errors.push({ text: `${prefix}${msg.params.text ?? ''}`.slice(0, 200) }) + }) + return { + async goto(url) { + await bidi.send('browsingContext.navigate', { context, url, wait: 'complete' }) + }, + async run(source) { + const r = await bidi.send('script.evaluate', { + expression: source, target: { context }, awaitPromise: false, resultOwnership: 'none', + }) + if (r.type === 'exception') throw new Error(r.exceptionDetails.text) + }, + async evaluate(expression) { + // BiDi returns a typed remote value; JSON round-trips what the + // callers here return, which is plain data. + const r = await bidi.send('script.evaluate', { + expression: `Promise.resolve(${expression}).then((v) => v === undefined ? undefined : JSON.stringify(v))`, + target: { context }, + awaitPromise: true, + resultOwnership: 'none', + }) + if (r.type === 'exception') throw new Error(r.exceptionDetails.text) + return r.result.type === 'string' ? JSON.parse(r.result.value) : undefined + }, + takeErrors() { + const out = errors + errors = [] + return out + }, + async close() { + off() + await bidi.send('browsingContext.close', { context }) + }, + } + }, + } +} From b81df312d8991abbf0285f9bddf3ed034780ec8d Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Fri, 9 Oct 2026 22:19:24 -0500 Subject: [PATCH 05/17] fix(about): a link to a README heading is not sent to an old reading position useReadmeFold keeps a reader's position in session storage for a reload, keyed by vue-router's entry number. That number restarts from the length of the history whenever a document loads, so a fresh load could reuse an older entry's number: on Nisaba and Nanna a cold load of about# landed at the position an earlier reload had saved, not at the heading, in both Chromium and Firefox. Found by the navigation check in the next commit. app/composables/useReadmeFold.ts: the saved position is read only when the navigation timing says the document is a reload or Back/Forward to it. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/composables/useReadmeFold.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/composables/useReadmeFold.ts b/app/composables/useReadmeFold.ts index 77397f1..855f0ed 100644 --- a/app/composables/useReadmeFold.ts +++ b/app/composables/useReadmeFold.ts @@ -65,7 +65,11 @@ export function useReadmeFold(readmeHtml: Readonly>) { open.value = true let top = Number.NaN try { - top = Number(sessionStorage.getItem(entry()) ?? Number.NaN) + // Only for the same entry come back to. vue-router numbers entries from + // the length of the history when a document loads, so a fresh load can + // reuse an older entry's number — and its saved position would send a + // link to `#heading` to wherever that one was left. + if (returning()) top = Number(sessionStorage.getItem(entry()) ?? Number.NaN) } catch {} await nextTick() @@ -98,3 +102,9 @@ function readmeHeading(hash: string): HTMLElement | null { const el = document.getElementById(id) return el?.closest('.readme') ? el : null } + +/** Whether this document is a reload, or Back or Forward to it, rather than a new visit. */ +function returning(): boolean { + const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined + return nav?.type === 'reload' || nav?.type === 'back_forward' +} From dc48fd3848e78b6458a66925052789a759e92df4 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Fri, 9 Oct 2026 22:19:24 -0500 Subject: [PATCH 06/17] feat(checks): npm run check:navigation, in both engines, in CI What a visitor's own navigation does to the folded README runs only in the browser, so neither check nor axe could see it break. On every about page whose README has a heading, scripts/check-navigation.mjs asserts that Back, Forward and a reload return the reader to where they were reading, that a visit by link starts folded, that a cold load and a client navigation to a README heading land on it, and that a fold the visitor closed stays closed on Back; any console error fails it too. It waits for scrolling to stop, because the site scrolls smoothly. 64 checks per engine, green on this build; against main's build it fails 32 in Chromium and 41 in Firefox. scripts/lib/browser.mjs: reload() for both drivers. package.json: the script. ci.yml: runs it in Chromium and Firefox. README.md, ROADMAP.md: documented, plus a new item for the client hash scroll that stops short. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 5 + README.md | 7 +- ROADMAP.md | 23 +++++ package.json | 1 + scripts/check-navigation.mjs | 192 +++++++++++++++++++++++++++++++++++ scripts/lib/browser.mjs | 13 ++- 6 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 scripts/check-navigation.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7bc6e7..2c8d48d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,11 @@ jobs: # is Firefox, and the engines differ: a link to a README heading # worked in Chromium and not in Firefox. npm run a11y:firefox + # What a visitor's own navigation does to the folded README — Back, + # Forward, reload, links to its headings — in both engines. None of + # it runs on the server, so nothing above can see it break. + npm run check:navigation + npm run check:navigation -- --firefox onion: name: onion gateway diff --git a/README.md b/README.md index ce45eb4..b41d38a 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,7 @@ npm run check -- --external # also follow links off the site npm run a11y # axe-core over every page, through jsdom npm run a11y:browser # the layout rules, in headless Chromium npm run a11y:firefox # …and the same, in headless Firefox +npm run check:navigation # Back, reload and README links, in Chromium (--firefox too) npm run vitals # LCP and CLS, phone and desktop, median of 3 npm run font # re-cut the preloaded core of Fira Code ``` @@ -241,7 +242,11 @@ loads, and fails on any console error, uncaught exception or CSP refusal the page produces along the way. `npm run a11y:firefox` is the same pass in headless Firefox, driven over WebDriver BiDi (set `FIREFOX_PATH` if it is not on `PATH`): the onion service is reached in Tor Browser, which is Firefox, and the -two engines do differ. All three walk every page the sitemap lists, so a new +two engines do differ. `npm run check:navigation` drives what a visitor's own +navigation does to each about page's folded README: Back, Forward and a reload +return a reader to the place they were reading, a visit by link starts folded, +and a link to a README heading lands on it, in Chromium or (`--firefox`) +Firefox. All of these walk every page the sitemap lists, so a new project is audited the moment it is published. Colour contrast is measured separately and more directly by `npm run contrast`. diff --git a/ROADMAP.md b/ROADMAP.md index e84b87f..7e690ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -841,6 +841,29 @@ record. There is no run log. two 8 px buttons each fail it; a clean page passes. Firefox reports a CSP refusal as a `javascript` log entry, so no listener is needed. +- [x] Hold the README fold to its behaviour — `npm run check:navigation` + (`scripts/check-navigation.mjs`), in CI in both engines. None of it runs + on the server, so nothing else could see it break. On every about page + whose README has a heading: unfold and read down, then Back, Forward and + Back, and a reload all return to the same place (±4 px of where the + reader was once the README's lazy images settled); a visit by link + starts folded at the top; a cold load of `about#` lands on the + heading; a client navigation to it shows it; a fold the visitor closed + stays closed on Back. 64 checks per engine, green on this build, and + against main's build 32 failures in Chromium and 41 in Firefox. It + waits for scrolling to stop rather than a fixed time, because the site + scrolls smoothly and a restore takes over a second. Writing it found a + bug in the fix: the reload position was keyed by vue-router's entry + number, which a fresh document load can reuse, so a cold link to a + heading went to an older reading position instead; it is now read only + on a reload or Back/Forward. +- [ ] A client navigation to a README heading stops short of it: Nuxt's hash + scroll follows the stylesheet's `scroll-behavior: smooth`, and README + images between here and the heading load while the page passes them + and push it down, so it ends on screen (checked) but not at the top. + Same before tonight's changes. Images with known sizes, or an instant + hash scroll, would each fix it; the first needs the README's image + sizes at render time. - [ ] Run axe's `color-contrast` in the browser pass too, once the contrast item below is decided. Left out deliberately: it would be red today on the shortfalls already waiting on that decision. diff --git a/package.json b/package.json index 3967b35..8fe4da1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "a11y": "node scripts/check-a11y.mjs", "a11y:browser": "node scripts/check-a11y-browser.mjs", "a11y:firefox": "node scripts/check-a11y-browser.mjs --firefox", + "check:navigation": "node scripts/check-navigation.mjs", "vitals": "node scripts/measure-vitals.mjs", "font": "node scripts/build-font-subset.mjs", "font:check": "node scripts/build-font-subset.mjs --check" diff --git a/scripts/check-navigation.mjs b/scripts/check-navigation.mjs new file mode 100644 index 0000000..540e909 --- /dev/null +++ b/scripts/check-navigation.mjs @@ -0,0 +1,192 @@ +/** + * What a visitor's own navigation does to a project page, in a real browser. + * + * node .output/server/index.mjs & + * npm run check:navigation # Chromium + * npm run check:navigation -- --firefox # Firefox + * + * The about page folds its README, and none of what follows runs on the + * server, so neither `npm run check` nor axe can see it break. Two bugs lived + * here unseen: a page reached by Back came back folded, so the scroll position + * the router saved no longer existed and a reader 6,000 px down a README + * landed at the bottom of the folded page; and a link to a README heading + * left Firefox — Tor Browser, for the onion service — at the same place with + * the heading hidden, because only Chromium opens a closed `
` for a + * fragment by itself. `app/composables/useReadmeFold.ts` fixed both. This + * holds them fixed, on every about page whose README has a heading: + * + * - unfold, read down, go to the blog tab, Back: unfolded, same place + * - Forward and Back again: the same + * - reload: unfolded, same place + * - blog tab, then the about tab by its link: folded, at the top + * - a cold load of `about#`: unfolded, the heading at the top + * - a client navigation to `about#`: unfolded, the heading on screen + * - fold it, blog tab, Back: still folded — even with the fragment + * + * And, as in `check-a11y-browser.mjs`, any console error fails it. + */ + +import { sitePages } from './lib/pages.mjs' +import { startDriver } from './lib/browser.mjs' + +const args = process.argv.slice(2) +const BASE = (args.find((a) => !a.startsWith('--')) + ?? process.env.CHECK_BASE_URL + ?? 'http://127.0.0.1:3000').replace(/\/$/, '') +const ENGINE = args.includes('--firefox') ? 'firefox' : 'chromium' + +/** How far from the saved position a restored one may land, in CSS pixels. */ +const SLACK = 4 +/** Where a heading scrolled to may sit: its scroll margin clears the masthead. */ +const HEADING_BAND = [-2, 200] + +const ABOUT = /^\/projects\/([^/]+)\/about$/ +const slugs = (await sitePages(BASE)).map((p) => ABOUT.exec(p)?.[1]).filter(Boolean) +if (!slugs.length) throw new Error(`${BASE}/sitemap.xml lists no about pages`) + +const browser = await startDriver(ENGINE) +console.log(`Navigation on ${slugs.length} about pages in ${browser.name} (${browser.binary})\n`) + +const failures = [] +let checked = 0 + +try { + const page = await browser.open({ width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }) + const sleep = (ms) => page.evaluate(`new Promise((r) => setTimeout(r, ${ms}))`) + /** + * Wait for the router to arrive at `path`, then for the page to stop + * moving. The site scrolls smoothly (`scroll-behavior: smooth`), so a + * restored position or a heading is reached over a second or more, not + * at once: scrolling is done when `scrollY` has held still for 400 ms. + */ + const arrive = async (path) => { + await page.evaluate(`(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)) + const until = Date.now() + 8000 + while (location.pathname !== ${JSON.stringify(path)} && Date.now() < until) await wait(50) + await new Promise((r) => requestIdleCallback(() => r(), { timeout: 2000 })) + await wait(300) + let last = -1, still = 0 + while (still < 4 && Date.now() < until) { + await wait(100) + still = scrollY === last ? still + 1 : 0 + last = scrollY + } + })()`) + } + const click = (selector) => page.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`) + const state = (id) => page.evaluate(`({ + path: location.pathname, + y: Math.round(scrollY), + open: document.querySelector('details')?.open ?? null, + heading: ${id ? `Math.round(document.getElementById(${JSON.stringify(id)})?.getBoundingClientRect().top ?? NaN)` : 'null'}, + })`) + + for (const slug of slugs) { + const about = `/projects/${slug}/about` + const blog = `/projects/${slug}/blog` + const problems = [] + const expect = (what, ok, got) => { + checked++ + if (!ok) problems.push(`${what} — got ${JSON.stringify(got)}`) + } + + await page.goto('about:blank') + await page.goto(BASE + about) + await arrive(about) + page.takeErrors() + // A heading from the middle of the README: deep enough that folding + // hides it, and the kind of link a contents list makes. + const id = await page.evaluate(`(() => { + const ids = [...document.querySelectorAll('.readme [id]')].map((e) => e.id) + return ids[Math.floor(ids.length / 2)] ?? null + })()`) + if (!id) { + console.log(` - ${about}: no README heading, skipped`) + continue + } + + let s = await state() + expect('a first visit starts folded, at the top', s.open === false && s.y === 0, s) + + // Unfold and read down past where the folded page ends, so a page that + // came back folded could not land there. + const foldedBottom = await page.evaluate('document.documentElement.scrollHeight - innerHeight') + await click('details > summary') + await sleep(300) + const target = await page.evaluate(`Math.min(${foldedBottom} + 1500, document.documentElement.scrollHeight - innerHeight - 10)`) + await page.evaluate(`scrollTo({ top: ${target}, behavior: 'instant' })`) + // Where the reader is once the README's lazy images around them have + // loaded and moved things, not where they were sent. + await arrive(about) + const left = (await state()).y + const deep = left > foldedBottom + 100 + const near = (y) => !deep || Math.abs(y - left) <= SLACK + + await click(`a[href="${blog}"]`) + await arrive(blog) + await page.evaluate('history.back()') + await arrive(about) + s = await state() + expect(`Back reopens the README at y ${left}`, s.open === true && near(s.y), s) + + await page.evaluate('history.forward()') + await arrive(blog) + await page.evaluate('history.back()') + await arrive(about) + s = await state() + expect(`Forward and Back again, at y ${left}`, s.open === true && near(s.y), s) + + await page.reload() + await arrive(about) + s = await state() + expect(`a reload reopens it at y ${left}`, s.open === true && near(s.y), s) + + await click(`a[href="${blog}"]`) + await arrive(blog) + await click(`a[href="${about}"]`) + await arrive(about) + s = await state() + expect('a visit by link starts folded, at the top', s.open === false && s.y <= 1, s) + + const hash = `#${encodeURIComponent(id)}` + await page.goto('about:blank') + await page.goto(BASE + about + hash) + await arrive(about) + s = await state(id) + expect(`a cold load of ${hash} opens it at the heading`, s.open === true && s.heading >= HEADING_BAND[0] && s.heading <= HEADING_BAND[1], s) + + await click(`a[href="${blog}"]`) + await arrive(blog) + await page.evaluate(`document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$router.push(${JSON.stringify(about + hash)})`) + await arrive(about) + s = await state(id) + // On screen, not at the top: this one scrolls smoothly (Nuxt's hash + // behaviour is the stylesheet's), and README images above the heading + // load while the page passes them, so where it stops depends on them. + expect(`a client navigation to ${hash} opens it with the heading on screen`, s.open === true && s.heading >= HEADING_BAND[0] && s.heading <= 900 - 40, s) + + await click('details > summary') + await sleep(300) + await click(`a[href="${blog}"]`) + await arrive(blog) + await page.evaluate('history.back()') + await arrive(about) + s = await state() + expect('folded by the visitor, it stays folded on Back', s.open === false, s) + + for (const { text } of page.takeErrors()) problems.push(`console: ${text}`) + console.log(` ${problems.length ? '✗' : '·'} ${about}${deep ? '' : ' (README too short to test the scroll position)'}`) + for (const p of problems) console.error(` ${p}`) + failures.push(...problems.map((p) => `${about}: ${p}`)) + } +} +finally { + await browser.stop() +} + +if (failures.length) { + console.error(`\n${failures.length} failure(s) in ${browser.name}.`) + process.exit(1) +} +console.log(`\n✓ ${checked} navigation checks across ${slugs.length} about pages in ${browser.name}.`) diff --git a/scripts/lib/browser.mjs b/scripts/lib/browser.mjs index 7c0c7ed..deda1e2 100644 --- a/scripts/lib/browser.mjs +++ b/scripts/lib/browser.mjs @@ -1,7 +1,8 @@ /** * One page-driving interface over two engines: Chromium through `cdp.mjs`, - * Firefox through `bidi.mjs`. Only as much as `check-a11y-browser.mjs` needs — - * open a page at a size, load a URL, evaluate in it, and hear what it logged. + * Firefox through `bidi.mjs`. Only as much as `check-a11y-browser.mjs` and + * `check-navigation.mjs` need — open a page at a size, load or reload a URL, + * evaluate in it, and hear what it logged. * * const browser = await startDriver('firefox') * const page = await browser.open({ width: 390, height: 844, deviceScaleFactor: 3, mobile: true }) @@ -61,6 +62,11 @@ async function chromiumDriver() { if (nav.errorText) throw new Error(`${url}: ${nav.errorText}`) await loaded }, + async reload() { + const loaded = cdp.once('Page.loadEventFired', sessionId) + await cdp.send('Page.reload', {}, sessionId) + await loaded + }, async run(source) { const { exceptionDetails } = await cdp.send('Runtime.evaluate', { expression: source }, sessionId) if (exceptionDetails) throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text) @@ -111,6 +117,9 @@ async function firefoxDriver() { async goto(url) { await bidi.send('browsingContext.navigate', { context, url, wait: 'complete' }) }, + async reload() { + await bidi.send('browsingContext.reload', { context, wait: 'complete' }) + }, async run(source) { const r = await bidi.send('script.evaluate', { expression: source, target: { context }, awaitPromise: false, resultOwnership: 'none', From 44eea71046a66ae78e9156fe717f2a1ed4fc6f50 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Fri, 9 Oct 2026 22:34:07 -0500 Subject: [PATCH 07/17] fix(a11y): no copy buttons that do nothing without script Every page is rendered on the server and reads fine with script off, which is how Tor Browser's "Safest" level serves the onion service. The [copy] buttons were the exception: shown, and inert when pressed. app/components/CodeLine.vue, CodeBlock.vue: the buttons carry needs-script. app/assets/css/main.css: @media (scripting: none) hides .needs-script, by the browser's own answer rather than by script, so nothing arrives late for a visitor with script. Checked in Firefox with javascript.enabled off and on, and in Chromium with script execution disabled over CDP. scripts/check-site.mjs: a