Commit Graph
56 Commits
Author SHA1 Message Date
marcandClaude Opus 5 e037f01dc2 Let a notebook's design be its own
Two things were drawn on every cover regardless of what was chosen, and
between them they are most of why a shelf of notebooks looked alike.

A 45-degree stripe texture was baked into the cover at 1.5% white. It was
meant to be imperceptible; on the darker gradients it bands visibly, and
since covers gained patterns of their own it was a second pattern
fighting the first. Removed.

And the same pair of rings sat in the middle of every one. The mark
belongs to the design now — a ring, a square, a diamond, a dot, a cross,
an arc, three bars — and the designs whose pattern already fills the face
wear nothing at all. Drawn with borders rather than an image, so each
takes its cover's own accent and stays sharp at any size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:44:15 +02:00
marcandClaude Opus 5 83b76b95a0 Make a separator legible on a phone
It was a one-pixel hairline in the border colour with faint grey text on
near-black: present, and on a small screen in daylight effectively
invisible. Marc reported it as missing, which it may as well have been.

Two-pixel rules in a colour that carries, a brighter and slightly larger
label, and room above it so it reads as the end of what came before
rather than as decoration on the note below.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:41:17 +02:00
marcandClaude Opus 5 7cb26898aa Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 02:20:55 +02:00
marcandClaude Opus 5 8ca1ed6e06 Choose a cover in two parts, and let the app be installed
A cover is a colour *and* a pattern now, chosen in turn. Offering every
combination as its own entry meant fifteen colours times a dozen patterns
in a grid nobody could take in — and in practice only the handful someone
had thought to write out. Each pattern is previewed over the colour in
hand, so what is on screen is the cover as it will be. `cover_pattern` is
its own column: they are two decisions, and folding them into one number
would mean renumbering every cover the day a pattern is added.

For one day the patterns *were* twelve appended entries. Those indices
are read back as the colour-and-pattern pair they were drawn as, so
nothing chosen then looks different now.

And three separate reasons the install button never appeared, none of
them in anyone's configuration:

- `beforeinstallprompt` fires once, early — usually before React has
  mounted — and is never fired again. A listener added from inside a
  component simply missed it. It is caught at the top of index.html now.
- A `<link rel="manifest">` is fetched with credentials omitted unless it
  says otherwise. Behind a reverse proxy that authenticates in front of
  the app, that one request comes back as the proxy's login page and the
  browser reads it as an invalid manifest — while the app itself works
  perfectly, because its own requests carry the cookie.
- The service worker cached whatever came back, so a lapsed session could
  be stored as the application itself. The app would then "work offline"
  by showing a login screen it could never get past, curable only by
  clearing the site's data. Only successful, unredirected responses are
  cached now, and the cache name is bumped to drop anything already
  stored under the old rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 02:20:37 +02:00
marcandClaude Opus 5 0d8d1180de Record the digest of the image that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:16:59 +02:00
marcandClaude Opus 5 608da16ac2 Make the phone work: touch reordering, reachable menus, and nothing lost offline
Four things that were built for a mouse on a desk and quietly did nothing
useful on a phone. The last one is the serious one.

**A note made offline lost everything written into it.** Its create
reached the server, the server named it something else, and every edit
still queued behind it went on addressing the name only that device ever
knew — 404, five retries, thrown away. The note arrived empty and
untitled, with nothing to say that anything had been lost. The database
was repointed at the new id all along; the queue was not. It is now, by
replacing the id across the whole queued payload rather than in a list of
named fields, because a list of fields is a list to forget to add to. The
same held for a notebook made offline and everything filed into it.

**The order of notes could not be changed on a phone**, because the list
went through the HTML5 drag API, which mobile browsers never fire from
touch. Pointer events instead, which mouse, pen and finger all produce. A
mouse still drags a row from anywhere; a finger drags from a grip,
because pressing the row has to go on scrolling and holding it has to go
on opening the menu. The row under the finger is found with
elementFromPoint — a finger raises no pointerover on anything it did not
press.

**"Move to..." opened off the side of the screen**, and could not be
opened at all without a hover. It is a tap now, unfolding in place where
there is no room beside the menu and flipping sides where there is. The
menu itself is placed by measuring rather than by assuming it is 220px
wide, its rows are tall enough to hit with a thumb, and Escape closes it.

