NOTES_BLOG.md

blog.siao.ai — journal

The journal for blog-siao-ai, the personal technical blog at blog.siao.ai. Chaptered by the question each piece of work set out to answer, newest at the bottom, append-only.

What this project is: a single-author, Markdown-only static blog for long-form narrative technical writing — "故事性敘述性的文件", as distinct from AnswerHub's Q&A corpus. It can embed real files from SiaoHub repos in a post, and SiaoHub shows a backlink the other way.

This file never states status. What is built, deployed or reachable lives in HANDOFF.md. The spec is SPEC_BLOG.md; the settled visual decisions are ClaudeDesign/BLOG.md.

Related: SPEC_BLOG.md (the spec, written from a /grill-me pass), ClaudeDesign/BLOG.md (concrete design tokens and layout rules), NOTES_SIAOHUB.md (the other half of the embed/backlink seam).


1. What shape should this even be? (the /grill-me pass)

Interviewed the owner rather than assuming, because the first plausible reading was wrong. AnswerHub already had a working BlogPost model, markdown rendering and a publish form — the obvious move was "expose that at a subdomain".

That was the wrong move, and one sentence killed it: AnswerHub is going to be retired. Reusing its blog would mean doing the same migration twice.

Decisions that came out of the interview, each one narrowing the next:

Question Answer Consequence
Reuse AnswerHub's blog, or new? New repo no Prisma, no auth, no DB
Who writes? Owner only no accounts, no roles, no moderation
How is it written? Plain Markdown files no web editor, git push publishes
How does it relate to SiaoHub? Quote files from it SiaoHub = projects, blog = narrative
Fetch embeds when? At build time zero runtime dependency for readers
Keeping embeds fresh? Manual per-repo webhook no credential in either direction
Where does the source live? GitHub git.siao.ai is for showing people
Where does it deploy? Cloudflare Pages like the homepage, not the self-hosted stack
Multi-language? Wiki-style: exists or doesn't no forced ten-language matrix

A contradiction the interview surfaced late, worth recording because it nearly shipped: the homepage's build philosophy is "commit the generated HTML, so the deploy path has no build chain in it." Carried over here, that would have silently broken the whole embed feature — Pages would replay a pre-baked commit, and a webhook-triggered rebuild would republish identical bytes. This repo therefore does run its build on Pages, and dist/ is gitignored. The two repos look similar and are deliberately opposite on this one axis.

Status: settled. Written up as SPEC_BLOG.md (27 user stories, 8 tickets).


2. The visibility hole the spec found but did not close

Writing the spec surfaced a question nobody had asked: SiaoHub has visibility tiers (public / visibility:preview / Forgejo-private). If a post embeds a file by hitting a public read path, does that become a way to publish something the tier says shouldn't be?

Recorded in SPEC_BLOG.md's Further Notes as unresolved, deliberately — rather than assuming an answer — and then made ticket 3, a prefactor blocking the embed feature.

Resolution: the embed endpoint goes through resolveRepoVisibility, the same seam every other content-serving route in SiaoHub uses, with no session — so it sees exactly what an anonymous browser visitor sees. A private repo is refused; a preview-tier repo is truncated to the same line limit the human-facing blob page shows.

A branch trap worth remembering. SiaoHub's main and its cjk-font-and-footer branch had different authorization seams (resolveRepoVisibility vs a newer loadBrowsableRepo refactor). The endpoint was first written against the branch that happened to be checked out, which would have made an unrelated feature depend on an unmerged refactor. Rewritten against main, then re-adapted when merging into the branch. Check which seam the target branch actually has before writing against it.

Status: solved, both halves merged.


3. Eight tickets, and what the tests could not see

Tickets 1–8 built: repo scaffold, Markdown pipeline, the SiaoHub read endpoint, the ::siaohub-file{} embed directive, backlinks.json, SiaoHub's backlink chip, the webhook runbook, per-post translations.

The pipeline is AnswerHub's exact unified/remark/rehype stack, reused deliberately for visual consistency.

One self-inflicted bug, caught in review: the renderer was first written to cache a single unified() processor at module scope and reuse it. posts.js renders posts concurrently via Promise.all, and AnswerHub's proven version builds a fresh pipeline per call. Reverted to match — an unrequested optimisation whose safety nobody had verified.

The lesson that shaped everything after. After ticket 8 the suite was green, so the site was declared done — and then a browser was opened and the math read E = mc²E = mc2. KaTeX's markup had shipped without its stylesheet, so the hidden MathML fallback was rendering visibly, on every page with a formula, the entire time.

Every test passed because every test asserted the substring "katex" appeared in the output. It did. It appears whether or not the stylesheet exists.

The fix was one cp and one <link>. The expensive part was the realisation: "the tests pass" and "it looks right" are different claims, and this suite could only ever make the first one. From here on, every visual change ends with a browser, and every regression test for a visual bug is verified to fail on the broken version before it is kept.

Status: all eight shipped. Design work continues in ch.4–8.


4. Deployment: what actually publishes, and what only looked like it

Two deployment questions, both answered wrong at first by inference and then corrected by checking the running artifact.

git.siao.ai does not deploy from main. Pushing SiaoHub's main did nothing visible. The host's ~/siaohub — which is the Docker build context — was checked out on cjk-font-and-footer, so that branch is what production runs. Worse, ~/bin/infra-deploy.sh runs docker compose up -d with no --build, so even a push to the right branch never rebuilds the image. Shipping SiaoHub means: merge into the branch the host has checked out, then docker compose build siaohub && docker compose up -d siaohub from ~/forgejo (which holds the real .env; ~/infra-apply has an identical compose file but no environment).

Cloudflare Pages, by contrast, is genuinely automatic — GitHub integration, push to main, build runs, deploy lands in ~2 minutes.

Status: both verified by request from outside, not inferred.


5. "有點不太部落格" — the reference was structural, not visual

First UI pass extended the homepage identity: ink-on-paper, EB Garamond, single centred column, tags one click away at /tags/. Green tests, and wrong.

The owner's reference was 零度博客 (xcode.me/Blog). The first attempt worked from a text description of it. Opening the actual site showed the gap immediately: a persistent right sidebar with categories, a tag cloud and a search box, article counts, tags on every list row. Density, always visible — not one click away.

The correction was structural, not visual: the colour and type identity stayed exactly as it was; a sidebar was added, tag chips moved onto every listing, and client-side search was built (a build-time search-index.json filtered in the browser — no backend, matching the site's zero-runtime-dependency posture).

What would have found this sooner: looking at the reference instead of reading about it. A summary of a design conveys its features and loses its proportions.

Status: shipped, and then partly overturned by ch.6 — see there.


6. "還可以更好,去參考一下 medium" → features, when the ask was layout

Read the ask as "add what Medium has" and shipped reading time, a · · · scene divider, a larger post headline, a reading-progress bar. All fine, none of them the answer.

The follow-up — "我是說看排版看設計" — was the correction. So: measured Medium's article page instead of describing it.

Medium here, before
reading column 680px 624px
position centred (487 / 487) 371 left / 659 right
body 20px / 32px 18px / 30.6px

The off-centre column was self-inflicted by ch.5: the sidebar had gone onto every page, so a post page held space for chrome the reader isn't using. Medium puts nothing beside an article.

Split into two page shapes from one layout(): browse (index/tags/archive — sidebar alongside) and read (a post — no sidebar, column centred). Reading type moved onto article, so prose is 20px while chrome stays smaller.

A bug found only by looking: listing headlines were rendering italic. .post-list p { font-style: italic } was meant for excerpts and also matched <p class="title">.

The progress bar's first formula was wrong — it showed ~70% before any scrolling. Caught by a test asserting 0% at the top, not by eye.

Status: shipped. Chapter 5's "sidebar everywhere" is superseded.


7. 字體 / 排版 / 分割 — three complaints, three measurements

Asked about typography, typesetting and dividers. Measured each rather than adjusting by eye.

字體. Canvas TextMetrics, same font-size: EB Garamond's cap height renders 65.8px per 100px em; Noto Serif TC's glyph height 91.6px. Latin was 72% the height of the Chinese beside it — Garamonds are historically small-on-the-body and CJK glyphs fill their em box, so every English word inside a Chinese sentence read a size too small. size-adjust: 108% on the @font-face → 0.776. On the font, not on selectors, so it cannot be forgotten somewhere.

排版. No 禁則處理. Added line-break: strict, so a line cannot begin with 。、」). Browsers default to permissive auto.

