The security day changed nothing about using the editor. The four days after it changed nothing about what the editor does.
That’s the split I didn’t expect going in. Everything up to the 23rd was correctness: does it round trip, does it ship, can someone else reach it. Everything from the 23rd to the 26th was feel, which is a much harder thing to argue about and the first work on this project that only matters because I’m the one typing.
Wearing Ghost’s clothes
The card menu was a flat list of bare labels. Nineteen of them. It worked, in the sense that the thing you clicked appeared.
It became a searchable grouped menu: an icon, a name and a one-line description for each card, under Primary, Embeds, Structure and Showcase, with a footer telling you what the keys do.
The interesting part is the trap underneath. The keyboard walks a single flat index over filterMenu(). Arrow down moves from 0 to 1 to 2. Group the rows for display and that index means nothing, unless the grouped rendering flattens back to exactly the same list in exactly the same order. So groupMenu() is a pure regrouping with that one invariant, asserted across several queries and then checked by hand in the browser: arrow down advances by one, and index 3 lands in Embeds.
That’s a bug you’d never see in a screenshot and would hit within ten seconds of using the menu.
The toolbar was worse, in a quieter way. It had no visible active state at all. aria-pressed was set correctly and nothing was drawn, so bold text and not-bold text looked identical on the button you’d just pressed. It now takes Ghost’s proportions, 34px targets, 8px radius, a divider before Link, and shows active as a filled pill.
The test for that compares computed background colours rather than class names, which matters more than it sounds. A class can be present while the CSS behind it has been pruned. That’s how you ship a button that says it’s active to a screen reader and looks inert to everyone else.
And the post settings stopped listing raw schema names in registry order. They group as Basics, Publishing, Garden and Details, with human labels, while the fields themselves still come from Zod, so a works post keeps its extra fields without anyone maintaining a second list.
Then the part I actually wanted
Typewriter mode. The caret holds the vertical centre of the screen, the paragraph you’re in stays at full strength, everything else dims.
Three decisions went into it and I got one of them wrong out loud.
The caret. IA Writer’s is a thick bar and I wanted that. CSS can’t set caret width, it follows the font, so a thick caret means hiding the native one and drawing your own, which then has to track blinking, focus loss, selection, IME composition and every card boundary by hand. That’s bigger and more fragile than the rest of the feature put together, and it breaks text input in ways only an IME user would ever find. So: caret-color: var(--color-konpeki). One line, and the browser keeps doing the hard part.
The dim. I asked for the unfocused paragraphs at 90% opacity. Which is a 10% dim, and on this site’s #003535 over white it’s very nearly invisible. I’d said a number that sounded like “slightly faded” and specified something you couldn’t see. It’s 0.35 now, in the range IA Writer actually uses, and it lives as one custom property on [data-editor-root] so retuning it is one value rather than a hunt.
The toggle. On by default, remembered per browser. Typewriter scrolling is genuinely obstructive when you’re editing mid-document or clicking around, so there has to be an escape hatch. The consequence is the bit worth writing down: with it off, the editor has to behave exactly as it did before, dim included. Otherwise it isn’t an escape hatch, it’s just a second mode.
The two lines that would have worked
Here’s the part of this that I keep turning over.
ProseMirror already has the machinery. scrollRectIntoView will centre the caret if you set scrollThreshold and scrollMargin to half the viewport, and the arithmetic works out exactly: a caret at y=100 in an 800px viewport gives moveY = -300, which lands it at 400. Two config lines. No plugin, no scroll handler, nothing of mine to maintain.
It’s the most attractive wrong answer I’ve hit on this project, and it died from reading the source rather than trying it.
view.scrollToSelection() only runs when state.scrollToSelection has been bumped. Typing bumps it, because tr.scrollIntoView() is part of how input transactions are built. A plain click doesn’t. So the caret would centre beautifully while you type and then sit wherever you clicked the moment you moved the cursor with the mouse. Half a typewriter mode, and specifically the half that feels broken.
The second reason is smaller and I like it more. The top branch of that function settles the caret’s top edge on the line, the bottom branch settles its bottom. So changing direction moves the page by one line height, for no reason a writer could ever perceive except that it twitches.
So it’s a plugin on selection change calling a small pure function, and the same reading proved ProseMirror won’t fight it: with the defaults, it doesn’t touch a caret that’s already on screen.
Lighting the right block
The dimming has one decision in it that took a wrong test to find.
The lit unit is the top-level block, not the paragraph. So a caret inside a Callout lights the whole card, frame and label included. Dimming a card’s own chrome while you type into it reads as broken rather than focused, which you only learn by looking at it.
And the decoration reads selection.from, never head. A NodeSelection puts head after the node, so using head would light the block below the one you selected.
The first version of that test passed anyway. The card was the last thing in the document, so the wrong position fell off the end and landed back on the card by accident. Correct result, wrong reason, and it would have kept passing right up until someone put a paragraph after a card.
That’s the third time on this project a test has been green for a reason that had nothing to do with the thing it was testing. I’ve stopped treating a passing test as evidence and started treating it as a claim I have to check.
Where it landed
Four days. A menu you can search, a toolbar that shows you what’s on, settings that read like settings, a caret that holds the middle of the screen and a page that dims around the sentence you’re writing.
The editor does exactly what it did on the 20th.
It’s the first stretch of this whole thing I couldn’t justify with a test result, and it’s the reason I now write in it by choice rather than to prove it works.
The last part is the 27th, which was meant to be two small features and turned into an archaeology dig.