This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is a static personal website (timbeach.com) built as a single-page application with a modular HTML/CSS/JS architecture. The site features:
- A clean, readable layout with day/night theming
- Dynamic article loading system built in vanilla JavaScript
- Custom markdown parsing and syntax highlighting
- Astronomical starfield on the /about route
- index.html — thin HTML shell: head + theme inline-script + masthead +
<main id="app">+ footer +<script type="module">. - css/site.css — all CSS: tokens, base, layout, components, responsive breakpoints. Day/night via
[data-theme="dark"]andprefers-color-scheme. - js/app.js — site bootstrap, homepage rendering, route registration, starfield-off helper.
- js/router.js — hash router (
#/,#/article/<slug>,#/music,#/about) with legacy#articles/<slug>.mdredirect. - js/article.js — article fetch + markdown render. Hosts the ported
parseMarkdown(paragraph-boundary parity withtools/render_article.py:extract_paragraphsis required by the TTS validate gate — DO NOT refactor without re-rendering all audio). - js/tts.js — read-aloud bar (transport, voice select, paragraph highlight via timings sidecar). Auto-closes on navigation away from an article.
- js/static-article.js — progressive enhancements for the static
a/<slug>/pages: image lightbox + read-aloud bar (importsmountTtsBarfromjs/tts.js; article metadata comes from data attributes baked in at build time). Browser-level harness attests/staticarticletest.html(serve repo root, headless-chrome dump-dom, PASS/FAIL in<title>). - js/search.js — homepage full-text search: lazy-loads
search-index.jsonon first keystroke, AND-matches whitespace-split terms across title/tags/summary/body (weighted 4/3/2/1), renders ranked results with<mark>-highlighted snippets into the More section. Degrades to metadata-only search if the index fetch fails (e.g. fresh clone before any build).Escapeclears;/focuses the box. - js/util.js — shared DOM-free helpers (
escapeHtml,formatDateShort), importable from node for logic checks. - js/theme.js — theme toggle, localStorage persistence, OS auto-detect fallback.
- js/starfield.js — astronomical starfield + sky-info widget. Mounted only on the
/aboutroute via init/destroy pair. - articles/ — markdown articles +
articles.jsonregistry. - audio/ — pre-rendered Opus + timings sidecars (TTS).
- tools/render_article.py — TTS pre-render + validate.
- tools/build_feed.py — RSS 2.0 generator (run by
deploy.sh). - tools/build_share_pages.py — per-article static page generator (run by
deploy.sh). See Static Article Pages. - tools/build_sitemap.py — sitemap.xml generator (run by
deploy.sh): homepage + every published, listed/a/<slug>/URL. Future-dated/unlisted articles are excluded (their pages carrynoindexuntil they go live). - tools/build_search_index.py — search index generator (run by
deploy.sh): bakessearch-index.json(slug → plain article text) viarender_article.extract_paragraphs, so index text extraction shares the TTS parity definition. Includes future-dated/unlisted articles on purpose — the client only searches its own date-filtered list. Tested bytools/test_build_search_index.py; browser-level harness attests/searchtest.html(serve repo root, headless-chrome dump-dom, PASS/FAIL in<title>). - search-index.json — generated full-text search index. Build artifact: gitignored, rebuilt each deploy, rsynced (not in
.deployignore). - feed.xml — generated RSS feed (regenerated each deploy). Item
<link>s point at the static/a/<slug>/pages (crawlers strip#fragments);<guid>s keep the historical#-URLs so subscribers aren't re-delivered everything. - sitemap.xml — generated sitemap (regenerated each deploy, tracked in git like feed.xml). Referenced from the static
robots.txt. - a/ — generated static article pages (
a/<slug>/index.html+og-<hash>.png). Build artifact: gitignored, rebuilt each deploy, rsynced (not in.deployignore). - deploy.sh — validate TTS → build feed → build share pages → build search index → build sitemap → rsync.
The site uses a lightweight article management system:
- Content Creation: Articles are written in markdown format in the
articles/directory - Metadata Management: Each article must be registered in
articles/articles.json:The"filename.md": { "title": "Article Title", "date": "YYYY-MM-DD", "tags": ["tag1", "tag2"], "emoji": "🎯", "summary": "One-sentence description shown on the homepage card and in the RSS feed.", "section": "override-label", "hero": "pix/hero-image.png", "audio": "audio/filename.ogg", "timings": "audio/filename.timings.json", "voice": "bm_lewis", "duration": 187.3 }
audio/timings/voice/durationfields are populated automatically by the render tool. Articles withoutaudiosimply don't show a read-aloud button (graceful degradation). The optionalsummaryfield powers both the homepage card descriptions and the RSS feed's<description>element.sectionoverrides the auto-derived label (first tag, capitalized, hyphens-to-spaces).herois reserved for future per-article hero images. - Dynamic Loading: Articles are fetched via JavaScript and parsed with custom markdown processor
- Read-aloud: Native
<audio>element streams the pre-rendered Opus;timeupdateevents drive paragraph highlighting from the timings sidecar. See TTS Pipeline below.
Removed in the 2026-05 redesign. Saved as a future Easter-egg follow-up (beads issue timbeach-d75).
./deploy.shSyncs the site to the production server via rsync, excluding .git/, archive/, and .well-known/ directories.
No build process required - open index.html directly in a browser or serve with any static file server.
The canonical path is the publish-article skill (run /publish-article <path> in Claude Code), which walks through:
- Acquire content (file path or inline)
- Handle images (copy to
pix/, rewrite paths) - Propose metadata, get approval
- Write
articles/{slug}.md+ extendarticles/articles.json - 4.5 — Render audio via
tools/render_article.py {slug} --voice {voice} - 4.6 — Validate parity via
tools/render_article.py --validate {slug} - Local preview, deploy, commit, push
Manual flow (if not using the skill):
- Drop the markdown into
articles/and add a basic entry toarticles.json tools/venv/bin/python tools/render_article.py {slug} --voice bm_lewis./deploy.sh(which validates first, then rsyncs)- Commit the markdown +
audio/{slug}.ogg+audio/{slug}.timings.json+ image assets
Read-aloud audio is pre-rendered at publish time, not synthesized in the reader's browser. See docs/tts-pipeline.md for the deeper architectural treatment; quick reference below.
Web Speech API doesn't work on Linux Brave without speech-dispatcher + espeak-ng. In-browser Kokoro WASM has unreliable real-time-factor on mid-range laptops (page-unresponsive warnings, multi-second gaps between paragraphs). Pre-rendering once on the author's laptop solves both: every reader gets identical playback regardless of OS, and timings are exact by construction so paragraph highlighting is always in sync.
| Task | Command |
|---|---|
| Render one article | tools/venv/bin/python tools/render_article.py {slug} --voice bm_lewis |
| Re-render with a different voice | tools/venv/bin/python tools/render_article.py {slug} --voice af_nicole --force |
| Render every article (batch) | tools/venv/bin/python tools/render_article.py --all |
| Validate timings vs markdown | tools/venv/bin/python tools/render_article.py --validate {slug} |
| Validate every article | tools/venv/bin/python tools/render_article.py --validate |
bm_lewis is the site default. Other voices auditioned during the original brainstorm:
- British male:
bm_daniel,bm_fable,bm_george,bm_lewis - British female:
bf_alice,bf_emma,bf_isabella,bf_lily - American female:
af_alloy,af_aoede,af_bella,af_heart,af_jessica,af_kore,af_nicole,af_nova,af_river,af_sarah,af_sky
Voice prefix b* → en-gb, a* → en-us (inferred automatically). Render-time factor on CPU is ~0.6× (a 5-min article renders in ~3 min).
cd ~/code/PROJECTS/VULTR_0/sites/timbeach.com
python3 -m venv tools/venv
tools/venv/bin/pip install -r tools/requirements.txt
tools/download-models.sh # downloads kokoro-v1.0.onnx (~310 MB) + voices-v1.0.bin (~27 MB)Requires ffmpeg on PATH (system package).
- "index 510 is out of bounds" → a single paragraph exceeds Kokoro's 510-phoneme context. The renderer auto-splits on
.!?and retries. If a sentence-less wall (e.g., a markdown table) still overflows, the whole article fails — see next item. - Markdown table mangled into spoken pipes →
extract_paragraphsusesmarkdown-it-pywith.enable("table")so tables are properly skipped (the client also doesn't include<table>in its TTS selector). If you see stuck pipes, your table syntax is non-standard or the renderer is missing.enable("table"). - Bar hides itself with
[tts] paragraph count mismatch→ the markdown was edited after rendering. Re-render with--force. - Bar hides itself with
[tts] first paragraph text mismatch→ same root cause as above; re-render with--force. - Validate passes but the bar still hides client-side → client/server parser divergence (
parseMarkdownandextract_paragraphsdisagree on paragraph boundaries). Two known causes have been fixed:# → <h2>title mapping (client selector excludes h2; server excludes h1), and per-line<p>wrapping (the client wraps every newline-separated line in its own<p>, so verse-style markdown should use\n\nseparators between every line). If a third divergence appears, the long-term fix is to extractparseMarkdowninto shared JS and validate via Node.
./deploy.sh runs tools/render_article.py --validate before rsyncing. If any article's timings are stale relative to its markdown, the deploy aborts. Catches "edited an article, forgot to re-render."
Neither social crawlers (LinkedIn, X, Facebook, Slack, iMessage) nor Googlebot
ever see the hash fragment — a #/article/<slug> URL always resolves to the
bare index.html. So deploy.sh generates a real static page per article,
which serves two jobs at once: per-article social previews AND the site's
Google-indexable surface.
tools/build_share_pages.pyreadsarticles/articles.jsonand writesa/<slug>/index.htmlwith that article'sog:/twitter:tags, a self-referencing canonical, and the full rendered article body (markdown → HTML viabuild_feed.render_article_html, relative image paths rewritten root-absolute,{{youtube:...}}lines converted to iframes). The pages get the same reader enhancements as the SPA viajs/static-article.js: image lightbox and a working "▶ Listen to this article" button that mounts the shared TTS bar in place. TTS parity on these pages holds by construction — the static body is rendered with markdown-it, the same rendererextract_paragraphsused to produce the timings — and is pinned for the whole real corpus bytest_real_corpus_tts_paragraph_parityintools/test_build_share_pages.py.- History (2026-07 SEO rework): these pages used to be zero-second meta-refresh redirect stubs into the SPA. Google classified them as "Page with redirect" and indexed nothing — do not reintroduce a redirect here.
- Gating: future-dated and
unlistedarticles still get pages (shareable by direct link) but carry<meta name="robots" content="noindex" />and are left out ofsitemap.xml. The nightly anacron redeploy regenerates pages, so a scheduled article becomes indexable automatically on its date. - Share the
https://timbeach.com/a/<slug>/URL, not the#URL. Crawlers read the baked-in tags; humans read the article right there (masthead links lead into the SPA). - og:image: always a crisp 1200×630
a/<slug>/og.png(rendered with Pillow). Handing crawlers an image already at OG dimensions avoids the blur/ crop they introduce when resampling an arbitrary-aspect source. Source precedence: explicitheroinarticles.json→ first image embedded in the article body → a branded text card (night-theme palette). Local images are contain-fit (whole image visible — safe for diagrams/screenshots) onto the dark canvas via Lanczos. A remoteheroURL is passed through verbatim. - The article reading view also has a ⧉ Copy share link button that copies
the
/a/<slug>/URL. - After deploying a new/edited article, re-scrape the URL in the LinkedIn Post Inspector to bust any cached preview.
- The canonical host is bare
https://timbeach.com. nginx on the vultr server (/etc/nginx/sites-available/vultr-2024) 301-redirectswww.timbeach.comandtimbeach.net/www.timbeach.netto it (timothybeach.orgalready did);index.htmlalso carries a canonical tag. robots.txt(static, checked in) points at the generatedsitemap.xml.- Article URLs submitted to search engines are the static
/a/<slug>/pages — never#-fragment URLs, which crawlers collapse into the bare homepage.
The site features an astronomically accurate starfield background:
- Star Catalog: Real star data loaded from
stars.json(100+ bright stars with actual RA/Dec coordinates) - Procedural Stars: 400 additional fainter stars with randomly generated but valid celestial coordinates
- Projection: Azimuthal equidistant (fisheye) projection, same as planetarium domes
- Center of screen = Zenith (directly overhead)
- Edges of screen = Horizon
- Azimuth determines position around the circle
- Real-time Calculations:
- Local Sidereal Time (LST) based on observer location and current time
- Converts celestial coordinates (RA/Dec) to horizon coordinates (Alt/Az)
- Only displays stars above the horizon
- Updates every second
The system uses proper astronomical formulas:
- Greenwich Mean Sidereal Time (GMST) calculation
- Local Sidereal Time from longitude
- Coordinate transformation from equatorial (RA/Dec) to horizontal (Alt/Az)
- Geolocation API for observer position
- Star size based on magnitude (brighter = larger)
- Color-coded by spectral type (emerald, sapphire, ruby, amethyst, topaz, aquamarine)
- Parallax scrolling with depth layers
- Twinkling animation with staggered delays
- Hover tooltips showing star names for cataloged stars
Real-time astronomical data shown in bottom-left corner:
- LST: Local Sidereal Time (HH:MM:SS)
- Location: Observer coordinates (from geolocation)
- Visible: Count of stars currently above horizon
- Vanilla JavaScript with no external dependencies
- CSS uses custom properties for theming
- Markdown parsing handles code blocks, headers, bold/italic, and links
- Terminal simulation includes loading animations and command history