Skip to content

A rich text editor for the markup language - #31

Merged
amyjko merged 25 commits into
mainfrom
feature/rich-text-editor
Aug 22, 2026
Merged

A rich text editor for the markup language#31
amyjko merged 25 commits into
mainfrom
feature/rich-text-editor

Conversation

@amyjko

@amyjko amyjko commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Adds a rich text editor for the markup language, with the raw markup still a keystroke away.

What it does

Descriptions, comments and process steps are edited as formatted text rather than as source: a toolbar, a real caret, keyboard shortcuts for every command, and Command+K to insert a reference to a role or process. The markup source view is still there behind one button, and everything stored is still markup — no migration, no new column, no change to what a description is.

How it is built

The browser owns the DOM while somebody is typing. Typing is never intercepted and never triggers a re-render; the DOM is read back into a document afterwards and the source derived from that. This is what keeps the caret, a screen reader's cursor, a braille cursor and an input method's own buffer where they were. Only deliberate commands — bold, Enter, a block type change — go the other way and rebuild the block they changed.

Deliberately not a strictly controlled editor that cancels every input and re-renders. Composition cannot be cancelled at all, so that arrangement is a fiction during exactly the input it claims to control, and every avoidable rebuild near the caret costs a screen reader user their place.

Commands are transforms on a document, not surgery on live nodes. Where the caret ends up after Enter on an empty list item, what Backspace does at the top of a quote — the matrix that eats most of an editor's bug budget is 48 table-driven cases here rather than a browser suite.

Only the blocks that changed are rewritten. Someone who fixed a typo did not ask for the other forty lines to be normalized, and a diff that says they did is one nobody can review.

No third party editor. ProseMirror is the better engine and this grammar would use perhaps a fifth of it, while fighting its schema to express "lists cannot nest" and "marks cannot combine" — and the serializer, the toolbar, the ARIA and the reference node all still have to be written either way. The DOM layer sits behind one small interface, so swapping the engine later means writing one implementation of eight methods.

Grammar changes

The editor parses on open and serializes on save, so the grammar needed a serializer, which it had never had. It also gains a \ escape, which finally makes a literal asterisk typeable. A backslash before anything else stays a literal backslash, so C:\shared and match \d+ digits keep their meaning and nothing stored needs migrating.

<name@role> becomes a first class node rather than a link resolved at render time. It serializes byte for byte as before.

Three ways the parser lost data are fixed, each with a test: an unclosed quote lost its last character, a URL containing a second @ lost everything after it, and headings kept the space after the hashes in their text.

Measured against production

npm run audit:markup ran read-only against production over 3332 values and 5183 blocks, 42% of which hold something a round trip could touch:

Words dropped 0
Never settles 0
Trees reshaped 0
Blocks rewritten 398 of 5183 (7.7%), all cosmetic

The 7.7% is the chance that the one block somebody edits gets tidied — a closing * added to an unterminated run, * bullets becoming -. Nothing renders differently, because every tree is preserved exactly. Blocks nobody touches keep their bytes.

That run found a real bug: whitespace just inside a formatting run was being moved outside it, and at the end of a line that put it where parsing trims it away. _Overview _ became _Overview_, and a space somebody typed was gone. Real documents are full of that shape and none of the 2000 generated test trees had it, because the generator avoided exactly the shape real writing is full of.

What is tested

  • 288 unit tests. A generated property test over 2000 trees holds serializing and reparsing to being lossless and idempotent. Rendering and reading back are held to being inverses over 1000 more.
  • 74 browser tests driving a real contenteditable: typing, Enter and Backspace at every boundary, selection, undo, paste, clipboard, references, the toolbar's roles and roving tab order, and composition through the devtools protocol.
  • axe over the editor in both views.
  • The help each button gives about its own keystroke is held against the shortcuts that actually work, in both directions.

What is not tested

No screen reader has been near this. Everything in this branch about NVDA, JAWS, VoiceOver, focus mode and braille cursors is reasoning from specification rather than observation, and this branch's own history is a decent argument for the difference: composed text that could not be undone, an empty comment box that saved nothing, references quietly degrading on paste, Enter ignoring the selection — every one of those came from running something rather than thinking about it.

