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
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.
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.
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.
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.
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.
scripts/showcase/run.mjs builds three sample documents plus a review
variant as .odt and .docx through the app's own exporters in headless
Chromium, opens each through the file dialog and takes the README
screenshots; docs/showcase/ holds the results. The README gets a
light/dark hero picture and a gallery of four tiles.
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.
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).
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).
Five legs in tests/lo-password.test.ts, driven through the repo's first UNO
helper because --convert-to cannot pass a password; self-skips without soffice
or python3-uno and runs with npm run test:lo. The probed format details live in
docs/architecture/encryption.md.
corpus/word/ holds each document saved again by Word — rsids,
proofErr, separator notes, theme docDefaults — asserted to read like
the docx-lib original and survive our round trip. The formula fixture
is reauthored: a hand-wrapped MathNumerator doubled m:num/m:den, which
Word refuses to open, and the radical gets a visible degree.
Three legs against the closed-loop blind spot the duplicate-style bug
exposed: package-lint.test.ts checks invariants the schemas cannot
express (unique style ids/names, dangling style/num/rel references,
balanced ranges, manifest completeness); lo-roundtrip gains a
soffice→fodt leg asserting the styles LibreOffice resolves (Heading
chain Arial/bold/sizes, Word defaults); lo-render-consistency.test.ts
renders the kitchen sink from both formats through LibreOffice and
compares pagination and pixels (ratchet thresholds). CI's LO job
gains poppler-utils; test:lo runs both LO files.
tests/kitchenSink.ts authors one deterministic document using every
feature; schema-validation.test.ts validates its .odt against the ODF 1.3
RelaxNG and its .docx parts (after MCE resolution, as Word reads them)
against the ECMA-376 transitional XSDs — the structural half of Word's
strict reader, which LibreOffice silently forgives. Schemas vendored under
tests/schemas/ (sources and offline patches in its README); self-skips
without xmllint, which CI now installs.
The return-address paragraph gets fontSize 8pt so its line box is 8pt
tall (a 12pt paragraph mark pushed the address zone 1.6mm low), and an
info line with no address half indents 10cm instead of leading with a
tab (a leading tab stays on the default grid). Measured in Chromium:
return address 44.97mm, address zone 49.98mm, info column 124.98mm,
fold marks 87/148.5/192mm.
The minifier strips every legal comment, so the bundle reached users without
the notice MIT and Apache-2.0 require to travel with a copy. collect-licenses
quotes all 97 bundled packages into public/licenses.txt, which deploys like the
font and dictionary licenses do. The About dialog's version now carries the
short commit, so a user of the hosted app can identify the source it was built
from - what AGPL 13 asks for and a fixed 0.1.0 could not answer.
tests/fuzzDoc.ts generates documents from a seeded PRNG (marks, lists,
tables, indents, XML-hostile text); fuzz-roundtrip.test.ts runs 50 seeds
through buildOdt -> importOdt and diffs the normalized JSON, naming the
failing seed. normalize/firstDiff moved out of roundtrip.test.ts into
tests/normalize.ts for reuse. The generator documents what is lossy by
design: default-equal marks in headings, indents and start in nested lists.
tests/smoke/run.mjs boots the production build in Playwright Chromium and
checks what jsdom cannot: the bundle boots, typing and the bold shortcut
reach ProseMirror, autosave survives a reload, a corpus ODT imports and
paginates; any uncaught page error fails the run. Wired as npm run
test:smoke and a CI job (build + chromium install), playwright-core is
now a devDependency.
Ctrl+F7, Review ▸ Thesaurus and the context menu open one dialog: the
word at the caret, the sense groups it is in, click one to replace it,
and a box to look up any other word. The data is MyThes re-packed to one
group per line (de 3.0MB/37k groups, en 6.7MB/143k) — the format repeats
every group once per member, ten times the bytes. Loaded on first use for
the document's language and scanned, not indexed: ~4ms a lookup, where a
word→group index measured ~100MB of memory against the text's ~8MB.
Ten documents the docx lib authors directly, plus the ODT twin
LibreOffice converts each into, now live in tests/corpus/ instead of the
gitignored fixtures dir. corpus.test.ts asserts both formats of a
document read the same and that each survives our export and import
either way. The heading styles gained the outline level Word writes —
without it LibreOffice converts a heading to a paragraph that merely
carries the style, and the two formats disagreed.
The marker moves from a CSS ::before to a widget decoration: generated
content is no text node, so it reached neither the PDF nor a measurement.
The note also carries the paragraph style its file names, so font, size and
colour are the document's; only the hanging indent stays the editor's,
because the style model has no first-line half to pair with the margin.
Adds a unit test for the numbering and the settings, plus the architecture
doc and the two open parity findings.
The palette outside the theme system, the token remap that lets a reused picker
adopt it, and the two things the fixed-height band forces — a menu that leaves
the flow, and a wrapper that names the menu it owns.
Also the deletion the ribbon would unlock if classic were ever retired, so the
option stays visible without anything being removed now.
The parity findings named the six files the corpus is built from, so every
measurement carried a document title into the history. They move to a
gitignored FINDINGS.md beside the corpus itself; the README keeps the harness.
A test that needs a document builds one, so nothing under src/ or tests/
reads the gitignored corpus — CI greps for exactly that.
A formula node stores only its LaTeX source; the MathML on screen, the ODF
formula object and Word's OMML are derived from it, and both importers parse
foreign markup back into that source, so an imported formula stays editable.
Word documents were losing their formulas outright: m:oMath sits beside the w:r
runs in its own namespace, past convertInline's `namespaceURI !== W` guard.
The root file is read at every session start and had grown to 87 KB, so every
session paid for knowledge it did not use. Move the per-directory detail into
nested CLAUDE.md files, which load when a file in that directory is touched,
and the five deep-dive topics into docs/. The root keeps the commands, the hard
rules and an index of where the rest lives.
Content moved verbatim — the probed Word/LibreOffice behaviour, sentinel order
and shared constants are what the code cannot say. CI fails the root over 120
lines, since a prose budget alone is what let it grow.