@plannotator/ui 0.31.0 → 0.33.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 (46) hide show
  1. package/HANDOFF.md +664 -0
  2. package/README.md +57 -1
  3. package/components/AnnotationPanel.tsx +96 -4
  4. package/components/AnnotationToolbar.tsx +25 -25
  5. package/components/CommentPopover.tsx +26 -0
  6. package/components/GraphvizBlock.tsx +86 -7
  7. package/components/HtmlSurfaceControls.tsx +170 -0
  8. package/components/InlineMarkdown.tsx +22 -2
  9. package/components/MermaidBlock.tsx +60 -26
  10. package/components/Settings.tsx +40 -1
  11. package/components/blocks/MathBlock.tsx +26 -14
  12. package/components/html-viewer/HtmlViewer.tsx +289 -6
  13. package/components/html-viewer/bridge-script.asset.js +4392 -0
  14. package/components/html-viewer/bridge-script.lite.ts +9 -0
  15. package/components/html-viewer/bridge-script.ts +54 -8
  16. package/components/html-viewer/hostThreads.ts +37 -0
  17. package/components/html-viewer/index.ts +22 -1
  18. package/components/html-viewer/srcdoc.ts +70 -1
  19. package/components/html-viewer/unanchored.ts +47 -0
  20. package/components/html-viewer/useHtmlAnnotation.ts +132 -5
  21. package/configure.ts +32 -0
  22. package/hooks/useHtmlRefresh.ts +149 -0
  23. package/hooks/useMathRenderer.ts +30 -0
  24. package/hooks/useSharing.ts +31 -5
  25. package/package.json +9 -3
  26. package/styles.css +1 -1
  27. package/types.ts +1 -0
  28. package/utils/generateIdentity.ts +64 -14
  29. package/utils/identity-tater.ts +36 -0
  30. package/utils/math-default-loader.ts +24 -0
  31. package/utils/math-eager.ts +25 -0
  32. package/utils/math.ts +149 -0
  33. package/utils/mermaid-eager.ts +28 -0
  34. package/utils/mermaid.ts +132 -0
  35. package/utils/parser.ts +38 -0
  36. package/utils/quickLabels.ts +13 -0
  37. package/webmcp/activity.ts +46 -0
  38. package/webmcp/changes.ts +227 -0
  39. package/webmcp/index.ts +72 -0
  40. package/webmcp/modelContext.ts +103 -0
  41. package/webmcp/nudges.ts +174 -0
  42. package/webmcp/policy.ts +50 -0
  43. package/webmcp/preference.ts +50 -0
  44. package/webmcp/schema.ts +81 -0
  45. package/webmcp/toolset.ts +337 -0
  46. package/webmcp/useToolset.ts +74 -0
package/README.md CHANGED
@@ -27,6 +27,9 @@ configurePlannotatorUI({
27
27
  skillCatalogTransport, // skill-reference catalog for comment composers
28
28
  skillContentTransport, // human-only skill contents for feedback injection
29
29
  serverSync,
30
+ webmcp, // browser-agent (WebMCP) provider policy: { enabled, namePrefix }
31
+ mathRendererLoader, // how KaTeX loads when no renderer is registered before first math render
32
+ identityGenerator, // sync generator behind the default "tater" name (no identityProvider)
30
33
  });
31
34
  ```
32
35
 
@@ -47,6 +50,16 @@ The sidebar/panel resize handle exposes seams for hosts that want different edge
47
50
 
48
51
  Building your own tooltip and removing the built-in double-click reset are host-side concerns (override `onDoubleClick` where you render the handle).
49
52
 
53
+ ### Lazy renderers and the eager entries (`utils/math`, `utils/generateIdentity`, `utils/mermaid`; 0.32.0)
54
+
55
+ 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 needs nothing from you (the block imports the engine inside its render effect and shows the source fence until the SVG lands, as it always did). Mermaid, KaTeX and the dictionary sit behind synchronous slots:
56
+
57
+ - **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.
58
+ - **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')`; 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. Plannotator keeps Mermaid eager by policy so it can never fail separately from the app: `import "@plannotator/ui/utils/mermaid-eager";` in your entry does the same for your bundle. 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.
59
+ - **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`.
60
+
61
+ Plannotator's own entries import the eager modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx`; `mermaid-eager` in the plan editor only, since the review editor never renders a Mermaid block), which is what keeps its single-file builds byte-identical and its portal entry chunk shaped as before; `tests/entry-assets.test.ts` fails if any of them is dropped. See HANDOFF.md "Lazy renderers and eager entries".
62
+
50
63
  ### Markdown editor extensions + wiki links (`MarkdownEditor` / `InlineMarkdown`)
