@plannotator/ui 0.41.2 → 0.43.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.
package/HANDOFF.md CHANGED
@@ -213,6 +213,8 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
213
213
  | `components/HtmlSurfaceControls` | The eye / refresh / pen header controls for an HTML surface, with per-string `labels` overrides. Presentation only. See "HTML annotation parity seams". |
214
214
  | `hooks/useHtmlRefresh` | Re-fetch a rendered HTML document through a host-supplied `fetchSnapshot`, remount the viewer on a reload generation, acknowledge the restore report once. See "HTML annotation parity seams". |
215
215
  | `shortcuts` (`useHtmlAnnotateShortcuts`, `defineShortcutScope`, the scope registry) | The declarative keyboard-shortcut engine and the per-surface scopes, including the HTML annotate scope (Mod+Shift+A toggles annotate mode, Mod+Shift+X shows/hides the tools). Pure: React plus `utils/platform`; no backend. |
216
+ | `utils/selectionActions` + `components/SelectionActionsDropdown` | The host selection-actions seam: `SelectionAction`, `SelectionActionContext`, the pure `buildSelectionActionContext`, and the dropdown `AnnotationToolbar` opens. Pure React; no backend. *(Blessed in 0.43.0.)* |
217
+ | `utils/mentions` + `components/MentionPicker` + `hooks/useMentionAutocomplete` | The `@` mention seam behind `CommentPopover`'s `mentionSource`: the pure grammar (`mentionTrigger`, `mentionMatches`, `applyMentionPick`, `survivingMentions`), the portaled picker, and the keyboard state machine. Pure React; no backend. *(Blessed in 0.43.0.)* |
216
218
  | `utils/inputMethod` (`getInputMethod`, `saveInputMethod`, `refreshInputMethodStamp`) | The per-surface pinpoint/drag input-method preference with its TTL. Persists through the `storageBackend` seam; no backend of its own. |
217
219
  | `utils/codeHighlight` / `utils/codeBlockMark` / `utils/syntaxTheme` | The Shiki-based fence highlighter, swap-surviving annotation marks, and palette→Shiki theme mapping. Replaces all `.hljs` styling. *(Blessed in 0.29.0.)* |
218
220
  | `utils/math` (`loadMathRenderer`, `getMathRenderer`, `getMathRendererSource`, `setMathRenderer`, `setMathRendererLoader`, `getMathRendererLoader`, `resetMathRenderer`) and `utils/math-eager` | The math renderer slot and its eager KaTeX registration. Import `utils/math-eager` for synchronous typesetting on the first commit; call `loadMathRenderer()` to pre-warm the lazy path. `resetMathRenderer()` empties the slot and keeps the registered loader; `setMathRendererLoader(null)` drops it. See "Lazy renderers and eager entries". |
@@ -873,8 +875,193 @@ to fall back on:
873
875
 
874
876
  ---
875
877
 
