From 2ea448f0913ba42f4a09cd0514b38eb8278c00d4 Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 22 Aug 2026 00:10:13 +0200 Subject: [PATCH] A bigger face, and the theme where the settings are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picture fills the account button now — twenty pixels of a face is a smudge — and light or dark moved out of the sidebar corner into Account → Profile, next to the typeface and how a chapter opens, which is the kind of thing it is. Moving it made it worth keeping: it was pure screen state, so every reload landed on dark whatever had been chosen. It is a setting on the account now, like the typeface, with a copy in the browser so the page does not start in one theme and turn to the other once the account arrives. The check I first wrote for the sidebar losing its switch could not fail. It counts the buttons in that corner instead. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 21 ++++++++++++++++ backend/server.js | 13 +++++++++- e2e/editor.mjs | 38 +++++++++++++++++++++++++++++ frontend/src/App.jsx | 29 ++++++++++++++++++++-- frontend/src/types.ts | 2 ++ frontend/src/ui/AccountMenu.jsx | 43 ++++++++++++++++++++++++++++++++- frontend/src/ui/Sidebar.jsx | 26 +++++++++++++------- readme.md | 13 +++++++--- 8 files changed, 168 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2432981..d7c5baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,27 @@ a rebuild is not bit-identical. --- +## 2026-08-22 — A bigger face, and the theme where the settings are + +**Images** — web `sha256:fe35b0e7…`, server `sha256:a557fb4d…` + +The server image changed: `theme` is now one of the settings an account keeps. + +**Changed** +- **The profile picture fills the account button**, 34px instead of 20 — a face + at twenty pixels is a smudge — and the same size in the account panel. +- **Light or dark moved into Account → Profile**, beside the typeface and how a + chapter opens, which is the kind of thing it is. It used to be a sun in the + corner of the sidebar: reached for by accident, and gone again on the next + device. That corner now holds one button, the way into the account. +- **And it stays.** The theme was pure screen state before, so every reload + landed on dark whatever had been chosen. It belongs to the account now and + follows you to the phone, with a copy in the browser so a reload does not flash + the other one while the account is being fetched. The server takes `dark` or + `light` and drops anything else, like every other setting written by a browser. + +--- + ## 2026-08-21 — Asking for the picture properly **Images** — web `sha256:4310304d…`, server `sha256:7a486929…` diff --git a/backend/server.js b/backend/server.js index 42029ae..914d8cd 100644 --- a/backend/server.js +++ b/backend/server.js @@ -316,6 +316,11 @@ const NOTE_FONTS = ["serif", "sans", "mono"]; * lays out its pages, or unfolded in place under its own row. A phone has room * for neither, and falls back to filling the screen with a way back. */ const CHAPTER_VIEWS = ["column", "inline"]; +/* Light or dark. On the account rather than in the browser, like the typeface — + * it is a choice about how your own notes look, and making it again on every + * device is making it badly. The browser keeps a copy of its own so a reload + * does not flash the other one while the account is being fetched. */ +const THEMES = ["dark", "light"]; function readPreferences(raw) { let parsed = null; @@ -326,9 +331,11 @@ function readPreferences(raw) { } const font = parsed?.noteFont; const view = parsed?.chapterView; + const theme = parsed?.theme; return { noteFont: NOTE_FONTS.includes(font) ? font : "serif", chapterView: CHAPTER_VIEWS.includes(view) ? view : "column", + theme: THEMES.includes(theme) ? theme : "dark", }; } @@ -988,13 +995,16 @@ app.put("/api/auth/profile", authMiddleware, (req, res) => { }); app.put("/api/auth/preferences", authMiddleware, (req, res) => { - const { noteFont, chapterView } = req.body || {}; + const { noteFont, chapterView, theme } = req.body || {}; if (noteFont !== undefined && !NOTE_FONTS.includes(noteFont)) { return res.status(400).json({ error: `noteFont must be one of ${NOTE_FONTS.join(", ")}` }); } if (chapterView !== undefined && !CHAPTER_VIEWS.includes(chapterView)) { return res.status(400).json({ error: `chapterView must be one of ${CHAPTER_VIEWS.join(", ")}` }); } + if (theme !== undefined && !THEMES.includes(theme)) { + return res.status(400).json({ error: `theme must be one of ${THEMES.join(", ")}` }); + } const current = readPreferences( db.prepare("SELECT preferences FROM users WHERE id = ?").get(req.userId)?.preferences ); @@ -1002,6 +1012,7 @@ app.put("/api/auth/preferences", authMiddleware, (req, res) => { ...current, ...(noteFont !== undefined ? { noteFont } : {}), ...(chapterView !== undefined ? { chapterView } : {}), + ...(theme !== undefined ? { theme } : {}), }; db.prepare("UPDATE users SET preferences = ?, updated_at = datetime('now') WHERE id = ?") .run(JSON.stringify(next), req.userId); diff --git a/e2e/editor.mjs b/e2e/editor.mjs index 9437366..286bfbe 100644 --- a/e2e/editor.mjs +++ b/e2e/editor.mjs @@ -362,6 +362,44 @@ try { "and survives a reload" ); + /* ── light or dark is a setting, and it belongs to the account ── + * It used to be a sun in the corner of the sidebar: reached for by accident, + * forgotten by the next device, and gone on every reload. */ + const shade = () => + p.evaluate(() => getComputedStyle(document.querySelector("#root > div") || document.body).backgroundColor); + const wasDark = await shade(); + + await p.getByTitle("Account").click(); + await p.waitForTimeout(900); + check( + (await p.locator("[data-theme-option]").count()) === 2, + "the account panel offers light and dark" + ); + await p.locator('[data-theme-option="light"]').click(); + await p.waitForTimeout(1500); + await p.keyboard.press("Escape"); + await p.waitForTimeout(900); + check((await shade()) !== wasDark, "choosing one repaints the app", `${wasDark} → ${await shade()}`); + + const themed = await api(p, "GET", "/auth/me"); + check( + themed.body?.preferences?.theme === "light", + "and it is stored on the account, not in this browser", + JSON.stringify(themed.body?.preferences) + ); + + await p.reload({ waitUntil: "networkidle" }); + await p.waitForTimeout(3000); + check((await shade()) !== wasDark, "and it is still there after a reload", await shade()); + + /* The sun in the sidebar is gone with it — two switches for one setting is one + too many, and that corner is for places to go. Counted where it stood: the + account button's own row, which used to hold two. */ + const cornerButtons = await p + .locator('[title="Account"]') + .evaluate((el) => el.parentElement.querySelectorAll("button").length); + check(cornerButtons === 1, "and the sidebar corner holds nothing but the account", `${cornerButtons} buttons`); + } 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 a37d0ea..64073e5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -96,7 +96,19 @@ async function claimProviderSignIn(reportFailure) { * to `useNoteData`. Rendering is delegated to the three panes below. */ export default function App() { - const [dark, setDark] = useState(true); + /* The theme lives on the account, and a copy lives here. + * + * The account is the truth — it should follow you to the phone, like the + * typeface — but it arrives a moment after the page does, and a page that + * starts dark and turns light is worse than one that guesses right. So the + * browser remembers what it drew last time and starts there. */ + const [dark, setDark] = useState(() => { + try { + return localStorage.getItem("inknote_theme") !== "light"; + } catch { + return true; // private mode: the default is as good as anything + } + }); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [syncStatus, setSyncStatus] = useState("synced"); @@ -171,6 +183,20 @@ export default function App() { useEffect(() => sync.onSyncStatus(setSyncStatus), []); + // What the account says wins as soon as it is known. + useEffect(() => { + const theme = user?.preferences?.theme; + if (theme) setDark(theme === "dark"); + }, [user?.preferences?.theme]); + + useEffect(() => { + try { + localStorage.setItem("inknote_theme", dark ? "dark" : "light"); + } catch { + /* nothing to remember with; the account still holds it */ + } + }, [dark]); + /* Why a sign-in through the provider failed, shown on the sign-in screen — the failure happens while the browser is away, so there is nobody left holding the request to report it to. */ @@ -1001,7 +1027,6 @@ export default function App() { T={T} user={user} dark={dark} - onToggleDark={() => setDark(!dark)} isMobile={isMobile} open={sidebarOpen} onClose={() => setSidebarOpen(false)} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 743a35f..52dc780 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -165,6 +165,8 @@ export type ChapterView = "column" | "inline"; export interface UserPreferences { noteFont: NoteFont; chapterView?: ChapterView; + /** Light or dark, kept on the account so it follows you to the next device. */ + theme?: "dark" | "light"; } export interface User { diff --git a/frontend/src/ui/AccountMenu.jsx b/frontend/src/ui/AccountMenu.jsx index cc3d0ed..8bceedd 100644 --- a/frontend/src/ui/AccountMenu.jsx +++ b/frontend/src/ui/AccountMenu.jsx @@ -24,6 +24,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin }; const chooseFont = (noteFont) => choosePreference({ noteFont }); const chooseChapterView = (chapterView) => choosePreference({ chapterView }); + const chooseTheme = (theme) => choosePreference({ theme }); const [tab, setTab] = useState("profile"); // profile | username | password /* Two different things. `viaProvider` — this account has no password here at all, so the tab for changing one would lead to a form that can only fail; @@ -178,7 +179,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin }} >
- +

)} + {/* Light or dark. It used to be a sun in the corner of the sidebar, + reached for by accident and forgotten on the next device; it is + a setting, so it lives with the settings and on the account. */} +
+ +
+ {[ + ["dark", "Dark", I.moon], + ["light", "Light", I.sun], + ].map(([value, label, icon]) => { + const active = (user.preferences?.theme || "dark") === value; + return ( + + ); + })} +
+
+ {/* Kept on the account rather than in this browser: a choice about how your own notes are set should follow you to the phone, not be made again on every device. The export is untouched — this diff --git a/frontend/src/ui/Sidebar.jsx b/frontend/src/ui/Sidebar.jsx index 3f28362..88e87d9 100644 --- a/frontend/src/ui/Sidebar.jsx +++ b/frontend/src/ui/Sidebar.jsx @@ -130,8 +130,6 @@ function InstallRow({ T, canInstall, reason, onInstall }) { export function Sidebar({ T, user, - dark, - onToggleDark, isMobile, open, onClose, @@ -215,14 +213,24 @@ export function Sidebar({

- -
diff --git a/readme.md b/readme.md index 68236eb..5ee9185 100644 --- a/readme.md +++ b/readme.md @@ -594,7 +594,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running | --- | --- | --- | | `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly | | `oidc` | 40 | the whole redirect journey against a provider that signs real tokens — and the four ways it must fail: a refusal, a replayed ticket, a token signed with an unpublished key, a token addressed to another client | -| `editor` | 40 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload | +| `editor` | 45 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload | | `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes | | `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable | | `drawing` | 22 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on | @@ -655,9 +655,14 @@ the left edge. Centred, the same leftover space reads as two margins and needs n explaining. **The typeface is a per-account setting** (Account → Profile): serif, sans or -mono. How a chapter opens is chosen there too, for the same reason. On the account rather than in the browser, because a choice about how your -own notes are set should follow you to the phone rather than be made again on -every device. The server accepts those three values and drops anything else — +mono. How a chapter opens is chosen there too, and light or dark, for the same +reason — the sun used to sit in the corner of the sidebar, where it was reached +for by accident, forgotten by the next device and lost on every reload. The +browser keeps its own copy of the theme so a reload does not flash the other one +while the account is being fetched; the account is what decides. On the account +rather than in the browser, because a choice about how your own notes are set +should follow you to the phone rather than be made again on every device. The +server accepts only the values it knows for each of them and drops anything else: that column is written by a browser, so it is an input like any other. **You write on paper.** Each page is a sheet of its own, centred, with a gap