remove_comments put the id into the overlay and the tracker then wrote an agent-flagged tombstone, which the nudge builder attributed to the human (the resolution signal). The tools now claim the ids they remove (claimRemoved) and removedSince skips them; a human deletion outside the tools still produces the nudge.
@plannotator/ui
Plannotator's document UI — markdown rendering, themes, the annotation editor, settings, comments, and layout — as installable building blocks. Published so a separate app (the commercial Workspaces app) can reuse the exact same experience, while Plannotator itself stays unchanged.
Ships with @plannotator/core: a small, browser-safe, zero-dependency package of the pure utilities and types ui builds on (carved out so ui can be installed standalone without Plannotator's server code).
Why this exists
Workspaces needs the same document experience Plannotator has — render docs, annotate, comment, theme, edit — but backed by its own infrastructure (its own storage, auth, realtime, AI). Rather than fork or rebuild, it installs these packages and plugs in its own backend. Plannotator passes nothing and behaves exactly as before.
How it works: host-override seams
Every place the UI talks to a backend (loading a doc preview, saving settings, persisting drafts, streaming comments, listing files, calling AI, etc.) is an optional seam that defaults to Plannotator's behavior. A host swaps in its own implementations through one call at startup:
import { configurePlannotatorUI } from "@plannotator/ui/configure";
configurePlannotatorUI({
storageBackend, // where settings persist
identityProvider, // who the current user is
imageSrcResolver, // how image paths resolve to URLs
docPreviewFetcher,
fileTreeBackend,
draftTransport,
externalAnnotationTransport, // live/agent comments
aiTransport,
skillCatalogTransport, // skill-reference catalog for comment composers
skillContentTransport, // human-only skill contents for feedback injection
serverSync,
webmcp, // browser-agent (WebMCP) provider policy: { enabled, namePrefix }
});
Anything you don't pass keeps Plannotator's default. A few component-specific overrides (e.g. an "open in editor" diff action) are passed as props where you render that component.
Resize-handle seams (ResizeHandle / useResizablePanel)
The sidebar/panel resize handle exposes seams for hosts that want different edge interactions (e.g. no hover reveal, click-to-collapse). All default to today's behavior — pass nothing and it's unchanged.
- Suppress / restyle the hover reveal. The inner visible track carries a
[data-resize-track]attribute (same host-CSS pattern as the collapse button's[data-collapse]), andResizeHandletakes atrackClassNameprop. To kill the pop-in from host CSS:[data-resize-track] { background: none !important; } - Click-to-collapse anywhere on the handle. The handle can't tell a click from a drag-start on its own — the hook owns the pointer state machine. Pass
onClicktouseResizablePanel; it fires on pointer-up only when the pointer never traveled pastclickThreshold(default 4px), so a genuine click on the full-width handle can collapse the panel while drags still resize:const resize = useResizablePanel({ storageKey, side: "left", onSnapClose: collapse, onClick: collapse });
Building your own tooltip and removing the built-in double-click reset are host-side concerns (override onDoubleClick where you render the handle).
Markdown editor extensions + wiki links (MarkdownEditor / InlineMarkdown)
-
MarkdownEditortakes CM6 extensions.extensions?: readonly Extension[](from@codemirror/state) is forwarded verbatim into the underlying editor — the seam forwikiLinks(config),y-codemirror.nextcollab bindings, custom keymaps.⚠️ Captured once per
documentId— not reactive. The engine reads the array a single time at document mount; swapping the array later is silently ignored until adocumentIdchange remounts. Pass a stable reference, and feed changing data through extension config callbacks that close over live state (refs/getters) — never through new arrays. -
wikiLinksis re-exported from@plannotator/ui/components/MarkdownEditortogether withWikiLinksConfig,WikiLinkSuggestion,WikiLinkResolvedTarget,WikiLinkStatus. Import it from there —@plannotator/atomic-editorstays off the supported-import list:import { MarkdownEditor, wikiLinks } from "@plannotator/ui/components/MarkdownEditor"; const editorExtensions = [wikiLinks({ suggest, resolve, onOpen })]; // stable reference! <MarkdownEditor markdown={md} documentId={docId} editorHandleRef={ref} extensions={editorExtensions} /> -
embedPicker(config)andembedSlashItem()are re-exported from the same surface. Compose the static item intoslashCommands({ items: [...] })and pass the picker beside it in the stableextensionsarray.getTargets,buildInsertLine, optionaluploadTarget, and optionalgetNoticestay live through callbacks. The host owns embed grammar and upload error UI; the package owns filtering, async anchor mapping, single-flight upload state, and paragraph-safe insertion through the re-exportedplanEmbedInsert(). -
The viewer resolves wiki-links synchronously.
InlineMarkdowntakesresolveLinkedDoc?: (target) => { label?; status?: 'active' | 'deleted' } | null— called with the raw stored target (opaque ids likedoc_01XYZ, no.mdnormalization). Return alabelto display live titles (stored label is the fallback, target the last resort); returnstatus: 'deleted'for a muted non-link ("Document deleted") instead of a live link. Absent ornull→ rendering is unchanged. Sync-only by design: back it with an in-memory cache.
Requires @plannotator/markdown-editor ^0.3.2 and @plannotator/atomic-editor ^0.7.0. See HANDOFF.md § "Wiki-link seams (0.27.0)".
Frozen markdown diff (MarkdownDiff)
MarkdownDiffrenders two markdown revisions as a frozen, themed comparison — the newer revision as the real (uncollapsed) document, deletions projected struck-through in place, char/word change emphasis, a change-count toolbar with prev/next, a clickable keyboard-accessible overview rail, and a changed-line gutter. The surface is never editable (edits are rejected at the state and view boundaries; the content DOM iscontenteditable="false").- Same shim pattern as
MarkdownEditor: theme resolves fromThemeProvider(or passmodedirectly),gridEnabledapplies the identical card chrome, andextensionscomposes CM6 extensions —wikiLinksincluded — into the frozen view, with the same captured-once, stable-reference calling convention:import { MarkdownDiff } from "@plannotator/ui/components/MarkdownDiff"; import { wikiLinks } from "@plannotator/ui/components/MarkdownEditor"; const diffExtensions = [wikiLinks({ resolve, onOpen })]; // stable reference! <MarkdownDiff originalMarkdown={older} modifiedMarkdown={newer} documentId={docId} editorHandleRef={ref} extensions={diffExtensions} /> - Bytes are the contract:
ref.current.getMarkdown()/.getOriginalMarkdown()return the exact input strings (CRLF and trailing whitespace included);getChangeCount()/goToNextChange()/goToPreviousChange()drive review navigation.
Requires @plannotator/markdown-editor ^0.4.0 and @plannotator/atomic-editor ^0.8.0 (which adds a @codemirror/merge peer — declared by this package). See HANDOFF.md § "Frozen markdown diff (0.28.0)".
Raw-HTML annotation viewer (HtmlViewer)
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.
WebMCP provider (@plannotator/ui/webmcp)
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".
modelContext.tsis the only file that spells the spec surface (local structural types, nowebmcp-typesdependency). A spec rename is a one-file change.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, andactive: falseaborts every registration (what Plannotator's Settings opt-out drives).AnnotationChangeTracker/buildNudgesare pure (no DOM): per-annotationseq, tombstones, a per-tab watermark withsinceoverride, and the nudge vocabulary every response carries.- 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 takepath(the folder-session shape) rather than one set per viewer (duplicate names across sets are skipped with a warning, never replaced). - Never register tools inside an untrusted iframe: the raw-HTML viewer's
sandbox="allow-scripts"frame and the live-app frame carry noallow="tools", and that is what keeps a framed page from impersonating the host's tools.
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).
Consuming it (e.g. from Workspaces)
npm install @plannotator/ui @plannotator/core
- Call
configurePlannotatorUI({ ... })once at startup with your backend. - Import the stylesheet:
import "@plannotator/ui/styles.css";(precompiled — no Tailwind wiring needed; if you'd rather run your own Tailwind over the package source, add@sourceglobs for@plannotator/ui'scomponents/,hooks/, andutils/dirs in your own CSS — the package doesn't ship its build entry). - Load the fonts in your app entry — the stylesheet references
--font-sans/--font-monobut does not ship font binaries (standard for a shared UI package; your app owns font loading). Plannotator uses Inter + Geist Mono:Or provide your own fonts and setimport "@fontsource-variable/inter"; import "@fontsource-variable/geist-mono";--font-sans/--font-monoto match. The same policy covers math: KaTeX's stylesheet + fonts are deliberately not instyles.css— if you render math, loadkatex/dist/katex.min.cssyourself (import, CDN tag, or self-hosted copy; see HANDOFF.md "Math rendering"). - Import components:
import { Viewer } from "@plannotator/ui/components/Viewer"; - Build with a bundler that compiles TS/TSX (Vite + React 19 + Tailwind v4). The packages ship source, so your bundler compiles them — set
moduleResolution: "bundler",allowImportingTsExtensions,jsx: "react-jsx".
Packages & publishing
@plannotator/core— pure utils + types, zero deps, browser-safe (CI enforces nonode:imports). Published.@plannotator/ui— React components/hooks + theme +configure(). Depends on@plannotator/core(exact-version lockstep). Published.@plannotator/shared,@plannotator/ai— stay private to the monorepo;sharedre-exportscore's modules via shims so Plannotator's internals are untouched.- Versioned in lockstep with the repo. Publish
corethenui: build each tarball withbun pm pack(resolvesworkspace:*to the exact version at pack time), thennpm publish *.tgz --provenance --access public— the repo's existing flow.
The one rule
Do not reimplement the document UI from scratch. A prior from-scratch rewrite broke the app and was reverted. The supported path is always: keep these components as-is and add a seam where a host needs different backend behavior. Never delete working Plannotator code until a human has confirmed parity in the browser.