The bridge now names WHICH annotations have no live representation on the page (every target dead, or the restore never resolved), reported on change through a new validated message and the HtmlViewer onUnanchoredChange prop, delivered in readOnly mode too. Fail-closed anchors previously hid markers silently. AnnotationPanel readOnly no longer suppresses the renderCardFooter slot: its contents are host-owned and may be read affordances (replies, links), so the host gates what belongs in it. Built-in delete/edit and direct-edit discard stay hidden.
59 KiB
Handoff: reusing Plannotator's document UI in Workspaces
This document is for the team building the commercial Workspaces app. It explains what this PR shipped, how the published packages are put together, and exactly how Workspaces plugs its own backend (storage, auth, realtime, AI) into the same document UI that Plannotator uses — without forking or rebuilding it.
If you read nothing else, read "The 60-second version", "Supported imports", and "The seam catalog".
The 60-second version
- Plannotator's document UI (markdown rendering, theme, the annotation editor, settings, comments, file browser, plan diff, layout) is now two installable npm packages:
@plannotator/ui(React components + hooks + theme) and@plannotator/core(pure utils + types, zero dependencies, browser-safe). - Workspaces installs both, imports the components it wants, imports one stylesheet, loads fonts, and calls
configurePlannotatorUI({ ... })once at startup to plug in its own backend. - Every place the UI talks to a backend is an optional seam. Each seam has a default that reproduces today's Plannotator behavior (hitting
/api/*over fetch). If Workspaces passes its own implementation, the UI uses that instead. If it passes nothing, it behaves like Plannotator. - Plannotator itself is unchanged — it passes nothing and keeps using the defaults. This is the core constraint the whole design protects (see "The law").
What this PR changed (inventory)
New package: @plannotator/core — a browser-safe, zero-dependency package carved out of @plannotator/shared. It holds the pure utilities and types ui depends on, so ui can be installed without dragging in Plannotator's Node/server code. Modules were moved with git mv (not copied). CI typechecks it with no @types/node so a node: import can't sneak in.
Core modules: agents, agent-jobs, agent-terminal, browser-paths, code-file, compress, crypto, external-annotation, extract-code-paths, favicon, feedback-templates, goal-setup, open-in-apps, project, source-save, plus extracted type files (config-types, storage-types, workspace-status-types, ai-context, types).
@plannotator/shared re-exports core via one-line shims — e.g. packages/shared/project.ts is just export * from '@plannotator/core/project';. This is why none of Plannotator's ~99 internal import sites changed: they still import from @plannotator/shared/* and get the moved code transparently.
@plannotator/ui got the host-override seams (the bulk of the diff) plus:
configure.ts— the single front door,configurePlannotatorUI().- Each seam file gained a
setX/resetX(orget) accessor and a default implementation. *.seam.test.tsxfiles — tests proving each seam defaults to Plannotator behavior and routes to a host override when set.- Precompiled
styles.css(~187KB, ~31KB gzip) built fromstyles-entry.cssviavite.css.config.ts, so a consumer doesn't have to wire Tailwind to use the theme. Font binaries are not bundled (the consuming app owns fonts) — including KaTeX's math fonts: the publish build deliberately excludeskatex/dist/katex.min.css(which would inline ~1.1MB of fonts). If you render math, see "Math rendering (KaTeX)" below. wideMode.tsmoved frompackages/editorintoui/utils(it was UI-layer state).
Net: roughly 130 files changed, +5k/−2.4k vs main (regenerate with git diff main --stat for exact numbers — this line goes stale with every rebase). Most of the deletions are the git mv of core modules out of shared; most of the additions are seams + tests + the moved core package.
Architecture: three packages, one rule
@plannotator/core ← pure utils + types. zero deps. browser-safe (no node:). PUBLISHED.
↑
@plannotator/ui ← React components + hooks + theme + configure(). PUBLISHED.
depends on core (exact-version lockstep).
↑
@plannotator/shared ← Node/git/server logic. PRIVATE to the monorepo.
re-exports core's moved modules via shims so Plannotator is untouched.
- Workspaces installs
@plannotator/ui+@plannotator/core. It never touchesshared(that's Plannotator's server-side code). - No circular dependencies by construction:
coreimports nothing,uiimportscore,sharedimportscore. One direction only. - The packages ship TypeScript source, not compiled JS. Workspaces' bundler compiles them (it's an internal consumer, and this keeps source-mapping and tree-shaking clean). That means Workspaces needs a TS/TSX-capable bundler — Vite + React 19 + Tailwind v4, with
moduleResolution: "bundler",allowImportingTsExtensions,jsx: "react-jsx". Because yourtsctype-checks the shipped.ts/.tsxwith your compiler options (skipLibCheckonly exempts.d.ts), the source is kept clean understrict: true— CI-enforced:packages/ui/tsconfig.strict-consumer.jsontype-checks the supported-import surface under full strict as part of the repo'stypecheck, mirroring a standalone Vite consumer (which is also how it was originally verified).
The seam pattern (how an override works)
Each seam is a module-level variable holding the current implementation, defaulting to Plannotator's behavior, with a setter:
// utils/storage.ts (representative)
export interface StorageBackend {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
const cookieBackend: StorageBackend = { /* Plannotator's cookie reads/writes */ };
let backend: StorageBackend = cookieBackend; // ← the default IS today's behavior
export function setStorageBackend(b: StorageBackend) { backend = b; } // ← host override
export function resetStorageBackend() { backend = cookieBackend; } // ← tests restore default
Everything in the UI reads through backend. Plannotator never calls the setter, so it stays on cookies. Workspaces calls setStorageBackend(itsOwnBackend) once at startup (via configurePlannotatorUI) and the whole UI persists settings to Workspaces' store instead.
A note on this being module-level (a "singleton") and not a React Provider: this is intentional and safe for a client-side app. Each user's browser runs its own copy of these variables; there's one logged-in user per browser; nothing is shared across users. The only setup where a module-level global is wrong is server-side rendering — one server process rendering for many concurrent users would let one user's render read another's identity. Workspaces does not do SSR, so this is a non-issue. If Workspaces ever adds SSR for this UI, that's the moment to revisit (the fix would be a React <PlannotatorUIServices> provider, and configurePlannotatorUI would become a thin compatibility shim over it). Until then, don't add that complexity.
The seam catalog
Pass any subset of these to configurePlannotatorUI({ ... }). Anything omitted keeps Plannotator's default. The interfaces below are the real contracts as shipped.
| Seam (config key) | Type | What it controls | Default behavior |
|---|---|---|---|
storageBackend |
StorageBackend |
Where UI settings persist (identity, plan-save prefs, toggles) | Cookies |
identityProvider |
IdentityProvider |
Who the current user is — stamps author, drives the (me) badge, and (via isEditable()) whether the Settings rename controls show |
Reads displayName from ConfigStore (server > cookie > generated "tater" name); editable |
imageSrcResolver |
(path, base?) => string |
Turns a stored image path/ref into a URL the browser can load | /api/image?path=… (http(s) URLs pass through unchanged) |
uploadTransport |
UploadTransport |
Where pasted/attached images upload to | POST /api/upload (multipart), returns { path } |
docPreviewFetcher |
(path, base?) => Promise<DocPreviewResult | null> |
Hover/inline preview of a linked .md doc |
GET /api/doc |
fileTreeBackend |
FileTreeBackend |
The file/folder browser tree + live-watch | GET /api/reference/files, EventSource watch |
draftTransport |
DraftTransport |
Auto-saved annotation drafts (survive a crash/reload) | GET/POST/DELETE /api/draft |
externalAnnotationTransport |
ExternalAnnotationTransport<T> |
Live/agent comments streamed into the doc | SSE /api/external-annotations/stream + polling snapshot + CRUD |
aiTransport |
AITransport |
The "Ask AI" chat session/query/abort/permission | POST /api/ai/{session,query,abort,permission} |
serverSync |
ServerSyncFn |
Push a settings change back to the server | No-op-ish (Plannotator's local sync) |
loadSettingsFromBackend |
boolean |
After install, re-hydrate settings from your storageBackend |
off |
Interface details worth knowing
StorageBackend — must be synchronous (getItem/setItem/removeItem return immediately). If Workspaces' real store is async (KV, D1, a Durable Object), back this with an in-memory cache that you hydrate before mounting the UI, and write through asynchronously. That's also what loadSettingsFromBackend: true is for — it re-reads settings from your backend right after install, once it's in place.
No cookies on a configured host. Settings resolution is lazy (first settings access, not module import). Plannotator's default backend is cookies (its servers run on random ports, so cookies are the only storage that survives across sessions there), and on first resolution the store seeds missing defaults — including a generated identity — into whatever backend is live. Because configurePlannotatorUI installs your storageBackend before anything reads a setting, a host that configures at startup gets zero plannotator-* cookies written to its origin, ever: all reads and seeding writes go to your backend. (Covered by config/configStore.lazyInit.seam.test.ts.) Only an unconfigured consumer — or one that reads settings before calling configure — falls back to cookie writes.
⚠️ Ordering is load-bearing and nothing enforces it. Call
configurePlannotatorUIonly after your settings hydration has completed. If you configure while the cache is still empty,loadSettingsFromBackendfinds nothing, seeds generated defaults into your backend viasetItem(including a freshly generated random display name), and nothing ever re-runs hydration — so the junk defaults can win over the user's real settings, and if yoursetItemwrites through to durable storage they persist. The sync-and-prehydrated rule is a contract, not a runtime check. (ForlocalStorage, which is already synchronous, none of this bites.)
IdentityProvider — getIdentity(): string (display name), isCurrentUser(author): boolean, and optional isEditable(): boolean (default editable). For Workspaces this is your auth'd user. Return isEditable() => false for logged-in users: Workspaces stamps the author from the server-side account id and users can't rename themselves, so the UI must hide its rename/regenerate controls — otherwise a locally-chosen name diverges from the server-stamped author (the "split author" hazard). Two things to know from the Workspaces side: (1) the current Me projection (GET /v1/me) carries only user_id + email — no display name — so until the backend adds a name field, getIdentity() can only return the email or id; (2) free-text author names are accepted for anonymous commenters on open docs, so isEditable() may return true for that branch.
UploadTransport — upload(file: File): Promise<{ path: string; originalName? }>. The default does Plannotator's POST /api/upload and returns the server path. For Workspaces, send the bytes to your asset API (PUT /v1/workspaces/:wsId/assets/:assetPath) and return the content-addressed URL (or an opaque ref) in path. Notes from the Workspaces asset layer: your API makes the caller choose the asset path and 409s if a document owns it, so your adapter — not the UI — owns path selection (namespace uploads, e.g. an assets/ prefix); it enforces a 10 MiB cap + content-type allowlist, so surface upload failures; and because asset URLs need no signing (content-addressed, served from the cookieless tot.page origin), imageSrcResolver can be a pass-through — returning a full URL in path renders directly (the default resolver passes http(s) URLs through).
DraftTransport — load(), save(body, { keepalive }), remove(generation, { keepalive }). The generation-gated tombstone and keepalive retry logic stay inside the hook; you only provide the three transport calls. keepalive: true means "best-effort deliver this even though the page is closing" (maps to fetch(..., { keepalive: true }) or navigator.sendBeacon). One non-obvious contract on load(): it returns { data, generation }, where generation is the deletion tombstone counter for the no-draft case — Plannotator's server encodes it in the 404 body so a stale tab can't resurrect a deleted draft. If your backend tracks draft deletions, return the tombstone generation with data: null; if it doesn't, return { data, generation: null } and the hook still works (you just lose stale-tab deletion protection).
ExternalAnnotationTransport<T> — subscribe(onEvent, onError) => unsubscribe, getSnapshot(since) => { annotations, version } | null (return null for "no changes", i.e. the 304 case), plus add/remove/update/clear. For Workspaces this is your realtime layer — a Durable Object WebSocket or SSE fanning out comment events. T extends { id: string; source?: string }; if your annotation type adds fields, call setExternalAnnotationTransport<YourType>() directly for full type safety (the configure front door pins the base type for ergonomics).
AITransport and FileTreeBackend currently return Response objects** (the raw fetch response) rather than parsed domain types — session/query return Promise<Response>, loadTree/loadVaultTree return Promise<Response> whose JSON is a known shape. This is a known rough edge (see "Known rough edges"). To satisfy these today, Workspaces has to hand back something Response-shaped (status, .json(), and for query, an SSE body stream). It works, but it leaks the old HTTP contract. We deliberately left it as-is for the first cut (move-don't-rewrite); expect to clean it up in a v2 driven by what's actually painful when you wire it.
How Workspaces consumes it
npm install @plannotator/ui @plannotator/core
// app entry, once at startup
import { configurePlannotatorUI } from "@plannotator/ui/configure";
import "@plannotator/ui/styles.css";
// load fonts (the stylesheet references --font-sans / --font-mono but ships no binaries)
import "@fontsource-variable/inter";
import "@fontsource-variable/geist-mono";
// …or provide your own fonts and set --font-sans / --font-mono to match.
configurePlannotatorUI({
storageBackend, // your settings store (localStorage is already sync)
identityProvider, // your auth'd user (isEditable:false for logged-in users)
imageSrcResolver, // your asset URL scheme (pass-through for content-addressed URLs)
uploadTransport, // upload pasted images to your R2 asset API
docPreviewFetcher, // your doc store
fileTreeBackend, // your workspace file tree + realtime watch
draftTransport, // your draft store
externalAnnotationTransport, // adapt your Yjs/WebSocket realtime onto this
// aiTransport, // omit — Workspaces has no AI backend yet (stays default/off)
serverSync, // your settings push
loadSettingsFromBackend: true, // re-hydrate settings from storageBackend after install
});
// then render the components you want
import { Viewer } from "@plannotator/ui/components/Viewer";
A few component-specific behaviors (e.g. an "open this diff in the editor" action) are passed as props at the render site rather than through configure — those are local to one component, not app-global.
Mapping the seams to Workspaces' actual stack
Grounded in a read of the Workspaces repo (apps/app, apps/usercontent, apps/web, the DocumentDO). The web app doesn't import this UI yet, so this is the greenfield wiring plan.
| Seam | Workspaces backing | Effort |
|---|---|---|
storageBackend |
window.localStorage — already synchronous, matches the seam as-is. (Server-syncing prefs later is optional; not needed for the seam.) |
trivial |
identityProvider |
Read the already-hydrated me from SessionContext (GET /v1/me). getIdentity() returns email/id (no name field yet), isCurrentUser(a) = a === me.user_id, isEditable() => false for logged-in users. |
thin adapter |
imageSrcResolver |
Pass-through — asset URLs are content-addressed and need no signing. | trivial |
uploadTransport |
PUT /v1/workspaces/:wsId/assets/:assetPath → R2 (AssetBytes interface). Adapter owns asset-path selection. |
new adapter |
docPreviewFetcher |
GET /v1/workspaces/:wsId/documents/:docId (D1 + git content store). |
thin adapter |
fileTreeBackend |
GET /v1/workspaces/:wsId/documents (D1 doc list); live-watch via the DocumentDO. |
thin adapter |
draftTransport |
KV or a per-doc Durable Object; sendBeacon for keepalive. |
thin adapter |
externalAnnotationTransport |
Transport kind differs — Workspaces realtime is Yjs-over-WebSocket (DocumentDO), and comments are REST with no live push. Adapt comment events onto the DO awareness channel (or add an SSE endpoint). | biggest adapter |
aiTransport |
No AI backend exists in Workspaces. Leave at default/off until one is built. | new infra (later) |
serverSync |
A Worker endpoint that persists the settings delta. | thin adapter |
Backend follow-up (Workspaces side, not a UI change): if you want readable author names instead of raw user_… ids in comments, the Me/annotation projections need to start carrying a display-name field (WorkOS has first_name/last_name; the current Me projection drops them).
Supported imports (the allowlist)
The exports map is broad (wildcards over ./components/*, ./hooks/*, ./utils/*), because Plannotator's own apps consume the package too. Importable is not the same as supported for a host. A number of exported modules still call Plannotator's local server directly, with no seam — they exist for Plannotator's plan-review/code-review apps and will break (failed fetches to /api/* on your origin) if a host renders them. (The wildcards aren't even literally complete: a handful of .ts files under components/ don't resolve through the *.tsx pattern — e.g. components/diagramLanguages. Everything in the supported table below resolves; stay on the list.)
We deliberately did not restructure the exports map in this PR (move-don't-rewrite); this list is the contract instead.
Supported — safe for a host that configures the seams
| Import | Notes |
|---|---|
configure (configurePlannotatorUI) |
The front door. Also re-exports every seam contract type (StorageBackend, IdentityProvider, UploadTransport/UploadResult, DraftTransport, ExternalAnnotationTransport/ExternalAnnotationEvent, AITransport, FileTreeBackend/VaultNode, ImageSrcResolver, DocPreviewFetcher/DocPreviewResult, ServerSyncFn) so host adapters need one import. |
theme / styles.css |
Theme tokens + precompiled stylesheet. Prefer styles.css. The raw theme export still @imports KaTeX (re-acquiring the fonts styles.css deliberately excludes, as separate lazy files) and contains Tailwind v4 @theme at-rules, so it's inert without Tailwind processing. |
types |
Annotation, Block, AnnotationType, etc. |
utils/parser (parseMarkdownToBlocks, exportAnnotations) |
Pure — no backend. |
components/BlockRenderer + the block components it renders (TableBlock, HtmlBlock, Callout, MermaidBlock, MathBlock, …) |
Pure rendering. |
components/InlineMarkdown |
Code-file hover previews route through the docPreviewFetcher seam. Wiki-link rendering takes the sync resolveLinkedDoc prop (live labels + deleted-doc treatment; see "Wiki-link seams (0.27.0)"). |
components/Viewer |
The full annotatable document. Required props: markdown and taterMode (pass false). Pass disableCodePathValidation unless you implement /api/doc/exists — code-path validation is a prop-level opt-out, not a configure seam. |
components/MarkdownEditor |
Theme-bridging wrapper over @plannotator/markdown-editor. Takes CM6 extensions via the extensions prop (captured ONCE per documentId — see "Wiki-link seams (0.27.0)") and re-exports wikiLinks + its config 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) |
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)". (Blessed in 0.29.0.) |
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.) |
AI is fully avoidable — with one precision worth knowing. No AI UI is reachable from the supported components: useAIChat is imported only by components/ai/DocumentAIChatPanel and useAIProviderConfig, neither of which any supported component imports, and CommentPopover's Ask-AI affordance exists only behind the optional onAskAI prop. configure.ts does statically import the useAIChat module (it needs setAITransport), but if you never use AI the hook is dead code and bundlers eliminate it — verified empirically: a standalone consumer's production bundle importing the full supported surface contains zero /api/ai strings. Don't import components/ai/* and don't pass aiTransport, and you ship no AI code.
Unsupported — calls Plannotator's local server, no seam
Don't import these in a host. Each hits hardcoded Plannotator endpoints:
components/sidebar/VersionBrowser,hooks/usePlanDiff,components/plan-diff/*—/api/plan/version(s)(Plannotator's version history; Workspaces builds its own versions UI anyway).hooks/useArchive,components/sidebar/ArchiveBrowser—/api/archive/*.hooks/useAgents,hooks/useAgentJobs,components/AgentsTab—/api/agents/*.components/Settings,components/settings/HooksTab— Plannotator-specific tabs (Obsidian vaults, hooks, integrations).components/ExportModal,components/OpenInAppButton—/api/save-notes,/api/open-in(Obsidian/Bear/editor integrations).components/goal-setup/*— Plannotator's goal-package scaffolding endpoints.hooks/useEditorAnnotations—/api/editor-annotations(VS Code extension only).hooks/useLinkedDoc—/api/docdirectly (thedocPreviewFetcherseam coversInlineMarkdown's hover previews, not this full linked-doc overlay).hooks/useValidatedCodePaths—/api/doc/exists(this is whatViewer'sdisableCodePathValidationturns off).utils/sharing— Plannotator's public paste service (share-URL feature).hooks/useUpdateCheck,components/MenuVersionSection,components/PlanHeaderMenu— Plannotator release checks.utils/planAgentInstructions,utils/reviewAgentInstructions— generate agent instructions that curl Plannotator's local API.
If Workspaces ever wants one of these surfaces, the path is the same as everything else: add a seam to the module in a Plannotator PR, don't fork the component.
Math rendering (KaTeX): one-time setup if you render equations
The renderer's MathBlock (and inline math) uses KaTeX. KaTeX's stylesheet and its ~1.1MB of math fonts are deliberately NOT in the published styles.css — bundling them would 9x the CSS for every page load, math or not. This is app-developer setup, done once; end users never touch it. Pick one:
- Self-hosted (recommended for production): copy
katex/dist/katex.min.css+katex/dist/fonts/to your own asset origin and add one<link rel="stylesheet">. No third-party dependency in your serving path; fonts download lazily, only on pages that actually render math. - CDN tag:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@<version>/dist/katex.min.css">in your HTML — pin<version>to thekatexversion in@plannotator/ui's package.json so CSS and the bundled KaTeX JS stay in step. Same lazy-font behavior; adds a third-party origin. - Bundler import:
import 'katex/dist/katex.min.css';next to yourstyles.cssimport — your bundler ships the fonts as separate lazy-loaded files. With npm/bun this resolves out of the box (katexis a dependency of@plannotator/uiand gets hoisted); under pnpm's strictnode_modules, addkatexto your own dependencies to import it directly.
If you skip all three and render math, equations appear as broken-looking raw HTML — that's the symptom to recognize. If you never render math, do nothing.
The annotation anchor schema (what you're storing)
When a host persists annotations (your REST comment API), the anchor fields on Annotation are the de facto contract. Store them as opaque JSON and round-trip them unchanged — but you should know what they are and when they go stale.
From @plannotator/ui/types:
interface Annotation {
// ...
originalText: string; // the exact text that was selected
startMeta?: { parentTagName: string; parentIndex: number; textOffset: number };
endMeta?: { parentTagName: string; parentIndex: number; textOffset: number };
mathTargets?: Array<{ blockId: string; tex: string; displayMode: boolean }>; // math selections only
}
startMeta/endMeta are web-highlighter's DOM anchors, captured against the rendered document: the tag name of the element containing the selection endpoint, the index of that element among all same-tag elements in the rendered DOM (document order), and the character offset within that element's text. They are positional, not content-addressed — they encode "the 14th P, character 32", not "this sentence".
Reattachment order (in useAnnotationHighlighter, when a stored annotation is re-applied to a rendered document):
- Math targets first — if
mathTargetsis present, the matching KaTeX elements are located byblockId+ exacttexstring. - Anchor restore —
highlighter.fromStore(startMeta, endMeta, originalText, id). Works when the rendered DOM structure matches what it was at capture time. - Text-search fallback — if the anchors produce nothing (DOM changed shape), the hook searches the rendered text for an exact, whitespace-normalized occurrence of
originalTextand wraps it manually. This finds the first occurrence — if the selected text appears more than once, the highlight can attach to the wrong instance. - Failure — if the text is gone too, the hook logs a
console.warnand applies no highlight. The annotation is not deleted: it still appears in the annotation panel and in exported feedback, it just has no visual anchor in the document body.
What this means for a host: anchors survive re-renders of the same markdown. Once the document body is edited, the anchors are best-effort — originalText is the real recovery key, and an annotation whose text was deleted degrades to a panel-only comment. If you build "comments follow the text through edits" on top of this (Workspaces will, with live editing), plan to re-anchor server-side or via your Yjs layer; don't expect these DOM anchors to do it.
Honesty note: the failure path (step 4) is exercised in real use but is not covered by automated tests — nothing in the suite asserts the stale-anchor behavior. Treat the described degradation as accurate-but-unverified-by-CI, and test it in your integration if you depend on it.
Migration caveat — reference-style link resolution (#923): parseMarkdownToBlocks now rewrites CommonMark reference links ([text][id]) and blanks their [id]: url definitions before splitting into blocks, so documents containing that syntax render differently than they did before this pass existed — a [text][id] pair that used to render as literal bracket text now renders as a link, and the definition line disappears from the rendered DOM entirely. That changes both the text and the per-tag DOM index at the affected positions. Any annotation whose startMeta/endMeta was captured against the old (pre-resolution) render of such a document — i.e. persisted before a host upgrades past this change — can restore onto the wrong text after upgrading, same as any other DOM-structure change described above; the text-search fallback (step 3) is the recovery path, and originalText is what to fall back to if you need to re-anchor server-side.
Known rough edges (and why they're fine for now)
-
AITransport/FileTreeBackendleakResponse. They return raw fetchResponseobjects instead of clean domain types ({ sessionId },AsyncIterable<AIMessage>,{ tree, workspaceStatus }). A reviewer correctly flagged this. We kept it deliberately: the goal of this PR was move-don't-rewrite, and reshaping these contracts is exactly the kind of redesign that's better driven by the real consumer (Workspaces) once you feel the pain. Plan a v2 pass on these two once you've wired them. -
InlineMarkdown.tsxis large (~1k lines) and now hosts thedocPreviewFetcherseam inline. Cheap future cleanup: extract the doc-preview seam into its own module so the renderer shrinks. Not blocking. -
Module-level singletons, not a Provider. Covered above — safe because Workspaces is client-side, not SSR. Only revisit if SSR is added.
-
The markdown editor can't take live-collab extensions yet.RESOLVED in 0.27.0. The plan of record shipped exactly as written:@plannotator/atomic-editor≥0.7.0 and@plannotator/markdown-editor≥0.3.2 thread an optionalextensions?prop through to the CM6 editor, and the ui shim now declares and forwards it (see "Wiki-link seams (0.27.0)"). You can thready-codemirror.next— or any CM6 extension, e.g.wikiLinks— throughcomponents/MarkdownEditor. Mind the capture-once-per-documentIdcaveat.
None of these block adoption. They're the honest "here's what we'd polish next" list.
UI engine: Base UI (0.23.0)
As of 0.23.0, @plannotator/ui is built on Base UI (@base-ui/react@^1.6.0 — caret, so your own Base UI install dedupes against ours; two copies would break context across portals) instead of Radix. This follows shadcn/ui making Base UI its default engine (July 2026). The migration was deliberate and whole-package: zero @radix-ui/* packages remain — no mixed engines. Per-component reports with hand-verification checklists live in packages/ui/.migration/.
Dependency changes
- Removed dependencies:
@radix-ui/react-dialog,react-dropdown-menu,react-popover,react-slot,react-tabs,react-tooltip. - Added dependency:
@base-ui/react@^1.6.0(regular dependency — installs transitively, nothing for you to add). - Peer dependency removed:
tailwindcss-animate. The kit's enter/exit animations are now CSS-transition-based (Base UI'sdata-starting-style/data-ending-style), so the plugin is no longer used. If your Tailwind config loaded it only for this package, you can drop it. Remaining peers are unchanged:react,react-dom,tailwindcss.
Breaking API changes in 0.23.0 (what a consumer must change)
asChild→render, everywhere.<Button asChild><a/></Button>becomes<Button render={<a/>}>label</Button>(children go on the wrapper, element props onrender). Applies toButton,Badge,DialogTrigger/DialogClose,DropdownMenuTrigger,PopoverTrigger, and tab parts.- Menu item selection:
onSelect(event)no longer exists. UseonClick; to keep the menu open after a click (the oldevent.preventDefault()idiom), passcloseOnClick={false}.textValue→label. DropdownMenuCheckboxItem/DropdownMenuRadioItemno longer close the menu on click by default (Base UI defaultscloseOnClicktofalsefor these two; plainDropdownMenuItemstill closes). PasscloseOnClickexplicitly for the old behavior.checked="indeterminate"is gone (boolean only).DropdownMenuLabelmust be nested inside aDropdownMenuGroup(it wiresaria-labelledby); a free-floating label was legal under Radix.PopoverAnchorexport removed. Base UI has no Anchor part; anchored positioning is a Positioner concern (if you need a custom anchor, ask for a seam — do not fork the wrapper).- Content-level focus/dismiss callbacks are gone.
onOpenAutoFocus/onCloseAutoFocus→initialFocus/finalFocusprops (element/ref/boolean, onDialogContent/PopoverContent/DropdownMenuContent).onEscapeKeyDown/onPointerDownOutside/onInteractOutside→ the Root'sonOpenChange(open, eventDetails): branch oneventDetails.reason('escape-key','outside-press','focus-out') and calleventDetails.cancel()to block the close. onOpenChangegains a secondeventDetailsargument on every overlay Root. Existing single-arg handlers keep compiling and working.- Styling hooks changed.
data-[state=open/closed]→data-open/data-closed; triggers exposedata-popup-open; active tab isdata-active(wasdata-[state=active]); highlighted menu items aredata-highlighted(items are no longer DOM-focused, sofocus:variants on menu items do nothing). CSS vars:--radix-<comp>-content-transform-origin→--transform-origin,--radix-<comp>-trigger-width→--anchor-width, available-size vars →--available-width/--available-height. - Tabs behavior: arrow keys now move focus WITHOUT activating (Base UI's manual-activation default; pass
<TabsList activateOnFocus>for the Radix feel), and an uncontrolledTabsactivates its first tab by default (Radix activated none). - Tooltip:
childrenmust be a single React element (was loosely typed). Unset-delay defaults shift: open delay 700ms → 600ms, skip-window 300ms → 400ms (irrelevant if you set them viaTooltipProvider).TooltipProviderdeliberately KEEPS the Radix-era prop names (delayDuration,skipDelayDuration,disableHoverableContent) and maps them internally — your provider call sites don't change. - Portals render a wrapper
<div>(Radix portals rendered nothing extra). Only matters if you style popups via direct-child selectors ondocument.body. Buttonnow defaults totype="button"(Base UI's Button primitive). Under Radix it rendered a plain<button>, whose implicit type issubmit— a bare<Button>inside a<form>no longer submits it. Passtype="submit"explicitly (it overrides the default). No in-repo forms exist; this is consumer-only.
Dialog/dropdown enter/exit animations look the same (fade+scale, 150–200ms) but are transitions, not keyframes — the subtle Radix slide-in-from-* nudge on menus is gone, matching the shadcn base registry look.
What did NOT change
- Every export name (
Dialog*,DropdownMenu*,Popover*,Tabs*,Tooltip*,Button,Badge,PopoutDialog,SearchableSelect) and the theme/token system. - The seam catalog and
configurePlannotatorUI()— the engine swap is invisible to the backend seams. - The strict-consumer TS gate (
tsconfig.strict-consumer.json) stayed green throughout; yourtsc --noEmitshould too.
Re-verify your seam contract against 0.23.0 before adopting; the list above is exactly what to test against.
Consumer enablement (0.24.0)
Six items accumulated through Workspaces' first three integration slices. All are additive; every default reproduces 0.23.0 behavior.
AnnotationPanelhost props.renderCardFooter?: (annotation) => ReactNode— a per-card slot at each plan-annotation card's foot (plug reply/resolve UI in; clicks inside the slot don't select the card).readOnly?: boolean— hides the built-in mutation affordances (delete/edit on all card kinds); selection and scrolling still work, and as of 0.30.0 the host footer slot still renders (see "Unanchored-annotation reporting + readOnly footer fix (0.30.0)").- Six more supported imports (already in the table above, tagged Blessed in 0.24.0):
TableOfContents,ResizeHandle+useResizablePanel,useActiveSection,useScrollViewport,utils/annotationHelpers. All verified under the strict-consumer gate. Viewer/CommentPopoverallowImages?: boolean. Passfalsewhen you have nouploadTransport— the attach-image affordance disappears instead of dead-ending. (CommentPopover already had the prop; Viewer now exposes and threads it.)ViewerreadOnly?: boolean. View-only users: suppresses every composer entry point (selection toolbar, comment popovers, quick labels, pinpoint, global comment, attachments, checkbox toggles) while existing annotations still render and select.- Stricter consumer gate.
tsconfig.strict-consumer.jsonnow also enforcesverbatimModuleSyntax,noUnusedLocals,noUnusedParameters— the shipped source passes them, so you no longer have to relax those flags in your own tsconfig. - Content-verifying restore (opt-in).
useAnnotationHighlighter({ verifyRestoredContent: true, onRestoreMismatch }): a position-based restore that resolves onto the wrong text (document drift) is removed and re-anchored by text search; if the original text is gone entirely,onRestoreMismatch(annotation, restoredText)fires and nothing is painted. Default off. If you built a host-side guard for this, you can delete it.
HtmlViewer rendering neutrality (0.25.0)
HtmlViewer no longer writes into a rendered document's namespace (Workspaces' upstream brief; supersedes the H-ask-1 patch — delete it on adoption). Arbitrary HTML now renders exactly as in a standalone browser tab:
- No bare token injection. Host theme tokens travel only as viewer-owned
--pn-*properties (srcdoc block and the bridge's theme handler, which now refuses non---pn-writes). A document defining--muted/--background/etc. keeps its own values in both host themes. - No root mutations. The
lightclass toggle and thecolor-scheme: lightinjection are gone for arbitrary documents; light/dark resolves from the document + OS. - Diff CSS gated and scoped.
<ins>/<del>styles are injected only whilediffActiveand targetins.plannotator-diff/del.plannotator-diff. If your host renders its own version-diff HTML through the viewer, tag the generated wrappers withclass="plannotator-diff"; author-written<ins>/<del>markup is never restyled. - Host theming is opt-in per document.
<meta name="plannotator-theme" content="host">in the document's head restores the bare-token push, thelightroot class, and a symmetriccolor-schemesync — for that document only. Documents relying on the old implicit override must add the tag.
The contract is pinned by components/html-viewer/srcdoc.test.ts (no bare custom-property declarations, no color-scheme, --pn-*-only bridge writes, scoped diff selectors).
Resize-handle seams + file-browser filtering (0.26.0)
Two additive changes; every default reproduces 0.25.0 behavior.
- Resize-handle host seams (
ResizeHandle+useResizablePanel, both already blessed). For hosts that want different edge interactions:ResizeHandlenew props:hideHoverTrack?: boolean(suppress the hover color-reveal entirely),trackClassName?: string(restyle the inner 4px track —classNameonly reaches the outer wrapper), andtooltip?: ReactNode(cursor-following hint, portaled todocument.body, hidden mid-drag). The track also carries a[data-resize-track]attribute (same host-CSS pattern as[data-collapse]), so you can kill the hover reveal from plain CSS:[data-resize-track] { background: none !important; }.useResizablePanelnew options:onClick?: () => voidandclickThreshold?: number(default 4).onClickfires on pointer-up only when the pointer never traveled past the threshold — the hook owns the pointer state machine, so this is the only reliable way to tell a click from a drag-start. Use it to make the whole handle a click-to-collapse target. It never fires on a snap-close or onpointercancel(aborted gestures — palm rejection, system gestures — only clean up drag state). WhenonClickhandles a click, the width is left untouched (not committed/persisted).- Plannotator's own apps now wire these into a new handle UX (no hover track, cursor tooltip, single-click collapse). The package defaults are unchanged — pass nothing and 0.25.0 behavior is exactly preserved.
packages/ui/README.md§ "Resize-handle seams" documents the same from the host's perspective.
- File-browser filtering (
FileBrowser, reached viauseFileBrowser). A built-in filter row above the tree: whitespace-separated tokens AND-match case-insensitively against each file's name (with and without extension) and path (backslashes normalized); folders match on their own name too. While filtering, folders are force-expanded (and non-interactive) and directory collapse state is ignored; Escape clears the query, then closes the input. No new props — consumers get it for free. Behavior pinned bycomponents/sidebar/FileBrowser.test.ts.
Wiki-link seams (0.27.0)
Consumer-enablement round for wiki-links (Workspaces' [[doc_01XYZ|label]] links over opaque doc ids). Three additive seams plus a housekeeping fix; every default reproduces 0.26.0 behavior.
-
MarkdownEditorextensionspassthrough. The shim (components/MarkdownEditor) now declaresextensions?: readonly Extension[](Extensionfrom@codemirror/state) and forwards it through@plannotator/markdown-editorinto the CM6 engine, appended after the built-ins. This is the seam forwikiLinks(config),y-codemirror.nextcollab bindings, custom keymaps (wrap inPrec.highto beat built-ins), etc.⚠️ Captured ONCE per
documentId— not reactive. The engine reads the array a single time, when it mounts the document. Swapping in a different array later is silently ignored until the next remount (adocumentIdchange). Pass a stable reference (module constant oruseMemokeyed ondocumentId), and never encode changing data in the array itself — extension config callbacks may close over live state (refs/getters); that is the supported way to feed dynamic data into a mounted editor.Build extensions against your own
@codemirror/*install: both editor packages declare@codemirror/stateas a peer, so there is one shared copy — a second copy breaks the editor. Seam pinned end-to-end bycomponents/MarkdownEditor.extensions.test.tsx(a facet-based probe mounted through the shim reaches the engine DOM). -
wikiLinksre-exported through the ui surface. Hosts must not import@plannotator/atomic-editor(outside the import allowlist);@plannotator/uiis the single contract.components/MarkdownEditorre-exportswikiLinksand its types —WikiLinksConfig,WikiLinkSuggestion,WikiLinkResolvedTarget,WikiLinkStatus. Usage: buildwikiLinks(config)and pass it via theextensionsprop. The config callbacks (suggest,resolve,onOpen) may close over live state — see the capture-once caveat above. Engine 0.7.0'spreferResolvedLabel?: booleanflag (labeled[[target|label]]links opt into showing the resolved title instead of the stored label) is part of the re-exportedWikiLinksConfig. -
InlineMarkdownresolveLinkedDoc. Synchronous host resolution of wiki-links in the viewer:resolveLinkedDoc?: (target: string) => { label?: string; status?: 'active' | 'deleted' } | null;- Callback absent, or returning
null→ exactly the previous rendering (stored label, live link). label→ displayed instead of the stored label; the stored label is the fallback, the raw target the last resort.status: 'deleted'→ a muted, struck-through non-link span titled "Document deleted" — no anchor, no pointer, no link icon, andonOpenLinkedDocis not wired — even whenonOpenLinkedDocis passed.- The callback receives the raw stored target (
doc_01XYZ), before the.md-appending path normalization;onOpenLinkedDockeeps receiving the normalized path (doc_01XYZ.md) for non-deleted links, unchanged. - Sync-only by design — back it with an in-memory cache you keep hydrated. There is deliberately no async variant, no loading state, no phantom-doc creation, no backlink machinery.
Behavior pinned by
components/InlineMarkdown.resolveLinkedDoc.test.tsx, includingnull→ byte-identicalinnerHTML. - Callback absent, or returning
-
H-ask-1 retired. The two one-line TS6133 fixes Workspaces carried against
components/html-viewer(unusedReactdefault import inHtmlViewer.tsx; unusedannotationsdestructured binding inuseHtmlAnnotation.ts) are applied at source. The shipped html-viewer files passtscunder the strict-consumer flags (--noUnusedLocalsincluded) — delete your patch on adoption.
Dependency note: 0.27.0 requires @plannotator/markdown-editor ^0.3.2 (adds extensions) and @plannotator/atomic-editor ^0.7.0 (adds wikiLinks + preferResolvedLabel).
Frozen markdown diff (0.28.0)
One additive component for the Workspaces versions/approvals surface: components/MarkdownDiff, a theme-bridging shim over @plannotator/markdown-editor@0.4.0's MarkdownDiff — a frozen two-revision markdown comparison. The newer revision renders as the real document (uncollapsed, full length); deletions are projected struck-through at their original positions; changed spans get character/word emphasis; a toolbar shows the change count with prev/next navigation; a clickable, keyboard-accessible overview rail and a changed-line gutter complete the review chrome. Every 0.27.0 surface is unchanged.
-
Same shim pattern as
MarkdownEditor. Import from@plannotator/ui/components/MarkdownDiff— neverAtomicDiffEditoror@plannotator/atomic-editordirectly (outside the import allowlist). The shim resolves the color mode fromThemeProvider(hosts without the provider passmodedirectly), imports the same@plannotator/markdown-editor/themes/plannotator.csstheme the editor shim imports, and mapsgridEnabledto the identical design-system card chrome — so toggling editor ↔ diff over the same document doesn't jump. -
The byte contract lives on the handle.
editorHandleRefreceives aMarkdownDiffHandle:getMarkdown()returns the exactmodifiedMarkdownsupplied andgetOriginalMarkdown()the exactoriginalMarkdown— byte-identical, including CRLF and trailing whitespace (the handle returns the caller's strings, not a CM6 read-back). Navigation rides the same handle:getChangeCount(),goToNextChange(),goToPreviousChange(), plusgetContentDOM()for host-level inspection. -
Frozen means frozen. The surface is never editable: document-changing transactions are rejected at both the state and view dispatch boundaries, and the content DOM is
contenteditable="false". Rendered links still work (onLinkClick). -
extensionscomposes like the editor's. Same seam, same calling convention: buildwikiLinks(config)(still re-exported fromcomponents/MarkdownEditor) and pass it throughextensions— wiki-links render inside the frozen view. Captured ONCE per mounted comparison (keyed ondocumentId+ both document strings): pass a stable array, feed changing data through callbacks that close over live state, and build against your own@codemirror/*copies (one shared@codemirror/state, as ever).
Seam pinned end-to-end by components/MarkdownDiff.reexport.test.tsx (public surface + types) and components/MarkdownDiff.frozen.test.tsx (byte preservation incl. CRLF/trailing-space fixtures, contenteditable="false", change navigation, wiki-link composition through the shim, theme/host-class forwarding).
Dependency note: 0.28.0 requires @plannotator/markdown-editor ^0.4.0 (adds MarkdownDiff) and @plannotator/atomic-editor ^0.8.0 (adds the frozen diff engine; new required peer @codemirror/merge, which @plannotator/ui now declares — single-copy discipline unchanged).
Raw-HTML annotation viewer + syntax-highlighting migration (0.29.0)
0.29.0 blesses the rebuilt raw-HTML annotation viewer as supported host surface and carries one breaking migration inherited from the diff-pane highlighter unification. Read both parts before upgrading from 0.28.0.
BREAKING: .hljs is gone — style code via pn-code
highlight.js was removed from the package; the single highlighter is now Shiki via @pierre/diffs (new dependency, pinned 1.3.2). Consumer impact:
- Any host CSS targeting
.hljsor.hljs-*token classes is inert. Fenced code blocks now carrypn-code font-mono language-{lang}— importCODE_BLOCK_CLASSfromutils/codeHighlightinstead of hardcoding class strings. - Per-theme token CSS is the wrong layer now. Fences resolve a real Shiki theme from the active palette (
utils/syntaxTheme,hooks/useFenceTheme); to change code colors, map the palette to a different Shiki theme — don't write token-class CSS. - New supported utils:
utils/codeHighlight(applyHighlight,highlightToHtml,codeBlockClassName,onCodeHighlightSwap),utils/codeBlockMark(annotation marks that survive highlight swaps),utils/syntaxTheme. All pure/browser-safe. - Language-less fences render as plain text — there is no auto-detection anywhere. Don't reintroduce it host-side; it breaks the byte-identity contract the annotation layer depends on.
- Remove any bundler alias on
highlight.js. A host that aliasedhighlight.js/lib/common(or any hljs path) while consuming ≤0.28.0 will now fail at config load — the module no longer exists in the dependency tree. Delete the alias along with the.hljsCSS. (Reported by the first 0.29.0 adopter.) - Known cosmetic install warning:
@pierre/diffs@1.3.2→@pierre/theming@1.0.0declares a peer of@pierre/theme@^1.1.0while2.0.0resolves. Upstream ranges we don't control; harmless, appears in every consumer's install output.
Blessed: components/html-viewer (HtmlViewer)
The overlay-projection annotation viewer for raw HTML (placed comment markers, pinpoint element anchors, shift-click multi-target, drag selection) is now on the supported allowlist, same standing as components/Viewer. The full architecture handoff (anchor model, reconcile loop, message protocol, test map) is a separate document — ask the maintainer for HANDOFF_HTML_ANNOTATION_v0.26.8.md. The contract summary:
- The contract is props + the validated message protocol — not
configurePlannotatorUI.HtmlVieweris driven by its props (rawHtml,annotations,onAddAnnotation,onSelectAnnotation,selectedAnnotationId,mode,inputMethod,readOnly, …) and adapts the sandboxed iframe's validated bridge messages to the same annotation controls the markdownVieweruses. Theconfigure()seams still govern what surrounds it (storage, drafts, images, AI), but nothing about the viewer itself routes throughconfigure(). Integrate on props; that is the path we maintain. - Numbering derives from the
annotationsprop — drive the prop. Marker numbers are computed from the prop's array order (matchingexportAnnotationsnumbering, globals occupying slots) and synced to the iframe on every prop change. Mounting with an empty prop and driving the viewer imperatively is NOT supported and will leave bubbles unnumbered; the imperative handle (applySharedAnnotations,removeHighlight,clearAllHighlights) exists for repaint scenarios on top of a prop-driven mount, not as a substitute for it. If your host architecture truly cannot supply the prop, ask for a numbering seam rather than working around it. readOnlyis view-only, not blank. WithreadOnly, committed annotations still restore, markers still paint with correct numbers, and clicking a marker still firesonSelectAnnotation; every authoring entry point (composer, toolbar, quick label, vim) is disabled. Pinned by the "readOnly view-only contract" tests incomponents/html-viewer/htmlPinpointProtocol.test.tsx.- Security envelope: opaque-origin
srcdocsandbox only. The iframe issandbox="allow-scripts"(noallow-same-origin) and both sides authenticate messages by source identity withtargetOrigin: "*". That pattern is safe only because asrcdocsandbox has an opaque origin. If a host serves annotated content from a real origin (a proxy, a hosted iframe), it must add stricttargetOriginand origin checks — do not reuse the"*"pattern there. - Multi-target cap is 16 on our side.
htmlAdditionalTargetsaccepts up to 16 additional anchors per comment; a host enforcing a smaller product cap (e.g. 7) should cap at composer level before submit — the stored schema is unchanged either way. As with every anchor field, persisthtmlAnchor/htmlAdditionalTargetsas opaque JSON and round-trip them unchanged (see "The annotation anchor schema").
@plannotator/core 0.23.0
Additive only, but required: @plannotator/ui 0.29.0 imports the new @plannotator/core/annotatable subpath (absent from published core 0.22.0), so core 0.23.0 must be installed/published first. Also picks up additive exports in agent-jobs, config-types, favicon, feedback-templates, and an external-annotation PATCH-merge fix (tool-submitted source markers are no longer clearable via PATCH).
Unanchored-annotation reporting + readOnly footer fix (0.30.0)
Two consumer-driven changes: the onUnanchoredChange callback (the accepted ask from the 0.29.0 adoption) and a behavior fix to AnnotationPanel's readOnly mode.
HtmlViewer onUnanchoredChange?: (ids: string[]) => void
Fail-closed anchors hide markers rather than guess, which previously meant an annotation whose content vanished from the page disappeared silently. The viewer now reports it:
- The callback receives the complete current set of annotation ids with no live representation on the page — every target dead (element disconnected AND text unfindable), or the restore never resolved anything. It fires only when the set changes, including back to
[]on recovery. An id being merely offscreen, clipped, or style-hidden is NOT unanchored: its content exists, so no report. - It fires in readOnly mode too — view-only surfaces are exactly where silently missing markers go unnoticed.
- Bounded like every bridge message: at most 512 ids of at most 256 chars; an out-of-contract report is rejected whole at the parent trust boundary.
- Timing: reports ride the overlay reconcile (rAF-coalesced), so expect them shortly after load, after page mutations, and after your own
annotationsprop changes — not synchronously with them.
Pinned by "unanchored ids are reported on change" in components/html-viewer/srcdoc.test.ts (bridge behavior) and the "unanchored report" suite in components/html-viewer/htmlPinpointProtocol.test.tsx (trust boundary + readOnly delivery).
AnnotationPanel readOnly no longer suppresses the host footer slot
Behavior change. Through 0.29.1, readOnly dropped the renderCardFooter slot entirely, which threw away host READ affordances (a replies list, a copy link) along with mutations — view-only panels lost their replies. As of 0.30.0 the footer slot always renders; readOnly hides only the built-in mutation affordances (delete/edit, direct-edit discard). The host gates its own footer contents: if you render mutation UI in the footer, gate it on your own view-only state. A host that relied on the automatic suppression must add that gate when upgrading.
Publishing & versioning
@plannotator/uiis now0.30.0;@plannotator/coreis0.23.0. Publishcorefirst — ui 0.29.0 imports@plannotator/core/annotatable, which does not exist in core 0.22.0. The ui→core dependency resolves exactly at pack time.- They depend on each other via
workspace:*. At publish time that must resolve to the exact version in the tarball, so publish with a tool that does that resolution (the repo's existing flow usesbun pm packto build the tarball, thennpm publish *.tgz --access public). Publishcorefirst, thenui. --provenanceonly works from a supported CI environment (GitHub Actions OIDC) — a local publish fails withAutomatic provenance generation not supported for provider: null. Until a CI publish job exists for these two packages, local publishes drop the flag. Publishing under--tag nextfirst lets the consumer preflight beforenpm dist-tag add <pkg>@<version> latestpromotes it.styles.cssis built by theprepackscript (bun run build:css) so the published tarball always carries fresh precompiled CSS.- There is no CI publish job for these two packages yet — first publish is manual from
mainafter merge. (Wiring a CI publish job is a follow-up.)
The law (guardrails for anyone editing @plannotator/ui)
These are enforced socially and, where possible, by CI. They exist because a prior from-scratch reimplementation of this UI broke the app and was reverted.
- Don't reimplement the document UI from scratch. Add a seam; don't rebuild.
- Every seam's default must reproduce today's Plannotator behavior. Plannotator passes nothing and stays byte-for-byte unchanged.
@plannotator/coreis browser-safe and zero-dep — nonode:imports. CI enforces it.- Never delete working Plannotator code until a human confirms parity in the browser.
See packages/ui/README.md and packages/ui/AGENTS.md (CLAUDE.md symlink) for the short version that lives next to the code.