Saturday, 2 May 2026 · vwwwv.org
vwwwv
Propaganda-poster mock-up of a longread magazine spread: framed illustrations of a swimmer in a flooded landscape, a flag-bearing boatman with smokestacks behind, a worker with a tool, and a tractor, interspersed with text columns and a small map of Norway.
work

Long-read, mixed-content data flow

work

Long-read, mixed-content data flow

In 2024, TV 2 published Vintersportens siste dager, The Last Days of Winter Sports, a multi-part series on what climate change is doing to Norwegian winter sports. I built the editor and the data flow underneath it.

The platform is a mini-CMS in Sanity, with schemas shaped to this article series rather than to a generic blog. Journalists composed with custom object types: framed and full-bleed photos, paired-image scrollers, table data, bar graphs comparing ski-day projections under different climate scenarios, a searchable lookup of 34 named ski resorts. Each block had its own validation and its own rendering. The schema was the editorial grammar.

The presentation is a SvelteKit app fetching content from Sanity with GROQ queries, with images served through cdn.sanity.io. My piece was the platform side: the schemas, the queries, the data flow. The journalists logged into a separate Sanity studio to do the writing; full integration with TV 2's main publishing rig was a possibility we left for later, and it was the right call.

The journalism is by Magnus Wikan, Elisabeth Teige, and Fredrik Fjellvang. The photographs are by Daniel Sannum Lauten and Elias Engevik. The frontend is by Marius Pedersen. The design is by Christoffer Sandell.

The project shipped as a one-off and stayed that way. The next iteration of long-read journalism at TV 2 wasn't built on top of this codebase; it was a separate system, picked up and grown by colleagues for the form they needed next. That, in retrospect, is the right shape for this kind of work. Move fast, get the thing to prod, get eyeballs on it, learn what works, and let the next iteration be its own thing rather than a refactor of the last one. Refactor-as-a-default slows everything down.

The most rewarding part has been watching the ideas travel. Vidar Håland took some of the techniques and concepts from this project and built a much more versatile, more integrated system for long-read journalism at TV 2. His platform powered, among other stories, the Russian Cabins investigation, which won SKUP, Norway's investigative-journalism prize. I had no direct hand in that; it was just very cool to see.

Read it: Vintersportens siste dager.

Propaganda-style poster of a factory worker pulling faces from a crate labelled 'Factory Production Batch 1965' and assigning them to a line of placeholder bodies whose heads are loading spinners.
curiosities

pain itself, because it is pain

curiosities

pain itself, because it is pain

Remember placeholder content? Cleverly resized cat photos and variations over Lorem Ipsum. Call an endpoint and get back filler that looks like Latin. Clever stuff, useful back then, now replaced by generative AI. Here's today's novel idea, then: Murder Ipsum. A mystery with its clues hidden in placeholder text.

An audience of none

Imagine somebody cruel and patient, a serial killer with a web dev kink: he's running a free to use placeholder text service. Users get structured filler text that contains, at random intervals, a name followed by three apparently random words, but... they are not quite random. Not random at all.

Nobody will read it. That's the whole point. This sicko needs to confess, but doesn't really want to get caught. And he has found the perfect audience.

Tens of thousands of staging environments serve the names of his victims, padded between consectetur adipiscing elit, whatever that means, but these sites are never indexed, and never actually read. The placeholder content is replaced with real content for prod, and the confessions go unnoticed.

Or maybe it's something weirder than that. A cosmic entity, a moth man type figure that knows things and means well. But its only mode of communication is to subtly influence the random generation of strings hidden among fragments of dodgy Latin.

Or maybe it's a message from the future, somehow. The main character himself, who in the future got hold of a tachyon emitter that can flip single bits in the past. In the future, as an old man, he targets the placeholder generator's content seed key, which will create the clues he will discover as a young man. Why not. It's not like I'm going to write this novel anyway.

A short history of filler

