@plannotator/ui 0.42.0 → 0.43.1

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` (and, since 0.43.1, `Viewer`'s and `HtmlViewer`'s): 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,303 @@ 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`, `selectionActionsIcon`, `quickLabels`) — the props live here. `selectionActionsIcon?: React.ReactNode` (0.43.1) is the glyph on the wand button, forwarded by `Viewer` (both toolbars) and `HtmlViewer`; absent → the package's own wand, which 0.43.1 also simplified to one thick diagonal with a single star (the six-spark glyph read as noise at 16px). Name, `data-selection-actions`, size and behavior of the button are untouched either way.
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
+ **Answered in 0.43.1 — both viewers now forward it; see the next section.**
1042
+
1043
+ ### The no-op guarantee, and how it is pinned
1044
+
1045
+ With neither prop supplied, the rendered DOM of both components is **byte-for-byte
1046
+ what `origin/main` renders**: the same components were mounted on the base commit
1047
+ and on this branch in the same harness and their `outerHTML` diffed to zero
1048
+ (`.annotation-toolbar` + `[data-comment-popover]`, 6376 bytes each, identical).
1049
+ On top of that, committed tests pin the structure rather than a snapshot:
1050
+ the default toolbar's button set and order (`Copy, Delete, Comment, Quick
1051
+ label, Looks good, Cancel`), the absence of `[data-selection-actions]` and of
1052
+ the picker, the exact attribute list on the Zap button, and — for the composer —
1053
+ that typing `@` opens nothing and that submit stays a two-argument call.
1054
+ `useMentionAutocomplete` with no source registers no listener, opens no menu
1055
+ state and returns one frozen empty id array; `AnnotationToolbar` renders no
1056
+ extra element and spreads no extra attributes.
1057
+
1058
+ Tests: `utils/mentions.test.ts` (11, DOM-free),
1059
+ `components/AnnotationToolbar.selectionActions.test.tsx` (8, DOM-gated),
1060
+ `components/CommentPopover.mentionSource.test.tsx` (11, DOM-gated).
1061
+
1062
+ ## `mentionSource` on the viewers (0.43.1)
1063
+
1064
+ 0.43.0 left `mentionSource` on `CommentPopover` alone, with an open question
1065
+ ("ask if you would rather pass it once on `Viewer`"). The answer is yes, so
1066
+ 0.43.1 threads it one level up and gives the picked ids somewhere to land.
1067
+ Same ruling as 0.43.0: an opt-in host capability that changes nothing for
1068
+ Plannotator's own users when it is not supplied. `packages/editor` and
1069
+ `packages/review-editor` are untouched by this release; core is UNCHANGED at
1070
+ `0.25.5`, so **ui 0.43.1 publishes alone**.
1071
+
1072
+ ### 1. The prop
1073
+
1074
+ `Viewer` and `HtmlViewer` each gain `mentionSource?: MentionSource` — the same
1075
+ type, unchanged, from `@plannotator/ui/types` (also `utils/mentions`) — and
1076
+ each forwards it to EVERY comment composer it mounts:
1077
+
1078
+ - `Viewer` → the text-selection composer (`useAnnotationHighlighter`'s) and the
1079
+ global / code-block one.
1080
+ - `HtmlViewer` → the pinpoint (selection) composer and the global one.
1081
+
1082
+ A host that wants mentions on a surface passes one prop instead of reaching
1083
+ into the viewer's composers. Passing it directly to a `CommentPopover` you
1084
+ mount yourself still works and is unchanged.
1085
+
1086
+ Deliberately NOT threaded: `plan-diff/PlanCleanDiffView`, `CodeFilePopout` and
1087
+ `goal-setup/GoalSetupSurface` mount composers too, but they are Plannotator-only
1088
+ surfaces off the supported-import list — the same line 0.43.0 drew for
1089
+ `selectionActions`. Ask if a host needs one.
1090
+
1091
+ ### 2. `Annotation.mentions` — where the ids go
1092
+
1093
+ ```ts
1094
+ interface Annotation {
1095
+ // …
1096
+ mentions?: readonly string[]; // opaque host ids, additive
1097
+ }
1098
+ ```
1099
+
1100
+ `onSubmit(text, images?, mentions?)` used to stop at the viewer: the third
1101
+ argument was received and dropped. It now rides onto the annotation the viewer
1102
+ hands `onAddAnnotation`, on every creation path behind those composers
1103
+ (`createAnnotationFromSource`, `createAnnotationFromMathSource`, the code-block
1104
+ path, both global comments, and the HTML pinpoint comment).
1105
+
1106
+ **The presence rule** is the whole no-op guarantee, so it is worth stating
1107
+ exactly: the key exists only when a `mentionSource` was supplied AND at least
1108
+ one id survived to submit. No source → no third argument → no key. A source
1109
+ whose tokens the author deleted before submitting → `[]` from the composer →
1110
+ still no key, never an empty array. Every write is a conditional spread
1111
+ (`...(mentions && mentions.length > 0 ? { mentions } : {})`), not `mentions,`,
1112
+ because the bare shorthand would put the key on the object with an `undefined`
1113
+ value and `'mentions' in ann` would start answering true for Plannotator.
1114
+
1115
+ The ids are opaque to the package. Mapping one to a person, notifying them, or
1116
+ rendering an avatar is entirely the host's business — `MentionPerson.id` is
1117
+ reported back verbatim, exactly as it was handed in.
1118
+
1119
+ ### 3. What the field does NOT touch
1120
+
1121
+ An id from a host's directory means nothing outside that host, so the field
1122
+ stays out of everything Plannotator produces:
1123
+
1124
+ - **Export.** `exportAnnotations`, `exportAnnotationEntry` and
1125
+ `exportLinkedDocAnnotations` never print it: an annotation carrying
1126
+ `mentions` exports byte-identically to the same annotation without it
1127
+ (`utils/parser.mentions.test.ts`). The readable `@Label` token is in the
1128
+ comment body, which is what the coding agent reads.
1129
+ - **Share links.** Dropped exactly like `htmlAnchor`, `elementContext` and
1130
+ `diagramAnchor` — the compact tuple format never carried extra fields, and a
1131
+ round trip restores `mentions: undefined` (pinned in
1132
+ `utils/sharing.multiTarget.test.ts`).
1133
+ - **External annotations.** `POST /api/external-annotations` builds its rows
1134
+ from an explicit field list and `PATCH` from an allowlist, so a `mentions`
1135
+ key on the wire is dropped as any unknown key is. No change was needed in
1136
+ `@plannotator/core/external-annotation`, in either runtime.
1137
+ - **The feedback archive.** `packages/shared/feedback-archive.ts` copies named
1138
+ fields into its record; `mentions` is not one of them and never reaches
1139
+ `index.jsonl` or a sidecar. No change needed.
1140
+ - **Drafts** carry it for free (annotations are opaque JSON to the draft
1141
+ transport), which is the behavior a host wants: a restored draft still knows
1142
+ who was named.
1143
+
1144
+ ### 4. Edit and reply paths
1145
+
1146
+ There is none to thread in these two viewers: both `Viewer` composers and both
1147
+ `HtmlViewer` composers are CREATION composers. Editing an existing comment
1148
+ happens in `AnnotationPanel`'s card, a plain textarea that has never had an
1149
+ `@` picker and takes no `mentionSource`; replies (`inReplyTo`) are created by
1150
+ the WebMCP catalog, not by a composer. So no annotation's `mentions` is
1151
+ rewritten after creation by this package — a host that edits a comment owns
1152
+ the field from then on. If you want the panel editor to pick people too, that
1153
+ is a separate prop on `AnnotationPanel` and worth asking for.
1154
+
1155
+ ### No-op guarantee, and how it is pinned
1156
+
1157
+ With no `mentionSource`, both viewers mount the composers they always did, no
1158
+ `@` listener is registered, no picker DOM exists, and the annotation object
1159
+ handed to `onAddAnnotation` has no `mentions` key at all (`'mentions' in ann`
1160
+ is false, asserted rather than `toBeUndefined()` — the difference between the
1161
+ conditional spread and the bare shorthand is invisible to the latter).
1162
+
1163
+ Tests (both DOM-gated, both in the workflow's DOM_TESTS step):
1164
+ `components/Viewer.mentionSource.test.tsx` (5) drives the real Viewer — the
1165
+ selection composer through the Vim toolbar's type-to-comment gesture and the
1166
+ global composer through its button — and
1167
+ `components/html-viewer/HtmlViewer.mentionSource.test.tsx` (3) drives the real
1168
+ HtmlViewer through a bridge pinpoint message and its global button. Plus the two DOM-free pins in
1169
+ §3 above.
1170
+
876
1171
  ## Publishing & versioning
877
1172
 
1173
+ - **ui 0.43.1 (`mentionSource` on the viewers): `@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 over 0.43.0: `mentionSource` on `Viewer` and `HtmlViewer` (forwarded to every comment composer each mounts) and the optional `Annotation.mentions` field the picked ids land on, set only when a source was supplied and a token survived. Nothing is removed, no new modules, no export-, share- or archive-visible change, and Plannotator passes none of it. See "`mentionSource` on the viewers (0.43.1)".
1174
+ - **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)".
878
1175
  - **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.