51
64
 
52
65
  - **`MarkdownEditor` takes CM6 extensions.** `extensions?: readonly Extension[]` (from `@codemirror/state`) is forwarded verbatim into the underlying editor — the seam for `wikiLinks(config)`, `y-codemirror.next` collab bindings, custom keymaps.
@@ -85,6 +98,49 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^
85
98
 
86
99
  `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.
87
100
 
101
+ #### HTML annotation parity seams (0.32.0)
102
+
103
+ 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:
104
+
105
+ - **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names. **HTML-only:** the projection carries `originalText`, `htmlAnchor` and `htmlAdditionalTargets`, and pins `blockId` to `""`, offsets to `0` and no `startMeta` / `endMeta`; on the markdown `Viewer` a projected `COMMENT` with quoted text still re-anchors by whole-document text search, but with `blockId` `""` and offsets `0` it loses export ordering (every such row sorts first and ties), the "lines N-M" location label, disambiguation when the same text repeats (first match wins), and the no-flash meta restore; a host that needs those carries `blockId`, the offsets and the web-highlighter metas in its own projection.
106
+ - **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side. **Nothing is delivered before the bridge's first post-restore report for a document (per reload generation):** a prop-side change before that point does not fire the callback, so do not gate host state on a prop-side delivery arriving first; treat the first call as the restore's verdict.
107
+ - **`hooks/useHtmlRefresh({ fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`**: the refresh cycle with the stale-response and document-change guards, backend behind `fetchSnapshot`.
108
+ - **`components/HtmlSurfaceControls`**: the eye / refresh / pen header controls with Plannotator's markup and `labels` overrides.
109
+ - **`AnnotationPanel` `unanchoredIds`**: an "Unanchored" chip on the listed cards.
110
+ - **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too). With the cap enforced upstream (bridge toggle, parent trust boundary on submit and on restore, `projectHostThreads` `maxTargets` on read), a composed comment never reaches the host with more targets than the cap, so a host's own cap-dropped handling (`capDroppedTargets` from `buildPersistedHtmlAnchor`, or a message-counting listener) is unreachable in normal operation; keep it only as a backstop for rows written by an older host build or by another writer. Byte-budget drops (`sizeDroppedTargets`) are a separate path and remain reachable.
111
+ - An `ExternalAnnotationTransport` whose `subscribe` emits `snapshot` on a host push keeps `useExternalAnnotations` off its fallback poll.
112
+
113
+ See HANDOFF.md § "HTML annotation parity seams".
114
+
115
+ #### The bridge script as an asset (`bridgeScriptUrl`; 0.33.0)
116
+
117
+ By default `HtmlViewer` inlines its 185 KB in-page bridge script into every srcdoc document. A host that serves the package's generated `components/html-viewer/bridge-script.asset.js` as a static file can pass its URL instead:
118
+
119
+ ```ts
120
+ import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-script.asset.js?url";
121
+
122
+ <HtmlViewer rawHtml={html} bridgeScriptUrl={bridgeScriptUrl} … />
123
+ ```
124
+
125
+ The srcdoc then carries one classic `<script src>` in the exact place the inline script sat (at the end of `<head>`, before the body), the browser caches the asset across documents, and the bridge's `ready` message carries `BRIDGE_PROTOCOL_VERSION`, which the viewer checks: a stale cached asset (no stamp, or another version) logs one console warning naming both versions and shows a dismissible error banner in the surface (`onBridgeUnavailable` fires too); no `ready` within `bridgeReadyTimeoutMs` (default 5000) shows a timeout banner. The URL is resolved against your document's base (`document.baseURI`) before it is written into the srcdoc, never against the framed page, so a page's own `<base href>` cannot redirect it. Plannotator passes nothing and stays inline; none of this runs on the inline path. **CSP:** the package sets no CSP `<meta>` in the srcdoc document, and the frame is an opaque origin so the classic script needs no CORS (no `crossorigin` is set), but a CSP delivered as a header on your page is inherited by the frame: allow `script-src` for the asset's origin. Because the frame is an opaque origin, an asset served with `Cross-Origin-Resource-Policy: same-origin` (common alongside COEP) is blocked; serve it with a CORP that admits cross-origin loads, or without CORP. To also drop the inline literal from your viewer chunk, alias the package's `./bridge-script` resolution to the generated `bridge-script.lite` module (see HANDOFF.md § "HTML viewer bridge as an asset").
126
+
127
+ #### Also blessed in 0.32.0: `shortcuts` and `utils/inputMethod`
128
+
129
+ - **`@plannotator/ui/shortcuts`**: the declarative keyboard-shortcut engine (`defineShortcutScope`, `useShortcutScope`) and the per-surface scopes, including `useHtmlAnnotateShortcuts` for the Mod+Shift+A Annotate/Interact chord on HTML surfaces. Pure React plus `utils/platform`; no backend.
130
+ - **`@plannotator/ui/utils/inputMethod`**: `getInputMethod(surface)` / `saveInputMethod(method, surface)` / `refreshInputMethodStamp(method)`, the per-surface pinpoint-or-drag preference with its TTL, persisted through the `storageBackend` seam.
131
+
132
+ ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
133
+
134
+ 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"`.
135
+
136
+ - `modelContext.ts` is the only file that spells the spec surface (local structural types, no `webmcp-types` dependency). A spec rename is a one-file change.
137
+ - `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).
138
+ - `AnnotationChangeTracker` / `buildNudges` are pure (no DOM): per-annotation `seq`, tombstones, a per-tab watermark with `since` override, and the nudge vocabulary every response carries.
139
+ - 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).
140
+ - 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.
141
+
142
+ 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).
143
+
88
144
  ## Consuming it (e.g. from Workspaces)
89
145
 
90
146
  ```bash
@@ -108,7 +164,7 @@ npm install @plannotator/ui @plannotator/core
108
164
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
109
165
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published.
110
166
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
111
- - Versioned in lockstep with the repo. Publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow.
167
+ - Versioned together (currently `@plannotator/ui` 0.33.0 on `@plannotator/core` 0.25.0). `core` is bumped only when something under `packages/core` changed, so `ui` can advance alone: 0.33.0 is such a release, published on the already available core 0.25.0. When both change, publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").
112
168
 
113
169
  ## The one rule
114
170
 
@@ -38,6 +38,52 @@ const TrashCardIcon = () => (
38
38
  </svg>
39
39
  );
40
40
 
41
+ /**
42
+ * Order annotations so every reply follows its parent (replies among
43
+ * themselves stay in creation order). A reply whose parent is absent renders
44
+ * as a top-level card. Without any `inReplyTo` the input order is returned
45
+ * unchanged, so annotations without replies render exactly as before.
46
+ */
47
+ export function threadReplies(sorted: Annotation[]): Array<{ annotation: Annotation; isReply: boolean }> {
48
+ if (!sorted.some((a) => a.inReplyTo)) return sorted.map((annotation) => ({ annotation, isReply: false }));
49
+ const ids = new Set(sorted.map((a) => a.id));
50
+ const byParent = new Map<string, Annotation[]>();
51
+ for (const a of sorted) {
52
+ if (a.inReplyTo && ids.has(a.inReplyTo) && a.inReplyTo !== a.id) {
53
+ const list = byParent.get(a.inReplyTo) ?? [];
54
+ list.push(a);
55
+ byParent.set(a.inReplyTo, list);
56
+ }
57
+ }
58
+ const out: Array<{ annotation: Annotation; isReply: boolean }> = [];
59
+ const emitted = new Set<string>();
60
+ const emit = (a: Annotation, isReply: boolean) => {
61
+ if (emitted.has(a.id)) return;
62
+ emitted.add(a.id);
63
+ out.push({ annotation: a, isReply });
64
+ for (const reply of byParent.get(a.id) ?? []) emit(reply, true);
65
+ };
66
+ for (const a of sorted) {
67
+ if (a.inReplyTo && ids.has(a.inReplyTo) && a.inReplyTo !== a.id) continue;
68
+ emit(a, false);
69
+ }
70
+ for (const a of sorted) emit(a, false);
71
+ return out;
72
+ }
73
+
74
+ /** Timeline position of an annotation: its own time, or its thread root's for replies. */
75
+ function threadTs(annotation: Annotation, all: Annotation[]): number {
76
+ let current = annotation;
77
+ const seen = new Set<string>();
78
+ while (current.inReplyTo && !seen.has(current.id)) {
79
+ seen.add(current.id);
80
+ const parent = all.find((a) => a.id === current.inReplyTo);
81
+ if (!parent) break;
82
+ current = parent;
83
+ }
84
+ return current.createdA;
85
+ }
86
+
41
87
  interface DirectEditsPanelItem {
42
88
  id: string;
43
89
  title?: string;
@@ -89,6 +135,10 @@ interface PanelProps {
89
135
  /** Embed only the timeline body in a host-owned stage. The host owns the
90
136
  * title, close control, visible-viewport geometry, and focus boundary. */
91
137
  presentation?: 'panel' | 'embedded';
138
+ /** Ids of annotations with no live location in the document (e.g. the
139
+ * HTML viewer's onUnanchoredChange report after a refresh). Matching
140
+ * cards show a small "Unanchored" chip. Absent: no chip, DOM unchanged. */
141
+ unanchoredIds?: ReadonlySet<string>;
92
142
  }
93
143
 
94
144
  export const AnnotationPanel: React.FC<PanelProps> = ({
@@ -115,6 +165,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
115
165
  renderCardFooter,
116
166
  readOnly = false,
117
167
  presentation = 'panel',
168
+ unanchoredIds,
118
169
  }) => {
119
170
  const isMobile = useIsMobile();
120
171
  const embedded = presentation === 'embedded';
@@ -123,10 +174,19 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
123
174
  const listRef = useRef<HTMLDivElement>(null);
124
175
  const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA);
125
176
  const sortedCodeAnnotations = [...codeAnnotations].sort((a, b) => a.createdAt - b.createdAt);
177
+ // Replies (`inReplyTo`) thread under their parent: each reply is lifted to
178
+ // sit right after its parent (and the parent's earlier replies) at the
179
+ // parent's timeline position. With no replies the order is untouched.
180
+ const threadedAnnotations = threadReplies(sortedAnnotations);
126
181
  const timelineEntries = [
127
- ...sortedAnnotations.map(annotation => ({ kind: 'plan' as const, ts: annotation.createdA, annotation })),
128
- ...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation })),
129
- ].sort((a, b) => a.ts - b.ts);
182
+ ...threadedAnnotations.map(({ annotation, isReply }) => ({ kind: 'plan' as const, ts: annotation.createdA, annotation, isReply })),
183
+ ...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation, isReply: false })),
184
+ ].sort((a, b) => {
185
+ const ta = a.kind === 'plan' ? threadTs(a.annotation, sortedAnnotations) : a.ts;
186
+ const tb = b.kind === 'plan' ? threadTs(b.annotation, sortedAnnotations) : b.ts;
187
+ if (ta !== tb) return ta - tb;
188
+ return a.ts - b.ts;
189
+ });
130
190
  const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
