Take the paper back out, and quiet the surfaces around it

The sheet was not what it wanted to be, so writing is plain text again: about 73
characters to the line, centred, no border and no shadow. The fixed page belongs
to the canvas — a stroke has to land in the same place on every screen, which
needs one agreed page — so it appears exactly when something is on it or about
to be: in Draw, or when the note already has a drawing. The cap lives in the
stylesheet rather than on a container, so it holds on a page as well as off one.

Emoji are gone from the note rows and the sidebar. They are drawn by the
operating system, so they looked different on every machine and were loud enough
that a row's picture caught the eye before its title did. Rows are tighter by
three pixels of padding.

The trash button no longer stands permanently in the danger colour beside the
star, which made the most destructive control the loudest thing on screen. It
sits behind a divider and turns red under the pointer, when it is about to be
used.

On a phone the toolbar's fourteen controls scrolled sideways, so the ones at the
end could only be reached by dragging the bar and nothing said they were there.
Nine remain, plus a menu holding the headings, quote, code and undo.

A new `sync` suite proves what was assumed: a PDF written on one device — the
document, the drawing and the pinned notes — reaches a second one. Two browser
contexts are two devices.

Both search suites read a row's title as the second line of its text, which only
worked while the icon was itself a text emoji; they read it by name now.

218 unit tests, 249 browser checks across twelve suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-11 15:57:22 +02:00
1 parent c12ffa250e
commit 830af6532a
13 files changed
+396 -155

No files matched your search