**Installing said nothing when it could not be done.** The entry appeared
only when the browser had offered a prompt, so where installing was
impossible there was no button and no reason. It is always there now and
names the cause — most usefully that a browser will only install a site
served over HTTPS, which is also why the service worker would not have
registered, so one cause was showing up as two mysteries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:16:41 +02:00
marcandClaude Opus 5 6eecf57247 Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:29:32 +02:00
marcandClaude Opus 5 9e4677c70b Patterns on the covers, a search box that is simply there, roomier margin notes
Four small things about the shape of the app.

Covers can be patterns as well as colours — ruled paper, graph paper,
pinstripe, bands, dots, cross-hatch, linen, washes, a ledger — drawn
entirely with CSS gradient layers. No image files, nothing to upload,
crisp at any size, and offline like everything else. A cover is stored as
an index into that list, so the new ones are appended and never inserted:
renumbering would silently repaint every notebook anyone already has.

Two things the patterns taught. One idea per cover: two crossed diagonals
meant as a chevron came out as noisy plaid at the size a cover is
actually read, and were replaced with plain bands. And the grid has to
scroll — with 27 covers the dialog ran past the bottom of a laptop screen
and took the Create button with it, which a check now holds at 1440x800.

The search box in a notebook is simply there now, and the button that
used to open it is gone. A magnifying glass cost a click and having to
remember that searching was possible at all; a search box is one line and
says for itself what it does.

Margin notes went from 208x74 to 288x112 — from three short lines to
about five. Three lines is a caption, not a remark on a passage, and the
space beside the page was sitting mostly empty.

And the notebook edit dialog was still part German, and would not say
what the server's default number of versions actually is. It names it
now, read from the server rather than written into the interface: it is
set with MAX_VERSIONS_PER_NOTE, and a hardcoded "50" would quietly start
lying the day that changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:29:00 +02:00
marcandClaude Opus 5 635a9632d1 Record the digest of the image that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:27:11 +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 816cc8e68b Record the digest of the image that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:17:55 +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 3ce867c6d0 Stop a drawing from making its note unwritable
The layer showing saved ink lies over the text. The canvas itself is deaf
to the pointer, but the box I wrapped it in yesterday was not — so on any
note carrying a drawing, every click meant for the text was swallowed
before it arrived, and the note simply could not be typed in.

Introduced with per-page drawing, where each page's ink got a wrapper to
be positioned by. The suite now clicks into the text of a note that has a
drawing and types, and asks what actually answers over the writing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:50:32 +02:00
marcandClaude Opus 5 fcdd29ba2b Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:27:18 +02:00
marcandClaude Opus 5 230e55903e Make build.sh executable
It has always been run as ./build.sh, which the file's own header says,
and the bit for it was never set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:26:51 +02:00
marcandClaude Opus 5 8e38404a69 Read a PDF where it is imported, and keep the text with the file
A PDF was searchable on a device once that device had opened it, because
the extracted text lived in that browser's IndexedDB and nowhere else. So
a document imported on the desktop was invisible to a search on the
phone — which is exactly the case that matters for filing invoices, since
those are imported and then never opened again.

The reading still never happens on the server. It has no PDF library and
is not getting one: the bytes arrive encrypted and are stored that way.
The device that imports the file reads it there and then, and the result
is stored beside the file in attachments.text_index, encrypted at rest
like everything else and opaque to the server. Every other device asks
for it after login and can then find a document it has never downloaded.

Not storing it was the earlier decision, on the reasoning that text
derivable from bytes the server already holds should not be kept twice.
True, and useless. A few hundred kilobytes against a file measured in
megabytes is a trade worth making.

A `null` answer is not an error — it means nobody has read this file yet.
Documents extracted before this existed are sent up the next time they
are opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:26:07 +02:00
marcandClaude Opus 5 228552d453 Let a drawing run over as many pages as the note
The ink was stuck on page one while the writing carried on without it,
which stopped making sense the moment a note could run to four pages.
Every page is exported anyway, so every page can be drawn on.

