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);
}