# Changelog

All notable changes to Tandem will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.14.0] - 2026-06-10

### Added

- **Structured output for typed MCP clients (#1080)** — six data-returning tools (`tandem_status`, `tandem_getTextContent`, `tandem_getAnnotations`, `tandem_checkInbox`, `tandem_listDocuments`, `tandem_search`) now advertise an MCP `outputSchema` and emit `structuredContent` alongside the unchanged text envelope, so non-Claude clients can validate responses end-to-end. Error responses from these tools carry the MCP-level `isError: true` flag. Per ADR-027 the annotation schemas structurally exclude `type: "note"` — user-private notes cannot appear in structured payloads. Tool descriptions across the catalog were also tightened (~5% smaller model-visible catalog), and stale `tandem_getAnnotations` documentation claiming notes were readable via `type: "note"` was corrected.
- **`.docx` external-conflict detection + restore-vs-reload prompt (#1069, #576 follow-up)** — editable `.docx` files were not file-watched, so an external edit (Word, another tool) landing while Tandem held unsaved edits went completely undetected until an explicit save silently skipped ("file modified externally"); and on restart, a session holding unsaved `.docx` edits either restored silently or — if the disk file had changed — was **silently discarded**, losing the only copy of those edits (binary formats never auto-save). Now: (1) `.docx` files are **file-watched** like `.md` — a clean document reloads from disk in place (annotations preserved + re-anchored; the reload path reads the raw bytes, never a utf-8 decode), while a document with **unsaved edits is never auto-reloaded**: it gets a per-document conflict flag, surfaced as a non-blocking warning banner ("**Keep my edits**" re-baselines so your next explicit save overwrites the disk version; "**Reload from file**" discards the unsaved edits and loads the disk content). Tandem's own `.docx` save never trips the detector (the binary save branch arms the watcher suppression before writing, consumed at event arrival). (2) Sessions now record whether they hold **unsaved edits** (`dirty` flag), and reopening a dirty `.docx` session raises the same keep-vs-reload banner — including when the disk file changed since the session was saved, in which case the session (the only copy of your edits) is **restored instead of dropped**, with the divergence flagged. A successful explicit save or a completed reload clears the flag automatically. New `POST /api/docx-conflict/resolve` route, `external-conflict` notification type, `docx-conflict-banner` / `docx-conflict-keep-btn` / `docx-conflict-reload-btn` testids, and a `tandem-banner--warning` variant. Also fixed along the way: file-watcher reloads now refresh the saved-baseline mtime, so an external reload no longer leaves the document permanently blocked from saving ("file modified externally" forever), and force-reload no longer stamps a writable `.docx`'s metadata read-only (#576 leftover).
- **Saving a `.docx` now writes pending comments back as real Word comments** (#1068, #576 v1.1) — explicit saves of a writable `.docx` emit every pending `comment`-type annotation as a native Word comment (`comments.xml` + `commentRangeStart`/`End` markers), anchored to the annotation's *current* range (CRDT `relRange` first, flat offsets as fallback; annotations whose ranges no longer resolve are skipped with a warning instead of failing the save). A Word comment you imported and promoted keeps its original comment identity across save → re-open, so it round-trips without duplicating. Privacy gate per ADR-027: `note` and `highlight` annotations — including un-promoted imported reviewer comments and all private replies — are **never** written to the file. Threaded replies flatten into the comment body with attribution lines (`docx@9.x` cannot write Word's threading part); accepted/dismissed comments are dropped from the saved file rather than resurrected. Also fixes a v1.0 body-export bug where hard line breaks were written *before* their text, reversing line order around `Shift+Enter` breaks.
- **In-product restore for pre-overwrite document backups (#1086)** — the pre-overwrite snapshots Tandem takes of `.md`/`.txt` files (see "Pre-overwrite document backups" below) were previously restorable only by hand (file-manager copy + quit-Tandem dance documented in `docs/troubleshooting.md`). Restore is now discoverable in-product on two surfaces. **MCP (primary, ADR-038):** `tandem_restoreBackup` — previously `.docx`-sidecar-only — now also covers text documents: call it without `backup` to list a document's snapshots (name + ISO timestamp + size, newest first), then with `backup` set to a snapshot name to restore it; the `.docx` sidecar behavior is unchanged. **Command palette:** a new "Restore a backup of this document…" action lists the snapshots and restores the most recent after a confirm (the palette can't render dynamic sublists, so older snapshots route through Claude). The restore is **not** a raw byte copy under an open document: it writes via `atomicWrite` wrapped in `suppressNextChange` (so the file watcher doesn't misread Tandem's own write as an external edit) and then routes through the file-watcher reload lifecycle (`reloadFromDisk`) — annotations are preserved and re-anchored, event observers reattach, the doc is marked clean, and the saved-baseline is refreshed so autosave isn't wedged by the "file modified externally" guard. The pre-restore on-disk bytes are themselves snapshotted first (once-per-run gate permitting), so a restore is reversible. Snapshot names are validated against the snapshot-tail regex + bare-basename check before touching the filesystem — `source.txt`, traversal shapes, and arbitrary names can never resolve. New loopback routes `GET /api/backups` + `POST /api/backups/restore` back the palette action; errors map to the standard coded set (`FILE_NOT_FOUND`, `READ_ONLY`, `RELOAD_IN_PROGRESS`, …). Unit-tested end to end (`tests/server/restore-backup.test.ts`): enumeration, restore-through-reload with annotation survival + watcher suppression, `.docx` sidecar unchanged, and the error cases.
- **Uninstall now cleans up after itself on every platform (`tandem --uninstall-scrub`)** — uninstalling Tandem used to leave orphans behind: `mcpServers.tandem` (and the legacy `tandem-channel`) entries in `~/.claude.json` and Claude Desktop configs made every future Claude session retry a dead MCP server, and the bundled skill kept advertising `tandem_*` tools that no longer existed. The Windows-only Cowork scrub is now extended cross-platform: it removes both MCP keys from every detected Claude config (Claude Code, Claude Desktop, Windows MSIX) and the bundled skill dir (only when it contains nothing Tandem didn't install). The removal path reuses `applyConfig`'s hardening (symlink rejection, size cap, BOM handling, 0o600/ACL atomic writes) with scrub-correct semantics it deliberately inverts: never create a missing config, never replace malformed JSON (an uninstall must not wipe `~/.claude.json` — it's skipped and logged by path only, with no parse detail that could embed tokens), never rewrite when nothing matched. Each step is isolated so one failure can't skip the firewall cleanup, and user data (sessions, annotations, document backups, keychain) is deliberately left in place. The command is now documented (`tandem --help`, [docs/cli.md](docs/cli.md)); the Windows uninstaller runs it automatically, and a new [docs/data-locations.md](docs/data-locations.md) enumerates every place Tandem stores state — app data, logs, keychain services, cross-program config entries — with manual full-cleanup steps per OS. Troubleshooting gains an ["MCP shows connected but Tandem tools fail"](docs/troubleshooting.md#mcp-shows-connected-but-tandem-tools-fail) section, and the README now states the audience constraint plainly (the AI side needs an MCP client + its subscription) and fixes the stale "Word files open in review-only mode" claim.
- **`tandem` CLI refuses old Node.js with a clear message** — the npm package declares `engines: node >=22`, but npm only *warns* at install time, so a Node 18/20 user got a cryptic crash somewhere downstream instead of an answer. The CLI entry now checks `process.version` before doing anything else (including before loading any dependency — `update-notifier` moved to a lazy import so nothing can crash ahead of the guard) and exits with "Node.js 22+ is required — you are running vX" plus a pointer at the nodejs.org LTS. Unrecognizable version strings fail open: the guard exists to clarify, never to brick an exotic runtime. `tandem doctor` keeps its own version check for diagnosing someone else's report.
- **Release confidence: desktop E2E on every release tag + per-release manual smoke checklist** — the shipped desktop artifact was the least-tested one: the tauri-driver WebDriver suite (`tauri-webdriver.yml`, webview-level key-interception coverage the Playwright suite structurally can't reach) only ran when someone remembered to dispatch it manually, and the things CI can't verify at all — real installers on real machines, Gatekeeper/SmartScreen behavior, the updater against the previous shipped version, OS file associations, sidecar lifecycle — had no documented procedure, so per-release verification depended on memory. The WebDriver workflow now also triggers on every `v*` tag push (deliberately as a release *signal*, not a publishing gate — a failure means investigate before announcing, and artifact publishing in `tauri-release.yml` is unaffected), and a new [docs/release-smoke-checklist.md](docs/release-smoke-checklist.md) captures the ~20-minute-per-platform manual pass: Windows (SmartScreen publisher check, install, file-assoc cold/warm start, updater from the previous version, no orphaned sidecar, uninstall/reinstall), macOS on real Apple Silicon hardware (browser-downloaded dmg so quarantine applies, zero Gatekeeper dialogs — the #428 residual that CI's codesign/notarization checks can't prove, updater, file associations), Linux (AppImage/deb/rpm launch), and the npm global-install path. The checklist leans on the new Copy Diagnostics button as its "sidecar healthy" probe and ends with an explicit record-the-outcome step — a skipped platform stated is fine; an unstated skip reads as "verified".
- **Settings → About: Copy Diagnostics + Open Log Folder** — a stuck desktop user previously had no in-app way to find their logs or share diagnostic state (`tandem doctor` exists, but only for people comfortable with a terminal — and the desktop app doesn't ship a `tandem` command on PATH). The About tab now has a **Copy Diagnostics** button that fetches a new loopback-only `GET /api/diagnostics` route — the embedded `tandem doctor` collector (Node version, ports, `/health`, SSE stream, annotation-store health, `~/.claude.json` MCP registration), threaded with the server's *live* ports so a `TANDEM_PORT`-overridden instance doesn't report itself "not running" — and puts a plain-text report on the clipboard, ready to paste into a GitHub issue. The two dev-repo-only checks (`node_modules/`, `.mcp.json` in cwd) are filtered out of the route's report with the pass/fail summary recomputed, since they'd fail for every Tauri/npm-global user and bury the real signal; the CLI keeps them. The route is **unconditionally loopback-gated** (the report embeds absolute paths and PIDs; it never contains token material) and **single-flight** (concurrent requests share one collector run, so request bursts can't amplify into self-probe bursts). In the desktop app, an **Open Log Folder** button (Tauri runtime only — the rotating `tandem.log` only exists there) reveals the log directory via the existing native `show_in_file_manager` command. `docs/troubleshooting.md` gains a "Sharing diagnostics" section including a privacy note that the copied text contains local absolute paths. New `settings-modal-copy-diagnostics-btn` / `settings-modal-open-log-folder-btn` testids.
- **Self-healing reclaim for a stale annotation-store lock (#1077)** — when a Tandem process is killed without a clean shutdown, the leftover `store.lock` can name a PID the OS has since recycled onto an unrelated process (Windows does this aggressively), leaving the server stuck in read-only mode with a dismiss-only banner. The store-readonly banner now has a **Reclaim** button (`store-readonly-reclaim`) that calls the new loopback-gated `POST /api/store/reclaim-lock` endpoint: it re-runs the staleness check with a best-effort OS process-identity probe (`/proc/<pid>/comm`, `ps`, or `tasklist`), reclaims the lock when the PID is dead or clearly belongs to a non-Tandem process, re-persists the live annotation state of every open document, and broadcasts the cleared read-only flag to all clients. If a live Tandem/node process (or an unidentifiable live process — fail safe) holds the lock, the endpoint refuses and the banner shows the server's explanation inline (`store-readonly-reclaim-error`). The lockfile is now written as JSON `{pid, startedAtMs, app}` (v2); legacy raw-PID lockfiles remain readable.
- **Stale browser tabs can no longer corrupt documents after a server restart (generation gate)** — a tab that survived a server restart used to CRDT-merge its old document state back on reconnect; whenever the server had loaded the file fresh from disk (no session, or the file was edited externally — the classic "I fixed it on disk, restarted, and Tandem reverted my file"), the two documents shared no history and the merge **duplicated the entire old document alongside the new content**, which autosave could then write to disk. The restart banner detected this only after the fact. Now every collaboration connection authenticates with the server run's **generation id** (fetched from `GET /api/info`, pinned per provider at construction): the server's `onAuthenticate` hook rejects stale-generation connections **before any of their sync messages are processed** — the protocol queues sync until auth passes, so a rejected tab's stale state never reaches the server. This covers **every room including the control channel** (an exempted stale control connection could otherwise clobber the broadcast document list — concurrent map writes resolve by a clientID coin flip — which is also why the generation id itself lives in process state and travels over HTTP only, never through the shared map). On rejection the client re-fetches the generation and **rebuilds everything with fresh documents that sync cleanly from the server**, showing the existing "Server restarted" banner. Trade-off, stated plainly: browser edits made *while the server was down* are discarded instead of merged (they were the corruption vector); graceful shutdowns are lossless thanks to the shutdown disk flush + session save, and hard kills lose at most the last autosave interval — same as before, minus the corruption. Verified by a real-WebSocket integration suite (`tests/server/stale-tab-resync.test.ts`): stale client rejected with server content byte-stable, control room gated, tokenless/pre-generation connections fail closed, rotated-generation rebuild admits fresh clients. Plan was adversarially reviewed pre-code by two agents that independently caught the two load-bearing design corrections (gate in `onAuthenticate`, not `onConnect`; no `CTRL_ROOM` exemption).
- **Pre-overwrite document backups + shutdown disk flush** — Tandem had no recovery path for user text files: the 60s autosave rewrites `.md`/`.txt` through the markdown serializer with no snapshot of the original, and the serializer has historically mangled files three separate times (#379, #605, lesson #69). Now, before Tandem's **first** disk write to a path per server run, the file's current on-disk bytes are copied verbatim (never through the serializer — surviving serializer bugs is the point) to `{APP_DATA}/doc-backups/<path-hash>/<name>-<timestamp>-<uuid>.<ext>`, alongside a `source.txt` recording the original path — plain copies restorable with any file manager. Retention: 3 per document (pruned on write), 30-day age sweep at boot, 500 MB total cap (snapshotting pauses with a notification if exceeded); byte-identical restarts don't churn new copies. A failed snapshot never blocks the save — the in-memory edits are the newer data — it warns once and retries next save. The same snapshot also guards the **Save-As-onto-existing-file** path, which previously overwrote a never-Tandem-written file with no recovery. Separately, graceful shutdown now **flushes dirty documents to disk** (bounded at 5s) before saving the session, so quitting no longer leaves the on-disk file lagging the session by up to 60s of edits — previously visible when opening the file in another editor before relaunching Tandem. Snapshots are keyed by file *path* (not document id) so renames and Save-As targets are each protected independently. New `file-io/doc-backup.ts` with unit tests; recovery steps documented in `docs/troubleshooting.md`.
- **`.docx` write-back: edit Word files and save them back as `.docx` (#576, body export)** — `.docx` files were review-only (`contenteditable=false`, never written to disk); the only way to keep edits was to convert to Markdown. Now `.docx` opens **editable** — edits are held in the Y.Doc (CRDT) and the original `.docx` is **never touched until you explicitly save**. On save (`Ctrl+S` / `tandem_save` / `POST /api/save`), Tandem serializes the current document body back to a real `.docx` via the `docx` package (promoted from a dev-only spike to a production dependency), through a new `saveBinary` adapter capability + `atomicWriteBuffer`. The export is **trust-boundary-gated**: hyperlinks are scrubbed to `http`/`https`/`mailto` (no `file://`/UNC/drive-path `targetMode="External"` relationships), images embed only inline `data:` bytes (no `r:link` external references), no `<w:object>` OLE embeds, and unknown node names degrade to plain text rather than passing through markup. **Fidelity is surfaced both ways:** on open, mammoth's lossy-import warnings (dropped footnotes, headers/footers, tracked changes, unsupported styles) are collapsed into a single user-facing notice so you know what the round-trip can't recover; on save, the body-export pre-flight flags any content it downgraded (unsupported blocks → plain text, non-embedded images → placeholder) and returns it on the save result. **Scope (v1.0): body content only** — Word comment round-trip (Tandem annotations ↔ `<w:comment>`), tracked changes, and the inline-image round-trip are filed as follow-ups; **auto-save never writes `.docx`** (it's disjoint from the auto-save format set), so the original is only ever overwritten by an explicit user action. Supersedes ADR-004's read-only-`.docx` default — the protective layer is now "never overwrite without an explicit save". The `ReviewOnlyBanner` / `review-only-banner` testid stay in the tree but no longer render for `.docx` (it opens writable); a cosmetic cleanup is filed. New `docx-export.ts` (converter + `detectExportFidelityIssues`) with round-trip + trust-boundary + adapter unit tests.
- **Opt-in crash reporting via `tauri-plugin-sentry` (#921)** — Tandem had no end-to-end crash telemetry: Rust panics in the desktop shell were invisible, native crashes produced no trace, the Node sidecar's `process.exit(1)` path shipped nothing, and the client `ErrorBoundary` caught render errors without reporting them. Tandem now wires `tauri-plugin-sentry` (Tauri v2) across all three layers — Rust panics + native minidumps (shell), `@sentry/browser` JS errors / unhandled rejections (WebView, bridged over Tauri IPC), and `@sentry/node` uncaught exceptions (sidecar, shipped before exit). **Telemetry is strictly opt-in and off by default**: nothing initializes unless the operator sets `TANDEM_SENTRY_DSN` to a [Sentry](https://sentry.io) or self-hosted [GlitchTip](https://glitchtip.com) DSN. With the variable unset (the default), `sentry::init` runs with an empty DSN (a no-op client), the plugin is never registered, the WebView is never injected, and `@sentry/node` is never even loaded — zero network egress. The Tauri shell forwards the DSN to the sidecar so both report to the same project as separate event sources. **PII is scrubbed before egress on every layer**: absolute home-directory paths collapse to `~`/`[user]`, Anthropic/bearer-style secrets are redacted, and request/document payloads and content breadcrumbs are dropped — document content and annotation bodies are never attached to events. No global `tracing`-subscriber collision: the Sentry Rust SDK's default features install no global subscriber, so it coexists with `tauri-plugin-log` (and the `devtools` feature). `single-instance` remains the first plugin; the Sentry plugin registers immediately after. Plumbed via a new `src-tauri/src/sentry_reporting.rs`, `src/client/sentry.ts`, and `src/server/sentry.ts`, each carrying the scrubbing logic with unit tests. The README "Privacy and trust" section documents the opt-in. (Build + desktop-runtime verification deferred to a manual Tauri pass.)
- **Raw-markdown source view / edit mode (#1021)** — Tandem's editor is WYSIWYG, but documents persist as raw markdown on disk; there was no way to see or edit that underlying source in the app. A new **`</>` toggle in the title bar** (and a **"Toggle markdown source view"** command-palette action) swaps the active `.md` document between the formatted editor and a full-document **editable** markdown textarea. Editing the source and returning to WYSIWYG round-trips the text through the same `remark` parser the autosave uses, then persists to disk; the source you see is byte-identical to what's written on disk (`saveMarkdown`). The toggle only appears for **editable `.md`** documents — non-`.md` formats and read-only `.md` (e.g. `CHANGELOG.md`, `.docx`) are excluded, both in the UI affordance and server-side. Because a source edit re-anchors the whole document, **annotations are cleared on round-trip** (the same trade-off as `tandem_open force:true`); an inline notice warns before you commit when annotations exist, and entering source view with *no* edits restores the formatted view untouched. Two new loopback routes back this: **`GET /api/document/raw`** (returns the literal markdown) and **`POST /api/document/reload`** (replaces Y.Doc content from a supplied markdown string; 1 MB cap on the inline body since the parse is synchronous, plus open/`.md`/not-read-only/not-already-reloading guards). The reload reuses the existing force-reload lifecycle (`clearAndReload`) but keeps the doc dirty so the user's edits are written, and serializes against the file-watcher reload via the shared in-progress guard. `Ctrl+S` in source view commits the edit instead of saving stale Y.Doc content. New `titlebar-source-toggle` + `source-view-*` testids; no annotation-schema or coordinate-system change. Plan reviewed pre-code by adversarial + security + editor-null-audit agents (which caught the annotation-store-wipe, read-only, dirty-flag, and concurrency-guard requirements before any code).
- **Settings → Appearance: "Match System" can resolve light to Warm (#993)** — when Theme is set to **System**, a new "Use Warm when system is light" toggle makes a light OS appearance resolve to the paper-tone **Warm** theme instead of neutral **Light**, while a dark OS appearance still resolves to **Dark**. Previously System could only ever pick Light or Dark, so Warm fans had to give up OS-driven dark-mode tracking to keep the warm canvas. The preference (`systemLightVariant`, default `"light"`, schema v13→v14) is threaded through `systemTheme()`/`resolveTheme()`/`applyTheme()` so both the initial resolve and the live `prefers-color-scheme` re-resolve honor it. New `appearance-system-light-warm` testid (shown only while Theme is System); no behavior change for existing users.
- **Rename an open file from the UI (#1017)** — there was previously no way to rename a file once it was open; you had to rename on disk (orphaning the tab's annotations and forcing a reload) or close and reopen. Now **double-click a tab's name, or press F2**, to edit the filename in place — Enter commits, Escape cancels. The rename keeps the same directory and extension (no format conversion, no move); scratchpads, uploads, and read-only docs (incl. `.docx`) are not renamable (they use Save As). Architecturally the **documentId / Hocuspocus room stays stable** — only the on-disk path migrates, exactly like Save-As's promote-in-place — so connected clients keep their live CRDT room and just see a new tab label; **annotations follow the file** (the durable envelope is migrated from the old path-hash to the new one, with a heal-write that closes a cross-doc recovery-steal window). A new **`POST /api/rename`** route and **`tandem_rename`** MCP tool expose the same operation to agents (MCP-first, ADR-038). Free-form names are validated server-side against path separators, `..`, Windows-illegal characters (notably `:`, the NTFS alternate-data-stream vector), reserved device names (`CON`/`NUL`/`COM1`…, keyed on the first-dot stem), trailing dots/spaces, UNC/`\\?\` prefixes, and symlinked targets; the extension must match case-insensitively. As part of this PR a pre-existing inverted flush order in `closeDocumentById` was fixed (Finding C) — a delete-then-close within the annotation debounce window could drop the tombstone and resurrect a deleted annotation; both the close and the new rename path now flush the durable store *before* tearing down the file-sync context. New `tab-rename-input-{id}` / `tab-name-{id}` testids; no annotation-schema or coordinate-system change.
- **Settings → Editor: default save folder (#1023)** — a new "Default Save Folder" preference controls where new files land when you use **Save As** in the desktop app. Pick a folder with the native picker (or type a path), and the save dialog opens there. When unset, Save As falls back to a smart default: your AI's configured working directory, then your home folder. The setting is stored client-side (independent of the Claude working directory) and is a no-op in the browser distribution, where Save As is a download. The integration lookup is time-boxed so a slow/absent server never blocks the save dialog. The native **Open** dialog now starts in this same smart-default directory too (previously it always opened at the OS default), so "where my files live" is one consistent notion across open and save; the three-tier resolution is factored into a shared `utils/default-directory.ts` consumed by both dialogs.
- **One-click "Install Claude Code" from the wizard** — on a genuinely fresh machine with no Claude installed, the integration wizard's empty state previously just told you to go install it yourself. It now adds **real binary detection** (probes `PATH` + the native `~/.local/bin` location, not just config-file presence — so "we couldn't find Claude" no longer fires for someone who has the CLI but hasn't launched it) and, when nothing is found, an **Install Claude Code** button that runs the official native installer for you. The server fetches the pinned `https://claude.ai/install.{sh,ps1}` script over HTTPS (scheme + host re-pinned on every redirect hop, 10 MiB streamed-byte cap), writes it to a locked-down temp file, and `execFile`s the interpreter on the *file* — **never `| bash` / `| iex`** (no shell, no `curl` dependency) and with a **minimal allowlisted environment** so the wire-fetched script never sees the server's auth token. Failure is surfaced honestly (real exit code + a temp-path-scrubbed stderr tail, never a faked success — and the honest-failure path is itself hardened: a temp-cleanup error can no longer flip a successful install into a 500, a download failure surfaces its own message instead of an opaque generic error, and an installer that exits 0 without the binary then appearing is reported rather than silently re-offering the button); the common post-install state — binary in `~/.local/bin`, not yet on `PATH` — is reported as the *success* it is ("Installed — open a new terminal, run `claude` once, then check again"), not an error. New `GET /api/integrations/claude-cli-status` (read-only, enum-only output — never the resolved path) and `POST /api/integrations/install-claude-code` (origin + loopback gates + an in-process mutex; no nonce — the install is host-pinned and idempotent). New testids: `integration-wizard-empty`, `integration-wizard-install-claude`, `integration-wizard-install-error`, `integration-wizard-install-success`. Known v1 limitation: the button lives in the empty state, so it doesn't surface when Claude *Desktop* is detected but the *CLI* is absent.
- **Annotation panel: document-position ordering with a chronological toggle (#1056)** — annotation cards in the side panel previously rendered in store arrival order (effectively oldest→newest), which scattered them relative to the text they annotate. The panel now sorts cards by **where each annotation is anchored in the document** (flat `range.from`, the same ordering the annotation keyboard navigation already used) by default, and a new **sort pill next to the Filter toggle** (`annotation-sort-toggle` testid) switches to **chronological (oldest first)** ordering by `timestamp`. Both modes tie-break deterministically by annotation id; the choice persists in `localStorage` (`tandem:annotationSortMode`, try-catch-guarded for storage-disabled browsers). Pending and resolved lists both honor the mode; bulk-action membership and counts are unchanged, and nothing about annotation exposure changes (ADR-027 intact — this is render order only, no `src/server/` changes). Sort logic lives in plain-TS `useAnnotationOrder.ts` (`sortAnnotationsByTimestamp` / `sortAnnotations`) with unit tests covering the default, the toggle, tie-breaks, and records missing position/timestamp data.

### Changed

- **Info notifications linger in the activity tray for 5 minutes instead of 4 seconds** — the tray's info-item TTL was tied to the toast duration (`TOAST_DISMISS_MS.info`), so an info entry left the tray the moment its toast vanished — the tray could never answer "I missed the toast, what just happened?" (surfaced by Copy Diagnostics' confirmation disappearing before the tray was opened). The tray TTL is now its own constant (`ACTIVITY_INFO_TTL_MS`, 5 minutes), decoupled from the 4-second toast. Warnings and errors persist until dismissed, unchanged; dedup-coalescing and the 50-item cap keep ambient info from flooding the tray during the longer window.
- **Annotate composer reskinned to the A8 design language — coral Send, WCAG-checked (#1006)** — the selection popup's note popover (textarea + "Note to self" / "Send to {model}") now follows the bundle's A8 Selection Mini-Toolbar recipe. The Send button drops the blue user-authorship outline (`--tandem-author-user` was the *user's* color on a Claude-directed action, and off A8's palette) for A8's coral comment treatment: a quiet coral tint + border keyed to the annotation action ("tinted, not shouting"), via two new WCAG-graded tokens extending the Claude family in `index.html` — `--tandem-author-claude-fg-strong` (text on the tint: 5.52:1 light / 6.79:1 dark / 5.16:1 warm) and `--tandem-author-claude-border` (3.73:1 light / 3.24:1 dark vs the tint; ≥3.68:1 vs the card surface), with forced-colors mappings. "Note to self" stays ghost/monochrome per A8's "bubble menus stay monochrome" principle. Controls align to A8's format-button sizing (28px height, `--tandem-r-3` radius, spacing/type from the token scales), the textarea gains a visible coral focus treatment (it previously had `outline: none` with no replacement) and a placeholder color token, and both buttons get hover, `:focus-visible`, and visible disabled states. Visual-only: testids (`popup-annotation-input`, `popup-note-submit`, `popup-comment-submit`), ADR-027 audience-first structure, and keybindings (Enter = newline, Alt+Enter = Note, Ctrl/Cmd+Enter = Send) are unchanged; no `src/server/` changes.
- **Unified onboarding wizard — detection-led, one click for the common path, Cowork folded in** — the first-run "Connect an AI assistant" wizard is now a single detection-led screen instead of a six-step `detect → pick → secrets → review → saving → done` flow. It opens straight to **Connect Claude**: detected Claude installs render as selectable cards with friendly status lines ("Ready to connect", "Already connected — we'll refresh it", "Has a custom setup — we won't touch it") instead of raw `ok`/`missing` enums, the optional auth token moves behind an **Advanced** disclosure (tokens only matter for non-default LAN binding — never on the happy path), and connecting is one click — the inline result *is* the summary (no separate review/summary step). Below the connect action, a **"More integrations"** section surfaces **Cowork** as an opt-in: on Windows a **Set up** button opens a minimal sub-view that shows the Windows firewall/UAC warning first and enables only via an explicit confirm (`cowork-enable-confirm-btn` stays the sole trigger; admin-declined is recoverable, not fatal), while macOS/Linux show "coming soon" and the browser hides the row entirely. **AI models** appear as a quiet "coming soon" line (the in-app model client doesn't exist yet — `BYO_MODELS_ENABLED` stays off). The done screen humanizes per-target apply results and adds a "what's next" hint (restart Claude Code / `/mcp`); error states get plain-language leads with raw detail in a collapsed Technical details block, and **Try again actually re-runs detection**. Chrome follows the onboarding-card family (the first-run / model-edit modals) — one padded card with a flowing header and footer (no fixed bars or scroll-fade), a Tab focus trap, and the MAIN↔Cowork view swap nested inside the stable dialog shell. The security-reviewed persist/apply logic is untouched (one atomic `save()`), with several secret-cleanup hardenings: it now also fires when a card with an already-stored token is unchecked (the single-screen flow makes store-then-unpick reachable), and it no longer deletes secrets once the integrations file has durably persisted — a throw between persist and apply (or a malformed `200` body) previously orphaned the just-saved config's keychain refs, surfacing later as `SECRET_MISSING`; additionally, an Advanced token stored but never saved (the user dismisses the wizard before saving) and a token stored under a failed keychain-write response are both now cleaned up rather than orphaned (the dismiss cleanup is gated on the pre-persist state so it can never delete a live ref); a single `createCoworkStatus` instance feeds both the Cowork row and the sub-view, and the always-mounted admin-declined modal is suppressed while the wizard owns that state. The dead first-run model-picker mount is removed from `App.svelte`. Also fixes buttons referencing the **nonexistent `--tandem-surface-elevated` token** (transparent backgrounds, here + 3 spots in Settings → AI Assistant) and adds a proper `--tandem-accent-hover` token (light + dark).
- **Forward the Claude Code session id to the Tandem server for correlation (#1043)** — Claude Code 2.1.157+ injects `CLAUDE_CODE_SESSION_ID` (+ `CLAUDECODE=1`) into the stdio MCP subprocesses it spawns (also on `--resume` since 2.1.163). The Tandem channel shim and stdio proxy now read it via `resolveClaudeSessionId()` — **only when `CLAUDECODE === "1"`** (so a value a user exported in their own shell is never forwarded), trimmed and printable-ASCII/length-guarded against header injection — and attach it as the read-only `X-Claude-Session-Id` header on outbound requests (`authFetch`, `tandem_reply`/permission relays, and every forwarded JSON-RPC POST). It's pure correlation metadata: server routes that don't read it ignore it, so no route schema changed and no server behavior changed. Also documents the streaming-tool-execution contract (always-on since 2.1.154; Tandem returns one discrete result per tool, so no change) and notes that `claude mcp` redacts credential headers since 2.1.141. No-op against pre-2.1.157 CLIs.
- **Updater audit — no rollback / no in-updater post-restart health-poll for v1, documented (#925)** — audited the Tauri auto-updater (`perform_install`) against Tauri v2 best practices and CrabNebula's auto-updates guide for the two gaps #925 raised: missing rollback on a failed relaunch, and a missing post-restart health-poll. The decisive constraint is that Tauri v2 `AppHandle::restart()` is **divergent** (exits + relaunches without returning), so an in-`perform_install` post-restart probe is unreachable dead code and rollback can't be driven from the old (now-gone) process. The post-restart health verification the issue wants **already exists for the sidecar** (`start_sidecar` → `wait_for_health` → `MAX_RESTARTS`, native "Server Error" dialog on exhausted failure; the `sidecar-restart-failed` WebView event is manual-`restart_sidecar`-only); only the rare Tauri-*shell* relaunch failure is uncovered, which neither cited reference handles (both stop at `download_and_install()` + relaunch). **Decision (c): ship v1 unchanged**, with the one in-process-only hardening that survives the constraint — a persisted "pending update" marker the next boot reads to surface a "your update may not have completed" hint — sketched as an optional, build-verified follow-up. Documented in `docs/spikes/updater-rollback-healthpoll-audit.md` and ADR-043 (`docs/decisions.md`). Docs-only; no code or behavior change.
- **Stop hook injects its workflow reminder as real context (#1045)** — `.claude/hooks/stop-cycle-check.sh` now emits its "uncommitted source edits" reminder on STDOUT as a `hookSpecificOutput.additionalContext` JSON object (built with `jq`, falling back to stderr when `jq` is unavailable), so Claude Code surfaces it as actual context instead of stderr-only noise. The one-shot-per-session gate (source-edit marker present, not already nudged, no newer commit) and `exit 0` semantics are unchanged; the no-pending-edits path still emits nothing. The version-pinning sub-question (min/max Claude Code version via managed settings) is deferred as a separate design decision.
- **Softer, friendlier wording on the two flagship error surfaces (#1016)** — the app-root error boundary and the lost-connection banner read less blunt without losing accuracy. The error boundary's heading goes from "Something went wrong" to "Something didn't go as planned", its body from "The editor encountered an unexpected error." to "The editor ran into an unexpected hiccup.", and the exhausted-recovery line from "Recovery attempts exhausted. Reload the page to continue." to "We weren't able to recover this time — reloading the page should get you going again." The connection banner goes from "Connection to the Tandem server has been lost. Ensure the server is running." to "We've lost the connection to the Tandem server. Please check that it's still running." Copy-only — no error codes, control flow, or testids changed; the still-accurate "reload" / "check the server is running" guidance is preserved, and no `err.message` or path is leaked. Unit + E2E assertions on the old strings were updated to match.
- **Clarify what "Decorations" means in the title-bar control (#1020)** — the title-bar Decorations dropdown now leads with a one-line explanation that decorations are the inline editor overlays (author colors, comment/highlight/note marks), and that toggling a type hides its marks in the document while side-panel cards stay visible. This matches the help text already shown under Settings → Appearance → Decorations and resolves the confusion that the setting did nothing visible (it was previously labeled only "Decorations" with no description). Defaults are unchanged — all four decoration types remain on by default — and no testids changed.
- **New-tab morph — clear two-phase motion (A29, follow-up to #977)** — the `+`→menu morph now expands **horizontally into a clean capsule first, then vertically into the card**, instead of co-timing both axes. The radius is held at a literal capsule value (`14px`, half the pill height) through the horizontal unroll and squares to the card radius only as the height grows, so the shape never flashes a flat lozenge; the interior rows cascade in during the vertical phase. Close reverses it. The card fill persists through the whole collapse (a *filled* card shrinks back into the `+`, with no empty-box gap), and the card/pill shadows crossfade so there's no shadow pop at the handoff. On close, focus is **restored to the element that was focused before the menu opened** (typically the editor) rather than parked on the `+`, so no stray focus ring lingers on the pill; the closing body is `inert` so focus is never stranded in the clipped panel, and on close the in-menu search field is **blurred synchronously** so a keyboard shortcut fired in the same tick (e.g. Escape then `Ctrl+W`) is no longer swallowed by the still-focused input. Fully reduced-motion-correct (all timing derives from the shared morph tokens).
- **A discoverable "Connect AI" entry point when AI isn't running (#1018/#1022)** — the app now surfaces a CTA when AI isn't actually working, instead of leaving users "Synced" with no way to find setup. Readiness keys on the launcher's `GET /api/launcher/status` truth — not the document-sync dot, and not `claudeActive` (which flaps to idle every few seconds and would make a working user's chip oscillate). When Claude Code isn't set up, a **"Connect AI"** chip appears (titlebar, secondary) and a primary CTA shows in the empty state, both opening the Claude Code wizard; when it's configured but stopped/crashed, the CTA becomes **"Restart Claude Code"** and relaunches it (reusing the existing relaunch command) rather than wrongly sending an already-set-up user back into setup. The prompt is **suppressed in Solo mode** (where working without AI is intentional) and never flashes during cold boot (gated on first-run-settled + connected + a successful status read; a transient status-fetch failure keeps the prior state). New `useAiReadiness` hook + `titlebar-connect-ai` / `empty-state-connect-ai` testids.
- **Loud-failure notices when you message AI but none is connected (#1018)** — sending a chat message or a "Send to Claude" comment while no AI is connected now pops a toast that **affirms the save and frames absence as deferred delivery** ("Message/Comment saved — no AI is connected yet. It'll be seen when AI connects"), with an inline **"Connect AI"** / **"Restart Claude Code"** action button — never "failed" or "lost" (the message/comment persists in the Y.Doc and is read whenever an agent next connects). The notice fires **only for outbound comments and chat**, never for notes or highlights, which are user-private and never sent to AI (ADR-027) — a "no AI connected" notice on those would be misleading. Suppressed in Solo mode and while readiness is still booting. Toasts gained optional action buttons (client-only — the handler never touches the SSE wire or the persisted activity tray). New `toast-action-{id}` testid; `data-ai-chip` state attribute added to the readiness chips/CTAs for test disambiguation.
- **First-run leads with Claude Code setup; in-app "bring your own API key" Models UI hidden until it works (#1018/#1022)** — AI in Tandem today is the external Claude Code integration (MCP). The in-app Models registry stores provider keys in the OS keychain, but no server-side LLM client consumes them yet, so configuring a model there did nothing — users entered a key, AI still didn't work, and concluded the app was broken. The entire BYO-models surface (first-run model picker, Settings → Models tab, titlebar default-model chip) is now gated behind a `BYO_MODELS_ENABLED` flag (off) until the outbound LLM client lands; with it off, first-run leads straight to the Claude Code wizard. The keychain storage layer and its server tests are untouched, so the path re-enables cleanly when wired.
- **Settings → AI Assistant now leads with a "Connect AI" callout when nothing is configured (#1022)** — a user with Claude credentials but no configured integration used to open Settings looking for "where do I enter my AI login" and find nothing: the Models (API key) tab is deliberately hidden (keys do nothing until the outbound LLM client exists), and the AI Assistant tab buried its only setup entry at the bottom behind a button labeled "**Reopen** integration wizard…" — phrased as a power-user re-entry, with nothing saying AI wasn't set up or that **no API key is needed** (Claude Code uses your existing Claude sign-in). Now, when the integrations load settles and finds no claude-code entry, the tab leads with a prominent callout — "No AI connected yet", a one-line explanation that no API key is required, and a **Connect AI…** button opening the setup wizard — and the bottom button reads "Open integration wizard…" until an integration exists. The wizard's connect step also gains a "you can reopen this anytime from Settings → AI Assistant" hint, so dismissing the auto-opened first-run wizard (which never auto-reopens for that version) is no longer a dead end. New `settings-modal-connect-ai-callout` / `settings-modal-connect-ai-btn` / `integration-wizard-reopen-hint` testids; no `src/server/` changes.
- **Titlebar connection label "Connected" → "Synced" (#1018)** — the green status dot reflects the document sync server, not whether AI is reachable. The literal word "Connected" implied AI was connected, which was the core of the "connected but no AI" confusion. The dot's color and behavior are unchanged; only the label is clearer.

### Removed

- **Silent auto-configuration of Claude is gone — setup is now wizard-driven (#477 PR 3c-ii-c, ADR-038 §2b).** The desktop app no longer silently writes Tandem's MCP entry into Claude's config files on every launch, and the `/api/setup` server route + Tauri `run_setup()` round-trip are deleted. **First-run setup runs through the in-app integration wizard** (auto-opens when no integration is configured; covers both the desktop bundle and the npm-browser path), replacing the Tauri-only "No AI client detected" dialog. **Breaking (CLI):** bare `tandem setup` no longer writes config — it prints wizard-driven guidance. Use **`tandem setup --apply [--force] [--target=claude-code|claude-desktop] [--with-channel-shim]`** for the non-interactive, scriptable write (CI / dotfiles); `tandem rotate-token` is unaffected. Real-time channel push on the desktop bundle is preserved by injecting the resolved channel-shim path into the sidecar as `TANDEM_CHANNEL_DIST` on spawn (the path the deleted `/api/setup` startup run used to carry). The tray "Setup AI Assistant" item now re-opens the wizard instead of re-running auto-config. Review polish: a network failure in the wizard's connect screen now reads "Could not reach the Tandem server…" instead of a raw `Failed to fetch` (across Chromium, WKWebView, and Firefox engine strings), while a genuine programming error still surfaces its real message; an unrecognized `tandem setup --target=` value warns, and if **every** `--target` is invalid the command now aborts rather than silently writing config to all detected clients; the desktop tray "Setup AI Assistant" surfaces a recoverable warning if its shortcut can't wire instead of becoming a silent no-op; a set-but-missing `TANDEM_CHANNEL_DIST` logs a diagnostic before falling back; and the `tandem rotate-token` "no token file" hint now points at `tandem` (which creates the token) rather than the no-longer-applicable `tandem setup`.

### Fixed

- **In-app Reduce Motion now stops the status-pill working dots and the annotation-card typing dots (#1081)** — `StatusBar.svelte`'s `.claude-working-dot` and `AnnotationCard.svelte`'s `.tandem-claude-typing-dot` honored the OS `prefers-reduced-motion` preference but were missing the in-app `body.tandem-reduce-motion` body-class guard, so toggling Reduce Motion in settings was silently ignored for these two pulsing-dot surfaces (the OS-level pref still worked). Both now mirror the integration wizard's dual-guard fix: `animation: none` plus an `opacity: 0.7` resting value so the dots stay visible when frozen. CSS-only; no markup, testid, or server changes.
- **Restart-sidecar button (and updater) now flush dirty docs before killing the sidecar (#1088)** — `restart_sidecar` and the updater's pre-install stop hard-killed the Node sidecar (`child.kill()`), so the graceful shutdown sequence never ran: up to ~60s of edits since the last autosave tick (the shutdown disk flush, PR #1087) were discarded, and the skipped session save made the server and surviving-WebView histories diverge on every restart (the generation gate from #1089 correctly rejects and rebuilds the stale tab — no corruption — but the unflushed edits were lost rather than merged). The sidecar now exposes a loopback-only **`POST /api/shutdown`** route that responds `202` immediately (so the caller's request is never killed mid-flight, deferred to the response `close` event) and then runs the exact same shutdown sequence as SIGTERM (`unwatchAll → stopAutoSave → autoSaveAllToDisk (5s-bounded) → saveCurrentSession → closeMcpSession → exit`), one-shot-guarded against repeat POSTs. Gating is deliberately **stricter than `assertLoopbackForMutation`**: the loopback requirement is unconditional (a process-kill endpoint must never be network-reachable, even by a Bearer-token holder on a `TANDEM_BIND_HOST` LAN bind — remote shutdown is a DoS primitive the LAN opt-in did not consent to), and an Origin header, when present, must pass the localhost/Tauri allowlist (absent Origin is allowed — the caller is the shell's reqwest client, and browsers always attach Origin to cross-site POSTs). HTTP mode only; stdio mode never registers the route and its SIGTERM path is unchanged. The Rust shell's new `stop_sidecar_gracefully` helper POSTs the route, waits up to **6s** for port release (aligned with the Node flush's 5s bound), and always falls back to the existing `kill_sidecar` — it also skips the POST entirely when no child is owned, so a debug build attached to an external `dev:standalone` server never shuts down a server it didn't spawn. Applied to both `restart_sidecar` and the updater's `perform_install`; the crash auto-restart path is unchanged (the process is already gone — nothing to flush). A process-wide in-flight gate additionally makes a second restart click during the (now longer) stop window a logged no-op instead of racing two stop/start tasks — the Tauri command returns before the async restart completes, so the button re-enables mid-restart (pre-existing race, widened by the graceful wait). Needs a manual Tauri desktop pass (restart button with unsaved edits; updater flow). No annotation-schema or coordinate-system change.
- **"No AI is connected" send notice no longer fires for an agent that connected since the last readiness poll (#1083)** — the dual-poll readiness fix (#1054/#1018) made the steady-state false positive impossible (an active MCP session promotes readiness to `ready`, suppressing the notice), but the chat/comment send handler read the polled `chip` imperatively at submit time, and that poll runs every 8 seconds. An agent whose MCP `initialize` landed *after* the last poll — connect Claude Code, then immediately send a message, a natural first action — still read as absent, so the "Message saved — no AI is connected yet" toast fired while the agent was live. The handler now confirms with a fresh, awaitable `/health` `hasSession` probe (`aiReadiness.probeSession()`) before alarming: a confirmed session suppresses the notice and folds into the polled state so the titlebar chip clears immediately instead of waiting out the poll interval. The probe follows the poll's fail-safe discipline — a fetch failure or redacted body answers with the last-known polled value, so a `/health` blip neither demotes a connected agent nor suppresses a genuinely-deserved notice; it also never cancels an in-flight background poll. New hook-level regression tests cover the stale-poll-then-fresh-session promotion, the confirmed-absent path, and both unknown-fallback directions. No server changes; no annotation-schema or coordinate-system change.
- **Install Claude Code: follow the installer's real cross-subdomain redirect** — a live fetch of the official installer showed `https://claude.ai/install.{sh,ps1}` 302-redirects to `https://downloads.claude.ai/claude-code-releases/bootstrap.{sh,ps1}` — a different host. The install runner pinned the redirect host to `claude.ai` *exactly*, so it rejected that legitimate hop (`refusing https://downloads.claude.ai`) and the one-click install would have failed on every real machine; the unit tests missed it because they mocked the redirect as same-host. The per-hop gate now accepts the apex **and any `*.claude.ai` subdomain** (anchored at the `.claude.ai` dot boundary, so `evilclaude.ai` / `claude.ai.evil.com` / `claude.ai@evil.com` stay rejected), and the URL handed to `httpsGet` is rebuilt from the just-validated host (no longer hardcoded to the apex). The scheme is still re-pinned to `https:` on every hop (a downgrade stays fatal). New tests lock in the cross-subdomain allow, the uppercase-host normalization path, and the lookalike-host rejections; security-reviewed.
- **Windows: the Tauri shell now reliably kills its `node-sidecar` on ungraceful exit (#987)** — killing the desktop app did not reliably terminate its spawned sidecar. `RunEvent::Exit => kill_sidecar` only fires on a *graceful* shutdown, so a force-quit (`taskkill /F`), a crash, or a dev-runner rebuild orphaned the sidecar — it kept listening on 3478/3479. Worse, because the debug-build reuse path *attaches* to an already-healthy server on the port, a "restart" could silently bind to an **orphaned old-code sidecar**, so server-code changes invisibly didn't take effect (the health endpoint and version string look identical). The spawned sidecar is now bound to a **Windows Job Object** created with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`: the parent process holds the only handle for its whole lifetime, so the OS closes it on parent exit — graceful **or** crash/`taskkill` — and reaps the sidecar (and any grandchildren). A restarted sidecar (new PID) re-assigns to the same job. Best-effort and fail-open: every job-object syscall failure (e.g. older Windows that disallows nested jobs) is logged and falls back to the existing graceful-kill path — it never blocks startup. Windows-only behind `#[cfg(target_os = "windows")]`; macOS/Linux continue to rely on `RunEvent::Exit + kill_sidecar` (a native parent-death reaper for those platforms — Linux `PR_SET_PDEATHSIG`, macOS kqueue — remains the deferred #800 spike). New `src-tauri/src/sidecar_job.rs`; needs a manual Windows desktop pass (force-quit / `taskkill` then relaunch and confirm no orphaned `node` survives).
- **OS file-association open failures now surface a toast instead of silently landing on welcome.md (#630)** — double-clicking a file Tandem can't open (unsupported extension, missing file, or a suspicious path shape like an NTFS alternate-data-stream colon) previously dropped to `welcome.md` with no feedback. The desktop shell now classifies the rejection and signals the WebView via two surfaces: a **buffered, path-free reason code** for the cold-start case (the rejection is classified in `setup()` *before* the `App.svelte` listener exists, so emitting an event there would drop silently — it's buffered and polled once via a new `get_startup_rejection` command, then **taken** so a WebView reload can't replay it), and a live **`startup-file-rejected` event** for the warm-start (second-instance) and macOS Apple-Event (`RunEvent::Opened`) paths, which fire while the app is already running. The toast message is composed **client-side from the reason code** so the rejected path never reaches the DOM (mirrors the `sidecar-restart-failed` contract); the resolved path stays in the `warn` log for diagnostics. The buffer is cleared on `restart_sidecar` so a stale rejection from a previous launch isn't replayed against the new sidecar. (`RejectionReason` and `classify_opened_url` already shipped in #819 / a later refactor; this is the deferred rejection-surfacing + macOS-coverage follow-up.) The macOS Apple-Event delivery path still needs a manual pass on real Apple hardware (per the issue's validation requirement). New `startup-file-rejected` event + `get_startup_rejection` command; no annotation-schema or coordinate-system change.
- **Cowork installer hardened against a scan→install TOCTOU window (#433)** — the per-workspace `cowork_install_into_workspace` / `cowork_uninstall_from_workspace` Tauri commands previously re-scanned the filesystem at IPC time and checked that the caller-supplied path string matched one of the freshly-walked workspaces. Between the scan the UI rendered and the install click, a workspace directory could be moved, replaced with a junction/symlink, or a brand-new path injected into the supplied string — a time-of-check-to-time-of-use window (defense-in-depth; the four-layer path guard already made exploitation hard). The scan now hands the UI an **opaque, process-local handle** per validated workspace (`cowork_scan_workspaces` returns `WorkspaceHandle { token, path }`) backed by an in-process registry mapping the 256-bit random token to the exact canonical `PathBuf` that passed the guard. Install/uninstall accept the **token**, not a path string, and resolve it back to that stored path — so a handle can only ever name a path the scan already validated; an injected/forged token has no entry and is rejected with no file I/O. The registry is **replaced (not merged) on every scan**, so tokens from a superseded scan stop resolving. As a final belt-and-suspenders gate, the resolved path is re-run through the four-layer guard (reparse-point / canonicalize / UNC / component-wise containment) immediately before any write, catching a directory swapped *after* the scan (a deleted workspace also fails this re-check, preserving the old reject-on-missing behavior; the specific rejection reason is preserved for triage). Windows-only (`#[cfg(target_os = "windows")]`); the non-Windows stubs are unchanged. No `src/server/` changes.
- **AI-readiness no longer shows "Restart Claude Code" while a manually-launched agent is connected (#1054/#1018)** — `useAiReadiness` derived readiness *solely* from the auto-launcher's `GET /api/launcher/status`, which only knows about the Claude Code process the launcher itself spawned. A Claude Code session launched manually from a terminal (with the tandem MCP server configured) is invisible to the launcher, so the launcher truthfully reported `running: false` and the titlebar showed the **"Restart Claude Code"** chip — even though tools and chat were flowing. Clicking that chip would have spawned a **second** agent on the same documents, and the same false-`stopped` state fired the misleading "no AI is connected yet" notice on chat/comment sends. The hook now also polls the server's loopback-only `/health` `hasSession` field (whether any MCP client transport is currently open) and treats an **active MCP session as `ready`** regardless of launcher state, suppressing both the restart CTA and the send notice. The two fetches share a generation guard (stale resolves are dropped) and follow the same fail-safe discipline as the launcher poll — a `/health` blip keeps the prior value, so a momentary failure never flips a connected agent's chip back on; `hasSession` only ever *promotes* to ready (never demotes), so a missing first read can't surface a false CTA. Whether to surface "MCP session active" as a first-class field on the `LauncherStatus` wire contract (vs. the current dual-poll) is left as a follow-up decision for Bryan (see PR body). No annotation-schema or coordinate-system change; `/health` is unchanged server-side beyond using the shared `API_HEALTH` path constant.
- **Rename: an *internal* failure during the new-path annotation re-wire no longer leaves a steal vector open (#1057)** — follow-up closing the residual that the #1040 fix explicitly documented. `wireAnnotationStore` swallows internal failures (e.g. a `loadAndMerge` throw) so an open/save never fails, but it previously returned normally — so `renameDocument`'s `rewired` flag stayed `true` and its `!rewired` guard (which disposes the still-live old-hash observer before the RMW step-2 `clear()`) never fired for an internal throw, leaving a post-`clear()` DELETE able to re-create `<oldHash>.json` (the stale-envelope steal vector). `wireAnnotationStore` now returns `{ wired: boolean }` — `true` only after `setFileSyncContext` runs — and `renameDocument` sets `rewired = result.wired`, so an internal throw leaves `rewired` false and the guard fires identically to a boundary rejection. The swallow contract is unchanged for the other eight call sites (all ignore the result, so open/save stays non-fatal). New helper-contract unit tests (success / sync-throw / async-reject) plus a rename regression that scopes the throw to the new path and asserts `<oldHash>.json` is not re-created after a post-return DELETE (verified red without the `wired` gating). No annotation-schema or coordinate-system change.
- **Switching away from a tab and back no longer jumps to the top (#1055)** — the editor's scroll container (`.editor-scroll`) is always-mounted across tab switches (document content swaps in place via the Tiptap editor's `ydoc` prop), so on a tab switch the previously-scrolled document used to reappear scrolled to the top. The active document's vertical `scrollTop` is now remembered per documentId and restored when you switch back. The outgoing tab's position is captured before the document swaps, and the incoming tab's saved offset is re-applied across a few animation frames so it survives the post-swap content-height settle (the browser would otherwise clamp `scrollTop` before the content is tall enough). New `editor-scroll-container` testid; no annotation-schema or coordinate-system change.
- **Windows: the window keeps its rounded corners when snapped and no longer shows an outline around the app shell (#984)** — the borderless (`decorations: false`) window relied on default Windows 11 behavior, which squares the corners on snap/maximize and could draw a thin 1px border. The desktop shell now opts in to `DWMWA_WINDOW_CORNER_PREFERENCE = DWMWCP_ROUND` and sets `DWMWA_BORDER_COLOR = DWMWA_COLOR_NONE` via `DwmSetWindowAttribute`, applied at window setup and re-asserted on `Resized` (snap/maximize always resize the window and reset the corner preference). Windows-only behind `#[cfg(target_os = "windows")]`; a no-op on macOS/Linux. A pre-Win11 `DwmSetWindowAttribute` failure is silently ignored (best-effort) and never aborts startup. (Requires manual verification on Windows 11.)
- **Rename no longer drops a durable annotation that is newer in the file than in the live doc (#1040 × #1041)** — a data-loss regression surfaced by the interaction of two already-merged PRs. #1051's rename now writes the new path-hash envelope from a **read-modify-write of the live Y.Doc** (rather than moving the old envelope), but `snapshot()` reads *only* the live Y.Maps. When a durable annotation was **newer in the OLD file envelope than in the live doc** — e.g. a note flushed at `rev 2`, then diverged to `rev 1` in the live map by a `withInternal` write the durable-sync observer skips (so no debounced write re-converges the envelope) — the pure-live RMW snapshot clobbered the new envelope with the stale `rev 1` record, and the subsequent re-wire's `loadAndMerge` (now reading the clobbered envelope) found nothing newer than live, so the `rev 2` file record was **lost on rename**. In the common case (live == file) this was invisible; #1041's strengthened note-privacy test exercised the divergent case and exposed it. The rename now **folds the old envelope's alive records forward into the live doc before the RMW snapshot** (new `mergeEnvelopeForward` helper reusing the existing `mergeMap`/`pickWinner` file-wins-by-rev path), so the file-newer record wins, lands in the RMW snapshot, and survives under the new path-hash. The fold writes through `withFileSync` (no raw `doc.transact`, no channel/durable echo, ADR-027-safe for notes), respects the effective tombstone ledger so a just-deleted record isn't resurrected, and is best-effort wrapped so it can never flip a committed rename to "error" (worst case degrades to the prior live-only snapshot). All of #1051's resurrection/rollback/step-2 fixes are preserved. A new regression test asserts a comment that is newer in the OLD file wins the rename merge and survives durably (verified red before the fix); the #1041 note test remains the primary ADR-027 guard. No annotation-schema or coordinate-system change.
- **Editor links now show a pointer cursor and reveal their destination on hover (#996)** — anchors in the editor previously rendered with the default text cursor and gave no hint of where they pointed. They now carry `cursor: pointer` (scoped to `.tandem-editor a[href]`) and a native `title` tooltip mirroring the link's `href`, so hovering surfaces the destination URL. The `title` is injected by extending the Tiptap Link mark's `renderHTML` (a `.docx`-imported explicit title still wins); click handling and the `openOnClick: false` navigation intercept are unchanged.
- **Rename no longer has sub-second windows that resurrect a just-deleted annotation (#1040)** — closing the concurrency windows documented (honestly, as "duplicate/resurrect, never lose") when #1017/#1038 landed open-file rename. The envelope migration now collapses the old `fs.rename` + heal-write split into a single **read-modify-write**: the new path-hash envelope is written *with the corrected `meta.filePath` already in place* **before** the old envelope is removed, so there is no instant where a byte-identical concurrent open can match a stale envelope and steal it (windows b and c). For the observer-detached gap (window a), the old-hash annotation observer now stays attached across the rename instead of being torn down up front, so a DELETE arriving mid-rename still records a tombstone, and that tombstone ledger is migrated forward into the new path-hash envelope so the deletion survives a later stale-tab merge. A follow-up review (`crdt-reviewer` + `annotation-model-reviewer`) found a **residual variant of window a**: a DELETE arriving during the post-rename flush IO, or during the re-wire's `loadAndMerge` envelope read, was recorded into the *old*-hash ledger by the still-attached observer with no later fold to carry it forward — and `loadAndMerge`'s seed *clobbered* the new-hash ledger from the just-written file (which didn't yet carry that tombstone), so the deletion could be discarded and the annotation re-inserted from the file. This is now closed on two complementary fronts: (1) `loadAndMerge` **unions** the file-derived tombstone seed with any pre-existing in-memory ledger (highest rev per id) instead of overwriting it — and applies the unioned set, so a migrated-forward delete is never dropped; and (2) the old→new ledger fold is performed inside `loadAndMerge` **after its `store.load()` read but before the merge**, *gated to the genuine rename* (an explicit `migrateTombstonesFrom` oldHash threaded through `wireAnnotationStore`), so a DELETE arriving during the read is folded forward and applied rather than resurrected — never folding tombstones across unrelated open/close. A final synchronous flush after the re-wire guarantees the migrated tombstone reaches disk before the rename returns. The force-reload path is unaffected (it clears the ledger and the envelope before re-wiring, so the union starts empty). The load-bearing `closeStore`-before-teardown ordering (#1017 Finding C) is preserved. All Y.Doc writes in the heal path go through `withFileSync` (no raw `doc.transact`); the tombstone fold is pure in-memory ledger mutation. New regression tests inject a DELETE during the RMW envelope write and during the `loadAndMerge` read and assert it stays deleted through reopen (each verified red before the fix); A second review caught a **HIGH regression on the failed-rename rollback path**: keeping the old observer attached (the window-a success-path fix) meant that on a rolled-back rename `oldHash` still matched the registered context, so the rollback re-wire's `loadAndMerge` re-seeded the old-hash tombstone ledger and the trailing `setFileSyncContext` then `"close"`-disposed the still-present same-hash context — deleting the ledger it had just repopulated, after which a stale-tab snapshot could overwrite `<oldHash>.json` with an empty tombstone list and resurrect the annotation. Fixed by restoring the master ordering **on the rollback path only** — `clearFileSyncContext(docId)` is called *before* the rollback re-wire so there is no stale same-hash context left to `"close"`-dispose after the re-seed (safe because Phase 1's `closeStore` already flushed the ledger to disk and nothing was renamed, so there is no concurrent-delete window on rollback); a new rollback regression test asserts the tombstone survives the rollback + a stale-merge snapshot + reopen (verified red before the fix). The forward `migrateTombstoneLedger` fold now spreads the source record so `.passthrough()` tombstone fields carry over. A final hardening pass closed a residual on the rename **success** path: the Phase-3 re-wire at the new path is wrapped in a swallowing try/catch, so if `wireAnnotationStore(... newPath ...)` throws internally, `setFileSyncContext` never runs and the **old**-hash observer stays registered and live (now pointing at the vanished `oldPath`) — after which RMW step-2's `clear()` of `<oldHash>.json` no longer protects against a concurrent DELETE that schedules a *fresh* debounced write **after** the clear, re-creating `<oldHash>.json` with the vanished path (a stale-envelope steal vector). The re-wire now records a `rewired` boolean and, **only when it failed** (`!rewired`), disposes the stale old-hash observer via `clearFileSyncContext(docId)` before the step-2 clear — gated on `!rewired` because on the success path `setFileSyncContext` already disposed the old context and `docId` points at the new hash, so an unconditional clear would tear down the freshly-wired new-hash observer. A new regression test forces the re-wire to throw, fires a DELETE after the rename returns, and asserts `<oldHash>.json` is not re-created (verified red without the guard); added `loadAndMerge` unit tests cover the union-not-clobber `seedHasExtraTombstones` branch and the cleared-ledger force-reload safety. reviewed pre-merge by the `crdt-reviewer` and `annotation-model-reviewer` agents. No annotation-schema or coordinate-system change.
- **New-tab menu opens rightward instead of off-screen when the `+` is near the left edge** — the A29 morph menu is right-anchored and grows left into its 460px card, but the title-bar tab strip left-justifies against the brand cluster, so with few tabs the `+` sits near the left viewport edge and the menu spilled off-screen to the left. The menu now measures the `+` slot on open and flips to grow **rightward** when there isn't room to grow left (re-anchoring both the shell and the `+` so the pill stays pinned at the slot). It stays direction-symmetric — with many tabs the `+` is far enough right that the menu keeps growing left toward the tabs.
- **A scratchpad saved to disk now reliably appears in the New Tab "recent files" list (#1019)** — when you promote a scratchpad to a real file via **Save As…** (e.g. `Scratchpad.md`), the new on-disk path is now registered in recents the instant the server confirms the write, instead of relying solely on the asynchronous openDocuments-broadcast → tab-reconcile → recents-sync effect chain (which could miss the file if the tab was closed before the round-trip completed). `runTauriSaveAs` records the server's resolved `targetPath` via the same `addRecentFile` path the file-open dialog and launcher already use, so it dedups cleanly against the later effect-driven add (no duplicate entry). Browser-mode Save As is unchanged: it downloads a blob without promoting the doc, so the still-ephemeral `upload://` scratchpad is correctly kept out of recents. localStorage access stays guarded (the recents helpers already wrap reads/writes in try/catch).
- **Selection popup no longer covers the highlight-color sub-menu (#1024)** — opening the highlight-color picker from the formatting bar while text is selected previously rendered the color swatches *behind* the selection pill. The formatting bar is a `position: fixed` wrapper that creates its own stacking context at `--tandem-z-sticky`, which sits below the selection popup at `--tandem-z-modal` — so any dropdown opened from the bar was trapped beneath the pill. The bar's wrapper now lifts to `--tandem-z-toast` (above the selection popup) only while the highlight sub-menu is open, so the sub-menu paints fully clear of the pill and drops back to `--tandem-z-sticky` on close. The sub-menu stays anchored to its toggle (z-index only; no positioning change). Visual-only; every testid preserved (`toolbar-highlight-color-toggle`, `toolbar-highlight-color-{yellow|green|blue|pink}`).
- **Onboarding tutorial copy now matches the UI control label (#1015)** — the in-app tutorial's "Ask a question" step told users to "click Comment", but the selection toolbar control is labeled **Annotate** (clicking it opens a popover whose buttons are "Send to {AI}" and "Note to self"). The step now reads "click Annotate, then send it to your AI assistant — or keep it as a private note to yourself." The matching `docs/user-guide.md` walkthrough is corrected the same way (it also dropped a stale "toggle @Claude" instruction that no longer exists in the UI). Copy-only; no behavior change, every testid preserved.
- **`tests/server/platform.test.ts` no longer binds hardcoded ports in the Windows reserved range (#1014)** — the three `waitForPort` tests bound fixed ports `49170`/`49171`/`49172`, which fall inside the IANA dynamic/ephemeral range that Windows reserves in chunks (Hyper-V/WSL/Docker). On such hosts those binds throw `EACCES` deterministically, failing the husky pre-push gate and forcing a blanket `HUSKY=0` bypass. The tests now bind an OS-assigned port (`listen(0)`) and use the resolved port, eliminating the reserved-range collision. Test-only; no production impact.
- **Closed a test-coverage gap in `resolveWordComments` (#1012)** — the suite exercised the function only with a single `importCommentId` per call. Added two tests covering the multi-comment path: resolving two distinct comments in one call (each mapped to its *own* last-paragraph paraId) and the dedup-by-`commentId` filter (two suggestions sharing one comment, asserting a single `commentEx` entry). Test-only; the production path was already correct.

### Tests

- **Strengthened the ADR-027 note-privacy rename test to cover the live-note `withFileSync` merge path (#1041)** — the existing rename note-privacy test got zero channel events "for free" because `closeStore` re-converges the moved envelope to equal the live Y.Map, so `loadAndMerge`'s `withFileSync` block performed no mutation. The new variant defeats that convergence (flush a note at `rev 2`, then diverge the live Y.Map to `rev 1` via a `withInternal` write the durable-sync observer skips), so `loadAndMerge` actually runs `annMap.set` on a live note inside the `withFileSync` transaction during rename — then asserts the note still leaks neither via channel events nor via the `getDocumentStore` read path `tandem_getAnnotations` uses. Test-only; no `src/server/` change.

## [0.13.6] - 2026-06-04

### Added

- **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).
- **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.
- **`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.
- **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).
- **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).
- **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).
- **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.
- **`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.
- **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.
- **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).
- **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).
- **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).
- **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).
- **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).
- **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).
- **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).
- **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).
- **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).
- **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.
- **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.
- **Motion — reply-thread disclosure (Phase 4 / #798, scene A13, locked REPLACE decision 3)** — an annotation's existing replies are now a **collapse-by-default disclosure** for both comments and notes: a "N replies" toggle with a chevron that rotates as the thread **unfolds** (a measured-height `discloseUnfold` Svelte transition, 380ms `--tandem-ease-out`, no `max-height` to clip a long thread) and the reply items **cascade in** (one-shot CSS `@keyframes`, 120ms stagger capped at the 4th reply, re-fired each open because the `{#if open}` block remounts `CommentThread`). The compose "Reply" button drops its parenthesized count (the count lives on the toggle now). **This disclosure replaces the former "Expand thread" → portaled `ReplyThreadOverlay`** (the shipped #840 overlay), whose only trigger was that full-density button — so the overlay, its button, and the now-dead density/stub-collapse `$effect` were removed (Bryan decision 2026-06-01). **Notes use the same A13 toggle** (#1000) — imported Word reply threads land on notes before the user promotes them, so both types share the unified `visibleReplies.length > 0` disclosure path. **ADR-027 preserved:** Claude-privacy for notes is server-enforced (channel observer + `channelVisibleReplies`), not a client display gate — showing a note's private replies to the owning user is correct; "private" means private from Claude. Dual reduced-motion guard (OS `prefers-reduced-motion` + the in-app `body.tandem-reduce-motion` class); `discloseUnfold` honors reduce-motion on the JS side. Visual/interaction-only: no `src/server/` changes, no Y.Doc/coordinate path, no annotation mutation. testids: `reply-toggle-{id}` added; `reply-thread-expand-{id}` + `reply-thread-overlay*` retired; `comment-thread` / `reply-{id}` / compose-form testids preserved.
- **Motion — annotation-filter chip bar (Phase 4 / #798, scene A15, locked REPLACE decision 5)** — the three native `<select>` dropdowns in the annotation rail's filter bar (type / author / status) become compact **icon chip groups** with a **sliding thumb** that glides under the active chip (240ms `--tandem-ease-out`). Each axis is a new `ChipGroup.svelte` child component (an ARIA `radiogroup` with arrow-key navigation): the reset chip is text ("All" / "Anyone" / "Any"); type/status chips use inline glyphs (highlight, comment, lock=note, sparkle=with-replacement; hollow-circle=pending, check=accepted, ✕=dismissed, the latter three carrying their status color); author chips use the authorship pips (orange Claude / blue you / gray imported). The thumb measures the active chip's box (`offsetLeft`/`offsetWidth`) via a synchronous value-keyed effect — `tick()`→`requestAnimationFrame`→measure so the new selection is laid out first — and re-measures on rail resize through a separate mount-time `ResizeObserver`; it's absolutely positioned so it never feeds back into chip layout (no measure loop). The thumb's transition lives only on a `.ready` class so it snaps to its first position instead of sliding in from zero, with the dual reduced-motion guard (OS `prefers-reduced-motion` + the in-app `body.tandem-reduce-motion` class — CSS-only, since the slide is a pure CSS transition). The filter prop contract is unchanged, so `SidePanel` is untouched; the old `FilterSelect.svelte` is removed. testids: the group roots `filter-{type,author,status}` are preserved and each chip adds `filter-{axis}-{value}` (E2E now drives them with `.click()` instead of `.selectOption()`). Per-chip counts are deferred to a follow-up; card collapse-out on filter is already shipped via `cardExit`. Visual/interaction-only: no `src/server/` changes, no annotation-model change (counts would be user-facing UI, never sent to Claude). Pre-code plan reviewed by the svelte-migration agent (caught two blockers before any code: a `{#snippet}` can't hold the per-group runes — it must be a child component; and an `async $effect` silently drops its `ResizeObserver` cleanup → leak).
- **Motion — annotation fly-to-margin (Phase 4 / #798, scene A27)** — submitting a new annotation from the selection popover (Alt+Enter = Note to self, Ctrl/⌘+Enter = Send to Claude, or the buttons) now **launches the new margin card from the closing popover's on-screen footprint** and glides it into its computed slot — a FLIP (translate + fade, 480ms `--tandem-ease-out`) so the eye follows what you just typed to where it lives. The popover's rect is captured at submit and crosses to the card's `in:` transition through a module-level delete-on-read ledger in `cardMotion.ts` (`registerFlySource`/`cardFlyToMargin`); **Map-presence is the gate** — only a submit registers a source, so every other margin-card mount (initial load, tab switch, scroll/filter re-render) finds none and instantly no-ops, i.e. only the just-submitted card flies. A 1000ms TTL backstop GCs an unconsumed source (card never mounts → graceful snap, no leak). Honors reduced motion (the in-app `reduceMotion` setting — now threaded through `MarginColumn` — OR OS `prefers-reduced-motion`, checked in JS; the source is consumed before the gate so it can't strand). **Translate-only (no scale)** — a full-rect scale would blur the card's text mid-flight; the dominant motion is the translate anyway (flagged for spot-check). Visual-only: no `src/server/` changes, no annotation-model change, the `withBrowser` create-write is byte-for-byte unchanged (`createAnnotation` only gained a `return id`), the C3 side-split + ADR-027 note privacy are untouched (the FLIP animates transform/opacity only and never reads the side decision), every `popup-*`/`margin-*`/`annotation-card-*` testid preserved. The transition gate is unit-tested (`cardMotion.test.ts`); pre-code plan + diff reviewed by crdt + annotation-model + svelte-migration agents (all PASS, no blockers).
- **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).
- **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).
- **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).
- **Motion — activity pill → tray single-shell morph (Phase 4 / #798, scene A23)** — the bottom-right activity center is now **one persistent shell** instead of a pill that pops a separate tray above it. Clicking the pill grows it in place: width + corner-radius lead (P1), then the tray body **unfurls upward** via `max-height` (P2, delayed by P1) while the pill stays pinned at the bottom as the close handle — the literal single-shell restructure (bottom-pinned `position:absolute` + flex column with the tray body first and the pill row last, so growing the shell taller moves only its top edge). Closing reverses the phase order (height collapses first, then width/radius). The shell reuses the shared `morphTiming.css` tokens (`--morph-p1/p2`) + the close-reversal delay convention, so its transitions get reduced motion for free via token-zeroing. The tray body (`role="region"`, carrying the `activity-tray` testid) stays **conditionally rendered** — it leaves the DOM when closed, so its rows mount fresh on open and the **per-row cascade** (`rowSlideUp`, staggered 540→1090ms) fires from a single `cascading`-window `$effect` (no `listEl` bind / reflow trick — keyframes fire on mount), and the closed-state `toHaveCount(0)` E2E contract holds with zero test changes. The **LED pulse** (warning 1.6s / error 1.4s, opacity-only) returns from the design source. `@keyframes` aren't covered by token-zeroing, so the cascade + LED carry the dual reduced-motion guard (OS `prefers-reduced-motion` + in-app `body.tandem-reduce-motion`) — forced with `!important` because the LED's `animation` rides a 4-class severity selector (`.activity-shell.has-error .pill-row .led`) that outranks any guard on specificity (a browser pass caught the LED still pulsing under reduced motion before the fix). Shell + tray-wrap use `overflow: clip` + `overflow-clip-margin` (not `hidden` — lesson #765; verified focus rings clear the clip on Clear-all / dismiss / the pill); the `.tray-list` keeps its intentional inner scroll. No focus-return effect (correct for a non-modal region toggled by a persistent always-focused button — unlike the M3 new-tab morph). The bundle's solo-arrival `.entering` slide-in is deferred. Visual-only: no `src/server/` changes, no annotation-model change, every activity testid preserved (`activity-pill`, `activity-tray`, `activity-empty`, `activity-clear-all`, `activity-row-{id}`, `activity-dismiss-{id}`, `activity-action-{id}`). Pre-code plan reviewed by svelte-migration + accessibility agents (which mandated keeping the `$derived` wrappers, confirmed `prevOpen` must stay a plain latch, and gated the focus-ring clip clearance).
- **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.
- **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.
- **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`.
- **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.
- **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.

### Changed

- **`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.
- **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.
- **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.
- **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.

### Fixed

- **`.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.
- **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).
- **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.)
- **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).
- **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.
- **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).

### Internal

- **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.

## [0.13.5] - 2026-05-29

### Added

- **`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).
- **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.

### Changed

- **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).
- **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).
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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).
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

### Fixed

- **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.
- **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.
- **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.

## [0.13.0] - 2026-05-25

### Added

- **Settings → Models tab + edit modal (Wave 2 PR 8b, #659)** — new tab in `SettingsModal` for managing the Models registry. Provider-grouped list with per-row enable toggle, edit, two-step delete confirm. `ModelEditModal` covers add and edit flows: provider select (Anthropic / OpenAI / Gemini / Ollama / llama.cpp), conditional cloud-vs-local fields, reveal-gated API key (masked `••••${last4}` on edit; "Replace key" button to enter a new value — existing key never round-trips through the DOM). Mandatory in-product disclosure banner about plaintext localStorage storage.
- **Models registry data model + `useModels` hook (Wave 2 PR 8a, #659)** — `TandemSettings` now carries a `models: ModelRegistryEntry[]` array tracking AI providers Tandem can call out to (Anthropic, OpenAI, Gemini, local Ollama, local llama.cpp). The data layer ships in this PR; the Settings → Models UI ships in PR 8b. Orthogonal to `IntegrationConfig` (#477) — that schema tracks MCP clients connecting INTO Tandem, while this tracks providers Tandem talks OUT to.
- **Multi-provider Models registry (#659, #784)** — keychain-backed secret storage, per-provider default selection, and a first-run model picker spanning Anthropic / OpenAI / Gemini cloud providers plus local Ollama / llama.cpp endpoints. A legacy-migration banner upgrades pre-registry single-key setups.
- **Schema v2 → v3 migration with forward-compat read-only mode** — `loadSettings` now walks an explicit migration chain (v1→v2→v3). An on-disk `schemaVersion` greater than v3 loads defensively with `_readOnly: true`, and `createTandemSettings.updateSettings` short-circuits writes on read-only settings. This is the load-bearing defence against a downgraded client clobbering a newer client's Models registry / future fields on first save.
- **Integration setup wizard + first-run auto-open (#477 PRs 1/3a/3b/3c-i/3c-ii-b — #728, #729, #730, #731, #773)** — a full-screen wizard for connecting MCP clients (Claude Code and other agents) to Tandem. Ships the `IntegrationConfig` schema with atomic storage + v1→v2→v3 migration, a non-mutating reader for existing `~/.claude.json` entries, and an apply path (`POST /api/integrations/apply`) that backs up the target config before rewriting it. On first launch with no `integrations.json` the wizard opens automatically; later launches do not. Enabled by default.
- **Native OS keychain on the Tauri desktop app (#477 PR 3c-tauri-keychain, #732)** — integration secrets are stored in the platform keychain on desktop, with graceful env-var fallback when the keychain is unavailable.
- **Auto-launcher for Claude Code (#477 PR 4a/4b, #800)** — Tandem spawns and supervises the Claude Code CLI alongside the server, with a native cross-platform parent-death reaper so the child is reliably cleaned up on exit. Adds command-palette actions "Relaunch Claude in this folder" and "Start fresh Claude conversation", a working-directory picker in Settings → Integrations, and a project-context skill bundled on startup. Enabled by default for users with a configured `claude-code` integration; opt out with `TANDEM_DISABLE_LAUNCHER=1`.
- **Native file picker + drag-and-drop on the Tauri desktop app (#378)** — open files through the OS-native file dialog and by dragging them onto the window; the File Open dialog is consolidated to a single Browse + Recent panel.
- **Heading-section collapse (#650)** — collapse everything under a heading via its fold control; collapse state persists per document in localStorage and survives heading-text edits and reloads.
- **Scratchpad save-to-disk (#827)** — save a scratchpad to a real file via the native dialog (`.md` / `.txt`); Save-As accepts user-chosen locations anywhere on disk while keeping the symlink + UNC guards.
- **Claude typing-presence indicator (#651)** — a presence cue shows when Claude is actively writing, with monotonic token ownership so overlapping presence updates resolve deterministically.
- **Warm canvas theme (#738)** — a fourth theme option (System / Light / Dark / Warm) and the foundation for the v7 floating-chrome redesign.
- **Re-landed D11 bundled fonts (#680)** — re-applied the fontsource Latin variable-axis builds of Source Serif 4, Inter Tight, and JetBrains Mono originally shipped in PR #663 then reverted via #678. The SIL OFL 1.1 license texts (`public/fonts/OFL-*.txt`) ship alongside the binaries per §4. Editor body text renders in Source Serif 4 via the existing `--tandem-editor-font-family` token.
- **Margin annotation view (#649, #711, #715, #719, #720)** — Word-style margin comment bubbles with breathing-room gaps and leader lines connecting each card to its anchor, action parity with the sidebar, and the annotation filter chip relocated into the panel header.
- **Margin view auto-collapses on rail open or narrow viewport (#683)** — opening the left rail now hides only the left margin column; opening the right rail hides only the right column. When the viewport gets narrow enough that margin reserve + open rails + readable editor would crowd, both columns hide together. A 32px hysteresis band on the viewport threshold prevents flicker when a user drags through the boundary. New `useViewportWidth` rune store provides the rAF-debounced subscription.
- **Status-bar word-count cycle + page count (#741, #792)** — the floating status pill cycles through word, character, and page counts.
- **Inline annotation decoration toggle (#791)** — turn the inline highlight/underline decorations on or off without hiding the annotations themselves.
- **Knowledge-graph pilot (#769, #771)** — 25 hand-curated concept/rule/ADR nodes with cross-edges, queryable via `npm run kg`.

### Changed

- **Design system re-skin (umbrella `feat/design-system-impl`):**
  - TitleBar + BrandMenu + ModeToggle re-skinned to the bundle's visual language (sub-PR 1.1)
  - Added `--tandem-swatch-light|dark|warm` semantic tokens for the BrandMenu theme-picker chips (fixed-identity colors, `:root` only)
  - 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)
  - 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.)
  - 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.
  - 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.
  - 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).
  - 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.
  - 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.
  - 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.
  - 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.
  - 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.**
  - 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).
  - 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.**
  - 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.
  - 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.
  - 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.
  - 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.
  - 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.
- **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.
- **v7 floating-chrome redesign sweep (Waves 1–M)** — a multi-wave reskin that lifts the editor chrome off the canvas:
  - 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).
  - TitleBar + DocumentTabs reflow: bar seam removed so chrome melts into the canvas (Wave 4a, #746), pill-shaped tabs (Wave 4b, #748), tabs lifted into the TitleBar center cluster with mask-fade overflow and a floating `+` button (Wave 4a/4b maximalist, #752, #755).
  - Left rail locked to outline-only (Wave D, #759); outline rail clears the status pill lane (Wave 6, #742); peek-from-side strips + edge-click panel collapse (Wave E, #764).
  - Narrow-viewport Settings sidebar drawer + hamburger (Wave 9, #745); `.docx` batch-promote with always-visible checkboxes (Wave 8, #756).
  - Native scrollbars hidden with mask-faded overflow edges (Wave B, #761); titlebar/status/right-rail header polish (Wave A, #760); placeholder + margin-card hover affordance (Wave F, #763).
  - Redesign-parity sweep across titlebar, rails, picker teardown, popup, composer (Waves G–L, #765) plus the Wave M titlebar dropdown + solo-mode fade + dark-mode v7 alignment (#776).
- **README + documentation rewrite (#777, #778)** — README rewritten for a lay reader with technical detail moved below the fold, plus new `docs/cli.md`, `docs/configuration.md`, `docs/troubleshooting.md`, and `docs/security.md`.
- **MCP-first integration policy; your AI as the default integration (ADR-038, #722)** — documents that Tandem's integration contract is MCP and Claude is the default concrete integration.

### Fixed

- **Inline code combined with bold/italic/strikethrough/link no longer loses its `code` formatting on save** — opening a `.md` file in Tandem (editable, not read-only) and letting autosave write it back used to drop the `code` mark on any span that also carried bold/italic/strike, and silently discard the surrounding link for code-in-link. The damage surfaced as doubled asterisks and `&#x20;` entity-spaces — code-heavy docs like `docs/decisions.md` were corrupted just by being viewed. `deltaToPhrasingContent` now treats `code` as a leaf and wraps link/strike/italic/bold unconditionally, and a new coalescing pass merges adjacent same-wrapper phrasing nodes so a bold run holding a code span serializes as one emphasis instead of several. Round-trip verified lossless across the repo's code-heavy docs.
- **Keyboard tab switch no longer reverts under a stale sync** — a local `Ctrl+1..9` (or click) tab switch is client-only and was being clobbered when a late `documentMeta` re-broadcast re-applied the server's active doc, surfacing as a flaky "Ctrl+N switches to the Nth tab" E2E test. The server now broadcasts a monotonic activation epoch (`activeDocumentEpoch`) alongside the active id; the client applies the server's active only when the epoch advances, so a stale re-sync is ignored while a genuine re-activation (e.g. re-opening the already-active doc) still steals focus.
- **Selection toolbar places itself clear of fixed chrome (#680)** — the selection BubbleMenu flips below the selection when above-placement would overlap the TitleBar + FormattingBar; selections that straddle the viewport fold pin the toolbar to the viewport bottom rather than clamping onto the chrome; and a 4px hysteresis band at the flip boundary keeps the toolbar from shimmering as a selection drifts across the threshold.
- **Clicked highlight stays focused over an overlapping comment (#817)** — clicking a highlight that overlaps a comment now collapses the ProseMirror selection so the overlay clears and the click-to-focus ordering resolves to the highlight; unknown annotation types fall back gracefully.
- **Solo/Tandem mode never auto-flips on a transient error (#822)** — a failed `/api/mode` poll preserves the last-known mode instead of silently reverting to Solo.
- **Annotation palette remap aligned with the v7 design (Wave 7, #743)**.
- **Post-wave correctness fixes across 9 sites (#758)** — a cleanup pass over regressions introduced during the redesign sweep.
- **Kept-tab metadata refreshes on document-list reconcile** — tab titles and state no longer go stale when the open-document list re-syncs.
- **Unsupported-extension toast names the right extension (#808)** — `extensionAllowed` extracts the basename correctly (handling dot-in-directory and dotfile edge cases) and surfaces `.htm` properly.
- **`.docx` comment-extraction failures surface via notification (#696, #701)** — instead of failing silently.

### Security

- **`IntegrationConfig.url` constrained to loopback only (#753)** — accepts `http://127.0.0.1[:port][/path]` only; rejects `localhost`, IPv6 loopback `[::1]`, other `127.x.x.x` addresses, `https`, and embedded credentials (`http://user:pw@…`). Legacy `http://localhost` entries are normalized to `http://127.0.0.1` on read; any other host shape is surfaced as invalid and re-prompts the wizard.
- **Windows ACL hardening on `.claude.json` (#643, #795)** — a restrictive DACL is applied to the written config.
- **`.claude.json` backed up before overwrite (#644, #796)** — backups written under `${appDataDir}/.backups/` with mode `0o600` (POSIX) / restrictive DACL (Windows), capped at `MAX_BACKUPS=3`. Token rotation triggers a backup, so up to 3 superseded bearer tokens may persist on disk per integration; clear the backup folder manually for tighter retention.
- **Malformed-input matrix + shape gates + BOM strip on integration-config reads (#645, #797)** — broken-JSON backups on Windows also receive a restrictive ACL, with the read-modify-write TOCTOU window closed.
- **CI guard: harness components must not leak into production client bundle (Wave 2 PR 7)** — new `scripts/ci/verify-harness-stripped.mjs` runs after `npm run build` and greps `dist/client/` for known harness symbols (`UpdateAvailableHarness`, `harness-acknowledge`, etc.). Defends against a future commit accidentally importing a harness component from a production-shipping module, which would expose internal state (e.g. acknowledge buttons, version accessors) to end users. Pairs with the #660 audit confirming every settings-open path routes through the ack-clearing wrapper.

### Internal

- **Annotation-lifecycle architecture pass (ADRs 031–037, #695–#714)** — origin-tagged transaction wrappers (ADR-031), a `RefreshResult` tagged variant (ADR-032), `DocumentRegistry` extracted from `document-service` (ADR-033), named file-open entry points (ADR-034), a per-key change-observer factory + `AnnotationLifecycle` module (ADR-035), the `FormatAdapter` capability set + `LoadResult` (ADR-036), and the `LayoutModel` rune store (ADR-037). Includes observer-driven tombstoning that records on every delete.
- **`#477` Phase 0 spikes (#712, #726, #750)** — session-resume Spike A (GO), plugin-monitor Spike B (NO-GO), marketplace-install spike re-validated NO-GO.
- **Sidecar-launcher validation spike (#642, #794)** — canonicalize-before-allowlist, Windows reparse-point walk, POSIX hardlink + world-writable-parent rejection, prototyped and tested under `#[cfg(test)]` in `integrations_probe.rs`. Not yet wired into the shipped spawn path.
- **Test-suite audit (#786)** — ADR-027 privacy bug fix, weak-assertion sweep, ADR-031 gap fill; E2E `waitForTimeout` flake removal (#785); schema-version assertions pinned to `CURRENT_SCHEMA_VERSION` (#789).
- **Refactors** — `matchShortcut` helper extraction + dead `useSettingsShortcut` removal (#820), shared SSE event-consumer extraction (#822), `THEME_OPTIONS` extracted from TitleBar (#790), typed `RejectionReason` from `extract_file_arg` (#819), dead Solo/Tandem mode plumbing removed.
- **Tooling / CI** — `check:tokens` extended with a bundle-color blocklist (#826), `claude-review` workflow hardened + auto `/code-review` on new PRs (#830), `block-e2e-port-kill` PreToolUse hook removed (#787).
- **Dependencies** — `ws` 8.19.0 → 8.20.1 (#772) and `qs` bumped (npm_and_yarn group); no CVE cited.
- **Docs** — `RelativeRange` invariants documented (#793), roadmap and CLAUDE.md file-map refreshes (#744, #749, #751), design-notes + redesign-review archival (#774, #775), reference-doc consolidation (#852, #854, #856), and AGENTS.md tracked as the cross-agent contributor guide (#858).

## [0.12.0] - 2026-05-15

### Fixed

- **Editor width slider was non-monotonic and overlapped margin annotations (PR #690)** — the slider claimed 40–100% but at 100% the wrapper returned `undefined`, falling back to a fixed `68ch` reading column; dropping to 95% jumped the wrapper wider than 68ch and collided with the absolutely-positioned `MarginColumn` cards (240px column + 8px edge inset per side). The derived now always returns a concrete percentage so 100% genuinely means 100%, and when margin view is enabled the wrapper subtracts `2 × (240 + 8)px` via `calc()` so editor text never sits underneath the margin cards.
- **`setEditable` no longer re-fires on spurious `readOnly` re-emits** — guarded the editor against redundant `setEditable` calls when the `readOnly` prop re-emits with an unchanged value, eliminating a class of cursor-position resets during rapid Y.Doc swaps.
- **OS file-association review fixes round 2 (#628 follow-up)** — three additional findings from a multi-PR review pass:
  - `restart_sidecar` now clears `SIDECAR_HEALTHY` via a new `clear_healthy_under_lock` helper that takes the `PendingOpens` mutex, mirroring `promote_healthy_and_drain`'s consumer-side flip. A bare atomic store outside the lock re-opened the same TOCTOU window the lock was introduced to close: a macOS `RunEvent::Opened` reading flag=true after `kill_sidecar` but before the clear could POST to a dying server. New `pending_opens_tests::restart_clears_flag_under_lock_so_late_producer_queues` materializes the proof.
  - `handle_opened_urls` hoists `token_store::get_or_create_token` out of the per-URL loop. An "Open With Tandem" multi-file batch now hits the keyring once instead of N times. Mirrors `post_drained_paths`.
  - `extract_file_arg` now documents that `is_file()` follows symlinks intentionally — the final read goes through server-side `openFileByPath` which is the authority for path validation. Prevents a future reviewer from "tightening" this into `symlink_metadata`-based checks and duplicating the server's allowlist.
- **OS file-association review fixes (#628 follow-up)** — six fixes addressing findings from a multi-agent review pass on the cold-start file-open path:
  - Cold-start file is now resolved once in Tauri's `setup()` and threaded into `start_sidecar` as an explicit parameter. `restart_sidecar` passes `None`, so a Settings → Restart Sidecar (or any auto-restart) no longer re-injects `TANDEM_OPEN_FILE` and never re-opens the original launch file.
  - Closed the `PendingOpens` drain TOCTOU window by serializing `SIDECAR_HEALTHY` access through the queue mutex on both consumer (`promote_healthy_and_drain`: flip flag + drain in one critical section) and producer (`try_queue_or_post`: check flag under same lock before push). Eliminates the load-before-push race the original drain-then-flip ordering left open.
  - Windows NTFS Alternate Data Stream colon check now runs on the resolved absolute path (post-`cwd.join`), not just the argv candidate. Closes a gap where a relative path with a suspiciously-positioned colon could slip through.
  - `maybeOpenStartupFile` catch narrowed to wrap `openFileByPath` only; `setActiveDocId` failures (programming-bug class) now propagate to the top-level startup error handler instead of being silently swallowed.
  - When `TANDEM_OPEN_FILE` is set but fails to open and the welcome.md fallback fires, a distinct "Falling back to welcome.md" log line correlates the two events for support diagnostics.
  - `token_store::get_or_create_token` errors are logged at warn level at all three call sites (no functional impact today since loopback bypasses Bearer enforcement; removes a silent-failure footgun if LAN bind is ever enabled).
- **Markdown serializer escape noise on `.md` saves (#605, v1.0 blocker)** — every `.md` file Tandem auto-saved was silently rewritten with backslash-escape noise by `remark-stringify`'s default `unsafe` table (`[anchor]` → `\[anchor]`, `foo_bar` → `foo\_bar`, etc.). The serializer in `src/server/file-io/markdown.ts` now overrides the `text` handler to call `state.safe()` (preserving block-context escapes for line-leading `# `, `- `, `> `, fence runs, table pipes, setext underlines) and then selectively un-escape four intra-text classes: `\[label]` when `label` is not a `definition` identifier in the same tree (parse-aware via a `unist-util-visit` pre-pass — guards against the collapsed-reference-link regression class; label is normalized per CommonMark; negative lookahead also rejects an immediately following `[` to block adjacent-bracket reference-link injection; label character class excludes `\` to prevent O(n²) backtracking on adversarial input like `\[\[\[\[\[…`), `\_` strictly between word chars (CommonMark §6.2 intra-word flanking rule), standalone `` \` ``, and `\~` not followed by another `~` (GFM strikethrough needs `~~`). GFM autolink-literal `@`/`.`/`:` and table `|` escapes still flow through `safe()` untouched. `CHANGELOG.md` was re-serialized through the fixed code as a one-time data cleanup. The `readOnly: true` workaround on the upgrade auto-open path (PR #603) is retained as defense-in-depth.

### Added

- **15 keyboard shortcuts (#626)** — grouped by area:

  - **Tabs:** `Ctrl+W` close active tab (records to in-memory LIFO), `Ctrl+Alt+T` reopen most-recently-closed tab, `Ctrl+1`..`Ctrl+9` jump to tab by index, `Ctrl+O` open file dialog.
  - **Find / outline:** `Ctrl+F` focus outline search when outline panel is visible, otherwise open find bar (doc scope); `Ctrl+Shift+F` open find bar pre-scoped to "Open tabs"; `Ctrl+G` / `Ctrl+Shift+G` find next/previous (falls back to opening find bar when no active query).
  - **Panels / chrome:** `Ctrl+\` toggle left panel, `Ctrl+Shift+\` toggle right panel, `Ctrl+Shift+M` toggle solo/tandem mode.
  - **Annotations:** `Alt+]` / `Alt+[` next/previous annotation; `Ctrl+Enter` / `Ctrl+Shift+Enter` accept/dismiss focused annotation; `Ctrl+Alt+M` open the comment popup on current selection; `Ctrl+Alt+A` toggle authorship colors.
  - **Editor polish:** `Alt+L` select containing block (paragraph / heading / list item).

  All letter shortcuts use `KeyboardEvent.code` (e.g. `KeyT`, `KeyM`) rather than `e.key` so they remain layout-independent (Dvorak / AZERTY) and fire correctly on macOS when Option is held — `Option+letter` produces alt characters (`†`, `µ`, `¬`, `å`) that don't match the unmodified letter `e.key`. Digits and Backslash already used `e.code`; Enter is layout-stable and remains on `e.key`. `Ctrl+M` vs `Ctrl+Shift+M` discrimination is now explicit (`!e.shiftKey` / `e.shiftKey`).

  `reopenClosedTab` now checks `response.ok` so server-side 4xx/5xx no longer silently drop the popped record (fetch only rejects on network failures). Failure restores the record onto the stack and surfaces a toast with the basename so the user can retry; previously a failed reopen was logged to devtools only and the record was lost.

  `Ctrl+Alt+M` distinguishes its preconditions: no selection → "Select text to comment"; read-only doc → "Document is read-only"; palette/find open → silent (the user is in a different UI context); selection toolbar setting off → "Enable selection toolbar in Settings to comment via keyboard". A single toast string would have misfired in three out of four cases.

  `useNotifications` now exposes a `push()` method so client-originated UI feedback (the toasts above) can be surfaced through the same toast container as server-pushed notifications, instead of `console.warn`-ing into devtools the user never opens.

### Fixed

- **`reloadFromDisk` two-write crash window (#622, PR #635)** — when the file watcher detects an external edit, `reloadFromDisk` previously ran `refreshAllRanges` and the relocation pass as two separate `MCP_ORIGIN` transactions; if the server was killed between them, durable annotation state could be left at partially-refreshed ranges. Both passes now merge into a single transaction via a new `skipTransact` parameter on `refreshAllRanges`, closing the crash window. Pre-existing in master; surfaced by audit v2.
- **Codebase leanness audit v2 (PR #621)** — eight-step audit (`docs/audit-v2.md`) covering dead code, dependency bloat, over-engineering, wrong-tool-for-the-job, and stale docs. Validated by four domain reviewers (annotation-model, crdt, security, svelte-migration) before any deletion. Outcomes:
  - **A1:** `reloadFromDisk` (file-watcher reload path in `mcp/file-opener.ts`) — first transaction (content repopulate + awareness clear) tagged `FILE_SYNC_ORIGIN` so the durable-annotation sync observer skips re-persisting state just loaded from disk. The second transaction (textSnapshot-driven relocation pass) stays `MCP_ORIGIN` so its writes persist (caught during the post-merge CRDT review).
  - **A2 + A2b:** Tutorial note now uses `author: "user"` (per ADR-027 — notes are user-private). `useTutorial.svelte.ts` updated to exclude tutorial-seeded annotations from its user-action detection so the step-1 → step-2 advance still gates on a real user-created annotation.
  - **A3:** Six `INVALID_RANGE` error codes in `mcp/annotations.ts` replaced with `NOT_FOUND` / `ANNOTATION_RESOLVED` / `INVALID_ARGUMENT`. `ToolErrorCodeSchema` extended to document the codes the code already uses.
- **Tab drag-to-reorder unblocked in Tauri + browser, then hardened against mid-drag races (PR #625)** — drag-to-reorder document tabs was wired end-to-end but didn't actually move tabs. Two independent root causes plus two follow-up refinements landed together: (1) **Tauri:** WebView2 on Windows defaults to `dragDropEnabled: true`, which routes all drag-drop through Tauri's native handler and blocks in-page HTML5 DnD entirely — flipped to `false` in `tauri.conf.json` (`useFileDrop.svelte.ts` already uses HTML5 `dataTransfer.files`, so OS file drops still work). (2) **Browser + Tauri:** an `$effect(() => { void tabs.length; clearDragState(); })` in `DocumentTabs.svelte` cleared `draggedId` / `dropTarget` whenever the `tabs` prop reference changed, so any upstream Yjs awareness / settings ping that re-derived `orderedTabs` during a drag nulled the drag state mid-flight; removed, and `handleDrop` now prefers closure-captured `draggedId` over `dataTransfer.getData("text/plain")` as defense-in-depth. (3) **Refinements:** a narrower replacement `$effect` clears drag state only when the dragged or target id actually disappears from `tabs` (mid-drag tab close — `dragend` doesn't fire reliably when the source element leaves the DOM), and `handleDragOver` now gates `e.preventDefault()` on `draggedId` being set so foreign drags (file from Explorer, now reachable in WebView thanks to `dragDropEnabled: false`) get the OS no-drop cursor instead of being silently swallowed. New `tests/client/DocumentTabs.svelte.test.ts` pins the original regression (case B fails red with the deleted effect re-added); the E2E `mouse drag reorders tabs` spec dispatches real HTML5 `DragEvent`s via `page.evaluate` because Playwright's `locator.dragTo()` synthesizes mouse events only and never fires HTML5 drag events.
- **Large markdown documents no longer freeze the editor on open (PR #612, closes #609)** — `loadContentIntoDoc` now batches the entire `mdastToYDoc` populate into a single `MCP_ORIGIN` transaction. Previously each `fragment.insert` / `xmlText.insert` fired its own CRDT update; on a ~4500-token document this flooded `y-prosemirror` with thousands of tiny updates, saturated the event loop, and tripped Hocuspocus disconnects. The shared `populateDocFromContent` helper now batches both the disk-open path (`openFileByPath`) and the upload path (`openFileFromContent`), so drag-dropped large markdown files no longer freeze either. Matches the existing `clearAndReload` / `reloadFromDisk` batching shape.
- **`.docx` files with malformed Word comments no longer abort the entire document open (PR #612)** — `injectCommentsAsAnnotations` runs inside the same flatten-into-outer transact as `htmlToYDoc`, so a single bad comment range used to throw out of the whole populate. The inject call is now contained per-comment with partial-write rollback (Yjs does not roll back inner-transact writes on throw, so we snapshot annotation keys before the inject and undo any that landed before the failure). Comment-extraction failures additionally surface as a warning notification instead of disappearing into the server log. Comment-injection failures (rare unhandled exceptions during annotation write) now surface as a warning notification too — symmetric with the extract-failure path, so the user always sees feedback when imported comments go missing.
- **Populate failures no longer poison the Y.Doc cache (PR #612)** — `populateDocFromContent` clears partial fragment + annotation state in a fresh top-level `MCP_ORIGIN` transact before rethrowing. Previously, a populate throw would leave the Hocuspocus-cached Y.Doc with half-applied content, and a retry would silently inherit the corrupted state.
- **Coalesce annotation decoration rebuilds with rAF (closes #610)** — `src/client/editor/extensions/annotation.ts` rebuilt the entire `DecorationSet` synchronously on every Y.Map('annotations') observer fire. On initial sync of a document with hundreds of annotations (force-reload, session restore, docx import) the observer can fire dozens of times in one tick — each one O(n) over every annotation — making the burst O(n²). The view-side observer now coalesces fires through `requestAnimationFrame`: one rebuild per frame, regardless of how many Y.Map mutations land in the burst. Adds a 500ms post-sync settle rebuild mirroring the `authorship.ts:223` pattern so initial-sync annotations land even if the observer attached before y-prosemirror populated the doc.

### Internal

- **Audit tooling (PR #621)** — added `knip` (devDep) + `audit:origins` + `audit:ymap-keys` scripts under `npm run`. `audit:origins` rewritten with the TypeScript compiler API after the initial regex+line-window heuristic produced 13/13 false positives.
- **Five Y.Map raw-key fixes (PR #621)** — `channel-routes.ts`, `document.ts`, `file-opener.ts` now use `Y_MAP_CLAUDE` / `Y_MAP_READ_ONLY` constants instead of raw string literals (Critical Rule #1 violations; functionally equivalent — same string values).
- **Dead-code sweep (PR #621)** — removed 13 React-migration stub files (`hooks/use*.ts` left as `export {};` after Svelte 5 port), 5 server exports (`killClaude`, `getHocuspocus`, `getClaudeStatus`, `AwarenessState`, `shutdownForTests` alias + test migration), 7 unused shared constants + 2 unused types, 4 unused client exports (`unregisterAction` / `unregisterByPrefix` / `getActions` from action registry; `createEditorFont`), 3 unused color exports (`errorStateColors` / `successStateColors` / `suggestionStateColors`), and 2 dependencies (`@tiptap/extension-unique-id`, `concurrently`). 11 commits, ~250 LOC removed, all CI green.
- **Centralize Tandem HTTP API paths into shared constants (closes #283)** — every `/api/*` path (registration + every client/CLI/channel/monitor fetch) now flows through `src/shared/api-paths.ts`. `API_BASE` in `src/client/utils/fileUpload.ts` no longer carries the `/api` suffix; clients build URLs as `${API_BASE}${API_FOO}`. Renaming a route now touches one file instead of N. Source-level coverage tests in `tests/channel/run-timeouts.test.ts` and `tests/monitor/runtime-fetches.test.ts` accept either the literal path or the resolved `API_*` constant.
- **Define `ChannelErrorCodeSchema` enum for `/api/channel-error` payloads (closes #284)** — channel-shim and monitor failure codes (`CHANNEL_CONNECT_FAILED`, `MONITOR_CONNECT_FAILED`) are now z.enum constants in `src/shared/types.ts` instead of free-form strings. The server route handler validates the incoming `error` field and returns 400 on unknown codes (still logs the rejected value so the diagnostic trail survives). Mirrors the existing `ToolErrorCodeSchema` pattern.
- **Extract shared `onOutsideEvent` dismiss helper (closes #589)** — `Toolbar.svelte` (scroll-based dismiss) and `HighlightColorPicker.svelte` (mousedown-based dismiss) both rolled their own document-listener + `contains(event.target)` guard. Consolidates into `src/client/utils/dismiss-outside.ts`. Capture-phase parity preserved at each call site.
- **Route `clearAndReload` through `populateDocFromContent` (closes #611)** — the force-reload path was the last remaining inline duplication of `loadDocx` + `extractDocxComments` + `htmlToYDoc` + `injectCommentsAsAnnotations`. Routing it through the shared helper means the rollback containment, partial-write cleanup, and comment-extract/inject notification UX that #612 added to the normal-open path now also apply on force-reload. Net delete: ~50 LOC, zero functional regressions.
- **Windows code signing via Azure Trusted Signing + OIDC (PR #685, closes #428)** — replaces self-signed Windows builds with Azure Trusted Signing (Basic tier, Individual Validation). CI authenticates via OIDC federation through a GitHub Actions environment as the subject anchor (no long-lived `AZURE_CLIENT_SECRET`); signing is restricted to `refs/tags/v*` refs by both a workflow-level pwsh guard and a `release`-environment deployment-tag rule. See ADR-030 for full decision rationale, operator setup steps, and rollback procedure.

### Changed

- **Browser distribution deprecated, CORS allowlist narrowed (PR #637, part of #477)** — Tauri desktop is the primary form factor; the npm-global `tandem start` path is being retired. `src/server/open-browser.ts` deleted and the browser auto-open branch removed from `mcp/server.ts`. `tandem start` now emits a deprecation warning and no longer sets `TANDEM_OPEN_BROWSER=1`. The overloaded env var is renamed to `TANDEM_TAURI_SIDECAR` at the three remaining sites (`src/server/index.ts`, `src-tauri/src/lib.rs`, `scripts/ci/stdio-smoke.mjs`). CORS / DNS-rebinding allowlists were narrowed in lockstep across HTTP (`isHostAllowed`, `LOCALHOST_ORIGIN_RE`) and WebSocket (Hocuspocus `onConnect`): the bare `localhost` hostname is rejected — only `127.0.0.1` and `tauri.localhost` are accepted. Tauri sends `Host: tauri.localhost`; the sidecar uses `127.0.0.1`. **Dev workflow note:** local dev must now be accessed at `http://127.0.0.1:5173`, not `http://localhost:5173`; Vite is pinned to `server.host: "127.0.0.1"` so the page origin is unambiguous. Client fetches (`API_BASE` in `fileUpload.ts`, notify-stream EventSource, `/api/close`, `/api/chat`) and Playwright config + E2E spec gotos / fetch helpers all migrated to `127.0.0.1` so the in-page origin and Node `Host` header both pass the narrowed allowlist.
- **Host allowlist audit completion (PR #686, finishes #477 PR 2)** — PR #637 narrowed the server's `isHostAllowed` allowlist to `127.0.0.1` + `tauri.localhost`, but the original audit grep was port-anchored (`localhost:347[89]`) and missed all template-literal forms (`localhost:${port}`). #686 sweeps the remaining surfaces: Rust supervisor URL constants (`HEALTH_URL` / `SETUP_URL` / `OPEN_URL` in `src-tauri/src/lib.rs`), Tauri devUrl + CSP `connect-src`, client Hocuspocus URL, CLI fan-out (`resolveTandemUrl()` default, `mcp setup` URL), server-internal callers (channel spawn, startup banner), OAuth metadata (`/.well-known/oauth-protected-resource` `resource` + `authorization_servers`), vite proxy target, distributed templates (`.mcp.json.example`, `.claude-plugin/plugin.json`, `.env.example`), and dev scripts. The visible symptom was `npm run dev:tauri` reporting "Server failed to start after 3 restart attempts" because the Rust supervisor's `HEALTH_URL` got 403 from the narrowed gate for 15s. A new Rust `#[cfg(test)] mod url_constants_tests` regression-guards the three supervisor URLs against drift back to `localhost`.

### Deferred

<!-- Populated as PRs land. -->

## [0.11.2] - 2026-05-13

### Fixed

- **`effect_update_depth_exceeded` on Tauri launch (PR #614, closes #613)** — installed v0.11.1 desktop builds threw Svelte's effect-depth error immediately on launch; the dev build (`npm run dev:tauri`) did not reproduce, narrowing the trigger to production-mode effect-flush scheduling. Three defense-in-depth fixes: (1) the authorship-toggle effect in `App.svelte` no longer dispatches a ProseMirror transaction on its first run — the plugin already reads `localStorage[AUTHORSHIP_TOGGLE_KEY]` at construction so the editor starts in the correct state; (2) rail-tab reconcile effects now `untrack` their writes to break any read-then-write self-dep; (3) the SettingsPopover error-clear effect skips assignment when values are already null. The existing in-code comment had explicitly warned this dispatch could "exceed the 1000-update depth limit"; under prod's tighter effect scheduling it did.

## [0.11.1] - 2026-05-13

### Added

- **Settings sidebar redesign (PR #600)** — pulls the Claude Design redesign's settings sidebar pattern into the existing tabbed popover. Each nav item gains an inline SVG icon; the sidebar header carries a live version chip from `/api/info`; a new persistent sidebar footer surfaces Changelog, Report-a-bug, and an MCP-connected status dot from every section instead of hiding them under About. `TANDEM_REPO_URL` and `TANDEM_ISSUES_NEW_URL` extracted to `shared/constants` and the three repo links routed through them.
- **Single titlebar consolidating all app chrome (PR #602)** — Tandem brand, Solo/Tandem mode toggle, Claude-active dot, authorship toggle, panel toggles, theme cycle, help, and settings are now in one titlebar row; the secondary toolbar row is deleted. Comment and Note now live exclusively in the floating selection popup (`popup-annotation-input`). Titlebar background uses `--tandem-surface-muted` so it blends with the formatting bar instead of looking like a lighter Windows chrome strip. `THEME_NEXT`/`THEME_LABEL` lookup tables replace the nested-ternary theme rotation; the orphan `useTabDirty` hook and stale `TitleBar.test.ts` are deleted. New testid: `title-bar`.

### Changed

- **`@tauri-apps/api` bumped to 2.11.x** — Tauri CLI v2.11+ enforces major.minor parity between the Rust crate and the npm package. The crate is at 2.11.1; the npm package was at 2.10.1, causing all platform builds to fail. This restores Tauri build parity.

### Fixed

- **Release-mode sidecar always spawns its own server (PR #601)** — a stale `tsx watch src/server/index.ts` from a `npm run dev:server` session was squatting on ports 3478/3479 and answering `/health`, so `start_sidecar` reused it and left the installed app's UI stuck on "Disconnected" — hover and right-click working, but every action that needed the server failing silently because the auth/session state didn't match. The `check_health` early-return is now gated on `cfg!(debug_assertions)` so the dev workflow (`cargo tauri dev` + `npm run dev:standalone`) still benefits from health-reuse, but installed builds never reuse a foreign sidecar. Also drops `stdio: "ignore"` from the Windows `taskkill` path so any future port-bind failure surfaces in logs instead of silently failing; `freePortWindows` now uses `execFileSync` (no shell).
- **CHANGELOG.md no longer rewritten on upgrade (PR #603)** — on version upgrade the app auto-opens `CHANGELOG.md` so the user can see what changed. Previously this opened writable, and the 60-second autosave timer would round-trip the file through `remark-stringify` with default escaping, leaving cosmetic backslash-escape noise on disk (`[1.0.0]` → `\[1.0.0]`, escaped underscores and backticks). The upgrade auto-open path now passes `readOnly: true`, matching the existing "View Changelog" button in Settings; autosave skips read-only documents so the file is not re-serialized. The underlying `remark-stringify` over-escape (which affects every `.md` round-tripped through Tandem) is tracked as #605 for v0.12.0.
- **Display name truncated to `USER_NAME_MAX_LEN` (PR #604, closes #599)** — `resolveUserName` now slices to the 40-char limit, plugging the gap where `persistUserName` and `subscribeToUserName`'s storage handler could persist or broadcast unbounded names from programmatic `setUserName` calls or legacy localStorage values. The `<input maxlength>` cap on the typed-input path remains; this fix covers the non-UI entry points. One slice in `resolveUserName` covers all three callsites.
- **Tutorial annotations now actually appear in `welcome.md` (PR #607)** — commit `acb81fd` (Mar 2026) rewrote welcome.md's intro paragraph but didn't update the matching `targetText` anchors in `tutorial-annotations.ts`. `indexOf("collaborative document editor")` and `indexOf("review your documents")` both returned -1 and 2 of 4 tutorial annotations silently failed to inject. Re-pointed the anchors to phrases that exist in current welcome.md (each occurs exactly once).
- **Titlebar review fixes (part of PR #602)** — async `onMount` listener-leak guard (in-flight Tauri-API awaits self-clean if the component unmounts mid-resolve), window controls correctly show disabled state when Tauri init fails, action-oriented theme button labels for WCAG 4.1.2 ("Switch to dark theme" describes the click outcome, not the current state), and `setup_overlay_titlebar` failure logged at error severity. New regression test `titlebar-listener-leak.test.ts` covers the unmount-mid-await path.
- **Settings changelog error surfacing (part of PR #600)** — the Changelog button lives in the always-rendered sidebar footer, but its error message used to live inside the About panel's `{:else}` arm — users on any other section got no feedback when a fetch failed. The error display now sits adjacent to the button and clears on section change.

### Internal

- **Token-violation hook promoted to blocking** — `check-token-violation.sh` PostToolUse hook now exits 2 (blocking) with `continueOnBlock` framing redirected to stderr so Claude Code sees actionable `file:line` details from the semantic-token scanner instead of silently warning. Hook framing aligned with `block-no-verify.sh` and `block-e2e-port-kill.sh`.

## [0.11.0] - 2026-05-11

### Added

- **Audience-first selection popup (AR3, PR #590)** — Replaces the three-mode state machine (`idle → comment | note`) with a unified popup that appears on text selection. The user types first and chooses audience at submit time via two buttons: "Comment" (sends to Claude, requires text) and "Note to self" (private, always enabled). Bold/Italic formatting and highlight color swatches remain as one-click actions in the top row. Enter submits as Comment; Shift+Enter inserts a newline; Escape dismisses. `InputGroup.svelte` deleted. New testids: `popup-annotation-input`, `popup-note-submit`, `popup-comment-submit`, `popup-highlight-{yellow|green|blue|pink}`.
- **Five annotation visual languages (AR2, PR #586)** — Claude-authored comments now render with a solid underline (`--tandem-author-claude`) instead of the same dashed style as user comments. All annotation inline decorations carry a `data-annotation-author` attribute for CSS targeting (e.g. theme overrides, annotation-patterns mode). The five languages are now fully distinct: highlight (colored bg), note (dotted muted underline), user comment (dashed blue), Claude comment (solid orange underline), suggestion (wavy violet).
- **Annotation schema foundation — audience model (AR1, PR #583)** — adds three optional fields to `AnnotationBase`: `audience` (`"private" | "outbound"`), `promotedFrom` (`"note"`), and `importSource` (`{ author, file }`). `sanitize.ts` derives `audience` on every read for legacy annotations (highlight/note/flag → `"private"`, comment → `"outbound"`, import → `"private"` per design brief). Wire-shape change: all MCP tool responses and channel events now include `audience`. Backward-compatible — existing annotations gain the field on first read; no data loss.
- **Command palette + action registry (closes #571)** — Ctrl+Shift+P opens a fuzzy-search command palette. A central action registry (`src/client/actions/registry.ts`) is the new source of truth for commands and their display shortcuts; the Settings → Shortcuts tab now derives its content from the registry rather than a hardcoded array. Ctrl+S and Ctrl+, are migrated from dedicated hook files into the global keydown handler; `useSaveShortcut.svelte.ts` and `useSettingsShortcut.svelte.ts` are deleted. ADR-029 records the design. New testids: `command-palette`, `palette-input`, `palette-item-{id}`, `palette-empty`.
- **Find / Replace bar (closes #570)** — Ctrl+F opens a find bar anchored to the bottom-right of the editor. Highlights all matches in the document using the existing highlight-yellow token; active match gets a warning-bg border. Enter / Shift+Enter cycle through matches. Replace replaces the active match and advances; All replaces in 100-match chunks to keep Yjs updates bounded. Regex-mode toggle (off by default) with inline error for invalid patterns. All options are session-only (not persisted). New testids: `find-replace-bar`, `find-input`, `replace-input`, `find-next-btn`, `find-prev-btn`, `replace-btn`, `replace-all-btn`, `find-close-btn`, `find-match-count`, `find-regex-toggle`, `find-case-toggle`, `find-word-toggle`.
- **Outline panel for H1–H3 navigation (closes #569)** — Settings → Appearance now offers a "Left Panel" radio (Side / Outline). When Outline is selected, the side/annotations panel is replaced with a compact heading navigator. Click any heading to jump the cursor. Roving tabindex for keyboard navigation. Disabled with explanatory text when the Tabbed layout (no left panel) is active. New testids: `outline-panel`, `outline-heading-{level}-{index}`, `left-slot-kind-radio-{side|outline}`.
- **Root-scoped editor font (closes #568)** — `--tandem-editor-font-family` is now applied to `document.documentElement` so the chosen font propagates to all surfaces (editor, tab labels, toolbar) rather than only the editor container. `applyEditorFontToRoot` and `createRootEditorFont` added alongside the existing per-element helpers.
- **Redesigned format badge on document tabs (closes #568)** — the 1-letter format icon is replaced with a styled pill badge (`MD`, `TXT`, `HTML`, `DOCX`) using format-specific semantic token colours. The dirty-dot slot is now always in layout with `visibility` toggled (prevents tab-width shift between dirty/clean states). New testids: `tab-format-badge-{id}`.
- **Temporary scratchpad (closes #475)** — `Ctrl+N` or "New Scratchpad" in the command palette / tab bar `+` menu opens an ephemeral in-memory document. Content is discarded when the tab is closed. Scratchpad paths use synthetic `upload://` URIs and are excluded from session restore and channel events. New `tandem_scratchpad` MCP tool lets Claude create scratchpads programmatically. Editor auto-focuses on mount for editable documents so the cursor is ready immediately.
- **Relative markdown link navigation (closes #479)** — Clicking relative `.md`, `.txt`, and `.html` links in the editor opens them as new Tandem tabs. External and non-supported links open in the default browser.
- **Documentation button in Settings (closes #457)** — A "View Documentation" button in the Settings popover About section opens `docs/workflows.md` as a read-only tab.
- **Store read-only warning banner (closes #506)** — When the annotation store is locked (read-only), a dismissible warning banner appears in the side panel. Dismiss state persists across sessions.
- **Claude Code automation hooks, agents, and skills (PR #591)** — 6 new hooks (stdout guard, svelte-check, token scanner, related-test runner, --no-verify blocker, E2E port-kill blocker), 2 specialized review agents (annotation-model, svelte-migration), 2 skills (`/changelog`, `/e2e-debug`), and `settings.json` wiring. Block hooks fail-closed on parse error; warn hooks include env-var opt-outs.
- **Theme-color meta tag sync** — `<meta name="theme-color">` updates reactively when the app theme changes, improving desktop PWA and mobile browser chrome appearance.

### Changed

- **Authorship toggle moved to toolbar (closes #587)** — The "Show Authorship" toggle moved from the Settings popover Accessibility section to the main toolbar right cluster for faster access. New testid: `toolbar-authorship-toggle`.
- **Settings dialog responsive breakpoint (closes #515)** — stacked single-column layout at ≤640px; sidebar capped at 45% of dialog height with vertical scroll; four E2E tests cover nav reachability, Tab cycling, focus-after-resize, and content width.
- **Redesign bundle checked into ****docs/redesign-bundle/**** (#521)** — captured the current handoff, HTML previews, CSS, and JSX surfaces used for the app-shell visual pass so follow-on UI work is grounded in a repo-local artifact instead of a transient design URL.
- **Regression coverage added for the remaining app-shell contracts (#521)** — new Playwright and Vitest checks now cover connection banners, reply threads, panel resize, layout switching, onboarding, readonly DOCX review, and apply-changes behavior.
- **Keyboard navigation E2E tests for floating selection toolbar (closes #516)** — Tab/Shift+Tab focus traversal, Enter activation, and Escape-to-editor focus return are now covered by four Playwright tests documenting APG-compliant behavior for transient contextual toolbars.
- **Redesign final QA suite (closes #522)** — Playwright tests covering viewport layouts (600/1280/1920px), `prefers-reduced-motion`, forced-colors/high-contrast mode, dark/light color scheme switching, and keyboard Tab-order reachability.
- **Automated WCAG AA gate** — `tests/e2e/accessibility.spec.ts` uses `@axe-core/playwright` to verify zero contrast violations in both light and dark mode on every CI run; editor content area excluded (user-authored content has arbitrary contrast).
- **Inline link input replaces browser prompt (closes #548, #589)** — The FormattingToolbar's Link button now opens an inline popover instead of `window.prompt()`. The input pre-populates with the existing href when editing a link; submitting empty unsets the link. New testids: `toolbar-link-input`, `toolbar-link-submit`, `toolbar-link-cancel`.

### Changed

- **Updater dialogs are now parented to the main window** — "Update Available", "No Updates Available", and "Update Error" dialogs attach to the Tandem window via `MessageDialogBuilder::parent()`, centering them over the app and inheriting Windows 11 dark-mode chrome from the `tauri-plugin-decorum` shell (closes #561, #553)
- **Custom window chrome via tauri-plugin-decorum** — native OS title bar replaced with a themed custom title bar that re-themes with the rest of the app; preserves Windows Aero Snap, Snap Layouts, resize border, and macOS traffic-light positioning (#554)
- **Tauri shell**: reload shortcuts (F5, Ctrl+F5, Shift+F5, Ctrl+R, Ctrl+Shift+R) are now blocked in the desktop app to prevent accidental navigation away from the editor; DevTools, Find, Print, and right-click context menu are preserved (#541)
- **Semantic token foundation expanded for redesign wave 2 (#521)** — added radius, font-size, shadow, z-index, editor-font-size, and highlight-color token families in `index.html`, plus checker rules that now flag raw `border-radius: <n>px` and inline `box-shadow: ... rgba(...)` in `src/client/`.
- **Read-only/info surfaces now use the shared info token family (#521)** — `ReviewOnlyBanner`, `ConnectionBanner`, `ToastContainer`, `StatusBar`, and related chrome now consume the shared token scales instead of hardcoded radius/text/shadow values.
- **Monitor and channel honor `CLAUDE_PLUGIN_OPTION_SERVER_URL`** — `resolveTandemUrl()` now checks the `CLAUDE_PLUGIN_OPTION_SERVER_URL` environment variable (exported by Claude Code's plugin host from `plugin.json` `userConfig`) before falling back to `TANDEM_URL` and the localhost default. Both the monitor (`src/monitor/index.ts`) and channel shim (`src/channel/run.ts`) benefit automatically. No change for existing installs that don't use `userConfig`. (Drafted as a standalone v0.10.1 patch that was never tagged; shipped as part of v0.11.0.)
- **Monitor and channel honor `CLAUDE_PLUGIN_OPTION_AUTH_TOKEN`** — new `resolveAuthToken()` function in `src/shared/cli-runtime.ts` mirrors `resolveTandemUrl()`. Precedence: `CLAUDE_PLUGIN_OPTION_AUTH_TOKEN` → `TANDEM_AUTH_TOKEN`. `authFetch` uses it automatically, so all stdio subcommands gain the new lookup without caller changes.

### Tests

- **Plugin state machine unit tests for slash command menu (#517)** — added 7 Vitest tests in `tests/client/slash-command.test.ts` that exercise the ProseMirror plugin via a real Tiptap Editor in happy-dom: active state on `/` insertion, close meta, select meta, non-empty selection guard, query filtering with index clamping, ArrowDown wrap-around, and Enter-to-execute.

### Removed

- **ReviewSummary**\*\* overlay removed with review mode already gone (#521)\*\* — the dead component and `App.svelte` mount path are deleted rather than carried forward as unreachable redesign debt.

### Fixed

- **Annotation console flood eliminated (closes #585)** — Deriving `audience` from annotation type is now silent; the `audience-derived` event type has been removed from the sanitization event system. New annotations also carry an explicit `audience` field at creation time.
- **Audience conflict guard (closes #584)** — User-authored notes and highlights can no longer be stored with `audience: outbound`. The sanitization layer enforces this invariant and emits an `audience-conflict-resolved` event when a conflict is detected.
- **Browser path: no light-flash on first paint for dark-mode users** — an inline pre-mount script in `index.html` reads the persisted theme preference (falling back to `matchMedia`) and sets `data-theme` on `<html>` before Svelte mounts, matching the behaviour the Tauri shell already provided via `window.__TANDEM_INITIAL_THEME__` (#551 partial — FOUC mitigated; matchMedia source-of-truth fix deferred to #477)
- **ErrorBoundary now offers in-place recovery before falling back to a full reload (#507)** — the app-root `<svelte:boundary>` re-renders children via `reset()` on a "Try to recover" click, capped at three attempts before forcing the user to reload. The budget resets after each successful recovery so an unrelated subsequent error gets a fresh three attempts. Failed-state surface uses `--tandem-error-bg`/`-border`/`-fg-strong` tokens (was neutral) and re-announces via `role="alert"` on each fresh failure.
- **Toolbar**: HighlightColorPicker border now uses `--tandem-border` token, correctly adapting to light/dark theme switching (#536)
- **Theme system: Tauri shell now reads Windows app-mode preference (****AppsUseLightTheme****) for ****theme: "system"**** instead of taskbar color mode (closes #535)** — `get_app_theme` Rust command reads `WebviewWindow::theme()`, which maps to `HKCU\...\Personalize\AppsUseLightTheme`. Initial theme is seeded before Svelte mounts; `useTauriTheme.svelte.ts` subscribes to `onThemeChanged` and polls every 3s while focused. `matchMedia` subscription is skipped in Tauri to prevent race conditions.
- **Tauri shell: live OS app-mode flips now retheme without restart** — `systemTheme()` reads the live `tauriTheme.current` reactive store (updated by the Tauri theme bridge) instead of a startup-only snapshot; `applyTheme()` in `useTheme.svelte.ts` subscribes reactively so `<html data-theme>` updates immediately when the user switches Windows between light and dark app mode (Codex P1 follow-up to #535).
- **Dark annotation highlight colors** — `--tandem-highlight-yellow/green/blue/pink` now have dark-adapted overrides in `[data-theme="dark"]`; the light `rgba(255, 235, 59, 0.3)`-style values were washed out against dark surfaces.
- **Forced-colors fallbacks for background-only state surfaces (closes #311)** — StatusBar status dots, toast badge, ModeToggle active button, BulkActions confirm button, AnnotationCard type-badge and Private pill now have `border`/`outline` fallbacks in `@media (forced-colors: active)`.

## [0.10.0] - 2026-05-03

Complete React → Svelte 5 migration. All 39 client `.tsx` files have been replaced with Svelte 5 rune-based equivalents; `react`, `react-dom`, and `@tiptap/react` are no longer in the bundle. Includes a review-mode correctness fix, accessibility improvements, and follow-on Codex security hardening.

### Removed

- **react**\*\*, ****react-dom****, ****@tiptap/react**** dropped (#472, #508)\*\* — the React adapter layer is gone. The editor integrates directly with `@tiptap/core` via Svelte 5 components. Bundle size and startup time both decrease.
- **tandem_suggest**\*\*, ****tandem_flag****, ****tandem_highlight**** hard-removed\*\* — stub tools deprecated in v0.9.0 (ADR-027) are now fully removed. MCP tool count: 28 → 25.

### Changed

- **React → Svelte 5 migration (#472, #508)** — all client components rewritten with Svelte 5 runes (`$state`, `$derived`, `$effect`). Component APIs, data-testid selectors, and observable behavior are unchanged; only the rendering layer is new.
- **Note annotation actions** — note cards in the side panel now show **Archive** and **Send to Claude** instead of Remove. "Send to Claude" promotes the note to a comment and fires an `annotation:created` channel event so Claude is notified immediately.

### Fixed

- **Review mode incorrectly treated private notes as review targets (#512, #523)** — Tab/Y/N keyboard navigation, "Accept All" / "Dismiss All" bulk actions, the "Review Complete" overlay trigger, tally counts, and the chat tab badge now all exclude `type: "note"` annotations. Notes remain visible as cards in the side panel. Word-imported comments (`author: "import"`) continue to be review targets.
- **Note privacy — ****tandem_getAnnotations**** and channel events never surface notes to Claude** — `type: "note"` entries are filtered from MCP tool responses and SSE channel events (Codex security review).
- **Y.Map key strings enforced via constants** — raw string literals for Y.Map keys eliminated across the codebase; all access goes through `Y_MAP_ANNOTATIONS`, `Y_MAP_AWARENESS`, etc. from `shared/constants.ts` (Codex security review).
- **Chat message XSS hardening** — link rendering in the chat panel now enforces a protocol allowlist (`https:`, `http:`, `mailto:`), blocking `javascript:` and other unsafe schemes (Codex security review).
- **annotation:edited**\*\* channel event deduplication\*\* — rapid successive edits no longer emit duplicate events to the channel (Codex security review).
- **svelte-check --fail-on-warnings**\*\* now gates the build\*\* — 26 pre-existing Svelte type warnings cleared; CI enforces zero-warning policy going forward.

### Added

- **Keyboard-accessible panel resize handles (#511, #524)** — Arrow keys resize by ±16 px, Page Up/Down by ±80 px, Home/End snap to the minimum/maximum width. `aria-valuenow` reflects the live panel width.
- **ARIA dialog focus management (#511, #524)** — HelpModal and ReviewSummary now trap Tab focus, restore focus on close, and close on Escape. Backdrops carry `role="presentation"`; dialog containers carry `role="dialog" aria-modal="true" tabindex="-1"`.
- **Form label associations (#511, #524)** — AnnotationEditForm inputs are now properly associated with their `<label>` elements.
- **AnnotationCard role corrected (#511, #524)** — changed from `role="button"` (nested-button violation) to `role="listitem"`.

## [0.9.1] - 2026-05-01

Hotfix patch bundling ADR-027 surface cleanup and file-I/O correctness fixes before the v0.10.0 Svelte conversion. All changes are patch-class; no MCP API changes.

### Fixed

- **Imported Word reviewer comments now surface to Claude by default (#482)** — `.docx` reviewer comments are imported as `author: "import"`, `type: "comment"` (was `type: "note"` in the unreleased PR #474 plan). Reverts the `tandem_getAnnotations` `includeImports` opt-in introduced in PR #474 — Claude can read imported comments alongside its own without an explicit flag, which matches the .docx review workflow. The opt-in plumbing (`includeImports` parameter, `importsExcluded` response field) is removed. Existing on-disk records with `author: "import", type: "note"` migrate transparently on read via `sanitizeAnnotation`; on next import the durable record is rewritten in place. Safe because PR #474 was never tagged in a release.
- **Markdown tables preserved across Tiptap round-trip (#379)** — bidirectional MDAST↔Y.Doc table conversion added to `mdast-ydoc.ts`. Tables with mixed column alignment, inline marks in cells, and empty cells all survive load/save cycles. Flat-offset alignment preserved so annotations anchored after a table resolve correctly.
- **HTML blocks and insertion-order fixed in mdast-ydoc (#496)** — raw HTML blocks (`<div>`, `<details>`, etc.) now round-trip as `html` nodes instead of being dropped. Insertion-order bug in `blockToYxml` fixed — the two-pass Y.XmlText attach-before-populate pattern now applied uniformly to all block types.
- **Channel shim per-request timeouts (#364)** — event bridge and `run.ts` now use bounded request-response fetches with split SSE handshake/body watchdogs and a 1 MB SSE frame buffer cap. `tandem_reply` returns a structured timeout error instead of hanging indefinitely.
- **Sanitize coercions routed to migration-log (#483)** — lossy ADR-027 type coercions in `sanitize.ts` (e.g. `flag` → `note`) now emit a `migration-log.ts` entry (once per doc/kind) instead of silently rewriting records, restoring the forensic trail for ADR-027 transitions.
- **Doc hash required for collection logs (#495)** — annotation collection log entries now require a `docHash` field, preventing cross-document log pollution from unkeyed writes.
- **Standalone monitor gated on backend readiness (#491)** — `dev:standalone` waits for the backend health endpoint before starting the monitor, eliminating the startup race that caused spurious connection errors.

### Tests

- **E2E toolbar regression guard (#484)** — Playwright coverage for the redesigned toolbar (ADR-027 note/comment/highlight flow), including a regression guard for the note button empty-annotation bug (#480).

## [0.9.0] - 2026-04-28

### Breaking Changes (MCP)

This is the last breaking-change window before semver lock. MCP tool count: 31 → 28.

- **tandem_suggest**\*\* deprecated (#259)\*\* — returns a structured error stub pointing to `tandem_comment` with `suggestedText`. Hard-remove in v0.10.0.
- **tandem_getContent**\*\* removed (#259)\*\* — superseded by `tandem_getTextContent`.
- **tandem_getSelections**\*\* removed (#259)\*\* — superseded by `tandem_checkInbox`.
- **tandem_setStatus**\*\* merged into ****tandem_status**** (#259)\*\* — `tandem_status` now accepts optional write params (`text`, `focusParagraph`, `focusOffset`). When params are present it writes to awareness; when absent it reads.
- **tandem_flag**\*\* deprecated (ADR-027, #473)\*\* — returns a `DEPRECATED` error stub. Use `tandem_comment` instead. Hard-remove in v0.10.0.
- **tandem_highlight**\*\* deprecated (ADR-027, #473)\*\* — returns a `DEPRECATED` error stub. Highlights are user-only. Hard-remove in v0.10.0.
- **Annotation ****directedAt**** field removed (ADR-027, #473)** — silently ignored on input; stripped from on-disk records via `sanitizeAnnotation` and the `normalizeAnnotation` fast path on read.

### Added

- **/api/info**\*\* endpoint (#441)\*\* — returns app version, MCP SDK version, tool count, data directory, and platform. Serves the Settings panel About footer.
- **Tabbed-left layout variant (#445)** — new `"tabbed-left"` layout mode places the side panel on the left and editor on the right. Three layout modes total: `tabbed`, `tabbed-left`, `three-panel`.
- **App version in Settings (#435)** — `useAppInfo` hook fetches `/api/info` and displays version + MCP SDK version in the Settings popover footer.
- **View Changelog button (#437)** — Settings panel button opens `CHANGELOG.md` as a read-only document tab via `POST /api/open` with `readOnly: true`.
- **Authorship ****data-tandem-author**** attributes (#443)** — authorship decorations switched from CSS classes (`.tandem-authorship--user`) to data attributes (`[data-tandem-author="user"]`), per ADR-026. Enables future attribute-based styling without class proliferation.
- **Schema foundations (#440, #442, #444, #450)** — `heldInSolo` field on `AnnotationBase`; 7 new `TandemSettings` fields (`accentHue`, `editorFont`, `density`, `defaultMode`, `highContrast`, `annotationPatterns`, `selectionToolbar`); `showAuthorship` default flipped to `true`; editor width minimum lowered from 50% to 40%.
- **Highlight palette migration (#450)** — palette switched from 5 colors to 4 (yellow/green/blue/pink). `LEGACY_COLOR_MAP` migrates `red` → `yellow`, `purple` → `blue` on annotation load.
- **CI stdio smoke test (#341)** — GitHub Actions step validates the Cowork stdio bridge (`scripts/ci/stdio-smoke.mjs`) on every push.
- **\__MCP_SDK_VERSION\_\_\*\*\*\* build-time injection** — tsup reads the real SDK version from the package root (not the CJS type marker) and injects it at build time.
- **tandem_getAnnotations**\*\* ****includeImports**** opt-in (ADR-027, #473)\*\* — accepts `includeImports: true` to surface `author: "import"` reviewer comments imported from `.docx` files. Default still excludes them so the user triages first. When imports are filtered out, the response includes `importsExcluded: N` so Claude can prompt the user to opt in.
- **Deprecated-tool user notifications (ADR-027, #473)** — `tandem_highlight`, `tandem_flag`, and `tandem_suggest` stubs now `pushNotification` a warning toast in addition to returning the `DEPRECATED` mcpError, so the user sees what Claude tried.

### Fixed

- **Cross-element edit merging (#456)** — canonical Y.js length + delta-walking merge for edits spanning multiple inline elements.
- **Annotation decoration initial-sync race** — Y.Map observers firing before y-prosemirror sync no longer produce empty decoration sets.
- **Channel checkpoint timing** — checkpoint advances after MCP notification delivery, not before, preventing event loss on reconnect.
- **Auto-save spurious tab switches** — auto-save no longer triggers `document:switched` events that confuse tab state.
- **Annotation recovery guard hardening** — narrowed guard scope for edge cases in session restore.
- **Event-bridge error handling** — uncaught errors in SSE delivery no longer crash the event loop.
- **MCP SDK version resolution** — `require("@modelcontextprotocol/sdk/package.json")` resolves to `dist/cjs/package.json` (a CJS type marker without `version`); build now walks back past `dist/` to find the real version.
- **Silent-migration logging (ADR-027, #473)** — `parseAnnotationDoc`, `migrateToV1`, and the `directedAt` strip fast path now log via the new `migration-log.ts` module (once per `${docHash}:${kind}`) instead of silently rewriting v0 records. Restores forensic trail for the v0→v1 transition.
- **normalizeReply**\*\* validation (#473)\*\* — replies are now Zod-validated before being merged; malformed entries are dropped + logged instead of poisoning the envelope.

### Changed

- **Redesign gap audit resolved (#439)** — 7 product decisions documented in ADR-026. Design response prompt at `docs/claude-design-response-prompt.md`.
- **Distribution items deferred** — #316 (macOS/Linux Cowork auto-setup), #317 (cross-platform firewall scoping), #322 (network-type detection) moved to v0.13.0. Requires macOS/Linux validation hardware.
- **Annotation type model unified to audience-based (ADR-027, #473)** — `flag` → `note`. Three types now: `highlight` (visual marker), `note` (private), `comment` (sent to Claude). Channel observer filters notes from SSE — they never reach Claude. `checkInbox` returns only `comment` annotations.
- **Note toolbar UX (#480)** — Note button now opens an inline input mirroring the Comment flow.
- **Note card visual distinction (#481)** — amber border + warning-bg tint distinguishes notes from comments in the side panel.

### Internal

- Annotation schema Zod validation with `LEGACY_COLOR_MAP` migration path.
- `ResizeHandle` and `TabbedPanelContainer` shared components extracted for layout code reuse.
- `useAppInfo` hook with exponential backoff retry for `/api/info` fetch.
- `file-opener` read-only mode support for changelog viewing.
- 900+ lines of new test coverage: authorship decorations, annotation decorations, panel layout, app info hook, settings fields, schema migration, info route, document edit edge cases.

## [0.8.0] - 2026-04-26

### Added

- **NSIS pre-install sidecar kill (#434)** — the NSIS installer now kills the running `node-sidecar.exe` process before file replacement, preventing "Error opening file for writing" failures during upgrade installs. Uses `nsis_tauri_utils::KillProcessCurrentUser` for user-scoped process termination. Tauri's built-in `CheckIfAppIsRunning` already handles the main binary.
- **Semantic token lint enforcement (#356)** — `npm run check:tokens` scans `src/client/` for raw hex and non-neutral `rgba()` violations. Runs on pre-commit via lint-staged, blocking merges that introduce unsanctioned color literals.
- **--tandem-suggestion-\*\*\*\*\* token family (#340)** — violet semantic tokens for replacement/suggestion annotations (`--tandem-suggestion`, `-fg-strong`, `-bg`, `-border`), visually distinct from the indigo accent family.
- **Annotation drop count surfacing (#351)** — `normalizeAnnotation` now returns drop counts in snapshot metadata so callers can detect lossy session migrations.
- **Plugin monitor declaration (#376)** — `plugin.json` now declares the `monitor` entry, closing the event push gap where Claude Code plugin installs didn't receive real-time notifications.
- **Persistent annotation undo (#415)** — undo state survives panel switches and scrolling; persists until page reload instead of clearing on the next render cycle.
- **Diagnostic position tests (#377)** — regression test suite for flat-offset resolution across headings, inline marks, nested lists, and blockquotes.

### Fixed

- **Three compounding coordinate system bugs (#260)** — inline markup (bold, italic, code) inflated character offsets; nested block structures (list items, blockquotes) lacked separators; and list item text extraction omitted `\n` between siblings. All three bugs compounded silently — a bold word inside a nested list could shift annotation placement by 10+ characters. Fixed in `getElementText()` and `extractText()` with full encapsulation of the position module behind `resolveToElement()`.
- **Flash animation alpha wash (#308)** — accept/dismiss flash used opaque background that hid annotation text; now uses `color-mix` with translucent blend against the surface.
- **Dark mode scrollbar styling (#369)** — scrollbars in the editor and side panel now respect the active theme.
- **Biome format check (#424)** — expanded a single-expression `useEffect` arrow that Biome 2.x reformatted differently than the original.

### Changed

- **User annotations simplified (#381)** — user-authored annotations show Edit and Remove only; Accept/Reject reserved for Claude and imported annotations. Reduces cognitive load — user notes are notes, not proposals.
- **Toolbar streamlined (#382)** — removed Replace and @Claude checkboxes from the annotation creation toolbar. These features remain available via MCP tools (`tandem_suggest`, `tandem_comment` with `directedAt`).

### Refactored

- **Codebase audit remediation (Phases 1–4)** — 8 god-files decomposed across 4 phases:
  - **Phase 1** (PRs #384–#389): wire-protocol types to `shared/`, token-store extraction, `awareness.ts` semantic tokens, Editor CSS extraction, tsconfig tightening, dead Tauri JS deps removed.
  - **Phase 2** (PRs #391, #392): `api-routes.ts` split into per-route handler modules; `file-opener.ts` decomposed into phased helpers with lifecycle tests.
  - **Phase 3** (PR #398, tests #399/408): event queue observer split — monolithic `queue.ts` broken into focused observer modules per Y.Map.
  - **Phase 4** (PRs #409–#413): `App.tsx` hooks extracted, `SidePanel.tsx` decomposed, `Toolbar.tsx`/`SettingsPopover.tsx` split, `AnnotationCard.tsx` broken into 3 sub-components.
- **Zero-arg handler factory simplification (#393)** — reduced boilerplate in MCP handler registrations.
- **Shared annotation test fixtures (#344)** — extracted reusable test helpers for annotation creation.
- **HTTP API silent failure surfacing (#396)** — API routes that swallowed errors now return proper status codes.

### Internal

- Mixed-partial `/api/setup` 207 test (#292).
- `@xmldom/xmldom` dependency bump (#390).
- CI: typecheck/lint/test gates on all PR base branches.

## [0.7.1] - 2026-04-20

### Fixed

- **MSIX Claude Desktop detection** — `tandem setup` now detects Claude Desktop installed via MSIX (Microsoft Store) and generates stdio MCP entries for it (#372)

## [0.7.0] - 2026-04-20

### Added

- **Auth token storage** — on first boot the server generates a 32-byte base64url token and persists it to the platform data directory (`%LOCALAPPDATA%\tandem\Data\auth-token` on Windows, `~/.local/share/tandem/auth-token` on Linux, `~/Library/Application Support/tandem/auth-token` on macOS). Subsequent boots reuse the token. First-boot race is protected by `O_EXCL` file creation. Tauri mode receives the token via `TANDEM_AUTH_TOKEN` env before sidecar spawn and never regenerates.
- **Auth middleware** — non-loopback MCP and API requests require `Authorization: Bearer <token>`. Loopback connections (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) remain exempt, preserving zero-config Claude Code usage. Token comparison uses SHA-256 on both sides before `crypto.timingSafeEqual` to eliminate the length oracle. Rate-limiting (5 attempts / 60 s) keyed by IPv4 address or IPv6 `/64` prefix with LRU eviction; Authorization headers are redacted from all rejection logs.
- **TANDEM_BIND_HOST**\*\* bind-mode selection\*\* — MCP HTTP server binds to `127.0.0.1` by default; set `TANDEM_BIND_HOST=0.0.0.0` (or a specific LAN IP) to expose Tandem on the local network. Hocuspocus WebSocket always stays loopback. Non-loopback bind without a token file exits 1 with guidance; `TANDEM_ALLOW_UNAUTHENTICATED_LAN=1` is the escape hatch. Multi-homed machines require `TANDEM_LAN_IP` to be set explicitly.
- **tandem rotate-token** — new CLI subcommand that atomically regenerates the auth token, notifies the running server to open a 60-second grace window for in-flight sessions, and re-runs `tandem setup` across all detected MCP config files. Prints old and new token fingerprints (first 8 hex chars of SHA-256). Refuses rotation when `TANDEM_AUTH_TOKEN` is set in the environment (Tauri mode).
- **Token forwarding in stdio bridge, monitor, and channel sidecars** — `tandem mcp-stdio`, `tandem monitor`, and the channel sidecar now forward `TANDEM_AUTH_TOKEN` as `Authorization: Bearer` on upstream HTTP calls. Malformed tokens (empty, `Bearer`-prefixed, < 32 chars, non-URL-safe) exit 1 with a specific message.
- **OAuth protected-resource metadata** — `/.well-known/oauth-protected-resource/mcp` now declares `bearer_methods_supported: ["header"]` and a literal-`localhost` `resource` field per RFC 9728.
- **/health**\*\* session-presence guard\*\* — `hasSession` is omitted from `/health` responses on non-loopback requests, preventing session-presence leakage on LAN binds.

### Security

- Loopback detection keys off `req.socket.remoteAddress` exclusively — `Host` header is never trusted for the loopback bypass decision.
- Fail-closed on LAN bind: `TANDEM_BIND_HOST=0.0.0.0` without a token file exits 1; the server never auto-generates a token and proceeds silently.
- `crypto.randomBytes` failure or non-writable data directory → server exits 1; no silent fallback.

## [0.6.4] - 2026-04-20

### Fixed

- **Flaky layout-switch E2E test (#281)** — `toHaveCount` assertions on resize-handle locators lacked explicit timeouts, causing intermittent CI failures under load when React re-renders were slower than the default 5 s expectation. All 8 assertions now carry `{ timeout: 10_000 }`, and the missing `right-panel-resize-handle` absence assertion in the tabbed-layout block has been added.
- **Silent crashes in CLI entry points (#336)** — `src/cli/index.ts` and `src/channel/index.ts` lacked `process.once("uncaughtException")` / `process.once("unhandledRejection")` handlers. Uncaught throws in `tandem start`, `tandem setup`, and the Tauri channel sidecar exited silently with code 0, surfacing as "tools never appear" with no diagnostics. Both entries now write a labelled message to stderr and exit 1. The `uncaughtException` handler uses `err: unknown` with an `instanceof Error` guard so non-Error throws (strings, plain objects) produce the actual thrown value rather than `"undefined"`.

## [0.6.3] - 2026-04-19

### Fixed

- **Annotation GC race on startup (#334)** — `cleanupOrphanedAnnotationFiles` previously ran as a `.then()` chain during boot, racing the boot-path doc opens. On upgrade paths where `sample/welcome.md` or `CHANGELOG.md` hadn't been opened in 30+ days, the GC could unlink the annotation file between read intent and the actual read, silently returning an empty doc. Now `await`-ed before all boot-path opens.
- **Settings Popover extends out of view (#306)** — centered the popover in the viewport with `transform: translate(-50%, -50%)` and added `maxHeight: calc(100vh - 32px)` + `overflowY: auto` so it is always fully visible and internally scrollable on short screens.
- **Dark-mode ****\*-bg**** tokens inconsistent (#307)** — `--tandem-success-bg` and `--tandem-warning-bg` in dark mode were hand-coded hex while `--tandem-error-bg` used `color-mix`. All three now use `color-mix(in srgb, var(--tandem-<semantic>) 15%, var(--tandem-surface))` for consistency with light-mode behavior.
- **stdio bridge silent-failure paths (#336 partial)** — three paths in `src/cli/mcp-stdio.ts` (preflight exit, `http.onclose`, `http.start()` TOCTOU) previously closed stdio without writing a JSON-RPC error, producing "tools never appear in Cowork" with no diagnostics. All three now synthesize `-32000` for any in-flight request ID before exit. Remaining #336 items (channel-shim tests, Windows npx smoke, nits) carry to v0.7.0.

### Changed

- **Annotation module internals** — extracted `mergeMap<T>` helper (#324), promoted `UPLOAD_PREFIX` to shared constants (#327), centralized app-data dir resolution in `platform.ts` (#328), extracted `ReplyAuthorSchema`, trimmed module headers, and dropped unused `docContexts` map (#332). No user-facing behavior changes.
- **Annotation serialization upgrades legacy ****type**** values on write (#329)** — records with non-canonical `type` (`"suggestion"` / `"question"` / anything outside `highlight` / `comment` / `flag`) are now routed through `sanitizeAnnotation` during snapshot serialization, which rewrites them to `"comment"`. **One-way lossy migration:** users with legacy-type annotations will see `type` flip to `"comment"` on the next durable write for that document — the original distinction between `suggestion` and `question` is not recoverable.
- **migrateToV1**\*\* reports drop counts (#330)\*\* — `migrateToV1(raw)` now returns `{ doc, droppedAnnotations, droppedReplies }` so future production callers can surface lossy upgrades to users rather than silently discarding malformed records. No production caller exists yet; a follow-up will wire drop counts to `npm run doctor` or a toast when the first caller lands.

### Internal

- Test coverage: `pickWinner`, `SerializedRelPos` edges, UNC paths, upload-path edges (#331); `wireAnnotationStore` perf baseline at 500/1000/5000 annotations (#335).
- Test sweep: stale hex color refs cleaned up post-PR #303 (#309).
- Accessibility: forced-colors fallback audit on PR #303 annotation surfaces (#311).
- CI: typecheck / lint / tests now gate on all PRs regardless of base branch; dropped unused `baseUrl` from `tsconfig.server.json` (#310).

## [0.6.2] - 2026-04-16

### Fixed

- **Plugin stdio entries crash-loop on Windows** — `tandem-editor@0.6.1`'s published `package.json` shipped `"workspaces": ["packages/*"]`, a dev-only field for the vestigial `packages/tandem-doc/` alias stub. On Windows with Node 24 + npm 11, the presence of `workspaces` in an installed consumer package caused `npx -y tandem-editor …` to fail with `ERR_UNSUPPORTED_ESM_URL_SCHEME` (the bin path was handed to the ESM loader as a raw `c:\…` string instead of a `file://` URL). Claude Desktop's plugin loader spawns the stdio entries via `npx -y`, so both `tandem` and `tandem-channel` crash-exited before any user code ran, manifesting in Cowork sessions as entries that flapped "connecting → gone" and never surfaced `tandem_*` tools. Removed the unused `packages/tandem-doc/` directory and the `workspaces` field; direct `node dist/cli/index.js` invocation was never affected, so the Tauri desktop app's bundled sidecar was fine and only the npm-tarball/`npx` path needed the fix.

## [0.6.1] - 2026-04-15

### Fixed

- **Tauri desktop app fails to start** — `src/cli/skill-content.ts` reads `skills/tandem/SKILL.md` at module-init via `readFileSync`, and `src/server/mcp/api-routes.ts` transitively imports it, so the bundled sidecar server crashed on startup with `ENOENT: skills/tandem/SKILL.md`. The `skills/` directory was never declared in `src-tauri/tauri.conf.json` bundle resources (latent since the v0.5.1 refactor to read SKILL.md at runtime). Add `"../skills/": "skills/"` so the sidecar's relative path resolution matches the npm-install layout. npm-published 0.6.0 was unaffected because `skills/` ships in the npm tarball via `package.json` `files`.

## [0.6.0] - 2026-04-15

### Added

- **Plugin bridge to Cowork** — new `tandem mcp-stdio` subcommand is a stdio ↔ HTTP JSON-RPC proxy so Claude Desktop's plugin loader surfaces the full `tandem_*` tool surface into Cowork VM sessions. Verified empirically that plugin-loaded HTTP MCP entries don't bridge to Cowork but plugin-loaded stdio entries do. The plugin's `.claude-plugin/plugin.json` now declares stdio entries for both `tandem` (proxy) and `tandem-channel` (existing shim re-exposed as `tandem channel` subcommand), invoked via `npx -y tandem-editor …` so the plugin cache never needs dev dependencies. Both subcommands share a strict preflight in `src/cli/preflight.ts` that fails fast with a single clear message when the Tandem server isn't running on `localhost:3479`.
- `tandem channel` CLI subcommand — npm-delivered entry for the plugin's `tandem-channel` MCP server; shares runtime with the standalone `src/channel/index.ts` binary via the new `src/channel/run.ts` extraction.
- **Settings expansion** — Settings popover grows from layout/dwell/authorship into a fuller preferences surface:
  - Ctrl+, / Cmd+, hotkey (AZERTY/QWERTZ/IME-safe, survives non-QWERTY layouts)
  - Display Name field, synced live with the StatusBar via a shared `useUserName` hook
  - Reduce Motion toggle (JS-gates all autoscroll paths; defaults to `prefers-reduced-motion`)
  - Text Size S/M/L for editor reading density (browser zoom remains the WCAG 1.4.4 path)
  - Theme Light/Dark/System (CSS custom-property token system on `<html data-theme>` with `forced-colors` support for Windows High Contrast)
- Tier 0 accessibility prerequisites on SettingsPopover: `role="dialog"` + `aria-modal`, focus trap, Escape-to-close, pointerdown outside-dismiss, radiogroup semantics, 24×24 hit targets, focus-return on close

### Changed

- Repo's project-level `.mcp.json` renamed to `.mcp.json.example` and gitignored so it no longer ships inside plugin installs. The plugin's own `.claude-plugin/plugin.json` is authoritative for MCP wiring. Developers who clone the repo should copy the example to `.mcp.json` (gitignored) if they want Claude Code to auto-connect locally.
- Settings heading renamed "Layout Settings" → "Settings"
- Settings popover hardcoded hex values swapped to CSS tokens (remaining components will migrate in a follow-up)
- `shutdownForTests` renamed to `shutdownMonitor` (test-only alias kept for backward compatibility)
- `refreshMode` IIFE now wrapped in an outer `.catch` to keep future synchronous throws off the hot path

### Fixed

- \*\*Monitor preserves last-known \*\***documentId** — doc-less events (e.g. `chat:message`) no longer blank out the tracked document, so the shutdown awareness clear always targets a valid document.
- **Monitor exits 1 on shutdown awareness failure** — if the final `clearAwareness` POST fails during SIGINT/SIGTERM, the monitor exits with a non-zero status rather than silently succeeding.
- **/api/setup**\*\* returns accurate status codes\*\* — 207 on partial failure (some targets configured, some failed) and 500 on total failure, instead of always returning 200.
- **Checkpoint after stdout write** — `lastEventId` is only advanced after `process.stdout.write` returns, so EPIPE on a closed pipe no longer silently skips an event on reconnect.
- **Async EPIPE surfaces as exit(1)** — `process.stdout.on('error')` listener now catches asynchronous EPIPE (plugin host closes pipe mid-stream); monitor exits 1 instead of silently advancing `lastEventId` past lost events.
- **Defensive exit on monitor fallthrough** — the retry loop exits 1 if it ever terminates without hitting the explicit exhaustion path.

## [0.5.1] - 2026-04-13

### Added

- **Claude Code plugin support** — monitor-based event push (`src/monitor/index.ts`) gives real-time notifications without polling or the channel shim. Install via `claude plugin marketplace add bloknayrb/tandem`.
- **--with-channel-shim**\*\* opt-in\*\* — `tandem setup --with-channel-shim` writes the legacy `tandem-channel` MCP entry for setups that can't install the plugin.

### Changed

- `tandem setup` no longer writes the `tandem-channel` MCP entry by default — running the plugin and the shim simultaneously produces duplicate event notifications. The shim is now opt-in only via `--with-channel-shim`.

### Fixed

- **Windows update failure** — sidecar is now killed before the NSIS installer runs, preventing "Error opening file for writing: node-sidecar.exe" during updates
- **Mode check fails closed** — `/api/mode` errors now fall back to "solo" at startup (privacy signal, not a permissive default) while the hot-path background refresh keeps the last known good value to avoid mid-session suppression.
- **Retry counter resets on stable uptime** — retry count now resets only after 60s of continuous uptime, not on every delivered event; prevents infinite reconnect loops when the server crashes after each event.
- **Exponential backoff on reconnect** — monitor reconnects use 2s/4s/8s/16s/30s backoff instead of a fixed 2s delay.
- **SIGINT/SIGTERM clears awareness** — monitor posts a final `clearAwareness` before exit so the "Claude is active" indicator doesn't hang in the browser.
- **Per-route fetch timeouts** — `AbortSignal.timeout` enforces budgets per route (connect 10s, mode 2s, awareness 5s, error report 3s) to prevent hung SSE connects or mode lookups from stalling the monitor.
- \*\*SSE parse errors don't advance \*\***lastEventId** — JSON parse failures and schema validation errors are logged with event ID + frame tail but do not advance `lastEventId`, so bad events are re-delivered on reconnect rather than silently dropped.
- **SKILL.md corrected** — `question` annotation guidance now uses `type === 'comment' && directedAt === 'claude' && author === 'user'`; all 5 highlight colors listed (yellow, red, green, blue, purple).

## [0.5.0] - 2026-04-13

### Added

- **Authorship tracking** — Y.Map overlay marks text as user-written or Claude-written, with text-color styling (blue for user, orange for Claude) (#190)
- **Threaded annotation replies** — reply to annotations with back-and-forth conversation threads (#187)
- **Claude cursor decoration** — character-level cursor shows where Claude is editing in real time (#209)
- **Auto-save** — documents save automatically on change; Ctrl+S triggers immediate manual save (#272)
- **Text zoom** — keyboard shortcuts (Ctrl+=/Ctrl+-) for adjusting text size in the Tauri desktop app (#273)
- **Three-panel default layout** — editor, side panel, and chat visible by default (#264)
- **Selection event suppression** — selection events only fire after a chat message is sent, reducing noise (#270)
- V1.0 release plan added to roadmap (#279)

### Fixed

- Session persistence, tab bar horizontal scrollbar, and tab cycling keyboard shortcuts (#278)
- Authorship styling uses text color instead of background highlight; reopen sync corrected
- Annotation replies renamed from Acknowledge/Dismiss to Accept/Reject for clarity

### Changed

- Pinned Hocuspocus and Y.js dependency versions to prevent upstream breakage (#271)
- EOL normalizer added to lint-staged for .yml and .md files (#263)
- Lessons learned applied to codebase and tooling (#280)

## [0.4.0] - 2026-04-12

### Added

- Tauri v2 desktop app wrapping the existing web editor (macOS, Linux, Windows)
- System tray with menu: Open Editor, Setup Claude, Check for Updates, About, Quit
- Single-instance detection — second launch focuses the existing window instead of opening a duplicate
- Node.js sidecar lifecycle: auto-spawn on startup, health polling, crash restart with exponential backoff
- Auto-updater checks on launch and every 8h; manual check available via tray menu "Check for Updates"
- Cross-platform CI release workflow (macOS arm64/x64, Linux x64, Windows x64) with GitHub Releases
- Window state persistence — position and size remembered across sessions
- Sample file copied to writable data directory on first run (supports read-only app bundles)
- Self-signed code signing for Windows builds in CI
- MCP setup auto-configuration on launch — writes Claude Code/Desktop config without manual `tandem setup`

### Fixed

- Accept `tauri.localhost` origin in WebSocket and CORS checks so the production WebView can connect
- Strip Windows `\\?\` extended-length path prefix returned by Tauri path APIs before passing to Node
- Sidecar binary name resolution corrected for Tauri's platform-specific naming convention
- Self-contained JS bundles via tsup `noExternal` so Tauri doesn't require `node_modules` at runtime
- Poll for port release before restarting after update install, preventing a startup race condition
- Surface last HTTP error in health-poll timeout diagnostics for easier debugging

### Changed

- Updater-unavailable log downgraded from error to debug (reduces noise in dev builds without updater keys)
- Unhandled sidecar events are now logged instead of silently dropped
- Warn when `sample/` directory is missing in release builds
- CI: fail build when updater signing key secret is absent
- CI: summary job catches partial platform build failures rather than reporting false success

## [0.3.2] - 2026-04-12

### Changed

- **Annotation type unification:** Three semantically identical types (`comment`, `suggestion`, `question`) collapsed into a single `comment` type with optional `suggestedText` and `directedAt` fields. `AnnotationTypeSchema` reduced from 5 values to 3: `highlight`, `comment`, `flag`. (#193, #245, #255)
- **Toolbar:** Three annotation buttons (Comment, Suggest, Ask Claude) replaced with a single Comment button with "Replace" and "@Claude" toggles (#193)
- **Side panel filters:** "Suggestions" → "With replacement", "Questions" → "For Claude" (#193)
- \*\*sanitizeAnnotation()\*\*\*\* moved to \*\***src/shared/sanitize.ts** — now available to both server and client code. Client-side Y.Map reads are sanitized to handle legacy session data. (#255)

### Added

- Link (Ctrl+K), Horizontal Rule, and Code Block buttons in the formatting toolbar (#204)
- Replacement cards show a visual diff — original text in red strikethrough → replacement in green (#195)
- Undo countdown progress bar — a shrinking indicator shows the 10-second undo window (#196)
- Review mode shortcut hints (Y / N / ↑↓ / Z) shown below the Review button (#200)
- Chat anchor previews expand on hover to show full text (#198)
- `disabledTitle` prop on toolbar buttons — annotation buttons show "Select text first" when no text is selected (#197)
- Explicit ✕ close button on the highlight color picker (#203)
- `tandem_comment` now accepts optional `suggestedText` and `directedAt` parameters (#193)
- `sanitizeAnnotation()` normalizes legacy `suggestion`/`question` entries at read boundaries — permanent migration for historical session data (#193)
- Exhaustive type switch in `buildDecorations` catches unhandled annotation types at compile time (#255)
- 8 new tests covering MCP tool params, sanitization edge cases, and legacy migration paths (#255)

### Fixed

- Toolbar wraps to a second row on narrow windows instead of overflowing; inline inputs shrink responsively (#192)
- Edit button on annotation cards now shows a visible "✎ Edit" label instead of icon-only (#201)
- Client-side legacy annotations (from pre-0.3.2 sessions) no longer render as invisible decorations or display raw JSON (#255)
- `sanitizeAnnotation` no longer drops `textSnapshot: ""` or `editedAt: 0` via falsy-check bug (#255)
- Event queue observer no longer silently dies if `sanitizeAnnotation` throws (#255)
- `handleEdit` catch-block no longer corrupts annotation data on JSON parse failure (#255)

### Deprecated

- `tandem_suggest` MCP tool — use `tandem_comment` with `suggestedText` parameter instead (#193)

### Removed

- **MCP wire change:** Removed unused `"overlay"` annotation kind from `AnnotationTypeSchema`. External clients sending `type: "overlay"` will now receive a Zod validation error. (#249)
- **MCP wire change:** `suggestion` and `question` annotation types removed from `AnnotationTypeSchema`. Use `comment` with `suggestedText` or `directedAt` fields. Legacy data is migrated automatically via `sanitizeAnnotation()`. (#193)

## [0.3.0] - 2026-04-07

### Wave 4: Notification & Interruption Redesign

- **Solo/Tandem mode** replaces All/Urgent/Paused interruption controls (#207, #226)
- **Dwell-time selection events** — selections fire after 1s hold (#188)
- **Configurable layout** — tabbed or three-panel, with settings popover (#206)
- **Click-to-navigate** — click annotated text to jump to annotation card
- **Tab badges** — notification counts on inactive panel tabs
- **Skill-directed response routing** — Claude responds in chat panel, not terminal
- Review banner replaced with per-annotation toasts (#208, landed earlier)
- `Y_MAP_MODE` constant, Zod validation for mode reads, error logging in channel event bridge
- 894 tests passing

## [0.2.12] - 2026-04-06

### Added

- Undo/redo toolbar buttons powered by Y.js UndoManager — tracks document edits with proper CRDT-aware undo scoping (#189, #210)
- Adjustable editor content width toggle — switch between comfortable and full-width layouts, preference persists in localStorage (#185, #205)
- SVG icons for unordered list, ordered list, and blockquote toolbar buttons, replacing plain text labels (#194)
- Automated npm publishing via GitHub Actions with OIDC trusted publisher (tokenless)

### Fixed

- Guard all localStorage access with try-catch for private/disabled browser storage modes; reset scroll position on annotation filter clear (#212, #202)

## [0.2.11] - 2026-04-06

### Added

- Auto-reload documents when files change on disk — Tandem detects external edits (e.g., Claude's Edit tool) via `fs.watch`, reloads content, and preserves existing annotations (#175)
- File watcher module with 500ms debounce and self-write suppression (prevents reload loops when Tandem saves)
- Toast notification when a document is reloaded from disk
- Runtime warning when `onDocSwapped` callback is missing during Hocuspocus doc swap (defensive guard for #178 audit)
- 28 new tests: observer reattachment, CTRL_ROOM lifecycle, buffer cap, file watcher debounce/suppress, annotation-preserving reload

### Fixed

- Dead CRDT `relRange` handling — `refreshRange` now strips broken CRDT anchors and re-anchors from flat offsets instead of leaving annotations permanently stuck with non-functional RelativePositions (#175)
- Buffer cap test was previously a no-op (empty loop body) — now actually exercises the event queue buffer (#178)

### Changed

- CLAUDE.md gotcha for Hocuspocus doc replacement updated to document the automatic `onDocSwapped` callback lifecycle (#178)

## [0.2.10] - 2026-04-05

### Added

- Resizable side panel — drag to resize between 200–600px, width persists in localStorage
- Accessibility: ARIA labels on annotation highlights (type-specific), annotation cards (`role="listitem"`, `aria-current`), annotation list (`role="list"`), review mode button (`aria-pressed`), live region for pending count and review progress

### Fixed

- Flaky session tests — each test file now uses an isolated temp directory via `vi.mock`, eliminating cross-file race conditions (#177)
- Session file writes use atomic rename with retry on Windows EPERM/EACCES (#173)

### Changed

- `atomicWrite()` extracted as shared helper in session manager — consolidates duplicate write-tmp-rename logic with exponential backoff retry

## [0.2.9] - 2026-04-05

### Fixed

- Changelog tab no longer disappears after upgrade — version check and sample/welcome.md now open before servers start, preventing CRDT merge races with stale browser tabs
- Tutorial annotation injection errors now get their own log message instead of being misattributed as file-open failures

## [0.2.8] - 2026-04-05

### Added

- CHANGELOG.md opens as the active tab on first startup after an npm update
- `checkVersionChange` helper tracks version transitions via `last-seen-version` file
- CHANGELOG.md now ships in the npm package

## [0.2.7] - 2026-04-05

### Fixed

- Force-reload (`tandem_open` with `force: true`) now clears Y.Doc in-place instead of destroying the Hocuspocus room — sidebar, observers, and connections survive
- TOCTOU fix: session deletion moved after successful reload transaction
- Observer ownership table corrected in architecture docs

### Added

- 4 new tests for force-reload (annotation clearing, awareness clearing, .txt reload, metadata)

## [0.2.6] - 2026-04-05

### Fixed

- Demo script rewritten to be self-referential for recording
- Observer ownership documentation added to architecture.md

## [0.2.5] - 2026-04-05

### Fixed

- `tandem setup` Claude Code MCP config path updated

## [0.2.4] - 2026-04-05

### Fixed

- Security audit findings (DNS rebinding, CORS, input validation)

## [0.2.3] - 2026-04-05

### Fixed

- `tandem setup` now writes Claude Code MCP config to `~/.claude.json` instead of `~/.claude/mcp_settings.json`, which Claude Code no longer reads

## [0.2.2] - 2025-04-05

### Fixed

- Silent failure review findings

## [0.2.1] - 2025-04-05

### Fixed

- Full security audit — 25 findings across 7 categories (#172)

## [0.2.0] - 2025-04-04

### Added

- Initial public release on npm as `tandem-editor`
- 30 MCP tools for collaborative document editing
- Multi-document tabs with CRDT-anchored annotations
- Chat sidebar with real-time channel push
- Support for .md, .docx, .txt, .html files
- `tandem` CLI with `setup` and `start` commands
- Claude Code skill auto-installation
