diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27c9a51..d225caf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,48 @@ not from the output of `docker push`.
---
+## 2026-08-16 — Marks, lines, and a table that breaks
+
+**Images** — web `sha256:…`, server `sha256:…`
+
+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.
+
+**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
+ 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.
+
+---
+
## 2026-08-12 — A notebook opens in its own order
**Images** — web `sha256:c077f0e0…`, server `sha256:c13af5d5…`
diff --git a/CLAUDE.md b/CLAUDE.md
index 349ea97..c038903 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,7 +34,7 @@ Beides muss vor einem Push grün sein.
# Unit (~4 s)
docker run --rm -v "$PWD/frontend:/w" -w /w node:20-alpine npx vitest run
-# Browser, zwölf Suiten – braucht den Dev-Stack
+# Browser, vierzehn Suiten – braucht den Dev-Stack
./e2e/run.sh # alle
./e2e/run.sh editor drawing # einzelne
```
diff --git a/backend/server.js b/backend/server.js
index a2b462a..520e4ef 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -456,6 +456,19 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_chapters_notebook ON chapters(notebook_id);
`);
+/* A mark of its own, so a row can be found by its shape rather than by reading
+ * every title. One short string — an emoji is one or two code points, and
+ * anything longer than a handful is not a mark. */
+if (!tableColumns("notes").includes("icon")) {
+ db.exec("ALTER TABLE notes ADD COLUMN icon TEXT");
+}
+if (!tableColumns("chapters").includes("icon")) {
+ db.exec("ALTER TABLE chapters ADD COLUMN icon TEXT");
+}
+if (!tableColumns("notes").includes("icon_color")) {
+ db.exec("ALTER TABLE notes ADD COLUMN icon_color TEXT");
+}
+
/* Added after chapters shipped, so an existing table needs it too. */
if (!tableColumns("chapters").includes("color")) {
db.exec("ALTER TABLE chapters ADD COLUMN color TEXT");
@@ -927,6 +940,9 @@ const SORT_MODES = ["manual", "updated", "oldest", "created", "title"];
/* An upper bound on the arrangement, so a malformed client cannot store an
* unbounded blob on a notebook. */
const MAX_SEPARATORS = 200;
+
+/** The ways a separator's rule can be drawn. Anything else is left off. */
+const SEPARATOR_STYLES = ["solid", "dashed", "dotted", "double", "thick"];
const MAX_SEPARATOR_LABEL = 80;
/** Keeps only what a separator is allowed to be. */
@@ -943,6 +959,10 @@ function cleanSeparators(input) {
* itself. `order` counts the notes before it, and that only means anything
* within one list. */
chapterId: typeof s.chapterId === "string" && s.chapterId ? s.chapterId.slice(0, 64) : null,
+ // Presentation, and it comes from a browser: a colour has to look like one
+ // and a style has to be one we draw, or it is simply left off.
+ color: cleanColor(s.color),
+ style: SEPARATOR_STYLES.includes(s.style) ? s.style : null,
}));
}
@@ -1080,14 +1100,14 @@ function isBigShrink(previous, next) {
/* ─── Note routes ─── */
app.get("/api/notes", authMiddleware, (req, res) => {
- const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
+ const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, icon, icon_color, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
res.json(notes);
});
app.get("/api/notes/trash", authMiddleware, (req, res) => {
// `chapter_id` travels with it: a note restored from the bin belongs back in
// the chapter it was filed in, not loose in the notebook.
- const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
+ const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, icon, icon_color, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
res.json(notes);
});
@@ -1234,7 +1254,7 @@ const replaceNoteAttachments = db.transaction((noteId, ids) => {
});
app.put("/api/notes/:id", authMiddleware, (req, res) => {
- const { title, content, notebookId, chapterId, starred, baseUpdatedAt, links, attachments } = req.body;
+ const { title, content, notebookId, chapterId, starred, icon, iconColor, baseUpdatedAt, links, attachments } = req.body;
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!note) return res.status(404).json({ error: "Not found" });
@@ -1289,6 +1309,15 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
WHERE id = ?`)
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, req.deviceId, req.params.id);
+ /* Cleared as deliberately as it is set: null means "take the mark off", which
+ COALESCE above cannot express. */
+ if (icon !== undefined) {
+ db.prepare("UPDATE notes SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id);
+ }
+ if (iconColor !== undefined) {
+ db.prepare("UPDATE notes SET icon_color = ? WHERE id = ?").run(cleanColor(iconColor), req.params.id);
+ }
+
if (chapterId !== undefined) {
db.prepare("UPDATE notes SET chapter_id = ? WHERE id = ?").run(chapterUpdate, req.params.id);
} else if (notebookId !== undefined && notebookId !== null && notebookId !== note.notebook_id) {
@@ -1713,16 +1742,30 @@ const MAX_CHAPTER_NAME = 120;
const cleanColor = (value) =>
typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : null;
+/**
+ * A row's own mark: one emoji, or nothing.
+ *
+ * Length is counted in code points, not in UTF-16 units — a single emoji is
+ * routinely two of the latter, and a flag or a family several more. Cut at eight
+ * so one grapheme with its modifiers fits and a pasted sentence does not.
+ */
+const cleanIcon = (value) => {
+ if (typeof value !== "string") return null;
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ return [...trimmed].slice(0, 8).join("");
+};
+
app.get("/api/chapters", authMiddleware, (req, res) => {
res.json(
db
- .prepare("SELECT id, notebook_id, name, color, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
+ .prepare("SELECT id, notebook_id, name, color, icon, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.all(req.userId)
);
});
app.post("/api/chapters", authMiddleware, (req, res) => {
- const { notebookId, name, color } = req.body || {};
+ const { notebookId, name, color, icon } = req.body || {};
const label = String(name ?? "").trim();
if (!label) return res.status(400).json({ error: "name required" });
if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" });
@@ -1736,8 +1779,8 @@ app.post("/api/chapters", authMiddleware, (req, res) => {
.prepare("SELECT COUNT(*) AS n FROM chapters WHERE notebook_id = ?")
.get(notebookId).n;
const id = uuid();
- db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, color, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?)")
- .run(id, req.userId, notebookId, label, cleanColor(color), order, req.deviceId);
+ db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
+ .run(id, req.userId, notebookId, label, cleanColor(color), cleanIcon(icon), order, req.deviceId);
broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId);
res.json({ id, notebookId, name: label, sortOrder: order });
});
@@ -1761,7 +1804,7 @@ app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
});
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
- const { name, sortOrder, color } = req.body || {};
+ const { name, sortOrder, color, icon } = req.body || {};
const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!chapter) return res.status(404).json({ error: "Not found" });
@@ -1771,8 +1814,18 @@ app.put("/api/chapters/:id", authMiddleware, (req, res) => {
if (!label) return res.status(400).json({ error: "name required" });
if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" });
}
- db.prepare("UPDATE chapters SET name = COALESCE(?, name), color = COALESCE(?, color), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?")
- .run(label ?? null, color === undefined ? null : cleanColor(color), sortOrder ?? null, req.deviceId, req.params.id);
+ db.prepare("UPDATE chapters SET name = COALESCE(?, name), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?")
+ .run(label ?? null, sortOrder ?? null, req.deviceId, req.params.id);
+
+ /* Colour and mark are set and *cleared* by the same field, so neither can ride
+ * on COALESCE the way the name does: null means "take it off", and COALESCE
+ * reads null as "leave it alone" — so choosing "None" quietly did nothing. */
+ if (color !== undefined) {
+ db.prepare("UPDATE chapters SET color = ? WHERE id = ?").run(cleanColor(color), req.params.id);
+ }
+ if (icon !== undefined) {
+ db.prepare("UPDATE chapters SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id);
+ }
broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
res.json({ success: true });
});
diff --git a/e2e/chapters.mjs b/e2e/chapters.mjs
index 2f12e07..8cb9f70 100644
--- a/e2e/chapters.mjs
+++ b/e2e/chapters.mjs
@@ -254,6 +254,61 @@ try {
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
+ /* ── a chapter can be divided too ──
+ * It is a list like the notebook is. Its lines are its own: each carries the
+ * list it was drawn in, so the notebook's stay outside and these stay here. */
+ /* Pressing a chapter row toggles it, so "make sure it is open" cannot be a
+ single click — whether it opens or closes depends on what was open before. */
+ const openChapterBlock = async () => {
+ for (let i = 0; i < 3; i++) {
+ if (await p.locator("[data-chapter-notes]").count()) return true;
+ await p.locator("[data-chapter-row]").first().click();
+ await p.waitForTimeout(1100);
+ }
+ return (await p.locator("[data-chapter-notes]").count()) > 0;
+ };
+ check(await openChapterBlock(), "a chapter unfolds under its row");
+ const insideBefore = await p.locator("[data-chapter-notes] [data-separator]").count();
+ await p.locator("[data-add-chapter-separator]").click();
+ await p.waitForTimeout(2200);
+ check(
+ (await p.locator("[data-chapter-notes] [data-separator]").count()) === insideBefore + 1,
+ "a line can be drawn inside a chapter",
+ String(await p.locator("[data-chapter-notes] [data-separator]").count())
+ );
+ const drawnIn = JSON.parse(
+ (await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Haus")?.separators || "[]"
+ );
+ check(
+ drawnIn.some((sep) => sep.chapterId) && drawnIn.some((sep) => !sep.chapterId),
+ "and it belongs to the chapter, while the notebook keeps its own",
+ JSON.stringify(drawnIn.map((sep) => [sep.label, sep.chapterId]))
+ );
+ await p.locator("[data-chapter-row]").first().click();
+ await p.waitForTimeout(1000);
+
+ /* ── a dialog does not close because a selection ended outside it ──
+ * Select a word in the field, drag a little past the edge, let go: the click
+ * lands on the backdrop — the nearest ancestor of press and release — and the
+ * dialog used to take that for "clicked beside me" and vanish mid-edit. */
+ await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
+ await p.waitForTimeout(600);
+ await p.getByText("Rename or recolour", { exact: true }).click();
+ await p.waitForTimeout(700);
+ const field = p.getByLabel("Chapter name");
+ const box = await field.boundingBox();
+ await p.mouse.move(box.x + box.width - 12, box.y + box.height / 2);
+ await p.mouse.down();
+ await p.mouse.move(box.x - 220, box.y + box.height / 2, { steps: 12 });
+ await p.mouse.up();
+ await p.waitForTimeout(800);
+ check(
+ (await p.locator("[data-name-dialog]").count()) === 1,
+ "a selection dragged past the edge does not close the dialog"
+ );
+ await p.keyboard.press("Escape");
+ await p.waitForTimeout(600);
+
/* ── 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
@@ -276,6 +331,53 @@ try {
JSON.stringify(coloured.map((c) => [c.name, c.color]))
);
+ /* ── a mark of its own ──
+ * A row found by its shape rather than by reading every title. The same glyph
+ * shows in the list and in the editor, so a note is recognised by one sign in
+ * both places. */
+ await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
+ await p.waitForTimeout(600);
+ await p.getByText("Icon", { exact: true }).click();
+ await p.waitForTimeout(700);
+ await p.getByLabel("Search icons").fill("invoice");
+ await p.waitForTimeout(400);
+ const offered = await p.locator("[data-icon-option]").count();
+ check(offered > 0 && offered < 20, "icons are searched by what they are for", `${offered} shown`);
+ await p.locator("[data-icon-option]").first().click();
+ await p.locator("[data-icon-save]").click();
+ await p.waitForTimeout(2200);
+ const marked = (await api(p, "GET", "/chapters")).body.find((c) => c.icon);
+ check(!!marked?.icon, "a chapter keeps the mark it was given", JSON.stringify([marked?.name, marked?.icon]));
+ check(
+ (await p.locator("[data-chapter-mark]").first().innerText()).trim() === marked.icon,
+ "and wears it in the list"
+ );
+
+ /* The same for a note, from the button left of its title. */
+ await p.locator("[data-note-row]").first().click();
+ await p.waitForTimeout(1200);
+ await p.locator("[data-note-icon]").click();
+ await p.waitForTimeout(700);
+ await p.getByLabel("Search icons").fill("house");
+ await p.waitForTimeout(400);
+ await p.locator("[data-icon-option]").first().click();
+ await p.locator('[data-icon-colour="#2da44e"]').click();
+ await p.locator("[data-icon-save]").click();
+ await p.waitForTimeout(2500);
+ const iconed = (await api(p, "GET", "/notes")).body.find((n) => n.icon);
+ check(!!iconed?.icon && iconed?.icon_color === "#2da44e", "a note keeps its mark and its colour", JSON.stringify([iconed?.title, iconed?.icon, iconed?.icon_color]));
+
+ /* It survives the trip to the server and back — the mark is on the note, not
+ in this browser. */
+ await p.reload();
+ await p.waitForTimeout(4000);
+ await openNotebook();
+ check(
+ (await p.locator("[data-note-icon]").getAttribute("data-note-icon")) === iconed.icon ||
+ (await p.locator("[data-note-mark]").first().innerText()).trim() === iconed.icon,
+ "and still wears it after a reload"
+ );
+
/* ── a chapter is not a wastebasket ──
* Removing one must never remove what it held: the notes come back to the
* notebook, where they can be seen and filed again. */
diff --git a/e2e/organise.mjs b/e2e/organise.mjs
index 726762c..ac79394 100644
--- a/e2e/organise.mjs
+++ b/e2e/organise.mjs
@@ -265,6 +265,40 @@ try {
await p.waitForTimeout(800);
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
+ /* ── a line can be given its own colour and shape ──
+ * A notebook divided into three parts is easier to read at a glance when the
+ * lines differ; grey-on-grey they all look like the same line repeated. */
+ await p.locator("[data-separator]").click({ button: "right" });
+ 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: "Teal" }).first().click();
+ await p.waitForTimeout(2000);
+ await p.locator("[data-separator]").click({ button: "right" });
+ await p.waitForTimeout(600);
+ await p.locator("[data-submenu-toggle]").filter({ hasText: "Line" }).first().click();
+ await p.waitForTimeout(500);
+ await p.locator("[data-submenu] button").filter({ hasText: "Dashed" }).first().click();
+ await p.waitForTimeout(2200);
+
+ const stored = (await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Ordnung");
+ const seps = JSON.parse(stored?.separators || "[]");
+ check(
+ seps[0]?.color === "#1f9c96" && seps[0]?.style === "dashed",
+ "a separator keeps its own colour and line style, on the server",
+ JSON.stringify(seps)
+ );
+ // Drawn, not just stored: the rule is a dashed border rather than a filled bar.
+ const drawn = await p.locator("[data-separator-rule]").first().evaluate((el) => {
+ const cs = getComputedStyle(el);
+ return { style: cs.borderTopStyle, colour: cs.borderTopColor };
+ });
+ check(
+ drawn.style === "dashed" && /31,\s*156,\s*150/.test(drawn.colour),
+ "and it is drawn that way, not only recorded",
+ JSON.stringify(drawn)
+ );
+
/* Removing is a right-click behind a confirmation. The × used to sit a few
pixels from the label you click to rename, so tidying the list and
destroying part of it were neighbouring gestures. */
diff --git a/e2e/run.sh b/e2e/run.sh
index 71969f8..2500904 100755
--- a/e2e/run.sh
+++ b/e2e/run.sh
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
URL="${INKNOTE_URL:-http://localhost:3000}"
SUITES=("$@")
-[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters offline sync)
+[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters tables offline sync)
mkdir -p shots
if [ ! -d node_modules ]; then
diff --git a/e2e/tables.mjs b/e2e/tables.mjs
new file mode 100644
index 0000000..5c072ad
--- /dev/null
+++ b/e2e/tables.mjs
@@ -0,0 +1,130 @@
+/* Tables, and what happens to one at the edge of a sheet.
+ *
+ * The writing surface is drawn as separate sheets with a gap between them. Text
+ * is pushed past that gap a block at a time, which is right for a paragraph and
+ * wrong for a table: moved whole, a table either jumps to the next sheet leaving
+ * the rest of this one blank, or — being longer than a sheet — has nowhere to
+ * jump to and lies straight across the gap with a row cut in half.
+ *
+ * So the claim under test is that a long table is broken *between its rows*, and
+ * that no row is left straddling a gap.
+ */
+import { OUT, check, createNote, createNotebook, launch, report, setTitle, withFreshUser } from "./lib.mjs";
+
+/** Where each sheet sits, and therefore where the gaps are. */
+const sheetBands = (page) =>
+ page.evaluate(() =>
+ [...document.querySelectorAll("[data-page-sheet]")].map((el) => {
+ const r = el.getBoundingClientRect();
+ return { top: r.top, bottom: r.bottom };
+ })
+ );
+
+/** Every table row on screen, with the band it occupies. */
+const rowBands = (page) =>
+ page.evaluate(() =>
+ [...document.querySelectorAll(".ProseMirror table tr:not(.ne-page-spacer-row)")].map((el) => {
+ const r = el.getBoundingClientRect();
+ return { top: r.top, bottom: r.bottom, text: el.textContent.trim().slice(0, 12) };
+ })
+ );
+
+const browser = await launch();
+let session;
+try {
+ session = await withFreshUser(browser, "tables");
+ const p = session.page;
+
+ await createNotebook(p, "Tabellen");
+ await p.locator("button").filter({ hasText: "Tabellen" }).first().click();
+ await p.waitForTimeout(700);
+ await createNote(p);
+ await setTitle(p, "Lange Tabelle");
+
+ /* Enough text to push the table down the sheet, then a table long enough to
+ run past the bottom of it — the two cases that used to go wrong. */
+ await p.locator(".ProseMirror").click();
+ for (let i = 0; i < 12; i++) {
+ await p.keyboard.type(`Zeile ${i} vor der Tabelle. `);
+ await p.keyboard.press("Enter");
+ }
+ await p.waitForTimeout(500);
+
+ await p.locator("[data-table-menu] button").first().click();
+ await p.waitForTimeout(400);
+ await p.getByText("Insert 3 × 3 table", { exact: true }).click();
+ await p.waitForTimeout(1200);
+ check((await p.locator(".ProseMirror table").count()) === 1, "a table can be inserted");
+
+ /* Rows are added from the menu on the table itself, which is also the thing
+ Marc uses — and the labels there are English like the rest of the app. */
+ await p.locator(".ProseMirror table td").first().click();
+ await p.waitForTimeout(300);
+ for (let i = 0; i < 26; i++) {
+ await p.keyboard.type(`R${i}`);
+ await p.locator(".ProseMirror table").click({ button: "right" });
+ await p.waitForTimeout(220);
+ await p.getByText("Insert row below", { exact: true }).click();
+ await p.waitForTimeout(220);
+ }
+ await p.waitForTimeout(2500);
+
+ check(
+ (await p.locator("[data-submenu-toggle], [role=menu]").count()) >= 0 &&
+ !/Kopfzeile|Zellen verbinden|Zelle teilen/.test(await p.locator("body").innerText()),
+ "the table's own menu speaks the same language as the rest of the app"
+ );
+
+ const bands = await sheetBands(p);
+ check(bands.length >= 2, "the table runs past the first sheet", `${bands.length} sheets`);
+
+ const spacers = await p.locator("tr.ne-page-spacer-row").count();
+ check(spacers >= 1, "the table is broken between its rows rather than moved whole", String(spacers));
+
+ /* The point of it all: nothing is left lying across a gap. A row may end
+ exactly on a sheet's edge — that is not a straddle — so a pixel of slack. */
+ const rows = await rowBands(p);
+ const gaps = bands.slice(0, -1).map((b, i) => ({ from: b.bottom, to: bands[i + 1].top }));
+ const straddling = rows.filter((r) => gaps.some((g) => r.bottom > g.from + 1 && r.top < g.to - 1));
+ check(
+ straddling.length === 0,
+ "and no row is left lying across the gap between two sheets",
+ straddling.map((r) => r.text).join(", ")
+ );
+
+ await p.screenshot({ path: `${OUT}/tables-split.png`, fullPage: true });
+
+ /* The header has to be legible on the dark theme, where the fill it had was a
+ difference you could only find by looking for it. */
+ // The table was inserted with a header row, so there is one to measure — the
+ // menu entry toggles it *off*, which would leave nothing to look at.
+ const header = await p.locator(".ProseMirror table th").first().evaluate((el) => {
+ const cs = getComputedStyle(el);
+ const cell = getComputedStyle(el.closest("table").querySelector("td") || el);
+ return { head: cs.backgroundColor, body: cell.backgroundColor, rule: cs.borderBottomWidth };
+ });
+ check(
+ header.head !== header.body && parseFloat(header.rule) >= 2,
+ "a header row is filled differently from the body and underlined",
+ JSON.stringify(header)
+ );
+
+ /* Written, nothing is lost: the spacers are decorations, so the body the
+ server keeps has the table and no page furniture in it. */
+ await p.waitForTimeout(2000);
+ const body = await p.evaluate(async () => {
+ const auth = { Authorization: "Bearer " + localStorage.getItem("inknote_token") };
+ const notes = await (await fetch("/api/notes", { headers: auth })).json();
+ const mine = notes.find((n) => n.title === "Lange Tabelle");
+ return (await (await fetch(`/api/notes/${mine.id}`, { headers: auth })).json()).content;
+ });
+ check(!/ne-page-spacer/.test(body || ""), "and no page furniture reaches the stored note");
+ check(/table/.test(body || ""), "which does hold the table itself", String(body || "").slice(0, 60));
+} catch (e) {
+ check(false, "suite ran to the end", e.message);
+} finally {
+ await session?.cleanup();
+ const code = report();
+ await browser.close();
+ process.exit(code);
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 7da7842..97dcb09 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -8,11 +8,12 @@ import { usePwaInstall } from "./hooks/usePwaInstall";
import { useNotebookDnd } from "./hooks/useNotebookDnd";
import { useNavigationHistory } from "./hooks/useNavigationHistory";
-import { CHAPTER_COLORS, DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
+import { CHAPTER_COLORS, DEFAULT_FOLDERS, SEPARATOR_STYLES, coverBackground } from "./ui/constants.jsx";
import { I } from "./ui/icons.jsx";
import { makeTheme, FONT_UI } from "./ui/theme";
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
import { NameDialog } from "./ui/NameDialog.jsx";
+import { IconPicker } from "./ui/IconPicker.jsx";
import { ContextMenu } from "./ui/ContextMenu.jsx";
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
import { AccountMenu } from "./ui/AccountMenu.jsx";
@@ -23,7 +24,7 @@ import { NotebookPicker, rememberNotebook } from "./ui/NotebookPicker.jsx";
import { ImportDialog } from "./ui/ImportDialog.jsx";
import { Sidebar } from "./ui/Sidebar.jsx";
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
-import { NoteList } from "./features/notes/NoteList.jsx";
+import { ChapterNotes, NoteList } from "./features/notes/NoteList.jsx";
import {
DEFAULT_NOTEBOOK_SORT,
DEFAULT_SORT,
@@ -99,6 +100,9 @@ export default function App() {
/* 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);
+ /* { title, value, colour, onSave } while a mark is being chosen for a note or
+ a chapter — one picker, because it is one decision either way. */
+ const [pickingIcon, setPickingIcon] = 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);
@@ -277,17 +281,30 @@ export default function App() {
activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next });
/* Written back whole: the notebook stores one list, and the ones belonging to
the other chapters have to survive a change made in this one. */
- const writeVisible = (visible) =>
- writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== listChapter), ...visible]);
+ const writeIn = (chapterId, visible) =>
+ writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== chapterId), ...visible]);
+ const writeVisible = (visible) => writeIn(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));
+ /* The same act inside a chapter. A chapter is a list like the notebook is, so
+ it can be divided like one — its lines carry its id and stay in it. */
+ const addChapterSeparator = () =>
+ activeChapter &&
+ writeIn(
+ activeChapter,
+ withNewSeparator(chapterSeparators, `sep-${Date.now().toString(36)}`, "", activeChapter)
+ );
const renameSeparator = (id, label) =>
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
+ /* Colour and pattern are presentation, so they are written the same way the
+ label is: onto the notebook's one list, leaving the others untouched. */
+ const restyleSeparator = (id, changes) =>
+ writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, ...changes } : s)));
const separatorContextMenu = (e, separator) => {
e.preventDefault();
@@ -297,6 +314,26 @@ export default function App() {
y: e.clientY,
items: [
{ label: "Rename", icon: I.edit, action: () => setRenamingSeparator(separator.id) },
+ {
+ label: "Colour",
+ icon: I.edit,
+ submenu: [
+ { label: "None", colorDot: T.faint, action: () => restyleSeparator(separator.id, { color: null }) },
+ ...CHAPTER_COLORS.map((c) => ({
+ label: c.label,
+ colorDot: c.value,
+ action: () => restyleSeparator(separator.id, { color: c.value }),
+ })),
+ ],
+ },
+ {
+ label: "Line",
+ icon: I.edit,
+ submenu: SEPARATOR_STYLES.map((st) => ({
+ label: st.label,
+ action: () => restyleSeparator(separator.id, { style: st.value }),
+ })),
+ },
{ divider: true },
{
label: "Remove separator",
@@ -357,6 +394,25 @@ export default function App() {
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), sortMode) : []),
[activeChapter, activeNotes, sortMode]
);
+ /* And the lines drawn inside it, which are its own — the notebook's stay in
+ the notebook, and each chapter's stay in that chapter. */
+ const chapterSeparators = useMemo(
+ () => separatorsFor(allSeparators, activeChapter),
+ [allSeparators, activeChapter]
+ );
+
+ /* Opening a notebook opens its first note with it.
+ *
+ * On a wide screen the editor is already there, and leaving it on "Select a
+ * note to edit" wastes two thirds of the window and a click on the thing you
+ * almost always want. On a phone the list *is* the screen, so opening a note
+ * would replace it and hide the notebook you just asked to see. */
+ useEffect(() => {
+ if (isMobile || activeNote || isTrashView) return;
+ if (activeFolder === "dashboard" || isSearching) return;
+ const first = filteredNotes[0];
+ if (first) setActiveNote(first.id);
+ }, [isMobile, activeNote, isTrashView, activeFolder, activeChapter, isSearching, filteredNotes]);
const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null;
// The editor opens as soon as the metadata is known; the body fills in when
@@ -501,6 +557,19 @@ export default function App() {
openNote(note.id);
});
+ /* A note's own mark. Written through its own call rather than with the title:
+ the title write is debounced behind typing, and a mark chosen with one press
+ would then arrive late or not at all. */
+ const pickNoteIcon = () => {
+ if (!noteMeta) return;
+ setPickingIcon({
+ title: "Icon for this note",
+ value: noteMeta.icon ?? null,
+ colour: noteMeta.iconColor ?? null,
+ onSave: (icon, colour) => data.setNoteIcon(noteMeta.id, icon, colour),
+ });
+ };
+
/**
* Runs a destructive action behind a confirmation prompt.
*
@@ -685,6 +754,20 @@ export default function App() {
onSave: (name, color) => data.editChapter(chapter.id, { name, color }),
}),
},
+ /* The mark is its own entry: a name and a colour are typed and picked
+ from a short list, a mark is hunted for in a grid — one dialog doing
+ both would be a form. */
+ {
+ label: "Icon",
+ icon: I.edit,
+ action: () =>
+ setPickingIcon({
+ title: `Icon for "${chapter.name}"`,
+ value: chapter.icon ?? null,
+ colour: chapter.color ?? null,
+ onSave: (icon, colour) => data.editChapter(chapter.id, { icon, color: colour }),
+ }),
+ },
{ divider: true },
{
label: "Remove chapter",
@@ -767,6 +850,28 @@ export default function App() {
// On a phone the list and the editor take turns; on a desktop both are shown.
/* A chapter's notes, unfolded under its own row. Indented and against a rule,
so it reads as "inside this" rather than as more of the same list. */
+ /* What a chapter holds, wherever it is shown. The column beside the list and
+ this unfolded block are the same list in two frames, so they draw it with
+ the same component — a chapter divisible in one and not the other would be
+ two different things under one name. */
+ const chapterContents = (compact) => (
+