Pages are numbered the way the PDF reader numbers them, and only pages
that carry ink are stored, so the keys are the drawn pages. While drawing
there is always one blank page past the end: pages otherwise come from
how far the writing runs, and to draw on page three you would first have
had to write three pages.

A drawing that lives entirely on page one is still written in the old
single-page shape. Nothing requires that — it means an ordinary note is
stored exactly as it always was, and a device running an older build
still shows it.

The bin clears the page being looked at. Undo and redo work on the
drawing as a whole, because one Undo button that means four different
things depending on where you scrolled is not a button anyone can use.

And a real bug found on the way out: the exporter has written
drawings/<note>.png since the interface was put into English, while the
importer was still looking for zeichnungen/<note>.png. A drawing exported
by this very app came back from its own archive as nothing at all. Both
names are read now, and so are the further pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:25:56 +02:00
marcandClaude Opus 5 b8c5c42217 Record the digest of the image that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:41:43 +02:00
marcandClaude Opus 5 73cc28696f Write down the standing decisions, and start a changelog
The rules this project is worked under — Docker for the backend, straight
to main, no backup feature because the host is backed up, the encryption
stays, no public sign-up — lived only in a conversation. They belong in
the repository, where they survive it.

The changelog answers a question the commit messages cannot: what is
actually running on the server. It carries the image digests, read back
from the registry rather than taken from the push output.

The readme described the note column as left-aligned. It is centred
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:38:58 +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
marc 558c15f2c9 Revert "Give the writing a page to sit on, so the empty half means something"
This reverts commit 7910179f29.
2026-08-11 18:34:50 +02:00
marcandClaude Opus 5 7910179f29 Give the writing a page to sit on, so the empty half means something
The text was capped at a readable width inside a much wider pane, which
left a large void on the right with nothing to explain it. The page is
now a drawn surface: the writing sits on it with a margin, and the space
that was empty is that margin.

Page boundaries used to be a hairline. They are a dashed band now, with
the next page's number in the margin beside it, so a boundary reads as
one page ending rather than as a rule in the text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:21:26 +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 11f37b7921 Let each visibility switch hide only its own thing
The margin notes hung off both switches: `hidden: hideInk || !showPins`, so
turning off the drawing took the markers and their column with it — one switch
turning off both of the things the pair exists to separate.

The checks look at what is actually visible — the ink layer's opacity, how many
markers are on the page, whether the column is there — and test both directions
separately, which is what the old code failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:49:47 +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 0a7cf26b28 Group the two visibility switches, and put the drawing tools back where they belong
"Zeichnung" and "Randnotizen" are two halves of one question — how much of your
own work do you want to see right now — and they sat at opposite ends of the
toolbar, each beside the tool that made them. They are now one pair in one shape
at the right-hand end. Each still appears only once there is something of its
kind to hide, so the pair can be lopsided, which is honest.

The drawing tools had drifted past the pin controls as those were added, so
switching to Zeichnen made them appear somewhere other than where the eye
already was. They follow the button that turns them on again.

A separator no longer carries a ×. It sat a few pixels from the label you click
to rename, putting the gesture that tidies the list next to the one that destroys
part of it — so removal is a right-click behind a confirmation that says the
notes are not affected, which is how everything else in that list is acted on.

218 unit tests, 237 browser checks across eleven suites. The toolbar checks
compare measured positions rather than presence, since the complaint was about
where things were, not whether they existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 01:24:25 +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 502aecfc92 Let a margin remark become a note, and lead to it ever after
Some remarks turn into a page of their own. "Notiz anlegen" on a margin card
makes one, titles it from what was written, and goes there; the pin keeps its id
so the passage leads to that note afterwards. The note is created with a link
back to the document, so the connection reads both ways and the document lists it
among its backlinks — a note born from a passage should not be a note with
nothing on it saying which passage. An existing note can be linked instead, and a
link can be let go of without touching the note. Both are offered on the phone
sheet too.

Two older bugs stood in the way and are fixed:

`openLinkTarget` finds a note's notebook by looking it up in the notes list, and
a note created in the same tick is not in the copy that callback closed over — so
jumping to a fresh note went nowhere at all. Creating one now navigates by the
notebook it already knows.

