Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8f271bd
chore(deps): vue-router 5.4.0
physics515 Oct 10, 2026
a843361
fix(about): Back returns a reader to the README they were reading
physics515 Oct 10, 2026
800df87
fix(about): a link to a README heading lands on it in Firefox
physics515 Oct 10, 2026
4033b44
feat(checks): run the browser accessibility pass in Firefox too
physics515 Oct 10, 2026
b81df31
fix(about): a link to a README heading is not sent to an old reading …
physics515 Oct 10, 2026
dc48fd3
feat(checks): npm run check:navigation, in both engines, in CI
physics515 Oct 10, 2026
44eea71
fix(a11y): no copy buttons that do nothing without script
physics515 Oct 10, 2026
93330fe
feat(checks): the navigation check at phone width too
physics515 Oct 10, 2026
eeddc84
docs(roadmap): tonight's research and the open questions it left
physics515 Oct 10, 2026
08d8391
fix(about): a README opened before hydration stays open
physics515 Oct 10, 2026
314c7b3
fix(checks): headless browsers no longer leave their profiles in /tmp
physics515 Oct 10, 2026
12bb426
feat(readme): README images reserve their space
physics515 Oct 10, 2026
0196dcd
test(checks): a client navigation to a README heading lands on it
physics515 Oct 10, 2026
776937d
perf(readme): image probes give up after 2 s on the server
physics515 Oct 10, 2026
de580eb
chore(data): refresh the fallback snapshot
physics515 Oct 10, 2026
3191c14
docs(roadmap): the vue-router item counts 42 routes, not 43
physics515 Oct 10, 2026
616a9ac
fix(a11y): the status page's endpoint links are big enough to tap
physics515 Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ 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
# 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
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ it is flat.
| --- | --- |
| `/` | The pitch, live project stats, and the catalogue |
| `/projects` | Every public project |
| `/projects/<slug>/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/<slug>` redirects here |
| `/projects/<slug>/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, and its images arrive with their sizes, so nothing jumps as they load. A desktop app's page also gets a download section: a direct link per platform to the newest release's installers. `/projects/<slug>` redirects here |
| `/projects/<slug>/blog` | That project's news, and `/projects/<slug>/blog/<post>` 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) |
Expand Down Expand Up @@ -224,6 +224,8 @@ 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 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
```
Expand All @@ -237,7 +239,14 @@ 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. `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, at both widths, 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`.

Expand Down
198 changes: 192 additions & 6 deletions ROADMAP.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ body {
/* Nothing on this site is rounded. */
*, *::before, *::after { border-radius: 0 !important; }

/* A control only script can make do anything — the copy buttons. Every page
is rendered on the server and reads fine without script, which is how Tor
Browser's "Safest" level serves this site's onion visitors; a button that
is pressed and does nothing is the one thing that reads worse. Hidden by
the browser's own answer, not by script, so a visitor with script never
sees it arrive late. <https://drafts.csswg.org/mediaqueries-5/#scripting> */
@media (scripting: none) {
.needs-script { display: none !important; }
}

@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
Expand Down
2 changes: 1 addition & 1 deletion app/components/CodeBlock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const target = useTemplateRef<HTMLElement>('target')
<span class="truncate"># {{ label }}</span>
<UButton
:ui="{ base: 'bg-transparent! p-0! ring-0! font-mono text-xs cursor-pointer hover:bg-transparent!' }"
class="ml-auto shrink-0 text-pn-muted hover:text-pn-fg-bright"
class="needs-script ml-auto shrink-0 text-pn-muted hover:text-pn-fg-bright"
:aria-label="`Copy ${label}`"
@click="copy(code, target)"
>
Expand Down
2 changes: 1 addition & 1 deletion app/components/CodeLine.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const target = useTemplateRef<HTMLElement>('target')
app.config.ts strips it back to a bracketed mono label. -->
<UButton
:ui="{ base: 'bg-transparent! p-0! ring-0! font-mono text-xs cursor-pointer hover:bg-transparent!' }"
class="shrink-0 text-pn-muted hover:text-pn-fg-bright"
class="needs-script shrink-0 text-pn-muted hover:text-pn-fg-bright"
:aria-label="`Copy to clipboard: ${code}`"
@click="copy(code, target)"
>
Expand Down
124 changes: 124 additions & 0 deletions app/composables/useReadmeFold.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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, and on arrival from a link elsewhere, because only Chromium
* opens a closed `<details>` 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 markup as it stands, so
* `open` starts from the document (see below). `onMounted` catches up — that
* is a reload, where the entry's state survives.
*/
export function useReadmeFold(readmeHtml: Readonly<Ref<string | null>>) {
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)
}

// Hydrating, the fold is whatever the server's markup is now: folded, unless
// the reader has already opened it (a slow line — Tor, say — leaves time to)
// or the browser opened it for a fragment. Vue writes the bound value over
// the element as it hydrates, so starting from `false` shut it under them.
const open = ref(nuxtApp.isHydrating ? foldInDocument() : 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) {
// Opened before hydration: no `toggle` reached `onToggle`, so record it.
if (history.state?.[KEY] !== true) history.replaceState({ ...history.state, [KEY]: true }, '')
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 {
// 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()
if (Number.isFinite(top)) window.scrollTo({ top, behavior: 'instant' })
else heading?.scrollIntoView({ block: 'start', behavior: 'instant' })
})
onBeforeUnmount(() => window.removeEventListener('pagehide', remember))

/** The `<details>`'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 }
}

/** 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
}

/** 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'
}

/** Whether the README's fold is open in the document as it stands. */
function foldInDocument(): boolean {
if (!import.meta.client) return false
return document.querySelector('.readme')?.closest('details')?.open === true
}
3 changes: 2 additions & 1 deletion app/pages/projects/[slug]/about.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -545,7 +546,7 @@ useSeoMeta({
<!-- Folded away: the pitch is above, and the full documentation is long. -->
<section v-if="hasReadme">
<TermRule label="documentation" />
<details class="mt-7 group">
<details class="mt-7 group" :open="readmeFold.open.value" @toggle="readmeFold.onToggle">
<summary
class="cursor-pointer list-none text-sm text-pn-dim transition-colors hover:text-pn-fg-bright"
>
Expand Down
8 changes: 6 additions & 2 deletions app/pages/status.vue
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,14 @@ useSeoMeta({
<!-- ── Endpoints ────────────────────────────────────────────────────── -->
<section class="mb-24">
<TermRule label="endpoints" />
<ul class="mt-8 space-y-2 text-xs">
<!-- Each link is its own 24px row, as in the footer, rather than 15px of
text with a gap: WCAG 2.2's minimum target size. Chromium's layout
passed it by a hair; Firefox's did not (`npm run a11y:firefox`, on
its first CI run). Same pitch as before. -->
<ul class="mt-7 text-xs">
<li v-for="path in ['/healthz', '/api/projects', '/sitemap.xml', '/robots.txt', '/releases.xml', '/news.xml']" :key="path">
<!-- Server routes, not pages: plain anchors, nothing for the router. -->
<a :href="path" class="text-pn-dim transition-colors hover:text-pn-fg-bright">{{ path }}</a>
<a :href="path" class="inline-block py-1 text-pn-dim transition-colors hover:text-pn-fg-bright">{{ path }}</a>
</li>
</ul>
</section>
Expand Down
112 changes: 56 additions & 56 deletions data/projects.generated.json

Large diffs are not rendered by default.

Loading
Loading