Rich text is the default for everyone as this stands. Manual passes across VoiceOver/Safari, NVDA/Firefox, NVDA/Chrome, JAWS/Chrome, iOS VoiceOver and Android TalkBack should happen before this ships, or the default should start as markup source and move once they have.

The three things most likely to be wrong, in order: Android TalkBack with GBoard, where every word is composed and the devtools protocol tests only reach desktop Chromium; whether a block command's re-render costs somebody their place in a virtual buffer; and whether arrow travel across a reference reads as the single object it is meant to be.

Also here

restore/'s recovery guide told people to use a direct database connection, which resolves to IPv6 only and fails at name resolution on a network without it — read while data is missing, by someone who then has a resolver error to interpret first. It now says to use the session pooler, why, and that its username is not what it looks like. The connection code says the same at the moment it happens.

amyjko and others added 25 commits August 22, 2026 11:05
Groundwork for a WYSIWYG editor. An editor parses on open and serializes on
save, so every stored document eventually makes the round trip -- but there was
no serializer at all, and no way to write a literal asterisk.

Add one, plus a backslash escape. A backslash before anything else stays a
literal backslash, so `C:\shared` and `match \d+ digits` keep their meaning and
no migration is needed.

A generated property test over 2000 trees covers the whole grammar with
adversarial content and asserts that serializing and reparsing is lossless and
idempotent. It caught a bare email address swallowing the word after it.

Fix three ways the parser lost data, each now tested. An unclosed quote lost its
last character to slice(1, -1). A URL containing a second @ lost everything
after it to split('@'). Headings kept the space after the hashes in their text.

Add Reference as its own segment, for `<text@role>` targets that aren't URLs.
It serializes byte for byte as before, so nothing stored changes. Resolving it
against the organization moves to ReferenceView, which lets LinkView drop its
getOrg() dependency -- the reason MarkupView only worked inside an org route. An
unresolvable reference now says so instead of rendering a dead link.

Record each block's source span, so the editor can eventually rewrite only the
blocks someone edited and leave the rest of the source byte for byte.

Along the way: the kind emoji was in the accessible name of every link, so
VoiceOver read role links as "gear Registrar, link"; and the combobox pointed
aria-controls at itself rather than at its list of options.

Add `npm run audit:markup` to measure what reserializing would do to stored
markup before any of this is turned on. It is read-only and skipped unless
pointed at a database. It checks its own detector against known cases first: a
corpus of plain prose reports that nothing would change whether the comparison
works or not, so it also reports how much was even in scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first third of the editor, and the part that can be proven without a
browser.

Rendering is imperative rather than the Svelte components that render markup for
reading. Svelte's reconciler cannot own DOM the browser is also mutating: the
moment an each block reruns over the paragraph someone is typing in, their
cursor is gone.

Reading goes back through the markup serializer rather than writing source
itself, so there is exactly one implementation of escaping. It copes with what
browsers produce as well as what the renderer produces -- <b> for <strong>, a
text node split in three by a keystroke, a stray <span style> from a paste --
and drops anything the grammar cannot express instead of smuggling it through to
be lost at save time.

The caret is held as a character offset into a line, not as a node and offset. A
saved node is stale as soon as normalizing the block replaces it; an offset
describes the text rather than the nodes the text happens to live in. A pill
counts as one character, which is what makes a reference take one step of arrow
travel and one press of backspace without any of that being written by hand.

Rendering and reading are held to being inverses over 1000 generated trees,
rather than by comparing HTML -- what has to match is the markup that comes back
out, not the tags. Two bugs came out of that: adjacent bold runs merged into one,
rewriting `*a**b*` as `*ab*`, and a formatted run split by typing came back as
several segments instead of one.

Add happy-dom, scoped to these two files. They are near pure tree walks, and
they are where corruption bugs live. Selection and composition are deliberately
not tested here -- happy-dom stubs enough of them to prove things that aren't
true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bold, Enter, Backspace and block type changes are pure functions from a document
and a caret to a document and a caret. The editor re-renders the block it
changed rather than performing surgery on live nodes.

Range surgery would avoid the re-render, but a command is something someone did
deliberately, not something that happens per keystroke, and typing itself never
comes through here. What this buys is that the fiddliest part of an editor --
where the caret ends up after Enter on an empty list item, what Backspace does
at the top of a list -- can be tested without a browser, a selection, or a mock.
The cases that eat 40% of an editor's bug budget are 18 tests here instead.

