Marks, lines, and a table that breaks

A long table lay straight across the gap between two sheets with a row
cut in half, and a shorter one jumped to the next sheet whole leaving
the rest of the first blank. Tables are broken between their rows now,
which is where a table breaks on paper. The spacer there is a tr with
one borderless cell: a div inside a tbody is not something the browser
keeps. Its own spacer rows are subtracted again on the next measurement
and counted into the shift every block below inherits — miss that and
everything under a broken table is measured a page too high.

A dialog closed in the middle of an edit if a selection was dragged past
its edge: the click lands on the nearest common ancestor of press and
release, which is the backdrop, and every dialog read that as "clicked
beside me". They remember where the press began now. All ten.

A note and a chapter can wear a mark of their own beside the name,
chosen from a short list searched by what a thing is for rather than
from the system emoji keyboard — a different thing on every machine, and
three thousand pictures for a decision that is "which of these is the
invoice notebook". A separator takes its own colour and one of five
shapes, and a chapter can be divided like the notebook it sits in: both
places a chapter is shown draw it with one component now, so it cannot
be divisible in the column and not in the unfolded block.

Also: a chapter's colour could not be cleared, because the update rode
on COALESCE and null there means "leave it alone". A table's header row
is legible on the dark theme. Three entries in the table's own menu were
still German. Opening a notebook opens its first note, on a wide screen
only — on a phone the list is the screen.

Fourteen suites green: 364 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-16 23:23:16 +02:00
1 parent 0c312e53c2
commit f1d3e38f8f
29 files changed
+1335 -149

No files matched your search

