diff --git a/CHANGELOG.md b/CHANGELOG.md index 12d6091..d7b0ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/.png` since the interface was put into English; the importer - was still looking for `zeichnungen/.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 - `
` — 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 + `
` 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). diff --git a/e2e/chapters.mjs b/e2e/chapters.mjs index b220d9d..8d56a8d 100644 --- a/e2e/chapters.mjs +++ b/e2e/chapters.mjs @@ -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); diff --git a/e2e/editor.mjs b/e2e/editor.mjs index 080240c..9437366 100644 --- a/e2e/editor.mjs +++ b/e2e/editor.mjs @@ -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); diff --git a/e2e/export.mjs b/e2e/export.mjs index 1dcb6c1..d82b975 100644 --- a/e2e/export.mjs +++ b/e2e/export.mjs @@ -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 diff --git a/frontend/src/editor/editor.css b/frontend/src/editor/editor.css index 551cc5a..d241756 100644 --- a/frontend/src/editor/editor.css +++ b/frontend/src/editor/editor.css @@ -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); diff --git a/frontend/src/export/markdown.test.ts b/frontend/src/export/markdown.test.ts index 16c4514..8c888ca 100644 --- a/frontend/src/export/markdown.test.ts +++ b/frontend/src/export/markdown.test.ts @@ -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```"); diff --git a/frontend/src/export/markdown.ts b/frontend/src/export/markdown.ts index 8062191..6b751b6 100644 --- a/frontend/src/export/markdown.ts +++ b/frontend/src/export/markdown.ts @@ -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."); diff --git a/frontend/src/export/print.ts b/frontend/src/export/print.ts index 4f49857..9e9287c 100644 --- a/frontend/src/export/print.ts +++ b/frontend/src/export/print.ts @@ -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; } diff --git a/frontend/src/import/markdownImport.test.ts b/frontend/src/import/markdownImport.test.ts index c78a56d..0a7c641 100644 --- a/frontend/src/import/markdownImport.test.ts +++ b/frontend/src/import/markdownImport.test.ts @@ -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"); }); diff --git a/frontend/src/ui/ChapterDialog.jsx b/frontend/src/ui/ChapterDialog.jsx index 8fee89b..3eddfa4 100644 --- a/frontend/src/ui/ChapterDialog.jsx +++ b/frontend/src/ui/ChapterDialog.jsx @@ -208,7 +208,9 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
Icon
- + {/* 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. */} +
+ ); + return ( <> 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. */} + None - {shown.map((icon) => ( - + {loose.length > 0 && ( + <> + Typed + {loose.map(tile)} + + )} + {shown.map((group) => ( + + + {group.name} + + {group.icons.map(tile)} + ))} - {shown.length === 0 && ( + {shown.length === 0 && loose.length === 0 && (
Nothing by that name.
diff --git a/frontend/src/ui/IconPicker.jsx b/frontend/src/ui/IconPicker.jsx index 7293566..829cf0f 100644 --- a/frontend/src/ui/IconPicker.jsx +++ b/frontend/src/ui/IconPicker.jsx @@ -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 >

{title}

- 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", - }} - /> - -
- {shown.map((icon) => ( - - ))} - {shown.length === 0 && ( -
- Nothing by that name. -
- )} -
+ {/* 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
+ {/* 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. */}
-