A drawing save waits 700ms for the next stroke, and leaving the document inside
that window dropped it. That is precisely what turning a remark into a note does,
so the link would have been lost on the way out. The viewer flushes a pending
write when it unmounts, which also covers drawing and navigating away quickly.

215 unit tests, 229 browser checks across eleven suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:40:38 +02:00
marcandClaude Opus 5 011ba82a94 Write pinned notes in the margin, and give the phone a sheet instead
A note about a passage belongs beside the passage, not on top of it. Only a
numbered marker stays on the page now, with a hairline out to a card in the
margin, level with its anchor. Cards keep one height so two notes close together
can be laid out by pushing the second down; the one being written in grows and
rises above the rest.

A phone has no margin to write in — a page narrow enough to read leaves no white
space beside it, and taking a column would make the document unreadable. There
the note comes up from the bottom instead: over the page, only while open, within
reach of a thumb, with the passage still visible above it.

Reserving the column exposed a loop worth remembering. A flex item is as wide as
its content unless told otherwise, so the column made the viewer wider, which
made there be room for the column: the page ended up at 254px inside a 420px
phone while the layout insisted it had 611px. The viewer and the editor pane now
say minWidth: 0, and the toolbar scrolls rather than cutting its last controls
off on a narrow screen.

Splitting each row into page and margin also broke which pages counted as
visible — the check read data-pdf-page off the row, found nothing, and cancelled
every text layer before it rendered. It reads the row's own attribute now.

215 unit tests, 219 browser checks across eleven suites, including the phone
layout at 420px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:46:56 +02:00
marcandClaude Opus 5 6438648313 Pin notes to passages in a PDF, and put the notes control with the rest
The panel beside a PDF is for notes about the document; a note that belongs to
one paragraph is a different thing, and one that has to begin "on page 14, third
paragraph" has already lost its place. Click Randnotiz, click the passage, write.

Pins ride in the same per-page overlay as the drawings, in PDF points, so one
stays on its sentence at every zoom and in the exported file. Living outside the
note body has consequences that each needed handling: nothing else would index
them for search, so the viewer feeds them in with their page numbers; clearing a
page's ink must not take them; and "is this overlay empty?" was answered by
counting strokes — in the client *and* independently in the server, so an
overlay holding only pinned notes was stored as NULL and lost every note on it.

They are stamped into the annotated export as a numbered marker plus a box of
text, on whichever side of the pin has room. Standard PDF fonts are Latin-1, so
characters outside it are replaced rather than failing the whole export.

The Notizen control moves into the viewer's toolbar. A second row for one button
cost reading height on every screen and put one of the document's controls
somewhere none of the others were. On a phone, where the viewer is not on screen
while the notes are, the way back sits in the panel.

Renaming a separator no longer shifts the row: the two rules always share what is
left over equally and the middle keeps one width in both states.

215 unit tests, 206 browser checks across eleven suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:08:20 +02:00
marcandClaude Opus 5 2cee00e38e Fix a permanent black line in the note list, and add separators
The drop indicator was a `borderTop` set in the same style object as a `border`
shorthand. Clearing the longhand resets its width and colour to the defaults
while the shorthand's `solid` stays, so a row kept a 2px black line for good —
measured at rest, before any drag. It is now its own element, which also stops
the list twitching as a row grows and shrinks by a pixel.

That element then turned out to be a dead strip of its own: it pushes the row
down as it appears, so the pointer ended up over the line rather than over the
row and releasing there dropped on nothing. The drag handlers moved to the whole
row and the indicator no longer takes pointer events. A strip below the last row
means "put it at the end", which otherwise needs a drop onto the final note.

Separators are lines drawn between notes, optionally named, stored on the
notebook beside its sort mode. They drag like notes do, and their position is
"how many notes come before me", so they hold their place as the notes around
them are renamed or edited. Shown only in the manual order — in "newest first"
the notes would slide past the line on every edit.

"All Notes" and "Starred" no longer offer a hand-made order: they are questions
asked of every notebook, so there is nothing for an arrangement to belong to.

