Commit Graph
18 Commits
Author SHA1 Message Date
marcandClaude Opus 5 9507761bce Colour, a page you can see the edge of, and a document on its own
Five of the nine things on the list, and one finding that came out of them.

Colour: a marker pen and an ink colour, written as marks here rather than
installed — the schema of a note decides what can be stored in it forever. A
highlight is a <mark> so a reader that drops the colour still shows the word
as marked, and marked text is painted dark whatever the theme. Both travel as
HTML in Markdown, like underline, and both are read back.

The PDF fills the screen: the browser's own full screen for the window frame,
an overlay for the sidebar and the list, Escape and a visible button for the
way out.

The dashboard leads with the recent notes, sorted by time whatever order the
notebooks are in. A tap on the blank part of a page puts the caret at the
nearest position instead of nowhere. A drag from the left edge shows the
sidebar. The page has an edge on a phone, where it had none.

The finding: at the speed a robot types, a long note loses keystrokes between
the editor and the pagination measuring after each one — 87 of 220
paragraphs. Nothing is lost at 50 characters a second, so the page-count
check now pastes its bulk text rather than measuring typing throughput. And
the history suite asked for "the first note in the list" without waiting for
the note to reach the server; it asks by name now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:40:21 +02:00
marcandClaude Opus 5 c86c63922a Quotes that hold more than a sentence, and one list of marks
A quote may hold whole blocks, and they now have room: the rule that gives
the writing its rhythm reaches only the editor's own children, so inside a
quote a heading, a list and a closing line stood jammed together. Leaving as
Markdown they were written on consecutive lines, which every other reader
folds into one — the sentence after a quoted list became more of the last
bullet. They are divided by a quoted blank line now, the same rule the top
level has had since 11 August.

The marks were two copies of one list, in two dialogs, and only one of them
had ever been extended. One list, one grid, twice as many marks, divided
under headings — and anything else can be typed into the search box, which
on a phone is where the emoji keyboard lives.

The changelog is a third of its length: one entry per day rather than one
per image, since a round that repaired what an earlier round the same day
broke reads as noise afterwards. Every digest is still listed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:15:55 +02:00
marcandClaude Opus 5 641acaae40 Three things that only showed on screen
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>
2026-08-17 19:14:38 +02:00
marcandClaude Opus 5 a9391cdcf2 One direction to drop in, and a new note that stays on top
Every new note pulled the arrangement apart. A note was made with no
position and left to the tie-break, which put it near the top by age --
which is not the same thing. A separator's position counts the rows above
it, so a row appearing without a renumber left every line one row too
high. placeOnTop builds the order from the list *without* the new note and
puts it in front: measuring the room after the furniture has moved would
write the misreading back as the truth.

A drop now lands the row under the one it was released on, and only there.
Dropping above a row and dropping below the row before it are the same move
described twice, so every gap could be aimed at from two sides. The strip
above the first row is the one place a drop under something cannot reach.

A chapter could only be dropped into, never dragged past: taking the whole
row as a place quietly took reordering away. The bottom edge of its header
is the gap now, the rest of the row is still the chapter.

A link showed the sheet of paper whatever it pointed at. The @ menu dropped
the mark on the way into the link, and the refresh that keeps a link current
ran once, while the editor was still empty -- content is poured in afterwards
without an update event, so nothing ever looked again.

The mark in the title bar and the box in a task list were each centred on a
line box rather than on the letters in it, which is not the same as being on
one line. The mark shares the title's baseline; the box is centred on the
capitals and sized in em.

Move to... is divided under headings that are deliberately not shaped like
entries, and each row carries the mark its chapter or notebook wears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:45:16 +02:00
marcandClaude Opus 5 d68b0775c2 One dialog per thing, and rows dragged into a chapter
A separator had three menu entries — rename, colour, line — plus an input
that appeared in the row itself: three shapes of interaction for one small
thing, and none of them showed the result. Choosing "dotted" from a list
of words is a guess. It is one Edit now, and the dialog draws the line as
it will look with every shape drawn beside its name, from the very rules
the list draws with: a sample that can disagree with the result is worse
than none.

A chapter goes the same way — name, mark and colour in one dialog, the
same one whether it is being made or changed, with the row drawn at the
top. Asked from three entries, getting a new chapter to look right took
three visits. The name dialog it used is gone.

A row is put into a chapter by dragging it onto it: the chapter lights
up and takes what is dropped rather than growing a line above it, since
a chapter is a place and not a neighbour. Notes and separators both, and
the "+ Separator" inside a chapter goes with it — a second way to make
the same thing in a second place is a second thing to keep straight.

A link now wears the mark of the note it points at, in the text and in
the @ menu. It travels when the note's mark changes, as the label
already did.

Two alignment faults: a task list's checkbox was nudged down by a fixed
0.35em measured once against one font, so it floated above the line it
belonged to — it is centred in a box one line tall now. And the title
bar's mark was spaced like the buttons at the far end and grew with its
contents, so picking an emoji shifted the title sideways.

