@plannotator/ui 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/HANDOFF.md +317 -29
  2. package/README.md +35 -3
  3. package/components/AnnotationPanel.tsx +159 -107
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +9 -0
  6. package/components/BlockRenderer.tsx +26 -14
  7. package/components/CodeFilePopout.tsx +131 -68
  8. package/components/CommentPopover.tsx +6 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/ImageLightbox.tsx +288 -17
  13. package/components/ImagePeek.tsx +255 -0
  14. package/components/ImageResolverContext.tsx +31 -0
  15. package/components/InlineMarkdown.tsx +123 -15
  16. package/components/LocalLinkContext.tsx +49 -0
  17. package/components/MenuVersionSection.tsx +51 -25
  18. package/components/OpenInAppButton.tsx +18 -49
  19. package/components/PlanHeaderMenu.tsx +2 -8
  20. package/components/ProviderIcons.tsx +10 -4
  21. package/components/RenderedMarkdown.tsx +40 -4
  22. package/components/Settings.tsx +33 -5
  23. package/components/SkillReferenceMenu.tsx +28 -6
  24. package/components/Tooltip.tsx +51 -4
  25. package/components/Viewer.tsx +214 -55
  26. package/components/ai/AIProviderBar.tsx +11 -0
  27. package/components/ai/AskSessionSetupHint.tsx +12 -0
  28. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  29. package/components/ai/SessionAskNotice.tsx +4 -3
  30. package/components/blocks/BlockquoteBlock.tsx +159 -0
  31. package/components/blocks/HtmlBlock.tsx +121 -9
  32. package/components/blocks/QuestionBlock.tsx +253 -104
  33. package/components/diagram/DiagramViewer.tsx +41 -4
  34. package/components/diagram/index.ts +1 -1
  35. package/components/html-viewer/HtmlViewer.tsx +73 -3
  36. package/components/html-viewer/bridge-script.asset.js +86 -0
  37. package/components/html-viewer/bridge-script.ts +86 -0
  38. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  39. package/components/icons/AgentIcons.tsx +9 -7
  40. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  41. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  42. package/configure.ts +27 -1
  43. package/hooks/useAgentJobs.ts +8 -3
  44. package/hooks/useAnnotationHighlighter.ts +128 -23
  45. package/hooks/useExternalAnnotations.ts +6 -0
  46. package/hooks/useOpenInApps.ts +88 -0
  47. package/hooks/useUpdateCheck.ts +38 -98
  48. package/hooks/useUpdateCheckSetting.ts +43 -0
  49. package/hooks/useUpdateToast.ts +101 -0
  50. package/package.json +2 -2
  51. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  52. package/shortcuts/index.ts +1 -0
  53. package/styles.css +1 -1
  54. package/types.ts +48 -0
  55. package/utils/agentClosed.ts +26 -0
  56. package/utils/agentToolSetting.ts +16 -9
  57. package/utils/aiProvider.ts +7 -0
  58. package/utils/askSessionSetupHint.ts +27 -0
  59. package/utils/blockTargeting.ts +14 -3
  60. package/utils/parser.ts +65 -51
  61. package/utils/pasteInsert.ts +234 -0
  62. package/utils/planDiffEngine.ts +272 -2
  63. package/utils/skillCatalog.ts +15 -2
  64. package/utils/skillReferences.ts +50 -13
  65. package/utils/storage.ts +5 -0
  66. package/utils/textCut.ts +41 -0
  67. package/utils/updateNotice.ts +120 -0
package/HANDOFF.md CHANGED
@@ -99,19 +99,19 @@ Pass any subset of these to `configurePlannotatorUI({ ... })`. Anything omitted
99
99
 
100
100
  ### Interface details worth knowing
101
101
 
102
- **`StorageBackend`** — must be **synchronous** (`getItem`/`setItem`/`removeItem` return immediately). If Workspaces' real store is async (KV, D1, a Durable Object), back this with an in-memory cache that you hydrate before mounting the UI, and write through asynchronously. That's also what `loadSettingsFromBackend: true` is for — it re-reads settings from your backend right after install, once it's in place.
102
+ **`StorageBackend`** — must be **synchronous** (`getItem`/`setItem`/`removeItem` return immediately). If the host's real store is async (a key-value store, a database, a server round trip), back this with an in-memory cache that you hydrate before mounting the UI, and write through asynchronously. That's also what `loadSettingsFromBackend: true` is for — it re-reads settings from your backend right after install, once it's in place.
103
103
 
104
104
  **No cookies on a configured host.** Settings resolution is **lazy** (first settings access, not module import). Plannotator's default backend is cookies (its servers run on random ports, so cookies are the only storage that survives across sessions there), and on first resolution the store seeds missing defaults — including a generated identity — into whatever backend is live. Because `configurePlannotatorUI` installs your `storageBackend` before anything reads a setting, a host that configures at startup gets **zero `plannotator-*` cookies** written to its origin, ever: all reads and seeding writes go to your backend. (Covered by `config/configStore.lazyInit.seam.test.ts`.) Only an unconfigured consumer — or one that reads settings before calling configure — falls back to cookie writes.
105
105
 
106
106
  > **⚠️ Ordering is load-bearing and nothing enforces it.** Call `configurePlannotatorUI` only **after** your settings hydration has completed. If you configure while the cache is still empty, `loadSettingsFromBackend` finds nothing, **seeds generated defaults into your backend via `setItem`** (including a freshly generated random display name), and nothing ever re-runs hydration — so the junk defaults can win over the user's real settings, and if your `setItem` writes through to durable storage they persist. The sync-and-prehydrated rule is a contract, not a runtime check. (For `localStorage`, which is already synchronous, none of this bites.)
107
107
 
108
- **`IdentityProvider`** — `getIdentity(): string` (display name), `isCurrentUser(author): boolean`, and optional `isEditable(): boolean` (default editable). For Workspaces this is your auth'd user. **Return `isEditable() => false`** for logged-in users: Workspaces stamps the author from the server-side account id and users can't rename themselves, so the UI must hide its rename/regenerate controls — otherwise a locally-chosen name diverges from the server-stamped author (the "split author" hazard). Two things to know from the Workspaces side: (1) the current `Me` projection (`GET /v1/me`) carries only `user_id` + `email` — **no display name** — so until the backend adds a name field, `getIdentity()` can only return the email or id; (2) free-text author names *are* accepted for anonymous commenters on open docs, so `isEditable()` may return `true` for that branch.
108
+ **`IdentityProvider`** — `getIdentity(): string` (display name), `isCurrentUser(author): boolean`, and optional `isEditable(): boolean` (default editable). For Workspaces this is your auth'd user. **Return `isEditable() => false`** for logged-in users: Workspaces stamps the author from the server-side account id and users can't rename themselves, so the UI must hide its rename/regenerate controls — otherwise a locally-chosen name diverges from the server-stamped author (the "split author" hazard). Two things to know: (1) when the host's user record carries no display name, `getIdentity()` can only return the email or id until the backend adds one; (2) free-text author names *are* accepted for anonymous commenters on open docs, so `isEditable()` may return `true` for that branch.
109
109
 
110
- **`UploadTransport`** — `upload(file: File): Promise<{ path: string; originalName? }>`. The default does Plannotator's `POST /api/upload` and returns the server path. For Workspaces, send the bytes to your asset API (`PUT /v1/workspaces/:wsId/assets/:assetPath`) and return the content-addressed URL (or an opaque ref) in `path`. Notes from the Workspaces asset layer: your API makes the **caller choose the asset path** and 409s if a document owns it, so your adapter — not the UI — owns path selection (namespace uploads, e.g. an `assets/` prefix); it enforces a **10 MiB cap + content-type allowlist**, so surface upload failures; and because asset URLs need **no signing** (content-addressed, served from the cookieless `tot.page` origin), `imageSrcResolver` can be a pass-through — returning a full URL in `path` renders directly (the default resolver passes http(s) URLs through).
110
+ **`UploadTransport`** — `upload(file: File): Promise<{ path: string; originalName? }>`. The default does Plannotator's `POST /api/upload` and returns the server path. For a host, send the bytes to your asset API and return the content-addressed URL (or an opaque ref) in `path`. Things a host asset layer commonly does that the adapter must handle: if the API makes the **caller choose the asset path** (and refuses a path a document owns), your adapter — not the UI — owns path selection (namespace uploads, e.g. an `assets/` prefix); if it enforces a **size cap or content-type allowlist**, surface upload failures; and when asset URLs need **no signing** (content-addressed, served from a cookieless origin), `imageSrcResolver` can be a pass-through — returning a full URL in `path` renders directly (the default resolver passes http(s) URLs through).
111
111
 
112
112
  **`DraftTransport`** — `load()`, `save(body, { keepalive })`, `remove(generation, { keepalive })`. The generation-gated tombstone and keepalive retry logic stay inside the hook; you only provide the three transport calls. `keepalive: true` means "best-effort deliver this even though the page is closing" (maps to `fetch(..., { keepalive: true })` or `navigator.sendBeacon`). One non-obvious contract on `load()`: it returns `{ data, generation }`, where `generation` is the **deletion tombstone counter** for the no-draft case — Plannotator's server encodes it in the 404 body so a stale tab can't resurrect a deleted draft. If your backend tracks draft deletions, return the tombstone generation with `data: null`; if it doesn't, return `{ data, generation: null }` and the hook still works (you just lose stale-tab deletion protection). Optionally, `save()` may resolve `{ staleGeneration }` when your store refused the body as stale (Plannotator's server answers `409 { draftGeneration }`); the hook then raises its counter above that generation and saves once more. Resolving nothing (the old `Promise<void>`) keeps the previous behavior.
113
113
 
114
- **`ExternalAnnotationTransport<T>`** — `subscribe(onEvent, onError) => unsubscribe`, `getSnapshot(since) => { annotations, version } | null` (return `null` for "no changes", i.e. the 304 case), plus `add/remove/update/clear`. For Workspaces this is your realtime layer — a Durable Object WebSocket or SSE fanning out comment events. `T` extends `{ id: string; source?: string }`; if your annotation type adds fields, call `setExternalAnnotationTransport<YourType>()` directly for full type safety (the `configure` front door pins the base type for ergonomics).
114
+ **`ExternalAnnotationTransport<T>`** — `subscribe(onEvent, onError) => unsubscribe`, `getSnapshot(since) => { annotations, version } | null` (return `null` for "no changes", i.e. the 304 case), plus `add/remove/update/clear`. For a host this is your realtime layer — a WebSocket or SSE channel fanning out comment events. `T` extends `{ id: string; source?: string }`; if your annotation type adds fields, call `setExternalAnnotationTransport<YourType>()` directly for full type safety (the `configure` front door pins the base type for ergonomics).
115
115
 
