A bigger face, and the theme where the settings are

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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-22 00:10:13 +02:00
1 parent ead51b9fa0
commit 2ea448f091
8 files changed
+168 -17

No files matched your search

+21
View File
@@ -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 ## 2026-08-21 — Asking for the picture properly
**Images** — web `sha256:4310304d…`, server `sha256:7a486929…` **Images** — web `sha256:4310304d…`, server `sha256:7a486929…`
+12 -1
View File
@@ -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 * 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. */ * for neither, and falls back to filling the screen with a way back. */
const CHAPTER_VIEWS = ["column", "inline"]; 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) { function readPreferences(raw) {
let parsed = null; let parsed = null;
@@ -326,9 +331,11 @@ function readPreferences(raw) {
} }
const font = parsed?.noteFont; const font = parsed?.noteFont;
const view = parsed?.chapterView; const view = parsed?.chapterView;
const theme = parsed?.theme;
return { return {
noteFont: NOTE_FONTS.includes(font) ? font : "serif", noteFont: NOTE_FONTS.includes(font) ? font : "serif",
chapterView: CHAPTER_VIEWS.includes(view) ? view : "column", 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) => { 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)) { if (noteFont !== undefined && !NOTE_FONTS.includes(noteFont)) {
return res.status(400).json({ error: `noteFont must be one of ${NOTE_FONTS.join(", ")}` }); return res.status(400).json({ error: `noteFont must be one of ${NOTE_FONTS.join(", ")}` });
} }
if (chapterView !== undefined && !CHAPTER_VIEWS.includes(chapterView)) { if (chapterView !== undefined && !CHAPTER_VIEWS.includes(chapterView)) {
return res.status(400).json({ error: `chapterView must be one of ${CHAPTER_VIEWS.join(", ")}` }); 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( const current = readPreferences(
db.prepare("SELECT preferences FROM users WHERE id = ?").get(req.userId)?.preferences 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, ...current,
...(noteFont !== undefined ? { noteFont } : {}), ...(noteFont !== undefined ? { noteFont } : {}),
...(chapterView !== undefined ? { chapterView } : {}), ...(chapterView !== undefined ? { chapterView } : {}),
...(theme !== undefined ? { theme } : {}),
}; };
db.prepare("UPDATE users SET preferences = ?, updated_at = datetime('now') WHERE id = ?") db.prepare("UPDATE users SET preferences = ?, updated_at = datetime('now') WHERE id = ?")
.run(JSON.stringify(next), req.userId); .run(JSON.stringify(next), req.userId);
+38
View File
@@ -362,6 +362,44 @@ try {
"and survives a reload" "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) { } catch (e) {
check(false, "suite ran to the end", e.message); check(false, "suite ran to the end", e.message);
} finally { } finally {
+27 -2
View File
@@ -96,7 +96,19 @@ async function claimProviderSignIn(reportFailure) {
* to `useNoteData`. Rendering is delegated to the three panes below. * to `useNoteData`. Rendering is delegated to the three panes below.
*/ */
export default function App() { 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 [user, setUser] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [syncStatus, setSyncStatus] = useState("synced"); const [syncStatus, setSyncStatus] = useState("synced");
@@ -171,6 +183,20 @@ export default function App() {
useEffect(() => sync.onSyncStatus(setSyncStatus), []); 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 — /* 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 the failure happens while the browser is away, so there is nobody left
holding the request to report it to. */ holding the request to report it to. */
@@ -1001,7 +1027,6 @@ export default function App() {
T={T} T={T}
user={user} user={user}
dark={dark} dark={dark}
onToggleDark={() => setDark(!dark)}
isMobile={isMobile} isMobile={isMobile}
open={sidebarOpen} open={sidebarOpen}
onClose={() => setSidebarOpen(false)} onClose={() => setSidebarOpen(false)}
+2
View File
@@ -165,6 +165,8 @@ export type ChapterView = "column" | "inline";
export interface UserPreferences { export interface UserPreferences {
noteFont: NoteFont; noteFont: NoteFont;
chapterView?: ChapterView; chapterView?: ChapterView;
/** Light or dark, kept on the account so it follows you to the next device. */
theme?: "dark" | "light";
} }
export interface User { export interface User {
+42 -1
View File
@@ -24,6 +24,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
}; };
const chooseFont = (noteFont) => choosePreference({ noteFont }); const chooseFont = (noteFont) => choosePreference({ noteFont });
const chooseChapterView = (chapterView) => choosePreference({ chapterView }); const chooseChapterView = (chapterView) => choosePreference({ chapterView });
const chooseTheme = (theme) => choosePreference({ theme });
const [tab, setTab] = useState("profile"); // profile | username | password const [tab, setTab] = useState("profile"); // profile | username | password
/* Two different things. `viaProvider` — this account has no password here at /* 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; 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
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
<Avatar user={user} size={28} /> <Avatar user={user} size={34} />
<h3 <h3
style={{ style={{
margin: 0, margin: 0,
@@ -325,6 +326,46 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
</div> </div>
)} )}
{/* 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. */}
<div style={{ marginTop: 22 }}>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 6, display: "block" }}>
Appearance
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8 }}>
{[
["dark", "Dark", I.moon],
["light", "Light", I.sun],
].map(([value, label, icon]) => {
const active = (user.preferences?.theme || "dark") === value;
return (
<button
key={value}
data-theme-option={value}
onClick={() => chooseTheme(value)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: "10px 6px",
borderRadius: 10,
border: `1.5px solid ${active ? T.accent : T.border}`,
background: active ? T.accent + "15" : "transparent",
color: active ? T.accent : T.text,
cursor: "pointer",
fontSize: 13,
}}
>
<span style={{ display: "flex" }}>{icon}</span>
{label}
</button>
);
})}
</div>
</div>
{/* Kept on the account rather than in this browser: a choice about {/* 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 how your own notes are set should follow you to the phone, not
be made again on every device. The export is untouched — this be made again on every device. The export is untouched — this
+17 -9
View File
@@ -130,8 +130,6 @@ function InstallRow({ T, canInstall, reason, onInstall }) {
export function Sidebar({ export function Sidebar({
T, T,
user, user,
dark,
onToggleDark,
isMobile, isMobile,
open, open,
onClose, onClose,
@@ -215,14 +213,24 @@ export function Sidebar({
</span> </span>
</div> </div>
<div style={{ display: "flex", gap: 4 }}> <div style={{ display: "flex", gap: 4 }}>
<button onClick={onToggleDark} style={iconButton(T)}> {/* Light or dark used to live here, next to the account. It is a
{dark ? I.sun : I.moon} setting, not something reached for while writing, and it now sits
</button> with the other settings — which leaves this corner to the one
<button onClick={onOpenAccount} title="Account" style={iconButton(T)}> button that is a place to go. */}
<button
onClick={onOpenAccount}
title="Account"
style={
user?.hasAvatar
? { ...iconButton(T), width: 34, height: 34, padding: 0, borderRadius: "50%" }
: iconButton(T)
}
>
{/* The picture instead of the person icon, for an account that has {/* The picture instead of the person icon, for an account that has
one. Not initials for the rest: the icon is what this button one — as large as the button, since a face at twenty pixels is
has always been, and only some accounts bring a face. */} a smudge. Not initials for the rest: the icon is what this
{user?.hasAvatar ? <Avatar user={user} size={20} /> : I.user} button has always been, and only some accounts bring a face. */}
{user?.hasAvatar ? <Avatar user={user} size={34} /> : I.user}
</button> </button>
</div> </div>
</div> </div>
+9 -4
View File
@@ -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 | | `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 | | `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 | | `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 | | `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 | | `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. explaining.
**The typeface is a per-account setting** (Account → Profile): serif, sans or **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 mono. How a chapter opens is chosen there too, and light or dark, for the same
own notes are set should follow you to the phone rather than be made again on reason — the sun used to sit in the corner of the sidebar, where it was reached
every device. The server accepts those three values and drops anything else — 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. 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 **You write on paper.** Each page is a sheet of its own, centred, with a gap