Files
hoelee-blog/docs/project-state.md
T
hoelee 4e64bfc9ae
Deploy / build (push) Successful in 33s
fix(listing): make the mobile archive reachable and clear the AA misses
Review pass on the 041471d delivery, measured rather than eyeballed.

/posts/ on a phone put its first archive row 2.36 screens down (featured block
1235 px + 435 px of controls = row at 1996 px at 390x844), so a reader who came
to browse had to scroll past three stacked 16:9 cards. Below 700 px the cards are
now horizontal — thumb 38 %, excerpt dropped, title clamped to three lines — and
the block header carries `All 91 posts ↓` into #archive-list (with scroll-margin
for the sticky header), which doubles as the bypass-block affordance. Block
1235 → 523 px, card 392 → 153 px, first row 1996 → 1309 px.

Two new small-text colours measured under WCAG AA (.feat-min 3.04:1,
.archive-status 3.67:1 at ~12 px) move to --muted → 5.78:1 and 6.99:1. The
featured heading went with them; pre-existing --faint dates (3.67:1) were left
alone rather than restyling 91 rows.

/categories/ still showed the old Gemini case-studies.webp while the category
page had the new topology banner — one category, two art languages. The tile now
uses case-studies-banner-1200.webp (14 KB) letterboxed on #0b1613: a 16:9 centre
crop sliced the outer host panels in half, the letterbox was measured seamless.

`6min read` was emitted without its space (template-literal fix → `6 min read`,
`6 分钟`, verified in dist/). Both /posts/ ledes stopped claiming the page is
purely newest-first.

probe-layout: 72/72 viewport checks, collapse still 425→0→425 (1280) and
523→0→523 (390) in EN and ZH.
2026-10-27 05:27:06 +08:00

58 KiB
Raw Blame History

Project State & Enhancement Backlog

Living list of what's done and what's next for blog.hoelee.com. Work through these one by one — don't batch unrelated changes. Check off items as they land.

Status key: ✅ done · 🔵 in progress · ⬜ not started

How to resume the project: start at the top-most ⬜ item in the Execution Plan (§2) below and work downward. Each step is self-contained, has a "done when" criterion, and references the governing doc. Don't jump ahead — earlier steps unlock later ones.

Slimmed 2026-10-19: the completed-phase logs (Phases A–D and F, plus the §1 batch detail) moved verbatim to docs/handoffs/project-state-history.md. What stays here is the live queue plus one summary line per finished phase. As of 2026-10-19 the only open ⬜ items in the queue are owner actions (Cloudflare Web Analytics, Search Console) — engineering has no unstarted step left.


1. Done (foundation)

  • ✅ Astro 5 static + Markdown, Gitea Actions CI/CD → nginx → Cloudflare (deployed)

  • ✅ Design system: hoelee.com brand palette, Inter + JetBrains Mono, light/dark, sticky nav, code copy button

  • ✅ Brand "Mr Hoelee" (replaced "hoelee.dev")

  • ✅ Header brand = logo mark + real name (2026-10-26): Lee Teong Hoe in the header, preceded by the three-colour brand mark (/images/hoelee-mark-96.png, 96 px rasterised from the brand vector so it stays sharp at 26 CSS px). On ≤600 px the header is two rows — brand + search/lang/theme on row 1, the four section links on row 2 (ul.nav-links takes flex-basis: 100%) — measured 320/360/390/414/600/768/1280: scrollWidth == innerWidth at every width, nothing clipped. Same favicon set as learning.hoelee.com.

  • ✅ Author card + Person/ProfilePage JSON-LD (E-E-A-T), article meta, reading time, related posts

  • ✅ Full SEO: canonical, Open Graph (+dims), twitter:card, favicon (all sizes), RSS + sitemap

  • ✅ Category pages (/categories/, /categories/[category]/)

  • ✅ /posts/ hybrid archive + case-study portfolio (2026-10-27): fixed-slug flagship cards, collapse-on-filter, portfolio hero, action links, 44 px tap targets — see §4e

  • ✅ Locale scheme: English flat in posts/, Chinese in posts/zh/ (lang derived from folder)

  • ✅ Language switcher in nav — links to the same post in the other language (auto-matches by zh/ prefix)

  • ✅ Locale-aware nav labels (EN/ZH)

  • ✅ Dark mode default (light opt-in via toggle)

  • ✅ Case study "How I Host This Blog"

  • ✅ Chinese translation of the case study (posts/zh/how-i-host-this-blog.md)

  • ✅ hello-world intro post

  • ✅ Case study "How I Built the DigiKedai Telegram AI Bot" + Chinese twin (posts/zh/how-i-built-the-digikedai-telegram-bot/)

  • ✅ Business framing corrected (website design & development = primary; email hosting = secondary)

  • ✅ Knowledge guides in docs/ + README index + hoelee-blog skill

  • ✅ Both repos public (Gitea + GitHub) with title/description/homepage/topics + v1.0.0 release

  • ✅ Live re-audit of the Sept research recorded (§2 Phase E + §4), 2026-09-29 — 53 posts × 2 languages, 127 sitemap URLs, 133/133 internal links 200 Batch index — full logs for every batch (measurements, commits, traps) are in docs/handoffs/project-state-history.md.

  • ✅ B1 image batch (2026-10-16) — article banner → <picture> WebP (70 banners 6.02 MB → 2.52 MB); related cards went text-only; list thumbs got srcset 400/800; desktop banner height capped.

  • ✅ B2 broken windows (2026-10-16) — /posts/zh/ 403 → 301; new /zh/posts/; ZH nav fixed; og:locale en → zh_CN; hreflang/<html lang> → zh-Hans; a real 404 page; nginx config → host bind mount (DSM stack 247).

  • ✅ B3 structure (2026-10-16) — site search had never worked (the Pagefind ES module was read as window.Pagefind); archive 18,220 → 3,788 px with a client-side filter; tag pages (235 tags measured, 156 used once); EN/ZH hero + nav fixes.

  • ✅ B4 identity & distribution (2026-10-16) — real author photo in all five surfaces + JSON-LD Person.image; About 1,024 → 7,357 chars; /llms.txt.

  • ✅ B5 baseline & hygiene (2026-10-18, 3f63150) — /og-default.jpg 211 KB → 45 KB carrying the headshot; banner-404 sweep to 72/72 clean; CJK system font stack; A2 branding alignment.

  • ✅ B6 visual redesign + Ideogram 4 pipeline (2026-10-18) — the consultant's hybrid "one art, four targets" template; bento theme tokens (a hardcoded dark scrim measured 1.96:1 text contrast in the light theme → 6.10). Phase F is its rollout.

  • ✅ B7 review-response (2026-10-19, 5941e85 + 5ec321a) — About counter derived from the collection (71 → 72); reading time is CJK-aware (ZH 1 min → 5 分钟); ZH wording fixes; hello-world's stale language policy and container count; hero leads with the capability; featured cards got the srcset they lacked; 48/48 mobile-overflow combinations at 0 px.

  • ✅ B8 Consultant Review R4 & Blueprints Architecture (2026-10-27) —

    • Stream separation: Homepage splits into 6 engineering posts + bottom GPU & Multimodal Lab strip.
    • Top navigation: Blueprints | Case Studies | All Posts | Work With Me (ZH: 系统蓝图 | 工程案例 | 全部文章 | 关于与合作).
    • Hero CTA: Malaysian SME button anchors directly to /about/#sme (ZH: /zh/about/#sme).
    • Dual-track /about/ retains canonical URL, redirects /work-with-me/ via 301.
    • Three System Blueprints (/blueprints/ and /zh/blueprints/):
      • self-hosted-infrastructure (162 Containers, 4 Layers)
      • ai-agent-automation (Deterministic Workflows + GPU Inference, 4 Layers)
      • sme-digital-suite (High-Performance Web, SifuMail, Entitlements, 4 Layers)
    • 94 post files mounted to the 12 Blueprint Layers across EN and ZH with in-article breadcrumbs.
    • Schema.org TechArticle enrichment with dependencies and audience.

