Patterns on the covers, a search box that is simply there, roomier margin notes

Four small things about the shape of the app.

Covers can be patterns as well as colours — ruled paper, graph paper,
pinstripe, bands, dots, cross-hatch, linen, washes, a ledger — drawn
entirely with CSS gradient layers. No image files, nothing to upload,
crisp at any size, and offline like everything else. A cover is stored as
an index into that list, so the new ones are appended and never inserted:
renumbering would silently repaint every notebook anyone already has.

Two things the patterns taught. One idea per cover: two crossed diagonals
meant as a chevron came out as noisy plaid at the size a cover is
actually read, and were replaced with plain bands. And the grid has to
scroll — with 27 covers the dialog ran past the bottom of a laptop screen
and took the Create button with it, which a check now holds at 1440x800.

The search box in a notebook is simply there now, and the button that
used to open it is gone. A magnifying glass cost a click and having to
remember that searching was possible at all; a search box is one line and
says for itself what it does.

Margin notes went from 208x74 to 288x112 — from three short lines to
about five. Three lines is a caption, not a remark on a passage, and the
space beside the page was sitting mostly empty.

And the notebook edit dialog was still part German, and would not say
what the server's default number of versions actually is. It names it
now, read from the server rather than written into the interface: it is
set with MAX_VERSIONS_PER_NOTE, and a hardcoded "50" would quietly start
lying the day that changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 00:29:00 +02:00
1 parent 635a9632d1
commit 9e4677c70b
11 files changed
+262 -48

No files matched your search