Fourteen suites green: 374 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:23:26 +02:00
marcandClaude Opus 5 f1d3e38f8f Marks, lines, and a table that breaks
A long table lay straight across the gap between two sheets with a row
cut in half, and a shorter one jumped to the next sheet whole leaving
the rest of the first blank. Tables are broken between their rows now,
which is where a table breaks on paper. The spacer there is a tr with
one borderless cell: a div inside a tbody is not something the browser
keeps. Its own spacer rows are subtracted again on the next measurement
and counted into the shift every block below inherits — miss that and
everything under a broken table is measured a page too high.

A dialog closed in the middle of an edit if a selection was dragged past
its edge: the click lands on the nearest common ancestor of press and
release, which is the backdrop, and every dialog read that as "clicked
beside me". They remember where the press began now. All ten.

A note and a chapter can wear a mark of their own beside the name,
chosen from a short list searched by what a thing is for rather than
from the system emoji keyboard — a different thing on every machine, and
three thousand pictures for a decision that is "which of these is the
invoice notebook". A separator takes its own colour and one of five
shapes, and a chapter can be divided like the notebook it sits in: both
places a chapter is shown draw it with one component now, so it cannot
be divisible in the column and not in the unfolded block.

Also: a chapter's colour could not be cleared, because the update rode
on COALESCE and null there means "leave it alone". A table's header row
is legible on the dark theme. Three entries in the table's own menu were
still German. Opening a notebook opens its first note, on a wide screen
only — on a phone the list is the screen.

Fourteen suites green: 364 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:23:16 +02:00
marcandClaude Opus 5 d82e72d358 Keep the gap blank, and give the writing more of the page
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>
2026-08-11 21:26:51 +02:00
marcandClaude Opus 5 97a3764d92 Write on paper: a sheet per page, with a gap between them
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>
2026-08-11 20:17:28 +02:00
marcandClaude Opus 5 dcc42bb8e0 Centre the writing column again
Pinning it to the left edge put the start of every line in the same
place, which was the point, but on a wide window it left the entire
right half of the pane as one dead block. Centred, the same leftover
space reads as two margins instead.

The page surface that tried to give that block a meaning is reverted in
the commit before this one: it made the whole sheet sit hard against the
left edge, which was worse than the problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:36:54 +02:00
marcandClaude Opus 5 fed53772aa Set the note left, count its pages, choose its typeface, align its text
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>
2026-08-11 17:17:13 +02:00
marcandClaude Opus 5 830af6532a Take the paper back out, and quiet the surfaces around it
The sheet was not what it wanted to be, so writing is plain text again: about 73
characters to the line, centred, no border and no shadow. The fixed page belongs
to the canvas — a stroke has to land in the same place on every screen, which
needs one agreed page — so it appears exactly when something is on it or about
to be: in Draw, or when the note already has a drawing. The cap lives in the
stylesheet rather than on a container, so it holds on a page as well as off one.

Emoji are gone from the note rows and the sidebar. They are drawn by the
operating system, so they looked different on every machine and were loud enough
that a row's picture caught the eye before its title did. Rows are tighter by
three pixels of padding.

The trash button no longer stands permanently in the danger colour beside the
star, which made the most destructive control the loudest thing on screen. It
sits behind a divider and turns red under the pointer, when it is about to be
used.

On a phone the toolbar's fourteen controls scrolled sideways, so the ones at the
end could only be reached by dragging the bar and nothing said they were there.
Nine remain, plus a menu holding the headings, quote, code and undo.

A new `sync` suite proves what was assumed: a PDF written on one device — the
document, the drawing and the pinned notes — reaches a second one. Two browser
contexts are two devices.

Both search suites read a row's title as the second line of its text, which only
worked while the icon was itself a text emoji; they read it by name now.

218 unit tests, 249 browser checks across twelve suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:57:22 +02:00
marcandClaude Opus 5 c12ffa250e Make the writing surface a sheet of paper
It was a page-shaped box that was not page-coloured: the same fill as everything
around it with a hairline border, so it read as an empty panel. Now it is warm
off-white in daylight and a raised surface at night — deliberately not a white
rectangle, since a page that glows brighter than the room is what makes an app
unusable in the evening.

The line ran to 84 characters, past the width at which the eye reliably finds the
start of the next line, and that was the single biggest reason the surface read
badly. Measured, not guessed: 620px of sheet with a 56px margin gives about 73.
It is centred now — it used to sit 28px from the left and stop short of the
right — with almost square corners instead of a dialog's 14px radius.

Blocks on the sheet are tinted from paper rather than from the app background,
or they read as sitting underneath it.

Five German strings survived the earlier pass, "Alle Notizen exportieren" among
them: that sweep filtered on umlauts, so sentences without one went straight
through. Swept again by word.

