Follow-ups from the Workspaces adoption of 0.33.0: a math-slot module hosts can redirect Mermaid's own katex import to (importer-scoped resolveId recipe in HANDOFF) so a math document fetches one KaTeX chunk owned by the host; HtmlViewer bridgeErrorDisplay ('banner' default, 'none' lets a host own the failure banner while onBridgeUnavailable still fires); the documented bridge alias narrowed to relative sibling imports; resetMathRenderer keeps a registered loader and discards stale in-flight loads via an epoch, with setMathRendererLoader(null) and getMathRendererLoader added. Plannotator's own bundles unchanged (markers and sizes within noise of main). Bumps @plannotator/ui to 0.34.0; core stays 0.25.0.
AI-assisted (Claude) under maintainer direction.
103 KiB
Handoff: reusing Plannotator's document UI in Workspaces
This document is for the team building the commercial Workspaces app. It explains what this PR shipped, how the published packages are put together, and exactly how Workspaces plugs its own backend (storage, auth, realtime, AI) into the same document UI that Plannotator uses — without forking or rebuilding it.
If you read nothing else, read "The 60-second version", "Supported imports", and "The seam catalog".
The 60-second version
- Plannotator's document UI (markdown rendering, theme, the annotation editor, settings, comments, file browser, plan diff, layout) is now two installable npm packages:
@plannotator/ui(React components + hooks + theme) and@plannotator/core(pure utils + types, zero dependencies, browser-safe). - Workspaces installs both, imports the components it wants, imports one stylesheet, loads fonts, and calls
configurePlannotatorUI({ ... })once at startup to plug in its own backend. - Every place the UI talks to a backend is an optional seam. Each seam has a default that reproduces today's Plannotator behavior (hitting
/api/*over fetch). If Workspaces passes its own implementation, the UI uses that instead. If it passes nothing, it behaves like Plannotator. - Plannotator itself is unchanged — it passes nothing and keeps using the defaults. This is the core constraint the whole design protects (see "The law").
What this PR changed (inventory)
New package: @plannotator/core — a browser-safe, zero-dependency package carved out of @plannotator/shared. It holds the pure utilities and types ui depends on, so ui can be installed without dragging in Plannotator's Node/server code. Modules were moved with git mv (not copied). CI typechecks it with no @types/node so a node: import can't sneak in.
Core modules: agents, agent-jobs, agent-terminal, browser-paths, code-file, compress, crypto, external-annotation, extract-code-paths, favicon, feedback-templates, goal-setup, open-in-apps, project, source-save, plus extracted type files (config-types, storage-types, workspace-status-types, ai-context, types).
@plannotator/shared re-exports core via one-line shims — e.g. packages/shared/project.ts is just export * from '@plannotator/core/project';. This is why none of Plannotator's ~99 internal import sites changed: they still import from @plannotator/shared/* and get the moved code transparently.
@plannotator/ui got the host-override seams (the bulk of the diff) plus:
configure.ts— the single front door,configurePlannotatorUI().- Each seam file gained a
setX/resetX(orget) accessor and a default implementation. *.seam.test.tsxfiles — tests proving each seam defaults to Plannotator behavior and routes to a host override when set.- Precompiled
styles.css(~187KB, ~31KB gzip) built fromstyles-entry.cssviavite.css.config.ts, so a consumer doesn't have to wire Tailwind to use the theme. Font binaries are not bundled (the consuming app owns fonts) — including KaTeX's math fonts: the publish build deliberately excludeskatex/dist/katex.min.css(which would inline ~1.1MB of fonts). If you render math, see "Math rendering (KaTeX)" below. wideMode.tsmoved frompackages/editorintoui/utils(it was UI-layer state).
Net: roughly 130 files changed, +5k/−2.4k vs main (regenerate with git diff main --stat for exact numbers — this line goes stale with every rebase). Most of the deletions are the git mv of core modules out of shared; most of the additions are seams + tests + the moved core package.
Architecture: three packages, one rule
@plannotator/core ← pure utils + types. zero deps. browser-safe (no node:). PUBLISHED.
↑
@plannotator/ui ← React components + hooks + theme + configure(). PUBLISHED.
depends on core (exact-version lockstep).
↑
@plannotator/shared ← Node/git/server logic. PRIVATE to the monorepo.
re-exports core's moved modules via shims so Plannotator is untouched.
- Workspaces installs
@plannotator/ui+@plannotator/core. It never touchesshared(that's Plannotator's server-side code). - No circular dependencies by construction:
coreimports nothing,uiimportscore,sharedimportscore. One direction only. - The packages ship TypeScript source, not compiled JS. Workspaces' bundler compiles them (it's an internal consumer, and this keeps source-mapping and tree-shaking clean). That means Workspaces needs a TS/TSX-capable bundler — Vite + React 19 + Tailwind v4, with
moduleResolution: "bundler",allowImportingTsExtensions,jsx: "react-jsx". Because yourtsctype-checks the shipped.ts/.tsxwith your compiler options (skipLibCheckonly exempts.d.ts), the source is kept clean understrict: true— CI-enforced:packages/ui/tsconfig.strict-consumer.jsontype-checks the supported-import surface under full strict as part of the repo'stypecheck, mirroring a standalone Vite consumer (which is also how it was originally verified).
The seam pattern (how an override works)
Each seam is a module-level variable holding the current implementation, defaulting to Plannotator's behavior, with a setter:
// utils/storage.ts (representative)
export interface StorageBackend {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
const cookieBackend: StorageBackend = { /* Plannotator's cookie reads/writes */ };
let backend: StorageBackend = cookieBackend; // ← the default IS today's behavior
export function setStorageBackend(b: StorageBackend) { backend = b; } // ← host override
export function resetStorageBackend() { backend = cookieBackend; } // ← tests restore default
Everything in the UI reads through backend. Plannotator never calls the setter, so it stays on cookies. Workspaces calls setStorageBackend(itsOwnBackend) once at startup (via configurePlannotatorUI) and the whole UI persists settings to Workspaces' store instead.
A note on this being module-level (a "singleton") and not a React Provider: this is intentional and safe for a client-side app. Each user's browser runs its own copy of these variables; there's one logged-in user per browser; nothing is shared across users. The only setup where a module-level global is wrong is server-side rendering — one server process rendering for many concurrent users would let one user's render read another's identity. Workspaces does not do SSR, so this is a non-issue. If Workspaces ever adds SSR for this UI, that's the moment to revisit (the fix would be a React <PlannotatorUIServices> provider, and configurePlannotatorUI would become a thin compatibility shim over it). Until then, don't add that complexity.
The seam catalog
Pass any subset of these to configurePlannotatorUI({ ... }). Anything omitted keeps Plannotator's default. The interfaces below are the real contracts as shipped.
| Seam (config key) | Type | What it controls | Default behavior |
|---|---|---|---|
storageBackend |
StorageBackend |
Where UI settings persist (identity, plan-save prefs, toggles) | Cookies |
identityProvider |
IdentityProvider |
Who the current user is — stamps author, drives the (me) badge, and (via isEditable()) whether the Settings rename controls show |
Reads displayName from ConfigStore (server > cookie > generated "tater" name); editable |
imageSrcResolver |
(path, base?) => string |
Turns a stored image path/ref into a URL the browser can load | /api/image?path=… (http(s) URLs pass through unchanged) |
uploadTransport |
UploadTransport |
Where pasted/attached images upload to | POST /api/upload (multipart), returns { path } |
docPreviewFetcher |
(path, base?) => Promise<DocPreviewResult | null> |
Hover/inline preview of a linked .md doc |
GET /api/doc |
fileTreeBackend |
FileTreeBackend |
The file/folder browser tree + live-watch | GET /api/reference/files, EventSource watch |
draftTransport |
DraftTransport |
Auto-saved annotation drafts (survive a crash/reload) | GET/POST/DELETE /api/draft |
externalAnnotationTransport |
ExternalAnnotationTransport<T> |
Live/agent comments streamed into the doc | SSE /api/external-annotations/stream + polling snapshot + CRUD |
aiTransport |
AITransport |
The "Ask AI" chat session/query/abort/permission | POST /api/ai/{session,query,abort,permission} |
serverSync |
ServerSyncFn |
Push a settings change back to the server | No-op-ish (Plannotator's local sync) |
loadSettingsFromBackend |
boolean |
After install, re-hydrate settings from your storageBackend |
off |
mathRendererLoader |
() => Promise<MathRenderer> |
How KaTeX is loaded when no renderer is registered before the first math node renders (see "Lazy renderers and eager entries"). Once registered, the package default is never called, not even as a fallback after a rejected load, and resetMathRenderer() keeps the registration (0.34.0); a default load already in flight at registration still fills the slot (pre-existing, see setMathRendererLoader), so register before the first math render |
utils/math-default-loader's import('katex'), JS only; CSS stays yours |
identityGenerator |
() => string |
The synchronous generator behind the default "tater" display name when no identityProvider is installed |
A built-in 16 x 16 word pool of the same adjective-noun-tater shape; Plannotator registers the full dictionary via utils/identity-tater |
Interface details worth knowing
StorageBackend — must be synchronous (getItem/setItem/removeItem return immediately). If Workspaces' real store is async (KV, D1, a Durable Object), back this with an in-memory cache that you hydrate before mounting the UI, and write through asynchronously. That's also what loadSettingsFromBackend: true is for — it re-reads settings from your backend right after install, once it's in place.
No cookies on a configured host. Settings resolution is lazy (first settings access, not module import). Plannotator's default backend is cookies (its servers run on random ports, so cookies are the only storage that survives across sessions there), and on first resolution the store seeds missing defaults — including a generated identity — into whatever backend is live. Because configurePlannotatorUI installs your storageBackend before anything reads a setting, a host that configures at startup gets zero plannotator-* cookies written to its origin, ever: all reads and seeding writes go to your backend. (Covered by config/configStore.lazyInit.seam.test.ts.) Only an unconfigured consumer — or one that reads settings before calling configure — falls back to cookie writes.
⚠️ Ordering is load-bearing and nothing enforces it. Call
configurePlannotatorUIonly after your settings hydration has completed. If you configure while the cache is still empty,loadSettingsFromBackendfinds nothing, seeds generated defaults into your backend viasetItem(including a freshly generated random display name), and nothing ever re-runs hydration — so the junk defaults can win over the user's real settings, and if yoursetItemwrites through to durable storage they persist. The sync-and-prehydrated rule is a contract, not a runtime check. (ForlocalStorage, which is already synchronous, none of this bites.)
IdentityProvider — getIdentity(): string (display name), isCurrentUser(author): boolean, and optional isEditable(): boolean (default editable). For Workspaces this is your auth'd user. Return isEditable() => false for logged-in users: Workspaces stamps the author from the server-side account id and users can't rename themselves, so the UI must hide its rename/regenerate controls — otherwise a locally-chosen name diverges from the server-stamped author (the "split author" hazard). Two things to know from the Workspaces side: (1) the current Me projection (GET /v1/me) carries only user_id + email — no display name — so until the backend adds a name field, getIdentity() can only return the email or id; (2) free-text author names are accepted for anonymous commenters on open docs, so isEditable() may return true for that branch.
UploadTransport — upload(file: File): Promise<{ path: string; originalName? }>. The default does Plannotator's POST /api/upload and returns the server path. For Workspaces, send the bytes to your asset API (PUT /v1/workspaces/:wsId/assets/:assetPath) and return the content-addressed URL (or an opaque ref) in path. Notes from the Workspaces asset layer: your API makes the caller choose the asset path and 409s if a document owns it, so your adapter — not the UI — owns path selection (namespace uploads, e.g. an assets/ prefix); it enforces a 10 MiB cap + content-type allowlist, so surface upload failures; and because asset URLs need no signing (content-addressed, served from the cookieless tot.page origin), imageSrcResolver can be a pass-through — returning a full URL in path renders directly (the default resolver passes http(s) URLs through).
DraftTransport — load(), save(body, { keepalive }), remove(generation, { keepalive }). The generation-gated tombstone and keepalive retry logic stay inside the hook; you only provide the three transport calls. keepalive: true means "best-effort deliver this even though the page is closing" (maps to fetch(..., { keepalive: true }) or navigator.sendBeacon). One non-obvious contract on load(): it returns { data, generation }, where generation is the deletion tombstone counter for the no-draft case — Plannotator's server encodes it in the 404 body so a stale tab can't resurrect a deleted draft. If your backend tracks draft deletions, return the tombstone generation with data: null; if it doesn't, return { data, generation: null } and the hook still works (you just lose stale-tab deletion protection).
ExternalAnnotationTransport<T> — subscribe(onEvent, onError) => unsubscribe, getSnapshot(since) => { annotations, version } | null (return null for "no changes", i.e. the 304 case), plus add/remove/update/clear. For Workspaces this is your realtime layer — a Durable Object WebSocket or SSE fanning out comment events. T extends { id: string; source?: string }; if your annotation type adds fields, call setExternalAnnotationTransport<YourType>() directly for full type safety (the configure front door pins the base type for ergonomics).
AITransport and FileTreeBackend currently return Response objects** (the raw fetch response) rather than parsed domain types — session/query return Promise<Response>, loadTree/loadVaultTree return Promise<Response> whose JSON is a known shape. This is a known rough edge (see "Known rough edges"). To satisfy these today, Workspaces has to hand back something Response-shaped (status, .json(), and for query, an SSE body stream). It works, but it leaks the old HTTP contract. We deliberately left it as-is for the first cut (move-don't-rewrite); expect to clean it up in a v2 driven by what's actually painful when you wire it.
How Workspaces consumes it
npm install @plannotator/ui @plannotator/core
// app entry, once at startup
import { configurePlannotatorUI } from "@plannotator/ui/configure";
import "@plannotator/ui/styles.css";
// load fonts (the stylesheet references --font-sans / --font-mono but ships no binaries)
import "@fontsource-variable/inter";
import "@fontsource-variable/geist-mono";
// …or provide your own fonts and set --font-sans / --font-mono to match.
configurePlannotatorUI({
storageBackend, // your settings store (localStorage is already sync)
identityProvider, // your auth'd user (isEditable:false for logged-in users)
imageSrcResolver, // your asset URL scheme (pass-through for content-addressed URLs)
uploadTransport, // upload pasted images to your R2 asset API
docPreviewFetcher, // your doc store
fileTreeBackend, // your workspace file tree + realtime watch
draftTransport, // your draft store
externalAnnotationTransport, // adapt your Yjs/WebSocket realtime onto this
// aiTransport, // omit — Workspaces has no AI backend yet (stays default/off)
serverSync, // your settings push
loadSettingsFromBackend: true, // re-hydrate settings from storageBackend after install
});
// then render the components you want
import { Viewer } from "@plannotator/ui/components/Viewer";
A few component-specific behaviors (e.g. an "open this diff in the editor" action) are passed as props at the render site rather than through configure — those are local to one component, not app-global.
Mapping the seams to Workspaces' actual stack
Grounded in a read of the Workspaces repo (apps/app, apps/usercontent, apps/web, the DocumentDO). The web app doesn't import this UI yet, so this is the greenfield wiring plan.
| Seam | Workspaces backing | Effort |
|---|---|---|
storageBackend |
window.localStorage — already synchronous, matches the seam as-is. (Server-syncing prefs later is optional; not needed for the seam.) |
trivial |
identityProvider |
Read the already-hydrated me from SessionContext (GET /v1/me). getIdentity() returns email/id (no name field yet), isCurrentUser(a) = a === me.user_id, isEditable() => false for logged-in users. |
thin adapter |
imageSrcResolver |
Pass-through — asset URLs are content-addressed and need no signing. | trivial |
uploadTransport |
PUT /v1/workspaces/:wsId/assets/:assetPath → R2 (AssetBytes interface). Adapter owns asset-path selection. |
new adapter |
docPreviewFetcher |
GET /v1/workspaces/:wsId/documents/:docId (D1 + git content store). |
thin adapter |
fileTreeBackend |
GET /v1/workspaces/:wsId/documents (D1 doc list); live-watch via the DocumentDO. |
thin adapter |
draftTransport |
KV or a per-doc Durable Object; sendBeacon for keepalive. |
thin adapter |
externalAnnotationTransport |
Transport kind differs — Workspaces realtime is Yjs-over-WebSocket (DocumentDO), and comments are REST with no live push. Adapt comment events onto the DO awareness channel (or add an SSE endpoint). | biggest adapter |
aiTransport |
No AI backend exists in Workspaces. Leave at default/off until one is built. | new infra (later) |
serverSync |
A Worker endpoint that persists the settings delta. | thin adapter |
Backend follow-up (Workspaces side, not a UI change): if you want readable author names instead of raw user_… ids in comments, the Me/annotation projections need to start carrying a display-name field (WorkOS has first_name/last_name; the current Me projection drops them).
Supported imports (the allowlist)
The exports map is broad (wildcards over ./components/*, ./hooks/*, ./utils/*), because Plannotator's own apps consume the package too. Importable is not the same as supported for a host. A number of exported modules still call Plannotator's local server directly, with no seam — they exist for Plannotator's plan-review/code-review apps and will break (failed fetches to /api/* on your origin) if a host renders them. (The wildcards aren't even literally complete: a handful of .ts files under components/ don't resolve through the *.tsx pattern — e.g. components/diagramLanguages. Everything in the supported table below resolves; stay on the list.)
We deliberately did not restructure the exports map in this PR (move-don't-rewrite); this list is the contract instead.
Supported — safe for a host that configures the seams
| Import | Notes |
|---|---|
configure (configurePlannotatorUI) |
The front door. Also re-exports every seam contract type (StorageBackend, IdentityProvider, UploadTransport/UploadResult, DraftTransport, ExternalAnnotationTransport/ExternalAnnotationEvent, AITransport, FileTreeBackend/VaultNode, ImageSrcResolver, DocPreviewFetcher/DocPreviewResult, ServerSyncFn) so host adapters need one import. |
theme / styles.css |
Theme tokens + precompiled stylesheet. Prefer styles.css. The raw theme export still @imports KaTeX (re-acquiring the fonts styles.css deliberately excludes, as separate lazy files) and contains Tailwind v4 @theme at-rules, so it's inert without Tailwind processing. |
types |
Annotation, Block, AnnotationType, etc. |
utils/parser (parseMarkdownToBlocks, exportAnnotations) |
Pure — no backend. |
components/BlockRenderer + the block components it renders (TableBlock, HtmlBlock, Callout, MermaidBlock, MathBlock, …) |
Pure rendering. |
components/InlineMarkdown |
Code-file hover previews route through the docPreviewFetcher seam. Wiki-link rendering takes the sync resolveLinkedDoc prop (live labels + deleted-doc treatment; see "Wiki-link seams (0.27.0)"). |
components/Viewer |
The full annotatable document. Required props: markdown and taterMode (pass false). Pass disableCodePathValidation unless you implement /api/doc/exists — code-path validation is a prop-level opt-out, not a configure seam. |
components/MarkdownEditor |
Theme-bridging wrapper over @plannotator/markdown-editor. Takes CM6 extensions via the extensions prop (captured ONCE per documentId — see "Wiki-link seams (0.27.0)") and re-exports wikiLinks, embedPicker, embedSlashItem, planEmbedInsert, and their public types. |
components/MarkdownDiff |
Theme-bridging wrapper over @plannotator/markdown-editor's frozen two-revision diff. Same shim pattern as components/MarkdownEditor (ThemeProvider bridge, extensions passthrough, grid card chrome); never editable. See "Frozen markdown diff (0.28.0)". |
components/CommentPopover |
Anchor capture + comment entry. Ask-AI UI renders only if you pass onAskAI. |
components/AnnotationPanel |
Renders from your annotation state; no fetches of its own. |
components/ThemeProvider |
Color-mode context. |
theme-modes (THEME_MODES, Mode) |
The supported Light/Dark/System catalog and mode type. Mode also remains exported from components/ThemeProvider for compatibility with existing consumers. |
components/ImageThumbnail / getImageSrc |
Routes through imageSrcResolver. |
components/AttachmentsButton |
Routes through uploadTransport. |
Seam-backed hooks: useAnnotationHighlighter, useAnnotationDraft, useCodeAnnotationDraft, useExternalAnnotations, useFileBrowser |
Their network access goes through the seams in the catalog above. |
config (ConfigStore) |
Persists through storageBackend. |
components/TableOfContents |
Pure — renders from blocks; pair with useActiveSection for scroll-spy. (Blessed in 0.24.0.) |
components/ResizeHandle + hooks/useResizablePanel |
Layout pair for draggable panel widths; persists the width through the storageBackend seam. (Blessed in 0.24.0.) |
hooks/useActiveSection |
Scroll-spy over rendered headings; no backend. (Blessed in 0.24.0.) |
hooks/useScrollViewport |
Resolves the scrolling element for viewport-aware UI; no backend. (Blessed in 0.24.0.) |
utils/annotationHelpers |
Pure annotation utilities (getAnnotationCountBySection, buildTocHierarchy + TocItem). (Blessed in 0.24.0.) |
components/html-viewer (HtmlViewer, projectHostThreads, buildPersistedHtmlAnchor) |
The raw-HTML annotation viewer: overlay-projected placed markers, pinpoint anchors, multi-target comments. Props + validated bridge protocol; no backend of its own. See "Raw-HTML annotation viewer + syntax-highlighting migration (0.29.0)" and "HTML annotation parity seams". (Blessed in 0.29.0.) |
components/HtmlSurfaceControls |
The eye / refresh / pen header controls for an HTML surface, with per-string labels overrides. Presentation only. See "HTML annotation parity seams". |
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". |
shortcuts (useHtmlAnnotateShortcuts, defineShortcutScope, the scope registry) |
The declarative keyboard-shortcut engine and the per-surface scopes, including the HTML annotate scope (Mod+Shift+A). Pure: React plus utils/platform; no backend. |
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. |
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.) |
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". |
utils/mermaid-math-slot |
Alias target only: what a host redirects Mermaid's own katex import to, so $$ labels in diagrams typeset through the math slot and the host build carries one KaTeX chunk. Never import it yourself. See "Lazy renderers and eager entries", item 2. |
utils/identity-tater |
Side-effect entry that registers the full username dictionary into the identity generator slot. Import it only if you rely on the default tater names and want the full dictionary; a host with identityProvider should not. |
utils/mermaid (loadMermaidRuntime, getMermaidRuntime, getMermaidRuntimeSource, setMermaidRuntime, MERMAID_CONFIG) and utils/mermaid-eager |
The Mermaid runtime slot and its eager registration. Import utils/mermaid-eager to keep Mermaid in your entry chunk as Plannotator does; omit it for the lazy path with retry. See "Lazy renderers and eager entries". |
AI is fully avoidable — with one precision worth knowing. No AI UI is reachable from the supported components: useAIChat is imported only by components/ai/DocumentAIChatPanel and useAIProviderConfig, neither of which any supported component imports, and CommentPopover's Ask-AI affordance exists only behind the optional onAskAI prop. configure.ts does statically import the useAIChat module (it needs setAITransport), but if you never use AI the hook is dead code and bundlers eliminate it — verified empirically: a standalone consumer's production bundle importing the full supported surface contains zero /api/ai strings. Don't import components/ai/* and don't pass aiTransport, and you ship no AI code.
Unsupported — calls Plannotator's local server, no seam
Don't import these in a host. Each hits hardcoded Plannotator endpoints:
components/sidebar/VersionBrowser,hooks/usePlanDiff,components/plan-diff/*—/api/plan/version(s)(Plannotator's version history; Workspaces builds its own versions UI anyway).hooks/useArchive,components/sidebar/ArchiveBrowser—/api/archive/*.hooks/useAgents,hooks/useAgentJobs,components/AgentsTab—/api/agents/*.components/Settings,components/settings/HooksTab— Plannotator-specific tabs (Obsidian vaults, hooks, integrations).components/ExportModal,components/OpenInAppButton—/api/save-notes,/api/open-in(Obsidian/Bear/editor integrations).components/goal-setup/*— Plannotator's goal-package scaffolding endpoints.hooks/useEditorAnnotations—/api/editor-annotations(VS Code extension only).hooks/useLinkedDoc—/api/docdirectly (thedocPreviewFetcherseam coversInlineMarkdown's hover previews, not this full linked-doc overlay).hooks/useValidatedCodePaths—/api/doc/exists(this is whatViewer'sdisableCodePathValidationturns off).utils/sharing— Plannotator's public paste service (share-URL feature).hooks/useUpdateCheck,components/MenuVersionSection,components/PlanHeaderMenu— Plannotator release checks.utils/planAgentInstructions,utils/reviewAgentInstructions— generate agent instructions that curl Plannotator's local API.
If Workspaces ever wants one of these surfaces, the path is the same as everything else: add a seam to the module in a Plannotator PR, don't fork the component.
Math rendering (KaTeX): one-time setup if you render equations
The renderer's MathBlock (and inline math) uses KaTeX. KaTeX's stylesheet and its ~1.1MB of math fonts are deliberately NOT in the published styles.css — bundling them would 9x the CSS for every page load, math or not. This is app-developer setup, done once; end users never touch it. Pick one:
- Self-hosted (recommended for production): copy
katex/dist/katex.min.css+katex/dist/fonts/to your own asset origin and add one<link rel="stylesheet">. No third-party dependency in your serving path; fonts download lazily, only on pages that actually render math. - CDN tag:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@<version>/dist/katex.min.css">in your HTML — pin<version>to thekatexversion in@plannotator/ui's package.json so CSS and the bundled KaTeX JS stay in step. Same lazy-font behavior; adds a third-party origin. - Bundler import:
import 'katex/dist/katex.min.css';next to yourstyles.cssimport — your bundler ships the fonts as separate lazy-loaded files. With npm/bun this resolves out of the box (katexis a dependency of@plannotator/uiand gets hoisted); under pnpm's strictnode_modules, addkatexto your own dependencies to import it directly.
If you skip all three and render math, equations appear as broken-looking raw HTML — that's the symptom to recognize. If you never render math, do nothing.
The JS side is separate and lazy by default: KaTeX's runtime is no longer on the static import graph of MathBlock / InlineMarkdown. A host that renders Viewer without importing @plannotator/ui/utils/math-eager gets the TeX source in the same wrapper for one frame, then the typeset markup once import('katex') resolves. See "Lazy renderers and eager entries" for the opt-back and the loader seam.
The annotation anchor schema (what you're storing)
When a host persists annotations (your REST comment API), the anchor fields on Annotation are the de facto contract. Store them as opaque JSON and round-trip them unchanged — but you should know what they are and when they go stale.
From @plannotator/ui/types:
interface Annotation {
// ...
originalText: string; // the exact text that was selected
startMeta?: { parentTagName: string; parentIndex: number; textOffset: number };
endMeta?: { parentTagName: string; parentIndex: number; textOffset: number };
mathTargets?: Array<{ blockId: string; tex: string; displayMode: boolean }>; // math selections only
}
startMeta/endMeta are web-highlighter's DOM anchors, captured against the rendered document: the tag name of the element containing the selection endpoint, the index of that element among all same-tag elements in the rendered DOM (document order), and the character offset within that element's text. They are positional, not content-addressed — they encode "the 14th P, character 32", not "this sentence".
Reattachment order (in useAnnotationHighlighter, when a stored annotation is re-applied to a rendered document):
- Math targets first — if
mathTargetsis present, the matching KaTeX elements are located byblockId+ exacttexstring. - Anchor restore —
highlighter.fromStore(startMeta, endMeta, originalText, id). Works when the rendered DOM structure matches what it was at capture time. - Text-search fallback — if the anchors produce nothing (DOM changed shape), the hook searches the rendered text for an exact, whitespace-normalized occurrence of
originalTextand wraps it manually. This finds the first occurrence — if the selected text appears more than once, the highlight can attach to the wrong instance. - Failure — if the text is gone too, the hook logs a
console.warnand applies no highlight. The annotation is not deleted: it still appears in the annotation panel and in exported feedback, it just has no visual anchor in the document body.
What this means for a host: anchors survive re-renders of the same markdown. Once the document body is edited, the anchors are best-effort — originalText is the real recovery key, and an annotation whose text was deleted degrades to a panel-only comment. If you build "comments follow the text through edits" on top of this (Workspaces will, with live editing), plan to re-anchor server-side or via your Yjs layer; don't expect these DOM anchors to do it.
Honesty note: the failure path (step 4) is exercised in real use but is not covered by automated tests — nothing in the suite asserts the stale-anchor behavior. Treat the described degradation as accurate-but-unverified-by-CI, and test it in your integration if you depend on it.
Migration caveat — reference-style link resolution (#923): parseMarkdownToBlocks now rewrites CommonMark reference links ([text][id]) and blanks their [id]: url definitions before splitting into blocks, so documents containing that syntax render differently than they did before this pass existed — a [text][id] pair that used to render as literal bracket text now renders as a link, and the definition line disappears from the rendered DOM entirely. That changes both the text and the per-tag DOM index at the affected positions. Any annotation whose startMeta/endMeta was captured against the old (pre-resolution) render of such a document — i.e. persisted before a host upgrades past this change — can restore onto the wrong text after upgrading, same as any other DOM-structure change described above; the text-search fallback (step 3) is the recovery path, and originalText is what to fall back to if you need to re-anchor server-side.
Known rough edges (and why they're fine for now)
-
AITransport/FileTreeBackendleakResponse. They return raw fetchResponseobjects instead of clean domain types ({ sessionId },AsyncIterable<AIMessage>,{ tree, workspaceStatus }). A reviewer correctly flagged this. We kept it deliberately: the goal of this PR was move-don't-rewrite, and reshaping these contracts is exactly the kind of redesign that's better driven by the real consumer (Workspaces) once you feel the pain. Plan a v2 pass on these two once you've wired them. -
InlineMarkdown.tsxis large (~1k lines) and now hosts thedocPreviewFetcherseam inline. Cheap future cleanup: extract the doc-preview seam into its own module so the renderer shrinks. Not blocking. -
Module-level singletons, not a Provider. Covered above — safe because Workspaces is client-side, not SSR. Only revisit if SSR is added.
-
The markdown editor can't take live-collab extensions yet.RESOLVED in 0.27.0. The plan of record shipped exactly as written:@plannotator/atomic-editor≥0.7.0 and@plannotator/markdown-editor≥0.3.2 thread an optionalextensions?prop through to the CM6 editor, and the ui shim now declares and forwards it (see "Wiki-link seams (0.27.0)"). You can thready-codemirror.next— or any CM6 extension, e.g.wikiLinks— throughcomponents/MarkdownEditor. Mind the capture-once-per-documentIdcaveat.
None of these block adoption. They're the honest "here's what we'd polish next" list.
UI engine: Base UI (0.23.0)
As of 0.23.0, @plannotator/ui is built on Base UI (@base-ui/react@^1.6.0 — caret, so your own Base UI install dedupes against ours; two copies would break context across portals) instead of Radix. This follows shadcn/ui making Base UI its default engine (July 2026). The migration was deliberate and whole-package: zero @radix-ui/* packages remain — no mixed engines. Per-component reports with hand-verification checklists live in packages/ui/.migration/.
Dependency changes
- Removed dependencies:
@radix-ui/react-dialog,react-dropdown-menu,react-popover,react-slot,react-tabs,react-tooltip. - Added dependency:
@base-ui/react@^1.6.0(regular dependency — installs transitively, nothing for you to add). - Peer dependency removed:
tailwindcss-animate. The kit's enter/exit animations are now CSS-transition-based (Base UI'sdata-starting-style/data-ending-style), so the plugin is no longer used. If your Tailwind config loaded it only for this package, you can drop it. Remaining peers are unchanged:react,react-dom,tailwindcss.
Breaking API changes in 0.23.0 (what a consumer must change)
asChild→render, everywhere.<Button asChild><a/></Button>becomes<Button render={<a/>}>label</Button>(children go on the wrapper, element props onrender). Applies toButton,Badge,DialogTrigger/DialogClose,DropdownMenuTrigger,PopoverTrigger, and tab parts.- Menu item selection:
onSelect(event)no longer exists. UseonClick; to keep the menu open after a click (the oldevent.preventDefault()idiom), passcloseOnClick={false}.textValue→label. DropdownMenuCheckboxItem/DropdownMenuRadioItemno longer close the menu on click by default (Base UI defaultscloseOnClicktofalsefor these two; plainDropdownMenuItemstill closes). PasscloseOnClickexplicitly for the old behavior.checked="indeterminate"is gone (boolean only).DropdownMenuLabelmust be nested inside aDropdownMenuGroup(it wiresaria-labelledby); a free-floating label was legal under Radix.PopoverAnchorexport removed. Base UI has no Anchor part; anchored positioning is a Positioner concern (if you need a custom anchor, ask for a seam — do not fork the wrapper).- Content-level focus/dismiss callbacks are gone.
onOpenAutoFocus/onCloseAutoFocus→initialFocus/finalFocusprops (element/ref/boolean, onDialogContent/PopoverContent/DropdownMenuContent).onEscapeKeyDown/onPointerDownOutside/onInteractOutside→ the Root'sonOpenChange(open, eventDetails): branch oneventDetails.reason('escape-key','outside-press','focus-out') and calleventDetails.cancel()to block the close. onOpenChangegains a secondeventDetailsargument on every overlay Root. Existing single-arg handlers keep compiling and working.- Styling hooks changed.
data-[state=open/closed]→data-open/data-closed; triggers exposedata-popup-open; active tab isdata-active(wasdata-[state=active]); highlighted menu items aredata-highlighted(items are no longer DOM-focused, sofocus:variants on menu items do nothing). CSS vars:--radix-<comp>-content-transform-origin→--transform-origin,--radix-<comp>-trigger-width→--anchor-width, available-size vars →--available-width/--available-height. - Tabs behavior: arrow keys now move focus WITHOUT activating (Base UI's manual-activation default; pass
<TabsList activateOnFocus>for the Radix feel), and an uncontrolledTabsactivates its first tab by default (Radix activated none). - Tooltip:
childrenmust be a single React element (was loosely typed). Unset-delay defaults shift: open delay 700ms → 600ms, skip-window 300ms → 400ms (irrelevant if you set them viaTooltipProvider).TooltipProviderdeliberately KEEPS the Radix-era prop names (delayDuration,skipDelayDuration,disableHoverableContent) and maps them internally — your provider call sites don't change. - Portals render a wrapper
<div>(Radix portals rendered nothing extra). Only matters if you style popups via direct-child selectors ondocument.body. Buttonnow defaults totype="button"(Base UI's Button primitive). Under Radix it rendered a plain<button>, whose implicit type issubmit— a bare<Button>inside a<form>no longer submits it. Passtype="submit"explicitly (it overrides the default). No in-repo forms exist; this is consumer-only.
Dialog/dropdown enter/exit animations look the same (fade+scale, 150–200ms) but are transitions, not keyframes — the subtle Radix slide-in-from-* nudge on menus is gone, matching the shadcn base registry look.
What did NOT change
- Every export name (
Dialog*,DropdownMenu*,Popover*,Tabs*,Tooltip*,Button,Badge,PopoutDialog,SearchableSelect) and the theme/token system. - The seam catalog and
configurePlannotatorUI()— the engine swap is invisible to the backend seams. - The strict-consumer TS gate (
tsconfig.strict-consumer.json) stayed green throughout; yourtsc --noEmitshould too.
Re-verify your seam contract against 0.23.0 before adopting; the list above is exactly what to test against.
Consumer enablement (0.24.0)
Six items accumulated through Workspaces' first three integration slices. All are additive; every default reproduces 0.23.0 behavior.
AnnotationPanelhost props.renderCardFooter?: (annotation) => ReactNode— a per-card slot at each plan-annotation card's foot (plug reply/resolve UI in; clicks inside the slot don't select the card).readOnly?: boolean— hides the built-in mutation affordances (delete/edit on all card kinds); selection and scrolling still work, and as of 0.30.0 the host footer slot still renders (see "Unanchored-annotation reporting + readOnly footer fix (0.30.0)").- Six more supported imports (already in the table above, tagged Blessed in 0.24.0):
TableOfContents,ResizeHandle+useResizablePanel,useActiveSection,useScrollViewport,utils/annotationHelpers. All verified under the strict-consumer gate. Viewer/CommentPopoverallowImages?: boolean. Passfalsewhen you have nouploadTransport— the attach-image affordance disappears instead of dead-ending. (CommentPopover already had the prop; Viewer now exposes and threads it.)ViewerreadOnly?: boolean. View-only users: suppresses every composer entry point (selection toolbar, comment popovers, quick labels, pinpoint, global comment, attachments, checkbox toggles) while existing annotations still render and select.- Stricter consumer gate.
tsconfig.strict-consumer.jsonnow also enforcesverbatimModuleSyntax,noUnusedLocals,noUnusedParameters— the shipped source passes them, so you no longer have to relax those flags in your own tsconfig. - Content-verifying restore (opt-in).
useAnnotationHighlighter({ verifyRestoredContent: true, onRestoreMismatch }): a position-based restore that resolves onto the wrong text (document drift) is removed and re-anchored by text search; if the original text is gone entirely,onRestoreMismatch(annotation, restoredText)fires and nothing is painted. Default off. If you built a host-side guard for this, you can delete it.
HtmlViewer rendering neutrality (0.25.0)
HtmlViewer no longer writes into a rendered document's namespace (Workspaces' upstream brief; supersedes the H-ask-1 patch — delete it on adoption). Arbitrary HTML now renders exactly as in a standalone browser tab:
- No bare token injection. Host theme tokens travel only as viewer-owned
--pn-*properties (srcdoc block and the bridge's theme handler, which now refuses non---pn-writes). A document defining--muted/--background/etc. keeps its own values in both host themes. - No root mutations. The
lightclass toggle and thecolor-scheme: lightinjection are gone for arbitrary documents; light/dark resolves from the document + OS. - Diff CSS gated and scoped.
<ins>/<del>styles are injected only whilediffActiveand targetins.plannotator-diff/del.plannotator-diff. If your host renders its own version-diff HTML through the viewer, tag the generated wrappers withclass="plannotator-diff"; author-written<ins>/<del>markup is never restyled. - Host theming is opt-in per document.
<meta name="plannotator-theme" content="host">in the document's head restores the bare-token push, thelightroot class, and a symmetriccolor-schemesync — for that document only. Documents relying on the old implicit override must add the tag.
The contract is pinned by components/html-viewer/srcdoc.test.ts (no bare custom-property declarations, no color-scheme, --pn-*-only bridge writes, scoped diff selectors).
Resize-handle seams + file-browser filtering (0.26.0)
Two additive changes; every default reproduces 0.25.0 behavior.
- Resize-handle host seams (
ResizeHandle+useResizablePanel, both already blessed). For hosts that want different edge interactions:ResizeHandlenew props:hideHoverTrack?: boolean(suppress the hover color-reveal entirely),trackClassName?: string(restyle the inner 4px track —classNameonly reaches the outer wrapper), andtooltip?: ReactNode(cursor-following hint, portaled todocument.body, hidden mid-drag). The track also carries a[data-resize-track]attribute (same host-CSS pattern as[data-collapse]), so you can kill the hover reveal from plain CSS:[data-resize-track] { background: none !important; }.useResizablePanelnew options:onClick?: () => voidandclickThreshold?: number(default 4).onClickfires on pointer-up only when the pointer never traveled past the threshold — the hook owns the pointer state machine, so this is the only reliable way to tell a click from a drag-start. Use it to make the whole handle a click-to-collapse target. It never fires on a snap-close or onpointercancel(aborted gestures — palm rejection, system gestures — only clean up drag state). WhenonClickhandles a click, the width is left untouched (not committed/persisted).- Plannotator's own apps now wire these into a new handle UX (no hover track, cursor tooltip, single-click collapse). The package defaults are unchanged — pass nothing and 0.25.0 behavior is exactly preserved.
packages/ui/README.md§ "Resize-handle seams" documents the same from the host's perspective.
- File-browser filtering (
FileBrowser, reached viauseFileBrowser). A built-in filter row above the tree: whitespace-separated tokens AND-match case-insensitively against each file's name (with and without extension) and path (backslashes normalized); folders match on their own name too. While filtering, folders are force-expanded (and non-interactive) and directory collapse state is ignored; Escape clears the query, then closes the input. No new props — consumers get it for free. Behavior pinned bycomponents/sidebar/FileBrowser.test.ts.
Wiki-link seams (0.27.0)
Consumer-enablement round for wiki-links (Workspaces' [[doc_01XYZ|label]] links over opaque doc ids). Three additive seams plus a housekeeping fix; every default reproduces 0.26.0 behavior.
-
MarkdownEditorextensionspassthrough. The shim (components/MarkdownEditor) now declaresextensions?: readonly Extension[](Extensionfrom@codemirror/state) and forwards it through@plannotator/markdown-editorinto the CM6 engine, appended after the built-ins. This is the seam forwikiLinks(config),y-codemirror.nextcollab bindings, custom keymaps (wrap inPrec.highto beat built-ins), etc.⚠️ Captured ONCE per
documentId— not reactive. The engine reads the array a single time, when it mounts the document. Swapping in a different array later is silently ignored until the next remount (adocumentIdchange). Pass a stable reference (module constant oruseMemokeyed ondocumentId), and never encode changing data in the array itself — extension config callbacks may close over live state (refs/getters); that is the supported way to feed dynamic data into a mounted editor.Build extensions against your own
@codemirror/*install: both editor packages declare@codemirror/stateas a peer, so there is one shared copy — a second copy breaks the editor. Seam pinned end-to-end bycomponents/MarkdownEditor.extensions.test.tsx(a facet-based probe mounted through the shim reaches the engine DOM). -
wikiLinksre-exported through the ui surface. Hosts must not import@plannotator/atomic-editor(outside the import allowlist);@plannotator/uiis the single contract.components/MarkdownEditorre-exportswikiLinksand its types —WikiLinksConfig,WikiLinkSuggestion,WikiLinkResolvedTarget,WikiLinkStatus. Usage: buildwikiLinks(config)and pass it via theextensionsprop. The config callbacks (suggest,resolve,onOpen) may close over live state — see the capture-once caveat above. Engine 0.7.0'spreferResolvedLabel?: booleanflag (labeled[[target|label]]links opt into showing the resolved title instead of the stored label) is part of the re-exportedWikiLinksConfig. -
InlineMarkdownresolveLinkedDoc. Synchronous host resolution of wiki-links in the viewer:resolveLinkedDoc?: (target: string) => { label?: string; status?: 'active' | 'deleted' } | null;- Callback absent, or returning
null→ exactly the previous rendering (stored label, live link). label→ displayed instead of the stored label; the stored label is the fallback, the raw target the last resort.status: 'deleted'→ a muted, struck-through non-link span titled "Document deleted" — no anchor, no pointer, no link icon, andonOpenLinkedDocis not wired — even whenonOpenLinkedDocis passed.- The callback receives the raw stored target (
doc_01XYZ), before the.md-appending path normalization;onOpenLinkedDockeeps receiving the normalized path (doc_01XYZ.md) for non-deleted links, unchanged. - Sync-only by design — back it with an in-memory cache you keep hydrated. There is deliberately no async variant, no loading state, no phantom-doc creation, no backlink machinery.
Behavior pinned by
components/InlineMarkdown.resolveLinkedDoc.test.tsx, includingnull→ byte-identicalinnerHTML. - Callback absent, or returning
-
H-ask-1 retired. The two one-line TS6133 fixes Workspaces carried against
components/html-viewer(unusedReactdefault import inHtmlViewer.tsx; unusedannotationsdestructured binding inuseHtmlAnnotation.ts) are applied at source. The shipped html-viewer files passtscunder the strict-consumer flags (--noUnusedLocalsincluded) — delete your patch on adoption.
Dependency note: 0.27.0 requires @plannotator/markdown-editor ^0.3.2 (adds extensions) and @plannotator/atomic-editor ^0.7.0 (adds wikiLinks + preferResolvedLabel).
Embed media picker (0.31.0)
The package now owns the reusable two-stage /embed authoring flow. The host still owns its target catalog, serialized embed grammar, and upload UI/API. This is a per-editor extension seam, not a configurePlannotatorUI() backend seam.
-
Single supported import.
components/MarkdownEditorre-exportsembedSlashItem(),embedPicker(config),EmbedKind,EmbedTarget,EmbedPickerConfig,planEmbedInsert(), andEmbedInsertPlan. Do not import the nested picker module,@plannotator/atomic-editor, or@plannotator/coredirectly from a host. -
Compose both stages. Add the static item to
slashCommands()and register the picker beside it:import { MarkdownEditor, embedPicker, embedSlashItem, slashCommands, } from "@plannotator/ui/components/MarkdownEditor"; const editorExtensions = [ slashCommands({ items: [embedSlashItem()] }), embedPicker({ getTargets: () => currentTargets, buildInsertLine: (target) => buildHostEmbedLine(target), uploadTarget: async (kind) => uploadHostTarget(kind), getNotice: (docBody) => currentEmbedNotice(docBody), }), ]; <MarkdownEditor extensions={editorExtensions} {...editorProps} />;The static item rewrites
/queryto/embedand reopens completion. The picker then performs case-insensitive substring matching over target titles and paths. It deliberately returnsfilter: falseso multi-word titles remain in the session. -
Captured once, callbacks stay live. The
extensionsarray is still captured once perdocumentId. Keep the extension reference stable and closegetTargets,buildInsertLine,uploadTarget, andgetNoticeover live refs or route state. Do not rebuild the array merely because target data changed. -
Grammar belongs to the host; splicing belongs to the package.
buildInsertLine(target)returns the exact line the host wants stored.planEmbedInsert()then normalizes that line into its own blank-line-delimited paragraph and places the caret on the following line. Host-specific path resolution, label escaping, and embed-fragment grammar stay outside the package. -
Upload is optional and single-flight. When
uploadTargetis absent, no upload row is rendered. When present, every picker state includesUpload HTML.... While its promise is pending, the typed/embedtext stays visible and a reopened picker shows an inertUploading...row. Resolving with a target inserts it throughbuildInsertLineand the same splice as an existing target; resolvingnullor rejecting leaves the typed command untouched. The package maps the anchor through CodeMirror transactions and silently drops the insert if the command was edited away. The host owns all failure UI. -
One CodeMirror dependency graph. The picker imports
@codemirror/autocomplete,@codemirror/state, and@codemirror/viewfrom@plannotator/ui's declared dependencies.@plannotator/atomic-editordeclares these as peers, so a consumer must resolve one shared copy. A second live copy of@codemirror/statebreaks extensions just as it does forwikiLinks.
Behavior is pinned by components/MarkdownEditor.embedPicker.test.ts, the supported re-export by components/MarkdownEditor.embedPicker.reexport.test.ts, and the pure splice planner by ../core/embed-insert.test.ts.
Lazy renderers and eager entries (0.32.0)
Four modules that used to ride every document read for a host that bundles by route now load on demand: the Mermaid runtime, the Graphviz engine, KaTeX, and the username dictionary. Plannotator's own apps register KaTeX and the dictionary eagerly in both the plan editor and the review editor, and the plan editor also registers the Mermaid runtime eagerly (the review editor never renders a Mermaid block and deliberately does not), so every surface renders exactly as before; the single-file builds are unchanged in size and first paint and the portal entry chunk keeps Mermaid as on main (the built-HTML markers and the A/B proof live in tests/entry-assets.test.ts and the PR that shipped this).
-
Graphviz: no seam, nothing to do.
GraphvizBlockimports@viz-js/vizinside its render effect. It already showed the source fence until the SVG landed, so the only change for a chunking host is that the first dot fence on a page fetches the engine. A failed import is dropped from the memo and re-attempted once with a freshimport()after a short delay; a persistently failing chunk surfaces as the existing error panel with the source, plus a Retry button that issues another fresh attempt (a diagram syntax error shows the panel exactly as before, without Retry). Hosts that aliased the specifier to a lazy shim can delete the shim.Mermaid: a runtime slot, filled eagerly by Plannotator.
utils/mermaidholds the slot (getMermaidRuntime,setMermaidRuntime,getMermaidRuntimeSource) and the one code pathMermaidBlockuses,loadMermaidRuntime(): it resolves at once from a filled slot and otherwise importsmermaidlazily, initialized once withMERMAID_CONFIG(securityLevel: 'strict'pinned by test), with the same drop-on-rejection, one automatic re-attempt and Retry button as Graphviz.utils/mermaid-eagerimports the runtime statically, initializes it at module evaluation (where the old module-scopeinitializeran) and fills the slot;packages/editor/App.tsximports it by policy, so Plannotator's plan surfaces keep Mermaid in their entry chunk and it can never fail separately from the app (on the share portalmermaid.corestays in the entry, as on main). The review editor does not import it because it never renders a Mermaid block. A host that wants the same addsimport '@plannotator/ui/utils/mermaid-eager'; a host that omits it gets the lazy path.Retry, honestly. An in-page retry cannot recover a chunk whose first fetch failed: browsers record a failed module fetch in the module map for the page lifetime, so a fresh
import()of the same URL rejects without a request, and package code cannot re-import under a new URL because Rollup minifies the chunk's export names. The retry therefore 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 avite:preloadErrorreload at app level. The panel with the source is always shown, never a blank. -
KaTeX: a renderer slot, filled eagerly by Plannotator.
utils/mathholds a synchronous slot (getMathRenderer,setMathRenderer,subscribeMathRenderer), an idempotentloadMathRenderer()whose default loader isimport('katex')(JS only; stylesheet policy is unchanged, see "Math rendering"; since 0.33.0 that default lives in its own module,utils/math-default-loader, see the paragraph on dropping its chunk below), andsetMathRendererLoader.MathBlockand inline math read the slot during render: filled, they typeset synchronously in the same render exactly as before; empty, they render the same wrapper (math-block/math-inline,math-annotatable,data-math-tex,data-math-display,aria-label,data-block-id) with the trimmed TeX as a text child, load the renderer from an effect, and re-render typeset when it lands. Annotation restore and block targeting key on those attributes, so a placeholder is addressable exactly like the typeset node.throwOnError: falseandtrust: falseare applied to every renderer, including one you register.This is the one place the pass-nothing law bends. A host that renders
Viewerand never imports the eager entry now gets lazy math: one frame of TeX text, then typeset. The one-line opt-back for the old behavior:import '@plannotator/ui/utils/math-eager';The seam for the lazy path:
configurePlannotatorUI({ mathRendererLoader: () => Promise.all([import('katex'), import('katex/dist/katex.min.css')]).then(([m]) => m.default) })puts KaTeX and its CSS on one chunk;loadMathRenderer()can be awaited before mounting a body that carries math if you would rather gate first paint yourself.Where the default
import('katex')lives, and how to drop its chunk (0.33.0, from 0.32.0 adoption feedback). The default loader isutils/math-default-loader(loadDefaultMathRenderer), the package's only runtime mention ofkatexoutsidemath-eager;utils/mathcalls it only while no loader is registered (loader === null), and a registered loader is never backfilled by it, not even after the host's load rejects (pinned inutils/math.test.ts). So with a loader registered the default is never requested. One pre-existing ordering rule still applies: a default load already in flight when the host registers its loader keeps going and fills the slot when it lands (documented onsetMathRendererLoader), so register the loader before the first math node renders, in your entry, not in an effect. It is still emitted: Rollup decides chunks statically and cannot see a runtime registration, so a host build that registers a loader still carries akatex-*.jschunk with animport()site pointing at it from the package. Measured on a two-entry Vite 6 consumer of this checkout (one entry registering a loader that is not KaTeX, one registering nothing): both builds emit one 484 KB chunk carrying the KaTeX body. A host that wants that chunk gone aliases the default module at a stub, which is why it is its own module:// vite.config.ts of a host that registers mathRendererLoader resolve: { alias: [{ find: /^(\.\/|@plannotator\/ui\/utils\/)math-default-loader$/, replacement: '/src/no-default-math.ts' }] } // src/no-default-math.ts export function loadDefaultMathRenderer(): Promise<never> { return Promise.reject(new Error('default math loader aliased out')); }With the alias the same consumer build emits zero chunks carrying the KaTeX body out of the package and the entry's only
import()in that area is the host's own loader chunk. Do not alias without registering a loader: math would then render as TeX text forever. Plannotator's entries importmath-eager, so the slot is filled before the first render and this branch is never reached there; the single-file builds inline the default throughinlineDynamicImportsas before (tests/entry-assets.test.tspins the split:utils/mathhas noimport('katex')site,utils/math-default-loaderhas the only one).Mermaid's own KaTeX, and the last shared chunk (0.34.0, from 0.33.0 adoption feedback). The alias above is not the whole story once a page can render a Mermaid diagram. The Mermaid runtime (11.15.0 in this checkout) typesets
$$...$$labels through its ownimport("katex"), insiderenderKatexUnsanitized, and it offers nothing to turn that off:legacyMathML/forceLegacyMathMLonly choose the output mode, the guard around the import is the@mermaid-js/tinybuild marker, and there is no hook to hand it a renderer. The import only runs for a label that matches Mermaid's$$test, but it is emitted regardless, so a host that registered a loader and aliased the default still built akatex-*.jschunk, and because that chunk then had two dynamic importers (the host's loader module and the Mermaid runtime) Rollup kept it separate from the host's loader chunk: a math document fetched two files, the 57-byte loader chunk plus the shared 261 KB KaTeX chunk. Measured on the scratch Vite 6 consumer of this checkout (loader registered, default aliased, a document with inline math, a display block and a flowchart with a$$label): one chunk carried the KaTeX body before,katex-*.js, imported bymermaid.core-*.jsand by the host's loader chunk; 367 JS chunks in all.The fix is a bundler-facing redirect to a package module,
utils/mermaid-math-slot, whose default export has the one method Mermaid calls (renderToString) and delegates to whatever fills the math slot, with Mermaid's own options (throwOnError: true,displayMode: true, the MathMLoutputmode) passed through untouched, so a KaTeX renderer produces exactly the markup Mermaid produced from its direct import. Redirect thekatexspecifier for importers inside themermaidpackage ONLY; a plainresolve.aliasonkatexwould also rewrite your own loader's import and break math everywhere:// vite.config.ts of a host that registers mathRendererLoader (beside the math-default-loader alias) import { fileURLToPath } from 'node:url'; const configFile = fileURLToPath(import.meta.url); const mermaidKatexToSlot: Plugin = { name: 'mermaid-katex-to-plannotator-slot', enforce: 'pre', resolveId(source, importer) { if (source !== 'katex' || !importer || !/[\\/]node_modules[\\/]mermaid[\\/]/.test(importer)) return null; return this.resolve('@plannotator/ui/utils/mermaid-math-slot', configFile, { skipSelf: true }); }, }; // plugins: [mermaidKatexToSlot, react(), ...]Two details of that snippet are layout-proofing. The importer test is
node_modules/mermaid/anywhere in the path, not a pattern for one install layout: a hoisted install puts the runtime atnode_modules/mermaid/, Bun's isolated layout atnode_modules/.bun/mermaid@11.15.0/node_modules/mermaid/, and pnpm's atnode_modules/.pnpm/mermaid@11.15.0/node_modules/mermaid/; every one of them ends in that segment, and the trailing separator keepsmermaid-somethingpackages out. The slot module is resolved from the host's own config file (configFile), not from the Mermaid importer: resolving from the importer walks up from Mermaid's location, which finds@plannotator/uion hoisted and Bun-isolated installs but not under pnpm's strictnode_modules, where the package is only visible from the host root. Resolving from the config file is the same lookup the host's own imports use; passing an absolute path to the file (path.resolve(...)of the installedutils/mermaid-math-slot.ts) works too.With the redirect the same consumer build emits one chunk carrying the KaTeX body, the host's own loader chunk (
host-katex-*.js, 261 KB, reached only by the entry'simport()),mermaid.core-*.jshas no KaTeX import left, and the chunk count drops to 366: one KaTeX chunk, owned by the host, one file fetched. The slot must be filled by the time Mermaid asks, soMermaidBlockawaitsloadMathRenderer()before rendering a diagram whose source carries a$$label (hasMermaidMath, Mermaid's own regex); on a filled slot that resolves at once, on the lazy path it runs your loader, and if that load fails the label throws a message naming the cause (MERMAID_MATH_SLOT_EMPTY_MESSAGE) which the block's error panel shows with the source. Do not import the module yourself; it exists to be resolved to. Plannotator does not redirect: its Mermaid keeps its direct KaTeX, inlined by the single-file builds with everything else, and the pre-render wait is a resolved promise there becausemath-eagerfilled the slot at startup. No test in the repo renders a real Mermaid diagram with a math label (Mermaid does not render under happy-dom), and nothing in Plannotator's own documents exercises$$labels; the bridge is pinned byutils/mermaid-math-slot.test.ts(delegation with Mermaid's exact options, KaTeX parity, the empty-slot error, the label regex) and the pre-render warm by the "Mermaid math labels warm the math slot" cases incomponents/DiagramBlock.lazyRetry.test.tsx.resetMathRenderer()keeps the loader (0.34.0). Through 0.33.0 the reset hook also nulled the registered loader, so a host test harness that reset the slot between cases silently fell back to the package defaultimport('katex')on the next render. Reset now empties the renderer and its source, forgets a load in flight (its late result no longer fills the slot; the nextloadMathRenderer()invokes the registered loader afresh) and leaves the loader registered.setMathRendererLoader(null)is the explicit way back to the package default, andgetMathRendererLoader()reads the registration;configurePlannotatorUIcannot unregister a loader (anullor absentmathRendererLoaderis a no-op there), so only a directsetMathRendererLoader(null)does.setMathRendererLoaderitself is unchanged: a load already in flight at registration still fills the slot, because the component that started it is waiting on that result. The other reset-style helpers were reviewed and are consistent with their names:resetIdentityProviderandresetIdentityGeneratorreset exactly the thing they name (the provider, the generator), and Mermaid's__setMermaidRuntimeLoaderForTestsis a stand-in by name. Pinned inutils/math.test.ts. -
Identity: a generator slot, filled eagerly by Plannotator.
utils/generateIdentityno longer importsunique-username-generator. It holds a synchronous generator slot (setIdentityGenerator,getIdentityGenerator) with a built-in fallback that produces the sameadjective-noun-tatershape from a 16 x 16 pool.utils/identity-taterregisters the full dictionary as a side effect and is what Plannotator's entries import. A host withidentityProvidernever calls the generator and, with the static import gone, no longer ships the word lists; delete any dictionary shim. A host that wants the full dictionary without its own provider imports@plannotator/ui/utils/identity-tater, or passes its ownidentityGeneratortoconfigurePlannotatorUI. The slot is synchronous on purpose:configStorepersists the first generated name to the identity cookie during the first render-time settings read, so a name that arrived later would be a visible identity change. -
Scope, as of 0.34.0. 0.32.0 shipped items 1 to 3 and deliberately left two things out of the design record's list: the raw-HTML bridge script as a separately served asset, and a lazy table popout. 0.33.0 ships the first (see "HTML viewer bridge as an asset" below) and, from adoption feedback, the
utils/math-default-loadersplit in item 2. 0.34.0 adds the Mermaid KaTeX redirect and theresetMathRendererfix in item 2, both from 0.33.0 adoption feedback. The lazy table popout is still not shipped and stays tracked in the design record for a follow-up.
Pinned by utils/math.test.ts, utils/mermaid-math-slot.test.ts, components/MathBlock.firstPaint.test.tsx, utils/generateIdentity.test.ts, components/MermaidBlock.test.ts, components/DiagramBlock.lazyRetry.test.tsx, and the eager-entry and built-HTML marker guards in tests/entry-assets.test.ts.
HTML viewer bridge as an asset (0.33.0)
HtmlViewer injects a 185 KB bridge script (BRIDGE_SCRIPT, components/html-viewer/bridge-script.ts) into every srcdoc document it renders. For a host that bundles by route that literal rode in the viewer chunk and was re-parsed by the browser per document. This release adds an opt-in, bridgeScriptUrl, and leaves the default untouched: Plannotator passes nothing, every Plannotator surface (the annotate srcdoc path, the version diff, PR HTML artifacts, linked .html docs, the share portal) still inlines the string, the live-app proxy still serves the same inline bridge from its own /__plannotator__/bridge.js route, the Pi and OpenCode copies are built from the same code, and the single-file bundles carry the literal exactly once as before (tests/entry-assets.test.ts counts it; the A/B of a Plannotator HTML annotate session on a main build against this build found identical DOM, requests and console).
What the package ships. prepack now also runs scripts/build-bridge-assets.ts, which derives two gitignored files beside the source module, both deterministic and both verified against the module's exports by components/html-viewer/bridgeAsset.test.ts:
components/html-viewer/bridge-script.asset.js: byte-for-byteBRIDGE_SCRIPT, the runnable IIFE. Export subpath@plannotator/ui/components/html-viewer/bridge-script.asset.js. The.asset.jsname is deliberate: a plainbridge-script.jsnext tobridge-script.tswould be picked first by Vite's extension probe for the package's own./bridge-scriptimports and break every consumer build.components/html-viewer/bridge-script.lite.ts: the sameANNOTATION_HIGHLIGHT_CSS,BRIDGE_PROTOCOL_VERSIONandLIVE_BRIDGE_BOOTSTRAPwithBRIDGE_SCRIPT = "". Export subpath@plannotator/ui/components/html-viewer/bridge-script.lite. An alias target only (below).
The TS module stays the source of truth because the Plannotator CLI and the Pi extension import its string exports under Bun.
Host wiring (Workspaces). Serve the asset same-origin as a hashed file through a Vite ?url import and pass the URL to the viewer:
import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-script.asset.js?url";
<HtmlViewer
rawHtml={html}
bridgeScriptUrl={bridgeScriptUrl}
bridgeReadyTimeoutMs={5000} // default; the wait for `ready` per document load
onBridgeUnavailable={(info) => ...} // { kind: 'timeout' | 'version-mismatch', url, ... }
...
/>
With the prop set, buildSrcdocInjection emits <script src="…"></script> in the exact position the inline <script> occupied (there is one injection point, buildBridgeScriptTag in srcdoc.ts, for both paths), so placement is unchanged: at the end of <head>, before the body, on both paths (the page's head scripts run before the bridge, its body scripts after). The URL is resolved against the PARENT document (resolveBridgeScriptUrl(url, document.baseURI)) before it is written into the srcdoc, never against the framed page: the injection follows any <base href> the page declares, so a relative URL left unresolved would let a hostile document point the viewer at an attacker-served bridge and defeat the version check. The srcdoc is rebuilt on rawHtml, theme and diff changes and the browser then re-fetches the asset from cache, so serve it with normal immutable-asset cache headers. An empty string counts as absent (inline). The prop is ignored in live (src) mode, where the proxy injects the bridge.
CSP. Confirmed by grep and pinned by test: the package never writes a CSP <meta> into the srcdoc document (the injection is one <style> and one <script>; an author-written CSP meta is still neutralized as before), and the bridge sets none at runtime. The srcdoc frame is an opaque origin, and a classic <script src> executes without CORS; no crossorigin attribute is set, so do not expect one. A Content-Security-Policy HTTP header on the host page IS inherited by the srcdoc document: a host with its own CSP must allow script-src for the origin the asset is served from (same-origin 'self' in the wiring above). Note the asset form is easier under CSP than the inline form, which would need 'unsafe-inline' or a nonce. One more header to check: an asset served with Cross-Origin-Resource-Policy: same-origin (common with COEP) is blocked for the opaque-origin frame; serve the bridge asset with a CORP that admits cross-origin loads (cross-origin) or without CORP.
Protocol version. BRIDGE_PROTOCOL_VERSION (exported from components/html-viewer/bridge-script and re-exported from components/html-viewer) is embedded in the bridge text and stamped on its ready message as protocolVersion. Note for the design record's "current state": BRIDGE_SCRIPT now carries its first ${} interpolation (that constant, evaluated at module load); it remains a plain string export with no per-session values, so the CLI, Pi and the live proxy consume it exactly as before. The parent (checkBridgeProtocolVersion, HtmlViewer's ready branch) compares it: on the inline path and in live sessions the two sides come from one bundle and always match; on the URL path a cached asset from a previous package version answers with an older stamp, or none, and the viewer logs one console warning naming both versions, shows a dismissible error banner over the top of the frame ([data-bridge-error="version-mismatch"], role="alert", a [data-bridge-error-dismiss] button; the page stays visible) and calls onBridgeUnavailable once. The ready is still honored (an older bridge answers every message shape it knows), so this is a loud diagnostic, not a refusal. Bump the constant whenever a bridge message shape changes in a way an older bridge or parent would misread; a bump forces a warning against any not-yet-redeployed asset, which is the point.
Who renders the strip (bridgeErrorDisplay; 0.34.0, from 0.33.0 adoption feedback). The package owns the failure strip by default: bridgeErrorDisplay="banner" renders the [data-bridge-error] element for both the mismatch and the timeout states exactly as 0.33.0 did, so Plannotator and every existing host are unchanged. A host that renders its own notice from onBridgeUnavailable passes bridgeErrorDisplay="none": no strip and no dismiss button are rendered for either state, while onBridgeUnavailable fires exactly as before and a version mismatch still logs its one console warning. Through 0.33.0 the strip could not be suppressed, so such a host showed two banners. The prop is meaningless on the inline path, which never shows a strip. Pinned by the two bridgeErrorDisplay cases in components/html-viewer/HtmlViewer.bridgeAsset.test.tsx.
Ready timeout. On the URL path only, bridgeReadyTimeoutMs (default 5000) is armed once per document load (URL or srcdoc change), read through a ref, so changing the prop after the bridge is ready never re-arms it; with no ready in time the surface shows [data-bridge-error="timeout"] naming the URL and the wait (not dismissible: the surface is dead), and onBridgeUnavailable({ kind: 'timeout', url, timeoutMs }) fires. A late ready clears it. The inline path arms no timer and can never show a banner.
Dropping the literal from the host chunk (optional). The URL path alone leaves the inline string in the chunk unused, because srcdoc.ts imports it statically (the default must stay synchronous). To remove it, alias the package's ./bridge-script resolution to the generated lite module in your bundler; with Vite:
resolve: {
alias: [{
find: /^\.\/bridge-script$/,
replacement: "@plannotator/ui/components/html-viewer/bridge-script.lite",
}],
}
The find is anchored on purpose (0.34.0; 0.33.0 documented /\/bridge-script$/). Every import of the module inside the package is the relative sibling form, ./bridge-script (srcdoc.ts, useHtmlAnnotation.ts, index.ts), so /^\.\/bridge-script$/ matches exactly those. The unanchored form matched any specifier ENDING in /bridge-script, which is also the shape of another package's entry point (some-dependency/bridge-script) or of a deeper import in your own tree (../vendor/bridge-script), and would have silently swapped those for the lite module too. If your own source has a sibling module named bridge-script, add an importer check (a customResolver on the alias entry, or a resolveId plugin that tests importer for @plannotator/ui/components/html-viewer/) rather than widening the pattern.
Under that alias an HtmlViewer rendered WITHOUT bridgeScriptUrl throws at render (buildBridgeScriptTag refuses to emit an empty inline script), so the misconfiguration cannot ship as a silently dead surface. Measured on the proof harness (PR #1398's description): the viewer chunk shrinks by the size of the literal, 557 kB to 371 kB (168 kB to 118 kB gzip).
Live app annotation is unaffected. packages/shared/live-proxy-bridge-inline.test.ts pins at source level that both proxy transports and both runtimes' composers still ship the inline bridge from the proxy route and never reference bridgeScriptUrl or the generated files.
Pinned by components/html-viewer/bridgeAsset.test.ts (generator bytes, manifest wiring, the single injection point, no CSP meta, the real bridge's stamped ready), components/html-viewer/HtmlViewer.bridgeAsset.test.tsx (URL srcdoc, stale-asset warning and banner, timeout and late ready, inline path unchanged), packages/shared/live-proxy-bridge-inline.test.ts and the bridge marker count in tests/entry-assets.test.ts.
Frozen markdown diff (0.28.0)
One additive component for the Workspaces versions/approvals surface: components/MarkdownDiff, a theme-bridging shim over @plannotator/markdown-editor@0.4.0's MarkdownDiff — a frozen two-revision markdown comparison. The newer revision renders as the real document (uncollapsed, full length); deletions are projected struck-through at their original positions; changed spans get character/word emphasis; a toolbar shows the change count with prev/next navigation; a clickable, keyboard-accessible overview rail and a changed-line gutter complete the review chrome. Every 0.27.0 surface is unchanged.
-
Same shim pattern as
MarkdownEditor. Import from@plannotator/ui/components/MarkdownDiff— neverAtomicDiffEditoror@plannotator/atomic-editordirectly (outside the import allowlist). The shim resolves the color mode fromThemeProvider(hosts without the provider passmodedirectly), imports the same@plannotator/markdown-editor/themes/plannotator.csstheme the editor shim imports, and mapsgridEnabledto the identical design-system card chrome — so toggling editor ↔ diff over the same document doesn't jump. -
The byte contract lives on the handle.
editorHandleRefreceives aMarkdownDiffHandle:getMarkdown()returns the exactmodifiedMarkdownsupplied andgetOriginalMarkdown()the exactoriginalMarkdown— byte-identical, including CRLF and trailing whitespace (the handle returns the caller's strings, not a CM6 read-back). Navigation rides the same handle:getChangeCount(),goToNextChange(),goToPreviousChange(), plusgetContentDOM()for host-level inspection. -
Frozen means frozen. The surface is never editable: document-changing transactions are rejected at both the state and view dispatch boundaries, and the content DOM is
contenteditable="false". Rendered links still work (onLinkClick). -
extensionscomposes like the editor's. Same seam, same calling convention: buildwikiLinks(config)(still re-exported fromcomponents/MarkdownEditor) and pass it throughextensions— wiki-links render inside the frozen view. Captured ONCE per mounted comparison (keyed ondocumentId+ both document strings): pass a stable array, feed changing data through callbacks that close over live state, and build against your own@codemirror/*copies (one shared@codemirror/state, as ever).
Seam pinned end-to-end by components/MarkdownDiff.reexport.test.tsx (public surface + types) and components/MarkdownDiff.frozen.test.tsx (byte preservation incl. CRLF/trailing-space fixtures, contenteditable="false", change navigation, wiki-link composition through the shim, theme/host-class forwarding).
Dependency note: 0.28.0 requires @plannotator/markdown-editor ^0.4.0 (adds MarkdownDiff) and @plannotator/atomic-editor ^0.8.0 (adds the frozen diff engine; new required peer @codemirror/merge, which @plannotator/ui now declares — single-copy discipline unchanged).
Raw-HTML annotation viewer + syntax-highlighting migration (0.29.0)
0.29.0 blesses the rebuilt raw-HTML annotation viewer as supported host surface and carries one breaking migration inherited from the diff-pane highlighter unification. Read both parts before upgrading from 0.28.0.
BREAKING: .hljs is gone — style code via pn-code
highlight.js was removed from the package; the single highlighter is now Shiki via @pierre/diffs (new dependency, pinned 1.3.2). Consumer impact:
- Any host CSS targeting
.hljsor.hljs-*token classes is inert. Fenced code blocks now carrypn-code font-mono language-{lang}— importCODE_BLOCK_CLASSfromutils/codeHighlightinstead of hardcoding class strings. - Per-theme token CSS is the wrong layer now. Fences resolve a real Shiki theme from the active palette (
utils/syntaxTheme,hooks/useFenceTheme); to change code colors, map the palette to a different Shiki theme — don't write token-class CSS. - New supported utils:
utils/codeHighlight(applyHighlight,highlightToHtml,codeBlockClassName,onCodeHighlightSwap),utils/codeBlockMark(annotation marks that survive highlight swaps),utils/syntaxTheme. All pure/browser-safe. - Language-less fences render as plain text — there is no auto-detection anywhere. Don't reintroduce it host-side; it breaks the byte-identity contract the annotation layer depends on.
- Remove any bundler alias on
highlight.js. A host that aliasedhighlight.js/lib/common(or any hljs path) while consuming ≤0.28.0 will now fail at config load — the module no longer exists in the dependency tree. Delete the alias along with the.hljsCSS. (Reported by the first 0.29.0 adopter.) - Known cosmetic install warning:
@pierre/diffs@1.3.2→@pierre/theming@1.0.0declares a peer of@pierre/theme@^1.1.0while2.0.0resolves. Upstream ranges we don't control; harmless, appears in every consumer's install output.
Blessed: components/html-viewer (HtmlViewer)
The overlay-projection annotation viewer for raw HTML (placed comment markers, pinpoint element anchors, shift-click multi-target, drag selection) is now on the supported allowlist, same standing as components/Viewer. The full architecture handoff (anchor model, reconcile loop, message protocol, test map) is a separate document — ask the maintainer for HANDOFF_HTML_ANNOTATION_v0.26.8.md. The contract summary:
- The contract is props + the validated message protocol — not
configurePlannotatorUI.HtmlVieweris driven by its props (rawHtml,annotations,onAddAnnotation,onSelectAnnotation,selectedAnnotationId,mode,inputMethod,readOnly, …) and adapts the sandboxed iframe's validated bridge messages to the same annotation controls the markdownVieweruses. Theconfigure()seams still govern what surrounds it (storage, drafts, images, AI), but nothing about the viewer itself routes throughconfigure(). Integrate on props; that is the path we maintain. - Numbering derives from the
annotationsprop — drive the prop. Marker numbers are computed from the prop's array order (matchingexportAnnotationsnumbering, globals occupying slots) and synced to the iframe on every prop change. Mounting with an empty prop and driving the viewer imperatively is NOT supported and will leave bubbles unnumbered; the imperative handle (applySharedAnnotations,removeHighlight,clearAllHighlights) exists for repaint scenarios on top of a prop-driven mount, not as a substitute for it. If your host architecture truly cannot supply the prop, ask for a numbering seam rather than working around it. readOnlyis view-only, not blank. WithreadOnly, committed annotations still restore, markers still paint with correct numbers, and clicking a marker still firesonSelectAnnotation; every authoring entry point (composer, toolbar, quick label, vim) is disabled. Pinned by the "readOnly view-only contract" tests incomponents/html-viewer/htmlPinpointProtocol.test.tsx.- Security envelope: opaque-origin
srcdocsandbox only. The iframe issandbox="allow-scripts"(noallow-same-origin) and both sides authenticate messages by source identity withtargetOrigin: "*". That pattern is safe only because asrcdocsandbox has an opaque origin. If a host serves annotated content from a real origin (a proxy, a hosted iframe), it must add stricttargetOriginand origin checks — do not reuse the"*"pattern there. - Multi-target cap is 16 on our side.
htmlAdditionalTargetsaccepts up to 16 additional anchors per comment; a host enforcing a smaller product cap (e.g. 7) should cap at composer level before submit — the stored schema is unchanged either way. As with every anchor field, persisthtmlAnchor/htmlAdditionalTargetsas opaque JSON and round-trip them unchanged (see "The annotation anchor schema").
@plannotator/core 0.23.0
Additive only, but required: @plannotator/ui 0.29.0 imports the new @plannotator/core/annotatable subpath (absent from published core 0.22.0), so core 0.23.0 must be installed/published first. Also picks up additive exports in agent-jobs, config-types, favicon, feedback-templates, and an external-annotation PATCH-merge fix (tool-submitted source markers are no longer clearable via PATCH).
Unanchored-annotation reporting + readOnly footer fix (0.30.0)
Two consumer-driven changes: the onUnanchoredChange callback (the accepted ask from the 0.29.0 adoption) and a behavior fix to AnnotationPanel's readOnly mode.
HtmlViewer onUnanchoredChange?: (ids: string[]) => void
Fail-closed anchors hide markers rather than guess, which previously meant an annotation whose content vanished from the page disappeared silently. The viewer now reports it:
- The callback receives the complete current set of annotation ids with no live representation on the page — every target dead (element disconnected AND text unfindable), or the restore never resolved anything. It fires only when the set changes, including back to
[]on recovery. An id being merely offscreen, clipped, or style-hidden is NOT unanchored: its content exists, so no report. - It fires in readOnly mode too — view-only surfaces are exactly where silently missing markers go unnoticed.
- Bounded like every bridge message: at most 512 ids of at most 256 chars; an out-of-contract report is rejected whole at the parent trust boundary.
- Timing: reports ride the overlay reconcile (rAF-coalesced), so expect them shortly after load, after page mutations, and after your own
annotationsprop changes — not synchronously with them.
Pinned by "unanchored ids are reported on change" in components/html-viewer/srcdoc.test.ts (bridge behavior) and the "unanchored report" suite in components/html-viewer/htmlPinpointProtocol.test.tsx (trust boundary + readOnly delivery).
AnnotationPanel readOnly no longer suppresses the host footer slot
Behavior change. Through 0.29.1, readOnly dropped the renderCardFooter slot entirely, which threw away host READ affordances (a replies list, a copy link) along with mutations — view-only panels lost their replies. As of 0.30.0 the footer slot always renders; readOnly hides only the built-in mutation affordances (delete/edit, direct-edit discard). The host gates its own footer contents: if you render mutation UI in the footer, gate it on your own view-only state. A host that relied on the automatic suppression must add that gate when upgrading.
HTML annotation parity seams (0.32.0)
Nine additive seams so a host can run the raw-HTML annotation surface with the same experience Plannotator ships, without app-local code around HtmlViewer. Every default reproduces 0.31.0 behavior; Plannotator's own app passes the same defaults and renders the same DOM (proven by a real-browser A/B of the header, the overlay markers and the annotations panel on a main build versus this build).
-
projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })andbuildPersistedHtmlAnchor(source, { maxBytes = 16384, maxTargets = 16 })are exported fromcomponents/html-viewer(pure, from@plannotator/core/html-anchor). The first projects a host's stored rows ({ id, originalText, htmlAnchor?, htmlAdditionalTargets?, state?, text?, author?, createdA?, images? }) onto theannotationsprop in the host's order, which is the marker numbering; an element anchor without quoted text stays a pageCOMMENT, anchors validate fail-closed, andmaxTargetscaps additional targets on read (default: the viewer's 16). A row with nothing restorable (no quote, no element anchor) projects bydocumentLevel:'global'(the default, Plannotator's model) makes it aGLOBAL_COMMENT, a document-level comment the panel renders without a quote line and the unanchored report never names;'unanchored'keeps it a pageCOMMENTwith an empty quote and no anchor, which the unanchored report names (the panel shows an empty quote line), for hosts that treat such rows as comments that lost their place. The second trims a composed comment's anchor for persistence: product cap first, then a byte budget that truncates the quote down to its 400-char floor before shedding targets from the end, withdroppedTargets(the total),capDroppedTargetsandsizeDroppedTargetsreported (a size drop must never be announced as the product cap). Kept targets serialize with keys intext, label, anchororder, the reference host's wire order, so stored anchors and fingerprints over them are stable on adoption. An input already in that order and within every bound round-trips byte-identical.projectHostThreadsis HTML-only. The projection carries exactly what the raw-HTML surface reads (originalText,htmlAnchor,htmlAdditionalTargets, the type, the presentational fields) and pinsblockIdto"",startOffset/endOffsetto0, with nostartMeta/endMeta. On the markdownViewera projectedCOMMENTwith quoted text still re-anchors:hooks/useAnnotationHighlighterrequiresblockIdonly on the math path and for a metas restore, and with no metas it falls tofindTextInDOM(originalText), a whole-container text search never scoped by block. What such a row loses withblockId""and offsets0: export ordering (exportAnnotationssorts by block index, which is-1for every such row, so they all sort first and tie), the "lines N-M" location label (nullwithout a block), disambiguation when the same text appears more than once (first match wins), and the no-flash meta restore. A host that needs any of those carriesblockId, the offsets and the web-highlighter metas in its own projection; a markdown-aware projection is more than a metas passthrough (the block id and offsets are the anchor) and is deliberately not attempted here. -
onUnanchoredChangeis complete over theannotationsprop and keyed to the bridge's restore. On every bridgeready(a fresh document, a srcdoc reload) the viewer posts its restore batch and then asks the bridge for one complete report (report-unanchored); the bridge answers after its next complete overlay pass even when the set is unchanged, the empty set included, and that answer is the first delivery for that document. Nothing is delivered before it, per document and per reload generation: a prop-side change that lands before the bridge's first post-restore report is folded into that report, not delivered on its own, so a host must not wait on a prop-side set arriving before the restore (a "no callback yet" state until then is the contract, not a missed event). Later bridge reports deliver as they arrive; a prop-side change delivers only when the union actually changes. The union adds what the bridge cannot see: page rows with no quoted text and no element anchor are reported without being posted (aGLOBAL_COMMENTis not, by design), and an id the viewer minted for a locally created comment that the host swapped out ofannotationsfor its own id is dropped. What this replaces on the host side: themark-appliedbookkeeping that fed an unanchored set (failed verdicts, textless rows, the swapped-out local id). It does not replacemark-appliedfor the local-to-server mark swap itself: the package still does not parse that message, and a host that wants the no-flash swap keeps removing its local mark withremoveHighlighton its own refetch (a host content with one frame of no mark removes it on the prop change instead). -
hooks/useHtmlRefresh({ enabled?, documentKey?, fetchSnapshot, onSnapshot, onUnanchored?, onResult? })returns{ canRefresh, isRefreshing, reloadGeneration, refresh, reportAnnotationRestore }.fetchSnapshot(documentKey)resolves{ status: 'ok', rawHtml } | { status: 'missing' } | { status: 'unavailable' }; a rejection counts asunavailable. Key the viewer onreloadGenerationand wire itsonUnanchoredChangetoreportAnnotationRestore. The hook owns the guards: a fetch superseded by a newer refresh or by adocumentKeychange never applies, and the restore acknowledgement fires once per reload generation with the viewer's first report for the remounted document, which by item 2 is the bridge's post-restore set, the empty set included, so a host clears its chip when a previous orphan re-anchors. Notifications are the host's, throughonResult. -
components/HtmlSurfaceControls({ armed, onToggleArmed?, toolsHidden?, onToggleTools?, canRefresh?, onRefresh?, isRefreshing?, compact?, labels? }): the eye, the refresh and the pen with the exact markup, data attributes (data-html-tools-toggle,data-html-refresh,data-html-annotate-toggle),aria-pressedon the pen and the eye,aria-disabledon an in-flight refresh (focus is kept), and the pen's pixel-stable border. Each control renders only when its handler is passed;compactrenders nothing.labelsoverrides any string per key (annotateTitle,interactTitle,annotateLabel,interactLabel,hideTools,showTools,refresh,refreshing,refreshTitle,refreshingTitle); the defaults are Plannotator's pen and eye strings, the refresh default is the neutral "Refresh document", and no penaria-labelis emitted unless a label is passed. Pin the armed state toannotateModeActiveand passonAnnotateModeExit/onAnnotateModeToggleto the viewer so Esc and Mod+Shift+A work. -
AnnotationPanelunanchoredIds?: ReadonlySet<string>renders a small "Unanchored" chip (data-annotation-unanchored) on matching cards. Absent, the DOM is unchanged (pinned by comparing the markup against an explicit empty set). -
HtmlViewerscrollBehavior?: 'smooth' | 'auto'ridesscroll-to { id, behavior? }so a host can carry itsprefers-reduced-motionacross the iframe boundary. Absent means smooth, as before; anything else fails closed to smooth. -
HtmlViewermaxAdditionalTargets?: number(0..16, default 16) is the host's product cap on shift-click targets per comment: enforced at the parent trust boundary, on submit and on restore, and carried onarm-multi-select { key, max }so the bridge's toggle stops at the same number for that draft (reset with the arm on every draft; a value above 16 never raises the package cap). Absent leaves the arm message unchanged. A host that adopts the package's 16 needs neither this prop nor a message-counting listener. Consequence for a host that passes a smaller cap: because it is enforced upstream at every step (the bridge stops the toggle, the parent boundary trims on submit and on restore,projectHostThreadsmaxTargetstrims on read), a composed comment never reaches host code with more targets than the cap, so the host's own cap-dropped handling (capDroppedTargetsfrombuildPersistedHtmlAnchor, or a counting listener) is unreachable in normal operation. Keep it only as a backstop for rows written by an older host build or another writer; the byte-budget drop (sizeDroppedTargets) is a different path and remains reachable. -
ExternalAnnotationTransport.subscribemay emitsnapshotfrom a host push.useExternalAnnotationsfalls back to 500 ms version-gated polling only when the stream errors before its first event. A transport whosesubscribedelivers a{ type: 'snapshot', annotations, version }event whenever the host's realtime layer signals a change (a Durable Object poke, a socket message) keeps the hook on the push path and the fallback poll is never entered. No package change; this is the sanctioned shape. -
Blessed imports:
shortcuts(useHtmlAnnotateShortcutsand the scope registry) andutils/inputMethodjoin the supported table above. Both are fetch-free and/api-free (verified by grep over the modules and everything they import);utils/inputMethodpersists through thestorageBackendseam.
Behavior is pinned by ../core/html-anchor.test.ts, components/html-viewer/unanchored.test.ts and the "unanchored report" suite in components/html-viewer/htmlPinpointProtocol.test.tsx, hooks/useHtmlRefresh.test.tsx, components/HtmlSurfaceControls.test.tsx, components/AnnotationPanel.unanchored.test.tsx, and the cap and scroll-to cases in components/html-viewer/srcdoc.test.ts and htmlPinpointProtocol.test.tsx.
@plannotator/core 0.25.0
Additive only, but required: @plannotator/ui 0.32.0 imports the new @plannotator/core/html-anchor subpath (projectHostThreads, buildPersistedHtmlAnchor), absent from published core 0.24.0, so core 0.25.0 must be installed/published first. Also carries the regenerated guide-viewer-manifest that pins the guides.show stylesheet with the HtmlSurfaceControls rules (see "Publishing & versioning").
0.32.0 also ships the WebMCP provider engine (@plannotator/ui/webmcp, the webmcp seam on configurePlannotatorUI, and the additive Annotation.inReplyTo field); see README.md "WebMCP provider".
Publishing & versioning
- The current pair is
@plannotator/ui0.34.0on@plannotator/core0.25.0. No lockstep again: nothing underpackages/corechanged since the 0.32.0 pair, so core is not republished and ui 0.34.0 pins the already published core 0.25.0 (only the ui tarball is built and published). 0.34.0 carries the 0.33.0 adoption feedback: the Mermaid KaTeX redirect targetutils/mermaid-math-slot,HtmlViewerbridgeErrorDisplay, the anchored bridge-script alias, andresetMathRendererkeeping the registered loader (withsetMathRendererLoader(null)andgetMathRendererLoader). 0.33.0 carried the bridge-script asset (bridge-script.asset.js,bridge-script.lite, both generated byprepack), theutils/math-default-loadersplit, andHANDOFF.mdinside the tarball so the README's section references resolve for a consumer. - Recent pairs, for the consumer's install matrix: ui 0.31.0 on core 0.24.0 (lockstep), ui 0.32.0 on core 0.25.0 (lockstep,
html-anchor), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only, core unchanged). - When a pair IS lockstep, publish
corefirst: ui 0.32.0 imports the new@plannotator/core/html-anchorsubpath, which no earlier published core (0.24.0 and before) has, just as ui 0.29.0 needed core 0.23.0 for@plannotator/core/annotatable. The ui→core dependency resolves exactly at pack time, from the lockfile: after a version bump, runbun installsobun.lockcarries the new workspace versions, orbun pm packwill still stamp the previous core version into ui's tarball (the 0.31.0 lesson). - The HTML annotation seams also changed the guides.show viewer stylesheet (five utility rules from
HtmlSurfaceControls; the viewer JS is unchanged), sopackages/core/guide-viewer-manifest.tsnow pins a CSS hash that exists on guides.show only after the deploy workflow has published this build's/v1/assets. A guide exported from this build before that deploy would pin a stylesheet the host does not serve yet: deploy guides.show before any release that ships this manifest. - They depend on each other via
workspace:*. At publish time that must resolve to the exact version in the tarball, so publish with a tool that does that resolution (the repo's existing flow usesbun pm packto build the tarball, thennpm publish *.tgz --access public). Publishcorefirst, thenui. --provenanceonly works from a supported CI environment (GitHub Actions OIDC) — a local publish fails withAutomatic provenance generation not supported for provider: null. Until a CI publish job exists for these two packages, local publishes drop the flag. Publishing under--tag nextfirst lets the consumer preflight beforenpm dist-tag add <pkg>@<version> latestpromotes it.styles.cssis built by theprepackscript (bun run build:css) so the published tarball always carries fresh precompiled CSS; since 0.33.0prepackalso runsbuild:bridge-assets, which generates the gitignoredbridge-script.asset.jsandbridge-script.lite.tsbeside their source. Both are infiles, so a tarball built withoutprepack(a hand-rollednpm pack --ignore-scripts) would ship export subpaths that resolve to nothing; always build withbun pm pack.- There is no CI publish job for these two packages yet — first publish is manual from
mainafter merge. (Wiring a CI publish job is a follow-up.)
The law (guardrails for anyone editing @plannotator/ui)
These are enforced socially and, where possible, by CI. They exist because a prior from-scratch reimplementation of this UI broke the app and was reverted.
- Don't reimplement the document UI from scratch. Add a seam; don't rebuild.
- Every seam's default must reproduce today's Plannotator behavior. Plannotator passes nothing and stays byte-for-byte unchanged.
@plannotator/coreis browser-safe and zero-dep — nonode:imports. CI enforces it.- Never delete working Plannotator code until a human confirms parity in the browser.
See packages/ui/README.md and packages/ui/AGENTS.md (CLAUDE.md symlink) for the short version that lives next to the code.