2. Execution Plan (work top → bottom, one step at a time)

The live queue is Phase E. Phases A–D and F are done; their logs moved to docs/handoffs/project-state-history.md on 2026-10-19. What stays below is one summary line per completed phase so "is this done?" is still answerable here without opening the archive.

Phase E — Distribution & visibility (added 2026-09-29 from the live re-audit — DO THESE FIRST)

The Sept research assumed the constraint was identity + content. Measured on 2026-09-29 that is no longer true: 53 posts live in both languages, 127 sitemap URLs, and all 133 internal links across the home, archive, category, about and ZH pages return 200. The site is built and healthy — almost nobody can find it, and there is no instrumentation to tell whether anyone does. Full evidence table in §4.

Step E1 — Analytics + the search-performance loop. ⬜

There is no analytics of any kind: home, post, about and ZH pages were grepped for GA/GTM, Plausible, Umami, Matomo, Clarity, PostHog and Cloudflare's beacon.min.js — zero hits. Without it, "did this post get read" is unanswerable, and the job-hunt thesis can't be verified. Decisions (2026-09-29):

Option Verdict
Cloudflare Web Analytics ✅ Primary. Free with no event/traffic cap, cookieless (no consent banner, no CLS cost), one snippet in the base layout — and it reports Core Web Vitals field data, which the Sept report's CWV item otherwise has no way to check.
Google Search Console ✅ Not optional, and half-done already. hoelee.com carries a google-site-verification=… TXT record, so a domain property already covers blog.hoelee.com. Unconfirmed: whether https://blog.hoelee.com/sitemap-index.xml was ever submitted. Only the GSC UI can answer that.
Umami (self-hosted on DSM — MIT, ~1 M events/month free cloud tier) 🔵 Optional later, only if per-event funnels or full data ownership are wanted.
GA4 / Plausible CE ❌ Skip — GA4 brings weight + a consent banner; Plausible CE needs Postgres and ClickHouse.
  • Enable CF Web Analytics for the zone in the Cloudflare dashboard. ⚠ There is no repo change to make — Cloudflare injects its own cloudflareinsights beacon once the site is added, so this is an owner action, not a layout edit. Re-check with curl -s https://blog.hoelee.com/ | grep -c cloudflareinsights → 0 = still not enabled (measured 2026-10-19).
  • Confirm the GSC domain property covers the blog, submit sitemap-index.xml (it serves 200 — measured 2026-10-19), and re-check "Discovered / Indexed" counts ~a week later.
  • Done when: a pageview from a second device shows up in CF Web Analytics, and GSC lists the sitemap as Submitted with the post URLs Discovered.
  • Governing doc: seo-reference.md.

E1 progress — self-hosted Umami deployed 2026-09-29 ✅ (deploy only; not yet public, not yet wired to the blog)

Item State
Stack DSM Portainer stack 284 umami (endpoint 2), container umami, host port 5410 → 3000, network bridge_hoelee
Image ghcr.io/umami-software/umami:postgresql-latest → resolved v3.4.0 (Node 22.23.2). ⚠ v3 publishes versioned tags on docker.umami.is, but ghcr only carries rolling tags (postgresql-v* stops at 2.16) — record the running version before any upgrade or there is no tag to roll back to
Database Reused the shared instance: stack 139 postgres → postgres-server (PG 17.10, Etc/UTC). New role + DB umami (login only, not superuser). CREATE EXTENSION pgcrypto works because PG 13+ treats it as trusted. ⚠ Data lives in /volume1/docker/postgres-server/data next to authentik / n8n / tubesync / crowdsec — same fate if that volume is restored
Env DATABASE_URL, APP_SECRET, TWO_FACTOR_ENCRYPTION_KEY, CLIENT_IP_HEADER=x-forwarded-for, DISABLE_TELEMETRY=1, DISABLE_UPDATES=1, MCP_ENABLED=1, TZ=Asia/Kuala_Lumpur. No cpus: (DSM has no CFS quota)
Verified on LAN /api/heartbeat → {"ok":true} (first hit 6.1 s cold, then 66 ms), /login 200, container healthy, prisma migrations created 16 tables
Ops notes /volume1/docker/umami/README.md
Public URL https://stats.hoelee.com live 2026-09-29 — DNS A → 180.73.9.11 (the user's usual path, not through Cloudflare) → router 443 → DSM nginx reverse proxy (ReverseProxy.json key 8df2ab4d-ff73-47a0-b5ad-5a09957e6402, frontend stats.hoelee.com:443 → localhost:10000 = the authentik outpost since the SSO cut-over) → http://umami:3000. Cert = Let's Encrypt CN=stats.hoelee.com, 2026-09-28 → 2026-12-27; http:// → 308. Public allowlist via skip_path_regex only: /script.js 200, POST /api/send 200/400, /api/heartbeat 200; every dashboard path 302 → auth.hoelee.com
Client IP fix ⚠ Because traffic does not pass Cloudflare, CLIENT_IP_HEADER was changed cf-connecting-ip → x-forwarded-for (what DSM nginx sets) on 2026-09-29, stack re-PUT and verified in the running container. Without it every visit would be attributed to the proxy. Consequence of no CF: no WAF/rate-limit/bot protection on this hostname
✅ Hardening (A+B) done 2026-09-29 (A) Default admin/umami replaced with a random 20-char password (C:\Users\hoelee\.secrets\umami-admin.txt) and APP_SECRET rotated → every previously issued session invalidated. Verified: old password 401, new password 200, old token 401. (B) New stack 285 umami-gateway (nginx:1.29-alpine, holds host port 5410 = what the DSM vhost targets) — public allowlist is exactly /script.js, /api/send, /api/heartbeat; everything else 403. Umami itself no longer publishes a public port; the dashboard moved to LAN-only 192.168.1.1:5411 (stack 284 republished 5411:3000), which is unreachable from the internet — verified from the VPS: TCP 5411 closed/filtered, TCP 443 open. ⚠ Superseded later the same day by the SSO gate below — the DSM vhost now points at the outpost, so stack 285 is no longer in the public path but is left running as the rollback
⚠ Gate design constraint found HTTP basic auth cannot be used here: the Umami front end sends Authorization: Bearer <jwt> on its own API calls, and a browser sends only one Authorization header — Bearer replaces Basic, so a basic-auth gate 401s every API call and the dashboard breaks. If a remote dashboard is ever wanted, one hostname is still enough — either rely on Umami's own login (+ enable 2FA) or put cookie auth (authentik forward-auth / oauth2-proxy) on this same hostname with skip-paths for the tracker (the traefik-login-numerology pattern). A second hostname (tracker vs admin) is the PostHog/Sentry-scale ingest-vs-app split, not a requirement. stats-admin.hoelee.com was only a hypothetical and was never created (NXDOMAIN verified)
End-to-end tracker verified A real pageview POSTed through the public gate was recorded: 1 pageview / 1 visitor, with country=MY, region=MY-07, city=George Town, browser chrome, os Windows 10 — proving the X-Forwarded-For chain (DSM nginx → gate → Umami) works and geo needs no Cloudflare headers. The temporary test website was deleted afterwards (website list back to 0)
Still open (a) CF Web Analytics still not enabled (independent of Umami — it is the zero-code option and would also give Core Web Vitals field data). (b) GSC sitemap submission still unconfirmed — only the GSC UI answers it. (c) ✅ Resolved 2026-09-30 — the tracker is wired into three sites (see Step B2j) and pageviews are landing with real geo + referrer; "recording" is no longer in doubt, only the CF/GSC half of E1 remains
Dashboard login Two ways in (since the SSO cut-over): https://stats.hoelee.com — authentik SSO first, then the app's own login — or the LAN break-glass http://192.168.1.1:5411. Credentials: admin + the random password in C:\Users\hoelee\.secrets\umami-admin.txt (agent-set 2026-09-29; change it in Settings → Profile if you prefer). Per-account 2FA is available (TWO_FACTOR_ENCRYPTION_KEY already set)