199 unit tests, 190 browser checks across eleven suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:45:38 +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 a32d6eba99 Export a PDF with the drawings written into it
Stage seven, and the last of the PDF work. A PDF note now offers the
document with your marks stamped in, the untouched original, or just the
notes written beside it.

pdf.js only reads, so writing needs pdf-lib — behind a dynamic import
like pdf.js, so the bundle everyone downloads carries no PDF writer for
the sake of people who never export one. Main bundle grew 6 kB; the
180 kB writer arrives only when used.

The coordinate decision from stage four is what makes this a translation
rather than a reconstruction: strokes were stored in page points from the
start, the only space a PDF understands, so nothing has to be guessed
back from screen pixels. Only the origin moves, PDF putting it at the
bottom-left.

The stored file is never modified — pdf-lib builds a new document — so a
clean copy stays available however often it has been drawn on. The suite
re-fetches the stored bytes after exporting and compares them, and checks
the plain export is byte-identical to what was imported.

Eraser strokes are dropped. On screen they remove ink by drawing in
destination-out; a PDF content stream has no equivalent, and stamping
them would put grey scribbles exactly where something had been rubbed
out.

The suite renders both files with pdf.js and counts dark pixels, 1633
against 2557. A larger file only proves bytes were added; this proves ink
can be seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:49:01 +02:00
marcandClaude Opus 5 7f9f39790f Write notes beside a PDF
Stage six. What you write goes into the note's ordinary content, so it is
encrypted, versioned, linkable and searchable like any other note — a PDF
note is a note with a document attached, not a separate kind of thing.

The panel is composed from the editor pieces rather than reusing
NoteEditor, which carries a whole drawing mode with its own canvas, tools
and undo stack. The PDF already has a drawing layer; a second one behind
the panel would be two places to draw with one of them invisible.

Side by side on a wide screen, two tabs on a narrow one, and either half
can be hidden — the second of the two toggles asked for. Reading the
original without one's own marks in the way is a normal thing to want.

The suite caught a real defect while checking this. A PDF note has two
sources of searchable text — what the reader typed and what the document
says — written at different moments by different code, both calling
setEntry. Whichever ran last was the only searchable half: typing a note
dropped the document's text, and reopening the document dropped the note.
The index now holds the two apart and replaces only the half it is given,
with six unit tests covering each direction, plus the snippet falling back
to the document so a hit inside a PDF still shows its sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:29:39 +02:00
marcandClaude Opus 5 b12b0d6142 Select text in a PDF, and find PDFs by their contents
Stage five. pdf.js draws pages as pictures, so selecting means laying
transparent spans over them exactly where each word was painted. The
layer carries the same scale as the canvas — they drift apart otherwise
and a selection lands on the wrong words. While drawing it stops taking
pointer events entirely, or a stroke would come out as a selection.

The text is also extracted and handed to the index that already holds the
notes, so one search box finds a sentence whether it was typed or
imported. Extraction runs after the document is on screen and yields
between pages: it must never stand between the reader and the first page.

The extracted text is deliberately not sent to the server. It is
derivable from a file the server already holds, so storing it again costs
space and a second thing to encrypt for nothing gained. The price, stated
plainly in the readme, is that a PDF becomes searchable on a device once
that device has opened it — the same shape as the limitation notes have.

Text runs are joined on pdf.js's hasEOL rather than concatenated. Without
it the last word of a line glues to the first of the next and matches
nothing when searched for.

The minimal text-layer CSS is written out rather than importing
pdfjs-dist/web/pdf_viewer.css, which carries the whole reference viewer's
toolbars and dialogs.

Suite now checks the layer sits over the page rather than stacked in a
corner, that the text is transparent, that dragging selects real words,
that a word from inside the file finds the note and one that is not in it
finds nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:12:06 +02:00
marcandClaude Opus 5 1be3a9b9ba Draw on PDF pages
Stage four. Strokes are kept per page in that page's own coordinate
space — PDF points at scale 1 — not in the fixed 1200x1600 space a note's
drawing uses. A PDF cannot use one space: every page has its own size,
and a stroke belongs to the page it was drawn on.

