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 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
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
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.
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.
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.
The unload warning followed the changes a file had not got yet; a document
that never was a file got none, on the grounds that the autosave keeps it.
It now warns as soon as such a document has content — the same emptiness
test the confirm before a new document uses.
The format has to be settled before the bytes are built, so a save that goes
the download route cannot become .docx by renaming it in the browser's dialog.
Save on a document with no file behind it now opens SaveFormatDialog and hands
the choice to Save As; from then on it saves in that format without asking.
Every document-scoped key now goes through docKey(), which hangs it off the
tab's document id; the first document keeps the empty id, so existing browser
copies read back unchanged. A fresh tab takes up the most recently used
document no live tab holds, else starts a new one — one signed timestamp per
document (edentext-live@id) says which, written only by the tab holding it.
Snapshots and embedded fonts get a database per document. The picture store
stays shared and content-addressed, so its sweep now drops only keys no stored
document names any more, instead of the open document's own.
openApp() passes page options through, so the showcase run sets
deviceScaleFactor 2 — same CSS viewport and layout as the tests, 2800x2000 px
images that stay sharp where the README scales them down.
The MathML operator dictionary makes ( [ { | stretchy, so a plain bracket grew
to the tallest thing in its row — next to a sum with a fraction, C(b) came out
in full row height. Only a fence node emits stretchy brackets now.
The 1x1 fixture carried an IDAT from a different image: its CRC fails and the
zlib stream is truncated. Chromium and WebKit render it anyway, Firefox reports
"Image corrupt or truncated", and the same bytes sat in eleven fixtures.
Header and footer go first, each with its own ops, and a variant flag is
flipped while a zone is open. The round trip compares a list's drawn kind and
marker rather than its node type and style name — a list that loses its name to
a formatted marker carries the style's own char — and skips the marks of an
inline atom, which no file keeps. A caret is snapped to a text position, a
swallowed File-menu click is retried, and WebKit's converging ResizeObserver
round is no page error. A new corpus pair brings all six zones through the
import path; the baseline holds it and a re-recorded showcase entry.
A page break reaches the file only from a body block or a list item — a cell,
a frame and a note body all drop it — and an indent on a list item's paragraph
is the same; a plugin sweeps both. A note anchor keeps no marks and stays out
of a text box. Sub- and superscript exclude each other, a font stack the
contenteditable leaves behind keeps its first family, and a weight Bold already
claims is no second attribute. A table's first row is all header cells or none
(the promotion carrying each cell's own attrs), a replace into a table reads the
lost column weight back from the column, and a list item holds no table.
Three regression tests for what a 5000-seed sweep turned up, each checked to
fail without its fix. A run repeating its style's own plain underline is
lossy by design, so the generator no longer puts one under a named style.
tests/layout/baseline.json records where each page starts and what the load cost, per
engine and platform; a page starting elsewhere is a layout change the run prints page by
page, a load over three times its recorded time a regression, and LAYOUT_UPDATE=1 records
it again. The monkey draws frame sizes, wraps, alignment and column counts too, holds the
history against the edits rather than the caches the editor recomputes, and leaves it
unchecked where the columns flow rewrote the document outside it. The differences both
importers make by design are named in the comparison; unhoist moved to normalize.ts.
An .odt kept only the first paragraph of a list item; the rest ride it SEG-separated and
are split back out inside the same item. A header row the table did not ask to repeat
came back as ordinary cells, and only DOCX wrote it. An endnote's picture pointed at a
relationship the package never minted, and neither note's pictures were read through
their own part's relationships. A frame band-wrapped flush left is at no side of its own.
Blocks wrapped into columns drop a page break nothing there honours, and a cell's content
expression now says what both exporters can write into one.
lo-fuzz.test.ts hands every fuzz seed to LibreOffice as .odt and .docx and
compares what comes back, listing the differences LibreOffice makes by design.
test:layout opens the corpus, the showcase and fuzz seeds in the browser, holds
the page count against LibreOffice and checks the lines for overlap, margin
escapes and stranded headings. test:monkey types at random on corpus documents
and asserts the schema, undo to the start, redo, and that both saved formats
read back. browser.mjs now carries the dev server and the settle helper.
A section opening on a side hands its one-page master over to a follow-up
master instead of asking for a right-only page, which doubled the page count.
DOCX names its default style Normal and marks it as the default, zone tabs
stay tabs, footnote numbering rides in the section properties, and w:position
counts half-points. The importer keeps content controls, shape text boxes and
index page numbers, and opens no phantom section on the leading master.
Editing rejects the nesting the formats cannot hold, note repairs ride the
undo history, and the showcase files are rebuilt.
tests/fuzzOptions.ts generates styles, outline numbering, sections with
zones, margins, paper and page numbering, decor, line numbering, notes and
metadata; the round trip compares per-section effective values, and what a
format has no place for is listed as lossy. The xmllint part of
schema-validation.test.ts moves to tests/schemaValidate.ts (one call per
schema) and a third leg validates every seed as ODT and DOCX. FUZZ_SEEDS
and SCHEMA_SEEDS widen the sweep.
Found with the showcase documents and by fuzzing the export options through
the round trip and the schemas. ODT: a style's line spacing as a percentage,
section masters laid out from their own zones, variants in schema order, the
watermark on section pages, NCName-encoded style names. DOCX: a section begins
a page, odd/even and w:fmt document-wide, styles with line spacing, shading and
borders, w:numPr and w:pBdr in order. Both importers: the next style, page
setup and text width per section. Editor: chapter counters via counter-set,
a heading in columns stays with its text.
GitHub Pages serves a .dic as text/x-c and leaves it uncompressed, while
text/plain goes out gzipped: the English list cost 552 KB over the wire
where 191 KB would do. Only the fetch path changes; the mount name the
checker sees stays <code>.dic. Measured first load 1.83 → 1.70 MB.
openApp() launches chromium, firefox or webkit from playwright-core; the smoke
and DOM runs pass in each. CI runs both per engine, WebKit on macOS for
Safari's text shaping. build.target is set explicitly to the browsers the
bundle promises. CLAUDE.md is back under its 120-line budget.
Brave ships with the File System Access API off, so a save is a download and
the browser's dialog cannot hand a changed name back: the bytes must be built
in the format chosen beforehand, or a renamed file carries the other format.
One entry per format settles that on every route, the DOCX export entry goes
as its duplicate, and the DOM run covers the download route.
A click lands wherever the element's centre happens to be, and a break before
the empty last paragraph yields no page; the editor's focus() lands on the next
animation frame, so a key sent before it was lost now and then.
Chrome's macOS save panel shows no format popup and admits only the first
picker type's extensions, so a second type could never be chosen there. Both
extensions now share one type, for documents and templates alike, and the
typed extension decides what is written. A document without a file saves
through that same picker, and the Save entry no longer names .odt.
A list of timestamps in both file menus paid for itself only on the day
something went wrong, and it was in the way on every other. The entries
are gone; loadDocument now offers the versions where the browser copy has
actually failed the reader — newest first, one question each, and the one
taken up starts under the boot flag like any other document. The store
loses its reactive singleton with them.
The name decides which file the next save writes, so it belongs in the
checksum beside the text and the page setup. The DOM run renames a
freshly opened document and expects the dot.
It is now an FNV-1a checksum over the text plus everything exportArgs()
hands the exporter, so a margin preset marks the document as much as a
keystroke does and an undo back to the saved state clears it again. The
page count stays out — a late-loading font must not mark anything. The
beat is throttled, not debounced, and the baseline is taken on the spot
instead of in the timer, which a repagination can push past a first edit.
The DOM run checks all four states.
npm run test:dom opens a corpus document in headless Chromium and holds
its page count against a settle, a reload, a zoom, a page break and its
undo, plus the unsaved dot — the components the vitest suite cannot see.
The smoke run now shares its boot with it (tests/browser.mjs).
One version every five minutes goes into an IndexedDB store, pictures
inline, and both file menus list them by time; a click puts that text
back and leaves the page setup, the styles and the zones alone. The
browser copy is one document deep, so a bad edit had nothing behind it.
The picker lists .odt and .docx, and the chosen extension decides which
exporter runs and which format the document keeps — no silent switch to
.odt for a file that came in as .docx. Both save paths now build their
bytes through one helper instead of repeating the argument list.
A caret move recounted every word; a blank title serialized the whole
document to JSON on every transaction. The count now runs once per changed
document, deferred off the keystroke, and the placeholder reads the first
heading straight off the document.
The heading constants move to styles/headings.ts and the filename logic to
storage/documentName.ts, which were all the editor needed of the exporter
at startup. Main chunk 2.1 MB → 1.37 MB (gzip 615 KB → 411 KB).
A reload that cut a slow start short looked like a freeze, and the parked
document was reachable only through the browser's storage tools. The user
is asked for another try; only declining parks it.
The 1 s debounce lost a tab closed inside it. Writes now queue behind each
other, so an older write finishing late can neither store an older document
nor sweep the pictures a newer one added; a page revived from the
back/forward cache writes again.
A STYLEREF field's result runs are the chapter a reader shows before it
repaginates. The DOCX import dropped them and set the field's text empty, so a
header saved as .docx came back with a blank running head. ODF kept it.
Found by comparing the header and footer zones on a round trip, which nothing
did — they are documents of their own and hold what only they can. The corpus
gains two legs besides: a second save, and Word's dialect on its way out as ODT.
The corpus legs pass seven of the exporters' nineteen arguments, so the other
twelve — note configuration, page decoration, line and page numbering, the
header/footer zones, document properties — were never round-tripped from a
file. A new unit test sets all of them and reads them back out of both formats.
The kitchen sink had only ever been written, never imported; it now makes both
round trips and comes back identical. Two fixture repairs it needed: notes in
anchor order, and a section break with sections to open.
Caught: a document holding no note yet lost its note configuration on .docx.
The corpus leg out of one format and into the other compared block types and
text, so anything the trip dropped below that passed. It now compares every
attribute each node carries, with the values a format states in its own units
excluded: proportional vs absolute column widths, and a formula's inline flag,
which ODF has no place for.
Two the stricter leg caught: a note lost the paragraph style it names and fell
to the stock 10pt, and a link stored an attribute equal to its default.
A header row is w:tblHeader on the row, the same thing ODF spells with
<table:table-header-rows>. An index's leader and tab stop ride its first row,
the level styles are a family per kind, and the stop is written even with no
page number running to it. A box carries its own ring (w:bodyPr/@lIns) and
where it sits across the column when behind the text.
A section past the first now names its zones even when blank: a sectPr
without a headerReference is Word's Link to Previous, which put a chapter's
running head on the pages a blank section was meant for.
15-chapters carries what only a stylesheet holds: numbering on the heading
styles and two heading styles of the document's own. The round-trip legs now
carry that stylesheet too — a style the file names but the built-ins never
heard of can only be written from there — and one absolute assertion says the
chapters read as headings, which a round trip cannot: both its sides go
through the same importer, so a consistent misreading compares equal.
A numbered heading style rides the package's factory slot; a second definition
under the same id leaves two, and LibreOffice reads the first.