@@ -146,6 +146,7 @@ export function EditorPane({
linkProps,
onBack,
onUpdate,
onPickIcon,
onToggleStar,
onTrash,
onRestore,
@@ -192,6 +193,31 @@ export function EditorPane({
}}
>
{isMobile && <BackButton T={T} onClick={onBack} />}
{/* The note's own mark, left of its name — where the eye goes first and
where the same mark sits in the list. Pressing it opens the picker. */}
<button
onClick={onPickIcon}
data-note-icon={note.icon || "none"}
title="Choose an icon for this note"
aria-label="Choose an icon for this note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
flexShrink: 0,
borderRadius: 9,
border: "none",
background: "transparent",
cursor: "pointer",
fontSize: note.icon ? 17 : 14,
lineHeight: 1,
color: note.iconColor || T.faint,
}}
>
{note.icon || I.note}
</button>
<input
type="text"
aria-label="Note title"
+3 -1
View File
@@ -2,12 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import * as sync from "../../syncEngine";
import { VersionPreview } from "../../editor/VersionPreview.jsx";
import { I } from "../../ui/icons.jsx";
import { useBackdropClose } from "../../ui/backdrop";
/* ═══════ VERSION HISTORY ═══════ */
/* Past states of a note, newest first. The server keeps a snapshot of what a
* note looked like before each editing session, so an overwrite that is only
* noticed later can still be undone. */
export function HistoryModal({ noteId, noteTitle, dark, onClose, onRestored, renderDrawing }) {
const backdrop = useBackdropClose(onClose);
const [versions, setVersions] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [detail, setDetail] = useState(null);
@@ -110,7 +112,7 @@ export function HistoryModal({ noteId, noteTitle, dark, onClose, onRestored, ren
zIndex: 2000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
onClick={(e) => e.stopPropagation()}
+192 -10
View File
@@ -76,6 +76,40 @@ const MIDDLE_MIN = 54;
/** Widest, so a long name cannot squeeze the rules away to nothing. */
const MIDDLE_MAX = 170;
/**
* How a separator's rule is drawn.
*
* Built as a border rather than a background, because that is the only way to
* get dashes and dots without an image — and `double` needs the height to carry
* two lines and the gap between them, or the browser draws one.
*/
const RULE_STYLES = {
solid: { height: 2, borderRadius: 2, solid: true },
dashed: { height: 0, borderTop: "2px dashed" },
dotted: { height: 0, borderTop: "3px dotted" },
double: { height: 0, borderTop: "4px double" },
thick: { height: 4, borderRadius: 2, solid: true },
};
/** One half of the line, left or right of the label. */
function Rule({ colour, style }) {
const shape = RULE_STYLES[style] || RULE_STYLES.solid;
return (
<div
data-separator-rule={style || "solid"}
style={{
flex: 1,
minWidth: 8,
height: shape.height,
borderRadius: shape.borderRadius,
...(shape.solid
? { background: colour }
: { borderTop: `${shape.borderTop} ${colour}` }),
}}
/>
);
}
/**
* A line drawn between notes, with an optional name.
*
@@ -89,6 +123,9 @@ const MIDDLE_MAX = 170;
*/
function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu, grip }) {
const [draft, setDraft] = useState(separator.label);
// Its own colour when it has been given one; the list's faint grey otherwise,
// which is what every separator looked like before there was a choice.
const colour = separator.color || T.faint;
useEffect(() => {
if (editing) setDraft(separator.label);
@@ -115,11 +152,8 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
}}
>
{grip}
{/* The two rules always share what is left over equally, and the middle
keeps one width whether it is showing a label or an input. Sizing it to
its contents made the whole row jump sideways the moment renaming
started, which is exactly when the eye is on it. */}
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
{/* The two rules always share what is left over equally. */}
<Rule colour={colour} style={separator.style} />
{/* Sized to the name it holds, between a floor and a ceiling. A fixed slot
kept the row from jumping when renaming began, at the cost of a short
name floating in the middle of a gap it did not fill. */}
@@ -173,7 +207,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
width: "100%",
border: "1px solid transparent",
background: "transparent",
color: separator.label ? T.text : T.muted,
color: separator.label ? separator.color || T.text : T.muted,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
@@ -189,7 +223,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
</div>
)}
</div>
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
<Rule colour={colour} style={separator.style} />
</div>
);
}
@@ -224,7 +258,19 @@ function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
>
{/* Its own colour, so a shelf of chapters can be told apart at a glance
— the point of having them is that they are different places. */}
<span style={{ display: "flex", color: chapter.color || T.accent, flexShrink: 0 }}>{I.books}</span>
<span
data-chapter-mark
style={{
display: "flex",
alignItems: "center",
color: chapter.color || T.accent,
flexShrink: 0,
fontSize: chapter.icon ? 15 : undefined,
lineHeight: 1,
}}
>
{chapter.icon || I.books}
</span>
<span
style={{
flex: 1,
@@ -245,6 +291,129 @@ function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
);
}
/**
* What is inside a chapter: its notes, with its own lines drawn between them.
*
* One component for both places a chapter can be shown — the column beside the
* list and the unfolded block under its row — because they are the same list in
* two frames, and a chapter that could be divided in one and not the other would
* be two different things wearing one name.
*
* A chapter's separators are its own. Each carries the list it was drawn in, so
* the notebook's lines stay in the notebook and these stay here.
*/
export function ChapterNotes({
T,
notes,
separators,
reorderable,
activeNote,
renamingSeparator,
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
onAddSeparator,
onSelectNote,
onNoteContextMenu,
compact = false,
}) {
// Lines only mean something where the order is the user's own, exactly as in
// the notebook's list — in "newest first" the notes slide past them.
const entries = reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry);
return (
<>
{entries.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{entries.map((entry) =>
entry.kind === "separator" ? (
<SeparatorRow
key={entry.id}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
onStartRename={onStartRenameSeparator}
onRename={onRenameSeparator}
onContextMenu={onSeparatorContextMenu}
grip={null}
/>
) : (
<button
key={entry.id}
data-chapter-note={entry.id}
onClick={() => onSelectNote(entry.id)}
onContextMenu={(e) => onNoteContextMenu(e, entry.note)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: compact ? "8px 10px" : "9px 10px",
borderRadius: compact ? 8 : 9,
border: `1px solid ${activeNote === entry.id ? T.accent + "55" : "transparent"}`,
background: activeNote === entry.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span
data-note-mark
style={{
display: "flex",
alignItems: "center",
color: entry.note.iconColor || T.faint,
flexShrink: 0,
fontSize: entry.note.icon ? 14 : undefined,
lineHeight: 1,
}}
>
{entry.note.icon || (entry.note.type === "pdf" ? I.pdf : I.note)}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entry.note.title}
</span>
</button>
)
)}
{/* A chapter can be divided like the notebook it sits in. Only where a
line can be seen, same as in the list outside. */}
{reorderable && onAddSeparator && (
<button
onClick={onAddSeparator}
data-add-chapter-separator
title="Insert a separator line in this chapter"
style={{
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
borderRadius: 8,
padding: "4px 9px",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
marginTop: 6,
}}
>
+ Separator
</button>
)}
</>
);
}
/**
* How this notebook's notes are ordered.
*
@@ -868,8 +1037,21 @@ export function NoteList({
width: "100%",
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{note.trashed ? I.trash : note.type === "pdf" ? I.pdf : I.note}
{/* Its own mark when it has one, otherwise the kind of
thing it is. The same glyph the editor shows, so a note
is recognised by the same sign in both places. */}
<span
data-note-mark
style={{
display: "flex",
alignItems: "center",
color: note.iconColor || T.faint,
flexShrink: 0,
fontSize: note.icon ? 14 : undefined,
lineHeight: 1,
}}
>
{note.trashed ? I.trash : note.icon || (note.type === "pdf" ? I.pdf : I.note)}
</span>
<span
data-note-title