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 }} >