From adf3addf75c79457adcd3d86867d67b3829ee429 Mon Sep 17 00:00:00 2001
From: callumalpass
Date: Tue, 6 Oct 2026 21:28:15 +1100
Subject: [PATCH 1/5] Bring comments to the text: inline composer, margin
marks, hover cards, newness
A comment or suggestion on a selection is written beside the passage
rather than in the sidebar. Commented lines are marked in the editor's
margin, hovering a passage shows who said what, and the active thread's
passage stands out. Threads others wrote in since the manuscript was
last opened here are marked new, and the list filters to new, mine or
one person's. Comment text renders its emphasis, code and links. The
whole-record button says what it is.
---
apps/writer/src/App.tsx | 6 +-
apps/writer/src/editor/Editor.tsx | 23 +++-
apps/writer/src/editor/comments.ts | 131 ++++++++++++++++++++-
apps/writer/src/ui/CommentsPanel.tsx | 70 +++++++++--
apps/writer/src/ui/comments-inline.test.ts | 33 ++++++
apps/writer/src/ui/comments.ts | 32 +++++
6 files changed, 277 insertions(+), 18 deletions(-)
create mode 100644 apps/writer/src/ui/comments-inline.test.ts
diff --git a/apps/writer/src/App.tsx b/apps/writer/src/App.tsx
index ac703685..4c777da1 100644
--- a/apps/writer/src/App.tsx
+++ b/apps/writer/src/App.tsx
@@ -14,7 +14,7 @@ import { useWriterFeedbackContext } from "./FeedbackRoot.js";
import { ConnectGate } from "./ui/ConnectGate.js";
import { CollectionPicker } from "./ui/CollectionPicker.js";
import { Home } from "./ui/Home.js";
-import { localNamespace, recordOpened } from "./ui/recent.js";
+import { localNamespace, recentManuscripts, recordOpened } from "./ui/recent.js";
import { Dialog } from "@mdbase-dev/ui/dialog";
import { zip } from "./export/zip.js";
import { loadThemePreference, saveThemePreference, type ThemePreference } from "@mdbase-dev/ui/theme";
@@ -208,13 +208,15 @@ function Manuscripts({ backend, collectionPicker }: { backend: WriterBackend; co
useWriterFeedbackContext(path ? "manuscript" : "manuscripts", backend.collectionName);
currentWorkspace.current = workspace;
(window as unknown as { writer?: unknown }).writer = { backend, workspace };
+ // When this manuscript was last opened here (read before the opening is recorded, below).
+ const since = useMemo(() => (path ? recentManuscripts(localNamespace(backend)).find((e) => e.path === path)?.at : undefined), [backend, path]);
useEffect(() => {
if (path) recordOpened(localNamespace(backend), path);
}, [backend, path]);
if (!path) return ;
if (!workspace) return null;
return <>
- setPath(null, force)} />
+ setPath(null, force)} />
- {comment.deletedAt ?
Withdrawn.
: comment.text ?
{comment.text}
: null}
+ {comment.deletedAt ?
Withdrawn.
: comment.text ?
: null}
);
}
diff --git a/apps/writer/src/ui/comments-inline.test.ts b/apps/writer/src/ui/comments-inline.test.ts
new file mode 100644
index 00000000..549b970a
--- /dev/null
+++ b/apps/writer/src/ui/comments-inline.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+
+import type { CommentThread } from "@mdbase-writer/core/comments";
+import { inlinePieces, threadIsNew } from "./comments.js";
+
+describe("comment text", () => {
+ it("renders emphasis, code and links, leaving the rest as written", () => {
+ expect(inlinePieces("Is this *Lyell*'s phrase? See `[^note]` and [the book](https://example.org/x), or https://example.org/y.")).toEqual([
+ { kind: "text", text: "Is this " },
+ { kind: "em", text: "Lyell" },
+ { kind: "text", text: "'s phrase? See " },
+ { kind: "code", text: "[^note]" },
+ { kind: "text", text: " and " },
+ { kind: "link", text: "the book", href: "https://example.org/x" },
+ { kind: "text", text: ", or " },
+ { kind: "link", text: "https://example.org/y", href: "https://example.org/y" },
+ { kind: "text", text: "." },
+ ]);
+ expect(inlinePieces("snake_case_name stays")).toEqual([{ kind: "text", text: "snake_case_name stays" }]);
+ });
+});
+
+const comment = (createdAt: string, createdBy?: string) => ({ path: `c/${createdAt}.md`, document: "[[a]]", motivation: "commenting" as const, status: "open" as const, createdAt, text: "x", ...(createdBy ? { createdBy } : {}) });
+
+describe("what is new since the last visit", () => {
+ const thread: CommentThread = { root: comment("2026-10-01T00:00:00Z", "[[people/sam]]"), replies: [comment("2026-10-05T00:00:00Z", "[[people/jordan]]")] };
+ it("marks a thread someone else wrote in after the last visit, but not one's own writing", () => {
+ expect(threadIsNew(thread, "2026-10-03T00:00:00Z", "[[people/sam]]")).toBe(true);
+ expect(threadIsNew(thread, "2026-10-03T00:00:00Z", "[[people/jordan]]")).toBe(false);
+ expect(threadIsNew(thread, "2026-10-06T00:00:00Z", undefined)).toBe(false);
+ expect(threadIsNew(thread, undefined, undefined)).toBe(false);
+ });
+});
diff --git a/apps/writer/src/ui/comments.ts b/apps/writer/src/ui/comments.ts
index 890b5dc9..5fc57b8a 100644
--- a/apps/writer/src/ui/comments.ts
+++ b/apps/writer/src/ui/comments.ts
@@ -138,3 +138,35 @@ export function when(iso: string, now = Date.now()): string {
if (abs < 7 * 86_400) return relative.format(Math.round(seconds / 86_400), "day");
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
}
+
+/** A run of comment text as it reads: plain, emphasised, code, or a link. */
+export type InlinePiece = { readonly kind: "text" | "em" | "strong" | "code"; readonly text: string } | { readonly kind: "link"; readonly text: string; readonly href: string };
+
+/**
+ * The light Markdown a comment uses (emphasis, code, links), as pieces to
+ * render; anything else stays as written. Only http(s) links are links.
+ */
+export function inlinePieces(text: string): InlinePiece[] {
+ const out: InlinePiece[] = [];
+ const pattern = /`([^`\n]+)`|\*\*(?=\S)([^\n]*?\S)\*\*|\*(?=\S)([^*\n]*?\S)\*|(?")\]]+[^\s<>")\].,;:!?])/gu;
+ let last = 0;
+ for (const m of text.matchAll(pattern)) {
+ const at = m.index ?? 0;
+ if (at > last) out.push({ kind: "text", text: text.slice(last, at) });
+ if (m[1] !== undefined) out.push({ kind: "code", text: m[1] });
+ else if (m[2] !== undefined) out.push({ kind: "strong", text: m[2] });
+ else if (m[3] !== undefined || m[4] !== undefined) out.push({ kind: "em", text: (m[3] ?? m[4]) as string });
+ else if (m[5] !== undefined && m[6] !== undefined) out.push({ kind: "link", text: m[5], href: m[6] });
+ else if (m[7] !== undefined) out.push({ kind: "link", text: m[7], href: m[7] });
+ last = at + m[0].length;
+ }
+ if (last < text.length) out.push({ kind: "text", text: text.slice(last) });
+ return out;
+}
+
+/** Whether anyone else wrote in the thread after `since` (a thread of one's own is never new). */
+export function threadIsNew(thread: CommentThread, since: string | undefined, me: string | undefined): boolean {
+ if (!since) return false;
+ const mine = (c: CommentRecord) => Boolean(me && c.createdBy && c.createdBy.toLowerCase() === me.toLowerCase());
+ return [thread.root, ...thread.replies].some((c) => !c.deletedAt && !mine(c) && c.createdAt > since);
+}
From 8db249a6fa2588af4ae461f515f560acb0523250 Mon Sep 17 00:00:00 2001
From: callumalpass
Date: Tue, 6 Oct 2026 21:28:15 +1100
Subject: [PATCH 2/5] Sources: most recently written first, cited-in sections,
highlight text search
Library sources list by the collection's modification time. An expanded
source names the sections that cite it, each a link. A search of three
letters or more, once typing pauses, also reads every source's Reader
highlights and lists the ones whose text matches.
---
apps/writer/src/backend/collection.ts | 5 +-
apps/writer/src/backend/connect.test.ts | 3 +-
apps/writer/src/backend/types.ts | 6 +-
apps/writer/src/editor/library-search.ts | 8 +-
apps/writer/src/ui/SourcesPanel.tsx | 121 ++++++++++++++++++++++-
5 files changed, 133 insertions(+), 10 deletions(-)
diff --git a/apps/writer/src/backend/collection.ts b/apps/writer/src/backend/collection.ts
index ffe9de22..46d81215 100644
--- a/apps/writer/src/backend/collection.ts
+++ b/apps/writer/src/backend/collection.ts
@@ -115,7 +115,7 @@ export class CollectionSchema {
let modified = false;
for (const role of this.byType.values()) for (const [domain, binding] of role) {
if (!binding.active) continue;
- modified ||= domain === "manuscript";
+ modified ||= domain === "manuscript" || domain === "source";
for (const field of needed[domain]) fields.add(binding.fields[field] ?? field);
}
return [
@@ -172,7 +172,8 @@ export class CollectionStore {
if (!binding.active) continue;
switch (domain) {
case "source": {
- const source = retainEffective && previous.source ? previous.source : libraryEntry(row.path, fieldsOf(effective, binding));
+ const written = row.values?.["file_mtime"] ?? row.file?.mtime;
+ const source = retainEffective && previous.source ? previous.source : libraryEntry(row.path, fieldsOf(effective, binding), typeof written === "string" ? written : undefined);
if (source) next.source = equal(previous?.source, source) ? previous!.source! : source;
break;
}
diff --git a/apps/writer/src/backend/connect.test.ts b/apps/writer/src/backend/connect.test.ts
index cd83ca13..7493c8cb 100644
--- a/apps/writer/src/backend/connect.test.ts
+++ b/apps/writer/src/backend/connect.test.ts
@@ -98,7 +98,7 @@ describe("annotation loading", () => {
expect(await f.backend.annotationPaths()).toEqual({ ok: true, value: f.rows.map((r) => r.path) });
expect(f.metadataQueries()).toEqual([{ types: ["highlight"], frontmatterMode: "persisted", output: "metadata",
select: [{ name: "csl", expression: 'record["csl"]' }, { name: "title", expression: 'record["title"]' },
- { name: "reading", expression: 'record["reading"]' }] }]);
+ { name: "reading", expression: 'record["reading"]' }, { name: "file_mtime", expression: "file.mtime" }] }]);
const annotations = await f.backend.annotationsForSource("sources/a.md");
expect(annotations).toMatchObject({ ok: true, value: [{ quote: "Quote A", locator: "p. 12" }, { quote: "Another A" }] });
expect(f.bodyQueries()).toEqual([{ types: ["highlight"], where: 'file.path in ["annotations/a.md","annotations/c.md"]', frontmatterMode: "persisted", includeBody: true }]);
@@ -155,6 +155,7 @@ describe("annotation loading", () => {
expect(discovery?.select).toEqual([
{ name: "csl", expression: 'record["csl"]' }, { name: "title", expression: 'record["title"]' },
{ name: "about", expression: 'record["about"]' }, { name: "date", expression: 'record["date"]' }, { name: "parent", expression: 'record["parent"]' },
+ { name: "file_mtime", expression: "file.mtime" },
]);
expect(f.bodyQueries()).toEqual([{ types: ["feedback"], where: 'file.path in ["comments/a.md","comments/reply.md"]', frontmatterMode: "persisted", includeBody: true }]);
expect(f.readRequests).not.toHaveBeenCalled();
diff --git a/apps/writer/src/backend/types.ts b/apps/writer/src/backend/types.ts
index 68922c56..e8382819 100644
--- a/apps/writer/src/backend/types.ts
+++ b/apps/writer/src/backend/types.ts
@@ -42,6 +42,8 @@ export interface LibraryEntry {
readonly item: CslItem;
readonly title: string;
readonly path: string;
+ /** When the source record was last written (ISO 8601), when the collection says. */
+ readonly modified?: string;
}
export interface CollectionIndex {
@@ -142,14 +144,14 @@ export const manuscriptBody = (starter = false, citekey?: string): string => sta
: "# Introduction {#sec-intro}\n\n";
/** The citekey of a CSL-JSON item in a Reader source's `csl` field. */
-export function libraryEntry(path: string, frontmatter: JsonObject | undefined): LibraryEntry | null {
+export function libraryEntry(path: string, frontmatter: JsonObject | undefined, modified?: string): LibraryEntry | null {
const csl = frontmatter?.["csl"];
if (!csl || typeof csl !== "object" || Array.isArray(csl)) return null;
const item = csl as Record;
const key = typeof item["id"] === "string" ? item["id"] : null;
if (!key) return null;
const title = typeof item["title"] === "string" ? item["title"] : typeof frontmatter?.["title"] === "string" ? frontmatter["title"] : key;
- return { key, item: { ...item, id: key }, title, path };
+ return { key, item: { ...item, id: key }, title, path, ...(modified ? { modified } : {}) };
}
/** `chapters/x.md` numbered for the nth try: `chapters/x-2.md` (and `figures/x.png` to `figures/x-2.png`). */
diff --git a/apps/writer/src/editor/library-search.ts b/apps/writer/src/editor/library-search.ts
index 561acede..3eaea4cf 100644
--- a/apps/writer/src/editor/library-search.ts
+++ b/apps/writer/src/editor/library-search.ts
@@ -60,9 +60,15 @@ export function authorYear(entry: LibraryEntry): string {
*/
const collator = new Intl.Collator();
const sorted = new WeakMap();
+/** The library as it is listed: most recently written first; sources the collection has no date for follow, by author and title. */
export function sortedLibrary(library: readonly LibraryEntry[]): readonly LibraryEntry[] {
let rows = sorted.get(library);
- if (!rows) { rows = [...index(library)].sort((a, b) => collator.compare(a.sortKey, b.sortKey) || collator.compare(a.entry.title, b.entry.title)).map((i) => i.entry); sorted.set(library, rows); }
+ if (!rows) {
+ rows = [...index(library)]
+ .sort((a, b) => (b.entry.modified ?? "").localeCompare(a.entry.modified ?? "") || collator.compare(a.sortKey, b.sortKey) || collator.compare(a.entry.title, b.entry.title))
+ .map((i) => i.entry);
+ sorted.set(library, rows);
+ }
return rows;
}
diff --git a/apps/writer/src/ui/SourcesPanel.tsx b/apps/writer/src/ui/SourcesPanel.tsx
index 7413dd1c..b97f2332 100644
--- a/apps/writer/src/ui/SourcesPanel.tsx
+++ b/apps/writer/src/ui/SourcesPanel.tsx
@@ -11,6 +11,18 @@ import { authorYear, searchLibrary, sortedLibrary } from "../editor/library-sear
import { readerSourceHref } from "../apps.js";
import { useRowWindow } from "./paging.js";
import { ChevronLeft, ChevronRight, ExternalIcon, SearchIcon } from "./icons.js";
+import { clipPassage } from "./comments.js";
+
+/** Where a source is cited: a place in the text, named by its section. */
+export interface CitationSite {
+ readonly record: string;
+ readonly offset: number;
+ readonly name: string;
+}
+
+/** A query this long also searches the text of highlights, once typing has paused this long. */
+const HIGHLIGHT_SEARCH_FROM = 3;
+const HIGHLIGHT_SEARCH_AFTER_MS = 700;
/** Library sources listed before "Show all". */
const LIBRARY_PREVIEW = 20;
@@ -57,7 +69,12 @@ export function SourcesPanel({
onRetry: retryLibrary,
notSetUp = false,
annotationsNotSetUp = false,
+ sitesFor,
+ onJump,
}: {
+ /** Where a source is cited, by section, for an expanded row. */
+ sitesFor?(key: string): readonly CitationSite[];
+ onJump?(record: string, offset: number): void;
annotationsNotSetUp?: boolean;
problem?: string | undefined;
onRetry?(): void;
@@ -93,6 +110,29 @@ export function SourcesPanel({
const sorted = useMemo(() => sortedLibrary(library), [library]);
const rest = useMemo(() => sorted.filter((e) => !cited.has(e.key)), [sorted, cited]);
const results = useMemo(() => (query.trim() ? searchLibrary(library, query, 60) : []), [library, query]);
+ const highlightSearch = query.trim().length >= HIGHLIGHT_SEARCH_FROM;
+ // Highlights are read once typing has paused, not on every keystroke.
+ const [settled, setSettled] = useState(false);
+ useEffect(() => {
+ if (!highlightSearch) { setSettled(false); return; }
+ const t = setTimeout(() => setSettled(true), HIGHLIGHT_SEARCH_AFTER_MS);
+ return () => clearTimeout(t);
+ }, [highlightSearch, query]);
+ const highlightIndex = useHighlightIndex(library, loadAnnotations, annotationVersion, highlightSearch && settled && !annotationsNotSetUp);
+ const inHighlights = useMemo(() => {
+ if (!highlightSearch) return [];
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
+ const found = new Set(results.map((e) => e.key));
+ const out: { entry: LibraryEntry; annotation: SourceAnnotation }[] = [];
+ for (const entry of library) {
+ if (found.has(entry.key)) continue;
+ const notes = highlightIndex.loaded.get(entry.path);
+ const hit = notes?.find((a) => { const text = `${a.quote ?? ""} ${a.note ?? ""}`.toLowerCase(); return words.every((w) => text.includes(w)); });
+ if (hit) out.push({ entry, annotation: hit });
+ if (out.length >= 30) break;
+ }
+ return out;
+ }, [highlightSearch, query, results, library, highlightIndex.loaded]);
const rowFor = (key: string) => panel.current?.querySelector(`.source-row[data-key="${CSS.escape(key)}"]`);
@@ -200,6 +240,8 @@ export function SourcesPanel({
onStep={onStepCitation}
href={sourceHref?.(entry)}
annotationsNotSetUp={annotationsNotSetUp}
+ sites={open === entry.key ? sitesFor?.(entry.key) ?? [] : NO_SITES}
+ {...(onJump ? { onJump } : {})}
/>
);
const shownRest = rest.slice(libraryWindow.from, libraryWindow.from + libraryWindow.size);
@@ -224,10 +266,31 @@ export function SourcesPanel({
{libraryProblem &&
;
+}
+
function ProblemsButton({ diagnostics, pending, checking, open, setOpen }: { diagnostics: readonly WriterDiagnostic[]; pending: boolean; checking: boolean; open: boolean; setOpen(open: boolean): void }) {
if (!diagnostics.length) {
const label = pending ? "Dependencies not checked" : checking ? "Checking…" : "No problems";
@@ -1398,6 +1581,9 @@ const SHORTCUTS: readonly [string, string][] = [
[`${MOD_LABEL} Shift \\`, "Editor and preview → editor only → preview only"],
[`${MOD_LABEL} Shift F`, "Search the whole manuscript"],
[`${MOD_LABEL} Shift E`, "Find a source"],
+ [`${MOD_LABEL} Shift M`, "Show comments"],
+ ["F7 / Shift F7", "Next / previous comment"],
+ ["F2", "Rename the chapter chosen in the outline"],
[`${MOD_LABEL} ,`, "Manuscript settings"],
[`${MOD_LABEL} B / ${MOD_LABEL} I`, "Bold / italic (again to remove it)"],
[`${MOD_LABEL} Shift K`, "Make the selection a link"],
diff --git a/apps/writer/src/ui/layout.test.ts b/apps/writer/src/ui/layout.test.ts
index 1b5d67ae..a1259d49 100644
--- a/apps/writer/src/ui/layout.test.ts
+++ b/apps/writer/src/ui/layout.test.ts
@@ -6,7 +6,9 @@ describe("gridFor", () => {
it("gives the sidebar its width, within bounds", () => {
expect(gridFor({ ...DEFAULT_LAYOUT, sidebarWidth: 320 }).columns).toMatch(/^320px /);
expect(gridFor({ ...DEFAULT_LAYOUT, sidebarWidth: 90 }).columns).toMatch(/^200px /);
- expect(gridFor({ ...DEFAULT_LAYOUT, sidebar: false }).areas).toBe(`"write divider preview"`);
+ // Hidden, the sidebar leaves a rail of its tabs.
+ expect(gridFor({ ...DEFAULT_LAYOUT, sidebar: false }).areas).toBe(`"rail write divider preview"`);
+ expect(gridFor({ ...DEFAULT_LAYOUT, sidebar: false }).columns.startsWith("44px")).toBe(true);
});
it("rounds and bounds a dragged width", () => {
expect(clampSidebar(333.6)).toBe(334);
diff --git a/apps/writer/src/ui/layout.ts b/apps/writer/src/ui/layout.ts
index 69f07aa5..24ec6217 100644
--- a/apps/writer/src/ui/layout.ts
+++ b/apps/writer/src/ui/layout.ts
@@ -6,6 +6,8 @@ export type SidebarTab = "outline" | "sources" | "comments";
export interface Layout {
readonly sidebar: boolean;
readonly tab: SidebarTab;
+ /** A second panel shown under the first, or none. */
+ readonly lowerTab: SidebarTab | null;
/** The sidebar's width in CSS pixels. */
readonly sidebarWidth: number;
readonly view: View;
@@ -19,7 +21,7 @@ export interface Layout {
}
const KEY = "mdbase-writer:layout";
-export const DEFAULT_LAYOUT: Layout = { sidebar: true, tab: "outline", sidebarWidth: 272, view: "both", split: 0.5, zoom: "fit", joinLines: true, livePreview: true };
+export const DEFAULT_LAYOUT: Layout = { sidebar: true, tab: "outline", lowerTab: null, sidebarWidth: 272, view: "both", split: 0.5, zoom: "fit", joinLines: true, livePreview: true };
export const ZOOM_STEPS = [0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2] as const;
export const SIDEBAR_MIN = 200;
@@ -33,6 +35,7 @@ export function loadLayout(): Layout {
return {
sidebar: typeof raw.sidebar === "boolean" ? raw.sidebar : DEFAULT_LAYOUT.sidebar,
tab: raw.tab === "sources" || raw.tab === "comments" ? raw.tab : "outline",
+ lowerTab: raw.lowerTab === "outline" || raw.lowerTab === "sources" || raw.lowerTab === "comments" ? raw.lowerTab : null,
sidebarWidth: typeof raw.sidebarWidth === "number" ? clampSidebar(raw.sidebarWidth) : DEFAULT_LAYOUT.sidebarWidth,
view: raw.view === "write" || raw.view === "preview" || raw.view === "both" ? raw.view : DEFAULT_LAYOUT.view,
split: typeof raw.split === "number" ? clampSplit(raw.split) : DEFAULT_LAYOUT.split,
@@ -56,7 +59,8 @@ export function saveLayout(layout: Layout): void {
/** Grid columns and areas for the desktop workspace. */
export function gridFor(layout: Layout): { columns: string; areas: string } {
const cols: [string, string][] = [];
- if (layout.sidebar) cols.push([`${clampSidebar(layout.sidebarWidth)}px`, "outline"]);
+ // Hidden, the sidebar leaves a rail of its tabs.
+ cols.push(layout.sidebar ? [`${clampSidebar(layout.sidebarWidth)}px`, "outline"] : ["44px", "rail"]);
if (layout.view !== "preview") cols.push([`minmax(0, ${layout.view === "both" ? layout.split : 1}fr)`, "write"]);
if (layout.view === "both") cols.push(["7px", "divider"]);
if (layout.view !== "write") cols.push([`minmax(0, ${layout.view === "both" ? 1 - layout.split : 1}fr)`, "preview"]);
From d546ebabb5999626cfad0b0ddf8c4d6dce363218 Mon Sep 17 00:00:00 2001
From: callumalpass
Date: Tue, 6 Oct 2026 21:28:15 +1100
Subject: [PATCH 5/5] Document the comment, source and sidebar changes; test
the phone tab's new name
---
README.md | 23 ++++++++++++++++++++---
apps/writer/scripts/browser-test.mjs | 2 +-
2 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 0390dbcf..751a1e51 100644
--- a/README.md
+++ b/README.md
@@ -95,8 +95,10 @@ does the same.
### Sources and citations
Open **Sources** to search your Reader library and insert a citation, optionally with a page
-number. Sources already cited in the manuscript appear first. You can also type `@` or `[@`
-to see completion suggestions by citekey, author, title, or year.
+number. Sources already cited in the manuscript appear first, the rest most recently written
+first. A search of three letters or more also looks through the text of your Reader highlights.
+An expanded source lists the sections that cite it. You can also type `@` or `[@` to see
+completion suggestions by citekey, author, title, or year.
Each source needs citation metadata and a citekey. Manage those in [mdbase Reader](https://reader.mdbase.dev)
in the same collection. Reader highlights can be inserted as quotations with their source citation
@@ -118,11 +120,24 @@ To include another Markdown record, put its embed on a line of its own:
Writer assembles these records in order. Citations, footnotes, and cross-references work across
the manuscript. Chapter records do not need a special manuscript type.
-Use **Outline** to open or add chapters. Drag chapters, or use **Alt+↑/↓**, to reorder them.
+Use **Outline** to open or add chapters. Drag chapters, or use **Alt+↑/↓**, to reorder them;
+double-click one (or press **F2**) to rename it. A chapter's headings fold away unless it is
+the one being written. Sections with open comments or problems show a small count or dot
+beside their word count.
Chapter cards in the editor show their title, word count, and problems, with a button to open them.
## Navigate and review
+Select a passage and press **Ctrl/⌘+Alt+M** to comment on it, or **Ctrl/⌘+Alt+S** to suggest an
+edit: the comment is written beside the passage. Commented lines are marked in the editor's
+margin, hovering a passage shows who said what, and **F7** steps to the next comment. The
+**Comments** panel lists every thread, with what others wrote since you last opened the
+manuscript marked as new, and can be filtered to one person's comments or your own.
+
+The sidebar's tabs can be opened side by side: the split button beside them shows a second
+panel beneath, so the outline and comments can be read together. Hiding the sidebar leaves a
+rail of its tabs.
+
Click a block in the preview to jump to the corresponding text. The preview also follows the
editor's cursor. **Problems** in the top bar lists unknown citekeys, missing labels, unavailable
images or records, and typesetting errors under the editor, each with its record and line; the
@@ -140,6 +155,8 @@ Useful shortcuts (**⌘** on macOS, **Ctrl** elsewhere):
| -------------- | ------------------------ |
| Ctrl/⌘+K | Open the command palette |
| Ctrl/⌘+Shift+E | Find a source |
+| Ctrl/⌘+Shift+M | Show comments |
+| F7 | Go to the next comment |
| Ctrl/⌘+, | Open manuscript settings |
| Ctrl/⌘+Shift+S | Choose an export format |
| F8 | Go to the next problem |
diff --git a/apps/writer/scripts/browser-test.mjs b/apps/writer/scripts/browser-test.mjs
index fa312c29..a1fa0b4d 100644
--- a/apps/writer/scripts/browser-test.mjs
+++ b/apps/writer/scripts/browser-test.mjs
@@ -600,7 +600,7 @@ await step("the phone layout switches between write, preview and outline", async
await page.locator(".preview-pane").waitFor({ state: "visible" });
assert.equal(await page.locator(".write").isVisible(), false);
await page.screenshot({ path: "out/e2e-mobile-preview.png" });
- await page.getByRole("button", { name: "Outline", exact: true }).click();
+ await page.getByRole("button", { name: "Sidebar", exact: true }).click();
await page.locator(".outline").waitFor({ state: "visible" });
await page.screenshot({ path: "out/e2e-mobile-outline.png" });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);