- vite assetsInlineLimit 0: fonts-work.css 42.8 KB -> 5.9 KB - Ghost posts fetched with limit=100&page=N instead of limit=all - 404 title uses site.name; drop the duplicate js-class script on /everything/ - deploy/compose.yml: LISTENBRAINZ_USER - HANDOFF and TODO: the site is live and the sources ran Audit findings 2, 6, 7, 18, 19, 21 (docs/audits/2026-11-11-audit.md). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
28 KiB
Decisions
Append-only. Never edit or delete an entry — supersede it with a new one that links back.
One entry per decision a future maintainer would otherwise have to reverse-engineer.
No index: grep -n '^## ' docs/DECISIONS.md lists every entry (date, title, line number).
2026-11-10 — Ghost HTML is sanitized with Bun's HTMLRewriter, outside src/core
Status: accepted (supersedes the regex safePostHtml in src/core/post-html.ts from the final-review fix wave)
Context: The regex sanitizer had known bypasses with hand-written HTML (<svg/onload>, comments hiding
tags, javascript: links, entity-encoded off-host URLs). The parked TODO suggested htmlparser2 or parse5.
Decision: src/site/post-html.ts runs an element and attribute allowlist on HTMLRewriter (lol-html, a
real HTML5 tokenizer built into Bun). URL attributes are entity-decoded before checking, because
getAttribute returns raw values. It runs only at build time under Bun, so it left the browser-shared
src/core; lazyImages (Markdown bodies) stays there.
Alternatives: htmlparser2 or parse5 plus our own allowlist (a new runtime dependency, which needs the
owner's approval); DOMPurify with a DOM shim (heavier, two dependencies).
Consequences: The build depends on Bun's HTMLRewriter (already the only runtime). SVG and MathML in
posts are dropped, along with Ghost's own audio/toggle card icons, which only work with Ghost's card JS.
2026-11-09 — Follow commission-compass's stack and conventions
Status: accepted
Context: The owner pointed at his sister repo commission-compass (same machine, same Gitea). It
already runs Bun 1.3 + Astro 7 (static) + Docker, with the same agent-docs layout.
Decision: Reuse its choices: Astro is the site framework, with an explicit exception to bun init's
"don't use Vite" advice; Conventional Commits; feat|fix|chore/<slug> branches merged to main locally
only on owner approval, no pull requests; plans removed before merge; CI in .gitea/workflows/; image
pushed to the Gitea registry; deployed as one container behind nginx-proxy through the homelab Ansible
config repo, secrets from OpenBao (see its docs/DEPLOY.md).
Alternatives: A hand-rolled static build on Bun.serve() + HTML imports (more work; diverges from the sister repo).
Consequences: Resolves the open "Astro vs Bun fullstack" question in the stack entry below.
Its separate content repo with a polling, self-rebuilding server is a candidate pattern for posts and Trakt/Hardcover refreshes, to be decided in the spec.
2026-11-09 — Stack: Bun + TypeScript + React, with Astro
Status: accepted
Context: The owner self-hosts and prefers Bun, TypeScript and React. The site must show content on
first paint (no loading screens) and be easy to post to (1–3 personal items a month).
Decision: Astro renders static HTML at build time; React is used only for interactive islands
(⌘K palette, AI filter, side sheet, players, comic reader, showcases). Content is Markdown/MDX with
frontmatter, posted by git push. Trakt and Hardcover are fetched at build time with scheduled
rebuilds. Output is static files in a small container, self-hosted.
Alternatives: A plain React SPA (blank first paint, against the no-loading principle). Bun's own
fullstack approach (Bun.serve() + HTML imports, which bun init's CLAUDE.md recommends over Vite)
was not yet weighed against Astro, which builds on Vite. See TODO.
Consequences: Astro runs under Bun but uses Vite internally, so it diverges from bun init's frontend advice.
2026-11-09 — Personal space is the "rich wall" with a half-yearly issue
Status: accepted
Context: The personal side hosts things the owner loves and thinks about (comics, games, films,
songs, drawings, essays, life story, Trakt/Hardcover), at 1–3 items a month.
Decision: mockups/personal-r2-b.html, plus a half-yearly zine/newspaper roundup inspired by personal-r2-c.
Alternatives: shelves (r2-a), seasonal zine as the front page (r2-c), personal homepage (r2-d),
and round 1 (P1–P5, too band-centric). Verdicts in docs/inspiration.md.
Consequences: Cards are typed by content kind; the design must look full with 10–20 items.
2026-11-09 — Work space is the dark showcase (tech-r3-c)
Status: accepted
Context: Four tech rounds. Paper metaphors felt boring; the sleek round 4 felt too modern.
Decision: mockups/tech-r3-c.html as it is. tech-r4-a/b are kept as reference only.
Alternatives: T1–T5, tech-r2-a…d, tech-r3-a/b, tech-r4-a/b.
Consequences: The start page's left gate (light grid paper) and tech-r3-c (dark) don't yet match; open question.
2026-11-09 — Two gates, not three spaces
Status: accepted
Context: An early reading split the site into "by hand before / with AI now / as a person".
Decision: Two gates, work and personal (mockups/nav-d.html), plus a small link to an
"everything at once" garden. The old 2021 site stays as an archive, reachable only via the
"by hand only" AI preference, not promoted. The AI level is a filter, not a section.
Alternatives: three doors (nav-a), a lens switch (nav-b), one garden as the home page (nav-c).
Consequences: Every project needs an AI level (ai-made / some-ai / by-hand / unknown).
2026-11-09 — Adopt AGENTS.md as single source of truth
Status: accepted
Context: Agents had no durable project context and no consistent workflow between sessions.
Decision: AGENTS.md is canonical; CLAUDE.md/GEMINI.md are pointers. Specs are permanent in
docs/specs/. Plans live in docs/plans/ only while work is in progress and are removed before the
merge request. Both override the superpowers default paths. bun init's generated CLAUDE.md rules
(use Bun over Node/npm/jest, bunx, no dotenv) were merged into AGENTS.md; its Bun.serve/HTML-import
frontend advice was not, pending the stack question above.
Alternatives: Per-tool rule files (drift, synchronisation debt); no rules file (agents re-explore
the repo every session — measurably slower and more tokens).
Consequences: Rule changes happen in one file; pointer files must stay thin. Plans are working
artefacts: kept after merge they go stale and contradict the code, so the spec is the record.
2026-11-09 — Runtime: one build fetches sources; timed rebuilds refresh them
Status: accepted
Context: Ghost, Trakt and Hardcover change without a content commit, but the site is static.
Decision: buildSite calls loadSources once and writes <cacheDir>/sources.json (read by getSiteData, which never
fetches) and <out>/sources-status.json. In serve mode the cache is <DATA_DIR>/sources, so an outage falls back to
the last good data. refresh rebuilds the same commit once the live build is SOURCES_INTERVAL (default 6h) old; such
rebuilds are never recorded as a broken build. /status carries per-source status. The build child does not get
CONTENT_REPO_TOKEN; source credentials pass through. getSiteData throws without SITE_CONTENT_DIR.
Alternatives: fetch inside Astro pages (network during render, hard to test); client-side fetches (breaks no-JS pages).
Consequences: Source freshness is bounded by SOURCES_INTERVAL; a rebuild costs one full Astro build.
2026-11-09 — Work space: sheet from inline templates, related writing by Ghost tag
Status: accepted
Context: The side sheet must work without fetching, and project pages must be shareable.
Decision: /work/ renders every project's story into <template id="p-<id>">; ProjectSheet clones it,
pushes /work/<id>/ (the static project page) and goes back on close. Its context for ←/→ is the visible links
of the [data-sheet-ctx] block it was opened from. The sheet hydrates client:idle because it is off-screen
until opened (client:visible would never fire). "Show anyway" on a filtered featured panel is a tiny inline
script that sets data-show. A Ghost post tagged with a project's id is listed as that project's writing.
The featured layout (wide / halves / wide reversed) follows the number of panels (max 4).
Alternatives: fetch the project page into the sheet (network on click); a related_posts field in the content
contract (another place to keep in sync).
Consequences: /work/ carries all project stories in its HTML; templates never contain islands, so the
sheet shows a small drawing instead of the interactive showcase.
2026-11-09 — Filter boxes, per-preference counts, defused Ghost embeds
Status: accepted
Context: Under the AI filter, writing lists could become empty frames with counts that included hidden posts;
Ghost posts can contain third-party iframes and eager images.
Decision: A .filter-box (base.css) disappears via :has() when the active preference hides every
[data-ai] link inside it; counts are rendered once per preference (.pc[data-for]) and CSS shows the active
one. The Now strip is a flex row so it closes up around a hidden cell. Post HTML passes through
safePostHtml (src/core/post-html.ts): images get loading="lazy" decoding="async", iframes become a link
to their source ("Open the embedded video/content ↗"). Images are not mirrored.
Alternatives: JavaScript that recounts and hides (breaks before hydration); stripping embeds silently.
Consequences: Relies on :has() (all current browsers); without it, the boxes simply stay visible.
2026-11-09 — Personal wall: lead years, open in place, masonry, click-to-load media
Status: accepted
Context: The owner posts 1–3 things a month; the wall must look curated with few items, work without
JavaScript, and load nothing third-party until the visitor acts.
Decision: Items and off-the-clock Ghost posts become one Entry (components/personal/entries.ts). The wall
holds the newest years until it has at least 6 entries (LEAD_MIN); earlier years fold into one closed
<details> each with small cards that link to item pages. Card width comes from kind × size (a span table).
Each wall card carries its teaser and its full view; a small Astro script adds .open, pushes
/off-the-clock/<id>/ and goes back on Esc/close, and the item page renders the same card already open. The masonry
(6px grid rows) is switched on by a synchronous inline script with a ResizeObserver, so it is in place before first
paint and without JS the wall is a plain grid. Chips, the custom audio controls and the comic reader's prev/next bar are hidden
by html:not(.js) CSS (class set inline by the layout); no-JS visitors get the full wall, native <audio>, and a
<noscript> row linking every comic page.
EmbedOnClick only exists for links on YouTube, Vimeo, Spotify or Bandcamp (EmbeddedPlayer URLs only, a page URL has
no track id); the poster is a real link out until hydrated. Live Hardcover/Trakt cards join the wall by their newest
rating's date, with typeset covers (no third-party images). Ratings show on one 5-star scale: items and Hardcover are
0–5 (the contract validates items to 0–5), Trakt is divided by 2; halves rounded.
Alternatives: calendar-year split (the wall would be empty every January); CSS columns (breaks newest-first order);
fetching item pages into the wall (network on click).
Consequences: /off-the-clock/ carries the full text of the lead years' entries; older years cost only a card each.
2026-11-09 — Issues, /everything/ and /simple/ designed from reference mockups
Status: accepted
Context: Spec §7.4, §8, §9 leave the issue page and /everything/ without an approved mockup; the builder designs
them from personal-r2-c (zine), nav-c (garden) and tech-3 (split page).
Decision: Issue pages use the personal theme and reuse the wall's Card in wall mode (teaser, title links to the
item page, a stretched link makes the whole card clickable since no open-in-place script runs there). Entries pack into
rows of 6 columns in reading order (components/issues/issue.ts): an entry that does not fit takes the rest of the row
if that is roomy enough, otherwise the previous one stretches, so rows have no holes and page numbers stay in order;
size l is a double page. The running issue shows "still filling up · closes " and a dashed blank
page; an empty one shows only the nameplate and a line. The cover line is generated from titles.
/everything/ uses the neutral theme retuned per page (Base page prop → data-page): a plum-tinted warm dark
between the two spaces, work's orange and the personal amber marking sides, and each row set in its side's type (Plex
Sans for work, Fraunces for off the clock). Filters are an island over static rows (data-side|kind|level, data-ai
only where there is a level), hidden without JS via html:not(.js). /simple/ is the tech-3 split, static; its
pointer-following dot grid became a fixed faint field. Fraunces joined the neutral font set (only downloaded where used).
A content item may not be called issues (validation error); an off-the-clock Ghost post with that slug is left out of
personal and /everything/, with a warning in the build log, rather than failing the build over third-party data.
Alternatives: dense grid flow (reorders pages); a new simple theme in Base (more font plumbing for one page);
failing the build on a Ghost slug clash (an outside editor could break deploys).
Consequences: issue pages ship the same card markup and islands as the wall for their entries.
2026-11-09 — Accessibility, motion and performance pass
Status: accepted
Context: Task 12 and deferred review items: headings, focus return, scroll lock, live regions, hover gating,
blocked storage, favicon, and Lighthouse mobile ≥ 90.
Decision: The start page's <h1> is the person's name, visually hidden (.sr-only), because the visible name is
split across the seam into two aria-hidden halves; "Work" and "Off the clock" are <h2>. Section headings in the
Now strips and project bodies moved up a level so no level is skipped (styles unchanged). /work/ sections are
computed once (workSections in components/work/sections.ts) so the top bar and "full history ↓" never link to a
missing #all / #cv. Storage goes through browserStorage(), which survives a throwing localStorage getter.
The gate's grain is an inline fixed-size SVG instead of a CSS data-URI background: as a background it was the mobile
LCP "image" and Lighthouse's simulation swung between 89 and 98; as inline SVG the LCP is the heading (100).
Work pages preload Geist Sans 400/600 and Geist Mono 400 (font-swap CLS 0.15 → 0). Favicon: public/favicon.svg,
an "LW" split mark in the gate's two palettes. Low-contrast tokens from the mockups are left as approved (TODO).
Alternatives: a visible name <h1> (would change the seam design); dropping the grain; font-display: optional
(fonts could be skipped on a first visit).
Consequences: e2e/a11y.spec.ts guards reduced motion, Tab reach and alt; the e2e web server builds with
SOURCES_FIXTURES=fixtures/sources.
2026-11-09 — Content contract: optional years, project images, honest 2021 summary
Status: accepted
Context: The 13 old projects had years: '–', a visible placeholder; the spec (§6.1) wants a static image
or a drawn frame for projects without a showcase, and the contract had no field for it. The old portfolio
entry said "the portfolio you are viewing right now", false on the new site.
Decision: years is optional (validator and rendering skip it when absent). Projects may carry image
(a file under media/ plus alt text); the old projects use their 2021 images. The 2021 portfolio project
reads "The 2021 version of this portfolio. Features data driven approach to loading projects.", using only
facts from its info.json. Old project dates stay conventional until the owner confirms them (TODO).
Alternatives: keeping – (a placeholder visitors can see); inventing years.
Consequences: Content without years or image is valid; the go-live list in docs/TODO.md covers the rest.
2026-11-09 — Tests are hermetic: SOURCES_FIXTURES and a separate content repo
Status: accepted
Context: Tests and e2e must never call Ghost, Trakt, Hardcover or Gitea, and the app repo must not hold
real content (it would couple site releases to writing).
Decision: Real content lives in portfolio-next-content (starter content pushed there with drafts for
anything unconfirmed). This repo holds only synthetic fixtures/ (content, sparse, sources). Setting
SOURCES_FIXTURES=<dir> makes loadSources read the fixture JSON instead of fetching (status ok); unit tests,
the e2e web server and scripts/smoke-test.sh all use it. Ratings from Trakt (1–10) and Hardcover (0–5) are
normalised at render time, not in the source modules. Source modules were written from documented API field
names and are unverified against the live services (TODO).
Alternatives: recorded HTTP mocks per test (brittle); fixture content in the same repo as real content.
Consequences: A passing suite says nothing about live API shapes; the first real build shows any mismatch on /status.
2026-11-09 — Docs are batched; the plan is removed after the branch review
Status: accepted
Context: Per-task ARCHITECTURE updates were deferred to the last task; the spec's implemented status and
the git rm of docs/plans/001-site.md wait for the whole-branch review.
Decision: docs/ARCHITECTURE.md, AGENTS.md, README.md, TODO.md and these entries were brought up to date in
one docs commit; the controller flips the spec status and removes the plan after the final review. Subagent
commits carry their own model in Co-Authored-By trailers. Test imports use explicit .ts extensions
(nodenext with allowImportingTsExtensions). This file is appended at the end, oldest first.
Alternatives: updating ARCHITECTURE every task (churn while the shape was moving).
Consequences: History between the first task and this commit may show stale docs.
2026-11-09 — Ghost post HTML is an allowlist transform
Status: accepted
Context: Off-the-clock post bodies are inlined into wall cards, so one Twitter embed ran third-party JS on page
load, and external images or autoplay video loaded without the visitor acting (spec: nothing third-party first).
Decision: safePostHtml(html, { allowedImageHost }) drops script, style, link, object and embed with
their contents, strips on* and autoplay, gives audio/video preload="none", keeps images on GHOST_URL's
host (or relative) lazily, and turns any other image into "View image on ↗". Inside an existing link (a
bookmark card) an off-host image becomes its alt text, so links never nest. Off-host poster, srcset and
style="…url()" attributes are dropped. It runs once, in loadSiteData, with the host from GHOST_URL.
Alternatives: a sanitiser dependency (a new runtime dependency for markup we control); mirroring images.
Consequences: Posts lose inline images from other hosts (they become links). Without GHOST_URL every absolute
image becomes a link.
2026-11-09 — The personal side says what the AI filter hides; "show all" is per view
Status: accepted
Context: Under "by hand only" own-work cards vanished from the wall, issues and start page with no note, and
the wall summary still counted them. The work list's "show all" reset the site-wide preference.
Decision: FilterNote.astro + src/site/scripts/filter-note.ts: a note rendered hidden, shown by JavaScript
with "N hidden by your AI filter (…). show all" when N > 0 (N counted from [data-ai] elements in a scope).
"show all" sets data-show on the hidden elements of that page only; the preference stays. The wall summary is
recounted from the visible cards. The work list's "show all" does the same (rows get data-show; a new
preference applies afresh). The start page's collage items and "Latest" link carry data-ai when they have a level.
Alternatives: resetting the preference (surprising: it changes every other page); a server-rendered count
(wrong without knowing the preference).
Consequences: Without JavaScript there is no note (and nothing is hidden, since data-pref is not set).
2026-11-10 — Items carry a list of interests; content contract 2
Status: accepted
Context: Spec 002. Some shares belong to two interests (a musical film; the band, which is music and made by the
owner). An item had exactly one interest, so it could only show under one chip.
Decision: interest takes one id or a list; the loader normalises to Item.interests (primary first) and keeps
interest = interests[0] for code that needs one (e.g. the live cards' interest). Wall cards carry only
data-interests; data-interest is gone, so there is one attribute to filter on. The meta line names the extra
interests. The content repo declares contract: 2 when it uses a list. Deploy order: app first, then content
(docs/DEPLOY.md).
Alternatives: keeping both attributes (two sources of truth); accepting lists under contract 1 (an older image
would fail on the list with an unclear "interest required" error instead of "unsupported contract 2").
Consequences: A two-interest card is counted under both chips and once under "everything".
2026-11-10 — Spec 002 card designs: soundtrack tray, band photo, whole cover
Status: accepted
Context: Two mockup rounds on the real CSS (docs/inspiration.md, "Spec 002 card mockups").
Decision: A film that is also music takes its soundtrack from the first url in media (alt = title, second image
= album art) and shows it as a dark tray across the card's foot (mockup C); Spotify plays in its 152 px player after a
click. A music item's first image replaces the stripes in every state (D). A comic with exactly one image shows it
whole as a 2:3 ink-framed panel in a 3-column card (H; the owner accepted F, G or H). credit shows as "by …" on
comics and songs.
Alternatives: B (record behind the poster) and G (framed cover beside the text), both kept in docs/TODO.md;
a new soundtrack field (unneeded: media already holds urls and alt text).
Consequences: No new content fields. A trailer URL on a film tagged music would become its "soundtrack".
2026-11-10 — An item's date is the day it went on the wall
Status: accepted
Context: The first real items (spec 002 §4) had no dates from the owner, and inventing "watched on" dates breaks
the no-invented-facts rule.
Decision: For shares without a known date, date is the day the item is put on the wall. That is true by construction.
Consequences: Several items can share a date; the owner can change any of them to a real date later.
2026-11-10 — The "right now" strip shows the last film, not "watching now"
Status: accepted
Context: Spec 003. Trakt's watching now is only filled during a check-in or scrobble; the owner doesn't scrobble,
so the Watching cell never showed on the live site. He logs every film right after watching it, usually with a rating.
Decision: Trakt is read as history/movies?limit=1 + ratings/movies (rating matched on the trakt id, else the
slug) + the existing ratings?limit=5. Sources['trakt'] is { lastFilm, recent }; the strip's cell is "Last film"
with stars and "watched ". The watching request and type are gone. A cached trakt.json from before reads as
lastFilm: null.
Alternatives: a last-watched block in the wall's Trakt card (mock B, not chosen); last TV episode (shows are rated at
the end, with no episode trail).
Consequences: One or more extra requests per build: film ratings are read 100 at a time, newest first, stopping at the page that has the last film (usually the first; at most 50 pages). A film watched but not yet rated shows
without stars until the next sync after rating it.
2026-11-10 — Film posters alternate sides; band photos are 4:3
Status: accepted
Context: The owner found the wall monotone with every poster on the left, and the 16:9 header cut a bandmate out.
Decision: On the wall, every second film card (newest first, counting films only) has its poster on the right
(film-grid flip); opened cards and item and issue pages keep it on the left. A music card's photo is 4:3 at 38%
from the top, capped at 700 px on the item page.
Consequences: A new film shifts the sides of the older ones below it.
2026-11-10 — Song cards drop the record
Status: accepted
Context: personal-r2-b gave the song sleeve a vinyl record that slid out on hover and spun while playing, with
22% of the card kept free on the sleeve's right for it. At rest the record was hidden, so the card looked lopsided
(owner, on the rachie card). A record peeking out at rest was tried and was too dark to read on the plum card.
Decision: The owner said to drop the record "from this design right now". The sleeve spans the card with equal
14 px margins; song titles and the play note break in balanced lines.
Consequences: No record animation anywhere. Bringing it back means re-adding .rec and the reserved gap.
2026-11-10 — Listening: the week's top track from ListenBrainz, counted by the site
Status: accepted
Context: Spec 004. The owner's listening is on ListenBrainz (mirrored from Last.fm). ListenBrainz's weekly stats
lagged by weeks; the listens were current.
Decision: The site fetches the listens (1000 per page, newest first, at most 5 pages, paging with max_ts until
past 7 days) and counts the top track itself, grouped case-insensitively, ties to the most recent. It replaces the
pinned-song cell "for now". The source only runs when LISTENBRAINZ_USER is set: an unkeyed default made the test
build call the live service.
Alternatives: ListenBrainz's stats endpoint (stale); Last.fm (needs an API key, same data).
Consequences: One to five requests per build. The production environment needs LISTENBRAINZ_USER.
2026-11-10 — The start page cassette shows a strip of the song's cover; the collage stacks
Status: accepted
Context: Spec 005. The cassette showed only the song title, cut off. The owner tried mockups and a cassette lab.
Decision: With an image, the cassette shows a 166 × 42 strip of it (crop 41% / 53%, the same for every song) under
a paper tab with the title and the item's credit as the artist; without an image, the old label. The collage stacks:
polaroid (layer 3, tilted 7°) over the cassette and the note (layer 2); a hovered scrap goes to layer 4. Nothing may go
below layer 2: the half's stretched "come in" link sits on layer 1 and would swallow hovers and clicks.
Alternatives: a per-song crop field (a contract change; only if a cover crops badly); the cover's own lettering.
2026-11-10 — The gate's grain is a pre-rendered tile used as a mask
Status: accepted (supersedes the inline-SVG grain in "Accessibility and performance pass")
Context: The owner felt the personal half lag on hover. The grain was a live SVG feTurbulence filter over
~1600 × 840 px; a trace showed it re-rasterised on every hover repaint (the lean, the Latest line, scrap lifts).
Decision: The filter was rendered once into a seamless 128 px tile (public/grain.webp, 12 KB, alpha only) and is
applied as a CSS mask over a warm fill (.art::before, opacity .18, own layer). It looks the same. Raster work while
sweeping the pointer (headless Chromium): personal half ~1750 → ~590 ms, work half ~1710 → ~910 ms.
Alternatives: the tile as a background-image (cheapest, but it became the phone LCP element again, the reason
the inline SVG was chosen); no grain (owner kept the texture).
Consequences: A mask image is not an LCP candidate; the phone LCP stays the collage's photo.
2026-11-11 — No asset is inlined into CSS; Ghost posts are fetched page by page
Status: accepted
Context: The 2026-11-11 audit (docs/audits/2026-11-11-audit.md, findings 2 and 6). Vite inlines assets under 4 KB
as base64, so fonts-work.css carried six Geist Mono subsets (Cyrillic-ext, Vietnamese, symbols) that unicode-range
would otherwise never download: 42.8 KB of render-blocking CSS (28 KB gzip). The Ghost client asked for limit=all,
which Ghost 6 is believed to cap at 100 posts.
Decision: vite.build.assetsInlineLimit: 0 in astro.config.mjs (fonts-work.css is now 5.9 KB, 1 KB gzip).
fetchGhost asks for limit=100&page=N and follows meta.pagination.next, up to 50 pages.
Alternatives: an inline-limit function that only exempts fonts (nothing else here is small enough to matter).
Consequences: Every imported asset is its own request; a test fails if a stylesheet contains url(data:.