Offsets count what a reader counts: a character is one and a whole pill is one,
so a reference takes a single step of travel and a single backspace. Slicing
keeps a pill only when the range covers all of it, because half a reference is
not something the grammar can express.

Undo is owned outright. Canceling a beforeinput takes that operation out of the
browser's stack, so a document edited partly by the browser and partly by us
would undo incoherently -- three characters, then a jump past a bold toggle to a
state from a minute ago. Entries are whole documents: these are comments and role
descriptions, so snapshotting is affordable and correct, where an inverse
operation stack is where weeks go. Typing coalesces into one step per burst;
commands are always their own step and close the burst before them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires the pieces together into something someone can type in: a toolbar, the
shortcuts, paste, a live region, and a toggle back to markup source.

The arrangement is that the browser owns the DOM while someone is typing, the
DOM is read back afterwards, and the source is derived from that. Typing is
never intercepted and never causes a re-render, which is what keeps the caret,
a screen reader's cursor, a braille cursor and an input method's own buffer
where they were. Only deliberate commands rebuild the block they changed.

Notably not done: cancelling every input and re-rendering, the way a strictly
controlled editor would. Composition cannot be cancelled at all, so that
arrangement is a fiction during exactly the input it claims to control, and
every avoidable rebuild near the caret costs a screen reader user their place.

The editable region carries no role on purpose. A textbox role is a leaf, so
screen readers stop exposing the headings, lists and quotes inside it to
structural navigation, which is most of what the grammar is for.

Only the blocks that changed are rewritten. Someone who fixed a typo did not ask
for the other forty lines to be normalized, and a diff that says they did is one
nobody can review.

Ctrl+Alt is AltGr on many European layouts, where it is how an @ is typed, so
heading shortcuts stand aside when that modifier is set.

A toolbar is one tab stop, not one per button: on a process page with a dozen
editable steps that is twelve tab presses instead of a hundred and twenty.

Two call sites wrapped the editor in a Labeled, which only names labelable
elements -- so a rich text editor had no accessible name at all. Labeled can now
name something by id instead.

Driving a real browser found three things unit tests could not. Splicing dropped
the blank lines between blocks, closing up deliberate spacing elsewhere in a
document. Without pre-wrap the browser ate a leading space, so Enter mid-sentence
lost it. And browsers substitute non-breaking spaces wherever an ordinary one
would collapse, which nobody typed and the grammar cannot tell apart.

Those tests mount the editor on a bare element against the dev server, so they
run without a signed in account. The Playwright config now runs the application
against a production build and the editor against dev, and npm test includes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The help text under the editor named Alt+F10 for the toolbar and Ctrl+Shift+M
for markup source. Neither existed.

Ctrl+Shift+M is worth having, so it now works. The host does not own the mode,
so it calls back to the component that does.

Alt+F10 is dropped. It is the ARIA practices convention, and TinyMCE and
CKEditor both use it, but the reason it exists does not apply here: those
editors capture Tab for indentation, which strands the toolbar outside ordinary
navigation. This grammar has no nesting, so Tab keeps its meaning, and the
toolbar sits ahead of the editor with a single tab stop. Shift+tab already
reaches it in one keystroke, so Alt+F10 would only be a second and far less
discoverable route to the same place. The help text says shift+tab instead.

Escape is dropped too, before it was written. The plan had it leave editing only
when nothing had changed, which is unpredictable in exactly the situation that
matters -- it would look broken whenever there was unsaved work. There is no
discard in MarkupView for it to mean, and Escape already closes a Dialog, so
binding it here would make one key mean two things depending on state nobody can
see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A selection spanning blocks is the browser's to put on the clipboard, so what
comes back on paste is the editor's own HTML rather than markup. The paste
walker knew about <strong> and <a> but not about the editor's own pills, so
copying a paragraph turned every reference in it into plain words. Bold survived;
references did not.

Found by driving a real browser rather than by reasoning about it. The rest of
what was assumed to be broken turns out to work: cut across paragraphs merges
the remaining halves correctly, and copy and paste across paragraphs round trips
through the HTML path intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copying a phrase wrote markup to the clipboard while copying across paragraphs
left the browser to write rendered text, so what an external paste got depended
on how much had been selected. Pasting back into Adminima was unaffected either
way, since the HTML the browser writes carries formatting and references
through.

