3b7958b7136482dd3483d794abc11c11eed3f6ec
41
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3b7958b713 |
A chapter with an order of its own
A chapter orders its own notes now, chosen in the same dialog as its name, mark and colour -- one decision about one thing. The notebook's setting governs the notebook's own list; a chapter is a place someone made and filled, so a reference chapter can stay alphabetical while the notebook around it stays as it was arranged. It starts hand-made, and a chapter put in one of the other orders loses its lines and its handles exactly as the notebook does: neither means anything against an order the app is choosing. A line still would not go under another line, and the comparator was only half of it. The write was then thrown away by the "did anything change?" test, which sorted the separators before comparing them -- to ignore the other chapters' lines riding along in the same field -- and a sorted comparison cannot see a sequence. Two lines in one gap carry the same position, so swapping them changes nothing else. It compares this list's own lines, in order. An open chapter swelled while rearranging: the strips at the two ends of its list stood open for the whole of the mode, adding half a finger of empty space inside every chapter. They are thin now, and only there while something is actually being carried -- which is also why the suite presses first and aims afterwards. Server: sort_mode on chapters, validated against the same five modes as a notebook's, and set outside the COALESCE so null means the hand-made order rather than "leave it alone". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f01522aa6f |
A chapter is a list too, and deleting stops moving things
Deleting a note moved everything else. A row leaving is the same problem as a row arriving, in reverse: a line's position counts the rows above it, so a note deleted above a line left the line counting rows that were no longer there, and everything under it appeared to jump up past it. withoutRow renumbers the list a row leaves, and every place one leaves says so -- the bin, a move, a chapter being removed. Arrivals land at the top: an old position belongs to an arrangement that has moved on. A line could not be dragged under another line. Two lines in the same gap carry the same position, and the comparator claimed "a separator comes first" without asking what the other one was -- both directions at once for that case. A comparator that contradicts itself has no defined result, so the sort returned them in whichever order it happened to walk. A chapter is always in the user's own order and can be arranged like any other list: its notes wear handles under the same switch as the list beside them and are dragged among themselves, strips at both ends included. The two lists keep apart by scope -- a chapter unfolded under its row holds rows inside a row of the notebook's list, so the nearest row above the pointer cannot say which list a drag belongs to. The sort menu is gone inside a chapter: the mode belongs to the notebook, and a menu there would offer a choice that cannot be honoured. The chapters suite no longer assumes the first chapter row is the one that matters. A chapter is a row in the arrangement and can be anywhere in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
c96885d5b3 |
A notebook opens in its own order
The hand-made order is where a notebook's own arrangement lives: separators show, rows can be put where they belong, nothing slides about by itself. Starting in "last changed" made both of those look broken until the sort menu was found — pressing "+ Separator" there drew a line the order cannot display, so the button appeared dead. A notebook nobody has arranged still reads newest-first, because untouched rows all carry position 0, so nothing is lost by starting there. The views keep "last changed": they are questions asked of every notebook at once and have nowhere to keep an arrangement. The button that draws a line is now only offered in the order that can show one. Removing a chapter left you stuck in the confirmation. Every destructive action goes through one helper that takes the question down again; this one set the dialog directly and bypassed it, so the chapter was removed and the prompt stayed over the app. Closing now happens where the dialog is rendered, so it happens whoever asked and however they asked. The suites did not catch that, and the reason is worth keeping: they read the list straight through the overlay, which is in the DOM the whole time, and passed while the screen was in fact stuck. Both now assert the question is gone afterwards. The separator is no wider than its name. It sat in a fixed 132px slot — chosen so the row would not jump when renaming began — with a short name floating in a gap it did not fill. Thirteen suites green: 345 browser checks, 241 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
165ccf3cf7 |
A chapter you can see, in the colour you chose
A chapter did not show until the notebook had a note in it. "Nothing here" was asked of the notes alone, so a notebook whose only contents were chapters showed the empty-list panel with the chapters hidden behind it — making a chapter in a fresh notebook appeared to do nothing at all. It is asked of every row now, and a notebook with chapters but no loose notes says so in one line rather than looking like a list that failed to load. The colour is chosen while the chapter is being made, in the dialog that asks for its name. It is what tells one chapter from the next, and having to right-click a fresh one to say which it is meant two visits for one decision. Renaming and recolouring afterwards are one entry in its menu and one write; the server already took the colour on create, the client simply never sent it. The word "here" is gone. The line along the top of the pane says which of the two places you are in, and saying it twice was noise. Thirteen suites green: 338 browser checks, 241 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b2a5f051fe |
A filed note stays filed, and the handles are asked for
Three things that all came from chapters being thought through too late. A note put into a chapter came back out of it a few seconds later. The server had it right the whole time; every sync rebuilds the local copy from the server's answer, and the function doing that never carried chapter_id — so each sync handed back a note belonging to no chapter and the notebook took it in again. The bin's query left the column out too, which would have unfiled anything restored from it. A separator drawn in a notebook vanished when a chapter was opened. "Which chapter is open" and "which list is on screen" were one value. Opened beside or below, the list still shows the notebook — but everything around it had switched to the chapter, so the notebook's own separators were filtered away and a drag among its rows was measured against the chapter's notes, found nothing, and did nothing. And where a new note lands is now visible and yours to choose: pressing in either place makes it the one being worked in, marked with a line along its top and the word "here", and the + button names it. Rearranging becomes a mode, switched on beside the sort menu — until then no row wears a grip and none can be dragged. Filing a note is back in the row's own menu, where starring and exporting already are; the button that had appeared in every row is gone with its dialog. The account panel closes with Escape. Thirteen suites green: 335 browser checks, 241 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d74fea4905 |
Chapters as rows, in two views, in their own colours
Three things Marc asked for, and one of them turned out to be the hinge
the other two hang from.
**A chapter is a row like any other.** Same grip, same drag, same place
in a hand-made arrangement as a note or a separator. That needed notes
and chapters numbered from *one* counter: numbered separately they are
two sequences that cannot be interleaved again, and a chapter dragged
between two notes springs back to the top. Positions therefore have to be
named rather than read off an array index, so POST /notes/reorder now
takes `positions: [{id, order}]` beside the older `ids`, and chapters
have a reorder route of their own.
**Two ways for a chapter to open**, chosen per account beside the
typeface, for the same reason that one lives there: it is a choice about
how your own notes are laid out and it should follow you to the phone.
Beside the list, its notes in a further column the way OneNote lays out
pages — the notebook's own list carrying on to the left, chapter rows and
all. Or unfolded under the chapter's own row, indented against a rule in
its colour. A phone has room for neither and fills the list instead, with
a way back, which is what it did before there was a choice.
**Its own colour**, from eight named ones rather than the full palette:
the point of chapters is that they are different places, and twenty
near-identical blues do not help anyone tell them apart.
The colour is validated on the way in — it is written by a browser, so
only a plain #rrggbb gets through and anything else becomes no colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
632ea5dcd9 |
Move a note in one press, and stop Draw inventing a page
Moving lived under a right-click, behind a submenu: two steps before the list of places appeared at all, and on a phone a long press followed by a flyout. There is a button on the row now, and it opens one screen with the chapters of this notebook and the other notebooks together, marking where the note already is. The spare sheet in Draw mode was drawn as solid paper, so pressing Draw on a one-page note made a second page appear from nowhere while the count still said "of 1". It exists for a good reason — pages otherwise come only from how far the writing runs, so drawing on page two would mean writing two pages first — but it should look like the offer it is. Dashed outline, half opacity, and it says what it is for. It counts once something is on it. And the two buttons above the list did not match: the one I added last round was larger and bolder than the one it sat beside, and the older one's target was nineteen pixels tall, which a finger misses. Both are one shape now. A separator's label matches a chapter's name in size for the same reason — two type treatments in one list is noise, and the difference was not saying anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1757ec9af3 |
Chapters: folders inside a notebook
A note is in exactly one chapter, or in none — which means the notebook itself. Chapters sit above the loose notes; tapping one opens its own list and the menu button becomes the way back out, which on a phone is the only way, since the list fills the screen. A chapter is not a wastebasket. Removing one never removes what it held: the notes come back to the notebook, where they can be seen and filed again. That is ON DELETE SET NULL, and it is also written out in the delete route, because it is the promise the whole thing rests on and should be visible where it happens rather than inferred from a schema. The dialog says so too, with the number of notes affected, and the suite removes a chapter holding three notes and asks the server for all three. Three rules keep the two levels honest. A note filed into a chapter leaves the notebook's own list, or filing it would appear to do nothing. A chapter belongs to its notebook, so the server refuses to file a note into a chapter of a book it is not in, and moving a note to another notebook clears its chapter. And separators belong to the list they were drawn in: a separator's order counts the notes before it, which means nothing across lists. Two bugs found while building it, both a day old and both affecting the desktop rather than the phone they were written for: - A submenu was clipped away. The context menu had been given `overflow-y: auto`, which cuts off anything flying out sideways — so the notebooks under "Move to..." were invisible and every click on them landed on the note list behind. The side was inverted too: `right: 100%` places a panel on the left. - A separator was a one-pixel hairline in the border colour with faint grey text on near-black. Present, and on a phone effectively invisible. And one test that never measured what it claimed. It asked whether a dragged note had become "the most recently changed" by reading the order on screen — but three notes made seconds apart share an `updated_at` second, so that order is a tie broken arbitrarily. It passed on luck for weeks and fell over on a run a fraction faster. It now compares the timestamps themselves, before and after the drag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |