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:
1 parent
ead51b9fa0
commit
2ea448f091
8 files changed
+168
-17
No files matched your search
@@ -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…`
|
||||
|
||||
+12
-1
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+27
-2
@@ -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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
||||
<Avatar user={user} size={28} />
|
||||
<Avatar user={user} size={34} />
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
@@ -325,6 +326,46 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
|
||||
</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
|
||||
how your own notes are set should follow you to the phone, not
|
||||
be made again on every device. The export is untouched — this
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
<button onClick={onToggleDark} style={iconButton(T)}>
|
||||
{dark ? I.sun : I.moon}
|
||||
</button>
|
||||
<button onClick={onOpenAccount} title="Account" style={iconButton(T)}>
|
||||
{/* Light or dark used to live here, next to the account. It is a
|
||||
setting, not something reached for while writing, and it now sits
|
||||
with the other settings — which leaves this corner to the one
|
||||
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
|
||||
one. Not initials for the rest: the icon is what this button
|
||||
has always been, and only some accounts bring a face. */}
|
||||
{user?.hasAvatar ? <Avatar user={user} size={20} /> : I.user}
|
||||
one — as large as the button, since a face at twenty pixels is
|
||||
a smudge. Not initials for the rest: the icon is what this
|
||||
button has always been, and only some accounts bring a face. */}
|
||||
{user?.hasAvatar ? <Avatar user={user} size={34} /> : I.user}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user