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:
1 parent
635a9632d1
commit
9e4677c70b
11 files changed
+262
-48
No files matched your search
@@ -8,6 +8,34 @@ not from the output of `docker push`.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
|
||||
**Added**
|
||||
- **Patterned notebook covers** — ruled paper, graph paper, pinstripe, bands,
|
||||
dots, cross-hatch, linen, washes, a ledger. Drawn entirely in CSS: no image
|
||||
files, nothing to upload, crisp at any size, and they work offline like
|
||||
everything else. Existing notebooks keep exactly the cover they had; the new
|
||||
ones are appended, never inserted.
|
||||
|
||||
**Changed**
|
||||
- **The search box in a notebook is simply there**, and the button that used to
|
||||
open it is gone. A magnifying glass cost a click and having to remember that
|
||||
searching was possible; the box is one line and says for itself what it does.
|
||||
- **Margin notes are bigger** — the column went from 208px to 288px and a card
|
||||
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.
|
||||
- The notebook dialog's cover grid scrolls, so the dialog cannot run past the
|
||||
bottom of a laptop screen and take the *Create* button with it.
|
||||
|
||||
**Fixed**
|
||||
- The notebook edit dialog was still part German — *"Versionen pro Notiz"*,
|
||||
*"Vorgabe"* — and would not say what the server's default actually is. It now
|
||||
names it (50), read from the server rather than written into the interface,
|
||||
since `MAX_VERSIONS_PER_NOTE` can be changed and a hardcoded number would
|
||||
quietly start lying.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-11 — Margins and a stray placeholder
|
||||
|
||||
**Images** — web `sha256:3a181ff3…`, server unchanged at `sha256:0832ee2b…`
|
||||
|
||||
+17
-2
@@ -278,6 +278,21 @@ const publicUser = (row) => ({
|
||||
preferences: readPreferences(row.preferences),
|
||||
});
|
||||
|
||||
/**
|
||||
* The account as it is handed to whoever just signed in as it.
|
||||
*
|
||||
* Carries one thing that is not about the account at all: how many versions a
|
||||
* notebook keeps when it names no number of its own. It is sent rather than
|
||||
* written into the interface because it is configurable —
|
||||
* `MAX_VERSIONS_PER_NOTE` — and a dialog that said "50" would quietly start
|
||||
* lying the day that is changed. It rides here because this is the one payload
|
||||
* every client already fetches, on login and on restoring a session.
|
||||
*
|
||||
* `publicUser` stays as it is: the admin list shows *other* people's accounts,
|
||||
* and a server-wide setting has no business being repeated on each row.
|
||||
*/
|
||||
const signedInUser = (row) => ({ ...publicUser(row), defaultMaxVersions: MAX_VERSIONS_PER_NOTE });
|
||||
|
||||
/* Interface settings the client may store. Anything else is dropped rather than
|
||||
* kept: this column is written by the browser, so it is an input like any other,
|
||||
* and a fixed shape is what stops it becoming a place to put arbitrary data. */
|
||||
@@ -653,7 +668,7 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
|
||||
|
||||
clearLoginAttempts(req);
|
||||
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
|
||||
res.json({ token, user: publicUser(user) });
|
||||
res.json({ token, user: signedInUser(user) });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
@@ -661,7 +676,7 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
|
||||
app.get("/api/auth/me", authMiddleware, (req, res) => {
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
res.json(publicUser(user));
|
||||
res.json(signedInUser(user));
|
||||
});
|
||||
|
||||
app.put("/api/auth/profile", authMiddleware, (req, res) => {
|
||||
|
||||
+2
-7
@@ -184,14 +184,9 @@ export async function createNote(page) {
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
/** Opens the search box in the note list and types into it. */
|
||||
/** Types into the note list's search box, which is always there. */
|
||||
export async function search(page, query) {
|
||||
const box = page.locator('input[aria-label="Search in this notebook"]');
|
||||
if ((await box.count()) === 0) {
|
||||
await page.locator("[data-search-toggle]").click();
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
await box.fill(query);
|
||||
await page.locator('input[aria-label="Search in this notebook"]').fill(query);
|
||||
await page.waitForTimeout(1300);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,18 @@ try {
|
||||
`${beforeDrag.join(",")} → ${arranged.join(",")}`
|
||||
);
|
||||
|
||||
/* The search box is simply there, with nothing to open first. A magnifying
|
||||
* glass costs a click and having to remember that searching is possible; the
|
||||
* box is one line and says for itself what it does. */
|
||||
check(
|
||||
(await p.locator('input[aria-label="Search in this notebook"]').count()) === 1,
|
||||
"the search box is there without opening anything"
|
||||
);
|
||||
check(
|
||||
(await p.locator("[data-search-toggle]").count()) === 0,
|
||||
"and the button that used to open it is gone"
|
||||
);
|
||||
|
||||
/* The point of the whole feature. */
|
||||
await search(p, "Beta");
|
||||
const searched = await titles();
|
||||
@@ -371,6 +383,24 @@ try {
|
||||
"and it lands in the notebook that was chosen, not the first one",
|
||||
`${fresh?.notebook_id} vs ${target.id}`
|
||||
);
|
||||
|
||||
/* ── choosing a cover ──
|
||||
* Covers are patterns as well as colours now, and there are enough of them
|
||||
* that the grid has to scroll: without that the dialog runs past the bottom of
|
||||
* a laptop screen and takes the Create button with it. */
|
||||
await p.setViewportSize({ width: 1440, height: 800 });
|
||||
await p.locator("button").filter({ hasText: "Dashboard" }).first().click();
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByRole("button", { name: "Add Notebook" }).click();
|
||||
await p.waitForTimeout(700);
|
||||
const covers = p.locator('[data-cover-option]');
|
||||
check((await covers.count()) > 20, "there are patterned covers as well as plain ones", `${await covers.count()} covers`);
|
||||
check(
|
||||
await p.getByRole("button", { name: "Create", exact: true }).isVisible(),
|
||||
"and the dialog still fits a laptop screen, Create and all"
|
||||
);
|
||||
await p.getByRole("button", { name: "Cancel" }).click();
|
||||
await p.waitForTimeout(400);
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.message);
|
||||
} finally {
|
||||
|
||||
@@ -699,6 +699,7 @@ export default function App() {
|
||||
notebook={editingNb}
|
||||
onClose={() => setEditingNb(null)}
|
||||
onSave={(u) => updateNotebook(editingNb.id, u)}
|
||||
defaultMaxVersions={user?.defaultMaxVersions}
|
||||
dark={dark}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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%",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
|
||||
- **Image Attachments** — Pasted images are stored as deduplicated blobs, not inlined into the note
|
||||
- **Freehand Drawing** — Pen, highlighter, eraser with adjustable thickness and color palette (20 colors, 7 pen sizes)
|
||||
- **Undo/Redo** — Full history stack for drawing operations
|
||||
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
|
||||
- **Notebooks & Folders** — Covers in colour or pattern — ruled paper, graph paper, stripes, dots, weave — drawn in CSS, no image files
|
||||
- **Star/Favorite** — Quick access to important notes
|
||||
- **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, pin notes to individual passages, write notes beside it, and export it with or without your marks
|
||||
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
|
||||
@@ -314,6 +314,26 @@ bug they cover actually happened:
|
||||
- `hasPendingWrites` must cover the debounce window, not just requests in flight
|
||||
- v1 note bodies must migrate without losing task state or smuggling in markup
|
||||
|
||||
### Notebook covers
|
||||
|
||||
A cover is stored as an **index** into `COVERS` in
|
||||
[`ui/constants.jsx`](frontend/src/ui/constants.jsx). Entries are therefore only
|
||||
ever appended, never reordered: renumbering them would silently repaint every
|
||||
notebook anyone already has.
|
||||
|
||||
The patterned ones are drawn entirely with CSS gradient layers — no image files,
|
||||
nothing to upload, nothing to fetch. They stay crisp at any size and work offline
|
||||
like the rest of the app. `background` takes a stack of layers, topmost first, so
|
||||
each is a pattern laid over the colour beneath it.
|
||||
|
||||
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 per cover, too — two
|
||||
crossed diagonals meant as a chevron came out as noisy plaid at that size and
|
||||
were replaced with plain bands.
|
||||
|
||||
The grid scrolls. Without that the dialog runs past the bottom of a laptop screen
|
||||
and takes the *Create* button with it, which a test now checks at 1440×800.
|
||||
|
||||
### Browser suites
|
||||
|
||||
The unit tests cover the data layer; these drive a real browser against a real
|
||||
@@ -514,6 +534,11 @@ fresh device with nothing to search. After login a background pass backfills the
|
||||
missing ones into IndexedDB, slowly enough to stay out of the way, indexing each
|
||||
as it lands. Searching therefore also works offline.
|
||||
|
||||
The box sits above the note list, always, with nothing to open first. It was
|
||||
behind a magnifying glass, which 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.
|
||||
|
||||
PDFs come after the notes in that pass. Their text is not derived from a note
|
||||
body but stored with the file, by the device that imported it — see [The reading
|
||||
happens on a device, never on the
|
||||
@@ -897,7 +922,7 @@ its history on a change that is not to the note at all.
|
||||
The panel beside a PDF is for notes about the document. A note that belongs to
|
||||
*one paragraph* is a different thing — and a note that has to begin "on page 14,
|
||||
third paragraph" has already lost its place. So a note can be pinned to a spot:
|
||||
click **Randnotiz**, click the passage, write.
|
||||
click **Pin note**, click the passage, write.
|
||||
|
||||
**The note is written beside the page, not on it.** Only a small numbered marker
|
||||
sits on the document, with a hairline out to a card in the margin, level with the
|
||||
@@ -906,8 +931,12 @@ is the one thing it must not do. Cards keep one height, so two notes close
|
||||
together are laid out by pushing the second down until they no longer overlap;
|
||||
the one being written in grows and rises above the rest.
|
||||
|
||||
The column is 288px wide and a card holds about five lines. It was 208 by 74 —
|
||||
three short lines, which is a caption, not a remark — while the space beside the
|
||||
page sat mostly empty.
|
||||
|
||||
**On a phone there is no margin.** A page narrow enough to read leaves no white
|
||||
space beside it, and taking 200px for a column would make the document itself
|
||||
space beside it, and taking a column that wide would make the document itself
|
||||
unreadable. There, tapping a marker brings the note up from the bottom — over the
|
||||
page, but only while it is open, within reach of a thumb, and with the passage
|
||||
still visible above it.
|
||||
|
||||
Reference in new issue
Block a user