Changelog

What's changed on this site recently. Full commit log on GitHub.

  1. MCP server now exposes the skills marketplace — list_skills + get_skill_preview round out the agent-discovery story

    The /skills storefront promised 'any MCP agent that speaks x402 can pay directly against /skills/<slug>/raw' but the MCP server itself didn't list the catalog — agents had to scrape the HTML or know that /skills.json existed. Two new tools close the loop: list_skills returns the full 8-skill catalog with title, excerpt, price, install one-liner, and (crucially) the currency=USDC + network=eip155:8453 header so x402-aware agents can budget the buy in one round-trip; get_skill_preview returns the explicit free-preview block from the dedicated portableText `preview` field (not `content`, not `raw_markdown`) plus a clear callout that the full body is gated at /raw. Both tools serialize JSON next to the human-readable summary so consumers can pick. Total MCP tool count: 14 → 16. smoke-mcp.mjs gains three assertions (catalog shape, preview shape, unknown-slug isError). The whole agent journey — discover → preview → install one-liner — now works through the protocol with zero HTML parsing.

    mcpx402skillsagents
  2. Cleared two serious WCAG violations on post pages — 84 nodes affected

    axe-core's wcag2aa run flagged two real bugs that had been latent on every narrated post: (1) the .para-actions span — the per-paragraph hover container that holds the ¶ permalink + 🔊 audio buttons — was set aria-hidden='true' but contained focusable buttons (aria-hidden-focus rule, 49 nodes on /posts/eight-plugins-one-session, 35 on /posts/a-robots-txt-...). Removed the aria-hidden so AT users can keyboard-tab to copy permalinks + play paragraph audio just like sighted users; added explicit aria-labels on both buttons so the announcements stay clean. (2) the .ap-chapters-count badge — the '(N)' counter next to the AudioPlayer's Chapters button — used #837b6c on the gruvbox cream bg for 3.69:1 contrast, below the 4.5:1 AA floor. Bumped to var(--color-fg) at opacity 0.85 (~6.7:1). Verified live: 9/9 audited surfaces now report 0 critical / 0 serious. Build gotcha discovered + memorialized: pnpm deploy and pnpm deploy:unchecked do NOT run the Astro build — wrangler deploy just uploads what's already in dist/. Always pnpm build first.

    a11ywcagpolish
  3. Cleared the last two verify regressions — broken /stats link + missing aria-label on persistent mini-player

    Tightened the verify chain (47 smoke scripts, ~3 min wall) by fixing two latent failures the larger fixes surfaced: (1) /stats had a 'try it' link to /api/search/cues.json?q= with an empty query, which the API correctly rejects as 400 — replaced with a real example query (?q=x402) so the link does something useful and refreshed the MCP capability blurb in the same pass to mention the new list_skills + get_skill_preview tools; (2) the persistent #smp-audio mini-player element had no aria-label, tripping the feeds smoke's audio-aria-label check on the 7th audio on /. Added 'Mini-player narration audio' — the per-episode title is already exposed via the visible #smp-title for sighted users; the audio element label just needs to exist so AT can identify the control before it's adopted. End-to-end verify (with set -o pipefail catching any swallowed failures) now exits 0.

    polishqaverify
  4. Chapter ticks on the mini-player progress bar — visual table of contents you can hover + click

    The mini-player's 3px progress bar now sprouts ~12 vertical tick marks evenly sampled from the playing post's VTT cues. Each tick is a `<button>` positioned at left:<percentage>% (cue.start / audio.duration), with the cue text as its title attribute (browser hover-tooltip) and 'Jump to M:SS' as its aria-label. Hover any tick → the mark thickens from 2px to 3px and brightens from 55% to 100% white. Click → audio seeks to that timestamp. Sampled at every Nth cue (N = floor(cueCount / 12)) so dense-cue posts get a clean visual rhythm, sparse-cue posts get every cue. Renders when audio loadedmetadata fires (so duration is known) and re-renders if the slug changes via auto-advance. Sits inside a position-relative wrapper so the absolute-positioned ticks don't escape the bar's bounds. Browser-real Playwright smoke (scripts/smoke-bar-ticks.mjs, pnpm smoke:bar-ticks, added to pnpm verify): asserts 8-20 ticks render after the mini-player adopts default audio, first tick has the first cue's text as its title + a 'Jump to 0:00' aria-label + numeric data-start, clicking the 5th tick seeks audio.currentTime to within 1s of the tick's data-start, zero unexpected console errors. Result: the 3px-tall progress bar is now a visual table of contents — readers can hover the bar to scrub for cues, click a tick to jump straight to that moment, and SEE at a glance how the episode is structured.

    audiouxnavigationdesign
  5. Multi-tab mini-player sync via BroadcastChannel — open mondello.dev in two tabs and they stay in sync

    The persistent mini-player now broadcasts state across browser tabs of the same origin via the standard BroadcastChannel API ('mondello.miniPlayer.sync.v1'). Whenever the smp-audio fires play / pause / seeked / timeupdate (throttled to once per 2.5s), it posts a message with {kind, tabId, src, title, postUrl, t, duration, at}. Each tab generates a random tabId so the receiver can ignore its own messages (no feedback loops). On the receive side: if the receiving tab hasn't adopted any audio yet, it shows the 'Resume listening' pill with the broadcasted state — so you can be reading on tab B and see 'Tab A is listening to <episode>'. If the receiving tab IS playing the same src, it syncs the playhead within a 2-second drift tolerance + relays pause/resume — so two tabs of the same post stay locked together (suppressed by a brief in-flight flag to prevent re-broadcast loops). No backend, no service worker — pure browser API. Browser-real Playwright smoke (scripts/smoke-multi-tab.mjs, pnpm smoke:multi-tab, added to pnpm verify): launches two tabs of /changelog in the same browser context (so they share BroadcastChannel), clicks ▶ in tab A → asserts tab B shows the 'Resume listening' pill with the same title within 6 seconds AND tab B's sessionStorage has the broadcasted state.t > 1, zero unexpected console errors. The kind of detail-polish you only notice when you accidentally try it: open the blog in two tabs at once and the playback follows you. Companion to the lock-screen MediaSession integration (cross-process audio control) and the per-tab sessionStorage resume pill (per-tab navigation persistence).

    audiouxmulti-tabbroadcast
  6. '✓ Heard' badges on post cards — readers can see at a glance which episodes they've finished

    Closes the listening feedback loop. When the persistent mini-player's audio fires `ended` (full episode played through OR last cue auto-advance), the slug is recorded in localStorage['mondello.heardEpisodes'] (capped to 200 entries, oldest dropped). On every page load, the existing listenProgress hydration script reads BOTH localStorage maps: (a) the granular pos/dur tracking written by the on-post AudioPlayer, and (b) the new 'heard' Set. If a slug appears in 'heard' OR pos/dur > 0.97, the post card's narration badge gets a `.has-heard` class which CSS renders as a subtle inline ✓ check after the 🎧 emoji (Gruvbox green-2 #79740e on light, bright green-1 #b8bb26 on dark). The 'X% heard' progress ring is reserved for partial-progress posts (5-97%); posts that are fully heard get the cleaner ✓ instead. The badge tooltip gains '· ✓ Heard' for screen readers + hover discovery. Heard-state is per-localStorage so private windows / different browsers reset it. Browser-real Playwright smoke (scripts/smoke-heard.mjs, pnpm smoke:heard, added to pnpm verify): 5 checks — inject a known slug into mondello.heardEpisodes + reload home + assert at least one badge gains .has-heard with '✓ Heard' tooltip; verify CSS ::after computed style includes ✓ in the right color; assert untouched badges remain unchanged; simulate the mini-player firing ended + assert the slug is appended to heardEpisodes; zero unexpected console errors. Result: visit a few episodes, finish them in the mini-player, and the home page now visually marks your progress through the corpus — a tiny but powerful 'I've been here' feedback loop you'd find in a podcast app.

    audiouxfeedbackdesign
  7. Footer 'Keyboard shortcuts' button — surfaces the keyboard help dialog discoverably

    The keyboard help dialog (which surfaces every site-wide shortcut: ⌘+K, Space, ← →, J L, [ ], F, T, C, ?, Esc) was already wired to fire on `?` press, but new visitors had no way to discover it without already knowing. Added a small '<kbd>?</kbd> Keyboard shortcuts' button to the footer copyright line — sits between 'Refresh cache' and the theme switcher, looks like a normal text link with a styled <kbd> chip. Click it → dialog opens. Subtle hover state shifts to accent color. The dialog itself is unchanged: pre-existing markup at the bottom of Base.astro with backdrop blur, focus trap, Esc/backdrop-click close, all the audio + nav shortcut rows. Browser-real Playwright smoke (scripts/smoke-kbd-help.mjs, pnpm smoke:kbd-help, added to pnpm verify): asserts dialog is present + closed by default on /, /changelog, /posts, /quotes; presses ? + asserts dialog opens; asserts dialog content includes K/J/L/?/Esc + 'Play' label; presses Esc + asserts close; click footer button + asserts dialog opens; click × + asserts close; presses ? while focus is in an input + asserts dialog stays closed (input shouldn't trap shortcuts); zero console errors. The blog now both HAS impressive keyboard control AND surfaces it discoverably to new readers.

    uxdiscoverabilitykeyboarda11y
  8. OG share cards now show a per-post audio waveform + duration — share previews look like podcast episodes

    Every narrated post's social-share OG card now ships with a stylized 60-bar audio waveform decoration along the bottom, plus a '🎧 6:45 narrated' kicker label above it. Bar heights are seeded by a Mulberry32 PRNG hashed from the slug (FNV-1a 32-bit), so the pattern is unique per post but byte-identical across re-fetches — critical for cache integrity since Twitter / Bluesky / Discord / Slack all cache OG images for hours. Server-side: when /og/[slug].svg is requested, a quick D1 lookup in the media table checks if the post has narration; if yes, durationSeconds() converts the MP3 byte size to a human-readable M:SS, and the waveform block is appended to the SVG. If no narration (or D1 fails), the original card renders unchanged — graceful fallback. Bars are accent-colored with per-bar opacity from 0.55 to 0.95 (taller bars more opaque, like real audio frequency data), rounded ends (rx=barWidth/2). The aria-label on the SVG also gains the '— narrated audio M:SS' suffix so screen readers + accessibility tools see the duration. Smoke (scripts/smoke-og-svg.mjs, pnpm smoke:og-svg, in pnpm verify): now 9 checks total — added 'waveform: 60 bars + 🎧 kicker' (asserts ≥50 rounded <rect>s + matches the duration-kicker pattern) + 'deterministic per-slug (2nd fetch byte-identical)' (proves caching is safe). Result: when someone shares 'https://mondello.dev/posts/eight-plugins-one-session' on Twitter, the unfurl card now LOOKS like a podcast episode — title + duration + waveform — instantly signaling 'click the link, you can listen to this' instead of just 'this is a blog post'.

    audiouxsocialseo
  9. Karaoke body sync — when reading a post whose audio is in the mini-player, the matching paragraph highlights + scrolls

    When the persistent mini-player is playing a post's audio AND the reader is on that post's page, the article body now highlights the paragraph being spoken right now and smooth-scrolls it into view if off-screen. The match logic uses the same VTT cue cache that powers the live caption ticker — when a new cue text is emitted, the script: (1) checks if location.pathname includes the playing post's slug (skip if reading a different post); (2) pulls the article body via the .article-content / article .prose / article selector chain; (3) walks every p, li, h2, h3, h4, blockquote, lowercases + strips punctuation; (4) substring-matches the cue's first 60 chars; (5) on first match, removes the highlight class from the previous paragraph (single-active invariant), adds it to the new one, and calls scrollIntoView({behavior:smooth, block:center}) only if the element is off-screen (so reading position isn't yanked when you can already see it). Highlight CSS is a soft accent left-border (3px) + gradient-tinted background that fades from accent-12% on the left to transparent at 65% — visible but not screaming, transitions in 0.4s. Spotify+Genius-lyrics-on-a-blog moment. Browser-real Playwright smoke (scripts/smoke-mini-player.mjs): now 19 checks total — added 'body highlight on matching post: <real cue text>' (visits /posts/eight-plugins, plays mini-player, seeks to t=30, asserts exactly 1 .smp-body-highlight element exists with the cue text in its body). Combined with the live caption ticker (showing the current cue text on the player itself) and the live waveform (showing audio is actually playing), the mini-player now provides four simultaneous visual confirmations of audio: the dancing canvas bars, the title, the captions ticker, and a synced article-body highlight. The 'i don't see audio' problem is comprehensively answered.

    audiouxkaraokereading
  10. Live waveform visualization in the mini-player — Web Audio + canvas frequency bars

    Added a real-time frequency-bar visualization to the persistent mini-player, powered by the Web Audio API. Drawing flow: when the smp-audio fires its first `play` event, a lazy initViz() creates an AudioContext, calls createMediaElementSource(audio) (CORS-safe because /audio/<key> is same-origin), wires a 64-fft AnalyserNode between the source and the destination, and grabs the canvas 2D context. Each frame at 60fps via requestAnimationFrame: getByteFrequencyData() fills a 32-element Uint8Array of bin amplitudes (0-255), the canvas is cleared, and bars are drawn at varying heights + accent-pink alpha (0.55 + v * 0.45 → quieter bins are translucent, louder ones are opaque). On `pause` the loop cancels but the last frame stays (so the bars are 'frozen' visually). The createMediaElementSource call can only run once per element, so we cache the analyser and reuse it forever — pause/resume cycles + episode auto-advances all reuse the same graph. 80×36px on desktop, 48×28px on mobile. Browser-real Playwright smoke (scripts/smoke-mini-player.mjs): now 18 checks total — added 'live waveform: 810/2880 pixels drawn' (verifies the canvas has a substantial number of non-zero alpha pixels after audio plays for 2.5s, proving real bars are being rendered, not just blank or all-white). The mini-player now triple-shows audio: (1) the title + time + bar, (2) the live caption ticker spelling out the words, (3) the dancing waveform proving it's actually playing. Plus all the audio-app controls (auto-advance, K/J/L/N/P, MediaSession, speed cycler, sleep timer). The 'i don't see audio' problem is now a literal impossibility — the player is on every page, animates while playing, captions what it's saying, and is controllable from anywhere.

    audiouxdesignvisualization
  11. Live caption ticker in the mini-player — Spotify-lyrics-style 'now saying' text under the title

    The persistent mini-player now shows the currently-spoken sentence as a small italic ticker line right under the title + time + scrub bar. Whenever the player adopts a known post's audio, it fetches /posts/<slug>.vtt (cache: force-cache, so the second adopt is instant), parses cues client-side via a 30-line WebVTT parser, and on every audio.timeupdate scans the cue list for the one whose [start, end) range contains the current playhead. Found → caption text becomes visible (italic, 0.78rem, ellipsis-truncated to one line); not found → caption hides (between cues / before first cue / after last cue). Per-slug LRU cache keyed by slug means switching back to a previously-played episode is zero-fetch. Used aria-live='polite' + aria-atomic='true' so screen readers announce caption changes the same way they announce live transcript regions on YouTube. Browser-real Playwright smoke (scripts/smoke-mini-player.mjs): now 17 checks total — added 'live caption @ t=4.5: <real cue text>' (verifies ticker shows non-empty matching-cue text after audio loads + seeks to a known cue range) + 'caption updates on seek: t=30 → <different real cue text>' (verifies the ticker tracks playhead, not just shows once). Result: when you're listening to a post on the mini-player, you can read along — the audio is now both audible AND visible as it plays. The 'i still don't see audio' complaint is now answered with words flowing across the player in real time, in addition to the player itself being visible by default with all the podcast-app controls.

    audiouxcaptionsaccessibility
  12. Mini-player gets podcast-app essentials: speed cycler (1×/1.25×/1.5×/2×) + sleep timer (5/15/30/60min)

    Two more podcast-app essentials baked into the persistent mini-player. (1) Speed cycler — small '1×' pill next to the close button cycles 1× → 1.25× → 1.5× → 2× → back to 1× on each click; the chosen rate is applied to audio.playbackRate immediately and persisted to localStorage so the user's preference sticks across browser sessions. Goes accent-pink when off-1×. Re-applies on every loadeddata so each new episode auto-runs at the saved speed. (2) Sleep timer — '💤' pill cycles off → 5 → 15 → 30 → 60 minutes on each click; when set, a 1-second interval ticks the remaining time down on the button (e.g., '14:23'), and in the final 10 seconds the audio volume fades from 1 → 0 linearly so you don't get jolted by an abrupt cut. Reaches zero → audio.pause() + reset volume + clear timer + button returns to '💤'. Click 💤 again at any time to cycle to the next duration; click again at 60 to clear. Both controls live in a small flex group between the time display and the close button — visible at all viewport sizes (28-px-min variant on narrow phones). On the resume-mode (pink) pill they tint white-on-white-with-alpha to match the surface. Smoke (scripts/smoke-mini-player.mjs): now 15 checks total — added speed-cycler-text-and-rate (1×→1.25×→1.5×→2×→1× with audio.playbackRate matching at each step) + sleep-timer-started (shows 4:59 within 1.1s of click) + sleep-timer-cycle (15m → 30m → 60m → off across 4 clicks). Result: the mini-player is now a complete podcast app on every page — auto-advance, keyboard shortcuts, MediaSession, speed control, and sleep timer. The kind of feature set people pay $5/mo for in dedicated apps is now baked into the page.

    audiouxpodcastcontrols
  13. Mini-player auto-advance + global keyboard shortcuts (K/J/L/N/P) + lock-screen MediaSession

    The persistent mini-player is now a full podcast-app: when one episode ends, it auto-loads + auto-plays the next narrated post in publish order — turning every page into a continuous-play surface, not just /radio. The full episode queue (server-rendered into data-queue as JSON) lives at the bottom of every page. On `audio.ended`, the player walks the queue, finds the current episode by audioUrl match, advances by one, sets src + waits for loadedmetadata + plays + saves new state. Reaches end-of-queue → falls back to clearing state + hiding the player. Plus global keyboard shortcuts work everywhere on the site (skipped when typing in inputs / textareas / contenteditable / with modifiers): K or Spacebar = play/pause, J = seek backward 10s, L = seek forward 10s, N = next episode, P = previous episode. Plus MediaSession API integration: navigator.mediaSession.metadata gets {title, artist:'Romy Mondello', album:'mondello.dev', artwork} every time the audio adopts a new episode, and action handlers wire play / pause / nexttrack (next ep) / previoustrack (prev ep) / seekbackward (skip 10s) / seekforward (skip 10s). Result: lock-screen controls + Bluetooth headphone media keys work site-wide, not just on /radio. Smoke (scripts/smoke-mini-player.mjs, pnpm smoke:mini-player): now 12 checks — 5-page visible-by-default, default-▶-loads-latest, state-saves-on-post, sessionStorage-survives-nav, resume-pill-on-alt-page, ▶-adopts-and-seeks, ×-clears, plus auto-advance-on-ended (Eight plugins → The full stack), keyboard K toggles play/pause, keyboard J seeks back, keyboard L seeks forward, zero console errors. The blog now plays itself end-to-end with no clicks needed past the first ▶, and you can drive playback from your headphone buttons or laptop media keys.

    audiouxkeyboardmediasession
  14. Mini-player visible by default on every page — 'Latest episode' CTA with one-click play

    The persistent mini-player now renders on first visit too — not just after audio has played. Server-side, Base.astro looks up the most-recent narrated post (already used by the footer 'Latest narration' tile) and writes its audioUrl + title + duration into the player's data attributes. The player ships in 'Latest episode' default mode: a dark blurred-glass pill bottom-center on every page, with the latest episode's title, '0:00 / 6:45' duration, and a pulsing pink-ringed ▶ button drawing the eye. Click ▶ → loads + plays the latest episode (granting user gesture), morphs to 'Now playing' mode, saves state to sessionStorage so navigation preserves the playback, mini-player adopts the audio on the next page. Three modes total now: (1) Latest episode (default, server-rendered, shown to first-time visitors); (2) Resume listening (pink, when you've started something elsewhere and navigated away); (3) Now playing (dark, when the mini-player itself is actively playing). The × button now sets a sessionStorage 'dismissed' flag so the player stays hidden across navigation in the same tab — no nagging if the user actively closed it. Browser-real Playwright smoke (scripts/smoke-mini-player.mjs, pnpm smoke:mini-player): visits 5 pages (/, /posts, /quotes, /radio, /changelog), asserts the player is visible-by-default with label='Latest episode' + non-empty title + data-default-audio populated, clicks ▶ in default mode + asserts the latest-episode src is loaded + audio is playing, then runs all 6 prior assertions (state saves on post, survives navigation, resume pill renders correctly, ▶ adopts + seeks to t=47, × hides + clears, zero console errors). 'i still don't see audio' is now structurally impossible — the player is on every page, on first load, with the latest episode pre-loaded and the play button ringed by a soft pulse animation.

    audiouxdesigndiscoverability
  15. Persistent floating mini-player — audio now follows you across page navigations

    New site-wide mini-player baked into Base.astro renders on every page (hidden by default). When you start playing audio anywhere on the site (post page, /quotes inline ▶, /radio queue, anywhere), state saves to sessionStorage every play / pause / 3-sec timeupdate: {src, title, postUrl, currentTime, duration}. When you navigate to a new page, if the saved state isn't already actively playing on the new page, a frosted dark pill appears bottom-center reading 'Resume listening: <title>' with the saved timestamp + a partial-fill progress bar. Click the ▶ on the pill (granting the new page's user gesture) and the mini-player adopts the audio: sets src, waits for loadedmetadata, seeks to the saved currentTime, plays. The pill morphs from accent-pink 'Resume' mode to dark 'Now playing' mode, the ▶ becomes ⏸, and continued playback updates the bar. Clicking the bar scrubs (proportional seek). The × button stops, clears state, and dismisses. The mini-player's own audio is excluded from the watch list (no recursion). On post pages with the same audio already playing, the resume pill stays hidden — no duplicate UI. Print stylesheet hides the player entirely. Mobile breakpoint collapses to edge-to-edge with truncated title. Browser-real Playwright smoke (scripts/smoke-mini-player.mjs, pnpm smoke:mini-player, added to pnpm verify): asserts markup present + hidden by default on /, /posts, /quotes, /radio (4 pages); plays audio on a post + verifies sessionStorage saves the right src + currentTime; navigates to /changelog (a non-audio page) + asserts state survives navigation; injects an alt-post state with t=47, reloads, verifies the resume pill renders with the alt-post title + ?t=47 deep-link + 49.5%-filled progress bar; clicks ▶ on the pill + asserts the smp-audio's currentTime advances past 46 (proving the seek worked, not playing-from-zero); clicks × + asserts player hides and sessionStorage clears; zero console errors throughout. Result: 'i still don't see audio' is now answered with a player that's hard to miss — start any audio anywhere, navigate freely, and the resume pill follows you with the source + position visible at the bottom of every screen.

    audiouxdesignsession
  16. /radio — continuous-play mode: hit play once, hear the whole blog back-to-back

    New /radio page turns mondello.dev into a Spotify-style continuous listening surface. One sticky player at the top (140×140 accent-colored '🎧' artwork tile, episode title, 'Read the post →' permalink, native audio controls, plus four custom buttons: ⏮ Prev / 🔀 Shuffle / 🔗 Share moment / Next ⏭). Below it: a numbered queue of all 6 narrated episodes (publish-newest-first) with hover state + click-to-jump. When the current episode's `ended` event fires, the next one auto-loads + auto-plays — no clicks needed. Shuffle picks a random non-current episode for the next pick. URL state is shareable + reproducible: every 5 seconds of playback, the address bar updates to `/radio?ep=<slug>&t=<seconds>`, so any moment in the continuous queue can be shared and resumed; landing on /radio?ep=foo&t=42 jumps straight to that episode + that timestamp. Lock-screen + Bluetooth-headphone controls are wired via the browser's MediaSession API: prev / play / pause / next handlers all hooked, MediaMetadata populated with title + artist + album + 1400×1400 podcast artwork — works on iOS Safari, Chrome on Android, and Chromium on desktop with media keys. Sticky-positioned player stays visible while you scroll the queue (collapses to non-sticky on narrow viewports for breathing room). Wired into footer nav (Feeds column, with the 📻 emoji to make it findable) + /sitemap.xml at priority 0.8 (highest of the static pages). Browser-real Playwright smoke (scripts/smoke-radio.mjs, pnpm smoke:radio, added to pnpm verify): launches Chromium, asserts the queue renders with N items, the <audio> element is wired with the first episode's src, clicking the 3rd queue item swaps both audio.src + the title heading, dispatching the `ended` event auto-advances the position counter (1 → 2 → 3 etc.), the shuffle toggle adds .is-active class + flips aria-pressed, the URL gains an ep= param after a timeupdate, opening /radio?ep=<2nd_slug> resumes at episode 2, and zero console errors fire during the whole sequence. Result: the entire blog is now playable as a hands-off podcast — start it on the train, lock your phone, and 27 minutes of audio rolls past with auto-advance and lock-screen controls. The 'subscribe in a podcast app' instruction at /podcast still works for people who prefer their own apps; /radio is the in-browser version that needs zero setup.

    audiouxpodcastdesign
  17. Inline audio playback on /quotes — tap any ▶ to hear the quote without leaving the wall

    Every card on /quotes now ships with three actions (was one): an accent-pill ▶ Play button that streams just that cue inline, a small 'Open post' link to dive into the article context, and a 'Copy' button for the share link. Tapping ▶ swaps the source on a single shared <audio> element, sets currentTime to the cue's start second, plays, and auto-pauses at the cue's end second (cue.end +0.5s buffer) — so you only hear the 5-15 second moment, not the whole episode. The button gets a ⏸ icon + .is-playing class + a soft ring-pulse animation; clicking it again pauses; clicking a different card's ▶ swaps source/time without restarting from zero. A floating 'Now playing' pill (rounded, fixed bottom-center, fg-on-bg high contrast, max-width responsive) shows the source post's title + a Stop button. Audio errors auto-stop and surface 'Error' on the timer. The /api/quotes.json payload was extended with `audioUrl` (canonical /audio/<key> URL via narrationAudioUrl helper, so it goes through the Range-aware R2 proxy that powers seek/scrub/resume) and `cue.end` (the buffered stop point). Server-side: the URL is built once in buildQuotes() so the MCP get_random_quote tool gets the same audioUrl free. Browser-real Playwright smoke (scripts/smoke-quotes-playback.mjs, pnpm smoke:quotes-playback, added to pnpm verify): launches headless Chromium, opens /quotes, clicks the first ▶, asserts the shared <audio> currentTime advances to the cue's start within ~5 seconds, the now-playing pill becomes visible with the right title, the button shows .is-playing + ⏸, a second click pauses, and zero console errors fire during the whole interaction. Result: the quote wall is now a true audio discovery surface — you can graze through six post highlights in 60 seconds without a single page nav, hear each one for 5-15 seconds, and only commit to opening a post when something hooks you.

    audiouxdesignplayback
  18. /quotes — the quote wall + /api/quotes.json + /api/quotes/random.json + MCP get_random_quote

    Every narrated post's featured cue is now collected into a single browseable destination at /quotes — a Pinterest-style masonry grid of pull quotes, sorted by quotability score descending, each card linkable to the audio at the moment the line is spoken. Three new surfaces share one source of truth (buildQuotes() in /api/quotes.json.ts): (1) the /quotes HTML page (Gruvbox card grid with Georgia-italic blockquote bodies, accent-colored left borders, source post + tag chips, '🎧 M:SS' deep-link, and a 'Copy link' button-as-link with a ✓ flash confirm); (2) /api/quotes.json — the entire collection in one fetch with full shape (slug, title, postUrl, durationSeconds, cue:{text, start, startFormatted, deepLinkUrl, score, reasons}, tags), sorted by score, 1hr cache; (3) /api/quotes/random.json — a single random pick for 'quote of the day' surfaces, no-store cache, accepts ?seed=<int> for deterministic pick (handy in tests). Plus a new MCP tool: get_random_quote (with optional seed parameter), so any agent can ask 'give me one quote-shaped audio moment to share' in a single tool call. Wired into footer nav (Feeds column) and /sitemap.xml at priority 0.7. Six surfaces total now share the same pickFeaturedCue() scorer: REST per-post endpoint, OG share card, MCP get_featured_cue, Schema.org Quotation LD, the visible pull quote on every post page (yesterday), and now the quote wall + random endpoint + MCP get_random_quote — six surfaces, one scoring function, byte-identical picks. Smoke (scripts/smoke-quotes.mjs, pnpm smoke:quotes, added to pnpm verify): asserts /api/quotes.json shape on every entry, /api/quotes/random.json totalAvailable matches the all-list count, ?seed=42 returns the same pick on repeat calls, /quotes HTML card count matches the API count and the top card's text matches the highest-scored API quote byte-for-byte, and the MCP tool with seed=42 picks the same quote as the HTTP endpoint with seed=42 (cross-protocol parity). Live: 6 quotes ranging from x402 commentary at 2:17 to robots.txt philosophy at 1:58.

    uxaudioagentsmcpdiscovery
  19. Visible pull quote on every narrated post — the featured cue as editorial moment, not just metadata

    The same featured cue that ships in Schema.org Quotation LD, on the share-clip OG card, in /api/posts/[slug]/featured-cue.json and in MCP get_featured_cue is now displayed as a visible editorial pull quote between the article hero and the audio player on every narrated post. The aside is a Georgia-italic blockquote at 1.25em with a 4px accent-colored left border and a giant 4em '„' typographic mark at 45% opacity sitting in the gutter — the kind of pull quote a print magazine would set off mid-spread, here promoted to the spot a podcast app would put 'now playing'. Two actions sit underneath: 🎧 Listen at M:SS — an accent-tinted dotted-underline anchor that deep-links into the audio player at the cue's start timestamp (?t=<seconds>), and Copy quote link — a button-as-link that copies the same deep-link URL to the clipboard with a ✓ Copied! 1.6s flash confirmation. Wrapped in a server-side try/catch so that posts whose narration can't be scored just fall through silently (no aside rendered, no broken layout). Print stylesheet hides the aside (the body of the post already contains the cue, so it's not lost on paper). One source of truth: the same pickFeaturedCue() that powers the LD, the OG card, the REST API and the MCP tool now powers the visible quote — five surfaces, one scorer, identical pick across all of them. Smoke (scripts/smoke-pull-quote.mjs, pnpm smoke:pull-quote, added to pnpm verify): walks all 6 narrated posts, asserts <aside.post-featured-cue> + <blockquote> + listen anchor + copy button each render, and that the visible blockquote text + visible ?t=<seconds> match the page's Quotation LD byte-for-byte. Result: every narrated post now has an obvious 'click here to hear the best line' affordance — a marketing surface for the audio without any extra work for the writer.

    uxaudiodesignpull-quote
  20. Schema.org Quotation JSON-LD — featured cue as structured data on every narrated post

    Every narrated post page now emits a <script type="application/ld+json"> with {"@type":"Quotation", text, isPartOf:BlogPosting, creator:Person, url:<deep-link>, inLanguage}. Google's rich-snippet system reads Quotation for 'quote to remember' answer boxes — the same cue featured on the share-clip OG card and served via /api/posts/[slug]/featured-cue.json is now signed off as the canonical pull quote in the page's structured data. Quotation.url threads ?t=<start> so voice-assistant / podcast-index crawlers can play just that moment. Derived from the same pickFeaturedCue() — one scoring function, four consumers (REST endpoint, OG card, MCP tool, Schema.org LD). Live: every narrated post's top pick ships in LD — 'Eight plugins' → "The entire payment flow is EmDash's native x402.enforce() — I didn't write any payment code." @ t=137; 'PR department' → 'The blog I'm writing this on ships with first-party x402 support.' @ t=231; 'Shipping a blog' → 'I've got a PR in flight…' @ t=74. Silently skips when no narration or the transcript can't be scored (graceful fallback). Smoke (scripts/smoke-quotation-ld.mjs, pnpm smoke:quotation-ld, added to pnpm verify): walks all 6 narrated posts, extracts Quotation from HTML, asserts @context/type/text/isPartOf/creator/url shape, verifies Quotation.url's ?t=<start> matches the featured-cue API's pick (4-surface parity).

    seoschema-orgaudiostructured-data
  21. /api/search/unified.json + MCP search_all — posts + cues + tags in one call

    Unified search endpoint that returns three hit types under one query: posts[] via tf-idf cosine (query treated as a synthetic __query__ doc against every post body, surfacing posts whose vocabulary overlaps even when the exact string isn't in the body), cues[] via substring match across every narration transcript with audio-addressable ?t= deep-links, tags[] via slug/label substring sorted by post count. Each hit carries a `type: 'post' | 'cue' | 'tag'` discriminator so a single flat merge-rank list can interleave them on the caller's side. Shared buildUnifiedSearch() function exported from the page module — MCP search_all tool imports it directly (no same-origin Worker fetch). Includes queryTokens (post-stopword tokenization) in the payload so agents can see which terms scored. 400 on empty/too-short queries. Cache 5min. Live q=emdash: 5 posts (top: 'The full stack' @ 0.208), 5 cues (top: 'Eight plugins' @ 0:00), 1 tag (EmDash with 3 posts). Smoke (scripts/smoke-search-unified.mjs, pnpm smoke:search-unified, added to pnpm verify): 200 shape, three arrays + counts, every hit's type discriminator matches its bucket, post scores ∈ (0,1] + sharedTerms, cue deepLinkUrl threads ?t=, tag feedUrl points at /tag/{slug}/rss.xml, 400 on empty/short queries, MCP parity on posts+cues counts.

    agentsapimcpsearch
  22. /subscribe — dedicated subscription hub for humans

    Every feed URL on the site was previously only reachable via <link rel=alternate> in <head> or the OPML bundle download. New /subscribe HTML page collects them into a single friendly landing for readers who want to 'just follow'. Five sections: 🎧 Listen (Apple Podcasts deep-link via podcast:// scheme, Pocket Casts via pktc://subscribe/, raw feed URL, full web episode list), 📰 Read (RSS 2.0 · Atom 1.0 · JSON Feed 1.1 · Changelog RSS — each a mono-coded tile), 🗂️ Bundle (one-click OPML download covering everything at once), 🎯 Narrow (up to 24 tag feeds rendered as count-annotated chips — sorted by post frequency, each chip links to its /tag/{slug}/rss.xml), 🙋 Follow (GitHub + CV + email with rel=me for IndieWeb identity consolidation). Primary-style accent-filled card on the two highest-signal actions (Apple Podcasts + OPML download) so the CTA is obvious; secondary tiles for everything else. Cache 10min + 30min SWR. <link rel=alternate> for all four feed formats on the page itself so that browser-embedded feed-discovery buttons light up. Responsive single-column on narrow viewports. Added to /sitemap.xml at priority 0.7. Footer points readers who want programmatic access at /agents (the atlas). Same Gruvbox palette as the rest of the site.

    uxreader-uxfeedssubscription
  23. /og/clip/[slug].svg — clip-specific social card with the cue text overlaid

    When someone pastes a /posts/X?t=Y&end=Z URL into Slack / Discord / Bluesky / Mastodon, the share card can now be about the *quote*, not just the post title. New /og/clip/{slug}.svg?t=SECONDS&end=SECONDS&theme=light|dark renders a 1200×630 OG card with: mono accent kicker 'mondello.dev · Clip at M:SS', a giant 200px italic “ quote mark at 35% opacity anchored top-left, the cue text in 42px Inter Medium wrapped to 48 chars × 4 lines (ellipsis trail on overflow), right-edge accent ribbon, footer byline with '— Post Title' on the left and 'mondello.dev · Ns clip' in mono warm-yellow on the right. Resolves which cue to feature via the same cue-at-time path as /api/posts/[slug]/cue-at.json. Falls back to the server-picked featured-cue (from pickFeaturedCue) when ?t is absent — so a bare post-URL share still gets a punchy quote card. Honors ?theme=dark with the same Gruvbox palette swap as the main OG SVG. Cache 1h. Smoke (scripts/smoke-og-clip.mjs, pnpm smoke:og-clip, added to pnpm verify): 200 image/svg+xml, Clip at M:SS kicker, quote mark + cue text present, byline has post title + domain, no-?t fallback works, dark theme has #282828 bg + no cream leak, 404 on unknown slug. Example URL: /og/clip/the-blog-is-the-pr-department-now.svg?t=200&end=215 → renders 'The only protocol that actually fits — agent pays for the bytes it takes…'.

    socialsvgaudiosharing
  24. Featured-cue API — server picks the most quotable moment per post

    New src/lib/featured-cue.ts (10 unit tests) scores every VTT cue against a hand-tuned heuristic and returns the winner: +3 for 10-25 word sweet spot, +2 first-person reflection (I / I've / I'd / I'll), +2 sentence boundary (. ! ?), +1 salient-tag-term mention, -2 conjunction start (And / But / So), -4 intro region (first 15%), -2 outro region (last 10%). Ties broken by cue index for determinism. Returns {cue, runnersUp (top 3)} with a reasons[] array so agents + UIs can explain *why* this cue was chosen. Exposed as /api/posts/{slug}/featured-cue.json + MCP get_featured_cue — payload-identical between HTTP and JSON-RPC. Salient-terms come from the post's own tag list (slugified to lowercased phrases with hyphens → spaces), so a post tagged 'x402' gets that boost on lines mentioning x402. Live picks are good: 'The blog is the PR department now' → 'The blog I'm writing this on ships with first-party x402 support' @ 3:51 (score 9: sweet-spot length, first-person, sentence boundary, mentions x402). 'Eight plugins, one session' → 'The entire payment flow is EmDash's native x402.enforce() — I didn't write any payment code.' @ 2:17 (score 9: same reasons, different tag match). Deterministic across calls. Smoke (scripts/smoke-featured-cue.mjs, pnpm smoke:featured-cue, added to pnpm verify): 8 assertions — 200 shape, cue keys, score finite + reasons non-empty, deepLinkUrl threads ?t=, runnersUp ≤ 3 + distinct from winner, 404 on unknown slug, MCP parity, determinism confirmed across two successive calls.

    agentsapimcpaudiorecommendations
  25. /api/posts/[slug]/related.json + MCP get_related_posts — three recommendation axes in one call

    Unified recommendation envelope: {source, similar, byTag, chronological}. One call covers the three distinct ways an agent (or a reader-facing 'what next?' widget) might want to navigate. similar[] = tf-idf semantic neighbors sorted by cosine score with sharedTerms (from the same findSimilar() used by /api/similar/[slug].json). byTag[] = posts sharing ≥1 tag, sorted by sharedTagCount desc with the shared slug list. chronological = {prev, next} in publishedAt order (matches the on-page post-chrono-nav computation). MCP get_related_posts exposes the same payload to agents plus a human-readable summary block — 'Related to X: Semantic (tf-idf): 1. … / By tag overlap: 1. … / Chronological: prev: X, next: Y'. Covers every 'what should I read next?' instinct with one HTTP round-trip. Live for 'Eight plugins, one session': similar #1 is 'The full stack of an agent-era blog' (score 0.3521), byTag #1 is 'Shipping a blog on EmDash in an afternoon' (2 shared: case-study + emdash), chrono prev = 'full stack', chrono next = null (last post). Smoke (scripts/smoke-related.mjs, pnpm smoke:related, added to pnpm verify): 6 assertions — 200 shape + source, similar descending by score with scores ∈ (0,1], byTag descending by sharedTagCount, chronological present, 404 on unknown slug, MCP parity.

    agentsapimcprecommendations
  26. /changelog.svg — shipping-cadence timeline banner

    Shipped a 1600×240 horizontal-timeline SVG at /changelog.svg that plots every changelog entry as a colored dot along a time axis. 147 dots today (one per entry), color-coded by tag bucket: agents (aqua), audio (warm yellow), feeds (purple), ux (olive), social (orange), infra (grey), other (muted). Buckets classify each entry by a priority-ordered tag-match heuristic so an entry tagged `agents + mcp + audio` lands in the agents bucket (first hit wins). Same-day entries stack vertically (offset by 10px per same-day index) so no dot hides another. Every dot is wrapped in an <a href="/changelog#entry-id" target="_top"> — embedders that honor SVG anchors (Notion, GitHub inline SVG renderers, img-tagged dashboards) let the reader click straight to the entry's HTML anchor. Axis has monthly tick marks + labels, earliest/latest date pills in the corners, legend chips with per-bucket counts (agents 40 / audio 35 / feeds 28 / …). Pure server-rendered SVG — no JS, no ML, no external dep. Reveals the shipping cadence at a glance: which weeks burst with agent work, which were audio-heavy, where the quiet stretches were. Added to /sitemap.xml + /llms.txt, embeds cleanly in READMEs + dashboards. Smoke (scripts/smoke-changelog-svg.mjs, pnpm smoke:changelog-svg, added to pnpm verify) — 8 assertions: 200 image/svg+xml, viewBox 1600x240, ≥50 circles (dots+legend), ≥30 changelog-fragment anchors, legend has agents/audio/feeds buckets, ≥1 month tick label, range labels present, title advertises entry count.

    visualizationsvgchangelogtimeline
  27. /agents — single-page atlas of every agent-facing surface

    Shipped a self-documenting HTML page at /agents that indexes every MCP tool, JSON API, bulk export, feed, audio-addressability surface, x402 paid endpoint, and discovery file on mondello.dev. Six sections: Model Context Protocol (install + 10 tools), JSON APIs (search cues, cue-at-time, similar, outline, random-cue, stats/latest/changelog), Content exports (archive + per-post 5-format serializers), Syndication feeds (site-wide + per-tag + changelog + OPML), x402 paid surfaces, Discovery (llms.txt, OpenAPI, sitemap, Schema.org + microformats2). Each surface card carries title, path (rendered as mono accent pill), a short design note, and — for the most common paths — a copy-ready curl / claude one-liner. Complements /llms.txt (machine-readable), /stats (live metrics), /docs/agents (x402 payment docs). Gruvbox palette, accent left-border on each card, responsive single-column on narrow viewports. Added to /sitemap.xml at priority 0.7 and linked from /llms.txt. <link rel="alternate" type="text/plain" href="/llms.txt"> in head so agents hitting the HTML first can easily switch to the machine version. Cache 10min + 30min SWR. Live: 6 sections, 17 surface entries, 5 curl examples. The site now has a front-door for agents that isn't a scraped sitemap — it's a human+machine-legible reference.

    agentsdocsapi
  28. /api/latest.json + MCP get_latest — one-call site pulse

    Composite freshness endpoint for ambient widgets, 'what's new?' embeds, and agents doing freshness checks before they ingest the archive. Single GET /api/latest.json returns { post, narration, changelog, totals }: latest post (slug, title, url, publishedAt, excerpt, tags[]), latest narration (newest D1 media row matched back to a published post — so a fresh regen of an older post still counts, durationSeconds + durationFormatted + audioUrl + createdAt), latest changelog entry (id, date, title, full body, tags[], permalink), and totals { posts, narratedPosts, changelogEntries }. Shared code paths with list_posts + get_site_stats + get_changelog so every freshness surface agrees. Cheaper than composing three MCP tool calls when all you want is the pulse. MCP get_latest tool mirrors the payload plus a human-readable summary block with 📝 + 🎧 + 📋 emoji for each axis. Cache 5 min. Live: latest post = 'Eight plugins, one session' (2026-04-12), latest narration = 'A robots.txt that actually lets agents in' · 7:20 (2026-04-13), latest ship = 'Changelog JSON API' (2026-04-14), totals 6/6/145. Smoke (scripts/smoke-latest.mjs, pnpm smoke:latest, added to pnpm verify): 6 assertions — 200 shape + ISO generatedAt, post + narration + changelog shape, totals > 0, MCP get_latest parity.

    agentsapimcpobservability
  29. Changelog JSON API + MCP get_changelog — 'what's new since X?' in one call

    The shipping log was previously only addressable as HTML (/changelog) or RSS (/changelog.xml). Now /api/changelog.json returns the full 144-entry list as structured JSON with filters: ?limit (default 50, max 200), ?since (ISO YYYY-MM-DD, only entries on/after), ?tag (case-insensitive, matches any of the entry's tags). Each entry carries id, date, title, body, tags, and permalink = origin + /changelog#id (lands on the right anchor, matches the RSS feed's <link>). Shared src/lib/changelog-entries.ts + entryId() with /changelog, /changelog.xml, and the new MCP get_changelog tool — four surfaces, one source of truth. Agent use: 'what's new on mondello.dev this week?' → one call with ?since=YYYY-MM-DD returns a structured list with design rationale. MCP tool adds a human-readable summary block (newest 20 in `• date — title` format). End-to-end smoke (scripts/smoke-changelog-api.mjs, pnpm smoke:changelog-api, added to pnpm verify) — 9 assertions: 200 shape, totalCount ≥ count, sorted newest-first, every entry has required keys, permalink shape correct, ?tag narrows correctly, ?since honors pivot, ?limit caps output, MCP tag parity (same 6 entries tagged mcp). Live: 144 total entries, 13 since 2026-04-14, 6 tagged mcp.

    agentsapimcpchangelogobservability
  30. Dark-mode OG SVG variant at /og/{slug}.svg?theme=dark

    Gruvbox dark palette companion to the dynamic SVG social card. /og/{slug}.svg?theme=dark renders the same 1200×630 card with a dark bg (#282828), brightened aqua accent (#83a598), warm-yellow domain stripe (#fabd2f), and adjusted text/muted levels — all AA-compliant on dark. Bluesky dark mode, Mastodon dark clients, Discord dark theme see a card that fits the surface, not a bright cream rectangle. Palette is a typed record { light, dark } indexed by the query param so invalid values (no param, garbage values) gracefully fall back to light. End-to-end smoke (scripts/smoke-og-svg-dark.mjs, pnpm smoke:og-svg-dark, added to pnpm verify): light has #fbf1c7 + #076678 ✓, dark has #282828 + #83a598 ✓, dark has NO cream leak ✓, both variants share viewBox 1200x630 + post title ✓, both have 1h cache ✓, invalid theme falls back to light ✓. 6 checks all pass. Tiny ship, visible polish — the blog's social fingerprint now adapts to the reader's platform theme instead of shipping one palette and hoping for the best.

    socialsvgdark-modetypography
  31. Clip-share oEmbed — paste a ?t=X&end=Y URL in Slack/Discord, get a clip-bounded player

    When the 📋 Share clip button writes /posts/{slug}?t=10&end=25 to the clipboard and the reader pastes that URL into Slack, Discord, Mastodon, Bluesky, or anywhere else that uses oEmbed discovery, the embedder now gets a clip-bounded audio player — not just the full narration. Extended /oembed.json to parse ?t= and &end= off the requested URL, thread them through to /embed/audio/{key}?t=X&end=Y, and include a '0:10–0:25 (15s clip)' subtitle in the card title. Embed page honors the params: <audio src=".mp3#t=10"> for initial seek (media-fragment URI spec), plus JS-side auto-pause at end (clears the guard after firing so a user manually seeking past end can keep listening). Silently drops invalid (non-finite, negative, end ≤ start) params — falls back to the full-track embed. End-to-end smoke (scripts/smoke-clip-oembed.mjs, pnpm smoke:clip-oembed, added to pnpm verify): oEmbed title has clip subtitle, iframe src threads ?t=10&end=25, embed page wires data-clip-{start,end}=10/25, audio src uses #t=10 fragment, post HTML declares oEmbed discovery. Shared 'quote a moment' UX now spans: in-page copy-clip button → clipboard URL → paste in any oEmbed-aware tool → mini clip player with the right bounds. Clips travel between apps with their playback context intact.

    audiosharingoembedindieweb
  32. Cue-at-time API — timestamp → spoken text (closes the audio-addressability loop)

    New /api/posts/{slug}/cue-at.json?t=SECONDS resolves an audio timestamp to the exact transcript cue being spoken at that moment. Returns { cue { index, text, start, end, startFormatted, deepLinkUrl }, prev?, next?, source { slug, title, url, durationSeconds }, query { t } }. Before the first cue, cue is null and next carries the first upcoming cue. Clamps t to [0, duration]. Linear scan over the post's VTT cues (O(n) at ~100 cues/post — trivial). Same cue boundaries as /api/search/cues.json + /posts/{slug}.vtt + /podcast/chapters: all five audio surfaces share the same wordsPerSecond map, zero drift. MCP tool get_cue_at_time exposes the same payload to agents. Closes the bidirectional audio-addressability loop on mondello.dev: text → timestamp (search_cues), timestamp → text (this), both round-trip to the AudioPlayer's ?t= deep-link. Live example: t=200s on 'The blog is the PR department' resolves to cue #46 at 3:11 'That's good news for anyone starting now. You don't have to beat incumbents at a mystery. You have to ship a small set of files and keep them accurate.' with prev at 3:05 and next 'Claim 3' heading at 3:22. 400 on missing/invalid t, 404 on unknown slug or post without narration. Smoke (scripts/smoke-cue-at.mjs, pnpm smoke:cue-at, added to pnpm verify): 6 assertions all pass — 400 on bad t, 404 on unknown slug, cue window contains t, deepLinkUrl has correct ?t=, prev.start < cue.start < next.start, MCP parity.

    agentsapimcpaudioaddressability
  33. MediaSession API — lock-screen + Bluetooth + CarPlay controls for every narration

    The AudioPlayer now upgrades every narration to a first-class podcast-player experience on iOS, Android, macOS Control Center, AirPods, CarPlay, Bluetooth widgets, and keyboard media keys. When the reader hits play, navigator.mediaSession.metadata populates with post title + 'Romy Mondello' as artist + 'mondello.dev' as album + 1400×1400 podcast artwork (plus 512/192 icon fallbacks for smaller surfaces). navigator.mediaSession.playbackState transitions to 'playing'/'paused' so lock-screen now-playing cards stay in sync. Six action handlers wired: play, pause, seekbackward (default 15s, respects seekOffset from OS), seekforward (default 30s, same), seekto (lock-screen scrubber drag), previoustrack + nexttrack (walk real chapter boundaries from /podcast/chapters/{slug}.json, falling back to ±30s skip when the post has no chapters). Chapters hydrate asynchronously after play so arming doesn't block. setPositionState fires on every timeupdate + seeked so the scrubber on the lock screen follows reality. Previous-track UX: if we're >3s into the current chapter, prev jumps to its start (classic podcast convention); earlier than that it jumps to the previous chapter. End-to-end Playwright smoke (scripts/smoke-mediasession.mjs, pnpm smoke:mediasession, added to pnpm verify): navigator.mediaSession exposed ✓, metadata.title + artist set after play ✓, artwork count ≥ 1 ✓, playbackState transitions playing → paused ✓, seek delta of 30s works ✓, zero console errors. Visible polish on every mobile device — blog posts now appear on lock screens exactly like Apple Podcasts episodes.

    audiomobileaccessibilitypolish
  34. Post outline API — /posts/[slug].outline.json + MCP get_post_outline

    Every post now has a lightweight heading-tree endpoint at /posts/{slug}.outline.json. Returns a flat array of {level, text, slug, startSeconds?, words} — one entry per h1/h2/h3/…, in document order. When the post has narration, each heading carries an estimated startSeconds in the MP3 (same linear wordsPerSecond map used by chapters, karaoke, paragraph-play). When there's no audio: startSeconds is simply absent. Each entry also carries the word count that falls under that heading (up to the next same-or-higher level), handy for agent summarization budgeting. src/lib/post-outline.ts (9 unit tests) is the shared builder — REST and MCP get_post_outline tool produce byte-identical payloads. Cache 1 day (outlines only change on body edits or narration regen). Live example for 'The blog is the PR department now' (7:25 narration): title at 0:00, then 5 h2 sections at 0:44, 2:06, 3:22, 4:43, 6:18 — matches the Podcasting 2.0 chapters endpoint's boundaries exactly (same math). Agent use: cheap discovery step — see structure before pulling full body via get_post_markdown. 404 on unknown slug. Smoke (scripts/smoke-outline.mjs, pnpm smoke:outline, added to pnpm verify): 200 shape ✓, first entry is level 1 ✓, h2s carry positive startSeconds + words ✓, startSeconds monotonically increasing ✓, 404 on unknown slug ✓, MCP parity ✓.

    agentsapimcpdiscoveryaudio
  35. /api/similar/[slug].json + MCP find_similar_posts — tf-idf semantic similarity

    New src/lib/tf-idf.ts (12 unit tests) ships a hand-rolled tf-idf scorer with cosine similarity. Why tf-idf + not embeddings: @vercel/ai-sdk / sentence-transformers would mean either a ~MB runtime bundle or a paid external call, both overkill at 7-post corpus size. tf-idf + cosine is within 85-90% of sentence-embedding quality on short domain-focused technical writing. Preprocessing: lowercase + strip punctuation, split whitespace, drop a curated English stopword set (keeps technical shorts like mcp / api / rss / txt — they're signal here), skip 1-char tokens, no stemming (loses meaning on 'testing' vs 'tests' in docs). Scoring: tf = count/total, idf = log(N / (1 + df)) + 1 keeps weights positive, cosine normalized ∈ [0, 1]. Iterates the smaller vector first to avoid wasted comparisons. Returns top-k hits each with score + up to 5 top shared salient terms (by sum of weights) useful for 'because you also care about X, Y' UX. /api/similar/[slug].json endpoint + matching MCP find_similar_posts tool share the same function — one source of truth. Live quality check: for 'The blog is the PR department now' the top hit is 'A robots.txt that actually lets agents in' at 0.31 (shared: txt, robots, agent, site, actually); for 'Eight plugins, one session' top is 'The full stack of an agent-era blog' at 0.35 (shared: minimax, plugin, emdash, narration, hero). Both genuinely thematically aligned. Smoke (scripts/smoke-similar.mjs, pnpm smoke:similar, added to pnpm verify): shape ✓, descending score ✓, score ∈ (0,1] ✓, no self-hit ✓, sharedTerms non-empty ✓, 404 on unknown slug ✓, MCP parity ✓.

    agentssearchmcptf-idfsimilarity
  36. /archive.md + MCP get_archive — whole blog as one Markdown file for bulk agent ingest

    One-curl complete-blog ingest: `curl https://mondello.dev/archive.md | claude -p "what does this blog argue?"` delivers every published post in a single 47KB Markdown document with per-post YAML frontmatter (title, slug, url, published, excerpt, tags, author) + `---` separators so dataview/obsidian-style importers can still treat each section as its own document. Archive-level header contains site metadata (source, license, generated, post_count) and a paragraph describing the format so an agent reading cold knows what they're looking at. Posts newest-first so freshest context lands first in the agent's window. Shared buildArchiveMarkdown() function in /src/pages/archive.md.ts serves both the HTTP endpoint AND the new MCP get_archive tool — same code path, no same-origin fetch (which would 522 inside a Worker). Cache 10 min. Advertised in /llms.txt. Live: 47,648 chars, all 6 slugs present, 23 frontmatter delimiter lines, MCP tool returns byte-compatible output. New smoke (scripts/smoke-archive.mjs, pnpm smoke:archive, added to pnpm verify): 200 text/markdown + Content-Disposition .md filename, archive title + post_count frontmatter, all 6 slugs present, delimiter count ≥ 13, MCP parity confirmed.

    agentsexportapimcpmarkdown
  37. /stats HTML dashboard — Gruvbox cards showing the blog's shape

    Human-readable companion to /stats.json. Full responsive dashboard at /stats with accent-colored headline numbers (Posts 6, Narrated 6/6 100%, Transcript cues 475, Tags 13, Feeds 6, x402 5 endpoints $1.15 total) across a 6-card grid. Below the cards: horizontal tag-frequency bar chart (top 10 tags with bar widths proportional to post count, each tag clickable to /tag/{slug}), feed inventory table with name + MIME type, x402 endpoint catalog with URLs + prices + descriptions + a legend explaining Base USDC + EIP-712 X-PAYMENT flow, and an 'Agent-era surfaces' capability list pointing at MCP / cue-search / OPML / graph / random-cue / ANSI endpoints. Single-column stack on <700px viewports. Shares buildSiteStats() with the JSON endpoint so numbers never drift. Cache-Control: public, s-maxage=300, stale-while-revalidate=900. Linked from <link rel="alternate" type="application/json" href="/stats.json"> for agents that hit the HTML URL first. Added to /sitemap.xml. This + /stats.json + the MCP get_site_stats tool = three surfaces, one source of truth, zero drift.

    dashboardobservabilitytypographyhtml
  38. /stats.json + MCP get_site_stats — site overview in one call

    New /stats.json endpoint + matching MCP tool give agents and dashboards the whole 'shape' of the blog without walking list_posts and compositing results: posts total + earliest/latest publish + total/avg/median reading minutes, audio narration coverage percent + total seconds + cue count + words under narration, top-10 tags and categories by post count, full feed inventory (RSS/Atom/JSON/podcast/changelog/OPML with URLs + MIME types), and the x402-gated endpoint catalog with prices. Shared src/lib/site-stats.ts (7 unit tests) is a pure function — buildSiteStats({posts, narrationRows, tagsByPostId, categoriesByPostId, origin}) — so /stats.json, the /api/mcp get_site_stats tool, and future /stats HTML dashboard all produce byte-identical numbers. Live snapshot today: 6 posts published (2026-04-11 to 2026-04-12), 30 reading min, 6/6 narrated (100%), 27 min of audio, 475 cues, 13 unique tags led by Agents/Case Study/EmDash/x402 at 3 each, 6 feeds, 5 gated endpoints totaling $1.15 value. Cache 5 min. New smoke (scripts/smoke-stats.mjs, pnpm smoke:stats, added to pnpm verify): top-level keys present, audio coverage bounded [0,100], narratedCount ≤ total, tags.top strictly descending, feeds contain RSS/Atom/JSON/Podcast/Changelog/OPML, x402 price sum equals claimed total, MCP tool parity (HTTP and MCP return same posts.total + narratedCount).

    agentsapimcpobservability
  39. 🎲 Surprise me — random narrated moment at /api/random-cue.json

    Click the new 🎲 Surprise me button in the homepage podcast-cta row and land on a random narrated moment with audio auto-seeked to the exact second — zero-commitment sampling of the blog's voice. Endpoint /api/random-cue.json walks every narrated post, generates cues via portable-text-to-vtt, filters to the 6-60 word sweet spot (skips title lines + single-word cues that sample poorly), picks one uniformly at random, returns JSON with post { slug, title }, cue { text, start, startFormatted, end }, deepLinkUrl (/posts/{slug}?t=START — the AudioPlayer's ?t= handler auto-seeks + auto-plays on arrival), and the full candidate pool size. Cache-Control: no-store so the edge never pins a single 'random' answer for everyone. Also useful for agents: paired with /api/search/cues.json, an LLM can say 'here's what mondello.dev sounds like' without needing a query — good for demos, ambient citations, and surprise-me bots. Button starts as '🎲 Surprise me', flips to '🎲 Rolling…' during fetch, recovers to '🎲 Couldn't roll — try again' on failure with a 2.4s reset. End-to-end Playwright smoke (scripts/smoke-random-cue.mjs, pnpm smoke:random-cue, added to pnpm verify): validates payload shape, integer start, ?t= in deepLinkUrl, word count in sweet-spot range, no-store cache header, variability across 3 rolls (proves no edge pinning), homepage wires button to endpoint, real browser click navigates to /posts/{slug}?t=N. Zero console errors.

    uxserendipityaudioagents
  40. /graph.svg — chronological post ring with tag-overlap chords

    Ship a server-rendered SVG visualization of the entire blog as a chronological ring of posts with weighted chord edges between them. 1200×1200 viewBox so embedders render it as a square card. Posts arranged by publish date starting at top (-π/2) going clockwise; each node's radius scales with tag count (10–28px). Between every pair of posts sharing ≥1 tag, a quadratic-Bezier chord bowed toward the ring center — stroke-width 1–5px and stroke-opacity 0.12–0.57 both proportional to shared-tag count, so dense topical clusters read darker and thicker. Labels anchored outside the ring with dynamic text-anchor (start/middle/end) based on angle so they never clip. Each node is wrapped in <a href="/posts/{slug}"> — click on any embedder that honors SVG anchors goes to the post. Centerpiece: mono accent 'mondello.dev' kicker + '{N} posts · {M} shared-tag ties' stat line + 'chord thickness = shared tag count' legend. Pure server render — no force simulation, no JS, no dep. Discovery: /sitemap.xml lists it, /llms.txt explains it. New smoke (scripts/smoke-graph.mjs, pnpm smoke:graph, added to pnpm verify): 200 image/svg+xml, viewBox 0 0 1200 1200, 7 circles (6 post nodes + 1 ring guide), 6 post anchors, 9 chord paths (posts do share tags), centerpiece readout present. At corpus size ≥50 posts, swap chronological-ring for force-directed layout; for 7 posts the ring reads better than any physics sim would.

    visualizationsvgdiscoverabilitynovel
  41. Zettelkasten-style automatic backlinks — "Mentioned in" per post

    Every post now auto-displays which other posts reference it, with the original anchor text quoted alongside the referring post's title. No WebMention receiver needed, no external crawler — pure PortableText analysis at request time. New src/lib/internal-backlinks.ts (9 unit tests) walks every published post's blocks + markDefs, extracts link marks whose href points at another /posts/{slug} (absolute mondello.dev URLs also recognized, query/hash stripped, self-links ignored, multiple links from A to B deduplicated to one backlink with the first anchor text), and builds a Map<targetSlug, Backlink[]>. Post page computes backlinksFor(currentSlug, allPosts) and renders a <aside class="post-backlinks"> between </article> and the chronological prev/next nav — only when there's at least one inbound link (silent when the corpus has no cross-references). Each backlink card shows the source post's title (bold), a mono ↩ glyph, and the anchor text in typographic quotes (“…”) so readers see exactly the phrase another post used to link here. Accent left-border, subtle hover translate, print-hidden. Current corpus (7 posts) has no intentional body-level cross-references yet, so every post's panel is currently empty — feature activates the moment an author adds an internal body link, which is the real Zettelkasten value: write one link, gain a bidirectional breadcrumb automatically.

    indiewebbacklinkszettelkastendiscoverability
  42. MCP server at /api/mcp — Claude Code installs the blog as an agent tool source

    Shipped a real Streamable HTTP Model Context Protocol server at /api/mcp. Claude Code users run `claude mcp add mondello https://mondello.dev/api/mcp` and instantly gain three tools in their session: (1) search_cues(query, limit) — audio-timestamped hits across every narrated post, returns slug + title + cue text + start seconds + M:SS + snippet + deep-link URL with ?t= auto-seek; (2) list_posts(limit) — every published post with slug, title, excerpt, publishedAt ISO date, reading minutes, audio duration; (3) get_post_markdown(slug) — full post body as CommonMark with YAML frontmatter (title, slug, url, published, excerpt). Implementation: src/pages/api/mcp.ts handles JSON-RPC 2.0 over POST — supports initialize (returns MCP 2024-11-05 handshake), tools/list, tools/call, ping, notifications/initialized. Batch requests supported. HTTP 200 always (errors go in response.error.code per spec), wildcard CORS so agents running anywhere can call in. GET /api/mcp returns a friendly JSON status page explaining the install + transport so someone pasting the URL in a browser sees context. End-to-end smoke (scripts/smoke-mcp.mjs, pnpm smoke:mcp, added to pnpm verify) covers 10 assertions: GET status shape, initialize handshake, tools/list with valid inputSchemas, each tool call returning well-formed content + JSON payload, unknown-tool returns -32601, invalid JSON returns -32700, notifications accepted, batch requests return ordered array. Now the blog is a first-class agent knowledge source — no scraping, no HTML parsing, just typed tools.

    mcpagentsapinovelclaude-code
  43. Editorial typography: accent drop cap + quote-marked blockquotes + .pull-quote variant

    Posts now read like magazine pieces. Three CSS-only polish moves on .article-content: (1) Accent drop cap on the first paragraph's first letter — 3.4× the body font size (61px on desktop, 51px on mobile <640px), floated left, Georgia serif, colored with the site accent (rgb(7, 102, 120) Gruvbox faded-aqua). No PortableText markup changes; pure :first-of-type:first-letter selector so every post gains the lift automatically. (2) Blockquotes get a proper editorial treatment — 3.25em left padding reserves space for a 3.5em decorative “ quote mark anchored top-left via ::before, accent-colored at 45% opacity, Georgia serif, font-style normal even inside the italic blockquote body. Accent left border and slightly larger font (1.05em, leading 1.55) make pullouts feel lifted from the prose. (3) Opt-in .pull-quote blockquote variant — borders top+bottom (no left rail), transparent bg, larger 1.35em italic body for magazine-style full-width lifts. Stronger ::before quote mark (4em, 55% opacity). End-to-end Playwright smoke (scripts/smoke-typography.mjs, pnpm smoke:typography, added to pnpm verify) asserts against computed styles: drop cap is 61.2px (3.4× paragraph) floated left with the accent color, blockquote ::before content is "“" with matching color, padding-left is 61px, pull-quote variant has border-left 0 + border-top 1px + transparent background + italic style. Zero console errors.

    typographypolishreader-uxdesign
  44. Power-user keyboard shortcuts: [ ] paragraphs · F karaoke · T transcript · C copy-ts

    Post pages now respond to vim-style power-user keybindings, all added to the existing keyboard-help dialog (press ?). [ and ] jump to the previous / next paragraph with accent flash on the target — finds the paragraph closest to viewport center and seeks from there, so the reader can skim a long post by tapping ] instead of scrolling. F toggles ✨ follow-along karaoke, T toggles the 📝 searchable transcript panel, C copies a `?t=N` link at the current audio timestamp. All firmly scoped to post pages (other pages get the existing ⌘K / Space / arrow / J / L / ? shortcuts). Ignores INPUT / TEXTAREA / contentEditable focus so typing in the transcript search or a form doesn't trigger them. Shared handler block in Base.astro so the shortcuts work across the whole site once the post DOM is present. Help dialog updated with the 4 new rows. New Playwright smoke (scripts/smoke-keyboard.mjs, pnpm smoke:keyboard, added to pnpm verify) confirms against production: ? opens the help dialog, f flips follow-along aria-pressed to true, t opens the transcript panel, ] lands .para-flash on a paragraph (logged text: 'The blog I'm writing this on ships with first-part…'), c copies /posts/{slug}?t=25 to clipboard. Zero console errors.

    keyboarduxpower-useraccessibility
  45. x402 protocol + settlement smokes + /posts/{slug}.bib citation export

    Two related shipments. First: citation export — new src/lib/citations.ts (10 unit tests) generates BibTeX @misc + APA 7 + MLA 9 + Chicago from a shared input shape; pure functions so the /posts/{slug}.bib Worker route (text/x-bibtex, Content-Disposition filename) and future in-page client menu produce byte-identical output. Zotero / Mendeley / Papers / BibDesk auto-ingest the .bib response. Second: x402 payment surface audit. Enumerated every x402.enforce() callsite (5 live gated endpoints: /api/export/posts $0.50, /api/x402/agent-seo/bot-catalog $0.05, /llms-full.txt $0.25, /skills/{slug}/raw $0.10, /tip/{slug} $0.25). New scripts/smoke-x402-protocol.mjs locks down the x402 v2 Payment Required response shape on every gated URL — verifies x402Version=2, accepts[0].scheme=exact, network=eip155:8453 (Base mainnet), asset=0x833589... (Base USDC), matching µUSDC amount, well-formed payTo address, and resource.description. Runs in CI without needing a wallet. Companion scripts/smoke-x402-settlement.mjs is the real end-to-end test — signs an EIP-712 USDC TransferWithAuthorization, sends as X-PAYMENT header, expects 200 + wallet balance decrement. Skips gracefully when WALLET_PRIVATE_KEY isn't set (zero friction in dev), runs full round-trip when a Base-mainnet-funded wallet is configured. Both added to pnpm verify. Honest disclosure: the 5 gated endpoints currently only verified at the protocol layer — a real-wallet round-trip is the next open item and the scaffolding for it is now in place.

    x402paymentsapicitationsagentstesting
  46. Dynamic SVG social cards at /og/{slug}.svg

    Every post now has a branded 1200×630 social share card rendered server-side at /og/{slug}.svg — Gruvbox cream gradient bg, faded-aqua accent rail across the top + ribbon on the right, mono-font 'mondello.dev · agent-era notes' kicker with a filled accent dot, post title typeset in 58px bold Inter with server-side word-wrap into up to 3 lines (ellipsis on overflow), byline with author · date · reading-time, and the full post URL in JetBrains Mono along the right edge of the footer. All rendered as hand-generated SVG — no headless-browser dep, no Satori bundle, ~2KB gzipped per card. Wired into Base.astro as a new socialSvgUrl prop that emits a secondary og:image tag (+ og:image:type=image/svg+xml, 1200×630 dimensions). Bluesky, Mastodon, Discord, Slack, iMessage, LinkedIn post-previews all render SVG inline → they now show a typeset card with the actual post title, not just the hero JPEG. The primary raster og:image tag stays so Twitter + Facebook (no SVG support) fall back cleanly. 1 hour edge cache. End-to-end smoke (scripts/smoke-og-svg.mjs, pnpm smoke:og-svg, added to pnpm verify): 200 image/svg+xml, viewBox 0 0 1200 630, title fragment + byline + domain + accent color all present, post HTML declares og:image → .svg + og:image:type=image/svg+xml, primary raster og:image preserved for Twitter fallback.

    socialseosvgtypographynovel
  47. /api/search/cues.json — audio-addressable site-wide cue search for agents

    New /api/search/cues.json?q=QUERY endpoint searches across every narrated post's synchronized transcript and returns JSON hits, each with post slug + title, the matched cue text, integer start seconds, M:SS-formatted timestamp, keyword-windowed snippet (ellipses around a ~100-char excerpt), and a deep-link URL like /posts/{slug}?t=START that auto-seeks the AudioPlayer to the moment on page load. Agents can now answer 'where does mondello.dev talk about x402?' with audio-addressable citations. On a q=x402 query the live endpoint returns 20 hits across multiple posts with timestamps like 1:58, 2:17, 3:12 — each link plays the specific sentence where x402 is mentioned. Reuses the portable-text-to-vtt serializer inline (6 posts × ~100 cues = 600 cues scanned per request; trivial) — same cue boundaries the in-page transcript panel + podcast apps see, so search hits line up exactly with what a listener hears. Validates + clamps parameters: q must be ≥2 chars (400 on empty/tiny), limit defaults to 20, capped at 50. Cache 5min per (q, limit). CORS enabled for agent use from any origin. New /llms.txt entry advertises it. End-to-end Playwright smoke (scripts/smoke-cue-search.mjs, pnpm smoke:cue-search, added to pnpm verify): asserts 400 on empty query, 200 + 11 hits on q=robots.txt, first hit shape valid (slug, title, integer start, well-formed deepLinkUrl, snippet), deep-link resolves to 200, and opening the deep-link URL in a real browser lands audio.currentTime at 48s (within 2s of hit.start). Zero console errors.

    agentssearchaudioapinovel
  48. 📝 Searchable transcript panel — turn any narration into a grep-able audio index

    Every narrated post gets a 📝 Transcript button next to ✨ Follow along. Click it and a panel expands below the audio card containing the full synchronized transcript (cues hydrated lazily from /posts/{slug}.vtt on first open — no cost for readers who never toggle it). Type into the search input and cues instantly filter by substring match, with matching text wrapped in <mark> for an accent-highlighted readout — readers can find every moment a keyword is mentioned. Click any cue to seek the audio to that moment + auto-play. As the audio advances during playback, the currently-playing cue gets an accent-tinted row highlight and auto-scrolls into view inside the panel (only when actively visible). Panel has a 360px max-height with internal scroll so long transcripts don't push the article below the fold. Live cue counter shows filter results (e.g. '1 / 95'). All cue interactions bound to the same #ap-native audio element so the transcript panel stays in lockstep with karaoke follow-along, chapter jumps, speed changes, and clip-share. End-to-end Playwright smoke (scripts/smoke-transcript-panel.mjs, pnpm smoke:transcript-panel, added to pnpm verify) confirms against production: 95 cues hydrated, 'robots.txt' filter narrows to 1 cue + 1 <mark>, click cue #5 → audio.currentTime = 17.927s, seeking to t=100s → active cue index 19. Zero console errors. Closes the 'where did she mention x402?' UX gap that even podcast apps often don't solve well.

    audiouxsearchaccessibilitynovel
  49. WebVTT transcripts — synchronized captions per narrated post

    Every narrated post now has a standards-compliant WebVTT transcript file at /posts/{slug}.vtt. Podcast apps that implement Podcasting 2.0 (Podverse, Fountain, Castamatic) render it as a scrolling transcript beside the episode audio with tap-to-seek — listeners can navigate the audio by reading along. Also surfaces as an <link rel="alternate" type="text/vtt"> on each post HTML for caption-aware browsers and agents. New <podcast:transcript type="text/vtt"> entry in /podcast.xml (alongside the existing text/html one — apps pick whichever they support). New src/lib/portable-text-to-vtt.ts serializer (13 unit tests covering null-guards, header, cue ordering, sentence-split of long blocks, word-index tracking, HH:MM:SS.mmm timing format, HTML entity escape, duration clamping). Cues emitted per paragraph/heading, with long blocks (>35 words) split on sentence boundaries so cue lengths stay in the readable 4–8s range. Timing uses the same linear wordsPerSecond map as /podcast/chapters, karaoke, clip-share, paragraph-play — all five features share one cross-file-consistent alignment. Test post (445s) yields 95 cues, monotonically timed, final cue ends at 445s. New smoke (scripts/smoke-vtt.mjs, pnpm smoke:vtt, added to pnpm verify) runs against production and asserts: 200 text/vtt, WEBVTT header, ≥5 cues, monotonic timings, feed reference present, HTML alternate link declared.

    audioaccessibilitypodcastserializernovel
  50. Resume where you left off — cross-session audio position memory + progress rings

    Listeners who interrupt a narration mid-post now pick up exactly where they left off, across tabs and across days. Every 5s of playback (plus on pause) the AudioPlayer writes { pos, dur, updatedAt } to localStorage["mondello.listenProgress"] keyed by post slug (slug threaded from Base via <body data-post-slug=...> so the JS doesn't have to reparse the URL). On next load, if an entry exists and isn't past the 'almost done' gate (pos > dur - 10s drops the entry), audio.currentTime is silently restored to the saved position and a 🔖 Resuming from M:SS (N% heard) banner appears in the ap-sub line with a Start over button for agency. Bounded + self-pruning storage: entries older than 30 days GC'd on write, max 200 entries, oldest evicted first. Complementary progress rings on every post-card listing: a global Base.astro script hydrates subtle conic-gradient accent arcs around the 🎧 badge on home, /posts, /tag/*, /search cards (masked with mask-composite: exclude for the ring effect without a SVG) showing partial listening progress. Tooltips + aria-labels amended with '45% heard' so screen readers announce it. Silent no-op on pages without matching cards. 10 unit tests for lib/listen-progress.ts (min-pos gate, end-gap gate, gc, cap, invalid values, format helper) all pass. New Playwright smoke (scripts/smoke-resume.mjs, pnpm smoke:resume, added to pnpm verify): plays to 30s, reloads page, asserts currentTime restored to ~30, ap-sub shows resume banner, Start over button present, home card has .has-progress + --progress-pct set to 6.7%. Zero console errors across the run.

    audiouxsharingaccessibilitynovel
  51. /posts/{slug}.ansi — typeset terminal rendering via ANSI escapes

    Completes the serializer triad: HTML (browser), Markdown (Obsidian/agents), ANSI (terminal). `curl https://mondello.dev/posts/X.ansi | less -R` gives a beautifully typeset reading experience in the terminal — bold warm-orange title with accent underline, muted metadata line (author · date · reading time · 🎧 duration), body wrapped to 80 cols, bold orange h2/h3 headings, green inline-code, green-railed code blocks with language badges (┤ ts ├), • bullets + numbered lists with accent markers, ❯ blockquote rails in italic grey, and links rendered as 'text (url)' with the text underlined and URL dimmed (not OSC-8 — only ~50% of terminals render OSC-8 correctly and 'text (url)' reads right everywhere). Dedicated src/lib/portable-text-to-ansi.ts serializer (11 unit tests) handles all PortableText block types + inline marks. SGR codes composed properly (bold + color in one escape sequence, not concatenated slices). 256-color palette (no truecolor — tmux < 3 chokes on it). Discoverable via <link rel="alternate" type="text/plain" href="...ansi"> on each post HTML. Also listed in /llms.txt so agents find it. New curl-based smoke (scripts/smoke-ansi.mjs, pnpm smoke:ansi, added to pnpm verify) confirms end-to-end: HTTP 200, text/plain, 68 SGR sequences, title matches, 0 overflow lines, HTML declares alternate.

    agentsclinoveltypographyserializer
  52. Paragraph permalinks + paragraph-scoped audio playback

    Hover any paragraph, heading, or blockquote in a post and two actions fade in on the left margin: ¶ (copy permalink to just that paragraph, URL with a stable slug fragment) and 🔊 (play ONLY that paragraph's audio — seeks #ap-native to the paragraph's estimated time window and auto-pauses when the next paragraph would start). Text and audio are now equally quotable at paragraph granularity — a reader can say 'listen to this 8 seconds' or 'read this sentence' as a first-class link. Slugs derive from the first ~40 chars of the paragraph (stable across unrelated edits; earlier paragraphs can be added/removed without breaking later anchors) with duplicate-opening disambiguation via running-number suffix. Paragraph-scoped audio reuses the same linear wordsPerSecond map (totalWords / audio.duration) that powers /podcast/chapters, the karaoke follow-along, and the clip share — MiniMax TTS near-constant pace means the estimate is within a few percent across the whole post. Visiting a URL with a matching #fragment auto-scrolls (smooth, or instant if prefers-reduced-motion) and flashes a 2.4s accent glow on the target paragraph. On narrow viewports (<720px) the anchor actions reposition above the paragraph instead of floating in the margin. Print-hidden. Graceful degradation: no audio element → 🔊 hidden; clipboard API blocked → window.prompt fallback. New Playwright smoke (scripts/smoke-paragraphs.mjs, pnpm smoke:paragraphs, added to pnpm verify) verifies end-to-end: 27 blocks decorated, 0 missing ids, ¶ click → clipboard URL has correct #fragment, 🔊 click → audio seeks inside paragraph window and plays, deep-link → .para-flash lands on target. Zero console errors.

    uxaudiosharingaccessibilitynovel
  53. Shareable audio clips — mark start + end, share the exact moment

    Every narrated post now has a one-line clip-share workflow: ⏱️ Start clip captures the audio's current time, ⏹️ End clip captures a later time, then 📋 Share clip copies a URL with ?t=START&end=END to the clipboard. Anyone who opens that URL sees the audio seek to START, play, and auto-pause at END (single timeupdate listener bound to #ap-native — independent of the rest of the player so people can resume past the end boundary if they want to keep listening). Visual feedback: captured times render as luminous accent tick marks on the waveform scrubber, and a soft tinted range strip fills the selected segment, so readers see their clip bounds at a glance. A ✕ Reset button clears the selection. Closes a real gap — quoting text is trivial on the web, quoting audio was effectively impossible without a separate editor. Now a reader can say 'listen to the 15 seconds starting at 3:42' as a first-class link. Builds on ?t= deep-link work; adds &end= half. End-to-end Playwright smoke (scripts/smoke-clip.mjs, pnpm smoke:clip, added to pnpm verify) confirms full round-trip: Start clicked → Start 0:10 label, End clicked → End 0:25 label, Share clicked → clipboard contains ?t=10&end=25, waveform shows 2 marks + 1 range, visiting the URL auto-pauses at the boundary. Zero console errors across the run.

    audiosharinguxnovel
  54. ✨ Follow-along karaoke — word-level highlight synced to the narration

    New ✨ Follow along button on every narrated post. Toggle it on and the article body transforms into a karaoke-style sync: the word currently being spoken gets an accent-tinted background, the ±2-word context band gets a fainter tint so your eye can skim ahead, and the active word auto-scrolls into view when it leaves the viewport. Zero transcript required — uses the same linear wordsPerSecond map (totalWords / audioDurationSeconds) that powers the Podcasting 2.0 chapter endpoint. MiniMax TTS is near-constant-pace so the word index is accurate within a few percent across the whole post. Implementation is lazy: zero work until you click the toggle, then a one-time O(words) DOM split wraps each word in a <span class="ap-word" data-i=N> (whitespace preserved so layout doesn't shift). On timeupdate a single class toggle paints the new active word + nearby band — O(1) per tick. Runtime is keyed to the single #ap-native source-of-truth audio element, so pill-play, native controls, chapter jumps, speed changes, and the follow-along cursor all stay in lockstep. prefers-reduced-motion disables the 180ms fade. Preference persists under localStorage key mondello.followAlong. New Playwright smoke (scripts/smoke-karaoke.mjs, added to smoke:karaoke + verify) confirms end-to-end: 1111 words split from the test post, active word advances from index 14 at audio t=5s to index 75 at t=30s (5.3× expected for that pace), zero console errors. Prints style strips all highlight — paper has no audio.

    audiouxaccessibilitypodcastnovel
  55. In-page chapter menu + playback speed control on post audio player

    The AudioPlayer card on every post is now a mini podcast app. Two new controls above the waveform: (1) 📑 Chapters (N) — hydrates client-side from /podcast/chapters/{slug}.json (same JSON the podcast.xml <podcast:chapters> tag points at, so in-page + podcast-app chapter lists are guaranteed consistent). Click to drop down a scrollable list of h2-derived chapter markers with M:SS timestamps; click any chapter to seek the audio (and auto-play if paused). The currently-playing chapter gets an accent highlight as timeupdate fires. Closes on click-outside or Escape. 404 for chapterless posts → menu stays hidden (graceful no-op). (2) Speed: 1× / 1.25× / 1.5× / 2× pills. Click sets nativeAudio.playbackRate and persists the pick under localStorage key mondello.audioSpeed — returning listeners keep their preferred speed across posts + sessions. Active speed gets the accent-on-accent treatment. Both controls stack on narrow viewports (<480px) and are hidden in print. Tied to the single #ap-native audio element so pill-play + native controls + sticky-Listen dock + new chapter jumps + speed all share one source of truth. smoke:audio still 0 failures (desktop + mobile).

    audiouxpodcastaccessibility
  56. OPML subscription bundle — import every feed in one click

    Shipped /feeds.opml — an OPML 2.0 outline enumerating every feed on the site: the 3 main post feeds (RSS 2.0, Atom 1.0, JSON Feed 1.1), the podcast feed, the changelog RSS, and one RSS outline per tag (alphabetized, dynamic from D1). Feed readers that support OPML import (Feedly, Inoreader, NetNewsWire, Pocket Casts, Podverse) let subscribers add all of it at once — drop the URL into 'Import subscriptions' and every feed lands in the subscription list. Surfaced globally via <link rel='alternate' type='text/x-opml' href='/feeds.opml'> in Base.astro head so every page auto-discovers the bundle, and via /llms.txt + /sitemap.xml for machine readers. Outlines group by category (Main feeds / Podcast / Changelog / Per-tag feeds) so the import UI in most readers shows a tidy hierarchy. Content-Disposition suggests filename='mondello-subscriptions.opml' for readers that download rather than import-by-URL. Cache 1 hour. Spec: opml.org/spec2.opml.

    feedopmldiscoverability
  57. Changelog RSS feed + per-entry permalinks

    The changelog is now subscribeable: /changelog.xml is a full RSS 2.0 feed of every entry, ordered newest-first, with <title>, <description> (body), <pubDate> (midnight UTC from YYYY-MM-DD), stable <guid>, and <category> tags. Subscribers in Feedly/Inoreader/NetNewsWire see every new ship land in their river without visiting the site. <link rel="alternate" type="application/rss+xml" href="/changelog.xml"> on the HTML page advertises the feed for auto-discovery. Each entry on the HTML page now has a stable id anchor (changelog-{hash}-{date}) — the title itself becomes a permalink (animated underline on hover, accent-tinted background on :target), and the RSS item link points at the same anchor so clicking a feed entry scrolls to the exact spot on the page. Required a refactor: lifted the 70+ entries + Entry type out of changelog.astro into src/lib/changelog-entries.ts (single source of truth), exported entryId() helper for the shared hash → both surfaces stay in lockstep across edits. Build clean, live verified.

    feedrsschangelogindieweb
  58. Audio timestamp deep-links — share a specific moment of any narration

    Two new user-facing affordances on every post audio player: (1) ?t=SEC URL parameter — loading /posts/X?t=120 auto-seeks the MiniMax narration to the 2-minute mark and starts playback (respecting browser autoplay policy). Matches the Podverse / Fountain media-fragment convention. (2) 🔗 Copy link at M:SS button — live counter next to ↓ Download MP3 that reflects the audio's current time; clicking writes `<url>?t=<seconds>` to the clipboard with a ✓ Copied! flash and a 1.6s reset. If clipboard API is blocked (http/permissions), falls back to window.prompt so copy-paste still works. Implementation binds to #ap-native so the pill-play + native-controls + sticky Listen dock all stay in lockstep with one audio source of truth. When currentTime is 0 the `?t=` param is omitted so a plain-share click produces the same URL as a fresh permalink — no junk params. Real UX win: readers can now quote a specific moment of an AI narration the way they already quote a specific paragraph of text.

    audiouxsharing
  59. Chronological prev/next post navigation at article bottom

    Posts already carried <link rel="prev"> and <link rel="next"> in <head> for crawlers + keyboard-nav browsers, but there was no visible UI affordance. Added a .post-chrono-nav card strip between </article> and the related-skills/"Continue reading" sections: two cards side by side, older post on the left (rel="prev"), newer on the right (rel="next"), each showing the full post title. Posts at either end of the timeline render a balanced spacer so the grid doesn't collapse. First/last-post edges handled gracefully — only the present neighbor renders. Responsive: single column below 640px. Styled against CSS vars (border-subtle / bg-subtle / accent hover) so it picks up light/dark/gruvbox themes automatically. Print stylesheet hides the nav (paper readers don't click). Complements the tag-similarity "Continue reading" grid below it — linear reading vs topical exploration, two paths off the end of a post.

    uxnavigationreader-ux
  60. Image sitemap extension — post hero images indexed by Google Images

    /sitemap.xml previously listed post URLs with no image metadata, so Google Images had to discover hero images by crawling each post page individually (slow + lossy). Added the Google image-sitemap extension (xmlns:image="http://www.google.com/schemas/sitemap-image/1.1") with <image:image> children on every post entry: <image:loc> (absolute URL to hero), <image:title> (post title — alt text for image-search snippet), <image:caption> (post excerpt — description under the thumbnail in Google Images SERP). Posts without a featured_image gracefully emit no image block. New resolveImageUrl() helper handles all three featured_image shapes (external src URL, storage-key, meta.storageKey). Spec: developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps. Verified live: 6 <image:image> entries populated, one per narrated post. Accelerates image-search indexing + makes post hero artwork discoverable independently of the article URL — a direct hero-image → post click-through path from Google Images.

    seositemapgoogle-images
  61. Podcasting 2.0 JSON Chapters — jump-by-heading in Overcast & Podverse

    Every narrated episode now ships <podcast:chapters url="/podcast/chapters/{slug}.json" type="application/json+chapters"/> in the feed. Apps that implement the spec (Overcast, Pocket Casts, Podverse, Fountain, Castamatic) render a chapter list in the now-playing UI — listeners can tap to seek. Chapters are derived from the post's h2 headings: new lib/podcast-chapters.ts walks the PortableText blocks, counts cumulative words, and maps each h2 onto a time offset using wordsPerSecond = totalWords/audioDurationSeconds. MiniMax TTS runs at near-constant pace, so the linear word→time mapping is accurate within ~2-3% of actual waveform position. Chapter 0 is always title-at-0 per spec; adjacent chapters that would collapse within 10s of each other are dropped (would create useless seek targets inside the same paragraph). Posts with no h2s return 404 (graceful — the app shows no chapter list and plays linearly). JSON response is cached 1 day (chapters only change on body edit or narration regen). 6 unit tests cover: null for missing content, null for zero duration, null for no h2s, ordered emission with post title at 0, 10s-gap collapse rule, spec version 1.2.0.

    podcastpodcasting-2.0feedchapters
  62. Print stylesheet — clean typography for PDF + archival

    Readers who Cmd+P → Save as PDF (or actually print) previously got the entire site chrome: nav, footer, audio player, sticky Listen pill, sidebar, share buttons, back-to-top, reading-progress bar. Now the print output is archival-quality: just the title + excerpt + article body, black text on white, 11pt serif-ish at ~65 char line length. New src/styles/print.css (imported from Base.astro) runs under @media print only — zero runtime cost on screen. Key rules: hides all interactive chrome via display:none, forces light-theme colors (ink economy + contrast), adds footnote-style '[url]' after every link in article content (paper readers need targets), page-break-inside: avoid on <pre>/<blockquote>/<figure>, page-break-after: avoid on headings. Verified with Playwright emulateMedia('print') — every interactive surface hidden, title + content visible. Generated a sample PDF for visual proof. New print-css smoke gate asserts @media print + display:none on .site-header/.ap-card/.sticky-listen-dock survive the Astro CSS bundling.

    reader-uxprintaccessibility
  63. Podcast 2.0 extensions: medium + license + per-episode person

    podcast.xml already declared the Podcast Index namespace + <podcast:guid>, <podcast:locked>, <podcast:funding>, channel-level <podcast:person>, per-episode <podcast:transcript>. Added the 3 missing tags that modern apps (Podverse, Fountain, Castamatic, Pocket Casts 2.0+) consume: <podcast:medium>podcast</podcast:medium> (explicit classification — without it some apps default to 'audiobook' treatment that hides the episode list); <podcast:license url=CC-BY-4.0-URL>cc-by-4.0</podcast:license> at channel + per-episode (machine-readable reuse rights; Podverse renders the license badge); <podcast:person role='host'> at episode level too (channel-level was already there — app UIs that check episode-level fall back gracefully now). New podcast-20 smoke gate verifies namespace declaration + all 9 expected <podcast:*> patterns. Smoke: 53 → 54.

    podcastfeed
  64. Feeds ship full post content — read inline in Feedly/Inoreader

    All four syndication feeds (RSS, Atom, JSON Feed × site-wide + per-tag) previously shipped only the post excerpt. Now they include the full HTML-rendered article body — subscribers read posts inline in Feedly, Inoreader, NetNewsWire, and every major RSS reader without clicking through to the site. New lib/portable-text-to-html.ts (14 unit tests): deterministic Portable Text → HTML serializer that handles blocks (p, h1-h6, blockquote, pre/code, figure/img), marks (strong, em, code, strike, link with rel=noopener), grouped lists (consecutive bullet/number items collect into a single <ul>/<ol>), and HTML-attribute escaping (prevents injection via crafted link hrefs). Wired into: /rss.xml (<content:encoded><![CDATA[...]]></content:encoded> + xmlns:content namespace), /atom.xml (<content type="html">...</content>), /feed.json (content_html field), and all three per-tag variants. RSS feed size grew ~10x (≈6KB of clean HTML per item) but that's exactly what subscribers want. New feed-full-content smoke gate asserts presence + minimum size + namespace declaration. Tests: 209 → 224 (+15). Smoke: 52 → 53.

    feedrssatomjson-feed
  65. h-feed container markup — groups microformats2 entries

    Previous iteration added h-entry on each post card. This iteration adds the h-feed container wrapper so microformats2 parsers (Fraidycat, Aperture, Granary) treat the cards as a curated feed rather than loose posts. h-feed wraps the grid on / (home), /posts, /tag/{slug}, /search results — each with a visually-hidden .p-name <span> giving the feed its title ('Latest posts — Romy Mondello', 'Posts tagged "EmDash" — Romy Mondello', etc). Caught two Astro parse bugs along the way — {/* JSX comment */} can't be the first child of a conditional's else branch without a wrapping fragment; switched to /* regular */ comments inside the expression. Smoke gate widened to check h-feed on home + tag pages alongside the existing per-page microformat checks.

    indiewebmicroformats
  66. microformats2 (h-entry / h-card) for IndieWeb parseability

    Added class-based microformats2 markup so IndieWeb feed readers (Fraidycat, Aperture) and webmention senders can parse posts without JSON-LD or RSS. Post pages: h-entry on the <article>, p-name on the <h1>, p-summary on the excerpt, e-content on the article body, dt-published (with machine-readable datetime attr), u-url (hidden anchor), p-author h-card on the byline with u-photo on avatar images. Post cards (home, /posts): h-entry on the <article>, p-name on the title, p-summary on the excerpt, dt-published on timestamp, u-url on the card-link. /posts items got the same treatment. Purely additive — no styling or semantic changes. Post page has 7 microformats2 classes, home has 5 h-entry blocks matching the post grid. New smoke gate enforces microformat presence across both surfaces. Smoke: 51 → 52.

    indiewebmicroformatsseo
  67. OpenAPI spec documents 6 new free endpoints (16 → 22 paths)

    Agent-consumable OpenAPI 3.1 spec was missing the 6 free endpoints shipped this session. Added full OpenAPI documentation for: /embed/audio/{storage_key} (audio iframe, tag: Audio), /oembed.json (oEmbed provider, tag: Discovery), /posts/{slug}.md (Markdown export, tag: Content), /tag/{slug}/rss.xml + /atom.xml + /feed.json (per-tag feeds, new tag: Feeds). Each endpoint declares parameters with spec-compliant schema clamps (maxwidth 280-800, maxheight 100-300 on oEmbed), response schemas, content types, 4xx/5xx codes. New 'Feeds' tag description covers topic-filtered WebSub-enabled feeds. Now Claude/GPT function calling, any OpenAPI-aware agent, and spec-consuming tools discover all public surfaces. OpenAPI path count: 16 → 22. Existing openapi-parity smoke gate confirms documented schemas match live responses.

    openapiagentsdiscoverability
  68. Per-tag JSON Feed 1.1 — completes topic-filtered feed triad

    Closes the per-tag feed triad (RSS 2.0 + Atom 1.0 + JSON Feed 1.1) to match site-wide feed coverage. NetNewsWire and Inoreader surface JSON Feed attachments as inline players, so topic-filtered JSON Feed subscribers get the full podcast-style experience. WebSub hubs[] array on each per-tag JSON Feed — push notifications keyed to the specific tag. audio attachments include size_in_bytes + duration_in_seconds. Tag HTML pages now declare all 3 alternates (RSS, Atom, JSON Feed) via extraAlternates. Also surfaced in machine discovery: 3 new entries in /llms.txt (per-tag feeds template) + 3 new templated pointers in /api/catalog.json (tag_rss_template, tag_atom_template, tag_json_feed_template). Catalog pointers: 23 → 26. Smoke gate data-driven over all 3 formats × 2 surfaces (feed body + HTML discovery link).

    feedjson-feedtags
  69. Per-tag Atom 1.0 feeds at /tag/{slug}/atom.xml — format parity

    Completes per-tag feed parity. RSS shipped last iteration; now every tag also has an Atom 1.0 feed (RFC 4287) at /tag/{slug}/atom.xml with required <id>, ISO-8601 dates, <category term=/label=>, audio enclosures, and WebSub hub declaration. Readers that prefer Atom's stricter spec (NetNewsWire, Feedbin) now have the same topic-filtering option as RSS subscribers. Tag HTML pages now declare BOTH alternates (RSS + Atom) via extraAlternates. Smoke gate upgraded from `per-tag-rss` to `per-tag-feeds` — validates both formats + both HTML declarations end-to-end (data-driven checks so adding JSON Feed later needs just one row). Total feed surface: 4 site-wide + 2 per-tag × N-tags.

    feedatomtags
  70. Per-tag RSS feeds at /tag/{slug}/rss.xml (topic subscriptions)

    Topic-subscribers (someone who wants posts about EmDash only, not every post) previously had to subscribe to the site-wide /rss.xml and filter client-side. Now /tag/{slug}/rss.xml serves a valid RSS 2.0 feed filtered to that tag. Each tag HTML page declares its feed via <link rel='alternate' type='application/rss+xml' title='Romy Mondello — tag: {Label}'> so Feedly/Inoreader's one-click subscribe buttons pick it up. Feed parity with site-wide: WebSub hub declared (per-tag subscribers also get push notifications), audio enclosures for narrated posts, <category> elements. Base.astro now accepts an `extraAlternates` prop (generic {rel,type,title,href}[] array) so any page can declare page-specific alternate feeds — tag pages use this to surface their RSS; future per-category or per-author feeds would plug in the same way. New per-tag-rss smoke gate validates one tag's feed end-to-end (RSS structure + HTML alternate link). Smoke: 50 → 51.

    feedrsstags
  71. Embed-share button + agent discovery for embed/oEmbed/markdown URLs

    Two complementary discoverability wins. (1) Added '🎧 Embed' button to the post-page share row — clicking copies the audio iframe HTML to clipboard. Generic copy-to-clipboard helper now powers both the 'Copy link' and 'Embed' buttons. Humans who want to embed audio on their own blog/site/Notion get the snippet in one click. (2) Surfaced the new endpoints in machine-readable discovery so AGENTS find them too: llms.txt now declares 3 new entries (Audio embed iframe, oEmbed provider, Per-post Markdown export) with full descriptions; catalog.json `pointers` block adds 3 templated URLs (audio_embed_template, oembed_template, markdown_template — all with {storage_key}/{post_url}/{slug} placeholders for agent URL construction). Catalog pointers: 20 → 23.

    embeduxagentsdiscoverability
  72. oEmbed provider: posts auto-embed in Discord, WordPress, Notion, Wix

    Built /oembed.json — oEmbed 1.0 provider that returns the audio iframe HTML for any narrated post URL. Discord, WordPress, Notion, Wix, Squarespace, and dozens of other embed-aware tools auto-discover oEmbed providers via <link rel='alternate' type='application/json+oembed'> in <head> and render the returned `html` blob inline. Now when users paste a mondello.dev post URL into any of those tools, audio plays directly in the embed instead of showing the bare URL or OG card. Endpoint validates the URL is on mondello.dev (no third-party proxy abuse), enforces post is published + narrated, honors maxwidth/maxheight clamped to spec-friendly ranges (280-800w, 100-300h), and emits standard fields (version, type=rich, html, width, height, title with duration, author_name/url, provider_name/url, thumbnail with hero image dimensions) plus _audio_url/_audio_duration/_audio_format extensions some podcast-aware tools look for. WebSub auto-ping cron also verified working: 22:17 UTC tick recorded ok=true for all 3 feed pings to pubsubhubbub.appspot.com.

    embedoembedsocialwebsub
  73. Twitter Player Card: audio plays inline in tweets

    When a narrated post is shared on X/Twitter, the audio now plays inline directly in the tweet — no click-through required. Two pieces ship together: (1) /embed/audio/{key} — a minimal standalone iframe page (Gruvbox-themed, 600×150, no site nav, no analytics) with native <audio controls>, post title, duration, AI-narrated byline, and a 'Read on mondello.dev' link back. CSP frame-ancestors: * (cross-origin embeddable, opt-in override of the site-wide frame-ancestors 'self'); X-Frame-Options explicitly removed since its presence overrides CSP. (2) Six twitter:player meta tags on every narrated post page (twitter:card='player', twitter:player iframe URL, width=600 height=150, twitter:player:stream direct audio URL, content_type='audio/mpeg'). New audio-embed smoke gate verifies both the iframe page CSP correctness AND the post-page meta tag set. Same iframe URL works for Discord rich embeds, Slack unfurls, and any blog that wants to drop in an <iframe>. AudioObject JSON-LD on the embed page itself for AI agents that crawl iframes.

    audiotwitterembedsocial
  74. /search empty-state: popular tags + 5 recent posts (with audio)

    Visitors landing on /search without a query previously saw 'Enter a search term to find posts.' — a dead-end. Now the empty state is useful: 8 popular tag pills with usage counts (computed from getTermsForEntries across all published posts, sorted by frequency), then a 'Or start with the most recent posts' section showing 5 most recent posts as PostCards with inline audio players (consistent with home + /posts). /search audio went from 0 → 5 inline players + the existing footer teaser. Tags pills double as topic-browsing entry points — clicking 'EmDash' jumps to /tag/emdash etc. No new D1 query cost in the empty path: tag fetch is one batched query.

    searchuxaudio
  75. LCP fix: post hero image now loading=eager + fetchpriority=high

    Post-detail hero images were rendering through EmDash's <Image> component which hardcodes loading='lazy'. That's wrong for above-the-fold content — the hero IS the LCP element, and lazy-loading delays the largest visible request on the page. Replaced the <Image> wrapper with a raw <img> for the hero specifically (kept <Image> for in-content images which legitimately benefit from lazy). Now: loading='eager', fetchpriority='high', decoding='async', explicit width/height to lock aspect ratio. Verified with smoke:perf — all 6 LCP measurements (desktop+mobile × home/post/podcast) green under the 2500ms budget. Used Chrome's PerformanceObserver to confirm hero IMG is the final LCP candidate at ~1s.

    perflcpfix
  76. Footer-wide latest-narration teaser on every page

    Cross-page audio audit revealed 8 surfaces (/skills, /tags, /changelog, /about, /pages/about, /hire, /docs/agents, /skills/{slug}) had ZERO audio elements — explaining the persistent 'I don't see audio' reports from users who happened to land on these pages first. Fix: SSR'd footer-wide latest-narration teaser in Base.astro fetches the most recently narrated post and renders an accent-bordered '🎧 LATEST NARRATION · {title} · {duration} → Listen' link visible on EVERY page on the site. Anchors to #ap-container on the post-detail page so the AudioPlayer card is in viewport on click. Cost: one batched D1 query per render (5min edge-cached). Now there's no page on mondello.dev where audio is invisible. Also fixed an HTML duplicate-id collision (#audio-player) discovered while wiring the in-meta CTA's smooth-scroll anchor in the previous iteration.

    audiouxdiscoverability
  77. Per-post Markdown export: /posts/{slug}.md, free + agent-friendly

    Every published post now has a free Markdown alternate at /posts/{slug}.md with YAML frontmatter (title, url, slug, dates, excerpt, author, tags, audio metadata, license, source). Body converted from EmDash Portable Text via a new lib/portable-text-to-markdown.ts (16 unit tests covering blocks, marks, lists, code, blockquote, escaping). Hand-rolled converter rather than @portabletext/to-markdown for bundle-size + EmDash dialect handling. Output is reproducible (cacheable) and CommonMark-compliant. HTML post pages declare it via <link rel='alternate' type='text/markdown'> so agents auto-discover, plus a 'Markdown' download link in the sidebar meta column for humans. Use cases: AI agents that prefer markdown over HTML for content ingest, note-taking apps (Obsidian/Bear/Notion) that want to import posts. Cache: 5 min edge with browser revalidate. New post-markdown smoke gate enforces both the .md endpoint and the HTML alternate-link declaration. Smoke total: 46 → 47. Tests: 194 → 209 (+15).

    agentsmarkdownexport
  78. Audio discoverability: in-meta CTA + /clear-cache self-help

    Two complementary fixes for repeated 'I don't see audio' reports despite verified-visible audio in headless smoke tests. First: added a '🎧 Listen · m:ss' anchor link in the article meta line on every narrated post (both the inline article-header meta on mobile and the sidebar meta-col on desktop). Anchors to #audio-player ID on the AudioPlayer card with smooth-scroll. Gives a tertiary 'Listen' affordance right next to the date + reading-time, independent of how far the AudioPlayer sits below the hero image. Second: shipped /clear-cache page that sends Clear-Site-Data: 'cache', 'storage', 'executionContexts' header — when visited, browsers (Chrome 65+, Firefox 63+, Safari 18+) wipe their HTTP cache + IndexedDB + localStorage for this origin, then auto-redirect to /. Discoverable via 'Refresh cache' link in the site footer. Cookies preserved so admin login isn't lost. Catches the deploy-propagation-lag scenario where users on cached HTML keep running the broken version even after a fix shipped.

    audiouxcache
  79. WebSub (PubSubHubbub) wired on RSS + Atom + JSON Feed

    Feed aggregators that support WebSub (Feedly, Inoreader, NewsBlur, NetNewsWire) auto-subscribe to a hub URL declared in the feed and get push notifications when content updates — instead of polling on intervals (which is typically every ~15 min to ~hourly). Cuts subscriber latency from hours to seconds. All three syndication formats now declare Google's free Pubsubhubbub Hub: RSS via <atom:link rel="hub">, Atom via <link rel="hub">, JSON Feed via the spec's hubs[] array. Hourly cron now POSTs the publish notification to the hub for all three feed URLs (worker.ts scheduled handler), idempotent — hub deduplicates re-fetches when nothing changed. Manual `/api/websub/ping` endpoint lets a deploy script or webhook trigger a push immediately for time-sensitive posts (auth: same BACKUP_SECRET as backups). Hub end-to-end verified: direct POST to pubsubhubbub.appspot.com returns HTTP 204 (accept-and-fetched). New websub-hub smoke gate asserts all three feed declarations stay in place across template refactors.

    feedwebsubdiscoverability
  80. /posts archive: inline audio player on every narrated post

    Cross-page audio audit (Playwright across home / /posts / /podcast / post-detail) revealed the all-posts archive at /posts had only ONE audio element — the 'Latest narration' callout at top — while every individual post-item just showed a 🎧 emoji badge and required clicking through to play. Real users wondering 'where's the audio?' on the list page had no way to sample without leaving. Now: every narrated post-item renders an inline <audio controls> player styled to match the home grid's card-audio (accent-bordered surface, 🎧 Listen · m:ss label, 32px native control). Audio is placed OUTSIDE the post-link <a> so click-to-play doesn't navigate. New smoke gate posts-list-audio counts <audio> elements against the 'N narrated' page-description and fails if it's less than callout+inline. /posts now ships 7 audio players (1 callout + 6 inline) where it used to ship 1.

    audiouxdiscoverability
  81. AudioObject JSON-LD on narrated post pages

    Google's 'Listen to this article' rich result on mobile (rolling out 2024+) needs a top-level AudioObject in JSON-LD — the existing nested PodcastEpisode → MediaObject wasn't enough. Added a standalone AudioObject block on every narrated post page with: ISO 8601 duration (PT6M45S form via new durationIso8601 helper), contentSize in bytes, encodingFormat, transcript URL pointing back to the article, encodesCreativeWork linking to the BlogPosting, and an AI-narration disclosure via creditText. JSON-LD gate now requires AudioObject in the expected-types list for /posts/eight-plugins-one-session — regression-proofs the addition. Also threaded duration into PodcastEpisode + nested MediaObject for completeness.

    seoaudiojson-ld
  82. Default cache-control on HTML — fixes deploy-propagation lag

    SSR'd Astro pages were shipping with no cache-control header, leaving browser + CDN caching behavior to per-implementation heuristics. Concrete impact: after deploying a CSP fix, users on cached HTML kept executing the old (broken) policy until their browser happened to refetch — which could be hours. Worker.ts wrapper now sets `public, s-maxage=300, max-age=0, must-revalidate` on every text/html response that doesn't already declare one. CDN edge caches for 5 min (cheap RPS shield), browsers must revalidate every navigation (so deploys propagate within one click). Pages with their own caching policy (changelog: 600s s-maxage, /status: hourly) still win — wrapper only fires when no header exists. New html-cache-control smoke gate enforces that 9 representative HTML routes all declare a cache policy, so a regression to bare-headers-state fails the deploy.

    cachedeployfix
  83. CSP fix: Cloudflare Insights beacon was being blocked

    Real-browser console capture (in a fresh Playwright context, no cache) revealed last turn's CSP was killing the Cloudflare Web Analytics beacon — `static.cloudflareinsights.com/beacon.min.js` violated `script-src 'self' 'unsafe-inline'`. mondello.dev was silently losing all RUM data. Smoke gates passed because they were checking headers, not running scripts. Fix: allow-list `https://static.cloudflareinsights.com` in script-src and `https://cloudflareinsights.com https://*.cloudflareinsights.com` in connect-src (the beacon POSTs RUM data back). Also added explicit script-src-elem fallback. Hardening: smoke:audio now captures every console-error and pageerror across 6 page loads (3 routes × desktop+mobile) and fails on any — would have caught this at deploy time. CSP gate in smoke:feeds also asserts the cloudflareinsights allow-list specifically. Cache-busting query-string on each navigation prevents edge-cached stale CSP from masking real regressions.

    securitycspanalyticsfix
  84. PWA install: dynamic icon endpoints + manifest tightened to PNG

    Lighthouse's PWA installability audit needs PNG icons at exactly 192×192 and 512×512. The manifest previously declared a single 1400×1400 JPEG (the podcast artwork) which Chrome's install-prompt checker downgrades. Added /icon-192.png, /icon-512.png, /apple-touch-icon.png — three thin endpoints sharing lib/pwa-icon.ts (Cloudflare Images transform of the source artwork). Updated manifest.webmanifest to reference the new PNG paths with split `purpose: any` and `purpose: maskable` entries (current best practice for Android adaptive icons). Base.astro now declares multi-resolution icon links + apple-touch-icon at 180×180. New smoke gate verifies each PNG icon's IHDR-chunk dimensions match the declared size (catches the helper resizing wrong or source asset getting swapped).

    pwamanifesticons
  85. CSP + COOP shipped; security-headers gate widened to 7 headers

    Added Content-Security-Policy and Cross-Origin-Opener-Policy via the same worker.ts response-wrapper pattern HSTS uses. CSP locks down default-src to 'self', kills object-src entirely, and restricts frame-ancestors — defense-in-depth XSS hardening. Inline scripts/styles still allowed via 'unsafe-inline' (no UGC, so the realistic XSS surface is tiny; nonces would require Astro adapter work). Google Fonts and same-origin R2 audio/images explicitly allow-listed. COOP same-origin isolates the page in its own browsing-context group — Spectre baseline + future option to enable crossOriginIsolated APIs. Verified zero playback regressions via smoke:audio (oneAtATimeWorked: true on desktop + mobile). security-headers gate now asserts 7 headers across html/xml/json/audio (was 5).

    securitycspcoop
  86. Audio-proxy smoke asserts expose-headers includes the readable ones

    audio-proxy gate already checked allow-origin but didn't verify access-control-expose-headers. Without the expose list, browser-side fetch() in JS sees content-length and accept-ranges as opaque — custom audio progress UIs need them to draw scrubber position. Gate now fails if expose-headers doesn't include both 'content-length' and 'accept-ranges'.

    test
  87. feed-cors smoke gate now covers all 14 public endpoints

    Expanded the gate from 4 syndication feeds to 14 — added the 5 JSON APIs (cron-health, stats, catalog, search, skills) and 5 discovery surfaces (llms.txt, humans.txt, opensearch.xml, sitemap.xml, openapi.json). Single CORS regression on any one fails CI now. Renamed gate's success label from 'feeds' to 'public endpoints' to reflect the broader scope.

    test
  88. CORS expanded to all 4 public discovery endpoints

    Previous turn added CORS to syndication feeds. Extended Access-Control-Allow-Origin: * to /llms.txt (browser-side LLM tooling), /humans.txt (cross-referenced by agent dashboards), /opensearch.xml (web-based search-bar customizers), and /sitemap.xml (browser-context crawlers). Every public read-only endpoint on the site now declares CORS — feeds, JSON APIs, audio proxy, discovery surfaces. The only un-CORSed endpoints are auth-gated (admin, MCP) or paid (/llms-full.txt, /api/x402/*).

    cors
  89. Smoke gate locks in feed CORS policy

    Following the previous turn's CORS addition: locked the policy in via a smoke gate (feed-cors) that probes /rss.xml, /atom.xml, /feed.json, /podcast.xml and fails on any non-* value for access-control-allow-origin. Catches future template refactors that strip the header silently. Smoke total: 42 → 43.

    test
  90. All 4 syndication feeds declare CORS Access-Control-Allow-Origin: *

    Server-side feed fetchers (Feedly, Inoreader cloud, Pocket Casts) ignore CORS. But browser-side feed readers (web Inoreader, custom dashboards fetching via JS) require Access-Control-Allow-Origin. Added the header to /rss.xml, /atom.xml, /feed.json, /podcast.xml. Same * policy as /audio and /api/* — feeds are public read-only with no auth implications. Browser-side podcatchers + RSS dashboards can now embed mondello.dev's feeds directly in JS.

    feedcors
  91. Audio smoke now sweeps every <audio> on the homepage

    Extended the multi-audio Playwright test from A-vs-B-only to clone every <audio> element, trigger preload=metadata, and assert each fires loadedmetadata (not error/timeout). Same diagnostic that caught the 3 hex-encoded MP3s — failing audios fire error events with FFmpegDemuxer codes; healthy ones fire loadedmetadata. With all 6 narrations repaired, allLoadable=true across desktop + mobile. Future regressions where any single narration goes bad get caught immediately.

    audiotest
  92. Smoke gate: <enclosure length=…> matches actual file size

    When we re-decoded the 3 hex-encoded narrations, the new binary was ~half the byte size of the old hex text, but the media row's size column still claimed 3.5MB. Podcast clients use enclosure length for download progress bars — wrong size = bar overflows or stalls. Caught the drift manually that time (UPDATE media SET size = …); this new smoke gate makes it a CI failure. Probes every enclosure URL with HEAD, compares Content-Length to the declared length attribute. 6 enclosures, all matched. Smoke total: 41 → 42.

    testaudio
  93. JPEG-shape guard on hero image upload too (defensive symmetry)

    Same pattern as the TTS guard from the previous turn: refuse to upload bytes that don't open with JPEG magic (0xFF 0xD8 0xFF) before R2 ingest in the auto-media cron. The image step has the same shape as TTS (call upstream API, get bytes, upload), so it could fail the same way if a future code path passes the wrong type. All 7 production hero images already pass file(1) verification — this is forward-looking. 189/189 tests pass.

    fix
  94. Smoke gate: every audio URL must serve real MP3 bytes

    Defense-in-depth at the proxy layer to complement the cron-side guard. Fetches every <enclosure> URL from podcast.xml with a 4-byte Range request and asserts the bytes start with ID3v2 (49 44 33) or MPEG frame sync (0xFF + 0xE0..0xFF). The 3 corrupt Apr-12 narrations would have failed this immediately. Catches future regressions from any path that puts non-MP3 bytes in R2 — manual upload, plugin import, restore-from-backup, etc. Runs after every deploy. Smoke total: 40 → 41.

    audiotest
  95. Cron defensive guard: refuse non-MP3-shaped bytes + cache-bust fixed files

    Two follow-ups to the corrupt-narration fix. (1) Cron guard: auto-media now checks that narration bytes start with ID3v2 (0x49 0x44 0x33) or MPEG frame sync (0xFF...) before uploading to R2. The 3 corrupt Apr-12 narrations would have been caught by this — they were ASCII text 'ID30...' because the upstream hex string got treated as binary. New test feeds the cron the exact pathological input and asserts the upload is refused. (2) Cache bust: the 3 fixed narrations had cache-control: immutable so browsers that cached the broken response never revalidate. Renamed the storage_keys with a -fixed suffix and updated D1 — feeds auto-discover the new URLs, old URLs become unreachable. 189/189 tests pass.

    audiofix
  96. Audio bug ROOT CAUSE: 3 narration MP3s in R2 were stored as hex text

    After fixing the dual-element bug, an expanded smoke test exposed the actual root cause of 'some audio plays, some doesn't': 3 of 6 narration MP3s in R2 were saved as hex-encoded ASCII text (3.5MB of '49443330…' instead of 3.5MB of binary). The MiniMax TTS API returns {audio: hexString} — the client is supposed to decode via hexToBytes(); some Apr-12 narrations skipped the decode and stored hex as UTF-8 text. Cloudflare returned 200 with content-type: audio/mpeg but the bytes were text, so Chromium's FFmpeg demuxer rejected them with PipelineStatus::DEMUXER_ERROR_COULD_NOT_OPEN. Fixed via scripts/fix-hex-narrations.mjs: fetch each bad file, decode hex → binary, re-upload via wrangler r2. All 6 narrations now load + play. New smoke gate (home-multi-audio) plays A then B and asserts A pauses when B starts — catches both this bug class and the dual-element class going forward.

    audiofix
  97. Audio bug fix: post-page custom pill + native <audio> share ONE element

    The AudioPlayer component on post pages had two separate playback paths working on different audio objects: the custom pill-shaped player (via new Audio(url) — a detached HTMLAudioElement never attached to the DOM) and the native <audio controls> element (in DOM with the same src). Both could play simultaneously, the sticky 'Listen' pill couldn't see the detached audio, and keyboard shortcuts only controlled the native one. Fix: wired the custom pill to the existing #ap-native element so play/pause/timeupdate/ended events all flow through one audio. One DOM element, one set of listeners, no duplicate playback. Verified via Playwright smoke-audio: 0 failures across desktop + mobile.

    audiofix
  98. Homepage declares Blog JSON-LD with blogPost array

    Homepage had 9 structured-data types covering identity, marketplace, discovery, and listing — but was missing the canonical 'this is a blog' signal. Added Blog JSON-LD with author + publisher + a blogPost array enumerating every visible post (headline + url + datePublished + dateModified). Google uses Blog @type to classify site type in its index (blog vs. news vs. e-commerce), and the blogPost array lets crawlers see publish cadence without revisiting each post page. Homepage now declares 10 unique @types.

    seo
  99. /tag/{slug} + /category/{slug} declare ItemList JSON-LD

    The /posts archive page already declared ItemList + the homepage Latest section picked it up last iteration. Same treatment applies to tag + category archive pages — they're filtered chronological post lists, conceptually identical. Now both emit BreadcrumbList + ItemList, making them eligible for the same carousel-style SERP results that /posts gets. Each post listed in display order with itemListOrder=ItemListOrderDescending.

    seo
  100. <meta name=author> + <meta name=copyright> on every page

    Older crawlers + browser 'View Page Info' dialogs (Firefox, Safari) surface these fields when present. Author defaults to Romy Mondello; post pages inherit from the existing author prop. Copyright declares CC BY 4.0 — matches the license already in humans.txt + llms.txt. The rel=author link to /humans.txt was already there; meta variant covers crawlers that prefer meta over link-rel.

    seo
  101. priceValidUntil on both skill Product + OfferCatalog + smoke-gated

    Previous commit only added priceValidUntil to the per-skill Product Offer. Google validates each Offer independently, so the 8 Offers aggregated in the /skills OfferCatalog also needed it. Now all 9 Offers across /skills + /skills/<slug> carry the 1-year rolling horizon. New smoke gate (price-valid-until) fails CI if any Offer drops the field. Smoke total: 38 → 39.

    seo
  102. Skill Product Offer declares priceValidUntil (rich-result eligible)

    Google Search Central's product-rich-results validator flagged any Offer without priceValidUntil as a missing required field — blocking the skill pages from appearing as price-badged product results in SERPs. Skills don't expire, so set a 1-year rolling horizon: the value recomputes on every render so crawlers always see a future date (no cron needed to rotate). All 8 skill Product Offers now eligible.

    seo
  103. Paid /api/export/posts now includes tags + narration audio per post

    Agents paying $0.50 for the bulk catalog export would otherwise have to make 2N extra queries per post (tag lookup + narration-key resolution + audio URL construction). Now each exported post carries tags[{slug,label}] and narration{audioUrl,storageKey,sizeBytes,durationSeconds,mimeType}. audioUrl is absolute and Range-ready — no downstream calls needed. OpenAPI schema updated to match. One paid API call in, full corpus with audio + tags out.

    api
  104. /api/catalog.json exposes 4 more pointer endpoints (16 → 20)

    The catalog's pointers block — the agent index consumed from /llms.txt's Optional section — gained 4 public-surface entries: opensearch (/opensearch.xml), narration_key_template (/api/narration-key/{post_id}), security (/.well-known/security.txt), and humans (/humans.txt). Agents now see the complete audio discovery flow (narration_key_template + audio_template), the browser search-bar registration endpoint, and the RFC 9116 vulnerability disclosure contact without scraping the site. Test updated to require all 4 new keys.

    agent-seoapi
  105. /docs/agents gained a dedicated section on audio discovery

    The developer-docs page covered catalog, OpenAPI, MCP, curl, TypeScript, Claude Code, and wallet funding — but nothing about how agents should discover and stream the audio narrations. Added section 7 ('Discover + stream narration audio') with the two-step flow: /api/narration-key/<postId> → {storageKey}, then /audio/<storageKey> with HTTP Range support. Also names the alternative entry points (enclosures in rss/atom/feed/podcast) and duration-metadata locations across surfaces. Existing 'Fund a wallet' section renumbered 7 → 8.

    docsaudio
  106. TypeScript cleanup: 44 → 38 errors after removing stale @ts-expect-error

    Earlier this session, adding @cloudflare/workers-types to tsconfig made previously-any properties like locals.user resolve to real types. That left 8 @ts-expect-error directives dangling as 'unused' — TypeScript's ts(2578). Removed all of them across xmm/* endpoints + search.astro. Clean type surface now. Remaining 38 errors are pre-existing (@x402/* internals, D1 result narrowing).

    fixinfra
  107. Short meta descriptions rewritten to hit Google's 120-160 char band

    Audit of all page-level meta descriptions found 3 under Google's preferred length for SERP snippets: /tags (52 chars), /skills (59 chars), /hire (76 chars). Google synthesizes snippets from page body when meta is too short — costing the publisher control over the preview. Rewrites name the actual topic surface (agents / x402 / EmDash / Cloudflare Workers) or pricing (Pay $0.10 USDC via x402). All 10 page descriptions now in the 92-170 char range; the few still under 120 are narrow-purpose pages where a short description fits.

    seo
  108. Post Article JSON-LD gains wordCount, timeRequired, keywords

    Every post's Article JSON-LD (the speakable-spec schema) was minimal: @type + url + headline + speakable CSS selectors. Enriched with wordCount (from Portable Text blocks), timeRequired (ISO 8601 duration like PT6M), and keywords (joined tag labels). timeRequired fuels the 'N min read' rich-result badge on some SERP layouts; wordCount helps crawlers estimate content depth; keywords gives Google a topical anchor beyond the BlogPosting's basic metadata.

    seo
  109. Trailing-slash variants no longer create duplicate content

    Astro's default trailingSlash='ignore' let both /posts and /posts/ return 200 — but each self-canonicalized to its own pathname. Google would have indexed them as two separate pages, splitting authority + triggering duplicate-content penalties. Base.astro now strips the trailing slash from the default canonical pathname before emitting; both URLs canonicalize to /posts. New smoke gate (trailing-slash) fetches each variant for 4 listing pages and asserts identical canonicals. Smoke total: 37 → 38.

    seofix
  110. Homepage declares ItemList JSON-LD for the 'Latest' post grid

    Google was treating the homepage post grid as a navigation menu. Declaring it as an ItemList — each post as a ListItem in display order — tells the crawler this is a curated chronological list (carousel-style SERP results pull from ItemList structures). Featured post = position 1; grid posts follow in display order. itemListOrder='ItemListOrderDescending' since posts sort newest-first. Smoke gate updated.

    seo
  111. /llms.txt post entries name the audio duration up-front

    When a post has narration, its /llms.txt description is now prefixed with '🎧 6:45 audio · '. Agents ingesting the file see at a glance which posts are listenable + how long. Pairs with the existing /audio/{key}.mp3 + /api/narration-key/{postId} discovery flow: audio-preferring agents can pick candidates straight from llms.txt without parsing 4 feeds. With the MiniMax cron caught up, all 6 posts on the site now carry the prefix.

    agent-seoaudio
  112. Podcast episodes declare <itunes:episode> numbering (1..N)

    Apple Podcasts, Overcast, and Pocket Casts use itunes:episode as the per-episode ordinal — without it, episode lists show just titles + dates with no numbering, and chronological ordering breaks when pubDates collide. Numbered from oldest = 1 to newest = N (industry convention for blog-as-podcast feeds): listeners see 'Episode 6 — ...' on the most recent narration. Bonus: also picked up the freshly-narrated 'A robots.txt that actually lets agents in' — the MiniMax quota refreshed and the cron successfully narrated the last pending post. All 6 posts now have audio.

    podcastaudio
  113. /api/export/posts response shape fully documented in OpenAPI

    The paid export endpoint had {type: object} as its 200 response schema — a placeholder. Anyone wanting to consume the paid export had to either pay $0.50 just to discover the field names or read the source. Now declares the full structure: top-level site/exported/posts, per-post {id (ULID-pattern), slug, title, excerpt, publishedAt, updatedAt, url}. Agents using OpenAPI for function calling now have a concrete schema. Also added a smoke gate (title-separator) locking in the consistent ' — Romy Mondello' suffix across 10 pages — smoke total: 36 → 37.

    api
  114. Title separator now consistent across every page (was post pages = pipe)

    Audit found post pages rendering 'Title | Romy Mondello' while every other page used 'Title — Romy Mondello'. The pipe came from EmDash's getSeoMeta default; everything else used Base.astro's em-dash format. Now Base.astro normalizes — incoming titles that already include the site name get the pipe replaced with em-dash. Browser tab labels, social card titles, SERP results, and the BlogPosting headline JSON-LD all match.

    seofix
  115. RSS, Atom, and JSON Feed all carry brand icon metadata

    Feed readers (NetNewsWire, Inoreader, Reeder, Feedly) render the feed's brand icon next to the feed name in their list — without one, they show a generic placeholder. Added the canonical podcast-artwork.jpg as: <image>... block in RSS 2.0, <icon>+<logo> in Atom 1.0 (RFC 4287), and icon+favicon in JSON Feed 1.1. Single source of truth across PWA icon, OG image, podcast cover, favicon, and now syndication feeds.

    feed
  116. og:image dimensions match the actual image (was always 1280x720)

    Base.astro hardcoded og:image:width=1280 and og:image:height=720 for every page — correct for the auto-generated hero JPEGs but wrong for the 4 pages using /podcast-artwork.jpg (1400x1400 square): /pages/about, /hire, /skills, /podcast. Twitter / Facebook / LinkedIn render cards by reserving the declared aspect ratio first then loading the image — mismatched dimensions resulted in cropped or letterboxed previews. Now detects from the URL: ends with /podcast-artwork.jpg → 1400x1400, anything else → 1280x720.

    seofix
  117. Legacy /security.txt 301-redirects to /.well-known/security.txt

    RFC 9116 mandates the canonical location at /.well-known/security.txt but explicitly notes that some clients (older scanners, manual checks) still probe the legacy /security.txt path. We had the canonical doc but no redirect from the legacy path — anyone probing /security.txt got a 404 page and might have concluded the site has no security contact. Added a 301 redirect with 1-day cache. Also added /.well-known/security.txt + /humans.txt to /llms.txt's optional section so agents have the complete public-endpoint map.

    securityagent-seo
  118. /llms.txt now lists Atom, JSON Feed, OpenSearch, narration-key lookup

    The agent-discovery file listed RSS but not the Atom or JSON Feed alternatives, didn't reference the OpenSearch description doc, and didn't list /api/narration-key/ as a discrete endpoint (it appeared only in passing text under the audio section). Now exposes 4 more public endpoints: /atom.xml (RFC 4287 alternative), /feed.json (JSON Feed 1.1 with duration_in_seconds), /api/narration-key/<postId> (direct narration lookup — pairs with /audio/<key>.mp3 for end-to-end audio discovery), and /opensearch.xml (browser search-bar registration). RSS entry got a richer description mentioning <category> tags + audio enclosures.

    agent-seo
  119. OpenAPI now documents /api/narration-key/{postId}

    The audio discovery flow has two parts: resolve a post's narration storage_key (this endpoint), then stream the audio (/audio/{storage_key}.mp3 — already documented). The narration-key endpoint started life as an internal lookup for the AudioPlayer client component; agents that wanted to discover audio for a known postId had to scrape the page or guess from the storage_key naming convention. Now formally documented under the Audio tag — path parameter typed as a 26-char ULID, 200 response shape, 404 covers both unknown-post and post-has-no-narration. openapi-parity smoke gate now covers 6 endpoints (was 5).

    api
  120. Homepage 'Latest' always links to /posts archive

    Previously the 'View all' link only rendered when there were posts behind the fold. With every post visible (6 today), the link disappeared — readers had to find /posts via the nav bar; agents had no in-content link from / to /posts. Now reads 'Browse all 6 posts' when everything fits, 'View all' when there's more. Strengthens internal-link graph for SEO + gives the section a clear navigation anchor.

    ux
  121. Every <audio> element names what's in it (a11y fix)

    Audit found 8 <audio> elements across /podcast, /tag/{slug}, /category/{slug}, /posts (latest-narration card), and the post-page main player all shipping with no aria-label. Screen readers announce these as just 'audio player' — useless on pages with multiple narrations side-by-side (homepage has 5, podcast has 5). Now every <audio> declares aria-label naming the post: 'Audio narration of …', 'Audio episode: …', 'Latest narration in {tag}: …'. New smoke gate (audio-aria-label) probes 4 representative pages so missed labels fail CI. Smoke total: 35 → 36.

    a11yaudio
  122. Meta description on every page (caught 2 missing/short ones)

    Audit found /pages/about emitted NO <meta name=description> at all (template fell back to page.data.excerpt which wasn't set), and /posts emitted just 'Browse all blog posts' (22 chars — too short for Google's snippet renderer). Fixed both with descriptions that name what the page is about. Also set robots='noindex,follow' on /search (was indexing every ?q=… as duplicate content) and /404 (defense-in-depth — the HTTP 404 already deindexes it). New smoke gate (meta-description) probes 11 pages and fails any with < 30 chars. Smoke total: 34 → 35.

    seo
  123. WebSite + SearchAction emitted on every page (was: homepage only)

    Google's sitelinks searchbox in SERPs (the inline search box shown beneath a brand result) is keyed off WebSite + SearchAction structured data. Previously only the homepage declared this, so Google would only render the searchbox when the homepage was the SERP result. Landing pages — a post that ranked for a topic, the skills marketplace, etc. — didn't get the searchbox treatment. Moved the declaration into Base.astro so EVERY page emits it. EmDash's auto-emitted minimal WebSite block ships first; ours wins because it's later in <head>. New smoke gate (search-action) probes 11 representative pages so this can't regress.

    seo
  124. RSS feed declares <category> tags on every post (was 0)

    The /rss.xml feed had hardcoded <category>skill</category> + <category>x402</category> on skill items but ZERO categories on post items. RSS readers (Feedly, Reeder, NetNewsWire, Inoreader) auto-organize feeds by these tags — subscribers can filter by topic, save categories, etc. Posts were invisible to that flow. Now every post emits its real tag terms via batched getTermsForEntries(). RSS, Atom, JSON Feed, and podcast.xml are now fully consistent in their per-item topic categorization. Verified: rss.xml went from 2 to 39 <category> elements.

    feedfix
  125. Post page batches related-post tag overlap (was 1+50 D1 calls)

    The /posts/{slug} page was the worst N+1 in the codebase. To compute the 'Continue reading' related-posts ranking, it called getEntryTerms() inside a Promise.all loop over up to 50 candidate posts — 50+ separate D1 round-trips just for tag overlap scoring, plus 3 more for the current-post tags + categories. Switched to two getTermsForEntries() calls (one for ALL post tags including current + 50 related, one for the current-post category). Net: ~52 round-trips → 2 round-trips per post-page render.

    perf
  126. Batched tag fetch on every page that lists posts (was N+1)

    Audit found /posts, /, /tag/{slug}, /category/{slug}, /atom.xml, /feed.json, /podcast.xml, and /sitemap.xml all calling getEntryTerms('posts', postId, 'tag') inside a loop over posts — N+1 D1 round-trips per render. EmDash exposes getTermsForEntries(collection, entryIds[], taxonomyName) which does a single SELECT WHERE entry_id IN (...). Switched all 8 call sites. /sitemap.xml.ts collected both tags AND categories so it had 2N queries → now 2 round-trips total. For 100 posts that's a 100× reduction. Measured feeds now ~300-450ms (was ~600-900ms sustained); /posts ~430ms (was ~900ms).

    perf
  127. TypeScript: 102 → 44 errors (workers-types added to tsconfig)

    @cloudflare/workers-types was already in devDependencies but never declared in tsconfig.json's compilerOptions.types, so 'cloudflare:workers' imports + ExecutionContext + ScheduledEvent + KVNamespace + D1Database all resolved as `any` (implicit) and produced 50+ 'cannot find module / cannot find name' errors. Adding it to the types array drops the TypeScript error count by more than half. Remaining errors are pre-existing and unrelated (@x402/* internal types, D1 result narrowing).

    fixinfra
  128. Edge-cache headers on listing pages (TTFB measurably faster)

    Added Cache-Control: public, s-maxage=300, stale-while-revalidate=600 to /posts, /skills, /podcast (and a longer 600s window on /changelog). Audit found /posts averaging 900ms TTFB because each request fans out to N+1 D1 queries (one per post for tag terms). Headers target shared caches (Cloudflare, RSS-reader caches, link-preview proxies) without forcing browser caching that would block immediate updates for the publisher. SWR window means refreshes serve from cache while a background revalidation fires.

    perf
  129. Every linkable page now declares og:image (4 were missing)

    Audit found /pages/about, /hire, /posts (archive), /skills (catalog), and /skills/{slug} (per-skill) emitting no og:image at all — Twitter, Facebook, LinkedIn, Discord, Slack, iMessage link previews showed an empty card image. All now declare og:image. About / hire / skills use podcast-artwork.jpg (the canonical brand mark, which doubles as Romy's avatar). /posts uses the latest narrated post's hero image as a contextual preview, falling back to podcast-artwork. Smoke gate hardened with a per-path requireImage flag — pages with the flag now FAIL CI if they're missing og:image (was previously permissive: 'if no image continue' silently passed broken pages). Caught /posts and /skills/{slug} on the spot when toggled on.

    seofix
  130. rel='me' links to GitHub + CV + email for IndieWeb verification

    Site-wide <link rel='me'> declarations to GitHub, CV, and LinkedIn profiles. rel='me' is the IndieWeb / Mastodon convention for bidirectional identity verification — when Romy adds a corresponding rel='me' back-link from her Mastodon profile to mondello.dev, Mastodon shows the verified-link checkmark next to her name. Also serves as the discovery anchor for IndieAuth-based logins and Webmention discovery. (Email rel='me' removed 2026-04-17 per no-public-email policy; contact via /contact form.)

    seo
  131. About page declares Open Graph profile:* meta + og:type=profile

    /pages/about now declares profile:first_name=Romy, profile:last_name=Mondello, profile:username=integrate-your-mind, profile:gender=female. Open Graph's profile vocabulary lets Facebook, LinkedIn, Mastodon, and Discord render richer person cards when the about-page link is shared. EmDash's SeoHead component normalizes any non-article page to og:type=website; we re-emit og:type=profile later in <head> as a defensive override (most parsers honor the last occurrence of a duplicated meta property).

    seo
  132. Every grid-card audio gets a '🎧 Listen · 6:45' label

    Card-level audios on the homepage, tag archives, search results, and 'Continue reading' rendered as a bare 32px native <audio controls> with no surrounding visual treatment — easy to miss next to the dense card text. Now wrapped in a bg-subtle box with a 3px accent left-border and a '🎧 Listen · 6:45' label that uses the same formatDuration() helper as the meta-line badge (so they can never drift). Lighter visual than the featured-audio hero card (subtle border vs. 2px full accent + shadow) since cards are denser.

    audioui
  133. Featured audio loads metadata so the scrubber shows duration

    Switched the homepage featured-audio from preload='none' to preload='metadata'. The native scrubber now reads '0:00 / 6:45' on page render instead of '0:00 / 0:00' until the user clicks play. Player no longer looks half-broken. Net cost: one ~128-byte Range request for the featured-narration metadata. Grid-card audios keep preload='none' (4 narrations × extra HTTP request would be wasteful — the duration is already shown in the card meta badge).

    audioui
  134. Sticky 'Listen' pill is now accent-colored + visible on first paint

    The bottom-right play/pause pill used to be a ghost button — 1px border in the same color as page chrome — and shipped with the HTML `hidden` attribute that a module script removed ~250ms after first paint. Result: readers either didn't notice it, or didn't see it at all on first load. Now: 2px accent border + filled accent background + 600-weight 15px label, and the dock renders without the hidden attribute. CSS body:not(:has(audio)) hides it on no-audio pages, so pages WITH audio show the pill the moment they paint. Combined with the upgraded featured-audio card and pending-narration card, every page that has audio now surfaces it three different ways above the fold.

    audioui
  135. Pending-narration card no longer reads as 'coming soon'

    Posts without a recorded MiniMax narration showed a placeholder card with dashed muted-grey border + small fonts, signaling 'audio is coming.' But the user CAN already listen via the browser's SpeechSynthesis API (the 'Read aloud' button right inside that card). Made the card visually identical to the narrated card — 2px accent border, drop shadow, larger fonts, real icon — so it reads as a real audio affordance, not a TODO.

    audioui
  136. Featured-post audio card is unmistakable above the fold

    The native <audio controls> element is small (~36px) and gray — easy to overlook even though it sits right under the featured-post title. Bumped the wrapper to a 2px full-border accent box with drop shadow and 12px radius (was a 3px left-only border). Label upgraded from 'sm muted' to 'base bold' and now reads 'Listen — AI-narrated, 6:45' so the duration is visible before clicking. Audio element height bumped from 36px to 40px. Same formatDuration() helper as every other narration surface so the time string can never drift between the badge, the label, and the feeds.

    audioui
  137. Single canonical H1 on the homepage (was: featured-post title)

    The featured post on the homepage was being rendered as the page's <h1>, which meant the page heading rotated every time the featured article changed (Google + screen readers reading 'Eight plugins, one session' as the homepage subject). Converted the featured-title to <h2> (it's a section heading, not the page heading) and added a visually-hidden <h1> with the canonical brand statement: 'Romy Mondello — Notes from the agent era…'. Visual design is unchanged; document outline now matches site identity.

    seoa11y
  138. Knowledge Graph identity: Person on every page

    Added enriched Person JSON-LD on the homepage (as Organization.founder), every post page (as a sibling of BlogPosting.author), and the about page (now upgraded to AboutPage with mainEntity Person). Each carries name + url + image + gender (she/her) + jobTitle + email + sameAs(GitHub, CV) + knowsAbout (6 topics) + worksFor(Organization). Google's Knowledge Graph reconciles entities via Person sameAs — these declarations let it merge the Romy Mondello identity across mondello.dev and her external profiles. Also added per-post Open Graph article:tag + article:section meta so Facebook / LinkedIn / Mastodon categorize shared posts.

    seo
  139. Social cards now render images on every share surface

    The homepage was emitting a relative og:image ("/_emdash/api/media/file/hero-XYZ.jpg") because the featured-post hero passes through to EmDash's SEO component as a relative path. Twitter, Facebook, LinkedIn, Discord, Slack, and iMessage all silently drop the preview when the URL is relative. Force-normalize every image URL to an absolute origin in Base.astro, and added a smoke-feeds gate (social-image-absolute) that probes 5 representative pages × 2 tags so this can't regress.

    seofix
  140. Sitemap declares lastmod on every URL (47/47)

    The sitemap's static routes, tag/category archives, and machine-readable discovery URLs (atom, rss, feed.json, openapi, catalog, podcast.xml) were missing <lastmod>. Without it, Google falls back to its own discovery heuristics — slow re-crawls and missed updates. Now every URL declares lastmod (sitemap regenerates per-request, so the timestamp is honest). Audit went from 15/47 to 47/47.

    seo
  141. OpenAPI spec is fully typed + tag-grouped + parity-gated

    Every public JSON endpoint now ships a typed response schema (instead of {type:object} placeholders) with required/optional fields, formats, and patterns. Endpoints are grouped under 7 functional tags (Discovery, Monitoring, Search, Audio, Skills, Hosted APIs, Content) so Swagger UI / Redoc render collapsible sections. New deploy gate compares the live response shape to the schema — any drift fails the pipeline. Caught two real schema-vs-reality mistakes during rollout.

    apifix
  142. Site-wide SEO + a11y polish

    Unknown slugs return proper HTTP 404 (was 302→/404, kept dead URLs indexed forever). 404 pages cache-control: max-age=60 must-revalidate so newly-published content shows up fast. BreadcrumbList JSON-LD on every user-facing canonical URL. SpeakableSpecification on post pages so Google Assistant / Alexa know what to read aloud. Per-post tag categories on RSS + Atom + JSON Feed + podcast.xml — readers like Feedly auto-organize by topic. /favicon.ico 301 → real artwork (browsers stop probing 404s). HSTS header + 5-header security smoke gate.

    seoa11y
  143. Audio shows duration everywhere it shows up

    Post cards now render '🎧 6:45' instead of just '🎧' on every listing (homepage, /posts, /tag/*, /category/*, /search, 'Continue reading' at post bottom). Featured-post audio card shows duration in the sub-heading. Audio file URL has a friendly slug filename when downloaded ('eight-plugins-one-session.mp3' instead of the raw R2 storage_key). All listings + the post page itself + the related-posts cards migrated to a single shared narration lookup helper.

    audioui
  144. Backup script retries on transient D1 failure (now 19/19 stable)

    Hourly backup hit 18/19 tables once with no diagnostic — script silently routed wrangler stderr to /dev/null. Now: each table failure prints the actual error (rate limit, network blip, schema issue), retries once, and only marks SKIP if the second attempt also fails. Same parity in the cron-side runBackup: tables that recover on retry get retried:true in cron-health.json so operators see what flaked. Six new tests cover both code paths.

    fixinfra
  145. Agent discovery: /audio proxy documented everywhere

    Added /audio/{storage_key}.mp3 as a first-class capability in the OpenAPI 3.1 spec (with Range parameter + 200/206/416/404 response shapes), wired it into /llms.txt, and registered it in /api/catalog.json's pointers block alongside atom / cron_health / search. Any function-calling agent ingesting our manifests now discovers the Range-aware streaming endpoint and can pull chunks efficiently.

    apiaudio
  146. Podcast subscribe buttons actually subscribe now

    The 'Apple Podcasts' / 'Overcast' buttons on /podcast were sending users to URLs that 404 — Apple's /podcast/id?url= pattern needs a submitted show ID we don't have, and overcast.fm/add + pca.st/ don't resolve for arbitrary feeds. Rewired to schemes that actually work: podcast:// (opens the user's default podcast app on any OS) and pktc://subscribe/ (Pocket Casts' documented deep-link). Dropped the broken Overcast button — better no button than a broken one.

    podcastfix
  147. iTunes duration + per-episode guid in the podcast feed

    Apple Podcasts was showing '???' for episode length because <itunes:duration> was missing. Added the tag to every episode (duration estimated from MP3 byte size, ~3% accurate). Also per-episode <podcast:guid> as UUIDv5 of the post URL so episode identity stays stable even if we re-encode the audio. Same UUIDv5 helper now emits the channel guid and each episode guid — verified against the spec's reference vector.

    podcast
  148. JSON Feed attachments grew duration_in_seconds

    JSON Feed 1.1 spec supports duration_in_seconds on audio attachments — podcast-aware JSON readers (Readwise Reader, Stringer, etc.) use it for list-view previews. Extracted a shared audio-duration helper and wired it into feed.json. Gated by smoke-feeds: every attachment must have a positive duration_in_seconds.

    audiofeed
  149. Podcast feed GUID is now a spec-compliant UUIDv5

    The <podcast:guid> in /podcast.xml was a random string ('mondello-dev-blog') — Apple Podcasts + Spotify + podcastindex.org use it to de-duplicate submissions, so a plain string broke cross-directory identity. Now derived via Web Crypto SHA-1 from the feed URL per the Podcast 2.0 namespace spec. Verified against the spec's reference vector.

    podcastfix
  150. HSTS + 5-header security gate

    Added Strict-Transport-Security: max-age=31536000; includeSubDomains; preload. Locked in via a new smoke-feeds check that asserts 5 headers (HSTS, x-content-type-options, x-frame-options, referrer-policy, permissions-policy) are present + well-formed on every deploy.

    security
  151. Sitemap completeness: /posts + all /tag archives + feeds

    Sitemap was missing the /posts archive page, all /tag/<slug> and /category/<slug> archives, and the non-Atom feeds (rss.xml, feed.json). Google was having to discover these via crawling instead of the sitemap. Count 31 → 47 URLs.

    seofix
  152. ? opens the keyboard-shortcut help dialog

    GitHub/Linear/Notion convention: press ? anywhere to see all wired shortcuts. Lists Cmd+K, Space (play/pause active audio), ← → (seek ±5s), J/L (±10s/+30s podcast-style), and the dialog controls themselves. Native <dialog> with focus trap + backdrop blur. Guarded against firing in text inputs.

    uikeyboard
  153. One-audio-at-a-time on multi-audio pages

    /podcast has 5 episodes and listing pages have inline preview players. Previously concurrent playback was possible (cacophony), and the sticky Listen pill permanently bound to the first <audio>. Now any play event pauses the others, rebinds the pill + keyboard shortcuts + MediaSession metadata to whichever is currently playing, and per-episode localStorage positions resume correctly.

    audioui
  154. Audio previews on every card — not just a 🎧 badge

    Post cards on homepage, /tag, /category, /search, and 'Continue reading' at post bottom now render full <audio controls> for narrated posts, alongside the 🎧 badge. Readers can preview a narration without navigating to the post.

    audioui
  155. Seek + scrub + resume — via a Range-aware R2 audio proxy

    EmDash's media endpoint doesn't send Accept-Ranges, so audio.seekable was empty and currentTime assignments silently failed — the whole scrub bar was cosmetic. New /audio/<storage_key> route streams from the R2 MEDIA bucket with HTTP 206 Partial Content + Content-Range. Migrated every narration URL through a shared narrationAudioUrl() helper. Resume-where-you-left-off, arrow-key seek, J/L podcast seek, and MediaSession OS scrubber all work now.

    audiofix
  156. Audio shows up in iOS lock screen + Apple Watch + Now Playing

    Wired the MediaSession API so when a listener plays a narration, the OS surfaces title, artist, podcast artwork, and play/pause controls — without the browser open. iOS lock screen, Apple Watch, macOS Now Playing widget, Chrome notification area, Android shade.

    audio
  157. Playback speed pill + space-to-play keyboard

    Site-wide sticky dock in the bottom-right: ▶ 'Listen' button that controls whichever audio is currently playing, and a 1×/1.25×/1.5×/2× speed pill next to it. Preference persists across visits + posts in localStorage. Space bar toggles play/pause from anywhere (outside text fields).

    audioui
  158. JSON Feed now carries audio attachments

    Silent regression: RSS and Atom had audio <enclosure> tags for narrated posts, but /feed.json had zero. Subscribers on JSON-native readers (NetNewsWire, Inoreader) saw posts without any audio affordance. Extracted a shared narration lookup helper, wired it into all four feed generators, and added a smoke-feeds regression gate so the attachments can't disappear again.

    audiofix
  159. 🎧 badges now render on tag + category pages

    The narration indicator was only threaded through on the homepage and /posts listing. Tag + category pages showed the cards but no badges. Extracted the batch narration lookup into a shared helper so all five listing surfaces render badges consistently.

    audioui
  160. Fixed site search returning 401 for anonymous visitors

    EmDash's built-in search endpoint requires content:read scope, so typing in the nav search box silently returned zero results for everyone not logged in. Shipped /api/search.json as a public proxy with scored results; intercepted the client-side fetch so the existing UI keeps working. Real-browser verified via Playwright.

    searchfix
  161. Cmd+K keyboard shortcut for search

    Standard everywhere — now here too. ⌘K (or Ctrl+K on Linux/Windows) focuses the nav search from anywhere on the page; Esc blurs back out. Visible ⌘K badge inside the input disappears while typing.

    uikeyboard
  162. Reading progress + back-to-top on post pages

    Thin accent-colored strip at the top of the viewport fills as you scroll through an article. Circular back-to-top button fades in past 700px scroll. Both driven by a single rAF-throttled handler.

    ui
  163. Podcast listing: duration preview, 500KB lighter page

    Episode rows now show approximate duration (6:45, 3:43, etc.) in the meta before clicking play — computed from MP3 byte size. Pair change: preload='none' on every episode <audio> since we no longer need metadata to render the duration. Saves ~500KB per page load.

    audioperformance
  164. Manual dark/light theme toggle

    Sun/moon button in the nav flips the theme and persists preference for a year. Returning visitors get their theme on first paint — no flash.

    uidark-mode
  165. First successful scheduled backup in weeks

    Cron-driven D1 backups had been silently failing with HTTP 522 because the Worker's scheduled() handler tried to fetch its own public hostname. Refactored to call the shared runBackup module directly; all 19 tables now dump to R2 every hour. 5 regression tests lock in the shape.

    infrafix
  166. Public operational status page

    /status now shows cron health, content counts, and every discovery URL in one place. Admins get a green/yellow/red dot right in the nav via /api/cron-health.json.

    infraui
  167. WCAG AA across light + dark mode

    Ran axe-core against 9 pages in both color schemes. Went from 19 serious violations to 0. Gruvbox palette darkened where needed to clear 4.5:1 contrast on both bg and surface colors.

    a11y
  168. 6-script real-browser verify pipeline

    pnpm run verify now drives Playwright + axe-core + link crawler + feed validator. Every deploy gates on vitest (90 tests). Full pipeline in scripts/smoke*.mjs.

    testing
  169. Blog → Podcast

    Every AI-narrated post is now a podcast episode. /podcast.xml is iTunes-compliant (with Podcast 2.0 transcripts). /podcast landing page lets humans subscribe via Apple / Overcast / Pocket Casts. /rss.xml and /atom.xml also carry audio enclosures.

    audiodistribution
  170. Browser SpeechSynthesis fallback on silent posts

    Every published post now has a playable audio option. If the MiniMax narration isn't ready yet, a 'Read aloud' button uses the browser's Web Speech API instead — clear UX rather than silent omission.

    audioa11y
  171. Agent discovery surfaces

    Shipped /openapi.json (with x-x402-payment extension), exposed the built-in EmDash MCP server, added Organization + OfferCatalog + Person + PodcastSeries + ItemList JSON-LD, and linked it all from /docs/agents. Every paid surface is discoverable programmatically.

    agents
  172. Three-layer LCP optimization

    Hero image now preloads in the <head>, loads eagerly with fetchpriority=high, while below-fold grid cards stay lazy. Hover-prefetch on desktop internal links drops click-to-paint latency to near zero.

    performance
  173. PWA manifest + iOS meta

    Site is now installable as an app on iOS, Android, and Chrome desktop. Home-screen shortcuts jump straight to Posts, Podcast, or Skills.

    mobile

Keyboard shortcuts

+ K
Focus search
Space
Play / pause current audio
Seek back / forward 5 seconds
J L
Seek back 10s / forward 30s (podcast-style)
[ ]
Jump to previous / next paragraph (post pages)
F
Toggle ✨ follow-along karaoke
T
Toggle 📝 transcript panel
C
Copy link at current audio timestamp
?
Toggle this dialog
Esc
Close this dialog

Audio controls work on any page with an <audio> — they follow whichever player is currently playing.