131
191
 
132
192
  // Scroll selected annotation card into view
@@ -221,6 +281,25 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
221
281
  <>
222
282
  {timelineEntries.map(entry => (
223
283
  entry.kind === 'plan' ? (
284
+ entry.isReply ? (
285
+ <div
286
+ key={entry.annotation.id}
287
+ data-annotation-reply="true"
288
+ className="ml-3 border-l-2 border-border/40 pl-1.5"
289
+ >
290
+ <AnnotationCard
291
+ annotation={entry.annotation}
292
+ isSelected={selectedId === entry.annotation.id}
293
+ isMe={isCurrentUser(entry.annotation.author)}
294
+ onSelect={() => onSelect(entry.annotation.id)}
295
+ onDelete={() => onDelete(entry.annotation.id)}
296
+ onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
297
+ readOnly={readOnly}
298
+ footer={renderCardFooter?.(entry.annotation)}
299
+ unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
300
+ />
301
+ </div>
302
+ ) : (
224
303
  <AnnotationCard
225
304
  key={entry.annotation.id}
226
305
  annotation={entry.annotation}
@@ -231,7 +310,9 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
231
310
  onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
232
311
  readOnly={readOnly}
233
312
  footer={renderCardFooter?.(entry.annotation)}
313
+ unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
234
314
  />
315
+ )
235
316
  ) : (
236
317
  <CodeAnnotationCard
237
318
  key={entry.annotation.id}
@@ -458,7 +539,9 @@ const AnnotationCard: React.FC<{
458
539
  onEdit?: (updates: Partial<Annotation>) => void;
459
540
  readOnly?: boolean;
460
541
  footer?: React.ReactNode;
461
- }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer }) => {
542
+ /** The annotation has no live location in the document (host-reported). */
543
+ unanchored?: boolean;
544
+ }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, unanchored = false }) => {
462
545
  const [isEditing, setIsEditing] = useState(false);
463
546
  const [editText, setEditText] = useState(annotation.text || '');
464
547
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -563,6 +646,15 @@ const AnnotationCard: React.FC<{
563
646
  {annotation.pageUrl}
564
647
  </span>
565
648
  )}
