NOTES_HOMEPAGE.md

siao.ai homepage — journal

What this project is: a personal homepage, built to a LoveFrom-inspired minimal brief, then walked back through two full architectural pivots on the animation before landing on the answer the owner actually wanted — no animation at all. "Done" turned out to mean: a plain two-section page, manual scroll, one working email link, nothing running in the background.

The animation chapters (5–11) are kept in full even though the animation was deleted in chapter 12. If anyone — including a future me — is tempted to re-add a "boulder falls and hits things" animation to this page, this is the record of two different implementations of that idea, both with real, specific bugs, both eventually rejected by the owner regardless of whether the bugs were fixed. Read this before re-opening that door.

Update after chapter 16: "no animation at all" above was the state as of chapter 13, not the final state. The owner brought back a small piece of motion — hold-then-auto-scroll — while still declining the boulder. The distinction that survived: the physics/collision idea (a falling object hitting things) is the part that was tried twice and rejected twice; "the page moves for you if you wait" as a bare mechanic was never itself the objection.


1. Design direction

Brief: "為我的 siao.ai 設計首頁,LoveFrom 那樣的極簡風格" (design a homepage for siao.ai, minimal like LoveFrom).

Fetched lovefrom.com for ground truth rather than guessing at the style from memory: single page, "LoveFrom" repeated, "a creative collective", a list of disciplines, one hyperlink in the entire site (founder's name). No nav, no CTA, no imagery.

Tension identified immediately: true LoveFrom minimalism has ~zero outbound intent — it doesn't need visitors to go anywhere. This site needs to send people to subdomains that don't exist yet. Resolved by distinguishing "true LoveFrom" (a manifesto, no exits) from "minimal but with an exit" (the chosen direction) — visually austere, but the visitor has somewhere to go.

Interview settled, in order: identity is siao.ai as the name itself ("Siao"), not the owner's legal name; statement is a Sisyphus line — "The summit is a lie. The boulder is real."; second section holds only [email protected] for now (no subdomains exist yet, and a "coming soon" list of unfinished things was explicitly rejected as worse than nothing); subdomains render from a plain data array so adding one later never touches layout code; EB Garamond self-hosted (LoveFrom's own face isn't licensable); Cloudflare Pages + GitHub, no build step.

Status: settled, not revisited since.

2. Spec and tickets

Ran /to-spec then /to-tickets against the interview above (no issue tracker configured, so both went to local files: SPEC.md and .scratch/siao-ai-homepage/issues/). Six tickets, dependency-ordered: static page → boulder animation → transition/loop → asset tests → motion tests → CI/CD.

Both SPEC.md and .scratch/ were later removed from git tracking (and then scrubbed from history entirely via git filter-branch) at the owner's request — planning artifacts belong in a separate self-hosted repo, not the public one. They still exist on disk in this directory tree and are gitignored.

Status: done. Tickets 01–06 below map to this list.

3. Ticket 01 — static two-scene page

Built the pageskeleton with no animation: Siao centred, statement below it, EB Garamond self-hosted (fonts/eb-garamond-latin.woff2, latin subset from Google Fonts' build, OFL license file included), second section with the empty PLACES array driving an empty <ul>. Verified in-browser and via a one-off Playwright screenshot script (headless browser tooling wasn't reliably scrolling in the sandboxed preview pane that session, so screenshots were taken with a throwaway node script instead — this became the standard verification method for the rest of the project).

Status: done, shipped in the first commit and never substantially changed.

4. Ticket 02–03 — the first boulder (CSS keyframes)

Built as pre-authored @keyframes — a parabola sampled every 10% and played back linear, because CSS easing curves don't actually model constant acceleration and the samples do. Boulder: hand-drawn irregular SVG polygon (12-point path, several iterations — the first attempt read as a regular hexagon at boulder size, not a rock; the accepted version has one long flat edge and one notch, asymmetric on purpose).

Two real bugs found and fixed within this design before it shipped:

Timing constants (--impact, --fall, etc.) were hand-solved algebra: given the parabola's shape, solve for the moment the boulder's centre coordinate equals the name's coordinate, write that time down as a CSS custom property. This is the detail that chapter 8 revisits and rejects.

Status: shipped, then substantially reworked twice (chapters 8, 9) before being deleted outright (chapter 12).

5. Test suite and CI/CD

Playwright: one seam (the rendered page), three categories per the spec — assets/links, accessibility + reduced-motion, two static-state visual snapshots. Deliberately no animation-timing assertions from the start — "nothing automated can judge whether the boulder falls well."

Found one real bug while writing this: use: { reducedMotion } (the Playwright fixture option) silently failed to reach the page in the installed Playwright version — tests passed against a page that was still animating. Switched to page.emulateMedia(), which worked. Kept the fixture-vs-emulateMedia distinction as a comment since it looks like a no-op change in a diff otherwise.

GitHub Actions on macos-latest (not the default Linux runner) — chosen because Playwright keys visual snapshot baselines by OS, and this machine has no Docker to render Linux ones locally. First CI run failed outright on an unrelated bug: python3 -m http.server binds IPv4 only, and the runner resolved localhost to ::1 first, so the webServer health check timed out. Fixed by binding and addressing everything via 127.0.0.1 explicitly, everywhere (config, docs, later scripts).

Cloudflare Pages connected via API once the GitHub App was installed on the account (a first attempt without it failed with an opaque "Cloudflare Pages Git installation" error — the App has to be installed through the dashboard first; the API can't do that part). Branch protection on main requires the test check.

Status: done. Confirmed working end to end (PR → preview URL → merge → production deploy) multiple times across the pivots below.

6. Repo history cleanup

Owner asked for SPEC.md and .scratch/ out of the public repo, not just untracked — git filter-branch to strip them from every commit, then force-push. Later asked for .claude/ (editor config) out the same way. Verified via git log --all --pretty=format: --name-only that the target files appear in zero commits after the rewrite, not just the tip.

Old commit SHAs remained fetchable via the GitHub API even after the force-push (force-push doesn't garbage-collect on the remote). Only way to be certain nobody could pull the old blobs back out: delete and recreate the GitHub repo outright, which was done, followed by re-connecting Cloudflare Pages and branch protection to the new repo id.

Status: done. Repo shihte/siao.ai as it exists now has never contained SPEC.md, .scratch/, or .claude/ in any reachable commit.

7. PR#1 — the statement gets struck too

Bug report: boulder visibly overlapped the name for several hundred ms before it "reacted" — read as passing through rather than hitting it. Root cause: the struck-name keyframe started from rest (position ∝ t²), so for the first ~150ms after "impact" the name barely moved while the boulder, already at speed, kept going — hence the overlap window. Not yet fixed at the physics level here; PR#1 mainly extended the design to include a second body (the statement) being struck later, using a second hand-solved impact time (--impact-2), keeping the same v0-from-rest keyframe shape for both bodies.

This PR also introduced the second-scene "landing" animation (each subdomain line/email dropping in from above, staggered) and a regression-tested it explicitly — sabotaged the reveal logic, confirmed the new test caught it, then restored the code. That verify-the-test-catches- the-bug step turned out to matter later (chapter 11) once physics replaced keyframes and several tests needed the same discipline applied to genuinely different failure modes.

Status: shipped, superseded by chapter 8 within the same session.

8. PR#2 — "One body, one blow" (rigid-body redesign)

Owner's actual instruction, several messages in: the exits section (mail + subdomains) is fixed at a set distance above the name — not the statement. Earlier assumption (name+statement welded together) was wrong and had to be walked back explicitly; the owner corrected it directly ("我說鎖定的是上面 Mail...不是句子跟 Siao").

Redesigned as: .rig (name + exits, welded, one strike moves both) is a separate body from .statement (untouched until the boulder — still a pre-authored curve — reaches it on its own schedule). Total travel distance (--delta) derived, not chosen: 100vh (one viewport for the camera to descend) + D (the fixed name→exits distance), so changing D alone keeps the arithmetic consistent.

Status: shipped. This structural idea (name+exits welded, statement independent) survived into the physics rewrite (chapter 10) and was still correct when the whole animation was deleted (chapter 12) — it was the implementation underneath it, not this structure, that kept breaking.

9. Bug reports after PR#2 — three real, distinct problems

Owner reported, in one message: (1) the email "rolled down but wasn't fixed" — implying drift, not a static offset; (2) scrolling back up didn't bring the name back; (3) the boulder still passed straight through rather than really hitting anything.

Diagnosed each independently with instrumented Playwright, not by guessing from the bug report wording:

Fix applied in this pass: replaced the IntersectionObserver entirely with a direct scroll event listener carrying its own state (awayFromTop, handedOver), checked against absolute scroll position rather than an edge-triggered observer callback. Loosened the "back at card" threshold from 45px to 30% of viewport height. Left (3) for the next session, since fixing it properly meant relitigating whether the pre-authored keyframe approach could ever be made to look like a real hit at all.

Status: (1) and (2) fixed within CSS-keyframe design; (3) escalated.

10. The physics quiz, and the wrong first answer

Owner asked, as a standalone physics question unconnected to code: "a boulder falls at velocity V and strikes an object — does the object end up at velocity V, −V, or V+quantum tunnelling?" Correct answer: V, in the limit of a heavy object striking a much lighter one inelastically (it gets carried along, not bounced back). The "quantum tunnelling" option was the joke distractor — and the owner then pointed out that the current code was doing exactly that: "你的程式告訴我是 C" — a polite way of saying the keyframe-based struck-name animation, which starts from rest (v0 = 0), doesn't model a real momentum transfer at all; visually it reads as "the boulder went through it," i.e. option C.

Owner's actual demand, stated directly and repeated when the first attempt to accommodate it half-measured it: a real physics simulation, not a better-tuned pre-authored curve. "而且現在是預製好的動畫,我要真實 物理判定,這我講過很多次了。"

First redesign proposal (before writing any code) tried to preserve the chapter-8 rigid-body idea (name+statement both struck by "one blow, propagated through the rigid connection") — and the owner immediately identified the flaw: if name and statement are one rigid body, the boulder cannot legitimately reach the statement without re-passing through the name it already hit, since they're welded and move as one. That's the exact bug being fixed, recreated one level up. Owner corrected the model directly (again): only the exits are welded to the name; the statement is its own free body, uninvolved until the boulder's own, independently-computed trajectory happens to reach it for real.

Status: model corrected before any physics code was written. This is the model chapter 11 actually implements.

11. The real physics rewrite

Replaced all @keyframes on the boulder/name/statement/exits with a requestAnimationFrame loop doing real position/velocity integration under one shared gravity constant, with real per-frame circle-vs-rect collision tests (not scheduled times). .rig (name+exits) and .statement became independent simulated bodies, each with its own struck flag, each getting the boulder's actual current simulated velocity at the frame collision is detected — genuine momentum transfer, not a number written into a keyframe in advance.

Real bugs found during this rewrite, each confirmed with instrumented Playwright before being called fixed — screenshots alone had already proven unreliable for judging this kind of bug in chapter 7:

Every one of these was confirmed with a synthetic sabotage-and-verify step (deliberately reintroduce the bug, confirm the relevant test goes red, then revert) rather than trusted because the code "looked right" — this is what chapter 7's regression-test discipline generalised to.

Verified across both desktop (1440×900) and mobile (390×844) viewports — the false-early-collision bug was specifically viewport-size-dependent (triggered by --name-fs's wide-viewport clamp), so a desktop-only check would have missed it recurring at other sizes.

Status: physics simulation worked, correctly, confirmed on both real collisions independently and at both tested viewport sizes. Deleted in its entirety one chapter later regardless (chapter 12) — being correct was not the same question as being wanted.

12. Test-suite flake — two different causes, easy to conflate

While stabilising the physics-based suite, tests/looks.spec.js's card snapshot started failing intermittently (~1 in 6–10 full-suite runs at 4 parallel workers) — but only under full-suite parallel load, never when run in isolation.

First hypothesis (wrong, but not unreasonable): CPU contention. The new physics loop does real per-frame style.transform writes and scrollTo() calls on the main thread, unlike the old GPU-composited CSS animation. Reasoned that four concurrent Chromium processes each doing this for ~1.5s could starve a fifth, unrelated snapshot test of a frame, producing a sub-pixel antialiasing diff. Applied a fix on this theory — raised maxDiffPixelRatio and added a CI-only retry — and it didn't actually make the flake go away at the rate expected (still ~1/10 with double the tolerance), which was the signal the hypothesis was wrong, not just insufficiently generous.

Actual cause, found by looking at the failing screenshot rather than reasoning about timing: the "actual" image showed the boulder at full size, un-hidden, and the text in a plain fallback serif — i.e. styles.css had not applied at all, not merely rendered with different antialiasing. document.fonts was empty. This is consistent with the dev server (python3 -m http.server, threaded but not bulletproof) occasionally dropping or badly delaying one request under a burst of near-simultaneous page loads from four parallel workers — most damagingly when that request is for styles.css itself, since losing it breaks the reduced-motion CSS rule, the font, and the layout all at once.

A second, separate failure was then found on a later run, which looked completely different in the diff image — correct font, correct layout, only faint red/white edge fringing around glyphs — genuine sub-pixel rendering noise, unrelated to the CSS-load failure above. So there were two distinct, independently-real flake sources being conflated by one shared symptom ("looks.spec.js sometimes fails").

Fix: reverted the tolerance/retry-only approach (wrong diagnosis, and a tolerance loose enough to hide a 6%-pixel diff risks hiding real regressions too). Kept a CI-only retry (still correctly justified — for the dev-server-drops-a-request case specifically, not for CPU contention). Added an explicit .boulder visibility assertion before the screenshot comparison in looks.spec.js, so a dropped-stylesheet run now fails immediately with a clear, specific message instead of producing a confusing pixel-diff artifact that looks like the antialiasing case.

Status: root-caused as two separate issues; addressed the diagnosable one directly, left the antialiasing case to the (correctly justified) CI retry rather than to a loosened threshold. Fully moot as of chapter 13 — the dev-server/physics-loop CPU story doesn't apply once there's no physics loop.

13. Scope cut — delete the animation entirely

Owner: "算了,刪除所有動畫、互動,正常的網頁就行,手動滾動。" (never mind, delete all animation and interaction, just a normal page, manual scroll.)

No animation to fix, tune, or re-litigate after this point. All of chapters 4–12 — three different collision models, a full physics engine, a two-cause flake investigation — ended up deleted in one instruction, in favour of the two-section static page from chapter 3 with none of the choreography layered on top.

Concretely: removed .boulder, .wreck, .rig, the whole requestAnimationFrame loop, all scroll-hijacking/camera-follow/replay logic from main.js (kept only the subdomain-list rendering, which never depended on any of it); removed all animation CSS (custom properties, @keyframes, absolute positioning tricks) from styles.css, back to plain flex-centred sections; deleted tests/motion.spec.js outright — there was no more motion to assert about; regenerated the two visual baselines against the now-much-simpler render.

Suite went from 24 tests with a known, bounded, occasionally-flaky tail (chapter 12) to 12 tests, stable across every repeated run tried afterward — because there was no more real-time behaviour left to be unstable.

Status: done, shipped, live.

Standing conclusion for future sessions: the owner explored the falling-boulder animation idea in real depth — twice, through two different implementation strategies, with the second one genuinely correct against its own physics — and chose the plain page anyway. Don't read the length of chapters 4–12 as evidence the animation direction was unfinished or worth resuming by default. It was finished, twice, and declined both times.

14. Git history collision (infrastructure incident, not a design issue)

Mid-session, discovered the local repo's commit history contained a long run of commits ("2F-1F-B1 locked geometry", "wasFallen state flag", "quantum tunneling" fix references) using terminology never used in this session's conversation — evidence that another process (a parallel Claude Code session, or the owner working directly) had been operating on the same working directory concurrently, entirely unbeknownst to this session, and had reset/reverted things independently in between this session's own git operations.

Verified the base commit those foreign commits eventually reverted to (3059c8e) had a tree identical to this session's last known-good state (58505b6, the "One body, one blow" merge) via git diff --stat between the two — confirming it was safe to branch from and build on top of without silently losing anything from this session's own history.

Reported the discovery to the owner plainly before proceeding, including the caveat that the old commit SHAs might still be reachable via GitHub's API for a while even though they were no longer in any branch. Owner authorized discarding it outright ("你可以完全任意地刪除它").

Status: resolved by owner's explicit authorization, not silently. No independent action was taken on the foreign history before that authorization was given.

15. Post-launch — Cloudflare email obfuscation, favicon

Owner reported the live page looked broken — a large blank gap, "滾不動 元素也不完整" (can't scroll, elements incomplete) — with a screenshot. The colourful icon visible in the screenshot turned out to be a browser extension button, not this site's favicon — a false lead, ruled out by checking what /favicon.svg actually was, not by asking the owner to clarify first.

Real cause, found by diffing the raw HTML served in production against what the repo actually contains: Cloudflare's zone-level "Email Address Obfuscation" feature (Scrape Shield) had rewritten the plain mailto:[email protected] link into a /cdn-cgi/l/email-protection#... placeholder plus an injected decoder script. Works fine when the script runs, but the page's one functional element (its whole reason for having a second section at all) was now silently dependent on a third-party script that ad-blockers and privacy extensions commonly block. No API token remained (deliberately revoked earlier in the session) to disable this directly — the owner needs to turn it off manually, Dashboard → Scrape Shield → Email Address Obfuscation.

Separately: favicon changed from the boulder-shaped mark to a plain serif "S" — the boulder-shaped icon stopped making sense the moment the boulder itself was deleted from the page (chapter 13). Built as an SVG <text> using a system serif stack rather than the self-hosted webfont, since favicons don't reliably load webfonts; checked rendered at both 32px and 16px before shipping (legible at both, softer but still identifiable at 16px, which is normal for serif detail at that size).

Status: favicon shipped and live. Email obfuscation fix is pending — owner action required, cannot be completed from this session (no Cloudflare API access).

16. First real subdomains, and a lighter kind of motion

Owner: two things in one message. (1) "改成待的夠久之後自動下滾,優化郵件 的位置" — change it so that after staying long enough, the page auto-scrolls down; optimize the email's position. (2) git.siao.ai and app.siao.ai now exist — add them, styling at my discretion for now.

Content. Both added to PLACES as live: true — the owner said "已經創建" (already created), taken at face value rather than second-guessed.

Auto-scroll. Deliberately not a return to the chapter-4–11 machinery. No physics, no camera tracking a falling object, no rigid bodies. Just: a single setTimeout (HOLD_MS = 4000), and on fire, one scrollIntoView({ behavior: "smooth" }) call to the exits section. Any wheel/touchstart/keydown/pointerdown cancels it — registered with { once: true }, so it's a one-time yield, not a per-event check. It does not re-arm if the visitor later returns to the top of their own accord; the earlier design's whole "loop" concept (re-triggering every time the card comes back into view) was not asked for here and would mean fighting the visitor a second time after they've already taken control once. Respects prefers-reduced-motion by skipping the whole mechanism, same as every motion decision earlier in this project.

Verified directly (not inferred from code review) via Playwright: left alone it reaches scrollY≈900 by t=5s but not by t=3.5s; a single wheel event before the hold keeps scrollY near 0 through t=4.5s even after the page is scrolled back to the top programmatically afterward; emulateMedia({ reducedMotion: "reduce" }) keeps scrollY at 0 through t=4.5s.

One real test bug, caught immediately. First version of the cancel-test asserted scrollY === 0 after a mouse.wheel(0, 1) followed by a programmatic scrollTo(0, 0). Failed consistently, 5/5, in the actual test runner despite an equivalent-looking raw script passing. Instrumented with timestamped logs and found: the CDP-dispatched wheel event's own scroll effect applies asynchronously, sometimes after the synchronous scrollTo(0, 0) call that was supposed to happen after it — so the page settles at scrollY = 1, not 0. Nothing wrong in main.js; the auto-scroll cancellation genuinely worked (scrollY stayed at 1 forever, never reached 900) — the test's assumption that a 1px wheel nudge plus an immediate scrollTo(0,0) lands on exactly zero was wrong. Fixed by asserting scrollY < 50 (matches the tolerance the chapter-9-era tests already used for the same class of assertion) instead of an exact value — the property under test is "did it cancel", not "what exact pixel did a 1px wheel nudge leave it at".

Email hierarchy. With real content in the places list, the email — previously the only thing in the exits section — started reading as an undifferentiated third list item rather than "a way to reach the owner." Resolved by giving it the same relationship the statement has to the name on the card: smaller (clamp(0.85rem, 1.2vw, 1rem) vs the list's clamp(1rem, 1.6vw, 1.25rem)), slightly more letter-spacing, larger gap above it (2.5rem → 3rem). Confirmed the size difference was real via computed-style checks before trusting a visual screenshot comparison — a modest change like this is easy to miss by eye at normal screenshot resolution, and a cropped/zoomed crop was what actually made it legible.

One flaky snapshot, not chased. The very first --update-snapshots run produced an exits.png baseline with no subdomain links at all, despite main.js on disk being correct. Deleting the stale snapshot files and re-running produced the correct baseline immediately, and consistently on repeat. Read as one-off dev-server/browser-cache staleness of the kind chapter 12 already characterised (this project's python3 -m http.server under Playwright's parallel test load), not investigated further — the fix (delete and regenerate) matched the already-understood category of failure.

Status: shipped, verified in production after the routine Cloudflare Pages alias-propagation delay (seen and correctly diagnosed several times before, in chapters 6 and 15) — polled siao-ai.pages.dev with cache-busting query params until the new build was actually being served, rather than trusting the first check.

17. Exits section visual redesign & typography scaling

Owner requested: "優化主頁下方網址、信箱的擺放方式,現在就是純粹存在,不叫「設計」,佈局也很奇怪" (Optimize the placement/layout of website links and email at the bottom of the homepage. Currently it just exists without feeling like 'design', and the layout is weird.) Followed by feedback: "我覺得可以更大一點" (I think it could be a bit bigger) and final approval: "可以,推吧" (Great, push it).

Layout & Alignment. The previous .exits section was left-aligned (align-items: flex-start), causing a jarring visual disconnect with the centered .card section above it. Wrapped .exits contents in a .exits-content container with max-width: 600px; width: 100%; display: flex; flex-direction: column; align-items: stretch;, centered vertically and horizontally within the viewport.

Editorial Aesthetics & Hairline Dividers.

Typography Scale Enlargement.

Accessibility Catch. During automated Playwright testing with AxeBuilder, identified a WCAG 2 AA contrast violation on .eyebrow when opacity: 0.7 was applied to var(--ink-dim) (#6E6B65), reducing the contrast ratio on background #FAF9F6 from 4.6:1 down to 2.8:1. Fixed by removing opacity reduction and letting var(--ink-dim) maintain its full 4.6:1 WCAG-compliant contrast ratio.

Verification & Deployment. All 16 Playwright tests passed (including structure, reduced motion, WCAG 2 AA contrast, and screenshot comparisons). Visual snapshots updated via npx playwright test --update-snapshots. Committed as d4d6541 and pushed to main for Cloudflare Pages auto-deployment.

Status: shipped and live in production on https://siao.ai.

18. Subdomain descriptions (code & repositories, web applications & tools)

Owner requested: "添加簡單的文字說明,例如我的作品集、應用程式等" (Add simple text descriptions, e.g., my portfolio / code repository, web applications, etc.)

Markup & Data Architecture. Added a desc property to items in the PLACES array:

Structured main.js to build a .place-info container wrapping .place-name and optional .place-desc elements.

Typography & Styling. Styled .place-desc in elegant italicized Garamond serif (font-size: clamp(0.82rem, 1.15vw, 0.95rem)), using var(--ink-dim) (#6E6B65) to maintain full 4.6:1 WCAG 2 AA contrast ratio compliance on #FAF9F6 background.

Verification & Deployment. All 16 Playwright tests passed. Updated visual snapshot baseline. Committed as d1cddbf and pushed to main for Cloudflare Pages auto-deployment.

Status: shipped and live in production on https://siao.ai.

19. Subdomain correction: app.siao.ai -> apps.siao.ai

Owner requested correction: "然後是apps不是app" (And it's apps not app).

Updated main.js and tests/page.spec.js from https://app.siao.ai to https://apps.siao.ai. Updated ClaudeDocs/HANDOFF.md. All 16 Playwright tests passed. Updated snapshot. Committed as a092760 and pushed to main for Cloudflare Pages auto-deployment.

Status: shipped and live in production on https://siao.ai.

20. AEO / SEO / GEO — meta tags, structured data, robots.txt

Owner: "再來做 AEO SEO GEO 優化,跟 sitemap.xml 還有 robots.txt" (do AEO/SEO/GEO optimization, plus sitemap.xml and robots.txt). Ran as a /grill-me session before writing anything, because the obvious approach — write a lot of descriptive copy for crawlers — would have directly contradicted the project's founding principle (chapter 1: reject content added just to have content).

The tension, surfaced first. SEO/AEO/GEO conventionally wants more crawlable text; this page has almost none, on purpose. Offered three directions: (A) pure correctness/indexability, no attempt to compete for keywords the page has no content to support; (B) add real descriptive copy, visible or hidden, to be more discoverable; (C) just generate the two requested files and stop. Owner picked A, with one refinement: A is about correctness, but "Siao" specifically needs to resolve to this person — not the Taiwanese slang term, not anyone else who happens to share the name. That's disambiguation, not content marketing, and it's compatible with A.

Where the actual bio came from. Owner pointed at a dormant project on disk (~/Lwopan/learning-platform/app/about-me/page.tsx) rather than dictating a bio from scratch — "應該可以滿足你,不足的再特別問我." Real, substantive content was there: self-taught developer, founder of a learning platform called 羅盤, Python/AI full-stack, WCA-certified speedcuber (competitor ID literally embedded "SIAO" in it — confirms "Siao" predates this domain as a real handle, not an invented one), age 14 explicitly stated.

The part that needed stopping and asking, not deciding alone. The source bio disclosed the owner's age and, elsewhere, their legal name. This is a minor's identifying information being asked to go into a publicly indexed, machine-readable, permanent channel — categorically different from a visible page (which the owner already controls the wording of) or a private conversation. Did not infer a privacy stance from context; asked directly. Owner's answer, verbatim: "是該刪除個資, 簡易的輪廓例如學生、開發者、自研等字眼可以放,14歲、[legal name] 這種就 不行,你應該有能力分清" — silhouette traits fine (student, developer, self-taught), identifying specifics (exact age, legal name) not fine. Landed on: "Siao — student, self-taught developer, working in Python and AI." No age, no name, no mention of 羅盤 (owner flagged that source material as dated, and didn't confirm it's still a project they want publicly tied to this identity) or the WCA credential (a traceable public record — including it would make the "no identifying specifics" rule pointless, since anyone could look up the competitor ID and get everything the redaction was trying to avoid).

sameAs, deliberately not populated. Owner wants the door open for future disambiguation via sameAs (GitHub/X/LinkedIn links in the JSON-LD Person schema) but doesn't have those decided yet. Correct schema.org practice is to omit an unpopulated property, not ship an empty array — a comment in the HTML shows the exact shape to add later instead.

Owner's technical understanding actively shaped the shape of the work, twice:

og:image deferred, explicitly filed next to the still-open Cloudflare email-obfuscation fix (chapter 15) rather than decided by default — "這等 一下直接丟在跟郵件一個分類" (put this in the same bucket as email). Both are real gaps, both need the owner's input (an image to design, a dashboard toggle only the owner can flip), neither blocks anything else.

sitemap.xml — asked whether it was even meaningful before building it. siao.ai is one page (no distinct routes), so a sitemap for it would be a single <url> entry — technically valid, practically close to theatre. Raised this rather than silently generating a token file. Owner's own follow-up reframed the real opportunity: a sitemap index spanning siao.ai + git.siao.ai + apps.siao.ai once those subdomains have real pages of their own — deferred to ClaudeDocs/HANDOFF.md as a TODO, not built now against content that doesn't exist yet.

robots.txt — explicit allow-all, not the absence of a file. Offered three shapes: allow everything; allow real-time AI retrieval bots (PerplexityBot, OAI-SearchBot — read a page live to answer a specific question) but block AI training crawlers (GPTBot, CCBot, Google-Extended — scrape for training data, don't come back); block everything. Given the owner's actual goal (an AI asked "who is Siao" should be able to read this page), option B looked like the natural fit and was the recommendation — but the owner chose full allow-all instead, for a reason worth recording verbatim rather than summarizing away: "也算是留名青史,因為有些爬蟲、AI 在沒有 robots.txt 的情況下不敢亂爬,所以特別設置的" — being remembered everywhere, deliberately, plus a real technical point (some conservative crawlers treat a missing robots.txt as reason to hold back, so an explicit Allow: / is stronger than no file at all, not merely equivalent to it).

Implementation. Single <head> change to index.html — description, canonical, og:*, twitter:*, JSON-LD Person — plus one new file, robots.txt (User-agent: * / Allow: /, no Sitemap: line yet since the file it would point to doesn't exist). JSON-LD's own description field deliberately differs from the meta/OG text: machine-facing structured data got the plain factual sentence only, the Sisyphus line stayed in the human-facing tags — the line is brand voice, not a fact about the entity, and schema.org's description is supposed to be the latter.

Sabotage-tested the identifying-info guard, not just written it. Temporarily inserted "14 years old" into the JSON-LD description and confirmed the new negative-assertion test (expect(...).not.toMatch(/\b\d{1,2}\s*(years?|歲)\b/i)) actually caught it before trusting the test. Reverting the sabotage via git checkout -- index.html wiped out the entire uncommitted SEO change, not just the one sabotaged line — same mistake as an earlier session's git checkout main.js incident (this journal, implicitly, since that incident predates formal note-taking here). Second time this exact failure mode has happened in this project: git checkout -- <file> reverts to the last commit, discarding all working-tree changes to that file, not just whatever was most recently typed into it. Rewrote the full file from the retained draft rather than redoing the interview.

Card snapshot: investigated, not chased. After restoring, one pre-existing test — looks.spec.js: the card — failed consistently (4/4 local runs, not intermittent) at ~6% pixel diff, same "every glyph edge fringed red/white, same actual text and position" signature as the antialiasing-class noise characterised in chapters 12 and 16. This predates the current change entirely: <head>-only edits cannot move pixels in .card. Traced the baseline's own git history and confirmed it was last regenerated after the chapter-17 redesign that touched .card-adjacent shared CSS, so in principle it should already be current — meaning the most likely explanation is a font-rendering difference between whatever machine produced that specific baseline commit and this one, both nominally "darwin" but not necessarily the same macOS/Chromium build. Deliberately did not regenerate the baseline from this local render — chapter 5 chose a macOS CI runner specifically so the authoritative baseline comes from one consistent environment, not from whichever contributor's laptop last touched --update-snapshots. Left it for CI to arbitrate: if CI also fails, that's real staleness worth a dedicated fix; if CI passes, it confirms this is a local-only rendering quirk and no baseline action is needed at all.

Status: implemented, tests written and passing locally apart from the pre-existing/environmental card-snapshot question above, which is CI's call to make — not yet committed or deployed as this entry is written.

21. Real-world baseline, day 1 — before any of it has had time to work

Owner ran two live checks the day chapter 20 shipped, unprompted, and brought back screenshots rather than taking my word that the markup was correct. Worth recording verbatim rather than summarized, because this is a baseline, not a verdict — the whole point of writing it down now is to have something honest to compare a later re-check against.

Google AI Overview, searching "Siao Ai": conflates the owner with three unrelated people/accounts — a Taiwan travel-vlog YouTube channel ("SIAO AI VIDEOS"), a musician ("謝曉艾"), and an unrelated parenting Facebook page ("Siao Ai", 880 followers). This is not a failure of the markup — it's a live demonstration of exactly the disambiguation problem chapter 20 exists to eventually fix. siao.ai was first successfully crawled by Google at 2026-08-02 01:27 (chapter 20's own Rich Results Test run) — Search/AI Overview indexes run on their own, slower refresh cycles, so seeing pre-siao.ai results the very next day is expected, not evidence the work didn't help.

Gemini, asked "誰是 siao.ai 的 Siao" with an explicit "search the web, don't rely on memory" instruction: answered with an invented profile — "AI 獨立開發者", "prompt engineering 架構設計", "分享大型語言模型的實務 部署與技術架構經驗" — none of which appears anywhere in siao.ai's actual meta description, JSON-LD, or visible copy ("student, self-taught developer, working in Python and AI" is as specific as the real page gets). Reads as pattern-completion from the domain's own .ai TLD and the name "Siao" rather than a grounded citation of the real page, even under an explicit instruction to search rather than recall. Owner's own caveat, worth keeping attached to this data point: "Gemini我是過好幾次 才有" — this answer only showed up after repeated attempts, meaning it is not a stable, reproducible response; other attempts likely returned something else or nothing. One sample is not enough to characterise Gemini's behaviour here, only enough to note that ungrounded-sounding fabrication is possible.

What this does and doesn't tell us. It confirms the crawl succeeded (chapter 20) and confirms the disambiguation problem is real and currently unsolved (expected — nothing propagates in under 24 hours). It does not tell us the JSON-LD/meta work failed; a same-day check against a system whose relevant index hadn't refreshed yet was never going to show anything else. The Gemini fabrication is a separate, model-behaviour concern that markup correctness doesn't directly reach — worth re-checking alongside the Google re-check, but not something to chase by adding more structured data now.

Follow-up, not yet done: re-run both checks a few weeks out (re-search "Siao Ai" on Google, re-ask Gemini the same question a few times to see if the fabricated-sounding answer persists, changes, or disappears) and compare against this entry. If Google's AI Overview still surfaces the same three unrelated accounts after a real reindex has had time to happen, that's the point to reconsider whether the Person schema alone is strong enough signal, or whether it needs the sameAs links (chapter 20's deferred item) to actually move it.

Status: baseline recorded, day 1. Re-check pending, no date fixed.

22. Ten languages, a hand-built switcher, and cutting the colophon

Grilling session, 2026-08-10. Nothing implemented yet — this chapter records the decisions and, more usefully, the two places where the reasoning turned.

Owner's opening complaint had two halves: the Chinese page "looks machine-translated" ("純粹翻譯看起來就很機翻,如果是我我不會想看"), and the two-language switcher can't scale to more languages. Asked which kind of translation problem it was, owner picked the structural one — the Chinese page is a term-for-term mirror of the English one — and added the thing that ended up reshaping the whole spec: some strings shouldn't be translated at all, starting with the motto.

Turn one: the cost of a language collapsed. The session started from "ten languages means ten pieces of copy nobody can proofread" — owner reads English and Traditional Chinese, and nothing else. That reads as a blocker. It stopped being one because of two scope cuts, not any technical decision: pinning the motto, elsewhere, contact/soon and the name to English in every language, then deleting the colophon entirely (owner: "底部版本記順便拿掉,看起來很怪"). After both, the only prose a new language displays is two short exit descriptions. Ten languages became genuinely cheap — one HTML file, one small subset font, two sentences. Worth remembering the shape of that: the feature became feasible by making the part that needed human judgement smaller, not by making the machinery cleverer.

Turn two: a decision quietly undid an earlier one's reasoning. The closed-state label for the switcher was decided by rejecting the 文A glyph, on the grounds that is a CJK character and would force the Russian and Arabic pages to ship a CJK font for one glyph. Two questions later, the chosen menu design lists all ten endonyms — 日本語, 한국어, Русский, العربية — on every page, which is the same cost multiplied by thirty. Caught it while working out the font budget. It survives only because <details> keeps its contents display: none when closed, and browsers don't download fonts for unrendered text, so the switcher's subset loads on first open rather than on every page load. That's luck, not design: if the menu ever becomes permanently expanded, the cost reappears immediately. Noted in the spec too.

Other decisions, briefly: ten languages (en, zh-Hant, zh-Hans, ja, ko, es, fr, de, ru, ar) at ten real URLs, generated from one i18n table by an offline script whose output is committed — the same pattern as subset-font.py, so "no build step" survives in the sense that actually matters (deploy touches no build chain). Client-side i18n on a single URL was rejected outright: it would have thrown away everything chapters 20–21 are about. /zh/ becomes /zh-hant/ with a 301. Arabic brings RTL, which turned out to be nearly free — the page is almost entirely centred, so inset-inline-end on the one cornered element covers it. Eight of the ten languages ship copy no human will have read; that's recorded in the spec as an accepted risk rather than left implicit.

Measured rather than assumed, using the project's own tests/font-coverage.js: the shipped EB Garamond latin subset is 236 glyphs / 44KB and already covers every accented character Spanish, French and German need — so three of the seven new languages cost no font work at all. It has no Cyrillic and no Arabic.

Spec rewritten in place at SPEC_HOMEPAGE_I18N.md (the previous two-language + colophon version is in git history; half of it was overturned, and leaving both halves interleaved would have misled). Tickets 07–13 in siao-ai/.scratch/siao-ai-homepage/issues/.

Status: spec and tickets written, no code changed.

23. Building the ten languages — what the screenshots caught that the tests didn't

Tickets 07–13 in one sitting. 100 tests green. The mechanical parts — generator, ten pages, hreflang mesh, sitemap, redirects, nine font subsets — went the way chapter 22 predicted and are not worth re-narrating. What is worth recording is the set of things that were only visible by looking.

Three defects survived a green suite, and all three were caught by opening a screenshot.

  1. The current language appeared twice. The menu dropped downward from the trigger, so "English" sat directly above "English" — the summary saying it, and the list's first item saying it again. Fixed by opening the list over the trigger so the first line lands exactly where the closed label was. No assertion would ever have noticed; both elements were correct in isolation.
  2. Synthetic oblique on Japanese and Arabic. The exit descriptions are set in italic. Neither script has one, so the browser sheared the glyphs sideways. Slanted kana is not emphasis, it's a typographic error with a name. Italic is now off for CJK and Arabic. (The Latin italic is synthetic too — one upright woff2, no italic file — but that has been true since launch and reads acceptably.)
  3. © 2026 Siao came out as Siao 2026 © on the Arabic page. The spec said English strings in an RTL page could be left to the bidirectional algorithm. Wrong: a leading neutral character like © gets handed to the right-hand end. It needs an explicit dir="ltr". Worth remembering as a general rule — bidi handles English inside Arabic prose, not an English line that starts with punctuation.

The visual snapshots do not have the resolution people assume. maxDiffPixelRatio: 0.01 is ~10,000 pixels at 1280×800. Adding the whole © 2026 Siao line to the exits section passed the existing snapshot without complaint. So the snapshots catch broken layout and nothing smaller — a missing language in a ten-item menu would very likely slip through too. That gap is currently covered from the other side (language.spec.js asserts all ten items, nine links, and every href/hreflang/lang), and the threshold itself is left for the owner to decide on.

A pre-existing hole surfaced, and it wasn't the one being looked for. The new glyph guard checks every rendered character against the union of all shipped faces. It immediately failed on , the outbound link arrow — which turns out to be in none of the fonts and to have been rendering from the visitor's system font since the day it was added. Not a regression; a fact nobody had had a way to notice. Left as a named exception rather than shipping a font file for one decorative arrow.

One change went beyond the ticket, and should have been in it. The exits list was still being built in the browser by main.js. Once a generator existed, having the site's only outbound links depend on a script was indefensible, so it moved into the generated HTML. main.js is now three small behaviours, each of which only improves a page that already works without it.

Nine font files, not the seven the spec estimated. The switcher's Han and Hangul stayed as two files rather than one merged face — merging layout tables across scripts buys nothing at 3KB and 1.6KB. The Han glyphs come from the Simplified face, which is the only one of the four CJK sources containing every character the ten language names need ( is absent from both the Traditional and Japanese faces). Every subset landed between 1.6KB and 13.5KB; the Arabic one keeps its layout tables, without which every letter renders in isolated form.

Status: implemented, tests green, not yet deployed. Eight languages still have no native reader.