From 9e4677c70bd7220ae40a462628c963ac43a84b9e Mon Sep 17 00:00:00 2001 From: marc Date: Wed, 12 Aug 2026 00:29:00 +0200 Subject: [PATCH] Patterns on the covers, a search box that is simply there, roomier margin notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small things about the shape of the app. Covers can be patterns as well as colours — ruled paper, graph paper, pinstripe, bands, dots, cross-hatch, linen, washes, a ledger — drawn entirely with CSS gradient layers. No image files, nothing to upload, crisp at any size, and offline like everything else. A cover is stored as an index into that list, so the new ones are appended and never inserted: renumbering would silently repaint every notebook anyone already has. Two things the patterns taught. One idea per cover: two crossed diagonals meant as a chevron came out as noisy plaid at the size a cover is actually read, and were replaced with plain bands. And the grid has to scroll — with 27 covers the dialog ran past the bottom of a laptop screen and took the Create button with it, which a check now holds at 1440x800. The search box in a notebook is simply there now, and the button that used to open it is gone. A magnifying glass cost a click and having to remember that searching was possible at all; a search box is one line and says for itself what it does. Margin notes went from 208x74 to 288x112 — 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. And the notebook edit dialog was still part German, and would not say what the server's default number of versions actually is. It names it now, read from the server rather than written into the interface: it is set with MAX_VERSIONS_PER_NOTE, and a hardcoded "50" would quietly start lying the day that changed. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 28 ++++++++ backend/server.js | 19 ++++- e2e/lib.mjs | 9 +-- e2e/organise.mjs | 30 ++++++++ frontend/src/App.jsx | 1 + frontend/src/features/notes/NoteList.jsx | 28 +++----- frontend/src/features/pdf/PdfMargin.jsx | 23 +++--- frontend/src/types.ts | 8 +++ frontend/src/ui/NotebookModals.jsx | 40 +++++++++-- frontend/src/ui/constants.jsx | 89 ++++++++++++++++++++++++ readme.md | 35 +++++++++- 11 files changed, 262 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dbbd18..455b1a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ not from the output of `docker push`. --- +## Unreleased + +**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…` diff --git a/backend/server.js b/backend/server.js index 68a050b..cec4e79 100644 --- a/backend/server.js +++ b/backend/server.js @@ -278,6 +278,21 @@ const publicUser = (row) => ({ preferences: readPreferences(row.preferences), }); +/** + * The account as it is handed to whoever just signed in as it. + * + * Carries one thing that is not about the account at all: how many versions a + * notebook keeps when it names no number of its own. It is sent rather than + * written into the interface because it is configurable — + * `MAX_VERSIONS_PER_NOTE` — and a dialog that said "50" would quietly start + * lying the day that is changed. It rides here because this is the one payload + * every client already fetches, on login and on restoring a session. + * + * `publicUser` stays as it is: the admin list shows *other* people's accounts, + * and a server-wide setting has no business being repeated on each row. + */ +const signedInUser = (row) => ({ ...publicUser(row), defaultMaxVersions: MAX_VERSIONS_PER_NOTE }); + /* Interface settings the client may store. Anything else is dropped rather than * kept: this column is written by the browser, so it is an input like any other, * and a fixed shape is what stops it becoming a place to put arbitrary data. */ @@ -653,7 +668,7 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => { clearLoginAttempts(req); const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" }); - res.json({ token, user: publicUser(user) }); + res.json({ token, user: signedInUser(user) }); } catch (err) { next(err); } }); @@ -661,7 +676,7 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => { app.get("/api/auth/me", authMiddleware, (req, res) => { const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); if (!user) return res.status(404).json({ error: "User not found" }); - res.json(publicUser(user)); + res.json(signedInUser(user)); }); app.put("/api/auth/profile", authMiddleware, (req, res) => { diff --git a/e2e/lib.mjs b/e2e/lib.mjs index de2b7ae..d527205 100644 --- a/e2e/lib.mjs +++ b/e2e/lib.mjs @@ -184,14 +184,9 @@ export async function createNote(page) { await page.waitForTimeout(1200); } -/** Opens the search box in the note list and types into it. */ +/** Types into the note list's search box, which is always there. */ export async function search(page, query) { - const box = page.locator('input[aria-label="Search in this notebook"]'); - if ((await box.count()) === 0) { - await page.locator("[data-search-toggle]").click(); - await page.waitForTimeout(400); - } - await box.fill(query); + await page.locator('input[aria-label="Search in this notebook"]').fill(query); await page.waitForTimeout(1300); } diff --git a/e2e/organise.mjs b/e2e/organise.mjs index 78458db..daf422b 100644 --- a/e2e/organise.mjs +++ b/e2e/organise.mjs @@ -107,6 +107,18 @@ try { `${beforeDrag.join(",")} → ${arranged.join(",")}` ); + /* The search box is simply there, with nothing to open first. A magnifying + * glass costs a click and having to remember that searching is possible; the + * box is one line and says for itself what it does. */ + check( + (await p.locator('input[aria-label="Search in this notebook"]').count()) === 1, + "the search box is there without opening anything" + ); + check( + (await p.locator("[data-search-toggle]").count()) === 0, + "and the button that used to open it is gone" + ); + /* The point of the whole feature. */ await search(p, "Beta"); const searched = await titles(); @@ -371,6 +383,24 @@ try { "and it lands in the notebook that was chosen, not the first one", `${fresh?.notebook_id} vs ${target.id}` ); + + /* ── choosing a cover ── + * Covers are patterns as well as colours now, and there are enough of them + * that the grid has to scroll: without that the dialog runs past the bottom of + * a laptop screen and takes the Create button with it. */ + await p.setViewportSize({ width: 1440, height: 800 }); + await p.locator("button").filter({ hasText: "Dashboard" }).first().click(); + await p.waitForTimeout(600); + await p.getByRole("button", { name: "Add Notebook" }).click(); + await p.waitForTimeout(700); + const covers = p.locator('[data-cover-option]'); + check((await covers.count()) > 20, "there are patterned covers as well as plain ones", `${await covers.count()} covers`); + check( + await p.getByRole("button", { name: "Create", exact: true }).isVisible(), + "and the dialog still fits a laptop screen, Create and all" + ); + await p.getByRole("button", { name: "Cancel" }).click(); + await p.waitForTimeout(400); } catch (e) { check(false, "suite ran to the end", e.message); } finally { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8fd0501..e4319fd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -699,6 +699,7 @@ export default function App() { notebook={editingNb} onClose={() => setEditingNb(null)} onSave={(u) => updateNotebook(editingNb.id, u)} + defaultMaxVersions={user?.defaultMaxVersions} dark={dark} /> )} diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index 1cce7b3..8931f78 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -346,7 +346,6 @@ export function NoteList({ }) { // Opened by the button and kept open while there is something typed in it, so // a search never disappears because the box collapsed under it. - const [searching, setSearching] = useState(false); return (
{!isTrashView && } - {!isTrashView && ( - - )} {!isTrashView && ( onQueryChange(e.target.value)} onKeyDown={(e) => { - if (e.key === "Escape") { - onQueryChange(""); - setSearching(false); - } + if (e.key !== "Escape") return; + onQueryChange(""); + e.currentTarget.blur(); }} placeholder={`Search in ${title}...`} aria-label="Search in this notebook" diff --git a/frontend/src/features/pdf/PdfMargin.jsx b/frontend/src/features/pdf/PdfMargin.jsx index 5b90653..4cee9e1 100644 --- a/frontend/src/features/pdf/PdfMargin.jsx +++ b/frontend/src/features/pdf/PdfMargin.jsx @@ -15,8 +15,11 @@ import { useEffect, useRef, useState } from "react"; * others, because that one is being read closely and the rest are not. */ -export const MARGIN_W = 208; -const CARD_H = 74; +/* The margin is a column you actually write in, not a strip of labels. It was + * 208 x 74, which held about three short lines and left the space beside the + * page mostly empty — for a remark on a passage, three lines is a caption. */ +export const MARGIN_W = 288; +const CARD_H = 112; const CARD_GAP = 6; /** Cards at their anchors, pushed down where they would sit on top of each other. */ @@ -108,7 +111,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) { }} aria-label="Pinned note" placeholder="Note on this passage…" - rows={4} + rows={7} style={{ width: "100%", resize: "vertical", @@ -116,9 +119,9 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) { borderRadius: 8, background: "transparent", color: T.text, - fontSize: 11.5, - lineHeight: 1.5, - padding: "5px 7px", + fontSize: 12.5, + lineHeight: 1.55, + padding: "7px 9px", outline: "none", fontFamily: "inherit", }} @@ -162,13 +165,13 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) { ) : (
setVersions(e.target.value)} - placeholder="Vorgabe" + placeholder={defaultMaxVersions ? String(defaultMaxVersions) : "Default"} aria-label="Versions per note" style={{ width: 110, @@ -108,16 +108,29 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) { }} />
- Older versions are only deleted once newer ones push them out — never by age. Leave empty for the - server's default. + Older versions are only deleted once newer ones push them out — never by age. Leave it empty + for the server's default{defaultMaxVersions ? `, which is ${defaultMaxVersions}` : ""}.
Cover style
-
+
{COVERS.map((c, i) => (