878
+ ## Host toolbar seams (0.43.0)
879
+
880
+ Two additive props, ruled in together by Plannotator's owner **on one
881
+ condition: each is an opt-in host capability that changes nothing for
882
+ Plannotator's own users when it is not supplied.** Plannotator is the provider
883
+ of these capabilities; it is not a product that uses them. It passes neither
884
+ prop anywhere, and `packages/editor` / `packages/review-editor` are untouched
885
+ by this release. Core is UNCHANGED at `0.25.5`, so **ui 0.43.0 publishes
886
+ alone**.
887
+
888
+ ### 1. `AnnotationToolbar` `selectionActions` — the host's own commands on a selection
889
+
890
+ ```ts
891
+ import type { SelectionAction, SelectionActionContext } from "@plannotator/ui/types";
892
+ // (also @plannotator/ui/utils/selectionActions)
893
+
894
+ interface SelectionActionContext {
895
+ text: string; // the toolbar's copy text, else the element's text
896
+ blockId: string; // the enclosing [data-block-id], '' on raw-HTML surfaces
897
+ startOffset: number; // offset of the selection inside that block's text
898
+ endOffset: number; // startOffset + text.length
899
+ element: HTMLElement; // the element the toolbar is anchored to
900
+ }
901
+
902
+ interface SelectionAction {
903
+ id: string;
904
+ label: string;
905
+ detail?: string; // dimmed second line
906
+ icon?: React.ReactNode; // a colored accent bar is drawn when absent
907
+ onSelect(ctx: SelectionActionContext): void;
908
+ }
909
+ ```
910
+
911
+ One toolbar button (a wand, `data-selection-actions`) opens the package's own
912
+ dropdown directly below it, in the quick-label picker's placement and chrome
913
+ (`components/SelectionActionsDropdown`: `SelectionActionsDropdown` is the list,
914
+ `FloatingSelectionActionsPicker` the portaled, viewport-clamped, flip-above
915
+ picker; rows carry `data-selection-action="<id>"` under `role="listbox"`).
916
+ Selecting an item calls `onSelect(ctx)` and closes the toolbar exactly as a
917
+ quick label does — **the package creates no annotation**; what an action means
918
+ is entirely the host's business.
919
+
920
+ - **Keyboard:** ArrowDown / ArrowUp move, Enter invokes, Escape closes the
921
+ dropdown (and only the dropdown — the toolbar stays open). Nothing is
922
+ preselected until the first arrow, the package-wide rule, so a stray Enter
923
+ over an open dropdown never fires a host command. Pointer hover highlights a
924
+ row and a click invokes it directly. While the dropdown is open, the
925
+ toolbar's own type-to-comment and Alt+digit listeners stand down, the same
926
+ way they do for `FloatingQuickLabelPicker`.
927
+ - **Where it sits:** the wand takes the slot the quick-labels Zap occupied and
928
+ the Zap moves one place right (`Copy | Delete | Comment | Actions | Quick
929
+ label | 👍 | Cancel`). Chosen over "actions to the right of the Zap" so that
930
+ ONE geometry rule covers both host configurations: with `quickLabels: false`
931
+ — the expected host setup — the wand sits exactly where the Zap sat, and a
932
+ host that keeps both gets its own actions in the primary slot it opted into.
933
+ - `undefined` or `[]` renders no button at all, and the empty array is pinned
934
+ by a test because "the host has no actions right now" is a real state.
935
+ - **The context is derived, not threaded.** `buildSelectionActionContext`
936
+ (`utils/selectionActions`, pure) walks up from the anchor element for
937
+ `[data-block-id]` and computes the offset by splitting the block's text on
938
+ the selection — deliberately the same arithmetic
939
+ `createAnnotationFromSource` uses, so an action sees the coordinates an
940
+ annotation created from that same selection would carry. On a surface with
941
+ no blocks (raw HTML) it degrades to `blockId: ''` and `startOffset: 0`
942
+ (`endOffset` is then the selection's length; an HTML annotation itself
943
+ stores `0`/`0`). The one deliberate deviation from the annotation path is
944
+ a selection the block does not contain — one spanning two blocks — where
945
+ the annotation path reports `blockText.length` and a host gets `0`.
946
+
947
+ ### 2. `AnnotationToolbar` `quickLabels` — the opt-out switch
948
+
949
+ `quickLabels?: boolean`, default `true`. `false` hides the Zap picker button
950
+ **and** makes the Alt+digit label shortcuts inert on that toolbar (hiding the
951
+ button while leaving the keys live was the obvious bug; a test pins both). It
952
+ does not touch the one-click 👍, which is a separate affordance, and it does
953
+ not clamp editor mode — a host that persists `'quickLabel'` mode still keeps
954
+ that state out of `Viewer`, exactly as with `AnnotationToolstrip`'s
955
+ `hideQuickLabel` (0.35.0).
956
+
957
+ ### 3. `CommentPopover` `mentionSource` — an `@` mention source for the composer
958
+
959
+ ```ts
960
+ import type { MentionPerson, MentionSource } from "@plannotator/ui/types";
961
+ // (also @plannotator/ui/utils/mentions)
962
+
963
+ interface MentionPerson {
964
+ readonly id: string; // opaque host id, reported back verbatim
965
+ readonly kind: "user" | "agent"; // agents are never taggable in a comment
966
+ readonly label: string;
967
+ readonly detail: string | null; // right-aligned hint (an email, "Agent")
968
+ readonly canOpen: boolean; // host access data; see onPickBlocked
969
+ }
970
+
971
+ interface MentionSource {
972
+ readonly people: readonly MentionPerson[];
973
+ readonly emptyNotice?: string | null; // honest-empty row
974
+ readonly onMentionsChange?: (ids: readonly string[]) => void;
975
+ readonly onPickBlocked?: (person: MentionPerson) => void;
976
+ }
977
+ ```
978
+
979
+ This is the shape the host's own reply box already uses (`MentionPerson` is
980
+ copied field for field from its `plannotator/mention-extension.ts`), so the
981
+ host fills `people` from its existing candidate hook and nothing has to be
982
+ mapped. The **package owns the typing rules and the picker**; the host owns
983
+ the people and what a mention means.
984
+
985
+ - **The grammar is ported, not reinvented** (`utils/mentions`, pure, unit
986
+ tested): the same `MENTION_QUERY_RE = /@[\w .-]*$/`, the same word-boundary
987
+ guard that makes `a@b.com` never open a menu, the same users-only /
988
+ not-already-tagged filtering on label OR detail, the same `@Label ` insertion
989
+ with the caret after it, the same `sanitizeMentionLabel`, and the same
990
+ surviving-token rule — deleting a token untags that person, so the body and
991
+ the reported ids can never disagree about who was named.
992
+ - **The picker** (`components/MentionPicker`) is portaled and `position:
993
+ fixed`, measured from the textarea's rect, above it by default and below
994
+ when there is not 196px of headroom — the composer card clips its own box,
995
+ so a menu positioned inside the textarea's wrapper is cut off. `role=
996
+ "listbox"` with `data-mention-picker`, rows `data-mention-option="<id>"`, the
997
+ empty notice `data-mention-empty` (one non-selectable row; with `people: []`
998
+ and no notice the menu simply stays closed).
999
+ - **Keyboard** (`hooks/useMentionAutocomplete`, modelled on
1000
+ `useSkillReferenceAutocomplete` and living in the same textarea beside it —
1001
+ `handleKeyDown` offers the event to the skill hook first, then this one):
1002
+ nothing is preselected, so Enter is a newline and Tab leaves the field until
1003
+ an arrow engages a row; ArrowDown from none lands on the first row, ArrowUp
1004
+ on the last; a mouse pick uses `mousedown` + `preventDefault` so it beats the
1005
+ blur. **One deliberate difference from the `/` and `$` trigger:** the arrows
1006
+ engage this menu even on a bare `@`, and Escape closes it whenever it is
1007
+ visible. `$` and `/` are ordinary prose characters whose menu must yield the
1008
+ arrows back to caret navigation; `@` at a word boundary is an unambiguous tag
1009
+ gesture, and typing `@` then ArrowDown is how the host's own reply box
1010
+ behaves.
1011
+ - **`onPickBlocked` is the no-access rule.** When a `canOpen: false` person is
1012
+ picked AND the host supplied `onPickBlocked`, the handler fires and
1013
+ **nothing is inserted** (the host shows its own no-access dialog). Without
1014
+ the handler such a person inserts like anyone else — the package never
1015
+ renders a disabled row it cannot explain. Both branches are pinned by tests.
1016
+ - **The ids reach the host two ways.** `onMentionsChange(ids)` fires on every
1017
+ text change with the surviving ids, and `onSubmit` gained an optional THIRD
1018
+ argument: `onSubmit(text, images?, mentions?)`. The third argument is passed
1019
+ **only when `mentionSource` is supplied** — without one the call is the
1020
+ two-argument call it has always been (`arguments.length === 2`, pinned).
1021
+ Hosts can use either; `onMentionsChange` alone is enough for a host that
1022
+ keeps its draft state outside the popover.
1023
+ - Nothing is wired to Plannotator data: there is no mention provider in this
1024
+ repo, and `configurePlannotatorUI` gains no seam for one. A host passes the
1025
+ prop where it renders the composer, the `HtmlViewer` / `Viewer` pattern.
1026
+
1027
+ ### Threading points
1028
+
1029
+ - `AnnotationToolbar` (`selectionActions`, `quickLabels`) — the props live here.
1030
+ - `Viewer` forwards both to BOTH of its toolbars (the text-selection toolbar
1031
+ and the code-block hover toolbar).
1032
+ - `HtmlViewer` forwards `selectionActions` to its selection toolbar. It does
1033
+ not take `quickLabels`: that surface is already `commentOnly`, which hides
1034
+ the Zap and the Alt+digit keys outright.
1035
+ - `plan-diff/PlanCleanDiffView` mounts a toolbar too and is deliberately NOT
1036
+ threaded: it is a Plannotator-only surface (the plan-version diff) and is not
1037
+ on the supported-import list. Ask if a host needs it.
1038
+ - `CommentPopover` (`mentionSource`). `Viewer` does NOT forward it: the viewer
1039
+ owns several composers and a per-composer decision belongs to the host that
1040
+ renders them. Ask if you would rather pass it once on `Viewer`.
1041
+
1042
+ ### The no-op guarantee, and how it is pinned
1043
+
1044
+ With neither prop supplied, the rendered DOM of both components is **byte-for-byte
1045
+ what `origin/main` renders**: the same components were mounted on the base commit
1046
+ and on this branch in the same harness and their `outerHTML` diffed to zero
1047
+ (`.annotation-toolbar` + `[data-comment-popover]`, 6376 bytes each, identical).
1048
+ On top of that, committed tests pin the structure rather than a snapshot:
1049
+ the default toolbar's button set and order (`Copy, Delete, Comment, Quick
1050
+ label, Looks good, Cancel`), the absence of `[data-selection-actions]` and of
1051
+ the picker, the exact attribute list on the Zap button, and — for the composer —
1052
+ that typing `@` opens nothing and that submit stays a two-argument call.
1053
+ `useMentionAutocomplete` with no source registers no listener, opens no menu
1054
+ state and returns one frozen empty id array; `AnnotationToolbar` renders no
1055
+ extra element and spreads no extra attributes.
1056
+
1057
+ Tests: `utils/mentions.test.ts` (11, DOM-free),
1058
+ `components/AnnotationToolbar.selectionActions.test.tsx` (8, DOM-gated),
1059
+ `components/CommentPopover.mentionSource.test.tsx` (11, DOM-gated).
1060
+
876
1061
  ## Publishing & versioning
877
1062
 
1063
+ - **ui 0.43.0 (host toolbar seams): `@plannotator/ui` only — `@plannotator/core` is UNCHANGED at `0.25.5`, so this publishes alone** (core 0.25.5 must already be published). Purely additive: `selectionActions` + `quickLabels` on `AnnotationToolbar` (forwarded by `Viewer`; `selectionActions` also by `HtmlViewer`), `mentionSource` on `CommentPopover`, an optional third `mentions` argument on that component's `onSubmit`, and the new supported modules `utils/selectionActions`, `utils/mentions`, `components/SelectionActionsDropdown`, `components/MentionPicker`, `hooks/useMentionAutocomplete`. Nothing is removed and Plannotator passes none of it. See "Host toolbar seams (0.43.0)".
1064
+ - **core 0.25.5 / ui 0.42.0 (diagram FILES, `.mmd`/`.mermaid`/`.dot`/`.gv`): additive on both packages, so the next publish is core-first.** `@plannotator/core/annotatable` gains `DiagramRenderKind`, `diagramRenderKindForPath`, `isDiagramRenderKind` and `annotateDiagramRenderKind`, and its built-in annotatable sets now include the four diagram extensions (`shouldStripFrontmatter` returns false for them — Mermaid's `--- … ---` config block is content — and they can no longer be registered through `markdownExtensions`). `@plannotator/ui` gains `diagramDocumentBlocks(text, kind)` on `utils/parser` (the ONE `code` block a whole-file diagram source renders as), `shareableDocumentMarkdown(markdown, renderAs)` on `utils/sharing`, the `DocumentRenderAs` type on `types` (`'markdown' | 'html' | DiagramRenderKind`, re-exporting core's kind), and an optional `Block.diagramSourceLineOffset` that `DiagramBlock` prefers over `Block.startLine` when resolving a diagram comment's `sourceLine` (unset on every parser-produced fence, so fences are byte-identical). `useLinkedDoc`'s `renderAs`/`setRenderAs`/`LinkedDocLoadData.renderAs` widen from `'markdown' | 'html'` to `DocumentRenderAs` — source-compatible for a host that only ever passes the old two, but a host whose own state is typed `'markdown' | 'html'` must widen its setter. Since core changes, **publish `core` first** and update UI's exact core dependency before packing ui.
878
1065
  - **The current pair is `@plannotator/ui` `0.41.2` on `@plannotator/core` `0.25.4`.** Core is UNCHANGED from 0.41.1, so 0.41.2 publishes alone (`ui` only; core 0.25.4 must already be published). 0.41.2 ships the palette-derived Mermaid node shadow at a default of 70% (`DEFAULT_MERMAID_SHADOW_AMOUNT`) and the Settings → Display "Diagram Shadow" control (0 / 40 / 70 / 100); see "Node shadow (the one thing that is not a colour)" under "Theme-aware Mermaid diagrams (0.40.0)" for the mapping — no API removal, only additive exports (`buildMermaidShadow`, `DEFAULT_MERMAID_SHADOW_AMOUNT`, `utils/diagramShadow`).
879
1066
  - The pair 0.41.1 shipped as was `@plannotator/ui` `0.41.1` on `@plannotator/core` `0.25.4`. Core is UNCHANGED from 0.41.0, so 0.41.1 published alone (`ui` only; core 0.25.4 must already be published). 0.41.1 is two fixes over 0.41.0 with no API change — the diagram engine is loaded lazily by the first diagram fence instead of riding every document read, and a press on the canvas's own controls no longer comments on the part behind them; see "0.41.1 — the engine is lazy, and the controls are not part of the diagram". The 0.41.0 notes below still describe the engine itself.
880
1067
  - **The pair 0.41.0 shipped as was `@plannotator/ui` `0.41.0` on `@plannotator/core` `0.25.4`. Publish `core` 0.25.4 first, then `ui` 0.41.0** (both by hand from `main` after merge; CI never publishes these packages). 0.41.0 is the diagram engine (see "Diagram engine (0.41.0)"): one renderer slot and one canvas behind `MermaidBlock` / `GraphvizBlock`, the `components/diagram` surface, the Graphviz runtime slot with `@viz-js/viz` pinned `3.30.0`, and `Annotation.diagramAnchor`; core 0.25.4 adds the `diagram-anchor` subpath ui imports, so a ui 0.41.0 on a published core 0.25.3 would fail to compile in a consumer.
package/README.md CHANGED
@@ -220,6 +220,42 @@ the in-flow host integration.
220
220
 
221
221
  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.
222
222
 
223
+ ### Host toolbar seams (0.43.0)
224
+
225
+ Two opt-in props for hosts that want their own commands and their own people
226
+ inside the annotation UI. Both default to today's behavior — pass neither and
227
+ the toolbar and the comment composer render byte-for-byte what they rendered
228
+ in 0.42.0 (proven by diffing the mounted `outerHTML` against the base commit).
229
+
230
+ - **`AnnotationToolbar` `selectionActions`** (forwarded by `Viewer` to both its
231
+ toolbars, and by `HtmlViewer` to the HTML selection toolbar): an array of
232
+ `{ id, label, detail?, icon?, onSelect(ctx) }`. They render as ONE wand
233
+ button (`data-selection-actions`) in the slot the quick-labels Zap occupied,
234
+ opening the package's dropdown below it (arrows + Enter + Escape, nothing
235
+ preselected until the first arrow). `onSelect` receives
236
+ `{ text, blockId, startOffset, endOffset, element }` — the same coordinates
237
+ an annotation created from that selection would carry (`blockId: ''` and
238
+ `startOffset: 0` on a surface with no blocks, such as raw HTML) — and the
239
+ toolbar closes. **The package creates no annotation:** what an action does is yours.
240
+ An empty array renders no button.
241
+ - **`AnnotationToolbar` `quickLabels`** (default `true`): `false` hides the Zap
242
+ picker and makes the Alt+digit label shortcuts inert on that toolbar. The
243
+ one-click 👍 is unaffected, and mode state is still yours to clamp.
244
+ - **`CommentPopover` `mentionSource`**: `{ people, emptyNotice?,
245
+ onMentionsChange?, onPickBlocked? }` over `MentionPerson`
246
+ (`{ id, kind, label, detail, canOpen }`). Typing `@` at a word boundary opens
247
+ a portaled picker measured from the textarea; picking inserts the readable
248
+ `@Label ` token, and the ids ride `onMentionsChange(ids)` plus an optional
249
+ third `onSubmit(text, images?, mentions?)` argument that is passed **only**
250
+ when a source is supplied. Deleting a token untags that person. A
251
+ `canOpen: false` person inserts nothing when you supply `onPickBlocked` (your
252
+ no-access dialog) and inserts normally when you do not. Not wired to any
253
+ Plannotator data and not a `configurePlannotatorUI` seam — pass it where you
254
+ render the composer.
255
+
256
+ See HANDOFF.md § "Host toolbar seams (0.43.0)" for the grammar, the keyboard
257
+ rules and the threading points.
258
+
223
259
  ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
224
260
 
225
261
  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"`.
