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
|
## 2026-08-21 — Asking for the picture properly
|
||||||
|
|
||||||
**Images** — web `sha256:4310304d…`, server `sha256:7a486929…`
|
**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
|
* 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);
|
||||||
|
|||||||
@@ -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
@@ -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)}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user