diff --git a/CHANGELOG.md b/CHANGELOG.md index 597eac6..8a766d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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…` diff --git a/e2e/chapters.mjs b/e2e/chapters.mjs index ecac39f..2f12e07 100644 --- a/e2e/chapters.mjs +++ b/e2e/chapters.mjs @@ -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", diff --git a/e2e/organise.mjs b/e2e/organise.mjs index d037156..726762c 100644 --- a/e2e/organise.mjs +++ b/e2e/organise.mjs @@ -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 ── */ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c7fe846..7da7842 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 && ( 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} /> diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index 2be1093..fcba287 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -70,8 +70,11 @@ const Grip = ({ T, ...rest }) => ( ); -/** 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. */}
-
+ {/* 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. */} +
{editing ? ( )} + {/* 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 && ( + )}
)} diff --git a/frontend/src/features/notes/noteSort.ts b/frontend/src/features/notes/noteSort.ts index 10d4dcd..13ed6a7 100644 --- a/frontend/src/features/notes/noteSort.ts +++ b/frontend/src/features/notes/noteSort.ts @@ -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 = { diff --git a/frontend/src/ui/ConfirmDialog.jsx b/frontend/src/ui/ConfirmDialog.jsx index ccecfeb..ae32e72 100644 --- a/frontend/src/ui/ConfirmDialog.jsx +++ b/frontend/src/ui/ConfirmDialog.jsx @@ -14,6 +14,10 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi ); return (
{ - 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({