@@ -255,7 +291,7 @@ npm install @plannotator/ui @plannotator/core
255
291
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
256
292
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
257
293
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
258
- - Currently `@plannotator/ui` 0.41.0 depends exactly on `@plannotator/core` 0.25.4. `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.
294
+ - Currently `@plannotator/ui` 0.43.0 depends exactly on `@plannotator/core` 0.25.5. `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.
259
295
 
260
296
  ## The one rule
261
297
 
@@ -6,6 +6,8 @@ import { type QuickLabel, getQuickLabels, THUMBS_UP_LABEL } from "../utils/quick
6
6
  import { copyTextToClipboard } from "../utils/clipboard";
7
7
  import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts";
8
8
  import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
9
+ import { FloatingSelectionActionsPicker } from "./SelectionActionsDropdown";
10
+ import { buildSelectionActionContext, type SelectionAction } from "../utils/selectionActions";
9
11
 
10
12
  type PositionMode = 'center-above' | 'top-right';
11
13
 
@@ -33,6 +35,21 @@ interface AnnotationToolbarProps {
33
35
  * the one label affordance restored to these surfaces. Markdown surfaces
34
36
  * keep the full toolbar. */
35
37
  commentOnly?: boolean;
38
+ /**
39
+ * Opt-in host capability: the host's own commands for this selection,
40
+ * rendered as ONE wand button (`data-selection-actions`) that opens the
41
+ * package's dropdown below it. Selecting an item calls `onSelect` with the
42
+ * selection context and closes the toolbar, exactly as a quick label does;
43
+ * the package creates no annotation — the host decides what an action means.
44
+ * Absent or empty → no button, no dropdown: today's toolbar.
45
+ */
46
+ selectionActions?: SelectionAction[];
47
+ /**
48
+ * Whether the package's own quick labels are offered (default true).
49
+ * `false` hides the Zap picker button and the Alt+digit label shortcuts on
50
+ * this toolbar. The one-click 👍 is a separate affordance and is unaffected.
51
+ */
52
+ quickLabels?: boolean;
36
53
  /** Hide the copy button (set when a keyboard copy handler exists) */
37
54
  hideCopyButton?: boolean;
38
55
  /** Close toolbar when element scrolls out of viewport */
@@ -51,6 +68,8 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
51
68
  onClose,
52
69
  onRequestComment,
53
70
  onQuickLabel,
71
+ selectionActions,
72
+ quickLabels: quickLabelsEnabled = true,
54
73
  copyText,
55
74
  commentOnly = false,
56
75
  hideCopyButton = false,
@@ -62,10 +81,24 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
62
81
  const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
63
82
  const [copied, setCopied] = useState(false);
64
83
  const [showQuickLabels, setShowQuickLabels] = useState(false);
84
+ const [showSelectionActions, setShowSelectionActions] = useState(false);
65
85
  const toolbarRef = useRef<HTMLDivElement>(null);
66
86
  const zapButtonRef = useRef<HTMLButtonElement>(null);
87
+ const actionsButtonRef = useRef<HTMLButtonElement>(null);
67
88
  const quickLabels = useMemo(() => getQuickLabels(), []);
68
89
 
90
+ // A picker counts as open only while the button that opens it is actually
91
+ // rendered. Without this, a host whose `selectionActions` go empty (or who
92
+ // flips `quickLabels` off) while its dropdown is open unmounts the dropdown
93
+ // and its Escape handler, but leaves the flag set — and the flag is what
94
+ // stands the toolbar's own Escape, type-to-comment and outside-dismiss
95
+ // listeners down, wedging the toolbar until the ✕ is clicked. Both flags are
96
+ // false whenever the props are absent, so Plannotator's toolbar is
97
+ // unchanged.
98
+ const hasSelectionActions = !!selectionActions && selectionActions.length > 0;
99
+ const selectionActionsOpen = showSelectionActions && hasSelectionActions;
100
+ const quickLabelsOpen = showQuickLabels && !commentOnly && quickLabelsEnabled;
101
+
69
102
  useEffect(() => { setCopied(false); }, [element]);
70
103
 
71
104
  const handleCopy = async () => {
@@ -119,8 +152,8 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
119
152
  if (e.defaultPrevented || e.isComposing) return;
120
153
  if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return;
121
154
 
122
- // When picker is open, let FloatingQuickLabelPicker own all keyboard input
123
- if (showQuickLabels) return;
155
+ // When a picker is open, let it own all keyboard input
156
+ if (quickLabelsOpen || selectionActionsOpen) return;
124
157
 
125
158
  if (e.key === "Escape") {
126
159
  onClose();
@@ -132,7 +165,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
132
165
  const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0';
133
166
  if (isDigit && !e.ctrlKey && !e.metaKey && e.altKey) {
134
167
  e.preventDefault();
135
- if (!commentOnly) {
168
+ if (!commentOnly && quickLabelsEnabled) {
136
169
  const digit = parseInt(e.code.slice(5), 10);
137
170
  const index = digit === 0 ? 9 : digit - 1;
138
171
  if (index < quickLabels.length) {
@@ -158,10 +191,10 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
158
191
  window.removeEventListener("keydown", handleKeyDown);
159
192
  releaseCapture();
160
193
  };
161
- }, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels, commentOnly]);
194
+ }, [onClose, onRequestComment, onQuickLabel, quickLabels, quickLabelsOpen, selectionActionsOpen, commentOnly, quickLabelsEnabled]);
162
195
 
163
196
  useDismissOnOutsideAndEscape({
164
- enabled: !showQuickLabels,
197
+ enabled: !quickLabelsOpen && !selectionActionsOpen,
165
198
  ref: toolbarRef,
166
199
  onDismiss: onClose,
167
200
  });
@@ -236,9 +269,37 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
236
269
  label="Comment"
237
270
  className="text-annotation-comment hover:bg-annotation-comment/10"
238
271
  />
272
+ {hasSelectionActions && (
273
+ <ToolbarButton
274
+ ref={actionsButtonRef}
275
+ onClick={() => setShowSelectionActions(prev => !prev)}
276
+ icon={<WandIcon />}
277
+ label="Actions"
278
+ className={selectionActionsOpen ? "text-primary bg-primary/10" : "text-primary hover:bg-primary/10"}
279
+ dataAttributes={{ 'data-selection-actions': 'true' }}
280
+ />
281
+ )}
282
+ {selectionActionsOpen && actionsButtonRef.current && (
283
+ <FloatingSelectionActionsPicker
284
+ anchorEl={actionsButtonRef.current}
285
+ actions={selectionActions!}
286
+ onSelect={(action) => {
287
+ setShowSelectionActions(false);
288
+ // Read at invoke time, not on every render: a toolbar with no
289
+ // host actions must not walk the element's text at all.
290
+ const actionText = copyText
291
+ ?? element.querySelector('code')?.textContent
292
+ ?? element.textContent
293
+ ?? '';
294
+ action.onSelect(buildSelectionActionContext(element, actionText));
295
+ onClose();
296
+ }}
297
+ onDismiss={() => setShowSelectionActions(false)}
298
+ />
299
+ )}
239
300
  {onQuickLabel && (
240
301
  <>
241
- {!commentOnly && (
302
+ {!commentOnly && quickLabelsEnabled && (
242
303
  <ToolbarButton
243
304
  ref={zapButtonRef}
244
305
  onClick={() => setShowQuickLabels(prev => !prev)}
@@ -253,7 +314,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
253
314
  label="Looks good"
254
315
  className="hover:bg-green-500/10"
255
316
  />
256
- {!commentOnly && showQuickLabels && zapButtonRef.current && (
317
+ {quickLabelsOpen && zapButtonRef.current && (
257
318
  <FloatingQuickLabelPicker
258
319
  anchorEl={zapButtonRef.current}
259
320
  onSelect={(label) => {
@@ -309,6 +370,12 @@ const ZapIcon = () => (
309
370
  </svg>
310
371
  );
311
372
 
373
+ const WandIcon = () => (
374
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
375
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 4V2m0 16v-2M8 9h2m10 0h2M17.8 11.8L19 13M15 9h0M17.8 6.2L19 5M3 21l9-9M12.2 6.2L11 5" />
376
+ </svg>
377
+ );
378
+
312
379
  const CloseIcon = () => (
313
380
  <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
314
381
  <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -320,7 +387,9 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, {
320
387
  icon: React.ReactNode;
321
388
  label: string;
322
389
  className: string;
323
- }>(({ onClick, icon, label, className }, ref) => (
390
+ /** Extra DOM attributes (e.g. data-selection-actions). Absent adds nothing. */
391
+ dataAttributes?: Record<string, string>;
392
+ }>(({ onClick, icon, label, className, dataAttributes }, ref) => (
324
393
  <button
325
394
  ref={ref}
326
395
  // Icon-only controls: the markers are inert outside the compact touch
@@ -331,6 +400,7 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, {
331
400
  onClick={onClick}
332
401
  title={label}
333
402
  className={`p-1.5 rounded-md transition-colors ${className}`}
403
+ {...dataAttributes}
334
404
  >
335
405
  {icon}
336
406
  </button>
@@ -9,6 +9,9 @@ import { hasUnsavedCommentContent } from '../utils/commentContent';
9
9
  import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
10
10
  import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
11
11
  import type { SkillReferenceToken } from '../utils/skillReferences';
12
+ import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
13
+ import { MentionPicker } from './MentionPicker';
14
+ import type { MentionSource } from '../utils/mentions';
12
15
  import {
13
16
  hasPrimaryCoarsePointer,
14
17
  shouldUseExpandedComposer,
@@ -51,8 +54,13 @@ interface CommentPopoverProps {
51
54
  isGlobal: boolean;
52
55
  /** Pre-filled text (for type-to-comment) */
53
56
  initialText?: string;
54
- /** Called on submit with comment text and optional images */
55
- onSubmit: (text: string, images?: ImageAttachment[]) => void;
57
+ /**
58
+ * Called on submit with comment text and optional images. The third
59
+ * argument carries the mention ids the body still tags, and is passed ONLY
60
+ * when a `mentionSource` is supplied — without one the call is the two
61
+ * arguments it has always been.
62
+ */
63
+ onSubmit: (text: string, images?: ImageAttachment[], mentions?: readonly string[]) => void;
56
64
  /**
57
65
  * One-click "Looks good" action (comment-only HTML/live surfaces, where
58
66
  * pinpoint clicks open this composer directly and never see the selection
@@ -77,6 +85,13 @@ interface CommentPopoverProps {
77
85
  askAIDisabled?: boolean;
78
86
  /** Opt-in: `/` and `$` skill-reference autocomplete (document UI surfaces). Off by default. */
79
87
  skillReferences?: boolean;
88
+ /**
89
+ * Opt-in host capability: an `@` mention source for this composer. Supplies
90
+ * the people, an optional honest-empty notice, and optional callbacks for
91
+ * the surviving mention ids and for a picked person the host blocks.
92
+ * Absent → no listener, no picker, no extra DOM: byte-identical composer.
93
+ */
94
+ mentionSource?: MentionSource;
80
95
  /** Opt-in (HTML multi-select): selected targets rendered as horizontally
81
96
  * scrollable chips above the textarea. Absent → byte-identical composer. */
82
97
  targetChips?: CommentTargetChip[];
@@ -166,6 +181,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
166
181
  askAIContext,
167
182
  askAIDisabled = false,
168
183
  skillReferences = false,
184
+ mentionSource,
169
185
  targetChips,
170
186
  onRemoveTargetChip,
171
187
  onHoverTargetChip,
@@ -451,14 +467,24 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
451
467
  </div>
452
468
  ) : null;
453
469
 
470
+ const mentionAc = useMentionAutocomplete({
471
+ text,
472
+ setText,
473
+ textareaRef,
474
+ source: mentionSource,
475
+ });
476
+
454
477
  const handleSubmit = useCallback(() => {
455
478
  const canSubmitEmpty = allowEmptySubmit && initialText.trim().length > 0;
456
479
  if (hasUnsavedContent || canSubmitEmpty) {
457
480
  if (draftKey) draftStore.delete(draftKey);
458
- onSubmit(text, allowImages && images.length > 0 ? images : undefined);
481
+ const submitImages = allowImages && images.length > 0 ? images : undefined;
482
+ // Without a mentionSource this is the two-argument call it always was.
483
+ if (mentionSource) onSubmit(text, submitImages, mentionAc.mentionIds);
484
+ else onSubmit(text, submitImages);
459
485
  restoreOpeningFocus();
460
486
  }
461
- }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent, restoreOpeningFocus]);
487
+ }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent, restoreOpeningFocus, mentionSource, mentionAc.mentionIds]);
462
488
 
463
489
  const handleAskAI = useCallback(async () => {
464
490
  const question = text.trim();
@@ -492,14 +518,31 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
492
518
  textareaRef,
493
519
  enabled: skillReferences,
494
520
  });
521
+ const readComposerCaret = useCallback(() => {
522
+ skillAc.onSelect();
523
+ mentionAc.onSelect();
524
+ }, [mentionAc, skillAc]);
495
525
  const skillListboxId = `skill-reference-listbox-${useId().replace(/:/g, '')}`;
526
+ const mentionListboxId = `${skillListboxId}-mentions`;
496
527
  const activeSkillOptionId =
497
528
  skillAc.menu?.activeIndex === null || skillAc.menu?.activeIndex === undefined
498
529
  ? undefined
499
530
  : `${skillListboxId}-option-${skillAc.menu.activeIndex}`;
531
+ // At most one of the two menus can be open (their triggers are disjoint), so
532
+ // the textarea's ARIA relationship points at whichever one is. All three are
533
+ // `undefined`/false with no menu open, which is every render Plannotator
534
+ // makes without a `mentionSource`.
535
+ const activeMentionOptionId =
536
+ mentionAc.menu === null || mentionAc.menu.activeIndex === null
537
+ ? undefined
538
+ : `${mentionListboxId}-option-${mentionAc.menu.activeIndex}`;
539
+ const composerListboxId = mentionAc.menu !== null ? mentionListboxId : skillListboxId;
540
+ const composerListboxOpen = skillAc.menu !== null || mentionAc.menu !== null;
541
+ const activeComposerOptionId = activeSkillOptionId ?? activeMentionOptionId;
500
542
 
501
543
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
502
544
  if (skillAc.onKeyDown(e)) return;
545
+ if (mentionAc.onKeyDown(e)) return;
503
546
  if (e.key === 'Escape') {
504
547
  e.stopPropagation();
505
548
  if (mode === 'dialog') {
@@ -634,18 +677,32 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
634
677
  <ComposerTextarea
635
678
  textareaRef={focusOnMountRef}
636
679
  value={text}
637
- onChange={(e) => { setText(e.target.value); skillAc.onSelect(); }}
680
+ onChange={(e) => { setText(e.target.value); readComposerCaret(); }}
638
681
  onKeyDown={handleKeyDown}
639
- onSelectCaret={skillAc.onSelect}
682
+ onSelectCaret={readComposerCaret}
640
683
  placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
641
684
  sizeClassName="min-h-32 max-h-full"
642
685
  skillReferences={skillReferences}
643
686
  tokens={skillAc.referenceTokens}
644
- listboxId={skillListboxId}
645
- listboxOpen={skillAc.menu !== null}
646
- activeOptionId={activeSkillOptionId}
687
+ listboxId={composerListboxId}
688
+ listboxOpen={composerListboxOpen}
689
+ activeOptionId={activeComposerOptionId}
647
690
  />
648
691
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
692
+ {mentionAc.menu && (
693
+ <MentionPicker
694
+ id={mentionListboxId}
695
+ people={mentionAc.menu.items}
696
+ emptyNotice={mentionAc.menu.emptyNotice}
697
+ active={mentionAc.menu.activeIndex}
698
+ anchor={mentionAc.menu.anchor}
699
+ onPick={(person) => {
700
+ const index = mentionAc.menu?.items.findIndex((p) => p.id === person.id) ?? -1;
701
+ if (index >= 0) mentionAc.select(index);
702
+ }}
703
+ onHover={() => {}}
704
+ />
705
+ )}
649
706
  </div>
650
707
 
651
708
  {/* Footer — DOM order sets tab order (Save first); row-reverse keeps the visual layout unchanged */}
@@ -784,18 +841,32 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
784
841
  <ComposerTextarea
785
842
  textareaRef={focusOnMountRef}
786
843
  value={text}
787
- onChange={(e) => { setText(e.target.value); skillAc.onSelect(); }}
844
+ onChange={(e) => { setText(e.target.value); readComposerCaret(); }}
788
845
  onKeyDown={handleKeyDown}
789
- onSelectCaret={skillAc.onSelect}
846
+ onSelectCaret={readComposerCaret}
790
847
  placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
791
848
  sizeClassName="max-h-64 min-h-[4.5rem]"
792
849
  skillReferences={skillReferences}
793
850
  tokens={skillAc.referenceTokens}
794
- listboxId={skillListboxId}
795
- listboxOpen={skillAc.menu !== null}
796
- activeOptionId={activeSkillOptionId}
851
+ listboxId={composerListboxId}
852
+ listboxOpen={composerListboxOpen}
853
+ activeOptionId={activeComposerOptionId}
797
854
  />
798
855
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
856
+ {mentionAc.menu && (
857
+ <MentionPicker
858
+ id={mentionListboxId}
859
+ people={mentionAc.menu.items}
860
+ emptyNotice={mentionAc.menu.emptyNotice}
861
+ active={mentionAc.menu.activeIndex}
862
+ anchor={mentionAc.menu.anchor}
863
+ onPick={(person) => {
864
+ const index = mentionAc.menu?.items.findIndex((p) => p.id === person.id) ?? -1;
865
+ if (index >= 0) mentionAc.select(index);
866
+ }}
867
+ onHover={() => {}}
868
+ />
869
+ )}
799
870
  </div>
800
871
 
801
872
  {/* Footer — same DOM-order/row-reverse pattern as the dialog footer above */}
@@ -1013,6 +1084,11 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
1013
1084
  return (
1014
1085
  <textarea
1015
1086
  data-pn-mobile-editable="true"
1087
+ // Absent any open menu these are all undefined, so the plain textarea
1088
+ // renders exactly the attributes it always did.
1089
+ aria-autocomplete={listboxOpen ? 'list' : undefined}
1090
+ aria-controls={listboxOpen ? listboxId : undefined}
1091
+ aria-activedescendant={activeOptionId}
1016
1092
  ref={attachRef}
1017
1093
  value={value}
1018
1094
  onChange={onChange}
@@ -292,7 +292,9 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
292
292
  onCreateComment: handleCreate,
293
293
  selectedCommentId,
294
294
  onSelectComment: onSelectAnnotation,
295
- sourceLineOffset: block.startLine,
295
+ // A fence's offset is its own opening line; a whole-file diagram source
296
+ // overrides it with 0 (see Block.diagramSourceLineOffset).
297
+ sourceLineOffset: block.diagramSourceLineOffset ?? block.startLine,
296
298
  retryToken,
297
299
  };
298
300