E1 progress — SSO gate wired 2026-09-29 ✅ (authentik forward-auth-style proxy in front of the dashboard; tracker stays public)

The user asked for the dashboard to be reachable remotely without publishing an Umami login page, i.e. one hostname, SSO on the dashboard, tracker open. Built on the existing authentik instance (auth.hoelee.com, 2026.8.2) instead of a second container:

Item State
authentik objects proxy provider pk 64 Provider Proxy Stats (mode=proxy, external_host=https://stats.hoelee.com, internal_host=http://umami:3000, skip_path_regex = ^/script\.js$ + ^/api/send + ^/api/heartbeat$ + `^/mcp(/
Per-app login flow auth-stats (designation authentication, 4 stages: identification → password → mfa-validation → user-login), so the SSO page for this app is its own branded screen and not shared with the other 19 apps. Provider authentication_flow points at it
App icon ⚠ In this build the application icon field is meta_icon (not icon — PATCHing icon silently no-ops). Uploaded with the house convention application-icons/umami.png via POST /admin/file/ (multipart, usage=media), then PATCH /core/applications/umami-stats/ {"meta_icon": "application-icons/umami.png"} → meta_icon_url /files/media/public/application-icons/umami.png verified 200 (21,989 B PNG)
DSM cut-over vhost …c378795c2acb.w3conf §2731 proxy_pass http://localhost:5410 → http://localhost:10000 (the outpost) + ReverseProxy.json "port" : 5410 → 10000; backups *.bak-20260929-sso-stats-sso. Both edits asserted unique first (grep -c 5410 = 1 in each file), nginx -t clean, reloaded
⚠ Pitfall found mode=proxy is required here, forward_single breaks it: forward_single leaves internal_host empty, so the outpost has no upstream — the SSO redirect chain looks perfectly healthy while every app path silently 404s (/script.js 404 with x-powered-by: authentik). proxy + internal_host=http://umami:3000 is also what HA (pk 6) / NocoDB (pk 62) use
⚠ Other pitfall After any provider change the embedded outpost needs ~1–2 min to pick up the new config; before that, / answers 302 → /flows/-/default/authentication/ and app paths 404. Judging the wiring before that window expires gives a false "it's broken"
Verified from the public internet (2026-09-29) anonymous / → 302 → /outpost.goauthentik.io/start → auth.hoelee.com/if/flow/auth-stats/… → 200 (same shape as the working auth-mysql chain); /login, /api/websites → 302 gated; /script.js 200, /api/heartbeat 200, POST /api/send 400 app-level
End-to-end tracker through the SSO path temp website + 2 pageviews POSTed to https://stats.hoelee.com/api/send → recorded 2 pageviews / 1 visitor / 1 session, country=MY, region=MY-07, city=George Town, referrer metric google.com → the outpost does not break X-Forwarded-For, geo still works; temp website deleted
MCP over the public URL (added 2026-09-29, same day) `^/mcp(/
Tracker wired into the two sites (2026-09-29) ✅ Website records created: blog.hoelee.com → 1817d8f6-0e49-4b9a-a50d-307e4f2962c7, www.digikedai.com → 61b57143-f3f4-4353-9ede-9c615146de84. Snippet added to blog/src/layouts/BaseLayout.astro (commit de17bea, pushed Gitea+GitHub, Gitea Actions → live in ~1 min; covers EN and ZH since both use the layout) and to dsm-resource-management/www/src/layouts/Base.astro (CF Pages via ./deploy.sh, deployment 4d3d6aa6.digikedai.pages.dev). Both tags carry is:inline (Astro would otherwise process the script) + data-domains so local dev never reports. Verified live HTML on both domains contains the data-website-id; verified end-to-end with a real Chrome (Canary CDP 9222): blog / + /zh/ → 4 pageviews/2 visitors, digikedai / + /products/ → 2 pageviews/1 visitor, geo MY / MY-07 / George Town, chrome/Windows 10; then POST /api/websites/<id>/reset cleared the test rows (both back to 0)
⚠ Verification pitfall: headless browsers are ignored Umami drops bot/headless user agents — HeadlessChrome/151 (DSM browserless) → 200 {"beep":"boop"} and nothing stored; a normal Chrome UA → 200 {"cache":…} and stored. So beep boop = ignored, not success, and an empty dashboard after a headless test is not a bug. Verify tracking with a real browser (Canary CDP 9222: curl -X PUT "http://127.0.0.1:9222/json/new?<url>"), or at least a normal-UA curl
Not done yet CF Web Analytics beacon + GSC sitemap-index.xml submission (both independent of Umami)
Re-measured 2026-10-18 Live homepage carries exactly one tracker tag (stats.hoelee.com/script.js, data-website-id=1817d8f6…) and zero cloudflareinsights references. Enabling CF Web Analytics is an owner dashboard action — there is no API token on this host with account scope (the deploy token purges only, and ~/.secrets/cloudflare-stats-token.txt is still an unfilled placeholder), and CF injects its beacon itself once the site is added, so there is no repo change to make. GSC submission likewise only answers in its own UI.
Known trade-off (honest) (1) SSO protects the dashboard route, but Umami has no OIDC, so after SSO the user still sees Umami's own login page — double login, expected, one-click after the first time (2FA is available per-account in Settings → Profile; TWO_FACTOR_ENCRYPTION_KEY already in the stack env). (2) <http://192.168.1.1:5411> remains a LAN break-glass path that bypasses SSO (still needs Umami credentials; verified closed from the internet) — delete/close it if that is not wanted. (3) If the authentik outpost goes down, the dashboard goes down with it (tracker too — but the site keeps loading, the tracker fails silently)
Rollback Start the gate again (sudo /usr/local/bin/docker start umami-gateway, or start stack 285 in Portainer) → re-point the vhost + ReverseProxy.json to 5410 → reload nginx. Backups of both files sit next to the originals. (The gate container is now Exited (0) — it was stopped once the outpost took over, so port 5410 is free; the stack is kept as the rollback asset)
Note The hermes3 authentik API token expired mid-session (DB says expires 2026-09-28 22:17:08Z); the leftover self-test account was removed through authentik's own ORM (ak shell), so no admin-group test user is left behind

Step E2 — Wire the two sites together (www.hoelee.com → blog). ⏸ Recorded and deferred — user decision 2026-10-17: 先记录 www.hoelee.com 过后再改 (record it now, revisit the main site later)

Measured: all six pages of www.hoelee.com (home, /zh-hans/, /about-mrhoelee/, T&C, support, privacy) contain zero references to blog.hoelee.com, while the blog links out to www.hoelee.com. The older, more established domain passes no authority and offers no click-path to the portfolio — one-way.

⚠ User decision 2026-09-29: www.hoelee.com needs a full overhaul; do NOT patch it piecemeal now. Fold this link into that overhaul (together with the v2.1 audit finding that its agency framing conflicts with the job hunt). Tracked here so it isn't lost.

Step E3 — Syndicate 2–3 flagship posts. ✅ Both halves automated (dev.to 2026-10-17, LinkedIn 2026-10-16; measured 2026-10-18)

seo-reference.md already carries the syndication policy (cross-post with canonical back to the original domain); until 2026-10-17 nothing had ever been syndicated, so all 72 posts existed only for people who already knew the URL — the daily dev.to drip below is the first distribution channel outside the site.

  • ✅ dev.to cross-posting is automated (2026-10-17). scripts/devto-crosspost.py in the Hermes scripts dir (C:\Users\hoelee\AppData\Local\hermes\scripts\) publishes one queued post per day with canonical_url pointing at the blog original, from a Hermes no_agent cron (devto daily crosspost, id ce8fc8bd23e6, 25 12 * * *, deliver Telegram). Queue = 70 ranked titles / every live post except two — hello-world (blog intro page), how-i-made-my-own-songs-with-suno-ai (8 <audio> players + a lyric <script> that dev.to's sanitiser strips) — at one per day ≈ 2.3 months. The post that had been held for review joined the queue on top on 2026-10-18, the day it published. devto-state.json makes it idempotent per slug; the API key lives in ~/.secrets/devto-api-key.txt, never in the repo. Two guards added the same day: an entry is refused while its frontmatter still says draft: true or its blog URL is not HTTP 200 (a transient 5xx after 3 retries aborts the whole run instead of skipping, so queue order is never reshuffled by a tunnel blip), and --validate re-checks all 69 entries read-only (last run: 69 entries, 1 published, 0 blocked). First article live and verified: https://dev.to/hoelee/how-i-built-the-digikedai-telegram-ai-bot-39of — canonical_url matches the blog post in the API read-back and in the live page's <link rel="canonical">, tags ai, python, showdev, automation, cover image re-hosted by dev.to (so the banner landed). ⚠ Quirks written into the hoelee-blog skill: the banner and the layout hero are frontmatter (a verbatim body copy ships imageless), relative links must be absolutised outside code fences only, and dev.to rejects unknown tags.
  • ✅ LinkedIn half is automated too (installed 2026-10-16, measured 2026-10-18). Hermes no_agent cron linkedin-daily-post (id 35f8985f609a, 30 12 * * *) runs scripts/publish_next.py --commit in D:/dev/hoelee-career-agent and stays silent unless it published / failed / ran dry — silence is not proof of work, so read cron/output/35f8985f609a/*.md: 5 posts published as of 2026-10-18 12:30 (today's was read-only-nocodb-dashboard-for-a-remote-database). Its "already published" ledger is NocoDB activity_events, not a local file. So the old manual criterion is superseded: what remains is feeding the approved-draft queue, not clicking share.
  • Done when: ✅ each dev.to copy is live with the canonical set (2 of 69 so far, one per day), and the LinkedIn drip is publishing (5 measured, daily 12:30).

Step E4 — Chinese-side routes and nav are broken. ✅ Done 2026-10-16 (batch B2), re-verified live 2026-10-17

Live ZH pages: the nav renders 「文章」→ /zh/ (fallback; /zh/posts/ 404s) and 「关于」→ /zh/ — i.e. the Chinese nav's About link is wrong even though /zh/about/ itself returns 200 and is in the sitemap. grep '/zh/about/' finds no inbound link anywhere on the site, so the ZH About page is orphaned. ⚠ Also measured 2026-10-16 while publishing four posts: /posts/zh/ returns 403 (not 404) while /zh/ and every /posts/zh/<slug>/ return 200 — there is no ZH post-archive route, so that path is answered by something else entirely (CDN/nginx, not the static build). Not a regression from the publish; it is this same missing route.

  • ✅ Built in batch B2 (src/pages/zh/posts/index.astro); nginx 301s /posts/zh/ → /zh/posts/; the ZH nav points 「文章」 → /zh/posts/ and 「关于」 → /zh/about/.
  • Done when: ✅ measured 2026-10-17 — /zh/posts/ 200 (74 KB, the ZH archive with every row), /zh/about/ 200, /posts/zh/ 301, and both ZH nav links resolve when clicked.

Step E5 — og:locale is wrong on all 53 Chinese pages. ✅ Done 2026-10-16 (batch B2), re-verified live 2026-10-17

ZH post pages emit <meta property="og:locale" content="en"> with og:locale:alternate = en_US.

  • Done when: ✅ measured 2026-10-17 — a ZH post page emits og:locale=zh_CN with og:locale:alternate=en_US.

Step E6 — Cloudflare edge HTML caching. ✅ Done 2026-10-17 (Cache Rule + purge + freshness proven); HSTS waived by the owner 2026-10-18

A post page used to return cf-cache-status: DYNAMIC. A static site behind CF should be edge-cached — lower TTFB and less origin traffic through the tunnel.

  • ✅ Cache Rule in place (owner action, 2026-10-17: Edge TTL → Override origin → 2 hours; the dashboard offers no 30-minute preset, which is why 2 h was picked — freshness comes from the deploy purge, not from the TTL). Measured the same day: 10 sequential requests to /posts/ → 9 × HIT / 1 × MISS, ages 7–49 s; a post page MISS → HIT (age 1 s) → HIT (age 661 s), TTFB 0.26–0.31 s on HIT vs 0.55 s on the cold MISS. ⚠ Two single-shot MISSes are NOT evidence the rule is broken. Cloudflare's edge cache is per data centre (CF-RAY showed NRT then SIN inside the same minute), so MISS twice in a row only means two caches were read from. Judge caching with ≥ 8 sequential requests (and read age), never with two.
  • ✅ The deploy-side purge is proven live, not assumed. The Gitea Actions run for 112cf7d (task 252 / run 11313, job 22965) logs Purging zone *** … {"success":true,"errors":[],"messages":[],"result":{"id":"…"}} — CF_TOKEN and CF_ZONE_ID are set and the API call succeeded, so a publish is not stuck behind the 2 h Edge TTL.
  • HSTS is still max-age=0; includeSubDomains; preload — waived by the owner 2026-10-18 (「HSTS 无视吧,没有关系的」). Re-measured the same day: still max-age=0; includeSubDomains; preload. Nothing is broken by it (max-age=0 makes preload inert, and Cloudflare's "Always Use HTTPS" already covers the http→https hop), so this is a deliberate no-action, not a gap. Do not re-open it unless the owner asks.
  • ✅ Freshness proven end-to-end, 2026-10-17. Before the push, /posts/ was a HIT still serving last-modified 14:35:23; 67 s after the push the same URL served last-modified 14:56:41 with age=0 — the purge invalidated the 2 h-cached copy and the new build was live inside a minute, so the Edge TTL cannot hide a publish. ⚠ Recipe: compare the last-modified header before/after a push (nginx stamps it at deploy time) — cf-cache-status: HIT alone can be a 2-hour-old build, which is exactly the trap this test exists to catch.
  • Done when: a repeat request shows cf-cache-status: HIT ✅ and a deploy goes live within ~1 minute ✅ (67 s measured) and HSTS carries the decided value ⏸ waived 2026-10-18 by the owner — the step is closed.

Step E7 — Content-volume guardrail. ⬜ ongoing

53 posts shipped in ~4 weeks (30 dated 2026-09). The volume itself is the "content-farm" signal content-guide.md §7 warns about, even though every post traces to real logs/commits. Two rules:

  • Keep the existing per-post "every claim traces to a source file or log" check — do not relax it for volume.
  • Never stack more than ~3–5 posts on one pubDate (worst day so far: 2026-09-18 with 5). Spreading a batch by backdating into archive gaps is the sanctioned method.

Step E9 — Topic queue after the 2026-10-20 sweep. ✅ Shipped 2026-10-20

Topic Slug Category Status
Local 1080p AI video on a 12 GB card (Wan 2.2 + SeedVR2, measured) running-ai-video-locally-on-a-12gb-gpu ai published EN+ZH 2026-10-20 (commits 3efeafc…df4e193)
SeedVR2 3B crashes ComfyUI at >41 frames (native access violation) — notes optional short note; the war story is already in the published post
Draft → final → upscale batch runner (make_short.py) — ai not written; the pipeline commands are in the published post

No topics were killed by the user in this sweep.

Step E8 — Small hygiene backlog. ✅ Done 2026-10-18

  • ✅ scraping-bot-walled-marketplace-warm-browser-session: banner generated + optimized (public/banners/...{png,webp}), frontmatter wired in EN+ZH twins. Measured with measure_og_banner.py → PASS (banner 8 rows, delta=2, overflow=0).
  • ✅ authentik-css-greater-than-bug (the > bug) — published 2026-10-16 (2295c0f): draft: true → false in EN + ZH, pubDate kept at 2026-09-20 with updatedDate: 2026-10-16 so lastmod stays honest, OG + banner generated (og rows=1 overflow=0; banner delta=2 overflow=0), and the CTA's displayed phone corrected (011-797 2969 → +60 12-797 2969). Verified live: EN/ZH/og/banner all 200, language switch both ways, /posts/ order still monotonic. The draft bank is now empty.
  • ✅ og-default image & harness — delivered & integrated 2026-10-18:
    • Layout delivered from outside (hoelee-og-delivery) with author photo (lee-teong-hoe-640.jpg), brand mark, real name, and full identity. Kept in repo as scripts/og-gen/template-default.html + og-default-specs.md.
    • Generator rewritten: scripts/og-gen/generate-default.mjs runs Chrome headless, probes DOM via --dump-dom (6 automated checks: overflow=0, margin>=64, headline>=48, bottom clear>=64, retired wordmark absent, identity lines present).
    • Format optimized to JPEG (45 KB, quality 85 mozjpeg) as the primary site-wide share card (/og-default.jpg), reducing weight by 4.7× vs unoptimized PNG (211 KB). PNG fallback is also rendered and kept beside it for backward compatibility.
    • Linked across BaseLayout.astro, PostList.astro, and src/pages/posts/[...slug].astro.
  • Done when: ✅ no page references a 404 image, the draft bank is empty, and the default share card matches the A1 photo.

Step E9 — Fonts tuned for aesthetic reading (EN & ZH). ✅ Done 2026-10-18

src/styles/global.css typography modernized with an intentional, zero-network-cost, highly-legible system font stack:

  • Sans / Body: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif
    • English: modern native system UI (San Francisco on macOS/iOS, Segoe UI on Windows, Roboto on Android) with excellent kerning and clarity.
    • Chinese (Hans): explicit priority to macOS/iOS PingFang SC (冬青黑体/苹方), followed by Windows Microsoft YaHei (微软雅黑) and Linux fallback, eliminating jagged default CJK rendering.
  • Code / Mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, Menlo, Monaco, monospace
    • Crisp ligatures and distinct 0/O/1/l/I glyphs for technical code snippets.
  • Zero webfont transfer overhead, 0 ms FOUT/FOIT layout shifts, fully aligned with the blog's ultra-fast static philosophy.

Phase F — Artwork rollout: v2 "one art, four targets" (DONE 2026-10-19)

  • ✅ All 72 base frames rendered (1360×768) and OCR-gated; art-meta.json carries 72 hand-written evidence lines (scripts/og-gen/build_art_meta.py regenerates them and aborts on any slug that lacks one).
  • ✅ 288 frontmatter fields migrated (/og/<slug>.jpg, ZH /og/zh-<slug>.jpg, banner: /banners/<slug>.jpg); the 144 v1 PNGs deleted; PostList.astro + [...slug].astro fixed for the new extensions.
  • ✅ Live: EN/ZH cards, banner WebP and both thumbs 200; v1 .png 404; build 251 pages, 0 missing image refs.
  • Re-surveyed 2026-10-19: 72/72 banners 1600×900 WebP · 72/72 -800 thumbs 800×420 · 72/72 -400 thumbs 400×210 · 144/144 share cards 1200×630 JPEG.
  • Open, and not engineering: the round-5 consultant questions ("no text" acceptance line, banner framing, dark art on the light theme) — see the phase log.
  • ⚠ Overwriting art in place keeps the same URLs, so a returning reader sees the old picture from their own cache; hard-refresh before judging a restyle batch.
  • ✅ Related Posts / 相关文章 went from 4 text-only cards to 6 cards carrying each post's own share card: ogImage per post means the EN page shows /og/<slug>.jpg and the ZH page /og/zh-<slug>.jpg — no cross-language art. Measured in the built HTML: 6 <img class="related-thumb">, all 6 files 200, ZH page resolves all 6 to zh- files.
  • Layout: 2 across on desktop, 1 across on phones (≤600px), where only the first 4 cards are shown (.related-extra hidden, i >= 4 in [...slug].astro). The 3-across version was tried first and reverted same day — two-up at 1200px keeps the baked-in OG type readable.
  • Verified with headless Chrome by injecting the built <section class="related"> + built CSS into a probe at 1264 / 884 / 500 px: gridTemplateColumns = 3 / 2 / 2 columns, visible cards 6 / 6 / 4, scrollWidth == clientWidth everywhere (no overflow), 6/6 images decoded.
  • ⚠ Headless --window-size=390 is silently clamped to 500 on Windows — the right-hand column looks clipped in the screenshot, but that is the capture, not the layout. Verify <500px by putting the probe in a 390px <iframe> and screenshotting that.
  • ⚠ --virtual-time-budget can expire before lazy images paint (5 of 6 cards came out as empty var(--border) rectangles at budget 6000). Use ≥20000 before declaring an image broken.
  • Tag pages now carry the same chrome as /posts/ (/tags/<tag>/ and /zh/tags/<tag>/ used to pass chrome={false} and show only a count line): the title/tag filter input, the category chips and the tag-chip strip. Category chips and the row filter stay scoped to the posts on the page, so their counts match the rows; the tag strip is built from the whole language collection (chipPosts) because those chips navigate — page-scoped counts there would lie — and the tag being viewed gets is-active + aria-current. Measured at 1264/500px: gaps 8px/22px between bar → category chips → tag chips → list, scrollWidth < clientWidth, 14 tag chips, 4 category chips on /tags/docker/.
  • Bytes: 6 × ~65 KB JPEG ≈ 390 KB, loading="lazy" and below the fold. The /og/thumb/ WebPs (5.8 / 16.1 KB) are keyed by base slug with no ZH variant, so they cannot be used here without first emitting /og/thumb/zh-<slug>-{400,800}.webp.

Phase H — Two posts shipped without art (2026-10-20), and the gate that stops it recurring

  • ⚠ Cause: the two posts from 2026-10-20 (yue2-on-a-12gb-gpu-songs-that-lost-their-endings, generating-subtitles-for-songs-without-timestamps, EN+ZH) were published text-complete only. render_from_art.mjs reads ogImage/banner but nothing writes them, and after the 2026-10-19 bulk migration no script had to — so both pages advertised /og-default.jpg (generic card in every share preview and in the new related grid) and rendered no hero banner. The pipeline itself was fine and up to date: python scripts/og-gen/sync_prompts_catalog.py --check named exactly these two slugs (missing=[...], 73 metaphors vs 75 posts), which is the check nobody ran before pushing.
  • ✅ Art generated with the current one-command flow (C:/AI/ideogram4/post_art.py --slug … --metaphor … --evidence …), same v2 semantic style as the other 73: no-text 1360×768 frame → 1600×900 banner (jpg+webp) → EN card → ZH card → 400/800 thumbs.
  • ✅ New gate: scripts/check-assets.mjs runs as step 1 of npm run build, so a post that is text-complete but not image-complete now fails the Gitea Actions build instead of reaching nginx. It checks, per published post: ogImage present and language-correct (EN /og/<slug>.jpg, ZH /og/zh-<slug>.jpg), banner present (/banners/<slug>.jpg, shared by both languages), and the existence of banner .jpg + .webp + -400 + -800 thumbs. First run against the unfixed tree reported 16 problems, all on these two slugs and nothing else — it catches this bug class and is silent on the other 73 posts.
  • Frontmatter for the two posts now carries all four fields; nothing else about the posts changed.

Phase I — Three video & browser automation posts (2026-10-22)

  • Added 3 posts in EN + ZH (6 files) backdated into the historical gaps of the archive with updatedDate: 2026-10-22:
    • how-i-built-an-automated-music-video-pipeline (category case-studies, pubDate 2025-04-18)
    • uploading-large-videos-to-youtube-via-cdp (category devops, pubDate 2025-08-22)
    • debugging-remotion-video-jitter (category engineering, pubDate 2025-11-26)
  • Full art suite rendered via render_from_art.mjs (banner jpg+webp, EN/ZH OG cards, 400/800 thumbs).
  • All 158 published posts pass scripts/check-assets.mjs and npm run build cleanly (271 pages indexed).

Phase A — Identity (DONE)

  • ✅ Real headshot in the About hero, the author card on every post, the home hero (EN + ZH) and JSON-LD Person.image; no 404 for any derivative.
  • ✅ One branding line ("Full-Stack Developer & DevOps Engineer") aligned across SITE.title, meta description, JSON-LD and the homepage hero.
  • ✅ Homepage Featured = a 2×2 bento grid over four pinned flagship slugs (fixed slugs, not "newest 4").
  • Deliberately deferred: a separate /start-here page — the homepage strip was the immediate win.

Phase B — Content (the long game; all batches B1–B7 landed)

  • ✅ 72 EN + 72 ZH published posts (live figure; measured 2026-10-19), 0 drafts, every post has its twin.
  • Still open: the two gotchas nobody has written yet — Site-to-site OpenVPN behind CGNAT and Fixing the WordPress /cv 301→404 chain. Both were on the old list and neither slug exists in src/content/posts/.
  • The per-session topic queue is not kept here any more: it is re-derived from the Hermes session store each time (rules in the hoelee-blog skill, Sourcing the next post).

Phase C — Discovery & structure (DONE) · Phase D — Polish (DONE / declined)

  • ✅ C1 per-post OG images — now /og/<slug>.jpg + /og/zh-<slug>.jpg (B6b); the shared default card backs the homepage and category pages.
  • ✅ C2 tag pages — live at /tags/<tag>/ above the post threshold (measured 2026-10-19: /tags/docker/ 200). ⚠ The old [ ] here was stale — B3 shipped them.
  • ✅ C3 /categories/ lists all 7 with descriptions and SVG art; an empty category renders "0 posts · coming soon" and is deliberately not a link (so its route 404s until its first post).
  • ✅ C4 /zh/posts/ and /zh/categories/ both exist (B2), and the ZH nav points at them. ⚠ The old "still missing" line was stale since 2026-10-16.
  • ✅ D1 search (Pagefind; the loader bug is B3). 🔵 D2 GSC submission → merged into Step E1, owner-gated. ⛔ D3 newsletter/email capture — deliberately not doing until there is real traffic.

3. Research Findings Snapshot (Sept 2026)

What the reference blogs do that blog.hoelee.com should mirror, ranked:

Finding Reference example Status on blog.hoelee.com
Real name + photo + one-line identity All four ⚠️ name ✅, photo ❌ (letter "M") — Step A1
Focused thesis (one sentence on what it's about) Julia Evans, Simon Willison ⚠️ has it, but title/hero drift — Step A2
Honesty about what you don't know Simon, Dan Abramov ✅ strong (DigiKedai "bugs that ate an afternoon")
Specific detail: code, diagrams, numbers, bug stories All four ✅ strong
Consistent cadence (slow is fine, dead is not) Julia (~monthly), Simon (daily) ⚠️ only 3 posts, all Sept 4–6 — Phase B
"Start here" / Favorites route Julia Evans, Josh Comeau ❌ — Step A3
RSS + sitemap + clean SEO All four ✅
Per-post OG images Josh Comeau ✅ done (verify 2026-09-29 — §4)
Search (once >15–20 posts) Josh Comeau ✅ done — Pagefind live (§4)

4. Live Audit Snapshot — 2026-09-29 (measured, not assumed)

Method: curl from the Windows host (direct curl to blog.hoelee.com worked on this date — the DSM fallback was not needed), cross-checked against the local repo at D:/dev/hoelee-blog.

Check Measured result
Posts 53 published EN + 53 ZH (54 files each, 1 draft) — every published post has a ZH twin, no orphans either way
Sitemap sitemap-index.xml → sitemap-0.xml, 127 URLs (53 posts + 53 ZH + 19 landing/category + /posts/), lastmod honest; 254 xhtml:link hreflang alternates
Internal links 133 unique internal links across home / /posts/ / /categories/ / about / ZH pages → 133 × 200, zero 404s
robots.txt Pure ASCII, allows all crawlers (search and AI), Disallow: /pagefind/, declares the sitemap
Search Pagefind live — /pagefind/pagefind.js 200, nav search toggle on both locales (D1 done)
Per-post OG Every post serves /og/<slug>.png; 1 post has no banner (scraping-bot-walled-…)
Analytics None at all. No GA/GTM, Plausible, Umami, Matomo, Clarity, PostHog or CF beacon.min.js on home, post, about or ZH pages → E1
Google Search Console hoelee.com TXT google-site-verification=g9jeE8… exists ⇒ a domain property already covers the blog. Sitemap submission unverified → E1
www.hoelee.com 6 pages, 0 references to blog.hoelee.com (one-way linking) → E2 (deferred). Also /cv is now 301 → cloud.hoelee.com Drive share (GET 200) — the v2.1 "broken /cv" finding is FIXED; note HEAD still returns 404 (a Pretty Link quirk), so don't re-diagnose it from a HEAD
Author identity Author card is still the letter avatar <div class="avatar">M</div> on About and every post; no photo anywhere on the site → A1
ZH nav 「文章」→ /zh/ (no /zh/posts/ route: 404) and 「关于」→ /zh/ — while /zh/about/ is 200 but orphaned (zero inbound links) → E4 / C4
og:locale ZH pages emit en + alternate en_US → E5
Homepage No featured/start-here strip (A3). <title> says "engineering, DevOps & self-hosting" while the hero says "full-stack developer and DevOps engineer" (A2, cosmetic — lowest priority)
Caching cf-cache-status: DYNAMIC on a post page (✅ now HIT — owner's Cache Rule, measured 2026-10-17, §4b); strict-transport-security: max-age=0 → E6
Tags Tags render as plain labels (not links), so /tags/<tag>/ 404s cause no broken links — C2 is a missed-discovery item, not a bug
Drafts Exactly one: authentik-css-greater-than-bug (EN + ZH) → E8
Cadence 30 posts dated 2026-09; worst single-day stack = 5 (2026-09-18) — the backdating work held, no single-day dump at the top of the feed
What did NOT change The Sept report's platform verdict (Astro static) and its theme advice (AstroPaper) — the platform call still holds; the theme pick is moot because the custom theme already shipped and works

4b. Live re-check — 2026-10-17 (after batches B1–B4)

Appended 2026-10-18 (measured after the B6 front-end deploy and the commit-churn publish):

  • / and /zh/ serve the bento featured grid (featured-bento-grid, 4 cards, OG thumbs naturalWidth 800); the light theme's pill contrast is fixed (1.96 → 6.10); last-modified moved within 67 s of the push, so the CF purge still works.
  • The commit-churn post is live in both languages (pubDate 2026-10-18), sitemap now 250 URLs, both new URLs carry lastmod 2026-10-18 + 2 hreflang alternates, RSS contains it, /posts/ order monotonic with it on top.
  • Cron facts at this moment: devto daily crosspost published 2 of a 70-deep queue, linkedin-daily-post has published 5 (silent unless it publishes — read cron/output/35f8985f609a/*.md, not last_status).

...

  • B6 Visual Redesign Milestone reached (2026-10-18):
    • Ideogram 4 pipeline running locally (1360x768, ar 16:9).
    • Outside consultant reviewed Round 1..4. Frosted Bento layout + blueprint grid + headline auto-fitting (FIT <n>px) verified on real Chrome Headless.
    • Delivery package Round 4 zipped (delivery-for-consultant-round4.zip, 2.4 MB).
    • Ready for the 72-post batch run — blocked on: the consultant's answers to the 3 typography questions, the og:image format decision (measured: 558 KB PNG → 84 KB JPEG q85), a GPU window for the ~70 missing base-art frames, and the promised OCR pseudo-text gate.

Method: curl from the Windows host, the unRaid nginx access log read over the Portainer API (no SSH to that host), and a DOM probe for the regenerated card. Spot values: /zh/posts/ 200 · /tags/docker/ 200 · /llms.txt 200 (48,386 B) · author photo live (lee-teong-hoe-640|128, WebP + JPEG) · no analytics beacon on any page · cf-cache-status: DYNAMIC + HSTS max-age=0 when the spot checks ran; the owner's Cache Rule landed later the same day, so see the table below for the re-measure.

Check Result
Closed since the Sept audit A1 real photo ✅ (B4) · A2 title & hero aligned ✅ (2026-10-18) · A3 featured strip ✅ (B3) · D1 search ✅ (B3 fixed the ES-module loader) · E4 ZH routes + nav ✅ (B2) · E5 og:locale ✅ (B2) · E8 draft bank + og-default JPEG + banner 404 fix ✅ (2026-10-18) · E9 typography system stack with CJK fallbacks ✅ (2026-10-18)
Still open E1 CF Web Analytics beacon + GSC sitemap (owner) · E6 HSTS still max-age=0 (owner) · E2 (recorded, deferred by the owner 2026-10-17) · E3 LinkedIn manual share
GitHub mirror was 24 commits behind — nothing had been pushed since d2b15d6, so all of B1–B4 existed only on Gitea. Pushed 2026-10-17: d2b15d6..f4b2ce9, now 0/0 with both remotes
/posts/zh/ redirect scheme the 301 came back as Location: http://blog.hoelee.com/zh/posts/ (nginx absolutised it with the scheme the tunnel reached it on), costing an extra Cloudflare "Always Use HTTPS" 308 hop. Fixed 2026-10-17 in the host nginx conf with absolute_redirect off (backup default.conf.bak-20261017 beside it), validated in a throwaway container against the real file, nginx -t + reload in the live container — now Location: /zh/posts/ on both the LAN origin and the public URL
/zh/posts/ 502 blip 3 short 502s inside one polling window, then 45/45 × 200 (15 each on /, /posts/, /zh/posts/). The nginx access log over 4 h holds zero 5xx (411 × 200 / 31 × 404 / 11 × 304 / 2 × 301) and the LAN origin answers in 3–20 ms ⇒ the 502s came from the tunnel/edge path, not from the page or the origin. Worth watching; not a defect in the archive
TTFB origin 3–20 ms vs public 1.3–5.7 s before the edge cache (E6). After the owner's Cache Rule landed the same day: 0.10–0.58 s on cf-cache-status: HIT (cold MISS 0.55–0.74 s)

Reminder the next session needs: the blog's nginx config is not in the repo — it is the host file /mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf on unRaid, reachable through the DSM Portainer (endpoint 3, stack 247) because unRaid's root login refuses SSH keys. Read it from the running container (docker exec nginx-hoelee-blog cat /etc/nginx/conf.d/default.conf), write it with a helper container that bind-mounts /mnt/user/docker/nginx-hoelee-blog read-write, then nginx -t before nginx -s reload.


4c. Live re-check — 2026-10-19 (after batches B5–B7, measured this session)

  • Posts: 72 EN + 72 ZH on disk, 0 drafts; build 251 pages; homepage renders 72 posts in English and 中文, the About pages now derive the same number.
  • Mobile: 12 routes × 4 widths (360 / 390 / 414 / 768) = 48/48 with scrollWidth − clientWidth == 0.
  • Art: 72/72 banners 1600×900 WebP; 72/72 -800 thumbs 800×420; 72/72 -400 thumbs 400×210; 144/144 share cards 1200×630 JPEG. Featured bento cards now carry srcset (400w/800w) and pick the 400 file at DPR 1.
  • Reading time: EN 5 min read vs ZH 5 分钟 on the same post (was EN 5 / ZH 1 before the CJK fix).
  • Not enabled (still owner-gated, re-measured today): Cloudflare Web Analytics — grep -c cloudflareinsights on the homepage returns 0. sitemap-index.xml, llms.txt and robots.txt all serve 200.
  • Remotes: Gitea and GitHub both at 5ec321a, 0 divergence.

4d. Consultant review of the three video posts — 2026-10-22

A paid reviewer read how-i-built-an-automated-music-video-pipeline, uploading-large-videos-to-youtube-via-cdp and debugging-remotion-video-jitter (EN + ZH). Verdict, receipts, number audit and the open items: docs/consultant-review-2026-10-22-3-video-posts.md.

  • Live (Gitea Actions 292 / 293 / 295 success, six pages 200, artwork md5 matches, check-assets.mjs 162/162 OK): f32ae90 did the fact corrections — "three minutes" → "a few minutes" + the 218 s scale, microsecond-accurate → frame-accurate (33.3 ms at 30 fps), "38% speedup" → 38.0 % less render time / 1.61× throughput, the absolute "Zero Jitter" / "Zero Light Flashing" claims scoped to the clip, 0o666 → 0o644, the memory claim scoped to the Chrome container, TL;DR + inline cross-links on all three (the trio had zero internal links), regenerated artwork. 04a1082 (parallel session) added the end-to-end timing table, the benchmark table, the layer-compositing maths and the upload-method comparison. cf4504e unified the render figure to 38.0 % everywhere — the table derives it from 218.3 s.
  • The reviewer got one item wrong: the "ti.mode = 0o 666 syntax error" does not exist. Source and live HTML both read 0o666; the live page splits the token across two highlight spans and his text extraction inserted a space. 0o666 is legal Python, so the 0o644 change is permission hygiene, not a bug fix.
  • Owner queue (nothing else blocks):
    • Confirm the provenance of the four stage timings in the pipeline post's timing table (14.2 / 2.1 / 32.4 / 48.6 s). They have no measurement trail in this repo and the reviewer's whole complaint is about unmeasured numbers — either confirm them as measured or relabel them as estimates. The render (218.3 s) and staging (1.89 s) figures do have a base.
    • Provide the before/after jitter + dark-flash source clips so the comparison GIF can be cut and embedded (the one reviewer item that needs an asset only the owner has).
    • Decide which session owns edits to these three posts. On 2026-10-22 two sessions edited the same files and one session's paragraphs rode into the other's commit — content survived, but the history stopped attributing correctly.

4e. Live re-check — 2026-10-27 (hybrid archive + case-study portfolio)

Delivered the four work orders in docs/consultant-review-2026-10-27-posts-and-casestudies-plan.md. Measured, not assumed — node scripts/probe-layout.mjs drives a headless Chrome over CDP and reports 72/72 page×viewport checks with scrollWidth == clientWidth, plus the collapse test.

  • /posts/ and /zh/posts/ are hybrid, not paginated. Three fixed-slug flagship cards (src/lib/featured.ts, 3 on the archive, 4 on the home page) sit above the complete year/month archive, which still lists every post and keeps Ctrl+F working. Typing in the filter or clicking a category chip collapses the flagship block (inert, height 0, data-collapsed="true") and the status line switches from All 91 posts to Showing 27 of 91 posts; clearing restores it. Verified at 1280 and 390 in both languages.
  • /categories/case-studies/ is a portfolio. src/components/PortfolioHero.astro — copy left, a 21:9 architecture banner right (2400×1029, 33 KB WebP + a 1200 px variant, generated deterministically by scripts/gen-case-studies-banner.mjs; pure geometry, no baked text). Ordering comes from src/lib/portfolio.ts (CASE_STUDY_FLAGSHIPS, narrative order scale → local AI → automation → shipped commercial result), not from pubDate, so publishing a new post cannot rewrite the story. Every card carries a measured pill (container counts, VRAM, cron counts — all read from the post body, never invented).
  • One shipped narrative. lib/featured.ts and lib/portfolio.ts share pickFeatured(), so the home page, the archive and the portfolio can never disagree about which write-ups are flagship.
  • Listing rows name their action: Read article → / Read case study → (阅读文章 → / 阅读案例复盘 →), as a redundant link kept out of the tab order (tabindex="-1", one keyboard stop per card). Mobile title links get a 44 px minimum tap height.
  • ZH leak fixed at the source. Archive.astro printed raw category slugs in the row tail and in the category chips; both now go through categoryLabel(category, lang) (运维架构 / 工程案例 / …).
  • Smaller craft fixes found while reviewing screenshots: the two chip strips were unlabelled and read as one list with a duplicated devops (category 30 vs topic 6) → labelled Category / Topic, stacked above the chips below 700 px; rows now cap tags at four plus a +N chip so card rhythm stops breaking; the nav gained aria-current="page" (it had no active state at all).
  • Docs: scripts/probe-layout.mjs is committed as the repeatable gate for this class of bug.

4e-bis. Review pass on the delivery (same day) — what measurement changed

An independent read of the shipped pages turned up four things the first pass missed. Fixed, remeasured:

  • Mobile archive was 2.4 screens away from its own list. At 390×844 the featured block ran 688→1923 px (3 × 392 px stacked cards) plus 435 px of controls, so the first 2026 row sat at 1996 px = 2.36 screens — a reader who came to browse had to scroll past everything. Below 700 px each featured card is now horizontal (thumb 38 %, excerpt dropped, title clamped to three lines): card 392 → 153 px, block 1235 → 523 px, first row at 1309 px = 1.55 screens. The block header gained All 91 posts ↓ (→ #archive-list, scroll-margin-top clears the sticky header), which doubles as the bypass-block affordance.
  • Two new small-text colours failed WCAG AA. Measured against their own backgrounds: .feat-min 3.04:1 and .archive-status 3.67:1 (--faint), where AA wants 4.5:1 under 18.66 px. Both moved to --muted → 5.78:1 and 6.99:1; the featured block's own heading moved with them. Pre-existing --faint dates (3.67:1) were left alone rather than restyling 91 rows.
  • One category, two art languages. /categories/ still showed the old Gemini /categories/case-studies.webp while the category page had the new 21:9 topology banner. The tile now points at case-studies-banner-1200.webp (14 KB) and letterboxes it (object-fit: contain on #0b1613, measured seamless) instead of centre-cropping — a 16:9 crop sliced the outer host panels in half.
  • 6min read had no space. {f.minutes} {labels.minutes} inside the nested feat-cta span is emitted without the separating space (the same pattern elsewhere in the repo does keep it, so this is compiler-adjacent, not universal). Replaced with a template literal → 6 min read / 6 分钟, verified in dist/.

/posts/ and /zh/posts/ ledes no longer claim the page is purely newest-first.

5. Conventions (non-negotiable)

  • Push git.hoelee.com first, then GitHub
  • English-first; Chinese for every post (same filename in posts/zh/ — post-guideline.md §8 wins over the older "selective" wording); no Malay
  • No overclaiming, especially Web3
  • Name identity: "Lee Teong Hoe" / "Mr Hoelee" + same photo + same sameAs handles everywhere
  • Business framing: website design & development is primary; email hosting is secondary
  • English post titles use Title Case
  • See docs/post-guideline.md for post-writing rules; docs/content-guide.md for strategy; docs/design-guide.md for UI