The same page on every screen

The text is laid out on a page of one fixed size and the sheet scaled to
the screen, so a phone breaks it exactly where a desktop does — twenty-one
lines to the page on both. The type size is computed from the page and the
line count rather than chosen.

A PDF's full screen is the browser window, not the screen, and its button
sits beside the zoom. "Recent Notes" is stamped by every write, not only by
a rename. And a PDF that will not open says which of the four reasons it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-26 13:23:03 +02:00
1 parent 9507761bce
commit 4013e959d6
12 files changed
+279 -85

No files matched your search

+35
View File
@@ -14,6 +14,41 @@ a rebuild is not bit-identical.
---
## 2026-08-26 — The same page on every screen
**Images** — web `sha256:e475c080…`, server `sha256:49a98c66…`
The server image carries no change of its own; `build.sh` builds both.
**Changed**
- **Twenty-one lines to the page, and the same twenty-one everywhere.** The text
is now laid out on a page of one fixed size and the whole sheet is scaled to
the screen, instead of being set in screen pixels. Set that way, a phone and a
desktop disagreed about everything that matters — the same note broke into
different lines on different pages depending on what you read it on, and a
phone page held eighteen lines where a desktop held far more. The type size is
computed from the page and the line count rather than chosen, so the count
survives a change to either; a desktop therefore shows larger writing on a
smaller-looking sheet, the way a ruled notebook app does.
- **Full screen for a PDF means the browser window, not the screen.** Asking the
browser for its own full screen took the tabs and the address bar with it,
which was more than was wanted. The overlay over the app — the part that
removes the sidebar, the list and the title bar — is the whole feature now.
The button moved next to the zoom, where the other question about how much
room the document gets already lives.
**Fixed**
- **"Recent Notes" is now sorted by when you last wrote in a note**, not by when
you last renamed one. The store always stamped every write; the lists on screen
read the app's own copy of the notes, and writing *in* a note left that copy
untouched — so the dashboard and the "Last changed" sort showed the order from
the last reload.
- **A PDF that will not open says why.** One message covered every failure and
guessed at the cause ("offline?"), which was usually wrong and left a report of
"it will not open on my phone" with nothing in it. The status now travels with
the failure: a missing file, a server that refused, a server that could not be
reached and a file that will not parse are four different messages.
## 2026-08-25 — Colour, a page you can see the edge of, and a document on its own
**Images** — web `sha256:3e1b47e1…`, server `sha256:89e474cc…`
+70
View File
@@ -261,6 +261,76 @@ try {
"and the gap stays blank — no placeholder printed at the foot of the page"
);
/* ── twenty-one lines, and the same twenty-one everywhere ──
*
* Two claims. First, a page holds the number of lines a ruled page holds —
* it held eighteen on a phone, which is what started this. Second, and the
* one that matters: the phone breaks the text in exactly the same places the
* desktop does. It did not, because the text used to be set in screen pixels:
* a 340-pixel sheet fitted nothing like what a 760-pixel one did, so the same
* note had different lines on different pages depending on what you read it
* on. The text is now laid out on a page of a fixed size and the whole sheet
* scaled, so the only thing a narrow screen changes is how big it looks.
*
* Measured in page units — every length divided by the sheet's own scale —
* because screen pixels are precisely what must not agree. */
const pageUnits = () =>
p.evaluate(() => {
const scale = Number(document.querySelector("[data-text-page-scale]").dataset.textPageScale);
const pm = document.querySelector(".ProseMirror");
const cs = getComputedStyle(pm);
const sheet = document.querySelector("[data-page-sheet]").getBoundingClientRect();
const top = pm.getBoundingClientRect().top;
const blocks = [...pm.children]
.filter((el) => !el.classList.contains("ne-page-spacer"))
.slice(0, 40)
.map((el) => {
const r = el.getBoundingClientRect();
return { y: Math.round((r.top - top) / scale), h: Math.round(r.height / scale) };
});
return {
lines:
(sheet.height / scale - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)) /
parseFloat(cs.lineHeight),
blocks,
};
});
const onDesktop = await pageUnits();
check(
Math.floor(onDesktop.lines) === 21,
"a page holds twenty-one lines",
`${onDesktop.lines.toFixed(2)} fit`
);
const phone = { width: 390, height: 844 };
const desktop = p.viewportSize();
await p.setViewportSize(phone);
await p.waitForTimeout(2000);
/* A phone shows one pane at a time, so the note may have been left behind in
the list when the window narrowed. Open it again before measuring. */
if ((await p.locator("[data-text-page-scale]").count()) === 0) await reopenNote(p, "Langer Text");
await p.waitForSelector("[data-text-page-scale]", { timeout: 15000 });
await p.waitForTimeout(3000);
const onPhone = await pageUnits();
check(
Math.floor(onPhone.lines) === 21,
"on a phone as well — the same page, not a smaller one",
`${onPhone.lines.toFixed(2)} fit`
);
const moved = onDesktop.blocks
.map((b, i) => ({ i, b, o: onPhone.blocks[i] }))
.filter(({ b, o }) => !o || Math.abs(b.y - o.y) > 1 || Math.abs(b.h - o.h) > 1)
.map(({ i, b, o }) => `#${i}: ${b.y}/${b.h} vs ${o ? `${o.y}/${o.h}` : "missing"}`);
check(
onPhone.blocks.length === onDesktop.blocks.length && moved.length === 0,
"and every paragraph falls in the same place on both, to the page unit",
moved.slice(0, 4).join(" | ")
);
await p.setViewportSize(desktop);
await p.waitForTimeout(2000);
if ((await p.locator(".ProseMirror").count()) === 0) await reopenNote(p, "Langer Text");
/* The note is still one document — pages are drawn, not stored. */
const stored = (await api(p, "GET", "/notes")).body[0];
const body = (await api(p, "GET", `/notes/${stored.id}`)).body.content;
+4 -4
View File
@@ -271,10 +271,10 @@ try {
await p.waitForTimeout(1500);
/* ── the document, and nothing else ──
* Two things at once: the browser's own full screen, which puts the page past
* the window frame, and an overlay over the app, which is what actually
* removes the sidebar, the list and the title bar. The second is the half that
* can be measured here — and the half a browser can refuse. */
* The app laid out as an overlay over itself: the document takes the whole
* browser window, and the sidebar, the note list and the title bar go. The
* browser's own full screen is deliberately not asked for — this is about
* giving the page to the document, not the screen. */
const workspaceBox = () =>
p.locator("[data-pdf-workspace]").evaluate((el) => {
const r = el.getBoundingClientRect();
+1 -1
View File
@@ -66,7 +66,7 @@
/* Set per account — see UserPreferences. The stack is the fallback chain, so
an unset preference still reads as the serif it always was. */
font-family: var(--ne-font, "Newsreader", Georgia, serif);
font-size: 15px;
font-size: var(--ne-size, 15px);
line-height: var(--ne-line);
padding: 20px 24px;
min-height: 100%;
+14 -7
View File
@@ -87,7 +87,7 @@ function makeSpacerRow(height, delta, colspan) {
}
/** The rows of a table, with the document position of each. */
function tableRows(node, wrapper, domTop, seenBefore) {
function tableRows(node, wrapper, domTop, seenBefore, scale) {
const table = wrapper.matches?.("table") ? wrapper : wrapper.querySelector(":scope table");
const body = table?.querySelector(":scope > tbody");
if (!body) return null;
@@ -108,8 +108,8 @@ function tableRows(node, wrapper, domTop, seenBefore) {
rows.push({
index,
pos,
top: rect.top - domTop - seenBefore - inner,
height: rect.height,
top: (rect.top - domTop) / scale - seenBefore - inner,
height: rect.height / scale,
});
pos += node.child(index).nodeSize;
index++;
@@ -121,6 +121,13 @@ function tableRows(node, wrapper, domTop, seenBefore) {
function computeBreaks(view, geometry) {
const pageHeight = geometry?.pageHeight ?? 0;
const gap = geometry?.gap ?? 0;
/* The text is laid out on a page of a fixed size and the whole sheet is then
* scaled to whatever room the screen has — that is what makes a phone break
* the text in exactly the same places a desktop does. `getBoundingClientRect`
* reports what is on the screen, so every measured length is divided back out
* of that scaling: the page height above, the spacer heights below and the
* margins read from the computed style are all in page units. */
const scale = geometry?.scale || 1;
if (!(pageHeight > 0)) return null;
const pitch = pageHeight + gap;
@@ -156,8 +163,8 @@ function computeBreaks(view, geometry) {
const cs = getComputedStyle(child);
const block = {
pos: offset,
top: rect.top - domTop - seen,
height: rect.height,
top: (rect.top - domTop) / scale - seen,
height: rect.height / scale,
marginTop: px(cs.marginTop),
marginBottom: px(cs.marginBottom),
};
@@ -167,11 +174,11 @@ function computeBreaks(view, geometry) {
* they push everything after the table down as surely as a block spacer
* does, and the next block's natural position has to allow for it. */
if (node.type.name === "table") {
const measured = tableRows(node, child, domTop, seen);
const measured = tableRows(node, child, domTop, seen, scale);
if (measured) {
block.rows = measured.rows.map((r) => ({ ...r, pos: offset + 1 + r.pos }));
block.columns = measured.columns;
block.height = rect.height - measured.inner;
block.height = rect.height / scale - measured.inner;
seen += measured.inner;
}
}
+7 -3
View File
@@ -123,7 +123,7 @@ export function useNoteEditor({
}
/** The editable page itself. */
export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resolveLinkLabel, resolveLinkIcon }) {
export function EditorSurface({ editor, dark, font, minHeight, fontSize, onOpenNote, resolveLinkLabel, resolveLinkIcon }) {
/* Note links carry the label they were created with, so renaming a note would
* leave every reference to it showing the old title.
*
@@ -236,8 +236,12 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
data-theme={dark ? "dark" : "light"}
data-font={font || "serif"}
onClick={handleClick}
// The whole sheet is somewhere to write, so it says so under the pointer.
style={{ minHeight, cursor: "text" }}
/* The whole sheet is somewhere to write, so it says so under the pointer.
`--ne-size` is the type size *on the page*: a paged note sets it so that
a fixed number of lines fills a sheet, and the sheet is then scaled to
the screen. Panels that show a note without a page — the notes beside a
PDF, a version preview — leave it unset and keep the reading size. */
style={{ minHeight, cursor: "text", ...(fontSize ? { "--ne-size": `${fontSize}px` } : null) }}
>
<EditorContent editor={editor} />
</div>
+62 -11
View File
@@ -25,6 +25,31 @@ import {
} from "../drawing/strokes.js";
import { COLORS } from "../../ui/constants.jsx";
/* ── the page the text is laid out on ──
*
* One page, one size, everywhere: the text is set on a sheet of exactly these
* units and the whole sheet is then scaled to whatever width the screen offers.
* Laying it out in screen pixels instead is what made a phone and a desktop
* disagree — the same note broke into different lines and different pages on
* each, because a 340-pixel sheet held nothing like what a 760-pixel one did.
* Now the scale is the only difference between them.
*
* The shape is the drawing's own page, so "page 2" means the same sheet whether
* you write on it or draw on it.
*/
const PAGE_W = 760;
const PAGE_H = Math.round((PAGE_W * CANON_H) / CANON_W);
const PAGE_GAP = 16;
/* Twenty-one lines to the page, the way Samsung Notes rules its paper — and the
* type size follows from that rather than being chosen and hoped for. The two
* numbers below are what `editor.css` sets on the writing surface; if either
* moves there, the size recomputed here keeps the count. */
const PAGE_PAD = 20;
const LINE_HEIGHT = 1.7;
const LINES_PER_PAGE = 21;
const TEXT_SIZE = Math.floor((PAGE_H - PAGE_PAD * 2) / LINES_PER_PAGE / LINE_HEIGHT);
import { I } from "../../ui/icons.jsx";
import { BacklinksPanel } from "../../ui/BacklinksPanel.jsx";
@@ -85,20 +110,25 @@ export function NoteEditor({
// new vector strokes so migrating a note never loses what was already drawn.
const legacyRaster = note.drawingData && !hideDrawing;
/* The gap between two sheets.
/* How much of a page fits on this screen: the one number that separates a
* phone from a desktop. Everything about the writing itself — line breaks,
* page breaks, where a paragraph starts — is decided in page units and then
* multiplied by this. */
const scale = stageSize.w / PAGE_W;
/* The gap between two sheets, in page units like everything else.
*
* The same idea as the PDF reader's, which is the point: a page in a note and
* a page in a document should read as the same kind of thing. The text flow
* knows about it too — `editor/pagination.js` holds it open inside the
* document so no paragraph is ever cut in half by it. */
const PAGE_GAP = isMobileViewport ? 10 : 16;
const pitch = stageSize.h + PAGE_GAP;
const pitch = (PAGE_H + PAGE_GAP) * scale;
/* Handed to the editor, and read fresh on every measurement rather than
* captured, so resizing the window re-breaks the text without rebuilding the
* editor and losing the cursor. */
const pageGeometry = useMemo(
() => ({ pageHeight: stageSize.h, gap: PAGE_GAP }),
[stageSize.h, PAGE_GAP]
() => ({ pageHeight: PAGE_H, gap: PAGE_GAP, scale }),
[scale]
);
const commitSheets = (next) => {
@@ -244,6 +274,8 @@ export function NoteEditor({
const recount = () => {
const el = surface();
if (!el || !stageSize.h) return;
/* Read in page units: `scrollHeight` is layout, and the sheet's scaling is
* a transform, which layout knows nothing about. */
const height = el.scrollHeight;
const scroller = scrollerRef.current;
/* Counted in whole pitches — a page and the gap after it — because that is
@@ -251,14 +283,16 @@ export function NoteEditor({
* four of a two-page note is on the note, and a count that ignored it
* would report two pages while showing four, leaving the fourth with no
* canvas to be redrawn on. */
const written = Math.max(1, Math.floor(Math.max(0, height - 1) / pitch) + 1);
const pagePitch = PAGE_H + PAGE_GAP;
const written = Math.max(1, Math.floor(Math.max(0, height - 1) / pagePitch) + 1);
const count = Math.max(written, drawnPages[drawnPages.length - 1] ?? 1);
/* The page under the middle of the viewport is the one you are reading —
* the same rule the PDF reader uses. Measuring from the top instead said
* "page 1" while you were looking at the foot of a two-page note, because
* a window is shorter than a page. */
const middle = scroller ? scroller.scrollTop + scroller.clientHeight / 2 : 0;
const current = Math.max(1, Math.min(count, Math.floor(middle / pitch) + 1));
// The scroller is in screen units, so it is divided back into page ones.
const middle = scroller ? (scroller.scrollTop + scroller.clientHeight / 2) / scale : 0;
const current = Math.max(1, Math.min(count, Math.floor(middle / pagePitch) + 1));
setPages((p) => (p.count === count && p.current === current ? p : { count, current }));
};
@@ -273,7 +307,7 @@ export function NoteEditor({
scroller?.removeEventListener("scroll", recount);
textEditor.off("transaction", recount);
};
}, [textEditor, stageSize.h, pitch, mode, drawnPages]);
}, [textEditor, stageSize.h, scale, mode, drawnPages]);
/* ── DRAWING: painting ──
* Only the page under the pointer is repainted while a stroke is being drawn;
@@ -797,7 +831,7 @@ export function NoteEditor({
maxWidth: "100%",
// Every sheet plus the gaps between them, but not a gap trailing
// off the last one.
minHeight: canvasPages * pitch - PAGE_GAP,
minHeight: canvasPages * pitch - PAGE_GAP * scale,
margin: "0 auto",
}}
>
@@ -868,17 +902,34 @@ export function NoteEditor({
width: "100%",
pointerEvents: mode === "text" ? "auto" : "none",
}}
>
{/* The sheet, at its own size, shrunk to fit the screen.
A transform rather than a smaller type size, because a type
size changes where the lines fall and that is exactly what has
to stay put: scaled this way the phone shows the same lines on
the same pages as the desktop, only smaller. Anchored top-left
so the scaling never moves the sheet away from the paper drawn
behind it. */}
<div
data-text-page-scale={scale}
style={{
width: PAGE_W,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
<EditorSurface
editor={textEditor}
dark={dark}
font={noteFont}
minHeight={stageSize.h}
minHeight={PAGE_H}
fontSize={TEXT_SIZE}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
resolveLinkIcon={resolveLinkIcon}
/>
</div>
</div>
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */}
{legacyRaster && (
<img
+26 -8
View File
@@ -48,6 +48,26 @@ const visibilityButton = (T, shown) => ({
color: shown ? T.muted : T.accent,
});
/**
* Why the document did not open, in words.
*
* One message for every failure told a phone that could not reach the server and
* a phone whose file was missing exactly the same thing, and both of them a
* guess ("offline?") that was usually wrong. The reason is known here — say it,
* so a report of "it will not open" arrives with the cause attached.
*/
function openFailure(err) {
if (err?.message === "AUTH") return "Session expired — please sign in again.";
if (typeof navigator !== "undefined" && navigator.onLine === false)
return "You are offline, and this file has not been downloaded to this device yet.";
const status = /^HTTP (\d+)$/.exec(err?.message || "")?.[1];
if (status === "404") return "The server no longer has this file (404).";
if (status) return `The server would not send the file (HTTP ${status}).`;
if (err?.name === "TypeError") return "The server could not be reached.";
// A file that arrived but will not parse: broken, or not a PDF at all.
return `The file could not be opened: ${err?.name || "Error"}: ${err?.message || err}`;
}
/** Pages this far outside the viewport are drawn ahead of being scrolled to. */
const RENDER_MARGIN_PX = 1200;
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
@@ -172,7 +192,7 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
* proportions before anything is drawn, so the scrollbar is honest from the
* first frame instead of jumping as pages arrive.
*/
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, focusPin, onFocusHandled }) {
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, zoomExtras, focusPin, onFocusHandled }) {
const [state, setState] = useState({ status: "loading" });
const [zoom, setZoom] = useState(1);
const [drawing, setDrawing] = useState(false);
@@ -400,13 +420,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
// console, where it can be read when someone reports "it does not open".
console.error(`[pdf] could not open document: ${err?.name || "Error"}: ${err?.message || err}`);
if (!cancelled) {
setState({
status: "error",
message:
err?.message === "AUTH"
? "Session expired — please sign in again."
: "The file could not be opened. Offline and never loaded?",
});
setState({ status: "error", message: openFailure(err) });
}
});
@@ -818,6 +832,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
+
</button>
{/* How big the document is drawn, and how much room it gets — the same
question asked twice, so the controls stand together. */}
{zoomExtras}
{/* What is shown on top of the document, kept together at the end of
the bar. Drawings and margin notes are two halves of one question —
"how much of my own work do I want to see right now" — and they used
+15 -38
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { PdfViewer } from "./PdfViewer.jsx";
import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
@@ -43,7 +43,6 @@ export function PdfWorkspace({
}) {
const [showNotes, setShowNotes] = useState(false);
const [full, setFull] = useState(false);
const rootRef = useRef(null);
// Set when a link in the notes points at a marker; the viewer clears it once
// it has scrolled there.
const [focusPin, setFocusPin] = useState(null);
@@ -66,36 +65,17 @@ export function PdfWorkspace({
/* ── the document, and nothing else ──
*
* Two things at once, because either alone is half a job. The browser's own
* full screen puts the document past the address bar and the window frame,
* which is the point of asking on a laptop or a tablet; and the app is laid
* out as an overlay over itself, which is what actually removes the sidebar,
* the note list and the title bar. A browser that refuses the first — an
* iPhone does — still gets the second.
* The app laid out as an overlay over itself: the document fills the browser
* window, and the sidebar, the note list and the title bar are gone. The
* browser's *own* full screen is deliberately not asked for — it swallows the
* tabs and the address bar too, which is more than was wanted here; this is
* about giving the page to the document, not the screen.
*/
const enterFull = () => {
setFull(true);
rootRef.current?.requestFullscreen?.().catch(() => {
/* refused or unavailable: the overlay is the whole feature anyway */
});
};
const leaveFull = useCallback(() => {
setFull(false);
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
}, []);
const enterFull = () => setFull(true);
const leaveFull = useCallback(() => setFull(false), []);
/* Leaving the browser's full screen — with its own Escape, with F11, by any
route — must take the overlay with it, or the app would be left pretending
to be full screen inside an ordinary window. */
useEffect(() => {
const onChange = () => {
if (!document.fullscreenElement) setFull(false);
};
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
/* And Escape works even where there was never a browser full screen to leave. */
/* Escape gets out. A way out that you can only know about is not a way out —
so the button stays visible in the toolbar as well. */
useEffect(() => {
if (!full) return undefined;
const onKey = (e) => {
@@ -198,10 +178,9 @@ export function PdfWorkspace({
</button>
);
/* Both controls in the viewer's own toolbar, beside the zoom — a second row
for two buttons would cost a strip of reading height on every screen. */
const toolbarExtras = (
<>
/* Beside the zoom rather than out on its own: both answer "how much of this
screen does the document get". */
const fullToggle = (
<button
data-pdf-fullscreen={full ? "on" : "off"}
onClick={() => (full ? leaveFull() : enterFull())}
@@ -210,14 +189,11 @@ export function PdfWorkspace({
>
{full ? <ShrinkIcon /> : <ExpandIcon />}
</button>
{notesToggle}
</>
);
return (
<div
ref={rootRef}
data-pdf-workspace
style={{
flex: 1,
@@ -235,7 +211,8 @@ export function PdfWorkspace({
note={note}
dark={dark}
onUpdate={onUpdate}
notesToggle={toolbarExtras}
notesToggle={notesToggle}
zoomExtras={fullToggle}
focusPin={focusPin}
onFocusHandled={() => setFocusPin(null)}
/>
+17 -1
View File
@@ -217,6 +217,20 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
/* ── Notes ── */
/**
* Marks a note as changed *now* in the list everything on screen reads from.
*
* The store behind `sync` already stamps the note on every write, but nothing
* on screen reads the store: the lists read this state. Only a title change
* used to stamp it here — so writing in a note left "Recent Notes" and the
* "Last changed" sort showing the order from the last reload, which is exactly
* the moment those two are supposed to react to.
*/
const touch = useCallback((id: string) => {
const updatedAt = new Date().toISOString();
setNotes((p) => p.map((n) => (n.id === id ? { ...n, updatedAt } : n)));
}, []);
const updateNote = useCallback((id: string, edit: NoteEdit) => {
if (edit.title !== undefined) {
const title = edit.title;
@@ -228,15 +242,17 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (edit.content !== undefined) {
const content = edit.content;
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), content } }));
touch(id);
void sync.updateNoteContent(id, content, edit.links, edit.attachments);
searchIndex.setEntry(id, notesRef.current.find((n) => n.id === id)?.title || "", content);
}
if (edit.drawingStrokes !== undefined) {
const drawingStrokes = edit.drawingStrokes;
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), drawingStrokes } }));
touch(id);
void sync.updateNoteDrawing(id, drawingStrokes);
}
}, []);
}, [touch]);
/** Replaces a body wholesale — used when restoring an older version. */
const setNoteContent = useCallback((id: string, content: NoteContent) => {
+3 -1
View File
@@ -255,7 +255,9 @@ const api = {
this.forgetToken();
throw new SyncError("AUTH");
}
if (!r.ok) throw new SyncError("REQUEST_FAILED");
// The status travels with the failure: "it would not open on my phone" is a
// different bug at 404 than at 502, and without it both look the same.
if (!r.ok) throw new SyncError("REQUEST_FAILED", `HTTP ${r.status}`);
return r.blob();
},
};
+25 -11
View File
@@ -675,11 +675,27 @@ block a meaning, which only moved the problem — the whole sheet sat hard again
the left edge. Centred, the same leftover space reads as two margins and needs no
explaining.
**Twenty-one lines to the page, and the same twenty-one on every screen.** The
text is laid out on a page of one fixed size — 760 × 1013 units, the drawing's
own proportions — and the whole sheet is then scaled to whatever width the screen
has. Set in screen pixels instead, as it was, a phone and a desktop disagreed
about everything that matters: a 340-pixel sheet held eighteen lines where a
760-pixel one held far more, so the same note broke into different lines on
different pages depending on what you read it on. Now the only thing a narrow
screen changes is how big the page looks. The type size is not chosen but
computed from the page and the line count, so the count survives a change to
either. A desktop therefore shows larger writing on a smaller-looking sheet, the
way a ruled notebook app does.
**The dashboard leads with what you were last writing.** The six most recently
edited notes come first and the shelf of notebooks second, because coming back to
a note is the common case and choosing a notebook is the occasional one. "Recent"
is sorted by time here whatever order the notebooks themselves are in — it is a
question about time, and a hand-made arrangement is the wrong answer to it.
question about time, and a hand-made arrangement is the wrong answer to it. The
time it sorts by is stamped on every write, not only on a rename: the store
behind the sync engine always did that, but the lists on screen read the app's
own copy of the notes, and writing *in* a note used to leave that copy untouched
— so "Recent" showed the order from the last reload.
**Tapping the blank part of a page starts writing there.** The text is only as
tall as what has been written, so most of a fresh page is *beside* the editable
@@ -926,16 +942,14 @@ page is expensive to draw and cheap to hold, so scrolling back up repaints
nothing. Canvases are sized in device pixels and scaled down by CSS, which is
what keeps text sharp on a high-density screen.
**Full screen is two things at once.** The button asks the browser for its own
full screen — which is what puts the document past the address bar and the window
frame, and the reason to want it on a laptop or a tablet — *and* lays the
workspace out as an overlay over the app, which is what actually removes the
sidebar, the note list and the title bar. Either alone is half the job, and a
browser that refuses the first (an iPhone does) still gets the second. Escape
leaves, and so does the button, which stays in the toolbar while full: a way out
that is only known and not visible is not a way out. Leaving the browser's full
screen by any other route takes the overlay with it, or the app would be left
pretending to be full screen inside an ordinary window.
**Full screen means the whole window, not the whole screen.** The workspace is
laid out as an overlay over the app: the document takes the browser window, and
the sidebar, the note list and the title bar go. The browser's own full screen is
deliberately not asked for — it swallows the tabs and the address bar too, which
is more than was wanted; this is about giving the page to the document, not the
screen. Escape leaves, and so does the button, which sits beside the zoom while
full: both answer "how much room does the document get", and a way out that is
only known and not visible is not a way out.
**Zooming holds something still.** A zoom changes how tall the document is, so
the scroll position means something different afterwards — left alone, every step