This markup is not meant to travel, so the rendered text is the more useful
thing to hand to Slack or an email, and now both cases hand over the same. The
special case is gone rather than extended: writing markup across blocks would
have meant cancelling the cut and reimplementing deletion, replacing browser
behavior that turns out to be correct.

Removing it exposed a test that had been passing for the wrong reason. Selecting
"the reference" used raw text offsets, which picked out the first letter of its
label; the old code mapped that to the whole pill on its way to the clipboard,
so the test passed without ever selecting what it claimed to. It now selects the
reference and the words on either side, and the reference survives because the
paste walker understands pills, which is where that belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Enter split at the caret and ignored what was selected, so pressing it with text
highlighted left that text sitting beside the break instead of replacing it.
Across blocks it was worse: selecting the end of one paragraph and the start of
the next and pressing Enter kept both in full and added a third.

Backspace was already right, because a selection is left to the browser and the
browser does it correctly. Only Enter was intercepted unconditionally.

Fixing it needed a deletion that spans blocks, which nothing had needed before.
What is left of the two ends becomes one line belonging to the block the
selection started in, and anything below the selection in the last block stays
the kind of block it was.

The rest of this is coverage. Enter and Backspace at every boundary the grammar
has -- both ends of a heading, of a quoted line, of a list item, of a paragraph
against each of those -- is 48 cases as a table, because the commands are
transforms on a document rather than surgery on nodes. That was most of the
reason for writing them that way.

Two of those cases were written wrong before they were right, both because the
parser trims a line before deciding what it is: "- " with nothing after it is not
an empty list item, it is a paragraph containing a hyphen. Shapes the parser
cannot produce from source are now built directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toolbar only reported bold and italic for a selection, so putting the cursor
inside a bold word showed bold as off. Sighted people can see the word is bold
and ignore the toolbar; someone reading it through a screen reader is told the
opposite of what is true. It now reads the character before the caret, or the one
after it at the start of a line, which is the usual convention.

Block kind and selections were already tracked correctly.

These are checked by mounting the real component and reading the attributes it
renders, rather than by checking the state that feeds it and trusting the rest
to follow -- which is how the last few assumptions here turned out to be wrong.
That confirms what had only been reasoning: aria-pressed carries the state,
undo and redo carry none because they are not toggles, the ten buttons share a
single tab stop, and the arrow keys move along the row and wrap.

A test found a test bug first: window.status is a property Window already has,
so an object stored there never survived to be read back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Command+K opens a dialog offering the organization's roles and processes, or a
web address, since `<name@target>` says both the same way. Selected words become
the label; choosing something with nothing selected takes its name. A reference
the caret is already on opens the dialog on itself and is replaced in place, and
can be removed while keeping its words -- which was otherwise a thing with no way
back out of it.

A dialog rather than an inline trigger on an @ key. It moves focus somewhere
real, Escape closes it, and it behaves the same with a screen reader as without,
where an inline combobox would mean putting a combobox role on the editable
region and taking away the structural navigation that is most of what the
grammar is for.

Opening it takes the selection away with the focus, so where the result goes is
remembered when the shortcut is pressed rather than looked up afterwards.

The rule for matching a reference to a role or process was written twice, in the
view that resolves one and now in the picker that offers one. They have to agree
or the picker offers references that render as unknown, so it is written once
and tested, including that what the picker offers is what the view resolves.

Two harness faults surfaced on the way, both mine rather than the editor's.
Placing a caret inside a reference does not focus the editor the way clicking
does, so the keyboard went to the document instead. And the page the tests
borrow has an error region above where they mount. Clicking a reference does
focus the editor, which is now checked rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing bold with nothing selected did nothing but explain itself, while the
toolbar showed bold as on because the caret was sitting in a bold word. Now it
means what it means everywhere else: the next thing typed comes out bold. It
works in both directions, so turning bold off at the end of a bold run carries
on plainly.

Only the first keystroke after choosing is intercepted. It lands inside what it
makes, so the browser can have the rest of the word back and the caret, the
screen reader's cursor and the input method are left alone for all of it. That
keystroke joins the same undo step as the word it begins rather than being a step
of its own.