That is what keeps a mark on the word it was drawn over at any zoom and
on any screen, and it is the only form that can be stamped back into the
file in stage seven, where page points are the only coordinates that mean
anything. Measured rather than assumed: zooming moves a stroke by 0.001
of the page.

Pen sizes are scaled by the page width. The sizes are calibrated for the
1200-unit note page, so on a 595-point A4 page the same number would have
drawn twice as thick.

Reading and drawing are separate modes. While reading the ink layer takes
no pointer events, so scrolling and selecting text reach the page beneath
it; while drawing, a finger still scrolls and only a pen or mouse marks,
because a touch-drag on a phone means scroll far more often than it means
draw. The drawing can be hidden without being erased, for reading the
original clean.

The server accepted only the single-page shape; it now takes either and
counts strokes across pages for its limit.

Carried over from notes: the save token guard, so the six-second poller
adopting a response issued before the local save cannot undo what was
just drawn. The suite waits nine seconds and checks the ink is still
there — that bug has cost strokes twice before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:54:10 +02:00
marcandClaude Opus 5 2986bcb620 Read imported PDFs in the app
Stage three: the viewer. Drawing on pages comes next.

pdf.js stays out of the bundle everyone downloads — it is larger than the
rest of the application put together, so it is behind a dynamic import and
a session that never opens a PDF never fetches it. Main bundle grew by
8 kB for the viewer shell; the 162 kB library arrives only when needed.
The worker is bundled rather than pulled from a CDN, because a viewer
that stops working during someone else's outage is not one you can rely
on offline.

Two things that only showed up once it ran in a browser:

- pdf.js 6 calls Uint8Array.prototype.toHex, which only very recent
  browsers have. Anything older failed with "n.toHex is not a function",
  which says nothing about the cause. Now on the legacy build, which
  compiles those away — a little larger, and the difference between
  working everywhere and working on this year's browsers.
- nginx has no MIME type for .mjs and served the worker as
  octet-stream, which a browser refuses to run as a module.

Pages are laid out at their true proportions before anything is drawn, so
the scrollbar is honest immediately and a document mixing portrait and
landscape keeps both — the first version scaled every page to the widest
one, which the suite caught by checking the two aspect ratios differ.

Bytes come from the authenticated attachment route and are cached in
IndexedDB. Content-addressed storage means a cached copy can never be
stale, so a document opened once needs no connection again. Verified with
the network switched off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:36:04 +02:00
marcandClaude Opus 5 53965281b4 Store imported PDFs as encrypted, deduplicated attachments
Stage two of PDF support: the file can now get in and out. No viewer
yet — that is next.

A PDF is an attachment like any other, so it inherits everything already
built: encryption at rest, naming by content hash, deduplication, the
orphan sweep, and the authenticated retrieval route the client already
caches offline. The note points at it through notes.pdf_attachment_id.

Raw request body rather than the base64 JSON images travel in. A scanned
book is tens of megabytes and base64 adds a third to that, on a path that
carries nothing but the file. The bytes are checked for %PDF- before
anything is written, so the declared content type is never trusted.

Images and PDFs are limited separately — INKNOTE_MAX_IMAGE_MB (25) and
INKNOTE_MAX_PDF_MB (200) — because a 25 MB picture is nearly always a
mistake and a 25 MB document is not. Both configurable; the limits exist
so a runaway upload cannot fill the disk, not to ration anything. The
JSON body limit now follows the image limit instead of being a second
number that could drift away from it.

Verified against a real PDF: uploaded, stored with the INKENC01 header,
returned byte-identical, deduplicated on a second upload, a GIF renamed
to .pdf refused with 415, unauthenticated refused with 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:06:34 +02:00
marcandClaude Opus 5 915856e1a4 Encrypt uploaded files at rest, like note bodies already were
Note text was encrypted from the start; the files beside it were not. A
copy of the uploads directory handed over every image in the clear while
the notes referring to them stayed unreadable — half a protection, and
the half that is easiest to walk off with.

Same key and cipher as note content, so a backup is worth exactly as much
as its .env either way, which was already true and is now true uniformly.

Files carry an INKENC01 header and say for themselves whether they are
encrypted. That is what makes the startup sweep idempotent, and it had to
be the file rather than a database row: attachments are shared between
users by content hash, so no single row owns one.

