A notebook opens in its own order

The hand-made order is where a notebook's own arrangement lives:
separators show, rows can be put where they belong, nothing slides about
by itself. Starting in "last changed" made both of those look broken
until the sort menu was found — pressing "+ Separator" there drew a line
the order cannot display, so the button appeared dead. A notebook nobody
has arranged still reads newest-first, because untouched rows all carry
position 0, so nothing is lost by starting there. The views keep "last
changed": they are questions asked of every notebook at once and have
nowhere to keep an arrangement. The button that draws a line is now only
offered in the order that can show one.

Removing a chapter left you stuck in the confirmation. Every destructive
action goes through one helper that takes the question down again; this
one set the dialog directly and bypassed it, so the chapter was removed
and the prompt stayed over the app. Closing now happens where the dialog
is rendered, so it happens whoever asked and however they asked.

The suites did not catch that, and the reason is worth keeping: they
read the list straight through the overlay, which is in the DOM the
whole time, and passed while the screen was in fact stuck. Both now
assert the question is gone afterwards.

The separator is no wider than its name. It sat in a fixed 132px slot —
chosen so the row would not jump when renaming began — with a short name
floating in a gap it did not fill.

Thirteen suites green: 345 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 21:45:03 +02:00
1 parent cc0b58dd2f
commit c96885d5b3
9 files changed
+223 -69

No files matched your search

