Grey letters ran down the bar while dragging a column edge. The editor marks
the column under the pointer by putting a small div in each of its cells;
drawn along the cell's right edge, that absolutely positioned child sent
Chromium down another painting path and the cell's own text came out a second
time, squeezed into the two pixels under the bar, one letter to a line. It
appeared with the bar and went with it, which named the culprit -- measured,
there was no text there at all, only three empty markers, and a forced repaint
did not clear it. The marker is display:none now and only says which cells the
column is made of; the line is an inset shadow on those cells, matched with
:has(). Two suspects were tried and reverted first: the table as its own paint
region, and separate borders. Neither changed anything, so neither stayed.
Opening a note flashed a long narrow page. The drawing stage starts at the
canonical page, 1600px tall, and the measurement that fits it to the pane ran
as an ordinary effect -- a frame too late. useLayoutEffect runs between the DOM
being built and the browser painting it, which is exactly the gap that flash
lived in. The test samples the sheet's height every frame across a note switch,
because one frame is all it was.
The mark above a note did not stand in the same column as the tools below it.
Vertically it was already exact -- measured, symbol centre 20, cap centre 20 --
but it began six pixels right of the first tool, and the title thirty. The
mark's box is only as wide as the mark now, so the mark lands on that column,
and the title follows nine pixels closer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things wrong with the paper.
"Start writing..." was printed at the foot of every page that had a break
after it. The placeholder extension styles `[contenteditable]:empty::before`,
and the spacer holding a gap open is exactly that: empty, and not
editable. It inherited a placeholder meant for an empty note.
And the column was too narrow for the sheet it now sits on — about 73
characters, which left 117px of blank paper down each edge and made the
writing look lost. Widened to about 80, a little short of where the eye
stops reliably finding the start of the next line, which leaves roughly
8% of the sheet as margin: a document's margin rather than a picture
frame.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The writing surface was a column of text in an empty pane. A sheet was
drawn behind it, but only once you started drawing — so writing looked
like text floating in the middle of nothing, and the space either side of
the column read as a void rather than as a margin.
Now every page is a sheet of its own, centred, with a gap after it: the
same way the PDF reader draws its pages, because a page in a note and a
page in a document should read as the same kind of thing.
The gap is the part that takes work. The browser knows nothing about the
sheets and lays the text out as one flow, putting a line straight across
the gap and cutting it in half. So editor/pagination.js measures the flow
and pushes any block that would straddle a gap onto the next page, with a
spacer of exactly the right height in front of it. A paragraph moves down
entire, which is what a page break means on paper. A block taller than a
page is left to run over, since splitting one would mean laying out its
insides ourselves.
The spacers are decorations, not content: they never enter the document,
never reach the server and never appear in an export. Two checks hold
that down — one asks whether any block sits in a gap, the other reads the
stored body back and looks for them there.
Two things that are easy to get wrong. The spacer's height is not the
distance the text moves: margins that used to collapse into each other no
longer do with something between them, so the layout gains that much for
free and the spacer is made exactly that much shorter. And heights change
with no transaction to announce it — a webfont landing, an image
arriving, the window resizing — which a ResizeObserver on the editor
element catches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The column sat in the middle, so it moved as the window was resized and the
start of every line landed somewhere new. It is against the margin now.
The toolbar counts pages in the words the PDF reader uses, with a hairline where
each ends. Pages are drawn, never stored: the note stays one document, Markdown
has no page, and a check asserts none reaches the stored body. The current page
is the one under the middle of the window — measuring from the top claimed "page
1" while you stood at the foot of a two-page note, because a window is shorter
than a page.
The typeface is a per-account setting rather than a per-browser one, because a
choice about how your own notes are set should follow you to the phone. The
server takes serif, sans or mono and drops anything else; that column is written
by a browser, so it is an input like any other.
Alignment is a small extension of our own — one attribute, a parse rule, a render
rule — so the schema every stored note is validated against stays in files this
project owns. Markdown has no alignment: what CommonMark allows is embedded HTML,
so non-left blocks travel as `<div align="…">`, the one form most readers still
honour, and the importer puts it back on the paragraphs. Left is never written.
The full run caught a crash I had put in: PdfWorkspace used `noteFont` without
taking it as a prop, which the build cannot see and the type checker does not
reach in .jsx. It killed the notes panel beside a PDF, and `offline` failed with
it because the whole app was broken.
224 unit tests, 263 browser checks across twelve suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The interface had drifted into two languages — the frame in English, everything
added later in German — which is worse than either, since the same idea appeared
under two names depending on when that part was written. Sorting by title still
uses the German collation, so Übung 2 comes before Übung 10: that is about the
notes, not the chrome.
Two archive folders were renamed with it (bilder/ → images/, zeichnungen/ →
drawings/). The importer reads both spellings, and both the old and new marker
line for a drawing — archives exported before this are on disk, and an import
that quietly dropped their drawings would be the worst kind of tidy-up.
Making a note, uploading a PDF and importing Markdown were three buttons side by
side, two of them icons you had to hover to identify. They are the same act —
putting something new in this notebook — so they are now one + menu.
The search box moves out of the sidebar and into the note list, labelled with the
notebook it narrows. Nothing changed about what it does: it has always searched
only the notebook you are in, but from the sidebar it read as searching
everything.
A separator no longer carries a sentence explaining the order the sort menu
already names. Renaming joins removing on the right-click menu — a left-click on
the label started an edit, so the row could not be touched without falling into
one.
218 unit tests, 238 browser checks across eleven suites. Two of those caught real
breakage from this change rather than needing a translation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The vector clocks in the server were never reached: no client sent one,
so the branch was dead and every write was a blind overwrite. Not "the
newer edit wins" — "the request that arrives last wins", which is worse:
a phone that was offline all day buries the laptop's newer work simply by
reconnecting late.
Each write now carries baseUpdatedAt, the revision it was written on top
of. If the note has moved on since, the incoming edit still wins — a
rejection would throw away what was just typed — but the state it
replaces is kept as a version marked "conflict", so both exist and the
history is where you choose. Clocks could have told a real conflict from
a device catching up, but that precision only pays off with a merge
interface, and this reuses the version history already built.
adoptRevision() stores the revision the server reports back, or the
second save in a row would be flagged as conflicting with the first.
Also removed, as agreed:
- POST /api/notes/pdf — accepted PDFs that nothing could ever read back,
with no retrieval route and no viewer. The readme advertised "PDF
Import & Annotation"; that claim is gone too.
- POST /api/sync — a batch endpoint the client never called.
- the tags column — notebooks, starring and search already cover it.
Columns on existing databases are left in place. Dropping them buys
nothing and is not worth touching a live database for.
Tables now have a right-click menu (editor/tableMenu.js) for rows,
columns, header and merging. It needs stopPropagation: without it the
same event reaches window, where the menu that is mounting reads it as a
click elsewhere and closes instantly — the reason the other context menus
in App.jsx already do this.
92 browser checks across seven suites, 125 unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They lived in a scratch directory under /tmp, which was cleared overnight;
four of the five were simply gone. Whatever they were worth, they were
worth nothing kept there.
Rewritten against a shared lib.mjs. The old ones registered their users
through the sign-up form, which no longer exists — each suite now creates
a throwaway account through the admin API and deletes it afterwards, so a
run leaves the database as it found it. The previous generation left nine
accounts behind; those are gone too.
Three test hooks went into the app, each of which earns its place beyond
testing: aria-label on the note title (the sidebar search box is also a
text input, and a screen reader could not tell them apart either),
data-note-row on list rows, data-user on admin rows.
Two suites were flaky in ways worth naming, because both were the test
misreading the app rather than the app misbehaving:
- ticking a task then inserting a note link put the link inside the task
list, since Control+End lands there. Verified against the server that
the tick itself persists correctly; the suite now leaves the list first
and asserts where the link ended up.
- the note-title lookup matched the sidebar's Trash entry, which wears the
same emoji as a note row.
56 checks across five suites, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>