The choice is remembered against the position it was made at, so moving the
caret abandons it rather than surprising someone later, and composition drops it
outright -- composition cannot be intercepted, and plain text under a toolbar
insisting it is bold would be worse than admitting it.

Writing the tests turned up a worse bug than the one they were for: an empty
document rendered no paragraph at all. The browser would put what was typed
straight into the editable element, where reading back does not look, so a new
comment would take text and save none of it. Empty documents now have a paragraph
to type into, and keep one when the last of their text is deleted.

The state assertions now poll. A selection change reaches the host through an
event, so reading straight after moving the caret raced it -- and every state
assertion written so far had that race, passing only because it usually won.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Composition is the part of an editor that cannot be reasoned about safely: the
events cannot be cancelled, and anything that touches the DOM while one is
running desynchronizes the keyboard's own buffer. Driving it through the
devtools protocol is the only automated coverage there is -- composing mid
paragraph, beside a reference, inside formatted text, into an empty document,
and with formatting chosen for what comes next.

It found that composed text could not be undone at all. Composition is never
intercepted and the input events it raises are all skipped, so nothing ever
recorded the state before it. That is most of what typing is for anyone using an
input method, and everything typed on Android.

Chromium only, so Safari and Android are still hand testing.

Two more bugs came out of running axe and mounting the real component:

aria-multiline is not a global attribute, and the editable region has no role to
take it -- deliberately, since a textbox role is a leaf and would hide the
headings and lists inside it from structural navigation.

Switching to the source view threw every time. Svelte clears a bound element to
null rather than undefined, so the check guarding it passed null through and an
editor was built around nothing.

The picker also read the organization straight out of context, which throws
anywhere there is not one. It now offers a web address alone rather than
refusing to open.

The help text is checked against what the editor does, both ways: every shortcut
it names is driven, and every key it mentions must be one of them. It caught its
first drift immediately -- the shortcut for the picker had been described as
added and never was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It reported how many stored values would change if reserialized whole, which
overstates the risk twice over. Saving rewrites only the blocks somebody edited,
so what matters is how likely the one block being edited is to be rewritten --
and a document whose blank line spacing would change is not a document that
loses anything.

So it now separates the two: meaning lost, which has to be zero, from bytes
changed, which is untidy rather than dangerous. And it counts blocks as well as
values, which is the number that says what one edit costs.

That number was silently zero. The tree and the spans came from two separate
parses, so the map was keyed by blocks that were equal to the ones being looked
up and not identical, and every lookup missed. It would have reported nothing to
worry about against production for the same reason a broken smoke alarm reports
no fire, so the counter now proves itself against known cases first, the way the
other detectors here already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test runner swallows console output when nothing fails, which is exactly
when this runs. Written straight to stdout instead, so the numbers arrive
without having to go looking in the file for them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A direct connection host resolves to IPv6 only unless the project has the IPv4
add-on, so on a network without IPv6 it fails at name resolution before anything
is attempted. The recovery guide gave that form in its example, which is the
worst place for it: it is read while data is missing, by someone who then has a
resolver error to interpret before they can start.

It now gives the session pooler, says why the direct connection is not offered,
and mentions that the pooler's username is postgres.<project-ref> rather than
postgres -- which fails as a password error and sends you looking in the wrong
place entirely.

The connection code says the same thing at the moment it happens, rather than
passing the resolver's error along on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Supabase signs its own certificates -- the pooler presents one from Supabase
Intermediate 2021 CA, which no public trust store carries -- so every connection
outside the local stack needs one passed in. The connection code has said so all
along, and told people to use --ca. The audit had no way to accept it, so the
advice was unanswerable.

AUDIT_DB_CA now takes the path, and expands a leading ~, which is the one thing
the connection code does not do for itself.

Verification is not made optional to get around this, and the recovery guide now
says why: a connection that cannot prove what it is talking to is not one to send
a production password down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The session pooler's username is postgres.<project-ref>, which sits at the front
of the connection string right where a database name sits at the end. Putting it
in the wrong half fails as "database postgres.<ref> does not exist", which reads
as a missing database and sends the search somewhere there is nothing to find.

The connection now says what it actually is, and prints the string put back
together the right way round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Production found it. Whitespace just inside a formatting run was being moved
outside it, and at the end of a line that put it somewhere parsing trims:
`_Overview _` became `_Overview_ ` became `_Overview_`, and a space was gone.
Real documents are full of that shape -- four of the first five findings were
this and nothing else.