879
1176
  - **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`).
880
1177
  - 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.
package/README.md CHANGED
@@ -220,6 +220,54 @@ 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` `selectionActionsIcon`** (forwarded by `Viewer` and
242
+ `HtmlViewer`): the glyph on the `selectionActions` button, so a host can match
243
+ the wand it draws elsewhere. Absent means the package's own wand; nothing else
244
+ about the button changes.
245
+ - **`AnnotationToolbar` `quickLabels`** (default `true`): `false` hides the Zap
246
+ picker and makes the Alt+digit label shortcuts inert on that toolbar. The
247
+ one-click 👍 is unaffected, and mode state is still yours to clamp.
248
+ - **`CommentPopover` `mentionSource`**: `{ people, emptyNotice?,
249
+ onMentionsChange?, onPickBlocked? }` over `MentionPerson`
250
+ (`{ id, kind, label, detail, canOpen }`). Typing `@` at a word boundary opens
251
+ a portaled picker measured from the textarea; picking inserts the readable
252
+ `@Label ` token, and the ids ride `onMentionsChange(ids)` plus an optional
253
+ third `onSubmit(text, images?, mentions?)` argument that is passed **only**
254
+ when a source is supplied. Deleting a token untags that person. A
255
+ `canOpen: false` person inserts nothing when you supply `onPickBlocked` (your
256
+ no-access dialog) and inserts normally when you do not. Not wired to any
257
+ Plannotator data and not a `configurePlannotatorUI` seam — pass it where you
258
+ render the composer.
259
+ - **`Viewer` / `HtmlViewer` `mentionSource`** (0.43.1): the same prop on the two
260
+ viewers, forwarded to every comment composer each of them mounts, so a host
261
+ wires mentions once per surface instead of per composer. The ids the author
262
+ kept ride onto the created annotation as `Annotation.mentions`
263
+ (`readonly string[]`, present only when a source was supplied and at least
264
+ one token survived). It is host data: the package never renders, exports,
265
+ shares or archives it.
266
+
267
+ See HANDOFF.md § "Host toolbar seams (0.43.0)" for the grammar, the keyboard
268
+ rules and the threading points, and § "mentionSource on the viewers (0.43.1)"
269
+ for the two viewer props and the annotation field.
270
+
223
271
  ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