Named by the hash of the plaintext, so identical uploads still collapse
into one copy — a fresh IV per file would have defeated deduplication.
Read whole rather than streamed, because GCM only proves integrity at the
last block. Written through a temporary name and renamed, so a crash
cannot strand a half-encrypted file that would fail its tag forever.

Verified: the one existing attachment went from 78 to 114 bytes with the
header in front, a second start did nothing, and images still load in the
browser after a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 19:11:08 +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 8c36c6121b Ask what a single note's export should contain, and fix drawings never being in one
Two questions instead of one, because the answers depend on each other:
Markdown holds text only, a PNG holds the drawing only, PDF holds both.
Listing every format flat would have offered combinations that cannot
exist. A note without a drawing skips the first step rather than showing
it mostly greyed out.

While testing that, the drawings turned out never to have made it into
any export at all — not the ZIP, not the PDF. strokeDocOf() *builds* a
stroke document from a bare array, and it was being handed one that was
already a document, so the strokes ended up nested one level too deep and
every length check read undefined. Nothing complained; the export simply
came out without the drawing. It went unnoticed because the notes the
earlier suite exported had none.

Now uses docStrokes(), and the suite draws on a note before exporting it,
checks the PNG's magic bytes, and checks it is 159 KB rather than an empty
white page — a check on the file merely existing would have passed against
the broken version too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 17:37:22 +02:00
marcandClaude Opus 5 2611d87f8c Export notes as Markdown or PDF
The reason to build this is not a missing feature — it is that trusting a
program you maintain alone with years of notes should not mean the notes
can only ever be read by that program.

Reachable from a note's toolbar, a notebook's context menu, and Account →
Alle Notizen exportieren.

Markdown carries over everything with an equivalent: headings, lists,
quotes, code, tables, and task state as `- [x]`. A link to another note in
the same export becomes a relative link to its file; a link to a note
outside it degrades to the plain title rather than leaving a dead
reference. Notes with images or a drawing arrive as a ZIP so the files
travel along, and a multi-note export gets a README index.

PDF goes through the browser's print dialog rather than a bundled PDF
library — the browser already has a good one, and it is the only format
where drawings sit inside the document. Markdown gets them as a PNG
beside the text, with a line saying so, because dropping them silently
would be the wrong kind of quiet.

Two decisions worth recording:

- the ZIP writer is eighty lines here rather than a dependency. Note text
  is small and images are already compressed, so compression bought
  nothing, and stored entries are a handful of fixed-layout records.
- Markdown escaping only covers what changes meaning mid-line, plus a
  leading list or heading marker. Escaping every character Markdown ever
  uses turns a sentence into `Hallo\. Alles gut\!`, which defeats the
  point of choosing a readable format.

42 unit tests on the serialiser and the archive format, plus a browser
suite that unpacks a real download and reads the bytes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 14:46:53 +02:00
marcandClaude Opus 5 311e992496 Move the browser suites into the repository
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>
2026-08-08 14:30:08 +02:00
marcandClaude Opus 5 41ba99c017 Node 20 → 24, and say what is wrong when the data directory is not writable
Node 20 left maintenance in April 2026, so a production instance was
running an interpreter that no longer gets security fixes.

The upgrade needed better-sqlite3 as well. On Node 24 the version we
pinned (^11) aborts inside the native addon —

  node[1]: void node::RemoveEnvironmentCleanupHook(...) at hooks.cc:142
  Assertion failed: (env) != nullptr

— so bumping only the base image would have shipped a server that dies on
shutdown. Raised to ^12, which handles Node 24; verified against a copy of
a real database that existing encrypted notes and version history still
decrypt, and that SIGTERM now closes cleanly.

Separately: a bind-mounted data directory the container cannot write to
surfaced as "SqliteError: unable to open database file" out of
better-sqlite3, which points at the database when the problem is on the
host. Startup now checks both directories and says which uid it runs as,
who owns the directory, and what to run to fix it.