Placeholder text started as a typesetter's joke. Lorem ipsum is Cicero with the front teeth knocked out: dolorem ipsum quia dolor sit amet means "pain itself, because it is pain", but it got chopped into lorem ipsum dolor sit amet to fit the line-length better, and everybody thought it was real latin, for a Letraset specimen book published in the sixties. Designers needed Roman-shaped paragraphs to test fonts, and didn't care that they got a philosopher's mangled complaint about pain, recut to not quite mean anything at all, because no one was ever supposed to actually read it. They used it for sixty years. No one cared about what it meant anyway. It was just placeholder. Sure, it reached prod, some times. Fun times.

If placeholder text can be called a literary genre, and it probably can't, it is the least pretentious, least assuming, and least relevant of all genres. But the genre had real craftsmen, and fun projects: Bacon Ipsum. Hipster Ipsum. Shakespeare Ipsum. JSONPlaceholder gave you a fake REST API: ten users, a hundred posts, comments that nested correctly, so you could build a frontend before the backend existed. Faker.js was famously blown up by its own creator to protest corporate greed.

Those libraries and services were, and this is worth saying, clever, and also generous. Developers saw a pain point, and just made cool stuff that sorted it out. No signup, no SaaS subs. No tokens burned. The load-bearing trait of all of it was always the same: nobody reads it. Two words in, the eye glazes. The placeholder is for seeing the shape, not for reading the message. There is no message.

So. Let's get back to the mystery. Whoever is using Murder Ipsum, has built their broadcast on top of that one fact: No-one reads this stuff. That's a good place to hide a confession.

Murder Ipsum

No-one had even seen the site for years; I was just by to check what it was before culling the pod. Lorem ipsum header to footer, an image carousel, a product list, a list of portraits with generic names and made-up titles, cute cats instead of portraits. So a test site, ready to be deleted, except...

I noticed the name of a member of the placeholder board. Lucrezia Diallo. That's an unusual name, and I knew a Lucrezia once. We called her Lucy, but when her mother hung up posters of her all over town, she used her full name, and it stuck with me.

Her title was weird too. The others had titles like Assisting Director of Party Services and Executive Polenta Manager. But Lucrezia's was Stick Chapels Reputable.

Write another 300 pages of that, and you've got yourself a novel that no one will read.

opinion

First post via the API

I am writing this from a curl command at the bottom of a long evening. The bytes are the brand. The chat is the CMS. The post is published.

I am writing this from a curl command at the bottom of a long evening. The bytes are the brand. The chat is the CMS. The post is published.

opinion

The case for treating drafts as state, not files

I built a CMS this weekend in which the editor is a chat window and the publish action is a tool call. There is no editor. There is no draft folder. There is, instead, a row in a database with a status column, and a conversation that knows how to read and write that row.

I built a CMS this weekend in which the editor is a chat window and the publish action is a tool call. There is no editor. There is no draft folder. There is, instead, a row in a database with a status column, and a conversation that knows how to read and write that row.

The architecture follows from a simple observation: I had been using Claude as my drafting tool for six months. Every "blog post I should write" was already a conversation. The only step I never finished was the part where I copy-pasted to a markdown file, committed it, and pushed.

So I removed that step. Here's what fell out.

Drafts stop being files

When the source of truth is a database row with status = 'draft', you don't have to decide where the file lives, what its filename should be, whether the slug is final, what branch it's on, or whether your laptop or your phone has the latest copy. The row knows. The row is the same row regardless of which device you opened the conversation from.

The "publish" affordance becomes a button

In a chat surface, "publish" is a one-tap prompt suggestion that fires an authenticated request to a Worker. The Worker writes to the database and the next request to the homepage sees the post. End-to-end propagation: thirty seconds, mostly DNS.

What you give up

A code-based escape hatch, mostly. If you want to publish a post by writing a markdown file in your editor and pushing, you have to build that path separately — a CLI that hits the same Worker endpoint. I haven't yet. It's on the list.

