@plannotator/ui 0.53.1 → 0.55.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 (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
package/README.md CHANGED
@@ -36,6 +36,8 @@ configurePlannotatorUI({
36
36
  alertIconRenderer, // (name) => ReactNode | null for a GitHub alert title line's <!-- icon: name --> (default: null, the type's icon)
37
37
  fenceTheme, // (colorTheme, mode) => Shiki theme name | undefined for code fences (default: SHIKI_THEME_MAP, else pierre-dark/light)
38
38
  componentTooltips, // true: ui's own hover text (components/HostTitle) draws as ui's Tooltip, not a native title (ui 0.53.0; default false)
39
+ digitHexIssueRefs, // true: digit-only #123456 / #12345678 link as issue refs (when a ref link can be built), not hex swatches (default false)
40
+ refCardTransport, // (href, ref, signal) => card data for an issue / PR / MR link; with one, useRefHoverCards(rootRef) draws hover cards (default: none, no card, no request). refCardRouteTransport(route) builds one over a server route
39
41
  loadSettingsFromBackend, // true: re-hydrate settings from your (synchronous) storageBackend after install, applied last
40
42
  });
41
43
  ```
@@ -62,10 +64,10 @@ Building your own tooltip and removing the built-in double-click reset are host-
62
64
  The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz has its own slot since 0.41.0 (`utils/graphviz`, the same shape as Mermaid's: lazy `import('@viz-js/viz')` on the first dot fence, or `setGraphvizRuntime` from the host) and the block shows the source fence under a status until the SVG lands. Mermaid, Graphviz, KaTeX and the dictionary sit behind synchronous slots:
63
65
 
64
66
  - **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3). The default `import('katex')` is the only runtime mention of `katex` in the package and lives in `utils/math-default-loader` (0.33.0), called only while no loader is registered; a registered loader is never backfilled by it, though a default load already in flight at registration still fills the slot (pre-existing), so register the loader before the first math render. Chunk emission is static, so a bundler still emits that chunk (never requested) unless you alias the module away; see HANDOFF.md "Lazy renderers and eager entries" for the two-line alias. The Mermaid runtime has its own `import("katex")` for `$$` labels, which leaves a second, shared KaTeX chunk in a host build even with the alias; since 0.34.0 a host redirects that one import (for importers inside the `mermaid` package only) to `@plannotator/ui/utils/mermaid-math-slot`, which typesets the labels through your registered renderer, so one KaTeX chunk remains and it is yours. Recipe and measurement in HANDOFF.md, same section. `resetMathRenderer()` empties the slot only and keeps a registered loader (0.34.0); `setMathRendererLoader(null)` is the explicit way back to the package default.
65
- - **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')` and the block shows the source fence under a "Rendering diagram" status until the SVG lands; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. **Since 0.40.0 (Mermaid 12.0.0, ELK layout by default, Safari 17.4+) this lazy path is Plannotator's own**: the plan editor no longer imports the eager entry, so a document with no diagram never downloads the runtime in a chunked build. `import "@plannotator/ui/utils/mermaid-eager";` in your entry registers and initializes the runtime at startup instead, if you would rather it never fail separately from the app. Mermaid 12 lays flowchart, state, class, ER and requirement diagrams out with ELK (orthogonal edges, different packing); every generated SVG `id` keeps its 11.x shape, but children of `g.edgePaths` are now in declaration order, so select edges by id (`{renderId}-L_{from}_{to}_{n}`, `{renderId}-edge{n}`), never by DOM position. Details in HANDOFF.md "Mermaid 12 (0.40.0)". **Diagrams follow the colour theme.** Before every render `MermaidBlock` calls `applyMermaidTheme` (`utils/mermaidTheme`), which reads the theme tokens off the document (`--background`, `--foreground`, `--card`, `--border`, `--muted`, `--muted-foreground`, `--primary`, the accent tokens and `--font-sans`; `readThemeTokens`), derives a complete `themeVariables` set for every diagram family from them (`buildMermaidThemeVariables(tokens, mode)`, pure; base theme `dark` under a dark resolved mode, `default` under light; every text-on-fill pair guarded to WCAG 4.5:1 and every line 3:1, rule in the module doc) and runs the global `mermaid.initialize` once per `(palette, mode, shadow amount)` key, re-rendering mounted diagrams when the key changes. The key comes from `useTheme()`, so a host that mounts `ThemeProvider` and ships `theme.css` gets diagrams in its palette with nothing to configure. **The node shadow follows the palette too.** Mermaid 12's neo look shadows every node from a fixed `rgba(185,185,185,1)` grey; the mapping keeps the look and replaces that filter, setting `themeVariables.dropShadow` from the page's own ground at `options.shadowAmount` (0..1, default `DEFAULT_MERMAID_SHADOW_AMOUNT` = **0.7** of Mermaid's geometry; 1 reproduces its `1px 2px 2px` exactly, 0 publishes `dropShadow: false` and the neo rules render `filter: none`). Its polarity follows the page — lighter than the ground on a dark page, darker on a light one, which is what Mermaid's own `insertLookDefs` does and what keeps a shadow visible at all. A host that wants Mermaid's grey passes its own `themeVariables.dropShadow` after ours; one that wants none passes `{ shadowAmount: 0 }`. `mermaidThemeKey(palette, mode, amount?)` appends the amount only when it is not the default, so a host that names none keeps the key it had. **Fallback contract:** with no tokens on the document (no `ThemeProvider`, no `theme.css`) `readThemeTokens` returns `undefined`, nothing is re-initialized, and the runtime keeps the static `MERMAID_CONFIG` it was initialized with, so such a host renders byte-identically to 0.39.0. `MERMAID_CONFIG` keeps its value and meaning (`securityLevel: 'strict'` pinned); the new exports are additive. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level.
67
+ - **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')` and the block shows the source fence under a "Rendering diagram" status until the SVG lands; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. **Since 0.40.0 (Mermaid 12.0.0, ELK layout by default, Safari 17.4+) this lazy path is Plannotator's own**: the plan editor no longer imports the eager entry, so a document with no diagram never downloads the runtime in a chunked build. `import "@plannotator/ui/utils/mermaid-eager";` in your entry registers and initializes the runtime at startup instead, if you would rather it never fail separately from the app. Mermaid 12 lays flowchart, state, class, ER and requirement diagrams out with ELK (orthogonal edges, different packing); every generated SVG `id` keeps its 11.x shape, but children of `g.edgePaths` are now in declaration order, so select edges by id (`{renderId}-L_{from}_{to}_{n}`, `{renderId}-edge{n}`), never by DOM position. Details in HANDOFF.md "Mermaid 12 (0.40.0)". **Diagrams follow the colour theme.** Before every render `MermaidBlock` calls `applyMermaidTheme` (`utils/mermaidTheme`), which reads the theme tokens off the document (`--background`, `--foreground`, `--card`, `--border`, `--muted`, `--muted-foreground`, `--primary`, the accent tokens and `--font-sans`; `readThemeTokens`), derives a complete `themeVariables` set for every diagram family from them (`buildMermaidThemeVariables(tokens, mode)`, pure; base theme `dark` under a dark resolved mode, `default` under light; every text-on-fill pair guarded to WCAG 4.5:1 and every line 3:1, rule in the module doc) and runs the global `mermaid.initialize` once per `(palette, mode, shadow amount)` key, re-rendering mounted diagrams when the key changes. The key comes from `useTheme()`, so a host that mounts `ThemeProvider` and ships `theme.css` gets diagrams in its palette with nothing to configure. **The node shadow follows the palette too.** Mermaid 12's neo look shadows every node from a fixed `rgba(185,185,185,1)` grey; the mapping keeps the look and replaces that filter, setting `themeVariables.dropShadow` from the page's own ground at `options.shadowAmount` (0..1, default `DEFAULT_MERMAID_SHADOW_AMOUNT` = **0.7** of Mermaid's geometry; 1 reproduces its `1px 2px 2px` exactly, 0 publishes `dropShadow: false` and the neo rules render `filter: none`). Its polarity follows the page — lighter than the ground on a dark page, darker on a light one, which is what Mermaid's own `insertLookDefs` does and what keeps a shadow visible at all. A host that wants Mermaid's grey passes its own `themeVariables.dropShadow` after ours; one that wants none passes `{ shadowAmount: 0 }`. `mermaidThemeKey(palette, mode, amount?)` appends the amount only when it is not the default, so a host that names none keeps the key it had. **Fallback contract:** with no tokens on the document (no `ThemeProvider`, no `theme.css`) `readThemeTokens` returns `undefined`, nothing is re-initialized, and the runtime keeps the static `MERMAID_CONFIG` it was initialized with, so such a host renders byte-identically to 0.39.0. `MERMAID_CONFIG` keeps its value and meaning (`securityLevel: 'strict'` pinned); the new exports are additive. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level. The `Viewer`'s lazy blocks (Mermaid, Graphviz, HTML and SVG embeds), the diagram popout and the Source pane each sit in `components/LazyBlockBoundary`: when a chunk cannot load, that one block shows its source under a quiet "Couldn't load this view. Reload" line ("This page was updated. Reload to see this" when the chunk answers 404), and the rest of the page stays mounted.
66
68
  - **Identity.** With an `identityProvider` the generator is never called and the word lists stay out of your bundle. Without one, default names come from a small built-in pool of the same `adjective-noun-tater` shape; `import "@plannotator/ui/utils/identity-tater";` registers the full dictionary, or pass your own `identityGenerator`.
67
69
 
68
- Plannotator's own entries import the eager math and identity modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx`), which is what keeps math typeset on the first commit and names minted from the full dictionary; neither app imports `mermaid-eager` since 0.40.0, so the Mermaid runtime rides a lazy chunk on the share portal and is inlined by `inlineDynamicImports` in the single-file builds. `tests/entry-assets.test.ts` fails if an eager math/identity import is dropped or an eager Mermaid import creeps back. See HANDOFF.md "Lazy renderers and eager entries" and "Mermaid 12 (0.40.0)".
70
+ Plannotator's own entries import the eager math and identity modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx`), which is what keeps math typeset on the first commit and names minted from the full dictionary; neither app imports `mermaid-eager` since 0.40.0, so the Mermaid runtime rides a lazy chunk, both on the share portal and in the plan and review apps (which since #1618 ship as one packed file whose server answers each chunk at `/assets/*`, instead of inlining them). `tests/entry-assets.test.ts` fails if an eager math/identity import is dropped or an eager Mermaid import creeps back. See HANDOFF.md "Lazy renderers and eager entries" and "Mermaid 12 (0.40.0)".
69
71
 
70
72
  ### Markdown editor extensions + wiki links (`MarkdownEditor` / `InlineMarkdown`)
71
73
 
@@ -112,6 +114,8 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^
112
114
 
113
115
  `components/html-viewer` is supported host surface as of 0.29.0: the overlay-projection annotation viewer for raw HTML (placed comment markers, pinpoint element anchors, shift-click multi-target comments). Its contract is **props plus the validated iframe message protocol** — not `configurePlannotatorUI()`, which only governs the backend surfaces around it. Drive the `annotations` prop (marker numbering derives from its array order); `readOnly` keeps markers painted and clickable while disabling all authoring. **0.29.0 also carries a breaking migration:** highlight.js is gone and `.hljs` selectors are inert — style code via the exported `pn-code` class (`CODE_BLOCK_CLASS` in `utils/codeHighlight`). See HANDOFF.md § "Raw-HTML annotation viewer + syntax-highlighting migration (0.29.0)" before upgrading from 0.28.0.
114
116
 
117
+ **`sandbox`** (published in ui 0.55.0): the srcdoc frame's whole `sandbox` attribute. The default is `"allow-scripts allow-modals"`, so a page's `alert`, `confirm` and `prompt` open. A host that shows documents other people wrote passes `sandbox="allow-scripts"`, so no dialog draws over its origin. `hostNavigates` still adds `allow-popups`. A non-string such as `null` gets the default. `""` is the strictest frame: no scripts, so nothing on the page can be annotated. A changed value applies when the frame next loads a document. Do not add `allow-same-origin`. See HANDOFF.md § "`HtmlViewer` `sandbox`".
118
+
115
119
  #### HTML annotation parity seams (0.32.0)
116
120
 
117
121
  Everything a host needs around `HtmlViewer` to match Plannotator's HTML annotation experience, all additive and all defaulting to today's behavior. Requires `@plannotator/core` 0.25.0 (the `html-anchor` subpath), so install and publish core before ui:
@@ -237,9 +241,40 @@ exactly. The standalone `StickyHeaderLane` remains supported for Plannotator's
237
241
  hidden-at-rest ghost lane, but its always-visible mode is an overlay and is not
238
242
  the in-flow host integration.
239
243
 
244
+ #### Content under the title (`Viewer.belowTitle`; ui 0.54.0)
245
+
246
+ `belowTitle?: ReactNode` draws a host's node right under the document's
247
+ title, e.g. a references block (pull requests, depends on, links, referenced
248
+ by). Use it instead of inserting into the Viewer's DOM.
249
+
250
+ - **Where.** When the body's first block is a level-1 heading, right after
251
+ that heading (`data-placement="below-title"`). Otherwise (a body that opens
252
+ with a paragraph, a `## heading`, or a `# title` further down) at the top of
253
+ the body, before the first block and after the frontmatter card
254
+ (`data-placement="top"`). The heading itself is unchanged.
255
+ - **Outside the document text.** The wrapper `[data-viewer-below-title]` is
256
+ `annotation-exclude` (no comment starts in it, its text is never searched
257
+ or quoted), `data-pinpoint-ignore` (pinpoint and Vim skip it),
258
+ `data-print-hide`, and carries no `data-block-id`; heading slugs, the TOC,
259
+ exports and share links come from `blocks` and never see it.
260
+ - **Comment positions ignore it.** web-highlighter's `startMeta`/`endMeta`
261
+ count elements by tag across the whole article, so a `<p>` or `<a>` in the
262
+ slot would renumber later comments. The highlighter stores and restores
263
+ positions as if the slot were not there, so the same comment has the same
264
+ metas with or without the slot, and with any slot contents. A selection
265
+ from the title that ends in the slot is cut back to the end of the title.
266
+ Only the wrapper the Viewer draws counts: its attribute carries a per-load
267
+ value, so a `<div data-viewer-below-title>` an author writes as raw HTML is
268
+ never taken for it. Match the wrapper by presence
269
+ (`[data-viewer-below-title]`), not by value.
270
+ - **Absent is absent.** `undefined`, `null` and booleans draw no wrapper; the
271
+ DOM is exactly the one without the prop.
272
+
273
+ See HANDOFF.md § "Viewer `belowTitle` (published in ui 0.54.0)".
274
+
240
275
  ### Diagram engine (`components/diagram`; 0.41.0)
241
276
 
242
- One renderer slot and one canvas render every Mermaid and Graphviz diagram — the fences in `Viewer` (`MermaidBlock`, `GraphvizBlock`), their popout, and whatever a host renders itself. `DiagramViewer` from `@plannotator/ui/components/diagram` takes `kind`, `source`, `theme` (`{ colorTheme, mode, shadowAmount? }`), `comments: DiagramComment[]` and `onCreateComment(anchor, text, additionalTargets)`; pass `onSave(source) => Promise<{ status: 'ok' } | { status: 'stale', currentSource }>` to get the Source pane (left of the canvas on desktop, under it on the phone; `readOnlySource` shows it without Save), and `sourceOpen` to toggle it. Zoom, pan and fit are the canvas's (wheel, drag, `+` `-` `0`, arrow keys); a click opens the composer at the part beneath while a drag pans (4 px threshold, 10 px for a finger), nothing highlights on a plain mouse-over (the ring under the pointer needs the platform modifier held), every edge carries an invisible 14 px hit path in one top layer and a click is resolved by priority over everything under the pointer (node, then edge, then cluster; an edge label is its edge), a click on no part comments on the whole diagram (kind `diagram`), sequence diagrams are addressable (actors, messages, notes, frames), a saved comment paints a ring and a numbered badge from the array order, and every comment re-resolves against each render through the engine's finder (id, then label, then unanchored — reported through `onUnanchoredChange`). The anchor is `DiagramAnchor` from `@plannotator/core/diagram-anchor` (`{ v: 1, family, kind, id | from + to, label, sourceLine }`, document lines), and Plannotator stores it as `Annotation.diagramAnchor`. Runtime slots: `utils/mermaid` (as before) and `utils/graphviz` (new, same shape, `@viz-js/viz` pinned `3.30.0`), each lazy on first use or filled by the host. `DiagramPopout` is the full-size viewer in the `PopoutDialog` chrome. See HANDOFF.md § "Diagram engine (0.41.0)" for every export, the adapter, the sanitizer and the migration notes.
277
+ One renderer slot and one canvas render every Mermaid and Graphviz diagram — the fences in `Viewer` (`MermaidBlock`, `GraphvizBlock`), their popout, and whatever a host renders itself. `DiagramViewer` from `@plannotator/ui/components/diagram` takes `kind`, `source`, `theme` (`{ colorTheme, mode, shadowAmount? }`), `comments: DiagramComment[]` and `onCreateComment(anchor, text, additionalTargets)`; pass `onSave(source) => Promise<{ status: 'ok' } | { status: 'stale', currentSource }>` to get the Source pane (left of the canvas on desktop, under it on the phone; `readOnlySource` shows it without Save), and `sourceOpen` to toggle it. Zoom, pan and fit are the canvas's (wheel, drag, `+` `-` `0`, arrow keys); a click opens the composer at the part beneath while a drag pans (4 px threshold, 10 px for a finger), nothing highlights on a plain mouse-over (the ring under the pointer needs the platform modifier held), every edge carries an invisible 14 px hit path in one top layer and a click is resolved by priority over everything under the pointer (node, then edge, then cluster; an edge label is its edge), a click on no part comments on the whole diagram (kind `diagram`), sequence diagrams are addressable (actors, messages, notes, frames), a saved comment paints a ring and a numbered badge from the array order, and every comment re-resolves against each render through the engine's finder (id, then label, then unanchored — reported through `onUnanchoredChange`). The anchor is `DiagramAnchor` from `@plannotator/core/diagram-anchor` (`{ v: 1, family, kind, id | from + to, label, sourceLine }`, document lines), and Plannotator stores it as `Annotation.diagramAnchor`. Runtime slots: `utils/mermaid` (as before) and `utils/graphviz` (new, same shape, `@viz-js/viz` pinned `3.31.0`), each lazy on first use or filled by the host. `DiagramPopout` is the full-size viewer in the `PopoutDialog` chrome. See HANDOFF.md § "Diagram engine (0.41.0)" for every export, the adapter, the sanitizer and the migration notes.
243
278
 
244
279
  ### Host toolbar seams (0.43.0)
245
280
 
@@ -368,17 +403,37 @@ slot and mentions on the edit box (0.45.0)".
368
403
  their own (the Inbox sorts a video's comments by the moment they are at).
369
404
  Ties keep creation order; replies still thread under their parent. Omit it
370
405
  and the panel sorts by creation exactly as before.
371
- - **`title?`, `titleNote?`, `cardParts?: (annotation) => { quote?, images? }`**
372
- (ui 0.53.0) — the header's title (default "Annotations") and a quiet note
373
- at its right, and per card whether the built-in quote and image row are
374
- drawn (the Inbox's video comments draw the moment and the frame in
375
- `renderCardHeader` instead). Omit them and the panel is as before.
406
+ - **`title?`, `titleNote?`, `cardParts?: (annotation) => { quote?, images?, text? }`**
407
+ (ui 0.53.0; `text` ui 0.54.0) — the header's title (default "Annotations")
408
+ and a quiet note at its right, and per card whether the built-in quote,
409
+ image row and comment text are drawn (the Inbox's video comments draw the
410
+ moment and the frame in `renderCardHeader` instead). `text: false` treats
411
+ the stored text as the host's stand-in, not the person's words: it is not
412
+ drawn and the card's edit box starts empty (the Inbox's drawing with no
413
+ words, whose text "See the marked-up image." is for the agent). Omit them
414
+ and the panel is as before.
415
+ - **`renderCardBody?: (annotation) => ReactNode`** (ui 0.54.0) — the host's
416
+ own body for a plan-annotation card, drawn where the comment text goes,
417
+ inside the card (a click on it selects the card). The Inbox draws a
418
+ drawing with no words as its picture. Omit it and nothing extra is drawn.
376
419
  - **Export: `exportLocation`** (ui 0.53.0) — a string a host sets on the
377
420
  COPY of an annotation it hands the exporters (`exportAnnotations`,
378
421
  `exportLinkedDocAnnotations`): one line printed under the entry's heading
379
422
  (the Inbox's `At 1:42 (102.4 s of 7:41)`). Never stored; an annotation
380
423
  without it exports byte-identically.
381
424
 
425
+ ### Image annotator seams (`components/ImageAnnotator`; ui 0.54.0)
426
+
427
+ - **`escapeCancels?: boolean`** — Escape closes the drawing screen and accepts
428
+ nothing; Enter and the ✓ accept; a click outside closes only an empty
429
+ drawing (strokes are never lost to a stray click); the ✓'s title reads
430
+ "Save (Enter)". The Inbox passes it (the owner's rule, 2026-10-26). Omit it
431
+ and Escape, Enter and a click outside all accept, under "Save (Esc)", as
432
+ plan review has always done.
433
+ - **`hint?: ReactNode`** — the line under the image in place of "Press Esc or
434
+ Enter or click outside to accept" (the Inbox: "Enter or ✓ adds to your
435
+ reply · Esc cancels"). Omit it and the line is as before.
436
+
382
437
  ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
383
438
 
384
439
  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"`.
@@ -387,7 +442,7 @@ The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.m
387
442
  - `useToolset({ id, active, build, deps, hooks })` attaches a named tool set to the document registry; handlers read through refs, so re-renders never re-register, and `active: false` aborts every registration (what Plannotator's Settings opt-out drives).
388
443
  - `AnnotationChangeTracker` / `buildNudges` are pure (no DOM): per-annotation `seq`, tombstones, a per-tab watermark with `since` override, and the nudge vocabulary every response carries.
389
444
  - A host with its own document state builds the same adapter-driven catalog Plannotator uses (`packages/editor/webmcp/documentTools.ts`, `buildDocumentTools(adapter, state, options)`) over its own getters and actions; multi-document pages should register one set whose tools take `path` (the folder-session shape) rather than one set per viewer (duplicate names across sets are skipped with a warning, never replaced).
390
- - Never register tools inside an untrusted iframe: the raw-HTML viewer's `sandbox="allow-scripts"` frame and the live-app frame carry no `allow="tools"`, and that is what keeps a framed page from impersonating the host's tools.
445
+ - Never register tools inside an untrusted iframe: the raw-HTML viewer's `sandbox="allow-scripts allow-modals"` frame and the live-app frame carry no `allow="tools"`, and that is what keeps a framed page from impersonating the host's tools.
391
446
 
392
447
  The one additive data-model change that rides with it: `Annotation.inReplyTo` (threaded replies; the panel indents them under the parent, the export nests them, share links drop them).
393
448
 
@@ -459,7 +514,53 @@ import { DiffFileTree } from "@plannotator/ui/components/DiffFileTree";
459
514
  - `role="tree"` with `treeitem` rows (`aria-level`, `aria-expanded`, `aria-selected`) and one tab stop. While focus is in the tree: ArrowDown/`j`, ArrowUp/`k`, Home, End select files in tree order (the review's keys, opening folders as needed); ArrowRight/ArrowLeft open/close a focused folder, ArrowLeft from a file focuses its folder. Unlike the review, nothing listens on `window`.
460
515
  - Styling is the `file-tree-item` rules in `styles.css` plus ThemeProvider tokens; no fetches, no seams.
461
516
 
462
- For a custom row, use the atoms (`DiffFileTreeFolderRow`, `DiffFileTreeFileRowContent`, `ChangeTypeLetter`, `DiffCounts`, `diffFileTreeIndent`) with `buildDiffFileTree` and `useDiffFileTreeExpansion`, as the review does. See HANDOFF.md § "Diff file tree".
517
+ For a custom row, use the atoms (`DiffFileTreeFolderRow`, `DiffFileTreeFileRowContent`, `ChangeTypeLetter`, `DiffCounts`, `diffFileTreeIndent`) with `buildDiffFileTree` and `useDiffFileTreeExpansion`. Plannotator's review shares the tree builder, the expansion hook and `ChangeTypeLetter`, but since its aligned sidebar it draws its own folder and file rows, so it is no longer an example of these atoms. See HANDOFF.md § "Diff file tree".
518
+
519
+ ### Document contents rail (`components/MarkdownContentsRail`; unreleased)
520
+
521
+ Workspaces' contents rail, moved here (approved 2026-10-03 in Workspaces; the move approved 2026-10-30) so Workspaces and the Plannotator Inbox draw the same one. Plan review does not use it.
522
+
523
+ ```tsx
524
+ import { DeferredMarkdownContentsRail } from "@plannotator/ui/components/DeferredMarkdownContentsRail";
525
+
526
+ // In the document pane, beside the scroll element; it positions itself (fixed) from that element.
527
+ <DeferredMarkdownContentsRail markdown={markdown} scrollViewport={scrollEl} />
528
+ ```
529
+
530
+ ```css
531
+ /* Its own stylesheet: import it OUTSIDE any cascade layer, after the host's base reset. */
532
+ @import "@plannotator/ui/contents-rail.css";
533
+ ```
534
+
535
+ - **Shape.** A 36 px dock of tick marks 24 px right of the reading column, vertically centred on the scroll element; it never changes the document's width. At most 10 ticks: a longer document is condensed into 10 spans, each shown by its shallowest heading nearest the span's middle. The current tick has `aria-current="location"`. A tick (or ↑ ↓ ← → Home End, Enter, while a tick has focus) opens a 256 px Contents panel to the LEFT of the dock, over the prose: every heading of levels 1 to 4, its breadcrumb its accessible name (`buildMarkdownContentsModel` also gives each heading a one-line excerpt, the first text after it cut at 180 characters). A pick is marked at once, smooth-scrolls the heading to the 80 px activation line (instantly under reduced motion) and flashes it. Escape on a tick closes the panel; Escape anywhere else closes a panel opened by pointer too (focus stays where it was), except while focus is in an editable control, a dialog, a menu or a listbox, whose Escape it is. It is `preventDefault`-ed and never stopped, so a host's own Escape handler sees it was used. An outside press, or focus leaving the panel, closes it too. No hover reveal, no left/right setting.
536
+ - **When it shows.** At least 4 headings of levels 1 to 4 (`hasContentsRailHeadings` in `utils/annotationHelpers`, over `tocHeadings(blocks, 4)`; a host can ask without loading the rail), a scroll element at least 480 px wide, and 68 px free between the reading column's right edge and the scroll element's right edge (its scrollbar excluded): `canShowContentsRail`, `resolveContentsRailGeometry`. A docked column outside the scroll element (comments) therefore makes it yield, never overlap.
537
+ - **Contract with the page.** Positions come from the rendered headings (`[data-block-type="heading"][data-block-id]`, `BlockRenderer`'s); only the rail's own heading ids are read, so an embedded document's headings (namespaced ids) never move the mark. The reading column is `[data-document-read-content]` inside the scroll element unless `contentSelector` names another (the Inbox passes the Viewer's `[data-doc-text-root]`). Any change to the scroll element's DOM re-binds the headings (a host that remounts its reading column, the same headings at new places, is followed).
538
+ - **Which heading is current.** The rail's own scroll math, moved with it into `utils/headingScrollSpy`: each heading's top is cached when the rail measures; every scroll frame a binary search finds the last heading at or above the activation line (`CONTENTS_ACTIVATION_OFFSET`, 80 px below the scroll element's top; Workspaces' was 52), and at the document's end the last heading is current. It needs no band, so a short pane, a jump (scrollbar drag, scroll restore, Home) and scrolling up all mark the right heading.
539
+ - **Loading.** `DeferredMarkdownContentsRail` mounts it after the page is interactive (`requestIdleCallback`, 2 s cap; a zero-delay timer where that is missing) from a lazy chunk.
540
+ - **Styles.** `pn-contents-rail-*` rules in their own stylesheet, `@plannotator/ui/contents-rail.css` (not in `theme.css` or `styles.css`). Import it outside any cascade layer: a later unlayered base reset beats layered rules and puts the ticks over the prose. Plain CSS on the host's tokens, each with a fallback: `--popover`, `--popover-foreground`, `--shadow-menu`, `--geometry-floating-radius`, `--geometry-structure-radius`, `--surface-hover`, `--motion-duration-fast|basic|relaxed`, `--ease-enter|exit`, `--primary`, `--foreground`, `--muted-foreground`. The labels inherit the font size.
541
+ - **Optional host props** (defaults reproduce Workspaces' rail): `blocks` (the host's parsed blocks instead of `markdown`; otherwise one cached parse per body, `utils/blockParse` `parseBlocksShared`), `contentSelector`, `hidden` (a host's own hide choice: draws nothing, still measures), `onAvailableChange(fits)` (whether it can show, after each measure, so a host can offer another way to Contents where it yields), `panelAction` (one control at the right of the open panel's title; the Inbox's Hide).
542
+ - Also moved: `hooks/usePrefersReducedMotion`.
543
+
544
+ ### Scroll-spies: one per surface
545
+
546
+ Two, both in the package, never two on one surface:
547
+
548
+ - **Plan review** keeps `hooks/useActiveSection`, unchanged: an IntersectionObserver band from 80 px below the scroll element's top to 20% of its height. `useActiveSection.positions.test.tsx` pins its answers at scroll positions that tell 80 from 52 or 100 px and 20% from 30%.
549
+ - **The contents rail's surface** (the rail, and `DocumentContents` where the rail yields) uses the rail's scroll math, `utils/headingScrollSpy` (above); `hooks/useHeadingScrollSpy(scrollViewport, blockIds)` is it for a list. Both scroll a pick to 80 px, so a pick lands where plan review's does.
550
+
551
+ ### Document contents (`components/DocumentContents`; unreleased)
552
+
553
+ A Contents list on the contents rail's surface, for where the rail yields: `TableOfContents` bound to one scroll element, with the rail's headings (levels 1 to 4) and the rail's scroll math (`useHeadingScrollSpy`) marking the heading being read, so the list and the rail never disagree. It reads the current heading at once on mount (a sheet opened mid-document). It draws no chrome; the host titles and places it. It keeps the marked entry in view inside the list's own scroll box.
554
+
555
+ ```tsx
556
+ import { DocumentContents } from "@plannotator/ui/components/DocumentContents";
557
+
558
+ <DocumentContents blocks={blocks} annotations={annotations} scrollViewport={scrollEl} onNavigate={closeSheet} />
559
+ ```
560
+
561
+ - The headings come from `tocHeadings(blocks, 4)` (`utils/annotationHelpers`), the function `buildTocHierarchy`, plan review's "has entries" check (levels 1 to 3, the default) and the contents rail use.
562
+ - `TableOfContents` takes two optional seams: `scrollViewport`, the element a click scrolls (default: the `ScrollViewportProvider` context, as before), and `maxLevel` (default 3). Plan review passes neither and is unchanged.
563
+ - Used by the Inbox's sheet (a phone, or where the rail yields) and the phone surface; plan review keeps `SidebarContainer`.
463
564
 
464
565
  ## Consuming it (e.g. from Workspaces)
465
566
 
@@ -479,12 +580,14 @@ npm install @plannotator/ui @plannotator/core
479
580
  4. Import components: `import { Viewer } from "@plannotator/ui/components/Viewer";`
480
581
  5. Build with a bundler that compiles TS/TSX (Vite + React 19 + Tailwind v4). The packages ship **source**, so your bundler compiles them — set `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `jsx: "react-jsx"`.
481
582
 
583
+ **Upgrade note: the plan look defaults to Clean.** `gridEnabled` (storage key `plannotator-grid-enabled`) now defaults to `false`, and a stored value counts only with the v2 marker `plannotator-plan-look-v2 = 'resolved'`. Plannotator's chooser and its Settings switch write it. On the first settings read, a v1 marker (`plannotator-plan-look-choice-resolved = 'true'`, or the legacy `plannotator-look-feel-announcement-seen = '2'`) next to a stored `'true'` is moved to Clean once (the stored value is rewritten to `'false'` and the v2 key set to `'notice'`); a v1 marker with Clean becomes `'resolved'` silently. Plannotator's apps then show a one-time notice. That includes the share portal's root and demo pages; only a shared session puts it off. A host that renders no chooser (Workspaces) shows nothing, so its users simply open in Clean, and every user with no marker opens in Clean too. To keep stored values instead, call `markLookAndFeelChoiceResolved()` from `@plannotator/ui/utils/lookAndFeelAnnouncement` before the first settings read, or seed `plannotator-plan-look-v2 = 'resolved'` in your `storageBackend`. A host that writes `gridEnabled` from its own UI should call `markLookAndFeelChoiceResolved()` with it, or the value will not survive a reload.
584
+
482
585
  ## Packages & publishing
483
586
 
484
587
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
485
588
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
486
589
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
487
- - Currently `@plannotator/ui` 0.48.0 depends exactly on `@plannotator/core` 0.25.8. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
590
+ - Currently `@plannotator/ui` 0.55.0 depends exactly on `@plannotator/core` 0.25.14. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. `bun run check:sibling-deps @plannotator/ui` (from the repo root; it reads npm) fails while the pinned core isn't on npm. See HANDOFF.md "Publishing & versioning" for the verification command.
488
591
 
489
592
  ## The one rule
490
593
 
@@ -193,7 +193,7 @@ export function AgentToolAnnouncementDialog({
193
193
  </p>
194
194
  </div>
195
195
  <p className="mt-3 text-pretty text-xs leading-relaxed text-muted-foreground" data-agent-tool-cost>
196
- Adds about 780 tokens to each request.{save.kind === 'saved' ? '' : ` ${startsLine(host)}`}
196
+ Adds about 800 tokens to each request.{save.kind === 'saved' ? '' : ` ${startsLine(host)}`}
197
197
  </p>
198
198
 
199
199
  <div className="mt-5 flex flex-col gap-3">
@@ -423,7 +423,7 @@ function JobCard({
423
423
  e.stopPropagation();
424
424
  onKill();
425
425
  }}
426
- className="absolute top-1.5 right-1.5 rounded p-1 text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
426
+ className="absolute top-1.5 right-1.5 rounded p-1 text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100"
427
427
  title="Kill agent"
428
428
  >
429
429
  <X size={12} />
@@ -1,8 +1,9 @@
1
- import React, { useState, useRef, useEffect, useId } from 'react';
1
+ import React, { createContext, useContext, useMemo, useState, useRef, useEffect, useId } from 'react';
2
2
  import { AnnotationType, type Annotation, type Block, type CodeAnnotation, type EditorAnnotation } from '../types';
3
3
  import type { MentionSource } from '../utils/mentions';
4
4
  import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
5
5
  import { MentionAutocompleteMenu, mentionActiveOptionId } from './MentionAutocomplete';
6
+ import { buildSectionRefIndex, parseSectionRefs } from '../utils/sectionRefs';
6
7
  import { isCurrentUser } from '../utils/identity';
7
8
  import { ImageThumbnail } from './ImageThumbnail';
8
9
  import { EditorAnnotationCard } from './EditorAnnotationCard';
@@ -16,6 +17,15 @@ import type { AnnotationScope, AnnotationDocumentGroup } from '../utils/annotati
16
17
  import type { QuestionPanelRow } from '../utils/questionAnswers';
17
18
  import { QuestionsPanelSection } from './QuestionsPanelSection';
18
19
  import { HostTitle } from './HostTitle';
20
+ import { isCommentSubmitKey } from '../utils/commentSubmitKey';
21
+ import { useConfigValue } from '../config/useConfig';
22
+ import { imageRegionNumbers, RegionCardInfo, RegionNumber, regionOf } from './ImageRegions';
23
+
24
+ /** What a region card (#2100) needs from its panel: each region's number on
25
+ * its image, and how to load the image for the crop thumbnail. */
26
+ const RegionCardContext = createContext<{ numbers: ReadonlyMap<string, number>; thumbSrc?: (src: string) => string | null }>({
27
+ numbers: new Map(),
28
+ });
19
29
 
20
30
  // Card type-word colors. Deletion uses `destructive` (reliably red on every
21
31
  // theme, matching the in-document .deletion highlight). Comment uses the
@@ -120,6 +130,8 @@ interface PanelProps {
120
130
  editorAnnotations?: EditorAnnotation[];
121
131
  onDeleteEditorAnnotation?: (id: string) => void;
122
132
  onClose?: () => void;
133
+ /** Navigate the document to a heading anchor, for `#Heading` references in comment text. */
134
+ onNavigateAnchor?: (hash: string) => void;
123
135
  /** Copy the full feedback payload. May resolve a success boolean; resolving
124
136
  * `false` suppresses the "Copied" flash. A void resolution (existing hosts)
125
137
  * is treated as success, preserving the original behavior. */
@@ -213,13 +225,30 @@ interface PanelProps {
213
225
  * the frame in `renderCardHeader`) turns them off per card. Absent: both
214
226
  * drawn, exactly as before.
215
227
  */
216
- cardParts?: (annotation: Annotation) => { quote?: boolean; images?: boolean };
228
+ cardParts?: (annotation: Annotation) => { quote?: boolean; images?: boolean; text?: boolean };
229
+ /**
230
+ * Optional: the host's own body for a plan-annotation card, drawn where the
231
+ * comment text goes, inside the card (a click on it selects the card). The
232
+ * Inbox draws a drawing that has no words as its picture, with `cardParts`
233
+ * `text: false` (its stored text is a stand-in for the agent, not the
234
+ * person's words: not drawn, and the edit box starts empty). Absent:
235
+ * nothing extra, exactly as before.
236
+ */
237
+ renderCardBody?: (annotation: Annotation) => React.ReactNode;
238
+ /**
239
+ * Comments on part of an image (`imageRegion`, #2100) draw a crop of their
240
+ * box in place of the quote; this turns an image's src as written into a
241
+ * URL the browser can load (null: no crop, the file and size still show).
242
+ * Absent: no crop.
243
+ */
244
+ imageRegionSrc?: (src: string) => string | null;
217
245
  }
218
246
 
219
247
  export const AnnotationPanel: React.FC<PanelProps> = ({
220
248
  emptyHint = 'Select text to annotate',
221
249
  isOpen,
222
250
  annotations,
251
+ blocks,
223
252
  onSelect,
224
253
  onDelete,
225
254
  onEdit,
@@ -256,7 +285,19 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
256
285
  title = 'Annotations',
257
286
  titleNote,
258
287
  cardParts,
288
+ renderCardBody,
289
+ imageRegionSrc,
290
+ onNavigateAnchor,
259
291
  }) => {
292
+ // `#Heading` links in comment text; nothing is parsed unless the host can navigate.
293
+ const sectionRefs = useMemo(
294
+ () => (onNavigateAnchor ? buildSectionRefIndex(blocks) : undefined),
295
+ [blocks, onNavigateAnchor],
296
+ );
297
+ const regionCards = useMemo(
298
+ () => ({ numbers: imageRegionNumbers(annotations), thumbSrc: imageRegionSrc }),
299
+ [annotations, imageRegionSrc],
300
+ );
260
301
  const isMobile = useIsMobile();
261
302
  const embedded = presentation === 'embedded';
262
303
  const mobilePanel = isMobile && !embedded;
@@ -517,8 +558,11 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
517
558
  footer={group.isCurrent ? renderCardFooter?.(annotation) : undefined}
518
559
  header={group.isCurrent ? renderCardHeader?.(annotation) : undefined}
519
560
  parts={group.isCurrent ? cardParts?.(annotation) : undefined}
561
+ body={group.isCurrent ? renderCardBody?.(annotation) : undefined}
520
562
  mentionSource={mentionSource}
521
563
  unanchored={group.isCurrent ? (unanchoredIds?.has(annotation.id) ?? false) : false}
564
+ sectionRefs={group.isCurrent ? sectionRefs : undefined}
565
+ onNavigateAnchor={onNavigateAnchor}
522
566
  />
523
567
  );
524
568
  return isReply ? (
@@ -623,6 +667,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
623
667
  >
624
668
  <AnnotationCard
625
669
  annotation={entry.annotation}
670
+ sectionRefs={sectionRefs}
671
+ onNavigateAnchor={onNavigateAnchor}
626
672
  isSelected={selectedId === entry.annotation.id}
627
673
  isMe={isCurrentUser(entry.annotation.author)}
628
674
  onSelect={() => onSelect(entry.annotation.id)}
@@ -632,6 +678,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
632
678
  footer={renderCardFooter?.(entry.annotation)}
633
679
  header={renderCardHeader?.(entry.annotation)}
634
680
  parts={cardParts?.(entry.annotation)}
681
+ body={renderCardBody?.(entry.annotation)}
635
682
  mentionSource={mentionSource}
636
683
  unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
637
684
  />
@@ -640,6 +687,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
640
687
  <AnnotationCard
641
688
  key={entry.annotation.id}
642
689
  annotation={entry.annotation}
690
+ sectionRefs={sectionRefs}
691
+ onNavigateAnchor={onNavigateAnchor}
643
692
  isSelected={selectedId === entry.annotation.id}
644
693
  isMe={isCurrentUser(entry.annotation.author)}
645
694
  onSelect={() => onSelect(entry.annotation.id)}
@@ -649,6 +698,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
649
698
  footer={renderCardFooter?.(entry.annotation)}
650
699
  header={renderCardHeader?.(entry.annotation)}
651
700
  parts={cardParts?.(entry.annotation)}
701
+ body={renderCardBody?.(entry.annotation)}
652
702
  mentionSource={mentionSource}
653
703
  unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
654
704
  />
@@ -691,7 +741,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
691
741
  </OverlayScrollArea>
692
742
 
693
743
  {/* Quick Actions Footer */}
694
- {headerCount > 0 && (
744
+ {headerCount > 0 && (onQuickCopy || (sharingEnabled && onShare)) && (
695
745
  <div className="border-t border-border/50 px-3 py-2 flex gap-1.5">
696
746
  {onQuickCopy && (
697
747
  <button
@@ -745,12 +795,12 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
745
795
  className="fixed inset-0 z-[59] bg-background/60 backdrop-blur-sm"
746
796
  onClick={onClose}
747
797
  />
748
- {panel}
798
+ <RegionCardContext.Provider value={regionCards}>{panel}</RegionCardContext.Provider>
749
799
  </>
750
800
  );
751
801
  }
752
802
 
753
- return panel;
803
+ return <RegionCardContext.Provider value={regionCards}>{panel}</RegionCardContext.Provider>;
754
804
  };
755
805
 
756
806
  function formatTimestamp(ts: number): string {
@@ -899,6 +949,44 @@ const DirectEditsCard: React.FC<{
899
949
  );
900
950
  };
901
951
 
952
+ /** Comment text, with each `#Heading text` as a link that scrolls to that heading. */
953
+ const AnnotationText: React.FC<{
954
+ text: string;
955
+ className: string;
956
+ sectionRefs?: Map<string, string>;
957
+ onNavigateAnchor?: (hash: string) => void;
958
+ }> = ({ text, className, sectionRefs, onNavigateAnchor }) => {
959
+ const parts = React.useMemo(
960
+ () => (sectionRefs && onNavigateAnchor ? parseSectionRefs(text, sectionRefs) : [text]),
961
+ [text, sectionRefs, onNavigateAnchor],
962
+ );
963
+
964
+ return (
965
+ <p className={className}>
966
+ {parts.map((part, i) =>
967
+ typeof part === 'string' ? (
968
+ part
969
+ ) : (
970
+ <a
971
+ key={i}
972
+ href={`#${part.anchor}`}
973
+ className="rounded-sm font-medium text-primary underline-offset-2 hover:underline focus-visible:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
974
+ onClick={(e) => {
975
+ // The host scrolls; the card's own onSelect would scroll to the
976
+ // annotation instead, and the URL hash stays as it was.
977
+ e.preventDefault();
978
+ e.stopPropagation();
979
+ onNavigateAnchor!(`#${part.anchor}`);
980
+ }}
981
+ >
982
+ {part.label}
983
+ </a>
984
+ ),
985
+ )}
986
+ </p>
987
+ );
988
+ };
989
+
902
990
  /**
903
991
  * The plan-annotation card's edit box.
904
992
  *
@@ -929,6 +1017,7 @@ const AnnotationEditComposer: React.FC<{
929
1017
  const mention = useMentionAutocomplete({ text: value, setText: onChange, textareaRef, source: mentionSource });
930
1018
  const listboxId = `annotation-card-mentions-${useId().replace(/:/g, '')}`;
931
1019
  const menuOpen = mention.menu !== null;
1020
+ const enterSavesComment = useConfigValue('enterSavesComment');
932
1021
 
933
1022
  // Focus-on-open, unchanged: this component mounts exactly when editing starts.
934
1023
  useEffect(() => {
@@ -943,7 +1032,7 @@ const AnnotationEditComposer: React.FC<{
943
1032
  // (and only a second Escape cancels the edit), and an arrowed-to row
944
1033
  // takes Enter. Mod+Enter is never consumed by the menu.
945
1034
  if (mention.onKeyDown(e)) return;
946
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
1035
+ if (isCommentSubmitKey(e, enterSavesComment)) {
947
1036
  e.preventDefault();
948
1037
  onSave(mention.mentionIds);
949
1038
  } else if (e.key === 'Escape') {
@@ -996,21 +1085,32 @@ const AnnotationCard: React.FC<{
996
1085
  /** The annotation has no live location in the document (host-reported). */
997
1086
  unanchored?: boolean;
998
1087
  /** Built-in parts to draw (see `cardParts`). */
999
- parts?: { quote?: boolean; images?: boolean };
1000
- }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false, parts }) => {
1088
+ parts?: { quote?: boolean; images?: boolean; text?: boolean };
1089
+ /** Host body, drawn where the comment text goes (see `renderCardBody`). */
1090
+ body?: React.ReactNode;
1091
+ sectionRefs?: Map<string, string>;
1092
+ onNavigateAnchor?: (hash: string) => void;
1093
+ }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false, parts, body, sectionRefs, onNavigateAnchor }) => {
1094
+ // `text: false`: the stored text is the host's stand-in, never the person's words; the edit box starts empty.
1095
+ const shownText = parts?.text === false ? '' : annotation.text || '';
1001
1096
  const [isEditing, setIsEditing] = useState(false);
1002
- const [editText, setEditText] = useState(annotation.text || '');
1097
+ const [editText, setEditText] = useState(shownText);
1098
+ // A comment on part of an image (#2100): numbered like its box, with the
1099
+ // crop, file, size and line in place of the quote.
1100
+ const regionCards = useContext(RegionCardContext);
1101
+ const region = regionOf(annotation);
1102
+ const regionNumber = region ? regionCards.numbers.get(annotation.id) : undefined;
1003
1103
 
1004
1104
  // Update editText when annotation.text changes
1005
1105
  useEffect(() => {
1006
1106
  if (!isEditing) {
1007
- setEditText(annotation.text || '');
1107
+ setEditText(shownText);
1008
1108
  }
1009
- }, [annotation.text, isEditing]);
1109
+ }, [shownText, isEditing]);
1010
1110
 
1011
1111
  const handleStartEdit = (e: React.MouseEvent) => {
1012
1112
  e.stopPropagation();
1013
- setEditText(annotation.text || '');
1113
+ setEditText(shownText);
1014
1114
  setIsEditing(true);
1015
1115
  };
1016
1116
 
@@ -1027,7 +1127,7 @@ const AnnotationCard: React.FC<{
1027
1127
  };
1028
1128
 
1029
1129
  const handleCancelEdit = () => {
1030
- setEditText(annotation.text || '');
1130
+ setEditText(shownText);
1031
1131
  setIsEditing(false);
1032
1132
  };
1033
1133
 
@@ -1066,6 +1166,12 @@ const AnnotationCard: React.FC<{
1066
1166
  {/* Header: type word + author · time + actions */}
1067
1167
  <div className="mb-1.5 flex items-center gap-1.5">
1068
1168
  <span className={cn('text-[11px] font-medium', typeColor)}>{typeLabel}</span>
1169
+ {region && regionNumber !== undefined && <RegionNumber n={regionNumber} selected={isSelected} />}
1170
+ {region && (
1171
+ <span data-annotation-region="true" className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground">
1172
+ Region
1173
+ </span>
1174
+ )}
1069
1175
  {annotation.diffContext && (
1070
1176
  <span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground">
1071
1177
  diff
@@ -1100,7 +1206,7 @@ const AnnotationCard: React.FC<{
1100
1206
  </div>
1101
1207
  )}
1102
1208
  {!readOnly && (
1103
- <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
1209
+ <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
1104
1210
  {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
1105
1211
  <HostTitle title="Edit annotation" name>
1106
1212
  <button
@@ -1130,14 +1236,21 @@ const AnnotationCard: React.FC<{
1130
1236
  isEditing ? (
1131
1237
  editComposer
1132
1238
  ) : (
1133
- <p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
1134
- {annotation.text}
1135
- </p>
1239
+ <AnnotationText
1240
+ text={annotation.text || ''}
1241
+ className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90"
1242
+ sectionRefs={sectionRefs}
1243
+ onNavigateAnchor={onNavigateAnchor}
1244
+ />
1136
1245
  )
1137
1246
  ) : (
1138
1247
  <>
1248
+ {/* A region shows its crop, file, size and line instead of a quote. */}
1249
+ {region && parts?.quote !== false && (
1250
+ <RegionCardInfo region={region} thumbSrc={regionCards.thumbSrc?.(region.src) ?? null} />
1251
+ )}
1139
1252
  {/* Quote — the annotated text */}
1140
- {parts?.quote !== false && (
1253
+ {!region && parts?.quote !== false && (
1141
1254
  <p className="mb-1.5 line-clamp-2 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1142
1255
  "{annotation.originalText}"
1143
1256
  </p>
@@ -1148,13 +1261,17 @@ const AnnotationCard: React.FC<{
1148
1261
  isEditing ? (
1149
1262
  editComposer
1150
1263
  ) : (
1151
- annotation.text && (
1152
- <p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
1153
- {annotation.text}
1154
- </p>
1264
+ shownText && (
1265
+ <AnnotationText
1266
+ text={shownText}
1267
+ className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90"
1268
+ sectionRefs={sectionRefs}
1269
+ onNavigateAnchor={onNavigateAnchor}
1270
+ />
1155
1271
  )
1156
1272
  )
1157
1273
  )}
1274
+ {body != null && body !== false && body}
1158
1275
  </>
1159
1276
  )}
1160
1277
 
@@ -1203,6 +1320,7 @@ const CodeAnnotationCard: React.FC<{
1203
1320
  }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false }) => {
1204
1321
  const [isEditing, setIsEditing] = useState(false);
1205
1322
  const [editText, setEditText] = useState(annotation.text || '');
1323
+ const enterSavesComment = useConfigValue('enterSavesComment');
1206
1324
  const textareaRef = useRef<HTMLTextAreaElement>(null);
1207
1325
 
1208
1326
  useEffect(() => {
@@ -1253,7 +1371,7 @@ const CodeAnnotationCard: React.FC<{
1253
1371
  <span className="text-[11px] font-medium text-primary">Code</span>
1254
1372
  <CardByline author={annotation.author} isMe={isMe} timestamp={annotation.createdAt} />
1255
1373
  {!readOnly && (
1256
- <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
1374
+ <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
1257
1375
  {onEdit && !isEditing && (
1258
1376
  <HostTitle title="Edit annotation" name>
1259
1377
  <button
@@ -1299,7 +1417,7 @@ const CodeAnnotationCard: React.FC<{
1299
1417
  value={editText}
1300
1418
  onChange={(e) => setEditText(e.target.value)}
1301
1419
  onKeyDown={(e) => {
1302
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
1420
+ if (isCommentSubmitKey(e, enterSavesComment)) {
1303
1421
  e.preventDefault();
1304
1422
  handleSaveEdit();
1305
1423
  } else if (e.key === 'Escape') {
@@ -61,7 +61,7 @@ export const AnnotationSidebar: React.FC<SidebarProps> = ({
61
61
  </span>
62
62
  <button
63
63
  onClick={(e) => { e.stopPropagation(); onDelete(ann.id); }}
64
- className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all p-1 rounded hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
64
+ className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 text-muted-foreground hover:text-destructive transition-all p-1 rounded hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
65
65
  title="Remove Annotation"
66
66
  >
67
67
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>