Quotes that hold more than a sentence, and one list of marks

A quote may hold whole blocks, and they now have room: the rule that gives
the writing its rhythm reaches only the editor's own children, so inside a
quote a heading, a list and a closing line stood jammed together. Leaving as
Markdown they were written on consecutive lines, which every other reader
folds into one — the sentence after a quoted list became more of the last
bullet. They are divided by a quoted blank line now, the same rule the top
level has had since 11 August.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-17 22:15:55 +02:00
1 parent c0523f3ba4
commit c86c63922a
14 files changed
+742 -887

No files matched your search

+199 -649
View File
@@ -1,198 +1,101 @@
# Changelog
What changed, newest first. One entry per round that went out as an image, with
the digests, so it stays possible to tell what is actually running on the server.
What changed, newest first — and the digests of every image that went out, so it
stays possible to tell what is actually running on the server. Digests are read
back from the registry (`docker buildx imagetools inspect`), never taken from the
output of `docker push`.
Digests are recorded from the registry (`docker buildx imagetools inspect`),
not from the output of `docker push`.
Kept short on purpose. A day's work usually went out as several images, and a
round that repaired what an earlier round the same day had broken says nothing
worth keeping on its own — so each day is one entry, describing where it ended
up, with every digest of that day listed. Where the server image is named as
unchanged in substance, the digest still moved: `build.sh` builds both images and
a rebuild is not bit-identical.
---
## 2026-08-17 — Three things that only showed on screen
## 2026-08-17 — Quotes that hold more than a sentence, and one list of marks
**Images** — web `sha256:30f67832…`, server `sha256:6ffe4b58…`
No backend source changed this round.
- **A quote may hold whole blocks — and now has room for them.** A heading, a
list and a closing line inside a quote are ordinary blocks, but the rule that
gives the writing its rhythm reaches only the editor's own children, so inside
a quote everything stood jammed against its neighbour, with the items of the
list further apart than the blocks around them.
- **The same divide when it leaves as Markdown.** Blocks inside a quote 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 separated
by a **quoted** blank line now — `>` on its own, since an empty line would end
the quote — which is the same rule the top level has had since 11 August, and
reading one back gives the blocks it was written with, quotes inside quotes
included.
- **Marks are one list again.** There were two copies, in the note dialog and the
chapter dialog, and only one of them had ever been extended — so which marks
existed depended on which dialog you happened to open. One grid, and with it
twice as many marks, **divided under headings** now that there are too many for
one wall of pictures.
- **A mark nobody thought of can simply be typed.** The search box is a text
field, which on a phone is exactly where the emoji keyboard lives, so anything
pasted or typed into it is offered as a tile — one glyph with its modifiers, the
same eight code points the server keeps.
- **The changelog is a third of its length.** A day usually went out as several
images, and a round that repaired what an earlier round the same day had broken
reads as noise afterwards. One entry per day now, saying where it ended up, with
every digest of that day listed.
---
## 2026-08-17 — A notebook arranged by hand
**Images** — web `sha256:c5e460cf…`, server `sha256:ac3b0527…`
(earlier that day: web `b7c9f8b7…`, `c1bcdcc4…`, `b8bc284a…`, `18a68a2d…`;
server `a0fd140a…`, `6c9af404…`, `a66cc8eb…`, `febc4b93…`)
No backend source changed this round; the server digest moved only because
`build.sh` builds both and a rebuild is not bit-identical.
The server gained one column: `sort_mode` on chapters.
**Fixed**
- **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 is what named the culprit —
measured, there was no text there at all, only three empty markers. The marker
is never drawn now; it only says which cells the column is made of, and the
line is an inset shadow on those cells.
- **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. Every note switch drew one frame at
the wrong size and then righted itself. Measured before the first paint now.
- **The mark above a note did not stand in the same column as the tools below
it.** Vertically it was already exact; sideways it began six pixels right of
the first tool, and the title thirty. The mark's box is only as wide as the
mark, so the mark itself lands on that column, and the title follows nine
pixels closer.
**Arrangement**
- **A drop lands the row *under* the one it was released on, and only there.**
Dropping above a row and below its predecessor are the same move described
twice, so every gap could be aimed at from two sides. A strip **above the first
row** covers the one place a drop *under* something cannot reach.
- **A new note appears at the very top and nothing else moves.** A row arriving
without a position left every separator counting rows that were no longer above
it, and a little more of the order came undone with each note. Every way in and
every way out — new, restored, moved, binned, or a chapter removed — now
renumbers the list.
- **A chapter is a place *and* a neighbour**: its header takes a row dropped on
it, its bottom edge is the gap under it. It is always in the user's own order,
and its notes are dragged under the same switch as the list beside them.
- **A chapter can be given an order of its own** — in the same dialog as its name,
mark and colour. In any order but the hand-made one it loses its lines and
handles, as the notebook does.
- **Two lines could not be put in one gap.** They carry the same position, and the
comparator claimed both directions at once for that case; the write that fixed
it was then discarded by the "did anything change?" test, which sorted before
comparing and so could not see a sequence.
---
## 2026-08-17 — A chapter with an order of its own
**Images** — web `sha256:b7c9f8b7…`, server `sha256:a0fd140a…`
The server image changed: `sort_mode` on chapters.
**Added**
- **A chapter orders its own notes**, chosen in the same dialog as its name, mark
and colour — they are 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 in the hand-made order, and a chapter put in one
of the others loses its lines and its handles exactly as the notebook does —
neither means anything against an order the app is choosing.
**Fixed**
- **A line still would not go under another line.** 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 — which read as though the notes in it had grown. They are
thin now, and only there while something is actually being carried.
---
## 2026-08-17 — A chapter is a list too, and deleting stops moving things
**Images** — web `sha256:c1bcdcc4…`, server `sha256:6c9af404…`
No backend source changed this round; the server digest moved only because
`build.sh` builds both and a rebuild is not bit-identical.
**Fixed**
- **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. Every place a row
leaves a list now closes the gap — the bin, a move to another notebook or
chapter, and a chapter being removed. Coming back from the bin, or arriving by
a move, lands at the top like anything else arriving; 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 said "a separator comes first"
without asking what the other one was — claiming 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, and the line sprang
back above every time. They keep the order they are written in now.
- **Removing a chapter is two moves, and only one was being made.** Its row
leaves the count, *and* the notes it held arrive in the notebook — at the top,
in the order they had inside it, since their old positions were numbered
against a list that no longer exists.
**Changed**
- **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 — two switches for two lists looked at together is one too many — and are
dragged among themselves, strips at both ends included. The two lists keep out
of each other's way 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 governs the notebook's own list; a chapter is a place someone filled by
hand, and a menu there would offer a choice that cannot be honoured.
---
## 2026-08-17 — One direction to drop in, and a new note that stays on top
**Images** — web `sha256:b8bc284a…`, server `sha256:a66cc8eb…`
No backend source changed this round; the server digest moved only because
`build.sh` builds both and a rebuild is not bit-identical.
**Fixed**
- **Every new note pulled the arrangement apart.** A note was made with no
position at all 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, and a
little more of the notebook's order came undone with each note. A new note is
now put at the very top and the list is renumbered to say so; every other row
keeps its place, lines included. Rows that had never been dragged stop sliding
about as they are edited, because they now carry a position of their own.
- **A chapter could only be dropped *into*, never dragged past.** Taking the
whole row as a place quietly took reordering away: a chapter could not be moved
among its neighbours, and nothing — note or line — could be put in the gap
under one. The **bottom edge of a chapter's header is that gap** now; the rest
of the row is still the chapter, and still lights up to say so.
- **A link showed the sheet of paper whatever it pointed at**, despite last
round's work. Two things: the `@` menu drew each note's own mark but dropped it
on the way into the link, and the refresh that keeps a link's label and mark
current ran once, while the editor was still empty — the note's content is
poured in afterwards, deliberately without an update event, so nothing ever
looked again. It now runs whenever the document changes.
- **The mark in the title bar sat above the line of the title.** Both boxes were
centred, which is not the same as being on one line: a title's box is taller
than its letters by the depth of a descender, so its capitals sit above the
middle while an emoji's picture fills its box. Measured, the mark was 2px high
and dipped below a line the title never crossed. They share a **baseline** now,
which needs no number.
- **The task list's box hung off the line it belonged to.** Centred in the full
line it was a 15px square against a 10px letter, its top level with the
capitals and its bottom 4px past the baseline. It is centred on the *letters*
and sized in em, so it stays the size of a capital at every type size.
**Changed**
- **A drop 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 — hard to aim
at, harder to read. There is now a strip **above the first row** as well as
below the last, because that is the one place a drop *under* something cannot
reach.
- ***Move to…* is divided under headings** — the notebook itself, *Chapters
here*, *Other notebooks* — instead of running two kinds of place together with
a rule between them. The headings are deliberately not shaped like entries: one
built like a menu row is a button that does nothing when pressed. Each row
carries the mark its chapter was given or the cover its notebook wears.
---
## 2026-08-17 — One dialog per thing, and rows dragged into a chapter
**Images** — web `sha256:18a68a2d…`, server `sha256:febc4b93…`
No backend source changed this round; the server digest moved only because
`build.sh` builds both and a rebuild is not bit-identical.
**Changed**
- **A separator has one *Edit*** instead of rename, colour and line as three
entries — and the dialog **draws the line as it will look**, with each of the
five shapes drawn beside its name. Choosing "dotted" from a list of words was a
guess. The preview is built from the very rules the list draws with; a sample
that can disagree with the result is worse than none. Renaming in the row
itself is gone with it.
- **A chapter likewise**: 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 menu entries, getting a new chapter to look right took three visits.
- **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 drop line above it — it is a
place, not a neighbour. Notes and separators both. The **"+ Separator" inside
a chapter is gone**: a line is drawn in the notebook and dragged in.
- **A link wears the mark of the note it points at**, in the text and in the
`@` menu, instead of the same sheet-of-paper glyph for everything.
**Fixed**
- **A task list's checkbox sat above the line it belonged to.** It was nudged
down by a fixed `0.35em`, measured once against one font at one size. It is
centred in a box exactly one line tall now, which holds at every size and for
tasks that wrap.
- **The title bar read as crooked.** The mark was a child of the header and got
the same spacing as the buttons at the far end, so it looked like a control
parked beside the title; and the box grew with its contents, so choosing an
emoji shifted the title sideways. Mark and name are one group in a fixed box.
**Elsewhere**
- ***Move to…* is divided under headings** — the notebook itself, *Chapters here*,
*Other notebooks* — each row wearing its chapter's mark or its notebook's cover.
The headings are deliberately not shaped like entries.
- **A separator and a chapter each have one *Edit*** instead of an entry per
property, and the separator dialog draws the line as it will look.
- **A link wears the mark of the note it points at**, in the text and in the `@`
menu, instead of one sheet-of-paper glyph for everything.
- **The mark, the title and the task-list box sit on the line they belong to.**
Boxes had been centred, which is not the same as letters being aligned; the
mark and title share a baseline, the checkbox is centred on the capitals and
sized in em, and the mark stands in the same column as the tools below it.
- **Grey letters ran down the bar while dragging a column edge.** An absolutely
positioned child in a cell of a `border-collapse` table sends Chromium down
another painting path, and the cell's own text came out a second time, squeezed
under the bar. The marker is never drawn now; the line is an inset shadow on the
cells the column is made of.
- **Opening a note flashed a long narrow page**, because the drawing stage was
measured a frame after it was drawn. Measured before the first paint now.
---
@@ -200,507 +103,154 @@ No backend source changed this round; the server digest moved only because
**Images** — web `sha256:1f9f5141…`, server `sha256:3b2f8b44…`
The server image changed: `icon` on notes and chapters, `icon_color` on notes,
colour and style on separators, and a fix to clearing a chapter's colour.
The server gained `icon` on notes and chapters, `icon_color` on notes, and colour
and style on separators.
**Added**
- **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* — "invoice", "house",
"travel" — rather than from the system emoji keyboard, which is a different
thing on every machine. The same glyph shows in the list and above the editor.
A note's mark takes a colour too.
- **A separator takes its own colour and one of five shapes** — solid, dashed,
dotted, double, thick. Three lines through one notebook read as the same line
repeated when they are all faint grey.
- **A chapter can be divided like the notebook it sits in.** Both places a
chapter is shown now draw it with one component, so it cannot be divisible in
the column and not in the unfolded block.
**Fixed**
- **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.
- **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 —
the backdrop — and every dialog read that as "clicked beside me". They now
remember where the press began. All ten of them.
- **A chapter's colour could not be cleared.** "None" sent null, and the update
- **A note and a chapter can wear a mark**, chosen from a short list searched by
what a thing is *for* — "invoice", "house", "travel" — rather than from the
system emoji keyboard, which is a different thing on every machine.
- **A separator takes its own colour and one of five shapes.** Three faint grey
lines through one notebook read as the same line repeated.
- **A long table lay across the gap between two sheets** with a row cut in half.
Tables are broken between their rows now, which is where a table breaks on
paper; the spacer is a `tr`, since a `div` inside a `tbody` is not something the
browser keeps.
- **A dialog closed in the middle of an edit** when a selection was dragged past
its edge: the click lands on the nearest common ancestor of press and release.
All ten of them remember where the press began.
- **A chapter's colour could not be cleared** — "None" sent null, and the update
rode on `COALESCE`, which reads null as "leave it alone".
- **Three entries in the table's right-click menu were still German.**
**Changed**
- **A table's header row is legible on the dark theme**: its own fill rather than
the general "slightly off the page" grey, and a rule under it.
- **Opening a notebook opens its first note**, on a wide screen. On a phone the
list is the screen, so opening a note there would hide the notebook.
- A table's header row is legible on the dark theme, and opening a notebook opens
its first note on a wide screen.
---
## 2026-08-12 — A notebook opens in its own order
## 2026-08-12 — Chapters, and a phone that works
**Images** — web `sha256:c077f0e0…`, server `sha256:c13af5d5…`
(earlier that day: web `65799ecb…`, `2041f103…`, `95657aaf…`, `3700247c…`,
`790ff73a…`, `1217b81b…`, `5ea846f3…`, `c1222665…`; server `a51b96ad…`,
`ed0b9970…`, `b4db0725…`, `181d77be…`, `3303d3dc…`, `06f94f43…`)
No backend source changed this round; the server digest moved only because
`build.sh` builds both and a rebuild is not bit-identical.
The server gained a `chapters` table, `chapter_id` on notes, a `color` column and
a reorder route for chapters, named positions on the note reorder, a
`cover_pattern` column, and the per-account setting for how a chapter opens.
**Fixed**
- **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 with nothing but a reload to get out of. Closing is
now done where the dialog is rendered, so it happens whoever asked.
The suites had **checked the list straight through the overlay** — which is in
the DOM the whole time — and passed. Both now assert the question is gone.
- **The separator button appeared to do nothing** in any order but the hand-made
one, because that is the only order a line shows in. The button is now only
there in that order.
**Changed**
- **A notebook starts in the hand-made order.** That is where its own
arrangement lives — lines show, rows can be dragged into place. A notebook
nobody has arranged still reads newest-first, since untouched rows share
position 0. The views ("All Notes", "Starred", the bin) keep "last changed":
they are questions asked of every notebook at once and have nowhere to keep an
arrangement.
- **The separator is smaller and no wider than its name.** The label 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. It is sized to the name now,
between a floor and a ceiling, with less room above it and smaller type.
- **A chapter has one menu entry for its name and colour**, not two. The
"Colour" submenu sat beside "Rename" offering half of what the dialog already
does, and read as a different thing.
---
## 2026-08-12 — A chapter you can see, in the colour you chose
**Images** — web `sha256:65799ecb…`, server `sha256:a51b96ad…`
No backend source changed this round. The server digest moved anyway because
`build.sh` builds both images and a rebuild is not bit-identical — the image is
the same code as `sha256:ed0b9970…`.
**Fixed**
- **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 now asked of
every row, and a notebook with chapters but no loose notes says so in one line
rather than looking like a list that failed to load.
**Changed**
- **A chapter's colour is chosen while it is being made**, in the same dialog as
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 later are one entry in its menu, and one write.
- **The word *here* is gone.** The line along the top of the pane says which of
the two places you are in; saying it twice was noise.
---
## 2026-08-12 — A filed note stays filed
**Images** — web `sha256:2041f103…`, server `sha256:ed0b9970…`
The server image changed: the bin now hands back a note's chapter with it.
**Fixed**
- **A note put into a chapter came back out of it by itself**, a few seconds
later. The server had filed it correctly the whole time; the sync that follows
rebuilds every local note 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's list 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.
**Changed**
- **Rearranging is a mode now.** The handle button beside the sort menu switches
it on; until then no row shows its grip and none can be dragged, and it goes
off again by itself when the list is left. Six dots stood beside every title
all the time for something done rarely, and an ordinary press could carry a
row off.
- **Filing a note is back in the row's own menu** and the button that had
appeared in every row is gone, with the dialog behind it. It belongs with
starring, exporting and discarding — things done to a row, in one place.
- **Which place a new note lands in is now visible and yours to choose.** With a
chapter open beside or below, the notebook's list and the chapter are both on
screen: pressing in either makes it the one being worked in, marked with a line
along its top and the word *here*, and the + button names it. Before, a new
note went into the chapter however far away you had since clicked.
- **The account panel closes with Escape.** Only a click beside it did, which on
a panel that wide means aiming at a sliver of page.
---
## 2026-08-12 — Chapters as rows, in two views
**Images** — web `sha256:95657aaf…`, server `sha256:b4db0725…`
The server image changed: a `color` column on chapters, a reorder route, named
positions on the note reorder, and the new per-account setting.
**Added**
- **Two ways for a chapter to open**, chosen per account: its notes in a further
column beside the list, the way OneNote lays out its pages, or unfolded under
the chapter's own row. A phone has room for neither and fills the list, with a
way back.
- **A chapter can be given its own colour**, since the point of having them is
that they are different places.
**Changed**
- **Chapters sit at the same level as notes and separators** — same grip, same
drag, same place in a hand-made arrangement. Notes and chapters are numbered
from one counter now; separately, the two sequences could not be interleaved
and a chapter dragged between two notes would spring back. `POST
/notes/reorder` takes named positions beside the older list of ids, and
chapters have a reorder route of their own.
---
## 2026-08-12 — Moving in one press
**Images** — web `sha256:3700247c…`, server unchanged at `sha256:181d77be…`
**Added**
- **A move button on every note row.** Moving used to be a right-click into a
submenu — two steps before the list of places appeared, and on a phone a long
press followed by a flyout. One press now opens a dialog with the chapters of
this notebook and the other notebooks on one screen, marking where the note
is now.
**Changed**
- **The spare page in Draw mode reads as an offer**, not as a page. A second
sheet appeared the moment Draw was pressed, drawn as solid paper, while the
count still said "of 1" — which looks like the app inventing a page. It is a
dashed outline saying "Draw here to add a page", and it counts only once
something is on it.
- *+ Chapter* and *+ Separator* are the same shape. The new one had been built
without matching the old, and the old one's 19px target was one a finger
misses. Separator labels also match a chapter's name in size now.
---
## 2026-08-12 — Chapters
**Images** — web `sha256:790ff73a…`, server `sha256:181d77be…`
The server image changed: it takes a `chapters` table and a `chapter_id` column
on notes, both added on start.
**Added**
- **Chapters — folders inside a notebook.** A note is in one or in none; tapping
a chapter opens its own list, with a way back out. Removing a chapter never
removes what it held: the notes come back to the notebook, and the dialog says
so with the number affected. Separators now belong to the list they were drawn
in, so a line drawn in one chapter does not appear in another.
**Changed**
- **A notebook's design is its own.** A 45-degree stripe texture was baked into
every cover at 1.5% white — meant to be imperceptible, visibly banding on the
darker gradients, and a second pattern fighting the one actually chosen. And
the same pair of rings sat in the middle of every notebook. The mark belongs
to the design now, and the designs whose pattern already fills the face wear
none.
**Fixed**
- **A submenu was being clipped away on a wide screen.** The menu had been given
`overflow-y: auto`, which cuts off anything flying out sideways — so the list
of notebooks under *Move to…* was invisible and every click on it landed on
the note list behind. The side was also inverted: `right: 100%` places a panel
on the left.
- **A separator was effectively invisible on a phone** — a one-pixel hairline in
the border colour with faint grey text on near-black.
---
## 2026-08-12 — Two-part covers, and an app that can be installed
**Images** — web `sha256:1217b81b…`, server `sha256:3303d3dc…`
The server image changed: it takes a `cover_pattern` column, added on start.
**Changed**
- **A cover is now a colour and a pattern, chosen in turn**, rather than a list
with the combinations someone happened to write out. Every combination is
available, and each pattern is previewed over the colour in hand. Covers saved
during the day the patterns were one list are read back as the pair they were
drawn as, so nothing repaints itself.
**Fixed**
- **The install button never appeared, even over HTTPS.** Two causes, both in
this app rather than in anyone's configuration:
- `beforeinstallprompt` fires once, early — usually before React has mounted —
and is never fired again, so a listener added from inside a component simply
missed it. It is caught at the top of `index.html` now.
- The manifest is fetched **without credentials** unless the link says
otherwise. Behind a reverse proxy that authenticates in front of the app,
that 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 link now says
`crossorigin="use-credentials"`.
- **The service worker could cache a proxy's login page as the application.**
Nothing checked that a response was the thing that had been asked for, so a
lapsed session could be stored as the app shell: it then "worked offline" by
showing a login screen it could never get past, curable only by clearing the
site's data by hand. Redirected and unsuccessful responses are no longer
cached, and the cache name is bumped so anything already stored under the old
rules is dropped.
---
## 2026-08-12 — Making the phone work
**Images** — web `sha256:5ea846f3…`, server unchanged at `sha256:06f94f43…`
**Fixed**
- **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 knew — a 404, five
retries, then thrown away. The note arrived empty and untitled with no sign
anything had been lost. The queue is now repointed along with the database.
The same held for a notebook made offline.
- **The order of notes could not be changed on a phone.** The list used the
HTML5 drag API, which mobile browsers never fire from touch. It uses pointer
events now, and every row has a grip: pressing the row itself still scrolls,
and holding it still opens the menu.
- **"Move to…" opened off the side of the screen** and could not be opened at
all with a finger. It was pinned to the right of the menu and reacted only to
hover. It now opens on a tap, unfolds in place on a narrow screen, and flips
to the other side on a wide one when there is no room.
- The menu itself is placed by measuring rather than by assuming it is 220px
wide, so it can no longer hang off an edge; rows are tall enough to hit with a
thumb, and Escape closes it.
**Changed**
- **The install entry is always there.** It used to appear only when the browser
offered a prompt, so where installing was impossible there was nothing on
screen at all — no button, no reason. It now says which of the fixable causes
applies: served over plain HTTP, or a browser that wants to be asked through
its own menu.
---
## 2026-08-12 — Covers, the search box, and room to write in the margin
**Images** — web `sha256:c1222665…`, server `sha256:06f94f43…`
The server image changed: it sends the default number of versions with the
account, so the notebook dialog can name it instead of guessing.
**Added**
- **Patterned notebook covers** — ruled paper, graph paper, pinstripe, bands,
dots, cross-hatch, linen, washes, a ledger. Drawn entirely in CSS: no image
files, nothing to upload, crisp at any size, and they work offline like
everything else. Existing notebooks keep exactly the cover they had; the new
ones are appended, never inserted.
**Changed**
- **The search box in a notebook is simply there**, and the button that used to
open it is gone. A magnifying glass cost a click and having to remember that
searching was possible; the box is one line and says for itself what it does.
- **Margin notes are bigger** — the column went from 208px to 288px and a card
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.
- The notebook dialog's cover grid scrolls, so the dialog cannot run past the
bottom of a laptop screen and take the *Create* button with it.
**Fixed**
- The notebook edit dialog was still part German — *"Versionen pro Notiz"*,
*"Vorgabe"* — and would not say what the server's default actually is. It now
names it (50), read from the server rather than written into the interface,
since `MAX_VERSIONS_PER_NOTE` can be changed and a hardcoded number would
quietly start lying.
---
## 2026-08-11 — Margins and a stray placeholder
**Images** — web `sha256:3a181ff3…`, server unchanged at `sha256:0832ee2b…`
**Changed**
- **Narrower margins on the sheet** — the column went from about 73 characters
to about 80, leaving roughly 8% of the paper as margin either side instead of
117px of empty page down each edge.
**Fixed**
- **"Start writing..." was printed at the foot of every page with 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.
- **Chapters — folders inside a notebook.** A note is in one or in none. Removing
a chapter never removes what it held, and the dialog says so with the number
affected. They sit at the same level as notes and separators: one counter, one
grip, one kind of drag.
- **Two ways for a chapter to open**, chosen per account: a further column beside
the list, or unfolded under its own row. A phone fills the list instead.
- **A chapter is made complete in one dialog** — name and colour together — and a
chapter with no notes in it is visible at last; "nothing here" had been asked of
the notes alone.
- **A notebook starts in the hand-made order**, which is where its arrangement
lives. The views across all notebooks keep "last changed": they have nowhere to
keep an arrangement. **Rearranging is a mode**, switched on beside the sort menu.
- **A filed note came back out of its chapter by itself.** The server had it right
the whole time; the sync that follows rebuilds every note from the server's
answer, and the function doing that never carried `chapter_id`.
- **Removing a chapter left you stuck in the confirmation** — that one path set
the dialog directly instead of going through the helper that takes it down. The
suites had read the list straight through the open overlay and passed; they now
assert the question is gone.
- **The order of notes could not be changed on a phone**: the list used the HTML5
drag API, which mobile browsers never fire from touch. Pointer events and a grip
per row. **"Move to…" opened off the side of the screen** and reacted only to
hover; it opens on a tap and flips side when there is no room.
- **A note made offline lost everything written into it** — its create reached the
server, the server named it something else, and every queued edit went on
addressing the name only that device knew. The queue is repointed with the
database now.
- **The install button never appeared, even over HTTPS.** `beforeinstallprompt`
fires before React mounts, so it is caught in `index.html`; and the manifest is
fetched without credentials unless the link says `use-credentials`, so behind an
authenticating proxy the browser read the login page as the manifest.
- **The service worker could cache a proxy's login page as the application** and
then "work offline" by showing a login screen it could never get past.
Redirected and unsuccessful responses are no longer cached.
- **A cover is a colour and a pattern, chosen in turn** — every combination, drawn
in CSS, with existing notebooks keeping exactly the cover they had.
- The search box in a notebook is simply there, the margin-note column is half
again as wide, and a submenu is no longer clipped away on a wide screen.
---
## 2026-08-11 — Writing on paper
**Images** — web `sha256:b764a9d8…`, server unchanged at `sha256:0832ee2b…`
**Images** — web `sha256:3a181ff3…`, server `sha256:0832ee2b…`
(earlier that day: web `b764a9d8…`, `dc804f7b…`, `ed11f034…`, `3b040d99…`;
server `1648498f…`)
The server gained a column and two routes for extracted PDF text.
**Added**
- **You write on paper.** Each page is a sheet of its own, centred, with a gap
after it — the same way the PDF reader draws its pages. The sheet used to
appear only once you started drawing, which left the writing mode looking like
text floating in an empty pane, and made the space either side of the column
read as a void rather than as a margin.
- Text is broken against those sheets, so no paragraph is ever cut in half by a
gap. The breaks are decorations: nothing enters the document, reaches the
server, or appears in an export.
**Fixed**
- **A note with a drawing could not be typed in at all.** The box wrapping each
page's ink layer lay over the text and swallowed every click meant for it. The
canvas inside was already deaf to the pointer; its wrapper was not. Introduced
the same day, with per-page drawing.
---
## 2026-08-11 — Drawing on every page, and PDFs findable everywhere
**Images** — web `sha256:dc804f7b…`, server `sha256:0832ee2b…`
The server image changed this round: it takes a new column and two routes.
`docker compose pull && docker compose up -d` — the column is added on start.
**Added**
- **A PDF is searchable on every device, not only on the one that opened it.**
The device that imports the file reads it there and then — the server still has
no PDF library and never opens one — and the extracted text is stored beside
the file, encrypted, so every other device can find the document without ever
downloading it. This is what makes an archive of scanned invoices work: they
are filed and then never opened, so waiting for someone to open one meant
waiting forever.
- **A drawing runs over as many pages as the note has.** The ink used to be stuck
on page one while the writing carried on without it. Pages are numbered the way
the PDF reader numbers them, and there is always one blank page past the end to
start a new one on. The bin clears the page being looked at; undo still works on
the drawing as a whole.
- A drawing that lives entirely on page one is still stored in the old shape, so
an ordinary note is written exactly as it always was and an older client still
shows it.
**Fixed**
- **A drawing exported in a ZIP was lost on re-import.** The exporter has written
`drawings/<note>.png` since the interface was put into English; the importer
was still looking for `zeichnungen/<note>.png` and found nothing. Both names
are read now, and so are the further pages of a drawing.
---
## 2026-08-11 — The writing column
**Images** — web `sha256:ed11f034…`, server unchanged at `sha256:1648498f…`
**Changed**
- The note column is centred again. It had been pinned to the left margin so the
start of every line would always be in the same place; on a wide window that
left the whole right half of the pane as one dead block.
- A page surface was drawn behind the text to give that block a meaning, and
reverted the same day: it made the sheet sit hard against the left edge, which
was worse than the problem it solved.
---
## 2026-08-11 — Pages, typefaces, alignment
**Images** — web `sha256:3b040d99…`, server `sha256:1648498f…`
**Added**
- **Page count while writing.** The toolbar says *Page 2 of 4*, in the same words
the PDF reader uses. Pages are counted, never stored: the note stays one
document, and a test asserts no page ever reaches the stored body.
- **The typeface is a per-account setting** — serif, sans or mono, chosen under
Account → Profile. On the account rather than in the browser, so it follows you
to the phone.
- **Text alignment**, with its own TipTap extension rather than the official one,
so the schema stays in files this project owns. Left is the default and is
never written into the document. In Markdown, non-left blocks travel as
`<div align="…">` — the one form most readers still honour — and the importer
reads it back onto the paragraphs.
**Fixed**
- The Markdown exporter did not put a blank line between top-level blocks, so
consecutive paragraphs ran together in other readers.
---
## 2026-08-11 — English throughout, and the PDF toolbar
**Added**
- **Search inside a notebook**, from the note list. The sidebar's global search
was removed: searching from inside a notebook should mean searching *that*
notebook.
- **New note / Upload PDF / Import Markdown** all live under the one `+` button.
**Changed**
- The interface is English throughout. It had drifted into two languages.
- The two visibility switches — drawing and margin notes — sit together at the
far right of the PDF toolbar, because they mean the same kind of thing.
- The drawing tools appear next to the *Draw* button, not past everything else.
- A separator is renamed and removed by right-click, with a confirmation for the
removal.
**Fixed**
- Hiding the drawing also hid the margin notes. Each switch now hides only its
own thing.
after it, and the text is broken against those sheets so no paragraph is cut in
half. The breaks are decorations: nothing enters the document, reaches the
server, or appears in an export. The toolbar says *Page 2 of 4*.
- **A drawing runs over as many pages as the note has**, numbered the way the PDF
reader numbers them, with one blank page past the end to start on. A drawing
that lives on page one alone is still stored in the old shape.
- **A PDF is searchable on every device**, not only the one that imported it: the
importing device extracts the text and stores it beside the file, encrypted. The
server still has no PDF library.
- **The typeface is a per-account setting** — serif, sans or mono — so it follows
you to the phone. **Text alignment** likewise, with its own extension so the
schema stays in files this project owns; non-left blocks travel as
`<div align="…">` in Markdown.
- **A note with a drawing could not be typed in at all**: the box wrapping each
page's ink layer lay over the text and swallowed every click.
- **A drawing exported in a ZIP was lost on re-import** — the exporter had written
`drawings/…` since the interface went English, the importer still looked for
`zeichnungen/…`.
- **"Start writing..." was printed at the foot of every page** with a break after
it: the placeholder styles `[contenteditable]:empty::before`, and the spacer
holding a gap open is exactly that.
- The Markdown exporter began putting a blank line between top-level blocks, and
the margins narrowed from 73 to about 80 characters.
---
## 2026-08-10 — Notes on passages
**Added**
- **Pinned notes in a PDF.** A remark can be attached to a passage, and is shown
in the margin beside the page — not written on top of it. On a phone, where
there is no margin, the same notes open as a bottom sheet.
- **Margin notes can be linked from the notes panel**, so a pinned remark can be
written about at length and still lead back to the passage it came from.
**Fixed**
- A grey drag indicator stayed visible in the note list after a drag. It was a
`borderTop` sitting beside a `border` shorthand in one style object.
- The server stored an overlay holding only pinned notes as `NULL`, throwing
every one of them away, because it counted strokes and nothing else.
- **Pinned notes in a PDF.** A remark is attached to a passage and shown in the
margin beside the page, never written on top of it; on a phone it opens as a
bottom sheet. Such a remark can be linked from the notes panel and written about
at length.
- The server stored an overlay holding only pinned notes as `NULL`, throwing every
one of them away, because it counted strokes and nothing else.
---
## 2026-08-09 — Arranging a notebook
**Added**
- **Sorting per notebook**, remembered on the notebook itself, including an
arrangement made by hand — which comes back intact after filtering.
- **Separators** to group notes inside a hand-made arrangement.
- **Markdown import**, single files or a ZIP this app exported.
- **Moving notes between notebooks.**
**Fixed**
- Switching to Draw shrank the page from 733px to 460px. The editor was a flex
item at the default `flex: 0 1 auto`, so it was only as wide as its widest row
— the toolbar, which loses a dozen buttons in Draw.
- **Sorting per notebook**, remembered on the notebook, including an arrangement
made by hand — which comes back intact after filtering.
- **Separators**, **Markdown import** (single files or a ZIP this app exported),
and **moving notes between notebooks**.
- Switching to Draw shrank the page from 733px to 460px: the editor was a flex
item at `flex: 0 1 auto`, so it was only as wide as its widest row — the
toolbar, which loses a dozen buttons in Draw.
---
## 2026-08-08 — PDFs
**Added**
- Import a PDF, read it, select its text, draw on each page, and export it with
the drawings written in.
- Notes written beside a PDF.
the drawings written in. Notes written beside a PDF.
- Uploaded files are encrypted at rest, like note bodies already were, and stored
as deduplicated, content-addressed attachments.
- Unused attachments are freed; versions are kept by count rather than by age.
as deduplicated, content-addressed attachments; unused ones are freed, and
versions are kept by count rather than by age.
- Export as Markdown or PDF; the browser suites moved into the repository.
**Changed**
- Vector clocks were never read. Replaced with a conflict check that works.
- Public sign-up removed — an administrator creates the accounts.
- Vector clocks were never read — replaced with a conflict check that works.
- Public sign-up removed: an administrator creates the accounts.
---
## Earlier
TipTap editor, version history, full-text search and attachments (2026-08-07);
drawing sync with WebP/JPEG support (2026-06-21); the first syncing alpha and PWA
(2026-02-18).
The interface became English throughout, and search inside a notebook replaced
the sidebar's global search (2026-08-11). Before that: the TipTap editor, version
history, full-text search and attachments (2026-08-07); drawing sync with
WebP/JPEG support (2026-06-21); the first syncing alpha and PWA (2026-02-18).
+35 -1
View File
@@ -629,12 +629,25 @@ try {
await p.waitForTimeout(600);
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
/* The whole set, as the chapter dialog offers it — kept to compare against
the note dialog further down. The two grids were once two lists, and marks
added to one did not exist in the other. */
const allMarks = (page) =>
page.evaluate(() => [...document.querySelectorAll("[data-icon-option]")].map((el) => el.dataset.iconOption));
const marksInChapterDialog = await allMarks(p);
const headings = await p.locator("[data-icon-group]").count();
check(headings >= 3, "the marks are divided under headings", `${headings} groups`);
await p.getByLabel("Search icons").fill("invoice");
await p.waitForTimeout(400);
// One extra: "no icon" sits in the grid, because taking a mark off is the
// same kind of choice as putting one on.
const offered = await p.locator("[data-icon-option]").count();
check(offered > 1 && offered < 20, "icons are searched by what they are for", `${offered} shown`);
check(
(await p.locator("[data-icon-group]").count()) < headings,
"and a heading with nothing under it is not shown"
);
await p.locator("[data-icon-option]").nth(1).click();
await p.locator("[data-chapter-save]").click();
await p.waitForTimeout(2200);
@@ -650,9 +663,30 @@ try {
await p.waitForTimeout(1200);
await p.locator("[data-note-icon]").click();
await p.waitForTimeout(700);
check(
JSON.stringify(await allMarks(p)) === JSON.stringify(marksInChapterDialog),
"a note is offered exactly the marks a chapter is",
`${marksInChapterDialog.length} marks`
);
/* A mark nobody thought of: the search box is a text field, which on a phone
is where the emoji keyboard lives, so anything typed into it is offered. */
await p.getByLabel("Search icons").fill("🦖");
await p.waitForTimeout(400);
check(
(await p.locator('[data-icon-option="🦖"]').count()) === 1,
"an emoji typed into the search box is offered as a mark"
);
await p.getByLabel("Search icons").fill("plainly not a mark");
await p.waitForTimeout(400);
check(
(await p.locator("[data-icon-option]").count()) === 1,
"while words that match nothing offer only the empty mark"
);
await p.getByLabel("Search icons").fill("house");
await p.waitForTimeout(400);
await p.locator("[data-icon-option]").first().click();
// Both dialogs show the same grid, and in it "no icon" is the first tile.
await p.locator("[data-icon-option]").nth(1).click();
await p.locator('[data-icon-colour="#2da44e"]').click();
await p.locator("[data-icon-save]").click();
await p.waitForTimeout(2500);
+38
View File
@@ -295,6 +295,44 @@ try {
await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)
);
/* ── a quote that holds more than a sentence ──
* Markdown lets a quote hold whole blocks, and so does the editor. What it did
* not do was give them any room: inside a quote nothing had a margin, so a
* heading, a list and a closing line stood jammed together while the items of
* the list were further apart than the blocks around them. */
await createNote(p);
await setTitle(p, "Zitat");
await p.locator(".ProseMirror").click();
await p.keyboard.type("> ### Quartalszahlen");
await p.keyboard.press("Enter");
await p.keyboard.type("- Umsatz");
await p.keyboard.press("Enter");
await p.keyboard.type("Gewinn");
await p.keyboard.press("Enter");
await p.keyboard.press("Enter");
await p.keyboard.type("Alles nach Plan.");
await p.waitForTimeout(1500);
const quoted = await p.evaluate(() =>
[...(document.querySelector(".ProseMirror blockquote")?.children || [])].map((el) => {
const r = el.getBoundingClientRect();
return { tag: el.tagName, top: r.top, bottom: r.bottom };
})
);
check(
quoted.map((c) => c.tag).join(",") === "H3,UL,P",
"a quote holds a heading, a list and a paragraph",
quoted.map((c) => c.tag).join(",")
);
const gaps = quoted.slice(1).map((c, i) => Math.round(c.top - quoted[i].bottom));
check(gaps.every((g) => g >= 6), "with room between them, as outside a quote", gaps.join(" | "));
/* And the same divide when it leaves as Markdown: written on consecutive
lines, every other reader folds that closing line into the last bullet. */
const quoteNote = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Zitat");
const quoteDoc = (await api(p, "GET", `/notes/${quoteNote.id}`)).body.content;
check(/blockquote/.test(quoteDoc), "and it is stored as a quote");
/* ── the typeface follows the account ── */
await p.getByTitle("Account").click();
await p.waitForTimeout(800);
+26
View File
@@ -96,6 +96,23 @@ try {
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
await p.waitForTimeout(1800);
/* A quote holding more than one kind of block. Written on consecutive lines,
Markdown reads the closing sentence as more of the last bullet — so the
blocks inside a quote have to be divided by a quoted blank line. */
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+End");
await p.keyboard.press("Enter");
await p.keyboard.press("Enter"); // out of the task list again
await p.keyboard.type("> ### Quartalszahlen");
await p.keyboard.press("Enter");
await p.keyboard.type("- Umsatz");
await p.keyboard.press("Enter");
await p.keyboard.type("Gewinn");
await p.keyboard.press("Enter");
await p.keyboard.press("Enter");
await p.keyboard.type("Alles nach Plan.");
await p.waitForTimeout(1800);
await captureDownloads(p);
/* ── single note as Markdown ── */
@@ -114,6 +131,15 @@ try {
check(/\|\s*Spalte\s*\|/.test(md), "the table came along", md.match(/\|.*\|/)?.[0] || "no table row");
check(md.includes("| --- |"), "the table has a separator row so it renders as a table");
check(md.includes("- [x] Erledigte Sache"), "the ticked task is a ticked checkbox");
check(
md.includes("> ### Quartalszahlen\n>\n> - Umsatz"),
"the blocks inside a quote are divided by a quoted blank line",
md.split("\n").filter((l) => l.startsWith(">")).join(" ⏎ ")
);
check(
md.includes("> - Gewinn\n>\n> Alles nach Plan."),
"so the sentence after a quoted list stays a sentence"
);
console.log("--- Markdown ---\n" + md + "\n--- Ende ---");
// The dialog stays up after an export so the result can be read; close it or
+10
View File
@@ -126,6 +126,16 @@
padding-left: 12px;
color: var(--ne-muted);
}
/* A quote may hold whole blocks — a heading, a list, a closing line — and inside
it they need the same rhythm as outside. The rule at the top of this file
reaches only the editor's own children, so everything in a quote stood jammed
against its neighbour, with the items of the list further apart than the
blocks around them. It comes after `p { margin: 0 }` and the headings on
purpose: those are exactly as specific, so written earlier they would win.
A paragraph inside a list item is more specific still and keeps its zero. */
.note-editor .ProseMirror blockquote > * + * {
margin-top: 0.6em;
}
.note-editor .ProseMirror code {
background: var(--ne-subtle);
border: 1px solid var(--ne-border);
+27
View File
@@ -102,6 +102,33 @@ describe("blocks", () => {
expect(md(doc(quote))).toBe("> zitiert");
});
it("divides the blocks inside a quote, so a list does not swallow what follows", () => {
const quote = {
type: "blockquote",
content: [
{ type: "heading", attrs: { level: 3 }, content: [text("Quartalszahlen")] },
{
type: "bulletList",
content: [
{ type: "listItem", content: [para(text("Umsatz"))] },
{ type: "listItem", content: [para(text("Gewinn"))] },
],
},
para(text("Alles nach Plan.")),
],
} as DocNode;
// The dividing line is quoted as well: an empty line would end the quote.
expect(md(doc(quote))).toBe(
"> ### Quartalszahlen\n>\n> - Umsatz\n> - Gewinn\n>\n> Alles nach Plan."
);
});
it("quotes a quote inside a quote", () => {
const inner = { type: "blockquote", content: [para(text("innen"))] } as DocNode;
const outer = { type: "blockquote", content: [para(text("außen")), inner] } as DocNode;
expect(md(doc(outer))).toBe("> außen\n>\n> > innen");
});
it("fences a code block and keeps its language", () => {
const code = { type: "codeBlock", attrs: { language: "js" }, content: [text("const a = 1;")] };
expect(md(doc(code))).toBe("```js\nconst a = 1;\n```");
+32 -18
View File
@@ -129,11 +129,17 @@ function block(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
return withAlignment(n, [`${"#".repeat(level)} ${inline(n.content, o)}`]);
}
case "blockquote":
return children(n, o)
.join("\n")
.split("\n")
.map((l) => (l ? `> ${l}` : ">"));
case "blockquote": {
/* A quote may hold anything a note may hold — headings, lists, tables,
* further quotes — and inside it the blocks need the same blank line
* between them as they do outside. Written on consecutive lines, a
* paragraph after a list is read as more of the last list item, and a
* table loses its header. The blank line is quoted as well (`>` on its
* own), because a truly empty line ends the quote instead of dividing it. */
const lines = stack(n.content || [], o);
while (lines.length && lines[lines.length - 1] === "") lines.pop();
return lines.map((l) => (l ? `> ${l}` : ">"));
}
case "codeBlock": {
const lang = n.attrs?.language || "";
@@ -172,6 +178,26 @@ function children(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
return (n.content || []).flatMap((c) => block(c, o, indent));
}
/**
* Blocks one after another, with a blank line between them.
*
* Without one, Markdown runs adjacent blocks together: a bullet list written
* straight after another list becomes more items of the first, and a quote or a
* code fence on the line after a list item is read as part of that item. That is
* not a quirk of one reader — it is what the format says, so the file rendered
* wrongly everywhere, and reading it back in produced a document that did not
* match the one exported. It holds wherever blocks sit side by side, which is
* why the quote uses it too and not only the top level.
*
* A block that renders to nothing is left out rather than contributing a gap.
*/
function stack(nodes: DocNode[], o: MarkdownOptions, indent = ""): string[] {
return nodes.flatMap((n) => {
const rendered = block(n, o, indent);
return rendered.length && rendered.some((l) => l !== "") ? [...rendered, ""] : [];
});
}
/** Renders list items, indenting anything nested inside them. */
function list(
n: DocNode,
@@ -230,19 +256,7 @@ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): str
}
}
/* A blank line between top-level blocks.
*
* Without one, Markdown runs adjacent blocks together: a bullet list written
* straight after another list becomes more items of the first, and a quote or
* a code fence on the line after a list item is read as part of that item.
* That is not a quirk of one reader — it is what the format says, so the file
* rendered wrongly everywhere, and reading it back in produced a document that
* did not match the one exported. */
const blocks = (doc.content || []).flatMap((n) => {
const rendered = block(n, options);
return rendered.length && rendered.some((l) => l !== "") ? [...rendered, ""] : [];
});
lines.push(...blocks);
lines.push(...stack(doc.content || [], options));
if (options.hasDrawing) {
lines.push("", "> ✏️ This note has a drawing, which Markdown cannot hold.");
+5
View File
@@ -128,6 +128,11 @@ const STYLES = `
li.task .box { font-size: 12pt; line-height: 1; }
li.task div p { margin: 0; }
blockquote { margin: 0 0 3mm; padding-left: 4mm; border-left: 2px solid #ccc; color: #444; }
/* A quote may hold headings and lists. Their own spacing is right between
them and wrong at the ends, where it pushes the rule beside the quote past
the text it belongs to. */
blockquote > :first-child { margin-top: 0; }
blockquote > :last-child { margin-bottom: 0; }
pre { background: #f4f4f4; padding: 3mm; border-radius: 2mm; overflow-wrap: anywhere;
white-space: pre-wrap; font-size: 9.5pt; }
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 9.5pt; }
@@ -34,6 +34,21 @@ describe("blocks", () => {
expect(quote.content![0]!.type).toBe("heading");
});
it("keeps a quote together across the `>` lines that divide it", () => {
const quote = first("> #### Kopf\n>\n> - eins\n> - zwei\n>\n> *Alles* nach **Plan**.");
expect(quote.type).toBe("blockquote");
expect(quote.content!.map((n) => n.type)).toEqual(["heading", "bulletList", "paragraph"]);
// A quoted blank line divides; it must not be read as the end of the quote.
expect(plain(quote.content![2]!)).toBe("Alles nach Plan.");
expect(quote.content![2]!.content![0]!.marks![0]!.type).toBe("italic");
});
it("reads a quote inside a quote", () => {
const outer = first("> außen\n>\n> > innen");
expect(outer.content!.map((n) => n.type)).toEqual(["paragraph", "blockquote"]);
expect(plain(outer.content![1]!)).toBe("innen");
});
it("joins the lines of a paragraph", () => {
expect(plain(first("erste Zeile\nzweite Zeile"))).toBe("erste Zeile zweite Zeile");
});
+3 -1
View File
@@ -208,7 +208,9 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
</div>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Icon</div>
<IconChooser T={T} value={icon} onPick={setIcon} height={170} />
{/* A little taller than it was: the headings between the groups cost a
row, and at 170 the first group of marks was the only one in view. */}
<IconChooser T={T} value={icon} onPick={setIcon} height={200} />
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 20 }}>
<button
+248 -77
View File
@@ -1,82 +1,255 @@
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
/**
* The marks a note or a chapter can wear, and the grid for choosing one.
*
* A fixed list rather than the system emoji keyboard. That keyboard is a
* different thing on every machine — on Android it is the one for typing, which
* means opening it inside a text field the app does not have — and it offers
* three thousand pictures for a decision that is "which of these is my invoice
* notebook". A few dozen, searchable by what they are for, is the whole job.
* A list of one's own rather than the system emoji keyboard. That keyboard is a
* different thing on every machine, and it offers three thousand pictures for a
* decision that is "which of these is my invoice notebook". Some dozens,
* searchable by what they are for, is the whole job — and the search box is a
* text field, so the keyboard is still there for whoever wants it.
*
* The words are what make it usable: nobody hunts a pictogram grid by shape.
* They are also why the list is written out rather than generated.
*
* One component, used by both dialogs that offer a mark. Two grids would drift.
* There are two ways a mark gets in. **Adding one to this list is one line** — a
* `{ char, words }` in whichever group it belongs to. Nothing else: what is
* stored on a note is the character itself, not a number into this list, so the
* order may change and entries may come and go without anything already chosen
* being disturbed. Give it the words someone would actually search for — what
* the thing is *for*, not what it depicts. And **anything else can be typed into
* the search box**, which is where the system emoji keyboard lives on a phone;
* see `typedMark`.
*
* The groups exist because the list outgrew a single wall of pictures. They are
* headings, not filters: searching looks through all of them at once.
*
* One component, used by both dialogs that offer a mark. Two grids would drift —
* and did: for a while there were two copies of this list, and only one of them
* was ever extended.
*/
export const ICONS = [
{ char: "📄", words: "note paper document default" },
{ char: "📝", words: "note write draft memo" },
{ char: "📌", words: "pin important stick" },
{ char: "⭐", words: "star favourite important" },
{ char: "❗", words: "important urgent attention" },
{ char: "✅", words: "done finished checked task" },
{ char: "🕒", words: "later waiting time pending" },
{ char: "💡", words: "idea thought concept" },
{ char: "🔧", words: "tool fix repair maintenance" },
{ char: "🔨", words: "build work project diy" },
{ char: "🏠", words: "house home flat property" },
{ char: "🔌", words: "power electricity socket energy" },
{ char: "💧", words: "water plumbing bill" },
{ char: "🔥", words: "heating gas fire boiler" },
{ char: "🚗", words: "car vehicle travel garage" },
{ char: "🧾", words: "receipt invoice bill accounting" },
{ char: "💶", words: "money euro payment bank finance" },
{ char: "🏦", words: "bank account finance" },
{ char: "📊", words: "chart numbers report statistics" },
{ char: "📅", words: "calendar date appointment plan" },
{ char: "✈️", words: "travel holiday flight trip" },
{ char: "🍳", words: "cooking recipe kitchen food" },
{ char: "🛒", words: "shopping list groceries" },
{ char: "🎁", words: "gift present birthday" },
{ char: "🎵", words: "music song audio" },
{ char: "📷", words: "photo picture camera" },
{ char: "🎬", words: "film video movie watch" },
{ char: "📚", words: "book reading study library" },
{ char: "🎓", words: "school study course learning" },
{ char: "💼", words: "work job office business" },
{ char: "🖥️", words: "computer server it hardware" },
{ char: "🌐", words: "web internet network domain" },
{ char: "🔒", words: "password secret secure private" },
{ char: "🐳", words: "docker container server" },
{ char: "🧠", words: "thinking notes knowledge brain" },
{ char: "❤️", words: "love personal family" },
{ char: "🐾", words: "pet animal dog cat" },
{ char: "🌱", words: "garden plant growing nature" },
{ char: "⚽", words: "sport football hobby" },
{ char: "🏃", words: "running fitness health sport" },
{ char: "💊", words: "health medicine doctor" },
{ char: "🩺", words: "doctor health medical appointment" },
{ char: "📦", words: "package delivery order storage" },
{ char: "🗂️", words: "files archive folder sorting" },
export const ICON_GROUPS = [
{
name: "Notes",
icons: [
{ char: "📄", words: "note paper document default" },
{ char: "📝", words: "note write draft memo" },
{ char: "✏️", words: "draft writing pencil unfinished" },
{ char: "📌", words: "pin important stick" },
{ char: "🔖", words: "bookmark marker reference" },
{ char: "⭐", words: "star favourite important" },
{ char: "❗", words: "important urgent attention" },
{ char: "❓", words: "question open unclear" },
{ char: "✅", words: "done finished checked task" },
{ char: "🕒", words: "later waiting time pending" },
{ char: "🚩", words: "flag follow up marked" },
{ char: "💡", words: "idea thought concept" },
{ char: "🧠", words: "thinking notes knowledge brain" },
{ char: "🔍", words: "research looking into search" },
{ char: "🗒️", words: "list scratch pad jotting" },
],
},
{
name: "Home & money",
icons: [
{ char: "🏠", words: "house home flat property" },
{ char: "🔑", words: "keys landlord tenancy access" },
{ char: "🔌", words: "power electricity socket energy" },
{ char: "💧", words: "water plumbing bill" },
{ char: "🔥", words: "heating gas fire boiler" },
{ char: "🌡️", words: "heating temperature thermostat climate" },
{ char: "🧾", words: "receipt invoice bill accounting" },
{ char: "💶", words: "money euro payment bank finance" },
{ char: "💳", words: "card payment subscription direct debit" },
{ char: "🏦", words: "bank account finance" },
{ char: "📊", words: "chart numbers report statistics" },
{ char: "📈", words: "growth investment shares budget" },
{ char: "🛠️", words: "repair maintenance handyman fixing" },
{ char: "🔧", words: "tool fix repair maintenance" },
{ char: "🔨", words: "build work project diy" },
{ char: "🧹", words: "cleaning chores household" },
{ char: "🪑", words: "furniture room interior" },
{ char: "📮", words: "post letter mail correspondence" },
{ char: "🗑️", words: "waste bin rubbish disposal" },
],
},
{
name: "Work & study",
icons: [
{ char: "💼", words: "work job office business" },
{ char: "🏢", words: "company employer firm office" },
{ char: "📅", words: "calendar date appointment plan" },
{ char: "🗓️", words: "schedule planning week year" },
{ char: "📚", words: "book reading study library" },
{ char: "🎓", words: "school study course learning" },
{ char: "👥", words: "meeting people team colleagues" },
{ char: "🗣️", words: "talk interview conversation minutes" },
{ char: "📞", words: "call phone contact" },
{ char: "✉️", words: "email letter message" },
{ char: "📎", words: "attachment paperwork documents" },
{ char: "🗂️", words: "files archive folder sorting" },
{ char: "📦", words: "package delivery order storage" },
{ char: "⚖️", words: "legal contract law insurance" },
],
},
{
name: "Tech",
icons: [
{ char: "🖥️", words: "computer server it hardware" },
{ char: "🌐", words: "web internet network domain" },
{ char: "🔒", words: "password secret secure private" },
{ char: "🐳", words: "docker container server" },
{ char: "⚙️", words: "settings configuration setup" },
{ char: "🧩", words: "plugin module integration piece" },
{ char: "🗄️", words: "database storage backup archive" },
{ char: "📡", words: "network router connection signal" },
{ char: "🔋", words: "power battery devices charging" },
{ char: "🖨️", words: "printer scanner paperwork device" },
{ char: "📱", words: "phone mobile app device" },
{ char: "🧪", words: "experiment testing trying out" },
],
},
{
name: "Life",
icons: [
{ char: "✈️", words: "travel holiday flight trip" },
{ char: "🧳", words: "packing luggage journey" },
{ char: "🏖️", words: "holiday beach break rest" },
{ char: "🚗", words: "car vehicle travel garage" },
{ char: "🚲", words: "bike cycling commute" },
{ char: "🍳", words: "cooking recipe kitchen food" },
{ char: "🍽️", words: "restaurant eating out dinner" },
{ char: "☕", words: "coffee break cafe morning" },
{ char: "🛒", words: "shopping list groceries" },
{ char: "🎁", words: "gift present birthday" },
{ char: "🎂", words: "birthday anniversary celebration" },
{ char: "🎄", words: "christmas holidays festive" },
{ char: "🎵", words: "music song audio" },
{ char: "📷", words: "photo picture camera" },
{ char: "🎬", words: "film video movie watch" },
{ char: "🎨", words: "art drawing design creative" },
{ char: "🎮", words: "games gaming console hobby" },
{ char: "⚽", words: "sport football hobby" },
{ char: "🏃", words: "running fitness health sport" },
{ char: "🎯", words: "goal aim target plan" },
{ char: "🌱", words: "garden plant growing nature" },
{ char: "🐾", words: "pet animal dog cat" },
{ char: "❤️", words: "love personal family" },
{ char: "👶", words: "child children family kids" },
{ char: "💊", words: "health medicine doctor" },
{ char: "🩺", words: "doctor health medical appointment" },
{ char: "🦷", words: "dentist teeth appointment" },
{ char: "🧘", words: "wellbeing calm mindfulness routine" },
],
},
];
export function IconChooser({ T, value, onPick, height = 200 }) {
/** Every mark, in one flat list — for searching, and for anything that just
wants to know whether a character is one of ours. */
export const ICONS = ICON_GROUPS.flatMap((g) => g.icons);
/**
* What the tiles under it are.
*
* Across the whole grid, and deliberately unlike the tiles: small, faint and
* flush left. Shaped like one it would be a button that does nothing when
* pressed. `name` is set only on the real groups, so a test can count them.
*/
function Heading({ T, name, children }) {
return (
<div
{...(name ? { "data-icon-group": name } : {})}
style={{
gridColumn: "1 / -1",
marginTop: 4,
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.7,
textTransform: "uppercase",
color: T.muted,
cursor: "default",
userSelect: "none",
}}
>
{children}
</div>
);
}
/** A group heading matches too: "tech" should find the tech ones. */
const matches = (icon, group, q) =>
icon.words.includes(q) || icon.char === q || group.toLowerCase().includes(q);
/**
* What was typed, when it is a picture rather than a word.
*
* The list above is deliberately short — but the search box is a text field, and
* on a phone a text field is exactly where the system emoji keyboard lives. So a
* mark nobody thought of can still be used: type or paste it, and it is offered
* as a tile. That is the second way marks get in, and it needs no release.
*
* Eight code points, the same limit the server keeps, so one glyph with its
* modifiers fits and a pasted sentence does not. Letters, digits and spaces mean
* a word was typed and a search is meant, not a mark.
*/
export function typedMark(query) {
const raw = [...query.trim()].slice(0, 8).join("");
if (!raw || /[\p{L}\p{N}\s]/u.test(raw)) return null;
if (!/\p{Extended_Pictographic}/u.test(raw)) return null;
return ICONS.some((i) => i.char === raw) ? null : { char: raw, words: "typed" };
}
export function IconChooser({ T, value, onPick, height = 200, autoFocus = false, onEscape = null }) {
const [query, setQuery] = useState("");
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ICONS;
return ICONS.filter((i) => i.words.includes(q) || i.char === q);
if (!q) return ICON_GROUPS;
return ICON_GROUPS.map((g) => ({ ...g, icons: g.icons.filter((i) => matches(i, g.name, q)) })).filter(
(g) => g.icons.length > 0
);
}, [query]);
/* Marks that are not in the list: what was just typed, and what this row is
already wearing if it was typed some other day. Without the second one, a
mark chosen that way was invisible when the dialog was opened again and
looked as though it had been lost. */
const loose = useMemo(() => {
const own = value && !ICONS.some((i) => i.char === value) ? { char: value, words: "chosen" } : null;
const typed = typedMark(query);
return [own, typed && typed.char !== own?.char ? typed : null].filter(Boolean);
}, [query, value]);
const tile = (icon) => (
<button
key={icon.char}
type="button"
data-icon-option={icon.char}
title={icon.words.split(" ")[0]}
onClick={() => onPick(icon.char)}
style={{
height: 40,
borderRadius: 10,
cursor: "pointer",
fontSize: 20,
lineHeight: 1,
border: value === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: value === icon.char ? `${T.accent}22` : "transparent",
}}
>
{icon.char}
</button>
);
return (
<>
<input
autoFocus={autoFocus}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && onEscape?.()}
aria-label="Search icons"
placeholder="Search — invoice, house, travel…"
placeholder="Search — invoice, house, travel… or type an emoji"
style={{
width: "100%",
padding: "9px 12px",
@@ -100,7 +273,11 @@ export function IconChooser({ T, value, onPick, height = 200 }) {
}}
>
{/* "None" belongs in the grid rather than off in a corner: taking a mark
off is the same kind of choice as putting one on. */}
off is the same kind of choice as putting one on. It stays put while
searching — it is not one of the pictures being searched through —
and it carries a heading like every other row, or it stands alone in
a line of empty space and reads as a gap. */}
<Heading T={T}>None</Heading>
<button
type="button"
data-icon-option="none"
@@ -120,27 +297,21 @@ export function IconChooser({ T, value, onPick, height = 200 }) {
>
—
</button>
{shown.map((icon) => (
<button
key={icon.char}
type="button"
data-icon-option={icon.char}
title={icon.words.split(" ")[0]}
onClick={() => onPick(icon.char)}
style={{
height: 40,
borderRadius: 10,
cursor: "pointer",
fontSize: 20,
lineHeight: 1,
border: value === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: value === icon.char ? `${T.accent}22` : "transparent",
}}
>
{icon.char}
</button>
{loose.length > 0 && (
<>
<Heading T={T}>Typed</Heading>
{loose.map(tile)}
</>
)}
{shown.map((group) => (
<Fragment key={group.name}>
<Heading T={T} name={group.name}>
{group.name}
</Heading>
{group.icons.map(tile)}
</Fragment>
))}
{shown.length === 0 && (
{shown.length === 0 && loose.length === 0 && (
<div style={{ gridColumn: "1 / -1", fontSize: 12, color: T.muted, padding: "10px 2px" }}>
Nothing by that name.
</div>
+11 -139
View File
@@ -1,75 +1,22 @@
import { useMemo, useState } from "react";
import { CHAPTER_COLORS } from "./constants.jsx";
import { IconChooser } from "./IconChooser.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Choosing the mark a note or a chapter wears beside its name.
* Choosing the mark a note wears beside its name, and the colour that goes with
* it.
*
* A fixed list rather than the system emoji keyboard. That keyboard is a
* different thing on every machine — on Android it is the one for typing, which
* means opening it inside a text field the app does not have — and it offers
* three thousand pictures for a decision that is "which of these is my invoice
* notebook". A few dozen, searchable by what they are for, is the whole job.
*
* The words are what makes it usable: nobody hunts a pictogram grid by shape.
* They are also the reason the list is written out rather than generated.
*/
const ICONS = [
{ char: "📄", words: "note paper document default" },
{ char: "📝", words: "note write draft memo" },
{ char: "📌", words: "pin important stick" },
{ char: "⭐", words: "star favourite important" },
{ char: "❗", words: "important urgent attention" },
{ char: "✅", words: "done finished checked task" },
{ char: "🕒", words: "later waiting time pending" },
{ char: "💡", words: "idea thought concept" },
{ char: "🔧", words: "tool fix repair maintenance" },
{ char: "🔨", words: "build work project diy" },
{ char: "🏠", words: "house home flat property" },
{ char: "🔌", words: "power electricity socket energy" },
{ char: "💧", words: "water plumbing bill" },
{ char: "🔥", words: "heating gas fire boiler" },
{ char: "🚗", words: "car vehicle travel garage" },
{ char: "🧾", words: "receipt invoice bill accounting" },
{ char: "💶", words: "money euro payment bank finance" },
{ char: "🏦", words: "bank account finance" },
{ char: "📊", words: "chart numbers report statistics" },
{ char: "📅", words: "calendar date appointment plan" },
{ char: "✈️", words: "travel holiday flight trip" },
{ char: "🍳", words: "cooking recipe kitchen food" },
{ char: "🛒", words: "shopping list groceries" },
{ char: "🎁", words: "gift present birthday" },
{ char: "🎵", words: "music song audio" },
{ char: "📷", words: "photo picture camera" },
{ char: "🎬", words: "film video movie watch" },
{ char: "📚", words: "book reading study library" },
{ char: "🎓", words: "school study course learning" },
{ char: "💼", words: "work job office business" },
{ char: "🖥️", words: "computer server it hardware" },
{ char: "🌐", words: "web internet network domain" },
{ char: "🔒", words: "password secret secure private" },
{ char: "🐳", words: "docker container server" },
{ char: "🧠", words: "thinking notes knowledge brain" },
{ char: "❤️", words: "love personal family" },
{ char: "🐾", words: "pet animal dog cat" },
{ char: "🌱", words: "garden plant growing nature" },
{ char: "⚽", words: "sport football hobby" },
{ char: "🏃", words: "running fitness health sport" },
{ char: "💊", words: "health medicine doctor" },
{ char: "🩺", words: "doctor health medical appointment" },
{ char: "📦", words: "package delivery order storage" },
{ char: "🗂️", words: "files archive folder sorting" },
];
/**
* The picker.
* The grid itself is `IconChooser` — the same one the chapter dialog uses. It
* had been copied here, and the copy fell behind: marks added to one list did
* not exist in the other, and which of the two you got depended on which dialog
* you happened to open.
*
* `value` and `colour` are what the row wears now; "None" is always offered,
* because a mark that cannot be taken off again is a trap.
*/
export function IconPicker({ title, value = null, colour = null, onSave, onClose, dark }) {
const [query, setQuery] = useState("");
const [picked, setPicked] = useState(value);
const [tint, setTint] = useState(colour);
const backdrop = useBackdropClose(onClose);
@@ -86,12 +33,6 @@ export function IconPicker({ title, value = null, colour = null, onSave, onClose
[dark]
);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ICONS;
return ICONS.filter((i) => i.words.includes(q) || i.char === q);
}, [query]);
const save = (char, colourValue) => {
onSave(char, colourValue);
onClose();
@@ -125,62 +66,7 @@ export function IconPicker({ title, value = null, colour = null, onSave, onClose
>
<h3 style={{ margin: "0 0 12px", fontSize: 16, fontWeight: 700, color: T.text }}>{title}</h3>
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && onClose()}
aria-label="Search icons"
placeholder="Search — invoice, house, travel…"
style={{
width: "100%",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13,
outline: "none",
boxSizing: "border-box",
}}
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(42px, 1fr))",
gap: 6,
marginTop: 14,
maxHeight: 240,
overflowY: "auto",
}}
>
{shown.map((icon) => (
<button
key={icon.char}
type="button"
data-icon-option={icon.char}
title={icon.words.split(" ")[0]}
onClick={() => setPicked(icon.char)}
style={{
height: 42,
borderRadius: 10,
cursor: "pointer",
fontSize: 21,
lineHeight: 1,
border: picked === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: picked === icon.char ? `${T.accent}22` : "transparent",
}}
>
{icon.char}
</button>
))}
{shown.length === 0 && (
<div style={{ gridColumn: "1 / -1", fontSize: 12, color: T.muted, padding: "10px 2px" }}>
Nothing by that name.
</div>
)}
</div>
<IconChooser T={T} value={picked} onPick={setPicked} height={240} autoFocus onEscape={onClose} />
{/* The tint is for the marks that are drawn from the app's own set — a
coloured emoji ignores it, which is why it is offered quietly. */}
@@ -218,24 +104,10 @@ export function IconPicker({ title, value = null, colour = null, onSave, onClose
</div>
</div>
{/* No "No icon" button down here: the grid's own "—" says it, and two
ways to do one thing differ in what else they do — that one saved and
closed in a single press while every other choice waited for Save. */}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 18 }}>
<button
type="button"
data-icon-clear
onClick={() => save(null, null)}
style={{
padding: "8px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
cursor: "pointer",
fontSize: 13,
marginRight: "auto",
}}
>
No icon
</button>
<button
type="button"
onClick={onClose}
+50
View File
@@ -0,0 +1,50 @@
/* The list of marks is written out by hand, and adding one is meant to be a
* single line. These are the two mistakes that line can make. */
import { describe, expect, it } from "vitest";
import { ICONS, ICON_GROUPS, typedMark } from "./IconChooser.jsx";
describe("the marks a note can wear", () => {
it("holds each character once", () => {
// A repeat is invisible in the grid and breaks the row it sits in: the
// character is the key, and it is also what a saved note stores.
const seen = ICONS.map((i) => i.char);
expect(seen.length).toBe(new Set(seen).size);
});
it("gives every mark words to be found by", () => {
// Nobody hunts a pictogram grid by shape, so a mark with no words is a mark
// nobody will ever find.
const wordless = ICONS.filter((i) => i.words.trim().split(/\s+/).length < 2);
expect(wordless.map((i) => i.char)).toEqual([]);
});
it("keeps every mark in a group", () => {
expect(ICON_GROUPS.every((g) => g.name && g.icons.length > 0)).toBe(true);
expect(ICONS.length).toBe(ICON_GROUPS.reduce((n, g) => n + g.icons.length, 0));
});
});
describe("a mark typed into the search box", () => {
it("offers a picture that is not in the list", () => {
expect(typedMark("🦖")?.char).toBe("🦖");
expect(typedMark(" 🛰️ ")?.char).toBe("🛰️");
});
it("offers nothing for a word, since that is a search", () => {
expect(typedMark("house")).toBe(null);
expect(typedMark("")).toBe(null);
expect(typedMark("2")).toBe(null);
// Punctuation is not a picture either, whatever it is made of.
expect(typedMark("!?")).toBe(null);
});
it("offers nothing for a mark the list already has", () => {
expect(typedMark("🏠")).toBe(null);
});
it("takes one glyph, not a pasted sentence", () => {
// The server keeps eight code points; anything longer is cut to the same.
expect([...(typedMark("🦖🦕🐊🐢🦎🐍🦂🕷️🦀")?.char ?? "")].length).toBe(8);
});
});
+43 -2
View File
@@ -541,6 +541,16 @@ itself. A `useLayoutEffect` runs between the DOM being built and the browser
painting it, which is exactly the gap that flash lived in. A test samples the
sheet's height every frame across a note switch, because one frame is all it was.
**A quote holds whole blocks.** A heading, a list and a closing line inside one
are ordinary blocks of the document, and they are spaced like ordinary blocks:
the rule that gives the writing its rhythm reaches only the editor's own
children, so inside a quote everything stood jammed against its neighbour — with
the items of a list further apart than the blocks around them, which read as the
quote being broken. The rule for a quote's children is written *after* the ones
for paragraphs and headings on purpose: they are exactly as specific, so earlier
it would lose. A paragraph inside a list item is more specific still and keeps
its zero.
**Pages are counted, not stored.** The toolbar says *Page 2 of 4* in the same
words the PDF reader uses. A page is the same 3:4 the canvas uses, so "page 2"
means the same thing writing or drawing. The note itself stays one continuous
@@ -1214,6 +1224,25 @@ editor **and in every link pointing at the note**, so it is recognised by one si
everywhere. A note's mark takes a
colour too; a chapter's uses the colour the chapter already has.
Marks get in two ways. **Anything can be typed into the search box** — it is a
text field, which on a phone is exactly where the system emoji keyboard lives, so
a mark the list never thought of is offered as a tile and used like any other.
Eight code points at most, the same limit the server keeps, so one glyph with its
modifiers fits and a pasted sentence does not; letters and digits mean a word was
typed and a search is meant.
The list itself lives in one place,
[`ui/IconChooser.jsx`](frontend/src/ui/IconChooser.jsx),
and **adding one is a single line** — a `{ char, words }` in whichever group it
belongs to. Nothing else has to be touched: what is stored on a note is the
character itself and not a number into the list, so entries may be added, moved
or dropped without disturbing anything already chosen. The words are the whole
point of the entry, so give it the ones somebody would actually type — what the
thing is *for*, not what it depicts. Both dialogs that offer a mark render that
same component; there were two copies of the list once, and only one of them
ever grew. The groups are headings, not filters — a search looks through all of
them at once, and a group with no match is not drawn.
**Separators** are lines drawn between notes, optionally named, stored on the
notebook beside the sort mode. Renaming and removing are both right-click, which
is how everything else in that list is acted on: a left-click on the label used
@@ -1351,7 +1380,14 @@ Writing the exporter also turned up a real bug in it: blocks were emitted
without a blank line between them, so a list written straight after another list
became more items of the first, and a quote or code fence on the next line was
read as part of the item above. That is not one reader's quirk — it is what the
format says — so the file rendered wrongly everywhere, not only here.
format says — so the file rendered wrongly everywhere, not only here. The same
rule was missing again one level down, inside a quote, until quotes were made to
carry more than a sentence.
Coming back the other way, a quote is read as a unit across the `>` lines that
divide it: a quoted blank line separates the blocks inside the quote, it does not
end it. Everything the exporter can put in a quote — headings, lists, tables,
code, further quotes — is read back out of one.
---
@@ -1375,7 +1411,12 @@ A note without a drawing skips the first question instead of showing it with two
thirds greyed out.
**Markdown** keeps the notes editable elsewhere. Headings, lists, quotes, code,
tables and task state all have equivalents and are carried over; a link to
tables and task state all have equivalents and are carried over. A quote may
hold whole blocks — a heading, a list, a closing line — and inside it they are
divided by a **quoted** blank line (`>` on its own), the same way blocks are
divided everywhere else: written on consecutive lines, the sentence after a
quoted list is read as more of the last bullet by every other reader, and a
truly empty line would end the quote instead of dividing it. A link to
another exported note becomes a relative link to its file, and a link to a note
outside the export degrades to its plain title rather than a dead reference.
A note with images or a drawing arrives as a ZIP so the files travel with it,