Changelog
What's changed on this site recently. Full commit log on GitHub.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
? 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.
-
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.
-
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.
-
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.
-
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.
-
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).
-
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.
-
🎧 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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.