Files
hoelee-blog/docs/handoffs/project-state-history.md
T

90 KiB
Raw Blame History

project-state.md — archived detail (verbatim, nothing reworded)

Split out of docs/project-state.md on 2026-10-19, when that file was slimmed from ~128 KB / 1,118 lines to a readable queue. Everything below is copied exactly as it stood in the old file:

  • the §1 batch logs (B1–B7) with their per-item measurements,
  • the full logs of Phase A (identity), Phase B (content), Phase C (discovery), Phase D (polish) and Phase F (the artwork rollout).

docs/project-state.md now keeps the live queue (Phase E), one summary line per completed phase, and the snapshots. Where the old text is known to be stale the main file says so — read the main file first.


§1 batch detail (B1–B7) — verbatim

  • ✅ B5 Baseline & Hygiene Batch, 2026-10-18 (commit 3f63150 live on production):

    • Default OG card upgraded: external delivery design with real author headshot integrated, optimised via mozjpeg q85 to 45 KB JPG (public/og-default.jpg, down from 211 KB PNG), with PNG retained for backwards compatibility.
    • 全站 72 篇 banner 404 卫生彻底清零:为 scraping-bot-walled-marketplace-warm-browser-session 补齐 terminal banner WebP 与 PNG 资产。
    • E9 modern system typography stack deployed: --font-sans and --font-mono upgraded to native multi-platform stack with PingFang SC / Microsoft YaHei CJK fallbacks.
    • A2 branding alignment: <title>, meta description, JSON-LD, and homepage hero unified to Mr Hoelee — Full-Stack Developer & DevOps Engineer.
    • All 251 pages cleanly verified, Gitea and GitHub remotes strictly 0/0 aligned.
  • ✅ B6 Visual Redesign with External Consultant & Ideogram 4 Pipeline, 2026-10-18 (Round 4 completed):

    • Outside design consultant review finalised: 70% Dark Cyber-Bento + 30% Blueprint schematic; 100% committed to Hybrid Paradigm ("One Art, Four Targets") — pure visual art for hero banners & list thumbnails (eliminating title duplication & bilingual collisions), programmatically composited typography for social share cards.
    • Frosted Bento Container Card (template-v2.2.html) deployed to solve layout emptiness:
      • 736×536 frosted acrylic card (backdrop-filter: blur(16px), gradient top highlight);
      • 2-line article description added to eliminate vertical voids;
      • 100% full-width terminal evidence bar ($ ...);
      • CSS-drawn 36px blueprint grid + top-left radial ambient glow restores industrial depth;
      • 3D base art scaled to 116% and shifted inward to bridge the central divide.
    • Self-fitting headline engine implemented: dynamically measures DOM lines in headless Chrome, stepping down font size (50px → 26px) until headline fits <= 3 lines with no orphan/widow lines; CJK titles intelligently split at punctuation; numbers glued with NBSP.
    • Front-end homepage enhanced: Featured section upgraded to 2×2 Cyber-Bento Grid (with metric pills and pulsing green status dots); bilingual navigation brand link fixed to /zh/; in-post category tags fixed to /zh/categories/.
    • Shipped to production 2026-10-18 (commit 6e9d58b), after a measured pass on both themes: the bento scrim and metric pill were hardcoded to a dark blend, which on the light theme produced a muddy grey band and 1.96:1 pill text contrast; both are now theme tokens (--card-veil, --pill-bg, --bento-visual-opacity), and the "pulsing" dot finally has an actual @keyframes bento-pulse (it had none, and it is disabled under prefers-reduced-motion). Measured in headless Chrome, 1280px and 390px, EN + ZH: 4 cards, 2 cols / 1 col, documentElement.scrollWidth − clientWidth = 0, no card overflow, all four -800.webp thumbs naturalWidth 800 — contrast dark pill 6.56 / badge 4.83 / h3 11.86 / desc 5.78, light pill 6.10 / badge 4.85 / h3 16.36 / desc 6.74 (WCAG AA ≥ 4.5). Tag chips restyled to mono + hairline border (4px radius) site-wide; ZH post pages link /zh/categories/<cat>/, ZH brand links /zh/.
    • Full catalog of 72 posts' unique physical metaphors and prompts extracted to scripts/og-gen/prompts_catalog.json, with the batch renderer scripts/og-gen/render_from_art.mjs + art-meta.json (committed 2026-10-18; the readable version of the catalog ships outside the repo at Desktop\blog-og-image-redesign-brief\03-72-Posts-Visual-Prompts-Catalog.md).
    • Batch rollout: 0 of 72 posts. Everything above is template-level: the two sample posts (running-production-infrastructure-solo, docker-reported-exit-0-the-oom-killer-disagreed) are rendered into Desktop\blog-og-image-redesign-brief\delivery-for-consultant-round4\ only. The repo/live site still serves the v1 set — 72 public/banners/*.png + 72 public/og/*.png generated by the terminal-panel generators on 2026-10-06…10-16 — so nothing on the site changed appearance from this work yet.
    • Three consultant questions are still unanswered (they gate the batch; the ask is written verbatim in delivery-for-consultant-round4\00-第四轮-实施反馈与3个待定细节.md §二): (1) English headline rag — (A) keep the stepped shape, (B) drop 3–5px for equal-width lines, (C) shorten the OG-only title via a new ogTitle frontmatter field, (D) other; (2) the hard CJK length cap for OG titles (currently assumed ≤30 chars incl. punctuation, EN ≤65); (3) whether list thumbnails should keep 100% right-focus or hold back to 85% for breathing room at ~398px display width.
    • Asset weight measured 2026-10-18 (must be decided before the batch). A composited OG card is 558 KB as rendered PNG vs the 42 KB average of today's 72 cards; ×144 (EN+ZH) that is ~84 MB in the repo and a much heavier share payload. Same card re-encoded: 256-colour PNG 240 KB, 128-colour PNG 194 KB, mozjpeg q85 84 KB, q78 70 KB. So the batch should ship JPEG for og:image (the precedent already set by /og-default.jpg, 45 KB) — which means an ogImage: path change on 144 frontmatter files, or a deliberate decision to keep PNG at ~240 KB and eat ~35 MB.
    • Known gap in the plan: the OCR pseudo-text gate on the base art (regenerate a frame when any readable character is detected) was promised to the consultant and is not implemented.
  • ✅ B4 identity & distribution batch, 2026-10-16 (fourth batch) — the photo and the About rewrite:

    • Real author photo (source Professional Portrait with Blue Gradient Background.png, 1254×1254, kept untouched in Pictures/ with a converted JPG master beside it). Two derivatives, WebP first with a JPEG fallback: lee-teong-hoe-640 (42 KB JPG / 26 KB WebP) and -128 (3 KB / 2 KB). Appears in the About hero, the author card on every post, the home hero (both languages) and JSON-LD Person.image — the node search engines use to bind name to face.
    • About page 1,024 → 7,357 characters, in five sections: identity + photo, "By the numbers" (162 containers / 3 hosts / 71 essays / 0 hosting companies between me and the servers), the real tech stack in four columns, the Hoelee Enterprise block (website design and development is the main business, email hosting secondary) and a contact split between hiring and business (WhatsApp for enquiries). Chinese twin rewritten to match.
    • The headshot's dark-blue background sat close to the page background, so the photo/ghero/avatar get a faint brand ring + shadow (measured: the photo reads as a deliberate element instead of a floating head).
    • /llms.txt generated from the collection at build time (48 KB, 142 post entries + section links) so LLM crawlers and answer engines can cite accurately; robots.txt now points at it.
    • Still open: the main site (www.hoelee.com) counter says 12+ Experts / 100+ Years Of Experience while the blog says he runs the stack solo — needs the owner's real numbers; and cross-posting to dev.to/Hashnode needs his accounts.
  • ✅ B3 structure batch, 2026-10-16 (third batch from the external design review) — all numbers measured in a real browser (CDP), not by eye:

    • Found and fixed a real bug: site search never worked. /pagefind/pagefind.js is an ES module (export { search, debouncedSearch, … }) that defines no window.Pagefind, but Search.astro loaded it with a classic <script src> and read window.Pagefind → undefined → every query in the overlay was answered "Search is unavailable." Live-verified before the fix. Loader is now a dynamic import(); the specifier must be a variable (const url = '…'; import(/* @vite-ignore */ url)) because Rollup resolves a literal path at build time and fails on a file that only exists in dist/. After: query "docker" → 12 hits, first = the Docker OOM post; ZH "容器" also returns hits.
    • Archive /posts/ + /zh/posts/: 18,220px → 3,788px (71 rows at 44px, grouped by year) with a client-side filter — title/tag/description text search + category chips + 14 tag chips, zero network requests (tested: "nginx" → 6 rows, chip devops → 28 rows, reset → 71).
    • Tag pages: 235 distinct tags measured (the earlier brief said 74 — wrong), 156 of them used exactly once; pages are generated only for tags with ≥ 3 posts (40 tags → 80 pages, build 165 → 245 pages). Tag pills on posts and listings link only when a page exists; one-off tags stay plain text. /tags/docker/ → 25 posts.
    • Homepage + ZH homepage: hero (288px desktop) with the real scale claim (162 containers / 3 hosts), 3 CTAs (case studies ↓ / hiring profile / SME work), Featured 4 fixed slugs (scale → automation → observability → a delivered business result) and Latest trimmed 10 → 6.
    • Long code blocks collapse at >420px with an "Expand (N lines) ↓" button and a gradient mask; the copy button still copies the full block (verified on how-i-host-this-blog: 1010px block).
    • Search is now visible (nav pill "⌕ Search /" plus / as a shortcut next to the existing Ctrl/Cmd+K) and the zero-result screen offers topic chips + the archive instead of a dead end (verified with a query that matches nothing).
    • ZH nav bug fixed in passing: the ZH hero linked "关于我" to the English /about/.
  • ✅ B2 broken-windows batch, 2026-10-16 (second batch from the external design review) — every item verified before/after with curl:

    • /posts/zh/ answered 403 (a directory with no index) → nginx now 301s it to the new Chinese archive.
    • /zh/posts/ did not exist (404) → new src/pages/zh/posts/index.astro (build went 163 → 165 pages).
    • ZH nav pointed "关于" at /zh/ → now /zh/about/, and "文章" → /zh/posts/.
    • og:locale was en on every Chinese page → now zh_CN, with og:locale:alternate on both languages (EN pages advertise zh_CN too).
    • hreflang value zh → zh-Hans, and <html lang> on ZH pages → zh-Hans.
    • No 404 page → src/pages/404.astro (status stays 404, branded page with the search overlay, archive links and the 3 newest posts).
    • JSON-LD Person gained worksFor (Hoelee Enterprise + www.hoelee.com); image still absent on purpose until the author photo exists.
    • nginx config moved from the stock image file to a host bind mount at /mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf (Portainer DSM stack 247, endpoint 3), validated in a throwaway container before the redeploy; the conf also sets /_astro/* to immutable 1-year and HTML to must-revalidate. Details in ops-runbook.md.
    • Still open for the owner (CF token limits, see below): zone HSTS is max-age=0 and HTML is not edge-cached — both are Cloudflare settings my token cannot write. The deploy workflow already purges the edge after each deploy once a Cache Rule exists.
  • ✅ B1 image batch, 2026-10-16 (first batch from the external design review) — measured with a real browser before/after, not by eye:

    • Article banner → <picture> with WebP q78 + PNG fallback: 70 banners 6.02 MB → 2.52 MB (42%). The PNG stays for og:image (social scrapers do not reliably render WebP cards). JPEG was measured worse than PNG on this art (q85 = 129%).
    • Related-post cards on a post page → text-only (category badge + title + date + read time). They used to load four 1200×630 PNGs (183 KB) to display 526×276 where the art's code is illegible anyway.
    • List thumbnails (home + archive) → /og/thumb/<name>-{400,800}.webp with srcset/sizes (400w 5.8 KB, 800w 16.1 KB). Homepage thumbnails ~450 KB → 62 KB; archive (71 rows) ~3.2 MB → 436 KB. They were fetching full 1200×630 PNGs for a 398px slot.
    • Desktop banner height capped (720px, centred): 67% → 45% of a 900px viewport, art intact rather than cropped.
    • Measured post page: images 277 KB → 38 KB, height 5,758 → 5,065px desktop / 9,423 → 8,807px mobile. Script: scripts/optimize-images.py; format policy: design-guide.md §3; runbook step in ops-runbook.md.
  • ✅ B7 review-response batch, 2026-10-19 — an outside content/UX review of the live site was reconciled claim-by-claim against the repo; only the measured defects were acted on.

    • About counter was hand-typed and had drifted: about.astro said 71 write-ups while the homepage rendered 72. Both About pages now count from the collection (getCollection('posts', isEn|isZh).length), so the number cannot drift again.
    • Reading time was English-only maths: minutesOf split the body on whitespace and divided by 200, so a Chinese post (no interior spaces) showed 1 min against 5 min for its English twin. New src/lib/reading-time.ts counts CJK ideographs at ~400/min plus Latin words at 200/min; pure-ASCII bodies are unchanged (same formula), the ZH post now reads 5 分钟, and the ZH article header no longer says "min read".
    • ZH copy defects: 主陆开发者 → 中国大陆开发者; 基元律动值得麻烦的原因 → 为什么值得考虑基元律动 (the English twin already said why it is worth the hassle).
    • hello-world contradicted the site: it still declared English primary with selective Chinese, and quoted a ~140-container homelab. Both language twins now say every post exists in both languages and quote 162 containers across three self-managed hosts.
    • 0 hosting companies between me and the servers was loose (the VPS is rented from a provider) → 0 managed hosting layers — I run all three hosts myself (EN + ZH).
    • Hero sub-paragraph now leads with the capability, not the container count; the number follows as evidence. <h1> and title/JSON-LD were left alone (A2 identity alignment).
    • Featured bento cards got the srcset/sizes they were missing (-400/-800 webp) — they were downloading the 800px file for a 226–341px slot. At DPR1 all widths now pick -400; card geometry measured unchanged (226/227/310/341 px).
    • Measured, not assumed: 12 routes × 4 widths (360/390/414/768) = 48/48 with zero horizontal overflow (scrollWidth − clientWidth == 0); art uniformity re-surveyed — 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. Build 251 pages, unchanged.
    • Deliberately not done, because the review's premise was stale or wrong: hreflang/canonical/og:locale and per-post OG images already ship (72/72 + 72/72), and the art system was already unified by B6b (round 7) — the reviewer was reading screenshots from before that rollout for the visual half.


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

Phase F — Artwork rollout: v2 "one art, four targets" (NEXT; user decision 2026-10-18)

Step B6b — Replace all 72 posts' artwork with the consultant-approved v2 set. ✅ Done and live 2026-10-19 (327d516)

Everything below is measured, not planned. A fresh session needs no other context to continue.

Piece State
The renderer scripts/og-gen/render_from_art.mjs (committed a41448b) — one zero-text base frame → banner 1600×900 (banners/<slug>.webp + .jpg fallback), social card 1200×630 (og/<slug>.jpg EN, og/zh-<slug>.jpg ZH), thumbs og/thumb/<slug>-{800,400}.webp
The layout scripts/og-gen/template-v2.2.html — frosted 736×536 bento card, blueprint grid, full-width evidence bar, self-fitting headline that reports FIT <n>px through <title>; a card that never settles fails the run
Per-post inputs scripts/og-gen/art-meta.json — art (default <art-dir>/<slug>.png), evidence (required, hand-written), optional ogTitle_en/zh, optional tags
Prompts scripts/og-gen/prompts_catalog.json — 72 entries, regenerated 2026-10-19 after the metaphor rewrite (the shipped file had only 21 distinct metaphors for 72 posts: 16 shared one visual, 15 another, 10 another)
Base art on disk 72 of 72 — C:/AI/ideogram4/out/<slug>.png, 1360×768, produced 2026-10-19 (batch 4.5 h, ~220 s/frame, 20 steps)
Evidence lines written 72 of 72 (scripts/og-gen/build_art_meta.py holds all 72, each quoting a number/log line/command from its own post)
Rendered targets 72 EN cards + 72 ZH cards + 72 banner .jpg + 72 banner .webp + 144 thumbs, all in public/; the 72 v1 public/og/*.png and 72 v1 public/banners/*.png were deleted
Frontmatter 288 fields migrated by scripts/og-gen/migrate_to_v2_assets.py --write (144 files: EN ogImage: /og/<slug>.jpg, ZH ogImage: /og/zh-<slug>.jpg, both banner: /banners/<slug>.jpg)
Component changes PostList.astro thumb helper now derives from the base slug (a ZH card is zh-<slug>.jpg while the thumb is <slug>-800.webp); [...slug].astro <picture> regex accepts .jpg
Live site serving v2 — deploy green; EN/ZH cards, banner WebP and both listings' thumbs all asserted 200, v1 .png now 404
Reference delivery C:/Users/hoelee/Desktop/blog-og-image-redesign-brief/delivery-for-consultant-round4/ (+ 00-第四轮-实施反馈与3个待定细节.md for the three open questions)

Rollout log — 2026-10-19 (every number measured this session).

  1. The prompt set was not usable as shipped. prompts_catalog.json carried 21 distinct metaphors for 72 posts (16 posts shared "concentric acrylic rings", 15 shared "rackmount hardware", 10 shared "diagnostic probe"), so the batch would have produced visibly duplicated art across a third of the site. Rewrote one concrete object + state per post → 72/72 unique, and generated the 72 structured JSON captions with C:/AI/ideogram4/build_captions.py (asserts the slug set matches src/content/posts/*.md and that no two metaphors are identical).
  2. GPU run. 72 frames at 1360×768 / 20 steps / --preset default, ~220 s each, 4.5 h total, resumable via C:/AI/ideogram4/run_batch.py --all (skips slugs whose frame exists). QC: 72/72 present, all exactly 1360×768, left-60 % mean luminance 10.2–23.1 (the space the card's text sits on).
  3. Caption defect found and fixed (3 of 3 pilot frames). With the subject described twice — high_level_description repeating the metaphor — the model drew a labelled diagram with a pseudo-title in the corner (Oypert, Oyristes tool, CONTRACT). One short noun phrase in the HLD + the subject only in elements[].desc cleared it.
  4. The "no text anywhere" rule fails on the accepted samples too. sample_oom_169_00001_.png OCRs as Kenel L.aser @0.874 plus six more hits. So the gate judges legibility, not hit count: a glyph box under ~14 px on the 1360×768 frame is invisible at banner (1600×900) and thumb (398 px) size.
  5. The model renders the prompt's own label nouns as text. Measured: access-denied barrier → "ACCCSS-DENIED", attachment clip → "Attachment Clip", eight labelled ports → a 389 px glyph, id-stamped cards → "ATACHMENT CLIP", 233 fresh rivets → a row of numbers, SSD slab → a 66 px "SSD", PLUGIN cartridge → "PLUGIN". Rewriting the metaphor to describe shape/material/state only fixed every one of them on re-render.
  6. Result after three correction rounds (final gate, 2026-10-19 04:17): 53 of 72 frames carry no ≥14 px OCR detection at all; 18 carry a single-character/geometry false positive only (O, T, 司, 00% — spot-checked by eye, they are edge and highlight misreads); 0 carry a legible word or phrase. Ten frames that had real pseudo-words were re-rolled (four of them twice), and every one came in at or below the accepted reference. The last two leaks were the model spelling an English noun phrase straight out of the shared style_description (sleek developer tooling aesthetic → "devalopertoolt"), so the style block is now free of generic noun phrases. Gate + tooling: C:/AI/ideogram4/ocr_gate.py (--baseline prints the accepted samples' numbers), qc_frames.py (per-frame stats + unlabelled contact sheets).
  7. Delivery shipped 2026-10-19 (327d516 → Gitea main, deploy green): 72 EN + 72 ZH cards, 144 banner files, 144 thumbs rendered into public/; the 144 v1 PNGs deleted; 288 frontmatter fields migrated; the two components fixed. npm run build → 251 pages, 216 distinct image references, 0 missing, 0 stale .png; live assertions afterwards: EN card 200 image/jpeg, ZH card 200 (did not exist before), banner WebP 200, thumbs 200 from both listings, v1 .png 404, and the ZH listing's thumbs resolve from the base slug (the old helper would have 404'd every one of them). The repo also carries the art inputs — scripts/og-gen/metaphors.json, captions/<slug>.json (72), a refreshed prompts_catalog.json with 72 distinct entries, via scripts/og-gen/sync_prompts_catalog.py --check.
  8. Art weight: 13.4 MB → 15.4 MB (+2.0 MB), and it buys 72 ZH cards that did not exist before. Per file: EN card 67 KB JPEG (v1 was 71 KB PNG), banner 40 KB JPEG + 30 KB WebP, thumb 7 KB WebP.

Round-6 — the consultant's review of the shipped set (2026-10-19). Two defects were real and both were ours, not the model's:

  1. ZH cards shipped a literal \ escape — 你的\"磁盘将满\"告警在说谎. The renderer's frontmatter parser stripped only the outer quotes and left YAML's \" in the string. Fixed by unescaping YAML escapes and converting straight quotes to “ ” pairwise.
  2. The artwork carried HUD readouts after all — the consultant read 60%, 100% and 00% off your-disk-full-alert-is-lying, plus a 10 and a ruler line on that-dying-ssd-…. Root cause: the shared style block inherited from the accepted samples (technical blueprint vector lines, status indicator lights) and the background's blueprint grid crosshairs and delicate vector coordinate lines, plus one metaphor that literally said "percentage gauge". The first delivery's own claim that the art was text-clean was wrong: the gate flagged 00%@0.84 at the old bar and it was classified as geometry noise. The gate now runs the consultant's bar (glyph box ≥ 12 px and ≥ 2 alphanumeric/percent characters; single glyphs exempt) and build_captions.py asserts a FORBIDDEN word list so blueprint/gauge/label language cannot return. Three pilot frames re-rendered at the fixed prompt: 0 OCR hits each (was SSD 66 px, IDENESLOES 92 px, 00% 15 px), then all 72 regenerated.

Rulings adopted: banner/thumb reframing (option B — subject targeted at 45–50 % of the banner width, thumbs cut from the banner; measured 45–47 % for 5 of 8 sampled frames, 35–44 % for two, ~14 % for one genuinely slender subject — widening the caption bbox does not enlarge the subject, measured), light-theme hairline + soft shadow (option A), and the evidence line capped at 65 characters. Cards are unchanged apart from the quote fix. Style is unchanged (option: keep the realistic 3D machinery look). Round-6b — the banner crop algorithm, rewritten after review (2026-10-19). The consultant looked at the probe sheet and found the detection box on the glassware frame covered only the local highlight. Correct, and the cause was structural, not a threshold:

attempt measured
column mean (what shipped in round 6) dark glass sits 2–6 levels above background → boxed the glow only: 8 % of the frame for an object group spanning 35 %+
per-row background sample + global threshold a left-margin sample cannot describe a horizontal gradient → 39 % on one frame, 69 % on the next
deviation from an estimated background plane + connected component (now) downsample 16×, median-filter the object away, upsample; `

The closing kernel mattered: at 9 px two frames split into components and the largest one boxed a sliver (4.2 % and 5.9 % of frame width for ~39 % objects). Zoom is computed on the silhouette (target 47 % of banner width, centre 53.5 %), the detected box is padded 4 % first, and the window is no longer clamped into the scaled frame — clamping is what glued a thin subject to the right edge. Shortfall is filled by extending the canvas with the art's own edge pixels one row at a time (a single averaged fill was tried and left a visible tonal step down the right edge).

Detection lives in Python (C:/AI/ideogram4/plan_banner_crops.py → out/banner-plan.json, frozen into the repo by sync_prompts_catalog.py, consumed by render_from_art.mjs), and verify_banner_crops.py re-detects the subject on the finished banners rather than trusting the plan. Measured on the delivered set: 72 banners, 0 clipped, silhouette median 47.0 %, centre median 53.5 % (two outliers reported: hello-world 64 % — the focal cursor is small but its platform spans the frame; my-backup-was-using-someone-elses-api-quota 25 %).

Also fixed here: art-meta.json no longer pins docker-reported-exit-0-the-oom-killer-disagreed and running-production-infrastructure-solo to sample_oom_169_00001_.png / sample_prod_169_00001_.png — those two frames were the HUD-carrying ones the consultant flagged, and the pins also kept those slugs out of the crop plan.

Round-7 — delivered (2026-10-19, Gitea f391682 + 4f60cdc, pushed to GitHub too). Final numbers, all measured on the delivered artifacts:

  • OCR gate (the consultant's bar: glyph box ≥ 12 px and ≥ 2 alphanumeric/percent characters): 72/72 frames clean; 16 frames carry sub-threshold single-glyph hits, exempt as geometry noise. Nine frames failed the first pass after the prompt rewrite and were re-rendered: every offending noun is now in the assertion list (a literal marked at 28 printed "28"; storefront kiosk → "PRINATA"; voucher card → "Voucher"; market booths → "10MARKET"; glass button → "OC"; gateway pylon → "GATELY").
  • Banner crops: 72 banners, 0 with the subject touching an edge, silhouette width median 47.0 % (min 39.9 %, max 63.6 %), centre x median 53.5 %. Two composition outliers, both reported rather than hidden: hello-world (64 % wide, centre 63 % — an open box whose platform spans the frame) and my-backup-was-using-someone-elses-api-quota (25 % — genuinely small object).
  • Live assertions (blog.hoelee.com): EN card, ZH card, banner webp and 400 px thumb are byte-identical to the local files (sha256), 24 assets sampled across 8 posts with 0 mismatches; the retired /og/<slug>.png returns 404; og:image and the <picture> source resolve to the new .jpg/.webp; the light-theme rule is live in the built CSS (html:not([data-theme=dark]) … border-color:#0f172a1f; box-shadow:…); zh post titles now print “ ” both on the card and in the HTML.
  • Two slugs no longer point at the consultant's accepted sample frames (sample_oom_…, sample_prod_…) — those were the HUD-carrying frames.

Two observations for the owner (both now decided by the consultant's round-5 ruling):

  • The hero banner's dead black left half is fixed by the renderer's reframing (above); what remains is that a genuinely slender subject (a thin cylinder) still reads small — that is the base frame's composition, and widening the caption bbox was measured not to change it.
  • Several frames are intentionally very dark; at full banner width the subject can read as small.

Owner decisions after round 7 (asked and answered 2026-10-19, no re-render needed):

  • The hero banner stays textless. Asked point-blank whether the 1600×900 hero should carry the headline (the base art's left 60 % reads like space reserved for text), the owner chose to keep it as is: the title is the page's HTML <h1>, and the text lives on the two share cards (EN + ZH) only. Do not burn a headline into the hero — baked-in text is unselectable, unindexable and needs a re-render per language.
  • "This post was never re-done" was a browser cache, not a missing artifact. The owner opened /posts/five-step-ai-roadmap-what-i-actually-run/ and reported it as not-done. Every artifact was present and live: five files 200 and byte-identical to disk (sha256 same), banner measured 47.5 % silhouette / 53.2 % centre, og:image correct per language, both remotes at 0 unpushed. v1 and v2 serve the same URLs for a slug, so the browser answered from its own cache — a hard refresh showed the new art. Tell the owner to hard-refresh when reporting a restyle batch; if a swap must stick for returning readers, change the filename or add a version query instead of overwriting the path.

Three decisions still open (asked of the consultant in round 4 §二; the owner may simply pick the defaults):

  1. English headline rag → default: (C) shorten the card headline only, via ogTitle_en in art-meta.json (article title untouched).
  2. CJK headline length cap → default: ≤30 characters incl. punctuation (EN ≤65).
  3. List thumbnail crop → default: keep 100% right-focus; switch to 85% only if the 3-line bento cards read cramped at ~398 px.

Format decision — measured, take it unless the owner objects. A composited card renders 558 KB as PNG vs a 42 KB average today; ×144 that is ~84 MB of repo and a heavy share payload. Same card: 256-colour PNG 240 KB, mozjpeg q85 84 KB, q78 70 KB. --format jpg is the renderer default. ⚠ Shipping .jpg means:

  • ogImage: changed on 144 frontmatter files (/og/<slug>.jpg, ZH /og/zh-<slug>.jpg), and
  • src/components/PostList.astro line ~42 must change from .replace(/\.png$/, …) to /\.(png|jpe?g)$/, or every listing thumb derives …-800.webp from slug.jpg and 404s.

Order of work

  1. Generate the 70 base frames (GPU), named <slug>.png under C:/AI/ideogram4/out/.
  2. Write 70 evidence strings into art-meta.json — draft them from the posts, then have the owner approve; never invent a number. The renderer aborts and lists every slug that lacks one.
  3. Dry-run the whole set to a scratch dir: node scripts/og-gen/render_from_art.mjs --all --limit 4 --out "$TMPDIR/og-check" — expect FIT on all four cards.
  4. Render into public/ (--all), then uv run --with pillow python scripts/optimize-images.py for the v1-derived WebPs only if any v1 file survives; the renderer already emits its own thumbs.
  5. Do the .jpg migration (frontmatter ×144 + PostList.astro) in the same commit as the images, or the site points at missing files.
  6. npm run build, then npm run preview-equivalent check + the four URL assertions, commit, push origin and github.

How to prove it worked (do not eyeball it — vision reads were unavailable on 2026-10-18)

  • Reproducibility: re-render the two sample slugs and their banner + 800 px thumb must be byte-identical to the accepted delivery (sha256 16e6a7b0, f3b29f38, c4e28291, 8095ec6c).
  • Card layout: probe .og/gen/<slug>-{en,zh}.html in an iframe (serve the repo root over http — file:// iframes are opaque origins) and assert title line count ≤3, documentElement.scrollHeight − 630 == 0, evidence bar scrollWidth − clientWidth == 0.
  • Tags/banner: python <skill>/scripts/measure_og_banner.py <slug> <repo> → rows==1, overflow==0, banner delta<=3 — it only covers the tag row and terminal panel, not the headline.
  • After deploy: every /og/<slug>.jpg, /og/zh-<slug>.jpg, /banners/<slug>.{webp,jpg}, -800.webp returns 200 with cache-busting (Cloudflare caches a negative 404 for 16 days).

Known gaps in the plan — updated 2026-10-19

  • ✅ The OCR pseudo-text gate promised to the consultant is implemented: C:/AI/ideogram4/ocr_gate.py, calibrated against the two accepted samples (--baseline). The consultant's bar (round 5) is glyph box ≥ 12 px on the 1360×768 frame AND ≥ 2 consecutive alphanumeric/percent characters; a single glyph (O, T, 门) is exempt geometry noise. Judge legibility, never raw hit count — the accepted reference has a readable phrase in it, and passing 00%@0.84 as noise is exactly what let 60 %/100 % ship.
  • ✅ art-meta.json now carries all 72 entries; scripts/og-gen/build_art_meta.py regenerates them and re-checks coverage against src/content/posts/*.md before the renderer runs.
  • Extra thumbs: the renderer emits 400 px and 800 px; nothing else consumes them.

Phase A — Identity — verbatim (DONE)

Phase A — Identity (highest ROI, ~2–3 hrs total)

Step A1 — Add a real author photo (headshot). ✅ Done 2026-10-16 (batch B4) The #1 gap vs. every reference blog: the avatar is a letter "M" placeholder and there's no photo anywhere on the site. Every credible personal dev blog has a human face.

  • Headshot supplied; two derivatives built (lee-teong-hoe-640: 42 KB JPG / 26 KB WebP, -128: 3 KB / 2 KB).
  • Photo renders in the About hero, the author card on every post, the home hero (EN + ZH) and JSON-LD Person.image.
  • Faceless og-default replaced twice since: the outside delivery's card now carries the same headshot (/og-default.jpg).
  • Governing doc: design-guide.md §2 (author box), §5 (E-E-A-T name+photo consistency).
  • Done when: ✅ a real face renders in the author card on About + every post; no 404 for any derivative.
  • ⚠ This checkbox stayed ⬜ for two days after the work shipped — the doc is a claim, the page is the fact. Re-checked 2026-10-18.

Step A2 — Align the homepage title & hero framing. ✅ Done 2026-10-18

  • Pick one line: "Full-Stack Developer & DevOps Engineer" aligned across SITE.title, SITE.description, homepage hero <h1>, and JSON-LD jobTitle.
  • Done when: ✅ homepage title, meta description, and hero all say the same thing about who Hoelee is.

Step A3 — Add a "Start here" / featured posts route. ✅ Homepage strip done 2026-10-18 (6e9d58b); dedicated page still deferred on purpose New visitors land on reverse-chronological "Latest posts" with no guidance to the best content (Julia Evans' Favorites, Josh Comeau's featured posts both solve this).

  • Homepage Featured section: a 2×2 Cyber-Bento grid over four pinned flagship slugs, each with its OG thumbnail, category badge and a measured metric pill (3 Hosts · 162 Containers, 11 Cron Jobs · 8 Pure Loops, …). EN + ZH twins, 1 column under 680px.
  • /start-here or /favorites page — deliberately deferred: the homepage strip is the immediate win, and the posts listing plus related rows already cover discovery; revisit only if the four flagships stop being the strongest content.
  • Done when: ✅ homepage shows a featured/start-here strip above "Latest posts" (verified in the built HTML: 4 bento-card, featured-bento-grid present).

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

Phase B — Content (80% of value; the long game)

Step B1 — Write the 2nd flagship case study: "Self-Hosting a Mem0 Memory Stack". ✅ Done 2026-09-16 The Mem0 flagship is already the single highest-value unwritten post in the backlog.

  • Write src/content/posts/self-hosting-mem0.md (category case-studies).
  • Write Chinese twin src/content/posts/zh/self-hosting-mem0.md (same filename → auto language-switch).
  • Follow the "hard job → post" template (§4 content-guide) + open with "why it matters" + end with hire CTA (§8 post-guideline).
  • Governing doc: content-guide.md §4/§8, post-guideline.md §8.
  • Done when: ✅ both EN + ZH pages live, language-switch works, hire CTA present.

Step B1b — Write the self-hosted STT case study. ✅ Done 2026-09-19 self-hosted-speech-to-text-api.md (EN + ZH): whisper.cpp on GPU + n8n auth gate + nginx gateway, with the four build traps and the "5x faster than typing" business case.

  • EN + ZH posts, custom OG + banner, hire CTA.
  • Done when: ✅ both pages build, language-switch verified, images generated.

Step B2 — Write 2–3 short "gotcha" posts (Google-friendly, compound over time).

  • "Replacing RDPGuard With IPBan: The Traps Nobody Documents" (EN + ZH, devops, 2026-09-19) — the uninstaller that unbans 12 attackers, --install-service doesn't exist in v4.1.0, ExpireTime vs BanTime. Both images custom.
  • "When Your Database Client Lies to You: Patching Workbench 26 for MariaDB" (EN + ZH, devops, 2026-09-19) — a client whose error handler crashed while reporting its own errors, masking every real failure; three patches to Oracle's bundled code, all stemming from major >= 8 being an invalid MySQL-vs-MariaDB test. Both images custom.
  • "Why Chrome Forgets Its Tabs in a Container — And How I Fixed It" (EN + ZH, devops, 2026-09-29) — the container kills the browser, so it never sees a clean exit and no restore mechanism fires (flag, hand-edited Preferences, RestoreOnStartup policy all verified failing); the 60-second snapshot keeper that fixed it, plus the stale Singleton* and custom-cont-init.d permission traps. Both images custom.
  • "The Forward-Auth Gate That Verified Perfectly — and Wasn't Live" (EN + ZH, devops, 2026-09-29) — the B2 forward-auth item, delivered with a better villain than Traefik: the gate passed every local check (302 → outpost, branded login page) while the public URL served the app directly, because that hostname is served by a Cloudflare tunnel rule that bypasses nginx; adds skip_path_regex (SSO for the UI, open API), the "count before you substitute" revert trap (11 vhosts share the outpost port) and the same-second reload race. Custom OG + banner, hire CTA, commit e7e4ee5.
  • "Site-to-site OpenVPN behind CGNAT"
  • "Fixing the WordPress /cv 301→404 chain" (from own audit)
  • Governing doc: content-guide.md §3 (post type #3), post-guideline.md.
  • Done when: ≥2 gotcha posts live (these are notes/devops, no Chinese translation required per §8).
  • ⚠ Note: post-guideline.md §8 (newer) says every post gets a ZH twin — the "no Chinese required" note above is stale. The RDPGuard post was published EN + ZH.

Step B2b — Draft bank. ✅ Both published 2026-09-27 (EN + ZH, with OG + banner, verified 200 on 2026-09-29) — migrating-codeigniter-iis-to-openlitespeed and upgrading-codeigniter-46-to-47. The publish recipe below stays valid for the next draft. Remaining draft: authentik-css-greater-than-bug (E8). Two finished posts (EN + ZH, each with frontmatter pointing at OG + banner paths) sitting in the repo but not built or listed — draft: true excludes them from all listings and generates no pages.

Slug Category Status Assets
migrating-codeigniter-iis-to-openlitespeed engineering drafted, unpublished OG + banner PNGs not yet generated
upgrading-codeigniter-46-to-47 notes drafted, unpublished OG + banner PNGs not yet generated

To publish one later:

  1. Flip draft: true → draft: false in both src/content/posts/<slug>.md and src/content/posts/zh/<slug>.md.
  2. Set the real pubDate (currently 2026-09-20, the draft date) in both files.
  3. Generate its images: node scripts/og-gen/generate.mjs <slug> and node scripts/banner-gen/generate.mjs <slug> (add a TERMINALS[slug] / BANNERS[slug] entry first for the custom panel).
  4. npm run build, commit, git push origin main.
  5. Verify both URLs return 200 and the language switcher links them.
  • Why these two: engineering had only 1 post and tutorials only 1 — the blog was ~all devops/case-studies. These put PHP/CodeIgniter (the actual day-job stack) on the blog, which is what a PHP full-stack recruiter searches for.
  • Governing doc: content-guide.md §3/§4, post-guideline.md §8.
  • Done when: both are published live with EN+ZH, custom OG + banner, and verified 200.

Step B2c — (unplanned) Publish "No API for Browser Translation" — the English-mode story. ✅ Done 2026-09-29 adding-english-mode-to-a-chinese-only-web-app (category engineering, EN + ZH): the <html lang="en"> lie that suppressed the browser's translate prompt, the fact that no browser-translate API exists, and the gateway-injected dictionary + floating EN button (871 labels, 77–88% measured coverage). Custom OG + banner, hire CTA, commit 8a9ba6a.

  • Why: engineering was the thinnest published category, and the story is a rare, searchable gotcha with hard numbers.
  • Done when: ✅ both pages 200, language switch links both ways, sitemap hreflang pair, RSS entry, OG + banner served.

Step B2d — (unplanned) Publish "How I Vet an Open-Source Dependency Before Betting On It". ✅ Done 2026-09-29 vetting-an-open-source-dependency-before-you-bet-on-it (category devops, EN + ZH): the six checks that corrected six assumptions from a 1,306-line architecture spec — repo vital signs via the GitHub API, grepping for the feature instead of reading for it (OIDC/SSO/SAML → 0 hits), reading a feature doc's target branch (white-label lives on multi-tenant, not main), reading the data model rather than the feature list (percent/fixed vs five assumed rule types), the Community-vs-Enterprise tier gate (API tokens free, SSO paid), and whether the money rail works in-country (selfhost + manual payouts, not Stripe Connect). Custom OG + banner, hire CTA, commit 8711136.

  • Why: forms a due-diligence cluster with how-to-verify-a-hosting-provider-before-you-buy and how-i-vetted-20-vps-providers-with-parallel-subagents — those cover vendors you pay, this covers code you depend on. devops is the most differentiated category.
  • Done when: ✅ both pages 200, language switch links both ways, OG + banner served (1200×630 / 1600×900), listing order monotonic on /posts/, / and /zh/, Gitea Actions task success.
  • ⚠ Overlap to watch: two parallel in-flight posts cover adjacent material (nocodb-sso-is-a-licensed-feature, authentik-forward-auth-gate-wasnt-live). If those publish, add cross-links so the trio reads as a series rather than repetition.

Step B2f — (unplanned) Publish the agent-safety + CDP-automation pair. ✅ Done 2026-09-29 Two posts from the DigiKedai voucher-automation session, both backdated into the archive's empty 2025 stretch (EN + ZH, custom OG + banner, commit 170f5cc):

Slug Category pubDate updatedDate What it is
i-let-an-agent-manage-my-shopee-vouchers ai 2025-07-08 2026-09-29 the audit → propose → auto design, the mode: propose gate, and the list-lag mistake that created an unplanned RM140 voucher
why-your-cdp-clicks-silently-fail devops 2025-03-19 2026-09-29 stale rect from scroll-behavior: smooth, coordinate clicks dropped on a hidden window, JS-dispatched events — and how to tell the three causes apart
  • Why 2025 dates: 2025-01 → 2025-10 was completely empty in the archive (nearest neighbours: 2024-09-24 and 2025-11-12). Backdating fills a real gap; updatedDate keeps lastmod / dateModified honest (post-guideline backdating rule). Neither post carries a date-, month- or version-pinned sentence — verified with grep -nE "20[0-9]{2}|January|…|tonight|this week" before moving the date.
  • ⚠ B2's gotcha list is still open: the three bullets there (Traefik forward-auth, OpenVPN behind CGNAT, WordPress /cv 301→404 chain) remain unstarted; why-your-cdp-clicks-silently-fail counts as an extra.
  • ⚠ Generator entries deliberately NOT committed. Another session had uncommitted edits in scripts/og-gen/generate.mjs / scripts/banner-gen/generate.mjs, so the TERMINALS / BANNERS entries for these two slugs were applied through throwaway generate.local.mjs copies (deleted afterwards) and the images were committed as static files. Re-add the four entries below the next time one of these images must be regenerated, or when the next post needs a panel in the same style:
TERMINALS['i-let-an-agent-manage-my-shopee-vouchers'] = `
    <div class="line"><span class="prompt">$</span><span class="cmd">python voucher_watch.py --auto</span></div>
    <div class="line"><span class="prompt">&nbsp;</span><span class="err">Confirm clicked · voucher not in the list — the list was 10 min stale</span></div>
    <div class="line"><span class="prompt">&nbsp;</span><span class="cmd">mode=propose</span><span class="fix">→ waiting for price confirmation ✓</span></div>`;

TERMINALS['why-your-cdp-clicks-silently-fail'] = `
    <div class="line"><span class="prompt">$</span><span class="cmd">cdp click '.picker-item input'</span></div>
    <div class="line"><span class="prompt">&nbsp;</span><span class="err">clicked @591,361 · nothing happened · document.hidden=true</span></div>
    <div class="line"><span class="prompt">&nbsp;</span><span class="cmd">bringToFront + dispatch MouseEvent</span><span class="fix">→ picker opens ✓</span></div>`;

BANNERS['i-let-an-agent-manage-my-shopee-vouchers'] = {
  titlebar: 'unraid — shopee voucher watch',
  lines: [
    { t: 'cmd', text: 'python voucher_watch.py --auto' },
    { t: 'dim', text: 'vouchers have no draft state — Confirm = live + escrow' },
    { t: 'err', text: 'Confirm clicked · not in list · judged "refused" · clicked twice more' },
    { t: 'ok',  text: 'it existed — the voucher list was ~10 min behind' },
    { t: 'err', text: 'cost: one unplanned RM14/29 voucher · exposure RM140' },
    { t: 'cmd', text: 'policy: mode=propose · cap RM300/month' },
    { t: 'ok',  text: 'PROPOSAL RM9.60 / min RM18 × 20 → WAITING FOR PRICE' },
    { t: 'hl',  text: 'agent-created vouchers since the gate: 0 · 4 live' },
  ],
  flow: [
    { n: '1', label: 'read-only audit' },
    { n: '2', label: 'propose params' },
    { n: '3', label: 'human confirms' },
    { n: '4', label: 'create once ✓' },
  ],
};

BANNERS['why-your-cdp-clicks-silently-fail'] = {
  titlebar: 'canary — seller centre via CDP',
  lines: [
    { t: 'cmd', text: "click '.picker-item.end-picker input'" },
    { t: 'err', text: 'clicked @591,361 · picker never opened' },
    { t: 'dim', text: 'scroll-behavior: smooth → rect read mid-animation' },
    { t: 'ok',  text: 'scrollBehavior=auto + behavior:instant → rect is real' },
    { t: 'err', text: 'document.hidden=true → coordinate clicks dropped' },
    { t: 'cmd', text: "['mousedown','mouseup','click'].forEach(dispatchEvent)" },
    { t: 'ok',  text: 'picker opens every time · Confirm lands ✓' },
    { t: 'hl',  text: '3 causes · 1 injected listener tells them apart' },
  ],
  flow: [
    { n: '1', label: 'arm listener' },
    { n: '2', label: 'elementFromPoint' },
    { n: '3', label: 'JS-dispatch click' },
    { n: '4', label: 'opens every time ✓' },
  ],
};
  • Done when: ✅ both pages 200 (EN + ZH), language switch links both ways, OG + banner served (1200×630 / 1600×900), listing order monotonic on /posts/, / and /zh/, Gitea Actions task success, and git status clean of other sessions' files.

Step B3 — Adopt the "hard job → post" habit. Every solved problem becomes a notes entry the same week.

  • Revisit cadence target: 2 posts/month → 1/week (content-guide.md §5).
  • Done when: 3 consecutive months hit the 2-posts/month floor.

Step B2e — (unplanned) Two posts out of the SSO / forward-auth session. ✅ Done 2026-09-29 Same working session that wired (and then deliberately rolled back) an authentik forward-auth gate in front of a self-hosted app produced two posts:

Slug Category What it argues Commit
authentik-forward-auth-gate-wasnt-live devops the gate verified perfectly from the host while the public URL bypassed it — two ingress layers per hostname; verify from outside and read which software answered (x-powered-by) e7e4ee5
nocodb-sso-is-a-licensed-feature notes the OIDC env vars are real and enforced at boot, but the feature is Business+; on an unlicensed build an unauthenticated GET /auth/oidc throws and exits(1); MySQL meta blocks licensing; the "drop-in" community fork is abandoned (0.255.2, 2024-10-29) 2d0de72
  • Both EN + ZH, custom OG + banner, hire CTA; both link to each other (one-way: the NocoDB post links to the gate post).
  • Why: the trap is rare and genuinely searchable (authentik forward auth, nocodb sso self-hosted), and both are first-person debugging stories with measured evidence — the moat per content-guide.md §7.
  • Done when: ✅ 4 pages 200 with expected content, language switch links both ways, 4 images served as image/png.

Step B2f — (unplanned) Four monitoring posts out of one Prometheus/Grafana session. ✅ Done 2026-09-29 One working session that unified monitoring across unRaid + Synology DSM + a VPS produced four posts. All four are backdated into the 2026-03-25 → 2026-09-04 archive gap (that stretch had no posts) with updatedDate: 2026-09-29 holding the real date, so the sitemap lastmod stays honest and listings still sort by pubDate:

Slug Category pubDate What it argues
smartctl-exit-code-32-skips-the-disks-that-matter notes 2026-04-14 smartctl's exit status is a bitfield, not a boolean: rc=32 means "SMART OK, attributes were below threshold in the past". An if ! smartctl guard skipped 2 of 4 SSDs — exactly the marginal ones. Fix: mask the informational bits (32/64), export rc as a metric.
why-your-grafana-dashboard-shows-no-data devops 2026-05-17 A template variable defined as label_values(...{nodename=~"$nodename"}) filters on itself → 0 options → $node empty → every panel No data while all targets are up. Also: why hand-substituting variable values during verification hides exactly this bug, and $__all ≠ .* in automated panel checks.
your-disk-full-alert-is-lying devops 2026-06-24 Percentage thresholds on multi-TB volumes fire while 500 GB remains; 92% "memory used" with 4.8 GB available is cache, not pressure. Alert on consequences: bytes free, MemAvailable, steal >50%. Includes the "keep the comparison in the threshold condition" rule and the mount-selector exclusions.
one-prometheus-for-unraid-synology-and-a-vps case-studies 2026-07-29 The flagship: node_exporter vs cAdvisor coverage matrix; name!="" for cAdvisor's non-container cgroups; "total storage" counting one NAS volume three times (/volume1, /opt, CIFS re-mount) and the dedup selector; a KVM guest exporting no CPU frequency at all (textfile collector, distinct metric name, merged with or); a container reporting its own ID as nodename. Result: 7 targets, 47 cores / 158 GHz / 142 GB / 64 TB / 155 containers on one screen.
  • All four EN + ZH, custom OG + banner, hire CTA naming "self-hosted monitoring pipelines"; no post carries an absolute date or "recently/as of" phrasing, which is what made the backdating safe (per post-guideline.md backdating rule).
  • Why: the blog had zero Prometheus/Grafana/monitoring posts while content-guide.md §2 lists monitoring under devops, "my most differentiated material" — and monitoring/grafana no data/smartctl exit code are heavily searched by exactly the audience this blog targets.
  • Done when: ✅ 8 pages 200 with expected content, language switch links both ways, 8 images served as image/png, archive order still monotonic on /posts/, the homepage and /zh/.

Step B2g — (unplanned) Three posts out of the Synology Office / spreadsheet-API session. ✅ Done 2026-09-29 One session spent making a Synology NAS read, write and chart spreadsheets produced three posts. All three are backdated into the 2024-09-24 → 2025-11-12 gap — the widest stretch in the archive with no posts — with updatedDate: 2026-09-26 holding the real date, so lastmod stays honest and listings still sort by pubDate:

Slug Category pubDate What it argues
synology-spreadsheet-api-is-a-container devops 2025-08-20 The flagship. Enumerating the DSM gateway (1,515 APIs) showed no cell-level Office endpoint, so I concluded the NAS had no spreadsheet API — wrong: it ships as the container synology/spreadsheet-api (image tag ↔ Office version table). Plus two diagnostics that lied (synopkg is_onoff reporting a running package as "not turned on"; ps without sudo on DSM listing only your own processes, which made a live stack look dead), a required AUTH_SECRET whose absence crashes with a minified stack trace, a 401 with provably correct credentials, 2FA that can never authenticate, 403 vs 404 semantics, a personal My Drive unreachable by any service account, and the verified fix — read/write/CSV/.xlsx with Synology's own engine evaluating the formulas, then a chart out the far end.
synology-api-401-with-the-correct-password notes 2025-09-10 The two causes of a 401 when the password is right: host must be an FQDN whose certificate the proxy accepts (a bare LAN IP fails its TLS handshake, and a failed handshake is reported identically to a bad password), and a 2FA account can never sign in (AuthorizationBody has no OTP field). Includes the three-command triage that separates them, and why a token that worked yesterday returns 401 today — it's bound to the DSM session, not just to a 28-day clock.
reading-a-containers-own-api-docs notes 2025-10-01 Extract a container's contract from the artifact instead of the vendor's page: --entrypoint cat the bundled OpenAPI spec, --entrypoint grep the bundle for the env-var contract and the defaults, read Env/Entrypoint/Cmd from the registry config blob without pulling a byte, decode the real listening port from /proc/net/tcp, and run detached to read startup logs without hanging the shell.
  • All three EN + ZH, custom OG + banner (centering measured, not eyeballed: gapAbove/gapBelow 47/49, 76/78, 106/108, delta=2px, overflow=0), hire CTA naming self-hosted integrations; the two notes posts link up to the flagship with a relative link.
  • Why: the search results for synology spreadsheet api / spreadsheet-api docker are Synology's own Hub page, a German how-to and two MCP wrappers — nothing covers the failure modes, and the "vendor tool told me the wrong thing" shape matches the blog's strongest existing genre (patching-workbench-26-for-mariadb, when-smart-says-healthy-but-your-raid-is-corrupting-data).
  • Done when: ✅ 6 pages 200 with expected content, language switch links both ways, 6 images served as image/png, archive order still monotonic on /posts/, the homepage and /zh/.

Step B2h — (unplanned) Audit + backdate of the six posts that landed on 2026-09-29. ✅ Done 2026-09-29 One publishing day put six EN posts on the same pubDate, so /posts/ opened with a single-day dump. Each was checked for date-, month- and version-pinned prose before its frontmatter was touched; two carried no pins and were moved into the archive's empty months with updatedDate: 2026-09-29 holding the real date:

Slug Category pubDate → updatedDate Why it was safe / blocked
adding-english-mode-to-a-chinese-only-web-app engineering 2025-06-11 → 2026-09-29 no absolute date, month name, version or relative-time phrasing anywhere in EN or ZH; fills the empty 2025-06
authentik-forward-auth-gate-wasnt-live devops 2026-08-12 → 2026-09-29 no pins. nocodb-sso-is-a-licensed-feature links back to it, so it must stay dated earlier than 2026-09-29 (it does), and the "licensing story in its own write-up" forward reference now reads as weeks rather than months. Fills the empty 2026-08
  • The four that had to stay on 2026-09-29, and the exact sentence that pins them:
    • read-only-nocodb-dashboard-for-a-remote-database — image tag nocodb/nocodb:2026.09.0 and a returned row timestamp 2026-09-27 01:54:16+00:00 → floor 2026-09-27.
    • nocodb-sso-is-a-licensed-feature — same 2026.09.0 image tag → floor 2026-09-01.
    • vetting-an-open-source-dependency-before-you-bet-on-it — quotes the GitHub API as "last push 2026-09-10" → floor 2026-09-11.
    • why-chrome-forgets-its-tabs-in-a-container — the setup table names Chrome 154 (≈ Oct 2026 on Chrome's cadence) and the post links back to scraping-bot-walled-marketplace-warm-browser-session (2026-09-13) as something already written → floor 2026-09-13.
  • Still empty, and therefore the spare slots for the next batch: 2024-10 → 2025-02 (five months) and 2025-04/05.
  • Done when: ✅ build clean, lastmod = 2026-09-29 for all four URLs (EN + ZH), listing order still monotonic on /posts/, the homepage and /zh/, both article pages render the historical date.

Step B2i — (unplanned) The web3 category, plus two engineering posts. ✅ Done 2026-09-29 web3 had zero posts and no route at all: /categories/ rendered its card with the label "0 posts · coming soon" as a non-link, and /categories/web3/ returned 404 (Astro only emits a category detail route once the category has posts). A Gitea sweep found seven real web3 repos whose commits date to 2024-08-15 → 2024-08-19, which is also why those posts could be backdated honestly. Five posts shipped (EN + ZH, custom OG + banner, hire CTA):

Slug Category pubDate updatedDate Source repo
fully-on-chain-svg-nfts web3 2024-10-08 2026-09-29 foundry-nft — MoodNft.sol, DeployMoodNft.s.sol
why-my-on-chain-nft-art-changed-on-windows web3 2026-08-19 — foundry-nft — the .gitattributes fix commit
chainlink-vrf-v2-lottery-contract web3 2024-12-10 2026-09-29 hardhat-smartcontract-lottery — Raffle.sol
verifying-a-pdf-report-page-by-page engineering 2026-09-28 2026-09-29 numerology-report — docs/pdf-pipeline.md
jpa-version-field-lost-update engineering 2026-08-19 — springboot-hoelee-demo — @Version
  • Why these dates: the two 2024 posts fill the empty 2024-10 and 2024-12 archive months with the real work date and updatedDate holding the true date, so lastmod stays honest. The two 2026-08-19 posts use the real work date rather than joining the 2026-09-29 pile-up. verifying-a-pdf-report-page-by-page was moved one day to 2026-09-28 for the same reason — it was the fifth post landing on 2026-09-29.
  • ⚠ Date pins were re-checked before moving any date. The only 202[0-9] hits in the two backdated posts are inside the contract address 0xc2022b56…, not dates. All cited figures were traced to source: 868596 / 4102 bytes / 993568 gas / 0.000535185588997216 ETH / block 6522146 / mint 181874 (deploy + mint logs), 335011 gas (.gas-snapshot), 2.5pt→7.5pt + 20,225,818 bytes + 30–490pt (docs/pdf-pipeline.md; the 27 pages / 12,789 pages figures live in app/Libraries/ReportPdf.php lines 21 and 196, not in the doc), @Version + POST_VERSION_CONFLICT in the Spring demo.
  • ⚠ The TERMINALS / BANNERS entries for all five slugs ARE committed this time (unlike B2f, which had to hide them in this file because a parallel session held uncommitted generator edits). Both generators were verified clean and the diffs purely additive (30/0 and 100/0) before editing.
  • Banner centering measured, not eyeballed: all five at 8 rows, gapAbove/gapBelow within 2px, overflow=0, scrollHeight == clientHeight == 636.
  • Done when: ✅ 2 new category routes (/categories/web3/, /zh/categories/web3/), build 127 pages clean, all 10 post URLs + 10 images 200, language switch both ways, listing order monotonic on /posts/, /, /zh/.

Step B2j — (unplanned) One post out of the analytics/SSO session, plus the tracker wired into three sites. ✅ Done 2026-09-30

The analytics session (Step E1) produced one flagship post and the material to justify it:

Slug Category pubDate Commit Live
one-hostname-public-tracker-sso-dashboard devops 2026-09-30 54d8cce EN + ZH + OG + banner all 200
  • Angle: "public ingest, private dashboard" on one hostname — the authentik proxy provider + skip_path_regex pattern, written up as the correct counterpart to authentik-forward-auth-gate-wasnt-live (which documented the gate that was never live). The two posts cross-reference the same instance from opposite directions, which is exactly the compounding an authentik series is supposed to do.
  • Two traps are the post's core value (both cost real time in this session): (1) cloning a working provider that used mode=forward_single leaves internal_host empty, so the SSO chain is textbook-correct while every app path 404s — the tell is x-powered-by: authentik on the 404; (2) the embedded outpost needs 1–2 minutes to pick up a provider change and answers 302 → /flows/-/default/authentication/ plus 404s until it does, which reads as "the wiring is broken".
  • Also in the post: why nginx basic auth can never gate this app (the browser carries one Authorization header, so the app's own bearer token displaces Basic and every API call 401s), the honest trade-offs (double login — no OIDC in the app; dashboard is as available as the outpost; one LAN break-glass port), and the public-internet verification table (including POST /api/send returning an app-level 400 rather than a gate 403 — proof the request reaches the app).
  • Generators committed with the post (both clean, diffs additive-only 5/0 and 21/0): 3-line TERMINALS entry, 8-row BANNERS entry with the 5-step flow.
  • Verified: build clean (129 pages, Pagefind 2 languages), 14 content assertions on the deployed pages (framing, both traps, CTA, language switch both ways, tracker tag, og + banner), listing order monotonic with the new post newest (2026-09-30), Gitea Actions run 122 success, EN/ZH/OG/banner 200 within a minute.
  • ⚠ Not done: no synthetic pageview this time — the real-Chrome CDP window (9222) was closed by the time the post shipped, and the house rule is to verify analytics with a real browser rather than a headless one. The page carries the tracker (asserted in the HTML), so the first real visit is what will show up.

Tracker now wired into three sites (E1 progress, 2026-09-30) — the analytics deployment is no longer "installed, not collecting":

Site website ID Where the snippet lives Deploy path
blog.hoelee.com 1817d8f6-0e49-4b9a-a50d-307e4f2962c7 src/layouts/BaseLayout.astro (EN + ZH share it) Gitea Actions, ~1 min
www.digikedai.com 61b57143-f3f4-4353-9ede-9c615146de84 dsm-resource-management/www/src/layouts/Base.astro npm run build + ./deploy.sh (CF Pages)
www.sifumail.com 9d76b8aa-486a-4e4a-b344-f1f93e9ba292 all 4 pages' <head> (static site, no shared template) scp to the VPS docroot /docker/web/www.sifumail.com/public/

⚠ Verification pitfall (cost me a false alarm): Umami drops bot/headless user agents. The same payload returns 200 {"beep":"boop"} and stores nothing from HeadlessChrome, but 200 {"cache":"…"} and stores from a normal Chrome UA — so "0 pageviews after a headless test" is not a bug. Verify with a real browser.

Step B2k — (unplanned) Two posts out of the reader/book.hoelee.com session: the upstream rewrite, and the front-door pattern. ✅ Done 2026-10-06

Slug Category pubDate Live
the-upstream-was-deleted-then-came-back-rewritten devops 2026-10-06 EN + ZH + OG + banner all 200
putting-a-front-door-on-an-app-you-cant-modify devops 2025-06-24 (backdated) EN + ZH + OG + banner all 200
  • Why these two, from the reader's own setup (stack 136) rather than a generic idea: the existing coverage of that project was the translation post (adding-english-mode-…) and the TTS post. The untouched material was (a) the dependency's death-and-resurrection and (b) the gateway/front-door packaging pattern.
  • ⚠ The upstream facts changed while the skill still said "dead". Re-verified live 2026-10-06: github.com/hectorqin/reader is alive — 11,036★ / 5,471 forks / AGPL-3.0 / default branch main, last push 2026-10-02 — but it is a from-scratch rewrite: the git history was re-initialised 2026-09-16 (chore: 初始化仓库) and holds 233 commits; Kotlin/Spring+Vert.x → TypeScript/Node, port 8080 → 5888, /storage/data JSON → /data SQLite, env config → admin-UI settings in the DB, image moved to cnb.cool/hectorqin/reader:main (branch/commit tags, deliberately no :latest), 0 releases / 0 tags; Docker Hub hectorqin/reader still 404. Upstream's own P0 notes admit the image-pull + container upgrade drill was never run and legacy-data auto-migration is unimplemented. The running deployment is unchanged (liangnianzhi/reader.hectorqin:latest-20250525). The hectorqin-reader skill was corrected in this session — a future session must not repeat "upstream is dead, there is no upgrade".
  • Post 1 writes that up as "dead vs rewriting" with the reusable checks (repository health ≠ artifact health; no tags ⇒ every upgrade is a SHA; mirror source and image; compare the shape before the features; read the maintainer's "not done yet" list; AGPL-3.0 if you run it for paying users).
  • Post 2 is the gateway pattern: a second nginx:alpine container owning the public port, the request-time resolver 127.0.0.11 (so the front page survives the app being down), client_max_body_size preserved for uploads, why a proxy body rewrite cannot touch a client-rendered SPA, the "reload → back to the front door" injection with its sessionStorage.gatePass consume-step, the app's own CSS hook for whitelabelling, and the silent-failure warning (the injections depend on the literals <html lang="en"> and </head>).
  • ⚠ Backdating rule applied to post 2 only. Post 1 is date-pinned (its facts are Sept–Oct 2026) so it ships on the real date. Post 2 went into the empty 2025-06-25 → 2025-07-07 window at 2025-06-24, updatedDate: 2026-10-06 so lastmod stays honest — and it is constrained to link backwards only (it cross-links adding-english-mode-…, dated 2025-06-11); a forward link to a later-dated post would contradict the backdate.
  • Generators committed with the posts (both were clean, deletions: 0, node --check OK) via scripts/append_generator_entries.py: 4-line TERMINALS + 8-row BANNERS per slug.
  • Verified after deploy: build clean (139 pages, Pagefind 2 languages, no duplicate-id warning); measure_og_banner.py PASS on both (og rows=1 overflow=0 missingHash=0; banner 8 rows, delta=2, overflow=0); 8 live URLs 200 with the right content types; sitemap 139 URLs, all four new entries lastmod 2026-10-06 with hreflang alternates; EN and ZH listing rows monotonic with the new posts at #2 (2026-10-06) and #54 (2025-06-24, between 2025-07-08 and 2025-06-11); language switch links both ways; CTA links present (Cloudflare rewrites the mailto: to email-protection — expected). Commit 378d55c, pushed Gitea + GitHub, both remotes 0/0; follow-up ef3f520 sharpened the wipe evidence after checking the actual repo (not just the API): master is a single commit dated 2026-06-22 whose README is the word deleted, sharing no common ancestor with main — the API's created_at still reads 2021-08-13 and would have misled a reader who trusted it.
  • Upstream source mirrored to Gitea (private) the same session: hoelee/reader-2026-rewrite — full clone of github.com/hectorqin/reader (main @ 5c59cae, plus the 1-commit master), pushed with git push --mirror. ⚠ Gitea's hook rejects refs/pull/* ("hook declined") — that is expected and harmless; branches and the whole object history land. A future session should re-push from a fresh git clone --mirror to refresh it, or switch the repo to a Gitea pull mirror (the existing hoelee/reader uses the 8h auto-sync mirror feature).
  • ⚠ Reminder for the next session: the assertion method matters — hrefs must be checked against raw HTML, not the tag-stripped text (a first pass reported the CTA and cross-links "missing" because tag-stripping removes href).

Step B2l — (unplanned) Two posts written from the 2026-10-09 → 10-16 sessions. ✅ Published 2026-10-16 (7b001dc)

Both were written, built and image-complete, then held for the user's read and published the same day on his go-ahead: 8 URLs (EN / ZH / og / banner × 2) verified 200, /posts/ order still monotonic, lastmod = 2026-10-16 on both. A third post written in the same batch (the-dialog-opened-in-7-seconds-my-script-waited-5, category ai) was dropped and deleted — see the note under the table. ⚠ The 7b001dc message claims "build 159 pages"; the real count was 153 — both posts had draft: false from the start, so publishing added no pages. The number in that message is wrong; the deploy itself was verified by URL.

Slug Category Proposed pubDate Source session (Hermes id) What it argues
calibrating-intel-quick-sync-hevc-quality tutorials 2026-10-14 20261014_132458_102ffe The full QSV calibration: ICQ 18–22 inflate thin sources (size gate 176.8/141.8/107.6%), the usable range starts at 24, the quality cliff sits between 28 and 30, excerpts rank quality but never speed (fast == medium byte-for-byte, 168,435,266 B), the four traps (/lib mount, rgb24 rate control, SKIP already HEVC hvc1, one QSV job at a time)
docker-reported-exit-0-the-oom-killer-disagreed devops 2026-10-16 20261016_034103_770729 "Container stopped unexpectedly" while docker inspect reports ExitCode 0 / OOMKilled=false — the truth was 6 cgroup OOM kills in dmesg (uptime stamps matching FinishedAt), driven by a crawler flood (25,976 IPs / 50k lines, 33,636×403 + 16,082×429); fix = 512M → 2g + memswap_limit: 2g, labelled a stopgap; plus the "my own bare curl got 403" verification trap
  • Deleted (do not resurrect): the-dialog-opened-in-7-seconds-my-script-waited-5 (the auto-LinkedIn composer race). Written 2026-10-16, briefly parked as a draft, then deleted outright on the user's instruction in 3e354cc: both .md files git rm'd and its TERMINALS/BANNERS entries removed from both generators (pure deletions — 0 added, 17 + 5 removed, node --check OK, 0 references left to the slug, the other three intact). Do not bring this topic back unless the user asks for it.

  • ⚠ Backdating verdict for this batch (do not re-litigate): neither held post may carry a historical date.

    • docker-reported-exit-0-…: its evidence is dated — Oct 12, Oct 16, the times 02:45 / 02:58 / 03:39 / 05:33 / 07:37 / 07:57, "two weeks of uptime", "18 minutes after the restart". Any older date makes the post contradict its own table. Ships on the real date (2026-10-16), updatedDate = the day it is actually pushed.
    • calibrating-intel-quick-sync-hevc-quality: the prose carries no calendar date, but two checkable pins fix it to 2026-10-14: (a) it names ffmpeg 9.0.2, and ffmpeg.org lists 9.0.2 as released 2026-09-18 (9.0 on 2026-08-04), so nothing before that date is honest; (b) the premise — a shipping QSV tier whose defaults mean "uncalibrated" — only existed from the tier landing (Batch 4b-1, 2026-10-14 11:41) to the calibration that evening (Batch 4b-2). Keep pubDate 2026-10-14, updatedDate = the real push day.
    • Deep backdating (2024/2025 archive gaps) therefore needs evergreen posts only — see B2m for which queued topics qualify.
  • State of each: EN + ZH written; rewrap_post.py → wrap_lists.py applied to the EN files (ZH untouched, one paragraph per line); OG + banner generated and measured (measure_og_banner.py → PASS on all four pairs); npm run build clean (155 pages, no duplicate-id warning); all 8 dist/ pages verified locally (language switch both ways, content assertions from whitespace-normalised tag-stripped text, listing order monotonic on /posts/, / and /zh/).

  • ⚠ The generators already carry all four TERMINALS/BANNERS entry sets — the three for this batch's slugs (two held + one parked) rode into 2295c0f together with the authentik entry (git stages whole files). They are inert until a slug is actually generated, so publishing each post needs only its two .md files plus its two PNGs.

  • To publish one: confirm the real pubDate (date, and the newest <time> on the live /posts/), then git add the four explicit paths (post EN/ZH + og + banner) and push origin main; verify the four URLs 200.

Step B2n — (unplanned) The silent-JavaScript-failure post, backdated into the 2025-02 gap. 🔵 held for the read

Slug Category pubDate updatedDate Source
two-ways-a-javascript-file-stops-working-and-looks-fine engineering 2025-02-20 2026-10-16 hevc-transcode docs/OPERATIONS.md §10.33
  • What it argues: a parameter named t shadowing the i18n t() function, and a let LANG declared after its first call (TDZ) — two unrelated one-line causes that each aborted the whole script while the page kept rendering the inline default-language text, so nothing looked wrong. The fix is asserting on effects in a real browser (typeof t === 'function', every inline handler resolves — 219 attributes / 217 named calls), plus a pseudo-locale (?lang=pseudo, strings stretched 1.6×) that catches untranslated nodes and overflow.
  • Why the 2025-02-20 date: the prose carries no calendar date and no checkable version pin, which is the condition for backdating (see B2m's note). It fills the empty stretch between why-your-cdp-clicks-silently-fail (2025-03-19) and chainlink-vrf-v2-lottery-contract (2024-12-10) — verified in the built feed: the post lands at position 64 of 67, monotonic, with lastmod = 2026-10-16 from updatedDate.
  • ⚠ Consequence of the deep date: no internal links. Every post it would naturally reference is dated later, and a backdated post may only link backwards — so it ships as a stand-alone page. If the cross-links are worth more than the gap (e.g. to it-reported-success-nothing-had-changed, 2026-10-08, the same "trust the signal, not the intent" theme), move the date into the recent window instead; that is a one-line frontmatter change in two files.
  • State: EN + ZH written (rewrap_post.py → wrap_lists.py on EN only), og + banner generated and measured (og rows=1 overflow=0; banner 8 rows delta=2 overflow=0), build clean at 155 pages, both dist/ pages asserted locally (language switch both ways, content, date 2025-02-20, sitemap hreflang pair). Not committed, not pushed — the working tree also carries the two generator entries for this slug (additive: 5 / 17 lines, deletions 0).

Step B2o — (unplanned) The four surviving candidates, written and dated. ✅ Published 2026-10-16 (08fa7a9)

Slug Category pubDate Source
my-backup-was-using-someone-elses-api-quota notes 2026-10-15 Hermes 20261015_170438_0e4312
space-reclamation-wasnt-stuck-it-was-waiting devops 2026-10-14 Hermes 20261014_203035_c6b198
adding-a-domain-to-someone-elses-mail-server notes 2025-05-06 Hermes 20261014_092537_4759b3
embedding-fonts-so-a-pdf-looks-the-same-everywhere engineering 2025-01-22 numerology-report docs/v2/FONTS.md + Hermes 20261011_085128_453ab1
  • What each argues. (1) A 1 TB restic seed stalled at zero bytes because rclone's shared OAuth client was rate-limited (consumer: projects/202264815644, quota_limit_value: 840000) — own the client, Desktop-app type, Publish the app or the refresh token dies every 7 days, and the callback accepts exactly one request; the old side gave back 1.012 TiB → 3.800 GiB while rclone reported itemNotFound after finishing. (2) The DSM Storage Manager card reads running 24×7 by design (run_all_time, schedule_table 168×1) — free space actually came back +69.0 / +46.8 GiB at the 09:29/09:34 snapshot rotations while the recycle bin held 5 GiB and the afternoon wrote −47.2 GiB; retention 1 means a delete frees only at tomorrow's rotation. (3) Inbound needs four records and worked first try; outbound hit 554 5.7.1 (no PTR, 4 RBLs clean) and the relay hit 550-From header sender domain not verified; PTR is one per IP, not per domain, forwarding needs Sieve and still cannot leave the box, so the design that survives is a receiving-side POP pull — verified now: forward mail.hoelee.com → 41.216.187.102 resolves, the reverse lookup for that IP still returns nothing. (4) Four families / eight static faces embedded: static only because the PDF engine's parser reads glyf/loca and ignores fvar (variable fonts resolve to Thin 100 / ExtraLight 200), font-weight: 600 rewritten to bold for the PDF side only, GB2312 ∪ Big5 subsetting silently cut real name characters (犇 骉 頔 祎 廸 玧 祾 赟 堃), and a glyph lint that fails the build.
  • Dates and their justification. The two 2026 posts are pinned by their own evidence (the seed's quota errors and today's samples) and ship on their real work dates; 2026-10-14 now carries two posts (this one and the QSV calibration) — within the E7 limit of ~3–5. The two 2025 dates are backdating: both have no calendar date, no quoted log timestamp and no checkable version pin in the prose (verified: the mail post's only month reference was removed, the fonts post cites no engine version). They fill 2025-05 (between 2025-03-19 and 2025-06-11) and 2025-01 (between 2024-12-10 and 2025-02-20) — both confirmed in the built feed, monotonic, at positions 64 and 67. updatedDate = 2026-10-16 on all four, so every lastmod is the real day.
  • State: EN + ZH written (rewrap_post.py → wrap_lists.py on EN only, ZH one paragraph per line); og + banner generated and measured for all four (rows=1 overflow=0, banner 8 rows delta=2 overflow=0); build clean at 163 pages; 8 dist/ pages asserted (language switch both ways, dates, content, sitemap hreflang, images).
  • ✅ Published the same day on the user's go-ahead (08fa7a9, 18 files): all 16 live URLs verified 200 (4 posts × EN / ZH / og / banner, correct content_type), live feed monotonic at positions #2 (2026-10-15), #3–#4 (2026-10-14), #65 (2025-05-06) and #68 (2025-01-22), sitemap 163 URLs with lastmod 2026-10-16 on all four EN/ZH pairs, and the two ZH pages carry the correct Chinese <title> + self-canonical + hreflang pair. Note for the next session: the EN and ZH pages of space-reclamation-… happen to be byte-length identical (20492 B) while differing in content — that is a coincidence, not a broken route (md5 differs, ZH contains 空间回收 and not the English title). The pre-existing og:locale = en on every ZH page is Step E5, untouched here.
  • Internal links: the two recent posts link backwards (to running-production-infrastructure-solo, 10-06; it-reported-success-nothing-had-changed, 10-08; your-disk-full-alert-is-lying, 06-24). The two backdated posts carry no prose links — every post they would naturally cite is dated later, and a backdated post may only link backwards. Their "related posts" rows are the theme's automatic ones, which every backdated post already has.

Step B2p — (unplanned) The commit-churn post. ✅ Published 2026-10-18 (0d73842), after the owner's read and a voice rewrite

Slug Category pubDate Source
why-500000-lines-of-commit-churn-only-left-230000-in-production engineering 2026-10-18 Hermes session 2026-10-17
  • What it argues: thirty days of autonomous-agent work across five self-hosted Gitea repos = 865 commits, 518,233 additions, 130,892 deletions — and production kept 235,000 lines. Verification gates and pruning, not generation speed, are what survive.
  • The owner's read changed two things (both before publishing):
    1. Voice. The draft opened "When managers or recruiters ask how productive an engineer is…" and kept addressing the reader in the second person (you have to move beyond the chat box, If your Git log shows…) — it read like a LinkedIn think-piece rather than a log entry, and it framed the post as written for recruiters. Rewritten in the first person (EN you/your 12 → 2, both in the CTA; ZH 你 5 → 2), headings changed from essay titles to actions (What the agents actually touch, What changed in how I work). All numbers, the table and the claims are unchanged — this was a voice edit, not a new draft.
    2. Title. Why 500,000 Lines of Commit Churn Only Left 230,000 Lines in Production → I Committed 518,000 Lines in 30 Days. Production Kept 235,000. (ZH: 30 天提交 51.8 万行,生产环境只留下 23.5 万行).
  • Two real defects found while fixing the card: the draft said "four automated gates" and then listed three (now fixed to three); and og-gen's title splitter broke at the word-count midpoint, which put the break at …Lines in / 30 Days — the splitter now prefers a sentence boundary near the middle and falls back to the midpoint (.og/gen measured afterwards: 2 title lines, overflowY 0, measure_og_banner.py PASS · tags 1 row · banner delta 2).
  • State: EN + ZH written and rewritten; og PNG regenerated for the new title, thumbs + banner WebP re-derived (optimize-images.py), build clean, /posts/ order monotonic with the post on top at 2026-10-18.
  • ⚠ The four files were held untracked for two days on purpose (publication gate). Nothing else is held now.

Step B2m — The next-session topic queue (user decision 2026-10-16). ⬜

From the same sweep, explicitly not to be written: the Photoshop "closes every five minutes" post (#6 in the sweep) and the Shopee abnormal-status / voucher-card post (#12). Everything below is queued, in the user's requested order of interest, each with its source session:

⛔ Dropped by the user on 2026-10-16 — do not write these, do not publish them. Of the seven candidates below, only #2 (rclone/Drive), #4 (NAS space reclamation), #6 (mail domain/PTR) and #7 (embedded fonts) survive; they are written and dated in Step B2o. The other three are dead: #1 The Packaged App 404'd Its Own CSS, #3 The UI Said "Idle" / the savings ledger and #5 The Scan Failed Because an HTML Error Page Was Parsed as JSON. The same instruction killed the four candidates that surfaced later the same day and were never entered here — the pause-button hole (pause_now only killing when self.running and self.pid), the ten rows that passed a gate that did not exist yet, one-file-one-worker, and the empty-path 400. Do not resurrect any of them without a fresh request from the user.

Which of these can carry a deep historical date (the user's 2026-10-16 question). Deep backdating needs a post whose facts are undated — no calendar date, no quoted log timestamp, and no version whose release date is checkable. In this queue that is only two: the mail-server/PTR post (concepts, no events) and the PDF font-embedding post (four font families, no dates). Both fit the big empty stretches — 2024-09-25 → 2025-03-18 (six months) and the near-empty 2025-07 → 2025-12 — with updatedDate = the real date. The other five are recent-window only: the NAS memory and idle-guard posts quote timestamped samples, the rclone one rests on a dated quota event, the scan failure and the router-401 posts both quote a runtime/version state.

Topic Category Source The hook
Moving a 1.5 TB cloud backup OneDrive → Google with rclone notes 20261015_170438_0e4312 seed stalled at 0 bytes/90 s with 159 rateLimitExceeded; gcloud holds only a TTS service account so the console is the critical path; the credential must be a Desktop app client for rclone's redirect
The UI said "idle", the memory guard had refused 83 times devops 20261015_232854_2d9fc2 lanes blocked 83× (34-minute waits) reported as idle; and the "savings" ledger lying — SUM(size − out_size) with rounded estimates, 632 of 688 rows size ≈ out_size, lifetime total fell 37.0 → 12.46 GB while the row count grew 591 → 670
Your NAS didn't hang — 121 GiB came back while you weren't looking devops 20261014_203035_c6b198 5-minute free-space samples: two snapshot rotations +69.0 / +46.8 GiB, recycle bin held only 5 GiB, then −47 GiB of new writes; regrowth + snapshot retention ≠ a stuck job
The scan failed because an HTML error page was parsed as JSON devops 20261015_200429_37ec35 SyntaxError: Unexpected token '<'; the real cause was a CJK path printed to a GBK console → UnicodeEncodeError killed 3 jobs at their own first log line
Adding a domain to someone else's mail server notes 20261014_092537_4759b3 PTR is per IP, not per domain; forward-confirmed rDNS uses postfix's own HELO name; a new domain still needs MX/SPF/DKIM/DMARC + a verified SMTP2GO sender; forwarding still dies on p=reject
The router that 401s /v1/models and serves chat completions with no key ai 20261015_210913_47cca4 two Google-login connections (7 + 11 live models), REQUIRE_API_KEY=false with an empty key table, a dead gemini-3.1-pro alias
Embedding fonts so a PDF looks the same on any machine engineering 20261011_085128_453ab1 four families + weights fixed in-repo instead of trusting the server or the reader's machine ⚠ pull the final implementation detail from the session/docs first — the session moved on mid-topic
Two ways a JS file stops working silently engineering hevc-transcode docs/OPERATIONS.md §10.33 a parameter named t shadowing the i18n t() function, and a let LANG declared after its first use (TDZ) — both abort the script while the page keeps rendering the old text, so it looks like nothing happened; the fix is asserting on effects (typeof t === 'function', key count > 0, ?lang=en really swaps), never on intent. No date or version pins — also a candidate for an archive gap.
The packaged app 404'd its own CSS devops hevc-transcode docs/OPERATIONS.md §10.31 package-data listed only web/*.html, so the installed copy shipped without app.css / app.js / the i18n packs and the page 404'd its own assets (only a local checkout worked); plus the deploy-order trap — a running process reads the installed copy, so the order must be "install the Python half → restart at an item boundary → swap the page".
Housekeeping (not posts): E1 CF Web Analytics + GSC sitemap, E3 syndicate 2–3 flagships, A1 author photo, E8 og-default.png face + the one banner-less post — — these still outrank writing anything new, per the 2026-09-29 audit

Phase C — Discovery & structure — verbatim

⚠ The [ ] marks on C2 (tag pages) and C4 (/zh/posts/) here are stale — both shipped in batch B2/B3. The corrected status lives in docs/project-state.md.

Phase C — Discovery & structure (Tier 2)

Step C1 — Per-post custom OG images. ✅ Done (verified live 2026-09-29) Every published post serves its own /og/<slug>.png (1200×630) generated by scripts/og-gen/generate.mjs, with the category chip derived from frontmatter. Two leftovers only: the shared og-default.png still backs the homepage and category pages (no face — folded into A1 / E8), and one post's banner 404s (E8).

Step C2 — Tag pages (/tags/[tag]/ archive pages for fine-grained discovery + internal linking).

  • Add tag archive routes (tags currently render as labels only).
  • Done when: clicking a tag on any post opens a working /tags/<tag>/ page.

Step C3 — Categories page shows all 7 categories (not just those with posts), with "0 posts / coming soon" for empty ones — signals intended coverage. ✅ Done 2026-09-13

  • /categories/ lists all 7 categories with name, description, per-category terminal-style SVG illustration (CategoryArt/Grid components) and a post count; empty ones show "0 posts · coming soon" as a non-link.
  • Done when: ✅ all 7 render with a placeholder for empty ones + descriptions + illustrations.

Step C4 — Dedicated /zh/posts/ and /zh/categories/ archive pages. 🔵 In progress

  • /zh/categories/ index + /zh/categories/[category]/ detail pages live (zh nav "分类" points there; PostList is locale-aware with zh-CN dates).
  • /zh/posts/ archive still missing — zh nav "文章" falls back to /zh/ landing. ⚠ Live-measured 2026-09-29: the ZH nav's 「关于」 is also wrong (points at /zh/, not /zh/about/), and /zh/about/ has zero inbound links — see Step E4, which folds into this one.
  • Done when: zh nav links to real /zh/posts/ + /zh/categories/ archives.

Phase D — Polish / later — verbatim (DONE)

Phase D — Polish / later (Tier 3)

Step D1 — Search. ✅ Done (verified live 2026-09-29) Pagefind is shipped: /pagefind/pagefind.js → 200, a search toggle in the nav on both locales, and the shards are correctly Disallowed in robots.txt. (Originally deferred until >20 posts.) Step D2 — Google Search Console submission. → merged into Step E1 (2026-09-29). A google-site-verification TXT record already exists on hoelee.com, so the GSC domain property exists; what is unconfirmed is whether the blog's sitemap was ever submitted. Step D3 — Newsletter / email capture — only after real traffic exists (agree: do NOT add yet).