tandem-editor 0.13.0 → 0.13.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,94 @@ All notable changes to Tandem will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.13.6] - 2026-06-04
11
+
12
+ ### Added
13
+
14
+ - **Native context menu on annotation cards (#923, Phase 3)** — right-clicking an annotation card — in the right rail *and* the margin view (including collapsed stub pips) — now opens a native OS menu instead of the bare WebView default. It's a faster path to the actions that already exist as card buttons, grouped with collapsing separators: **Accept** / **Dismiss** (non-user, pending), **Reply…** (note + comment — opens the in-card composer), **Edit…** (opens the in-card editor), **Send to Claude** (user notes), **Copy text** (the annotation body → clipboard), and **Archive** (note) / **Remove** (highlight / comment). Built on the Phase 1/2 plumbing with zero new IPC machinery: the request crossing the Tauri boundary is **booleans only** — the annotation id never leaves the webview (it's captured in a module-level gesture singleton) — and Rust emits one of a closed `ctx:annotation:*` id set back through the same shared `forward_context_menu_event`. The emitted id is **re-validated against the live annotation** before dispatch (the modal popup can be held open across a status change), and the gating predicates are **extracted and shared with the in-card buttons** so the menu and the buttons can't drift. As part of this, the **Edit affordance is corrected to user-authored annotations only** (`author === "user"`) on both the menu and the existing button — a user shouldn't rewrite Claude's or an imported comment's text (`tandem_editAnnotation` is Claude's own path); the spurious Reply button on highlights is likewise gone. Because the rail and margin can be open simultaneously, the `context-menu-action` listener is **shared and refcounted** (idempotent teardown so a panel unmount can't dead-key the survivor). Browser (npm-install) mode keeps its native WebView menu. ADR-027 is unchanged — the note-privacy boundary is server/MCP/channel, so replying to your own note (or copying its text to your own clipboard) is fine. No `src/server/` changes, no new testids (native menu); a new `data-annotation-id` attribute on the card root is the delegation hook. New: `src/client/panels/annotation-context-menu.ts` (pure predicates + request builder) and `annotation-context-menu-host.ts` (gesture singleton + refcounted listener + shared `runAnnotationAction`); `show_annotation_context_menu` + `build_annotation_context_menu_spec` in `src-tauri/src/lib.rs`. Plan reviewed pre-code by four adversarial agents (svelte-migration, annotation-model, crdt, security) — which caught the refcount-underflow dead-menu, the snapshot-vs-live-state dispatch, the import-note gating, and the gateless-remove re-validation before any code. The native popup needs a manual desktop pass (not E2E-automatable).
15
+ - **Imported Word reply threads & private note replies (#1000)** — Word reviewer comment *threads* now import as real reply threads: the root comment becomes a note (as before) and its replies attach to that note as a reply thread, instead of being flattened into separate stray notes or dropped. More broadly, notes can now carry reply threads at all — you can reply to your own notes, and imported Word replies show the original reviewer's name as a byline. **These note/import replies are user-private and never sent to Claude**, mirroring how note bodies are already private (ADR-027): privacy is stamped on each reply at creation (`AnnotationReply.private`), so promoting a note to a comment ("Send to Claude") surfaces the note body but keeps the pre-promotion thread local — it is never back-published. Word threading is reconstructed from `commentsExtended.xml` (`w15:paraIdParent`), is idempotent across re-imports, and degrades to the old flat behavior for documents without threading metadata. Untrusted imported reply bodies and author names are length-bounded, and the thread walk is cycle/depth-guarded.
16
+ - **`tandem_appendContent` MCP tool + seedable scratchpads (#979)** — agents can now author **structured, multi-paragraph content** through MCP. Previously `tandem_edit` was single-paragraph only (newlines landed as literal text) and a fresh empty document/scratchpad had no addressable range, so `tandem_edit {from:0,to:0}` returned `INVALID_RANGE` — there was no way to seed initial content at all. The new `tandem_appendContent({ content, documentId? })` parses markdown and **appends real block structure** (headings, lists, blank-line-separated paragraphs) to the end of the document by reusing the existing `mdast→Y.Doc` pipeline (new `appendMdast` helper, factored out of `mdastToYDoc` so the two share one two-pass build). It is **non-destructive by design** (per scope decision): appending at the end shifts no existing flat offsets, so existing annotations and authorship ranges stay valid and nothing is cleared — deletion stays with `tandem_edit` / `tandem_open({force:true})`. Appended text is attributed to Claude via authorship (mirroring `tandem_edit`); `stampClaudeAuthorshipWholeDoc` gained an optional `startIndex` that stamps **only** the new blocks while keeping absolute flat offsets (a flatCursor-priming bug here was caught by pre-code CRDT review). `tandem_scratchpad` now accepts an optional `content` to seed a new scratchpad with structure in one call, and `tandem_edit` on an empty document now returns a clear `EMPTY_DOCUMENT` error pointing at the seeding path instead of a generic `INVALID_RANGE`. v1 is **markdown-only** (read-only `.docx` and non-`md` docs are rejected with `FORMAT_ERROR`; a 1 MB inline cap returns `FILE_TOO_LARGE`); `.txt`/plaintext append and arbitrary mid-document positional insertion are deferred as follow-ups. Tool count is now 30 (27 active). Plan reviewed pre-code by crdt + annotation-model + scope agents (which caught the authorship-offset bug and the `.txt` wipe trap before any code). No `src/server/` annotation-schema or coordinate-system change.
17
+ - **GFM task lists — interactive checkboxes that round-trip (#982)** — `- [ ] todo` / `- [x] done` now render as real, clickable checkboxes instead of plain bullets, and the checked state survives the `open → save` round-trip (previously it was silently dropped — the #981 audit had pinned it as a documented gap). Rather than Tiptap's separate `TaskList`/`TaskItem` nodes (which cannot mix plain bullets and checkboxes in one list), Tandem models a task item the way CommonMark/GFM/mdast and GitHub themselves do: an *ordinary* `listItem` carrying a per-item `checked` tri-state (`null` = plain bullet, `false`/`true` = checkbox). Because `checked` is just an attribute on the unchanged `listItem` node, three things fall out for free: a single list can hold **plain bullets and checkboxes side by side** and round-trips byte-faithfully (a plain `- item` is never rewritten to `- [ ] item`); **ordered task lists** (`1. [ ] x`) work; and the Y.Doc node name stays `listItem`, so flat-text offsets and annotation anchoring are provably untouched (the attribute is invisible to `getElementText`/`getElementTextLength`). The checkbox is drawn by a **widget-decoration plugin** (`src/client/editor/extensions/list-item-checkbox.ts`, a `ListItem` extension), not a NodeView, so the node's `contentDOM` stays the default `<li>` and list rendering/selection/decorations are unchanged — matching the editor's existing decoration-plugin idiom. Create a checkbox by typing `[ ] ` / `[x] ` at the start of a list item (input rule) or via the new **Task list** slash-menu command. The server mapping in `mdast-ydoc.ts` reads/writes the same `checked` attribute (stored as a real boolean — the representation y-prosemirror writes when a box is toggled — so it round-trips through yjs `ContentAny` byte-identically; the reverse read also tolerates a string). New tests: `tests/server/file-io/task-list-mapping.test.ts` (both directions, tolerant read, offset invariance), `tests/client/list-item-checkbox.test.ts` (schema/attr contract + checkbox rendering + slash command), plus mixed-list and ordered-task-list cases added to the `markdown-fidelity` fixture and the inverted #982 pin. No `src/server/mcp/` data-model or annotation-schema change. The approach was chosen from a four-frame divergent brainstorm (schema / fidelity / prior-art / skeptic lenses) and the implementation was reviewed by crdt + general adversarial agents (which verified the boolean round-trip against the y-prosemirror/yjs source and the decoration-vs-offset isolation). Known v1 limits, deferred: pressing Enter after a checkbox item starts a plain bullet (type `[ ] ` to continue the checklist), and pasting markdown containing task items doesn't yet detect the checkbox (file-open does).
18
+ - **Native context menu on the tab strip (#923, Phase 2)** — right-clicking a document tab now opens a native OS menu with **Close**, **Close Others**, **Close to the Right**, **Copy Path**, and **Reveal in Finder / Show in File Explorer / Show in File Manager** (label is OS-specific). Built on the Phase 1 plumbing with zero new IPC machinery: the same boolean-only request shape, the same `build_menu_from_spec` mapping (extracted from the editor's `build_context_menu`), and the same single app-level `on_menu_event` that forwards any `ctx:`-prefixed id back to the webview — tab items are `ctx:tab:*`. Item enablement is computed from the live tab list: **Close Others** needs >1 tab, **Close to the Right** needs a tab after the clicked one in display order, and **Copy Path** / **Reveal** are disabled for scratchpad / `upload://` tabs (no real on-disk path, via the shared `isScratchpadPath`/`isUploadPath` helpers). Bulk closes route through the existing `closeTabAndRecord` (so the scratchpad-unsaved `window.confirm` guard and the reopen-closed-tab stack both apply) and snapshot their target ids from the current order *before* looping, since each close mutates the tab list. Reveal reuses the existing `show_in_file_manager` command. The tab menu replaces the native WebView menu on all platforms (a tab pill has no Look-Up value, unlike editor plain text); browser (npm-install) mode keeps its native menu. The `context-menu-action` Tauri event is shared with the editor menu — each surface validates against its own closed id set (`isTabContextMenuActionId` / `isContextMenuActionId`) and drops the other's ids. The `contextmenu` listener is attached imperatively on the scroll container (not an inline handler) so the `role="tablist"` element isn't forced to take a focus-stealing `tabindex`. No new testids (native menu). New: `src/client/tabs/tab-context-menu.ts`; `show_tab_context_menu` + `build_tab_context_menu_spec` in `src-tauri/src/lib.rs`; `onCloseOthers`/`onCloseToRight` on `DocumentTabs`. Reviewed by the `svelte-migration-reviewer` agent. **Phase 3** (annotation-card menu) remains deferred behind its two prerequisite refactors (clamped margin-card layout + user-authored `onEdit` gating).
19
+ - **Markdown fidelity: footnotes, reference-style links, and inline HTML now round-trip (#981)** — audited every CommonMark + GFM construct end-to-end and closed the silent-drop gaps. Constructs Tandem has no first-class editor node for — **footnote references + definitions** (`[^1]` / `[^1]: …`), **reference-style links + their definitions** (full `[t][ref]`, collapsed `[ref][]`, shortcut `[ref]`, and the `[ref]: url` defs), and **inline HTML** — were previously parsed by `remark-gfm` but then **dropped entirely** on the way into the Y.Doc (they carry no `.value`, so the old `mdast-ydoc` default cases returned nothing), so they vanished on the first save. They are now preserved verbatim: block constructs store their markdown source in a `paragraph` carrying a new `markdownRaw` attribute, inline constructs store theirs under a new `rawMarkdown` mark, and both re-emit as mdast `html` nodes on save (which `remark-stringify` writes byte-exact, bypassing the text escaper). The round-trip is a **stable fixed point** — re-opening re-parses the emitted source back into the same gfm nodes and re-stores them. Because the source is stored as real **text/marks (never embeds)**, flat annotation offsets stay aligned (verified at the slice level). Nested inline images now preserve their full `![alt](url "title")` source instead of degrading to alt-text. A new Appearance toggle — **Show raw markdown** (`appearance-show-raw-markdown`, default on) — hides these raw markers in the editor via CSS only (the source always stays in the file and saves regardless). A new `tests/fixtures/markdown-fidelity.md` + `markdown-fidelity.test.ts` exercise every construct through `open → save → reopen` with idempotency, content-preservation, and coordinate-stability assertions. **GFM task lists (checkboxes)** remain a documented gap, tracked in **#982** (they degrade to plain bullets — pinned by a test so the gap can never become a silent drop). Documented normalizations (setext→ATX, indented→fenced, autolink angle form) are recorded in ADR-042. New: `src/client/editor/extensions/raw-markdown.ts`, `serializeMdastBlock`/`serializeMdastInline` in `markdown.ts`. No annotation-schema or coordinate-system change; pre-code plan reviewed by crdt + serialization + svelte-migration agents (which caught the `ALL_MARKS` allowlist requirement and the `normalizeKnownFields` settings gotcha before any code).
20
+ - **Native context menus on the editor surface (#923, Phase 1)** — right-clicking in the editor now builds a native OS menu via Tauri v2's built-in `tauri::menu` API (no plugin), replacing the bare WebView default. The menu is **context- and platform-conditional**: right-clicking a **table cell** opens table-structure ops (insert/delete row & column, merge/split cell, delete table) — tables previously had *no* editing UI, so this is their primary affordance; right-clicking a **link** offers Open / Copy / Remove Link (Open re-runs the `isSafeExternalHref` allowlist via a shared `openHref`, so the menu path can't bypass the click-time gate); **plain text** on Windows/Linux gets Undo/Redo + Cut/Copy/Paste/Paste-as-Plain-Text/Select All, while on **macOS plain text the native WebView menu is preserved** (no `preventDefault`) so Look Up / Services / spellcheck survive — building our own `NSMenu` there would *lose* them, the reverse of the issue's premise. Clipboard items are native `PredefinedMenuItem`s (the `linux-libxdo` Cargo feature is enabled so Cut/Copy/Paste/Select All work on Linux); Undo/Redo route to the Yjs undo manager (native undo would desync the Y.Doc), and Paste-as-Plain-Text reuses the exact Ctrl+Shift+V slice builder (`buildPlainTextSlice`) so the two entry points can't diverge. **Security contract** (enum-in / id-out): the `show_context_menu` command takes only a kind enum + booleans — never an href or path — and the app-level `on_menu_event` (registered once) emits one of a fixed set of `ctx:` action ids back to the webview, which validates it against a closed dispatch map and drops unknown/forged ids; the link href stays module-local and is re-validated on use. On right-click the PM selection is moved to the click point via `posAtCoords` (right-click doesn't move the caret), so table/clipboard commands act on the right place, and table Merge/Split are `editor.can()`-gated. Browser (npm-install) mode keeps its native WebView menu. No new testids (native menu). New: `src/client/editor/context-menu/{types,detect,dispatch,install}.ts` + `src/client/editor/utils/plain-paste.ts`; `show_context_menu` + `build_context_menu_spec` in `src-tauri/src/lib.rs`. Plan reviewed pre-code by six adversarial agents (annotation-model, security, UX-conventions, Tauri-v2-feasibility, CRDT, Svelte-lifecycle) — which caught the macOS Look-Up regression, the stale-selection bug, the `Promise<UnlistenFn>` leak, and the `linux-libxdo` gap before any code. **Phase 2** (tab-strip and annotation-card menus) is designed but deferred; native popup behavior (esp. the macOS passthrough) needs a manual desktop pass.
21
+ - **`tandem doctor` is now a real CLI subcommand (#319, CLI parity)** — the setup diagnostics that previously ran only via `npm run doctor` are now wired into the installed `tandem` binary: `tandem doctor` prints a human-readable check report and `tandem doctor --json` emits a single machine-readable JSON document on stdout (schema `{ ok, crashed, failures, warnings, summary, error, results[] }`, each result optionally carrying a `data` block — including annotation-store telemetry: doc count, total bytes, corrupt count, newest-write age, schema version, and lock-PID liveness). The diagnostics logic moved into an importable `src/cli/doctor.ts` module so tsup bundles it into `dist/cli` (the `scripts/` dir is not shipped in the npm package, so spawning the old script would have nothing to run in a global install). The module splits a pure `runDoctor()` collector (no `process.argv`/`process.exit`) from a `runDoctorCli({ json })` printer + exit-code wrapper, shared by both the new subcommand and the `npm run doctor` shim (`scripts/doctor.mjs`, now a thin delegate run via tsx). Exit codes are unchanged: 0 pass, 1 failures, 2 crash. The Tauri Diagnostics menu UI portion of #319 remains out of scope (needs desktop validation), so the issue stays open.
22
+ - **Motion language — foundations (Phase 4 / #798)** — added the two theme-independent easing tokens that carry the entire Tandem motion vocabulary to `index.html`'s `:root`: `--tandem-ease-out` (`cubic-bezier(0.2, 0.8, 0.2, 1)`, the primary entrance/exit curve) and `--tandem-ease-standard` (`cubic-bezier(0.4, 0, 0.2, 1)`, reserved for longer layout transitions). Inert and shared — no surface consumes them yet; each motion cluster references `var(--tandem-ease-out)` as it lands. The dual-mechanism reduced-motion scaffold (OS `prefers-reduced-motion` + the `body.tandem-reduce-motion` class wired from the `reduceMotion` setting in `App.svelte`) already exists from the Phase-3 re-skin, so this is purely the token definition. The canonical scene spec is `docs/design-system-impl/motion.md`. No behavior change.
23
+ - **Motion — annotation rail card arrival + resolve (Phase 4 / #798, scenes A4·A10·A1)** — the annotation side-panel cards now animate. A newly-arrived pending card **slots in** (height 0→full + fade-up) and a resolved card **leaves** with a direction that reads its fate: Accept settles it **upward** (absorbed), Reject slides it **right** (discarded), and a card that merely drops off the list (filtered out, removed) gets a **neutral fade** — siblings reflow continuously because the card animates its own height (no `animate:flip`, no wrapper, so the `role="list"`→`role="listitem"` ownership is untouched). Accept/Reject buttons get a quick press-in. New `src/client/panels/cardMotion.ts` houses two measured-height Svelte transitions (`cardEnter`/`cardExit`) plus a cubic-bezier solver so the JS timing matches the `--tandem-ease-out` token exactly; transitions are opt-in per call site (only the pending list animates — resolved/margin cards are inert) and fully honor reduced motion (the `reduceMotion` setting OR the OS `prefers-reduced-motion`, checked in JS since CSS media can't reach a JS transition). A4's editor-side gutter ping is deferred to the editor cluster. Visual-only: no `src/server/` changes, no annotation-model change, every testid preserved. Pre-code plan reviewed by svelte-migration + annotation-model + crdt agents (caught an ARIA-ownership break and a stale-exit-direction bug before any code).
24
+ - **Motion — editor surfaces (Phase 4 / #798, scenes A18·A6a·A20a)** — three in-editor cues now animate. The active **find match hops** between hits with a one-shot amber pulse as `findNext`/`findPrev` advances (A18); clicking an annotation card **pulses its anchored text** with an accent tint that settles to a resting highlight so you can see where in the document the comment lives (A6a); and the **slash menu lifts in** from the cursor anchor when you type `/` (A20a). All three are one-shot CSS `@keyframes` (not `transition`s — ProseMirror recreates decoration spans on rebuild, and `classList` toggles restart an animation but not necessarily a transition), timed with the `--tandem-ease-out` token, and each carries the dual reduced-motion guard (OS `prefers-reduced-motion` + the in-app `body.tandem-reduce-motion` class — there is no app-wide catch-all). The slash-menu entrance fires once on open and does not replay while filtering (the container is display-gated, not rebuilt; the per-row cascade is intentionally out of scope). CSS-only: no `src/server/` changes, no markup or coordinate changes, every testid preserved. Pre-code plan reviewed by crdt + code-review agents (both confirmed the animation-vs-transition mechanism against the decoration lifecycle). Held to separate PRs: A5 gutter recolor (design-visible reconcile), A4 editor gutter ping (new ProseMirror decoration), A16a swatch-pick (picker auto-closes), A17 streaming (no substrate yet).
25
+ - **Motion — annotation arrival gutter ping (Phase 4 / #798, scene A4 editor half)** — when a new annotation lands, its anchored paragraph gets a brief gutter ping in the editor, pairing with the rail card's slot-in so "a card arrived" and "here's where it lives in the text" read as one event. A new self-contained extension (`src/client/editor/extensions/annotationPing.ts`) owns an ephemeral node decoration (added on arrival, auto-removed after ~700ms) — deliberately separate from the perf-tuned annotation inline-decoration plugin. Arrival is gated by a **liveness window** — a quiet-window debounce that holds pings until the initial Y.Map sync burst settles, then folds everything synced so far into a seen-set — so bulk loads (force-reload, session restore, `.docx` import, first-run tutorial seeding, all of which stamp fresh timestamps) don't storm pings; it also mirrors the editor's own display gate (only `pending`, non-muted types ping). Fully honors reduced motion (the plugin skips the ping entirely under the in-app setting or OS `prefers-reduced-motion`, with a CSS guard as backstop). Pure client view-decoration: no `src/server/` changes, no Y.Doc write, no annotation-model change (ADR-027 safe — a local cue about the user's own annotation, never sent to the agent). The arrival predicate is unit-tested as a pure function; pre-code plan + diff reviewed by crdt + annotation-model agents (caught a bulk-load misfire in the original timestamp-based design).
26
+ - **Motion — Claude-presence gutter reconcile (Phase 4 / #798, scene A5)** — the "Claude is editing" indicators now read as one coral signal. The pulsing paragraph **gutter rail** (the 3px bar at the left margin) was the lone blue cue in the set (`--tandem-accent`) while every other Claude-presence mark — the focused-paragraph border, the ghost caret — is already coral; it's recolored to `--tandem-author-claude` and now **breathes** (its existing 2s opacity swell gains a soft coral bloom at the peak). The ghost **caret** retimes from a `1.2s step-end` hard blink to the canon `900ms ease-in-out` breathe — and the breathe animates the caret bar's `border-left-color`, not the span opacity, so the child "AI" identity label stays fully legible (an opacity breathe would have continuously dimmed it). Reconcile, not a fresh add: production already shipped both loops (the canon row's "ADD" misattributes them, and names the wrong source file — the CSS lives in `editor/editor.css`, the decoration in `editor/extensions/awareness.ts`). The box-shadow bloom is keyframe-only so the existing dual reduced-motion guards (`@media prefers-reduced-motion` + `body.tandem-reduce-motion`) fully neutralize it — the rail stays a solid coral bar, the caret solid, no motion. CSS-only: no `src/server/` changes, no decoration/markup change, every testid preserved. Design-visible (changes a shipped looping indicator) → **parked for Bryan's Tauri spot-check**, not auto-merged. Pre-code plan reviewed by code-review agent (caught the AI-label legibility regression and pinned the keyframe-only box-shadow / overflow constraints).
27
+ - **Motion — tab close (Phase 4 / #798, scene s3)** — closing a document tab now collapses it on the inline axis (width → 0) + fades, so the adjacent tabs glide left to fill instead of snapping. A new `tabExit` measured-width Svelte transition joins `cardMotion.ts` (reusing its exact `--tandem-ease-out` bezier solver), applied as an `out:` directive on the `TabItem` root — it runs once on close, then the node is removed. `min-width:0` defeats the tab's content min-width (the filename's 80px floor) so the collapse reaches 0, and `overflow:clip` clips the name without becoming a focus-stealing scroll box. Honors reduced motion (the in-app `reduceMotion` setting — now threaded into `TabItem` — OR OS `prefers-reduced-motion`, checked in JS): collapse is immediate, no slide. The `out:` keeps the closing tab in the DOM for ~200ms, so the leaving node is made `pointer-events:none` (a click on the collapsing tab would otherwise switch to an id already gone from the tab list → null active tab → wiped editor; this also drops it from `elementFromPoint`) and the drag-reorder guard gained a drop-target-existence check (a lingering closing tab could otherwise be picked as a reorder target). Visual-only: no `src/server/` changes, every testid preserved; the existing Ctrl+W tab-close E2E specs already exercise the exit window functionally (Playwright runs motion-on by default), and `tabExit`'s config is unit-tested. Pre-code plan + diff reviewed by svelte-migration + code-review agents (caught the missing `reduceMotion` threading, an `overflow:hidden`→`clip` focus-scroll hazard, a racy brief-presence test assertion, the drag-target gap, and a click-on-collapsing-tab editor-wipe).
28
+ - **Motion — mode-toggle thumb-slide (Phase 4 / #798, scene A8, partial)** — the Solo↔Tandem segmented toggle now has a **sliding active pill**: a decorative thumb that slides between the two segments on a mode flip (`transform: translateX`, `220ms var(--tandem-ease-out)`) instead of the active background snapping. The two segments are flex-equalized so the half-width thumb lands exactly on either (the `translateX(100%)` math is exact given equal buttons); the thumb carries the active fill + shadow (the `.on` class now only sets the active label color). No mount-slide (the position class is set at render). Dual reduced-motion guard (`@media prefers-reduced-motion` + `:global(body.tandem-reduce-motion)`), and the active state is still conveyed by `aria-pressed` (the thumb is `aria-hidden` + `pointer-events:none`). **Scope-down (flagged):** A8's *other* half — the annotation rail revealing/hiding with a width+opacity transition on Solo↔Tandem — is deferred. It's the same `display:none` collapse-animation problem as A12's peek-strip (also deferred), entangled with the load-bearing rail shell + drag-resize in `App.svelte`, and only fires on the niche solo + `soloRailHidden` path; it belongs with the A12 rail-collapse work, not bolted onto this contained toggle change. `.svelte`-only: no `src/server/` changes, every testid (`mode-toggle`/`mode-solo-btn`/`mode-tandem-btn`) preserved. Design-visible → **parked for Bryan's Tauri spot-check**, not auto-merged. Diff reviewed by svelte-migration-reviewer (verified reactivity, no mount-slide, the translateX math, and the `:global` reduced-motion scoping).
29
+ - **Motion — command palette entrance (Phase 4 / #798, scene A11)** — opening the command palette (Ctrl+Shift+P) now animates in: the dimming scrim fades (200ms) and the modal scales up from 0.96 + lifts + fades (260ms), both on `--tandem-ease-out`. Pure CSS `@keyframes` in the component `<style>`: the scrim and modal mount once under `{#if open}`, so the entrance fires exactly once per open and never replays while you type (only the results list re-renders per keystroke — the animated chrome is a stable ancestor that doesn't). Dual reduced-motion guard (OS `prefers-reduced-motion` + the in-app `body.tandem-reduce-motion` class — there is no app-wide catch-all). Scoped out: the canon's per-row cascade (the keyed results `{#each}` redraws on every filter keystroke, so a per-row entrance would re-fire — same reason A20a's row cascade was deferred) and the "⌘K hint floats out" (N/A — production shows an Esc hint, not a floating ⌘K chip). CSS-only: no `src/server/` changes, no markup/data changes, every testid preserved; the `@`-prefix annotation-result rendering is untouched (motion is on the scrim/modal chrome only). Pre-code plan reviewed by svelte-migration + code-review agents (pinned the "`animation` must live in `<style>`, not inline `style=`" rule so Svelte rewrites the hashed keyframe name, and the `transform`-creates-a-containing-block caveat for any future `position:fixed` palette child).
30
+ - **Motion — batch/bulk toolbars + onboarding stepper (Phase 4 / #798, scenes A24·A25·A22)** — three more surfaces animate. The **batch-promote bar** (imported-note → Claude promote) and the **bulk-actions bar** (accept/reject-all pending) now slide down into place + fade + grow their height as they appear, and slide back out on the snappier `--tandem-ease-standard` curve when their data clears — the height animates so the annotation list below reflows continuously instead of snapping (the bulk bar isn't sticky, so the snap would otherwise show). Two new measured-height Svelte transitions (`barIn`/`barOut`) join `cardMotion.ts` and reuse its exact `--tandem-ease-out` bezier solver; used as `in:`/`out:` directives so they run once on appearance/disappearance and never re-fire on the bar's count/label re-renders (the persistent-identity form of the canon's "class-toggled transition, never a re-firing animation"). The **onboarding progress dots** gain a one-shot pop on the newly-reached dot (a real component-`<style>` CSS animation, fired by the dot freshly gaining `.is-current` — the monotonic stepper pops exactly the just-reached dot), and the step panel **cross-fades** in on advance (`{#key}` + `in:fade`). Production keeps its dot row (not the bundle's progress *line*) and the bars keep their top anchor (not the bundle's bottom) — shape carries information. All paths honor reduced motion (the bars/fade via the in-app `reduceMotion` setting OR OS `prefers-reduced-motion` checked in JS; the dot via a CSS dual-guard). Two canon elements are scoped out as design-visible-not-motion: A24's in-flight spinner/hold-LED (`promoteNotesToComments` is synchronous — no pending phase to show) and A25's "checkboxes cascade onto cards" (import select-checkboxes are always-visible, with no bulk-select-mode toggle to cascade on — the checkbox already arrives with its card via A4). Visual-only: no `src/server/` changes, no annotation-model change, every testid preserved. Pre-code plan reviewed by svelte-migration + annotation-model + code-review agents (caught a Svelte-5-WAAPI test-assertion error, a layout-snap, and an in-app-reduce-motion leak before any code).
31
+ - **Motion — tab save-confirmation pip (Phase 4 / #798, scene A2)** — saving a dirty document now plays a brief confirmation on its tab: the amber unsaved **dot** gives way to a green **check** that scales/fades in and out, so a save reads as a settled event rather than the dot just vanishing. The indicator lives in a fixed-width slot (so dot/check/empty never shift the tab), and the check is **mount-gated** (`{#if justSaved}`) so its CSS `@keyframes` re-fires on *every* save. The rising-edge detector (a `dirty→clean` transition = a save) clears its flag **unconditionally on every non-save edge** — including the case where Svelte coalesces a `dirty→clean→dirty` flap into one flush — so a stale check can never strand (a coalesced save's flash is skipped, which is invisible, rather than leaving a stuck check, which is a visible bug; this is the same stranding class a prior reviewer caught in the A9 connection-dot work). Dual reduced-motion guard (OS `prefers-reduced-motion` + the in-app `body.tandem-reduce-motion` class). Scoped to the save *morph*: the canon's relocation of the indicator into the trailing close-× slot (a layout/interaction change, not motion) is deferred. Visual-only: no `src/server/` changes, no annotation-model change, `unsaved-indicator-{id}` / `tab-{id}` testids preserved. Pre-code plan reviewed by the svelte-migration agent (caught the effect-coalescing stranding path and the fixed-width requirement before any code).
32
+ - **Motion — margin leader "settle" geometry (Phase 4 / #798, scene C4, locked decision 1)** — the margin-view annotation connector (the bezier "leader" from a highlighted span out to its margin bubble) adopts the bundle's **settle** curve shape. Both control points now sit a proportional `SETTLE_K = 0.62` of the horizontal span inward (`cx1 = startX + 0.62·dx`, `cx2 = endX − 0.62·dx`), replacing the old fixed 10px/8px asymmetric offsets — a symmetric S that leaves the anchor and meets the card both horizontally with one gentle bend, "laying into" the column even at large vertical deltas (collision-pushed bubbles), and degrading to a clean horizontal line when the bubble is level with its anchor. Because `dx = endX − startX` is naturally signed, the curve mirrors per side without a `side` flag — so the now-redundant `side` field (and the `LeaderSide` type) were dropped from `LeaderEndpoints`, making the side-agnostic contract structural rather than a comment (the path function never needed `side`; the caller's `editorX`/`columnX` already encode direction). Static geometry only — no animation, no reduced-motion concern; the per-author leader tint (decision 2) already matched and is untouched. Pure SVG-local pixel math: no `src/server/` change, no document coordinate-system (flat/ProseMirror/Yjs) touch, no annotation-model change; `margin-leaders-{side}` / `margin-column-{side}` testids preserved. The geometry was proven correct by the crdt-reviewer (X(t) strictly monotonic for k>0.5 → no kink; the two sides are exact horizontal mirrors) on the pre-code plan.
33
+ - **Motion — connection state machine (Phase 4 / #798, scene A9)** — the StatusBar connection dot now reads as a state machine: **offline** = red static, **reconnecting** = an amber breathe (`900ms ease-in-out`, replacing the old `1.2s` opacity flicker), **connected** = a one-shot green bloom (`500ms --tandem-ease-out`) on the reconnect rising-edge (reusing the existing `showReconnectedFlash` signal). The bloom is gated on `connected && showReconnectedFlash` so a connection *flap* within the flash window can't strand a green bloom over a red/amber state. **Canon-vs-production adaptation:** the canon specifies a radiating ring/bloom, but the floating status pill is `overflow: hidden` and would clip a box-shadow ring — so both the breathe and the bloom are expressed **in-bounds** via `transform: scale` + opacity (the dot's color already comes from state). Also closes a reduced-motion gap: the connection dot and the Claude-presence dot were previously **unguarded** — both now honor the dual guard (`@media prefers-reduced-motion` + `:global(body.tandem-reduce-motion)`, with `!important` to beat the inline-style animation). The 3-dot Claude typing pill is unchanged (its pulse is a separate, already-guarded concern — reconciled, not dropped). `.svelte`-only: no `src/server/` changes, every testid preserved. Design-visible → **parked for Bryan's Tauri spot-check** (toggle the dev Network panel), not auto-merged. Pre-code plan reviewed by svelte-migration + code-review agents, which converged on the flap-edge stranding bug, the `:global` keyframe-placement trap, and the `:global` reduced-motion scoping — all fixed before code.
34
+ - **Motion — selection popup annotate morph (Phase 4 / #798, scene A26)** — clicking **Annotate** in the selection popup now **morphs the popup in place** into the note composer instead of instantly swapping its contents: the annotate block unfurls (grid-row `0fr→1fr`, to its natural height with an `--tandem-ease-out` settle) while the format block collapses, and the shell's corner radius eases between states. Both content blocks are now **always mounted** (the inactive one collapsed + `inert`, so its clipped controls are neither focusable nor screen-reader-reachable and a hidden textarea can't capture a stale draft) — that's what gives the unfurl a "from" value and removes the mount-timing races the old `{#if}` swap had with textarea focus. Placement is decoupled from the animating height (decided against a constant reserve) and an above-placed popup is bottom-anchored, so it grows **upward** without any per-frame reposition, flip, or snap — no freeze flag needed. A new shared `src/client/panels/morphTiming.css` carries the morph timing tokens (`--morph-p1/p2/cascade`) and a single token-zeroing reduced-motion guard (OS `prefers-reduced-motion` + in-app `body.tandem-reduce-motion`) that reaches scoped consumers via inheritance — the foundation the remaining single-shell morphs (A23/A29) will reuse. **Flagged design deviation (option B):** per the morph-family alignment, width is *not* morphed (it's constant at the natural format width); the width-unroll belongs to the selection-popup *entrance* (scene A28). Honors reduced motion (instant swap) and degrades to an instant swap on any WebView that doesn't interpolate `grid-template-rows`. Conflict-#5 contract fully preserved: audience-first popup, Enter=newline / Alt+Enter=note / Ctrl⌘+Enter=comment, dual empty-content guard, every `popup-*` testid. Visual-only: no `src/server/` changes, no annotation-model change. Pre-code plan reviewed by svelte-migration + annotation-model agents (which simplified the design — the height/placement decoupling dissolved the originally-planned freeze-flag machinery — and made the `inert` block a load-bearing draft-integrity fix).
35
+ - **Motion — selection-popup entrance + dwell (Phase 4 / #798, scene A28)** — the selection popup now (a) appears only after the selection **gesture finishes** — for mouse selections it waits for pointer release (not just a mid-drag pause), then a short **100ms** intent dwell; keyboard selections use the same 100ms dwell on settle (a NEW client-side intent gate — distinct from `selectionDwellMs`, which gates the *server* channel selection event — so a quick drag-through doesn't flash the popup), and (b) **enters with a Svelte transition** that unrolls its measured natural **width and height** out of the cursor corner (the width-unroll deferred from A26/M1) + fades, on `--tandem-ease-out`. The dwell re-arms on each selection-endpoint change while pending and stops re-arming once shown, so a drag-extend after the popup appears repositions it rather than hiding it; the explicit Ctrl+Alt+M "comment now" shortcut bypasses the dwell entirely. The popup is **cursor-anchored** — its corner is pinned at the cursor point (pointer-release X/Y for mouse, the caret for keyboard) and the pills unroll away from it: rightward, and downward by default (flipping above near the viewport bottom), replacing the former selection-box centering. The left-edge clamp is **frozen during the unroll** (an `entering` flag gates the width-feedback ResizeObserver, set in the same batched write that shows the popup so it wins the mount race; the first popup of a session seeds the clamp from `scrollWidth` so it can't unroll off the right edge) so the popup can't jitter as its width grows. Honors reduced motion (the in-app `reduceMotion` setting — now threaded into `Toolbar` — OR OS `prefers-reduced-motion`; the dwell itself, being an intent gate not motion, is kept). Per-button row cascade deferred (flagged). Conflict-#5 fully preserved (submit keybindings, dual empty-content guard, `withBrowser` create-write, every `popup-*` testid unchanged); range capture still reads the live selection at submit so the dwell can't annotate a stale range. Visual/interaction-only: no `src/server/` changes, no annotation-model change. `popupEnter` and the cursor-anchored `computeSelectionToolbarPosition` are unit-tested; plan + diff reviewed by svelte-migration + annotation-model agents (plan review caught a ResizeObserver-vs-`entering` ordering race + the reduced-motion threading gap before any code).
36
+ - **Motion — new-tab launcher `+`→menu morph (Phase 4 / #798, scene A29)** — clicking the tab-strip `+` now **morphs the pill in place** into the new-tab menu instead of popping a separate panel: a persistent single-shell grows from the 28×28 `+` slot into the 460px menu — width + corner-radius lead (P1), then the body unfurls to its natural height (P2, `grid-template-rows: 0fr→1fr`, no measurement), reversing on close. The menu is **un-portaled** — it was a `use:portal` + `position:fixed` panel anchored under the `+`; it now lives inside the shell as a real DOM descendant (the old `positionMenu`/`anchorEl`/`clickOutside` are gone, dismissal is owned by DocumentTabs' existing window-pointerdown handler). The shell is a sibling of `role="tablist"` and sits **outside** the `.tab-scroll-mask` scroller, so its growth is never clipped by horizontal tab overflow; a 28×28 in-flow placeholder keeps the tab layout stable while the absolute shell grows down-and-left. `overflow: clip` + `overflow-clip-margin` (not `hidden` — avoids a focus-autoscroll scroll-container, lesson #765) clips the reveal while letting descendant focus rings paint. **A11y:** the `+` gains `aria-haspopup="dialog"` + `aria-expanded`, and focus returns to the trigger on close when it would otherwise be lost (Escape/item-select → `activeElement` falls to `<body>`), but not on click-outside; the `role="dialog"` body stays conditionally rendered so it leaves the a11y tree (and `toHaveCount(0)`) when closed. Reuses the shared `morphTiming.css` tokens (`--morph-p1/p2/cascade`) + token-zeroing reduced-motion guard. **Scoped out:** the canon's per-row body cascade (the conditional body unmounts on close, so a re-firing per-row rise would need a persistent body + a `visibility`-hiding mechanism + a multi-spec contract change — disproportionate; the `overflow:clip` shell already gives a clip-reveal). Visual-only: no `src/server/` changes, every testid preserved. Pre-code plan reviewed by svelte-migration + accessibility agents (caught a load-bearing `menuEl` bind the un-portal would have dropped — it backs arrow-key nav — and the WCAG 2.4.3 focus-return gap).
37
+ - **Inline/embedded images render in documents (#153)** — Tiptap's `Image` extension is now registered (with `allowBase64` enabled), so documents display images inline instead of dropping them. **Markdown** `![alt](url)` is supported: a standalone image (which `remark` parses as `paragraph > image`) is promoted to a block-level `image` node in the mdast↔Y.Doc pipeline so it renders, and round-trips back to `![alt](url)` on save; images mixed with inline text are split into their own block plus surrounding paragraphs (no content loss). **DOCX** embedded images already arrive as base64 `data:` URIs via mammoth's default image converter and now flow through to the same `image` node. Block images are atom/leaf nodes that contribute zero flat-text characters, so existing annotation ranges stay aligned across an embedded image (covered by new `tests/server/document-model.test.ts` alignment assertions). Base64-embedded images inflate Y.Doc/CRDT state size proportionally to the encoded payload — a known tradeoff for inline rendering.
38
+ - **UI E2E coverage for `.docx` reviewer-comment batch-promote (AR5-T4, #945)** — a Playwright spec (`tests/e2e/batch-promote.spec.ts`) drives the full flow through a real browser against a committed binary `.docx` fixture: imported Word comments render as private notes (`author: "import"`, `type: "note"`, `audience: "private"`) and are excluded from Claude's view, then per-card selection + `BatchPromoteBar` promotes them to Claude-visible `type: "comment"`, `audience: "outbound"` annotations. The privacy boundary (ADR-027) and Claude visibility are asserted via the MCP test client. The fixture is reproducibly generated by `scripts/fixtures/make-reviewer-docx.mjs`. Test-only — no application code changed.
39
+ - **Session management UI (#103)** — a "Saved sessions" section in the file-open dialog lists every persisted document session with its file path, last-accessed time, and live annotation count, replacing the need to hand-delete platform-specific session files. Each row can be reopened (routes through the existing `/api/open` flow) or deleted, plus a "Clear all" action removes every document session at once (chat history and `upload://` sessions are preserved). Backed by three new server routes — read-only `GET /api/sessions` plus mutating `POST /api/sessions/delete` and `POST /api/sessions/clear`, the latter two gated on `assertOriginAllowlisted` + `assertLoopbackForMutation` and a read-only-store check before any deletion. Annotation counts derive from the durable annotation envelopes (best-effort; corrupt/missing envelopes report 0). New testids: `sessions-section`, `sessions-toggle`, `sessions-empty`, `sessions-loading`, `sessions-clear-all`, `sessions-error`, `session-row`, `session-reopen`, `session-delete`.
40
+ - **Annotation schema v1→v2 migration framework (#320)** — the durable annotation envelope now has a versioned migration runner modeled on `src/server/integrations/migrations.ts`: an ordered `MigrationFn[]` chain plus `migrateUp(input, fromVersion, toVersion)` in `src/server/annotations/migrations/`, wired into `parseAnnotationDoc`'s load path. The framework ships with a dormant, proof-of-shape v1→v2 migration (identity over the payload, re-stamps `schemaVersion`); `SCHEMA_VERSION` stays `1`, so `migrateUp` is a no-op on every read today and v1 files load byte-identically. When the first real v2 lands, bumping `SCHEMA_VERSION` to `2` activates the chain with no further wiring. Migration failures are treated as corruption (quarantined) rather than crashing the load path; the upgraded shape persists on the next durable write (migrate-on-read, mirroring the integrations store). No on-disk shape change. No `src/server/mcp/` changes.
41
+ - **Attribute Claude-authored documents at creation (#937)** — when Claude writes a document wholesale (creates the file, then `tandem_open`s it), its text was unattributed because authorship is otherwise only stamped by `tandem_edit`. `tandem_open` now accepts an optional `authoredBy: "claude"` flag (schema `z.literal("claude")` — it cannot forge `"user"` attribution) that, once the file has loaded, stamps Claude authorship across the document: one `AuthorshipRange` per top-level element, each spanning that element's post-prefix text so the CRDT anchor resolves cleanly even when the doc opens with a heading prefix (a single whole-doc `[0, len]` range would degrade to flat-only). Stamps use deterministic IDs (`claude-block-{index}`) so re-open / session-restore / force-reload re-`set` the same keys instead of appending duplicates, and the authorship map is never bulk-cleared, so browser-added `author:"user"` ranges (a user reclaiming a block by editing it) are preserved. Writes are origin-tagged `withMcp` (no channel/ADR-027 leak — authorship is a separate Y.Map from annotations/notes). The `tandem` skill (bumped to `version: 3`) tells Claude to pass the flag after authoring a document. Two known limitations: the per-block gutter shows the dominant author by character count (so a user edit reclaims a block — desired), and authorship is not in the durable-sync observer, so stamps are lost on server restart — the skill guidance covers re-stamping on the creating open.
42
+
43
+ ### Changed
44
+
45
+ - **`DocumentStore` interface extracted for MCP tool logic (#315)** — pure internal refactor. The MCP tool handlers previously reached straight into a raw `Y.Doc` + `Y.Map` (via a `getDocAndAnnotations` helper). A new `DocumentStore` interface (`src/server/mcp/document-store.ts`) names the operations the handlers actually perform — `getText`, `createAnnotation`, `editAnnotation`, `acceptAnnotation`/`dismissAnnotation`, `removeAnnotation`, `getAnnotation`, `listAnnotations`/`listAnnotationsRefreshed`, `refreshAnnotation`, `transactMcp`, `addReply`/`listReplies` — so tool logic depends on an interface instead of `Y.Map.get`/`set`. The single `YDocStore` implementation is a thin wrapper that delegates to the existing standalone helpers (`createAnnotation`, `collectAnnotations`, `addReplyToAnnotation`, `removeAnnotationById`, the `acceptPending`/`dismissPending` lifecycle, `refreshAllRanges`/`refreshRange`), so Y.Map structures and `withMcp` origin tagging (ADR-031) are byte-identical to before. Handlers in `annotations.ts`, `awareness.ts`, `navigation.ts`, and `docx-apply.ts` now call `store.method()`. `FileOnlyStore` is intentionally out of scope. No MCP tool signature, name, registration, or wire shape changed; the existing MCP/annotation suites stay green unchanged, and a new `tests/server/document-store.test.ts` pins the store's outputs against the standalone helpers as a contract test.
46
+ - **Welcome tutorial refreshed to match shipped features (#265)** — `sample/welcome.md` and the onboarding walkthrough had drifted from the current app. The welcome doc now introduces the five-row annotation taxonomy (highlights, notes, comments, suggestions, imported Word comments) with the ADR-027 note-privacy boundary stated plainly, Solo vs. Tandem modes, and a "Get Around Faster" section covering the command palette (Ctrl+Shift+P), new tab / scratchpad (Ctrl+N), find & replace (Ctrl+F, cross-tab + regex/case/word), the outline panel + heading-section collapse, customizable keyboard shortcuts (Ctrl+Shift+,), the margin annotation view, Claude typing-presence, and the multi-provider Models registry + first-run picker. The `OnboardingTutorial` step bodies were updated to mention the margin view, tabs/scratchpad/palette, and shortcut customization. The four tutorial-annotation anchors (`highlight text and your AI sees it`, `edit this document at the same time`, `simplify onboarding`, `accept or dismiss`) remain present and unique so `injectTutorialAnnotations` still anchors all four seeds; `tests/fixtures/welcome-snapshot.md` was updated in lockstep. Step IDs/titles, the idempotency guard, and all tutorial testids (`onboarding-tutorial`, `tutorial-next-btn`, `tutorial-dismiss-btn`) are preserved. No `src/server/` or annotation-injection logic changed.
47
+ - **Agent-agnostic display — the UI names the model you're actually using (#438, Phase 1)** — Tandem speaks MCP to any LLM (ADR-038: Claude is the default integration, not the only one), but the UI hardcoded "Claude" everywhere, so a user running GPT/Gemini/a local model saw the wrong name. Every user-visible "Claude" identity string now resolves from the user's selected model in the Models registry, via a small pure resolver (`src/client/utils/agentLabel.ts`) + a reactive rune wrapper (`useAgentLabel.svelte.ts`, same getter-export idiom as `createLayoutModel`). A fixed per-surface rule (no setting): the **status pill** shows the *specific* model ("Claude Opus 4.8"); **everything else** — annotation/chat author labels, the per-card and status typing-presence text, the author filter option, the inline agent-comment aria-label, and the imperative "Send to {X}" / "not sent to {X}" buttons — shows the *family* brand ("Claude" / "GPT" / "Gemini" / "Local model"), falling back to a neutral **"Assistant"** when no model is configured (and resolving the sole model when exactly one exists without an explicit default). The durable `author: "claude"` enum is unchanged — it stays the internal role discriminator ("the agent, not the human"), so ADR-027 note privacy and the suggestion-authoring guard (both keyed on the value, not the label) are untouched; `Y_MAP_CLAUDE`, the `--tandem-author-claude` tokens, and the `.tandem-claude-*` classes are likewise unchanged (in-memory awareness key + reconnect wire contract; cosmetic). The server-side `.docx`→Markdown review export no longer leaks the raw `(claude)` literal — it maps to a neutral "Assistant"/"You"/imported-reviewer label (the server can't see the browser's model registry). Onboarding copy is genericized to "your AI assistant". Honest cross-model *durable provenance* (an additive `authorAgent` field, so a doc annotated by GPT last month still reads "GPT" after switching) is deferred to Phase 2; under "pick one model and stick with it," live resolution is honest in practice. The original #438 concurrency/coordination scope (transport multiplexing, per-agent inbox/presence partitioning, routing, per-client identity/auth) is deferred to a separate follow-up gated on a real multi-agent workflow. Reviewed pre-code by crdt + annotation-model + svelte-migration + red-team agents; no `src/server/` data-model changes, no annotation-schema migration, no coordinate paths, every testid preserved.
48
+ - **macOS notarization re-enabled in the release pipeline (#428)** — the App Store Connect API key trio (`APPLE_API_ISSUER` / `APPLE_API_KEY` / `APPLE_API_KEY_PATH`) was commented out during the v0.12.0-era Apple notary-service outage; it is now re-enabled in `tauri-release.yml`. The path stays a graceful no-op while the signing secrets are unset (build ships unsigned exactly as before), and activates the moment `APPLE_CERTIFICATE` and the API-key secrets are populated. A new **"Verify macOS signature + notarization"** CI gate mirrors the existing Windows signature check: it fails the release job if a produced `.app` is unsigned or missing a stapled notarization ticket (via `codesign --verify --deep --strict` + `xcrun stapler validate`, an offline deterministic check), and skips with a `::notice::` while unsigned-by-design. Added `docs/428-macos-notarization-runbook.md` covering enrollment confirmation, Developer ID cert + `.p8` API-key generation, the exact GitHub Secrets/Variables to populate, the notary-health check, and the throwaway-RC-tag smoke test. No application code changed.
49
+
50
+ ### Fixed
51
+
52
+ - **`.docx` "mark comment done" wrote the wrong paraId (#1007)** — when `tandem_save` applied an accepted suggestion to a `.docx` and marked the overlapping imported Word comment as resolved, it wrote `commentsExtended.xml`'s `w15:commentEx/@paraId` using the **document-anchor paragraph's** `w14:paraId` (where the comment's range *starts* in `word/document.xml`) instead of the **comment's own last-paragraph** `w14:paraId` (inside `word/comments.xml`). Per OOXML `CT_CommentEx` (§2.5.39) those are different paraId spaces, so Word never matched the entry to the comment and the resolve-on-accept feature silently no-opped. `resolveWordComments` now reads `word/comments.xml` and uses each comment's last-paragraph paraId (original case preserved so it matches both the comment's `<w:p>` and any entry Word already wrote), and — when `commentsExtended.xml` already tracks that comment — **updates the existing `commentEx` entry's `done` in place** (case-insensitive paraId match) rather than skipping it or writing a duplicate. The now-unused document-anchor `commentParagraphIds` map was removed from `buildOffsetMap`/`OffsetMap`. Export-side metadata only — no annotation-model, coordinate-system, or privacy impact. New `resolveWordComments` tests build a real `word/comments.xml` and assert the comment's own last-paragraph paraId is used, an existing entry is updated (not duplicated), and an unrelated entry is left intact.
53
+ - **macOS Apple Silicon: app failed to start — sidecar SIGTRAP under hardened runtime (#983)** — the signed `.app` came up but the bundled `node-sidecar` crashed immediately with `SIGTRAP` during V8 initialization (`pthread_jit_write_protect_np` → `ThreadIsolation::Initialize`), so nothing ever bound `127.0.0.1:3479` and setup failed. Root cause: the sidecar ships with hardened runtime **enabled** but **no entitlements** — specifically missing `com.apple.security.cs.allow-jit` — so the kernel rejects V8's W^X JIT mapping. Added `src-tauri/entitlements.plist` (`allow-jit` + `allow-unsigned-executable-memory`, the minimal verified set) and wired it via `bundle.macOS.entitlements` in `tauri.conf.json` so `tauri-action` applies it during signing. The "Verify macOS signature + notarization" CI step now also **asserts the nested `node-sidecar` itself carries `allow-jit`** (`codesign -d --entitlements`) — codesign does not inherit an app's entitlements to nested binaries, so if a toolchain change ever stops applying `bundle.macOS.entitlements` to the externalBin, the next signed build fails loudly here instead of shipping another silent SIGTRAP, with the re-sign fallback called out in the error. **Requires verification on a signed + notarized arm64 build** (no macOS signing in this environment); the config change is inert on normal CI (signing only runs on tagged release builds with the Apple secrets set).
54
+ - **Slash menu re-opened when the caret merely landed after an existing "/" (#998)** — the slash-command plugin re-derived its active state from `(text, caret)` on *every* transaction, so clicking or arrowing back to just after a pre-existing `/token` re-opened the menu even though the `/` was ordinary document text. Opening (inactive → active) is now gated on the triggering transaction being a *typed insertion that ends at the caret*: selection-only moves, pastes/drops (`uiEvent`/`paste` meta), and remote/MCP `y-sync$` syncs can no longer open the menu, while typing the `/` (or query characters while already open) still does. The already-open path is unchanged, so the query still tracks and the menu still closes when the caret leaves the token. The insertion check iterates `tr.mapping.maps` with left-bias forward mapping so an appended input-rule/IME step can't misclassify a real keystroke. Plugin `init` no longer seeds an active state, so a document that loads with the caret after a `/token` won't auto-open. (Note: backspacing the query after an Escape-dismiss no longer re-opens — consistent with the typed-only rule.)
55
+ - **Real-time push to Claude Code was silently off on default installs (#985)** — every setup path registered only the HTTP `tandem` MCP entry and omitted (or actively removed) `tandem-channel`, betting on a plugin monitor that Claude Code never activates (Spike B / #712). Meanwhile the desktop auto-launcher spawns Claude with `--dangerously-load-development-channels server:tandem-channel` — a flag pointing at a server nobody registered — so push silently degraded to `tandem_checkInbox` polling. The channel shim is now registered **by default** for the Claude Code target across all three flows (CLI `tandem setup`, the in-app integration wizard, and the desktop tray/startup `/api/setup` run), gated on the `dist/channel/index.js` build artifact actually existing (`shouldRegisterChannelShim` — Claude Code default-on, Claude Desktop always off, with an explicit `--with-channel-shim` override preserved). The "create wins" invariant is now enforced structurally inside `applyConfig` so no flow — including the wizard's user-confirmed diff — can delete a channel entry it just created, and `tandem rotate-token` now heals the registration instead of dropping it. Security: `/api/setup` validates the body-supplied `channelPath` against the canonical `dist/channel/index.js` artifact shape (plus existence) before turning it into an executable `node <path>` MCP command, so the now-live path can't be coerced into running an arbitrary `.js`. `tandem doctor` now inspects `~/.claude.json` (the file Claude Code actually reads) instead of the legacy `~/.claude/mcp_settings.json`, so its push diagnostic reflects reality. Known limitation: hand-started "bring-your-own" Claude Code sessions still need the `--dangerously-load-development-channels` flag passed explicitly (the desktop auto-launcher passes it for you).
56
+ - **Settings-migration robustness against field loss on update (#941)** — hardened and pinned the `loadSettings` forward-migration chain so a version bump can never silently drop a user-set field (e.g. the `marginView` Word-style margin annotation view, #649). Audit confirmed the chain is already field-preserving — every step spreads the prior blob and `normalizeKnownFields` reads each field with a default — so no logic change was needed; the fix documents the spread-not-rebuild contract for future migration authors and adds a regression suite asserting that a maximally-customized older blob (v2) keeps **every** field across a full bump to the current schema, plus per-version coverage for `marginView`. Note: the user-reported "settings reset on update" symptom is the Tauri WebView clearing localStorage on app update (the stored blob is gone entirely, not mis-migrated), which is outside the migration layer's reach; this change makes the migration path provably safe so it cannot become a second cause.
57
+ - **Tandem brand button partially unclickable in the desktop app** — in the Tauri desktop build, the top ~2/3 of the titlebar brand/menu button silently failed to open the brand menu (only the bottom strip responded); the browser build was unaffected. `tauri-plugin-decorum` injects a fixed `[data-tauri-decorum-tb]` overlay (`top:0`, `height:32px`, `z-index:100`) whose child is a full-width `data-tauri-drag-region`, so the top 32px of the window is a drag strip. The brand button's `-10px` top margin pulls it up to y≈8, putting its upper ~24px under the strip where clicks became OS window-drags. Lifting only `.brand-btn` to `--tandem-z-titlebar` (99999 > 100, still below the 100000 above-titlebar modal layer) clears the strip; decorum's own window controls sit at the top-right, so there's no overlap, and the whole `.title-bar` is deliberately *not* lifted (it spans full width as a drag region and would cover min/max/close).
58
+
59
+ ### Internal
60
+
61
+ - **AR5/AR6 annotation-migration hardening (v0.14.0 gate)** — test coverage for the already-shipped Word-import → private-note → batch-promote pipeline (AR5) and tutorial annotations (AR6); no production behavior change. Adds unit coverage for the privacy-critical promote transform (`sendNoteToClaude` / `promoteNotesToComments`: `import→user`, `note→comment`, `private→outbound`, `color`/`suggestedText` strip, sanitize-stability), a channel-emit test pinning the `author`+`type`+note-predecessor gate (and the privacy negatives — un-promoted notes, imports, and author-not-flipped writes must not surface), and an in-process import→promote→Claude-visible integration test with slice + CRDT-`relRange` round-trip assertions (guards against silently-clamped anchors). Closes the AR6 anchor-drift gap by asserting the live `sample/welcome.md` stays anchor-equivalent (flat-text projection) to the snapshot fixture and that every tutorial target resolves uniquely in the live file. The Playwright `.docx`-fixture UI E2E is deferred to a follow-up.
62
+
63
+ ## [0.13.5] - 2026-05-29
64
+
65
+ ### Added
66
+
67
+ - **`Ctrl+T` opens the new-tab menu** — the tab strip's `+` dropdown (recent files / New Scratchpad / Browse) now has a keyboard entry point, matching the universal "new tab" convention. Registered as a remappable shortcut (`new-tab-menu`, default `Ctrl+T`), so it's customizable in Settings → Shortcuts like its siblings; pressing it again toggles the menu closed. Distinct from `Ctrl+Alt+T` (reopen closed tab).
68
+ - **Customizable keyboard shortcuts (ADR-041)** — the ~17 App-level discrete shortcuts (Save, Save As, Settings, command palette, New Scratchpad, Close/Open/Reopen tab, toggle mode/authorship/panels, next/previous annotation, comment on selection, select block) are now user-remappable in Settings → Shortcuts via click-to-record. Remaps layer over the matcher (override-first), so users who don't customize see byte-identical behavior; text-formatting / Tiptap keymaps and family shortcuts (`Ctrl+1..9`, find, accept/dismiss, `?`) stay fixed. Conflict detection blocks a remap onto any shortcut already in use — including the fixed matcher branches it derives live from the matcher itself, so loose branches like `Ctrl+Shift+/` (help), `Ctrl+Alt+F` (find), and `Ctrl+Shift+3` (jump-to-tab) can't be silently shadowed. Overrides are validated on load/merge (junk, non-bindable, fixed-colliding, and duplicate-chord entries are dropped). The Help modal reflects effective bindings.
69
+
70
+ ### Changed
71
+
72
+ - **Design system re-skin — annotation selection (QA follow-up)** — two pre-existing umbrella behaviors (not master re-sync regressions; sibling class to #837/#838/#840). **Empty selection is now a valid resting state.** The lifted `useAnnotationReview` auto-set effect previously force-selected the first pending annotation the instant the active id went null (vestigial bulk-review model — there's no dedicated review mode anymore), so a card was *always* highlighted. It now early-returns on null and only AUTO-ADVANCES when the currently-active annotation stops being live (resolved/deleted), landing on empty after the last one. A document opens with nothing selected. **Three deselect gestures** were added: `Escape`, clicking empty rail background (the annotation list container, via `e.target === e.currentTarget` so card / "resolved"-summary clicks pass through), and clicking editor text that isn't an annotation (new `onClearAnnotation` on `Editor`). Keyboard accept/dismiss falls back to the first pending review target when nothing is selected, via a shared `activeOrFirstPending` helper used by BOTH the `Ctrl+Enter` shortcut and the command-palette accept/dismiss commands (the two had silently diverged — palette no-op'd while the shortcut worked). Escape uses the `e.defaultPrevented` protocol; the reply-thread overlay, selection popup, and Help modal were made to consume Escape (capture-phase + `stopPropagation` for the two window-level ones) so closing them no longer also clears the selection. **Selected-card glow fixed** — `.is-review-target` used `box-shadow: 0 0 0 3px var(--tandem-accent-bg)`, the same color as the card fill, so once the one-shot `tandem-annotation-flash` faded the selected state read as a flat hard-edged stroke. Replaced with a contrasting accent-border edge + a soft accent glow (per the bundle's two-layer focus-ring idiom). Orphaned `getActiveReviewAnn` removed from the review hook. No `src/server/` changes; no annotation data-model, coordinate, or MCP-write paths touched; every testid preserved (`aria-current` is simply absent when nothing is selected). Per-text Claude authorship for wholesale Claude-generated documents is tracked separately as a feature (out of scope here).
73
+ - **Design system re-skin — empty states (3.11 D5, #896)** — `EmptyState.svelte` adopts the bundle's D5 recipe for its two real-triggered states: **A (no document open)** — stacked-docs-with-plus SVG, "Nothing open yet", and an **Open file…** pill wired to the file-open dialog; **C (server unavailable)** — broken-link SVG with a `--tandem-error` X (error conveyed by geometry *and* color, not color alone), "Server unavailable", a **Retry** pill wired to `yjsSync.reconnect()`, and an **Open settings** ghost link wired to the settings modal (bundle's "primary + ghost link, never two primaries"). The headline moves from production's `--tandem-font-serif` 17px to the bundle's **sans 15px/600** — an empty-state headline is UI chrome, not reading content, so it matches the rest of the re-skinned chrome (Bryan's sign-off). The disconnect-debounce `$effect` is preserved verbatim (no prop-in-cleanup hazard), and production's `connected && !claudeActive` Claude/MCP positioning line is kept on state A (carries information the bundle omits). Bundle state B (no production trigger — `EmptyState` only renders with no doc open) and the four extended 44×44 surfaces (no consumers) are intentionally not built; the app-window chrome framing is demo-only. New testids `empty-state-{open-file,retry,open-settings}`; decorative SVGs are `aria-hidden`. No `src/server/` changes, no annotation/coordinate paths. svelte-migration-reviewer CLEAR (pre-code plan pass).
74
+ - **Design system re-skin — annotation conversation (3.3)** — five surfaces in the annotation-card family align to a coherent recipe without touching the shipped-1.5 cardTint taxonomy or any coordinate path (card surface is range-free by design). **`AnnotationCardActions`** lifts every inline-style on the 5 button variants into a scoped `<style>` block with `.aca-btn--accept`/`--reject`/`--ghost`/`--send` classes, gains `:hover`/`:focus-visible` states (especially the `--send` accent-invert) that inline styles can't express, and respects dual-mechanism reduced-motion. **`AnnotationCardHeader`** lifts all 6 inline-style attributes into `.ach-*` classes; the per-variant `badgeBg`/`badgeFg`/`dotColor` tokens stay inline (they vary per variant prop) but the status-color ternary collapses into `class:is-accepted` / `class:is-rejected` directives and the `.ach-edit-btn` gains proper hover/focus states. **`AnnotationCard`** Expand-thread button becomes a `.tandem-expand-thread` pill consistent with the rest of the floating-chrome family; the sacred 6-branch `cardTint` (lines 80–88) is preserved verbatim — derived-spec §3.3's 2-tint anti-pattern stays superseded by 1.5's full-taxonomy tint, locked per Conflict #8. **`CommentThread`** swaps reply-author colors from `--tandem-accent` / `--tandem-fg-muted` to the semantic `--tandem-author-claude` / `--tandem-author-user` tokens (cluster 3.3 decision #6) and adds 4px author dots before each name so the rail family rhymes with the header's 6px dot at a smaller weight. **`ReplyThreadOverlay`** quoted-source block gains `font-family: var(--tandem-font-serif)` + `font-style: italic` (cluster 3.3 decision #7) so the quoted material reads as voiced text, not UI chrome. No `src/server/` changes, no range/coordinate paths touched, no MCP write paths touched, 33+ testids preserved, all `$state`/`$effect`/`$derived`/`bind:this` reactivity surfaces unchanged. crdt + annotation-model + svelte-migration reviewers CLEAR.
75
+ - **Design system re-skin — cowork trio (3.7 PR-C)** — final of three thin PRs for the combined 3.7+3.8 cluster. All three Cowork-integration surfaces lift their const-string `style` declarations + inline `style="..."` attributes into scoped `<style>` blocks with `.cos-*` / `.cs-*` / `.cad-*` classes. **`CoworkOnboardingStep`** drops the two const-string `primaryBtnStyle`/`secondaryBtnStyle`; gains `.cos-btn--primary`/`--ghost` with proper `:hover` and `:disabled` states (inline ternaries couldn't express these). **`CoworkSettings`** drops six const-string style declarations (`sectionLabelStyle`, `helpTextStyle`, `infoBannerStyle`, `errorBannerStyle`, `primaryBtnStyle`, `secondaryBtnStyle`); the `workspaceRowStyle(ws)` runtime computation is intentionally preserved as inline style because the per-row border/bg/fg vary by status family (success/warning/error) and the `STATUS_TOKENS` map is the source of truth — the new `.cs-workspace-row` class provides static display/radius/font-size and the inline overrides handle the family-specific tokens. The `cursor: wait` busy state moves to a `class:is-busy={busy}` directive driven by `$state`. **`CoworkAdminDeclinedModal`** aligns to the cluster-3.2 modal family — backdrop → `color-mix(in srgb, var(--tandem-bg) 70%, transparent)`, dialog → `--tandem-r-5` (error-border preserved as the surface's own identity), with `.cad-btn--primary`/`--ghost`/`--destructive` variants. All 30+ testids preserved across the three files. svelte-migration-reviewer CLEAR. 62/62 cowork unit tests pass.
76
+ - **Design system re-skin — wizard modals (3.7 PR-B)** — second of three thin PRs for the combined 3.7+3.8 cluster. Aligns the three model/integration modals to the cluster-3.2 modal family recipe without touching reactivity, secrets contracts, or testids. **`ModelEditModal`** (the big one — 100% inline-style block prior) hoists every `style="..."` attribute into a scoped `<style>` block with `.mem-*` classes: backdrop swaps to `color-mix(in srgb, var(--tandem-bg) 70%, transparent)`, dialog `--tandem-r-4` → `--tandem-r-5` + 1px border, width gains `min(480px, calc(100vw - 40px))` resilience, close-× adopts the family `.settings-modal-close` shape (28×28, `--tandem-r-2`, fg-subtle → fg + surface-sunk on hover/focus-visible — `:hover`/`:focus-visible` cannot be expressed inline), all fields/inputs/select share a single `.mem-field`/`.mem-input(--mono)` recipe, and the Save/Cancel buttons become `.mem-btn--primary`/`.mem-btn--ghost`. **The reveal-gated API-key contract (#659) is preserved verbatim** — `{#if isEditing && initialEntry?.apiKeyRef && !replacingKey}` still gates the masked-preview branch (`••••••••{existingKeyTail}` + Replace button), and the plaintext `type="password"` `autocomplete="off"` input only renders in `{:else}`. **`FirstRunModelPickerModal`** and **`IntegrationWizardModal`** already had `<style>` blocks; this PR aligns just the modal-family tokens — scrim → `color-mix`, dialog → `--tandem-r-5`, IntegrationWizard gains the 1px border, both close buttons reshape to the family 28×28 pattern, and IntegrationWizard's `.iw-actions button` gains `:hover` + `:focus-visible` states. `type="password"` and `autocomplete="off"` preserved on all three secret inputs; `integration-wizard-keychain-fallback` banner + testid preserved. Every existing testid preserved across all three modals (`model-edit-{modal,cancel,provider,displayname,modelid,apikey,apikey-replace-btn,endpoint,save,advanced}`, `first-run-{model-modal,providers,provider-{anthropic,openai,gemini,local-ollama,local-llamacpp},displayname,modelid,apikey,endpoint,error,save,skip,skip-secondary}`, `integration-wizard{,-close,-step-{detect,pick,secrets,review,saving,done,error},-continue-{detect,pick,secrets},-pick-{kind},-secret-{input,submit}-{id},-keychain-fallback,-save,-apply-result-{id},-done-close}`). All `$state`/`$effect`/`$derived`/`bind:this` reactivity surfaces untouched. Wizard step-indicator markup (per derived-spec §3.7) and the F7 success-flash are deferred to follow-ups — both require new reactive surface and are outside this PR's CSS/markup-only scope. security-reviewer + svelte-migration-reviewer CLEAR.
77
+ - **Design system re-skin — primitives (3.8)** — first of three thin PRs for the combined 3.7+3.8 cluster (split for reviewer focus on the security-sensitive wizard modals). **`CollapsibleSection`** gains a rotating chevron (`›` glyph, 140ms `transform` transition rotating to 90° on `[open]`) consistent with the rest of the floating-chrome family, and lifts its const-string inline styles into a scoped `<style>` block; the reduced-motion guard is dual-mechanism (`prefers-reduced-motion` + `body.tandem-reduce-motion`) parallel to cluster 3.10's authorship-decoration treatment. **`ApplyChangesButton`** lifts its inline conditional-ternary `style="..."` block into a `<style>` block with `.acb-btn` + `is-disabled` / `is-applying` class directives; the info-family token choice (`--tandem-info-bg` / `--tandem-info-fg`) is preserved deliberately — "Apply as Tracked Changes" reads as an informational hand-off to Word, not as a primary commit, so accent tokens are intentionally avoided here. testids preserved (`apply-changes-btn`; CollapsibleSection's `testid` + `${testid}-toggle` passthrough). Public API of both primitives unchanged so consumers (ModelEditModal etc.) rerender on the new internals automatically.
78
+ - **Design system re-skin — foundation (W0)** — hardened the bundle-token CI gate (`scripts/check-semantic-tokens.ts`) ahead of the Phase 3 cluster re-skins: added `#1e1e2e` (the D7 onboarding prototype's dark-swatch stand-in, sourced by cluster 3.11) to `BUNDLE_BLOCKLIST_HEX` with a pinning test, and corrected the now-stale bundle source path in the blocklist docstring. No runtime or token changes.
79
+ - **Design system re-skin — side-rail collapse (R)** — rebuilt the side-rail collapse from a swap (`{#if visible}<rail>{:else}<PeekStrip>`) into an always-mounted dual-layer shell: the full panel and a contextual peek sliver are both mounted, display-toggled by a `collapsed` state, so the rail's instance and scroll position persist across collapse instead of unmounting. The collapsed peek now previews its panel's contents — outline tick-marks (left) and one color-coded dot per annotation (right, keyed to the five-row author/type taxonomy). Collapse is a snap; the width-slide + opacity crossfade between layers is deferred to motion (#798). Testids, focus restoration, and the Alt+Shift+Arrow keyboard model are preserved.
80
+ - **Design system re-skin — banner family (3.1)** — three D2/D3/D4 banners now share the bundle's banner recipe consistently. The shared `tandem-banner.css` gains a `--tandem-shadow-1` lift on the base (per recipe — banners read as elevated above the editor surface) and a `--error` family modifier (parallel to the existing `--info`) so D2 ConnectionBanner can express its error semantics through tokens rather than an opaque override. **D2 ConnectionBanner** switches from `--info` to `--error` (lost connection is an error state, per recipe). **D3 UpdaterBanner** is unchanged (info family, already on shared class). **D4 ReviewOnlyBanner** is refactored from ~50 lines of inline styles into the shared `tandem-banner` class with `--info`; the dismiss control becomes the family's icon-× pattern and the banner gains `role="status"` + `aria-live="polite"` for family parity. CTAs (Retry / Restart-to-install / Convert-to-Markdown) preserved across all three — the recipe's "dismiss-only" anti-pattern is overruled by the bundle-vs-production rule: each CTA carries a recovery action specific to the banner's context that stripping would erase. testids, dismiss-key persistence, and the `Convert to Markdown` flow preserved.
81
+ - **Design system re-skin — batch + bulk (3.4)** — `BatchPromoteBar` adopts the bundle's quieter sticky-bar recipe: the bar surface moves from `--tandem-accent-bg` to `--tandem-surface` + `--tandem-border` + `--tandem-shadow-1` (the accent now reads as the "Send to Claude" CTA only — primary signal moves from the whole bar to the action button it carries), and the "N selected" count drops to `--tandem-text-2xs` / `--tandem-fg-subtle`. testids and the don't-render-when-empty guard preserved; BulkActions is intentionally untouched (it's a confirmation widget, not a sticky promote bar — the recipe's sticky/shadow shape doesn't apply, and its success-bg/error-bg semantic colors carry the accept-vs-reject information that a re-skin must preserve).
82
+ - **Design system re-skin — standalone surfaces (3.11, A9 + D7)** — applied the newly-shipped bundle designs for two of the three 3.11 surfaces. **A9 Highlight Color Picker**: the popover gains a `--tandem-r-4` radius, a top-pointing 1px-bordered caret (twin pseudo-element technique) toward its toggle, and each swatch is restructured from a single bg-colored button into a 24×24 transparent outer with a 20×20 colored inner + an absolute check SVG; hover gives a soft border + 1.12 scale + shadow-1, selected gives a 2px `accent-bg` + 3.5px `accent` glow ring plus the check. testids preserved (`toolbar-highlight-color-toggle`, `toolbar-highlight-color-{yellow|green|blue|pink}`, `color-picker-close`); no API change so the `FormattingBar`/`Toolbar` callsites stay untouched. **D7 Onboarding Tutorial**: the Next/Done button radius shifts from `--tandem-r-2` to `--tandem-r-pill` to match the rest of the floating-chrome family (one-line change; testids, activation gate, and localStorage try-catch preserved). **D5 Empty States deferred** — faithful bundle adoption requires functional CTAs (`Open file…`, `Retry`, `Open settings`) + state restructuring + a serif-vs-sans product call (production's `font-family: serif` headline is a deliberate Tandem-editor echo); filed for a follow-up iteration once consumer needs and design direction settle.
83
+ - **Design system re-skin — Settings tabs (3.6)** — the Settings shell (`SettingsModal.svelte`) already shipped the bundle's sidebar-nav + scrolling-content layout in Phase 1; this cluster cleans up the per-tab content styling against the bundle's `ui_kits/app/Settings.svelte` content recipe without stripping production-specific information. Added two shared `:global` classes to the shell — `.settings-hint` (11px / `--tandem-fg-subtle` / 1.4 line-height) and `.settings-mode-btn(s)` (semantic-radiogroup segmented control with `[aria-checked="true"]`-driven active state) — and replaced inline 10px hint blocks across Collaboration + ClaudeCode tabs and the inline Tandem/Solo mode-button recipe in Collaboration with those classes. Models' provider-group header consumes the existing shared `.settings-section-label` instead of duplicating its inline style. About's data row is monospaced (matching the bundle) and uses the bundle's 14px column gap. No structural changes, no testid changes, no production information removed; Shortcuts is a no-op (already minimal). The bundle's pill-input shape, the About "View Documentation" button promotion to mode-btn styling, and the in-tab top border above About are intentionally NOT adopted — those are decoration calls that touch production's longstanding shape and warrant explicit sign-off.
84
+ - **Design system re-skin — selection mini-toolbar (A8)** — the selection popup realigns to the final A8 spec ("2026-05-26"). The formatting row regroups to `B I S </> Link │ H lists quote code-block │ Decorations │ swap`: **Link moves up into the inline-marks group** (right after Code) and **Heading leads the block group** (this reorder also flows to the persistent formatting bar, which mirrors the format row). **Two-pill capsule layout** — the format state is now a transparent column of two independently-bordered `.tandem-floating-pill` capsules (`popup-format-row` = controls, `popup-annotate-row` = swatches + Annotate) separated by a 5px gap the editor shows through, replacing the former single pill with an internal hairline; the outer shell sheds its chrome in the format state and re-acquires it as the note-popover card in the annotate state, with the A26 morph's P1 now tweening the full chrome (bg/border/shadow/backdrop) alongside border-radius. **Heading shows a serif `H▾`** (serif glyph + faint caret; the active level "H1/2/3" readout is preserved) in both popup and bar. The **Annotate button gains a leading pencil icon**. The **horizontal-rule control is dropped from the popup** (it stays on the persistent bar) — a sanctioned override of Conflict #5's full-mirror for the popup (inserting an `<hr>` over a text selection has no sensible meaning; recorded in `conflicts-resolved.md`). The annotate row's highlight strip **leads with a "none"/eraser swatch** (`popup-highlight-none`) that clears any user highlight on the selection in one click via the client-side `clearHighlight()` (accepted/edited highlights preserved; `withBrowser` origin-tagged). The popup's bar control is a **persistent hide/show-bar swap** (`popup-show-formatbar-btn`, toggles both ways; aria-label switches "Hide/Show formatting bar"). Client-only, no `src/server/` change; every existing testid preserved (`popup-format-row`/`popup-annotate-row` added). M1/A26 + M2/A28 motion rides on top of this surface.
85
+ - **Design system re-skin — find/replace + mode toggle (3.9)** — two CSS-only re-skins layering the bundle's B4 Find/Replace and A8 ModeToggle recipes onto the existing components. **FindReplaceBar** lifts every inline `style="..."` attribute into a scoped `<style>` block with `.fr-*` classes (recipe-faithful to B4): inputs default to `--tandem-surface-sunk` and lift to `--tandem-surface` on `:focus` (so the bar's content reads as nested into its floating surface), the match-count chip moves to `--tandem-font-mono` (state-not-content), prev/next nav buttons + close-× gain proper `:hover`/`:focus-visible` states that inline styles can't express, and replace buttons gain a quiet hover wash. Scope pills consolidate from inline ternary recipes onto an `.fr-scope-pill.on` class pattern. Production-specific anchoring (bottom-right of the editor pane, rather than the bundle's top-right) is preserved — position is information, not decoration. The 13 `$state` / 4 `$effect` / 2 `$derived` / `bind:this={queryInput}` reactivity surface is untouched, and every testid (`find-replace-bar`, `find-input`, `find-scope-pills`, `find-scope-{doc,tabs}`, `find-match-count`, `find-{prev,next,close}-btn`, `find-{case,word,regex}-toggle`, `replace-input`, `replace-{btn,all-btn}`, `find-cross-doc-results`) is preserved. **ModeToggle** aligns to the bundle's `.a8 .seg` recipe: 2px track padding + 1px border (was 3px no border), font-size 12→11px / weight 600, button padding 4×12→5×14px, active-pill shadow swapped to the `--tandem-shadow-1` token. The A8 sliding-thumb motion is intentionally deferred to motion (#798). DocumentTabs is intentionally untouched — the bundle's `.c7-tab` recipe already lives on `TabItem.svelte` from Phase 1.2/1.13.
86
+ - **Design system re-skin — modals (3.2)** — the three full-screen modals (`HelpModal`, `FileOpenDialog`, `ErrorBoundary`) align to the bundle's modal recipe. Backdrops swap from `rgba(0, 0, 0, 0.x)` to `color-mix(in srgb, var(--tandem-bg) 70%, transparent)` so the wash adapts to theme (warm/dark/light) rather than always reading as black. Dialog shells lift from `--tandem-r-4` to `--tandem-r-5`; `FileOpenDialog` additionally gains the `1px solid var(--tandem-border)` family-defining hairline (already present on `HelpModal`). The close `×` button on both `HelpModal` and `FileOpenDialog` adopts the `.settings-modal-close` shape (28×28 grid, `--tandem-r-2`, `--tandem-fg-subtle` on transparent → `--tandem-fg` on `--tandem-surface-sunk` hover/focus-visible) as a per-component scoped class — `:hover`/`:focus-visible` cannot be expressed inline. `ErrorBoundary` gains a `min-height: 36px` on its `.btn` rule (anti-pattern guard); its inline-error layout intentionally stays as-is — promoting it to a full modal surface is a markup restructure outside this cluster's scope. testids preserved on all three surfaces (`help-modal`, `help-modal-close`, `file-open-dialog`, `file-open-browse`, `error-boundary-recover-btn`, `error-boundary-reload-btn`); reactivity (HelpModal's focus-trap `$effect`, `ErrorBoundary`'s recovery counter + `<svelte:boundary>`) unchanged.
87
+ - **Design system re-skin — annotation decorations (3.10)** — the inline annotation/authorship decorations already follow ADR-026 (character-level `data-tandem-author` attributes with a paragraph-level dominant-author gutter) and use the protected color tokens, so production stays the source of truth for this surface. This cluster closes a latent accessibility gap: the Claude-presence animations (the active-paragraph gutter pulse and the character-cursor blink) were not honored under reduced motion. They are now wrapped for both the OS `prefers-reduced-motion` pref and the in-app `reduceMotion` setting (`body.tandem-reduce-motion`), staying fully visible but static. Locked the gutter-reduction invariant with a unit test: a mixed-author paragraph keeps both per-character tints under a single dominant (majority-chars, user-wins-tie) gutter bar. No token or coordinate-path changes.
88
+ - **Audience & monetization direction recorded (ADR-040)** — documentation now reflects the decided product direction: Tandem targets **individuals** (not institutions), the moat is the **same-canvas / no-copy-paste review experience** backed by **persistent, queryable annotations + the .docx review-record loop**, and monetization is **free during public beta → a one-time paid license at v1.0** with **offline signed-license activation**. Existing beta users will be grandfathered with a free license. Updated `docs/decisions.md` (new ADR-040; ADR-039 reserved for the Agent SDK adapter), `README.md`, `docs/positioning.md`, `docs/roadmap.md` (#394), `docs/security.md`, `docs/workflows.md`, and `docs/user-guide.md`. No code changes — the in-app license-verification, trial gate, and license-checked updater are v1.0 engineering work tracked separately.
89
+
90
+ ### Fixed
91
+
92
+ - **Authorship gutter no longer renders as a full-height bar down the editor** — the per-paragraph authorship thread (#518B) anchored its `position: absolute` `::before` to the wrong element: the intended positioning-context rule used a `.tandem-editor .ProseMirror p` descendant selector, but Tiptap stacks both classes on a single node, so the selector matched nothing and non-empty paragraphs stayed `position: static`. The gutter then anchored to `.tandem-editor` (the ~500px editor body) and stretched the full height instead of spanning its one block. Empty paragraphs masked the bug because `p.is-empty` carries its own `position: relative`, so the bar only appeared once real text was typed. Fixed by correcting the selector to `.tandem-editor p, …h1–h6`; the same latent defect in `.docx` paged layout is corrected too.
93
+ - **Markdown save no longer leaves `\@` escape noise in non-email prose (#850)** — the serializer's #605 un-escape chain now conditionally reverses `\@`→`@` in positions that cannot re-form a GFM email autolink-literal (e.g. `@`-handles, `@` with no host, numeric-only TLDs), while keeping the escape where a `local@domain`-shaped host follows, for canonical output consistent with how `\[`/`\_` are handled. The host guard is deliberately conservative — verified zero false-negatives against the GFM autolink boundary, including the leading-dot host `user@.com`. This is an escape-noise cleanup, not a structural-safety fix: CommonMark un-escapes `\@`→`@` at parse time, so an email-shaped `@` autolinks on the next load regardless of the escape. Follow-up to PR #849.
94
+ - **Orphaned atomic-write temp files are reaped on startup** — a `.tandem-tmp-*` sibling left behind when the process is force-killed (dev restarts, crashes) between `writeFile` and `rename` is now swept from the annotations and sessions dirs at boot if it's over an hour old. Files younger than an hour, and all real store/session files, are never touched.
95
+
8
96
  ## [0.13.0] - 2026-05-25
9
97
 
10
98
  ### Added
@@ -30,6 +118,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
30
118
 
31
119
  ### Changed
32
120
 
121
+ - **Design system re-skin (umbrella `feat/design-system-impl`):**
122
+ - TitleBar + BrandMenu + ModeToggle re-skinned to the bundle's visual language (sub-PR 1.1)
123
+ - Added `--tandem-swatch-light|dark|warm` semantic tokens for the BrandMenu theme-picker chips (fixed-identity colors, `:root` only)
124
+ - FormatBar pill chrome re-skinned to the bundle's recipe — 26px buttons, `--tandem-r-pill` corners, hover/active states moved from inline style to scoped CSS so `:hover` / `:focus-visible` win the cascade (sub-PR 1.2)
125
+ - Authorship toggle moved from TitleBar to FormatBar (sub-PR 1.2). Testid renamed `toolbar-authorship-toggle` → `formatbar-authorship-toggle`. Keyboard shortcut `Ctrl+Alt+A` and command-palette action unchanged. (The standalone toggle was later subsumed into the Decorations control — sub-PR 1.13 below.)
126
+ - Editor body + left outline rail re-skinned (sub-PR 1.3) — h1/h2 type-role tokens with serif display face and tightened tracking, outline-panel header label, active-heading tick, and a 12px edge-collapse rail with a centered grip bar. Active tick is driven by reactive `style=` on the span (a scoped-CSS descendant override failed to settle on first paint). Bundled live-smoke fixes: scroll-spy now measures its threshold from the scroll container rather than the (content-scrolling) ProseMirror element, so the active heading tracks scroll instead of freezing on the first heading; an "End of document" pill adds trailing scroll room so the last heading can be pinned to the top from the outline; the right edge-collapse rail runs full-height to match the left; and the Solo/Tandem ModeToggle track uses `--tandem-surface-sunk` so the active pill stays visible in dark mode.
127
+ - Collapsed-rail peek strip enriched (sub-PR 1.4) — hovering or focusing a peek strip now widens it to 28px and reveals a rotated panel label ("Outline" / "Annotations"). The rail container, body scroll-fade, and edge-collapse grip already matched the bundle (shipped via Wave E + sub-PR 1.3), so no change there. The right rail's Annotations/Chat tabs intentionally keep the production segmented-pill toggle rather than the bundle's underline tabs (consistent with the ModeToggle). The bundle's peek content preview (outline ticks / annotation dots) is deferred pending data plumbing + motion work.
128
+ - Annotation cards re-skinned (sub-PR 1.5) — the 3px left-edge type border is replaced by a full-card background tint per type (note → warning, suggestion → violet, comment → author-tinted, highlight → its own color, imported → neutral), a 6px authorship dot is added to the card header (user/Claude; imported keeps its byline), and the card chrome is lifted: rounder corners (`--tandem-r-5`), a soft resting shadow that raises on hover, and pill-shaped action buttons. Added `--tandem-author-user-bg` / `--tandem-author-claude-bg` tint tokens (light: `color-mix`; dark: hand-coded saturated hex). The five-file card split, dispatcher, audience-first separation, `annotation-private-pill`, suggestion diff block, and all testids are preserved (Conflict #8 "lift color" interpretation — no render-path merge, no `src/server/` changes).
129
+ - Command palette re-skinned (sub-PR 1.6) — added a leading search glyph and a trailing "Esc" keycap chip to the input row, a shared keycap treatment for the footer prefix hints (`#`/`@`/`?`/`>`) over a `--tandem-surface-muted` bar, rounded inset result rows, a wider (640px) modal with `--tandem-r-5` corners, and a `backdrop-filter: blur` overlay. The action registry contract is frozen (visual-only per Conflict #3) and the prefix-routing keyboard-hint footer is preserved; the bundle's per-item icons and section headers were intentionally skipped because they would require registry-contract changes and don't fit production's prefix-routed result model. All testids preserved; no `src/server/` changes.
130
+ - Command palette correctness follow-ups (sub-PR 1.6) — Escape now dismisses the palette regardless of which element holds focus (a capture-phase window listener replaces the unreliable modal-div `onkeydown`), and the dimming overlay's `z-index` was raised above the title bar's decorum drag-lift (`99999`) so the `+`new-tab button and Solo/Tandem toggle are covered by the backdrop instead of poking through.
131
+ - Settings shells re-skinned (sub-PR 1.7) — the SettingsModal and the legacy SettingsPopover were aligned to the bundle's `ui_kits/app/Settings` recipe: 12px dialog corners (`--tandem-r-5`), a 28px grid-centered close button with a `--tandem-surface-sunk` hover, sidebar `gap` tightened to `--tandem-space-3`, and nav-button hover/transition parity across both shells. The popover's close button moved from an inline `style=` to a `.settings-close-btn` class so `:hover`/`:focus-visible` apply (it previously had neither), and the popover nav buttons gained the missing `:hover` + focus ring. The responsive shell (narrow-viewport hamburger / 640px reflow) and tab registry are frozen (Conflict #4); tab-body content is deferred to Phase 2/3. The version chip stays `--tandem-fg-subtle` rather than the bundle's `--tandem-fg-faint` (which lowers contrast on the 10px chip below the AA-safe margin; Conflict #6 production-tokens-win). All testids preserved; no `src/server/` changes.
132
+ - StatusBar pill aligned to the bundle (sub-PR 1.8) — the floating status pill's vertical padding was bumped (`4px`→`6px`) and its forced height (`--tandem-h-statusbar`, a phantom token with no other consumer) dropped so the pill is content-driven like the bundle's `.status-pill`; the connection and Claude status dots shrank `8px`→`7px` to match the bundle's `.claude-pulse`. Base text color kept at `--tandem-fg-muted` (not the bundle's `fg-subtle`): the pill sits at `opacity: 0.4` at rest, so the lower-contrast subtle tone would compound below the AA-safe margin (same rationale as the 1.7 version chip). The bundle's `·` group separators were deferred (a structural change better reviewed visually). All functionality preserved (faint-until-hover, word-count cycle, connection states, display-name input, Review-Only, Claude working pill); all testids preserved; no `src/server/` changes. StatusBar is not in the baseline capture set, so no baseline regen.
133
+ - Recents store now carries open timestamps (sub-PR 1.9a) — the `tandem:recentFiles` localStorage shape migrated from `string[]` to `{ path, openedAt }[]` (legacy entries coerce to `openedAt: 0`; malformed entries dropped). Re-adding an already-present path preserves its original timestamp so the open-tab recents-sync doesn't churn. Groundwork for the a7 new-tab launcher (1.9b); no user-visible change yet. **1.9 was elevated from "clean port" to a full feature rebuild by an explicit scope override — see `docs/design-system-impl/conflicts-resolved.md` → Applied Overrides.**
134
+ - New-tab menu rebuilt as the a7 two-column launcher (sub-PR 1.9b) — the single-column recents dropdown is replaced by a searchable launcher: a full-width search bar filtering recents by name + path (with a live "{n} of {total}" count, clear button, and match highlighting), a left column of recents (file-type pip, name, directory, and a relative "when" label derived from 1.9a's timestamps), and a right column of actions — primary "New scratchpad", "Browse files…", and "Reopen last closed" (shown only when the in-session closed-tab stack is non-empty), each with its production shortcut hint. A keyboard-hint footer, search auto-focus on open, and ↑↓ list navigation round it out. The closed-tab stack (`useClosedTabStack`) was promoted to a reactive `.svelte.ts` singleton so the reopen affordance enables/disables live; clipboard import from the bundle was intentionally dropped (marginal over `Ctrl+N`+paste). Added `--tandem-filetype-txt` / `--tandem-filetype-html` pip tokens. Preserves `palette-item-new-scratchpad`; adds `new-tab-{search,recent-*,browse,reopen-closed,empty,no-match}`. No `src/server/` changes (clipboard's server path was never wired).
135
+ - Activity center added (sub-PR 1.10a) — the d1-toasts bundle is an activity center, not a toast restyle, so notifications now feed two surfaces from one store: warning/error notifications briefly POP a transient toast (re-skinned to the bundle's icon-square + message + `×count` vocabulary) AND land in a new persistent activity tray (bottom-right pill that expands into a scrollable history with severity glyph, relative time, coalesce count, hover-reveal dismiss, and "Clear all"). The tray is localStorage-backed (`tandem:activityHistory`, capped at 50, info entries TTL-pruned) and survives reload. Info notifications are gated by entry point: client-originated echoes (user actions) pop briefly, ambient server (SSE) info stays quiet-to-tray. New testids `activity-pill`, `activity-tray`, `activity-empty`, `activity-clear-all`, `activity-row-{id}`, `activity-dismiss-{id}`; transient `toast-*` testids preserved. The bundle's trayIn/rowIn/ledpulse animations are deferred to #798 (Conflict #9 — shipped static). No new color tokens; no `src/server/` changes. **Elevated from "clean port" to a feature by an explicit scope override — see `docs/design-system-impl/conflicts-resolved.md` → Applied Overrides.**
136
+ - Activity-tray Retry action (sub-PR 1.10b) — a `save-error` row now shows a "Retry" button that re-runs the save for the failed document (`activity-action-{id}`). If that document was closed since the error fired, Retry instead surfaces a "Reopen the document to retry" notice (a closed doc can't be saved). A pure `resolveActivityAction` resolver maps notification `type` → action; only `save-error` has a safe v1 semantic, Undo is deferred. Client-only (no `src/server/` changes); the server re-emits `save-error` on a failed retry, so no client error path is needed.
137
+ - Slash menu re-skinned to the bundle's B3 design (sub-PR 1.12) — each block row now leads with a 26px icon badge (¶-style heading glyphs / stroke-SVG list, quote, and code icons) and trails a mono shortcut-alias chip (`h1`, `ul`, `code`, …), inside a wider card-shaped surface (`--tandem-r-5`, 256px). The selected row drops its border in favor of an accent fill + accent-tinted badge; its shortcut chip text uses `--tandem-accent-fg-strong` rather than the bundle's `--tandem-accent` (accent-on-accent-bg is AA-marginal in light mode — same Conflict #6 call as the 1.7 version chip / 1.8 status pill). Icon badge and alias chip are `aria-hidden` with an explicit `aria-label` so each option's accessible name stays exactly its label. Visual-only: the command registry (8 blocks, existing labels), the inline `/`-typing trigger, live filtering, and keyboard/pointer behavior are unchanged; the bundle's separate filter-input row and empty-state are intentionally dropped (production filters inline and closes on zero matches), and the `popIn` animation is deferred to #798 per Conflict #9. All testids/roles preserved; no `src/server/` changes; not a baseline-capture scene, so no baseline regen.
138
+ - Decorations control (sub-PR 1.13) — annotation/authorship display toggles consolidated into a **Decorations split button in the formatting bar**: the eye half mutes/restores all decorations (clean reading view ⇄ restore), the caret half opens per-type options (authorship colors, comments, highlights, notes). This **subsumes the standalone authorship toggle** added in 1.2 — the `formatbar-authorship-toggle` testid is removed and authorship becomes the dropdown's first row (`Ctrl+Alt+A` and the command-palette action are unchanged). The single `showAnnotationDecorations` flag (#596) is split into per-type `showComments` / `showHighlights` / `showNotes` settings plus a transient `decorationsMuted` master overlay; a mirrored four-row group lives in Settings → Appearance. Master mute is an overlay (it never clobbers the per-type prefs, so restore returns exactly the prior set); editing any row auto-unmutes. The control is mounted outside the formatting bar's overflow-clip track so its dropdown is never clipped and never truncates on a narrow window. Display-only — ADR-027 is unchanged (notes are still never read by Claude; this only hides the user's own marks in their own view). Settings schema migrated v8→v9 (old "all marks off" maps onto all three per-type flags). No `src/server/` changes.
139
+ - Selection surface + optional formatting bar (sub-PR 1.11) — the on-text selection popup is now **always the full stacked surface**: a format pill (reusing `FormattingToolbar` in a new `variant="popup"` mode — the full mark/block set minus Undo/Redo, which stay on the bar) plus the **mirrored Decorations control**, stacked over the annotate pill (highlight swatches + Annotate). The persistent floating formatting bar becomes **optional/hideable** via a trailing collapse control (`formatbar-hide-btn`), governed by a new `formattingBarVisible` setting (default `true`, schema migrated v9→v10); restore it via the **Show formatting bar** button that appears in the selection popup while the bar is hidden (`popup-show-formatbar-btn`, the symmetric affordance to the bar's collapse control), the **Toggle formatting bar** command-palette action, or the new **Show formatting bar** Appearance toggle (`appearance-formatting-bar`) — and formatting stays fully reachable in the popup while the bar is hidden. The annotate popover's keybindings changed (Conflict #5, overridden 2026-05-26): **plain `Enter` = newline**, **`Alt+Enter` = Note to self** (private), **`Ctrl/Cmd+Enter` = Send to Claude** (outbound); both submits are modifier-gated and empty-guarded (the "Comment" button is relabeled "Send to Claude", testid `popup-comment-submit` preserved). Display/UI only — ADR-027's audience-first model is unchanged (notes never read by Claude); all `popup-*` / `toolbar-link-*` / `toolbar-highlight-*` / `decorations-*` testids preserved; no `src/server/` changes.
140
+ - Modal layering tokenized — title-bar poke-through fixed app-wide (#839). Introduced `--tandem-z-titlebar` (the `tauri-plugin-decorum` drag-overlay lift) and `--tandem-z-above-titlebar` tokens, replacing the raw `99999`/`100000`/`100001` magic numbers in TitleBar and SettingsModal/CommandPalette. Every full-screen modal overlay now sits above the title bar so its controls are dimmed, not clickable through: HelpModal, FileOpenDialog, ModelEditModal, IntegrationWizardModal, FirstRunModelPickerModal, CoworkAdminDeclinedModal, and the legacy SettingsPopover. `ReplyThreadOverlay` additionally portals to `<body>` because it renders inside the right rail's stacking context (`z-index: 1`), where a raised z-index alone couldn't escape. Contextual z-indexes that live inside the title bar's own stacking context (brand menu, selection toolbar) are deliberately unchanged.
33
141
  - **Network settings split into Connection / Advanced (Wave 2 PR 6)** — connection status, transport, and the restart-sidecar button stay always-visible; loopback port, degraded-banner delay, reconnect strategy, hold-while-offline, and token rotation collapsed under a new "Advanced" disclosure. Disclosure state is ephemeral (resets each time the modal opens). New `CollapsibleSection.svelte` primitive uses native `<details>/<summary>` for free keyboard a11y.
34
142
  - **v7 floating-chrome redesign sweep (Waves 1–M)** — a multi-wave reskin that lifts the editor chrome off the canvas:
35
143
  - Edge-anchored side rails with inner-rounded corners (Wave 2, #739) and a shared `.tandem-floating-pill` recipe (Wave 3, #740) applied across the formatting bar, status bar (Wave 5, #741), selection popup (Wave C, #762), and slash menu (Wave 10, #757).
package/README.md CHANGED
@@ -55,10 +55,12 @@ Tandem is built to work with Anthropic's Claude out of the box. Other AI tools c
55
55
  ## Who Tandem is for
56
56
 
57
57
  - If you draft long-form writing and want a second reader for tone and structure.
58
- - If you review contracts, policies, RFP responses, or compliance filings and want a faster pass.
59
- - When a colleague hands you a report to mark up.
58
+ - If you review documents — an essay, a thesis chapter, a report, or a contract and want a faster pass.
59
+ - When a colleague hands you a document to mark up.
60
60
  - When the AI wrote a draft and you need to decide what to keep.
61
61
 
62
+ Tandem is built for individuals working on their own documents. The example document types above are just that — examples; the workflow is the same whatever you are writing or reviewing.
63
+
62
64
  ## Getting started
63
65
 
64
66
  ### System requirements
@@ -104,13 +106,13 @@ See [docs/workflows.md](docs/workflows.md) for examples of how this looks in dai
104
106
  - Tandem itself runs on your computer and stores your documents on your disk. We do not operate any servers that hold your files.
105
107
  - When you ask the AI to do something, the text you share with it goes to whichever AI service you are using. For example, if you connect Claude, the text goes to Anthropic under their terms. Tandem does not relay or copy your document anywhere else.
106
108
  - Tandem includes a private notes feature. Notes you mark as private are stripped from every response the AI sees ([ADR-027](docs/decisions.md)).
107
- - Tandem does not collect telemetry or analytics.
109
+ - Tandem does not collect telemetry or analytics — no usage data, no crash reporting. When paid licensing arrives at v1.0, running the app will validate a signed license file on your own machine (no network call required); update checks will remain network-only, carry no analytics, and the update service will log only what it needs to authorize the download.
108
110
 
109
111
  See [docs/security.md](docs/security.md) for the full security model.
110
112
 
111
113
  ## Where Tandem is headed
112
114
 
113
- Tandem is on the way to a v1.0 release. Recent releases added support for multiple AI providers and in-app configuration for connections and models. Work still in progress covers improvements to how Word documents round-trip through the editor, turnkey setup on macOS and Linux, and final polish. The full plan lives in [docs/roadmap.md](docs/roadmap.md).
115
+ Tandem is on the way to a v1.0 release. Recent releases added support for multiple AI providers and in-app configuration for connections and models. Work still in progress covers improvements to how Word documents round-trip through the editor, turnkey setup on macOS and Linux, and final polish. Tandem is free during the public beta; at v1.0 it moves to a one-time paid license, and beta users are grandfathered with a free license. The full plan lives in [docs/roadmap.md](docs/roadmap.md).
114
116
 
115
117
  ## Documentation
116
118
 
@@ -129,7 +131,7 @@ Tandem is on the way to a v1.0 release. Recent releases added support for multip
129
131
 
130
132
  ## License
131
133
 
132
- Tandem is free to use. It is licensed under the Business Source License 1.1 (BUSL-1.1); see [LICENSE](LICENSE) for the terms.
134
+ Tandem is free during the public beta. At v1.0 it moves to a one-time paid license; existing beta users are grandfathered with a free license. It is licensed under the Business Source License 1.1 (BUSL-1.1); see [LICENSE](LICENSE) for the terms.
133
135
 
134
136
  ---
135
137
 
@@ -145,7 +147,7 @@ The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is an open s
145
147
 
146
148
  The integration policy is set by [ADR-038](docs/decisions.md#adr-038-mcp-first-integration-policy-claude-as-default-integration):
147
149
 
148
- > Tandem's integration contract is **MCP**. The default integration is **Claude** (Claude Code + Claude Desktop) — it's what we recommend, what we test against, and it ships with the channel push, cowork, plugin monitor, and auto-launcher features. Any MCP-capable client can connect to the same MCP HTTP endpoint and use the same 26 tools, but the Claude-specific transports don't apply. Other clients are **best-effort, MCP-contract-compatible, not validated** today.
150
+ > Tandem's integration contract is **MCP**. The default integration is **Claude** (Claude Code + Claude Desktop) — it's what we recommend, what we test against, and it ships with the channel push, cowork, plugin monitor, and auto-launcher features. Any MCP-capable client can connect to the same MCP HTTP endpoint and use the same 27 tools, but the Claude-specific transports don't apply. Other clients are **best-effort, MCP-contract-compatible, not validated** today.
149
151
  >
150
152
  > **Integration setup** runs through the integration setup wizard (#477 PR 3). Today's transitional behavior — Tandem auto-writing its MCP entry to Claude's config files on Tauri startup — is **deprecated when the wizard ships**. Going forward, every integration (Claude included) is configured via the wizard, never silently.
151
153
 
@@ -161,7 +163,7 @@ Client compatibility:
161
163
 
162
164
  ### MCP tools at a glance
163
165
 
164
- 26 active tools across five capability areas. Full reference: [docs/mcp-tools.md](docs/mcp-tools.md).
166
+ 27 active tools across five capability areas. Full reference: [docs/mcp-tools.md](docs/mcp-tools.md).
165
167
 
166
168
  - **Document.** Open, switch, list, close, and convert documents; read text content and outlines; save back to disk.
167
169
  - **Annotation.** Create, resolve, remove, and edit annotations; query the annotation list; export a review report.
@@ -18200,7 +18200,7 @@ var HighlightColorSchema = external_exports.enum(["yellow", "green", "blue", "pi
18200
18200
  var SeveritySchema = external_exports.enum(["info", "warning", "error", "success"]);
18201
18201
  var TandemModeSchema = external_exports.enum(["solo", "tandem"]);
18202
18202
  var AuthorSchema = external_exports.enum(["user", "claude", "import"]);
18203
- var ReplyAuthorSchema = external_exports.enum(["user", "claude"]);
18203
+ var ReplyAuthorSchema = external_exports.enum(["user", "claude", "import"]);
18204
18204
  var AnnotationActionSchema = external_exports.enum(["accept", "dismiss"]);
18205
18205
  var ExportFormatSchema = external_exports.enum(["markdown", "json"]);
18206
18206
  var DocumentFormatSchema = external_exports.enum(["md", "txt", "html", "docx"]);