Choose a cover in two parts, and let the app be installed

A cover is a colour *and* a pattern now, chosen in turn. Offering every
combination as its own entry meant fifteen colours times a dozen patterns
in a grid nobody could take in — and in practice only the handful someone
had thought to write out. Each pattern is previewed over the colour in
hand, so what is on screen is the cover as it will be. `cover_pattern` is
its own column: they are two decisions, and folding them into one number
would mean renumbering every cover the day a pattern is added.

For one day the patterns *were* twelve appended entries. Those indices
are read back as the colour-and-pattern pair they were drawn as, so
nothing chosen then looks different now.

And three separate reasons the install button never appeared, none of
them in anyone's configuration:

- `beforeinstallprompt` fires once, early — usually before React has
  mounted — and is never fired again. A listener added from inside a
  component simply missed it. It is caught at the top of index.html now.
- A `<link rel="manifest">` is fetched with credentials omitted unless it
  says otherwise. Behind a reverse proxy that authenticates in front of
  the app, that one request comes back as the proxy's login page and the
  browser reads it as an invalid manifest — while the app itself works
  perfectly, because its own requests carry the cookie.
- The service worker cached whatever came back, so a lapsed session could
  be stored as the application itself. The app would then "work offline"
  by showing a login screen it could never get past, curable only by
  clearing the site's data. Only successful, unredirected responses are
  cached now, and the cache name is bumped to drop anything already
  stored under the old rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 02:20:37 +02:00
1 parent 0d8d1180de
commit 8ca1ed6e06
17 files changed
+455 -221

No files matched your search