649
+ {unanchored && (
650
+ <span
651
+ data-annotation-unanchored="true"
652
+ className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground"
653
+ title="This comment no longer matches a location in the document"
654
+ >
655
+ Unanchored
656
+ </span>
657
+ )}
566
658
  <span className="text-[10px] text-muted-foreground/50 truncate">
567
659
  {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
568
660
  </span>
@@ -2,20 +2,13 @@ import React, { useState, useEffect, useRef, useMemo } from "react";
2
2
  import { AnnotationType } from "../types";
3
3
  import { createPortal } from "react-dom";
4
4
  import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
5
- import { type QuickLabel, getQuickLabels } from "../utils/quickLabels";
5
+ import { type QuickLabel, getQuickLabels, THUMBS_UP_LABEL } from "../utils/quickLabels";
6
6
  import { copyTextToClipboard } from "../utils/clipboard";
7
7
  import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts";
8
8
  import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
9
9
 
10
10
  type PositionMode = 'center-above' | 'top-right';
11
11
 
12
- const THUMBS_UP_LABEL: QuickLabel = {
13
- id: 'thumbs-up',
14
- emoji: '👍',
15
- text: 'Looks good',
16
- color: 'green',
17
- };
18
-
19
12
  const isEditableElement = (node: EventTarget | Element | null): boolean => {
20
13
  if (!(node instanceof Element)) return false;
21
14
  if (node.matches('input, textarea, select, [role="textbox"]')) return true;
@@ -34,9 +27,11 @@ interface AnnotationToolbarProps {
34
27
  onQuickLabel?: (label: QuickLabel) => void;
35
28
  /** Text to copy when the button is clicked */
36
29
  copyText?: string;
37
- /** Comment-only surfaces (HTML / live-app viewer): hide the Delete action.
38
- * Markdown surfaces keep the full toolbar. Quick labels are already gated
39
- * by the presence of onQuickLabel. */
30
+ /** Comment-only surfaces (HTML / live-app viewer): hide the Delete action,
31
+ * the quick-label picker, and the Alt+digit label shortcuts. A provided
32
+ * onQuickLabel then renders ONLY the hardcoded 👍 "Looks good" button —
33
+ * the one label affordance restored to these surfaces. Markdown surfaces
34
+ * keep the full toolbar. */
40
35
  commentOnly?: boolean;
41
36
  /** Hide the copy button (set when a keyboard copy handler exists) */
42
37
  hideCopyButton?: boolean;
@@ -132,14 +127,17 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
132
127
  return;
133
128
  }
134
129
 
135
- // Alt+N applies quick label (picker closed)
130
+ // Alt+N applies quick label (picker closed). Comment-only surfaces
131
+ // suppress this path: their only label affordance is the 👍 button.
136
132
  const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0';
137
133
  if (isDigit && !e.ctrlKey && !e.metaKey && e.altKey) {
138
134
  e.preventDefault();
139
- const digit = parseInt(e.code.slice(5), 10);
140
- const index = digit === 0 ? 9 : digit - 1;
141
- if (index < quickLabels.length) {
142
- onQuickLabel?.(quickLabels[index]);
135
+ if (!commentOnly) {
136
+ const digit = parseInt(e.code.slice(5), 10);
137
+ const index = digit === 0 ? 9 : digit - 1;
138
+ if (index < quickLabels.length) {
139
+ onQuickLabel?.(quickLabels[index]);
140
+ }
143
141
  }
144
142
  return;
145
143
  }
@@ -160,7 +158,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
160
158
  window.removeEventListener("keydown", handleKeyDown);
161
159
  releaseCapture();
162
160
  };
163
- }, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels]);
161
+ }, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels, commentOnly]);
164
162
 