224
272
 
225
273
  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 +303,7 @@ npm install @plannotator/ui @plannotator/core
255
303
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
256
304
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
257
305
  - `@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.
306
+ - 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
307
 
260
308
  ## The one rule
261
309
 
@@ -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,27 @@ 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
+ * Opt-in host capability: the glyph on the `selectionActions` button, so a
49
+ * host can match the wand it draws elsewhere. Absent → the package's own
50
+ * wand. Nothing else about the button changes (name, data attribute, size).
51
+ */
52
+ selectionActionsIcon?: React.ReactNode;
53
+ /**
54
+ * Whether the package's own quick labels are offered (default true).
55
+ * `false` hides the Zap picker button and the Alt+digit label shortcuts on
56
+ * this toolbar. The one-click 👍 is a separate affordance and is unaffected.
57
+ */
58
+ quickLabels?: boolean;
36
59
  /** Hide the copy button (set when a keyboard copy handler exists) */
37
60
  hideCopyButton?: boolean;
38
61
  /** Close toolbar when element scrolls out of viewport */
@@ -51,6 +74,9 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
51
74
  onClose,
52
75
  onRequestComment,
53
76
  onQuickLabel,
77
+ selectionActions,
78
+ selectionActionsIcon,
79
+ quickLabels: quickLabelsEnabled = true,
54
80
  copyText,
55
81
  commentOnly = false,
56
82
  hideCopyButton = false,
@@ -62,10 +88,24 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
62
88
  const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
63
89
  const [copied, setCopied] = useState(false);
64
90
  const [showQuickLabels, setShowQuickLabels] = useState(false);
91
+ const [showSelectionActions, setShowSelectionActions] = useState(false);
65
92
  const toolbarRef = useRef<HTMLDivElement>(null);
66
93
  const zapButtonRef = useRef<HTMLButtonElement>(null);
94
+ const actionsButtonRef = useRef<HTMLButtonElement>(null);
67
95
  const quickLabels = useMemo(() => getQuickLabels(), []);
68
96
 
97
+ // A picker counts as open only while the button that opens it is actually
98
+ // rendered. Without this, a host whose `selectionActions` go empty (or who
99
+ // flips `quickLabels` off) while its dropdown is open unmounts the dropdown
100
+ // and its Escape handler, but leaves the flag set — and the flag is what
101
+ // stands the toolbar's own Escape, type-to-comment and outside-dismiss
102
+ // listeners down, wedging the toolbar until the ✕ is clicked. Both flags are
103
+ // false whenever the props are absent, so Plannotator's toolbar is
104
+ // unchanged.
105
+ const hasSelectionActions = !!selectionActions && selectionActions.length > 0;
106
+ const selectionActionsOpen = showSelectionActions && hasSelectionActions;
107
+ const quickLabelsOpen = showQuickLabels && !commentOnly && quickLabelsEnabled;
108
+
69
109
  useEffect(() => { setCopied(false); }, [element]);
70
110
 
71
111
  const handleCopy = async () => {
@@ -119,8 +159,8 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
119
159
  if (e.defaultPrevented || e.isComposing) return;
120
160
  if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return;
121
161
 
122
- // When picker is open, let FloatingQuickLabelPicker own all keyboard input
123
- if (showQuickLabels) return;
162
+ // When a picker is open, let it own all keyboard input
163
+ if (quickLabelsOpen || selectionActionsOpen) return;
124
164
 
125
165
  if (e.key === "Escape") {
126
166
  onClose();
@@ -132,7 +172,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
132
172
  const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0';
133
173
  if (isDigit && !e.ctrlKey && !e.metaKey && e.altKey) {
134
174
  e.preventDefault();
135
- if (!commentOnly) {
175
+ if (!commentOnly && quickLabelsEnabled) {
136
176
  const digit = parseInt(e.code.slice(5), 10);
137
177
  const index = digit === 0 ? 9 : digit - 1;
138
178
  if (index < quickLabels.length) {
@@ -158,10 +198,10 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
158
198
  window.removeEventListener("keydown", handleKeyDown);
159
199
  releaseCapture();
160
200
  };
161
- }, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels, commentOnly]);
201
+ }, [onClose, onRequestComment, onQuickLabel, quickLabels, quickLabelsOpen, selectionActionsOpen, commentOnly, quickLabelsEnabled]);
162
202
 
163
203
  useDismissOnOutsideAndEscape({
164
- enabled: !showQuickLabels,
204
+ enabled: !quickLabelsOpen && !selectionActionsOpen,
165
205
  ref: toolbarRef,
166
206
  onDismiss: onClose,
167
207
  });
@@ -236,9 +276,37 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
236
276
  label="Comment"
237
277
  className="text-annotation-comment hover:bg-annotation-comment/10"
238
278
  />
279
+ {hasSelectionActions && (
280
+ <ToolbarButton
281
+ ref={actionsButtonRef}
282
+ onClick={() => setShowSelectionActions(prev => !prev)}
283
+ icon={selectionActionsIcon ?? <WandIcon />}
284
+ label="Actions"
285
+ className={selectionActionsOpen ? "text-primary bg-primary/10" : "text-primary hover:bg-primary/10"}
286
+ dataAttributes={{ 'data-selection-actions': 'true' }}
287
+ />
288
+ )}
289
+ {selectionActionsOpen && actionsButtonRef.current && (
290
+ <FloatingSelectionActionsPicker
291
+ anchorEl={actionsButtonRef.current}
292
+ actions={selectionActions!}
293
+ onSelect={(action) => {
294
+ setShowSelectionActions(false);
295
+ // Read at invoke time, not on every render: a toolbar with no
296
+ // host actions must not walk the element's text at all.
297
+ const actionText = copyText
298
+ ?? element.querySelector('code')?.textContent
299
+ ?? element.textContent
300
+ ?? '';
301
+ action.onSelect(buildSelectionActionContext(element, actionText));
302
+ onClose();
303
+ }}
304
+ onDismiss={() => setShowSelectionActions(false)}
305
+ />
306
+ )}
239
307
  {onQuickLabel && (
240
308
  <>
241
- {!commentOnly && (
309
+ {!commentOnly && quickLabelsEnabled && (
242
310
  <ToolbarButton
243
311
  ref={zapButtonRef}
244
312
  onClick={() => setShowQuickLabels(prev => !prev)}
@@ -253,7 +321,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
253
321
  label="Looks good"
254
322
  className="hover:bg-green-500/10"
255
323
  />
256
- {!commentOnly && showQuickLabels && zapButtonRef.current && (
324
+ {quickLabelsOpen && zapButtonRef.current && (
257
325
  <FloatingQuickLabelPicker
258
326
  anchorEl={zapButtonRef.current}
259
327
  onSelect={(label) => {
@@ -309,6 +377,14 @@ const ZapIcon = () => (
309
377
  </svg>
310
378
  );
311
379
 
380
+ // One thick diagonal wand with a single four-point star at its tip: the
381
+ // earlier glyph carried six sparks and read as noise at 16px.
382
+ const WandIcon = () => (
383
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
384
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 21L13.5 10.5M17 3v7M13.5 6.5h7" />
385
+ </svg>
386
+ );
387
+
312
388
  const CloseIcon = () => (
313
389
  <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
314
390
  <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -320,7 +396,9 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, {
320
396
  icon: React.ReactNode;
321
397
  label: string;
322
398
  className: string;
323
- }>(({ onClick, icon, label, className }, ref) => (
399
+ /** Extra DOM attributes (e.g. data-selection-actions). Absent adds nothing. */
400
+ dataAttributes?: Record<string, string>;
401
+ }>(({ onClick, icon, label, className, dataAttributes }, ref) => (
324
402
  <button
325
403
  ref={ref}
326
404
  // Icon-only controls: the markers are inert outside the compact touch
@@ -331,6 +409,7 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, {
331
409
  onClick={onClick}
332
410
  title={label}
333
411
  className={`p-1.5 rounded-md transition-colors ${className}`}
412
+ {...dataAttributes}
334
413
  >
335
414
  {icon}
336
415
  </button>