+6 -3
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { COVERS } from "./constants.jsx";
import { coverBackground, coverStyle } from "./constants.jsx";
/* ═══════ NOTEBOOK COVER ═══════ */
export function NotebookCover({
@@ -15,7 +15,10 @@ export function NotebookCover({
onDragOver,
onDrop,
}) {
const cover = COVERS[notebook.cover % COVERS.length];
// Colour and pattern are two choices; the spine and accent come from the
// colour, the face from both.
const cover = coverStyle(notebook.cover, notebook.coverPattern);
const face = coverBackground(notebook.cover, notebook.coverPattern);
const [hovered, setHovered] = useState(false);
return (
<button
@@ -114,7 +117,7 @@ export function NotebookCover({
top: 0,
right: 0,
bottom: 0,
background: cover.bg,
background: face,
borderRadius: "2px 8px 8px 2px",
boxShadow: isActive
? `0 4px 20px rgba(0,0,0,0.3),0 0 0 3px ${cover.accent}88`
+98 -81
View File
@@ -1,9 +1,87 @@
import { useMemo, useState } from "react";
import { COVERS } from "./constants.jsx";
import { COVERS, PATTERNS, coverBackground, coverParts } from "./constants.jsx";
/**
* Choosing a cover: its colour, then the pattern over it.
*
* Two decisions rather than one list. Offering every combination as its own
* entry meant fifteen colours times a dozen patterns in a grid nobody could take
* in — and only the handful someone had thought to write out. Each pattern is
* previewed over the colour that is currently chosen, so what is on screen is
* the cover as it will be, not a sample of the pattern on its own.
*/
function CoverChooser({ T, cover, pattern, onCover, onPattern }) {
const swatch = (selected) => ({
width: "100%",
aspectRatio: "3/4",
borderRadius: 8,
border: selected ? `3px solid ${T.active}` : "2px solid transparent",
cursor: "pointer",
position: "relative",
overflow: "hidden",
});
return (
<div style={{ marginBottom: 22 }}>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Cover colour</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(5,1fr)",
gap: 8,
maxHeight: 200,
overflowY: "auto",
paddingRight: 4,
}}
>
{COVERS.map((c, i) => (
<button
key={i}
data-cover-option={i}
onClick={() => onCover(i)}
style={{ ...swatch(coverParts(cover, pattern).cover === i), background: c.bg }}
>
<div style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }} />
</button>
))}
</div>
<div style={{ fontSize: 12, color: T.muted, margin: "14px 0 8px" }}>Pattern</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(5,1fr)",
gap: 8,
maxHeight: 200,
overflowY: "auto",
paddingRight: 4,
}}
>
{PATTERNS.map((p, i) => (
<button
key={p.id}
data-pattern-option={i}
title={p.label}
onClick={() => onPattern(i)}
style={{
...swatch(coverParts(cover, pattern).pattern === i),
// Previewed over the colour in hand, because that is the cover.
background: coverBackground(cover, i),
}}
/>
))}
</div>
</div>
);
}
export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxVersions }) {
const [name, setName] = useState(notebook.name);
const [sel, setSel] = useState(notebook.cover);
/* Colour and pattern are separate choices now. A notebook saved when they
were one list holds an index into that list, so it is read back as the pair
it was drawn as rather than repainting itself. */
const [sel, setSel] = useState(() => coverParts(notebook.cover, notebook.coverPattern).cover);
const [pat, setPat] = useState(() => coverParts(notebook.cover, notebook.coverPattern).pattern);
// Empty means "use the server's default", which is why it is a string here and
// not a number with a stand-in value.
const [versions, setVersions] = useState(notebook.maxVersions == null ? "" : String(notebook.maxVersions));
@@ -11,7 +89,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
const save = () => {
if (!name.trim()) return;
const n = versions.trim() === "" ? null : Math.max(1, Math.round(Number(versions)));
onSave({ name: name.trim(), cover: sel, maxVersions: Number.isFinite(n) ? n : null });
onSave({ name: name.trim(), cover: sel, coverPattern: pat, maxVersions: Number.isFinite(n) ? n : null });
};
const T = useMemo(
() => ({
@@ -112,44 +190,13 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
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,
/* 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%",
aspectRatio: "3/4",
borderRadius: 8,
background: c.bg,
border: sel === i ? `3px solid ${T.active}` : "2px solid transparent",
cursor: "pointer",
position: "relative",
overflow: "hidden",
}}
>
<div
style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }}
/>
</button>
))}
</div>
</div>
<CoverChooser
T={T}
cover={sel}
pattern={pat}
onCover={setSel}
onPattern={setPat}
/>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button
onClick={onClose}
@@ -192,6 +239,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
export function CreateNotebookModal({ onClose, onCreate, dark }) {
const [name, setName] = useState("");
const [sel, setSel] = useState(0);
const [pat, setPat] = useState(0);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
@@ -258,47 +306,16 @@ export function CreateNotebookModal({ onClose, onCreate, dark }) {
boxSizing: "border-box",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && name.trim()) onCreate({ name: name.trim(), cover: sel });
if (e.key === "Enter" && name.trim()) onCreate({ name: name.trim(), cover: sel, coverPattern: pat });
}}
/>
<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,
/* 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%",
aspectRatio: "3/4",
borderRadius: 8,
background: c.bg,
border: sel === i ? `3px solid ${T.active}` : "2px solid transparent",
cursor: "pointer",
position: "relative",
overflow: "hidden",
}}
>
<div
style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }}
/>
</button>
))}
</div>
</div>
<CoverChooser
T={T}
cover={sel}
pattern={pat}
onCover={setSel}
onPattern={setPat}
/>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button
onClick={onClose}
@@ -317,7 +334,7 @@ export function CreateNotebookModal({ onClose, onCreate, dark }) {
</button>
<button
onClick={() => {
if (name.trim()) onCreate({ name: name.trim(), cover: sel });
if (name.trim()) onCreate({ name: name.trim(), cover: sel, coverPattern: pat });
}}
style={{
padding: "8px 20px",
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { COVERS } from "./constants.jsx";
import { coverBackground } from "./constants.jsx";
/**
* Asks which notebook something should go into.
@@ -141,7 +141,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", on
height: 10,
borderRadius: 3,
flexShrink: 0,
background: COVERS[nb.cover % COVERS.length].bg,
background: coverBackground(nb.cover, nb.coverPattern),
}}
/>
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { COVERS, DEFAULT_FOLDERS } from "./constants.jsx";
import { DEFAULT_FOLDERS, coverBackground } from "./constants.jsx";
import { I } from "./icons.jsx";
import { FONT_UI } from "./theme";
@@ -352,7 +352,7 @@ export function Sidebar({
width: 20,
height: 14,
borderRadius: 3,
background: COVERS[nb.cover % COVERS.length].bg,
background: coverBackground(nb.cover, nb.coverPattern),
flexShrink: 0,
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
+102 -88
View File
@@ -40,94 +40,6 @@ export const COVERS = [
{ 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
@@ -137,3 +49,105 @@ export const DEFAULT_FOLDERS = [
{ id: "starred", name: "Starred", icon: "star" },
{ id: "trash", name: "Trash", icon: "trash" },
];
/**
* A pattern laid over a cover, chosen separately from its colour.
*
* Two decisions rather than one list: a cover is a colour *and* a texture, and
* offering every combination as its own entry would mean fifteen gradients times
* a dozen patterns in a grid nobody could take in. Picking each in turn is both
* shorter and gives every combination, not the handful someone wrote out.
*
* `layers` are CSS background layers laid **over** the gradient — no image
* files, nothing to upload, crisp at any size and offline like the rest of the
* app. They are deliberately faint: a cover is read at the size of a thumb with
* a title across it, and anything louder fights the title. One idea each, too —
* two crossed diagonals meant as a chevron came out as noisy plaid at that size.
*/
export const PATTERNS = [
{ id: "plain", label: "Plain", layers: "" },
{
id: "ruled",
label: "Ruled",
layers: "repeating-linear-gradient(0deg,rgba(255,255,255,.12) 0 1px,transparent 1px 17px)",
},
{
id: "grid",
label: "Grid",
layers:
"repeating-linear-gradient(0deg,rgba(255,255,255,.10) 0 1px,transparent 1px 20px), repeating-linear-gradient(90deg,rgba(255,255,255,.10) 0 1px,transparent 1px 20px)",
},
{
id: "pinstripe",
label: "Pinstripe",
layers: "repeating-linear-gradient(45deg,rgba(255,255,255,.09) 0 6px,transparent 6px 14px)",
},
{
id: "bands",
label: "Bands",
layers: "repeating-linear-gradient(0deg,rgba(0,0,0,.18) 0 13px,transparent 13px 26px)",
},
{
id: "dots",
label: "Dots",
layers: "radial-gradient(rgba(255,255,255,.18) 1.4px,transparent 1.5px) 0 0/13px 13px",
},
{
id: "hatch",
label: "Cross-hatch",
layers:
"repeating-linear-gradient(45deg,rgba(255,255,255,.08) 0 1px,transparent 1px 11px), repeating-linear-gradient(-45deg,rgba(255,255,255,.08) 0 1px,transparent 1px 11px)",
},
{
id: "linen",
label: "Linen",
layers:
"repeating-linear-gradient(0deg,rgba(255,255,255,.07) 0 1px,transparent 1px 4px), repeating-linear-gradient(90deg,rgba(0,0,0,.07) 0 1px,transparent 1px 4px)",
},
{
id: "ledger",
label: "Ledger",
layers: "repeating-linear-gradient(90deg,rgba(255,255,255,.09) 0 1px,transparent 1px 26px)",
},
{
id: "glow",
label: "Corner light",
layers: "radial-gradient(120% 85% at 18% 0%,rgba(255,255,255,.20),transparent 62%)",
},
{
id: "foot",
label: "Foot band",
layers: "linear-gradient(0deg,rgba(0,0,0,.34) 0 22%,transparent 22%)",
},
];
/**
* Covers written before patterns were their own choice.
*
* Twelve patterned entries were appended to `COVERS` for a day, so a notebook
* may still hold an index into them. Rather than let those repaint themselves,
* each is read back as the colour-and-pattern pair it was drawn as.
*/
const LEGACY_COVER = {
15: [11, 1], 16: [2, 2], 17: [3, 3], 18: [9, 4], 19: [5, 5],
20: [8, 6], 21: [12, 7], 22: [4, 4], 23: [8, 9], 24: [0, 9],
25: [2, 10], 26: [8, 8],
};
/** The colour and pattern a notebook is actually drawn with. */
export function coverParts(cover, pattern) {
const legacy = LEGACY_COVER[cover];
if (legacy) return { cover: legacy[0], pattern: legacy[1] };
return { cover: ((cover % COVERS.length) + COVERS.length) % COVERS.length, pattern: pattern || 0 };
}
/** The CSS `background` for a cover: its pattern laid over its colour. */
export function coverBackground(cover, pattern) {
const parts = coverParts(cover, pattern);
const base = COVERS[parts.cover];
const layers = PATTERNS[parts.pattern]?.layers;
return layers ? `${layers}, ${base.bg}` : base.bg;
}
/** The rest of a cover's colours — the spine, and the accent used beside it. */
export const coverStyle = (cover, pattern) => COVERS[coverParts(cover, pattern).cover];