165
163
  useDismissOnOutsideAndEscape({
166
164
  enabled: !showQuickLabels,
@@ -238,20 +236,22 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
238
236
  />
239
237
  {onQuickLabel && (
240
238
  <>
241
- <ToolbarButton
242
- ref={zapButtonRef}
243
- onClick={() => setShowQuickLabels(prev => !prev)}
244
- icon={<ZapIcon />}
245
- label="Quick label"
246
- className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"}
247
- />
239
+ {!commentOnly && (
240
+ <ToolbarButton
241
+ ref={zapButtonRef}
242
+ onClick={() => setShowQuickLabels(prev => !prev)}
243
+ icon={<ZapIcon />}
244
+ label="Quick label"
245
+ className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"}
246
+ />
247
+ )}
248
248
  <ToolbarButton
249
249
  onClick={() => onQuickLabel(THUMBS_UP_LABEL)}
250
250
  icon={<span className="block w-4 h-4 text-sm leading-4 text-center">👍</span>}
251
251
  label="Looks good"
252
252
  className="hover:bg-green-500/10"
253
253
  />
254
- {showQuickLabels && zapButtonRef.current && (
254
+ {!commentOnly && showQuickLabels && zapButtonRef.current && (
255
255
  <FloatingQuickLabelPicker
256
256
  anchorEl={zapButtonRef.current}
257
257
  onSelect={(label) => {
@@ -53,6 +53,14 @@ interface CommentPopoverProps {
53
53
  initialText?: string;
54
54
  /** Called on submit with comment text and optional images */
55
55
  onSubmit: (text: string, images?: ImageAttachment[]) => void;
56
+ /**
57
+ * One-click "Looks good" action (comment-only HTML/live surfaces, where
58
+ * pinpoint clicks open this composer directly and never see the selection
59
+ * toolbar's 👍). Renders a thumbs-up button in the footer; disabled once
60
+ * the user has typed or attached anything, so a click can never discard a
61
+ * draft. The parent owns annotation creation and closing.
62
+ */
63
+ onQuickLookGood?: () => void;
56
64
  /** Optional live draft observer for submit paths outside the popover. */
57
65
  onDraftChange?: (text: string, images?: ImageAttachment[]) => void;
58
66
  /** Called when popover is closed/cancelled */
@@ -148,6 +156,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
148
156
  isGlobal,
149
157
  initialText = '',
150
158
  onSubmit,
159
+ onQuickLookGood,
151
160
  onDraftChange,
152
161
  onClose,
153
162
  draftKey,
@@ -521,6 +530,21 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
521
530
  (allowEmptySubmit && initialText.trim().length > 0);
522
531
  const canAskAI = !!onAskAI && !askAIDisabled && text.trim().length > 0;
523
532
 
533
+ // Shared by both footers. Disabled once anything is typed or attached so a
534
+ // click can never discard a draft; with content present, Save is the path.
535
+ const quickLookGoodButton = onQuickLookGood ? (
536
+ <button
537
+ type="button"
538
+ onClick={onQuickLookGood}
539
+ disabled={hasUnsavedContent}
540
+ className="inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:text-foreground hover:bg-green-500/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
541
+ title={hasUnsavedContent ? 'Clear the comment to use Looks good' : 'Add "Looks good" without typing'}
542
+ >
543
+ <span aria-hidden="true">👍</span>
544
+ Looks good
545
+ </button>
546
+ ) : null;
547
+
524
548
  if (mode === 'dialog') {
525
549
  return createPortal(
526
550
  <div
@@ -632,6 +656,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
632
656
  {!coarsePointer && (
633
657
  <span className="text-[10px] text-muted-foreground">{submitHint}</span>
634
658
  )}
659
+ {quickLookGoodButton}
635
660
  {onAskAI && (
636
661
  <button
637
662
  onClick={handleAskAI}
@@ -781,6 +806,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
781
806
  {!coarsePointer && (
782
807
  <span className="text-[10px] text-muted-foreground">{submitHint}</span>
783
808
  )}
809
+ {quickLookGoodButton}
784
810
  {onAskAI && (
785
811
  <button
786
812
  onClick={handleAskAI}
@@ -1,6 +1,6 @@
1
1
  import React, { useRef, useState, useEffect, useCallback } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
- import { instance } from '@viz-js/viz';
3
+ import type { Viz } from '@viz-js/viz';
4
4
  import type { Block } from '../types';
5
5
 
6
6
  interface ViewBox {
@@ -14,13 +14,52 @@ const ZOOM_STEP = 0.25;
14
14
  const MIN_ZOOM = 0.25;
15
15
  const MAX_ZOOM = 8;
16
16
 
17
- let vizInstancePromise: ReturnType<typeof instance> | null = null;
18
-
19
- function getVizInstance() {
20
- vizInstancePromise ??= instance();
17
+ /**
18
+ * The Graphviz engine (about 1.2 MB of Emscripten JS) is imported inside the
19
+ * render effect, not statically, so a host that bundles by route only fetches
20
+ * it when a dot fence is on the page. WASM instantiation was already deferred
21
+ * to first render; in Plannotator's single-file builds the import is inlined
22
+ * and resolves in a microtask ahead of a render that was already asynchronous.
23
+ */
24
+ const loadVizInstance = (): Promise<Viz> => import('@viz-js/viz').then((m) => m.instance());
25
+
26
+ let vizLoader = loadVizInstance;
27
+ let vizInstancePromise: Promise<Viz> | null = null;
28
+
29
+ /**
30
+ * Delay before the one automatic re-attempt after a failed engine import.
31
+ * Only chunking hosts can fail here (a single-file build never fetches).
32
+ */
33
+ let runtimeRetryDelayMs = 750;
34
+
35
+ /**
36
+ * Memoized engine. A rejected load is dropped from the memo so the next call
37
+ * (the automatic re-attempt, a later mount, or the Retry button) issues a
38
+ * fresh import() instead of replaying the cached rejection.
39
+ */
40
+ function getVizInstance(): Promise<Viz> {
41
+ if (!vizInstancePromise) {
42
+ const attempt = vizLoader().catch((err: unknown) => {
43
+ if (vizInstancePromise === attempt) vizInstancePromise = null;
44
+ throw err;
45
+ });
46
+ vizInstancePromise = attempt;
47
+ }
21
48
  return vizInstancePromise;
22
49
  }
23
50
 
51
+ /** Test hook: stand in for the engine import and shorten the retry delay. */
52
+ export function __setVizLoaderForTests(
53
+ loader: (() => Promise<Viz>) | undefined,
54
+ options?: { retryDelayMs?: number },
55
+ ): void {
56
+ vizLoader = loader ?? loadVizInstance;
57
+ vizInstancePromise = null;
58
+ runtimeRetryDelayMs = options?.retryDelayMs ?? 750;
59
+ }
60
+
61
+ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
62
+
24
63
  function parseViewBox(svgEl: SVGSVGElement): ViewBox | null {
25
64
  const raw = svgEl.getAttribute('viewBox');
26
65
  if (!raw) return null;
@@ -107,6 +146,11 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
107
146
  const containerRef = useRef<HTMLDivElement>(null);
108
147
  const [svg, setSvg] = useState('');
109
148
  const [error, setError] = useState<string | null>(null);
149
+ // True when the failure was the engine import itself (a chunking host's
150
+ // fetch), which is the only failure a Retry can change; a dot syntax error
151
+ // keeps the panel exactly as it always was.
152
+ const [runtimeUnavailable, setRuntimeUnavailable] = useState(false);
153
+ const [retryToken, setRetryToken] = useState(0);
110
154
  const [showSource, setShowSource] = useState(false);
111
155
  const [isExpanded, setIsExpanded] = useState(false);
112
156
 
@@ -158,8 +202,28 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
158
202
  let cancelled = false;
159
203
 
160
204
  const renderDiagram = async () => {
205
+ let viz: Viz;
206
+ try {
207
+ try {
208
+ viz = await getVizInstance();
209
+ } catch {
210
+ // Transient chunk failure on a chunking host: one automatic
211
+ // re-attempt with a fresh import() after a short delay. In a
212
+ // single-file build the first await never rejects, so this branch
213
+ // is unreachable there and the success path is unchanged.
214
+ await wait(runtimeRetryDelayMs);
215
+ if (cancelled) return;
216
+ viz = await getVizInstance();
217
+ }
218
+ } catch (err) {
219
+ if (!cancelled) {
220
+ setError(err instanceof Error ? err.message : 'Failed to render diagram');
221
+ setRuntimeUnavailable(true);
222
+ setSvg('');
223
+ }
224
+ return;
225
+ }
161
226
  try {
162
- const viz = await getVizInstance();
163
227
  const renderedSvg = await viz.renderString(block.content, { format: 'svg' });
164
228
  const cleaned = renderedSvg
165
229
  .replace(/ width="[^"]*"/, ' width="100%"')
@@ -177,10 +241,12 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
177
241
  naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned);
178
242
  setSvg(cleaned);
179
243
  setError(null);
244
+ setRuntimeUnavailable(false);
180
245
  }
181
246
  } catch (err) {
182
247
  if (!cancelled) {
183
248
  setError(err instanceof Error ? err.message : 'Failed to render diagram');
249
+ setRuntimeUnavailable(false);
184
250
  setSvg('');
185
251
  }
186
252
  }
@@ -191,7 +257,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
191
257
  return () => {
192
258
  cancelled = true;
193
259
  };
194
- }, [block.content]);
260
+ }, [block.content, retryToken]);
195
261
 
196
262
  useEffect(() => {
197
263
  zoomLevelRef.current = 1;
@@ -362,6 +428,19 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
362
428
  <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
363
429
  </svg>
364
430
  <span className="text-xs text-destructive font-medium">Graphviz Error</span>
431
+ {runtimeUnavailable && (
432
+ <button
433
+ type="button"
434
+ onClick={() => {
435
+ setError(null);
436
+ setRetryToken((token) => token + 1);
437
+ }}
438
+ className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
439
+ title="Retry loading the diagram renderer"
440
+ >
441
+ Retry
442
+ </button>
443
+ )}
365
444
  </div>
366
445
  <pre className="p-3 text-xs text-destructive/80 overflow-x-auto">{error}</pre>
367
446
  <pre className="p-3 text-xs text-muted-foreground bg-muted/30 border-t border-border/30 overflow-x-auto">