Whitespace now stays where it was written. The one exception is a bold run
opening a line with a space, which reads as a bullet, since `* ` is one. That
space moves out, where the line trim takes it -- but a leading space at the start
of a line was never going to survive the trim either way.

A stray asterisk in the middle of a sentence now comes back exactly as it went
in, apart from the closing marker the parser was already supplying for itself.

The audit was asking the wrong question, which is why this looked worse than it
was. It called any change of tree shape "meaning lost", so the normalization it
was built to permit came back as four false alarms with the real one buried
among them. It now separates words going missing, which must be zero, from a
document that never stops changing, which must also be zero, from a tidier tree
saying the same thing, which is neither. The shapes production turned up are
kept as cases, so the distinction cannot quietly rot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three thousand values and five thousand blocks, 42% of them holding something
the round trip could touch: no words dropped, nothing that fails to settle, and
every tree preserved exactly. What changes is 7.7% of blocks getting tidied when
somebody edits them, and none of it shows up on the page.

Written down so a later run has something to be compared against. Moving any of
the first three off zero is a regression whatever else changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Switching to the markup view threw and took the content with it. Svelte clears a
bound element to null rather than undefined, and the check on the text area only
looked for undefined. That is the same fault fixed on the element beside it two
weeks ago; fixing one and not its sibling is why both are now typed as possibly
null, so the compiler insists.

Bolding a selection collapsed the caret to the start of it, putting it back
before the words just formatted. The words now stay selected and face the way
they were chosen, so a second command acts on the same text.

Quotes did not look like quotes while being edited. Their appearance lived in
QuoteView's scoped style, which cannot reach DOM the editor builds itself. It
moves to the global styles both read from, rather than being written twice.

The shortcuts move out of the footer and onto the buttons, where the tooltip
writes them for the keyboard in front of the reader -- ⌘⇧8 on an Apple keyboard,
Ctrl+Shift+8 elsewhere. They go in aria-keyshortcuts as well, which is where a
screen reader is meant to find them, rather than in the accessible name, where
they would be read out every time a button was passed.

There is a button for the picker now, which had a shortcut and no way to be
clicked. Undo and redo are ↺ and ↻. Headings are h1 and h2 rather than shouting.

The drift guard moves with the shortcuts: it reads them off the buttons, checks
both forms are present and that neither leaks into the name, and holds the keys
the toolbar advertises against the ones the tests drive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The link button was taller than the others because its glyph came from an emoji
font, whose em box is taller than the page's own. It is an @ now, which is both
the same height and the character the grammar actually uses to write a
reference.

The footer is gone from the rich text view. It existed to name the shortcuts,
and the buttons name their own, so all it did was get read out ahead of every
visit to the field. The source view keeps its syntax note, which nothing else
says. Rich text no longer claims to be described by anything.

Making room for the toolbar shoves everything under it down the page. It slides
in now, so where the space came from is visible rather than the text appearing
to jump by itself -- unless the reader has asked for less of that, in which case
it still just appears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A two-way transition held the editor on the page while its exit played, and
saving puts the rendered version up at that same moment -- so for a sixth of a
second there were two copies of the same text, one above the other.

Only the way in was ever wanted: seeing where the space came from as everything
below moves down. Going the other way, the result should be there at once.

Eased on the way in as well, which is the rest of what made it feel rough.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Symmetry, with the overlap that broke it last time handled properly rather than
by taking one half away.

The two-way transition holds the editor on the page while it plays, and saving
puts the rendered version up at that same moment, so the same paragraph was
briefly on screen twice. The rendered version now takes no height until the
editor has finished going -- not animated itself, just waiting, which is all it
needed to do.

Both sides read the length of the wait from one place, since the whole thing
only works while they agree on it, and both stay still for anyone who has asked
for less movement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two states were both children of the row that holds the content and the
edit button, so while one was leaving and the other had arrived, the row shared
its width between them and the editor's text rewrapped at half size as it went.

Taking up no height was not enough, because height is not what a row divides.
They live in one column now, which is a single child of that row however many
things are inside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amyjko
amyjko merged commit 8c9be77 into main Aug 22, 2026
1 check passed
@amyjko
amyjko deleted the feature/rich-text-editor branch August 22, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant