Dieselben Namen wie in Aether. Die Leiste gehoert keiner App, sie ist ueberall
dieselbe Sache — dann soll sie auch ueberall gleich heissen. Ein zweiter Name
fuer dieselbe Einstellung ist eine Fehlerquelle beim Umziehen und beim Suchen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A strip along the top that leads to the other apps of the same system. It is
served by HoneyOS; InkNote only says where to find it, through
INKNOTE_HONEYBAR_ORIGIN. Empty means no bar and no change at all.
The origin is served by the API rather than built into the page, because an
image is pulled ready-made and a service worker keeps whole responses: anything
frozen into index.html would outlive the setting.
Layout follows the bar through --honeybar-h. The body has overflow:hidden, so
the surplus height would not have scrolled but been cut off at the bottom; and
#root padded itself around the notch that the bar now covers, which would have
counted that gap twice.
Unit 263/263, all fourteen browser suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The picture fills the account button now — twenty pixels of a face is a
smudge — and light or dark moved out of the sidebar corner into Account →
Profile, next to the typeface and how a chapter opens, which is the kind of
thing it is.
Moving it made it worth keeping: it was pure screen state, so every reload
landed on dark whatever had been chosen. It is a setting on the account now,
like the typeface, with a copy in the browser so the page does not start in
one theme and turn to the other once the account arrives.
The check I first wrote for the sidebar losing its switch could not fail. It
counts the buttons in that corner instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two assumptions, both mine, and either one was enough for an account to end
up with no picture. It was looked for only in the ID token, which a provider
is free to keep lean while holding the rest at userinfo — PocketID does
exactly that — and the endpoint was never asked. Then the image was fetched
with no credentials, while avatars are commonly behind the provider's own
login.
Userinfo is now asked when something is missing, and believed only when its
sub matches the token whose signature was checked. The access token rides
along with the picture request when, and only when, the picture lives at the
provider.
The third fault was the one that cost the time: it failed silently, so there
was no way to tell which of the two it was. Every outcome is a line in the
log now. And the test provider keeps its picture out of the token and behind
its door, so the check passing is evidence for both new paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The password tab was hidden for accounts with no password, which is not the
same as accounts that sign in through the provider — an account linked to one
kept its password and therefore kept the tab. That is precisely the account
the person setting this up is using. It goes by "signs in over there" now; the
password it kept is the emergency door, set by an administrator.
And the picture. The provider sends one, the server fetches it at each
sign-in and stores it encrypted in a table beside the users rather than in
them, and serves it back through the API — so the page never talks to the
provider, no third-party cookie is needed for a face to appear, and it
survives the provider being down like everything else here. Fetched once per
account into an object URL, since an img tag cannot carry a bearer token.
The suite's provider now serves a picture too, and the check asks the browser
whether it actually decoded rather than whether the element exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Has an identity at the provider" and "has no password here" are two
different questions, and the interface was answering the second while being
asked the first: an account that existed before and was linked — the usual
case for whoever sets this up — carried no mark at all in the administration
list.
The server now reports both. The list marks every linked account, says which
of them have no password, and offers to give one rather than to reset
something that is not there. The account panel names the provider it signs in
through, which is also the missing explanation for the password tab that
disappeared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OpenID Connect, off unless four settings are present. It is a second way to
the same token the password login mints — every route and the WebSocket
still know only a signed JWT — which is why it fits in one file and needs no
new dependency: Node imports a JWK itself, so checking an ID token is a
signature and five claims.
Authorization Code with PKCE, exchanged on the server so the client secret
stays there. The browser comes home with a one-time ticket rather than the
token, because a token in a redirect URL sits in the history, in the proxy's
log and in the referrer of the next request.
Somebody the app has not seen gets an account; an account that already
exists under that name is linked to the identity rather than duplicated, by
subject from then on. Never an administrator. An account made this way has
no password here, and the interface stops offering one — but an admin can
still set one, which is the way back in if the provider is ever gone.
The suite signs in at a provider of its own that signs real RS256 tokens,
and holds down the four ways the journey must fail as well as the way it
must work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A quote may hold whole blocks, and they now have room: the rule that gives
the writing its rhythm reaches only the editor's own children, so inside a
quote a heading, a list and a closing line stood jammed together. Leaving as
Markdown they were written on consecutive lines, which every other reader
folds into one — the sentence after a quoted list became more of the last
bullet. They are divided by a quoted blank line now, the same rule the top
level has had since 11 August.
The marks were two copies of one list, in two dialogs, and only one of them
had ever been extended. One list, one grid, twice as many marks, divided
under headings — and anything else can be typed into the search box, which
on a phone is where the emoji keyboard lives.
The changelog is a third of its length: one entry per day rather than one
per image, since a round that repaired what an earlier round the same day
broke reads as noise afterwards. Every digest is still listed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Grey letters ran down the bar while dragging a column edge. The editor marks
the column under the pointer by putting a small div in each of its cells;
drawn along the cell's right edge, that absolutely positioned child sent
Chromium down another painting path and the cell's own text came out a second
time, squeezed into the two pixels under the bar, one letter to a line. It
appeared with the bar and went with it, which named the culprit -- measured,
there was no text there at all, only three empty markers, and a forced repaint
did not clear it. The marker is display:none now and only says which cells the
column is made of; the line is an inset shadow on those cells, matched with
:has(). Two suspects were tried and reverted first: the table as its own paint
region, and separate borders. Neither changed anything, so neither stayed.
Opening a note flashed a long narrow page. The drawing stage starts at the
canonical page, 1600px tall, and the measurement that fits it to the pane ran
as an ordinary effect -- a frame too late. useLayoutEffect runs between the DOM
being built and the browser painting it, which is exactly the gap that flash
lived in. The test samples the sheet's height every frame across a note switch,
because one frame is all it was.
The mark above a note did not stand in the same column as the tools below it.
Vertically it was already exact -- measured, symbol centre 20, cap centre 20 --
but it began six pixels right of the first tool, and the title thirty. The
mark's box is only as wide as the mark now, so the mark lands on that column,
and the title follows nine pixels closer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Two things wrong with the paper.
"Start writing..." was printed at the foot of every page that had a break
after it. The placeholder extension styles `[contenteditable]:empty::before`,
and the spacer holding a gap open is exactly that: empty, and not
editable. It inherited a placeholder meant for an empty note.
And the column was too narrow for the sheet it now sits on — about 73
characters, which left 117px of blank paper down each edge and made the
writing look lost. Widened to about 80, a little short of where the eye
stops reliably finding the start of the next line, which leaves roughly
8% of the sheet as margin: a document's margin rather than a picture
frame.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The writing surface was a column of text in an empty pane. A sheet was
drawn behind it, but only once you started drawing — so writing looked
like text floating in the middle of nothing, and the space either side of
the column read as a void rather than as a margin.
Now every page is a sheet of its own, centred, with a gap after it: the
same way the PDF reader draws its pages, because a page in a note and a
page in a document should read as the same kind of thing.
The gap is the part that takes work. The browser knows nothing about the
sheets and lays the text out as one flow, putting a line straight across
the gap and cutting it in half. So editor/pagination.js measures the flow
and pushes any block that would straddle a gap onto the next page, with a
spacer of exactly the right height in front of it. A paragraph moves down
entire, which is what a page break means on paper. A block taller than a
page is left to run over, since splitting one would mean laying out its
insides ourselves.
The spacers are decorations, not content: they never enter the document,
never reach the server and never appear in an export. Two checks hold
that down — one asks whether any block sits in a gap, the other reads the
stored body back and looks for them there.
Two things that are easy to get wrong. The spacer's height is not the
distance the text moves: margins that used to collapse into each other no
longer do with something between them, so the layout gains that much for
free and the spacer is made exactly that much shorter. And heights change
with no transaction to announce it — a webfont landing, an image
arriving, the window resizing — which a ResizeObserver on the editor
element catches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>