分割. Tables had a full grid on every cell — a spreadsheet, not typesetting. Now horizontal hairlines only. Code blocks were github-dark, measured 13.93:1 against the paper: legible, and a hole punched in a warm-paper page.

The light panel then broke contrast, and axe caught it before it shippedgithub-light's keyword red came to 4.08:1 on the tint, under the 4.5 floor. Rather than guess a replacement, wrote a throwaway script measuring the worst token of all 18 bundled light shiki themes against the actual panel colour:

light-plus                  4.59  ← the only one that passes
github-light-high-contrast  4.50
github-light-default        4.06
github-light                3.12
min-light                   1.58

Light syntax themes are generally not built to AA — a muted comment colour is the point of a comment colour. Recorded in render-markdown.js so the next person re-measures rather than assumes.

Status: shipped, with a test that computes every rendered token's contrast and was verified to fail on github-light.


8. Two titles, too much serif, and no brand colour

Owner sent a screenshot: "siao.ai blog" rendered twice, stacked — the header's name and the index's own <h1>. Plus "太多襯線字體,讀起來 難受", and an instruction to use the family palette.

All three were right.

The doubled title came from ch.6: removing the index's h1 was never done when the header gained the site name. The header's name is now the <h1> on the index and a plain <span> elsewhere.

Too much serif — this was a genuine mistake in ClaudeDesign/BLOG.md, which had said "one font stack for everything." True for the marketing homepage, which is one screen with almost no chrome. This site has a nav, sidebar, search, tag chips, bylines and table headers; all of them in Garamond at six sizes gave the eye no signal about what was prose and what was furniture. Split: serif for reading matter, the platform's own sans (zero bytes) for interface. The wordmark stays serif — it is brand, not interface.

article sets serif for its whole subtree, so interface inside a post must say so. .byline silently inherited it until a test caught it.

The accent. Sage green #587b60, the literal token from APP_FAMILY.md (measured there at 4.52:1 on this paper; re-measured here, identical). Given exactly one job — interaction or location: in-prose links (from the start, not only on hover, which a touch reader never receives), hover/focus, the nav's current section, the progress bar, the · · · divider. Everything else stays ink / ink-dim.

This uncovered dead code: the [aria-current] rule had been in styles.css since the first version and nothing ever set the attribute. Not just an unused style — a missing landmark for screen-reader navigation.

Status: shipped, extracted to ClaudeDesign/BLOG.md.


9. A doubled hairline, and the much worse thing behind it

Owner's screenshot: two parallel lines at the top of the index. Cause was trivial — the header's border-bottom plus .post-list's border-top, which had made sense while an h1 sat between them until ch.8 removed it.

Verifying the fix is where the real bug appeared. After deploying, a freshly opened browser tab still computed border-top: 1px on .post-list, while curl against the same URL returned a stylesheet that no longer contained the rule.

Cloudflare Pages serves styles.css with max-age=14400 and the filename never changed. Every design change in chapters 5–8 was invisible for four hours to anyone who had visited recently — and it had misled the verification of three separate changes in a single session, each time looking like "the deploy hasn't landed yet."

Fixed with content-hashed asset names (styles.3ab9b67a.css). A changed file is a different URL, so a cache cannot serve a stale one. search-index.json stays unhashed on purpose — search.js fetches it by a literal path at runtime.

Cache headers, checked: HTML is max-age=0, must-revalidate (correct — it must be, since it names the hashed assets); the hashed assets keep max-age=14400, now safely.

What would have found this sooner: checking document.styleSheets and the computed value, rather than trusting a hard reload. A hard reload is a request about the document; sub-resources can still come from cache.

Status: solved. Three asset tests rewritten to assert the invariant (assets are hashed, and every asset the HTML links exists in dist/) instead of literal filenames — the second half catches a failure the old tests structurally could not see.