Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 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 — 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>/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 (put back in one jump, as a browser restores a page — the site's smooth scrolling is for following a link, not for returning), 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 @@ -145,9 +145,11 @@ service was last reached over Tor, from the gateway's own ten-minute self-fetch.
Thirty minutes without one turns `/healthz`'s `status` to `degraded` (still a
200: the clearnet site is serving). The server
also logs one JSON object per request on stdout, failed ones included, each
with the client's `ip`, `ua` and `ref` — `docker logs basicautomation-site | jq
with the client's `ua` and `ref`, the `ip` Caddy forwarded (behind Docker's port
proxy that is the bridge gateway) and `cfIp`, the visitor as Cloudflare names
them — a header, not a verified hop. `docker logs basicautomation-site | jq
'select(.status >= 400)'`, or `select(.via == "onion")` for the visits that
came over Tor — and warns once an hour for
came over Tor, which carry no address at all — and warns once an hour for
as long as it has been answering from the snapshot, once when the GitHub
quota runs out (`event: "upstream.rate_limited"`), and once an hour while the
onion service is unreachable (`onion.unreachable`, then `onion.recovered`). Every other line the app
Expand Down Expand Up @@ -246,7 +248,9 @@ 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
(`--firefox`) Firefox. A reload it fails prints a frame-by-frame trace of
the page's first seconds — every scroll call, scroll position and page height —
so a failure on CI says where the pixels went. 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
67 changes: 58 additions & 9 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,30 @@ record. There is no run log.
now take those fields from one `requester()`
(`server/plugins/request-log.ts`); still one line per request, HEAD
still logged as HEAD, onion requests still `ip: null`.
- [x] …and it names a visitor again. 5,548 of 5,555 lines in 21 hours of
live log (2026-10-10) carried `ip: 172.18.0.1`: Caddy trusts no proxy,
so its `x-forwarded-for` is its own peer, and that peer is Docker's
bridge gateway. Each line now also carries `cfIp`, from Cloudflare's
`cf-connecting-ip`, which Caddy passes through
(`shared/log/client.ts`). Logged under its own name because nothing on
the way in checks that a request came through Cloudflare; nothing in
the site decides anything from it. Over Tor, neither address.
<https://developers.cloudflare.com/fundamentals/reference/http-headers/>
- [ ] A verified client address at the edge — **owner's stack**. Caddy sees
every clearnet request from `172.18.0.1`, so neither its
`{client_ip}` nor the `x-forwarded-for` it hands the site is the
visitor, and Caddy cannot tell Cloudflare from anyone else who reaches
the origin. Docker's docs say a published port's IPv6 traffic to an
IPv4-only bridge network goes through the userland proxy, which
connects from the gateway; this host has a global IPv6 address and
`caddy-shared-network` is IPv4-only, which would fit, but whether
Cloudflare reaches the origin over IPv6 is in the Cloudflare
dashboard, not here. With the real peer in hand, Caddy's
`trusted_proxies` set to Cloudflare's ranges plus `client_ip_headers
CF-Connecting-IP` would make `x-forwarded-for` the verified visitor and
`cfIp` redundant.
<https://docs.docker.com/engine/network/port-publishing/>
<https://caddyserver.com/docs/caddyfile/options>
- [x] `/api/posts?project=<unknown>` is a 404, like `/api/projects/<unknown>`,
not an empty list.
- [x] Answer HEAD wherever GET is answered. Nitro routes by filename suffix, so
Expand Down Expand Up @@ -462,15 +486,19 @@ record. There is no run log.
output. Nuxt's own `scrollBehavior` governs all of it, so the new
defaults change nothing a visitor sees here.
<https://github.com/vuejs/router/releases/tag/v5.4.0>
- [ ] Nuxt 4.6.1 (published 2026-10-09 22:59 UTC), once it has soaked like
4.6.0 did. A patch release: the fixes are mostly dev-server and typing
(`Serialize` types aligned with JSON, `$fetch` typed as `TypedFetch`,
`useId` hydration keeping `onServerPrefetch`), plus a Nitro performance
change that builds the portable event's web view lazily. The first issue
filed against it, nuxt/nuxt#36529 (an aborted navigation to an unmatched
route renders a 404), reproduces on 3.x too, so it is not a 4.6.1
regression. Land it with the full gates plus `check:navigation` in both
engines. <https://github.com/nuxt/nuxt/releases/tag/v4.6.1>
- [x] Nuxt 4.6.1 (published 2026-10-09 22:59 UTC), landed 2026-10-10 after
27 hours with two issues filed against it, neither touching this site:
nuxt/nuxt#36529 (an aborted navigation to an unmatched route renders a
404) reproduces on 3.x too, and #36530 is the `cloudflare_module`
preset. A patch release: mostly dev-server and typing fixes, plus a
Nitro change that builds the portable event's web view lazily. Taken
with vue-tsc 3.3.13 (setup macros without static imports) and
`@types/node` 26.6.5; the lockfile also moved cssnano 9.3 → 9.6, and the
built CSS came out byte-identical (same hash). Typecheck, 273 tests,
build, `check`, `a11y`, `a11y:browser`, `a11y:firefox`,
`check:navigation` in both engines (144 each), `audit:runtime`; all 42
routes answer as on 4.6.0, every page body the same size.
<https://github.com/nuxt/nuxt/releases/tag/v4.6.1>
<https://github.com/nuxt/nuxt/issues/36529>
- [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
Expand Down Expand Up @@ -911,6 +939,27 @@ record. There is no run log.
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.
- [x] A restored scroll position is a jump, not an animation. `main` went red
after #20 on `check:navigation`: a reload of onyums' README at 390 px
landed 7 px past the reader's place, and this branch's first CI run
21 px past on Skidbladnir's. The site's `scroll-behavior: smooth`
applied to vue-router's own restore, a bare `scrollTo({ left, top })`
on Back, Forward and reload, so each was an animation from the top,
racing the README fold's instant restore — on a fast machine it lost
by nothing, on a slow one it did not. With the CPU slowed 6× all 16
reload cases failed (7–34 px past). `app/router.options.ts` keeps
Nuxt's `scrollBehavior` and marks a saved position `instant`, as a
browser's own restoration is; the same 6× run passes all 144 with the
slack at 1 px. A failed reload check now prints a frame-by-frame trace
of the page's first seconds, which is how this was found.
- [x] The browser accessibility pass ran axe on a page still scrolling. The
site scrolls smoothly, so `check-a11y-browser.mjs`'s bare `scrollTo`
calls only started animations: its reading pass stalled about a screen
down, and its return to the top was still ~1,700 px out when axe ran,
so links passing under the fixed masthead failed `target-size` as
"partially obscured" — /status in Firefox, one run in four
(2026-10-10). Every jump is `instant` now, and the pass throws if the
page is not at the top when axe starts.
- [x] …and a README opened before the app hydrates stays open. Binding the
fold to `open` made hydration write the bound `false` over the element,
so a reader who opened it in the first moments — on a slow line, Tor
Expand Down
14 changes: 7 additions & 7 deletions app/composables/useReadmeFold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@ export function useReadmeFold(readmeHtml: Readonly<Ref<string | null>>) {
// 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.
// A reload: the browser, and vue-router from `history.state.scroll`, restore the scroll
// position against the page as the server sent it, folded, so a visitor
// reading the README lands short of where they were. The fold's own state
// can't ride along in the entry — Chromium drops a `replaceState` made
// while the page unloads — so the position 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 {
Expand Down
32 changes: 32 additions & 0 deletions app/router.options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { RouterConfig } from 'nuxt/schema'
// Nuxt's own router options, which this file would otherwise replace. Not an
// export of the package, so it is reached by path: if Nuxt moves it, the
// build fails here rather than the site quietly losing Nuxt's scrolling.
import nuxt from '../node_modules/nuxt/dist/pages/runtime/router.options.js'

/**
* A restored scroll position is jumped to, not animated to.
*
* The site scrolls smoothly (`scroll-behavior: smooth` in `main.css`), which
* is meant for a reader following a link down the page. vue-router restores
* a saved position with a bare `scrollTo`, so the CSS turned every Back,
* Forward and reload into an animation from wherever the page started —
* traced on a reload of Skidbladnir's about page as `scrollTo({ left: 0, top:
* 6537 })`. On an about page that animation races the README fold's own
* restore (`useReadmeFold`), and on a slow machine it ends off the reader's
* place: main's CI landed 7 and 21 px past it on a reload, and with the CPU
* slowed 6× here, every one of 16 reloads landed 7–34 px past.
*
* Browsers put a reader back where they were in one jump, so this does too.
* Nuxt's `scrollBehavior` — which waits for the page to finish loading and
* handles hashes — is kept and only told to jump. It hands a saved position
* back as the very object it was given, so that is what is matched. (A
* plugin can't do this: Nuxt's router plugin sets `router.options.scrollBehavior`
* back to these options once the first navigation is done.)
*/
export default <RouterConfig>{
async scrollBehavior(to, from, saved) {
const position = await nuxt.scrollBehavior?.(to, from, saved)
return position && saved && position === saved ? { ...saved, behavior: 'instant' } : position
},
}
80 changes: 40 additions & 40 deletions data/projects.generated.json

Large diffs are not rendered by default.

Loading
Loading