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>
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>
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>
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>
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>