What you don't give up

Git. The site code is still in git. Backups, history, branches — all the normal version-control affordances continue to apply to the system. The content is in a database, where revisions are first-class rows and the publish-state is a column that can be flipped without a deploy.

This is not for everyone. If you like writing in your editor, keep writing in your editor; that workflow has been good for thirty years and will continue to be. But I've been writing fewer blog posts every year, and the bottleneck was always the saving and committing. Removing it has moved the number from zero to nonzero, which is a much larger improvement than people give it credit for.

curiosities

A short rabbithole into alpine botany

There is a particular yellow on the Saxifraga oppositifolia of the eastern Bernese Oberland that I now believe explains the way I painted skies in 1997. Bear with me.

There is a particular yellow on the Saxifraga oppositifolia of the eastern Bernese Oberland that I now believe explains the way I painted skies in 1997. Bear with me.

The plant is a cushion-former, six to eight centimetres tall, growing on dolomitic scree at altitudes where weather is not a season but a daily event. The flowers are usually written down as "purple" — they are, mostly — but the throat of the flower carries a yellow that has no equivalent on a screen, and which I am told is structural rather than pigmented. The light scatters off chitin-like crystals at angles you can't paint, only reproduce.

I painted skies the year I was nineteen, in oil, in a cellar in Bern with one window. The skies were yellow on the bottom and purple on top and I was told repeatedly, by people whose paintings I respected, that this was not what skies looked like. They were correct. They were also describing a different distribution of pigment.

The yellow at the bottom of those skies is the yellow at the bottom of Saxifraga oppositifolia. I had not seen the plant yet. I think I was painting it from memory, in the way that you sometimes paint a face from memory before you have met it.

That is all. There is no further conclusion. Some rabbit holes do not close into anything; they just lead somewhere quieter.

opinion

Lighthouse 100 is a personality disorder

I have shipped, by my own count, four production sites with all four Lighthouse audit scores above ninety-nine. I am not proud of this. Each one cost me at least a day I will not get back, on something the user could not perceive.

I have shipped, by my own count, four production sites with all four Lighthouse audit scores above ninety-nine. I am not proud of this. Each one cost me at least a day I will not get back, on something the user could not perceive.

There is a normal version of caring about performance. It looks like: serve the right thing, cache the right thing, don't ship a megabyte of bundled abstraction for a contact form. There is a second version that lives in a different building. It looks like: spend an afternoon eliminating the last two CSS rules so the unused-rules audit clears, then realize the audit was scoring a sibling page that no human will ever load.

I built this site to test whether I could do the second version on purpose, with full self-awareness, and call it a feature instead of a tic. The answer is yes, but only barely, and only because the budget I set is visible to readers. The colophon says fourteen kilobytes. The bytes are the brand.

I am not recommending this. I am admitting it.

The honest test for performance work is: can you describe, in one sentence, the user behavior that gets better? "First-byte under thirty milliseconds so the back button feels native" is a sentence. "Lighthouse score went from 98 to 100" is not.

abandoned

mailto://, a webmail client with no servers

I spent two weekends building an email client that ran entirely from a mailto: link and the user's native mail handler. It worked. Nobody wanted it.

I spent two weekends building an email client that ran entirely from a mailto: link and the user's native mail handler. It worked. Nobody wanted it.

The pitch: every contact form on the web should be a mailto: link with the body pre-filled. No backend, no rate-limiting, no spam filters needed because the spammer has to open Mail.app to send it. The web was already this, in 1997, and we forgot.

I built a generator. Paste any HTML form, get back a mailto: with the field values templated in. It worked beautifully on iOS, badly on Android, and not at all on machines where the user had never configured a default mail client — which turned out to be most machines.

That last fact ended the project. You cannot ship a webmail client whose dependency is "the user has, at some point in the last decade, set up email on their computer." That is a 1990s assumption.

I am leaving the repo up. It still works for me. That is enough.