116
116
  **`AITransport`** and **`FileTreeBackend`** currently return `Response` objects** (the raw `fetch` response) rather than parsed domain types — `session/query` return `Promise<Response>`, `loadTree/loadVaultTree` return `Promise<Response>` whose JSON is a known shape. **This is a known rough edge** (see "Known rough edges"). To satisfy these today, Workspaces has to hand back something `Response`-shaped (status, `.json()`, and for `query`, an SSE body stream). It works, but it leaks the old HTTP contract. We deliberately left it as-is for the first cut (move-don't-rewrite); expect to clean it up in a v2 driven by what's actually painful when you wire it.
117
117
 
@@ -137,12 +137,12 @@ configurePlannotatorUI({
137
137
  storageBackend, // your settings store (localStorage is already sync)
138
138
  identityProvider, // your auth'd user (isEditable:false for logged-in users)
139
139
  imageSrcResolver, // your asset URL scheme (pass-through for content-addressed URLs)
140
- uploadTransport, // upload pasted images to your R2 asset API
140
+ uploadTransport, // upload pasted images to your asset API
141
141
  docPreviewFetcher, // your doc store
142
142
  fileTreeBackend, // your workspace file tree + realtime watch
143
143
  draftTransport, // your draft store
144
- externalAnnotationTransport, // adapt your Yjs/WebSocket realtime onto this
145
- // aiTransport, // omit — Workspaces has no AI backend yet (stays default/off)
144
+ externalAnnotationTransport, // adapt your realtime (WebSocket, SSE) onto this
145
+ // aiTransport, // omit unless you have an AI backend (stays default/off)
146
146
  serverSync, // your settings push
147
147
  loadSettingsFromBackend: true, // re-hydrate settings from storageBackend after install
148
148
  });
@@ -155,24 +155,24 @@ import { Viewer } from "@plannotator/ui/components/Viewer";
155
155
 
156
156
  A few component-specific behaviors (e.g. an "open this diff in the editor" action) are passed as **props** at the render site rather than through `configure` — those are local to one component, not app-global.
157
157
 
158
- ### Mapping the seams to Workspaces' actual stack
158
+ ### Mapping the seams to a host backend
159
159
 
160
- Grounded in a read of the Workspaces repo (`apps/app`, `apps/usercontent`, `apps/web`, the DocumentDO). The web app doesn't import this UI yet, so this is the greenfield wiring plan.
160
+ What each seam asks of a host, and how much adapter work it usually takes. The host app's own routes and storage are its business; this table only names the shape the seam needs.
161
161
 
162
- | Seam | Workspaces backing | Effort |
162
+ | Seam | What the host provides | Effort |
163
163
  |---|---|---|
164
- | `storageBackend` | `window.localStorage` — already synchronous, matches the seam as-is. (Server-syncing prefs later is optional; not needed for the seam.) | trivial |
165
- | `identityProvider` | Read the already-hydrated `me` from `SessionContext` (`GET /v1/me`). `getIdentity()` returns email/id (no name field yet), `isCurrentUser(a) = a === me.user_id`, `isEditable() => false` for logged-in users. | thin adapter |
166
- | `imageSrcResolver` | Pass-through — asset URLs are content-addressed and need no signing. | trivial |
167
- | `uploadTransport` | `PUT /v1/workspaces/:wsId/assets/:assetPath` → R2 (`AssetBytes` interface). Adapter owns asset-path selection. | new adapter |
168
- | `docPreviewFetcher` | `GET /v1/workspaces/:wsId/documents/:docId` (D1 + git content store). | thin adapter |
169
- | `fileTreeBackend` | `GET /v1/workspaces/:wsId/documents` (D1 doc list); live-watch via the DocumentDO. | thin adapter |
170
- | `draftTransport` | KV or a per-doc Durable Object; `sendBeacon` for keepalive. | thin adapter |
171
- | `externalAnnotationTransport` | **Transport kind differs** — Workspaces realtime is Yjs-over-WebSocket (DocumentDO), and comments are REST with no live push. Adapt comment events onto the DO awareness channel (or add an SSE endpoint). | biggest adapter |
172
- | `aiTransport` | **No AI backend exists** in Workspaces. Leave at default/off until one is built. | new infra (later) |
173
- | `serverSync` | A Worker endpoint that persists the settings delta. | thin adapter |
174
-
175
- **Backend follow-up (Workspaces side, not a UI change):** if you want readable author names instead of raw `user_…` ids in comments, the `Me`/annotation projections need to start carrying a display-name field (WorkOS has `first_name`/`last_name`; the current `Me` projection drops them).
164
+ | `storageBackend` | A synchronous settings store (`window.localStorage` already matches as-is; an async store needs a prehydrated in-memory cache, see "Interface details"). | trivial |
165
+ | `identityProvider` | The signed-in user the host already holds. `getIdentity()` returns whatever display value the host has (an email or id when there is no name), `isCurrentUser(a)` compares against the user's id, `isEditable() => false` for signed-in users. | thin adapter |
166
+ | `imageSrcResolver` | Pass-through when asset URLs are content-addressed and need no signing; otherwise the host's URL mapping. | trivial |
167
+ | `uploadTransport` | A call to the host's asset upload API. The adapter owns asset-path selection. | new adapter |
168
+ | `docPreviewFetcher` | A read of one document from the host's document store. | thin adapter |
169
+ | `fileTreeBackend` | The host's document list, plus its own live-watch if it has one. | thin adapter |
170
+ | `draftTransport` | Any per-document store the host has; `sendBeacon` (or `fetch` keepalive) for the close path. | thin adapter |
171
+ | `externalAnnotationTransport` | **The transport kind may differ.** A host whose realtime is a WebSocket channel, or whose comments are REST with no live push, adapts its comment events onto `subscribe` (or adds an SSE endpoint). | biggest adapter |
172
+ | `aiTransport` | Only if the host has an AI backend. Leave at default/off until it does. | new infra (later) |
173
+ | `serverSync` | An endpoint that persists the settings delta. | thin adapter |
174
+
175
+ **Backend follow-up (host side, not a UI change):** readable author names instead of raw user ids in comments need the host's user and annotation data to carry a display-name field.
176
176
 
177
177
  ---
178
178
 
@@ -1718,8 +1718,63 @@ Everything host-facing that changed between ui 0.50.0 / core 0.25.10 and this pa
1718
1718
  - **"Ask this session" announcement (#1690):** new `components/AskSessionAnnouncementDialog`, `utils/askSessionAnnouncement` (cookie `plannotator-announce-ask-session-seen`) and `hooks/useFirstRunAnnouncementWindow`. Plannotator's apps mount it; nothing in the library mounts it for a host.
1719
1719
  - **gruvbox palette (#1721):** sets `--diffs-addition-color-override` (dark `#b8bb26`, light `#79740e`), so added diff lines draw in gruvbox green instead of the text colour. This changed the guides.show viewer stylesheet (see "Publishing & versioning").
1720
1720
 
1721
+ ## Viewer `answerOnly` (ui 0.53.0)
1722
+
1723
+ Additive, one optional `Viewer` prop; Plannotator passes none, so its render is unchanged. Added for the Plannotator Inbox window (`packages/inbox`), which shows an agent's message with answerable `:::question` cards and takes no comments on the message text.
1724
+
1725
+ - **`answerOnly?: boolean`**: every annotation affordance is off exactly as under `readOnly` (the selection and code-block toolbars, both comment composers, quick labels, pinpoint hover, the annotation header, the global comment and attachments buttons, Vim), and the document actions (Global comment, Copy) are not drawn at all. Unlike `readOnly`, question cards keep `onAnswerQuestion` / `onSaveQuestionAnswer` / `onToggleQuestionDecisionRecording` and checkboxes keep `onToggleCheckbox`. `readOnly` still wins: `readOnly` with `answerOnly` is plain read-only.
1726
+ - Inside, `Viewer` computes `authoringOff = readOnly || answerOnly` and uses it wherever it used `readOnly` for authoring; the question and checkbox handlers still read `readOnly`. Absent or false, every path is the one it was.
1727
+ - The article keeps its card padding; a host that wants the bare text (as the Inbox does) overrides it from its own CSS on `[data-print-region="article"]`.
1728
+
1729
+ ## Viewer `resolveLocalLink` (ui 0.53.0)
1730
+
1731
+ Additive, one optional `Viewer` prop; Plannotator's plan review and annotate pass none, so their render is unchanged. Added for the Plannotator Inbox, where a link in a message to a file the message attached opens that file beside the thread.
1732
+
1733
+ - **`resolveLocalLink?: (href: string) => LocalLinkResolution | null`** (`components/LocalLinkContext.tsx`): asked about every markdown link and raw-HTML `<a href>` whose target is a local path (`isLocalLinkTarget`: not `#anchor`, not `//host`, no URL scheme). `{ kind: 'open', open, title?, href? }` draws a real link whose click (and Enter) calls `open` and never follows the href. With `href` (the host's own address for the same thing), that is the link's href and a new-tab click (Cmd/Ctrl/Shift, middle button), "Open in new tab", drag and copy use it; without it the href is the target as written and a middle click is suppressed. `{ kind: 'unavailable', title }` draws the link text as an inert span (raw HTML: the `<a>` loses its `href`), so nothing is fetched. `null` keeps the renderer's default for that link. External links are never passed to it.
1734
+
1735
+ ## Viewer `questionDecisionLocked` (ui 0.53.0)
1736
+
1737
+ Additive, one optional `Viewer` prop; Plannotator passes none, so its render is unchanged. Added for the Plannotator Inbox (step 3, decisions), where one message can hold a sent question and an open one.
1738
+
1739
+ - **`questionDecisionLocked?: (question: IndexedQuestion) => boolean`**: true for a question passes it neither `onToggleQuestionDecisionRecording` nor `onOpenQuestionDecision`, so its decision tag draws as a plain tag (on or dimmed off, as `questionDecisionRecording` says), exactly as under `readOnly`. Other questions in the same `Viewer` keep their switch and opener. Absent or false, every path is the one it was.
1740
+
1741
+ ## Viewer `renderQuestionCard` and `renderQuestionBelowPrompt` (ui 0.53.0)
1742
+
1743
+ Additive, two optional `Viewer` props; Plannotator passes none, so its render is unchanged. Added for the Plannotator Inbox's replaced questions, where an agent supersedes an earlier question and the host draws it as a card nobody can answer.
1744
+
1745
+ - **`renderQuestionCard?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => ReactNode | undefined`**: a returned node is drawn in place of that question's answer card, inside a `<div data-block-id data-block-type="directive">`; `undefined` keeps the answer card. Adds no classes to the shared stylesheet.
1746
+ - **`renderQuestionBelowPrompt?: (question: IndexedQuestion) => ReactNode`** (`QuestionBlock`'s `renderBelowPrompt`, through `BlockRenderer`'s `renderQuestionBelowPrompt`): host content right under a card's prompt, e.g. "Replaces <an earlier question>"; null for none.
1747
+
1748
+ ## The composer handed to the host: `onHostDraft`, `stepPin`, `hideRedline` (ui 0.53.0)
1749
+
1750
+ Additive, all opt-in; Plannotator passes none of it, so its render is unchanged. Added for the Plannotator Inbox surface (PLAN step S1, `packages/inbox/surface`), the single-file build a phone's native shell hosts: the shell's own sheet writes every comment, so the viewers report what a comment would be on and draw no composer.
1751
+
1752
+ - **`HostDraft`** (`types`): `{ annotation, intent: 'selection' | 'compose', label?, cancel }`. `annotation` is the one a comment there would carry, with an empty `text`. `cancel()` drops the pending highlight or pin; a draft already replaced by a newer one ignores it. After saving, the host calls `cancel()` and draws the saved annotation through `annotations` / `applySharedAnnotations`.
1753
+ - **`Viewer` `onHostDraft?: (draft: HostDraft | null) => void`** (and the same option on `useAnnotationHighlighter`): whenever the selection toolbar or the comment composer would open, the draft goes to the host instead (`'selection'` for a settled selection, `'compose'` for Comment mode), and null when it closes. A pinpoint tap opens it as `'compose'`; the next tap replaces an open draft. The document actions (Global comment, Copy), the code-block and table hover toolbars are not drawn, and a pinpoint tap on a code block does nothing. Ignored when authoring is off. `createAnnotationFromSource` was split into a side-effect-free `buildAnnotationFromSource` and the create step; created annotations are unchanged.
1754
+ - **`HtmlViewer` `onHostDraft`** (and on `useHtmlAnnotation`): a text selection reports `'selection'`, a pin `'compose'` with the pinpoint label; the selection clearing reports null. Nothing of the package's toolbar or composer opens. Interact and Annotate set from outside need no new prop: `annotateModeActive` already is that seam.
1755
+ - **`ViewerHandle.stepPin?: (direction: 'parent' | 'child') => void`**, implemented by `HtmlViewer` only: moves a pinned draft to the element around it, or back the way it came (then the first element child), by posting the new bridge message `plannotator-bridge-step-pin`. The bridge re-pins through its own pinpoint path, so the parent sees an ordinary pinpoint selection. Additive to the protocol: an older bridge asset (`bridgeScriptUrl`) ignores it, so `BRIDGE_PROTOCOL_VERSION` is unchanged.
1756
+ - **`DiagramViewer` `onHostDraft?: (draft: DiagramHostDraft | null) => void`**: a click on a part reports `{ anchor, cancel }` (the ring stays drawn) and no `DiagramComposer` is rendered; the host passes the saved comment back in `comments`. Counts as being able to comment, like `onCreateComment`. `DiagramHostDraft` is exported from `components/diagram`.
1757
+ - **`AnnotationToolstrip` `hideRedline?: boolean`**: omits only the Redline action, as `hideQuickLabel` omits Label.
1758
+ - **Fix, `HtmlViewer` and `useHtmlAnnotation`:** both register their frame-message listeners in a layout effect instead of a passive one. A passive effect can run after a fast srcdoc frame has already posted the bridge's `ready`; the lost ready left the bridge with no input method or mode, so no pin could land (seen on a Linux WebKit CI runner in the surface's proof). Listening from the commit that inserts the iframe closes that race; nothing else changes.
1759
+
1760
+ ## `HtmlViewer` `hostNavigates`, `ViewerHandle.revealPin` and `ViewerHandle.takeSelection` (ui 0.53.0)
1761
+
1762
+ Additive, opt-in; Plannotator passes none of it, so its render is unchanged. Added for the iPhone app's attachment screen (PLAN step M2): the app's `WKWebView` decides every link itself, and a link in an agent's page must reach it as a new window opened by a person's tap, which the app checks against its own record of the touch, rather than as a frame message, which a page's script can forge.
1763
+
1764
+ - **`HtmlViewer` `hostNavigates?: boolean`**: in Interact, a person's tap on a non-fragment link opens it as a new window (`window.open(href, '_blank', 'noopener')`; the srcdoc sandbox becomes `allow-scripts allow-popups`) instead of being reported through the frame protocol, so `onOpenLink` does not fire for it. The host's web view hands the window to its own delegate, which decides it; the frame itself never navigates (the surface's CSP would refuse an off-folder frame load in any case). A scripted click (`isTrusted` false) opens nothing. In-page `#` links still scroll in the frame and an armed pinpoint tap still pins.
1765
+ - **Bridge message `plannotator-bridge-set-host-navigates`** (`{ active }`), posted on ready while the prop is set. Additive to the protocol: an older bridge asset ignores it and keeps cancelling, so `BRIDGE_PROTOCOL_VERSION` is unchanged.
1766
+ - **`ViewerHandle.revealPin?: () => void`** (`HtmlViewer`): scrolls the pinned draft into the middle of the frame through the new bridge message `plannotator-bridge-reveal-pin`, for a host whose own panel covers the page's lower part (the iPhone's comment panel shrinks the frame and the pin must stay in sight while Parent and Child move it). Additive to the protocol; an older bridge asset ignores it.
1767
+ - **`ViewerHandle.takeSelection?: () => boolean`** (markdown `Viewer`, from `useAnnotationHighlighter`): with `onHostDraft` set on a touch screen, a text selection is no longer painted as a pending highlight, neither by the library at `touchend` (`highlighter.run()` is not called) nor when it settles (400 ms after the last `selectionchange`); painting replaced the system selection, and the iPhone's edit menu with it, before the person could reach the app's Comment. The host calls `takeSelection()` from its own Comment action: the same paint runs then and the draft goes out through `onHostDraft` as `'selection'`. Returns false with nothing selected in the document. Without `onHostDraft`, and with a mouse, nothing changes.
1768
+
1721
1769
  ## Publishing & versioning
1722
1770
 
1771
+ - **core 0.25.13 / ui 0.53.0: both change. Order: `core` 0.25.13, then `ui` 0.53.0. guides.show is already deployed.** ui pins core `0.25.13` exactly. It imports no core subpath or export that 0.25.12 lacks (checked name by name), but the exact pin cannot resolve until 0.25.13 is on npm, so core still goes first. core 0.25.13 moves the guides.show viewer pin from `viewer.BYGvfCcj.js` + `viewer.CBpqTZpM.css` (0.25.12) to `viewer.Cs7U8_7B.js` + `viewer.BbPFXtTg.css`. Both files, `chunks/index.DU_F3bOf.js` and every `langs` chunk answer 200 on `https://guides.show/v1/`, and the served files match the manifest's SRI hashes, so no manual deploy is needed. The previous pairs were ui 0.52.0 and ui 0.52.1 on core 0.25.12. **No export is removed from either package**, so a consumer upgrades without code changes, but these behave differently and Workspaces should check them:
1772
+ 1. **Block quotes and headings (ui `utils/parser`).** `parseMarkdownToBlocks` returns ONE `blockquote` block per run of `>` lines, its `content` the quote's inner markdown, rendered by the new `BlockquoteBlock` (fences, lists, nested quotes, tables inside one `<blockquote>`). Block ids after a quote that used to split shift down (restore re-anchors by text), and a host that reads a quote's `block.content` now sees fence and list markers. A heading now needs a space (or the line's end) after its `#`s, at top level too: `#123 fixed`, `#hashtag` and `#!/bin/sh` are paragraph text, no longer `<h1>`. See "Block quotes render their content (ui 0.53.0; …)".
1773
+ 2. **Long choice labels (core `question-block`).** `parseQuestionAnswer` caps `selected` labels at the new `MAX_QUESTION_SELECTED_CHARS` (20,000) instead of 200, `canonicalQuestionAnswer` maps an answer an older build cut at 200 back to its full label, and `questionExportItems` passes `choices` for labels over 200 (`questionChoicesNeedCanonicalLabels`). A host that checks picks against the choices itself must not cut picked labels shorter than the labels it compares them with. See "Long choice labels are picked whole (core 0.25.13; …)".
1774
+ 3. **Wider unions** (a compile error only in an exhaustive `switch`): `AgentToolHost` gains `'oh-my-pi'`; core `external-annotation`'s `session-closed` event has `by: "agent" | "session"` (ui's `useExternalAnnotations` passes it on, `utils/agentClosed` adds `closedReviewCopy`); core `inbox-types` gains the `"superseded"` question state and more `InboxRecordKind` / `InboxIdPrefix` members, and `inbox-questions` reads a bare negative word (`Stopped: no`) as no stop.
1775
+ 4. **Smaller visible changes.** Core `CODE_FILE_REGEX` also matches `.mjs`, `.cjs`, `.mts` and `.cts`, so those paths link as code files; the Pi provider icon is the three-colour pi.dev mark (no longer `currentColor`); the question card's eyebrow pills share one 18 px box and an off decision tag is fainter; `hooks/useUpdateCheck` (Plannotator-only, see "not for hosts") no longer asks GitHub from the browser and returns null unless the server's advert is passed.
1776
+ 5. **Questions (core `question-block`).** (a) `QUESTION_AUTHORING_GUIDE` was rewritten (2,213 to 2,654 characters) and now ends with an Inbox-only bullet naming `read_thread`, `supersedes` and `Replaces:`. A host that pastes the guide into its own agent prompts now passes on Plannotator Inbox wording; filter that bullet out or write your own. (b) `parseQuestionBlock` reads a `Replaces:` line whose every token is id-shaped (`msg_…/q-…`, or a bare `q-…`) into the new optional `ParsedQuestion.replaces` instead of context, wherever it sits in the block. Any other `Replaces:` line is prose as before, and `questionKey` is unchanged. (c) `QuestionAnswer` gains optional `by_reply` (kept by `parseQuestionAnswer` only beside `skipped: true`). `formatQuestionAnswersSection` prints such a skip as "Skipped (not answered in this reply)" and adds a count line telling the agent to treat those as skipped.
1777
+ Everything else is additive and inert until a host passes it: on `Viewer` `answerOnly`, `resolveLocalLink`, `questionDecisionLocked`, `renderQuestionCard` / `renderQuestionBelowPrompt`, `refRepo` and `onHostDraft` (also on `HtmlViewer` and `DiagramViewer`, with `DiagramHostDraft`); `ViewerHandle.stepPin` / `revealPin` / `takeSelection`; `HtmlViewer` `hostNavigates`; `AnnotationToolstrip` `hideRedline`; `RenderedMarkdown` `resolveLocalLink` / `resolveImage` / `headingAnchors`; the `PlanCleanDiffView` seams and `foldUnchangedPlanDiff`; `CodeAnnotation.markdownPreview`; `AnnotationPanel` `emptyHint`; `CodeFileView` (the body of `CodeFilePopout`, exported); the Oh My Pi setup hint; Plannotator's own update modules (`Settings` `updateCheckSetting`, `hooks/useUpdateToast`, `hooks/useUpdateCheckSetting`, `utils/updateNotice`). Core adds `crypto`'s `encryptWithKey` / `decryptWithKey` / `deriveRelayKeys`, `diff-files`' `isLineRangeInPatch`, `source-save`'s decided and cross-origin responses, an optional error on `serverSessionMismatchBody`, Inbox wire types (`inbox-attachments.ts`, `inbox-surface-bridge.ts`, both re-exported by `inbox-types`), and `annotatable`'s `isDotenvFileName(name)` (#1568): the dotenv-family rule on a file NAME (`.env`, any name ending in `.env`, `.envrc`, and `.env.*` outside the annotatable docs, so `.env.example` stays). The Inbox attachment rule and Plannotator's HTML asset route both use it; the attachment rule is unchanged.
1723
1778
  - **core 0.25.11 / ui 0.51.0: both change. Order: guides.show deploy, then `core` 0.25.11, then `ui` 0.51.0.** ui imports `canonicalQuestionAnswer` and `PI_THINKING_LEVELS`, which no published core before 0.25.11 has, and pins core `0.25.11` exactly. core 0.25.11 pins a new guides.show viewer stylesheet (`viewer.DuqkfIUz.css`, from the gruvbox fix #1721; the viewer JS `viewer.BYGvfCcj.js` is unchanged), which guides.show serves only after a deploy, so run the deploy by hand first (next bullet). **ui 0.51.0 is BREAKING for 0.50.0 consumers of the "Ask this session" exports**: it removes `findUsableSessionBridge`, `resolveSessionBridgeFallback`, the `'fallback'` `SessionAskAction` member, `SessionAskActions`' `fallbackLabel` and `DocumentAIChatPanel`'s `sessionAskFallbackLabel`, and makes a listed bridge the only Ask AI selection. Release notes must say so. Everything else is additive or a fix (including #1725's agent tool switch and offer); see "Ask this session (shipped in 0.50.0; …)", "Comment composer trim (0.51.0; …)", "Ask AI from a diagram comment (0.51.0; …)" and "Questions, restore, drafts and the rest (ui 0.51.0, core 0.25.11)".
1724
1779
  - **guides.show before a core publish that moves the viewer pin.** guides.show deploys on Plannotator release tags only. When a core publish carries a new `guide-viewer-manifest` hash (any change to the shared CSS or the guide chain) and no release tag has shipped it yet, run the deploy by hand first (`gh workflow run guides-show-deploy.yml --ref main`), then check that `curl -I https://guides.show/v1/<css>` and `<js>` from the manifest answer 200. Otherwise every host's guide pages load without that asset (core 0.25.9 hit this; fixed by a manual deploy). The deploy is add-only.
1725
1780
  - **core 0.25.9 / ui 0.49.0 (question host seams): both change. Publish `core` first**: ui imports the new `@plannotator/core/markdown-structure` subpath and the new `findQuestionBlocks` / decision fields, so a ui on a published core 0.25.8 fails to compile in a consumer. See "Question host seams (ui 0.49.0, core 0.25.9)".
@@ -1880,7 +1935,7 @@ Pinned by `components/DiagramBlock.askAI.test.tsx` and
1880
1935
 
1881
1936
  ---
1882
1937
 
1883
- ## Ask AI names a text selection's source lines (unreleased; additive, no core change)
1938
+ ## Ask AI names a text selection's source lines (ui 0.52.0; additive, no core change)
1884
1939
 
1885
1940
  An Ask AI question asked from a markdown text selection now says which
1886
1941
  source lines the selection is on, so a phrase that occurs more than once in
@@ -1920,7 +1975,7 @@ heading for single-block selections) and `utils/aiPrompt.test.ts`.
1920
1975
 
1921
1976
  ---
1922
1977
 
1923
- ## `CompletionOverlay` `done` state (unreleased; additive)
1978
+ ## `CompletionOverlay` `done` state (ui 0.52.0; additive)
1924
1979
 
1925
1980
  `submitted` accepts a new value, `'done'`: the session finished with nothing
1926
1981
  sent (Plannotator uses it for an annotate Done with nothing to send). It
@@ -1937,7 +1992,7 @@ so this is a visible change for `'exited'` only there. Every other value
1937
1992
  renders exactly as before. The overlay root now carries
1938
1993
  `data-completion-state={submitted}`.
1939
1994
 
1940
- ## Quick Labels: reorder and editable emoji (unreleased; additive, no core change)
1995
+ ## Quick Labels: reorder and editable emoji (ui 0.52.0; additive, no core change)
1941
1996
 
1942
1997
  Settings → Labels (#1736) lets a person move a label up or down (per-row
1943
1998
  buttons, `aria-label="Move <text> up|down"`, a polite live region announces
@@ -1970,7 +2025,7 @@ What a host should know:
1970
2025
  emoji grapheme, including ZWJ sequences, flags, keycaps and skin tones, or
1971
2026
  null) and `emojiFromFieldInput`. Nothing was removed or renamed.
1972
2027
 
1973
- ## Question decision toggle and status tag (ui 0.52.0, unreleased; additive, no core change)
2028
+ ## Question decision toggle and status tag (ui 0.52.0; additive, no core change)
1974
2029
 
1975
2030
  What Workspaces asked for: the reviewer can switch a `Decision: when answered`
1976
2031
  question's recording off and on from the card itself, and the host can draw
@@ -1988,9 +2043,10 @@ Plannotator passes none of them, so its cards render exactly as before.
1988
2043
  function of the question, called per question card, so it can read your
1989
2044
  store by `question.question.key`). It applies only to a question carrying
1990
2045
  `Decision: when answered` (`decisionOnAnswer`) whose decision is not
1991
- already recorded. Off draws the same "Records a decision" tag dimmed (in
1992
- the theme's `--muted-foreground`, not by opacity, so it stays as legible as
1993
- any secondary text) with a dotted outline and no fill (never different
2046
+ already recorded. Off draws the same "Records a decision" tag dimmed (since
2047
+ the eyebrow fix below: `--muted-foreground` mixed 55% with transparent, so
2048
+ it reads as off beside the "Open" tag, which is plain `--muted-foreground`)
2049
+ with a dotted outline and no fill (never different
1994
2050
  words), and hides the "Answering this
1995
2051
  records a decision" row (`[data-question-decision-row]`). A recorded
1996
2052
  decision (`Decision: [statement](url)`) and a question with no decision line
@@ -2029,8 +2085,240 @@ row hidden when off, recorded and plain questions untouched, read-only cards
2029
2085
  static, `statusTag: 'none'`, the `Viewer` threading, and a card with none of
2030
2086
  the props rendering the same markup.
2031
2087
 
2088
+ ## Decision toggle on any question, and an opener for the host's decision card (ui 0.52.1; additive, no core change)
2089
+
2090
+ Two gaps Workspaces found in 0.52.0 against its approved Inbox design
2091
+ ("the tag is the switch" on every question, off by default; "clicking the
2092
+ tag's words opens the decision card"). Plannotator passes none of this, and a
2093
+ card without the new props renders byte-identically to 0.52.0 (checked by
2094
+ rendering main and the branch through `BlockRenderer` and `Viewer`,
2095
+ interactive and read-only, with no props, with a 0.52.0-shaped host, and with
2096
+ a 0.52.0 host returning a boolean for every question).
2097
+
2098
+ | `Viewer` | `BlockRenderer` | `QuestionBlock` |
2099
+ | --- | --- | --- |
2100
+ | `questionDecisionScope?: 'when-answered' \| 'any'` | same | `decisionScope?: 'when-answered' \| 'any'` |
2101
+ | `onOpenQuestionDecision?: (key, anchor: HTMLElement) => void` | same | `onOpenDecision?: (key, anchor: HTMLElement) => void` |
2102
+
2103
+ 1. **Any question can record a decision, opt-in.** With
2104
+ `questionDecisionScope="any"` (`decisionScope: 'any'`),
2105
+ `decisionRecording` / `questionDecisionRecording` also reaches a question
2106
+ WITHOUT a decision line: a boolean adds the "◇ Records a decision" tag in
2107
+ that state (and, with `onToggleDecisionRecording`, makes it the toggle),
2108
+ `undefined` leaves the question as it was (no tag). The default,
2109
+ `'when-answered'`, is the 0.52.0 behaviour: only `Decision: when answered`
2110
+ questions carry the tag, and a boolean returned for a plain question is
2111
+ ignored, as 0.52.0 documented. It is an explicit opt-in rather than
2112
+ "a boolean means show it" so that a 0.52.0 host that returns a boolean for
2113
+ every question does not grow tags on its plain questions in a patch
2114
+ release. On shows the same "Answering this records
2115
+ a decision" row a `Decision: when answered` question shows; off hides it.
2116
+ The default for such a question is whatever you return: start it `false`
2117
+ for "off by default". A question with a recorded decision
2118
+ (`Decision: [statement](url)`) is still never affected, and
2119
+ `data-question-decision` still describes the question's own line (absent
2120
+ for a plain question); `data-question-decision-recording="on" | "off"`
2121
+ appears once you supply a state.
2122
+ 2. **A separate opener (`onOpenDecision` / `onOpenQuestionDecision`).** Given,
2123
+ the tag is drawn as two adjacent targets inside one visual tag
2124
+ (`[data-question-decision-tag]`), siblings, never nested:
2125
+ - the diamond is the switch: `<button aria-pressed aria-label="Record as a
2126
+ decision" data-question-decision-toggle>` calling
2127
+ `onToggleDecisionRecording(key, next)`. Without a toggle handler the
2128
+ diamond is decoration, not a control.
2129
+ - the words "Records a decision" are `<button aria-haspopup="dialog"
2130
+ data-question-decision-open>`, calling `onOpenDecision(key, element)`
2131
+ with the words' own button as the popover anchor. The card keeps no
2132
+ open state; your popover owns focus and returns it to the anchor.
2133
+ The opener is offered whether recording is on or off: what an off
2134
+ question's card does (switch it on as it opens, or open a card that says
2135
+ it is off) is the host's call. Tab order is switch, then words, both with
2136
+ the `ring-2 ring-primary` focus ring the 0.52.0 toggle uses (a ring, not
2137
+ an outline, because the dotted outline draws the off state, which is now
2138
+ on the wrapping tag). The off look was unchanged here: muted-foreground
2139
+ text and a dotted outline (#1744's contrast fix); see "Question card
2140
+ eyebrow" below for the fade it has since. Without `onOpenDecision` the
2141
+ whole tag is the toggle exactly as in 0.52.0.
2142
+ 3. **Read-only cards are never interactive.** No answer handler (or
2143
+ `Viewer` `readOnly`, which also drops `onOpenQuestionDecision`) draws the
2144
+ tag as a plain tag in its current state.
2145
+
2146
+ Measured in headless Chromium with the default theme: switch and words are
2147
+ 7.37:1 (dark) / 8.98:1 (light) off, 5.84:1 / 5.15:1 on; the focus ring is
2148
+ 7.53:1 / 6.53:1 against the card. The split tag draws exactly the 0.52.0 tag
2149
+ (127.66 × 17.75 px, the same 4 px between diamond and words). Target size
2150
+ (WCAG 2.5.8) is met without changing that look: the diamond (18 × 17.75 px
2151
+ drawn) carries an invisible `before:` box 6 px past it on every side, so it
2152
+ clicks as 24 × 29.75 px; the words, positioned and later in the tree, paint
2153
+ over the box's right-hand part, so a click on the words is always the words,
2154
+ and the words (110 px wide) meet the spacing exception.
2155
+
2156
+ **No stylesheet change**: only utility classes the package already ships
2157
+ (the hit box is the `before:absolute before:-inset-1.5 before:content-['']`
2158
+ pattern of the annotation panel's icon buttons), so `styles.css` and the
2159
+ guides.show viewer manifest are unchanged (`check:manifest` in sync).
2160
+
2161
+ Pinned by `components/blocks/QuestionBlock.decisionAnyQuestion.test.tsx`
2162
+ (DOM-gated): the tag on a plain question only with the `'any'` scope and a
2163
+ supplied state (a boolean without the scope changes nothing), the
2164
+ toggle's arguments and the row on/off, the switch and the opener as sibling
2165
+ buttons with the anchor element, tab order, the opener without a toggle
2166
+ handler, 0.52.0 behavior without `onOpenDecision`, read-only cards, `Viewer`
2167
+ threading, and no-prop markup.
2168
+
2169
+ ## Question card eyebrow: the off decision tag reads as off; the pills align (ui 0.53.0; styling only)
2170
+
2171
+ No prop, attribute or markup change; a host that passes nothing renders the
2172
+ same elements with new classes on the eyebrow's pills.
2173
+
2174
+ - **The off tag fades.** `muted-foreground` alone was exactly the color of
2175
+ the "Open" tag beside it (8.98:1 light, 7.37:1 dark against the card in
2176
+ the default theme), so an off tag read as barely dimmer than an on one.
2177
+ The off color is now `color-mix(in oklab, var(--muted-foreground) 55%,
2178
+ transparent)`: text, icon and dotted outline (all `currentColor`) at the
2179
+ strength `opacity: 0.55` would give, measured at 2.76:1 (light) and
2180
+ 3.06:1 (dark). It is deliberately not `opacity`, which would also fade
2181
+ the `ring-primary` focus ring on the switch and the words; the ring stays
2182
+ at full strength. Still inline, still on `[data-question-decision-tag]`
2183
+ (split tag) or the toggle / static tag.
2184
+ - **One pill box.** Every pill in the eyebrow (Open / Answered / Settled /
2185
+ Skipped, "Decision", "Records a decision" in all three forms) is 18 px
2186
+ tall (`h-[18px]`, `leading-4`) with a 1 px border, transparent except on
2187
+ "Open", so the dashed tag is no longer 2 px taller. The pill group is an
2188
+ `inline-flex items-center gap-1` row, so the split tag (whose inline-flex
2189
+ baseline came from its icon-only switch) no longer sits higher than the
2190
+ status tag. Measured in Chromium in the Inbox: every pair now shares
2191
+ height (18 px) and centre in every state, where "Open" and "Skipped" were
2192
+ 1.6 px lower and "Open" 2 px taller.
2193
+ - **guides.show viewer unchanged.** The viewer's stylesheet scans
2194
+ `packages/ui/components`, so only classes it already ships are used, and
2195
+ `align-[1px]` (now inert inside the flex row) is kept because it is its
2196
+ only use; `check:manifest` stays in sync, no deploy.
2197
+
2198
+ ---
2199
+
2200
+ ## Ask AI setup hint for Oh My Pi (ui 0.53.0; additive, no core change)
2201
+
2202
+ New module `utils/askSessionSetupHint` (`askSessionSetupHint(origin, providers)`, `OMP_EXTENSION_INSTALL_COMMAND`) returns a one-line note when origin is `oh-my-pi` and no session-bridge provider is offered (#1814): the `plannotator` CLI run from OMP's shell has no "Ask this session", while the Plannotator extension loaded into OMP does. `AIProviderBar` gains an optional `setupHint` prop and `DocumentAIChatPanel` an optional `askSessionSetupHint` prop, rendered by the new `components/ai/AskSessionSetupHint` as a muted line under the provider bar (also under "No AI providers available"). Both are absent by default, so a host that passes neither renders exactly as before.
2203
+
2204
+ ---
2205
+
2206
+ ## Agent tool host `oh-my-pi` (ui 0.53.0; additive)
2207
+
2208
+ `utils/agentToolSetting`'s `AgentToolHost` gains `'oh-my-pi'` (the Plannotator Pi extension running inside Oh My Pi, where the `plannotator` tool is on by default): `parseAgentToolSetting` accepts it, `agentToolHostName` answers "Oh My Pi", `agentToolAppliesWhen` "the next time Oh My Pi starts", and `agentToolCostNote` names OMP's one-line listing instead of Pi's per-request cost. `agentToolOfferApplies` / `agentToolOfferHostOf` never offer it (the offer is for hosts whose tool is off by default). A host that never sends `agentToolHost: 'oh-my-pi'` sees no change; code that switches exhaustively over `AgentToolHost` needs the new case.
2209
+
2210
+ ---
2211
+
2212
+ ## `Viewer` `refRepo` (ui 0.53.0; additive; needs no new core subpath)
2213
+
2214
+ New optional `refRepo?: { display: string; host?: string } | null` on `Viewer`: the repository bare `#123` / `@user` refs link into (#1840), without the repo badge `repoInfo` also draws. It wins over `repoInfo` for links only; `null` and absent both fall back to `repoInfo`, so links follow `repoInfo?.display` / `repoInfo?.host` exactly as before, so Plannotator's plan and review apps are unchanged. The Inbox passes it per message (a badge on every message would be noise).
2215
+
2032
2216
  ---
2033
2217
 
2218
+ ## Block quotes render their content (ui 0.53.0; parser output change, no API change)
2219
+
2220
+ `parseMarkdownToBlocks` now returns ONE `blockquote` block per run of consecutive `>` lines (blank `>` lines included), where it used to split the run whenever a line started with a list marker, a heading, a code fence or a nested `>`. The block's `content` is the quote's own markdown: each line with its `>` and one following space or tab removed, deeper indentation kept, trailing whitespace trimmed, one content line per source line; blank lines at the quote's edges are dropped and `startLine` moves past leading ones. So `exportAnnotations` labels a quote with its real span (`lines 3–7`).
2221
+
2222
+ The renderer is `components/blocks/BlockquoteBlock` (BlockRenderer's non-alert `blockquote` case; also the plan diff view): a quote whose inner blocks are only paragraphs renders exactly as before (same DOM, one `<p>` per paragraph). Anything else renders inside the one `<blockquote>`: a code fence as a real `CodeBlock` (no literal backticks, no mention/issue linking inside code), lists, nested quotes, tables, headings. Inner blocks carry no `data-block-id`, so selections, restores, exports and pinpoint all resolve to the quote block. Spacing comes from a hoisted `<style href="plannotator-quote-body">` (the QuestionContext pattern), scoped to `.pn-quote-body`. A `:::question` written inside a quote renders as a plain callout, never a question card (`findQuestionBlocks` already ignores it). `utils/blockTargeting` treats a `<blockquote>` block as one text block, never a code, table or display-math target (inline math keeps the paragraph rule).
2223
+
2224
+ **Nesting cap.** `MAX_QUOTE_DEPTH` (12, exported from `BlockquoteBlock`): each nested level is a component and a parse of its own, so a quote nested thousands deep (`>>>>…`, possible in any third-party text) would overflow the stack and unmount the tree. Past the cap the rest of the quote renders as prose text, its remaining `>` markers included. Code review's `MarkdownBody` applies the same cap.
2225
+
2226
+ **Heading rule (also changes top-level parsing).** A heading is now 1 to 6 `#` followed by a space or the end of the line (CommonMark ATX). Before, any line starting with `#` was a heading, so `#123 fixed`, `#hashtag` and `#!/bin/sh` rendered as `<h1>` (an issue ref at the start of a line lost its link). They are now paragraph text, at top level and inside quotes; `####### seven` is text too. `# Title`, `###` and `###### Six` are unchanged.
2227
+
2228
+ **What a quoted line now renders as.** Inside a quote, a line is parsed the way the same line is parsed at top level, so these quoted lines render as their block type where they used to render as literal text in a quote of their own: `- `, `* `, `1. ` (list items, nesting by indentation), `# ` headings, ```` ``` ```` fences, `|` tables, `---` / `***` rules, `$$` / `\[` display math, `:::kind` directives (a `:::question` as a plain callout), block-level HTML tags (`<details>` …), and `>` (a nested quote).
2229
+
2230
+ Unchanged: GitHub alerts (`> [!NOTE]` … keep their old body text and `AlertBlock`), single-line and wrapped prose quotes, the `Block` type, every prop. What a host may notice: block ids after a quote that used to split shift down (ids are positional; restore re-anchors by text as for any edit), and a host that reads `block.content` of a quote sees the inner markdown (fences, list markers) where it used to see one line.
2231
+
2232
+ **Plan diff.** `utils/planDiffEngine` treats a quote run that holds a code fence as one atomic diff unit (like an unquoted fence), so an edit inside a quoted fence is one modified quote instead of chunks that open a fence and never close it. Quotes without a fence still diff line by line.
2233
+
2234
+ **Known limitation (same as a top-level fence).** A partial text highlight inside a quoted code block is lost from the page when the fence re-highlights (a theme or palette change): the swap replaces the `<code>` element's children, and the swap listener re-paints only whole-block code marks. The annotation itself stays in the panel and the export, and comes back on reload.
2235
+
2236
+ ---
2237
+
2238
+ ## Stale-tab guard: `serverSession` (ui 0.52.0, core 0.25.12; additive)
2239
+
2240
+ Plannotator's servers issue one random `serverSession` nonce per process (new `@plannotator/core/server-session`) and refuse a decision that echoes a different one with `409 { code: "session_mismatch" }`, so a tab left open on a reused port cannot decide a new session. The client half is `utils/serverSession` (`adoptServerSession`, `withServerSession`, `withServerSessionQuery`, `noteServerSessionMismatch`, `useServerSessionReplaced`) plus `components/ServerSessionReplacedBanner`. Hosts that post decisions to their own backend need none of it: nothing in the shared components sends the field on its own, and a request without it is always accepted.
2241
+
2242
+ ## `RenderedMarkdown` `resolveLocalLink`, `resolveImage` and `headingAnchors` (ui 0.53.0; additive, no core change)
2243
+
2244
+ - **`resolveLocalLink?: LocalLinkResolver`** on `RenderedMarkdown`: the same contract as Viewer's `resolveLocalLink` (see above), provided through `LocalLinkContext` to every block it renders. Plannotator's code review passes it for the Markdown preview so a relative link opens the linked file in the review, or renders inert ("Not in this review"), and never navigates the tab.
2245
+ - **`resolveImage?: ImageResolver`** on `RenderedMarkdown` (`components/ImageResolverContext.tsx`): asked about every markdown image and raw-HTML `<img src>`, with the source as written. `{ kind: 'src', src }` loads that URL; `{ kind: 'unavailable', title }` draws an inert placeholder span (the alt text, `title` as its tooltip, `data-image-unavailable`) instead of an `<img>`, so nothing is fetched; `null` keeps the default resolution (`getImageSrc` / remote URLs as is).
2246
+ - With `resolveImage`, the host speaks for every media URL, so nothing loads from the page origin unvetted. Raw-HTML blocks are rewritten on an inert `<template>` before insertion (no request goes out first): `<img srcset>` / `sizes` are dropped (the resolved `src` stays), `<picture><source>` is dropped (its `<img>` is the fallback), and `<video>` (with its `src`, `poster` and `<source>`s) becomes a placeholder, because the resolver answers for images only. An image the resolver chose that then fails to load (`error`) turns into the same placeholder instead of a broken-image icon, in markdown and raw HTML alike.
2247
+ - **`headingAnchors?: boolean`**: headings get the plan Viewer's slug ids (`buildHeadingSlugMap`, GitHub's `-1` dedup), so a host can scroll `#section` links itself. Absent, headings carry no id.
2248
+ - Each provider wraps the output only when its prop is passed, so an enclosing provider (a `Viewer`'s) still applies when a prop is absent. A host that passes none of these renders exactly as before (`RenderedMarkdown.hostSeams.test.tsx` compares the DOM with the pre-seam body).
2249
+
2250
+ ## Plan diff line ranges, folding, and `PlanCleanDiffView` seams (ui 0.53.0; additive, no core change)
2251
+
2252
+ - **`PlanDiffBlock.oldStart/oldEnd/newStart/newEnd`** (`utils/planDiffEngine`): `computePlanDiff` now fills the 1-based inclusive source lines of each block on each side, trimmed of blank lines; a side with no non-blank line has none (added blocks have no old range, removed blocks no new range). Every other field is unchanged.
2253
+ - **`foldUnchangedPlanDiff(blocks, { context = 1, minHidden = 2 })`** → `PlanDiffSegment[]`: pure. Changed blocks pass through; an unchanged block keeps `context` sections next to each neighbouring change and folds the rest into a `PlanDiffFold` (`pieces`, `sectionCount`, up to three plain `headings`, line span, stable `key`) when at least `minHidden` would be hidden.
2254
+ - **`PlanCleanDiffView`** gains optional `renderBlock(block, orderedIndex, side)` (`side` is `'old'` for removed content and the old half of a modified block), `lineGutter` (a `data-plan-diff-gutter` column: "11–12", "old 15–16", both on a modified block; `annotation-exclude`, `aria-hidden`, unselectable), `foldUnchanged` (a `data-plan-diff-fold` row that expands in place) and `unchangedClassName`. Plannotator's code review uses them for its rendered Markdown diff. Plan review passes none and renders the same DOM as before (`PlanCleanDiffView.seams.test.tsx`). The gutter sits on the first line of text (baseline-aligned), past a heading's top margin.
2255
+ - **`CodeAnnotation.markdownPreview?: MarkdownPreviewAnchor`** (`types`): additive. Code review sets it on a file comment made by selecting text in a Markdown preview (`originalText`, `view`, optional `diffContext` and web-highlighter positions). Hosts that never set it see no change.
2256
+ - **`reviewMarkdownPreviewShortcuts`** (`shortcuts`, code-review scope `review-markdown-preview`, section "Markdown Files": `P`, the next Markdown view): additive.
2257
+
2258
+ ## Long choice labels are picked whole (core 0.25.13; no API break)
2259
+
2260
+ `parseQuestionAnswer` used to cut every `selected` label to `MAX_QUESTION_CHOICE_LABEL_CHARS` (200). Task-list choices (`- [ ]`) are never cut by the parser, so a task-list label over 200 characters could never be picked: the card sent the whole label, the read-back answer held the first 200 characters, the card did not show it picked, and the Plannotator Inbox refused it (`422 "<first 200 chars>" is not one of the question's choices`). Now `selected` labels are capped at the new **`MAX_QUESTION_SELECTED_CHARS`** (= `MAX_QUESTION_BODY_CHARS`, 20,000: no label a block can hold is longer), so every label round-trips whole. `MAX_QUESTION_CHOICE_LABEL_CHARS` keeps its value and its one parse use (cutting a long plain-bullet label at a sentence end). **`canonicalQuestionAnswer`** also maps an answer an older build cut at 200 characters back to the one choice whose label (or alias) begins with it; two choices sharing their first 200 characters leave it as stored. That lookup is exact: a long label adds no case or punctuation matching (normalized matching still runs only for choices with `aliases`). `questionExportItems` now also passes `choices` when a label is over 200 characters (new **`questionChoicesNeedCanonicalLabels(choices)`** decides it), so the export prints the full label for such an answer; a host that builds its own `QuestionExportItem`s should use the same rule. The parser, labels and `questionKey` do not change. **A host that validates answers itself** (Workspaces' own check that a pick is one of the choices) must not cut picked labels shorter than the choice labels it compares them with.
2261
+
2262
+ ## Grapheme-safe cuts on `inbox-questions` (next core publish; additive, no new subpath)
2263
+
2264
+ `@plannotator/core/inbox-questions` gains two exports, **`cutText(text, max, ellipsis = "…")`** and **`sliceText(text, max)`** (source `packages/core/text-cut.ts`, which is deliberately NOT its own subpath, so core's export map stays the published one). Both count UTF-16 code units like `slice`, so a limit means what it did, but cut only between grapheme clusters (`Intl.Segmenter`, with a surrogate-pair fallback): an emoji, a flag, a family or a letter with its marks is kept whole or dropped whole, never drawn as "�". **`deriveInboxSubject`** now uses `cutText`, so a derived subject whose 120-character cut used to land inside an emoji ends before it instead; any other subject is byte-identical. The UI does not import them; publish them with the next core, no ui change needed. The question-prompt cut changed in the next section.
2265
+
2266
+ ## Question prompts are cut whole (next core publish; additive, no new subpath)
2267
+
2268
+ `@plannotator/core/question-block` gains **`capQuestionPrompt(prompt)`**: the prompt an answer keeps, cut at `MAX_QUESTION_PROMPT_CHARS` (400) between grapheme clusters (`sliceText`), never inside an emoji. `parseQuestionAnswer` and `emptyQuestionAnswer` use it, and so does `inbox-questions` wherever it compares or stores a prompt, so the two never disagree (`checkInboxAnswer` also takes the plain 400-unit cut an older client made). A prompt of 400 code units or fewer is byte-identical; a longer one whose 400th unit is half of an emoji now ends before the emoji instead of with a lone surrogate. **A host that cuts prompts itself** (Workspaces' own answer check) should call `capQuestionPrompt` rather than `slice(0, 400)`, or an answer whose prompt was cut across an emoji will read as a different question. The Pi extension vendors `text-cut.ts` beside `question-block.ts` for this.
2269
+
2270
+ ## Question cards: the Suggested line, the read-only Other row, a host status label (ui, next publish; plan review and annotate change too)
2271
+
2272
+ Three changes to `components/blocks/QuestionBlock`, from the Plannotator Inbox stress test (rows 36 to 38). The first two are bug fixes that show in plan review and annotate exactly as in the Inbox (the owner chose "fix them for everyone"); the third is an optional seam.
2273
+
2274
+ - **The Suggested line on choice questions.** A choice question whose `Recommended:` names none of its choices kept the text in `suggestedText` and offered "Accept recommended", which fills Other with it, but never showed it, so the button did something the reader could not see. The card now draws the same "Suggested … Use" line a text question draws, above the choices, while unanswered, and adds it to the fieldset's `aria-describedby`. The line carries `data-question-suggested` (on text questions too). A choice question whose recommendation names a choice is unchanged (no `suggestedText`, no line).
2275
+ - **The read-only Other row.** A read-only card (no `onAnswer`, a closed or sent question) drew an empty Other row, a bare dashed "+", when the answer had no Other text. The row is now drawn only when the card can be answered or the answer used Other. An editable card is unchanged.
2276
+ - **`statusLabel?: (question, status) => { label, tone? } | undefined`** on `QuestionBlockProps`, passed through `BlockRenderer` and `Viewer` as **`questionStatusLabel`**, and exported type **`QuestionStatusLabel`** (`tone`: one of the card's own tag tones, `'ok' | 'open' | 'skip'`, default `'open'`). Return words for the card's status tag (Open / Answered / Settled / Skipped) to replace it, or undefined to keep it; ignored with `statusTag: 'none'`. The Inbox says "Not sent" for a pick it has saved but not sent, where the card said "Answered" next to the footer's "Picked …, not sent". Absent (Plannotator's plan review and annotate pass none): today's tags, same DOM.
2277
+
2278
+ ## Guided review cards: one count, any heading level (guide-viewer; the guides.show viewer is regenerated)
2279
+
2280
+ `@plannotator/guide-viewer` is not published, but code review and the guides.show viewer use it, so for the record: **`GuideSectionCard`** gains **`numbered?: boolean`** (default true). `GuideView` passes `numbered={false}` to the "Everything else" card and numbers the sections against `guide.sections.length`, so the header's "14 sections" and the cards' "01 / 14" agree (the cards used to read "01 / 15"). A card with no position and no Reviewed control draws no empty row. **`renderMarkdownProse`** reads `#` to `######` as the card's one sub-heading style (an `h3`), where only `### ` was a heading and `## Heading` showed its hashes; a heading also ends the paragraph above it. Seven hashes, or none followed by a space, are prose. The pinned viewer manifest (`@plannotator/core/guide-viewer-manifest`) is regenerated: 399.7 KB gz of the 400 KB budget (it was 399.6).
2281
+
2282
+ ## `CodeFileView` `overflow` (ui, next publish; additive, no core change)
2283
+
2284
+ **`CodeFileView`** (`components/CodeFilePopout`) gains **`overflow?: 'scroll' | 'wrap'`**, handed to Pierre's `File`: `'scroll'` (the default, today's view) scrolls long lines sideways, `'wrap'` wraps them. `CodeFilePopout` passes nothing and is unchanged. The Inbox wraps a code file with a line over 1,000 characters (a minified bundle), with no toggle.
2285
+
2286
+ ## A reply with no answers leaves the questions open (next core publish; additive exports, one output change)
2287
+
2288
+ `@plannotator/core/inbox-questions` gains **`inboxStillOpenLine(questions)`** and **`INBOX_STILL_OPEN_LINE`** (a RegExp matching one line of it). **`composeInboxReplyBody`** changes output in one case only: when `sent` is empty (the reply carries no answer and no skip), it now puts the still-open line after the words: "No questions answered yet; all N are still open. Don't re-ask them; the person will answer in the Inbox." (N: the questions still open or picked and not sent, counted over the new optional `stillOpen` input, default `questions`; the Inbox passes every question the replied-to message's session asked in the thread; the singular and an "earlier answers" variant are in the source; nothing when none is open). A body with at least one answer or skip is byte-identical. A host that composes Inbox replies with it gets the new line; a host that strips the store's text from a reply (as the window's `replyWords` does) should cut at a line `INBOX_STILL_OPEN_LINE` matches. `question-block.ts` (plan review's `formatQuestionAnswersSection`) is unchanged. No ui change.
2289
+
2290
+ ## Accept recommended on pick-any questions; a whole choice in a `Recommended:` list (ui and core, next publish; plan review, annotate and the Inbox change too)
2291
+
2292
+ - **`QuestionBlock`**: on a `:::question-multi` card whose `Recommended:` names choices, "Accept recommended" now stays offered while the answer is not exactly those choices (`isRecommendedQuestionAnswer`), where it went away at the first tick. Accept still sets exactly `recommendedQuestionAnswer` (the recommended choices, extra ticks and Other cleared, the note kept), so after it the button goes away, and focus moves to the first checked choice (or the field holding the suggestion) instead of dropping to the page. Pick-any questions recommending only a suggestion, pick-one and text questions, settled questions and read-only cards are unchanged. No prop or DOM change otherwise.
2293
+ - **`parseQuestionBlock`** (core): a multi `Recommended:` list that names a choice by its full text, `— description` included, whose description holds the list's separator (`Reply box, Comments on attached files — one line of work, could ship first`), now matches every choice instead of none (it was a Suggested line). After the plain split fails, neighbouring parts are joined back, but only into a span that is exactly a choice's full text, label or name, and only when every part is covered. It runs only where the plain split failed, so a recommendation that split cleanly is unchanged; one main matched only through the final lettered fallback can now match more (plain bullets `a. Alpha`, `b. Beta — slow, steady` with `Recommended: a. Alpha first, b. Beta — slow, steady` named only `a.` and now names both).
2294
+ - **`Viewer` `questionReadOnly?: (question: IndexedQuestion) => boolean`** (additive): true draws that one question card read-only, exactly as under `readOnly` (no picks, Other, note, Skip or Accept recommended; its decision tag a plain tag), while the other questions in the document stay answerable. The Inbox passes it for every question whose state is not open or picked (sent, closed by a Mark read or the archive, superseded), so a sent card in a message whose other question is still open no longer offers to re-answer it (the store would take that pick and make the question picked again; the thread-state model says the window does not offer it, `adr/implementation/inbox-thread-state.md`, the Pick row). Absent (plan review, annotate): every question follows `readOnly`, unchanged.
2295
+
2296
+ ## Skill references outside the comment popover (ui + core, next publish; additive, Plannotator unchanged)
2297
+
2298
+ For the Plannotator Inbox's composers (approval "inbox-skill-references"). Every default reproduces today's markup and export byte for byte.
2299
+
2300
+ - **`components/ComposerTokenTextarea`** (new, exported): the comment composer's textarea with its token highlight mirror, moved out of `CommentPopover` (which renders it with every default). New optional props: **`textClassName`** (classes on both the textarea and the mirror; default `COMPOSER_TEXT_CLASSES`, also exported), **`inputClassName`** / **`mirrorClassName`** (extra classes on one side), **`inputProps`** (further textarea attributes; an `aria-label` there replaces the placeholder default). `CommentPopover` re-exports the `ComposerMentionChips` type.
2301
+ - **`HumanOnlySkillNotice`** gains **`destination?: string`**; without it the line uses the host's word, **`setSkillInstructionsDestination`** / **`configurePlannotatorUI({ skillInstructionsDestination })`**, default `'your feedback'`. **`SkillReferenceMenu`** rows carry **`data-skill-item-part="name" | "description" | "root"`** on their three spans (a styling hook; no visual change).
2302
+ - **`utils/skillReferences`**: **`renderSkillReferencesBlock(refs, { contentOf?, injectedNames?, external?, audience? })`**, the block for references a caller already resolved; `skillReferenceExportBlock` now calls it with the reviewer wording (output unchanged). `audience` is a `SkillBlockAudience` from the new **`@plannotator/core/skill-reference-block`** (`REVIEWER_SKILL_AUDIENCE`, `INBOX_REPLY_SKILL_AUDIENCE`, `INBOX_MESSAGE_SKILL_AUDIENCE`, `skillsReferencedHeading`, `skillInstructionsIncludedNote`, and the Inbox's reader `splitInboxSkillsBlock` / `withoutInboxSkillsBlock`). ui now imports this new core subpath, so the next ui publish needs the next core.
2303
+ - **`@plannotator/core/markdown-structure`** gains **`codeFenceFor(code)`** (three backticks, or one more than the longest backtick run in the code). Code review's feedback export fences Highlighted text, Replaces and Suggested code with it: byte-identical unless the quoted code holds a ``` run.
2304
+ - **`utils/skillCatalog`**: **`normalizeSkillContent`** is exported; **`setSkillCatalogExportRegistration(on)`** (host configuration: `resetSkillCatalogCache` leaves it alone), and **`configurePlannotatorUI({ skillReferenceExport })`** (default true): false keeps the fetched catalog out of the per-comment export, for a host that composes one block per message itself.
2305
+
2306
+ ## The image peek, a stepping lightbox, and `Viewer` image seams (ui, next publish; additive, no core change)
2307
+
2308
+ For the Plannotator Inbox's one viewer (approval "inbox-image-viewer", 2026-10-15). A host that passes none of the new props sees no change.
2309
+
2310
+ - **`components/ImagePeek`** (new): the picture list a stepper opens ("A · Peek"). Props `rows: ImagePeekRow[]` (`{ key, label, items: ImagePeekItem[] }`; an item is `{ key, name, thumb?, tile?, count?, attached?, detail? }`), `current`, `onPick(key)`, `onClose(reason: 'key' | 'outside')`, `variant: 'popover' | 'sheet'` (a phone's bottom sheet, three across, with a backdrop), `label`, `id`, `className`. An ARIA `grid` (rows with a `rowheader`, `gridcell` items, `aria-selected` on the open one); focus starts on the open item; ← → along the items, ↑ ↓ between rows, Home / End, Enter / Space pick, Esc / Tab close. A press outside a popover closes it, except on an element marked `data-peek-opener`. `forwardRef` exposes **`ImagePeekHandle.handleKey(event)`** for a modal that intercepts keys before they reach it. Images are only `<img src>`. Also exported: `peekMove` (the key rule, pure).
2311
+ - **`ImageLightbox`** gains **`images?: LightboxImage[]`** (`{ src, alt, section? }`): with two or more it is a full-screen stepping view ("2 of 4 · in “Options”", ← → and `]` `[`, the end note, Actual size, the pill and the peek); without it, the same single-image overlay byte for byte. Also exported: `collectDocumentImages(container)` and `lightboxImageName(image)`.
2312
+ - **`Viewer`** gains **`imageLightboxStepping?: boolean`** (the lightbox steps through every zoomable image of the rendered document; Plannotator's plan review and annotate pass it) and **`onImageClick?: (src, alt) => void`** (the host takes image clicks and no lightbox opens; the Inbox opens a message's captured image in its own viewer). Both absent: unchanged.
2313
+
2314
+ ## `Tooltip` `shouldOpen` (ui, next publish; additive, no core change)
2315
+
2316
+ **`Tooltip`** (`components/Tooltip`) gains **`shouldOpen?: (trigger: Element) => boolean`**, asked each time the tooltip would open; `false` keeps it closed that time (Base UI's `onOpenChange` with `cancel()`). Unset, it always opens, as before. The Inbox passes a check that the trigger's text is cut (`isTextCut` in `utils/textCut.ts`: any whole pixel of `scrollWidth` over `clientWidth`, or the text's Range width over the box's content width by a hundredth of a pixel, since Chrome draws an ellipsis for a 0.06 px overflow that the integer widths hide), so a cut label shows its whole text only while it is cut.
2317
+
2318
+ **`components/HostTitle`** (new) and **`configurePlannotatorUI({ componentTooltips })`** (default false): ui's own hover text (the annotation panel's and toolbar's buttons and labels, a question card's "Record as a decision" tag, a host-resolved local link's `LocalLinkResolution.title`) goes through `HostTitle`. Default: the child gets a native `title`, exactly as before. With `componentTooltips: true` (the Plannotator Inbox) it is ui's `Tooltip` instead (150 ms, Escape passes through, nothing under `hover: none`), a title that was a control's only name (`name`) becomes its `aria-label`, and any other title (not a `cut` label, not equal to the control's own label) stays readable as its description: a `hidden` element the child names with `aria-describedby`, since the tooltip tags nothing for a screen reader. Off, the child gets exactly the native title it had (an empty `title=""` included) and nothing else (`HostTitle.dom.test.tsx`). `utils/textCut`'s `isTextCut(el, partSelector?)` takes the selector of a first part that counts as the text (the Inbox passes its FileName's). `Tooltip`'s popup now draws at `z-[110]` (was `z-50`), above the annotation toolbar's `z-[100]`, and carries `data-pn-tooltip` (a host styles it). `HostTitle` portals its tooltip by `tooltipContainerOf(trigger)` (exported): the open native `<dialog>` the trigger sits in, else the nearest element marked `data-pn-tooltip-root` (the Inbox's window root, so its tokens reach the popup), else the body. `setComponentTooltips` / `resetComponentTooltips` / `getComponentTooltips` for tests. Not covered yet: the ImageAnnotator toolbar, CommentPopover and other ui titles keep native titles in every host.
2319
+
2320
+ **`Tooltip`** also gains **`container?: HTMLElement | null`**, where the popup is portaled (Base UI's `Portal` `container`). Unset or null: the document body, as before. The Inbox passes the open native `<dialog>` a trigger sits in (its search palette, compose and its modal dialogs), since a popup in the body is drawn under the dialog's top layer. And **`escapePassesThrough?: boolean`**: Escape closes the tooltip without using the key up (the close is canceled, so Base UI neither prevents nor stops it, and the tooltip closes through its own state), so a page's Escape still works while a tooltip is open. Unset: Base UI's default, unchanged.
2321
+
2034
2322
  ## The law (guardrails for anyone editing `@plannotator/ui`)
2035
2323
 
2036
2324
  These are enforced socially and, where possible, by CI. They exist because a prior from-scratch reimplementation of this UI broke the app and was reverted.
package/README.md CHANGED
@@ -26,6 +26,8 @@ configurePlannotatorUI({
26
26
  aiTransport,
27
27
  skillCatalogTransport, // skill-reference catalog for comment composers
28
28
  skillContentTransport, // human-only skill contents for feedback injection
29
+ skillReferenceExport, // false: no per-comment "Skills referenced" list (a host composing one block per message)
30
+ skillInstructionsDestination, // what the human-only line says instructions go with (default "your feedback")
29
31
  serverSync,
30
32
  webmcp, // browser-agent (WebMCP) provider policy: { enabled, namePrefix }
31
33
  mathRendererLoader, // how KaTeX loads when no renderer is registered before first math render
@@ -348,6 +350,22 @@ Neither prop reaches `CodeAnnotation` cards (the review-editor shape), which
348
350
  take no `renderCardFooter` either. See HANDOFF.md § "Annotation card header
349
351
  slot and mentions on the edit box (0.45.0)".
350
352
 
353
+ - **`compareAnnotations?: (a, b) => number`** (unreleased) — the order of the
354
+ comment timeline and of each All-files group, for comments with an order of
355
+ their own (the Inbox sorts a video's comments by the moment they are at).
356
+ Ties keep creation order; replies still thread under their parent. Omit it
357
+ and the panel sorts by creation exactly as before.
358
+ - **`title?`, `titleNote?`, `cardParts?: (annotation) => { quote?, images? }`**
359
+ (unreleased) — the header's title (default "Annotations") and a quiet note
360
+ at its right, and per card whether the built-in quote and image row are
361
+ drawn (the Inbox's video comments draw the moment and the frame in
362
+ `renderCardHeader` instead). Omit them and the panel is as before.
363
+ - **Export: `exportLocation`** (unreleased) — a string a host sets on the
364
+ COPY of an annotation it hands the exporters (`exportAnnotations`,
365
+ `exportLinkedDocAnnotations`): one line printed under the entry's heading
366
+ (the Inbox's `At 1:42 (102.4 s of 7:41)`). Never stored; an annotation
367
+ without it exports byte-identically.
368
+
351
369
  ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
352
370
 
353
371
  The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.modelContext`) call in-page tools on a document surface. Feature-detected once; a browser without the API sees no registration, no DOM, no network, no timers. Seam: `configurePlannotatorUI({ webmcp: { enabled, namePrefix } })`, default enabled with the `plannotator.` prefix; pass `enabled: false` to keep a host page tool-free, or your own prefix to namespace the tools beside your own. There is deliberately no confirmation seam: the catalog is read-and-comment only (no approve / submit / close tools), and the agent may only edit or remove comments stamped `source: "browser-agent"`.
@@ -384,13 +402,27 @@ See HANDOFF.md § "Questions in documents (0.48.0, core 0.25.8)".
384
402
 
385
403
  See HANDOFF.md § "Question host seams (ui 0.49.0, core 0.25.9)".
386
404
 
387
- **Decision recording toggle and status tag (ui 0.52.0, unreleased).** Also optional on `Viewer`, also unused by Plannotator:
405
+ **Decision recording toggle and status tag (ui 0.52.0).** Also optional on `Viewer`, also unused by Plannotator:
388
406
 
389
- - `questionDecisionRecording(question) => boolean | undefined`: false draws a `Decision: when answered` question's "Records a decision" tag dimmed with a dotted outline and hides its "Answering this records a decision" row. A recorded decision is never affected.
407
+ - `questionDecisionRecording(question) => boolean | undefined`: false draws a `Decision: when answered` question's "Records a decision" tag faded (muted-foreground at 55%; the focus ring stays at full strength) with a dotted outline and hides its "Answering this records a decision" row. A recorded decision is never affected.
390
408
  - `onToggleQuestionDecisionRecording(key, next)`: makes that tag an `aria-pressed` toggle button; store `next` and return it from `questionDecisionRecording`. Not offered under `readOnly` or in a card without an answer handler.
391
409
  - `questionStatusTag: 'none'`: hides the card's own Open / Answered / Settled / Skipped tag so you can draw your own; the decision tags stay.
410
+ - `renderQuestionCard(question, savedAnswer)` (ui 0.53.0): your own card in place of a question's answer card (return `undefined` to keep it), e.g. for a question the asker withdrew; `renderQuestionBelowPrompt(question)`: content under a card's prompt.
411
+
412
+ See HANDOFF.md § "Question decision toggle and status tag (ui 0.52.0; …)".
413
+
414
+ **Decision toggle on any question, and a decision-card opener (ui 0.52.1).** Also optional on `Viewer`, also unused by Plannotator:
415
+
416
+ - `questionDecisionScope?: 'when-answered' | 'any'` (default `'when-answered'`, the 0.52.0 behaviour). With `'any'`, `questionDecisionRecording(question)` also applies to a question with no decision line: a boolean adds the "Records a decision" tag in that state (true also shows the "Answering this records a decision" row); `undefined` leaves it tag-less. Return `false` for "off by default".
417
+ - `onOpenQuestionDecision(key, anchor)`: splits the tag into a diamond switch (`aria-pressed`, "Record as a decision", driven by `onToggleQuestionDecisionRecording`) and the words as a button (`aria-haspopup="dialog"`) that calls this with its own element to anchor your decision card. Offered on and off; not offered under `readOnly`. Without it the whole tag is the toggle, as in 0.52.0.
418
+
419
+ See HANDOFF.md § "Decision toggle on any question, and an opener for the host's decision card (ui 0.52.1, …)".
420
+
421
+ **Questions without decision lines (after ui 0.53.0, core 0.25.13).** `Viewer` `questionDecisionLines?: boolean` (default `true`, today's behaviour). `false` parses every `:::question` card with core's new `{ decisionLines: false }` option (`parseQuestionBlock(kind, body, options?)`, `indexQuestionBlocks(blocks, options?)`): a `Decision:` line is shown as context, and no card gets a decision tag or row from it. The prompt, key, numbering and choices are the default parse's (the line still ends a choice right above it). For a host that records no decisions; the Plannotator Inbox passes it since Decisions were removed there (2026-10). Every decision seam above is unchanged.
422
+
423
+ **Answers without comments (ui 0.53.0).** `Viewer` `answerOnly`: annotation authoring and the document actions (Global comment, Copy) are off as under `readOnly`, while question cards and checkboxes keep their handlers, for a host that shows a message to answer, not a document to annotate. See HANDOFF.md § "Viewer `answerOnly` (ui 0.53.0)".
392
424
 
393
- See HANDOFF.md § "Question decision toggle and status tag (ui 0.52.0, unreleased; …)".
425
+ **Local links decided by the host (ui 0.53.0).** `Viewer` `resolveLocalLink`: the host decides what a link to a local path does (open it itself, or draw it as inert text), markdown and raw HTML alike. See HANDOFF.md § "Viewer `resolveLocalLink` (ui 0.53.0)".
394
426
 
395
427
  ### Diff file tree (`components/DiffFileTree`; next ui release)
396
428