Changelog

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

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. ? 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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
  15. 🎧 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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
  23. 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
  24. 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
  25. 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
  26. 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
  27. 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
  28. 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
  29. 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)
?
Toggle this dialog
Esc
Close this dialog

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