Colophon

Read this in the style of Walt Whitman

This is rommy.blog. A personal blog.

Pages are plain HTML, generated by a Node.js build script at deploy time — no framework, no bundler, no runtime dependencies. Content lives in a Cloudflare D1 database: Writing posts, drafts, Reading logs, Sharing links, and Thinking notes. The build script reads D1 on each deploy and writes static HTML to Cloudflare Pages. Generated pages aren't committed to Git — only source, styles, and hand-authored pages live in the repo. Saving from the admin triggers a Pages rebuild; so does pushing to main. Every response carries a Content-Security-Policy and other hardening headers (X-Frame-Options, Referrer-Policy, Permissions-Policy), set once for the whole site rather than per page. One policy for every page means it also has to name everything the admin needs and the public pages never touch — the R2 bucket the browser hands a video to directly, and the temporary blob: URL a chosen file is previewed from. Both were missing for a while, and Thinking video uploads failed the whole time without saying why, so a test now reads the policy and checks those origins are still allowed.

Editing happens through a password-protected admin UI, behind Cloudflare Access. Each of its tabs has an address of its own — /admin/thinking, /admin/writing, /admin/reading, /admin/sharing, /admin/location, /admin/health, and /admin/login, which is also what /admin/ opens — so a tab survives a bookmark, a reload, and the back button. None of those is a real page: Cloudflare rewrites them all to the one admin page, which reads the path to decide which tab to show. Every tab but Login is behind the password, and arriving at one of them signed out sends you back to the front of the site. A Cloudflare Worker handles the API: writing to D1, uploading media to R2, serving photos, audio, and video from rommy.blog/media (same-origin, edge-cached, with byte-range support for streaming and iOS), and cross-posting to Micro.blog (via Micropub), Bluesky, and Mastodon. Writing and Reading cross-posts include the post or book title before the link. Writing, Reading, and Sharing cross-posts to Bluesky include an explicit link card — title, description, and thumbnail fetched at post time. Large og:image URLs are resized and re-encoded to stay under Bluesky's 1 MB embed limit before upload; if the card still fails, the post goes out without a thumbnail. Thinking photo posts syndicate the full text and image(s) natively; audio and video also syndicate as native, playable attachments on Micro.blog and Mastodon, and on Bluesky, where video (MP4 or iPhone MOV) is sent through Bluesky's own video-processing service so it transcodes and plays natively there too. When a native attachment is included, the Thinking permalink is appended to the syndicated text so readers can still reach the canonical post (Bluesky's link-card embed already points there for audio and any video that falls back to a card). Falls back to a link-only post — with an Audio: or Video: prefix — when a file is over a platform's size limit or unavailable. Bluesky truncates at 300 characters, and the admin shows a soft guide while typing. Homepage section headings have hover tooltips that Claude Fable refreshes after content changes; Reading tab intro copy is redrafted the same way when books are added or removed. Deleting a Thinking note removes it from all platforms where it was syndicated. Background work — the Oura sync, each rebuild, every syndication attempt — records its outcome to D1, and the admin has a Health tab listing every job's last run and the recent run log, with a dot on the tab itself, so a background failure can't stay silent. The dot goes by what is true now rather than by what went wrong at some point in the week: red when a job's most recent run failed or a scheduled job has gone quiet past its hour, amber when something failed in the last seven days but has run fine since, green when the week is clean. A stumble a job has already picked itself up from is worth noting and not worth an alarm, so it no longer holds the dot red for the rest of the window. Saving fires the rebuild hook more than once within a few seconds; Cloudflare answers the redundant call by pointing at the build it has already queued, and that is now recorded as skipped rather than failed.

Writing uses Quill. Images go inline from the toolbar — type in the same paragraph to wrap text, or click a photo to reposition or delete it. On save, image-only paragraphs are merged with the following paragraph so floats render correctly. Drafts are stored in D1; published posts keep a version history. Published work stays editable: the Writing tab's Published sub-tab lists every post with a link to the live page and Edit, Unpublish, and Delete beside it, and a post's own page grows small edit, unpublish, and delete links beside the reading time when the browser holds a signed-in admin session — invisible to everyone else, and no substitute for the password, which the Worker still demands before it hands the post over, takes an edit back, or removes anything. Unpublishing and deleting both confirm on the page itself rather than in a pop-up, the way the Thinking archive does. Unpublishing returns the post to Drafts and keeps its version history, so publishing it again continues where it left off; deleting takes the history with it. Neither can call back anything already cross-posted to Micro.blog, Bluesky, or Mastodon.

Photos are stored in R2 and served from rommy.blog/media. Uploads up to 25 MB are compressed to 5 MB or smaller on save (GIFs must already be under 5 MB). rommy.blog and Micro.blog get the compressed file; Bluesky receives a further JPEG when it still exceeds 2 MB. On Writing posts, portrait-oriented photos default to half width; click once to enlarge, again to shrink.

Thinking notes live in D1, with optional photo(s), audio (Apple Voice Memos M4A), or video (MP4 or iPhone MOV, up to 100 MB) — one kind of medium per note. Up to four photos can be attached; two or more render as an equal-size, cropped 2×2 grid at the width of the blog, and clicking any tile opens a full-viewport lightbox with previous/next controls, keyboard arrows, and swipe-to-navigate on mobile — wrapping at both ends, and rendering edge-to-edge on phones, including landscape orientation. Text is optional when media is attached. Video uploads go directly from the browser to R2 via presigned URLs; photos and audio pass through the Worker. A video's grid still is taken in the browser, off the preview player, as soon as the file is chosen — and saving waits for that frame rather than sending whatever happens to be ready, since decoding one takes a moment and posting straight away used to outrun it. Audio and video play on rommy.blog only, served from R2 through the Worker's edge cache. Permalink slugs are generated from US Eastern time. Bare URLs become clickable links; #hashtags link to the matching tag page on Bluesky. There are no preview cards on the site itself, with two exceptions: YouTube links get a native, playable embed beneath the note, and Spotify links (tracks, albums, playlists, episodes, shows, or artists) get a compact native embed the same way, so both play right there on rommy.blog. Otherwise, Bluesky, Micro.blog, and Mastodon unfurl links in their own clients, and Bluesky turns hashtags into clickable tags. Each note's footer shows the date and, when GPS data is available, a neighborhood label linked to Google Maps — see location tracking below.

The Thinking archive at /thinking/ offers List, Grid and Map views, switched with three small icon buttons under the heading; the choice is remembered in localStorage. Six type-filter buttons sit beside them — photo, video, audio, YouTube, Spotify, and text — and by default every post is visible in whichever view you're in; click one filter to solo-select that type (click again to clear). Filters apply to all three views. Links to the archive from elsewhere always land on /thinking/ with no filter active; choosing a filter updates the URL to a shareable path — /thinking/images/, /thinking/videos/, /thinking/audio/, /thinking/youtube/, /thinking/music/ for Spotify, /thinking/text/ — so a filtered view can be bookmarked or linked directly. Grid groups notes by month and shows one square thumbnail per post. Photo tiles use the note's image, resized to 512 px squares at the edge via the Worker's /media/thumb/ route. Native video tiles use a 512×512 JPEG poster captured at upload (or backfilled from the first frame); until one exists the tile is a plain grey square with a play triangle, and once a poster is backfilled the next build picks it up rather than trusting its earlier verdict that there was none. YouTube tiles pull the public hqdefault preview image — the largest size YouTube makes for every video, where the bigger ones exist only if the original was that big and otherwise come back missing; Spotify tiles pull 640×640 album art when available. Notes with no thumbnail to show — audio, and text on its own — appear as a clipped snippet in a card instead. Every tile, photographs included, carries a small badge in its bottom-right corner saying what kind of note it is: a camera, a video camera, a microphone, the YouTube and Spotify marks, or “Tt” for plain text. Grid images load lazily — deferred until grid view is active and the tile nears the viewport, with a small concurrency cap so the page stays snappy on long archives. The third view is a map. Every note that has a location gets pinned, carrying the icon of whatever it is — a photograph, a film, a song — and clicking a pin opens a small card with the words of the post and a link through to it; clicking the map anywhere else closes the card again. But a note only ever stored the name of a neighborhood, never a coordinate — the underlying GPS stays private — so the pins are placed by looking those names back up, and they sit at the middle of a neighborhood rather than anywhere I actually stood. Some of those names are ambiguous: there is a Harris Township in Pennsylvania and another in Michigan, and asked cold the world offers Michigan first. So the build asks for several candidates and keeps the one nearest to where the name was recorded in the first place — a private check that never leaves the build and never reaches the page, and if even the nearest one is implausibly far away, the note simply goes unpinned rather than pinned somewhere wrong. Which is also why thirty-eight notes from home are one pin wearing the number thirty-eight instead of thirty-eight pins: they share a point exactly, and no amount of zooming would ever separate them. The map only loads itself when you ask for it, so nobody pays for it who never opens it.

Reading at /reading/latest/ (with a curated Must Reads list) is a book log linked to Bookshop.org, sorted by the month you read each title (ym), not the day you added it. The archive uses path-based tabs — /reading/latest/ and /reading/must-reads/ — with the URL updating when you switch; each tab opens with a short taste summary, redrafted by Claude Fable when books are added or removed. The archive has the same list/grid toggle as Thinking (also remembered in localStorage): list shows month and title; grid is a storefront-style catalog grouped by month — three columns on desktop, two on mobile — with cover art under each title. When you add a book in the admin, an optional author field helps disambiguate cover search; the picker queries Open Library, Apple Books, and Google Books, and you can swap covers later from the archive list. Chosen covers are stored in D1 and can be replaced with custom images uploaded to R2; several Must Reads titles use custom cover art hosted at rommy.blog/media/reading/covers/. At build time, any entry still without a cover gets an automatic Open Library lookup (ISBN-first from the Bookshop URL, then a guarded title search).

Under Elsewhere on the homepage, Feeds is a collapsible row — a small arrow points right until you click, then down, revealing indented links to Bluesky, Mastodon, and micro.blog.

The Now page follows the nownownow convention: latest Thinking, current Reading, a Working blurb, a Walking line with your latest daily step count from an Oura Ring (synced to D1 on a schedule), shown alongside the distance those steps come to in miles and kilometres — Oura reports no true walking distance, so it is the step count times an average 0.762 m stride, the way a pedometer does it, and a Current Location section — a MapLibre neighborhood map using OpenFreeMap basemaps (Liberty in light mode, Dark Matter in dark mode). The public map shows only a reverse-geocoded neighborhood label and bounding box, never exact GPS; the label links to OpenStreetMap. Theme toggle switches basemap style. The dark basemap arrives almost unreadable — land and water differ by about one percent of brightness, so the coastline simply is not there, and the town names are a dim grey. Rather than brighten the whole picture, which cannot separate two things that are already the same colour, every map on the site repaints the style itself as it loads: the land lifted a little, the water made properly blue, the labels and borders brought up. Buildings and parks are repainted alongside them, or they would show as black holes in the lifted ground. The land is kept close to the colour of the page, so the map sits in the page rather than glowing on top of it. Sharing is a linklog; Claude Fable assigns each link a topic tag the moment it's added, and that tag becomes hashtags on the Micro.blog, Bluesky, and Mastodon cross-post — a link tagged Food & Recipes goes out with #food #recipes. The archive at /sharing/ filters by a dropdown built from those tags, and unfurls every link into a preview card — image, description, and source — fetched once and kept in a build cache in D1 — the same cache that holds book covers, album art, and video posters — so later builds only fetch what's new, covering existing links and any added later. The homepage list stays plain: date and title only. The Changelog is generated from Git commit history. Dates display in US Eastern time.

Everything on the site is searchable from /search/, linked in the footer of every page: Writing posts, Thinking notes, Reading and Must Reads, and Sharing links. There is no search service behind it. The whole corpus is a few hundred short items — well under a hundred kilobytes of text — so the build writes it out as a single index and the matching runs in your own browser; the index is fetched only when you open the search page. Every word you type has to match somewhere, so adding a word narrows the results rather than widening them; titles count for more than body text, a whole phrase for more than scattered words, and ties go to the more recent item so a growing archive doesn't bury new writing. Accents fold, so cortazar finds Cortázar. Each result carries a highlighted excerpt around the first match, / anywhere on the page focuses the box, and the query stays in the address as ?q=, so a search can be bookmarked or sent to someone. The admin is deliberately left out of the index.

Private GPS from the Overland iOS app lands in D1. When you publish a Thinking note, the Worker finds the nearest fix within fifteen minutes — falling back to within six hours if nothing that close exists — and reverse-geocodes a neighborhood label (Brooklyn coordinates use NYC Planning GeoSearch). Raw coordinates never appear on the public site. Thinking footers link the neighborhood label to Google Maps. The admin Location tab shows two maps and no numbers: the latest thousand points drawn as a path with start and end markers, and beneath it a heatmap of everywhere I have ever been logged — one dot per patch of ground about a hundred metres across, shaded by the number of times I was recorded there. Clicking any dot names it: a small card gives the neighborhood and borough, and how many points landed in that square. The name is looked up only when I ask for it, one dot at a time, and kept afterwards, rather than fetching several hundred names nobody wanted to read. The raw coordinates are no longer listed anywhere. Every image the site refers to is checked when it is built: if a picture points at a file that is not there, the build stops rather than publishing a page with a hole in it. That check exists because a post carried a broken image for months without anyone noticing — an address that does not exist returns a page rather than an error, so the browser is handed a document where it expected a photograph and quietly shows nothing. The heatmap is counted fresh out of the database on every visit, so it keeps filling in as the phone keeps reporting; the counting happens in SQL rather than the browser, because the log grows by about fifteen hundred points a day and would eventually be far too large to send. Because home accounts for most of those points, the shading is logarithmic and measured against the ninety-fifth percentile rather than the busiest square — otherwise every place but home would be the faintest shade. The two themes get opposite ramps rather than one: on the light basemap the quiet cells start at a strong blue and the busy ones darken toward black, and on the dark basemap that reverses, dim blue up to near-white, because on each map the other direction is the one that vanishes. Same OpenFreeMap basemaps and theme toggle as the Now page. Permalink pages prefetch video for faster playback; the archive and homepage load metadata only.

When a rommy.blog URL is shared elsewhere, the build emits Open Graph and Twitter meta tags. Writing posts use the summary and first in-post image; Thinking permalinks use a text snippet and the note's first image, with a favicon fallback.

Writing post pages support Webmentions. Incoming mentions are received by webmention.io and displayed via client-side fetch — likes and reposts as avatars, replies as comments. Bridgy bridges Mastodon activity back to webmention.io, so Mastodon replies and boosts appear as webmentions. Thinking has no comments.

The design is minimal: warm off-white, a single typeface (Hanken Grotesk), photos where they belong. Dark mode is what a new visitor gets by default; the footer toggle — sitting beside the Search link — switches to the light theme and remembers the choice between visits. There is no client-side framework beyond the theme toggle, site search, archive view toggles, type filters, the Feeds drawer, archive pagination, photo expand/collapse, and MapLibre maps on the Now page and admin Location tab.

Visitor analytics use Google Analytics 4. The contact form is handled by Formspree.

Source code is MIT licensed. Built and maintained with Cursor and Claude Code.