Files
backnotprop__plannotator/packages/ui/HANDOFF.md
Michael Ramos 8e0c51f5ff fix(ui): 0.33.0 adoption feedback and bump @plannotator/ui to 0.34.0 (#1402)
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.
2026-08-27 12:08:00 -07:00

103 KiB
Raw Permalink Blame History

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 (or get) accessor and a default implementation.
  • *.seam.test.tsx files — tests proving each seam defaults to Plannotator behavior and routes to a host override when set.
  • Precompiled styles.css (~187KB, ~31KB gzip) built from styles-entry.css via vite.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 excludes katex/dist/katex.min.css (which would inline ~1.1MB of fonts). If you render math, see "Math rendering (KaTeX)" below.
  • wideMode.ts moved from packages/editor into ui/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 touches shared (that's Plannotator's server-side code).
  • No circular dependencies by construction: core imports nothing, ui imports core, shared imports core. 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 your tsc type-checks the shipped .ts/.tsx with your compiler options (skipLibCheck only exempts .d.ts), the source is kept clean under strict: true — CI-enforced: packages/ui/tsconfig.strict-consumer.json type-checks the supported-import surface under full strict as part of the repo's typecheck, 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 configurePlannotatorUI only after your settings hydration has completed. If you configure while the cache is still empty, loadSettingsFromBackend finds nothing, seeds generated defaults into your backend via setItem (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 your setItem writes through to durable storage they persist. The sync-and-prehydrated rule is a contract, not a runtime check. (For localStorage, 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/doc directly (the docPreviewFetcher seam covers InlineMarkdown's hover previews, not this full linked-doc overlay).
  • hooks/useValidatedCodePaths — /api/doc/exists (this is what Viewer's disableCodePathValidation turns 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:

  1. 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.
  2. CDN tag: <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@<version>/dist/katex.min.css"> in your HTML — pin <version> to the katex version 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.
  3. Bundler import: import 'katex/dist/katex.min.css'; next to your styles.css import — your bundler ships the fonts as separate lazy-loaded files. With npm/bun this resolves out of the box (katex is a dependency of @plannotator/ui and gets hoisted); under pnpm's strict node_modules, add katex to 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):

  1. Math targets first — if mathTargets is present, the matching KaTeX elements are located by blockId + exact tex string.
  2. Anchor restore — highlighter.fromStore(startMeta, endMeta, originalText, id). Works when the rendered DOM structure matches what it was at capture time.
  3. Text-search fallback — if the anchors produce nothing (DOM changed shape), the hook searches the rendered text for an exact, whitespace-normalized occurrence of originalText and wraps it manually. This finds the first occurrence — if the selected text appears more than once, the highlight can attach to the wrong instance.
  4. Failure — if the text is gone too, the hook logs a console.warn and 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)

  1. AITransport / FileTreeBackend leak Response. They return raw fetch Response objects 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.

  2. InlineMarkdown.tsx is large (~1k lines) and now hosts the docPreviewFetcher seam inline. Cheap future cleanup: extract the doc-preview seam into its own module so the renderer shrinks. Not blocking.

  3. Module-level singletons, not a Provider. Covered above — safe because Workspaces is client-side, not SSR. Only revisit if SSR is added.

  4. 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 optional extensions? prop through to the CM6 editor, and the ui shim now declares and forwards it (see "Wiki-link seams (0.27.0)"). You can thread y-codemirror.next — or any CM6 extension, e.g. wikiLinks — through components/MarkdownEditor. Mind the capture-once-per-documentId caveat.

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's data-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)

  1. asChild → render, everywhere. <Button asChild><a/></Button> becomes <Button render={<a/>}>label</Button> (children go on the wrapper, element props on render). Applies to Button, Badge, DialogTrigger/DialogClose, DropdownMenuTrigger, PopoverTrigger, and tab parts.
  2. Menu item selection: onSelect(event) no longer exists. Use onClick; to keep the menu open after a click (the old event.preventDefault() idiom), pass closeOnClick={false}. textValue → label.
  3. DropdownMenuCheckboxItem / DropdownMenuRadioItem no longer close the menu on click by default (Base UI defaults closeOnClick to false for these two; plain DropdownMenuItem still closes). Pass closeOnClick explicitly for the old behavior. checked="indeterminate" is gone (boolean only).
  4. DropdownMenuLabel must be nested inside a DropdownMenuGroup (it wires aria-labelledby); a free-floating label was legal under Radix.
  5. PopoverAnchor export 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).
  6. Content-level focus/dismiss callbacks are gone. onOpenAutoFocus/onCloseAutoFocus → initialFocus/finalFocus props (element/ref/boolean, on DialogContent/PopoverContent/DropdownMenuContent). onEscapeKeyDown/onPointerDownOutside/onInteractOutside → the Root's onOpenChange(open, eventDetails): branch on eventDetails.reason ('escape-key', 'outside-press', 'focus-out') and call eventDetails.cancel() to block the close.
  7. onOpenChange gains a second eventDetails argument on every overlay Root. Existing single-arg handlers keep compiling and working.
  8. Styling hooks changed. data-[state=open/closed] → data-open/data-closed; triggers expose data-popup-open; active tab is data-active (was data-[state=active]); highlighted menu items are data-highlighted (items are no longer DOM-focused, so focus: 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.
  9. Tabs behavior: arrow keys now move focus WITHOUT activating (Base UI's manual-activation default; pass <TabsList activateOnFocus> for the Radix feel), and an uncontrolled Tabs activates its first tab by default (Radix activated none).
  10. Tooltip: children must 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 via TooltipProvider). TooltipProvider deliberately KEEPS the Radix-era prop names (delayDuration, skipDelayDuration, disableHoverableContent) and maps them internally — your provider call sites don't change.
  11. Portals render a wrapper <div> (Radix portals rendered nothing extra). Only matters if you style popups via direct-child selectors on document.body.
  12. Button now defaults to type="button" (Base UI's Button primitive). Under Radix it rendered a plain <button>, whose implicit type is submit — a bare <Button> inside a <form> no longer submits it. Pass type="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; your tsc --noEmit should 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.

  1. AnnotationPanel host 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)").
  2. 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.
  3. Viewer/CommentPopover allowImages?: boolean. Pass false when you have no uploadTransport — the attach-image affordance disappears instead of dead-ending. (CommentPopover already had the prop; Viewer now exposes and threads it.)
  4. Viewer readOnly?: 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.
  5. Stricter consumer gate. tsconfig.strict-consumer.json now also enforces verbatimModuleSyntax, noUnusedLocals, noUnusedParameters — the shipped source passes them, so you no longer have to relax those flags in your own tsconfig.
  6. 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:

  1. 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.
  2. No root mutations. The light class toggle and the color-scheme: light injection are gone for arbitrary documents; light/dark resolves from the document + OS.
  3. Diff CSS gated and scoped. <ins>/<del> styles are injected only while diffActive and target ins.plannotator-diff/del.plannotator-diff. If your host renders its own version-diff HTML through the viewer, tag the generated wrappers with class="plannotator-diff"; author-written <ins>/<del> markup is never restyled.
  4. Host theming is opt-in per document. <meta name="plannotator-theme" content="host"> in the document's head restores the bare-token push, the light root class, and a symmetric color-scheme sync — 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.

  1. Resize-handle host seams (ResizeHandle + useResizablePanel, both already blessed). For hosts that want different edge interactions:
    • ResizeHandle new props: hideHoverTrack?: boolean (suppress the hover color-reveal entirely), trackClassName?: string (restyle the inner 4px track — className only reaches the outer wrapper), and tooltip?: ReactNode (cursor-following hint, portaled to document.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; }.
    • useResizablePanel new options: onClick?: () => void and clickThreshold?: number (default 4). onClick fires 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 on pointercancel (aborted gestures — palm rejection, system gestures — only clean up drag state). When onClick handles 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.
  2. File-browser filtering (FileBrowser, reached via useFileBrowser). 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 by components/sidebar/FileBrowser.test.ts.

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.

  1. MarkdownEditor extensions passthrough. The shim (components/MarkdownEditor) now declares extensions?: readonly Extension[] (Extension from @codemirror/state) and forwards it through @plannotator/markdown-editor into the CM6 engine, appended after the built-ins. This is the seam for wikiLinks(config), y-codemirror.next collab bindings, custom keymaps (wrap in Prec.high to 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 (a documentId change). Pass a stable reference (module constant or useMemo keyed on documentId), 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/state as a peer, so there is one shared copy — a second copy breaks the editor. Seam pinned end-to-end by components/MarkdownEditor.extensions.test.tsx (a facet-based probe mounted through the shim reaches the engine DOM).

  2. wikiLinks re-exported through the ui surface. Hosts must not import @plannotator/atomic-editor (outside the import allowlist); @plannotator/ui is the single contract. components/MarkdownEditor re-exports wikiLinks and its types — WikiLinksConfig, WikiLinkSuggestion, WikiLinkResolvedTarget, WikiLinkStatus. Usage: build wikiLinks(config) and pass it via the extensions prop. The config callbacks (suggest, resolve, onOpen) may close over live state — see the capture-once caveat above. Engine 0.7.0's preferResolvedLabel?: boolean flag (labeled [[target|label]] links opt into showing the resolved title instead of the stored label) is part of the re-exported WikiLinksConfig.

  3. InlineMarkdown resolveLinkedDoc. 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, and onOpenLinkedDoc is not wired — even when onOpenLinkedDoc is passed.
    • The callback receives the raw stored target (doc_01XYZ), before the .md-appending path normalization; onOpenLinkedDoc keeps 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, including null → byte-identical innerHTML.

  4. H-ask-1 retired. The two one-line TS6133 fixes Workspaces carried against components/html-viewer (unused React default import in HtmlViewer.tsx; unused annotations destructured binding in useHtmlAnnotation.ts) are applied at source. The shipped html-viewer files pass tsc under the strict-consumer flags (--noUnusedLocals included) — 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.

  1. Single supported import. components/MarkdownEditor re-exports embedSlashItem(), embedPicker(config), EmbedKind, EmbedTarget, EmbedPickerConfig, planEmbedInsert(), and EmbedInsertPlan. Do not import the nested picker module, @plannotator/atomic-editor, or @plannotator/core directly from a host.

  2. 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 /query to /embed and reopens completion. The picker then performs case-insensitive substring matching over target titles and paths. It deliberately returns filter: false so multi-word titles remain in the session.

  3. Captured once, callbacks stay live. The extensions array is still captured once per documentId. Keep the extension reference stable and close getTargets, buildInsertLine, uploadTarget, and getNotice over live refs or route state. Do not rebuild the array merely because target data changed.

  4. 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.

  5. Upload is optional and single-flight. When uploadTarget is absent, no upload row is rendered. When present, every picker state includes Upload HTML.... While its promise is pending, the typed /embed text stays visible and a reopened picker shows an inert Uploading... row. Resolving with a target inserts it through buildInsertLine and the same splice as an existing target; resolving null or 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.

  6. One CodeMirror dependency graph. The picker imports @codemirror/autocomplete, @codemirror/state, and @codemirror/view from @plannotator/ui's declared dependencies. @plannotator/atomic-editor declares these as peers, so a consumer must resolve one shared copy. A second live copy of @codemirror/state breaks extensions just as it does for wikiLinks.

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).

  1. Graphviz: no seam, nothing to do. GraphvizBlock imports @viz-js/viz inside 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 fresh import() 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/mermaid holds the slot (getMermaidRuntime, setMermaidRuntime, getMermaidRuntimeSource) and the one code path MermaidBlock uses, loadMermaidRuntime(): it resolves at once from a filled slot and otherwise imports mermaid lazily, initialized once with MERMAID_CONFIG (securityLevel: 'strict' pinned by test), with the same drop-on-rejection, one automatic re-attempt and Retry button as Graphviz. utils/mermaid-eager imports the runtime statically, initializes it at module evaluation (where the old module-scope initialize ran) and fills the slot; packages/editor/App.tsx imports 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 portal mermaid.core stays 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 adds import '@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 a vite:preloadError reload at app level. The panel with the source is always shown, never a blank.

  2. KaTeX: a renderer slot, filled eagerly by Plannotator. utils/math holds a synchronous slot (getMathRenderer, setMathRenderer, subscribeMathRenderer), an idempotent loadMathRenderer() whose default loader is import('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), and setMathRendererLoader. MathBlock and 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: false and trust: false are applied to every renderer, including one you register.

    This is the one place the pass-nothing law bends. A host that renders Viewer and 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 is utils/math-default-loader (loadDefaultMathRenderer), the package's only runtime mention of katex outside math-eager; utils/math calls 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 in utils/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 on setMathRendererLoader), 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 a katex-*.js chunk with an import() 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 import math-eager, so the slot is filled before the first render and this branch is never reached there; the single-file builds inline the default through inlineDynamicImports as before (tests/entry-assets.test.ts pins the split: utils/math has no import('katex') site, utils/math-default-loader has 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 own import("katex"), inside renderKatexUnsanitized, and it offers nothing to turn that off: legacyMathML / forceLegacyMathML only choose the output mode, the guard around the import is the @mermaid-js/tiny build 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 a katex-*.js chunk, 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 by mermaid.core-*.js and 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 MathML output mode) passed through untouched, so a KaTeX renderer produces exactly the markup Mermaid produced from its direct import. Redirect the katex specifier for importers inside the mermaid package ONLY; a plain resolve.alias on katex would 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 at node_modules/mermaid/, Bun's isolated layout at node_modules/.bun/mermaid@11.15.0/node_modules/mermaid/, and pnpm's at node_modules/.pnpm/mermaid@11.15.0/node_modules/mermaid/; every one of them ends in that segment, and the trailing separator keeps mermaid-something packages 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/ui on hoisted and Bun-isolated installs but not under pnpm's strict node_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 installed utils/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's import()), mermaid.core-*.js has 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, so MermaidBlock awaits loadMathRenderer() 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 because math-eager filled 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 by utils/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 in components/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 default import('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 next loadMathRenderer() invokes the registered loader afresh) and leaves the loader registered. setMathRendererLoader(null) is the explicit way back to the package default, and getMathRendererLoader() reads the registration; configurePlannotatorUI cannot unregister a loader (a null or absent mathRendererLoader is a no-op there), so only a direct setMathRendererLoader(null) does. setMathRendererLoader itself 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: resetIdentityProvider and resetIdentityGenerator reset exactly the thing they name (the provider, the generator), and Mermaid's __setMermaidRuntimeLoaderForTests is a stand-in by name. Pinned in utils/math.test.ts.

  3. Identity: a generator slot, filled eagerly by Plannotator. utils/generateIdentity no longer imports unique-username-generator. It holds a synchronous generator slot (setIdentityGenerator, getIdentityGenerator) with a built-in fallback that produces the same adjective-noun-tater shape from a 16 x 16 pool. utils/identity-tater registers the full dictionary as a side effect and is what Plannotator's entries import. A host with identityProvider never 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 own identityGenerator to configurePlannotatorUI. The slot is synchronous on purpose: configStore persists 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.

  4. 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-loader split in item 2. 0.34.0 adds the Mermaid KaTeX redirect and the resetMathRenderer fix 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-byte BRIDGE_SCRIPT, the runnable IIFE. Export subpath @plannotator/ui/components/html-viewer/bridge-script.asset.js. The .asset.js name is deliberate: a plain bridge-script.js next to bridge-script.ts would be picked first by Vite's extension probe for the package's own ./bridge-script imports and break every consumer build.
  • components/html-viewer/bridge-script.lite.ts: the same ANNOTATION_HIGHLIGHT_CSS, BRIDGE_PROTOCOL_VERSION and LIVE_BRIDGE_BOOTSTRAP with BRIDGE_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.

  1. Same shim pattern as MarkdownEditor. Import from @plannotator/ui/components/MarkdownDiff — never AtomicDiffEditor or @plannotator/atomic-editor directly (outside the import allowlist). The shim resolves the color mode from ThemeProvider (hosts without the provider pass mode directly), imports the same @plannotator/markdown-editor/themes/plannotator.css theme the editor shim imports, and maps gridEnabled to the identical design-system card chrome — so toggling editor ↔ diff over the same document doesn't jump.

  2. The byte contract lives on the handle. editorHandleRef receives a MarkdownDiffHandle: getMarkdown() returns the exact modifiedMarkdown supplied and getOriginalMarkdown() the exact originalMarkdown — 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(), plus getContentDOM() for host-level inspection.

  3. 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).

  4. extensions composes like the editor's. Same seam, same calling convention: build wikiLinks(config) (still re-exported from components/MarkdownEditor) and pass it through extensions — wiki-links render inside the frozen view. Captured ONCE per mounted comparison (keyed on documentId + 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:

  1. Any host CSS targeting .hljs or .hljs-* token classes is inert. Fenced code blocks now carry pn-code font-mono language-{lang} — import CODE_BLOCK_CLASS from utils/codeHighlight instead of hardcoding class strings.
  2. 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.
  3. New supported utils: utils/codeHighlight (applyHighlight, highlightToHtml, codeBlockClassName, onCodeHighlightSwap), utils/codeBlockMark (annotation marks that survive highlight swaps), utils/syntaxTheme. All pure/browser-safe.
  4. 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.
  5. Remove any bundler alias on highlight.js. A host that aliased highlight.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 .hljs CSS. (Reported by the first 0.29.0 adopter.)
  6. Known cosmetic install warning: @pierre/diffs@1.3.2 → @pierre/theming@1.0.0 declares a peer of @pierre/theme@^1.1.0 while 2.0.0 resolves. 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:

  1. The contract is props + the validated message protocol — not configurePlannotatorUI. HtmlViewer is 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 markdown Viewer uses. The configure() seams still govern what surrounds it (storage, drafts, images, AI), but nothing about the viewer itself routes through configure(). Integrate on props; that is the path we maintain.
  2. Numbering derives from the annotations prop — drive the prop. Marker numbers are computed from the prop's array order (matching exportAnnotations numbering, 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.
  3. readOnly is view-only, not blank. With readOnly, committed annotations still restore, markers still paint with correct numbers, and clicking a marker still fires onSelectAnnotation; every authoring entry point (composer, toolbar, quick label, vim) is disabled. Pinned by the "readOnly view-only contract" tests in components/html-viewer/htmlPinpointProtocol.test.tsx.
  4. Security envelope: opaque-origin srcdoc sandbox only. The iframe is sandbox="allow-scripts" (no allow-same-origin) and both sides authenticate messages by source identity with targetOrigin: "*". That pattern is safe only because a srcdoc sandbox has an opaque origin. If a host serves annotated content from a real origin (a proxy, a hosted iframe), it must add strict targetOrigin and origin checks — do not reuse the "*" pattern there.
  5. Multi-target cap is 16 on our side. htmlAdditionalTargets accepts 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, persist htmlAnchor/htmlAdditionalTargets as 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).


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:

  1. 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.
  2. It fires in readOnly mode too — view-only surfaces are exactly where silently missing markers go unnoticed.
  3. 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.
  4. Timing: reports ride the overlay reconcile (rAF-coalesced), so expect them shortly after load, after page mutations, and after your own annotations prop 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).

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).

  1. projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? }) and buildPersistedHtmlAnchor(source, { maxBytes = 16384, maxTargets = 16 }) are exported from components/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 the annotations prop in the host's order, which is the marker numbering; an element anchor without quoted text stays a page COMMENT, anchors validate fail-closed, and maxTargets caps additional targets on read (default: the viewer's 16). A row with nothing restorable (no quote, no element anchor) projects by documentLevel: 'global' (the default, Plannotator's model) makes it a GLOBAL_COMMENT, a document-level comment the panel renders without a quote line and the unanchored report never names; 'unanchored' keeps it a page COMMENT with 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, with droppedTargets (the total), capDroppedTargets and sizeDroppedTargets reported (a size drop must never be announced as the product cap). Kept targets serialize with keys in text, label, anchor order, 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. projectHostThreads is HTML-only. The projection carries exactly what the raw-HTML surface reads (originalText, htmlAnchor, htmlAdditionalTargets, the type, the presentational fields) and pins blockId to "", startOffset / endOffset to 0, with no startMeta / endMeta. On the markdown Viewer a projected COMMENT with quoted text still re-anchors: hooks/useAnnotationHighlighter requires blockId only on the math path and for a metas restore, and with no metas it falls to findTextInDOM(originalText), a whole-container text search never scoped by block. What such a row loses with blockId "" and offsets 0: export ordering (exportAnnotations sorts by block index, which is -1 for every such row, so they all sort first and tie), the "lines N-M" location label (null without 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 carries blockId, 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.

  2. onUnanchoredChange is complete over the annotations prop and keyed to the bridge's restore. On every bridge ready (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 (a GLOBAL_COMMENT is not, by design), and an id the viewer minted for a locally created comment that the host swapped out of annotations for its own id is dropped. What this replaces on the host side: the mark-applied bookkeeping that fed an unanchored set (failed verdicts, textless rows, the swapped-out local id). It does not replace mark-applied for 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 with removeHighlight on its own refetch (a host content with one frame of no mark removes it on the prop change instead).

  3. 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 as unavailable. Key the viewer on reloadGeneration and wire its onUnanchoredChange to reportAnnotationRestore. The hook owns the guards: a fetch superseded by a newer refresh or by a documentKey change 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, through onResult.

  4. 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-pressed on the pen and the eye, aria-disabled on an in-flight refresh (focus is kept), and the pen's pixel-stable border. Each control renders only when its handler is passed; compact renders nothing. labels overrides 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 pen aria-label is emitted unless a label is passed. Pin the armed state to annotateModeActive and pass onAnnotateModeExit / onAnnotateModeToggle to the viewer so Esc and Mod+Shift+A work.

  5. AnnotationPanel unanchoredIds?: 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).

  6. HtmlViewer scrollBehavior?: 'smooth' | 'auto' rides scroll-to { id, behavior? } so a host can carry its prefers-reduced-motion across the iframe boundary. Absent means smooth, as before; anything else fails closed to smooth.

  7. HtmlViewer maxAdditionalTargets?: 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 on arm-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, projectHostThreads maxTargets trims on read), a composed comment never reaches host code with more targets than the cap, so the host's own cap-dropped handling (capDroppedTargets from buildPersistedHtmlAnchor, 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.

  8. ExternalAnnotationTransport.subscribe may emit snapshot from a host push. useExternalAnnotations falls back to 500 ms version-gated polling only when the stream errors before its first event. A transport whose subscribe delivers 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.

  9. Blessed imports: shortcuts (useHtmlAnnotateShortcuts and the scope registry) and utils/inputMethod join the supported table above. Both are fetch-free and /api-free (verified by grep over the modules and everything they import); utils/inputMethod persists through the storageBackend seam.

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/ui 0.34.0 on @plannotator/core 0.25.0. No lockstep again: nothing under packages/core changed 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 target utils/mermaid-math-slot, HtmlViewer bridgeErrorDisplay, the anchored bridge-script alias, and resetMathRenderer keeping the registered loader (with setMathRendererLoader(null) and getMathRendererLoader). 0.33.0 carried the bridge-script asset (bridge-script.asset.js, bridge-script.lite, both generated by prepack), the utils/math-default-loader split, and HANDOFF.md inside 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 core first: ui 0.32.0 imports the new @plannotator/core/html-anchor subpath, 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, run bun install so bun.lock carries the new workspace versions, or bun pm pack will 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), so packages/core/guide-viewer-manifest.ts now 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 uses bun pm pack to build the tarball, then npm publish *.tgz --access public). Publish core first, then ui.
  • --provenance only works from a supported CI environment (GitHub Actions OIDC) — a local publish fails with Automatic 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 next first lets the consumer preflight before npm dist-tag add <pkg>@<version> latest promotes it.
  • styles.css is built by the prepack script (bun run build:css) so the published tarball always carries fresh precompiled CSS; since 0.33.0 prepack also runs build:bridge-assets, which generates the gitignored bridge-script.asset.js and bridge-script.lite.ts beside their source. Both are in files, so a tarball built without prepack (a hand-rolled npm pack --ignore-scripts) would ship export subpaths that resolve to nothing; always build with bun pm pack.
  • There is no CI publish job for these two packages yet — first publish is manual from main after 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.

  1. Don't reimplement the document UI from scratch. Add a seam; don't rebuild.
  2. Every seam's default must reproduce today's Plannotator behavior. Plannotator passes nothing and stays byte-for-byte unchanged.
  3. @plannotator/core is browser-safe and zero-dep — no node: imports. CI enforces it.
  4. 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.