Frontend build now copies the lockfile and uses npm ci, so the image is
built from the dependency versions that were tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:40:45 +02:00
marcandClaude Opus 5 982de53c77 Explain the ${VAR:-fallback} syntax where it is actually read
The line reads as if /srv were appended to whatever you set. Says so
right above it now, and drops the comment block that repeated the mkdir
instruction already in the header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:25:09 +02:00
marcandClaude Opus 5 6dcb77fa73 Shorten .env.example; move the long explanations to the readme
76 lines of commentary for five settings was more wall than help. The
file now says what to set; the reasoning behind the data directory, and
the two ways of putting it under /root, live in the readme where there is
room for them.

Also brought the deployment section up to date: it still told people to
export secrets by hand and run docker-compose, with no word about the
first admin, which is now the step that decides whether you can log in at
all.

INKNOTE_DATA_DIR stays commented out — one .env serves both compose
files, so setting it would drag local development off ./data too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:17:30 +02:00
marcandClaude Opus 5 4e93259c04 Document putting the data directory under /root
/root is mode 0700 and the container runs as uid 1000, so it cannot even
traverse it — the server fails to write no matter who owns the directory
underneath. Both ways out are written down, with the trade each makes:
chmod o+x /root keeps the unprivileged container and still refuses ls,
running as uid 0 avoids the chmod but puts a break-out on root.

Also noted that the path has to start with / or ./, since anything else
is read as a named volume name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:13:45 +02:00
marcandClaude Opus 5 a61e6901b1 Move .env.example where Compose actually looks, and make it true
It sat in backend/ while Compose reads .env from the repository root, so
the "cp .env.example .env" in the compose header pointed at nothing.

It also documented four settings that Compose never forwarded to the
container: ALLOWED_ORIGINS and the three version-retention knobs would
have been set in .env and silently done nothing. They are passed through
now, unset meaning the server's own defaults. Checked both directions:
every variable Compose reads is documented, and nothing documented is
ignored.

Dropped PORT from the example — the bundled nginx proxies to 4000 by
name, so changing it could only break that. Added the data-directory and
uid/gid settings, which were read but undocumented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:09:29 +02:00
marcandClaude Opus 5 d01aa85ff9 Clean up the production compose file
Three leftovers: a named-volume block that nothing has used since the
move to bind mounts, the obsolete version key, and a comment claiming the
admin variables only apply to an empty database — they also decide whom
to promote when upgrading one that has users but no admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:05:21 +02:00
marcandClaude Opus 5 68e184163e Remove public sign-up; accounts are created by an administrator
This is a private instance, so anyone able to reach the login page could
previously give themselves an account. The registration route is gone
rather than hidden, and the login screen no longer hints that accounts
can be made.

Accounts now come from an admin under Account → Administration: create,
delete, and reset a password. Appointing further admins is deliberately
not offered — with one admin it is not needed, and it is the one power
that turns a compromised account into a lost instance.

The first admin comes from INKNOTE_ADMIN_USER / INKNOTE_ADMIN_PASSWORD,
used only when the database has no users, or to choose whom to promote
when an older database has users but no admin. Once an admin exists both
are ignored, so leaving them in .env cannot reset anything.

Two things that would otherwise have been quietly wrong:

- a deleted user's token stayed valid until it expired, because tokens
  are signed rather than stored and there was no session to revoke.
  authMiddleware now checks the account still exists, so an open tab
  stops working the moment the account is deleted.
- deleting an account takes its notes, notebooks, drawings and version
  history with it via ON DELETE CASCADE. Verified: no orphaned rows in
  notes, notebooks, sync_log, attachments or note_versions.

Deletion asks for the username to be typed back, since it destroys data
that version history cannot bring back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:53:08 +02:00
marcandClaude Opus 5 55a6b3caab Correct the registry namespace to the Docker org
The previous commit moved the images to honey/, which was wrong: the
images have lived under the Docker organisation since February, alongside
learning-tracker and whopays, and are linked to this repository there.
Only the placeholder 'marc' was never real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:05:48 +02:00
marcandClaude Opus 5 dc33d614ad Point the registry at the namespace that actually exists
The images were tagged gitea.../marc/..., a placeholder that was never
changed and is not a Gitea account, so every push failed with a 404 and
the images had in fact never been published. The registry owner is honey.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:02:19 +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