+2 -2
View File
@@ -27,8 +27,8 @@ try {
const p = session.page;
const titles = () =>
p.locator("[data-note-row]").evaluateAll((rows) =>
rows.map((r) => r.querySelector("span:nth-child(2)")?.textContent?.trim() || "")
p.locator("[data-note-row] [data-note-title]").evaluateAll((els) =>
els.map((e) => e.textContent.trim())
);
const sortBy = async (mode) => {
await p.locator("[data-sort-button]").click();
+1 -1
View File
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
URL="${INKNOTE_URL:-http://localhost:3000}"
SUITES=("$@")
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise offline)
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise offline sync)
mkdir -p shots
if [ ! -d node_modules ]; then
+4 -4
View File
@@ -20,11 +20,11 @@ const PNG =
/* Rows in the note list carry data-note-row; matching on the emoji instead would
* also pick up the sidebar's Trash entry, which wears the same one. */
/* By name, not by position. Reading the second line of the row's text worked
only while the icon was a text emoji; the moment it became an SVG the second
line was the excerpt. */
const titles = (p) =>
p.$$eval("[data-note-row]", (els) =>
// The button holds emoji, title and date on separate lines.
els.map((e) => e.innerText.split("\n").filter(Boolean)[1]).filter(Boolean)
);
p.$$eval("[data-note-row] [data-note-title]", (els) => els.map((e) => e.textContent.trim()));
const browser = await launch();
let session;
+99
View File
@@ -0,0 +1,99 @@
/* One account, two devices.
*
* Everything here is written on one machine and read on another, and the parts
* travel differently: a note's text is an encrypted column, a drawing is a
* second one, and a PDF is a file the note only points at. That last is the one
* worth proving — the bytes are uploaded once and fetched by id, so a device
* that has never seen the document has to be able to ask the server for it.
*
* A second browser context is a second device: its own storage, its own cache,
* its own session.
*/
import { newPage, signIn, check, createNotebook, launch, report, withFreshUser } from "./lib.mjs";
import { writeFileSync } from "node:fs";
const PDF = "/tmp/sync-test.pdf";
const s1 = "BT /F1 22 Tf 60 700 Td (Handbuch Seite eins) Tj ET";
const objs = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
`<< /Length ${s1.length} >>\nstream\n${s1}\nendstream`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
let out = "%PDF-1.4\n"; const offs = [];
objs.forEach((o, i) => { offs.push(out.length); out += `${i + 1} 0 obj\n${o}\nendobj\n`; });
const xref = out.length;
out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
for (const o of offs) out += `${String(o).padStart(10, "0")} 00000 n \n`;
out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
writeFileSync(PDF, out, "latin1");
const browser = await launch();
let s;
try {
s = await withFreshUser(browser, "sync");
const pc = s.page;
await createNotebook(pc, "Sync");
await pc.locator("button").filter({ hasText: "Sync" }).first().click();
await pc.waitForTimeout(700);
const chooser = pc.waitForEvent("filechooser");
await pc.locator("[data-add-menu]").click();
await pc.waitForTimeout(400);
await pc.locator('[data-add-option="Upload PDF"]').click();
await (await chooser).setFiles(PDF);
await pc.waitForTimeout(5000);
check((await pc.locator("[data-pdf-page]").count()) > 0, "device A imported the PDF");
// draw a stroke and pin a note
await pc.locator("[data-pdf-mode]").click();
await pc.waitForTimeout(500);
const box = await pc.locator('[data-pdf-page="1"]').boundingBox();
await pc.mouse.move(box.x + 80, box.y + 200);
await pc.mouse.down();
await pc.mouse.move(box.x + 260, box.y + 240, { steps: 10 });
await pc.mouse.up();
await pc.waitForTimeout(2000);
await pc.locator("[data-pdf-mode]").click();
await pc.waitForTimeout(500);
await pc.locator("[data-pdf-pin-mode]").click();
await pc.waitForTimeout(400);
await pc.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.3);
await pc.waitForTimeout(800);
await pc.getByLabel("Pinned note").fill("Auf dem PC geschrieben");
await pc.getByRole("button", { name: "Done" }).click();
await pc.waitForTimeout(3000);
/* ── a second device: its own browser context, its own storage ── */
const phone = await newPage(browser, 1400, 950);
await signIn(phone, s.user, s.pass);
await phone.locator("button").filter({ hasText: "Sync" }).first().click();
await phone.waitForTimeout(1500);
await phone.locator("[data-note-row]").first().click();
await phone.waitForTimeout(6000);
check((await phone.locator("[data-pdf-page]").count()) > 0, "and the second device gets the document itself");
const inked = await phone.locator('[data-pdf-ink="1"]').evaluate((c) => {
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
let n = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) n++;
return n;
});
check(inked > 100, "the drawing came with it", `${inked} inked pixels`);
check((await phone.locator("[data-pin]").count()) === 1, "and the pinned note");
check(
(await phone.locator("[data-pin-card]").innerText()).includes("Auf dem PC geschrieben"),
"with its text",
await phone.locator("[data-pin-card]").innerText()
);
await phone.context().close();
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await s?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}
+148 -56
View File
@@ -56,6 +56,7 @@ const AtIcon = () => (
export function EditorToolbar({ editor, T, icons, compact, dark }) {
const [, force] = useState(0);
const [showTable, setShowTable] = useState(false);
const [showMore, setShowMore] = useState(false);
const [linkDialog, setLinkDialog] = useState(null);
// ProseMirror state changes do not go through React; subscribe so the active
@@ -80,6 +81,15 @@ export function EditorToolbar({ editor, T, icons, compact, dark }) {
return () => document.removeEventListener("pointerdown", close);
}, [showTable]);
useEffect(() => {
if (!showMore) return;
const close = (e) => {
if (!e.target.closest?.("[data-more-menu]")) setShowMore(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, [showMore]);
if (!editor) return null;
const btn = (active) => ({
@@ -274,7 +284,91 @@ export function EditorToolbar({ editor, T, icons, compact, dark }) {
/>
)}
{!compact && (
{/* On a phone the rest goes behind one button.
*
* There were fourteen controls in a row that scrolled sideways, so the
* ones at the end could only be reached by dragging the bar — and nothing
* said they were there. A menu is one tap and shows what it holds. */}
{compact ? (
<div style={{ position: "relative" }} data-more-menu>
<button
style={btn(showMore)}
title="More"
data-toolbar-more={showMore ? "on" : "off"}
onClick={() => setShowMore((v) => !v)}
>
{icons.dots}
</button>
{showMore && (
<div
style={{
position: "absolute",
top: 36,
right: 0,
minWidth: 180,
background: T.toolbar,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: 6,
zIndex: 200,
boxShadow: "0 8px 20px rgba(0,0,0,.25)",
}}
>
{[
[
"Body text",
() => editor.chain().focus().setParagraph().run(),
editor.isActive("paragraph"),
],
[
"Heading 1",
() => editor.chain().focus().toggleHeading({ level: 1 }).run(),
editor.isActive("heading", { level: 1 }),
],
[
"Heading 2",
() => editor.chain().focus().toggleHeading({ level: 2 }).run(),
editor.isActive("heading", { level: 2 }),
],
[
"Heading 3",
() => editor.chain().focus().toggleHeading({ level: 3 }).run(),
editor.isActive("heading", { level: 3 }),
],
[null],
[
"Quote",
() => editor.chain().focus().toggleBlockquote().run(),
editor.isActive("blockquote"),
],
[
"Code block",
() => editor.chain().focus().toggleCodeBlock().run(),
editor.isActive("codeBlock"),
],
[null],
["Undo", () => editor.chain().focus().undo().run(), false],
["Redo", () => editor.chain().focus().redo().run(), false],
].map(([label, action, active], i) =>
label === null ? (
<div key={`d${i}`} style={{ height: 1, background: T.border, margin: "5px 2px" }} />
) : (
<button
key={label}
style={{ ...menuItem, color: active ? T.active : T.text }}
onClick={() => {
action();
setShowMore(false);
}}
>
{label}
</button>
)
)}
</div>
)}
</div>
) : (
<>
{divider}
<button
@@ -291,63 +385,61 @@ export function EditorToolbar({ editor, T, icons, compact, dark }) {
>
<CodeIcon />
</button>
{divider}
<select
value={
editor.isActive("heading", { level: 1 })
? "h1"
: editor.isActive("heading", { level: 2 })
? "h2"
: editor.isActive("heading", { level: 3 })
? "h3"
: "p"
}
onChange={(e) => {
const v = e.target.value;
if (v === "p") editor.chain().focus().setParagraph().run();
else
editor
.chain()
.focus()
.toggleHeading({ level: Number(v[1]) })
.run();
}}
style={{
border: `1px solid ${T.border}`,
borderRadius: 6,
padding: "3px 6px",
background: T.toolbar,
color: T.text,
fontSize: 11,
cursor: "pointer",
}}
>
<option value="p">Body</option>
<option value="h1">H1</option>
<option value="h2">H2</option>
<option value="h3">H3</option>
</select>
{divider}
<button
style={{ ...btn(false), opacity: editor.can().undo() ? 1 : 0.35 }}
title="Undo"
onClick={() => editor.chain().focus().undo().run()}
>
{icons.undo}
</button>
<button
style={{ ...btn(false), opacity: editor.can().redo() ? 1 : 0.35 }}
title="Redo"
onClick={() => editor.chain().focus().redo().run()}
>
{icons.redo}
</button>
</>
)}
{divider}
<select
value={
editor.isActive("heading", { level: 1 })
? "h1"
: editor.isActive("heading", { level: 2 })
? "h2"
: editor.isActive("heading", { level: 3 })
? "h3"
: "p"
}
onChange={(e) => {
const v = e.target.value;
if (v === "p") editor.chain().focus().setParagraph().run();
else
editor
.chain()
.focus()
.toggleHeading({ level: Number(v[1]) })
.run();
}}
style={{
border: `1px solid ${T.border}`,
borderRadius: 6,
padding: "3px 6px",
background: T.toolbar,
color: T.text,
fontSize: 11,
cursor: "pointer",
}}
>
<option value="p">Body</option>
<option value="h1">H1</option>
<option value="h2">H2</option>
<option value="h3">H3</option>
</select>
{divider}
<button
style={{ ...btn(false), opacity: editor.can().undo() ? 1 : 0.35 }}
title="Undo"
onClick={() => editor.chain().focus().undo().run()}
>
{icons.undo}
</button>
<button
style={{ ...btn(false), opacity: editor.can().redo() ? 1 : 0.35 }}
title="Redo"
onClick={() => editor.chain().focus().redo().run()}
>
{icons.redo}
</button>
</>
);
}
export default EditorToolbar;
+15 -16
View File
@@ -8,10 +8,10 @@
--ne-text: #1f2328;
--ne-muted: #656d76;
--ne-faint: #afb8c1;
--ne-border: #ded8cd;
--ne-border: #d0d7de;
--ne-accent: #0969da;
--ne-surface: #fbfaf7;
--ne-subtle: #f2efe8;
--ne-surface: #ffffff;
--ne-subtle: #f6f8fa;
}
.note-editor[data-theme="dark"] {
--ne-text: #e6edf3;
@@ -19,28 +19,27 @@
--ne-faint: #484f58;
--ne-border: #30363d;
--ne-accent: #388bfd;
--ne-surface: #161c24;
--ne-subtle: #1d242e;
--ne-surface: #0d1117;
--ne-subtle: #161b22;
}
.note-editor .ProseMirror {
/* About 73 characters to the line at this size — measured, not guessed. The
text used to run the full width of the pane, past 84, which is beyond the
point where the eye reliably finds the start of the next line. Capped here
rather than on the container so it holds on a page as well as off one. */
max-width: 527px;
margin-left: auto;
margin-right: auto;
outline: none;
color: var(--ne-text);
font-family: "Newsreader", Georgia, serif;
font-size: 16px;
line-height: 1.75;
/* A margin, the way a page has one. The old 24px let the text run to the very
edge of the sheet, which is the one thing paper never does. */
padding: 44px 56px 64px;
font-size: 15px;
line-height: 1.7;
padding: 20px 24px;
min-height: 100%;
word-break: break-word;
}
@media (max-width: 767px) {
.note-editor .ProseMirror {
padding: 22px 20px 40px;
}
}
.note-editor .ProseMirror > * + * {
margin-top: 0.6em;
}
+17 -2
View File
@@ -214,10 +214,25 @@ export function EditorPane({
<button onClick={onOpenHistory} title="Version history" style={toolbarButton(T, T.faint)}>
{I.history}
</button>
<button onClick={onToggleStar} style={toolbarButton(T, note.starred ? "#f0b429" : T.faint)}>
<button
onClick={onToggleStar}
title={note.starred ? "Unstar" : "Star"}
style={toolbarButton(T, note.starred ? "#f0b429" : T.faint)}
>
{note.starred ? I.starFill : I.star}
</button>
<button onClick={onTrash} style={toolbarButton(T, T.danger)}>
{/* Set apart, and no longer painted red while doing nothing. Standing
permanently in the danger colour next to the star made the most
destructive control the loudest thing on the screen; it turns red
under the pointer, when it is about to be used. */}
<div style={{ width: 1, height: 18, background: T.borderLight, margin: "0 4px" }} />
<button
onClick={onTrash}
title="Move to Trash"
onMouseEnter={(e) => (e.currentTarget.style.color = T.danger)}
onMouseLeave={(e) => (e.currentTarget.style.color = T.faint)}
style={toolbarButton(T, T.faint)}
>
{I.trash}
</button>
</div>
+28 -34
View File
@@ -20,6 +20,7 @@ import {
sizeCanvas,
} from "../drawing/strokes.js";
import { COLORS } from "../../ui/constants.jsx";
import { I } from "../../ui/icons.jsx";
import { BacklinksPanel } from "../../ui/BacklinksPanel.jsx";
@@ -67,6 +68,15 @@ export function NoteEditor({
// new vector strokes so migrating a note never loses what was already drawn.
const legacyRaster = note.drawingData && !hideDrawing;
/* Whether this note needs a page at all.
*
* The fixed 3:4 stage exists for the canvas: a stroke has to land in the same
* place on every screen, which needs one agreed page. Writing needs no such
* thing, and paid for it with a bordered box and an empty page below two lines
* of text. So the page appears exactly when something is on it — or about to
* be — and plain text is simply text. */
const needsPage = mode === "draw" || hasStrokes || !!legacyRaster;
const commitStrokes = (next) => {
strokesRef.current = next;
setStrokes(next);
@@ -75,15 +85,6 @@ export function NoteEditor({
const T = useMemo(
() => ({
bg: dark ? "#0d1117" : "#ffffff",
/* The sheet.
*
* In daylight it is warm off-white — plain white next to a white toolbar
* is not a sheet, it is an absence of one. At night it is a raised surface
* rather than a white rectangle: a page that glows brighter than the room
* is what makes an app unusable in the evening, and "paper" in a dark room
* still means "the surface I write on", not "white". */
paper: dark ? "#1b2430" : "#fbfaf7",
paperEdge: dark ? "#333f4e" : "#e4dfd5",
toolbar: dark ? "#161b22" : "#f6f8fa",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
@@ -163,13 +164,7 @@ export function NoteEditor({
const mobile = window.innerWidth < 768;
setIsMobileViewport(mobile);
const sidePad = mobile ? 10 : 28;
/* How wide a sheet gets.
*
* With 56px of inner margin this leaves a text column of about 68
* characters at the body size — measured, not guessed. The old 760 ran to
* nearly 90, past the width at which the eye reliably finds the start of
* the next line, and the single biggest reason the surface read badly. */
const desktopMax = 620;
const desktopMax = 760;
const minW = mobile ? 220 : 320;
const maxAllowed = Math.max(220, W - sidePad * 2);
const targetW = mobile ? maxAllowed : Math.min(desktopMax, maxAllowed);
@@ -460,9 +455,11 @@ export function NoteEditor({
* width a property of the window, which is what it always looked like. */
flex: 1,
minWidth: 0,
// The desk the sheet lies on, a touch darker than the sheet so the
// shadow has something to fall onto.
background: isMobileViewport ? T.paper : dark ? "#0b0f14" : "#eceae5",
background: isMobileViewport
? T.bg
: dark
? "linear-gradient(180deg,#0f141b 0%,#0d1117 60%)"
: "linear-gradient(180deg,#f8fafd 0%,#eef3f8 70%)",
}}
>
{/* Main toolbar */}
@@ -671,25 +668,22 @@ export function NoteEditor({
<div
style={{
position: "relative",
width: stageSize.w,
minHeight: stageSize.h,
// Centred. It used to sit 28px from the left and stop short of the
// right, so the margins were visibly unequal.
// Without a page the column is as wide as it needs to be to read
// comfortably, and centred; with one it is the canvas's page.
// The text caps its own width in the stylesheet, so this is only
// the page when there is one.
width: needsPage ? stageSize.w : "100%",
minHeight: needsPage ? stageSize.h : undefined,
margin: "0 auto",
background: T.paper,
border: isMobileViewport ? "none" : `1px solid ${T.paperEdge}`,
borderRadius: isMobileViewport ? 0 : 3,
// Paper lies on something; a sheet with no shadow is a panel.
boxShadow: isMobileViewport
? "none"
: dark
? "0 10px 30px rgba(0,0,0,0.45)"
: "0 10px 28px rgba(60,50,30,0.13)",
background: needsPage ? T.bg : "transparent",
border: needsPage && !isMobileViewport ? `1px solid ${T.border}` : "none",
borderRadius: needsPage && !isMobileViewport ? 14 : 0,
boxShadow: needsPage && !isMobileViewport ? "0 16px 36px rgba(0,0,0,0.14)" : "none",
}}
>
<div
style={{
minHeight: stageSize.h,
minHeight: needsPage ? stageSize.h : undefined,
width: "100%",
pointerEvents: mode === "text" ? "auto" : "none",
}}
@@ -697,7 +691,7 @@ export function NoteEditor({
<EditorSurface
editor={textEditor}
dark={dark}
minHeight={stageSize.h}
minHeight={needsPage ? stageSize.h : 0}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
/>
+9 -6
View File
@@ -524,7 +524,9 @@ export function NoteList({
padding: 30,
}}
>
<div style={{ fontSize: 36 }}>{isTrashView ? "🗑️" : "📝"}</div>
<div style={{ display: "flex", opacity: 0.5, transform: "scale(2.2)", marginBottom: 12 }}>
{isTrashView ? I.trash : I.note}
</div>
<div style={{ fontSize: 13, textAlign: "center" }}>
{isTrashView ? "Trash is empty" : "No notes yet"}
</div>
@@ -603,7 +605,7 @@ export function NoteList({
display: "flex",
flexDirection: "column",
width: "100%",
padding: "12px 14px",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
background: activeNote === note.id ? T.accentSoft : "transparent",
@@ -621,10 +623,11 @@ export function NoteList({
width: "100%",
}}
>
<span style={{ fontSize: 13 }}>
{note.trashed ? "🗑️" : note.type === "pdf" ? "📄" : "📝"}
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{note.trashed ? I.trash : note.type === "pdf" ? I.pdf : I.note}
</span>
<span
data-note-title
style={{
fontSize: 13,
fontWeight: 600,
@@ -638,7 +641,7 @@ export function NoteList({
{note.title}
</span>
{note.starred && !note.trashed && (
<span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>
<span style={{ display: "flex", color: "#f0b429" }}>{I.starFill}</span>
)}
</div>
{snippet && (
@@ -657,7 +660,7 @@ export function NoteList({
{snippet}
</div>
)}
<div style={{ fontSize: 10, color: T.faint, marginTop: 3 }}>
<div style={{ fontSize: 10.5, color: T.faint, marginTop: 3 }}>
{fmtDate(note.updatedAt)}
</div>
</button>
+2 -2
View File
@@ -164,7 +164,7 @@ export function Sidebar({
onClick={() => onSelectFolder("dashboard")}
style={{ ...folderButton(T, activeFolder === "dashboard"), padding: "9px 12px", fontWeight: 600 }}
>
<span style={{ fontSize: 15 }}>📚</span>
<span style={{ display: "flex", color: "inherit" }}>{I.books}</span>
<span>Dashboard</span>
</button>
</div>
@@ -176,7 +176,7 @@ export function Sidebar({
onClick={() => onSelectFolder(f.id)}
style={folderButton(T, activeFolder === f.id)}
>
<span style={{ fontSize: 14 }}>{f.icon}</span>
<span style={{ display: "flex", color: "inherit" }}>{I[f.icon]}</span>
<span>{f.name}</span>
{f.id === "all" && <span style={countBadge(T, T.faint)}>{activeNotes.length}</span>}
{f.id === "trash" && trashedCount > 0 && (
+6 -3
View File
@@ -40,8 +40,11 @@ export const COVERS = [
{ bg: "linear-gradient(135deg,#1f1c2c,#928dab)", spine: "#15131f", accent: "#b39ddb" },
{ bg: "linear-gradient(135deg,#654ea3,#eaafc8)", spine: "#4e3c7e", accent: "#f8bbd0" },
];
/* 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
* the same on every machine. */
export const DEFAULT_FOLDERS = [
{ id: "all", name: "All Notes", icon: "📋" },
{ id: "starred", name: "Starred", icon: "⭐" },
{ id: "trash", name: "Trash", icon: "🗑️" },
{ id: "all", name: "All Notes", icon: "stack" },
{ id: "starred", name: "Starred", icon: "star" },
{ id: "trash", name: "Trash", icon: "trash" },
];
+62
View File
@@ -506,6 +506,68 @@ export const I = {
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
),
/* Line icons for the places that used to carry emoji.
*
* Emoji are drawn by the operating system: they look different on every
* machine, sit at a different baseline, and are loud — a row's picture caught
* the eye before its title did. These match the stroke weight of everything
* else here. */
note: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
<polyline points="14 3 14 8 19 8" />
<line x1="9" y1="13" x2="15" y2="13" />
<line x1="9" y1="17" x2="13" y2="17" />
</svg>
),
books: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="9" y1="6" x2="16" y2="6" />
</svg>
),
stack: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
),
dots: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="1.7" />
<circle cx="12" cy="12" r="1.7" />
<circle cx="19" cy="12" r="1.7" />
</svg>
),
pin: (
<svg
width="15"
+3 -29
View File
@@ -332,12 +332,13 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `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` | 7 | strokes survive the poller, and match across four viewport widths |
| `pdf` | 85 | import, page proportions, lazy loading, and reading with the network off |
| `pdf` | 91 | import, page proportions, lazy loading, and reading with the network off |
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `organise` | 38 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `organise` | 39 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `offline` | 12 | a cold page load with the network off still opens notes and searches them |
| `sync` | 5 | a PDF written on one device — document, drawing and pinned notes — reaches a second one |
Each suite creates a throwaway account through the admin API and deletes it
afterwards, so runs leave the database as they found it. They need an admin to
@@ -368,33 +369,6 @@ state of their own, so they can be read on their own.
---
## The writing surface
A real sheet, lying on a desk. It had been a page-*shaped* box that was not
page-*coloured* — the same fill as everything around it with a hairline border,
so it read as an empty panel rather than as paper.
- **Warm off-white in daylight** (`#fbfaf7`). Plain white next to a white toolbar
is not a sheet, it is the absence of one.
- **A raised surface at night** (`#1b2430`), lighter than the desk, with an edge
and a shadow — deliberately not a white rectangle. A page that glows brighter
than the room is what makes an app unusable in the evening; in a dark theme
"paper" means *the surface I write on*, not *white*.
- **About 73 characters to the line**, measured rather than guessed: 620px of
sheet with a 56px margin. It ran to 84 before, past the width at which the eye
reliably finds the start of the next line, and that was the single biggest
reason the surface read badly.
- **Centred**, with almost square corners. It used to sit 28px from the left and
stop short of the right, and carried a 14px radius that belongs to a dialog.
Blocks *on* the sheet — quotes, code, table headers — are tinted from paper
rather than from the app background, or they would read as sitting underneath it.
The sheet keeps the drawing's page proportion, so a short note leaves an empty
page below it. That is what a sheet of paper does, and it is the price of having
one: the canvas needs a fixed page for a stroke to land in the same place on
every screen.
## Interface language
English throughout. It had drifted into two languages — the frame in English,