+1
View File
@@ -699,6 +699,7 @@ export default function App() {
notebook={editingNb}
onClose={() => setEditingNb(null)}
onSave={(u) => updateNotebook(editingNb.id, u)}
defaultMaxVersions={user?.defaultMaxVersions}
dark={dark}
/>
)}
+9 -19
View File
@@ -346,7 +346,6 @@ export function NoteList({
}) {
// Opened by the button and kept open while there is something typed in it, so
// a search never disappears because the box collapsed under it.
const [searching, setSearching] = useState(false);
return (
<div
@@ -405,17 +404,6 @@ export function NoteList({
</div>
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
{!isTrashView && (
<button
onClick={() => setSearching((v) => !v)}
data-search-toggle={searching || query ? "on" : "off"}
title="Search this notebook"
aria-label="Search notes"
style={{ ...iconButton(T), color: searching || query ? T.accent : T.muted }}
>
{I.search}
</button>
)}
{!isTrashView && (
<AddMenu
T={T}
@@ -450,18 +438,20 @@ export function NoteList({
{/* The search box lives with the list it narrows, not in the sidebar.
Searching has always been scoped to the notebook you are in; from the
sidebar it read as searching everything, which it never did. */}
{(searching || query) && !isTrashView && (
sidebar it read as searching everything, which it never did.
Always here, rather than behind a button. A search box is one line and
says for itself what it does; a magnifying glass is a line too, plus a
click, plus having to remember that searching is possible at all. */}
{!isTrashView && (
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.borderLight}` }}>
<input
autoFocus
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
onQueryChange("");
setSearching(false);
}
if (e.key !== "Escape") return;
onQueryChange("");
e.currentTarget.blur();
}}
placeholder={`Search in ${title}...`}
aria-label="Search in this notebook"
+13 -10
View File
@@ -15,8 +15,11 @@ import { useEffect, useRef, useState } from "react";
* others, because that one is being read closely and the rest are not.
*/
export const MARGIN_W = 208;
const CARD_H = 74;
/* The margin is a column you actually write in, not a strip of labels. It was
* 208 x 74, which held about three short lines and left the space beside the
* page mostly empty — for a remark on a passage, three lines is a caption. */
export const MARGIN_W = 288;
const CARD_H = 112;
const CARD_GAP = 6;
/** Cards at their anchors, pushed down where they would sit on top of each other. */
@@ -108,7 +111,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
}}
aria-label="Pinned note"
placeholder="Note on this passage…"
rows={4}
rows={7}
style={{
width: "100%",
resize: "vertical",
@@ -116,9 +119,9 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
borderRadius: 8,
background: "transparent",
color: T.text,
fontSize: 11.5,
lineHeight: 1.5,
padding: "5px 7px",
fontSize: 12.5,
lineHeight: 1.55,
padding: "7px 9px",
outline: "none",
fontFamily: "inherit",
}}
@@ -162,13 +165,13 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
) : (
<div
style={{
fontSize: 11.5,
lineHeight: 1.45,
fontSize: 12.5,
lineHeight: 1.5,
color: note.text ? T.text : T.muted,
maxHeight: CARD_H - 22,
maxHeight: CARD_H - 24,
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitLineClamp: 5,
WebkitBoxOrient: "vertical",
wordBreak: "break-word",
}}
+8
View File
@@ -115,6 +115,14 @@ export interface User {
isAdmin?: boolean;
/** Interface settings, kept on the account so they follow you to the phone. */
preferences?: UserPreferences;
/**
* How many versions a notebook keeps when it names no number of its own.
*
* Sent by the server rather than written into the interface: it is set with
* `MAX_VERSIONS_PER_NOTE`, and a dialog that said "50" would start lying the
* day that is changed.
*/
defaultMaxVersions?: number;
}
/** A user row as the admin panel sees it. */
+33 -7
View File
@@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
import { COVERS } from "./constants.jsx";
export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxVersions }) {
const [name, setName] = useState(notebook.name);
const [sel, setSel] = useState(notebook.cover);
// Empty means "use the server's default", which is why it is a string here and
@@ -83,14 +83,14 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
/>
<div style={{ marginBottom: 18 }}>
<label style={{ fontSize: 12, color: T.muted, display: "block", marginBottom: 5 }}>
Versionen pro Notiz
Versions per note
</label>
<input
type="number"
min="1"
value={versions}
onChange={(e) => setVersions(e.target.value)}
placeholder="Vorgabe"
placeholder={defaultMaxVersions ? String(defaultMaxVersions) : "Default"}
aria-label="Versions per note"
style={{
width: 110,
@@ -108,16 +108,29 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
}}
/>
<div style={{ fontSize: 11, color: T.muted, marginTop: 6, lineHeight: 1.5 }}>
Older versions are only deleted once newer ones push them out — never by age. Leave empty for the
server's default.
Older versions are only deleted once newer ones push them out — never by age. Leave it empty
for the server's default{defaultMaxVersions ? `, which is ${defaultMaxVersions}` : ""}.
</div>
</div>
<div style={{ marginBottom: 22 }}>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Cover style</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 8 }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(5,1fr)",
gap: 8,
/* Scrolls rather than growing. With patterns the list is long
enough that the dialog would run past the bottom of a laptop
screen and take the Create button with it. */
maxHeight: 268,
overflowY: "auto",
paddingRight: 4,
}}
>
{COVERS.map((c, i) => (
<button
key={i}
data-cover-option={i}
onClick={() => setSel(i)}
style={{
width: "100%",
@@ -250,10 +263,23 @@ export function CreateNotebookModal({ onClose, onCreate, dark }) {
/>
<div style={{ marginBottom: 22 }}>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Choose cover style</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 8 }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(5,1fr)",
gap: 8,
/* Scrolls rather than growing. With patterns the list is long
enough that the dialog would run past the bottom of a laptop
screen and take the Create button with it. */
maxHeight: 268,
overflowY: "auto",
paddingRight: 4,
}}
>
{COVERS.map((c, i) => (
<button
key={i}
data-cover-option={i}
onClick={() => setSel(i)}
style={{
width: "100%",
+89
View File
@@ -39,6 +39,95 @@ export const COVERS = [
{ bg: "linear-gradient(135deg,#aa4b6b,#6b6b83,#3b8d99)", spine: "#7a3550", accent: "#f48fb1" },
{ bg: "linear-gradient(135deg,#1f1c2c,#928dab)", spine: "#15131f", accent: "#b39ddb" },
{ bg: "linear-gradient(135deg,#654ea3,#eaafc8)", spine: "#4e3c7e", accent: "#f8bbd0" },
/* ── Patterned ──
*
* A cover is stored as an *index* into this list, so these are appended and
* never reordered: renumbering them would silently repaint every notebook
* anyone already has.
*
* Drawn entirely with CSS gradient layers — no image files, nothing to
* upload, nothing to fetch, and they stay crisp at any size and work offline
* like everything else here. `background` takes a stack of layers, topmost
* first, so each of these is a pattern laid over the colour beneath it.
*
* The patterns are deliberately faint. A cover is read at the size of a thumb
* with a title across it, and anything louder fights the title. */
// Ruled paper — the one that actually looks like a notebook.
{
bg: "repeating-linear-gradient(0deg,rgba(255,255,255,.10) 0 1px,transparent 1px 17px), linear-gradient(135deg,#1b2735,#2c5364)",
spine: "#101a24",
accent: "#80cbc4",
},
// Graph paper.
{
bg: "repeating-linear-gradient(0deg,rgba(255,255,255,.09) 0 1px,transparent 1px 20px), repeating-linear-gradient(90deg,rgba(255,255,255,.09) 0 1px,transparent 1px 20px), linear-gradient(135deg,#243b2f,#3e7a5e)",
spine: "#16281f",
accent: "#a5d6a7",
},
// Diagonal pinstripe.
{
bg: "repeating-linear-gradient(45deg,rgba(255,255,255,.08) 0 6px,transparent 6px 14px), linear-gradient(135deg,#3a1c71,#5c2a8f)",
spine: "#2a1352",
accent: "#ce93d8",
},
// Wide diagonal bands.
{
bg: "repeating-linear-gradient(-45deg,rgba(0,0,0,.16) 0 18px,transparent 18px 36px), linear-gradient(135deg,#f7971e,#ffd200)",
spine: "#c2740f",
accent: "#fff59d",
},
// Dots.
{
bg: "radial-gradient(rgba(255,255,255,.16) 1.4px,transparent 1.5px) 0 0/13px 13px, linear-gradient(135deg,#8b1e3f,#3c1053)",
spine: "#5e1029",
accent: "#f48fb1",
},
// Cross-hatch, both diagonals.
{
bg: "repeating-linear-gradient(45deg,rgba(255,255,255,.07) 0 1px,transparent 1px 11px), repeating-linear-gradient(-45deg,rgba(255,255,255,.07) 0 1px,transparent 1px 11px), linear-gradient(135deg,#232526,#414345)",
spine: "#141516",
accent: "#b0bec5",
},
// Linen weave — very fine, reads as texture rather than as lines.
{
bg: "repeating-linear-gradient(0deg,rgba(255,255,255,.06) 0 1px,transparent 1px 4px), repeating-linear-gradient(90deg,rgba(0,0,0,.06) 0 1px,transparent 1px 4px), linear-gradient(135deg,#6b705c,#a5a58d)",
spine: "#4a4e40",
accent: "#ddbea9",
},
// Broad horizontal bands. Two crossed diagonals were meant to be a chevron
// and came out as noisy plaid — at thumbnail size a pattern has to be one
// idea, not two.
{
bg: "repeating-linear-gradient(0deg,rgba(0,0,0,.20) 0 13px,transparent 13px 26px), linear-gradient(135deg,#0b486b,#f56217)",
spine: "#073349",
accent: "#ffcc80",
},
// Light from the top corner, over deep blue — nothing repeating at all.
{
bg: "radial-gradient(120% 85% at 18% 0%,rgba(255,255,255,.22),transparent 62%), linear-gradient(135deg,#0f2027,#16324f)",
spine: "#08161d",
accent: "#90caf9",
},
// Two washes crossing, like ink bleeding into paper.
{
bg: "radial-gradient(80% 60% at 85% 15%,rgba(255,145,180,.35),transparent 60%), radial-gradient(90% 70% at 10% 90%,rgba(120,200,255,.30),transparent 60%), linear-gradient(135deg,#20143a,#3a2757)",
spine: "#150d28",
accent: "#f8bbd0",
},
// A band across the foot, the way a label sits on a ledger.
{
bg: "linear-gradient(0deg,rgba(0,0,0,.35) 0 22%,transparent 22%), linear-gradient(135deg,#134e5e,#71b280)",
spine: "#0b3a46",
accent: "#b9f6ca",
},
// Fine vertical rules on near-black — a ledger.
{
bg: "repeating-linear-gradient(90deg,rgba(255,255,255,.08) 0 1px,transparent 1px 26px), linear-gradient(135deg,#111318,#242a33)",
spine: "#0a0c10",
accent: "#cfd8dc",
},
];
/* The icon is a key rather than an emoji: the sidebar draws it from the app's
* own set, so it matches the stroke weight of everything around it and looks