218 unit tests, 244 browser checks across eleven suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:08:54 +02:00
marcandClaude Opus 5 52bd2b8f3d Put the interface into English, and give the notebook one way in
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>
2026-08-11 02:17:22 +02:00
marcandClaude Opus 5 4c1b632029 Link the margin notes from the notes, and let them be hidden
The linking went the wrong way round. A remark in the margin is short by design;
when there is more to say about that passage it belongs in the notes beside the
document — and it has to be possible to say *which* passage. So the pins are
offered to the editor's `@` menu exactly like notes and notebooks are, labelled
with their page, and following one scrolls the document to the spot and opens the
remark. Pins are unhidden first: a link leading to something invisible would read
as broken.

A pin link is the same node with kind "pin", and is deliberately kept out of the
links reported to the server. Its target is a marker inside this note's own
overlay, so no note has that id, and reporting it would fill the backlink table
with rows matching nothing.

They can also be hidden altogether. Reading a document as it was printed is a
normal thing to want; the toolbar takes the markers and the column away together
and the page takes the width back.

The other direction — making a note out of a remark — is removed, along with its
picker and the pin's noteId: 357 lines fewer than before. The unmount flush it
turned up stays, since a drawing save was genuinely being dropped by navigating
away inside its debounce.

218 unit tests, 231 browser checks across eleven suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 20:00:46 +02:00
marcandClaude Opus 5 0a0864c58b Sort a notebook, import Markdown back, and stop the page shrinking in Draw
The paper measurably shrank on switching to Draw — 733px to 460px on the same
screen. The editor was a flex item at its default `flex: 0 1 auto`, so its width
was its widest row's: the toolbar. Swapping a dozen formatting buttons for six
drawing ones collapsed it. It now fills the pane, so the width is a property of
the window.

Each notebook remembers how its notes are ordered, on the server rather than per
device. The manual order has to survive being looked away from, so `reindex`
renumbers every note in the notebook rather than the filtered view, and dragging
is switched off while a search narrows the list. Reordering has its own route:
`PUT /notes/:id` would bump `updated_at` and snapshot a version, so a drag would
move a note to the top of "last changed" and spend a slot of its history.

Markdown and our own ZIP archives come back as notes, with their images
re-uploaded as attachments. A drawing returns as the PNG it was exported as —
vectors are not recoverable from pixels — and the import says so rather than
leaving it to be discovered. Writing the parser turned up a real bug in the
exporter: blocks were emitted with no blank line between them, so a list after a
list became one list and a quote after an item was read as part of it.

Creating or importing from "All Notes" now asks which notebook instead of
silently taking the first one. The web-link prompt is a real dialog: a bare
domain becomes https, javascript: is refused with a reason. The PDF viewer's
hide-drawing control is a labelled button rather than a filled-or-hollow dot,
and the export icon points down into a tray like every other download.

186 unit tests, 179 browser checks across eleven suites, including a new
`offline` suite that loads the app cold with the network off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:38:03 +02:00
marcandClaude Opus 5 3acb8b66a4 Free unused attachments, and keep versions by count rather than by age
Two rules governed version retention: the newest 50 per note, and a sweep
of anything past 90 days. The second was wrong. The count already bounds
what a note can accumulate, so age only ever deleted history nothing had
replaced — and it bit hardest exactly where history is most wanted, a
note left alone for a quarter of a year losing its whole past for having
done nothing. It is off unless VERSION_RETENTION_DAYS is set.

How many versions to keep is now a notebook's own setting, falling back
to the server default when left empty. A reference notebook can keep
fifty of everything while a scratch one keeps three.

That makes freeing attachments answerable. A picture taken out of a note
cannot be deleted straight away: the versions from before still show it,
and restoring one would find a hole. It is released once no current note
and no kept version refers to it — which, with retention by count, is
when the last version holding it is pushed out.

The server cannot look for itself, bodies being encrypted, so the client
reports a note's attachments on every save as it already does for links,
and a version inherits what the note held when it was snapshotted — by
definition what that version contains.

Two things make it safe rather than merely plausible. Silence is not
"uses nothing": a note written before this existed has no rows, which
looks identical to one with no pictures, so nothing is released for a
user while any of their notes has yet to report — the prefetch that fills
the search index reports for them on its first pass. And an upload has a
row before the note using it does, so recent attachments are left alone.

POST /api/admin/housekeeping runs the sweep on demand.

Caught while testing: the same block of markup had been pasted into the
create-notebook dialog as well, where the state it referenced did not
exist — the dialog crashed on open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:22:46 +02:00
marcandClaude Opus 5 df279a5d36 Replace unused vector clocks with a conflict check that works, drop the rest
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>
2026-08-08 18:59:08 +02:00
marcandClaude Opus 5 92d525b3ab TipTap editor, version history, search, attachments — and a cleanup
The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.

Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
  missing; it refuses to start. The old fallback silently made every stored
  note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
  not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
  already eaten drawing strokes and note links; sync.hasPendingWrites() now
  guards every path that takes a body from the server.

Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
  to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
  into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
  encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
  fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
  the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.

Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
  covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
  unreadable.

Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
  search, layout, history and drag each live behind a hook, and the four
  panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
  the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
  restoring a history entry pushed a duplicate one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:00:14 +02:00