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
shipped — github-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.