Everything since 0.3.0 on 2026-09-09: a cover page as the file draws it — page-placed
frames, themed shape colours, floating tables, drawing groups — the run-through wrap
modes as pickable frames, a text box's vertical anchor, the fonts a saved document
carries with it, the header band's height and the table inside it, and the selection
surviving an attribute change.
Both formats carry a vertical anchor on the box body — ODF
draw:textarea-vertical-align, DOCX wps:bodyPr/@anchor — and both
sides of the I/O hard-coded "top", so an imported box dropped its
centering and a saved one never got it. New textVAlign attr, read
and written in both formats, rendered by flexing the rotor (the
handles are absolute and stay out of that flow), set in the Shape
Format tab beside the vertical-text button.
The ribbon mounts only the open tab, so the link, bookmark, cross-
reference and formula dialogs in InsertTab.svelte heard neither Ctrl+K,
nor the context menu, nor a double-click on a formula while any other
tab was up. They move to Ribbon.svelte; the Insert tab's buttons fire
the same events, and a formula event without a position opens the empty
dialog the button used to open directly.
Same leaf-node defect as the frames: setNodeMarkup replaces the atom and
the node selection maps off it, so the dialog's Apply dropped the
selection ring from the formula it had just changed. The frames' helper
is now shared as atomAttrTr and carries the leaf's own marks.
setNodeMarkup replaces a leaf node outright, so the node selection maps
off it: setting the wrap mode, resizing or rotating closed the frame's
floating toolbar and the ribbon's contextual tab, which made picking a
second mode take an extra click on the picture. frameAttrTr puts the
selection back where it stood.
Both frame toolbars and the ribbon's Arrange group now offer the two
run-through entries; they are one wrap mode, told apart by inFront.
Picking any mode by hand drops the offsets and the coordinate system
they were measured in (droppedFrameAttrs), so a page-placed frame set
to a side wrap no longer exports page-relative coordinates for a
paragraph-relative offset.
A frame out of the flow has no text position to re-anchor to, so the
drag adds the pointer delta to its own offsets (startFreeMove).
Serving the app then needs no Node toolchain: download the archive from the
release, unzip, point a web server at the folder it unpacks as. The notes come
from the changelog section for that version. The tag is held against
package.json first, whose version the build stamps into the app.
Both importers already read a package's embedded faces and register them, but
nothing wrote them back: a document set in a font nobody has installed lost its
look on the next machine. ODF gets plain TTFs under Fonts/ with a manifest entry
each, merged into the font-face declaration the name already has; Word gets a
per-part GUID, an obfuscated .odttf and its w:embed... in the family's w:font.
The picker lists the families too, and a slow store read no longer replaces the
faces of a file opened while it was still running.
The editor has no group node, so wpg:wgp is mapped out of its child
coordinate space: the first child keeps the group's own placement — which is
what reserves an inline group's box in the line — and the rest run through
the text over it at their place in the group. A label over a picture needs
the box in front of the text, so inFront now rides a text box through both
formats as well.
Word's positionH never counts from the anchor character, and the page a
frame lands on changes as pagination inserts spacers above it. placeFromPage
measures the frame's static position and turns both offsets into the margins
that reach the page corner — x from the text column, y from the page top —
and every pagination pass re-places them. A run-through text box can now sit
in front of the text (inFront) instead of always behind it.
A new rule in CLAUDE.md: weigh which legs the change touches, run each once
when its input is final, no up-front baseline round. Reclaimed the four lines
it costs by tightening the commit-message and documenting-a-change rules.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
Word's w:tblpPr takes a table out of the flow; the editor kept it in, so
whatever followed — and every frame anchored to it — sat as far down as the
table is tall. It now arrives as the box whose content a table may be, which
is the shape LibreOffice keeps such a table in, and both exporters write it
back as the file's own floating one. A drawing in one of its cells is
ordinary table text, so the box-in-box guard lets it stand.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
One absolute number for three engines could not hold: they are three times apart on
this document (laptop median 19 ms in Gecko, 40 in Blink, 55 in WebKit) and a CI runner
is about twice a laptop, so WebKit failed at 116 ms against 100. Each engine now holds
double what CI measures, on the 90th percentile — the first stroke of a burst warms
caches and a shared runner stalls once in a while, which no typist feels.
Claude-Session: https://claude.ai/code/session_01Gpw1ctsR8FrdNMrpqePtik
A second element per zone doubled the header/footer layer — 150 absolutely placed
boxes in the 75-page window, laid out again on every pass. The zone's ::after grows
out to the page edges instead, which keeps the hover hint and the double-click on
the element that already handles them.
Claude-Session: https://claude.ai/code/session_01Gpw1ctsR8FrdNMrpqePtik
The 78px cap cannot wrap a word without a space in it, so a German compound
painted past the button and across its neighbour. A hugging label drops the
cap and sets its own width.
A double-click only reached the zone box (~28px of a 76px band), so the part
nearest the page edge did nothing; a hit rect now covers the band edge to edge.
The trailing-break discount no longer applies to an empty zone, where that break
is the caret line itself — the placeholder sat a line below the typed text and
jumped up on the first keystroke.
The base Enter chain's liftEmptyBlock lifted an empty block out of the
section (and split the fragment mid-run), so the caret left column 1 for
the page below instead of moving on to column 2. Inside a section Enter
now only splits the block; a list keeps its own escape.
Claude-Session: https://claude.ai/code/session_01Gpw1ctsR8FrdNMrpqePtik
Word's w:tblpPr takes a table out of the flow — a cover page's layout table is
one — and the editor's block table cannot, so what follows it sits as far down
as the table is tall, a frame anchored there with it. Nothing in the document
said so; the import now warns, as it does for the other shapes it cannot hold.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
A line shape carries no height, and both exporters stood in the 96px default a
text box gets — so a horizontal divider was written across the frame's
diagonal and came back as one. A line kind keeps the 0 it has.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
The zone importer read the band's height off a table's rows but not off the
paragraph itself, so the value our own export writes for it came back as
nothing: saving a document with a tall header and opening it again put the
body 8mm up and the rule with it. The first line's w:spacing (direct, else its
style's) now joins the rows' extra, as its alignment and tab stops already do.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
No style name reaches a cell, so the editor draws the level's own 12pt/6pt
margins there — but a Word heading whose style chain sets no spacing has none,
and the chain is baked into the cell's blocks, so its silence is a real 0. It
is written as one now; a cell paragraph's 0 stays unset, as it was. A subtitle
under a heading came out 4.2mm low, and everything under it with it.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
The zone's table rows carry their height in w:trHeight, which the collapsed
paragraph had no way to hold: a 2.2cm header band read as one line and every
page's body started 8mm high. The height its lines do not fill now rides the
paragraph's space above, which the band measurement already takes — and a
header draws that space too, so the rule line the cell carries lands at the
foot of the band instead of under the first line. A footer's space above stays
a measurement: it holds the ODF zone's own gap to the body.
Measured on one template: 15 parity reports against LibreOffice down to 6.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
The browser hit-tests a behind-text frame last of all — the page and every
paragraph over it come first — so a cover picture could not be clicked at all:
the caret landed in the text as if it were not there. A mousedown over one now
goes to the frame's own node view, which selects and drags it as any other
frame; text painted over the point still wins, as the caret does in both
products. A box's node view mirrors renderHTML's data-wrap for it, as an
image's already did.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
Word's positionV relativeFrom="page" and ODF's style:vertical-rel="page" — how
a cover page's own blocks are placed — were dropped by both importers, so every
such frame sat at its anchor paragraph instead: a title page's coloured blocks
landed wherever the text happened to end. The offset now rides `wrapFromPage`
on the frame, the node view resolves the page the anchor lands on from the page
grid, and both exports write the relation back.
A shape behind the text also goes under a picture behind it (z-index -2, the
sheet moving to -3): probed against LibreOffice, which paints a cover picture
over a shape whatever stacking order the file gives them.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
A divider line is written as a frame of zero height, and the node view handed
that straight to the SVG's viewBox and viewport — a zero-high viewport turns
rendering off altogether, so the line was simply absent. The viewport is now
at least the stroke, which it overflows by half as in any flat frame.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
Only <a:srgbClr> was read, so every shape naming a theme slot — which is what
a Word template's own colours are — came back unfilled and its rules black or
gone: a cover page lost both its coloured blocks and its divider lines. The
scheme is read from theme1.xml by slot, with the text/background aliases
Word's default mapping gives them, and the lumMod/lumOff/tint/shade modifiers
applied per channel.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
Both importers matched a file's format against the seven date and four time
formats the picker offers and kept anything else as the text the producer had
cached — a Word template's `DATE \@ "MMMM d"` then froze at the day it was
written. An unlisted format now rides its own DOCX picture as the key:
`findFormat` parses it, so display, ODF number style and DOCX picture are
derived from it as they are for a catalog entry, and it re-exports unchanged.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
A .docx zone holding a table lost everything in it: hfParagraphs walked only
the direct w:p children, so the one-cell table every Word template draws its
rule line with left the zone empty. Its cells' paragraphs now become lines of
the zone, as they already do on the ODF side, and the cell's own borders and
shading (its w:tcBorders over the table's w:tblBorders) become the collapsed
paragraph's box — which is what draws the rule.
Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
Everything since 0.2.0 on 2026-09-06: the run of load and typing latency work,
each tab editing its own document, the format prompt on a first save, the
unsaved-close warning, and two fixes.
The check was queued in idle time, which on a long document falls between two
pagination passes: its decoration swap redraws the view, and the pass behind it
pays for the redraw. It now waits until no pass is running or queued
(`isPaginating`), so an opened file paginates first and is checked after.
Measured on a 460-page file: settled 5.99 s → 5.10 s (interleaved A/B, median
of seven; a second set of nine gave 5.76 → 5.34). Spacer, index and text-box
geometry byte-identical on five files.
A long document placed a zone box on every page, and the browser laid all of
them out again on every pagination pass. Only a window around the current page
carries its zones now; a capture or a printout opens it to the whole document
first, so the PDF export and Cmd+P are unchanged.
Measured on a 460-page file: settled 6.17 s → 5.27 s (interleaved A/B, median
of seven). Spacer, index and text-box geometry byte-identical on five files.
Both indexes and the cross-references each read it for themselves, twice per round in
the index's case: 59 readings of it over a load where 37 do. The round hands one to
every reader instead, so they all resolve their pages against the same grid, and the
lazy holders they kept go with it. No measurable time — the style pass a reading forces
is owed to the geometry read that follows it either way.
Reading an added node's position computes the style of the whole document, and the band
rebuilds its galleries whenever a document is opened: 300 ms of that during a load, for
40 gallery buttons and no panel at all. A click or a key in the band still classifies its
records at once; anything else is looked at a task later, where the style is there for
free. A 460-page file is on screen 270 ms earlier, every panel pinned as before.
Checking every word of a 460-page file costs half a second, and at mount it was paid
before the document was on screen. The pass now waits for idle time (2 s timeout), as
does the one a language or dictionary change asks for; the check after an edit is
unchanged. The file is named 200 ms earlier and settles 700 ms earlier, with the same
page count, spacers, page numbers and references.
coordsAtPos called straight from the selection handler lays the whole document out
before anything is painted — half a second on a 460-page file, and the writes of the
load that follow it throw the result away. The read is queued as its own task and
coalesced, so a run of selection changes costs one.
Every field resolving against the settled layout — each index's page numbers, every
cross-reference — registers with scheduleFieldRound instead of taking a frame of its
own. A round measures for all of them before any of them writes, since a write between
two measurements lays the whole document out again, and their cached results ride one
transaction: a dispatch of its own costs a view update over the document, 55 ms per
index on a 460-page file. That file settles about 220 ms earlier, with every spacer,
page number and reference exactly where it was on five documents.
A single ResizeObserver refits the frames, reading each rotor before any wrapper is
written: a per-view one arrives in a callback of its own, so the browser laid the
document out again between two frames. pm-pagecount is announced only where the layout
differs from the last announcement — every reader re-reads the whole document from it and
writes back, which is another pass. Both node views also leave the DOM alone when update()
brings the same attrs object. A 460-page file holding 450 frames and two indexes settles
2.4 s earlier and is named 200 ms earlier, with every spacer and page number where it was.
The format pre-pass and the importer read the same inflated archive, where each
unzipped the file for itself, and a picture is encoded through the browser's
Uint8Array.toBase64, the chunked loop standing in where there is none.
On a 14 MB file holding 600 pictures that takes 250 ms of inflating and 285 ms of
encoding off the open: it lands 480 ms earlier and settles 240 ms earlier, and a
small illustrated file lands 170 ms earlier.
Cross-references resolve in one batch per pass, the index reads every leader gap
before filling one, and a zone's tabs lay out with the other zones' — and only
where a field asks for the page count or the chapter map.
List marker and list style decorations are rebuilt only for a transaction
reaching into a list; the unsaved dot compares documents structurally, and the
autosave serializes when it writes rather than per keystroke.
The columns flow applies every op it can judge in a pass, so a section pages in
one pass. Two long files that took 33 s and 20 s now settle in 9 s and 2 s.
The image NodeView reads the page grid from the view ProseMirror hands it at
construction; editor.view is a throwing proxy until the view is built, and a
saved document with such a frame is passed in exactly then.
The dom leg loads its band document through the autosave, the path a reload
takes. The layout baseline is re-recorded for the two showcase files whose
second page was reworked after the baseline was taken.
A `:has()` left of `+` in editor.css made Chromium restyle every element
under .tiptap on each inserted child, so the band-clearing rule keys on a
data-wrap-band attribute the imageLine plugin writes. getLineRects reuses
one Range instead of leaving hundreds alive. TipTap's Placeholder, which
hit-tested the viewport on every keystroke, is replaced by placeholder.ts.
docPosBeforeElement is memoised per pass. At 124 pages the pass after a
split drops from 410 to 150 ms and the split keystroke from 219 to 50 ms.
The dirty ranges ride the plugin state and are mapped through every
transaction; the check after a pause swaps only those textblocks'
squiggles, and the whole document is checked on a language or
dictionary change. The pause after a keystroke in a 124-page document
drops from 363 ms to 11 ms (Chromium) and from 1.4 s to 17 ms (Firefox).
A unit test drives the recheck; the dom run asserts the pause is free.
An edit's pass waits for 300 ms of quiet instead of running between keys, so
while the typing goes on only the keystroke itself costs. When the quiet comes,
edits that only put text into one textblock and left the content's bottom where
the last pass had it skip the pass: the block is as tall as before, so nothing
below it moved. A break inside the block still runs it. Measured at the top of
a 124-page document: 41 ms a keystroke in Chromium, no pass for a letter typed
and taken back. The dom run holds both.
The page-break decorations are mapped through every transaction and each
spacer widget carries a key, so a pass that lands the same spacers keeps
their DOM instead of rebuilding all of them. An edit within 150 ms of the
last pass waits for a pause in the typing. Measured at the top of a
124-page document: 1.5 s a keystroke before, 40 ms after. The dom run
holds the budget.