Anchor Website 13
Opus 4.8
Summary
A very large session on the Anchor 2 renderer of a327ex.com. Committed the prior session's uncommitted deep-link-nav batch, refreshed owned media, built a one-command deploy.sh (and fixed a font-determinism bug it exposed), did a big video/media fidelity pass, added responsive layout (fluid reflow + a mobile hamburger menu), fixed browser reload-key passthrough, and prepped (but did NOT flip) the root cutover. Also assessed two big new feature ideas (a creator chat, and a Box2D "letter-drop" announcement demo). Live a327ex.com was never touched — everything is on staging new.a327ex.com.
Session-number correction (Anchor Website 13 → 12 for the PRIOR session):
- The previous private seal (
vault/private-session-16.md) was titled "Anchor Website 13" but the seals ran 15="Anchor Website 11" then 16, skipping 12. Renamed the seal + the twoPLAN.mdreferences to "Anchor Website 12" (the font-subset "12" ref was already correct). Committeda58d9d8. (This new session is therefore genuinely "Anchor Website 13".)
Committed the prior session's in-progress batch (it had run out of context mid-work):
- Anchor2
947fe8b(pushed to origin, private repo): the web deep-link nav bindings (web_location_path/web_push_path/web_nav_pop, EM_JS under#ifdef __EMSCRIPTEN__, desktop no-ops) + font embolden (FT_Outline_Embolden, ppem-scaled = constant fraction of the em, optional 4th arg tofont_render_config). - a327ex-site
e6ebc73(pushed prod): the renderer side —main.luadeep-link nav wiring (url_for_slug,nav_to_path, boot readswindow.location,load_page/show_homepush the URL,update()pollsweb_nav_pop), per-theme embolden (apply_themewrapper +THEME_EMBOLDEN),theme.luaold-posts Source Sans as bundled TTF,convert.luano-intraword-emphasis,engine-boot.js(Module.locateFileabsolute-URL fix so a deep link doesn't 404 the bundle, loading cover, mobile bail, embed strip), nginx cover + no-cache, newassets/source-{sans,code}TTFs.
Cutover assessment + full list of what's left to port over (asked by the owner): laid out the end state (Lua HTML for crawlers/no-JS/mobile + injected engine-boot.js overlay for desktop; media from R2), the hard blockers (the CF bundle-cache mismatch bug, merging the staging nginx into the live block, prod video path), the switch mechanics (reversible sub_filter toggle), content-currency (the /msg sync automation), and the fidelity/mobile items. Owner set the arc: port over → letter-drop → announcement.
Two new feature ideas assessed (owner asked "what's your assessment"):
- Creator chat (bubble → chat window with the site owner, + an owner-side client): honestly critiqued as by far the biggest thing proposed — the UI is easy (existing toolkit) but the 80% is a real-time messaging backend, and the engine has ZERO networking (confirmed by grep). Recommended: engine renders the visitor UI, a tiny WS+SQLite service on the VPS, and the owner side starts as a Telegram/Discord bridge (mobile notifications, nothing to keep running) rather than a desktop Anchor client (which needs new desktop net bindings). Deferred as its own phased post-cutover project.
- Box2D "letter-drop" for the announcement (turn visible glyphs into falling physics boxes that stack): confirmed FEASIBLE — Box2D IS in the engine (
b2Body/b2World,include/box2d/*.c, exposed via thecolliderclass). Real work = enumerate visible glyphs with screen rects (renderer draws whole word-tokens vialayer_text, so needs a small per-glyph binding or Lua char-walk) + rotated glyph rendering (layer_texthas no rotation → bake glyphs to textures or a small binding) + Box2D orchestration; perf unknown = a screenful is ~1-3k bodies (stress case → cap/curate). A one-shot gimmick, low robustness bar. Owner: letter-drop rides the cutover; chat is separate.
Media refresh (owner flagged new YouTube videos + tweets weren't pulled):
- Found exactly what was missing via
uncached_media.py(replicates fetch_media's^\s*::(youtube|short)scan + parsesmedia.luawith lua54, skipping the 8 known-dead ids): 3 new YouTube videos (QB15JltRf-M,ef5M1xf5A9w,9K8OfIn6VSA) + 1 new tweet (2071495165454262748). - Pulled them all (targeted
--ids), uploaded to R2 (upload_r2.sh, rclone off-PATH). The YT anti-bot stack (Firefox cookies + node + yt-dlp EJS solver) works from the agent shell. Manifests: 34/36 tweets, 124/132 videos (rest = the known-dead). All in bundled messages → bundle-only deploy, no/datasync.
⭐ deploy.sh + uncached_media.py (owner asked to capture the deploy research so it isn't re-derived):
tools/deploy.sh: one-command deploy to the render dir + R2. Default = rebuild bundle + deploy + verify;--content= pull media (tweets/YT-targeted/embeds/emoji/dims) + reconvert + subset + R2 + sync content-changed lazy/data;--engine= also rebuild the web engine. YT pull never aborts the deploy. Ends with a local↔VPS matched-pair md5 check.- Found + fixed a real bug while testing: the bundle hash churned every
--contentrun even with no content change. Traced it tosubset_fonts.py—fontToolsstampshead.modifiedwith the current time, so the font subsets came out byte-different each run → a fresh 12 MBrenderer.dataevery deploy (wasteful with CF bypass-caching). Fixed withSOURCE_DATE_EPOCH; verified two builds now produce the identical hash. - Also fixed a Windows-
md5sum*-prefix bug in the/datasync awk.
Deep-link flash fix: a cold deep link briefly flashed the homepage/sidebar before the fetched post swapped in. Considered an engine "hold the cover" fix, but the default theme is site_dark (bg #111 ≈ the #1a1a1a cover), so the simple renderer-only fix works: a new view='loading' state paints the cover-dark, no sidebar, so the HTML cover hands off seamlessly. (Committed with the fidelity batch.)
Analytics / bot forensics (owner surprised by Cloudflare's "1.52k unique visitors"): analyzed the VPS nginx logs directly. Over a full day: 11,050 requests / 1,688 unique IPs; ~49% unambiguous bots (empty-UA + declared), 3,915 requests (35%) hitting scanner paths (wp-login/.env/.git/.php), 870 of 1,688 IPs literally running vuln scans, Zabbix alone 1,728 requests. Human-candidate IPs (content page + real browser UA + no scanning) ≈ 779, ~529 read a deep page → real humans ≈ 400–600/day, majority is bots. Noted this is normal, undercounted (CF filters more at the edge), and the SEO layering means bots get the Lua HTML and never boot the engine.
Fidelity pass (committed 4949617):
- YouTube embeds forced 16:9 (
layout_video_element— square-thumbnail music videos were rendering as giant squares); off-aspect videos letterbox on black when played (video_fit_cardcontain-fit); tweet/local::videokeep their true aspect. - Persistent "Watch on YouTube" button (bottom-right of the frame) → opens on YouTube, same as the title.
- CJK-aware title truncation (
mixed_truncate) —text_truncatemeasures with the Latin font alone, so a CJK title never truncated and overflowed the frame; fixed a real overflow the owner reported. - Spacing to match the CSS: image/video block margin 32→16 (1em, was 2em); message content→date gap 22→16 (
.feed-date-row margin-top: 1rem). Also caught a clip bug where the feed drew at 704 while built at the responsive width (_content_w). - Whole tweet / steam / spotify frame clickable → new tab (the tweet's video still plays, its images still lightbox; handled inside each element's draw so it works in the article view AND the homepage feed); hand cursor over image/video/tweet/embed in both contexts (
canvas_clickable_at).
Reload keys (committed 594d307): F5 / Ctrl+R / Ctrl+Shift+R were swallowed by SDL while the canvas had focus (owner had to click the browser chrome to reload). Added a capture-phase keydown listener (before the engine's) that stopImmediatePropagations the reload keys without preventDefault, so the browser reloads normally. In engine-boot.js + web-shell.html.
⭐ RESPONSIVE LAYOUT (matching the site's fluid flex + <768 mobile menu):
- Stage 1 — fluid reflow (
4949617):compute_layout()recomputes the column geometry fromwidth(sidebar min-width floor; content capped at TEXT_W but wraps narrower);_content_wis the responsive content width (notTEXT_W);update()reflows on width change;settings_applyrecomputes on a text-width change. Works off the BOOT window size (web_native_resolution sets game_width from the canvas at load). Owner verified the medium range (~900px) after the clip-bug fix. - Stage 1b — live-resize: ATTEMPTED then PARKED. Added
layer_resize(in-place FBO texture + stencil-rbo realloc) + anemscripten_set_resize_callbackthatSDL_SetWindowSize+ adopts the new drawable as game_width + resizes layers. Web engine compiled + deployed. Owner tested: resizing "cuts everything off, doesn't change at all" —SDL_SetWindowSizedoesn't resize the Emscripten canvas so game_width never changed; a real fix needsemscripten_set_canvas_element_size+ rewiring the compositor (main_loop_iterationreadsSDL_GetWindowSize) to use game_width for web_native + DPR handling — the same tangle as high-DPR rendering. Revertedanchor.cclean (staging keeps a harmless inert-callback wasm). Parked as not worth blocking a testing convenience on. - Stage 2 — mobile menu (
bb906d5): below 768px, full-width column + a sticky top bar (a327ex→ home + a hamburger) that toggles a full-width nav overlay reusingsidebar_draw; content top gets+TOPBAR_H; ≥768 unchanged. Owner verified ("looks correct"). Responsive is now visually complete.
⭐ CUTOVER PREPPED, NOT FLIPPED: the CF cache rule is live (owner set it up: bypass /anchor.* /renderer.* /engine-boot.js). Staging verified. The merged nginx block deploy/nginx-a327ex.conf is committed (header marks it PREPARED / NOT-APPLIED) — the flip = sudo cp onto the VPS + nginx -t + reload, a later-session step (the sub_filter overlay-inject is a 4-line toggle → instant revert). Agent has SSH + passwordless sudo on the VPS.
Session wrap: updated renderer/PLAN.md (new top handoff) + the project_anchor_website.md memory + the nginx-config status note; committed docs 47e3624. Next session (owner's plan): (1) mobile support = touch input + high-DPR (this also resolves live-resize); (2) if context left, the Box2D letter-drop demo; (3) the full flip, then the announcement. Sealed private as "Anchor Website 13".
🔒 Only the summary of this log is public. Private because it contains too many website internal details.