+33
View File
@@ -8,6 +8,39 @@ not from the output of `docker push`.
---
## 2026-08-12 — A notebook opens in its own order
**Images** — web `sha256:…`, server unchanged in substance
**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…`
+24 -5
View File
@@ -254,17 +254,25 @@ try {
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
/* ── its own colour ── */
/* ── its own colour, afterwards ──
* The same dialog that named it. There used to be a "Colour" submenu beside
* this entry as well: two things in the menu, one of them offering half of
* what the other did. */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.locator("[data-submenu-toggle]").filter({ hasText: "Colour" }).first().click();
await p.waitForTimeout(500);
await p.locator("[data-submenu] button").filter({ hasText: "Green" }).first().click();
check(
(await p.locator("[data-submenu-toggle]").filter({ hasText: "Colour" }).count()) === 0,
"there is one entry for a chapter's name and colour, not two"
);
await p.getByText("Rename or recolour", { exact: true }).click();
await p.waitForTimeout(700);
await p.locator('[data-colour-option="#2da44e"]').click();
await p.getByRole("button", { name: "Save", exact: true }).click();
await p.waitForTimeout(2000);
const coloured = (await api(p, "GET", "/chapters")).body;
check(
coloured.some((c) => c.color === "#2da44e"),
"a chapter can be given its own colour",
"a chapter can be recoloured after the fact",
JSON.stringify(coloured.map((c) => [c.name, c.color]))
);
@@ -279,6 +287,17 @@ try {
await p.getByRole("button", { name: "Remove", exact: true }).click();
await p.waitForTimeout(2500);
/* The question has to be gone afterwards.
*
* It was not: removing a chapter did its work and left the prompt standing
* over the app, with nothing but a reload to get out of. Every check below
* still passed — they read the list straight through the overlay, which is
* there in the DOM the whole time. */
check(
(await p.locator("[data-confirm-dialog]").count()) === 0,
"confirming closes the question rather than leaving it over the app"
);
check(
(await p.locator("[data-chapter-row]").count()) === 1,
"the chapter is gone, and only that one",
+28
View File
@@ -64,6 +64,23 @@ try {
check((await titles()).length === 3, "three notes to arrange", (await titles()).join(", "));
/* ── what a notebook is in before anyone chooses ──
* Its own order. That is where a notebook's arrangement lives: lines show,
* rows can be put where they belong, nothing slides about by itself. It used
* to open in "last changed", so both of those looked broken until the sort
* menu was found — a notebook nobody had arranged still reads newest-first,
* because untouched rows all share position 0. */
check(
(await p.locator("[data-sort-button]").getAttribute("data-sort-button")) === "manual",
"a notebook is in its own order to begin with",
await p.locator("[data-sort-button]").getAttribute("data-sort-button")
);
check(
(await p.locator("[data-add-separator]").count()) === 1,
"so a line can be drawn in it straight away"
);
check((await titles())[0] === "Gamma", "and it reads newest first until something is moved", (await titles()).join(", "));
/* ── the built-in orders ── */
await sortBy("updated");
check((await titles())[0] === "Gamma", "newest first by default", (await titles()).join(", "));
@@ -237,6 +254,13 @@ try {
// notes would slide past the line on every edit.
await sortBy("updated");
check((await p.locator("[data-separator]").count()) === 0, "it is hidden in the other orders");
/* And the button that draws one goes with it. Standing in an order that
cannot show a line, pressing it produced nothing at all — which reads
exactly like a button that is broken. */
check(
(await p.locator("[data-add-separator]").count()) === 0,
"and so is the button that draws one, which could only do nothing there"
);
await sortBy("manual");
await p.waitForTimeout(800);
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
@@ -261,6 +285,10 @@ try {
await p.getByRole("button", { name: "Remove" }).click();
await p.waitForTimeout(2000);
check((await p.locator("[data-separator]").count()) === 0, "and then it is gone");
check(
(await p.locator("[data-confirm-dialog]").count()) === 0,
"and the question is gone with it, not left standing over the app"
);
check((await titles()).length === 3, "with every note still there", (await titles()).join(", "));
/* ── exporting a note and importing it back ── */
+51 -40
View File
@@ -25,6 +25,7 @@ import { Sidebar } from "./ui/Sidebar.jsx";
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
import { NoteList } from "./features/notes/NoteList.jsx";
import {
DEFAULT_NOTEBOOK_SORT,
DEFAULT_SORT,
canReorder,
sortModesFor,
@@ -95,8 +96,9 @@ export default function App() {
const [renamingSeparator, setRenamingSeparator] = useState(null);
const [historyFor, setHistoryFor] = useState(null);
const [ctxMenu, setCtxMenu] = useState(null);
// { title, value, onSave } while a chapter is being named or renamed.
const [namingChapter, setNamingChapter] = useState(null);
/* The one dialog that asks for a name — a chapter's, with its colour, or a
separator's. Held here because both are started from menus that live here. */
const [naming, setNaming] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(null);
// Bumped on a version restore to force a fresh editor on the restored body.
const [restoreEpoch, setRestoreEpoch] = useState(0);
@@ -246,7 +248,7 @@ export default function App() {
/* A real notebook remembers its order on the server; a view keeps one for the
session. Either way the note list is told a single mode. */
const rawSortMode = activeNotebook
? activeNotebook.sortMode || DEFAULT_SORT
? activeNotebook.sortMode || DEFAULT_NOTEBOOK_SORT
: virtualSort[activeFolder] || DEFAULT_SORT;
// A view has nowhere to keep a hand-made arrangement, so it cannot be in one —
// even if an older version of the app left "manual" behind on a notebook that
@@ -278,6 +280,9 @@ export default function App() {
const writeVisible = (visible) =>
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== listChapter), ...visible]);
/* One press draws the line; it is named afterwards from its own menu, like it
is renamed. A dialog in front of it asked a question about something that
reads perfectly well unnamed. */
const addSeparator = () =>
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", listChapter));
const renameSeparator = (id, label) =>
@@ -496,15 +501,16 @@ export default function App() {
openNote(note.id);
});
/** Runs a destructive action behind a confirmation prompt. */
const confirm = (dialog) =>
setConfirmDialog({
...dialog,
onConfirm: async () => {
await dialog.onConfirm();
setConfirmDialog(null);
},
});
/**
* Runs a destructive action behind a confirmation prompt.
*
* Only sets the question. Closing it again is done where the dialog is
* rendered, so it happens whoever asked and however they asked: this used to
* be wrapped here instead, and the one caller that set the dialog directly —
* removing a chapter — did its work and then left the prompt standing over
* the app with nothing but a reload to get out of.
*/
const confirm = (dialog) => setConfirmDialog(dialog);
const trashNote = (id) =>
confirm({
@@ -645,9 +651,11 @@ export default function App() {
rather than a second visit through the right-click menu. */
const addChapter = () => {
if (!activeNotebook) return;
setNamingChapter({
setNaming({
title: "New chapter",
label: "Chapter name",
value: "",
colors: CHAPTER_COLORS,
color: null,
onSave: (name, color) => data.createChapter(activeNotebook.id, name, color),
});
@@ -660,45 +668,42 @@ export default function App() {
x: e.clientX,
y: e.clientY,
items: [
/* One entry, not two. The name and the colour are asked for together
when a chapter is made, so changing them is the same dialog again —
a second "Colour" submenu beside it offered half of what this does
and read as a different thing. */
{
label: "Rename or recolour",
icon: I.edit,
action: () =>
setNamingChapter({
setNaming({
title: "Chapter",
label: "Chapter name",
value: chapter.name,
colors: CHAPTER_COLORS,
color: chapter.color ?? null,
onSave: (name, color) => data.editChapter(chapter.id, { name, color }),
}),
},
{
label: "Colour",
icon: I.edit,
submenu: [
{ label: "None", colorDot: T.faint, action: () => data.editChapter(chapter.id, { color: null }) },
...CHAPTER_COLORS.map((c) => ({
label: c.label,
colorDot: c.value,
action: () => data.editChapter(chapter.id, { color: c.value }),
})),
],
},
{ divider: true },
{
label: "Remove chapter",
icon: I.trash,
danger: true,
action: () =>
setConfirmDialog({
confirm({
title: "Remove chapter?",
message: `"${chapter.name}" is removed. Its ${chapter.noteCount} note${
chapter.noteCount === 1 ? "" : "s"
} stay — they go back to the notebook.`,
confirmLabel: "Remove",
danger: true,
onConfirm: () => {
if (activeChapter === chapter.id) setActiveChapter(null);
void data.deleteChapter(chapter.id);
onConfirm: async () => {
if (activeChapter === chapter.id) {
setActiveChapter(null);
setInChapter(false);
}
await data.deleteChapter(chapter.id);
},
}),
},
@@ -1170,16 +1175,16 @@ export default function App() {
)}
/>
)}
{namingChapter && (
{naming && (
<NameDialog
title={namingChapter.title}
value={namingChapter.value}
label="Chapter name"
colors={CHAPTER_COLORS}
color={namingChapter.color}
confirmLabel={namingChapter.value ? "Save" : "Create"}
onSave={namingChapter.onSave}
onClose={() => setNamingChapter(null)}
title={naming.title}
value={naming.value}
label={naming.label ?? "Chapter name"}
colors={naming.colors}
color={naming.color}
confirmLabel={naming.confirmLabel ?? (naming.value ? "Save" : "Create")}
onSave={naming.onSave}
onClose={() => setNaming(null)}
dark={dark}
/>
)}
@@ -1188,7 +1193,13 @@ export default function App() {
title={confirmDialog.title}
message={confirmDialog.message}
confirmLabel={confirmDialog.confirmLabel}
onConfirm={confirmDialog.onConfirm}
/* The one place the question is taken down, whatever it asked and
whoever set it. Leaving that to each caller left one of them
holding the screen hostage after doing its work. */
onConfirm={async () => {
await confirmDialog.onConfirm?.();
setConfirmDialog(null);
}}
onCancel={() => setConfirmDialog(null)}
dark={dark}
/>
+34 -11
View File
@@ -70,8 +70,11 @@ const Grip = ({ T, ...rest }) => (
</div>
);
/** Width the separator's label keeps in both states, so renaming moves nothing. */
const MIDDLE_W = 132;
/** Narrowest the label's box gets, so an unnamed line still has something to hit. */
const MIDDLE_MIN = 54;
/** Widest, so a long name cannot squeeze the rules away to nothing. */
const MIDDLE_MAX = 170;
/**
* A line drawn between notes, with an optional name.
@@ -103,10 +106,11 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
style={{
display: "flex",
alignItems: "center",
gap: 8,
/* Room above it, so it reads as the end of what came before rather than
as decoration attached to the next note. */
padding: "14px 8px 8px",
gap: 7,
/* A little room above, so it reads as the end of what came before rather
than as decoration attached to the next note — but only a little: it
is a line between two rows, not a heading of its own. */
padding: "9px 8px 5px",
cursor: grip ? "grab" : "default",
}}
>
@@ -116,7 +120,17 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
its contents made the whole row jump sideways the moment renaming
started, which is exactly when the eye is on it. */}
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
<div style={{ flex: "0 0 auto", width: MIDDLE_W, display: "flex" }}>
{/* Sized to the name it holds, between a floor and a ceiling. A fixed slot
kept the row from jumping when renaming began, at the cost of a short
name floating in the middle of a gap it did not fill. */}
<div
style={{
flex: "0 1 auto",
minWidth: MIDDLE_MIN,
maxWidth: MIDDLE_MAX,
display: "flex",
}}
>
{editing ? (
<input
autoFocus
@@ -132,6 +146,9 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
}}
aria-label="Separator label"
placeholder="Section"
// Follows what is being typed, so the row grows with the name
// instead of standing in a slot sized for a name nobody wrote.
size={Math.max(6, draft.length + 1)}
style={{
width: "100%",
minWidth: 0,
@@ -139,9 +156,9 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
borderRadius: 6,
background: "transparent",
color: T.text,
fontSize: 12.5,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.8,
letterSpacing: 0.6,
textAlign: "center",
// Matches the button's box exactly, so nothing shifts vertically
// either when the input replaces it.
@@ -157,9 +174,9 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
border: "1px solid transparent",
background: "transparent",
color: separator.label ? T.text : T.muted,
fontSize: 12.5,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.8,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
padding: "1px 4px",
@@ -686,6 +703,11 @@ export function NoteList({
+ Chapter
</button>
)}
{/* Only where a line can be seen. A separator belongs to the hand-made
order and is hidden in every other one, so the button stood in
orders where pressing it produced nothing at all — which reads
exactly like a button that is broken. */}
{reorderable && (
<button
onClick={onAddSeparator}
data-add-separator
@@ -704,6 +726,7 @@ export function NoteList({
>
+ Separator
</button>
)}
</div>
)}
+17
View File
@@ -14,6 +14,23 @@
import type { Chapter, NoteMeta, Separator, SortMode } from "../../types";
/**
* What a notebook is in until it is told otherwise.
*
* The hand-made order, because that is where a notebook's own arrangement lives:
* separators show, rows can be dragged into place, and nothing slides about on
* its own. A notebook that has never been arranged loses nothing by it —
* untouched rows all carry position 0 and fall back to newest first, which is
* what the old default showed.
*/
export const DEFAULT_NOTEBOOK_SORT: SortMode = "manual";
/**
* What a *view* is in — "All Notes", "Starred", the bin.
*
* Not the hand-made one: those are questions asked of every notebook at once, so
* there is nothing for an arrangement to belong to and nowhere to keep it.
*/
export const DEFAULT_SORT: SortMode = "updated";
export const SORT_LABELS: Record<SortMode, string> = {
+4
View File
@@ -14,6 +14,10 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
);
return (
<div
// Named so a test can say "and the question is gone afterwards". Without
// that, a check reads the list straight through the overlay and passes
// while the screen is in fact stuck behind it.
data-confirm-dialog
style={{
position: "fixed",
inset: 0,
+7 -6
View File
@@ -47,10 +47,11 @@ export function NameDialog({
inputRef.current?.select();
}, []);
const ready = !!name.trim();
const save = () => {
const trimmed = name.trim();
if (!trimmed) return;
onSave(trimmed, color);
if (!ready) return;
onSave(name.trim(), color);
onClose();
};
@@ -157,14 +158,14 @@ export function NameDialog({
</button>
<button
onClick={save}
disabled={!name.trim()}
disabled={!ready}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: name.trim() ? T.accent : T.border,
background: ready ? T.accent : T.border,
color: "#fff",
cursor: name.trim() ? "pointer" : "default",
cursor: ready ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
+25 -7
View File
@@ -439,8 +439,8 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `organise` | 53 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `chapters` | 34 | a filed note stays filed across a sync; the notebook's separators survive a chapter being opened; handles appear only while rearranging |
| `organise` | 58 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `chapters` | 36 | a filed note stays filed across a sync; the notebook's separators survive a chapter being opened; handles appear only while rearranging |
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
@@ -1059,10 +1059,22 @@ finding one sorted differently on the phone than on the desktop is its own small
annoyance. A reference book can stay alphabetical while a journal stays
newest-first.
**A notebook starts in the hand-made order.** That is where a notebook's own
arrangement lives: separators show, rows wear their handles once rearranging is
switched on, and nothing slides about by itself. A notebook nobody has arranged
still reads newest-first — untouched rows all carry position 0 and fall back to
it — so nothing is lost by starting there, while starting in "last changed" made
both separators and dragging look broken until the sort menu was found.
The views are different: "All Notes", "Starred" and the bin are questions asked
of every notebook at once, so there is nothing for an arrangement to belong to
and nowhere to keep it. They start in "last changed" and are not offered the
hand-made order at all.
| Mode | Order |
| --- | --- |
| `manual` | the user's own arrangement, dragged into place — notebooks only |
| `updated` | most recently changed first *(the default)* |
| `manual` | the user's own arrangement, dragged into place — notebooks only, *and what a notebook starts in* |
| `updated` | most recently changed first *(the default for the views: All Notes, Starred, the bin)* |
| `oldest` | least recently changed first |
| `created` | most recently created first |
| `title` | A–Z, case- and umlaut-aware, with numbers read as numbers |
@@ -1090,9 +1102,15 @@ edit, and the × for removal sat a few pixels from that same target. Removal ask
first, and says the notes are not affected. They move by dragging like a note does — a
divider that cannot move with what it divides is worse than none — and their
position is recorded as "how many notes come before me", so they stay put as the
notes around them are renamed, edited or opened. They are shown only in the
manual order: in "newest first" the notes would slide past the line on every
edit.
notes around them are renamed, edited or opened.
One press draws the line; it is named afterwards from its own menu, the same way
it is renamed. A line reads perfectly well unnamed, so nothing is asked first.
They are shown only in the manual order: in "newest first" the notes would slide
past the line on every edit — and **the button that draws one is only there in
that order too**. Standing in the others it could do nothing at all when pressed,
which reads exactly like a button that is broken.
Reordering also has its own route, `POST /api/notes/reorder`. Going through
`PUT /api/notes/:id` would bump `updated_at` and snapshot a version — so