From 902c2abe38c7732591a95039eb6e0c4b0e1a38c4 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 18:23:48 -0700 Subject: [PATCH 01/67] docs(adr): revert failed document-ui cutover, add ADR 004 with corrected reuse plan The document-ui extraction/cutover (ADRs 002/003) was an AI-driven rewrite that broke the app; the code was reverted. Add ADR 004 as the source of truth: share @plannotator/ui as published building blocks for the Workspaces app, keep Plannotator's app unchanged, gate on human-verified parity. Banner the reverted ADRs and point AGENTS.md/CLAUDE.md at 004 so future agents don't rebuild the mess. --- AGENTS.md | 2 + ...ral-document-ui-package-20260620-083633.md | 133 ++ ...ument-ui-parity-cutover-20260621-122015.md | 89 ++ ...blished-building-blocks-20260622-180637.md | 82 ++ ...nt-ui-extraction-intent-20260620-085249.md | 287 +++++ ...i-parity-cutover-intent-20260621-122245.md | 268 +++++ ...urrent-state-and-parity-20260621-115603.md | 216 ++++ ...-ui-extraction-boundary-20260620-082002.md | 744 ++++++++++++ ...-document-ui-extraction-20260620-082343.md | 266 ++++ .../document-ui-extraction-20260620-083307.md | 1066 +++++++++++++++++ ...mpleteness-review-fixes-20260622-085528.md | 519 ++++++++ ...ument-ui-parity-cutover-20260621-121115.md | 467 ++++++++ 12 files changed, 4139 insertions(+) create mode 100644 adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md create mode 100644 adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md create mode 100644 adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md create mode 100644 adr/implementation/document-ui-extraction-intent-20260620-085249.md create mode 100644 adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md create mode 100644 adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md create mode 100644 adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md create mode 100644 adr/research/synthesis-document-ui-extraction-20260620-082343.md create mode 100644 adr/specs/document-ui-extraction-20260620-083307.md create mode 100644 adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md create mode 100644 adr/specs/document-ui-parity-cutover-20260621-121115.md diff --git a/AGENTS.md b/AGENTS.md index f80726e43..d8cfb1ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs and annotation of arbitrary markdown files. +> **Reusing the document UI (theme / markdown / editor / settings / layout) in the commercial Workspaces app? Read `adr/decisions/004-reuse-document-ui-as-published-building-blocks-*.md` FIRST.** ADRs 002 and 003 (and their `document-ui-extraction` / `document-ui-parity-cutover` specs and intents) describe a reverted, failed attempt — a from-scratch reimplementation that broke the app. Do **not** implement them or recreate `packages/document-ui`. The corrected plan in 004 is: share `@plannotator/ui` as published building blocks, keep Plannotator's app unchanged, never delete working code until a human confirms parity in the browser. + ## Project Structure ``` diff --git a/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md b/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md new file mode 100644 index 000000000..54f3779e1 --- /dev/null +++ b/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md @@ -0,0 +1,133 @@ +# 002. Extract a Provider-Neutral Document UI Package + +> ⚠️ **RE-SCOPED / SUPERSEDED BY ADR 004 — DO NOT IMPLEMENT AS WRITTEN.** The provider-neutral `DocumentReviewSurface` / `DocumentHostApi` approach in this ADR was attempted, broke the app, and was reverted on 2026-06-22. The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** — read it first. Kept here as history. + +Date: 2026-06-20 + +## Status + +Accepted + +## Context + +Plannotator began as a plan review UI. In Plan Mode, the Claude Code hook intercepts `ExitPlanMode`, starts the server, opens the browser, and waits for approve or deny. + +The product has since shifted. The main document workflow is now broader than plan review: users run annotate on markdown, text, HTML, URLs, folders, and last assistant messages. The same React app currently handles all of those modes. The annotate server serves document sessions through `/api/plan`, and `packages/editor/App.tsx` switches between plan review, annotate file, annotate folder, annotate message, raw HTML, archive, and goal setup. + +A sister Workspaces repo now needs the same document review experience. It should get the same Plannotator UI patterns for rendering, annotation, comments, file trees, edit state, draft restore, and feedback assembly, but with different provider mechanics: document ids instead of filesystem paths, workspace manifests instead of local directory walks, `If-Match` or version ids instead of disk hashes, and workspace APIs instead of `/api/doc` and `/api/source/save`. + +The current package split does not express this boundary. `@plannotator/ui` contains reusable primitives, but many hooks and components call hard-coded `/api/*` routes. `@plannotator/editor` contains the app shell and important document-domain behavior such as editable document state, source reconciliation, direct edits, draft restore, and agent-terminal integration. Moving only `Viewer` or renderer components would leave the hard product behavior trapped in `App.tsx` and force Workspaces to recreate it. + +The key abstraction is not local source-save. Local source-save is Plannotator's current writeback provider. The reusable concept is provider-neutral document writeback state: + +- clean +- dirty +- saving +- saved +- conflict +- missing +- error + +Plannotator local writeback uses `/api/source/save`, disk hashes, mtime, EOL metadata, file watching, and missing local files. Workspaces writeback uses workspace document APIs, `If-Match`, versions, missing document rows, and workspace restore semantics. The UI state and user experience should be shared; persistence details should belong to the host/provider. + +## Decision + +We will extract one shared package: + +```text +@plannotator/document-ui +``` + +The package will expose a product-level document review surface: + +```tsx + +``` + +The package will own the reusable document review loop: + +- markdown and raw HTML rendering +- markdown parsing and block rendering +- annotation lifecycle and highlight restoration +- comments, attachments, and annotation panel behavior +- linked document navigation +- document/file tree rendering and badges +- edit mode and edit session state +- provider-neutral writeback state +- conflict, missing, and error UI patterns +- draft save and restore behavior +- feedback and saved-change payload assembly +- code path validation UI and inline link handling +- optional Ask AI and agent-delivery integration points + +The package public contract must be provider-neutral. It must not require filesystem paths, `/api/source/save`, disk hashes, or local source-save terminology. Public types should use concepts such as `DocumentRef`, `LoadedDocument`, `DocumentReviewSession`, `DocumentHostApi`, `DocumentWritebackStatus`, `SaveDocumentRequest`, and `SaveDocumentResult`. + +Local Plannotator source-save will become the first provider implementation behind a browser-side adapter: + +```text +createPlannotatorHttpDocumentApi() +``` + +That adapter will map current routes and local source-save metadata into the provider-neutral contract: + +- `GET /api/plan` +- `GET /api/doc` +- `POST /api/doc/exists` +- `GET /api/reference/files` +- `GET /api/reference/files/stream` +- `POST /api/source/save` +- `GET/POST/DELETE /api/draft` +- `POST /api/feedback` +- `POST /api/approve` +- `POST /api/exit` + +The current routes will remain stable during extraction. We will not rename `/api/plan` as part of this work. + +Workspaces will be able to implement its own `DocumentHostApi` using workspace document ids, manifests, annotation APIs, versions, and `If-Match` behavior. That adapter does not need to live in this repository. + +The host app will continue to own runtime and environment policy: + +- CLI/plugin command interception +- server startup and browser opening +- auth +- provider implementation +- plan-mode hook stdout behavior +- plan history +- plan diff +- archive +- goal setup +- permission mode setup +- note-app settings and persistence policy +- terminal runtime, WebTUI sidecar, remote-mode security, and installer logic + +Plan review becomes one host mode that supplies a document, capabilities, and approve/deny behavior to the shared document surface. Annotate remains the reference use case because it exercises the full document experience: arbitrary files, folders, raw HTML, linked docs, writeback, drafts, and optional agent delivery. + +We will extract in phases: + +1. Create `packages/document-ui` with provider-neutral contracts. +2. Add `createPlannotatorHttpDocumentApi()` over current Plannotator routes. +3. Move document-domain state out of `packages/editor`, renaming public concepts from local source-save to provider-neutral writeback where appropriate. +4. Create `DocumentReviewSurface` around the existing viewer, HTML viewer, editor toggle, linked-doc behavior, file tree, annotation panel, drafts, and writeback state. +5. Move provider-neutral feedback assembly into the package while keeping Plannotator's agent-specific markdown wrapping in the host. +6. Shrink `packages/editor/App.tsx` into a host shell that loads the session, configures capabilities, handles plan/annotate policy, and renders `DocumentReviewSurface`. +7. Add contract and surface tests, including an in-memory provider and Bun/Pi route mapping tests. + +## Consequences + +Plannotator's document experience becomes the upstream UI for both Plannotator and Workspaces. + +The first extraction target is not just `Viewer`. The implementation must move the document-domain behavior that makes the UI useful: writeback state, draft restore, linked-doc state, comments, edit state, file tree badges, and feedback assembly. + +The package boundary will force Plannotator-local assumptions behind an adapter. Filesystem paths, disk hashes, mtime, EOL metadata, and `/api/source/save` remain valid implementation details for Plannotator local sessions, but they must not become required public fields. + +The writeback model becomes shared and provider-neutral. This gives Workspaces the same dirty/saving/saved/conflict/missing/error UI without inheriting local filesystem semantics. + +`@plannotator/ui` will likely remain a lower-level UI primitive package at first. `@plannotator/document-ui` can depend on it and gradually pull document-specific components into the new package. We will avoid a giant one-shot component move. + +Plan diff, archive, goal setup, permission mode setup, and terminal runtime stay host-owned in the first boundary. They can be exposed as optional slots or capabilities where needed, but they are not core document package responsibilities. + +Bun/Pi server parity remains required. A frontend package extraction does not remove the need to update both server implementations when route behavior changes. The first implementation should avoid route shape changes and use adapter mapping instead. + +Tests must cover both the provider-neutral package behavior and the Plannotator local adapter behavior. At minimum, we need contract tests for Bun and Pi `/api/plan` and `/api/doc` mapping, source-save-to-writeback mapping, conflict and missing writeback results, in-memory provider surface behavior, linked-doc annotation caching, draft restore, and feedback payload assembly. + +This decision increases near-term implementation work because we are extracting behavior rather than only components. It reduces long-term duplication and prevents the sister repo from reimplementing Plannotator's document state machine under a different backend. diff --git a/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md b/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md new file mode 100644 index 000000000..cb4384dd0 --- /dev/null +++ b/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md @@ -0,0 +1,89 @@ +# 003. Complete Document UI Parity Cutover + +> ⚠️ **REVERTED — DO NOT IMPLEMENT.** This cutover was attempted by an AI agent and failed: it produced a ~26,500-line from-scratch reimplementation, deleted the working `App.tsx`, and broke rendering (dead sidebars, wrong experience). Reverted on 2026-06-22. **This ADR is superseded by `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** — read it before doing any document-UI work. Kept here as a post-mortem only. + +Date: 2026-06-21 + +## Status + +Accepted + +## Context + +ADR 002 established `@plannotator/document-ui` as the provider-neutral package for Plannotator's reusable document review experience. The branch has since implemented a substantial package: provider-neutral session/document types, `DocumentHostApi`, `DocumentReviewSurface`, writeback state, drafts, linked documents, document tree state, annotation persistence, feedback assembly, image handling, a Plannotator HTTP adapter, and an in-memory provider test harness. + +The package is real and green, but the app has not yet been cut over. `packages/editor/App.tsx` still owns the default Plan Review / Annotate render path. The new package surface is only mounted through an opt-in bridge behind `VITE_DOCUMENT_SURFACE=1`. + +The old shell still owns several parity-critical workflows: + +- plan diff and version browser +- richer document chrome: toolstrip, sticky controls, sidebars, panels, file/message navigation, code previews, and shortcuts +- full Ask AI panel +- agent terminal shell +- archive mode +- goal setup +- settings, share/export/import, and note integrations +- Plannotator route and environment side effects + +A sister Workspaces repo needs the same document review UI with a different provider. Keeping the shared package as an optional renderer while the hard behavior remains in `App.tsx` would recreate the coupling this extraction is meant to remove. + +## Decision + +We will finish the cutover so `@plannotator/document-ui` becomes the default production document review surface for the Plan Review / Annotate app. The feature-flagged bridge path will be removed after parity is reached. + +The package owns the reusable document review loop: + +- markdown and raw HTML document review +- annotation lifecycle, comments, global comments, image attachments, and annotation persistence hooks +- linked document navigation +- document tree/file tree UI, badges, and provider-neutral document/message navigation +- document editing and provider-neutral writeback states +- draft restore UI and state +- feedback payload assembly +- plan/document version browsing and diff UI +- generic Ask AI document-review surface when a host AI API exists +- code/link preview UI when the host can load or validate targets +- default chrome needed for parity: toolstrip, sticky controls, sidebars, panels, empty states, banners, shortcuts, and action buttons + +The host owns environment and product policy: + +- server routes, auth, browser opening, process lifetime, CLI/plugin/hook integration, and `ExitPlanMode` stdout decisions +- Plannotator settings persistence +- share/paste service policy and import/export modal policy +- Obsidian, Bear, and Octarine integrations +- agent terminal runtime, PTY/WebSocket bridge, installer, and remote security policy +- goal setup business logic +- archive storage, list loading, and archive-specific actions +- provider transport details for documents, comments, versions, and watches + +Version and diff support will move into `@plannotator/document-ui` as an optional provider-neutral capability. The host will load versions; the package will provide the default version browser, base-version selection, markdown diff computation, clean/raw diff render modes, diff annotations, edit-blocking behavior, and feedback inclusion. Plannotator will adapt `/api/plan/versions` and `/api/plan/version`; Workspaces can adapt its own document versions API without inheriting Plannotator route names. + +Archive and goal setup will not become core document-ui concepts for this cutover. Archive may be mounted through host slots or by loading read-only documents into the surface. Goal setup remains host-owned. + +Agent terminal runtime will stay host-owned. The package may provide slots and generic delivery state, but it will not own PTY, WebSocket, runtime install, remote-mode security, or terminal prompt policy. + +Ask AI will be shared only at the document-review surface level. The package may own the panel shell, document context, and in-document ask affordances. The host will own provider/model settings, auth, permission policy, and transport. + +`packages/editor/App.tsx` will be reduced to a Plannotator host shell. It should load the session through the Plannotator adapter, read settings, configure host slots and side effects, render completion/modals that remain Plannotator-owned, and render `DocumentReviewSurface`. It should no longer directly orchestrate the main document viewer, HTML viewer, plan diff viewer, annotation panel, linked-doc state machine, archive document rendering path, file/message navigation state, source-save UI state, or direct document feedback assembly. + +## Consequences + +The branch now has a concrete completion target: there should be one production document-review path, and it should go through `@plannotator/document-ui`. + +The cutover requires more work than the initial extraction because parity gaps must be closed before old code can be deleted. The biggest new package capability is provider-neutral version/diff support. + +The shared package will become larger and more product-shaped. That is intentional: the reusable value is the document review loop, not just renderer components. + +The Plannotator host shell remains necessary. It will still own routes, settings, share/export/note policy, archive storage, goal setup, terminal runtime, and hook/plugin behavior. Those are not shared document UI responsibilities. + +Workspaces gets a clear integration point: implement `DocumentHostApi` for workspace documents, manifests, annotations, writeback, and versions. It should not need to reimplement Plannotator's document state machine or inherit local source-save vocabulary. + +ADR 002 remains valid as the package boundary decision. This ADR extends it by declaring the cutover requirement and moving version/diff into the shared package as an optional capability. + +The implementation is complete only when: + +- the normal Plan Review / Annotate app renders through `@plannotator/document-ui` +- `VITE_DOCUMENT_SURFACE` is gone from production code +- the old document-review render path is removed +- plan review, annotate file, annotate folder, annotate last, raw HTML, linked docs, source-save, drafts, plan diff, Ask AI, terminal slot, archive, export/share, and note integrations still work +- Workspaces can supply a provider without depending on Plannotator local source-save terms diff --git a/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md b/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md new file mode 100644 index 000000000..28bad179d --- /dev/null +++ b/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md @@ -0,0 +1,82 @@ +# 004. Reuse the Document UI as Published Building Blocks (Reverts 003, Re-scopes 002) + +Date: 2026-06-22 + +## Status + +Accepted. + +**This ADR is the single source of truth for sharing Plannotator's document UI with the commercial Workspaces app.** It **reverts ADR 003** and **re-scopes ADR 002**. + +> If you are an agent or contributor about to work on "document-ui extraction," read this ADR first. Treat ADRs 002 and 003, and their specs/intents (`adr/specs/document-ui-extraction-*`, `adr/specs/document-ui-parity-cutover-*`, `adr/implementation/document-ui-*-intent-*`), as a **post-mortem of a failed attempt** — not a plan to execute. The `packages/document-ui` package they describe was deleted. Do not recreate it. + +## Context + +### What we actually want + +The commercial app, **Workspaces**, needs to reuse Plannotator's *presentation layer*: theme, Markdown rendering, the editor, settings UI, and layout/components — including the comment/annotation *rendering*. + +Workspaces is a separate, Cloudflare-based collaborative document platform. It owns its own world: + +- documents stored as versioned blob history (Git-like), D1 metadata, a raw file-serving worker (`tot.page`) +- workspaces/folders, public/private/open sharing, raw file URLs +- document version history and restore +- anchored comments and replies, shared with teammates +- agents commenting on documents via API keys +- realtime collaboration through Durable Objects +- browser login via WorkOS (hosted) or Cloudflare Access (self-host) +- hosted at `workspaces.plannotator.ai`, raw content at `tot.page` + +The shared thing is therefore **UI building blocks, not an application.** Workspaces renders Plannotator's components and feeds them *its own* data, comments, versions, and realtime sync. Plannotator keeps feeding the same components *its* local hook/file data. Same look; different data and backend behind it. + +### What we tried before (002/003) and why it failed + +The previous attempt built a ~26,500-line, 70-file `packages/document-ui` package containing a provider-neutral `DocumentReviewSurface` + `DocumentHostApi` meant to be *the whole app* for both Plannotator and Workspaces. It then deleted Plannotator's working 4,685-line `packages/editor/App.tsx` and routed the real app through the new surface. The result did not render correctly — dead sidebars, missing chrome, a different experience. The branch was reverted on 2026-06-22 (all of it was uncommitted working-tree changes; a backup patch + archive of the dead code is in the session scratchpad). + +Root causes (these are what this ADR exists to prevent repeating): + +1. **Abstracted for a consumer that didn't exist yet.** The provider-neutral contract was designed against an imagined Workspaces backend that couldn't be run or tested. Premature/speculative generality. +2. **The method was a rewrite, not a move.** Every behavior was re-derived as a new "provider-neutral decision function" with its own unit test. ~80 such steps = a from-scratch reimplementation by construction. +3. **The acceptance bar couldn't see the failure.** Verification was `bun test` / `typecheck` / `build` only. 357 unit tests stayed green while the actual rendered app was broken. Nobody opened it. +4. **Deleted the known-good code before parity existed.** The team's own parity SPIKE measured only ~55–65% app-visible parity, yet the working shell was deleted anyway, with a demo page as the fallback. + +## Decision + +1. **Plannotator's app stays as it is.** No cutover. `packages/editor/App.tsx` and the current experience are the reference to preserve, not a thing to replace. There is no "flip the production app to a new surface" step in this plan. + +2. **Share by publishing `@plannotator/ui` (and, if a slimmer editor package is needed, a small editor package) as versioned npm packages.** Workspaces installs them as a dependency. There is no shared "whole-app surface," no `DocumentReviewSurface`, and no `DocumentHostApi`. + +3. **Shared = presentation building blocks that take their data via props/callbacks.** In scope: + - theme and color tokens (`packages/ui/theme.css`) + - Markdown parser + block renderer (`parser.ts`, `BlockRenderer`, block components) + - document viewer / editor components + - settings UI + - layout / chrome components (toolbars, sidebars, panels) + - comment / annotation **rendering** components (the visual presentation of an anchored comment and its replies) + + The real, *narrow* extraction work is this: where a shared component currently calls a hard-coded `/api/*` route or reaches into Plannotator-only globals, lift that I/O up to a prop or callback so the host supplies it. Make the components backend-agnostic. **Do not rebuild their logic.** + +4. **NOT shared — each app owns its own:** document and comment *data* and state, realtime sync, version storage, feedback/delivery, server routes, auth, and backend. Workspaces wires comments to Durable Objects and its D1/blob store; Plannotator wires them to its local hook/file model. The shared component renders what it is given and emits events; it does not know who stores the data. + +## Hard rules (these are the safeguards we lacked) + +- **Move, don't rewrite.** Relocate existing code and change import paths. If a slice produces a large amount of brand-new code, stop — that is the warning sign that you are reimplementing instead of extracting. +- **No hard-coded routes or backend assumptions in shared packages.** Data comes in via props; actions go out via callbacks. +- **Parity is the gate, and a human verifies it in the browser.** After any change, Plannotator must look and behave identically across every mode: plan review; annotate file / folder / last; raw HTML; archive; goal setup; sidebars; plan diff; keyboard shortcuts; themes; settings; editor. Passing tests are necessary but **not sufficient** — last time they were green the entire time the app was broken. +- **Never delete or replace working code until a human signs off on parity**, mode by mode. Keep the old path until the replacement is proven. +- **Small, reviewed increments.** One component family at a time, eyeballed in the running app. No day-long unattended agent runs. + +## Consequences + +- Plannotator is never at risk during this work; its app keeps running unchanged the whole time. +- Workspaces gets a real, versioned dependency (`@plannotator/ui`) it can build its own product around, without inheriting Plannotator's routes, hooks, or local-file assumptions. +- The boundary is honest and small: **share the look, own the data.** A comment renders the same in both apps; how it syncs and persists is each app's own concern. +- Publishing adds a release/version step for the shared package(s). That is the accepted cost of a clean separate-repo boundary (Workspaces is its own repo on Cloudflare). +- ADRs 002 and 003 and their specs/intents are kept as history. The 2026-06-22 review-fixes spec (`adr/specs/document-ui-feature-completeness-review-fixes-*`) remains useful as a **checklist of behaviors the UI must preserve** — but read it as an inventory, not a build plan. + +## References + +- Reverts: `adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md` +- Re-scopes: `adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md` +- Failed-attempt parity inventory (reuse as a checklist only): `adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md` +- Sound research on how the current system works: `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` diff --git a/adr/implementation/document-ui-extraction-intent-20260620-085249.md b/adr/implementation/document-ui-extraction-intent-20260620-085249.md new file mode 100644 index 000000000..e0dd7dc78 --- /dev/null +++ b/adr/implementation/document-ui-extraction-intent-20260620-085249.md @@ -0,0 +1,287 @@ +# Document UI Extraction Intent + +> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Implementation log of the failed `@plannotator/document-ui` extraction (reverted 2026-06-22). The long "implemented slice" list here is a record of the from-scratch rewrite that broke the app. Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. History only. + +Status: active + +Date: 2026-06-20 + +## Intent + +Make Plannotator's document review experience reusable by Plannotator and Workspaces without turning the shared package into either a thin renderer or a local-filesystem abstraction. + +The shared package should own the product behavior users recognize as Plannotator's document review loop: + +- render markdown and raw HTML +- annotate text and blocks +- manage comments, global comments, and image attachments +- navigate linked documents +- browse document trees +- edit documents +- show clean, dirty, saving, saved, conflict, missing, and error writeback states +- restore drafts +- assemble annotation feedback and saved-change context + +The host should own routes, auth, server calls, plan-mode hook behavior, local disk or workspace persistence, and provider-specific policy. + +## Current Read + +The strongest boundary is a single `@plannotator/document-ui` package with a provider-neutral contract: + +```tsx + +``` + +The first extraction target is not `Viewer` alone. `Viewer` is important, but the hard reusable value is the document-domain state around it: loaded document identity, linked docs, editable content, writeback state, saved-change context, draft restore, document tree badges, and feedback assembly. + +Local source-save is Plannotator's first provider. It should stay behind `createPlannotatorHttpDocumentApi()` and map into provider-neutral writeback concepts. Workspaces should be able to implement the same contract with workspace document ids, manifests, versions, `If-Match`, and its own annotation APIs. + +## Execution Path + +1. Establish provider-neutral contracts in `packages/document-ui`. +2. Add a Plannotator HTTP adapter over the current server routes. +3. Move pure document-domain state first: writeback records, draft restore shapes, saved-change tracking, and feedback edit assembly. +4. Keep existing Plannotator UI behavior working through compatibility wrappers while logic moves out of `packages/editor`. +5. Wrap the current render and annotation experience in `DocumentReviewSurface`. +6. Shrink `packages/editor/App.tsx` into a host shell that loads session data, configures capabilities, handles plan/annotate policy, and renders the shared surface. +7. Add an in-memory provider test harness so Workspaces behavior can be exercised without Plannotator-local routes. + +## Guardrails + +- Do not rename `/api/plan` or current Plannotator routes during the extraction. +- Do not expose `/api/source/save`, disk hashes, mtime, or filesystem paths as required shared-package concepts. +- Do not split the package into many small packages yet. +- Do not move plan diff, archive, goal setup, permission-mode setup, or terminal runtime into the first shared surface. +- Preserve Bun/Pi server parity when route behavior changes, but avoid route changes in the first extraction slice. +- Keep Plannotator-specific final message wrapping in the Plannotator host; move provider-neutral feedback assembly into the package. + +## Implemented Slice + +The first implementation slice is intentionally narrow but now covers the reusable document-domain contract and its first Plannotator adapter: + +- create `packages/document-ui` +- define `DocumentRef`, `LoadedDocument`, `DocumentReviewSession`, `DocumentHostApi`, and writeback result types +- add `createPlannotatorHttpDocumentApi()` over current Plannotator routes +- move writeback state into provider-neutral helpers +- move direct-edit and saved-change feedback assembly into provider-neutral helpers +- move annotation/global attachment feedback assembly into provider-neutral helpers +- add document tree state with provider-neutral row identity, expansion, aggregate counts, and writeback badges +- add linked-document cache/navigation state with linked annotation feedback entries +- add provider-neutral draft state that saves/restores annotations, linked-document annotation entries, dirty writeback documents, and saved-change context through `DocumentHostApi` +- add provider-neutral edit/writeback state that drives active edit buffers, save requests, save success, conflicts, missing documents, discard, reload-conflict, unsaved documents, and saved-change entries through `DocumentHostApi.saveDocument` +- add an initial `DocumentReviewSurface` wrapper that resolves the active document, seeds writeback state, exposes render-state, lazy-loads the existing Plannotator markdown/raw-HTML renderers, routes renderer linked-document clicks through `hostApi.resolveLinkedDocument`/`hostApi.loadDocument`, and renders provider-neutral document chrome for writeback status, draft restore, edit/save/discard, conflict overwrite, conflict reload, linked-document back/error controls, document tree/file-row navigation, and annotation/right-panel presentation for current-document comments, attachments, code annotations, linked-document feedback, unsaved writeback edits, and saved-change context +- route raw-HTML iframe local document links through the same linked-document resolver as markdown links while leaving external, anchor, unsafe, and annotation-control clicks alone +- add typed React host slots (`terminalPanel`, left/right extras, header actions, footer), Plannotator-theme layout classes, and real renderer mode options (`selection`, `comment`, `redline`, `quickLabel`, `drag`, `pinpoint`) so the default surface can carry host-specific app chrome without owning host policy +- add `createMemoryDocumentHostApi()` as a Workspaces-like in-memory provider harness with document ids, manifest trees, base-revision saves, conflict/missing results, draft round trips, linked-doc resolution, and watch events +- add provider-neutral review lifecycle actions to `DocumentReviewSurface`: assemble feedback payloads with linked annotations, unsaved direct edits, and saved-change context; call `hostApi.submitFeedback`, `hostApi.approve`, and `hostApi.exit`; clear drafts after successful terminal actions; surface action errors and default action buttons without baking in Plannotator route policy +- add provider-neutral writeback watching through `useDocumentWritebackWatch`: subscribe to `hostApi.watchDocuments`, reload changed open documents through `hostApi.loadDocument`, reconcile clean updates, preserve dirty buffers as conflicts, mark deleted/missing documents, and expose watch state from the shared surface +- add optional provider-neutral annotation persistence through `hostApi.loadAnnotations`/`hostApi.saveAnnotations`, so Workspaces can back the same comment UI with its annotations API while local Plannotator can keep relying on drafts and terminal feedback +- add Plannotator host-session normalization and editor load-plan derivation in the local HTTP adapter: `/api/plan` responses are now translated once into document-session mode flags, render mode, markdown/html payloads, annotate source, sharing settings, source-file paths, root source-save writeback, recent messages, archive/goal setup metadata, version metadata, and the concrete app-shell initialization plan before `packages/editor/App.tsx` applies React side effects +- add an explicit opt-in Plannotator app bridge for `DocumentReviewSurface`: when `VITE_DOCUMENT_SURFACE=1` or `true`, `packages/editor/App.tsx` now hands the normalized session to `PlannotatorDocumentSurfaceBridge`, which mounts `` using the Plannotator HTTP adapter while the default production path keeps the legacy Plannotator shell +- move Plannotator direct-edit feedback compatibility formatting into `@plannotator/document-ui/plannotator-feedback`, including legacy direct-edit wording, saved-file-change wording, edit badge stats, panel item builders, provider-neutral current direct-edit content resolution over live/stored edit buffers, direct-edit commit decisions for stored edits/panel reveal/remapping, direct-edit discard decisions for reset/remap behavior, direct-edit draft restore decisions with CRLF normalization and current-work-wins skipping, direct-edit feedback presence decisions, saved-change-vs-direct-edit panel precedence, and current direct-edit feedback-section gating while writeback buffers are pending; `packages/editor` now imports that behavior from the shared package +- move Plannotator source-save editable-document state and disk reconciliation into `@plannotator/document-ui` adapter exports (`plannotator-source-documents`, `plannotator-source-reconciliation`), preserving local disk hash/missing-file behavior as Plannotator compatibility while keeping it out of the provider-neutral core contract +- move the Plannotator source-document `/api/doc` probe/snapshot client into `@plannotator/document-ui/plannotator-source-client`, so source-save hash refresh, missing-file detection, and markdown snapshot loading sit with the local adapter instead of the editor shell +- move the Plannotator restored single-file draft selection helper into the source-document adapter helpers, so source-save draft restore display policy is shared rather than editor-local +- move reusable feedback text assembly into `@plannotator/document-ui/feedback-text`: current-document annotations, linked-document annotations, editor annotations, code-file annotations, multi-message feedback, empty-feedback sentinels, source-specific titles, converted-source caveats, and linked-document markdown block enrichment now live in the shared package while Plannotator delivery policy stays in the editor shell +- move reusable multi-message feedback entry assembly into `@plannotator/document-ui/feedback-text`: the shared package now converts message picker rows plus linked-session annotation state into parser-ready message feedback entries, including root markdown blocks, linked-document markdown blocks, global attachments, and code annotations; `packages/editor/App.tsx` keeps selecting/saving message state and deciding when message-mode feedback is active +- move provider-neutral feedback submission interpretation into `@plannotator/document-ui/feedback-submission`: the shared package now composes annotation text with direct-edit and saved-change sections, reports whether review content exists, distinguishes saved-change-only context from unsent feedback, produces feedback-loss wording, and decides whether approve-with-notes payloads should include feedback text +- move annotate feedback target selection into `@plannotator/document-ui/feedback-submission`: the shared package now chooses linked document, source file, active file, folder, or current-file fallback targets for annotate feedback while `packages/editor/App.tsx` keeps Plannotator's message/file feedback templates and terminal delivery side effects +- move provider-neutral annotation remapping and highlight-restore decisions into `@plannotator/document-ui/annotation-remap`: markdown edits, reloads, and draft restores can now re-anchor annotations by selected text against newly parsed blocks, preserve diff/global/checkbox annotations, clear stale positional metadata when block ids move, mark missing text with an empty block id, choose which annotations should be restored into document highlights, detect missing restored highlights through a host-provided lookup, and build missing-highlight warning copy; `packages/editor/App.tsx` keeps markdown state, edit generation, DOM lookup, highlight repaint, and toast side effects +- move Plannotator-specific route payload assembly into `@plannotator/document-ui/plannotator-delivery`: approve, deny, annotate feedback, note-integration payloads, plan-save payloads, message-scope fields, and draft-generation URL helpers now sit with the local adapter while the editor shell keeps deciding when to call each route +- add a Plannotator delivery client in `@plannotator/document-ui/plannotator-delivery` and wire `packages/editor/App.tsx` approve, deny, annotate-feedback, annotate-approve, and annotate-exit handlers through it; the editor shell still owns settings lookup, saved-change validation side effects, terminal fallback, and submitted-state UI +- move generic agent-delivery state into `@plannotator/document-ui/agent-delivery`: feedback hashing, delivery records, target matching, duplicate-send decisions, current-delivery derivation, delivered-status visibility, and feedback-to-send flags are now provider-neutral; Plannotator's terminal helper keeps only terminal prompt/target formatting and adapts to the shared record shape +- move saved-change validation decisions into `@plannotator/document-ui/saved-change-validation`: submit-time stale/unverified blocking and draft-restore kept/changed-or-missing/unverified interpretation are now shared, while Plannotator keeps toast, cleanup, and draft-scheduling side effects in the host shell +- move direct-edit begin/change state decisions into `@plannotator/document-ui/edit-feedback`: non-writeback edit sessions now normalize CRLF before seeding the edit baseline, resolve missing original baselines, and report dirty/diff state through shared direct-edit lifecycle decisions while `packages/editor/App.tsx` keeps React state, source-save branching, terminal feedback revision, and draft scheduling side effects +- move direct-edit commit/discard display decisions into `@plannotator/document-ui/edit-feedback`: stored edit content, edit-stat reset/input, edit-panel reveal, editor dirty/diff reset, and remap content now flow through shared direct-edit decisions before `packages/editor/App.tsx` applies refs and annotation repaint +- move direct-edit draft-restore display decisions into `@plannotator/document-ui/edit-feedback`: restored, skipped, and ignored draft edit outcomes now map to stored edit content, edit-stat input, editor diff reset, edit-panel reveal, and remap content before `packages/editor/App.tsx` applies refs, annotation repaint, toasts, and draft scheduling +- move document review action lifecycle state into `@plannotator/document-ui/action-controller`: submitting/exiting lanes, open-session outcomes, submitted completions, and failure recovery are now shared; `packages/editor/App.tsx` approve, deny, annotate feedback, annotate approve, annotate exit, goal-setup exit, and callback delivery paths now use the shared controller while preserving Plannotator route policy and terminal fallback behavior +- move reusable review chrome copy and surface visibility decisions into `@plannotator/document-ui/chrome`: recovered-draft messages, add-feedback prompts, saved-change awareness text, unsaved-edit warnings, unsaved writeback continuation decisions, feedback/approve/exit/primary-submit action-intent decisions, submit-shortcut routing/ignore decisions, print-shortcut routing/ignore decisions, version/diff edit-block decisions, document-navigation edit-block decisions, document layout width state, feedback-loss warnings, completion-overlay title/subtitle decisions, sticky-header visibility, annotation-toolstrip visibility, folder-empty state, normal-document visibility, inline document-control visibility, left-sidebar collapsed/expanded visibility, left-sidebar tab open/toggle/wide-exit decisions, initial/TOC sidebar preference decisions, empty-TOC auto-close decisions, document-area collapsed-sidebar offset, sidebar tab visibility, right-panel tab visibility, right-panel toggle/reveal decisions, AI-panel visibility, panel resize-handle visibility, header action visibility/control state, viewer remount identity, linked-document breadcrumb variants/back labels, document copy labels, open targets, and message-picker count state are now shared; `packages/editor/App.tsx` and `AppHeader` keep the existing dialog/components, Claude Code issue links, warning continuation callbacks, provider capability flags, agent checks, DOM event wiring, callbacks, print side effect, Plannotator-specific linked-document labels, and local storage/path wording +- move provider-neutral Ask AI context assembly into `@plannotator/document-ui/ai-context`: the shared package now derives plan vs document AI context, document targets, source metadata, raw HTML vs markdown content, thread keys/titles, general ask labels, folder-empty blocking, and readable target priority without depending on the AI provider package; `packages/editor/App.tsx` keeps `useAIChat`, provider/model settings, terminal fallback delivery, toasts, and prompt formatting +- move reusable left-sidebar tab/open state into `@plannotator/document-ui/left-sidebar`: the shared generic controller now owns active-tab/open state, raw open/close transitions, review open/toggle transitions, preference-decision application, empty-TOC auto-close application, and wide-mode exit effects; `packages/editor/App.tsx` keeps concrete Plannotator tab content, archive/file/message loading side effects, resize widths, and invokes the host-owned wide-mode exit side effect +- move reusable right-panel tab/open state into `@plannotator/document-ui/right-panel`: the shared controller now owns annotation/AI active-tab state, open/close transitions, toggle/reveal transitions, compact-viewport reveal policy, and wide-mode exit effects; `packages/editor/App.tsx` keeps resize widths, mobile layout, and invokes the host-owned wide-mode exit side effect +- move reusable annotation state, visibility, feedback-presence, and provider-mutation routing into `@plannotator/document-ui/review-state`: the shared package now owns the root annotation/code-annotation/selection/global-attachment reducer, semantic add/select/update/remove actions, opposite-selection clearing, React-style setter adapters for Plannotator compatibility hooks, local/provider annotation merging while preferring live provider copies over draft-restored duplicates, rendered-viewer vs diff annotation partitioning, message/document feedback presence/counts, and provider-vs-local edit/delete routing; `packages/editor/App.tsx` keeps external annotation route calls, DOM highlight repaint, checkbox visual overrides, file-popout opening, and linked-document cache side effects +- move linked-document annotation badge/count summaries into `@plannotator/document-ui/linked-state`: the shared package now derives per-document annotation counts from linked-document caches, scopes those counts with a host-owned containment predicate, and summarizes annotations outside the active document for right-panel badges; `packages/editor/App.tsx` keeps the legacy `useLinkedDoc` cache, Plannotator filesystem path containment predicate, file-browser highlighting timer, and `AnnotationPanel` prop shape +- move linked-document editable-load decisions into `@plannotator/document-ui/linked-state`: the shared package now decides when linked-document navigation suspends an active writeback edit, clears active editability for non-editable/HTML targets, opens a folder-linked markdown document as editable, and resets an already-open editor session from current-vs-baseline content; `packages/editor/App.tsx` keeps the Plannotator editable-document store mutations, source-save keys, and React state side effects +- move linked-document back edit-state decisions into `@plannotator/document-ui/linked-state`: returning from a linked document now gets a shared decision for whether to exit edit mode and reset active editor dirty/diff state while `packages/editor/App.tsx` keeps invoking linked-document back, file-browser active-file clearing, and archive selection clearing +- move linked-document editable snapshot decisions into `@plannotator/document-ui/linked-state`: before linked-document navigation and submission, the shared package now decides whether to snapshot live editor content, displayed content, or nothing while `packages/editor/App.tsx` keeps reading the editor handle and mutating the Plannotator editable-document store +- move reusable linked-message annotation cache helpers into `@plannotator/document-ui/linked-state`: the shared package now counts annotations across root documents, linked documents, attachments, and code comments; creates empty message annotation snapshots; normalizes immutable message root content when picker messages change; and builds per-message badge counts over a linked-session-like shape without depending on Plannotator `/api/doc` or local filesystem paths; `packages/editor/App.tsx` keeps the legacy `useLinkedDoc` hook, message picker state, code-popout side effects, and feedback-entry rendering +- move current-message annotation state and active message badge-count decisions into `@plannotator/document-ui/linked-state`: the shared package now builds the live selected-message snapshot from message rows, linked-document session snapshots, code annotations, and selected code comment ids, and overlays that live state onto cached per-message counts; `packages/editor/App.tsx` keeps storing the cache ref/state and invoking the legacy linked-doc restore side effects +- move message-state cache merge/count recomputation and annotate feedback message-scope decisions into `@plannotator/document-ui/linked-state`: the shared package now folds the live selected-message state into cached message states, produces refreshed per-message annotation counts, and decides selected-message vs multi-message feedback scope for submissions; `packages/editor/App.tsx` keeps cache refs/state setters and Plannotator route body construction +- move message selection decisions into `@plannotator/document-ui/linked-state`: the shared package now decides whether a message picker request should be ignored or should select a normalized target message state, using cached message state when present and empty message state otherwise; `packages/editor/App.tsx` keeps the actual selected-message state update, legacy linked-doc restore, and code annotation restoration side effects +- move active message annotation count summaries into `@plannotator/document-ui/linked-state`: the shared package now derives total message feedback count, annotated message ids, and has-annotation flags from active per-message counts; `packages/editor/App.tsx` keeps rendering those values and passing them to sidebar/submission policy +- move wide/focus layout mode decisions into `@plannotator/document-ui/wide-mode`: wide-mode availability, enter/toggle/forced-exit decisions, sidebar/panel snapshot capture, sidebar/panel snapshot restore, explicit sidebar reopen, explicit panel reopen, and no-restore exit behavior are now shared; `packages/editor/wideMode.ts` remains a compatibility wrapper for the old Plannotator option names +- move reusable edit/writeback chrome decisions into `@plannotator/document-ui/edit-chrome`: markdown edit availability/reason classification, save button labels/disabled/tone state, edit/done/cancel/discard labels, edit-exit click transitions, stale discard-confirmation reset decisions, dirty/failed writeback status predicates, save-shortcut document/host/ignore routing, conflict banner copy, and missing-document banner copy are now shared; `packages/editor/App.tsx` maps those neutral states to existing Tailwind classes, passes Plannotator's disk wording and surface-mode facts, and keeps host notes/export fallback behavior, while the default `DocumentReviewSurface` uses the same save-label helper +- move reusable writeback edit-session chrome state into `@plannotator/document-ui/edit-chrome`: active-buffer dirtiness, conflict overwrite availability, and cancel-mode derivation now come from provider-neutral writeback content/status inputs while `packages/editor/App.tsx` keeps Plannotator source-document field mapping and button rendering +- move Plannotator local source-save request and response mapping into `@plannotator/document-ui/plannotator-source-client`: the adapter now builds `/api/source/save` bodies, maps success metadata back to source-save capabilities, preserves conflict snapshots, and normalizes local write errors; `packages/editor/App.tsx` keeps applying those mapped results to its compatibility store, repainting annotations, and showing Plannotator toasts +- move Plannotator local source-save result application into `@plannotator/document-ui/plannotator-source-documents`: mapped save results now update the source-document compatibility store for saved, live-dirty-after-save, conflict, clean-updated, conflict-unavailable, and error outcomes; `packages/editor/App.tsx` keeps repaint, toast, panel, and draft-scheduling side effects +- move Plannotator source-save display classification into `@plannotator/document-ui/plannotator-source-documents`: saved, clean-updated, conflict, conflict-unavailable, error, and noop outcomes now map to active editor state, edit-stat inputs, repaint/reset text, panel reveal, draft-save intent, edited-buffer clearing, and notification intent before `packages/editor/App.tsx` applies React effects and toasts +- move Plannotator source-backed edit-session begin/change classification into `@plannotator/document-ui/plannotator-source-documents`: entering edit mode now normalizes displayed source text, seeds the source edit buffer, and reports disk-baseline diff state, while live editor changes update source state and report edit-session/disk-baseline dirtiness; `packages/editor/App.tsx` keeps React UI flags and draft scheduling +- move Plannotator source-backed edit-session begin/change display classification into `@plannotator/document-ui/plannotator-source-documents`: source edit begin/change outcomes now map to edit-session reset text and active editor dirty/diff state before `packages/editor/App.tsx` applies refs, React editing flags, terminal feedback revision, and draft scheduling +- move Plannotator source-backed edit-commit classification into `@plannotator/document-ui/plannotator-source-documents`: committing the editor buffer now updates the source-document compatibility store, normalizes editor line endings, and reports disk-baseline diff state; `packages/editor/App.tsx` keeps edit-stat rendering, panel opening, markdown repaint, and draft-scheduling side effects +- move Plannotator source-backed edit-commit display classification into `@plannotator/document-ui/plannotator-source-documents`: committed, clean, and ignored source-edit outcomes now map to edited-buffer clearing, edit-stat reset/input, edit-panel reveal, and normalized markdown remap content before the host applies the shared edit-display effect plan +- move Plannotator source-file discard and reload-conflict outcome/display classification into `@plannotator/document-ui/plannotator-source-documents`: source-backed discard now reports active/non-active, removed-file, and replacement-text outcomes, then maps them to active editor reset, repaint text, root empty-document reset, linked-document back-navigation intent, active-file cleanup intent, and draft-save intent; reload-conflict reports the reloaded snapshot and maps it to repaint/reset, clean edit state, draft-save intent, and notification intent; `packages/editor/App.tsx` keeps applying React state, highlights, linked-doc/file-browser effects, toasts, and draft scheduling +- move Plannotator missing source-file selection display classification into `@plannotator/document-ui/plannotator-source-documents`: selecting a missing source-backed file now maps to reopened markdown content, active source key, optional edit-session reset text, and active editor dirty/diff/stat input before `packages/editor/App.tsx` applies linked-document, file-browser, and React side effects +- move Plannotator source-backed draft restore display classification into `@plannotator/document-ui/plannotator-source-documents`: restored source drafts now decide single-file vs active-folder display, active-key selection, repaint text, edit-stat inputs, and panel reveal in the local adapter; `packages/editor/App.tsx` keeps applying React state, highlights, and draft-scheduling side effects +- move Plannotator source-backed draft restore edit-display classification into `@plannotator/document-ui/plannotator-source-documents`: restored source draft display outcomes now map to shared active editor dirty/diff/stat state and edit-panel reveal intent while `packages/editor/App.tsx` keeps remapping the newly restored annotation list before applying the shared edit-display effects +- move Plannotator source-document reconcile event classification into `@plannotator/document-ui/plannotator-source-reconciliation`: file-missing, clean-update, status-update, and conflict events now map to active-document repaint/reset, edit-state, edit-stat, and notification outcomes in the local adapter; `packages/editor/App.tsx` keeps the actual React state updates, highlight repaint, toasts, and draft scheduling +- move the default `DocumentReviewSurface` editor-session lifecycle into `@plannotator/document-ui/documentEditorSession`: begin/change/save/overwrite/discard/reload-conflict/draft restore now coordinate through the provider-neutral writeback and draft controllers instead of living inline in the renderer +- move reusable edit-display effect planning into `@plannotator/document-ui/edit-display`: repaint text, edit-session reset text, active editor dirty/diff/stat state, edit-panel reveal, draft-save intent, and edited-buffer clearing now normalize through one provider-neutral plan before the Plannotator shell applies DOM repaint, refs, toasts, and local linked-file cleanup +- move the default `DocumentReviewSurface` toolbar/sidebar chrome state into `@plannotator/document-ui/chrome`: edit/save/conflict visibility, pending action labels, document-tree sidebar visibility, and annotation-persistence badge visibility now come from a pure shared helper before the surface renders its default JSX +- make shared document-surface image upload provider-owned: `DocumentReviewSurface` now exposes and passes `hostApi.uploadImage` into markdown, raw-HTML, global, and per-comment attachment controls, while legacy `@plannotator/ui` callers still keep the `/api/upload` fallback and providers without upload support disable file upload rather than silently calling Plannotator-local routes +- make shared document-surface image display provider-owned: `DocumentReviewSurface` now passes a host-owned image URL resolver through markdown images, raw HTML blocks, attachment thumbnails, and re-edit previews; the Plannotator HTTP adapter maps local paths to `/api/image`, while generic providers can return workspace asset URLs without inheriting Plannotator-local routes +- self-review tightened the image contract: the Plannotator adapter now populates `LoadedDocument.imageBase` for root and linked documents, derives image bases from `documentRef.path` when needed, resets thumbnail load/error state when image URLs change, treats Windows absolute paths as absolute in the legacy image fallback, and treats `allowImageAttachments: false` as disabling attachment controls rather than only disabling file upload +- map Plannotator renderer linked-doc hrefs into neutral refs in the local HTTP adapter, preserving `/api/doc` base resolution without exposing local source-save as a shared-package requirement +- wire `packages/editor/App.tsx` through the Plannotator HTTP document adapter for initial session loading +- map legacy Plannotator `/api/draft` source-save records into neutral draft/writeback records in the local adapter +- keep Plannotator compatibility wrappers in `packages/editor` so current feedback wording and saved-file validation behavior stay stable + +This gives both repositories a concrete contract to evaluate before the larger React surface move. + +## Remaining Work + +- Continue visual parity work where the existing editor still owns sticky toolstrips, sidebars, plan diff, archive, goal setup, Ask AI, and other app-shell policy outside `DocumentReviewSurface`. +- Shrink `packages/editor/App.tsx` into a Plannotator host shell for plan/annotate policy, route handling, settings, and legacy plan-mode behavior. +- Decide whether threaded comment/reply history and version history live in this package contract now or stay as host-provided optional capabilities until Workspaces integration exercises them. + +## Current Verification + +Validated after the latest document-ui extraction: + +- `bun test` for the focused `packages/document-ui` suite: 101 passing tests across 17 files. +- `bun test` for the focused `packages/document-ui` suite after feedback assembly extraction: 105 passing tests across 18 files. +- `bun test` for the focused `packages/document-ui` suite after feedback submission extraction: 110 passing tests across 19 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator delivery extraction: 115 passing tests across 20 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator delivery client wiring: 117 passing tests across 20 files. +- `bun test` for the focused `packages/document-ui` suite after generic agent-delivery extraction: 120 passing tests across 21 files. +- `bun test` for the focused `packages/document-ui` suite after saved-change validation decision extraction: 123 passing tests across 21 files. +- `bun test` for the focused `packages/document-ui` suite after action-controller extraction: 128 passing tests across 22 files. +- `bun test` for the focused `packages/document-ui` suite after chrome-copy extraction: 134 passing tests across 23 files. +- `bun test` for the focused `packages/document-ui` suite after edit-chrome extraction: 138 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-save client extraction: 141 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-save result application extraction: 145 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-file discard/reload outcome extraction: 151 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-backed edit-commit extraction: 155 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-backed edit-session begin/change extraction: 161 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator source-document reconcile event classification extraction: 165 passing tests across 24 files. +- `bun test` for the focused `packages/document-ui` suite after annotation-remap extraction: 170 passing tests across 25 files. +- `bun test` for the focused `packages/document-ui` suite after annotation highlight-restore helper extraction: 173 passing tests across 25 files. +- `bun test` for the focused `packages/document-ui` suite after edit-availability extraction: 176 passing tests across 25 files. +- `bun test` for the focused `packages/document-ui` suite after viewport-state extraction: 179 passing tests across 25 files. +- `bun test` for the focused `packages/document-ui` suite after wide-mode extraction: 185 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after right-panel state extraction: 187 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after left-sidebar state extraction: 189 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after sidebar-tab state extraction: 191 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after unsaved writeback continuation extraction: 193 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after review action-intent extraction: 197 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after header action-state extraction: 200 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after submit-shortcut gate extraction: 203 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after save-shortcut decision extraction: 205 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after right-panel toggle extraction: 207 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after left-sidebar tab decision extraction: 209 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after sidebar preference decision extraction: 213 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after wide-mode enter/toggle decision extraction: 219 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after right-panel reveal decision extraction: 221 passing tests across 26 files. +- `bun test` for the focused `packages/document-ui` suite after right-panel controller extraction: 226 passing tests across 27 files. +- `bun test` for the focused `packages/document-ui` suite after left-sidebar controller extraction: 231 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after annotation visibility/count extraction: 235 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after annotation mutation-routing extraction: 238 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after root annotation-state hook wiring: 240 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after annotation reducer-action wiring: 241 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after linked-message annotation cache extraction: 243 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after multi-message feedback entry extraction: 244 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after current-message state/count extraction: 247 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after message cache/scope extraction: 249 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after message selection decision extraction: 251 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after message count-summary extraction: 252 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after linked-document file badge-count extraction: 255 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after document chrome identity/label extraction: 257 passing tests across 28 files. +- `bun test` for the focused `packages/document-ui` suite after Ask AI context extraction: 262 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after print shortcut decision extraction: 263 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after version/diff edit-block extraction: 264 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after document-navigation edit-block extraction: 265 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after document layout width extraction: 266 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after edit-exit transition extraction: 268 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after current direct-edit content resolver extraction: 269 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit feedback/panel extraction: 271 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after current direct-edit feedback-section gate extraction: 273 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after linked-document editable-load decision extraction: 275 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit commit decision extraction: 276 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit discard decision extraction: 277 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit draft restore decision extraction: 278 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after source-backed draft restore display extraction: 282 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after source-save display classification extraction: 287 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after source discard/reload display classification extraction: 293 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after shared edit-panel presentation extraction: 295 passing tests across 29 files. +- `bun test` for the focused `packages/document-ui` suite after shared default editor-session extraction: 300 passing tests across 30 files. +- `bun test` for the focused `packages/document-ui` suite after shared edit-display effect planning extraction: 305 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after source-backed edit-commit display classification extraction: 309 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit commit/discard display decision extraction: 312 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit draft-restore display decision extraction: 313 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after direct-edit begin/change state decision extraction: 315 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after source-backed edit-session begin/change display classification extraction: 317 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after writeback edit-session chrome-state extraction: 318 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after missing source-file selection display classification extraction: 321 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after source-backed draft restore edit-display classification extraction: 322 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after linked-document back edit-state decision extraction: 323 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after linked-document editable snapshot decision extraction: 324 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after annotate feedback target selection extraction: 325 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after agent-delivery state derivation extraction: 326 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after Plannotator editor load-plan extraction: 326 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after default surface chrome-state extraction: 327 passing tests across 31 files. +- `bun test` for the focused `packages/document-ui` suite after provider-owned image upload threading: 328 passing tests across 31 files. +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/ui/components/html-viewer/bridge-script.test.ts`: 29 passing tests. +- `bun test` for the focused `packages/document-ui` suite after provider-owned image display threading: 329 passing tests across 31 files. +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/ui/components/html-viewer/bridge-script.test.ts` after ADR self-review fixes: 29 passing tests. +- `bun test` for the focused `packages/document-ui` suite after ADR self-review fixes: 329 passing tests across 31 files. +- `bun run typecheck` after ADR self-review fixes. +- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build` after ADR self-review fixes. +- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build` after ADR self-review fixes. +- `bun run --cwd apps/hook build` after ADR self-review fixes. +- `bun run --cwd apps/review build` after ADR self-review fixes. +- `git diff --check` after ADR self-review fixes. +- `bun run typecheck` after provider-owned image display threading. +- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. +- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build`. +- `bun run --cwd apps/hook build`. +- `bun run --cwd apps/review build`. +- `git diff --check`. +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/ui/components/html-viewer/bridge-script.test.ts`: 16 passing tests. +- `bun test packages/document-ui/documentReviewChrome.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 55 passing tests. +- `bun test packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 3 passing tests. +- `bun test packages/editor/documentSurfaceBridge.test.ts`: 2 passing tests. +- `bun test packages/editor/documentSurfaceBridge.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 13 passing tests. +- `bun test packages/document-ui/documentAIContext.test.ts`: 5 passing tests. +- `bun test packages/document-ui/documentFeedbackText.test.ts`: 5 passing tests. +- `bun test packages/document-ui/documentFeedbackSubmission.test.ts`: 6 passing tests. +- `bun test packages/document-ui/documentAgentDelivery.test.ts`: 4 passing tests. +- `bun test packages/document-ui/plannotatorHttpApi.test.ts`: 12 passing tests. +- `bun test packages/document-ui/documentEditDisplay.test.ts`: 6 passing tests. +- `bun test packages/document-ui/documentEditorSession.test.ts`: 5 passing tests. +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx`: 11 passing tests. +- `bun test packages/document-ui/editFeedback.test.ts packages/document-ui/plannotatorFeedback.test.ts`: 29 passing tests. +- `bun test packages/document-ui/plannotatorSourceDocuments.test.ts`: 60 passing tests. +- `bun test packages/document-ui/documentLinkedState.test.ts`: 20 passing tests. +- `bun test packages/document-ui/documentReviewState.test.ts`: 14 passing tests. +- `bun test packages/document-ui/documentEditChrome.test.ts`: 12 passing tests. +- `bun test packages/document-ui/documentReviewChrome.test.ts`: 43 passing tests. +- `bun test packages/document-ui/documentReviewLeftSidebar.test.ts`: 5 passing tests. +- `bun test packages/document-ui/documentReviewRightPanel.test.ts`: 5 passing tests. +- `bun test packages/document-ui/documentWideMode.test.ts packages/editor/wideMode.test.ts`: 18 passing tests. +- `bun test packages/ui/components/html-viewer/bridge-script.test.ts`: 4 passing tests. +- `bun run --cwd apps/review build`. +- `bun run --cwd apps/hook build`. +- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build`. +- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. +- `bun run typecheck`. +- `git diff --check`. + +## Next Slice + +The next highest-value extraction is the remaining app-shell coupling around the review surface: sticky toolstrip components, sidebars, plan diff/archive/goal setup, source-file discard/reload side effects, and actual editor/toolbar layout still live directly in `packages/editor/App.tsx`. Text assembly, submission interpretation, annotate feedback target selection, root annotation state, annotation visibility/counting, annotation provider-mutation routing, linked-document file badge/count summaries, linked-document editable-load decisions, linked-document back edit-state decisions, linked-document editable snapshot decisions, linked-message annotation cache/counting/current-state/scope/selection/count-summary decisions, multi-message feedback entry assembly, annotation remapping/highlight-restore decisions, Ask AI context assembly, Plannotator route payload shapes, Plannotator route calls, Plannotator host-session/editor load-plan mapping, opt-in `DocumentReviewSurface` app bridge, saved-change validation decisions, action lifecycle state, review chrome copy, document chrome identity/labels, direct-edit content resolution, direct-edit feedback/panel decisions, direct-edit begin/change state decisions, direct-edit commit/discard/draft-restore decisions, direct-edit commit/discard/draft-restore display decisions, current direct-edit feedback-section gating, unsaved writeback continuation decisions, review action-intent decisions, submit/print shortcut gate decisions, version/diff edit-block decisions, document-navigation edit-block decisions, document layout width state, header action-state decisions, viewport visibility, left-sidebar state/layout/tab visibility/tab open-toggle decisions/sidebar preference decisions, right-panel state/visibility/toggle/reveal decisions, wide/focus layout mode enter/toggle/exit decisions, edit/writeback chrome decisions, writeback edit-session chrome state, edit-exit transition decisions, save-shortcut writeback routing, markdown edit availability, shared edit-panel presentation for unsaved/saved writeback edits, shared default renderer editor-session lifecycle, shared edit-display effect planning, Plannotator local source-save request/response mapping, source-save result application/display classification, source-backed edit-session begin/change/commit classification, source-backed edit-session begin/change display classification, source-backed edit-commit display classification, source-file discard/reload outcome/display classification, missing source-file selection display classification, source-backed draft restore display/edit-display classification, source-document reconcile event classification, and generic agent-delivery state now sit in `@plannotator/document-ui`; the terminal runtime, prompt formatting, plan-mode warnings, Claude Code issue-link markup, route policy, provider capability flags, agent checks, DOM event wiring, host notes/export fallback behavior, external annotation route calls, checkbox visual overrides, and Plannotator compatibility-store/toast/panel side effects remain host-owned. + +## References + +- Research: `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` +- Synthesis: `adr/research/synthesis-document-ui-extraction-20260620-082343.md` +- Spec: `adr/specs/document-ui-extraction-20260620-083307.md` +- Decision: `adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md` diff --git a/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md b/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md new file mode 100644 index 000000000..8d4593514 --- /dev/null +++ b/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md @@ -0,0 +1,268 @@ +# Document UI Parity Cutover Intent + +> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Implementation log of the failed cutover (reverted 2026-06-22). Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. History only. + +Date: 2026-06-21 + +## What + +We are finishing the `@plannotator/document-ui` extraction by making it the production document-review surface for Plan Review and Annotate. The package already contains much of the provider-neutral document state, rendering, writeback, draft, linked-document, annotation, feedback, image, and Plannotator adapter work. The remaining intent is to close the parity gaps, remove the `VITE_DOCUMENT_SURFACE` opt-in path, and shrink `packages/editor/App.tsx` into a Plannotator host shell instead of keeping it as a second document-review implementation. + +The shared package should own the reusable experience that Workspaces also needs: markdown and raw HTML review, annotations, attachments, linked documents, document trees, document/message navigation, edit/writeback states, drafts, feedback assembly, version/diff browsing, generic Ask AI surface behavior, code/link previews, and the default chrome around those workflows. Plannotator-specific policy should remain outside the package: routes, settings, share/export/note behavior, archive storage, goal setup, terminal runtime, plugin/hook behavior, and local source-save transport details. + +## Why + +The point of this branch is not to create an optional renderer beside the old app. The point is to make the Plannotator document review experience reusable by a sister Workspaces repo without forcing Workspaces to reimplement the hard state machine in `App.tsx`. If the package stays opt-in and the old shell remains the real product path, the extraction fails in practice: Workspaces gets components, but not the product behavior users recognize. + +The provider boundary matters because Plannotator local source-save and Workspaces document writeback are different implementations of the same user-facing states. Plannotator uses `/api/source/save`, disk hashes, mtime, file watches, missing local files, and local drafts. Workspaces will use document ids, manifests, versions, `If-Match`, annotation APIs, and workspace-specific missing/conflict behavior. The UI should share clean, dirty, saving, saved, conflict, missing, error, draft restore, feedback assembly, and version diff behavior without requiring either provider to pretend it is the other. + +## How + +The implementation should proceed by closing package parity first, then cutting over the app. The first major missing package capability is provider-neutral version and diff support: add host API methods for listing and loading document versions, map Plannotator's `/api/plan/versions` and `/api/plan/version`, and move the version browser, diff view modes, diff annotations, and edit-blocking behavior into `@plannotator/document-ui`. This should be optional capability, so Workspaces can implement it with its own versions API and hosts without versions do not see the UI. + +After version/diff, the default `DocumentReviewSurface` chrome needs to reach visible parity with the old shell for the generic document workflows: toolstrip, sticky controls, wide/focus controls, sidebars, panel resize/collapse behavior, folder empty state, file/message navigation, linked-document chrome, code/link preview, shortcuts, and raw HTML controls. File and message browsing should become provider-neutral document navigation through `DocumentRef` and `DocumentTreeNode`, while local filesystem containment, vault retry behavior, and Plannotator route details stay in the Plannotator adapter or host. + +Writeback should then become authoritative inside the package. The old app should stop owning duplicate source-save UI state, edit/save/discard/reload-conflict behavior, draft restore decisions, and direct feedback assembly. Plannotator source-save remains first-class in the Plannotator adapter, but the shared contract remains writeback-oriented rather than disk-oriented. + +Ask AI should move only as far as the reusable document-review surface. The package can own document context, an AI panel shell, and in-document ask affordances when `hostApi.askAI` exists. The host keeps provider/model settings, auth, permission handling, terminal fallback behavior, and provider-specific transport. + +Terminal, archive, goal setup, settings, export/share/import, and note integrations should be mounted around or beside the package surface through host shell code and slots. The terminal runtime, PTY bridge, installer, remote-mode security, archive storage, goal setup semantics, and note-app policy are not shared document-ui responsibilities. + +The final cutover is to remove the feature flag and delete the old document render path. At that point `packages/editor/App.tsx` should load the Plannotator session, configure the Plannotator document API and host slots, render completion/modals that remain Plannotator-owned, and mount `DocumentReviewSurface`. It should no longer directly orchestrate `Viewer`, `HtmlViewer`, `PlanDiffViewer`, `AnnotationPanel`, `usePlanDiff`, `useLinkedDoc`, `useArchive`, file/message navigation, source-save UI state, or feedback assembly for the main document path. + +Completion means the normal app renders through `@plannotator/document-ui` with no `VITE_DOCUMENT_SURFACE` flag, the old document-review path is gone, existing Plannotator workflows still work, and Workspaces can implement the same UI through `DocumentHostApi` without inheriting local source-save vocabulary. + +## Implemented Slice: Provider-Neutral Version/Diff + +The first cutover slice moved version/diff support into the package boundary instead of leaving it only in the old editor shell. + +`@plannotator/document-ui` now defines provider-neutral version types and host API methods for listing and loading document versions. `DocumentReviewSurface` owns version state through `useDocumentVersions`, computes markdown diffs through the shared diff engine, exposes version state through the render state, renders a default version navigator, and can switch the document body into a package-owned clean/classic/raw diff view. + +The Plannotator HTTP adapter maps the existing `/api/plan/versions` and `/api/plan/version` routes into the provider-neutral contract. It also seeds the session with `previousPlan` and `versionInfo`, so the package can show previous-version changes without forcing an immediate extra fetch. The memory host API now supports version seeds, version listing, and version loading so Workspaces-like behavior can be tested without local Plannotator routes. + +This slice deliberately does not move Plannotator's VS Code diff route into the shared package. That route is local host policy. The package now owns the reusable document diff surface; richer route-specific actions can be added later through host actions or slots. + +Verification: + +- `bun test packages/document-ui`: 332 passing tests. +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/document-ui/memoryDocumentHostApi.test.ts`: 37 passing tests. +- `bun run --cwd packages/document-ui typecheck`. +- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. +- `git diff --check`. + +Full repo `bun run typecheck` is still blocked before `packages/document-ui` by existing `packages/ui` type errors in `AnnotationToolbar.tsx` and `config/settings.ts`; the document-ui package typecheck itself passes. + +## Implemented Slice: Default Surface Routing and Delivery Parity + +The next cutover slice removed the runtime `VITE_DOCUMENT_SURFACE` gate from the editor app. `packages/editor/App.tsx` now routes normal Plan Review and Annotate document sessions through `PlannotatorDocumentSurfaceBridge` by default when a `DocumentReviewSession` is available. The bridge eligibility is now mode-based (`plan-review`, `annotate`, `annotate-folder`, `annotate-message`) and explicitly leaves shared sessions, archive, goal setup, and demo fallback on the host shell for now. + +The package action contract now carries draft generation and approve feedback. `DocumentReviewSurface` passes those values to the host API for submit, approve, and exit actions. The Plannotator HTTP adapter maps those provider-neutral actions through the existing production delivery helpers: + +- Plan Review feedback uses `/api/deny` with plan-save settings and draft generation. +- Plan Review approval uses `/api/approve` with plan-save, permission mode, agent switch, note-app settings, optional approve-with-feedback text, document text, and draft generation. +- Annotate feedback uses `/api/feedback` with annotations, code annotations, message scope, and draft generation. +- Annotate approve/exit use the existing draft-generation query parameter routes. + +The editor bridge remains the owner of Plannotator host policy. It supplies plan-save settings, permission mode, agent-switch preference, note-app configuration, and note auto-save status through the adapter context instead of moving those settings into `@plannotator/document-ui`. + +Shared feedback rendering now includes direct-edit and saved-file-change sections in addition to annotations, linked-document feedback, and code annotations. That keeps edit feedback intact when the package surface submits through the Plannotator adapter. + +This slice also fixed the previous full-repo typecheck blockers in `packages/ui/components/AnnotationToolbar.tsx` and `packages/ui/config/settings.ts`. + +Verification: + +- `bun test packages/editor/documentSurfaceBridge.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts`: 32 passing tests. +- `bun test packages/document-ui packages/editor/documentSurfaceBridge.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx`: 337 passing tests. +- `bun test packages/editor`: 51 passing tests, 7 skipped existing hook tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. +- `rg -n "VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no code matches. + +Remaining cutover work: the old `packages/editor/App.tsx` document-review implementation is still present for archive, goal setup, shared sessions, and fallback/demo paths, and much of the old main-path code still exists in the file even though normal Plan Review and Annotate sessions now route through the package bridge. The final cleanup still needs host-slot parity for settings/share/export/note/archive/goal/terminal surfaces and then deletion of the duplicate old document-review orchestration. + +## Implemented Slice: Host Router Before Legacy Shell + +The default app no longer enters the legacy `App.tsx` hook graph before mounting the shared document surface. `packages/editor/App.tsx` now exports a thin router that renders `PlannotatorDocumentSurfaceHost` first, with the renamed legacy shell (`LegacyPlannotatorApp`) only as a fallback. + +`PlannotatorDocumentSurfaceHost` owns the Plannotator host bootstrap for normal document sessions: it loads `/api/plan` through the Plannotator document adapter, initializes config, chooses package-surface eligibility from the `DocumentReviewSession`, handles first-time Claude permission setup, preserves plan-arrival note auto-save behavior, computes completion copy, and mounts `PlannotatorDocumentSurfaceBridge`. Shared URL shapes (`/p/` and share-looking hash payloads) bypass package preloading so the existing legacy share loader can still restore shared documents without an API session. + +The legacy shell no longer stores a `documentSurfaceSession` or contains a second `PlannotatorDocumentSurfaceBridge` early return. That means normal Plan Review and Annotate sessions reach `@plannotator/document-ui` before legacy annotation, edit, diff, sidebar, and viewer hooks mount. Archive, goal setup, shared sessions, and demo/API-failure fallback still use the legacy shell. + +Verification: + +- `bun test packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 5 passing tests. +- `bun test packages/editor`: 53 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 334 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. + +Remaining cutover work: move or slot the still-host-owned archive, goal setup, shared-session, settings/share/export/note, and terminal surfaces so the legacy shell can be deleted instead of retained as fallback. The renamed `LegacyPlannotatorApp` still contains the old document-review orchestration for those fallback paths. + +## Implemented Slice: Thin Editor Entrypoint + +The editor entrypoint is now a real host shell instead of the giant legacy implementation. The previous `packages/editor/App.tsx` body was moved to `packages/editor/LegacyPlannotatorApp.tsx`, and `packages/editor/App.tsx` is now a small wrapper that mounts `PlannotatorDocumentSurfaceHost` with `LegacyPlannotatorApp` as fallback. + +This does not delete the old implementation yet, but it makes the production entrypoint shape match ADR 003: the app entry configures a Plannotator host route and the package surface is tried first. The old document-review orchestration is isolated behind a legacy fallback module for archive, goal setup, shared URLs, and demo/API-failure cases. + +Verification: + +- `wc -l packages/editor/App.tsx packages/editor/LegacyPlannotatorApp.tsx packages/editor/PlannotatorDocumentSurfaceHost.tsx` showed `App.tsx` at 9 lines and the legacy fallback isolated in `LegacyPlannotatorApp.tsx`. +- `bun test packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 5 passing tests. +- `bun test packages/editor`: 53 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 334 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. + +Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with package-owned document review behavior plus small host-owned shells/slots for archive, goal setup, shared-session loading, settings/share/export/note, and terminal runtime. + +## Implemented Slice: Goal Setup Host Cutover + +Goal setup is now routed before the legacy shell. `PlannotatorDocumentSurfaceHost` recognizes `mode: "goal-setup"` from the Plannotator adapter, normalizes the goal setup bundle, initializes host config, and renders a new `PlannotatorGoalSetupHost` instead of falling through to `LegacyPlannotatorApp`. + +This keeps the ADR boundary intact: goal setup remains host-owned environment workflow, not package-owned document review behavior. The new host shell wraps the existing `GoalSetupSurface`, supplies the top-level Submit and Close actions, posts close through the existing `/api/exit` endpoint, and uses the same completion overlay copy as the document surface. + +Verification: + +- `bun test packages/editor/PlannotatorGoalSetupHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 6 passing tests. +- `bun test packages/editor`: 54 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 334 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. + +Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with package-owned document review behavior plus small host-owned shells/slots for archive, shared-session loading, settings/share/export/note, and terminal runtime. Goal setup no longer needs the legacy shell on the normal API path. + +## Implemented Slice: Archive Host Cutover + +Standalone archive mode is now routed before the legacy shell. `PlannotatorDocumentSurfaceHost` recognizes archive content from the Plannotator adapter and renders a new `PlannotatorArchiveHost` instead of entering `LegacyPlannotatorApp`. + +The archive shell keeps archive storage and lifecycle host-owned. It uses the existing archive API routes (`/api/archive/plans`, `/api/archive/plan`, `/api/done`), reuses `ArchiveBrowser` for the saved-plan list, reuses the existing markdown `Viewer` for rendering archived plans, and keeps the archive completion overlay behavior. The `Viewer` import is browser-lazy so non-browser host imports and unit tests do not load `web-highlighter` before `window` exists. + +Verification: + +- `bun test packages/editor/PlannotatorArchiveHost.test.tsx packages/editor/PlannotatorGoalSetupHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 7 passing tests. +- `bun test packages/editor`: 55 passing tests, 7 skipped existing hook tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. + +Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with shared-session loading, demo/API-failure fallback, settings/share/export/note host actions, and terminal runtime slots. Goal setup and standalone archive no longer need the legacy shell on their normal API paths. + +## Implemented Slice: Shared Session Host Cutover + +Shared URL sessions now route before the legacy shell. `PlannotatorDocumentSurfaceHost` still bypasses `/api/plan` preloading for share-shaped URLs, but it now renders `PlannotatorSharedSessionHost` instead of falling through to `LegacyPlannotatorApp`. + +The shared host keeps share/callback policy host-owned while using the package document surface. It decodes hash payloads and short `/p/` paste-service links into a provider-neutral in-memory document session, seeds shared annotations and global attachments into `DocumentReviewSurface`, and disables provider persistence/drafts for the portable shared context. Shared sessions expose a host-owned `Copy Link` header action that assembles an updated share URL from the current package feedback payload. Bot callback links (`cb`/`ct`) are handled as host delivery: package submit/approve actions call back with an updated annotated share URL, but the package remains unaware of Plannotator's share URL format. + +To support host-owned share/export actions without coupling them into the package, `DocumentReviewSurface` header action slots can now be render functions that receive the current feedback payload and action helpers. Existing static slot nodes continue to work. + +Verification: + +- `bun test packages/editor/sharedDocumentSession.test.ts packages/editor/PlannotatorSharedSessionHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 19 passing tests. +- `bun test packages/editor`: 58 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 335 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. + +Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with demo/API-failure fallback, fuller settings/share/export/note host actions, and terminal runtime slots. Normal document review, annotate, goal setup, standalone archive, and shared URL sessions no longer need the legacy shell on their normal paths. + +## Implemented Slice: Annotate Agent Terminal Host Slot Cutover + +Annotate agent terminal delivery is now wired into the package-backed production document surface without moving the terminal runtime into `@plannotator/document-ui`. + +`PlannotatorDocumentSurfaceHost` passes the Plannotator terminal capability from the loaded `/api/plan` session into `PlannotatorDocumentSurfaceBridge`. The bridge keeps the terminal runtime host-owned: it lazy-loads `AnnotateAgentTerminalPanel`, mounts it through the existing `DocumentReviewSurface` terminal slot, and exposes host header actions for opening, hiding, stopping, and sending feedback to the agent terminal. + +The default package submit action now preserves the legacy annotate behavior when a terminal session is ready. The bridge renders the current package feedback payload, wraps it with the Plannotator annotate feedback template, sends it to the terminal, records the delivery key, clears the draft through `DocumentReviewSurface`, and keeps the review session open. Duplicate sends of the same feedback/body/target in the same terminal session are treated as already delivered. If terminal delivery fails, the bridge falls back to the original `/api/feedback` submit path. + +The package contract gained a small provider-neutral submit result flag, `keepSessionOpen`, so host-owned delivery channels can clear drafts without forcing the completion overlay. The package still does not own PTY/WebSocket setup, runtime install, remote-mode security, agent selection, or terminal prompt policy. + +Verification: + +- `bun test packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/document-ui/DocumentReviewSurface.test.tsx`: 17 passing tests. +- `bun run --cwd packages/document-ui typecheck`. +- `bun test packages/editor`: 59 passing tests, 7 skipped existing hook tests. +- `bun run typecheck`. +- `bun test packages/document-ui`: 336 passing tests. +- `bun run --cwd apps/hook build`. +- `git diff --check`. + +Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with demo/API-failure fallback and fuller settings/share/export/note host actions. The annotate terminal no longer needs the legacy shell on the normal package-backed annotate file/folder paths. + +## Implemented Slice: Legacy Shell Deletion And Package Fallback + +The editor app no longer imports or mounts `LegacyPlannotatorApp`. `packages/editor/App.tsx` now mounts `PlannotatorDocumentSurfaceHost` directly, and the previous legacy fallback branch in `PlannotatorDocumentSurfaceHost` has been replaced by a package-backed fallback route. + +The fallback route is intentionally small and host-owned. If there is no active `/api/plan` session, or if a future unsupported session mode reaches the host, `PlannotatorDocumentSurfaceFallback` renders the existing demo plan through `DocumentReviewSurface` with an in-memory provider. This keeps development/no-server behavior available without preserving a second document-review implementation. + +`packages/editor/LegacyPlannotatorApp.tsx` has been deleted. Source checks now confirm there are no remaining `LegacyPlannotatorApp`, `status: 'legacy'`, `VITE_DOCUMENT_SURFACE`, `USE_DOCUMENT_SURFACE`, or `documentSurfaceSession` references in production packages/apps outside built artifacts. + +Verification: + +- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 336 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. +- `test ! -e packages/editor/LegacyPlannotatorApp.tsx`. +- `rg -n "LegacyPlannotatorApp|status: 'legacy'|VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE|documentSurfaceSession" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no matches. + +Remaining cutover work: fuller Plannotator host actions for settings, share/export/import, and quick note saves still need to be mounted around the package surface. The duplicate legacy document-review implementation is no longer present. + +## Implemented Slice: Plannotator Host Actions Around Package Surface + +Normal package-backed Plan Review and Annotate sessions now have Plannotator host actions mounted through the `DocumentReviewSurface` header slot. `PlannotatorDocumentSurfaceBridge` renders the existing host-owned `PlanHeaderMenu`, `Settings`, `ExportModal`, and `ImportModal` components around the package surface. + +The bridge prepares export/share/note data from the current package feedback payload rather than from legacy app state. It renders annotation output through the shared feedback assembler, uses the active document text or current edit/save payload for exported markdown, generates hash share URLs and paste-service short URLs through the existing Plannotator sharing utilities, downloads annotations, prints, and posts quick note saves to `/api/save-notes` for Obsidian, Bear, and Octarine. Settings remain Plannotator host policy and are conditionally mounted only when opened so server-rendered tests do not load browser-only settings stores. + +The package boundary remains intact: `@plannotator/document-ui` still receives only generic header slots, feedback payload callbacks, and provider-neutral annotation import actions. Plannotator share URLs, paste-service policy, note-app settings, and the settings UI stay in `packages/editor`/`@plannotator/ui`. + +Import review now decodes Plannotator hash links and paste-service short links in the host bridge, converts share payload annotations through the existing sharing utilities, and merges them into the package surface through the provider-neutral annotation import slot action. The host still owns Plannotator URL formats; the package owns only the annotation merge. + +Verification: + +- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. +- `bun test packages/document-ui`: 339 passing tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. + +Remaining cutover work at this point: package-owned generic Ask AI surface behavior still needs the package default panel and Plannotator HTTP adapter wiring. + +## Implemented Slice: Generic Ask AI And Import Parity + +`DocumentReviewSurface` now renders a provider-neutral Ask AI panel when the session exposes `canUseAskAI` and the host implements `hostApi.askAI`. The package owns document/plan context assembly, the panel shell, and streamed text rendering. The host still owns AI provider/model selection, auth, permission handling, and transport. + +The Plannotator HTTP adapter now implements `hostApi.askAI` over the existing `/api/ai/session` and `/api/ai/query` endpoints. It creates/reuses an AI server session per document review context, forwards context updates when the package context changes, and maps the server SSE stream into package-level `DocumentAskAIEvent` messages. + +Import review parity is also complete for normal package-backed sessions. `DocumentReviewSurface` exposes a provider-neutral `importAnnotations` slot action. `PlannotatorDocumentSurfaceBridge` keeps Plannotator share URL parsing host-owned, decodes hash and short links with the existing sharing utilities, and merges imported annotations/global attachments into the package annotation state without reaching into viewer DOM or highlighter internals. + +Verification: + +- `bun test packages/document-ui`: 339 passing tests. +- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. +- `test ! -e packages/editor/LegacyPlannotatorApp.tsx`. +- `rg -n "LegacyPlannotatorApp|status: 'legacy'|VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE|documentSurfaceSession|Import review is not available" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no matches. + +Cutover status: the normal app entry no longer keeps a legacy document-review shell or feature-flag path. Plan Review, Annotate file/folder/message, shared sessions, archive, goal setup, fallback/demo, terminal delivery, settings/share/export/import/note actions, version/diff, writeback, drafts, annotation state, feedback assembly, and generic Ask AI now route through the package-backed document surface plus small Plannotator host shells/slots. + +## Self-Review Follow-Up + +The ADR self-review found and fixed two issues after the initial green run. + +First, `PlannotatorDocumentSurfaceBridge` could create a copied short share link from stale prepared export state when the current document required the paste-service short-link path. The bridge now generates short links from an explicit prepared export payload, so copy-share fallback uses the current package feedback payload. + +Second, the Plannotator HTTP adapter showed the generic Ask AI panel for every writable session. The old shell checked `/api/ai/capabilities` and hid AI when no provider was registered. `createPlannotatorHttpDocumentApi().loadSession()` now checks that capabilities route and maps `canUseAskAI` from actual provider availability. + +The self-review also cleaned tab-indented JSX in the touched surface file. + +Verification after self-review: + +- `bun test packages/document-ui`: 339 passing tests. +- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. +- `bun run typecheck`. +- `bun run --cwd apps/hook build`. +- `git diff --check`. diff --git a/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md b/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md new file mode 100644 index 000000000..eb510cf51 --- /dev/null +++ b/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md @@ -0,0 +1,216 @@ +# Spike: Document UI Current State and Parity + +> ℹ️ **Context still useful; the direction it informed was reverted.** This honestly reported the failed cutover was only ~55–65% at parity. The cutover was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting. + +Date: 2026-06-21 + +## Question + +What exactly is the state of the `@plannotator/document-ui` extraction now, how close is it to parity with the current Plan Review / Annotate app, and what should be finalized inside the shared package versus left to Plannotator or other hosts? + +## Scope + +This spike reads the current branch code. It does not change product code. + +Primary files inspected: + +- `packages/document-ui/package.json` +- `packages/document-ui/index.ts` +- `packages/document-ui/types.ts` +- `packages/document-ui/DocumentReviewSurface.tsx` +- `packages/document-ui/plannotatorHttpApi.ts` +- `packages/document-ui/memoryDocumentHostApi.ts` +- `packages/document-ui/documentReviewChrome.ts` +- `packages/editor/App.tsx` +- `packages/editor/PlannotatorDocumentSurfaceBridge.tsx` +- `packages/editor/documentSurfaceBridge.ts` +- `adr/implementation/document-ui-extraction-intent-20260620-085249.md` + +Verification run: + +- `bun test packages/document-ui` +- Result: 329 passing tests across 31 files. + +## Executive Read + +The extraction is real and substantial. It is accurate to say that much of Plannotator's document-review domain behavior has been moved into a new `@plannotator/document-ui` package. + +It is not accurate to say the app has been cut over to the package yet. + +The current production app still defaults to `packages/editor/App.tsx`. The new surface is mounted only when `VITE_DOCUMENT_SURFACE` is `1` or `true`, through `PlannotatorDocumentSurfaceBridge`. The old editor shell is still the main render path. + +My current read: + +- Package capability: roughly 70-80 percent of the hard reusable document-domain logic is extracted. +- Current-app parity: roughly 55-65 percent, depending on whether plan diff, archive, goal setup, Ask AI panel, and terminal are considered part of the required reusable surface. +- Cutover/no-legacy readiness: roughly 40-50 percent. The shared package is green, but the app still depends on a large legacy shell for important visible workflows. + +The branch is past "prototype contract" and into "candidate package," but it still needs a deliberate parity/cutover pass before deleting the old UI. + +## What Exists Now + +### Package Footprint + +`packages/document-ui` is a real package with explicit exports, not a single component dump. It exports the surface, provider-neutral types, memory provider, Plannotator HTTP adapter, feedback assembly, edit/writeback helpers, annotation persistence, draft state, linked state, tree state, chrome decisions, panel/sidebar state, Ask AI context, delivery helpers, and Plannotator compatibility helpers. + +The package is currently about 28.6k lines including tests. The main app shell is still about 4.8k lines: + +- `packages/document-ui/DocumentReviewSurface.tsx`: 1,437 lines. +- `packages/document-ui/*.ts/*.tsx`: 28,643 total lines including tests. +- `packages/editor/App.tsx`: 4,773 lines. + +### Provider-Neutral Contract + +The core types are now provider-neutral: + +- `DocumentRef` is provider/document identity, not local file identity (`packages/document-ui/types.ts:77`). +- `DocumentWritebackStatus` is `clean | dirty | saving | saved | conflict | missing | error` (`packages/document-ui/types.ts:90`). +- `LoadedDocument` carries content, render mode, image base, and optional writeback capability (`packages/document-ui/types.ts:129`). +- `DocumentReviewSession` carries mode, root document/ref, root tree ref, capabilities, and UI labels (`packages/document-ui/types.ts:177`). +- `SubmitDocumentFeedbackPayload` contains annotations, linked annotations, code annotations, attachments, direct edits, saved changes, and message scope (`packages/document-ui/types.ts:378`). +- `DocumentHostApi` abstracts load, linked-doc resolution, tree listing, document watching, save, drafts, annotation persistence, uploads, image URLs, feedback, approve, exit, Ask AI, and agent delivery (`packages/document-ui/types.ts:437`). + +This is the right conceptual center. Workspaces can implement the same contract with workspace document ids, manifests, versions, `If-Match`, and its annotation APIs. Plannotator implements it with `/api/plan`, `/api/doc`, `/api/source/save`, `/api/draft`, `/api/upload`, and `/api/image`. + +### Default Surface + +`DocumentReviewSurface` is no longer just a render prop wrapper. It now owns substantial product behavior: + +- Resolves the initial document from session/root/ref (`packages/document-ui/DocumentReviewSurface.tsx:144`). +- Seeds and tracks writeback state (`packages/document-ui/DocumentReviewSurface.tsx:171`). +- Owns root annotation state (`packages/document-ui/DocumentReviewSurface.tsx:195`). +- Owns linked-document state (`packages/document-ui/DocumentReviewSurface.tsx:216`). +- Owns optional annotation persistence (`packages/document-ui/DocumentReviewSurface.tsx:223`). +- Owns document tree state (`packages/document-ui/DocumentReviewSurface.tsx:242`). +- Owns edit/writeback controller state (`packages/document-ui/DocumentReviewSurface.tsx:250`). +- Owns provider watch reconciliation (`packages/document-ui/DocumentReviewSurface.tsx:259`). +- Owns draft save/restore state (`packages/document-ui/DocumentReviewSurface.tsx:269`). +- Builds feedback payloads with linked annotations, direct edits, and saved changes (`packages/document-ui/DocumentReviewSurface.tsx:384`). +- Calls host feedback, approve, and exit APIs (`packages/document-ui/DocumentReviewSurface.tsx:419`). +- Renders a default chrome with header, writeback badges, annotation-persistence badges, edit/save/discard/conflict buttons, submit/approve/close buttons, document navigator, feedback panel, draft banners, and error banners (`packages/document-ui/DocumentReviewSurface.tsx:551`, `packages/document-ui/DocumentReviewSurface.tsx:613`, `packages/document-ui/DocumentReviewSurface.tsx:693`). +- Renders markdown and raw HTML through the existing Plannotator renderer modules while routing image upload/image display and linked-doc opens through the provider API (`packages/document-ui/DocumentReviewSurface.tsx:1240`, `packages/document-ui/DocumentReviewSurface.tsx:1292`). + +This means the package already owns a meaningful document review loop. + +### Plannotator Adapter + +The Plannotator adapter is also substantial: + +- `createPlannotatorHttpDocumentApi()` maps current server routes into `DocumentHostApi`. +- `createPlannotatorHostSessionState()` normalizes `/api/plan` responses into document-session and host-session state (`packages/document-ui/plannotatorHttpApi.ts:408`). +- `createPlannotatorEditorLoadPlan()` derives the legacy editor load plan from normalized session state (`packages/document-ui/plannotatorHttpApi.ts:497`). +- Capabilities are mapped from local server data, including raw HTML, folder browsing, source-save writeback, share, Ask AI, agent terminal availability, and version support (`packages/document-ui/plannotatorHttpApi.ts:877`). + +This is good layering: local source-save details remain in Plannotator adapter exports, not in the provider-neutral `DocumentHostApi`. + +### Opt-In Bridge + +The bridge exists and is thin: + +- `packages/editor/documentSurfaceBridge.ts` decides the flag and renders feedback text through shared feedback assembly (`packages/editor/documentSurfaceBridge.ts:18`, `packages/editor/documentSurfaceBridge.ts:22`). +- `packages/editor/PlannotatorDocumentSurfaceBridge.tsx` creates the Plannotator HTTP API and mounts `` (`packages/editor/PlannotatorDocumentSurfaceBridge.tsx:44`, `packages/editor/PlannotatorDocumentSurfaceBridge.tsx:55`). +- `packages/editor/App.tsx` only uses the bridge behind `USE_DOCUMENT_SURFACE` (`packages/editor/App.tsx:130`, `packages/editor/App.tsx:3905`). + +This is the clearest evidence that the package is not yet the default app path. + +## What Still Lives In The Old App + +The old editor shell still owns major parity features and side effects: + +- Plan diff/version behavior: `usePlanDiff`, base-version selection, diff activation, and `PlanDiffViewer` render path remain in `App.tsx` (`packages/editor/App.tsx:817`, `packages/editor/App.tsx:4267`). +- Legacy linked-doc hook and Plannotator editable-source side effects remain in `App.tsx` (`packages/editor/App.tsx:888`, `packages/editor/App.tsx:938`). +- Archive browser state and archive selection remain in `App.tsx` (`packages/editor/App.tsx:968`, `packages/editor/App.tsx:4166`). +- External/editor annotation route integration remains in `App.tsx` (`packages/editor/App.tsx:1346`). +- Sticky header lane, annotation toolstrip, wide/focus inline controls, HTML tools toggle, checkbox overrides, code-file popout, message picker chrome, and Plannotator-specific viewer props remain in `App.tsx` (`packages/editor/App.tsx:4213`, `packages/editor/App.tsx:4235`, `packages/editor/App.tsx:4298`, `packages/editor/App.tsx:4411`). +- Goal setup is rendered from the old shell (`packages/editor/App.tsx:4255`). +- Agent terminal panel and resize shell remain in the old shell (`packages/editor/App.tsx:4078`). +- Ask AI panel and provider settings remain in the old shell (`packages/editor/App.tsx:4509`). +- Export/share/import modals and note integrations remain in the old shell (`packages/editor/App.tsx:4577`). +- The old `AppHeader` still controls Plannotator-specific top-level actions, settings, archive actions, callback actions, note-app actions, and AI/sidebar toggles (`packages/editor/App.tsx:3929`). + +Some of these should remain host-owned. Others are parity gaps if the package is meant to become the default document-review capability. + +## Parity Matrix + +| Area | Current State | Parity Read | +| --- | --- | --- | +| Provider-neutral document/session contract | In package | Strong | +| Markdown render and annotate | In package through existing `@plannotator/ui` renderer | Mostly there | +| Raw HTML render and annotate | In package through `HtmlViewer`; bridge-script tests exist | Mostly there | +| Image attachments and image display | Provider-owned in package | Strong | +| Linked document navigation | Package has provider-neutral state; old app still owns Plannotator filesystem side effects | Partial | +| Document tree/file browser | Package has tree state/default navigator; old app still owns richer file-browser tab and watchers | Partial | +| Writeback state | Provider-neutral core and Plannotator adapter exist | Strong | +| Local source-save compatibility | In package under Plannotator-specific exports | Strong for Plannotator, acceptable as adapter-specific | +| Draft restore | Provider-neutral core exists; old app still owns some display and side effects | Mostly there | +| Annotation persistence | Provider-neutral load/save contract exists | Mostly there | +| Feedback text/payload assembly | Shared package owns most assembly | Strong | +| Submit/approve/exit lifecycle | Package has default lifecycle; host still owns route policy in legacy path | Mostly there | +| External/editor annotations | Feedback text supports them; route/SSE integration remains old-app owned | Partial | +| Ask AI | Context helpers and host API type exist; full panel/session UI remains old-app owned | Partial | +| Plan versions/diff | Capability flag exists, but no generic host API and default surface does not render version browser/diff | Gap | +| Archive browser | Adapter carries archive metadata; default package surface does not provide archive browser parity | Gap or host-owned, depending decision | +| Goal setup | Old-app owned | Host-owned or package slot, not core document review | +| Agent terminal | Old-app owned; package has a slot and delivery helpers | Correctly host-owned runtime, partial UI slot | +| Sticky toolstrips/wide/focus polish | Decisions extracted, but package default chrome is simpler | Partial | +| Settings/share/import/export/note apps | Old-app owned | Correctly host-owned | +| Plugin/server routes/auth/browser open | Host/server owned | Correctly outside package | + +## What Should Be Finalized Inside The Package + +The package should own the reusable document-review loop end to end: + +1. `DocumentReviewSurface` as the default production surface for plan review, annotate file, annotate folder, annotate message, and workspace document review. +2. Provider-neutral document identity, loading, linked-doc navigation, tree navigation, annotation state, annotation persistence, draft restore, image upload/display, edit/writeback, conflict/missing/saving/saved chrome, feedback payload assembly, and submit/approve/exit actions. +3. A real default chrome that reaches parity with the current visible document experience: annotation toolstrip, sticky controls where applicable, feedback panel behavior, document navigation, file/tree badges, writeback badges, draft banners, and polished markdown/raw-HTML render behavior. +4. Optional document version/diff capability. This is the biggest missing reusable feature. The package already has `supportsVersions`, but it needs provider-neutral methods such as `listDocumentVersions`, `loadDocumentVersion`, and maybe `compareDocumentVersions`. Plannotator would adapt `/api/plan/versions` and `/api/plan/version`; Workspaces would adapt its versions API. The package should own the diff toggle/viewer because Workspaces explicitly needs the same review experience with a different provider. +5. Optional Ask AI surface behavior when `hostApi.askAI` exists. The package should own document target/context assembly and the in-document ask affordance. The host should still own provider/model config, auth, permission policy, and transport. +6. Optional annotation-provider watch/poll capability if Workspaces needs live comment updates. The current `loadAnnotations`/`saveAnnotations` contract is a good base, but route/SSE details should stay adapter-owned. +7. Plannotator local adapter as a first-class adapter, not as core vocabulary. Keep source-save, disk hash, mtime, missing local files, `/api/source/save`, and current draft compatibility in `plannotator-*` exports. +8. A memory/provider test harness that proves Workspaces-like behavior without local filesystem assumptions. +9. Contract tests for parity behavior. The package tests are green now, but cutover needs tests that assert the default surface can handle markdown, raw HTML, folder tree navigation, linked docs, writeback conflict/missing, drafts, version diff, and feedback assembly without the old `App.tsx` state machine. + +## What Should Stay Out Of The Package + +The package should not own host environment policy: + +1. Server route implementation, auth, process lifetime, browser launching, remote/local port behavior, and plugin command/hook handling. +2. Plan-mode `ExitPlanMode` hook behavior and stdout decision shape. +3. Plannotator note integrations: Obsidian, Bear, Octarine. +4. Share/paste service policy, short URL generation, import/export modal policy, and hosted share URLs. +5. Agent terminal runtime, PTY/WebSocket bridge, terminal installation, and terminal provider policy. The package should keep slots/state helpers, not own the terminal runtime. +6. Workspaces server calls and auth. Workspaces should provide an adapter implementing `DocumentHostApi`. +7. Local filesystem source-save internals as generic concepts. Those belong in the Plannotator adapter namespace. +8. The code-review/diff app in `packages/review-editor`; that is a different product surface. +9. Product settings UI and Plannotator-specific header menu policy. +10. External annotation transport details. The package can define optional annotation persistence/watch contracts, but SSE route names and provider mutation routes belong in adapters. + +## Cutover Work To Delete The Old UI + +If the branch goal is "the app uses the package and old/legacy code goes away," the remaining work is not another broad extraction pass. It is a focused parity and cutover pass: + +1. Make a crisp scope decision for plan diff, archive, goal setup, Ask AI, and terminal. + - My recommendation: move version/diff into the package as optional document capability. + - Keep archive as either a Plannotator host tab/slot or an optional adapter-provided document collection, not mandatory core. + - Keep goal setup host-owned or slot-based unless Workspaces needs it. + - Keep terminal runtime host-owned; use slots and delivery state. + - Move Ask AI UI only up to the provider-neutral level; host owns provider config and permissions. +2. Bring `DocumentReviewSurface` default chrome to parity for annotate/file/folder/message and plan review. +3. Add the missing generic version/diff API and render path in the package. +4. Wire Plannotator production path to the bridge without `VITE_DOCUMENT_SURFACE`. +5. Delete or collapse duplicate `App.tsx` document-domain state once the package owns it. +6. Leave `App.tsx` as a Plannotator host shell: load session, read settings, configure adapters/slots, handle route/policy side effects, render package surface. +7. Add a cutover test matrix: + - `bun test packages/document-ui` + - `bun run typecheck` + - `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build` + - `bun run --cwd apps/hook build` + - targeted browser smoke for annotate markdown, annotate raw HTML, annotate folder, plan review, linked docs, source-save conflict/missing, and plan diff. + +## Bottom Line + +We have extracted much of the Plannotator document UI and domain behavior into its own package. That is a proper thing to say. + +We have not yet made the package the app. The old shell is still the default path and still owns visible parity-critical workflows. + +The clean path is to finish the package around the actual document-review loop, especially version/diff and default chrome parity, then flip the production app to the package and remove the duplicate editor state. Keeping the old shell around indefinitely would defeat the point of this branch; deleting it now would cut out real behavior users still rely on. diff --git a/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md b/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md new file mode 100644 index 000000000..ea5b037da --- /dev/null +++ b/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md @@ -0,0 +1,744 @@ +# Spike: Document UI Extraction Boundary + +> ℹ️ **Research still useful; the direction it informed was reverted.** This accurately describes how the current document UI works, but the extraction approach it fed into (ADRs 002/003) was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting on any recommendation here. + +Date: 2026-06-20 + +## Question + +Build a concrete understanding of how the Plan Review app now powers annotate-mode document review, and identify the boundary for extracting that document experience into a shared UI package. + +The historical center of gravity was Plan Mode: Claude Code intercepted `ExitPlanMode`, opened Plannotator, and waited for approve or deny. The current primary document workflow is broader: + +- run annotate on a markdown, text, HTML, URL, or folder target +- run last-message annotation +- optionally review-gate an artifact with approve or feedback +- sometimes keep an agent terminal running inside the annotate session + +## Scope + +This spike only reads the current branch code. It does not change product code. + +Primary files inspected: + +- `packages/editor/App.tsx` +- `packages/editor/components/AppHeader.tsx` +- `packages/editor/components/AnnotateAgentTerminalPanel.tsx` +- `packages/editor/agentTerminalIntegration.ts` +- `packages/editor/directEdits.ts` +- `packages/editor/editableDocuments.ts` +- `packages/editor/sourceDocumentClient.ts` +- `packages/editor/sourceDocumentReconciliation.ts` +- `packages/editor/savedFileChangeValidation.ts` +- `packages/ui/components/Viewer.tsx` +- `packages/ui/components/BlockRenderer.tsx` +- `packages/ui/components/InlineMarkdown.tsx` +- `packages/ui/components/MarkdownEditor.tsx` +- `packages/ui/components/html-viewer/HtmlViewer.tsx` +- `packages/ui/components/html-viewer/useHtmlAnnotation.ts` +- `packages/ui/components/AnnotationPanel.tsx` +- `packages/ui/components/sidebar/FileBrowser.tsx` +- `packages/ui/components/sidebar/SidebarContainer.tsx` +- `packages/ui/hooks/useAnnotationDraft.ts` +- `packages/ui/hooks/useAnnotationHighlighter.ts` +- `packages/ui/hooks/useFileBrowser.ts` +- `packages/ui/hooks/useLinkedDoc.ts` +- `packages/ui/hooks/usePlanDiff.ts` +- `packages/ui/hooks/useArchive.ts` +- `packages/ui/hooks/useAIChat.ts` +- `packages/ui/hooks/useExternalAnnotations.ts` +- `packages/ui/hooks/useValidatedCodePaths.ts` +- `packages/ui/utils/parser.ts` +- `packages/ui/types.ts` +- `packages/server/annotate.ts` +- `packages/server/index.ts` +- `packages/server/reference-handlers.ts` +- `packages/server/reference-watch.ts` +- `packages/server/agent-terminal.ts` +- `apps/hook/server/index.ts` +- `apps/opencode-plugin/index.ts` +- `apps/pi-extension/index.ts` +- `apps/pi-extension/server/serverAnnotate.ts` +- `apps/pi-extension/server/reference.ts` +- `apps/pi-extension/server/file-browser-watch.ts` + +## Short Answer + +There is not a separate "Annotate app" today. Annotate mode is the Plan Review app running in a different server-provided mode. + +The Bun annotate server deliberately serves document content through `/api/plan` so the existing plan editor bundle can render it. `packages/editor/App.tsx` is the real composition root for plan review, annotate, annotate-last, annotate-folder, archive, goal setup, linked docs, direct edits, AI, drafts, external annotations, file browser, raw HTML, and agent terminal. + +The reusable pieces are already partly in `@plannotator/ui`, but that package is not a clean document UI package. Many components and hooks inside it fetch hard-coded `/api/*` routes. The actual document-product state machine is split between `@plannotator/ui` and `@plannotator/editor`, with the largest orchestration still in `App.tsx`. + +A good extraction should not start by moving `App.tsx` wholesale. The safer boundary is a document-review surface with an explicit host API adapter and optional capabilities. + +## Current Runtime Shape + +### Entry points + +Claude Code, Droid, OpenCode, and Pi all route manual document review into annotate mode. + +Claude Code and Droid run the CLI-style commands: + +- `plannotator annotate ` +- `plannotator annotate-last` / `plannotator last` + +OpenCode intercepts `plannotator-annotate`, `plannotator-last`, and `plannotator-review` before the agent sees the command. This is important: OpenCode clears command prompt output so a large file path is not auto-attached to the agent context before Plannotator opens. + +Pi implements native command handlers, but converges on the same server/UI contract. + +The CLI annotate command does input detection before starting the server: + +- `https://...`: fetch with Jina Reader by default, or fetch plus Turndown with `--no-jina` +- folder: open annotate-folder mode and show the file browser +- `.html` / `.htm`: render raw HTML by default, or convert to markdown with `--markdown` +- `.md`, `.mdx`, `.txt`: read file text directly + +Annotate-last resolves recent assistant messages from each agent's transcript or session store. It can pass a picker list of recent messages to the frontend. + +### Servers + +There are two server implementations with matching API surfaces: + +- Bun server in `packages/server/*`, used by Claude Code, Droid, and OpenCode paths. +- Pi server in `apps/pi-extension/server/*`, using Node HTTP primitives and generated shared files. + +The annotate Bun server is `startAnnotateServer(options)` in `packages/server/annotate.ts`. The Pi mirror is `apps/pi-extension/server/serverAnnotate.ts`. + +The annotate server intentionally reuses `/api/plan`: + +```text +GET /api/plan -> { + plan, + origin, + mode, + filePath, + sourceInfo, + sourceConverted, + sourceSave, + gate, + renderAs, + rawHtml?, + convertHtml, + sharingEnabled, + shareBaseUrl, + pasteApiUrl, + repoInfo, + projectRoot, + isWSL, + serverConfig, + agentTerminal?, + recentMessages? +} +``` + +That endpoint is the switch that turns the plan editor bundle into annotate mode. + +Other annotate-mode endpoints used by the document UI: + +- `GET /api/doc`: open linked docs, folder files, and code-file previews. +- `POST /api/doc/exists`: validate code-file links discovered in markdown. +- `GET /api/reference/files`: build the folder file browser tree. +- `GET /api/reference/files/stream`: SSE watch for folder tree, git status, and open source file changes. +- `POST /api/source/save`: atomically save source-backed markdown, mdx, or text files. +- `GET /api/share-html`: lazily prepare portable raw HTML for sharing. +- `GET /api/html-assets//`: serve relative HTML support assets. +- `GET/POST/DELETE /api/draft`: persist annotations, attachments, and direct edits. +- `POST /api/feedback`: return annotated feedback to the invoking session. +- `POST /api/approve`: approve a review-gated annotate session. +- `POST /api/exit`: close without feedback. +- `GET/POST /api/ai/*`: Ask AI sessions. +- `GET/POST/PATCH/DELETE /api/external-annotations*`: live annotations from external tools. +- `WebSocket /api/agent-terminal/pty/`: optional annotate-mode agent terminal. + +The server also owns the security boundary for document access. `getAnnotateReferenceRootPaths()` scopes file access to the folder target, current working directory, the source file directory, and realpath equivalents. `/api/doc` and `/api/doc/exists` resolve within those roots. + +### Source-save capability + +Source save is negotiated by the server and carried to the UI as `sourceSave`. + +Enabled only for local `.md`, `.mdx`, or `.txt` documents. Disabled for: + +- message mode +- folder root before a file is selected +- raw HTML rendering +- converted HTML/URL content +- non-local URLs +- unsupported extensions +- missing or unreadable files + +The capability includes: + +- scope: `single-file` or `folder-file` +- path, basename, language +- content hash, mtime, size, and EOL style + +The UI uses these fields as optimistic concurrency metadata when calling `/api/source/save`. + +## Current UI Shape + +### Composition root + +`packages/editor/App.tsx` is 4,685 lines and owns the product state machine. + +It initializes from `/api/plan`, then branches across: + +- normal plan review +- annotate single file +- annotate last message +- annotate folder +- raw HTML annotate +- archive +- goal setup +- shared sessions + +Core state clusters in `App.tsx`: + +- document content: `markdown`, `renderAs`, `rawHtml`, `shareHtml`, `sourceInfo`, `sourceConverted`, `sourceFilePath`, `imageBaseDir`, `projectRoot` +- parsed document: `displayedMarkdown`, `frontmatter`, `blocks` +- annotations: document annotations, code annotations, external annotations, editor annotations, linked-doc annotation cache, global image attachments +- editor state: markdown edit mode, direct-edit stats, dirty flags, editable document records +- mode flags: `annotateMode`, `gate`, `annotateSource`, archive mode, goal setup, message picker +- layout: left sidebar, right annotation panel, wide mode, resizable panes, agent terminal pane +- server/session capabilities: origin, sharing URLs, repo info, AI providers, agent terminal capability + +The key render switch is: + +- `renderAs === "html"`: render `HtmlViewer` +- `isEditingMarkdown`: render `MarkdownEditor` +- otherwise: render `Viewer` + +This means markdown, editable markdown, and raw HTML are different render surfaces inside the same app shell. + +### Markdown parser and block model + +`parseMarkdownToBlocks(markdown)` in `packages/ui/utils/parser.ts` creates `Block[]`. + +The parser is intentionally simple and stable for annotation anchoring. It handles: + +- headings with deterministic ids +- paragraphs +- blockquotes and GitHub alert callouts +- list items and task checkboxes +- fenced code blocks +- tables +- horizontal rules +- raw HTML blocks +- directive containers +- inline enhancements through render components + +`Block.startLine` is part of the feedback contract. `exportAnnotations()` uses it to generate human-readable feedback with line labels. + +This creates a strong coupling: + +```text +markdown -> parseMarkdownToBlocks -> Block ids and startLine + -> Viewer highlights and annotation blockId + -> exportAnnotations feedback +``` + +Any extracted package must preserve this chain or own a replacement end to end. + +### Viewer + +`packages/ui/components/Viewer.tsx` is 970 lines. It is not just a presentational renderer. + +It owns: + +- `useAnnotationHighlighter` +- web-highlighter lifecycle +- code-block annotation path +- sticky headers and scroll behavior +- code path validation through `useValidatedCodePaths` +- heading anchors and hash navigation +- global comments and image attachments +- quick labels +- table and code popouts +- doc badges +- Ask AI hooks at comment and document level + +`Viewer` delegates block rendering to `BlockRenderer`, `CodeBlock`, `TableBlock`, `MermaidBlock`, `GraphvizBlock`, and related components. + +`InlineMarkdown` is another important coupling point. It linkifies code-file references and wiki/doc links, fetches `/api/doc` for hover previews, and relies on `CodePathValidationContext`. + +### Raw HTML viewer + +`HtmlViewer` renders raw HTML in an iframe through `srcdoc`. The server rewrites relative assets to `/api/html-assets/...`. + +Annotation inside raw HTML uses `useHtmlAnnotation` and an injected bridge script. It communicates selection, comments, deletions, and quick labels with `postMessage`. + +HTML annotations do not use markdown blocks the same way markdown annotations do. They carry text and bridge mark ids, with `blockId` effectively empty. This is a separate annotation path hidden behind the same `ViewerHandle` contract: + +- `removeHighlight` +- `clearAllHighlights` +- `applySharedAnnotations` + +### Markdown editing and direct edits + +`MarkdownEditor` in `@plannotator/ui` is a thin Plannotator-themed wrapper around `@plannotator/markdown-editor`. + +The editing state is not in that component. It lives in `App.tsx` plus `packages/editor/editableDocuments.ts`. + +For normal plan review, editing produces a Direct Edits feedback section. For source-backed annotate files, editing can save back to disk through `/api/source/save`. + +After edits, `App.tsx` calls `applyEditedDocument(next)`: + +- reparse markdown +- remap annotations by original selected text +- clear positional metadata when a block changes +- update markdown +- bump `editGeneration` +- repaint highlights + +This annotation remapping is a critical behavior. It is easy to lose if editing is extracted separately from rendering and export. + +### Source-backed folder files + +`useEditableDocuments()` tracks one record per source-backed document: + +- session-open text and hash +- disk baseline +- current text +- dirty/saving/saved/conflict/error/missing status +- saved change context for feedback +- conflict snapshots when disk changed + +The source document reconciliation loop watches directories containing open source docs through `/api/reference/files/stream`. On SSE events, it refetches snapshots through `/api/doc` and reconciles: + +- clean file changed on disk: update UI to disk +- dirty file changed on disk: mark conflict +- file disappeared: mark missing +- stale async snapshot: ignore by sequence/hash guard + +The file browser uses `useFileBrowser()` plus `FileBrowser.tsx`. It displays: + +- markdown/text/html file tree +- workspace status from git metadata +- annotation counts by file +- edit status markers from `editableDocuments` + +Folder mode selection opens files through linked-doc machinery, but source-backed folder files can become editable and saveable. + +### Linked docs + +`useLinkedDoc()` is central to the document experience. + +It handles same-surface navigation to another markdown or HTML document: + +- snapshot current root or linked document +- cache annotations and attachments per filepath +- clear and restore highlights +- switch `markdown`, `renderAs`, `rawHtml`, and `shareHtml` +- restore cached doc state on back +- keep annotation counts for file browser/sidebar + +Linked docs can be raw HTML or markdown. This means `renderAs` is not only a session-level mode; it is active-document scoped. + +### Drafts + +`useAnnotationDraft()` persists: + +- full `Annotation[]` +- code annotations +- global attachments +- direct edited markdown +- dirty source-backed edited documents +- already-saved source-backed file changes +- draft generation number + +The hook is intentionally best-effort and uses debounced `/api/draft` writes with `keepalive` flushes on page hide. Draft generation prevents a late save from resurrecting a draft after submit. + +Draft restore in `App.tsx` is complex because it has to validate saved file changes against disk, restore dirty source documents, maybe reopen a single restored file, remap annotations, and repaint highlights. + +### Feedback and approval + +Plan mode: + +- Approve posts `/api/approve`. +- Deny posts `/api/deny`. +- Feedback may include annotations, editor annotations, linked-doc annotations, code-file annotations, direct edits, saved file changes, and note-app settings. + +Annotate mode: + +- Feedback normally posts `/api/feedback`. +- Gate approval posts `/api/approve`. +- Close posts `/api/exit`. +- If the agent terminal is ready, feedback is sent directly to the terminal instead of `/api/feedback`. + +`getCurrentFeedbackPayload()` is the important document feedback seam in `App.tsx`. It composes exported annotations plus direct-edit and saved-file-change sections, then wraps them for the target agent/file/message context. + +### Agent terminal + +Agent terminal is annotate-only for `annotate` and `annotate-folder`, not `annotate-last`. + +Server side: + +- Bun uses `createBunAgentTerminalBridge()`. +- Pi mirrors it with `createNodeAgentTerminalBridge()`. +- The server advertises `agentTerminal` capability in `/api/plan`. +- A tokenized WebSocket path is generated under `/api/agent-terminal/pty/`. +- Remote sessions disable terminal by default unless `PLANNOTATOR_AGENT_TERMINAL_REMOTE=1`. +- The Bun bridge starts a Node sidecar for WebTUI and proxies browser WebSocket traffic to the sidecar. + +UI side: + +- `AnnotateAgentTerminalPanel` uses `@plannotator/webtui/browser` and `@plannotator/webtui/react`. +- It stores the preferred agent id and terminal display settings locally. +- It exposes `sendMessage()` and `stop()` through an imperative ref. +- `App.tsx` tracks whether the terminal is running, open, ready, and whether the current feedback payload was already delivered. + +When terminal delivery succeeds, the browser does not close the annotate session. It marks the feedback as delivered and keeps the terminal workflow live. + +### AI + +Ask AI is a server-backed capability exposed by `/api/ai/capabilities` and used through `useAIChat()`. + +When the annotate agent terminal is ready, `App.tsx` hides normal AI chat streaming and routes Ask AI prompts to the terminal instead. + +This is another package boundary concern: AI can be a document-surface capability, but the provider registry and terminal fallback are host/session concerns. + +## Package Boundary Today + +`@plannotator/ui` already contains a lot of reusable document primitives: + +- parser and feedback export utilities +- `Viewer` +- `HtmlViewer` +- `MarkdownEditor` +- annotation toolbar/panel pieces +- file browser UI and hook +- linked-doc hook +- draft hook +- sidebar shell +- AI chat hook and UI +- external annotation hooks +- plan diff and archive pieces + +`@plannotator/editor` contains the app shell and several document-domain state modules: + +- `App.tsx` +- source edit state and reconciliation +- direct-edit feedback sections +- source document client +- source document path helpers +- agent terminal panel and integration +- app header +- shortcuts surface + +This split is historical, not architectural. `@plannotator/ui` is broad and route-aware. `@plannotator/editor` is a product shell that imports almost every document primitive and wires them into server APIs. + +Line counts that indicate the current extraction pressure: + +- `packages/editor/App.tsx`: 4,685 +- `packages/ui/components/Viewer.tsx`: 970 +- `packages/editor/components/AnnotateAgentTerminalPanel.tsx`: 746 +- `packages/ui/components/AnnotationPanel.tsx`: 731 +- `packages/editor/editableDocuments.ts`: 666 +- `packages/ui/hooks/useLinkedDoc.ts`: 494 +- `packages/ui/hooks/useFileBrowser.ts`: 358 +- `packages/server/annotate.ts`: 661 +- `apps/pi-extension/server/serverAnnotate.ts`: 561 + +## Extraction Risks + +### 1. Direct `/api/*` fetches inside reusable UI + +Many `@plannotator/ui` hooks and components call API routes directly: + +- `useAnnotationDraft`: `/api/draft` +- `useFileBrowser`: `/api/reference/files`, `/api/reference/files/stream`, `/api/reference/obsidian/files` +- `useLinkedDoc`: default `/api/doc` +- `usePlanDiff`: `/api/plan/version`, `/api/plan/versions` +- `useAIChat`: `/api/ai/*` +- `useExternalAnnotations`: `/api/external-annotations*` +- `useEditorAnnotations`: `/api/editor-annotations`, `/api/editor-annotation` +- `useValidatedCodePaths`: `/api/doc/exists` +- `InlineMarkdown`: `/api/doc` +- `OpenInAppButton`: `/api/open-in/apps`, `/api/open-in` +- `ExportModal`: `/api/save-notes` + +That is acceptable for an app-local UI package, but not for a reusable document package unless the package declares those routes as its required host API. + +### 2. `App.tsx` mixes mode policy with document mechanics + +Examples: + +- Plan approve/deny and annotate feedback live beside editor remapping. +- Archive/goal setup branches live beside folder annotation. +- AI provider defaults live beside source-file conflict handling. +- Agent terminal delivery status affects whether feedback buttons are enabled. +- Sidebar auto-open rules depend on archive, goal, folder, HTML, and TOC state. + +Moving this all at once would preserve complexity under a new package name. + +### 3. Parser, highlight anchors, and feedback export are one contract + +The markdown renderer cannot be extracted independently from: + +- `Block` ids +- `Block.startLine` +- annotation `blockId` +- text-search restoration +- direct-edit remapping +- feedback export + +These should move or remain together. + +### 4. Source-save behavior is part of the document product + +Source save is not a small add-on. It includes optimistic concurrency, file watch reconciliation, conflict UX, missing-file UX, draft restore, saved file change feedback, and file browser edit badges. + +If source save stays outside an extracted package, the package still needs extension points for all those statuses and actions. + +### 5. Raw HTML is a parallel rendering and annotation stack + +The raw HTML path uses iframe bridge annotations, rewritten asset URLs, and share HTML preparation. It is not just another markdown block type. + +### 6. Dual server parity remains required + +Endpoint changes must be made in both: + +- `packages/server/*` +- `apps/pi-extension/server/*` + +A frontend extraction can reduce UI duplication, but it does not remove this server parity requirement. + +## Candidate Package Boundary + +The practical package is not "all of `App.tsx`." It is a document review surface. + +Working name: + +```text +@plannotator/document-ui +``` + +Primary exported component: + +```text +DocumentReviewSurface +``` + +It should own: + +- markdown/raw-HTML render switch +- annotation lifecycle +- annotation panel integration +- linked document navigation +- file browser document picking +- markdown edit mode +- source-save document state +- draft persistence integration +- feedback payload assembly + +It should not own directly: + +- Claude/OpenCode/Pi command interception +- server startup +- browser opening +- note-app integration persistence policy +- plan version history +- archive browsing +- goal setup +- agent-specific transcript lookup +- exact terminal sidecar implementation + +Those should remain host/app concerns passed as data, capabilities, callbacks, or optional slots. + +## Suggested Host API Adapter + +Instead of hard-coded route fetches in the surface, define a `DocumentHostApi` adapter. + +Shape at a high level: + +```ts +interface DocumentHostApi { + loadSession(): Promise; + loadDocument(request: LoadDocumentRequest): Promise; + validateCodePaths(request: ValidateCodePathsRequest): Promise; + listFiles(request: ListFilesRequest): Promise; + watchFiles?(request: WatchFilesRequest): EventSourceLike; + saveSource?(request: SourceSaveRequest): Promise; + loadDraft(): Promise; + saveDraft(draft: DraftPayload): Promise; + deleteDraft(generation: number): Promise; + submitFeedback(payload: SubmitFeedbackPayload): Promise; + approve?(): Promise; + exit?(): Promise; + uploadImage?(file: File): Promise; + loadShareHtml?(path?: string): Promise; +} +``` + +The current Bun/Pi HTTP routes can be one implementation of that adapter: + +```text +createPlannotatorHttpDocumentApi() +``` + +This avoids baking `/api/plan` into every reusable hook. It also makes local storybook/unit tests easier because the surface can run against an in-memory adapter. + +## Suggested Extraction Sequence + +### Step 1. Define contracts without moving UI + +Create shared document session types around the current `/api/plan` annotate shape and `/api/doc` loaded-document shape. + +Do not rename server routes yet. Keep route compatibility. + +Useful contracts: + +- `DocumentSession` +- `DocumentMode` +- `LoadedDocument` +- `DocumentSourceInfo` +- `DocumentFeedbackPayload` +- `DocumentHostApi` +- `DocumentCapabilities` + +This gives the code a vocabulary before package movement. + +### Step 2. Extract API client wrappers + +Move route fetches behind a client object used by `App.tsx`. + +Good first candidates: + +- `/api/doc` +- `/api/doc/exists` +- `/api/reference/files` +- `/api/reference/files/stream` +- `/api/source/save` +- `/api/draft` +- `/api/share-html` + +The goal is not abstraction for its own sake. The goal is to make the eventual package boundary explicit and testable. + +### Step 3. Move source document state out of `@plannotator/editor` + +The source-edit modules are already cohesive: + +- `editableDocuments.ts` +- `sourceDocumentClient.ts` +- `sourceDocumentReconciliation.ts` +- `savedFileChangeValidation.ts` +- `sourceDocumentPaths.ts` +- `directEdits.ts` +- `draftRestoreSelection.ts` + +These are document-domain modules. They are stronger candidates for `@plannotator/document-ui` than plan-specific code. + +### Step 4. Extract document surface around existing components + +Create a component that accepts: + +- initial document session +- host API adapter +- current origin/config info +- optional capability slots: AI, terminal, notes/export, sharing, archive, plan diff +- callbacks for submit/approve/exit + +At this step, `packages/editor/App.tsx` becomes a host shell that still handles plan-specific behavior, but delegates document mechanics. + +### Step 5. Split plan-only features from annotate-first features + +Plan-only or mostly plan-only: + +- `/api/approve` and `/api/deny` behavior for `ExitPlanMode` +- plan save settings +- plan version history +- plan diff browser +- archive sidebar +- permission mode setup + +Annotate/document-first: + +- render markdown/raw HTML +- annotations and feedback +- linked docs +- folder browser +- source save +- direct edits +- drafts +- external annotations +- image attachments + +The extracted package should make plan-only features optional instead of requiring them in every document session. + +## Recommended Initial Decision + +Extract a document-review package, not a plan-review package. + +The package should treat plan review as one host mode that supplies a document and approve/deny callbacks. Annotate should be the reference use case for the package because it exercises the full document surface: arbitrary files, folders, raw HTML, linked docs, source save, drafts, and optional terminal delivery. + +Do not make `@plannotator/ui` the final boundary by default. It is currently a mixed component library plus route-aware app helpers. Either: + +- create `@plannotator/document-ui` and move document-domain pieces there, or +- carve a `/document` export surface inside `@plannotator/ui` with a documented host API contract. + +The separate package is cleaner if the goal is reuse across applications. + +## Verification Map + +Existing tests that cover likely extraction-sensitive behavior: + +- `packages/ui/utils/parser.test.ts` +- `packages/ui/components/InlineMarkdown.test.ts` +- `packages/ui/markdownEditorFidelity.test.tsx` +- `packages/ui/annotationDraftPersistence.test.tsx` +- `packages/ui/hooks/useFileBrowser.test.tsx` +- `packages/ui/components/sidebar/FileBrowser.test.ts` +- `packages/editor/directEdits.test.ts` +- `packages/editor/editableDocuments.test.ts` +- `packages/editor/editableDocumentsHook.test.tsx` +- `packages/editor/sourceDocumentClient.test.ts` +- `packages/editor/sourceDocumentReconciliation.test.ts` +- `packages/editor/savedFileChangeValidation.test.ts` +- `packages/editor/agentTerminalIntegration.test.ts` +- `packages/server/annotate.test.ts` +- `packages/server/annotate-doc-url.test.ts` +- `packages/server/annotate-html-assets.test.ts` +- `packages/server/reference-handlers.test.ts` +- `packages/server/reference-watch.test.ts` +- `packages/server/agent-terminal.test.ts` +- `apps/pi-extension/server.test.ts` +- `apps/pi-extension/server/agent-terminal.test.ts` +- `apps/pi-extension/server/file-browser-watch.test.ts` + +Tests to add before or during extraction: + +- a contract test for the Bun and Pi annotate `/api/plan` shapes +- a contract test for `/api/doc` loaded markdown, raw HTML, converted HTML, code-file preview, and source-save metadata +- a component test for the document surface with an in-memory host API adapter +- an integration test that edits a folder file, saves it, restores draft state, and sends feedback with saved file change context +- an integration test that switches linked docs between markdown and raw HTML and preserves per-document annotations + +## Open Questions + +1. Should the extracted package include the right annotation panel, or only the document renderer plus hooks? + +Recommendation: include it. The panel is part of the annotation product, and feedback export depends on the same state. + +2. Should the agent terminal live in the document package? + +Recommendation: keep terminal transport and sidecar out. Include only an optional "agent delivery" capability or slot. The current panel can move later if the package is intended to ship the full annotate workspace. + +3. Should plan diff and archive move with the package? + +Recommendation: no for the first extraction. They depend on plan history endpoints and plan-specific mental models. Leave them as host-provided optional sidebar tabs. + +4. Should route names change away from `/api/plan` for annotate? + +Recommendation: not during extraction. The route name is historical but functional. Put a typed client over it first, then rename only if there is a separate compatibility plan for Bun and Pi. + +5. Should raw HTML and markdown be separate surfaces? + +Recommendation: keep one document surface with two render engines. Users experience them as one annotate workflow, and linked docs can switch between them. + +## Bottom Line + +The current architecture works because the annotate server impersonates the plan server enough for `packages/editor/App.tsx` to boot the same React app in annotate mode. + +The extraction should preserve the successful part of that design: one document review experience across plan, file, folder, HTML, URL, and last-message workflows. + +The extraction should remove the fragile part: document behavior is currently spread across a massive app shell and route-aware shared UI hooks. The next architectural boundary should be a document surface plus a typed host API adapter. diff --git a/adr/research/synthesis-document-ui-extraction-20260620-082343.md b/adr/research/synthesis-document-ui-extraction-20260620-082343.md new file mode 100644 index 000000000..69bbd4b8d --- /dev/null +++ b/adr/research/synthesis-document-ui-extraction-20260620-082343.md @@ -0,0 +1,266 @@ +# Synthesis: Document UI Extraction + +> ℹ️ **Context still useful; the direction it informed was reverted.** The extraction approach synthesized here (ADRs 002/003) was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting. + +Date: 2026-06-20 + +Status: Synthesis + +## Research Reviewed + +- `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` +- `adr/research/SPIKE-source-edit-reliability-20260618-090850.md` +- `adr/research/SPIKE-source-edit-race-and-conflict-20260618-095558.md` +- `adr/research/SPIKE-annotate-agent-terminal-production-runtime-20260618-212101.md` +- `adr/research/synthesis-annotate-agent-terminal-production-runtime-20260618-212604.md` + +## What The Research Says + +Annotate is not a separate frontend today. It is the Plan Review app booted in another mode. The annotate server serves document sessions through `/api/plan`, and `packages/editor/App.tsx` decides whether the session is plan review, annotate file, annotate folder, annotate last message, raw HTML, archive, or goal setup. + +The desired extraction is therefore not a renderer extraction. The reusable product is the document review experience: + +- markdown and raw HTML viewing +- markdown block rendering +- annotations and comments +- annotation panel +- linked document navigation +- file browser rows and status badges +- editor toggle +- source-backed save state +- draft persistence +- feedback payload assembly + +Those pieces are currently split across `@plannotator/ui` and `@plannotator/editor`. `@plannotator/ui` has many reusable primitives, but several of them call hard-coded `/api/*` routes. `@plannotator/editor` has the main app shell plus document-domain state like editable documents, source reconciliation, direct edits, and terminal integration. + +So the current split is historical, not a clean package boundary. + +## Recommended Direction + +Create one shared document package: + +```text +@plannotator/document-ui +``` + +Do not split into renderer, tree, comments, editor, and sidebar packages now. The seams are not stable enough. The first useful boundary is one document surface with a typed host API. + +The primary export should be something like: + +```text +DocumentReviewSurface +``` + +It should own the document product mechanics: + +- render markdown or raw HTML +- manage annotation state and highlight restoration +- render the comments/annotation panel +- support linked docs +- support folder file picking +- support markdown edit mode +- manage source-save document state +- integrate draft save/restore +- assemble document feedback + +The host app should keep runtime and mode policy: + +- Claude/OpenCode/Droid/Pi command interception +- server startup and browser opening +- plan-mode approve/deny hook behavior +- plan history, plan diff, archive, and goal setup +- note app policy and settings persistence +- transcript lookup for annotate-last +- terminal runtime/sidecar implementation + +## Main Architectural Move + +Introduce a host API adapter before moving the UI. + +The document surface should not directly know that the current Plannotator server uses `/api/plan`, `/api/doc`, `/api/source/save`, or `/api/draft`. It should depend on an interface such as: + +```ts +interface DocumentHostApi { + loadSession(): Promise; + loadDocument(request: LoadDocumentRequest): Promise; + validateCodePaths(request: ValidateCodePathsRequest): Promise; + listFiles(request: ListFilesRequest): Promise; + watchFiles?(request: WatchFilesRequest): EventSourceLike; + saveSource?(request: SourceSaveRequest): Promise; + loadDraft(): Promise; + saveDraft(draft: DraftPayload): Promise; + deleteDraft(generation: number): Promise; + submitFeedback(payload: SubmitFeedbackPayload): Promise; + approve?(): Promise; + exit?(): Promise; +} +``` + +The current Bun and Pi HTTP routes can be implemented as: + +```text +createPlannotatorHttpDocumentApi() +``` + +This keeps the existing routes stable while giving the UI a real boundary. + +## Package Contents + +Move or expose these as document-domain code: + +- `Viewer` +- `HtmlViewer` +- `MarkdownEditor` +- markdown parser and block types +- annotation highlighter integration +- annotation/comment panel patterns +- linked-doc hook +- file browser hook and rows +- draft hook +- editable document state +- source document client and reconciliation +- saved file change validation +- direct edits feedback builder +- code path validation and inline link handling + +Keep these outside for the first extraction: + +- plan diff +- archive browser +- goal setup +- permission mode setup +- server implementations +- agent terminal sidecar/runtime resolver +- CLI/plugin integrations + +The annotation panel should be included. It is part of the document product, not a peripheral widget. Feedback export and annotation state depend on it. + +Raw HTML should stay in the same document surface. It is a separate render engine, but users experience it as the same annotate workflow, and linked docs can switch between markdown and raw HTML. + +## Source Save Is Core + +The source-edit research matters for extraction. + +Source save is not just "write file on Save." It includes: + +- optimistic concurrency through hash and mtime metadata +- file watch reconciliation +- stale snapshot guards +- conflict recovery with current disk snapshots +- missing-file state +- draft restore +- saved file change feedback +- file browser edit badges + +If the extracted package does not own this state, it will need so many extension points that the package will not actually own the document experience. + +Recommendation: move the source document state modules into the document package early. + +## Terminal And AI Boundary + +Agent terminal is part of the annotate workspace, but the terminal runtime is not part of document UI. + +The package can support an optional "agent delivery" capability: + +- send feedback to a running agent +- report whether the current feedback has already been delivered +- route Ask AI prompts to an agent when provided +- render an optional slot or panel if the host supplies one + +The host should keep: + +- WebTUI sidecar +- tokenized WebSocket path +- remote-mode gating +- runtime install/preflight +- agent discovery + +Normal Ask AI should also be capability-driven. Provider detection and server sessions are host concerns; document UI can consume an abstract ask/send interface. + +## Bun/Pi Constraint + +Frontend extraction does not remove server parity work. + +The Bun server and Pi server both expose the document endpoints. Any route shape change still needs both implementations updated. + +For the extraction, avoid route renames. Keep `/api/plan` for compatibility and put a typed adapter over it. Rename only later if there is a deliberate migration plan. + +## Implementation Order + +1. Define document contracts. + +Create shared types for `DocumentSession`, `LoadedDocument`, `DocumentCapabilities`, `DocumentFeedbackPayload`, and `DocumentHostApi`. Model the current annotate `/api/plan` and `/api/doc` shapes without changing routes. + +2. Add an HTTP adapter. + +Move route calls behind `createPlannotatorHttpDocumentApi()`. Start with `/api/doc`, `/api/doc/exists`, `/api/reference/files`, `/api/reference/files/stream`, `/api/source/save`, `/api/draft`, and `/api/share-html`. + +3. Move source document modules. + +Move the cohesive source-edit modules out of `@plannotator/editor` and into the document package. This reduces `App.tsx` before the main surface extraction. + +4. Create `DocumentReviewSurface`. + +Wrap the existing viewer, HTML viewer, editor toggle, linked docs, file browser, annotation panel, drafts, and source-save state behind one component. + +5. Turn `packages/editor/App.tsx` into a host shell. + +The app should load session data, configure host capabilities, handle plan-specific flows, and delegate document mechanics to `DocumentReviewSurface`. + +6. Add contract and surface tests. + +Add Bun/Pi contract tests for `/api/plan` and `/api/doc`, plus a component test using an in-memory `DocumentHostApi`. + +## What Counts As A Good First Result + +The first successful extraction should make this true: + +- annotate file/folder/HTML/last still work +- plan review still works through the same document surface +- source save and drafts still work +- `App.tsx` no longer owns document mechanics directly +- document UI can run in tests without a live Plannotator server +- no endpoint rename is required +- no extra package split is introduced + +## Keep Out Of Scope + +Do not redesign the UI. + +Do not split into multiple small UI packages yet. + +Do not move the terminal runtime into the document package. + +Do not rename `/api/plan` during extraction. + +Do not fold plan diff, archive, or goal setup into the first document package boundary. + +Do not try to solve all Pi/Bun server duplication as part of the frontend package extraction. + +## Open Decisions + +1. Should the package be a new workspace package or a documented `/document` export inside `@plannotator/ui`? + +Recommendation: new package. `@plannotator/ui` is already broad and route-aware; a new package makes the boundary visible. + +2. Should source save be mandatory or optional? + +Recommendation: optional capability, but first-class inside the package. Sessions without source save should degrade cleanly. + +3. Should plan review be implemented as a document mode or a host wrapper? + +Recommendation: host wrapper. Plan review supplies a document plus approve/deny callbacks. The document package should not know about hook stdout decisions. + +4. Should the terminal panel move later? + +Recommendation: maybe, but only after the document surface exists. The runtime stays host-owned either way. + +## Recommendation + +Proceed toward an ADR that accepts one shared document UI package with a host API adapter. + +The key decision is not "move components to another folder." The key decision is to make Plannotator's document review experience the upstream surface and make plan review one consumer of that surface. + +That preserves the best part of the current architecture: one rich review experience across plans, files, folders, HTML, URLs, and last messages. + +It fixes the weak part: the document product is currently trapped inside a very large app shell and route-aware UI helpers. diff --git a/adr/specs/document-ui-extraction-20260620-083307.md b/adr/specs/document-ui-extraction-20260620-083307.md new file mode 100644 index 000000000..be176d17f --- /dev/null +++ b/adr/specs/document-ui-extraction-20260620-083307.md @@ -0,0 +1,1066 @@ +# Spec: Shared Document UI Package + +> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Spec for the failed `@plannotator/document-ui` extraction (reverted 2026-06-22). The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. Kept here as history only. + +Date: 2026-06-20 + +Status: Draft + +## Intent + +Extract Plannotator's document review experience into one shared UI package that can be used by Plannotator and a sister Workspaces repo. + +The package should not be a thin markdown renderer. It should own the reusable document review loop: + +- render markdown and raw HTML documents +- annotate text and blocks +- show comments and attachments +- navigate linked documents +- browse document/file trees +- edit documents +- show dirty/saving/saved/conflict/missing/error states +- restore drafts +- assemble feedback and saved-change context + +The host app should own routing, auth, server calls, environment capabilities, and provider-specific persistence. + +## Package + +Create one package: + +```text +@plannotator/document-ui +``` + +Do not split into renderer/tree/comments/editor packages yet. The first stable boundary is the full document review surface. + +Primary export: + +```tsx + +``` + +The package may later expose lower-level hooks and components, but those are secondary. The product-level export is the surface. + +## Design Principle + +The core contract must be provider-neutral. + +Do not name the shared contract around Plannotator's local source-save implementation. Local source-save is one provider. The reusable concept is document writeback state: + +```ts +type DocumentWritebackStatus = + | "clean" + | "dirty" + | "saving" + | "saved" + | "conflict" + | "missing" + | "error"; +``` + +Plannotator local implements writeback with: + +- `/api/source/save` +- disk hashes +- mtime +- EOL metadata +- missing local files +- source-save draft restore + +Workspaces implements writeback with: + +- `/v1/workspaces/{workspace}/documents/{document}` +- `If-Match` +- document versions +- missing document rows +- workspace restore semantics + +The package should own the common UI and state behavior. Providers own the persistence details. + +## Goals + +1. Make Plannotator's document experience the upstream UI for both repos. + +2. Keep one shared package, not many narrowly split packages. + +3. Move document-domain behavior out of `packages/editor/App.tsx`. + +4. Preserve current Plannotator behavior for: + +- plan review +- annotate file +- annotate folder +- annotate last message +- raw HTML annotation +- linked docs +- source-backed file editing +- drafts +- feedback submission + +5. Let Workspaces provide a different backend with the same document UI: + +- workspace manifest based document tree +- document ids instead of filesystem paths +- workspace versions instead of disk hashes +- workspace annotations and replies API +- workspace auth and routes + +6. Keep current Plannotator server routes stable during extraction. + +7. Make the document surface testable with an in-memory host API. + +## Non-Goals + +Do not redesign the user interface as part of this extraction. + +Do not rename `/api/plan` during the extraction. + +Do not solve Bun/Pi server duplication as part of the frontend package boundary. + +Do not move CLI/plugin command interception into the package. + +Do not move server startup or browser opening into the package. + +Do not move the agent terminal runtime, WebTUI sidecar, or runtime installer into the package. + +Do not make plan diff, archive, goal setup, or permission mode setup first-class parts of the first document package boundary. + +Do not require a filesystem path for every document. + +## Package Responsibilities + +`@plannotator/document-ui` owns: + +- `DocumentReviewSurface` +- document render mode switch: markdown, raw HTML, editing +- markdown parsing and block model +- markdown block rendering +- raw HTML iframe annotation bridge +- annotation lifecycle +- highlight restoration +- comments and annotation panel +- image attachments +- linked document navigation +- document tree/file tree rendering and status badges +- edit toggle and edit session state +- provider-neutral writeback state +- conflict/missing/error UI patterns +- draft save and restore state +- feedback payload assembly +- saved-change section assembly +- code path validation UI +- inline link handling +- optional Ask AI integration points +- optional agent-delivery integration points + +The host app owns: + +- app routing +- server endpoints +- auth +- current user/session identity +- provider implementation +- browser opening +- plugin/CLI integration +- local filesystem access +- workspace API access +- plan-mode hook stdout behavior +- plan history +- plan diff +- archive +- goal setup +- note-app settings and persistence policy +- terminal runtime and sidecar + +## Terminology + +### Host App + +The application that embeds `DocumentReviewSurface`. + +Examples: + +- Plannotator plan/annotate app +- Workspaces web app + +### Provider + +The host-side implementation of document loading, saving, tree listing, draft persistence, annotations, versions, and feedback submission. + +Examples: + +- Plannotator local provider +- Workspaces provider +- in-memory test provider + +### DocumentRef + +Provider-neutral identity for a document. + +A document may have a filesystem path, but the package must not require one. + +### Writeback + +Provider-neutral document edit persistence. + +Local Plannotator's current `sourceSave` is one writeback implementation. Workspaces document save is another. + +### Feedback + +The review output assembled from annotations, global comments, image attachments, direct edits, saved changes, linked-document comments, and provider-specific metadata. + +## Core Types + +This is a draft interface shape. Exact names can change during implementation, but the concepts should remain. + +```ts +export type DocumentProviderId = string; + +export interface DocumentRef { + id: string; + providerId?: DocumentProviderId; + label: string; + title?: string; + path?: string; + parentId?: string; + kind?: "document" | "folder" | "message" | "url" | "html" | string; + metadata?: Record; +} + +export type DocumentRenderMode = "markdown" | "html"; + +export interface LoadedDocument { + ref: DocumentRef; + content: string; + renderMode: DocumentRenderMode; + rawHtml?: string; + shareHtml?: string; + sourceInfo?: string; + converted?: boolean; + baseForRelativeLinks?: DocumentRef | string; + imageBase?: string; + writeback?: DocumentWritebackCapability; +} + +export type DocumentWritebackStatus = + | "clean" + | "dirty" + | "saving" + | "saved" + | "conflict" + | "missing" + | "error"; + +export interface DocumentWritebackCapability { + writable: boolean; + status: DocumentWritebackStatus; + revision?: string; + language?: "markdown" | "mdx" | "text" | string; + reason?: string; + message?: string; + providerState?: unknown; +} + +export interface DocumentWritebackState { + ref: DocumentRef; + status: DocumentWritebackStatus; + dirty: boolean; + revision?: string; + sessionOpenContent?: string; + savedContent?: string; + currentContent?: string; + conflict?: DocumentConflict; + error?: string; + providerState?: unknown; +} + +export interface DocumentConflict { + latestContent: string; + latestRevision?: string; + message?: string; + providerState?: unknown; +} + +export interface SaveDocumentRequest { + ref: DocumentRef; + content: string; + baseRevision?: string; + providerState?: unknown; + overwriteConflict?: boolean; +} + +export type SaveDocumentResult = + | { + ok: true; + ref?: DocumentRef; + revision?: string; + providerState?: unknown; + } + | { + ok: false; + code: "conflict"; + message: string; + latestContent: string; + latestRevision?: string; + providerState?: unknown; + } + | { + ok: false; + code: "missing" | "not-writable" | "validation" | "network" | "unknown"; + message: string; + providerState?: unknown; + }; +``` + +## Session Types + +```ts +export type DocumentSessionMode = + | "plan-review" + | "annotate" + | "annotate-folder" + | "annotate-message" + | "workspace-review" + | string; + +export interface DocumentReviewSession { + id: string; + mode: DocumentSessionMode; + origin?: string; + rootDocument?: LoadedDocument; + initialDocumentRef?: DocumentRef; + rootTreeRef?: DocumentRef; + capabilities: DocumentCapabilities; + ui?: DocumentSessionUi; + providerState?: unknown; +} + +export interface DocumentCapabilities { + canAnnotate: boolean; + canEdit: boolean; + canWriteback: boolean; + canApprove?: boolean; + canExit?: boolean; + canShare?: boolean; + canUploadImages?: boolean; + canOpenLinkedDocuments?: boolean; + canBrowseDocuments?: boolean; + canUseAskAI?: boolean; + canDeliverToAgent?: boolean; + supportsRawHtml?: boolean; + supportsVersions?: boolean; +} + +export interface DocumentSessionUi { + title?: string; + subtitle?: string; + primaryActionLabel?: string; + approveLabel?: string; + exitLabel?: string; +} +``` + +## Host API + +The surface talks to a provider through `DocumentHostApi`. + +```ts +export interface DocumentHostApi { + loadSession?(): Promise; + + loadDocument(request: LoadDocumentRequest): Promise; + + resolveLinkedDocument?(request: ResolveLinkedDocumentRequest): Promise; + + validateCodePaths?( + request: ValidateCodePathsRequest, + ): Promise; + + listDocuments?( + request: ListDocumentsRequest, + ): Promise; + + watchDocuments?( + request: WatchDocumentsRequest, + ): DocumentWatchSubscription; + + saveDocument?( + request: SaveDocumentRequest, + ): Promise; + + loadDraft?( + request: LoadDraftRequest, + ): Promise; + + saveDraft?( + request: SaveDraftRequest, + ): Promise; + + deleteDraft?( + request: DeleteDraftRequest, + ): Promise; + + uploadImage?( + file: File, + ): Promise; + + submitFeedback?( + payload: SubmitDocumentFeedbackPayload, + ): Promise; + + approve?( + payload: ApproveDocumentPayload, + ): Promise; + + exit?( + payload: ExitDocumentPayload, + ): Promise; + + askAI?( + request: DocumentAskAIRequest, + ): Promise | AsyncIterable; + + deliverToAgent?( + payload: DocumentAgentDeliveryPayload, + ): Promise; +} +``` + +### LoadDocumentRequest + +```ts +export interface LoadDocumentRequest { + ref: DocumentRef; + baseRef?: DocumentRef; + preferredRenderMode?: DocumentRenderMode; +} +``` + +### Tree Types + +```ts +export interface DocumentTreeNode { + ref: DocumentRef; + kind: "folder" | "document"; + children?: DocumentTreeNode[]; + annotationCount?: number; + writebackStatus?: DocumentWritebackStatus; + disabled?: boolean; + metadata?: Record; +} + +export interface DocumentTreeResult { + root: DocumentTreeNode; + workspaceStatus?: unknown; +} +``` + +### Watch Types + +```ts +export interface DocumentWatchSubscription { + close(): void; + onEvent(callback: (event: DocumentWatchEvent) => void): () => void; +} + +export type DocumentWatchEvent = + | { type: "ready"; ref?: DocumentRef } + | { type: "changed"; ref?: DocumentRef; reason?: string } + | { type: "deleted"; ref: DocumentRef } + | { type: "error"; message: string }; +``` + +The Plannotator local adapter can implement this over `EventSource('/api/reference/files/stream')`. + +The Workspaces adapter can implement it over its own workspace document event system, polling, or no-op watches. + +## DocumentReviewSurface Props + +```ts +export interface DocumentReviewSurfaceProps { + session: DocumentReviewSession; + hostApi: DocumentHostApi; + initialDocument?: LoadedDocument; + initialAnnotations?: Annotation[]; + initialCodeAnnotations?: CodeAnnotation[]; + initialGlobalAttachments?: ImageAttachment[]; + className?: string; + slots?: DocumentReviewSlots; + options?: DocumentReviewOptions; + onSubmitted?: (result: SubmitFeedbackResult) => void; + onApproved?: () => void; + onExited?: () => void; + onError?: (error: DocumentReviewError) => void; +} + +export interface DocumentReviewSlots { + leftSidebarExtraTabs?: React.ReactNode; + rightPanelExtraTabs?: React.ReactNode; + terminalPanel?: React.ReactNode; + headerActions?: React.ReactNode; + footer?: React.ReactNode; +} + +export interface DocumentReviewOptions { + defaultEditorMode?: "selection" | "comment" | "redline" | "quickLabel"; + defaultInputMethod?: "drag" | "pinpoint"; + allowRawHtml?: boolean; + allowWideMode?: boolean; + allowImageAttachments?: boolean; + persistUiPreferences?: boolean; + disableDrafts?: boolean; + hideDocumentNavigator?: boolean; + hideAnnotationPanel?: boolean; +} +``` + +## State Ownership + +The package owns frontend state for: + +- active document +- linked document stack +- per-document annotation cache +- selected annotation +- global comments and attachments +- parsed blocks +- markdown edit session +- writeback status map +- dirty document set +- conflict/missing/error display +- draft payload +- saved-change records +- file/document tree badges +- feedback payload assembly + +The provider owns durable state for: + +- document content +- revisions or hashes +- versions +- saved annotations, if the host persists them +- draft storage backend +- auth and permissions +- server-side conflict detection +- route shape + +## Writeback State Machine + +The package should implement the common frontend state machine. + +### clean + +The current editor buffer matches the loaded baseline. + +Allowed actions: + +- edit +- annotate +- navigate away +- submit feedback + +### dirty + +The user changed editable content but has not saved it. + +Allowed actions: + +- save +- discard +- continue editing +- submit only if the product policy allows unsaved direct edits + +Plannotator local should continue to include direct edits in feedback where appropriate. + +Workspaces may choose to require save before submit or include unsaved edits as proposed changes. + +This policy should be configurable by the session or provider. + +### saving + +A writeback request is in flight. + +Allowed actions: + +- show saving state +- prevent duplicate save +- avoid applying stale watch snapshots + +### saved + +The user saved a change during this review session. + +Allowed actions: + +- show saved state +- include saved-change context in feedback where the session requests it +- treat current saved content as the new baseline + +### conflict + +The provider rejected save because the remote/local document changed. + +Required provider data: + +- latest content +- latest revision or provider-specific conflict state + +Allowed actions: + +- reload latest +- overwrite, when policy allows and the edit buffer is available +- keep editing +- discard + +The package should not show overwrite if the provider or current state cannot perform it. + +### missing + +The document no longer exists or cannot be resolved. + +Allowed actions: + +- show missing row/state +- preserve annotations and draft context when possible +- allow discard/close +- allow restore/recreate only if provider advertises support + +### error + +An operation failed without a recoverable conflict or missing state. + +Allowed actions: + +- retry if operation is retryable +- discard local edits +- submit only if policy allows + +## Provider Policies + +Some behavior must be provider/session configurable: + +```ts +export interface DocumentWritebackPolicy { + submitWithUnsavedEdits: + | "allow-as-direct-edits" + | "block" + | "ask"; + submitWithUnverifiedSavedChanges: + | "allow" + | "block" + | "ask"; + conflictOverwrite: + | "allowed" + | "disallowed" + | "provider"; + missingDocumentRestore: + | "none" + | "recreate" + | "provider"; +} +``` + +Plannotator local likely uses: + +- `submitWithUnsavedEdits: "allow-as-direct-edits"` for plan edits +- stricter behavior for source-backed saved-change verification +- `conflictOverwrite: "allowed"` only when live editor buffer is available +- `missingDocumentRestore: "none"` for first extraction + +Workspaces likely uses: + +- `submitWithUnsavedEdits: "block"` or `"ask"` depending on product choice +- `submitWithUnverifiedSavedChanges: "block"` if version checks fail +- `conflictOverwrite: "provider"` +- `missingDocumentRestore: "provider"` if workspace restore exists + +## Drafts + +The package owns draft shape and restore behavior, but the host owns persistence. + +Drafts should store provider-neutral data: + +```ts +export interface DocumentReviewDraft { + annotations: Annotation[]; + codeAnnotations?: CodeAnnotation[]; + globalAttachments: ImageAttachment[]; + editedDocuments?: DraftEditedDocument[]; + savedChanges?: DraftSavedDocumentChange[]; + activeDocumentRef?: DocumentRef; + selectedAnnotationId?: string; + generation: number; + timestamp: number; +} + +export interface DraftEditedDocument { + ref: DocumentRef; + baseRevision?: string; + baseContent: string; + currentContent: string; + providerState?: unknown; +} + +export interface DraftSavedDocumentChange { + ref: DocumentRef; + beforeContent: string; + afterContent: string; + beforeRevision?: string; + afterRevision?: string; + providerState?: unknown; +} +``` + +Plannotator local can map existing draft fields to this shape. + +Workspaces can persist drafts in its own storage and map workspace revisions into `baseRevision` / `afterRevision`. + +Draft generation remains important. It prevents late saves from resurrecting cleared drafts after submit. + +## Feedback Assembly + +The package should assemble a provider-neutral feedback payload: + +```ts +export interface SubmitDocumentFeedbackPayload { + sessionId: string; + mode: DocumentSessionMode; + activeDocument?: DocumentRef; + annotations: Annotation[]; + linkedDocumentAnnotations?: LinkedDocumentAnnotationEntry[]; + codeAnnotations?: CodeAnnotation[]; + globalAttachments?: ImageAttachment[]; + directEdits?: DirectEditEntry[]; + savedChanges?: SavedDocumentChangeEntry[]; + messageScope?: unknown; + providerState?: unknown; +} +``` + +The package can also expose a renderer for human-readable markdown feedback, but the host decides what to do with the payload. + +Plannotator local host behavior: + +- convert payload into current agent feedback text +- call `/api/feedback`, `/api/approve`, or `/api/deny` +- optionally route feedback to the agent terminal + +Workspaces host behavior: + +- save comments/replies through annotation APIs +- save review state through workspace APIs +- submit or share feedback according to workspace product rules + +## Linked Documents + +Linked document navigation must use `DocumentRef`, not filesystem path as the only identity. + +The package should handle: + +- opening linked docs +- preserving root document state +- caching annotations per document +- switching between markdown and raw HTML render modes +- returning to the prior document +- showing annotation counts in the tree + +The provider handles: + +- resolving link text/path/id to `DocumentRef` +- loading document content +- enforcing access and auth +- choosing whether relative links are path-based, manifest-based, or id-based + +## Document Tree + +The package should render a tree of `DocumentTreeNode`. + +Tree rows should support: + +- folders +- documents +- active document indicator +- annotation count +- writeback status badge +- workspace/git/provider status metadata +- missing/deleted rows +- disabled rows + +The package should not assume git status. It can accept provider metadata and render known generic status patterns. Plannotator local can map git workspace status into this metadata. + +## Raw HTML + +Raw HTML support remains part of the package. + +The package owns: + +- iframe rendering +- annotation bridge integration +- shared viewer handle behavior +- raw HTML annotation state + +The provider owns: + +- asset rewriting +- raw HTML sanitization/permission policy if needed +- portable/share HTML generation + +## Ask AI + +Ask AI should be an optional capability. + +The package owns: + +- where Ask AI affordances appear +- how selected document context is gathered +- how annotation context is included + +The host owns: + +- provider selection +- auth +- session creation +- streaming implementation +- terminal fallback + +## Agent Delivery + +Agent delivery is optional and host-owned. + +The package can accept: + +```ts +export interface DocumentAgentDeliveryCapability { + available: boolean; + deliveredKey?: string; + send(payload: DocumentAgentDeliveryPayload): Promise; +} +``` + +The package can use this to: + +- show delivered/current feedback state +- avoid duplicate sends +- route Ask AI prompts to an agent when configured + +The package must not own: + +- WebTUI runtime +- sidecar process +- WebSocket URL creation +- remote-mode security +- runtime installation + +## Plannotator Local Adapter + +Add a browser-side adapter around current routes: + +```ts +createPlannotatorHttpDocumentApi(options?: { + baseUrl?: string; +}): DocumentHostApi +``` + +Initial route mapping: + +- `loadSession` -> `GET /api/plan` +- `loadDocument` -> `GET /api/doc` +- `validateCodePaths` -> `POST /api/doc/exists` +- `listDocuments` -> `GET /api/reference/files` +- `watchDocuments` -> `GET /api/reference/files/stream` +- `saveDocument` -> `POST /api/source/save` +- `loadDraft` -> `GET /api/draft` +- `saveDraft` -> `POST /api/draft` +- `deleteDraft` -> `DELETE /api/draft` +- `uploadImage` -> `POST /api/upload` +- `submitFeedback` -> `POST /api/feedback` +- `approve` -> `POST /api/approve` +- `exit` -> `POST /api/exit` + +The adapter should map local `sourceSave` into provider-neutral writeback fields. + +The core package should not leak `sourceSave` into its public contract except through local adapter internals. + +## Workspaces Adapter + +The sister repo should be able to implement: + +```ts +createWorkspaceDocumentApi(options: { + workspaceId: string; + auth: WorkspaceAuth; + baseUrl: string; +}): DocumentHostApi +``` + +Expected mapping: + +- load tree from workspace manifest +- resolve linked docs from manifest/document ids +- load documents by document id +- save with `If-Match` or version id +- map versions/ETags to `revision` +- map deleted/unavailable docs to `missing` +- load comments/replies from annotations API +- persist drafts through workspace draft storage +- load versions through versions API + +This adapter does not need to live in the Plannotator repo if the package contract is stable. + +## Migration Plan + +### Phase 1. Contracts + +Create `packages/document-ui`. + +Add: + +- core types +- `DocumentHostApi` +- provider-neutral writeback types +- draft types +- feedback payload types + +No product behavior changes. + +### Phase 2. Local HTTP Adapter + +Implement `createPlannotatorHttpDocumentApi()` over current routes. + +Use it from `packages/editor/App.tsx` where possible without moving major UI yet. + +Goal: route calls begin moving behind the adapter. + +### Phase 3. Move Document Domain Modules + +Move or re-export: + +- editable document state +- source document client/reconciliation, renamed toward writeback where public +- saved file change validation, generalized to saved document change validation +- direct edits +- draft restore selection +- path helpers only where local-specific + +Rename public concepts from source-save to writeback. Keep local source-save names inside the local adapter. + +### Phase 4. Extract Surface Shell + +Create `DocumentReviewSurface` around existing: + +- `Viewer` +- `HtmlViewer` +- `MarkdownEditor` +- annotation panel +- linked doc hook +- file browser hook +- draft hook +- writeback state + +`packages/editor/App.tsx` still owns mode decisions and passes session/capabilities. + +### Phase 5. Move Feedback Assembly + +Move provider-neutral feedback assembly into document-ui. + +Keep Plannotator-specific final text wrapping in the Plannotator host. + +### Phase 6. Host Cleanup + +Shrink `packages/editor/App.tsx` into: + +- load session +- create Plannotator host API +- configure plan/annotate actions +- render `DocumentReviewSurface` +- render plan-only sidecars such as plan diff/archive when needed + +### Phase 7. Workspaces Integration + +Workspaces implements its adapter and embeds the surface. + +Any missing extension points should be added to the contract, not patched through Plannotator-local assumptions. + +## Testing + +Add contract tests: + +- Plannotator Bun `/api/plan` maps to `DocumentReviewSession` +- Pi `/api/plan` maps to the same `DocumentReviewSession` +- `/api/doc` markdown maps to `LoadedDocument` +- `/api/doc` raw HTML maps to `LoadedDocument` +- `/api/doc` converted HTML maps to non-writable document +- `/api/doc` source-save maps to writeback capability +- `/api/source/save` conflict maps to provider-neutral conflict + +Add package tests: + +- in-memory provider loads a document +- annotate and restore highlights +- edit document and transition clean -> dirty -> saving -> saved +- conflict result transitions to conflict and shows conflict controls +- missing document transition keeps row and draft context +- linked document navigation preserves annotation cache +- raw HTML document can create annotations through the common surface handle +- draft restore rehydrates edited documents and saved changes +- feedback payload includes annotations, linked doc comments, direct edits, and saved changes + +Keep existing tests active: + +- parser tests +- markdown editor fidelity tests +- annotation draft persistence tests +- file browser tests +- editable document tests +- source reconciliation tests +- server annotate/reference tests +- Pi server parity tests + +## Acceptance Criteria + +The spec is satisfied when: + +- `@plannotator/document-ui` exists as one package. +- It exports provider-neutral contracts. +- It exports `DocumentReviewSurface`. +- Plannotator uses the surface for plan review and annotate flows. +- Plannotator local behavior is preserved. +- Local source-save is represented as writeback in the package contract. +- The package public API does not require filesystem paths. +- The package public API does not expose `/api/source/save`. +- The package public API does not expose local disk hash semantics as required fields. +- Workspaces can implement a provider using document ids, manifests, `If-Match`, and versions. +- The document surface can run in tests with an in-memory provider. +- Plan diff/archive/goal setup remain host-owned. +- Agent terminal runtime remains host-owned. + +## Open Questions + +1. Should `@plannotator/document-ui` depend on `@plannotator/ui`, or should document components move out of `@plannotator/ui`? + +Working recommendation: start with `@plannotator/document-ui` depending on `@plannotator/ui` for primitives and gradually move document-specific components into the new package. Avoid a giant one-shot move. + +2. Should the Plannotator local HTTP adapter live in `@plannotator/document-ui` or `@plannotator/editor`? + +Working recommendation: put the browser-side adapter in `@plannotator/document-ui` if it only uses fetch and public routes. Keep server-only code out. + +3. Should feedback assembly produce markdown text or structured data? + +Working recommendation: produce structured data first and expose a markdown formatter. Plannotator can keep its agent-specific markdown wrapper. + +4. Should unsaved edits be allowed in Workspaces feedback? + +Working recommendation: make this a provider policy. Do not bake Plannotator's direct-edit behavior into all providers. + +5. Should comments/replies persistence be part of the host API now? + +Working recommendation: include extension points now, but do not require persistent comments for the first Plannotator extraction. Workspaces can implement persistence through the adapter. + +## Decision Draft + +Adopt one provider-neutral shared document UI package. + +The core abstraction is not local source-save. The core abstraction is document writeback state plus a host API. + +Plannotator local source-save becomes the first provider implementation. Workspaces becomes a second provider implementation. Plan review becomes one host mode that supplies a document, capabilities, and approve/deny behavior to the shared document surface. diff --git a/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md b/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md new file mode 100644 index 000000000..6f2a03b55 --- /dev/null +++ b/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md @@ -0,0 +1,519 @@ +# Spec: Document UI Feature Completeness Review Fixes + +> ⚠️ **FAILED ATTEMPT — USE AS A CHECKLIST ONLY, NOT A BUILD PLAN.** This catalogs the parity gaps in the reverted `@plannotator/document-ui` cutover (reverted 2026-06-22). It is a useful *inventory of behaviors the UI must preserve*, but do NOT implement it as written — it patches a reimplementation that was thrown away. Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. + +Date: 2026-06-22 + +Status: In Progress + +## Intent + +Close the verified post-review parity gaps in the `@plannotator/document-ui` cutover so the package surface is feature-complete for Plannotator's Plan Review and Annotate app. + +The branch already performs a real architecture cutover: `packages/editor/App.tsx` mounts the new host, and `@plannotator/document-ui` owns much of the document-review state. The remaining work is not to restart the extraction. The remaining work is to wire the production surface to the behavior that was extracted, or to port the few still-missing UI entry points. + +Feature-complete means the normal Plan Review and Annotate production path can ship without the old App shell and without knowingly dropping user-facing behavior from the pre-cutover app. + +## Current Read + +The review findings are mostly valid. The package has many of the right helpers and tests, but the mounted production path does not yet call or expose several of them. + +Confirmed high-impact gaps: + +- Header actions are hidden in Plan Review because the whole `slots` object is gated on agent terminal availability. +- Submit, approve, and close call host APIs directly instead of routing through the extracted safety decisions. +- Annotate-last message data is created by the adapter but not rendered or sent back as an active message scope. +- External annotations and VS Code editor annotations still have server endpoints and shared hooks, but the document surface does not subscribe to them. +- Plan diff uses a new read-only renderer instead of the existing interactive diff viewer with block annotations and VS Code diff support. + +Confirmed secondary gaps: + +- Saved source-change validation exists but is not called before submit/approve. +- Shortcut registries exist but are not registered in the new surface. +- Code-file popout and code-file annotation entry points are not wired from `Viewer`. +- Raw HTML share does not call `/api/share-html` to build portable HTML with inlined relative assets. +- Wide/focus mode helpers exist but are unreachable. +- The new sidebar is versions plus file tree, without old TOC/archive/vault/reference parity. +- Settings are partially stubbed after the menu is restored. +- Print, checkbox task toggles, product announcements, and dead old header cleanup remain smaller follow-ups. + +## Definition Of Done + +The document UI cutover is feature-complete when: + +- Plan Review has Settings, Export, Share, Import, print, note integrations, and any host header actions visible without requiring agent terminal support. +- Approve, Send Feedback, Close, gate-mode approve, and submit shortcuts all run the same safety checks as the old app. +- Unsaved writeback edits, feedback-loss cases, stale saved source changes, missing files, and no-op saved changes are handled before delivery. +- Annotate file, annotate folder, annotate raw HTML, and annotate-last all preserve their feedback target and navigation behavior. +- External annotations and editor annotations appear in the UI and are included in exported/submitted feedback. +- Plan diff/version review supports interactive diff annotations and the VS Code diff affordance. +- The expected keyboard shortcuts work from the production surface. +- Code-file links can open the code-file popout and create code-file annotations where supported. +- Portable sharing of raw HTML sessions preserves relative assets. +- Existing package and editor tests pass, and targeted regression tests cover the formerly missing wiring. + +## P0 Required Fixes + +### 1. Always Mount Header Actions + +Problem: + +`PlannotatorDocumentSurfaceBridge` returns `undefined` slots when `terminalAvailable` or `agentTerminal` is false. This hides `PlanHeaderMenu`, Settings, Export, Import, Share, print, and note actions in normal Plan Review. Agent terminal is only available for annotate file/folder sessions, so the primary Plan Review flow loses its menu. + +Required behavior: + +- `headerActions` must always be provided for supported Plannotator sessions. +- Only `terminalPanel` and terminal-specific header buttons should be gated by terminal availability. +- Plan Review and Annotate should both receive the common Plannotator host actions. + +Implementation shape: + +- Split `hostHeaderActions` from terminal slots in `PlannotatorDocumentSurfaceBridge`. +- Return a slots object unconditionally, with `terminalPanel` set to `null` when unavailable. +- Keep terminal delivery logic unchanged. + +Acceptance: + +- Plan Review shows the options menu with Settings, Export, Share, Import, and print. +- Annotate sessions still show terminal controls only when terminal capability is available. +- Add a render test that plan-review mode includes `data-document-review-header-actions` and the menu trigger when terminal is unavailable. + +### 2. Wire Action Safety Decisions + +Problem: + +The package contains decision helpers for feedback loss, unsaved writeback edits, gate-mode primary action, print shortcut behavior, and submit shortcut behavior. The production buttons call `state.submitFeedback()`, `state.approve()`, and `state.exit()` directly. + +Required behavior: + +- Before Send Feedback, Approve, or Close, the surface must evaluate extracted chrome/action decisions. +- Unsaved writeback edits must warn before close, approve, or send feedback. +- Approve must warn when feedback would be lost. +- Close must warn when feedback would be lost. +- Gate-mode annotate should approve when there is no feedback and send feedback when feedback exists. +- Submit shortcut behavior must use the same action decision as the primary button. + +Implementation shape: + +- Add a small action coordinator inside `DocumentReviewSurface` or as a package hook. +- Render a package-owned confirmation dialog using copy from `documentReviewChrome.ts`. +- Keep host APIs simple: hosts should receive the final approved submit/approve/exit call, not own these generic decisions. +- Use existing `buildUnsavedDocumentEditContinuationDecision`, `decideDocumentReviewFeedbackAction`, `decideDocumentReviewApproveAction`, `decideDocumentReviewExitAction`, `decideDocumentReviewPrimarySubmitAction`, and `buildUnsavedDocumentEditWarningCopy`. + +Acceptance: + +- Tests prove dirty writeback documents block direct submit/approve/close until confirmed. +- Tests prove approve warns when feedback would be lost. +- Tests prove close warns when feedback would be lost. +- Tests prove gate-mode empty annotate primary action calls approve. +- Manual Plan Review: add annotation, click Approve, see feedback-loss warning where old app warned. + +### 3. Reconnect Annotate-Last Message Workflow + +Problem: + +`createPlannotatorEditorLoadPlan()` builds `messages.recentMessages` and `messages.selectedMessageId`, and the Plannotator delivery layer can submit `selectedMessageId` and `feedbackScope`. The new production host does not pass or render `loadPlan.messages`, and `createFeedbackPayload()` only includes `messageScope` when it is manually injected. + +Required behavior: + +- Annotate-last must show the recent message set or an equivalent message navigation UI. +- The selected message must be visible and changeable. +- Annotations must stay associated with their selected message. +- Feedback must include `selectedMessageId`. +- If multiple messages are annotated, feedback must include `feedbackScope: "messages"` or the provider-neutral equivalent expected by the adapter. + +Implementation shape: + +- Prefer modeling messages as provider-neutral documents, if that can be done without distorting the contract. +- Otherwise add a narrow message session controller owned by `DocumentReviewSurface` or the Plannotator bridge. +- Cache annotations per message using existing linked-document/message state helpers where possible. +- Pass the resolved `messageScope` into `createFeedbackPayload()`. + +Acceptance: + +- Annotate-last opens with the selected recent message. +- Switching messages restores that message's annotations. +- Annotating one message sends that message id. +- Annotating multiple messages sends multi-message scope. +- Existing message feedback formatting remains unchanged. + +### 4. Reconnect External And Editor Annotations + +Problem: + +`useExternalAnnotations` and `useEditorAnnotations` still exist in `@plannotator/ui`, and server endpoints still exist. The document surface does not subscribe to them, does not show them in the panel, and does not include editor annotations in feedback. + +Required behavior: + +- External annotations posted to `/api/external-annotations` appear in Plan Review and Annotate where applicable. +- VS Code editor annotations appear when running inside VS Code. +- External annotation updates and deletes are reflected in the UI. +- Editor annotations can be deleted from the UI. +- Feedback/export includes editor annotations and external annotations in the same wording as before. + +Implementation shape: + +- Add optional host/provider annotation channels to `DocumentHostApi`, or provide Plannotator host hooks through surface slots if route names must remain host-owned. +- Keep route names and SSE transport Plannotator-owned. +- Merge external annotations into surface annotation state without duplicating persisted local annotations. +- Pass editor annotations into feedback text assembly. +- Reuse existing `AnnotationPanel`/`EditorAnnotationCard` behavior where possible. + +Candidate host API: + +```ts +interface DocumentHostApi { + watchExternalAnnotations?( + request: WatchExternalAnnotationsRequest, + ): ExternalAnnotationSubscription; + deleteExternalAnnotation?(request: DeleteExternalAnnotationRequest): Promise; + updateExternalAnnotation?(request: UpdateExternalAnnotationRequest): Promise; + loadEditorAnnotations?(request: LoadEditorAnnotationsRequest): Promise; + deleteEditorAnnotation?(request: DeleteEditorAnnotationRequest): Promise; +} +``` + +Acceptance: + +- Posting a plan-review annotation through `/api/external-annotations` shows it without reload. +- Deleting an external annotation removes it. +- VS Code editor annotations appear inside the document review feedback panel. +- Submitted feedback includes editor annotations. + +### 5. Restore Interactive Plan Diff Parity + +Problem: + +The new `DocumentVersionDiffViewer` renders read-only diff blocks. The old `PlanDiffViewer` supports clean/raw modes, block-level diff annotation, selected diff annotations, and `/api/plan/vscode-diff`. + +Required behavior: + +- Version diff supports diff annotations with `diffContext`. +- Diff annotations appear in the feedback panel and exported/submitted feedback. +- The VS Code diff button works for Plannotator plan versions. +- Provider-neutral hosts can choose whether an external diff action is available. + +Implementation shape: + +- Prefer reusing `@plannotator/ui/components/plan-diff/PlanDiffViewer` in the package renderer module. +- If direct reuse is too coupled, port the interaction model into `DocumentVersionDiffViewer`. +- Replace direct `fetch("/api/plan/vscode-diff")` with an optional host API action. + +Candidate host API: + +```ts +interface DocumentHostApi { + openDocumentVersionDiff?(request: OpenDocumentVersionDiffRequest): Promise; +} +``` + +Acceptance: + +- Plan Review with prior versions shows interactive diff blocks. +- Hovering/clicking changed diff blocks can create comments/deletions/quick labels. +- Diff annotations include `[In diff content]` in submitted feedback. +- VS Code diff opens through the Plannotator host when a base version exists. +- Workspaces can omit the external diff action without breaking diff review. + +### 6. Validate Saved Source Changes Before Delivery + +Problem: + +Saved source-change validation exists, but submit/approve does not call it. Old behavior protected against stale disk state, missing files, and no-op saved edits before feedback delivery. + +Required behavior: + +- Before submit or approve, saved changes must be validated when the provider supports probing. +- Stale, missing, and no-op saved changes must be dropped or warned according to existing decisions. +- Unverified changes must be preserved when validation cannot prove they are stale. + +Implementation shape: + +- Keep generic validation in `@plannotator/document-ui`. +- Keep local source-save probe logic in the Plannotator host/adapter. +- Route Plannotator `validateSavedFileChanges()` into the surface action coordinator before delivery. + +Acceptance: + +- Tests cover valid, stale, missing, no-op, and unavailable saved-change probes. +- Submit payload only includes valid/unverified saved changes. +- UI reports dropped saved changes clearly enough for the user to understand what happened. + +## P1 Feature Completeness Fixes + +### 7. Register Keyboard Shortcuts + +Required behavior: + +- `Mod+Enter` submits the primary action. +- `Mod+P` opens print while preserving print-mode CSS behavior. +- `Escape` exits plan diff when diff is active. +- Input-method double-tap shortcuts work where supported. +- Shortcuts respect dialogs, text input focus, editing state, and submitted/exiting states. + +Implementation shape: + +- Register the existing `planReviewSurface` and `annotateSurface` shortcut scopes in the new surface/host. +- Use extracted `decideDocumentReviewSubmitShortcut` and `decideDocumentReviewPrintShortcut`. +- Wire `usePrintMode()` in the mounted app. + +Acceptance: + +- Shortcut tests cover disabled states and text input focus. +- Manual smoke confirms `Mod+Enter` and `Mod+P`. + +### 8. Restore Code-File Popout And Code Annotations + +Required behavior: + +- Markdown/PFM code-file links can open the code-file popout. +- Code-file annotations can be created and submitted. +- Code path validation continues to run through the host. + +Implementation shape: + +- Pass `onOpenCodeFile` into `Viewer`. +- Mount `CodeFilePopout` from `@plannotator/ui`. +- Use existing `useCodeFilePopout()` and host API code-file loading. +- Keep local filesystem route details in Plannotator host/adapter. + +Acceptance: + +- Clicking a code-file link opens the popout. +- Creating a code annotation adds it to the panel. +- Submitted feedback includes code-file annotations. + +### 9. Restore Portable Raw HTML Sharing + +Required behavior: + +- Raw HTML annotation sessions shared through Export/Share use portable HTML with relative assets inlined. +- The display HTML used by the review iframe should not be assumed to be the share HTML. + +Implementation shape: + +- Add `prepareShareHtml?` to the host API, or keep it as a Plannotator header action helper. +- Plannotator implementation calls `/api/share-html`. +- Cache prepared share HTML per active HTML document where sensible. + +Acceptance: + +- Sharing an HTML file with relative images/styles produces a share that renders correctly outside the local server. +- Markdown sharing remains unchanged. + +### 10. Restore Wide/Focus And Chrome Polish Needed For Parity + +Required behavior: + +- Wide/focus mode is reachable when `allowWideMode` is enabled and unavailable in archive/diff states. +- Left and right panels behave consistently with wide/focus transitions. +- Sticky controls, panel collapse, resize behavior, and visible document max-width are close enough to old Plan Review/Annotate behavior for normal use. + +Implementation shape: + +- Wire `documentWideMode.ts`, `documentReviewLeftSidebar.ts`, and `documentReviewRightPanel.ts` into `DocumentReviewSurface`. +- Keep user preference persistence host-owned or option-driven. +- Avoid making Plannotator-only settings required by core document UI. + +Acceptance: + +- Wide/focus controls exist when enabled. +- Entering wide/focus hides panels and can restore previous layout. +- Diff/archive states do not leave the layout stuck. + +### 11. Sidebar And Reference Parity + +Required behavior: + +- The left sidebar should cover the core old navigation workflows: TOC, versions, file tree, and in-session archive/reference access if those remain expected in Plan Review. +- Folder annotate should show the file tree with badges and writeback status. +- `openSidebarTab` from the load plan must be honored. + +Implementation shape: + +- Keep generic sidebar mechanics in the package. +- Keep Obsidian vault discovery and archive storage Plannotator-host owned. +- Use slots for Plannotator-only archive/vault/reference tabs if they are not generic. + +Acceptance: + +- Folder annotate opens the files tab by default. +- Archive mode or archive tab behavior matches the decided scope. +- TOC is available for long markdown documents if parity requires it. + +### 12. Finish Settings/Header Integration + +Required behavior: + +- Settings opened from the restored header should have real AI provider data. +- App version should come from package/app metadata, not `0.0.1`. +- Agent instruction copy should be enabled if that feature remains supported. +- Tater/grid/user display settings should either work or be explicitly declared out of scope. + +Implementation shape: + +- Plannotator host owns these values and passes them to the header slot. +- The package only exposes slot props and surface state needed by host actions. + +Acceptance: + +- Settings AI tab shows available providers. +- Header About/version is correct. +- Agent instructions copy works or is intentionally removed with tests/docs updated. + +## P2 Cleanup And Explicit Non-Goals + +These items should not block the feature-complete cutover unless the user/product bar says otherwise: + +- Product announcement dialogs for Plan AI and Look & Feel. These are product-owned notices, not core document review behavior. +- Moving Plannotator adapter subpath exports out of `@plannotator/document-ui`. This is boundary cleanup, not a Plannotator parity blocker. +- Deleting dead `AppHeader.tsx` and other old shell remnants once no imports remain. +- Re-adding `VITE_DIFF_DEMO` fallback behavior. This is dev/demo-only. +- Full old visual chrome parity for every ornamental detail. Preserve workflow capability first, then polish. + +## Callback Scope Decision + +Shared/hash session callback support exists in `PlannotatorSharedSessionHost`. Normal API-mode callback query support was not found in the new production host path. + +Decision needed: + +- If `?cb=&ct=` was only a shared-session workflow, no P0 work is needed. +- If API-mode sessions still need callback approval/feedback, add callback config parsing to `PlannotatorDocumentSurfaceHost` and route submit/approve through the same callback utility. + +Acceptance if in scope: + +- API-mode callback URLs preserve feedback and approval behavior. +- Shared-session callback behavior remains unchanged. + +## Package Boundary Requirements + +The package should own generic document review behavior: + +- annotation lifecycle +- feedback assembly +- writeback state and writeback warnings +- draft restore +- document tree/navigation +- version diff and diff annotations +- shortcuts and generic chrome decisions +- generic code-file preview hooks if a host can load targets + +The host should own environment behavior: + +- Plannotator route names +- note apps +- share/paste policy +- app version and settings data +- agent terminal runtime +- local source-save probing +- VS Code diff route +- external annotation transport route names +- archive/vault storage mechanics + +The Workspaces integration should be able to implement `DocumentHostApi` without importing Plannotator local source-save concepts. Any new host API should use provider-neutral names such as writeback, versions, annotations, external diff, and prepared share HTML. + +## Test Plan + +Unit and integration tests: + +- `bun test packages/document-ui` +- `bun test packages/editor` +- `bun run typecheck` +- `bun run --cwd apps/hook build` +- `git diff --check` + +New or updated tests should cover: + +- Header actions visible without terminal. +- Submit/approve/close safety warnings. +- Gate-mode primary action decision. +- Annotate-last selected and multi-message feedback scope. +- External annotation subscription/update/delete merge behavior. +- Editor annotation feedback inclusion. +- Diff annotation creation and feedback inclusion. +- Optional external version diff host action. +- Saved-change validation before delivery. +- Shortcut registration and blocking states. +- Code-file popout open path and code annotation feedback. +- Raw HTML share HTML preparation. + +Manual smoke tests: + +- Plan Review from `ExitPlanMode`: menu, approve, deny/send feedback, diff, settings, export/share/import. +- Annotate markdown file: annotations, source save, saved-change validation, close warnings. +- Annotate folder: file tree, badges, open files, writeback statuses. +- Annotate raw HTML with relative assets: render, annotate, share. +- Annotate-last: select messages and submit one-message and multi-message feedback. +- VS Code mode if available: editor annotations and VS Code diff. +- External annotation API: post, update, delete while UI is open. + +## Implementation Order + +1. Fix header slots so Plan Review has the menu again. +2. Wire the action coordinator and confirmation dialogs. +3. Add saved-change validation into the same action path. +4. Reconnect annotate-last message state and `messageScope`. +5. Reconnect external/editor annotations. +6. Restore interactive plan diff and VS Code diff host action. +7. Register shortcuts and print mode. +8. Restore code-file popout/code annotations. +9. Restore portable raw HTML sharing. +10. Wire wide/focus/sidebar parity and settings polish. +11. Remove dead old shell leftovers after the feature-complete path is verified. + +## Scope Decisions + +- Recent messages stay as provider-neutral message review state for this PR, not as regular `DocumentRef` entries. The surface owns message navigation/cache behavior and the Plannotator adapter maps it into annotate-last delivery. +- Standalone archive host is enough for this PR. In-session archive/vault/reference sidebar tabs remain P1/product-scope work. +- API-mode callback support is not required for this cutover. The old header only rendered callback actions for non-API shared sessions, and shared/hash callback support remains preserved. +- Restore workflow parity, not pixel-perfect old chrome. +- External/editor annotations are generic optional `DocumentHostApi` watch/delete capabilities, with Plannotator route names kept inside the Plannotator HTTP adapter. + +## Implementation Status: 2026-06-22 + +Completed in the current worktree: + +- Header actions are mounted in Plan Review without requiring annotate terminal support. +- Send Feedback, Approve, Close, and primary submit paths now route through package-owned action safety checks and confirmation dialogs. +- Saved source-file changes are validated before submit/approve, with stale, missing, and no-op changes filtered before delivery. +- Annotate-last message state is surfaced through a message navigator, cached per message, and submitted with `messageScope` and `messageAnnotations`. +- External annotations and VS Code editor annotations are exposed through a provider-neutral annotation watch/delete host API, rendered in the surface, and included in feedback assembly. +- Plan version diffs use the interactive plan diff viewer again, restoring block annotations and the Plannotator VS Code diff affordance. +- Basic production shortcuts and print behavior are restored for `Mod+Enter`, `Mod+P`, and diff `Escape`. +- Input-method switching is package-owned again: the surface owns mutable `drag`/`pinpoint` state and registers the existing Alt hold / Alt Alt input-method hook. +- Code-file links can open the code-file popout and create code-file annotations through the package surface. +- Raw HTML export/share preparation calls the Plannotator `/api/share-html` route before falling back to display HTML. +- Wide/focus controls are exposed by the package surface when `allowWideMode` is enabled, Plannotator enables them in the production bridge, and active wide/focus mode hides side panels until exit. +- Header settings now receive real AI provider capability data, the app version value, and enabled agent-instruction copy behavior. +- The unused old-shell `packages/editor/components/AppHeader.tsx` file and stale read-only diff renderer helpers were removed. +- Callback compatibility was audited: the old header only rendered bot callback actions for non-API shared sessions, and shared/hash callback sessions remain handled by `PlannotatorSharedSessionHost`. + +Verified: + +- `bun test packages/document-ui` -> 357 pass, 0 fail. +- `bun test packages/editor` -> 64 pass, 7 skip, 0 fail. +- `bun run typecheck` -> pass. +- `bun run --cwd apps/hook build` -> pass. +- `git diff --check` -> pass. +- Vite dev server smoke: `bun run --cwd apps/hook dev --host 127.0.0.1` served `http://127.0.0.1:3000/`, `/api/plan`, and `/api/plan/versions` successfully. +- Playwright Chromium smoke against the Vite-rendered production app path passed for: + - Plan Review header menu, Settings menu item, interactive diff view, wide-mode toggle, external annotation display, editor annotation display, print shortcut, and approve delivery. + - Plan Review share-link copy, global-comment creation, and deny/send-feedback delivery through `/api/deny`. + - Annotate markdown source-save edit/save via `/api/source/save`. + - Annotate folder document-tree expansion and `/api/doc` navigation. + - Annotate raw HTML share/export preparation via `/api/share-html`. + - Annotate-last multi-message navigator rendering. +- Playwright Chromium SSE smoke passed for browser consumption of `/api/external-annotations/stream` snapshot events. + +Additional focused checks after wide/focus, input-method, raw HTML share, code-file URL, and cleanup wiring: + +- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/DocumentReviewSurface.interaction.test.tsx packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx` -> 36 pass, 0 fail. +- Interaction coverage now dispatches `Mod+Enter`, `Mod+P`, `Alt`, and wide-mode clicks against a mounted DOM surface. +- Bridge coverage now verifies `/api/share-html` is called for raw HTML export/share and falls back safely when portable HTML is unavailable. +- Code-file coverage now verifies the popout `/api/doc` URL boundary uses the target path and active document base. +- `bun run typecheck` -> pass. + +Still open or requiring manual confirmation: + +- Old sidebar/reference parity is intentionally not a P0 blocker for this PR under the current scope decision: standalone archive host is enough, while in-session archive/vault/reference tabs remain P1/product-scope work. +- Host-only integrations still need manual confirmation in their native environments: the VS Code extension/editor-annotation producer and real external-annotation producers. The browser-rendered consumer paths are covered by the Playwright smoke above. diff --git a/adr/specs/document-ui-parity-cutover-20260621-121115.md b/adr/specs/document-ui-parity-cutover-20260621-121115.md new file mode 100644 index 000000000..85c96046a --- /dev/null +++ b/adr/specs/document-ui-parity-cutover-20260621-121115.md @@ -0,0 +1,467 @@ +# Spec: Document UI Parity Cutover + +> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Spec for the failed cutover (reverted 2026-06-22). The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. Kept here as history only. + +Date: 2026-06-21 + +Status: Draft + +## Intent + +Finish the `@plannotator/document-ui` extraction so the Plan Review / Annotate app uses the package as the real production document surface, with no parallel legacy document UI path left behind. + +The target is not to move every Plannotator feature into the shared package. The target is to move the reusable document-review experience into the package, then leave Plannotator-specific environment behavior in a small host shell. + +## Target State + +`packages/editor/App.tsx` should stop being the document-review product. It should become a Plannotator host shell that: + +- loads the session through the Plannotator adapter +- reads Plannotator settings and environment capabilities +- wires Plannotator-only routes and side effects +- provides host slots for settings, export/share, note integrations, archive, goal setup, and terminal +- renders `DocumentReviewSurface` + +The normal production path should not require: + +```text +VITE_DOCUMENT_SURFACE=1 +``` + +`VITE_DOCUMENT_SURFACE` should be removed once parity is reached. + +## Ownership Rule + +The package owns the document review loop. + +The host owns environment policy. + +### Package owns + +- markdown and raw HTML document review +- annotation creation, editing, deletion, selection, and persistence hooks +- global comments and image attachments +- linked document navigation +- document tree/file tree UI and badges +- message/document navigation when messages are represented as documents +- source/document editing UI +- writeback states: clean, dirty, saving, saved, conflict, missing, error +- draft restore UI and state +- feedback payload assembly +- plan/document version browsing and diff UI +- generic Ask AI panel and in-document ask affordances when a host AI API exists +- code/link preview UI when the host can load or validate targets +- generic shortcuts for document review actions +- default chrome needed for parity: toolstrip, sticky controls, sidebars, panels, empty states, banners, and action buttons + +### Host owns + +- server routes +- auth +- browser opening and process lifetime +- CLI/plugin/hook integration +- `ExitPlanMode` stdout decisions +- Plannotator settings persistence +- share/paste service policy +- import/export modal policy +- Obsidian, Bear, and Octarine integrations +- agent terminal runtime, PTY/WebSocket bridge, installer, and remote security policy +- goal setup business logic +- archive storage and list loading +- provider transport details for comments, versions, documents, and watches + +## Required Work + +### 1. Make `DocumentReviewSurface` the default app surface + +Remove the feature-flagged bridge as a separate product path. + +Current state: + +- `packages/editor/App.tsx` computes `USE_DOCUMENT_SURFACE`. +- The package surface is only rendered when the flag is enabled. +- The old app shell remains the default render path. + +Required changes: + +- Replace the default editor render path with the package surface. +- Keep a thin Plannotator host shell, but do not keep both document-review implementations. +- Delete `shouldUseDocumentSurfaceBridge()` and the `VITE_DOCUMENT_SURFACE` runtime branch after parity is green. +- Move or delete old `App.tsx` document-domain state that duplicates package state. + +Acceptance: + +- Running the normal app with no env flag renders `DocumentReviewSurface`. +- `rg VITE_DOCUMENT_SURFACE packages apps` returns no production code hits. +- `packages/editor/App.tsx` no longer imports or directly orchestrates `Viewer`, `HtmlViewer`, `PlanDiffViewer`, `AnnotationPanel`, `usePlanDiff`, `useLinkedDoc`, or `useArchive` for the main document path. + +### 2. Add provider-neutral versions and diff + +Plan diff/version browser is the biggest package gap. It should move into `@plannotator/document-ui` as optional document version capability. + +Add host API methods: + +```ts +interface DocumentHostApi { + listDocumentVersions?(request: ListDocumentVersionsRequest): Promise; + loadDocumentVersion?(request: LoadDocumentVersionRequest): Promise; +} +``` + +Draft types: + +```ts +interface DocumentVersionRef { + id: string; + label: string; + createdAt?: number; + revision?: string; + providerState?: unknown; +} + +interface DocumentVersionsResult { + versions: DocumentVersionRef[]; + currentVersionId?: string; + previousVersionId?: string; + providerState?: unknown; +} + +interface LoadedDocumentVersion { + version: DocumentVersionRef; + document: LoadedDocument; +} +``` + +Package behavior: + +- fetch and show versions when `session.capabilities.supportsVersions` is true +- select a base version +- compute markdown diffs in the package, using existing diff utilities +- render clean/raw diff modes +- support diff annotations +- block version/diff actions while document editing is dirty +- expose version state through render props for custom hosts + +Plannotator adapter: + +- map `/api/plan/versions` +- map `/api/plan/version` +- use existing `previousPlan` and `versionInfo` as initial version data when available + +Workspaces adapter expectation: + +- map workspace document versions API +- use workspace document ids and versions, not local history paths + +Acceptance: + +- Plan review with previous versions shows the same diff affordance as the old app. +- Version browser works from the package surface. +- Diff annotations are included in feedback with the current legacy wording. +- Workspaces can implement the version API without Plannotator route names. + +### 3. Bring default chrome to visible parity + +The current default `DocumentReviewSurface` chrome works, but it is simpler than the old shell. The package surface needs parity for the generic document review experience. + +Move or recreate in package: + +- annotation toolstrip +- sticky header lane behavior +- wide/focus document controls +- document max-width behavior +- raw HTML tool visibility toggle +- folder empty state +- linked document breadcrumb/back chrome +- message picker as document navigation, if message mode remains supported +- feedback panel count and delete/edit behavior +- right panel resize/collapse behavior +- left sidebar collapsed rail and tab behavior +- keyboard shortcuts for submit, print, diff exit, save, and panel/sidebar toggles +- code-file/link preview when the host can load the target +- checkbox override behavior if editable checkboxes remain part of rendered markdown review + +Keep host-owned: + +- user preference storage +- Plannotator-specific issue/help links +- product-specific header menu +- print side effect +- settings modal + +Acceptance: + +- Annotate markdown, annotate raw HTML, annotate folder, annotate last message, and plan review do not visibly regress from the old app for core review actions. +- Default package UI has no obvious missing document controls compared with the old app. +- Package surface remains usable without Plannotator-specific settings or note integrations. + +### 4. Turn file/message browsing into provider-neutral document navigation + +The package already has document tree state. It needs to become the real default file/message navigation path. + +Required changes: + +- Treat folders, files, and recent messages as `DocumentTreeNode` / `DocumentRef` data. +- Let Plannotator adapter map `/api/reference/files` and `/api/reference/files/stream` to `listDocuments` and optional watch behavior. +- Let Workspaces adapter map workspace manifest rows to the same tree. +- Preserve annotation counts and writeback status badges in the tree. +- Preserve highlighted/annotated file behavior where it is generic. +- Keep local filesystem containment and vault retry mechanics in the Plannotator adapter/host. + +Acceptance: + +- Annotate-folder can select markdown, text, and raw HTML files through the package surface. +- File annotation counts survive navigation. +- Writeback statuses show on tree rows. +- Message mode can navigate recent assistant messages without bespoke `App.tsx` state. + +### 5. Finalize writeback and local source-save cutover + +The provider-neutral writeback core is mostly done. The remaining work is to stop the old shell from applying separate source-save state. + +Required changes: + +- Route all active document edit/save/discard/reload-conflict behavior through package writeback state. +- Keep Plannotator source-save behavior inside `plannotator-*` adapter helpers. +- Ensure missing local files, disk conflicts, stale saved changes, and draft-restored edits behave the same as the old path. +- Remove duplicate editor/source-save state from `App.tsx` after package behavior is authoritative. + +Acceptance: + +- Saving source-backed markdown/text files works from the package surface. +- Dirty, saving, saved, conflict, missing, and error states match current semantics. +- Draft restore preserves dirty writeback buffers and saved-change context. +- No generic shared type requires disk hash, mtime, EOL, or filesystem path. + +### 6. Move Ask AI surface behavior into the package + +The package already has Ask AI context helpers and `hostApi.askAI`. It needs the UI path if parity requires the package surface to replace the old shell. + +Required changes: + +- Add a default AI panel when `session.capabilities.canUseAskAI` and `hostApi.askAI` are available. +- Use package-owned document context assembly. +- Support document-targeted ask from comments or selected document regions. +- Let the host provide provider/model settings and permission handling. +- Keep terminal fallback and agent-specific prompt policy host-owned. + +Possible host API extension: + +```ts +interface DocumentHostApi { + askAI?(request: DocumentAskAIRequest): Promise | AsyncIterable; + listAIProviders?(): Promise; + respondToAIPermission?(response: DocumentAIPermissionResponse): Promise; +} +``` + +Acceptance: + +- The old Ask AI panel can be replaced for document review sessions. +- Hosts without AI do not see AI UI. +- Provider/model/auth policy does not leak into core document types. + +### 7. Keep agent terminal as a host slot, but finish integration points + +Do not move the terminal runtime into `@plannotator/document-ui`. + +Required changes: + +- Keep `terminalPanel` or a refined terminal slot in `DocumentReviewSlots`. +- Let package chrome show/hide terminal entry points when the host provides a terminal slot/capability. +- Keep generic agent-delivery state in the package. +- Keep PTY, WebSocket, runtime install, remote-mode security, and shell prompt construction in Plannotator host code. + +Acceptance: + +- Annotate-mode terminal can be mounted beside the package document surface. +- Package can show delivered-to-agent status without knowing terminal transport details. +- Workspaces is not forced to implement a terminal. + +### 8. Handle archive without making it core document review + +Archive is Plannotator-specific storage, but it still needs a path after `App.tsx` is shrunk. + +Required changes: + +- Do not make archive mandatory in `DocumentHostApi`. +- Expose enough slot support for a host archive tab or collection browser. +- Plannotator host owns archive plan loading, selection, copy, and done behavior. +- Archive selection can load a read-only `LoadedDocument` into the package surface or render through a host-provided archive mode. + +Acceptance: + +- Plannotator archive mode still works after old `App.tsx` document shell is gone. +- Archive does not appear in Workspaces unless Workspaces opts into a comparable collection provider. + +### 9. Keep goal setup host-owned + +Goal setup is not document review. It should not become core package behavior. + +Required changes: + +- Render goal setup from the Plannotator host shell, not the legacy document shell. +- Keep `GoalSetupSurface` in its current package unless a later decision moves it. +- Ensure goal setup submit/exit still uses shared action-controller helpers only where useful. + +Acceptance: + +- Goal setup works without the old document-review render path. +- `@plannotator/document-ui` does not need goal setup-specific public types. + +### 10. Keep settings, share, export, and note integrations host-owned + +These are Plannotator product policies, not shared document review behavior. + +Required changes: + +- Package exposes current feedback payload/rendered feedback through callbacks or render state. +- Host uses that payload for export/share/import and note integrations. +- Host injects header/menu actions through slots. +- Package does not call paste service, Obsidian, Bear, or Octarine routes. + +Acceptance: + +- Export/share/import still work in Plannotator. +- Note-app saves still work in Plannotator. +- Workspaces can ignore these features or provide its own host actions. + +### 11. Finalize annotation provider integration + +Current package annotation persistence is a good base. Full parity needs live provider annotations to stop being old-app-specific. + +Required changes: + +- Keep `loadAnnotations` and `saveAnnotations`. +- Add optional watch/subscribe support if live updates are required: + +```ts +interface DocumentHostApi { + watchAnnotations?(request: WatchDocumentAnnotationsRequest): DocumentAnnotationSubscription; +} +``` + +- Package owns merging local draft annotations with provider-owned annotations. +- Host/provider owns external transport, SSE route names, VS Code editor annotation routes, and permission policy. + +Acceptance: + +- External/provider annotations can appear in the package surface. +- Editing or deleting provider annotations routes through the provider where appropriate. +- Hosts without live annotations still work through load/save/draft behavior. + +### 12. Cut down `packages/editor/App.tsx` + +After parity lands, remove old document-product orchestration. + +Keep in editor host shell: + +- load session +- build Plannotator host API +- read settings +- wire Plannotator host slots +- render completion overlay +- render modals owned by Plannotator +- handle plan-mode and annotate route policy + +Remove from editor host shell: + +- document annotation reducer +- linked-doc state machine +- plan diff state machine +- archive document rendering path +- file/message document navigation state +- markdown/html viewer rendering +- document edit/writeback UI state +- direct document feedback assembly +- duplicate draft restore logic + +Acceptance: + +- The old document body path is gone. +- The file is understandable as a host shell, not a product state machine. +- Any remaining Plannotator-specific code has a clear reason to stay host-owned. + +## Dependency Order + +1. Add missing package contracts: versions/diff, optional annotation watch, refined slots. +2. Move version/diff state and rendering into the package. +3. Bring package chrome to visible parity for toolstrip, sidebars, panels, file/message navigation, and code previews. +4. Wire Plannotator adapter to the new contracts. +5. Move Ask AI surface behavior into the package, keeping provider config host-owned. +6. Mount terminal/archive/settings/export/note integrations through host slots. +7. Flip default app path to `DocumentReviewSurface`. +8. Delete old duplicate editor document state. +9. Run parity verification and fix regressions. + +## Verification + +Minimum automated checks: + +```text +bun test packages/document-ui +bun run typecheck +bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build +bun run --cwd apps/hook build +bun run --cwd apps/review build +git diff --check +``` + +Browser smoke checks: + +- plan review approve +- plan review deny with annotations +- plan diff/version browser +- annotate markdown file +- annotate raw HTML file +- annotate folder and switch files +- annotate last message and switch messages +- linked markdown document navigation +- code-file/link preview +- image upload and image display +- source-save success +- source-save conflict +- source file missing and save/recreate behavior +- draft restore after reload +- Ask AI open/ask/permission if enabled +- agent terminal slot open/close/delivered status +- archive browse/done +- export/share/note actions + +## Non-Goals + +- Do not redesign Plannotator's visual language. +- Do not move server route implementations into the package. +- Do not rename current Plannotator routes as part of this cutover. +- Do not make Workspaces adapter code live in this repo. +- Do not make local source-save terms part of the provider-neutral core. +- Do not move terminal runtime or note-app policy into the package. +- Do not keep both old and new document UI paths after cutover. + +## Open Decisions + +1. Should archive be represented as a host-provided document collection API, or only as a Plannotator host slot? + + Recommendation: host slot for this cutover. Add a collection API later only if Workspaces has a matching need. + +2. Should Ask AI provider/model settings be shown inside the shared package panel or injected by host slot? + + Recommendation: package owns the panel shell and messages; host injects provider settings/actions. + +3. Should goal setup remain in `@plannotator/ui` or move to another host package? + + Recommendation: leave it where it is for this cutover. The important thing is that it no longer depends on the old document shell. + +4. Should package version/diff compare be host-computed or package-computed? + + Recommendation: host loads versions; package computes markdown diff by default. Add optional host-computed diff only if Workspaces needs semantic/version-specific compare results. + +## Completion Criteria + +This work is complete when: + +- The normal Plan Review / Annotate app renders through `@plannotator/document-ui`. +- There is no `VITE_DOCUMENT_SURFACE` cutover flag. +- The old document-review render path is removed. +- Plan review, annotate file, annotate folder, annotate last, raw HTML, linked docs, source-save, drafts, plan diff, Ask AI, terminal slot, archive, export/share, and note integrations all still work. +- Workspaces can implement the same UI by supplying a `DocumentHostApi` without inheriting Plannotator local source-save vocabulary. From 42350a946bb0d4ae4bee97b243189d41d6ddb169 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 19:35:45 -0700 Subject: [PATCH 02/67] docs(adr): add verified document-ui extraction plan, supersede draft inventory 36-agent verification of the reuse inventory: confirmed the /api coupling but found the draft missed Viewer's transitive backend call, the cookie settings layer, 3 React contexts + identity singleton, SSE transports, and harder packaging blockers. Adds the verified per-subsystem extraction plan with a parity guardrail on every step; flags the draft inventory as superseded. --- ...ment-ui-reuse-inventory-20260622-183000.md | 121 ++++++++++++++ ...xtraction-plan-verified-20260622-184500.md | 158 ++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md create mode 100644 adr/specs/document-ui-extraction-plan-verified-20260622-184500.md diff --git a/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md b/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md new file mode 100644 index 000000000..c81de1aef --- /dev/null +++ b/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md @@ -0,0 +1,121 @@ +# Spike: Document UI Reuse Inventory (for Workspaces) + +Date: 2026-06-22 + +> ⚠️ **SUPERSEDED — first draft, materially incomplete.** A 36-agent verification found this audited only one coupling axis (literal `/api/` strings) and missed five others — most importantly: **Viewer is not actually "clean"** (fires `/api/doc/exists` on mount), an **uncounted cookie-persistence layer** (`storage.ts`, ~24 modules), **3 React contexts + a global identity singleton**, **SSE/EventSource** transports, and harder-than-stated **packaging blockers**. Use the verified version instead: **`adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`**. Kept here as the first-pass record. + +> Companion to **ADR 004** (`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`). This is the concrete lay-of-the-land for sharing Plannotator's document UI with the commercial Workspaces app: what exists, what is reusable as-is, what is wired to Plannotator's backend, and a first-cut order of work. Read 004 first for the *why* and the safety rules. + +## The shape in one paragraph + +The document UI is two buckets. **Bucket 1 — `packages/ui`** (~39,400 lines, 108 components + 31 hooks + ~45 utils) is the reusable library: rendering, file browser, sidebar, editor, theme. **Bucket 2 — `packages/editor/App.tsx`** (4,685 lines, ~276 stateful hooks, 13 server fetches) is the Plannotator-specific *glue* that fetches Plannotator data and assembles it into the app. **Bucket 1 is what we share. Bucket 2 stays Plannotator's; Workspaces writes its own equivalent glue.** Of the 108 components, only **26 files** call Plannotator's server directly — those are the wires to cut. The other ~82 already take their data from the outside and are reusable as-is. + +## Bucket 1: `packages/ui` component library + +| Folder | Count | What it is | Workspaces relevance | +| --- | --- | --- | --- | +| `components/` (top level) | 65 | Viewer, MarkdownEditor, AnnotationPanel, modals, toolbars, etc. | Core | +| `components/blocks/` | 8 | Markdown block renderers (code, table, callout, alert, HTML…) | Core (doc rendering) | +| `components/sidebar/` | 7 | SidebarContainer, SidebarTabs, FileBrowser, VersionBrowser, ArchiveBrowser, MessagesBrowser, CountBadge | Core (sidebar + file tree) | +| `components/html-viewer/` | 1 | Raw HTML viewer | Core | +| `components/plan-diff/` | 6 | Plan version diff viewer | Maybe (Workspaces has version history) | +| `components/ImageAnnotator/` | 3 | Annotate images | Maybe | +| `components/ai/` | 2 | Ask-AI chat panel | Maybe (own AI) | +| `components/ui/` | 8 | Low-level primitives (buttons, dialogs…) | Core | +| `components/core/` | 2 | Shared core | Core | +| `components/icons/` | 4 | SVG icons | Core | +| `components/settings/` | 1 | Settings tab(s) | Partial | +| `components/goal-setup/` | 1 | Plannotator goal workflow | Plannotator-only | + +Plus `packages/ui/theme.css` (the theme/color tokens — pure, fully reusable), 31 hooks, ~45 utils, and `shortcuts/` (keyboard registry). + +## The 26 backend-coupled files (the wires to cut) + +Grouped by purpose. "WS" = does Workspaces need it. + +### Document rendering — WS: YES (do first) +| File | Calls | Note | +| --- | --- | --- | +| `components/blocks/HtmlBlock.tsx` | `/api/image` | image src in markdown | +| `components/ImageThumbnail.tsx` | `/api/image` | image thumbnails | +| `components/InlineMarkdown.tsx` | `/api/doc` | inline linked-doc loads | +| `hooks/useLinkedDoc.ts` | `/api/doc` | navigate doc → doc | +| `hooks/useValidatedCodePaths.ts` | `/api/doc/exists` | validate code-file links | +| `components/AttachmentsButton.tsx` | `/api/upload` | attach images to comments | + +### File tree / browser — WS: YES (core to Workspaces) +| File | Calls | Note | +| --- | --- | --- | +| `hooks/useFileBrowser.ts` | `/api/reference/files`, `/api/reference/files/stream`, `/api/reference/obsidian/*` | the file tree data source | + +### Comments / annotations / drafts — WS: YES (agents + teammates commenting) +| File | Calls | Note | +| --- | --- | --- | +| `hooks/useAnnotationDraft.ts` | `/api/draft` | autosave annotation drafts | +| `hooks/useCodeAnnotationDraft.ts` | `/api/draft` | autosave code annotations | +| `hooks/useExternalAnnotations.ts` | `/api/external-annotations`, `/api/external-annotations/stream` | **agents posting comments** — directly relevant to Workspaces | + +### Versions / diff — WS: MAYBE (Workspaces has version history) +| File | Calls | Note | +| --- | --- | --- | +| `hooks/usePlanDiff.ts` | `/api/plan/version`, `/api/plan/versions` | version list + fetch | +| `components/plan-diff/PlanDiffViewer.tsx` | `/api/plan/vscode-diff` | opens VS Code (Plannotator-local; WS would drop this one button) | + +### Settings / config — WS: PARTIAL (Workspaces feeds its own config) +| File | Calls | Note | +| --- | --- | --- | +| `config/configStore.ts` | `/api/config`, `/api/diff`, `/api/plan` | app config bootstrap | +| `config/settings.ts` | `/api/config` | settings load/save | +| `components/Settings.tsx` | `/api/ai/capabilities`, `/api/config`, `/api/obsidian/vaults` | settings panel | +| `components/settings/HooksTab.tsx` | `/api/config`, `/api/hooks/status` | Plannotator hooks tab (WS drops) | + +### Sharing / export / open-in — WS: PARTIAL (Workspaces has its own sharing) +| File | Calls | Note | +| --- | --- | --- | +| `utils/sharing.ts` | `/api/paste`, `/api/paste/` | short-URL share | +| `components/ExportModal.tsx` | `/api/save-notes` | save to Obsidian/Bear/Octarine | +| `components/OpenInAppButton.tsx` | `/api/open-in`, `/api/open-in/apps` | open in local app (local-only; WS drops) | + +### Ask AI / code-review agents — WS: NO / OWN +| File | Calls | Note | +| --- | --- | --- | +| `hooks/useAIChat.ts` | `/api/ai/*` | Ask-AI streaming (WS would wire its own AI) | +| `hooks/useAgents.ts` | `/api/agents` | agent provider detection | +| `hooks/useAgentJobs.ts` | `/api/agents/*` | code-review agent jobs (review feature, not docs) | + +### Archive / editor-annotations / plan-injection — WS: NO (Plannotator-only) +| File | Calls | Note | +| --- | --- | --- | +| `hooks/useArchive.ts` | `/api/archive/*`, `/api/done`, `/api/plan` | Plannotator plan archive | +| `hooks/useEditorAnnotations.ts` | `/api/editor-annotation(s)` | VS Code editor annotations | +| `components/goal-setup/GoalSetupSurface.tsx` | `/api/goal-setup/submit` | Plannotator goal workflow | +| `utils/planAgentInstructions.ts` | `/api/external-annotations`, `/api/plan` | plan-time prompt injection | + +### Tally +- **~10 coupled files Workspaces clearly needs** (rendering + file tree + comments). +- **~6 partial** (settings/config/sharing — Workspaces supplies its own source through the same shape). +- **~10 Plannotator-only** (archive, goal-setup, hooks, VS Code, code-review agents, open-in) — Workspaces simply won't mount these; no work needed beyond not importing them. + +## Bucket 2: the glue (`packages/editor/App.tsx`) + +4,685 lines, ~276 stateful hooks, 13 fetches. This is **not shared.** It is Plannotator's assembly layer: it bootstraps from `/api/plan`, runs the approve/deny hook flow, owns sidebar/panel/wide-mode layout state, and feeds everything into the Bucket-1 components. Workspaces writes its own (smaller) equivalent that bootstraps from its Cloudflare APIs and feeds the same components. + +**Caveat that matters:** some of "the experience" (which sidebar tab is open, file-tree expansion, panel resize, wide/focus mode) currently lives *inside this glue file*, not inside the reusable components. Part of the work is pushing that behavior **down into the components** (e.g. `SidebarContainer` owns its own open/close) so Workspaces' glue stays thin and doesn't have to re-derive layout logic. (Re-deriving that logic generically is exactly what the reverted attempt did wrong — push it into the real components instead.) + +## Packaging state + +`packages/ui/package.json` already declares `@plannotator/ui` with a fine-grained `exports` map (components, hooks, utils, config, types, theme). But it is `version: 0.0.1`, `type: module`, source-only (no build step, no publish). To be installable by an outside repo it needs: a real version, a build (or confirmed source-export consumption), peer-deps sorted (React, CodeMirror, Radix, etc.), and a publish target. Moderate, not hard. + +## First-cut order of work (the safe path from ADR 004) + +Each step: lift the server call out to a prop/callback, leave the component's logic intact, confirm Plannotator still looks identical, then move on. One item at a time. + +1. **Rendering core** — `/api/image`, `/api/doc`, `/api/doc/exists`, `/api/upload` (HtmlBlock, ImageThumbnail, InlineMarkdown, useLinkedDoc, useValidatedCodePaths, AttachmentsButton). Makes a doc render anywhere. +2. **File tree** — `useFileBrowser`. Makes the tree take a data source. +3. **Comments/drafts** — `useAnnotationDraft`, `useCodeAnnotationDraft`, `useExternalAnnotations`. Makes comments (incl. agent-posted) provider-driven. +4. **Versions** — `usePlanDiff` (keep the VS Code button as an optional prop Workspaces omits). +5. **Config/settings shape** — let `configStore`/`settings` take their source from the host instead of `/api/config`. +6. **Packaging** — turn `@plannotator/ui` into a real publishable package. +7. **Push layout state into components** — sidebar/panel/wide-mode behavior currently in `App.tsx` moves into the sidebar/layout components so Workspaces' glue stays thin. + +Steps 1–5 are independent and can be done in any order / in parallel by different people. Step 6 can start anytime. Step 7 is the largest and is best done last, informed by what Workspaces actually needs. diff --git a/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md b/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md new file mode 100644 index 000000000..47abf8646 --- /dev/null +++ b/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md @@ -0,0 +1,158 @@ +# Spec: Document UI Extraction Plan — Verified + +Date: 2026-06-22 + +> Produced by a 36-agent verification workflow (5 coupling-sweep lenses + 15 subsystem analyses + 15 adversarial parity reviews + synthesis). It **verifies and supersedes** the draft inventory `adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md`, which was directionally correct but materially incomplete. Governed by **ADR 004**. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. Every seam below is "lift the URL/global to an optional prop, with **today's literal as the verbatim default**." + +**Repo:** `/Users/ramos/plannotator/feat-pkg-document-ui` · **Library:** `packages/ui` · **Glue:** `packages/editor/App.tsx` + +## Subsystem parity verdicts + +`safe` = extract via straightforward seams. `risky` = extractable, but contains timing-sensitive/stateful code that must be **moved verbatim**, not re-derived (the reverted attempt's failure mode). + +| Subsystem | Verdict | Effort | WS | +|---|---|---|---| +| Theme & tokens | safe | S | core | +| Markdown parsing + block rendering | safe | S | core | +| Document Viewer + annotation highlighting | **risky** | S–M | core | +| Raw HTML viewer | safe | S | core | +| Markdown editor | safe | S | core | +| File tree / browser | **risky** | M | core | +| Sidebar shell + tabs | safe | S | core | +| Comments / annotations / drafts | **risky** | L | core | +| Versions / plan diff | safe | M | maybe | +| Settings / config | safe | M | partial | +| Sharing / export / notes | safe | S | partial | +| Ask AI / agents | **risky** | M | maybe/own | +| Images: upload / thumbnail / annotate | safe | S | core | +| The glue (App.tsx layout) | safe | S | n/a | +| Packaging @plannotator/ui | safe | M | gate | + +## 1. What the draft inventory missed (verified corrections) + +The draft audited only one coupling axis — literal `/api/` strings at call sites — and was blind to five others. + +- **A. Viewer is NOT clean (most consequential).** `Viewer.tsx:532` calls `useValidatedCodePaths(...)` **unconditionally**, which POSTs `/api/doc/exists`. Mounting Viewer fires a Plannotator backend call. Viewer is **NEEDS_SEAM**, not one of the "clean 82." +- **B. An uncounted cookie-persistence subsystem.** `utils/storage.ts` (`document.cookie`) is the **sole** settings backend, imported by ~24 modules (theme, TOC/plan-width/sticky prefs, panel resize, editor mode, agent switch, AI provider, identity). NEEDS_SEAM (inject a `{getItem,setItem,removeItem}` adapter; it's already localStorage-shaped). +- **C. Three React contexts + one global singleton, never mentioned.** + - `ScrollViewportContext` — consumed in `packages/ui` (Viewer, StickyHeaderLane, PinpointOverlay, TableOfContents) but its **only Provider lives in the glue** (`App.tsx:3888`). Mounted elsewhere, sticky headers / pinpoint / scroll-to-anchor / TOC scrolling silently break. NEEDS_SEAM. + - `configStore` singleton — module-level, eager cookie reads, hardcoded `fetch('/api/config')` write-back (L118). Reached **transitively** via `identity.ts` by Viewer, AnnotationPanel, HtmlViewer, `useAnnotationHighlighter`, diff views, Settings. This is **annotation authorship** ("which comments are mine") — core to Workspaces commenting. NEEDS_SEAM (host-supplied `currentUser`/`isCurrentUser`). + - `CodePathValidationContext` — intra-library, null-tolerant. TRANSFER_AS_IS. +- **D. SSE (EventSource) is a distinct transport** the draft collapsed into REST siblings: `useFileBrowser.ts:297`, `useExternalAnnotations.ts:44`, `useAgentJobs.ts:66`. Workspaces' backend must speak SSE or supply a `subscribe` callback. +- **E. `useFileBrowser` is NOT transfer-as-is** — hard-codes `/api/reference/files` (L116), `/api/reference/obsidian/files` (L224), and the SSE watcher (L297). Only its expansion state is pure. +- **F. Packaging is harder than "moderate."** Hard blockers: `@plannotator/ai` + `@plannotator/shared` are `workspace:*` + `private` + `0.0.1`; a **phantom `dompurify`** dep (imported, not declared); **no `peerDependencies`** (React-duplication risk); `exports` point at raw `.tsx`/`.ts` with no `main`/`module`/`types`. +- **G. Small factual fixes** (so we don't act on bad data): theme count is a clean **51:51** (the "53/52" was a grep artifact); `useTheme()` does **not** throw without a provider (seeded default); `getImageSrc` is **one** shared seam across 5 consumers, not 3 separate wires; `utils/sharing.ts` calls the **external** paste service (base URLs parameterized), not a Plannotator `/api/paste` route. + +## 2. Master extraction plan (dependency-ordered) + +Each step: **default === today's literal**, additive optional prop/callback, logic untouched. The guardrail is how you prove Plannotator didn't change. + +### Step 0 — Packaging unblock (do first; gates external install, zero runtime effect). Effort M. +- Add `dompurify` to `packages/ui` deps at the root's exact `^3.3.3` (version mismatch could change sanitization output). +- Resolve the two `workspace:* / private` deps: publish `@plannotator/ai` + `@plannotator/shared` with real versions, **or** inline the ~11 verified browser-safe subpaths ui value-imports (all Web-API-only — Web Crypto, CompressionStream — no `node:*`). +- Add `peerDependencies` (react, react-dom, tailwindcss, tailwindcss-animate, radix set, lucide-react); keep as devDeps for in-repo typecheck. +- Fix stale `tsconfig.json:21` alias `@plannotator/shared` → nonexistent `../shared/index.ts`; align `diff` range (`^8.0.3` vs root `^8.0.4`). +- Keep the **source-only** export model (no dist build — a build could change what Plannotator ships); document required consumer bundler settings (`isolatedModules`, JSX runtime, `allowImportingTsExtensions`). +- Add a `files` allowlist incl. `assets/`, `sprite_package_*/`, themes; exclude `*.test.*` (the only upward `ui→editor` import is `shortcuts.test.ts`). +- **Guardrail:** `bun run build:hook` + `build:opencode` produce byte-identical bundles; in-repo React still resolves to one copy. + +### 1. Rendering core — images. Effort S. +- *As-is:* BlockRenderer, sanitizeHtml, inlineTransforms, parser render path. +- *Seam:* the single `getImageSrc` (ImageThumbnail.tsx:6) shared by 5 consumers (ImageThumbnail, InlineMarkdown, HtmlBlock, AttachmentsButton, Viewer). Introduce a module-level/context override whose default is the current body verbatim (http passthrough + conditional `&base`). Do **not** thread a Viewer-level prop — it can't reach InlineMarkdown/HtmlBlock. +- **Guardrail:** all 5 importers emit identical `/api/image?path=…&base=…`. Keep the default resolver **module-level (stable identity)** so HtmlBlock's `React.memo` + effect deps are untouched (otherwise `
` collapse on re-render). + +### 2. Rendering core — doc fetch + code-path validation. Effort S. +- *Seam A:* InlineMarkdown hover preview `fetch('/api/doc?…')` (L154) → optional `fetchCodeFileContents` defaulting to the literal (same `{path, base?}`, **no `convert=1`** — that's glue). `useLinkedDoc` already accepts `buildUrl`; `useCodeFilePopout` is already prop-driven. +- *Seam B:* gate Viewer's validation — add `disableCodePathValidation`/inject result; default = today (on). +- **Guardrail:** Plannotator passes nothing → hover previews + code-link rendering identical; `/api/doc/exists` still fires. + +### 3. Image upload + attachments. Effort S. +- *Seam:* `AttachmentsButton` `fetch('/api/upload')` (L140) → `onUpload(file) => Promise<{path}>`. Preserve multipart field name `'file'` and `{path}` return shape. Keep `deriveImageName` export stable. +- **Guardrail:** capture-phase paste listener + `stopPropagation` unchanged (no double-attach with App.tsx's bubble-phase paste). + +### 4. File tree. Effort M (highest-risk SSE move). +- *As-is:* `FileBrowser.tsx` helpers + CountBadge, expansion state. +- *Seam:* lift `useFileBrowser`'s three fetch URLs + the **entire** SSE watcher effect (L289-342: EventSource, 120ms debounce, ready-dedup, cleanup) into a default `loadTree`/`loadVaultTree`/`watchTrees` object — moved **verbatim**, URL literals the only relocatable part. `useFileBrowser()` must stay callable with zero args. +- **Guardrail:** existing `useFileBrowser.test.tsx` stays green **without modification**. If it needs rewriting, the default changed → regression. + +### 5. Comments / annotations / drafts. Effort L (risky). +- *As-is:* AnnotationSidebar, EditorAnnotationCard, commentContent, anchors, annotationHelpers, useExternalAnnotationHighlights. +- *Seam A — draftTransport:* wrap the 5 `/api/draft` fetches; `save` rejects on failure (preserves keepalive-retry). Keep generation bookkeeping in the hook. **Document the 3-party protocol:** `getDraftGeneration()` escapes into App.tsx and rides `/api/approve`/`/api/deny` bodies; server tombstone-gates in `shared/draft.ts`. A host swapping transport must replicate generation-gated delete-on-submit or ghost drafts resurrect. +- *Seam B — external-annotations transport:* move the **entire** effect body (EventSource + snapshot-gated fallback + `?since`/304 polling at 500ms) verbatim into a default `subscribe()`. Keep reducer + optimistic mutators. `enabled` flag already host-suppliable. +- *Seam C — identity:* `isCurrentUser(author)` + `getIdentity()` author-stamping (3 creation sites) → optional `author?`/`isCurrentUser?` props defaulting at the App.tsx call site to existing `identity.ts` functions. +- **Guardrail:** approve/deny payloads still carry `getDraftGeneration()`; SSE→polling fallback identical; `(me)` badge renders; every annotation stamped. Note: web-highlighter restoration is **renderer-coupled** — Workspaces must reuse BlockRenderer+InlineMarkdown+inlineTransforms as a unit. + +### 6. Versions / plan diff. Effort M. +- *As-is:* `planDiffEngine.ts`, Badge, ModeSwitcher, RawDiffView. +- *Seam:* inject fetchers into `usePlanDiff` (default → `/api/plan/version(s)`); optional `onOpenVscodeDiff` in `PlanDiffViewer` (default → `/api/plan/vscode-diff`). Keep error handling in the hook (asymmetric: selectBaseVersion `alert()`s, fetchVersions silent). +- *CSS gap:* block/raw-diff + `.annotation-highlight` rules live in **`packages/editor/index.css` (L119-219)**, not the package. Move into `packages/ui/theme.css` (pure move) or document as a host CSS contract. +- **Guardrail:** App.tsx calls with no opts → identical traffic + alert behavior. + +### 7. Settings / config. Effort M. +- *As-is:* `config/settings.ts` (pure cookie+default+mappers). +- *Seam A:* inject only the final `fetch('/api/config')` write-back (L118) via `setServerSync(fn)`. **Keep singleton construction, eager cookie reads, 300ms debounce, deepMerge byte-identical** (a naive per-`set()` fetch changes batching/timing). +- *Seam B:* `Settings.tsx` `fetch('/api/obsidian/vaults')` (L748) → `onDetectObsidianVaults?` default = real fetch; keep `useEffect [obsidian.enabled]` + auto-select-first-vault verbatim (a `[]` no-op default kills auto-select). +- *Seam C:* storage adapter (shared with steps 9/10). Keep literal keys (`plannotator-theme`, `plannotator-toc-enabled`, `plannotator-plan-width`, …) so existing cookies still read. +- *PLANNOTATOR_ONLY:* `HooksTab.tsx`. +- **Guardrail:** Plannotator passes nothing → identical cookie keys, merged `/api/config` POST, vault auto-select. + +### 8. Sharing / export / notes. Effort S. +- *As-is:* `sharing.ts`, `useSharing`, obsidian/bear/octarine wrappers, `callback.ts`. +- *Seam:* `ExportModal` `fetch('/api/save-notes')` (L150) → `onSaveToNotes` → `{success, error}`. Keep `showNotesTab = isApiMode && !!markdown` byte-for-byte. +- *PLANNOTATOR_ONLY:* `OpenInAppButton`. + +### 9. Theme & tokens. Effort S (safe). +- *As-is:* `theme.css` + 51 `themes/*.css` + `print.css` as **one atomic commit**. `themeRegistry` + `ThemeProvider` together. +- *Seam:* inject `storage` into `ThemeProvider` + `uiPreferences`; optional `mode?` on `MarkdownEditor`. +- **Guardrail:** do not touch synchronous `applyThemeClasses` (L96-98) or the rAF `transitions-ready` toggle (L107-111) — reordering causes FOUC. Keep `@source` globs in lockstep if files move. + +### 10. Markdown editor. Effort S (lowest-coupled). +- `MarkdownEditor.tsx` is a 41-line theme-bridge over published `@plannotator/markdown-editor@0.1.0` + `@atomic-editor/editor@0.4.3`. `editorMode.ts` is glue (App.tsx-only). +- **Guardrail:** keep `GRID_CARD_CLASSES` under a `@source`-scanned path (else grid card loses border/shadow). + +### 11. PLANNOTATOR_ONLY — never imported by Workspaces (no work). +`useAutoClose` (Glimpse), `useEditorAnnotations` (`window.__PLANNOTATOR_VSCODE`), `useUpdateCheck` (hardcoded github releases), `useArchive`/`ArchiveBrowser`, `useAgents`/`useAgentJobs`, `GoalSetupSurface`, `planAgentInstructions`, `annotateAgentTerminal` (ws:// derivation), `useSharing` `/p/` routing. They stay in the app shell. + +### 12. Ask AI. Effort M (risky — mechanical-move-only). +- *Seam:* extract **exactly** the 5 fetch literals in `useAIChat` behind a default `transport`. **Do NOT touch** the SSE reader loop (L233-304), epoch/createRequest guards, or the supersede-abort fetch position (L153-158). Capabilities fetch + provider resolution + cookie `aiConfig` init stay in the **shell** (pulling them into the lib is the forbidden re-derivation). + +## 3. Top cross-cutting parity risks + +1. **Cookie-storage swapped globally.** `storage.ts` underlies ~24 modules. Inject per-host; never change the default; keep literal `plannotator-*` keys. Otherwise Plannotator loses theme/layout/identity persistence across random-port hook invocations. +2. **`getImageSrc` resolved per-component** instead of the one shared resolver → some images break with no type error. Single override over the existing default. +3. **Over-extracting glue coordinators (the reverted-approach trap).** App.tsx's panel toggles entangle wide-mode exit + agent-terminal teardown; sidebar auto-open/close is policy keyed on `tocEnabled`/`hasTocEntries`/`isPlanDiffActive`. Keep these as opaque PLANNOTATOR_ONLY glue. +4. **Identity drift.** If `author`/`isCurrentUser` default to `undefined`/`''` instead of live `getIdentity()`/`isCurrentUser`, annotations lose author + `(me)` ownership silently. +5. **CSS that ships in the app shell, not the package** (plan-diff rules, font `@import`s, Tailwind `@source`, `GRID_CARD_CLASSES`). Move files without updating `@source` in lockstep → utilities render unstyled. Silent visual breakage in Plannotator's own build. +6. **Re-render instability from non-stable injected callbacks** (HtmlBlock memo/deps) → collapses open `
`. Keep defaults module-level. +7. **SSE→polling fallback / draft-generation protocols** are timing-sensitive state machines — move as **copies**, not re-derivations. + +## 4. Glue guidance (App.tsx) — be conservative + +**Push DOWN (default = today):** +- The seam defaults (image resolver, doc fetch, upload, draft/external transports, configStore write-back, obsidian detect, save-notes) — defaults live at the App.tsx call site wrapping the current literal. +- `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts`: two pure functions, no relative/circular deps — byte-identical move + one import-path edit (App.tsx:109). Effort S. +- Ship a `ScrollViewportContext` provider/wrapper with the package. + +**LEAVE in the glue (PLANNOTATOR_ONLY — do NOT genericize):** +- Bootstrap from `/api/plan`, approve/deny hook flow, `getDraftGeneration` submit-body wiring. +- Right-panel/wide-mode/agent-terminal coordinators + auto-open/close sidebar policy (risk #3). +- `fileBrowserDirs` derivation + `showFilesTab` + load-orchestration; tab-visibility `show*Tab` + archive lazy-fetch. +- AI capabilities fetch + provider resolution + cookie `aiConfig` init. +- Panel-resize CSS-var writes (`--rpanel-w`/`--toc-w`/`--agent-terminal-w`). + +**Hard rule for the draft's "step 7" (push layout into components):** keep `show*Tab`, `width`, `onTabChange` (with its archive side effect) as **opaque props/callbacks**. `useSidebar`/`useResizablePanel`/`SidebarContainer` are already prop-driven and already reused by `review-editor/App.tsx` — Workspaces writes its **own** coordinator over the same primitives. Re-deriving the coordinator generically is the forbidden path. + +## 5. Packaging blockers (verified) + +| Blocker | Severity | Fix (no logic change) | +|---|---|---| +| `@plannotator/ai` + `@plannotator/shared` are `workspace:* / private / 0.0.1` | HARD | Publish both (real version) or inline the ~11 browser-safe value-imported subpaths | +| Phantom `dompurify` dep (imported, not declared) | HARD | Add to ui deps at exact `^3.3.3` | +| No `peerDependencies` block | MED | Move react/react-dom/tailwindcss(-animate)/radix/lucide to peers; keep devDeps | +| Fonts + Tailwind `@source` live in consumer `index.css` | MED | Ship a documented CSS entry; host on Tailwind v4 | +| Source-only `exports` (no `main`/`module`/`types`) | MED | Keep source model + document bundler settings; no dist build | +| `diff` version drift (`^8.0.3` vs `^8.0.4`) | LOW | Align to `^8.0.4` | +| Stale tsconfig alias → nonexistent `../shared/index.ts` | LOW | Fix when converting shared off `workspace:*` | +| Static asset imports + no `files` allowlist | LOW | Add `files` incl. assets/sprites/themes; exclude tests | + +**Non-blockers (verified — do not "fix"):** all `@plannotator/shared` value imports are Web-API-only; `@plannotator/ai` is `import type` only; `@plannotator/shared/storage` (node:fs) is `import type` only (erased under `isolatedModules`). `theme.css` is pure. From efd95f83e37e49b9f04d68f81240bc60542a24e1 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 19:43:29 -0700 Subject: [PATCH 03/67] docs(adr): add document-ui extraction roadmap + parity checklist Phase 0-7 execution roadmap (safety net -> packaging -> foundation seams -> rendering -> navigation -> comments -> extras -> publish) and the reusable 'did it break?' parity checklist run after every step. Both enforce the law: move + decouple, never rewrite; Plannotator's experience cannot change. --- ...document-ui-extraction-roadmap-20260622.md | 93 +++++++++++++++++++ .../document-ui-parity-checklist-20260622.md | 92 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 adr/implementation/document-ui-extraction-roadmap-20260622.md create mode 100644 adr/implementation/document-ui-parity-checklist-20260622.md diff --git a/adr/implementation/document-ui-extraction-roadmap-20260622.md b/adr/implementation/document-ui-extraction-roadmap-20260622.md new file mode 100644 index 000000000..9de0af989 --- /dev/null +++ b/adr/implementation/document-ui-extraction-roadmap-20260622.md @@ -0,0 +1,93 @@ +# Document UI Extraction — Phased Roadmap + +Date: 2026-06-22 · Baseline commit: `30cfcebb` + +> The order of operations for making `packages/ui` reusable by the commercial Workspaces app. Governed by **ADR 004**; the per-step detail (exact seams, files, line numbers) lives in the verified plan `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`; the safety net is `adr/implementation/document-ui-parity-checklist-20260622.md`. +> +> **THE LAW:** move + decouple, never rewrite. Plannotator's experience cannot change. Every step = lift a URL/global to an optional prop whose **default is today's literal**, logic untouched. +> +> **The rhythm, every step:** make one small change → run the parity checklist → confirm identical → commit → next. Small steps, a human eyeballing the result. No multi-day unattended runs. + +## Phase ordering at a glance + +| Phase | What | Risk to Plannotator | Can Workspaces start? | +|---|---|---|---| +| 0 | Safety net (parity baseline + checklist) | none (no code change) | — | +| 1 | Packaging unblock | none (invisible) | — | +| 2 | Three foundation seams (storage, image, scroll-context) | low | — | +| 3 | Rendering stack (theme, markdown, viewer, html, editor) | low | **Yes — after this** | +| 4 | Navigation (sidebar + file tree) | medium (file tree SSE) | builds on it | +| 5 | Comments / annotations / drafts | medium (move verbatim) | builds on it | +| 6 | Optional extras (versions, settings, sharing, AI) | low–medium | as needed | +| 7 | Glue cleanup + publish | low | consumes the published package | + +--- + +## Phase 0 — Safety net (do once, before any code change) +**Goal:** be able to prove Plannotator didn't change after every step. **Risk:** none. +- [ ] Capture the automated baseline (Part A of the checklist): `typecheck`, `bun test` count, all three builds, bundle fingerprint → save to `scratchpad/parity-baseline.txt`. +- [ ] Keep a baseline build (and/or screenshots of each mode) to diff against. +- [ ] Confirm the checklist covers every mode you ship. + +## Phase 1 — Packaging unblock (invisible; gates external install) +**Goal:** make `@plannotator/ui` installable by an outside repo, with zero runtime change. **Risk:** none (no pixel changes). See verified plan "Step 0." +- [ ] Add the missing `dompurify` dependency at the root's exact `^3.3.3`. +- [ ] Resolve the two internal `workspace:* / private` packages (`@plannotator/ai`, `@plannotator/shared`) — publish them, or inline the ~11 verified browser-safe subpaths the UI value-imports. +- [ ] Add a `peerDependencies` block (react, react-dom, tailwindcss, tailwindcss-animate, radix set, lucide-react); keep as devDeps for in-repo typecheck. +- [ ] Fix the stale `tsconfig.json:21` alias (points at a nonexistent file); align the `diff` version (`^8.0.3` → `^8.0.4`). +- [ ] Add a `files` allowlist (assets, sprites, themes; exclude `*.test.*`). +- [ ] Keep source-only exports (no dist build); document required consumer bundler settings. +- **Guardrail:** builds byte-identical; in-repo React still resolves to one copy. + +## Phase 2 — Three foundation seams (everything else leans on these) +**Goal:** decouple the three cross-cutting pieces first so later phases are clean. **Risk:** low. +- [ ] **Storage adapter** — inject a `{getItem,setItem,removeItem}` into the cookie layer (`utils/storage.ts`); default = current cookie impl; **keep literal `plannotator-*` keys**. (Underlies ~24 modules — theme, layout prefs, identity.) +- [ ] **Image resolver** — the single `getImageSrc` shared by 5 consumers; module-level override, default = today's `/api/image` body verbatim, stable identity. +- [ ] **Scroll/layout context** — ship a `ScrollViewportContext` provider with the package (today its only provider lives in the glue at `App.tsx:3888`). +- **Guardrail:** identical cookie keys; all images emit identical URLs; sticky headers / TOC scroll / pinpoint unchanged. + +## Phase 3 — Rendering stack (the first visible win) +**Goal:** a document renders with the Plannotator look outside the app. **Risk:** low (Viewer is the one "risky" item; gate its validation call). +- [ ] Theme & tokens (`theme.css` + 51 `themes/*.css` + `print.css` as one atomic move). +- [ ] Markdown parsing + block rendering (BlockRenderer, blocks, inline transforms) — mostly transfer-as-is. +- [ ] Document Viewer — gate the unconditional `/api/doc/exists` validation (`Viewer.tsx:532`); default on. +- [ ] Doc-fetch seam for InlineMarkdown hover preview (`/api/doc`). +- [ ] Raw HTML viewer. +- [ ] Markdown editor (41-line shim over the published editor packages). +- **Milestone:** 👉 **Workspaces can start building in parallel here** — render docs while the rest proceeds. + +## Phase 4 — Navigation (sidebar + file tree) +**Goal:** the file-tree experience Workspaces is built around. **Risk:** medium (file-tree live updates). +- [ ] Sidebar shell + tabs (`SidebarContainer`/`SidebarTabs`/`useSidebar`) — already prop-driven, transfer-as-is. +- [ ] File tree: lift `useFileBrowser`'s fetch URLs **and the entire SSE watcher effect verbatim** into a default object; `useFileBrowser()` stays callable with zero args. +- **Guardrail:** existing `useFileBrowser.test.tsx` stays green **without modification** (if it needs rewriting, the default changed). + +## Phase 5 — Comments / annotations / drafts (the big one) +**Goal:** the core collaborative piece (teammates + agents commenting). **Risk:** medium — move the timing-sensitive parts verbatim. Last among core work because it touches the most. +- [ ] Draft transport seam (5 `/api/draft` fetches) — **document the 3-party draft-generation protocol** (escapes into approve/deny bodies; server tombstone-gates). +- [ ] External-annotations transport — move the **entire** SSE + polling-fallback effect verbatim into a default `subscribe()`. +- [ ] Identity seam — `author?`/`isCurrentUser?` props defaulting to the live `identity.ts` functions at the call site. +- **Guardrail:** approve/deny still carry the draft generation; live updates + fallback identical; `(me)` badge + author stamping intact. Note: highlight restoration is renderer-coupled — Workspaces must reuse BlockRenderer+InlineMarkdown as a unit. + +## Phase 6 — Optional extras (only when Workspaces needs them) +**Risk:** low–medium. Do not build preemptively. +- [ ] Versions / plan diff (inject fetchers; optional `onOpenVscodeDiff`; resolve the diff CSS that lives in the app shell). +- [ ] Settings / config (configStore write-back seam; obsidian-detect seam; storage adapter from Phase 2). +- [ ] Sharing / export / notes (`onSaveToNotes` seam; keep notes-tab gate verbatim). +- [ ] Ask AI (extract only the 5 fetch literals behind a `transport`; **do not touch** the SSE reader loop or epoch guards; capabilities/provider-resolution stay in the shell). + +## Phase 7 — Glue cleanup + publish +**Risk:** low. +- [ ] Move `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts` (pure move + one import-path edit). +- [ ] **Leave the coordinators in the glue** — right-panel/wide-mode/agent-terminal teardown, auto-open/close sidebar policy, tab-visibility + archive lazy-fetch, AI capabilities/provider init, panel-resize CSS-var writes. Workspaces writes its **own** thin coordinator over the same prop-driven primitives. (Re-deriving these generically is the forbidden path.) +- [ ] Publish `@plannotator/ui`; Workspaces installs and builds its own app/glue against it. + +--- + +## Hard guardrails (never violate) +1. **Default === today's literal.** Every seam ships with the current behavior as the default; Plannotator passes nothing and is unchanged. +2. **Move verbatim, never re-derive.** Especially the SSE transports, draft-generation protocol, configStore batching, and AI reader loop — copy them; do not "simplify." +3. **Never change the storage default** — inject per host; keep `plannotator-*` keys. +4. **Keep glue coordinators opaque** — they entangle side effects; genericizing them is how the last attempt broke. +5. **Run the parity checklist after every step.** Green automated checks are not enough — eyeball the app. +6. **Never delete a working path until parity is confirmed by a human**, mode by mode. diff --git a/adr/implementation/document-ui-parity-checklist-20260622.md b/adr/implementation/document-ui-parity-checklist-20260622.md new file mode 100644 index 000000000..013ffe3d9 --- /dev/null +++ b/adr/implementation/document-ui-parity-checklist-20260622.md @@ -0,0 +1,92 @@ +# Document UI — Parity Checklist ("did it break?") + +Date: 2026-06-22 · Baseline commit: `30cfcebb` + +> The safety net for the document-ui extraction (see ADR 004 + the verified plan `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`). **THE LAW: Plannotator's experience cannot change.** Run this checklist after *every* extraction step. If anything below differs from baseline, the step changed behavior — stop and fix before continuing. Passing automated checks are necessary but NOT sufficient; the manual click-through is the part that actually catches regressions (last time, tests were green while the app was broken). + +## How to use this +1. Before starting work, capture the **baseline** (Part A) once. Save the outputs. +2. After each extraction step: re-run Part A and compare, then walk Part B for the surfaces the step could touch (when in doubt, walk all of it). +3. Green automated + identical manual = the step preserved parity. Proceed. + +--- + +## Part A — Automated baseline (fast, run every step) + +Run from repo root. Record PASS/FAIL + the bundle fingerprint. + +```bash +bun run typecheck # must pass +bun test # record pass count; must not drop vs baseline +bun run build:hook # must succeed +bun run build:review # must succeed +bun run build:opencode # must succeed +# fingerprint the shipped artifacts — compare hash before/after a step: +find apps/hook/dist apps/opencode-plugin -name '*.html' -type f -exec shasum {} \; | sort +``` + +- [ ] `typecheck` passes +- [ ] `bun test` pass count ≥ baseline (note the number) +- [ ] all three builds succeed +- [ ] **bundle fingerprint recorded** (for a pure code-move step with defaults intact, the hashes should be byte-identical; if they change, understand exactly why) + +> Baseline capture (do once, fill in): typecheck ____ · test count ____ · build ____ · hashes saved to `scratchpad/parity-baseline.txt` + +--- + +## Part B — Manual click-through (the real test) + +Launch the relevant surface and confirm each item looks and behaves **identically to baseline**. Tip: keep a baseline build/screenshots of each screen to diff against. + +### Plan Review (`ExitPlanMode` flow, or `bun run dev:hook`) +- [ ] Plan renders: headings, code blocks, tables, callouts, alerts, task lists, images, links +- [ ] Theme correct on first paint (no flash/FOUC), theme switch works +- [ ] Select text → annotation toolbar appears → add comment / deletion / global comment +- [ ] Comment shows the right author, `(me)` badge on your own +- [ ] Sidebar: Table of Contents, Version Browser, Archive tabs all open and work +- [ ] Plan diff: `+N/-M` badge → toggle diff → rendered + raw modes → annotate a diff block +- [ ] Approve, and Deny-with-feedback both deliver correctly +- [ ] Export / Share (copy link + short URL) / Import round-trips +- [ ] Settings opens; AI providers, theme, identity all present +- [ ] Keyboard shortcuts: `Mod+Enter` submit, `Mod+P` print, sidebar toggles, wide mode + +### Annotate file (`plannotator annotate `) +- [ ] File renders with full markdown/PFM support +- [ ] Edit the doc → Save → source file on disk updates; saved-change banner correct +- [ ] Draft autosave/restore survives a reload +- [ ] Code-file links open the code popout; code annotations create + submit +- [ ] Send annotations delivers feedback + +### Annotate folder (`plannotator annotate /`) +- [ ] File tree renders with badges + writeback status +- [ ] Expand/collapse folders; open files; **live updates** when a file changes on disk +- [ ] Per-file annotations stay associated; multi-file feedback assembles correctly + +### Annotate last message / raw HTML +- [ ] Annotate-last: recent message(s) show; switching messages restores their annotations; feedback carries the message id/scope +- [ ] Raw HTML: renders, annotate, share produces portable HTML with assets + +### Archive / Goal setup +- [ ] Archive view lists saved decisions with approved/denied badges; read-only render +- [ ] Goal setup surface submits and closes + +### External / editor annotations (if applicable) +- [ ] External annotations posted to the API appear live (and update/delete reflect) +- [ ] VS Code editor annotations appear and are included in feedback (VS Code mode) + +### Cross-cutting visual +- [ ] All themes render (spot-check a few); print mode CSS intact +- [ ] Wide/focus mode hides/restores panels correctly +- [ ] Panel resize + sidebar collapse behave the same +- [ ] Images load everywhere (markdown body, inline, HTML blocks, comment attachments, re-edit previews) + +--- + +## What "fail" looks like (high-risk regressions to watch — from the audit) +- Theme/layout/identity **forgets settings** → the cookie-storage default got swapped instead of injected. +- **Some images load, some don't** → a `getImageSrc` call site was missed. +- **`(me)` badge or comment author missing** → identity default became empty instead of the live function. +- **Open `
` collapse on re-render** → a non-stable callback was threaded into a memoized block. +- **Sticky headers / scroll-to-anchor / TOC scroll broken** → ScrollViewport provider not mounted. +- **Plan-diff blocks render unstyled** → CSS that lives in the app shell wasn't accounted for. +- **Live updates stop / ghost drafts reappear** → an SSE or draft-generation protocol was re-derived instead of moved verbatim. From e132d9606bbd46d555f964cf6dcbdfac9385796a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 19:52:55 -0700 Subject: [PATCH 04/67] =?UTF-8?q?build(ui):=20packaging=20unblock=20for=20?= =?UTF-8?q?external=20install=20(Phase=201)=20=E2=80=94=20no=20runtime=20c?= =?UTF-8?q?hange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0: captured parity baseline (typecheck/test/build + shipped-bundle hashes). Phase 1 packaging fixes to packages/ui, metadata only: - add phantom dompurify ^3.3.3 dep (imported in sanitizeHtml/aiChatFormat, was undeclared) - align diff ^8.0.3 -> ^8.0.4 with root - add peerDependencies (react, react-dom, tailwindcss, tailwindcss-animate); keep as devDeps - add files allowlist (excludes tests); remove dead tsconfig @plannotator/shared alias Verified byte-identical: typecheck pass, 1620 tests pass/0 fail, all 3 builds OK, shipped plan+review bundle hashes unchanged from baseline. Remaining Phase 1 blocker (@plannotator/ai + @plannotator/shared workspace:* deps) deferred pending a publish-vs-inline decision; logged in worklog. --- .../document-ui-phase-0-1-worklog-20260622.md | 61 +++++++++++++++++++ packages/ui/package.json | 37 +++++++++-- packages/ui/tsconfig.json | 1 - 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 adr/implementation/document-ui-phase-0-1-worklog-20260622.md diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md new file mode 100644 index 000000000..f586d159c --- /dev/null +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -0,0 +1,61 @@ +# Document UI Extraction — Phase 0 & 1 Work Log + +Date: 2026-06-22 · Baseline commit: `30cfcebb` + +> Execution record for Phase 0 (safety net) and Phase 1 (packaging unblock) of the roadmap (`adr/implementation/document-ui-extraction-roadmap-20260622.md`). Law: move + decouple, never rewrite; **Plannotator's experience cannot change** — proven below by byte-identical shipped bundles. + +## Phase 0 — Safety net (DONE) + +Captured the known-good baseline at commit `30cfcebb` (saved to `scratchpad/parity-baseline.txt`): + +| Check | Baseline result | +|---|---| +| `bun run typecheck` | PASS (exit 0) | +| `bun test` | **1620 pass / 0 fail**, 1650 ran across 123 files | +| `bun run build:review` / `build:hook` / `build:opencode` | all OK | +| Shipped plan UI hash (`apps/hook/dist/index.html`, `redline.html`, `opencode-plugin/plannotator.html`) | `4ca0cbe9dd85c3674e6122f1e830704076efa129` | +| Shipped review UI hash (`apps/hook/dist/review.html`, `opencode-plugin/review-editor.html`) | `f404d00d9a47785ca925776d48b7a67b2b30b9dd` | + +The reusable click-through checklist lives at `adr/implementation/document-ui-parity-checklist-20260622.md`. The bundle-hash compare is the automated half; the manual click-through is run before any *behavioral* (non-packaging) step. + +## Phase 1 — Packaging unblock (DONE except one decision) + +All changes are package metadata only — no source/runtime change. Files touched: `packages/ui/package.json`, `packages/ui/tsconfig.json`, `bun.lock`. + +### Findings (verified against source before editing) +- **Phantom `dompurify` dependency (real latent bug).** Imported in `packages/ui/utils/sanitizeHtml.ts:1` and `utils/aiChatFormat.ts:3` but absent from `packages/ui/package.json`. Worked in-repo only via root hoisting; would break a standalone install. Root pins `dompurify ^3.3.3`. +- **`diff` version drift.** ui had `^8.0.3`; root has `^8.0.4`. +- **Stale tsconfig alias (dead).** `tsconfig.json:21` mapped bare `@plannotator/shared` → `../shared/index.ts`, which does not exist. Verified **no file in ui imports the bare specifier** — only `@plannotator/shared/*` subpaths (handled by the other, correct alias on the next line). The dead line was inert (typecheck passed with it) but removed for correctness. +- **No `peerDependencies`.** react/react-dom/tailwindcss were plain `dependencies`, risking duplicate-React when consumed by an external app. +- **No `files` allowlist.** A publish would have shipped test files and could have missed assets/themes. + +### Changes made +1. **Added `dompurify ^3.3.3`** to `dependencies` (matches root exactly — a version mismatch could change sanitization output). +2. **Aligned `diff` `^8.0.3` → `^8.0.4`** (matches root). +3. **Added a `peerDependencies` block** — `react`, `react-dom`, `tailwindcss`, `tailwindcss-animate` — and removed them from `dependencies`. Also added the same four to `devDependencies` so in-repo typecheck/build still resolve them. (Scope decision: only the singleton/build-time packages were made peers. Radix, lucide, cva, clsx, tailwind-merge, etc. stay as regular `dependencies` — they are owned by the library and have no duplicate-instance hazard. This is the conventional, lower-risk choice and diverges deliberately from the audit's broader "radix→peer" suggestion.) +4. **Added a `files` allowlist** (source dirs + assets/themes/sprites + `theme.css`/`print.css`/`types.ts`/`globals.d.ts`), excluding `**/*.test.*` and `test-setup`. Preparatory — only affects a future publish. +5. **Removed the dead `tsconfig.json` alias line.** +6. **Kept the source-only `exports` model — no dist build added** (a build could change what Plannotator ships). Consumer bundler requirements to document for Workspaces: `isolatedModules`, the automatic JSX runtime, `allowImportingTsExtensions`, and Tailwind v4 (`@theme inline` is v4-only). + +### Verification (post-change, vs Phase 0 baseline) +| Check | Result | Matches baseline? | +|---|---|---| +| `bun install` | clean, "no changes" to install tree (confirms dep moves didn't perturb resolution) | — | +| `bun run typecheck` | PASS (exit 0) | ✅ | +| `bun test` | **1620 pass / 0 fail** | ✅ identical | +| 3 builds | all OK | ✅ | +| plan UI bundle hash | `4ca0cbe9dd85c3674e6122f1e830704076efa129` | ✅ **byte-identical** | +| review UI bundle hash | `f404d00d9a47785ca925776d48b7a67b2b30b9dd` | ✅ **byte-identical** | + +**Conclusion: Plannotator's shipped app is byte-for-byte unchanged.** The packaging box is now cleaner and closer to installable, with zero impact on the open-source experience. + +## Remaining Phase 1 item — ONE decision required (not done) + +**`@plannotator/ai` and `@plannotator/shared` are `workspace:* / private / 0.0.1`.** This is the one genuine blocker to an external `@plannotator/ui` install (an outside repo cannot resolve `workspace:*` private packages). It was **deliberately not actioned** because it is a strategic fork, and one path (publishing) is outward-facing and needs explicit authorization. Two options: + +- **Option A — Publish `@plannotator/ai` + `@plannotator/shared`** (drop `private`, real versions, push to the registry). Cleanest dependency graph; lets Workspaces also reuse shared logic directly. Cost: two more published packages to maintain/version; needs registry auth. **Outward-facing — requires explicit go-ahead before any publish.** +- **Option B — Inline the browser-safe subpaths ui actually value-imports** into `@plannotator/ui` (verified Web-API-only: compress, crypto, agents, code-file, feedback-templates, project, favicon, agent-jobs, browser-paths, extract-code-paths, goal-setup). Keeps `@plannotator/ui` self-contained, no extra published packages. Cost: code duplication vs `@plannotator/shared`, and it is a real code change (must re-run the full parity verification). + +When this is decided, also revisit the `tsconfig.json` `@plannotator/shared/*` alias (currently correct for in-repo; changes if shared is published/inlined). + +> Note: the `@plannotator/ai` import is `import type` only (erased at compile). Most `@plannotator/shared` imports are also type-only or Web-API-only; verified no `node:*` value imports reach a bundle. So this blocker is about *package resolution for external install*, not about node code leaking into the browser. diff --git a/packages/ui/package.json b/packages/ui/package.json index 9e907296d..84842cb1e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,6 +19,28 @@ "./types": "./types.ts", "./theme": "./theme.css" }, + "files": [ + "components", + "config", + "hooks", + "icons", + "lib", + "shortcuts", + "utils", + "assets", + "themes", + "sprite_package_additional", + "sprite_package_new", + "sprite_package_pulluphang", + "globals.d.ts", + "types.ts", + "theme.css", + "print.css", + "plannotator.webp", + "!**/*.test.ts", + "!**/*.test.tsx", + "!test-setup" + ], "dependencies": { "@plannotator/atomic-editor": "^0.5.0", "@codemirror/autocomplete": "^6.20.3", @@ -52,7 +74,8 @@ "@viz-js/viz": "^3.25.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "diff": "^8.0.3", + "diff": "^8.0.4", + "dompurify": "^3.3.3", "highlight.js": "^11.11.1", "katex": "^0.16.47", "lucide-react": "^1.14.0", @@ -60,18 +83,24 @@ "mermaid": "^11.12.2", "motion": "^12.38.0", "perfect-freehand": "^1.2.2", + "tailwind-merge": "^3.6.0", + "unique-username-generator": "^1.5.1" + }, + "peerDependencies": { "react": "^19.2.3", "react-dom": "^19.2.3", - "tailwind-merge": "^3.6.0", "tailwindcss": "^4.1.18", - "tailwindcss-animate": "^1.0.7", - "unique-username-generator": "^1.5.1" + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@happy-dom/global-registrator": "^20.10.1", "@types/bun": "^1.2.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", "typescript": "~5.8.2" }, "scripts": { diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 683f100a6..2076dec37 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -18,7 +18,6 @@ "esModuleInterop": true, "types": ["bun"], "paths": { - "@plannotator/shared": ["../shared/index.ts"], "@plannotator/shared/*": ["../shared/*"] } }, From e021c44184a298a3c82153bbf03fa8f20c9bb947 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 20:09:16 -0700 Subject: [PATCH 05/67] feat(ui): make image URL resolution host-overridable (Phase 2, seam 1) getImageSrc now delegates to a module-level resolver defaulting to the verbatim Plannotator /api/image logic; add setImageSrcResolver/resetImageSrcResolver so a host (Workspaces) can resolve images via its own backend. All 5 consumers and the signature unchanged. Verified: default URLs byte-identical, typecheck pass, 1620 tests pass/0 fail, builds OK. No Plannotator behavior change. --- .../document-ui-phase-0-1-worklog-20260622.md | 10 +++++++ packages/ui/components/ImageThumbnail.tsx | 29 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index f586d159c..0a2096f4a 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -59,3 +59,13 @@ All changes are package metadata only — no source/runtime change. Files touche When this is decided, also revisit the `tsconfig.json` `@plannotator/shared/*` alias (currently correct for in-repo; changes if shared is published/inlined). > Note: the `@plannotator/ai` import is `import type` only (erased at compile). Most `@plannotator/shared` imports are also type-only or Web-API-only; verified no `node:*` value imports reach a bundle. So this blocker is about *package resolution for external install*, not about node code leaking into the browser. + +## Phase 2 — Foundation seams (in progress) + +Three cross-cutting seams that later phases depend on. Each: lift the backend wire to an optional override, default = today's behavior. For these *code* changes the bundle hash legitimately changes; parity is proven by behavior tests (+ eyeball where there's something visual to see). + +### Seam 1 — Image resolver (DONE) +- **File:** `packages/ui/components/ImageThumbnail.tsx` (the single `getImageSrc`, shared by 5 consumers: ImageThumbnail, InlineMarkdown, HtmlBlock, AttachmentsButton, Viewer). +- **Change:** extracted the body into `defaultImageSrcResolver` and a module-level `imageSrcResolver` (stable identity); added `setImageSrcResolver(fn)` for a host to override once at startup, and `resetImageSrcResolver()` for tests. `getImageSrc(path, base?)` signature unchanged; it now delegates to the active resolver, default = the verbatim old `/api/image` logic. +- **Why no Viewer-level prop:** a prop can't reach InlineMarkdown/HtmlBlock; the module-level override is the only thing all 5 consumers share. +- **Verified:** default output byte-identical across remote-passthrough, base-append, and absolute-path cases (URL probe); override + reset work; typecheck pass; 1620 tests pass / 0 fail; all 3 builds OK. Dev-mode eyeball N/A — the mock serves no images and this change only affects the URL string (proven identical), so there is nothing visual to regress. diff --git a/packages/ui/components/ImageThumbnail.tsx b/packages/ui/components/ImageThumbnail.tsx index 605c714cb..1b19ec716 100644 --- a/packages/ui/components/ImageThumbnail.tsx +++ b/packages/ui/components/ImageThumbnail.tsx @@ -1,9 +1,12 @@ import React, { useState } from 'react'; +export type ImageSrcResolver = (path: string, base?: string) => string; + /** - * Get the display URL for an image path or URL + * Default image URL resolver — Plannotator's local server behavior, verbatim. + * Remote URLs pass through; local paths resolve through `/api/image`. */ -export const getImageSrc = (path: string, base?: string): string => { +const defaultImageSrcResolver: ImageSrcResolver = (path, base) => { if (path.startsWith('http://') || path.startsWith('https://')) { return path; // Remote URL, use directly } @@ -14,6 +17,28 @@ export const getImageSrc = (path: string, base?: string): string => { return url; }; +// Module-level resolver, stable identity. Defaults to Plannotator's behavior so +// callers and consumers are unchanged. A host (e.g. Workspaces) calls +// `setImageSrcResolver` once at startup to resolve images via its own backend. +let imageSrcResolver: ImageSrcResolver = defaultImageSrcResolver; + +/** Override how image paths resolve to URLs. Call once at app startup. */ +export const setImageSrcResolver = (resolver: ImageSrcResolver): void => { + imageSrcResolver = resolver; +}; + +/** Reset to the default (Plannotator local) resolver. Mainly for tests. */ +export const resetImageSrcResolver = (): void => { + imageSrcResolver = defaultImageSrcResolver; +}; + +/** + * Get the display URL for an image path or URL. + * Delegates to the active resolver (default = Plannotator `/api/image`). + */ +export const getImageSrc = (path: string, base?: string): string => + imageSrcResolver(path, base); + interface ImageThumbnailProps { path: string; size?: 'sm' | 'md' | 'lg'; From 2c9563fc563854728777bca80e15047f6ff0bbc6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 20:17:23 -0700 Subject: [PATCH 06/67] feat(ui): make settings storage backend host-overridable (Phase 2, seam 2) storage.ts cookie impl is now the default 'cookieBackend'; add setStorageBackend/ resetStorageBackend so a host (Workspaces) can persist settings via its own storage. getItem/setItem/removeItem delegate to the active backend; the ~24 consumers and literal plannotator-* keys are unchanged. Verified: swap works, typecheck pass, 1620 tests pass/0 fail, builds OK, theme persists across reload. --- .../document-ui-phase-0-1-worklog-20260622.md | 6 ++ packages/ui/utils/storage.ts | 78 ++++++++++++++----- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index 0a2096f4a..32ee9a7a6 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -69,3 +69,9 @@ Three cross-cutting seams that later phases depend on. Each: lift the backend wi - **Change:** extracted the body into `defaultImageSrcResolver` and a module-level `imageSrcResolver` (stable identity); added `setImageSrcResolver(fn)` for a host to override once at startup, and `resetImageSrcResolver()` for tests. `getImageSrc(path, base?)` signature unchanged; it now delegates to the active resolver, default = the verbatim old `/api/image` logic. - **Why no Viewer-level prop:** a prop can't reach InlineMarkdown/HtmlBlock; the module-level override is the only thing all 5 consumers share. - **Verified:** default output byte-identical across remote-passthrough, base-append, and absolute-path cases (URL probe); override + reset work; typecheck pass; 1620 tests pass / 0 fail; all 3 builds OK. Dev-mode eyeball N/A — the mock serves no images and this change only affects the URL string (proven identical), so there is nothing visual to regress. + +### Seam 2 — Storage backend (DONE) +- **File:** `packages/ui/utils/storage.ts` (the cookie `getItem`/`setItem`/`removeItem`, sole persistence for ~24 modules: theme, layout/TOC/width prefs, identity, auto-close, etc.). +- **Change:** moved the cookie implementation into a default `cookieBackend: StorageBackend`; added a module-level `backend` (default = cookies), `setStorageBackend(b)` for a host to swap, and `resetStorageBackend()` for tests. `getItem`/`setItem`/`removeItem` now delegate to the active backend; signatures and the `storage` object unchanged. Literal `plannotator-*` keys preserved. +- **Consumers untouched:** the ~24 modules keep calling `getItem`/`setItem` exactly as before. +- **Verified:** seam routes to an injected backend and `resetStorageBackend` restores cookies (in-memory probe); typecheck pass; 1620 tests pass / 0 fail (suite exercises storage through a real DOM); all 3 builds OK; manual eyeball — theme/settings persist across reload (cookie round-trip intact). diff --git a/packages/ui/utils/storage.ts b/packages/ui/utils/storage.ts index 6c6e60ab1..a83b74cdf 100644 --- a/packages/ui/utils/storage.ts +++ b/packages/ui/utils/storage.ts @@ -9,39 +9,77 @@ const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365; +export interface StorageBackend { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** + * Default backend: cookies. + * Used instead of localStorage so settings persist across the random ports each + * hook invocation uses (cookies are scoped by domain, not port). + */ +const cookieBackend: StorageBackend = { + getItem(key) { + try { + const match = document.cookie.match(new RegExp(`(?:^|; )${escapeRegex(key)}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; + } catch (e) { + return null; + } + }, + setItem(key, value) { + try { + const encoded = encodeURIComponent(value); + document.cookie = `${key}=${encoded}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax`; + } catch (e) { + // Cookie not available + } + }, + removeItem(key) { + try { + document.cookie = `${key}=; path=/; max-age=0`; + } catch (e) { + // Cookie not available + } + }, +}; + +// Active backend. Defaults to cookies so Plannotator is unchanged. A host +// (e.g. Workspaces) calls setStorageBackend once at startup to persist settings +// through its own storage instead. +let backend: StorageBackend = cookieBackend; + +/** Override the storage backend. Call once at app startup. */ +export function setStorageBackend(b: StorageBackend): void { + backend = b; +} + +/** Reset to the default (cookie) backend. Mainly for tests. */ +export function resetStorageBackend(): void { + backend = cookieBackend; +} + /** - * Get a value from cookie storage + * Get a value from storage (default = cookies) */ export function getItem(key: string): string | null { - try { - const match = document.cookie.match(new RegExp(`(?:^|; )${escapeRegex(key)}=([^;]*)`)); - return match ? decodeURIComponent(match[1]) : null; - } catch (e) { - return null; - } + return backend.getItem(key); } /** - * Set a value in cookie storage + * Set a value in storage (default = cookies) */ export function setItem(key: string, value: string): void { - try { - const encoded = encodeURIComponent(value); - document.cookie = `${key}=${encoded}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax`; - } catch (e) { - // Cookie not available - } + backend.setItem(key, value); } /** - * Remove a value from cookie storage + * Remove a value from storage (default = cookies) */ export function removeItem(key: string): void { - try { - document.cookie = `${key}=; path=/; max-age=0`; - } catch (e) { - // Cookie not available - } + backend.removeItem(key); } /** From 782f79c7dfebcf593561c5656216c75d1f4a03ad Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 20:59:09 -0700 Subject: [PATCH 07/67] feat(ui): make MarkdownEditor theme mode host-supplyable (Phase 3) Add optional mode? prop; mode now mode ?? resolvedMode. Plannotator passes no mode (App.tsx:4261) so it keeps using ThemeProvider's resolvedMode unchanged. A host without ThemeProvider can supply mode directly. Verified: typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched. --- packages/ui/components/MarkdownEditor.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ui/components/MarkdownEditor.tsx b/packages/ui/components/MarkdownEditor.tsx index 23442c5ba..8d44e267e 100644 --- a/packages/ui/components/MarkdownEditor.tsx +++ b/packages/ui/components/MarkdownEditor.tsx @@ -24,17 +24,20 @@ interface MarkdownEditorProps { /** Mirrors the Viewer card's outer maxWidth so toggling view<->edit doesn't jump. */ maxWidth?: number | null; gridEnabled?: boolean; + /** Theme color mode. Defaults to the ThemeProvider's resolved mode (Plannotator + passes nothing); a host without ThemeProvider can supply it directly. */ + mode?: React.ComponentProps['mode']; } /* Theme-bridging shim around @plannotator/markdown-editor. App.tsx renders its ThemeProvider inside its own JSX, so the resolved color mode must be read from a component beneath the provider — here — and passed down as a prop. */ -export const MarkdownEditor: React.FC = ({ gridEnabled, ...props }) => { +export const MarkdownEditor: React.FC = ({ gridEnabled, mode, ...props }) => { const { resolvedMode } = useTheme(); return ( ); From c49fb684b48d345313e063f20ce2a75747176cdb Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 20:59:09 -0700 Subject: [PATCH 08/67] feat(ui): allow hosts to opt out of code-path validation (Phase 3) Viewer gains optional disableCodePathValidation? threaded to a new disabled? arg on useValidatedCodePaths; when set, the /api/doc/exists probe is skipped. Default undefined for Plannotator => validation stays on, /api/doc/exists fires exactly as today. Verified: typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched. Also logs Phase 3 workflow outcome + remaining scroll/docfetch pieces. --- .../document-ui-phase-0-1-worklog-20260622.md | 17 +++++++++++++++++ packages/ui/components/Viewer.tsx | 6 +++++- packages/ui/hooks/useValidatedCodePaths.ts | 11 ++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index 32ee9a7a6..cb264e392 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -75,3 +75,20 @@ Three cross-cutting seams that later phases depend on. Each: lift the backend wi - **Change:** moved the cookie implementation into a default `cookieBackend: StorageBackend`; added a module-level `backend` (default = cookies), `setStorageBackend(b)` for a host to swap, and `resetStorageBackend()` for tests. `getItem`/`setItem`/`removeItem` now delegate to the active backend; signatures and the `storage` object unchanged. Literal `plannotator-*` keys preserved. - **Consumers untouched:** the ~24 modules keep calling `getItem`/`setItem` exactly as before. - **Verified:** seam routes to an injected backend and `resetStorageBackend` restores cookies (in-memory probe); typecheck pass; 1620 tests pass / 0 fail (suite exercises storage through a real DOM); all 3 builds OK; manual eyeball — theme/settings persist across reload (cookie round-trip intact). + +## Phase 3 — Rendering stack (in progress) + +Teed up + adversarially reviewed by the `phase3-rendering-stack` workflow (36→22 agents; tee-up → execute-in-isolated-worktree → parity review → synthesis). Workflow verdicts: **3 noop** (theme, markdown, html-viewer — already decoupled by Phase 2 / already prop-driven, nothing to land), **3 safe** (editor, viewer, scroll), **1 "blocked"** (docfetch — false alarm: the execute worktrees were auto-removed, so the reviewer saw the clean real tree; the spec is sound, just needs real application). Note: the workflow's in-worktree `typecheck`/`tests` were unreliable (missing deps in throwaway worktrees) — landings are verified authoritatively on the real tree against the Phase 0 baseline. All landings done by hand on the real tree with the parity suite. + +### Seam — Markdown editor theme mode (DONE) +- **File:** `packages/ui/components/MarkdownEditor.tsx`. Added optional `mode?` prop; `mode={resolvedMode}` → `mode={mode ?? resolvedMode}`; destructured `mode` out of `...props`. +- **Parity:** Plannotator's only `` call (App.tsx:4261) passes no `mode` → falls to `resolvedMode` → identical. Verified: typecheck pass, 1620 tests / 0 fail, builds OK, App.tsx untouched, no `mode=` caller. + +### Seam — Viewer code-path validation gate (DONE) +- **Files:** `packages/ui/components/Viewer.tsx` + `packages/ui/hooks/useValidatedCodePaths.ts`. Added optional `disableCodePathValidation?` prop threaded to a new `disabled?` arg on the hook; when set, the `/api/doc/exists` probe is skipped (`ready: true`, empty map). Default undefined for Plannotator → validation stays on. Added `disabled` to the effect deps (always undefined for Plannotator → no behavior change). +- **Parity:** no `disableCodePathValidation` caller in editor/apps → Viewer still fires `/api/doc/exists` exactly as today. Verified: typecheck pass, 1620 tests / 0 fail, builds OK, App.tsx untouched. + +### Remaining Phase 3 +- **scroll** (safe) — extract a render-transparent `ScrollViewportProvider` into `packages/ui/hooks/useScrollViewport.ts`; rewire App.tsx's `ScrollViewportContext.Provider` (3888/4427) to use it; keep App.tsx's own `useActiveSection` consumption and the sidebar-TOC-reads-MAIN-viewport invariant. Touches App.tsx → land isolated + manual eyeball (scroll a plan, confirm TOC active-section tracks). +- **docfetch** (apply for real) — `InlineMarkdown.tsx` hover-preview `fetch('/api/doc')` → injectable `docPreviewFetcher` defaulting to today's literal (matching the getImageSrc/setStorageBackend pattern); keep the `useCallback` deps unchanged. Manual eyeball (hover a code-file link, preview popover appears). +- **noops:** theme, markdown, html-viewer — nothing to land (verified already reusable). diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 761fb713f..a76156edc 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -75,6 +75,9 @@ interface ViewerProps { * so out-of-tree relative references (e.g. `../foo.ts` in a linked doc) * resolve against the doc's own directory rather than only cwd. */ codePathBaseDir?: string; + /** Opt out of `/api/doc/exists` code-path validation (host without that + * endpoint). Default undefined for Plannotator => validation stays on. */ + disableCodePathValidation?: boolean; linkedDocInfo?: LinkedDocBadgeInfo | null; // Plan diff props planDiffStats?: { additions: number; deletions: number; modifications: number } | null; @@ -178,6 +181,7 @@ export const Viewer = forwardRef(({ linkedDocInfo, imageBaseDir, codePathBaseDir, + disableCodePathValidation, copyLabel, actionsLabelMode = 'full', archiveInfo, @@ -551,7 +555,7 @@ export const Viewer = forwardRef(({ setViewerCommentPopover(null); }, []); - const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir); + const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir, disableCodePathValidation); return ( diff --git a/packages/ui/hooks/useValidatedCodePaths.ts b/packages/ui/hooks/useValidatedCodePaths.ts index 5b6c31be6..198deace3 100644 --- a/packages/ui/hooks/useValidatedCodePaths.ts +++ b/packages/ui/hooks/useValidatedCodePaths.ts @@ -29,6 +29,7 @@ export type ValidatedMap = Map; export function useValidatedCodePaths( markdown: string, baseDir?: string, + disabled?: boolean, ): { validated: ValidatedMap; ready: boolean } { const [validated, setValidated] = useState(new Map()); const [ready, setReady] = useState(false); @@ -37,6 +38,14 @@ export function useValidatedCodePaths( setValidated(new Map()); setReady(false); + // Host opt-out (e.g. a backend with no /api/doc/exists). Default undefined + // for Plannotator => unchanged. When disabled, skip validation: code links + // render optimistically (no server probe), same as an empty candidate set. + if (disabled) { + setReady(true); + return; + } + const candidates = extractCandidateCodePaths(markdown); if (candidates.length === 0) { setReady(true); @@ -76,7 +85,7 @@ export function useValidatedCodePaths( return () => { cancelled = true; }; - }, [markdown, baseDir]); + }, [markdown, baseDir, disabled]); // Stable reference: only changes when validated/ready actually change. // Without memoization, the parent provider's value is a fresh object every From 7c2bec1cd09b41fa01b48a617989e8bb64c8f8d8 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 21:20:42 -0700 Subject: [PATCH 09/67] feat(ui): make code-file hover preview fetch host-overridable (Phase 3) Add DocPreviewFetcher seam (default = verbatim /api/doc fetch) + setDocPreviewFetcher/resetDocPreviewFetcher; route handleMouseEnter through it, useCallback deps unchanged. No caller overrides it => Plannotator fetches /api/doc identically. typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/components/InlineMarkdown.tsx | 38 ++++++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 5c636cdb6..24f49be86 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -9,6 +9,37 @@ import type { ValidationEntry } from "../hooks/useValidatedCodePaths"; import { CodeFilePicker } from "./CodeFilePicker"; import { normalizeMathTex, renderMathToHtml } from "./blocks/MathBlock"; +export interface DocPreviewResult { + contents?: string; + filepath?: string; +} +export type DocPreviewFetcher = (path: string, base?: string) => Promise; + +/** + * Default code-file hover-preview fetcher — Plannotator's `/api/doc` behavior, verbatim. + */ +const defaultDocPreviewFetcher: DocPreviewFetcher = async (path, base) => { + const params = new URLSearchParams({ path }); + if (base) params.set('base', base); + const res = await fetch(`/api/doc?${params}`); + return await res.json(); +}; + +// Module-level fetcher, stable identity. Defaults to Plannotator's `/api/doc`. +// A host (e.g. Workspaces) calls setDocPreviewFetcher once at startup to load +// hover previews from its own backend. +let docPreviewFetcher: DocPreviewFetcher = defaultDocPreviewFetcher; + +/** Override how code-file hover previews are fetched. Call once at app startup. */ +export const setDocPreviewFetcher = (fetcher: DocPreviewFetcher): void => { + docPreviewFetcher = fetcher; +}; + +/** Reset to the default (Plannotator `/api/doc`) fetcher. Mainly for tests. */ +export const resetDocPreviewFetcher = (): void => { + docPreviewFetcher = defaultDocPreviewFetcher; +}; + /** * Decide how a candidate code-file path should render based on validation state: * - 'link' → clickable, opens directly via onOpenCodeFile(resolvedOrInput) @@ -150,11 +181,8 @@ const CodeFileLink: React.FC<{ if (hoverPreviewRef.current) return; showTimerRef.current = setTimeout(async () => { try { - const params = new URLSearchParams({ path: candidate }); - if (baseDir) params.set('base', baseDir); - const res = await fetch(`/api/doc?${params}`); - const data = await res.json(); - if (data.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); + const data = await docPreviewFetcher(candidate, baseDir); + if (data?.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); } catch {} }, 150); }, [candidate, hasLineRef, gate.render, cancelHide, baseDir]); From 82e78cf4b5b90018b370d5cb292ce3d64bc70793 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 21:20:42 -0700 Subject: [PATCH 10/67] feat(ui): ship ScrollViewportProvider with the library (Phase 3 scroll) Add render-transparent ScrollViewportProvider (createElement, keeps .ts) so the scroll-viewport context travels with @plannotator/ui instead of living only in App.tsx. Rewire App.tsx provider tags (3-line delta); identical tree/value/ position, sidebar TOC still reads the MAIN viewport. Fix stale OverlayScrollbars doc-comment. typecheck pass, 1620 tests/0 fail, builds OK, eyeball: TOC tracks. --- packages/editor/App.tsx | 6 ++--- packages/ui/hooks/useScrollViewport.ts | 34 ++++++++++++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 5a91114d6..7a205f88a 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -48,7 +48,7 @@ import { usePrintMode } from '@plannotator/ui/hooks/usePrintMode'; import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; -import { ScrollViewportContext } from '@plannotator/ui/hooks/useScrollViewport'; +import { ScrollViewportProvider } from '@plannotator/ui/hooks/useScrollViewport'; import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport'; import { useIsMobile } from '@plannotator/ui/hooks/useIsMobile'; import { @@ -3935,7 +3935,7 @@ const App: React.FC = () => { )} {/* Main Content */} - +
{/* Tater sprites — inside content wrapper so z-0 stacking context applies */} {taterMode && } @@ -4478,7 +4478,7 @@ const App: React.FC = () => { )}
-
+ {/* Code File Popout */} {codeFilePopout.popoutProps && ( diff --git a/packages/ui/hooks/useScrollViewport.ts b/packages/ui/hooks/useScrollViewport.ts index 8390a2146..a4f178976 100644 --- a/packages/ui/hooks/useScrollViewport.ts +++ b/packages/ui/hooks/useScrollViewport.ts @@ -1,16 +1,16 @@ -import { createContext, useContext } from 'react'; +import { createContext, useContext, createElement, type ReactNode } from 'react'; /** * Provides the currently-active scroll viewport element to descendants. * - * When the app is wrapped in , the element that actually - * scrolls is the library's internal viewport div — not
. Any code that - * needs the scroll container (IntersectionObserver roots, scroll event - * listeners, scrollTo / getBoundingClientRect offsets) must consume this - * context instead of `document.querySelector('main')`. + * The element that actually scrolls is the host element rendered by + * (native scroll) — not
. Any code that needs the + * scroll container (IntersectionObserver roots, scroll event listeners, + * scrollTo / getBoundingClientRect offsets) must consume this context instead + * of `document.querySelector('main')`. * - * The value is `null` until the OverlayScrollbars instance has mounted and - * initialized. Consumers should handle that transient state. + * The value is `null` until the scroll element has mounted. Consumers should + * handle that transient state. */ export const ScrollViewportContext = createContext(null); @@ -18,3 +18,21 @@ export const ScrollViewportContext = createContext(null); export function useScrollViewport(): HTMLElement | null { return useContext(ScrollViewportContext); } + +/** + * Render-transparent provider for the active scroll viewport element. + * + * The host mounts this around its layout and feeds it the MAIN content's scroll + * element, so descendants — including a sidebar Table-of-Contents rendered + * inside it — resolve to the main viewport (not the sidebar's own scroll area). + * Ships with the package so consumers work without app-shell wiring. + */ +export function ScrollViewportProvider({ + viewport, + children, +}: { + viewport: HTMLElement | null; + children: ReactNode; +}) { + return createElement(ScrollViewportContext.Provider, { value: viewport }, children); +} From c9964e2769bf46a0c8a6ea066a4a59bc9fbf5d20 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 22 Jun 2026 21:20:42 -0700 Subject: [PATCH 11/67] fix(ui): disabled code-path validation should keep links clickable (self-review) The Phase-3 disabled branch set ready=true with an empty map, which makes gateCodePath demote every code link to plain text. Leave ready=false so the no-validation fallback renders links optimistically. No Plannotator impact (never disables). Logs Phase 3 completion + reusability note. typecheck pass, 1620 tests/0 fail, builds OK. --- .../document-ui-phase-0-1-worklog-20260622.md | 24 +++++++++++++++---- packages/ui/hooks/useValidatedCodePaths.ts | 6 ++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index cb264e392..0e4fb1ad2 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -88,7 +88,23 @@ Teed up + adversarially reviewed by the `phase3-rendering-stack` workflow (36→ - **Files:** `packages/ui/components/Viewer.tsx` + `packages/ui/hooks/useValidatedCodePaths.ts`. Added optional `disableCodePathValidation?` prop threaded to a new `disabled?` arg on the hook; when set, the `/api/doc/exists` probe is skipped (`ready: true`, empty map). Default undefined for Plannotator → validation stays on. Added `disabled` to the effect deps (always undefined for Plannotator → no behavior change). - **Parity:** no `disableCodePathValidation` caller in editor/apps → Viewer still fires `/api/doc/exists` exactly as today. Verified: typecheck pass, 1620 tests / 0 fail, builds OK, App.tsx untouched. -### Remaining Phase 3 -- **scroll** (safe) — extract a render-transparent `ScrollViewportProvider` into `packages/ui/hooks/useScrollViewport.ts`; rewire App.tsx's `ScrollViewportContext.Provider` (3888/4427) to use it; keep App.tsx's own `useActiveSection` consumption and the sidebar-TOC-reads-MAIN-viewport invariant. Touches App.tsx → land isolated + manual eyeball (scroll a plan, confirm TOC active-section tracks). -- **docfetch** (apply for real) — `InlineMarkdown.tsx` hover-preview `fetch('/api/doc')` → injectable `docPreviewFetcher` defaulting to today's literal (matching the getImageSrc/setStorageBackend pattern); keep the `useCallback` deps unchanged. Manual eyeball (hover a code-file link, preview popover appears). -- **noops:** theme, markdown, html-viewer — nothing to land (verified already reusable). +### Seam — Doc-fetch (code-file hover preview) (DONE) +- **File:** `packages/ui/components/InlineMarkdown.tsx`. Added `DocPreviewResult`/`DocPreviewFetcher` + module-level `docPreviewFetcher` (default = verbatim `/api/doc` fetch) + `setDocPreviewFetcher`/`resetDocPreviewFetcher`; routed `handleMouseEnter` through it. `useCallback` deps unchanged. +- **Parity:** no `setDocPreviewFetcher` caller → Plannotator still fetches `/api/doc?path=&base=` identically. Verified: typecheck pass, 1620 tests / 0 fail, builds OK. (Hover popover not visible in dev mock — same caveat as images; call is provably identical.) + +### Seam — Scroll viewport provider (DONE) +- **Files:** `packages/ui/hooks/useScrollViewport.ts` (added render-transparent `ScrollViewportProvider` via `createElement` — kept `.ts`, no JSX; fixed the stale OverlayScrollbars doc-comment) + `packages/editor/App.tsx` (import + the two provider tags at 3888/4427: `ScrollViewportContext.Provider value=` → `ScrollViewportProvider viewport=`). +- **Parity:** `ScrollViewportProvider` renders exactly `ScrollViewportContext.Provider value={viewport}` — identical tree/value/position; App.tsx delta is 3 lines. Sidebar TOC still resolves to the MAIN viewport. Verified: typecheck pass, 1620 tests / 0 fail, builds OK; **manual eyeball — TOC active-section tracks main-content scroll, click-to-scroll works.** + +### Self-review fix — viewer `disabled` path (DONE) +- **Found:** the Phase-3 viewer seam's `disabled` branch set `ready=true` with an empty map, which makes `gateCodePath` demote every code link to **plain text** (since ready+no-entry => 'plain'). Wrong for the seam's purpose (a host disabling validation wants links to stay clickable). Did NOT affect Plannotator (never disables) but the seam was incorrect. +- **Fix:** `useValidatedCodePaths.ts` disabled branch now just `return;` (leaves `ready=false`), so `gateCodePath`'s no-validation fallback renders code links **optimistically (clickable)**. Re-verified: typecheck pass, 1620 tests / 0 fail, builds OK. + +### Noops (nothing to land — verified already reusable) +theme, markdown, html-viewer — decoupled by Phase 2 / already prop-driven. + +### Reusability note (intentional, not a defect) +Three seams now share the shape `defaultX` + module-level `x` + `setX`/`resetX` (image resolver, storage backend, doc-preview fetcher). NOT abstracted into a generic helper: they live in different files, have different call ergonomics (a bare function vs. a `{getItem,setItem,removeItem}` object vs. an async fetcher), and the duplication is ~4 trivial lines each. A shared `createOverridable()` would add indirection for little gain and churn three already-verified files. Revisit if a 4th/5th appears. + +## Phase 3 status: COMPLETE +All 7 pieces resolved — 4 landed (editor, viewer, doc-fetch, scroll), 3 noop. Plannotator byte-unchanged throughout (shipped behavior verified; App.tsx touched only by the 3-line scroll rewire). Scroll provider (the "announcer") now ships in `@plannotator/ui`, closing the Phase-2 deferred seam. diff --git a/packages/ui/hooks/useValidatedCodePaths.ts b/packages/ui/hooks/useValidatedCodePaths.ts index 198deace3..00b6462a2 100644 --- a/packages/ui/hooks/useValidatedCodePaths.ts +++ b/packages/ui/hooks/useValidatedCodePaths.ts @@ -39,10 +39,10 @@ export function useValidatedCodePaths( setReady(false); // Host opt-out (e.g. a backend with no /api/doc/exists). Default undefined - // for Plannotator => unchanged. When disabled, skip validation: code links - // render optimistically (no server probe), same as an empty candidate set. + // for Plannotator => unchanged. When disabled we never probe and leave + // ready=false, so gateCodePath's no-validation fallback renders code links + // optimistically (clickable) instead of demoting them to plain text. if (disabled) { - setReady(true); return; } From a191faed0a51ec6e7d939f4d05e4d260b9ce336a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 08:35:51 -0700 Subject: [PATCH 12/67] feat(ui): make file-tree backend host-overridable (Phase 4) Lift useFileBrowser's three backend wires (load-dir fetch, obsidian-vault fetch, and the SSE live-watch effect moved VERBATIM) into an injectable FileTreeBackend with default + setFileTreeBackend/resetFileTreeBackend, same pattern as the image /storage seams. useFileBrowser() stays zero-arg; default fetch/SSE URLs identical. Sidebar confirmed noop (zero backend wires, already reused by review-editor). Verified: useFileBrowser.test.tsx passes 6/0 UNMODIFIED (DOM_TESTS=1), typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched, manual eyeball (annotate adr/: tree loads, file-switch works, new file appears live via SSE). Plannotator byte-unchanged. Logs two pre-existing bugs found during testing (not regressions). --- .../document-ui-phase-0-1-worklog-20260622.md | 18 ++ packages/ui/hooks/useFileBrowser.ts | 158 +++++++++++------- 2 files changed, 119 insertions(+), 57 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index 0e4fb1ad2..b2e9b3c46 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -108,3 +108,21 @@ Three seams now share the shape `defaultX` + module-level `x` + `setX`/`resetX` ## Phase 3 status: COMPLETE All 7 pieces resolved — 4 landed (editor, viewer, doc-fetch, scroll), 3 noop. Plannotator byte-unchanged throughout (shipped behavior verified; App.tsx touched only by the 3-line scroll rewire). Scroll provider (the "announcer") now ships in `@plannotator/ui`, closing the Phase-2 deferred seam. + +## Phase 4 — Navigation (sidebar + file tree) + +Teed up + multi-lens adversarially reviewed by the `phase4-navigation` workflow (tee-up → execute-in-worktree → 4 parity lenses → synthesis), then landed + verified by hand on the real tree. + +### Sidebar (NOOP — nothing to land) +Confirmed transfer-as-is: SidebarContainer/SidebarTabs/CountBadge/FileBrowser/VersionBrowser/ArchiveBrowser/MessagesBrowser and `useSidebar` have **zero** backend wires — all backend interaction arrives as injected callback props, or a pre-built `fileBrowser` prop. Already reused by `packages/review-editor/App.tsx` (`useSidebar`), a second consumer with a different tab union. No edit. + +### Seam — File tree backend (DONE) +- **File:** `packages/ui/hooks/useFileBrowser.ts` only. Lifted the three backend wires into an injectable `FileTreeBackend` (`loadTree`/`loadVaultTree`/`watchTrees`) with a `defaultFileTreeBackend` + `setFileTreeBackend`/`resetFileTreeBackend`, same module-level pattern as the image/storage seams. +- **The SSE live-watch effect moved VERBATIM** into `watchTrees` — EventSource URL, 120ms debounce timers, `readyPaths` dedup, `onmessage` ready/changed dispatch, and `clearTimeout`+`source.close()` cleanup byte-identical. The only substitution is `fetchTreeRef.current(path,{quiet:true})` → injected `onChange(path)` (the hook passes exactly that). The `typeof EventSource === "undefined"` guard relocated into `watchTrees` (returns `undefined` → no cleanup), behavior-identical. `useFileBrowser()` stays zero-arg; default fetch/SSE URLs unchanged. +- **Parity:** no `setFileTreeBackend` caller in editor/apps → Plannotator uses the default. Verified: **`useFileBrowser.test.tsx` passes 6/0 UNMODIFIED** (the strongest guardrail — it asserts the URLs, timer, and SSE behavior via fake `fetch`/`EventSource`; run with `DOM_TESTS=1`); typecheck pass; full `bun test` 1620/0; builds OK; App.tsx untouched. **Manual eyeball** (real `annotate adr/` session): tree loads, file-switching works, new file appears live via SSE without reload. + +### Phase 4 status: COMPLETE — sidebar noop, file-tree seam landed. Plannotator byte-unchanged. + +### Discovered (PRE-EXISTING, out of scope — not caused by this work) +1. **Edit/save header state leaks across file switches** in annotate-folder mode: editing+saving file A leaves the Saved/Done/wide-focus header showing when you switch to file B without editing it. Reproduced on the **baseline with the Phase 4 change reverted** (A/B confirmed) → pre-existing App.tsx bug, not a regression. Lives in the folder file-switch handler (`handleFileBrowserSelect` / edit-session reset), unrelated to `useFileBrowser`. Worth a separate fix. +2. **Annotating the repo root (`annotate ./`) bogs down** — the file walker + chokidar SSE watcher choke on 1.4GB of node_modules (16 dirs); the code already warns about this. Pre-existing scaling limit; use a bounded folder. Not a code defect introduced here. diff --git a/packages/ui/hooks/useFileBrowser.ts b/packages/ui/hooks/useFileBrowser.ts index 563b38ad4..5ce29c7ee 100644 --- a/packages/ui/hooks/useFileBrowser.ts +++ b/packages/ui/hooks/useFileBrowser.ts @@ -82,6 +82,101 @@ function remapWorkspaceStatusForDir( }; } +/** + * File-tree backend. Defaults to Plannotator's HTTP endpoints (generic files, + * Obsidian vault, and the SSE live-watch stream) so Plannotator is unchanged. A + * host (e.g. Workspaces) calls setFileTreeBackend once at startup to source the + * tree from its own transport instead. + */ +export interface FileTreeBackend { + /** Load a directory tree. Resolves to the same shape the /api/reference/files endpoint returns: a Response whose JSON is { tree, workspaceStatus?, error? }. */ + loadTree(dirPath: string): Promise; + /** Load an Obsidian vault tree. Resolves to a Response whose JSON is { tree, error? }. */ + loadVaultTree(vaultPath: string): Promise; + /** + * Begin live-watching the given directory paths. `onChange(path)` is invoked + * (already debounced/deduped) whenever a watched tree should be re-fetched. + * Returns a cleanup function. Returning undefined means no watcher started. + */ + watchTrees(paths: string[], onChange: (path: string) => void): (() => void) | undefined; +} + +const defaultFileTreeBackend: FileTreeBackend = { + loadTree(dirPath) { + return fetch(`/api/reference/files?dirPath=${encodeURIComponent(dirPath)}`); + }, + loadVaultTree(vaultPath) { + return fetch(`/api/reference/obsidian/files?vaultPath=${encodeURIComponent(vaultPath)}`); + }, + watchTrees(paths, onChange) { + if (typeof EventSource === "undefined") return undefined; + + const timers = new Map>(); + const readyPaths = new Set(); + const params = new URLSearchParams(); + for (const path of paths) params.append("dirPath", path); + const source = new EventSource(`/api/reference/files/stream?${params.toString()}`); + const scheduleFetch = (path: string) => { + const existing = timers.get(path); + if (existing) clearTimeout(existing); + timers.set(path, setTimeout(() => { + timers.delete(path); + onChange(path); + }, 120)); + }; + const scheduleEventFetch = (dirPath: unknown) => { + if (typeof dirPath === "string" && paths.includes(dirPath)) { + scheduleFetch(dirPath); + return; + } + for (const path of paths) scheduleFetch(path); + }; + const hasSeenReady = (dirPath: unknown): boolean => { + if (typeof dirPath === "string" && paths.includes(dirPath)) { + if (readyPaths.has(dirPath)) return true; + readyPaths.add(dirPath); + return false; + } + + const hadAll = paths.every((path) => readyPaths.has(path)); + for (const path of paths) readyPaths.add(path); + return hadAll; + }; + source.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as { type?: string; dirPath?: string }; + if (data.type === "ready") { + if (hasSeenReady(data.dirPath)) scheduleEventFetch(data.dirPath); + return; + } + if (data.type !== "changed") return; + scheduleEventFetch(data.dirPath); + } catch { + return; + } + }; + + return () => { + for (const timer of timers.values()) clearTimeout(timer); + source.close(); + }; + }, +}; + +// Active backend. Defaults to Plannotator's HTTP endpoints so Plannotator is +// unchanged. A host calls setFileTreeBackend once at startup to override. +let fileTreeBackend: FileTreeBackend = defaultFileTreeBackend; + +/** Override the file-tree backend. Call once at app startup. */ +export function setFileTreeBackend(b: FileTreeBackend): void { + fileTreeBackend = b; +} + +/** Reset to the default (HTTP endpoint) backend. Mainly for tests. */ +export function resetFileTreeBackend(): void { + fileTreeBackend = defaultFileTreeBackend; +} + export function useFileBrowser(): UseFileBrowserReturn { const [dirs, setDirs] = useState([]); const [expandedFolders, setExpandedFolders] = useState>(new Set()); @@ -113,9 +208,7 @@ export function useFileBrowser(): UseFileBrowserReturn { }); try { - const res = await fetch( - `/api/reference/files?dirPath=${encodeURIComponent(dirPath)}` - ); + const res = await fileTreeBackend.loadTree(dirPath); const data = await res.json(); if (!res.ok || data.error) { @@ -220,9 +313,7 @@ export function useFileBrowser(): UseFileBrowserReturn { }); try { - const res = await fetch( - `/api/reference/obsidian/files?vaultPath=${encodeURIComponent(vaultPath)}` - ); + const res = await fileTreeBackend.loadVaultTree(vaultPath); const data = await res.json(); if (!res.ok || data.error) { @@ -287,58 +378,11 @@ export function useFileBrowser(): UseFileBrowserReturn { ); useEffect(() => { - if (!watchDirsKey || typeof EventSource === "undefined") return; - + if (!watchDirsKey) return; const paths = watchDirsKey.split("\n").filter(Boolean); - const timers = new Map>(); - const readyPaths = new Set(); - const params = new URLSearchParams(); - for (const path of paths) params.append("dirPath", path); - const source = new EventSource(`/api/reference/files/stream?${params.toString()}`); - const scheduleFetch = (path: string) => { - const existing = timers.get(path); - if (existing) clearTimeout(existing); - timers.set(path, setTimeout(() => { - timers.delete(path); - fetchTreeRef.current(path, { quiet: true }); - }, 120)); - }; - const scheduleEventFetch = (dirPath: unknown) => { - if (typeof dirPath === "string" && paths.includes(dirPath)) { - scheduleFetch(dirPath); - return; - } - for (const path of paths) scheduleFetch(path); - }; - const hasSeenReady = (dirPath: unknown): boolean => { - if (typeof dirPath === "string" && paths.includes(dirPath)) { - if (readyPaths.has(dirPath)) return true; - readyPaths.add(dirPath); - return false; - } - - const hadAll = paths.every((path) => readyPaths.has(path)); - for (const path of paths) readyPaths.add(path); - return hadAll; - }; - source.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as { type?: string; dirPath?: string }; - if (data.type === "ready") { - if (hasSeenReady(data.dirPath)) scheduleEventFetch(data.dirPath); - return; - } - if (data.type !== "changed") return; - scheduleEventFetch(data.dirPath); - } catch { - return; - } - }; - - return () => { - for (const timer of timers.values()) clearTimeout(timer); - source.close(); - }; + return fileTreeBackend.watchTrees(paths, (path) => { + fetchTreeRef.current(path, { quiet: true }); + }); }, [watchDirsKey]); return { From 1decac24b7f5c0ed0ae561e244fcbb070e788457 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 08:50:53 -0700 Subject: [PATCH 13/67] docs(adr): research + synthesis + spec for Phase 5 (comments/annotations/drafts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five-probe code research of the comment system. Key finding: most comment UI is already portable (panel/popover/toolbar/highlighter prop-driven; review-editor already reuses the hooks). Phase 5 narrows to 3 seams — draft transport (+ the 3-party generation protocol), external-annotation transport (SSE->polling, move verbatim), and identity/authorship — plus 2 non-extraction items: renderer coupling (document as a contract) and replies/threading (defer as a new feature). --- ...ment-ui-comments-system-20260623-084806.md | 73 ++++++++++++++++ ...is-document-ui-comments-20260623-084806.md | 52 ++++++++++++ ...cument-ui-comments-seam-20260623-084806.md | 85 +++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 adr/research/SPIKE-document-ui-comments-system-20260623-084806.md create mode 100644 adr/research/synthesis-document-ui-comments-20260623-084806.md create mode 100644 adr/specs/document-ui-comments-seam-20260623-084806.md diff --git a/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md b/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md new file mode 100644 index 000000000..0bedd2039 --- /dev/null +++ b/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md @@ -0,0 +1,73 @@ +# Spike: Comments / Annotations / Drafts System (Phase 5) + +Date: 2026-06-23 + +> Code research for Phase 5 of the `@plannotator/ui` reuse effort (governed by ADR 004; roadmap `adr/implementation/document-ui-extraction-roadmap-20260622.md`). Five parallel probes mapped the comment/annotation/draft system on the real tree. Goal: know every backend wire and every timing-sensitive invariant before speccing the seams. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. + +## Headline + +**Most of the comment UI is already portable.** The comment *components* (`AnnotationPanel`, `CommentPopover`, `AnnotationToolbar`, `AnnotationToolstrip`, `EditorAnnotationCard`) and the highlighter hook (`useAnnotationHighlighter`) are prop-driven with no backend wires. `review-editor` already reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged — a second consumer proving portability. So Phase 5 is **narrower than its "big one" reputation on the UI side**; the work is concentrated in **three seams** (draft transport, external-annotation transport, identity) plus **two structural constraints** (renderer coupling, no reply model). + +## Annotation state lives in the host, not a shared reducer +- Plan: `packages/editor/App.tsx:255` `useState`; Review: `packages/review-editor/App.tsx:121` `useState`. There is **no shared annotation reducer** in `packages/ui` — each host owns its annotation array. So Workspaces will own its annotation state too; the shared package supplies the components/hooks that operate on it. (This is fine and matches review-editor.) + +## Seam 1 — Draft transport (`/api/draft`) + the generation protocol + +**Files:** `packages/ui/hooks/useAnnotationDraft.ts` (plan, full-featured), `packages/ui/hooks/useCodeAnnotationDraft.ts` (review, simpler). Server: `packages/shared/draft.ts`, `packages/server/shared-handlers.ts`, approve/deny in `packages/server/index.ts` + `annotate.ts`. + +- **Wires:** `GET/POST/DELETE /api/draft`. POST body carries `{annotations, codeAnnotations, globalAttachments, editedMarkdown, editedDocuments, savedFileChanges, draftGeneration, ts}`. DELETE uses `?generation=N`. 500ms debounce (`DEBOUNCE_MS`). +- **The 3-party generation protocol (the fragile part):** + 1. Client keeps `draftGenerationRef` (starts 0), **pre-increments before each POST** (`++draftGenerationRef.current`); `getDraftGeneration()` returns the *next* gen (`ref.current + 1`) — `useAnnotationDraft.ts:383`. + 2. That value **escapes the hook** and is threaded into submit by the host: `App.tsx:1960-1963` `withDraftGeneration(path)` appends `?draftGeneration=`; used on `/api/approve` (App.tsx:2704), `/api/exit` (2715); `/api/deny` and `/api/feedback` carry it in the **body** (2626, 2683). **Per-endpoint source differs:** plan approve/deny read from body; annotate approve/exit read from **URL** (`annotate.ts:557,550`), feedback from body (573). + 3. Server **tombstone-gates** (`shared/draft.ts`): `saveDraft` rejects if `draftGeneration <= deletedGeneration` (L98) or `< storedGeneration` (L102); `deleteDraft` writes a tombstone at the deletion generation (L150); ignores stale deletes (L146). This is what prevents a late async draft-save from **resurrecting a draft after submit** (ghost drafts). +- **Timing-sensitive, must move VERBATIM:** the `keepalive: true` POST with **retry-without-keepalive on failure** gated by generation match (L357-364); the `visibilitychange`/`pagehide` **flush** that fires a final keepalive save on tab close (L389-405); the refs (`draftGenerationRef`, `timerRef`, `latestRef` non-reactive getters, `canPersistRef`, `hasMountedRef`). `canPersist = isApiMode && !isSharedSession && !submitted`. +- **Already portable:** the hooks are pure (no host imports); `shared/draft.ts` is runtime-agnostic node:fs. The wires are the only coupling. + +## Seam 2 — External-annotation transport (the live-comment channel) + +**Files:** `packages/ui/hooks/useExternalAnnotations.ts`, `useExternalAnnotationHighlights.ts`. Server: `packages/server/external-annotations.ts` (+ Pi mirror), `packages/shared/external-annotation.ts` (store, validators, event types). + +- **This is the "teammates + agents commenting live" channel.** External tools/agents `POST /api/external-annotations`; the UI shows them live. +- **Transport state machine (move VERBATIM):** primary `EventSource('/api/external-annotations/stream')` delivers `snapshot|add|remove|clear|update` events into an internal reducer with **optimistic mutators** (delete/clear/update update local state, then call the server; SSE reconciles). On SSE error **before first snapshot**, fall back to **polling** `GET /api/external-annotations?since=` every **500ms** (`POLL_INTERVAL_MS`), honoring **304 Not Modified** when `since === store.version`. 30s SSE heartbeat (`:` comment). Version is session-scoped (`versionRef` starts 0). Fallback triggers once (`!receivedSnapshotRef && !fallbackRef`) and doesn't switch back. +- **Already generic + gated:** `useExternalAnnotations` is shape-generic and takes an `enabled` flag. Plan: `enabled: isApiMode && !goalSetupMode` (App.tsx:1135). **Review already reuses it** for `CodeAnnotation` with `enabled: !!origin` (App.tsx:284). `useExternalAnnotationHighlights` paints them via the Viewer handle (filters out global/diff, 100ms mount delay, fingerprint dedup). +- **Merge policy is host-owned:** App.tsx dedups local vs external by `source+type+originalText` (plan) / `source+type+filePath+lineStart+lineEnd+side` (review). +- **Seam = inject the transport** (a `subscribe()` + CRUD + `getSnapshot(since)` object) whose default reproduces the SSE→polling machine exactly. Server store/validators/SSE encoding (`shared/external-annotation.ts`) move wholesale. + +## Seam 3 — Identity / authorship ("which comments are mine") + +**Files:** `packages/ui/utils/identity.ts`, `generateIdentity.ts`, `config/configStore.ts`, `config/settings.ts`. + +- `getIdentity()` reads `configStore.get('displayName')`; resolution **server config > cookie (`plannotator-identity`) > generated `{adj}-{noun}-tater`**. `isCurrentUser(author)` compares `author === configStore.get('displayName')` (`identity.ts:47-50`). +- **Stamp sites (9 hardcoded `getIdentity()`):** `Viewer.tsx:456,518`, `useAnnotationHighlighter.ts:273`, `html-viewer/HtmlViewer.tsx:210`, `html-viewer/useHtmlAnnotation.ts:142,258,296,333`, `plan-diff/PlanCleanDiffView.tsx:169`. **Display sites (2 `isCurrentUser()`):** `AnnotationPanel.tsx:194,204` → renders the `(me)` badge (518, 651). +- **Partly already host-controllable:** identity persists via the **swappable storage backend** (Phase 2 `setStorageBackend`) and can be seeded from server config via `configStore.init(serverConfig)`. So a host can already set the identity *value*. The remaining seam is making the **stamp/display callable** overridable: optional `author?` / `isCurrentUser?` defaulting to the existing functions, so Workspaces (real WorkOS logins) supplies the logged-in user instead of a tater name. +- `Annotation.author` / `CodeAnnotation.author` are optional fields; `sharing.ts` preserves author across share/import (already collaborative). + +## Constraint A — Renderer coupling (structural, not a seam) + +**Files:** `useAnnotationHighlighter.ts` (`findTextInDOM` L106-235, `applyAnnotationsInternal` L293-403), `utils/inlineTransforms.ts` (`transformPlainText` = emoji + smartypants), `BlockRenderer.tsx`, `InlineMarkdown.tsx`, `@plannotator/web-highlighter@0.8.1`. + +- Highlight **restoration** re-anchors a saved annotation by searching the rendered DOM for `originalText`, with a fallback that applies `transformPlainText` (because the renderer turns `:smile:`→😄, `---`→—, straight→curly quotes). So restoration **only works if the host renders markdown to the same text** the transforms produce. +- Code blocks use **manual `` wrapping** (web-highlighter can't sit inside hljs spans); removal re-runs `hljs.highlightElement`. +- **Implication:** Workspaces must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` **as a unit** for highlights to land. This is a documented integration contract, not a wire to cut. (Optional future: expose `transformPlainText` as overridable, but default stays.) + +## Constraint B — No reply / threading model (a gap, not a regression) + +- `Annotation` and `CodeAnnotation` are **flat**: a comment is one `text` field. No `parentCommentId`, `replies`, `threadId`. `CommentPopover` has a module-level draft cache but composes single comments. +- Workspaces wants **replies/threads** (teammates discussing on a doc). That is a **new feature**, not part of "make today's behavior reusable." Adding threading touches the type, the panel, and the popover — and must NOT change Plannotator's flat experience. **Out of scope for Phase 5's parity-preserving extraction**; flag as a Workspaces-side addition (build replies as a host-layer on top of, or a backward-compatible extension of, the shared components later). + +## Already-portable inventory (no Phase-5 work needed) +`AnnotationPanel.tsx`, `AnnotationToolbar.tsx`, `AnnotationToolstrip.tsx`, `CommentPopover.tsx`, `EditorAnnotationCard.tsx`, `AnnotationSidebar.tsx`, `useAnnotationHighlighter.ts`, `useExternalAnnotationHighlights.ts`, `utils/commentContent.ts`, `utils/annotationHelpers.ts`, `utils/anchors.ts`, and the `exportAnnotations`/`exportCodeFileAnnotations`/`exportEditorAnnotations` serializers in `parser.ts` (pure, no API). `AnnotationPanel` only touches identity via the display-only `isCurrentUser` (Seam 3). + +## Out of scope / host-owned (confirmed) +- `useEditorAnnotations` (`/api/editor-annotation(s)`, gated by `window.__PLANNOTATOR_VSCODE`) — VS Code IPC, host-only, not a document-UI seam. +- Feedback/submit routes (`/api/feedback`, `/api/approve`, `/api/deny`, `/api/exit`) and their payload policy — host-owned (Workspaces has its own). +- Annotation state ownership and the external-merge/dedup policy — host-owned. + +## Per-seam evidence map +| Seam | Key files | Backend wires | Move-verbatim invariants | +|---|---|---|---| +| 1 Drafts | useAnnotationDraft.ts, useCodeAnnotationDraft.ts, shared/draft.ts | `GET/POST/DELETE /api/draft`; generation in approve/deny/feedback/exit | generation pre-increment + tombstone gate; keepalive retry; visibility/pagehide flush; the 5 refs | +| 2 External | useExternalAnnotations.ts, useExternalAnnotationHighlights.ts, shared/external-annotation.ts, server/external-annotations.ts | SSE `/stream`; `GET ?since=`; `POST/PATCH/DELETE` | SSE→polling fallback machine; 500ms poll; 304 gate; 30s heartbeat; optimistic mutators; version-scoping | +| 3 Identity | identity.ts, configStore.ts, settings.ts | (none directly; via configStore→storage, swappable) | resolution order server>cookie>tater; 9 stamp sites; 2 `(me)` sites | +| A Renderer | useAnnotationHighlighter.ts, inlineTransforms.ts, BlockRenderer/InlineMarkdown | none | restoration depends on exact rendered text; manual code-block `` | +| B Replies | types.ts, AnnotationPanel, CommentPopover | none | flat model today; threading is a NEW feature, keep Plannotator flat | diff --git a/adr/research/synthesis-document-ui-comments-20260623-084806.md b/adr/research/synthesis-document-ui-comments-20260623-084806.md new file mode 100644 index 000000000..552383a41 --- /dev/null +++ b/adr/research/synthesis-document-ui-comments-20260623-084806.md @@ -0,0 +1,52 @@ +# Synthesis: Comments / Annotations / Drafts (Phase 5) + +Date: 2026-06-23 + +> Synthesizes `SPIKE-document-ui-comments-system-20260623-084806.md` against the verified plan (`adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`) and ADR 004. Settles the shape of Phase 5. + +## The reframing + +Phase 5 has been called "the big one." The research **confirms it's the most interconnected subsystem but narrows the actual work.** Three facts change the picture: + +1. **The comment UI is already portable.** Panel, popover, toolbar, highlighter hook — all prop-driven, no backend wires. Nothing to extract. +2. **A second consumer already proves it.** `review-editor` reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged, with `enabled` gates and shape-generics already in place. The portability pattern exists; we extend it, we don't invent it. +3. **Annotation state is host-owned already.** Each app holds its own `useState` array; there is no shared reducer to wrestle. Workspaces owns its state too. + +So Phase 5 = **three transport/identity seams** + **two things that are NOT extraction work** (a renderer constraint to document, and a replies feature to defer). + +## What we will do: three seams (same pattern as Phases 2–4) + +Each is the proven shape: a module-level default that reproduces today's literal behavior, plus an optional `setX` override; Plannotator passes nothing and is byte-unchanged. + +### Seam 1 — Draft transport +Inject a `DraftTransport` (load/save/delete) into `useAnnotationDraft` and `useCodeAnnotationDraft`, default = today's `/api/draft` fetches **verbatim**, including the `keepalive` retry and the `visibilitychange`/`pagehide` flush. **The generation protocol is the hard part and must be preserved end-to-end:** `getDraftGeneration()` still escapes the hook and the host still threads it into submit (`withDraftGeneration`), and the seam must document that a host swapping transport also has to honor generation-gated delete-on-submit (or ghost drafts return). The refs and pre-increment timing move verbatim. + +### Seam 2 — External-annotation transport +Inject an `ExternalAnnotationTransport` (`subscribe(onEvent,onError)` + optimistic CRUD + `getSnapshot(since)`) into `useExternalAnnotations`, default = the SSE→polling state machine **moved verbatim** (EventSource primary, 500ms polling fallback, 304 gate, 30s heartbeat, fallback-once semantics). The reducer and optimistic mutators stay in the hook. The `enabled` gate is already host-suppliable. The server store/validators/SSE encoding in `shared/external-annotation.ts` are already shared; a Workspaces backend implements the same event contract over Durable Objects instead of SSE. + +### Seam 3 — Identity +Make authorship overridable: optional `author?` (or an injected `getIdentity`) at the ~9 stamp sites and an optional `isCurrentUser?` at the 2 `(me)` display sites, **defaulting to the existing `identity.ts` functions**. Storage is already swappable (Phase 2) and `configStore.init(serverConfig)` already seeds identity from the server, so much of identity is host-controllable today; this seam closes the last gap so Workspaces' real logins (WorkOS) drive authorship instead of tater names. + +## What we will NOT do in Phase 5 + +### Constraint A — Renderer coupling: document it, don't fight it +Highlight restoration re-anchors against the rendered DOM and depends on `transformPlainText` (emoji + smart punctuation) matching the renderer's output. **Workspaces must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` as a unit.** This is an integration contract we write down, not a wire we cut. (Optional later: expose `transformPlainText` as overridable with today's default — but not required for Phase 5.) + +### Constraint B — Replies/threading: defer as a new feature +Comments are flat today. Threading is something **Workspaces wants but Plannotator does not have** — so building it is *adding a feature*, which is explicitly outside "make today's behavior reusable without changing Plannotator." Phase 5 ships the flat model unchanged. Replies become a later, backward-compatible enhancement (a host layer over the shared components, or an optional `replies?` extension that Plannotator never populates), planned on its own once the seams land. + +## Why this ordering and risk read +- **Identity (Seam 3) is the lowest-risk** and partly done — do it first to warm up. +- **Drafts (Seam 1) is medium** — the generation protocol is fiddly but well-understood; the guardrail is that approve/deny/feedback/exit still carry the generation and ghost drafts don't return. +- **External (Seam 2) is the riskiest** — a timing-sensitive state machine that must move verbatim (the exact trap that sank the reverted attempt). Do it last, with the SSE→polling fallback proven by an eyeball (kill the stream, confirm polling takes over). +- Everything else (panel, popover, toolbar, highlighter, exporters) is already portable — **no work, just confirm noop** like the sidebar in Phase 4. + +## Open decisions for the spec/ADR +1. **Identity injection mechanism:** optional `author?` prop threaded to stamp sites vs. an injected `getIdentity`/`isCurrentUser` pair (module-level setter, like the other seams). Lean: module-level setter (`setIdentityProvider`) for consistency and to avoid threading props through 9 sites. +2. **Replies:** confirm it's deferred (recommended) vs. scoped into Phase 5. Recommend defer. +3. **Renderer constraint:** document-only (recommended) vs. also extract `transformPlainText` as overridable now. Recommend document-only. + +## References +- Spike: `adr/research/SPIKE-document-ui-comments-system-20260623-084806.md` +- Verified plan (Phase 5 / step 5): `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md` +- Decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/specs/document-ui-comments-seam-20260623-084806.md b/adr/specs/document-ui-comments-seam-20260623-084806.md new file mode 100644 index 000000000..f64519d83 --- /dev/null +++ b/adr/specs/document-ui-comments-seam-20260623-084806.md @@ -0,0 +1,85 @@ +# Spec: Comments / Annotations / Drafts Seam (Phase 5) + +Date: 2026-06-23 · Status: Draft (iterate before implementing) + +> Implementation spec for Phase 5 of the `@plannotator/ui` reuse effort. Grounded in `SPIKE-document-ui-comments-system-20260623-084806.md` + `synthesis-document-ui-comments-20260623-084806.md`. Governed by ADR 004. THE LAW: each seam is a module-level default reproducing today's literal behavior + an optional override; Plannotator passes nothing and is byte-for-byte unchanged. Move verbatim, never rewrite — especially the SSE machine and the draft generation protocol. + +## Scope + +**In scope (3 seams):** draft transport, external-annotation transport, identity/authorship. +**Confirmed noop (already portable):** AnnotationPanel, CommentPopover, AnnotationToolbar, AnnotationToolstrip, EditorAnnotationCard, AnnotationSidebar, useAnnotationHighlighter, useExternalAnnotationHighlights, commentContent/annotationHelpers/anchors, the `export*Annotations` serializers. +**Out of scope:** replies/threading (new feature — defer), renderer coupling (document as a contract), `useEditorAnnotations` / VS Code IPC (host-only), feedback/submit routes and merge/dedup policy (host-owned). + +## Order of work (lowest risk first) + +### Step 1 — Identity / authorship seam (effort S, lowest risk; partly already done) +**Files:** `packages/ui/utils/identity.ts` and the stamp/display sites. +- Add a module-level identity provider with default = today's functions, matching the Phase 2–4 pattern: + ```ts + // identity.ts + export interface IdentityProvider { + getIdentity(): string; + isCurrentUser(author: string | undefined): boolean; + } + const defaultIdentityProvider: IdentityProvider = { getIdentity, isCurrentUser }; // existing impls + let identityProvider = defaultIdentityProvider; + export function setIdentityProvider(p: IdentityProvider): void { identityProvider = p; } + export function resetIdentityProvider(): void { identityProvider = defaultIdentityProvider; } + ``` + Then route the 9 stamp sites and 2 display sites through `identityProvider.getIdentity()` / `identityProvider.isCurrentUser()`. Keep the existing `getIdentity`/`isCurrentUser` exports working (they remain the default). +- **Alternative considered:** thread an optional `author?`/`isCurrentUser?` prop through Viewer/panel. Rejected for now — 9 stamp sites across Viewer + html-viewer + diff make a module-level provider cleaner and lower-churn. (Decide in review.) +- **Parity guardrail:** no caller sets the provider → tater identity + `(me)` badge behave exactly as today. Verify: existing identity/annotation tests green; eyeball a comment shows the tater name + `(me)`. + +### Step 2 — Draft transport seam (effort M) +**Files:** `packages/ui/hooks/useAnnotationDraft.ts`, `packages/ui/hooks/useCodeAnnotationDraft.ts`. +- Introduce a `DraftTransport` and module-level default reproducing today's fetches verbatim: + ```ts + export interface DraftTransport { + load(): Promise<{ data: unknown | null; generation: number | null }>; + save(body: object, opts: { keepalive?: boolean }): Promise; + remove(generation: number, opts?: { keepalive?: boolean }): Promise; + } + ``` + Default `save` keeps the **keepalive-true POST with retry-without-keepalive on failure gated by generation match**; default `remove` does `DELETE /api/draft?generation=N`; default `load` does `GET /api/draft` + reads `draftGeneration` from the (404) body. +- **Keep inside the hook (do not move into the transport):** the `draftGenerationRef` pre-increment, the 500ms debounce, the `latestRef` non-reactive getters, `canPersistRef`/`hasMountedRef` gates, and the `visibilitychange`/`pagehide` flush effect. These are stateful/timing-sensitive — verbatim. +- **Document the 3-party protocol in the seam's doc comment:** `getDraftGeneration()` still escapes to the host; the host still threads it into submit (`withDraftGeneration` → `/api/approve`,`/api/exit` URL; `/api/deny`,`/api/feedback` body; annotate reads approve/exit from URL). A host swapping transport **must** replicate generation-gated delete-on-submit and tombstoning, or ghost drafts resurrect. +- **Parity guardrail:** no caller overrides transport → identical `/api/draft` traffic and identical generation in approve/deny/feedback/exit. Verify: existing draft tests green (esp. `packages/shared/draft.test.ts` generation invariants — server side is untouched); typecheck; full `bun test` ≥ baseline; eyeball: type a comment, reload → draft restores; submit → draft gone, doesn't reappear. + +### Step 3 — External-annotation transport seam (effort M–L, riskiest; do last) +**Files:** `packages/ui/hooks/useExternalAnnotations.ts` (+ `useExternalAnnotationHighlights.ts` stays as-is). +- Introduce an `ExternalAnnotationTransport` and module-level default reproducing the SSE→polling machine verbatim: + ```ts + export interface ExternalAnnotationTransport { + subscribe(onEvent: (e: ExternalAnnotationEvent) => void, onError: () => void): () => void; + getSnapshot(since: number): Promise<{ annotations: T[]; version: number } | null>; // null on 304 + add(items: T[]): Promise; + remove(id: string): Promise; + update(id: string, fields: Partial): Promise; + clear(source?: string): Promise; + } + ``` + Default `subscribe` = `new EventSource('/api/external-annotations/stream')` wiring; default `getSnapshot` = `GET /api/external-annotations?since=` with 304→null; CRUD = today's optimistic-then-fetch calls. +- **Keep inside the hook (verbatim):** the reducer that applies `snapshot|add|remove|clear|update`, the **fallback-once** logic (`!receivedSnapshotRef && !fallbackRef`), the **500ms** poll interval, the version-scoped `versionRef`, and the optimistic local mutation before the network call. The default transport owns the EventSource/heartbeat/304 wire; the hook owns the state machine that drives it. +- The `enabled` flag stays host-suppliable (plan: `isApiMode && !goalSetupMode`; review: `!!origin`). Server `shared/external-annotation.ts` (store, validators, event types, SSE encoding) is already shared and unchanged. +- **Parity guardrail:** no caller overrides → identical SSE connection, identical 500ms/304 polling, identical optimistic CRUD. Verify: existing external-annotation tests green; **eyeball both paths** — (a) POST to `/api/external-annotations` shows live without reload (SSE); (b) kill/black-hole the stream → confirm polling takes over and still updates. App.tsx merge/dedup untouched. + +## Renderer-coupling contract (document, no code change) +Write a short integration note (in the package README or a `docs` doc) stating: a host consuming the annotation UI must render markdown through `@plannotator/ui` `BlockRenderer` + `InlineMarkdown` + `utils/inlineTransforms` (which applies `transformPlainText`), because highlight restoration re-anchors against that exact rendered text. Optional future work: expose `transformPlainText` as overridable with today's default. + +## Replies/threading (explicitly deferred) +Not built in Phase 5. When scoped later, do it backward-compatibly (Plannotator keeps the flat single-comment experience; threading is additive and Plannotator never populates it). Tracked as a separate spec. + +## Definition of done (Phase 5) +- Identity, draft, and external-annotation transports are host-overridable, each defaulting to today's behavior. +- Plannotator byte-unchanged: shipped bundles behave identically; full `bun test` ≥ baseline (1620/0); typecheck; builds; App.tsx changes limited to (at most) wiring the defaults at call sites if needed (ideally zero — module-level defaults mean Plannotator passes nothing). +- Eyeball confirmed: comment author/`(me)`, draft save+restore+no-ghost, live external annotations via SSE, and SSE→polling fallback. +- Renderer-coupling contract written down. Replies deferred with a note. + +## Per-step parity guardrail (run after each) +`bun run typecheck` · `bun test` must stay ≥ 1620/0 (+ the touched suite green, unmodified where a guardrail test exists) · `bun run --cwd apps/review build && bun run build:hook` · `git diff packages/editor/App.tsx` minimal/empty · manual eyeball for the step's surface. + +## Open questions (resolve before/within ADR) +1. Identity: module-level `setIdentityProvider` (recommended) vs. props. +2. Replies: deferred (recommended) vs. in-scope. +3. Renderer `transformPlainText`: document-only (recommended) vs. extract overridable now. +4. Whether to ship the three seams as one Phase-5 PR or three small verify-gated commits (recommended: three commits, identity → drafts → external, like Phase 3). From 60668a3f488f170905ba916b258745dd522de87f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 08:53:53 -0700 Subject: [PATCH 14/67] =?UTF-8?q?docs(adr):=20accept=20ADR=20005=20?= =?UTF-8?q?=E2=80=94=20make=20comments/annotations/drafts=20host-overridab?= =?UTF-8?q?le=20(Phase=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three seams (identity, draft transport, external-annotation transport), each defaulting to today's behavior; renderer coupling documented as a contract; replies/threading deferred as a new feature. Locks in the recommended choices from the Phase 5 spec/synthesis. --- ...mments-host-overridable-20260623-085309.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 adr/decisions/005-make-comments-host-overridable-20260623-085309.md diff --git a/adr/decisions/005-make-comments-host-overridable-20260623-085309.md b/adr/decisions/005-make-comments-host-overridable-20260623-085309.md new file mode 100644 index 000000000..bd7ff2c8d --- /dev/null +++ b/adr/decisions/005-make-comments-host-overridable-20260623-085309.md @@ -0,0 +1,52 @@ +# 005. Make Comments / Annotations / Drafts Host-Overridable (Phase 5) + +Date: 2026-06-23 + +## Status + +Accepted + +## Context + +ADR 004 set the plan: make `@plannotator/ui` reusable by the commercial Workspaces app by lifting each Plannotator-specific wire up to an optional override whose default is today's behavior, never changing Plannotator. Phases 0–4 did this for packaging, image/storage, the rendering stack, and the file tree. + +Phase 5 is comments — the core of Workspaces (teammates and AI agents commenting on documents, live). It was assumed to be the largest, most dangerous phase. Five code-research probes (`adr/research/SPIKE-document-ui-comments-system-20260623-084806.md`, synthesized in `adr/research/synthesis-document-ui-comments-20260623-084806.md`) found a narrower reality: + +- The comment **UI is already portable** — `AnnotationPanel`, `CommentPopover`, `AnnotationToolbar`, `AnnotationToolstrip`, `EditorAnnotationCard`, `useAnnotationHighlighter`, the `export*Annotations` serializers — all prop-driven, no backend wires. +- A **second consumer already proves it**: `review-editor` reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged. +- Annotation **state is host-owned already** (each app's own `useState`), so there is no shared reducer to wrestle. + +The real coupling is three things: the draft transport (`/api/draft`) plus a fragile 3-party "generation" protocol that prevents ghost drafts; the external-annotation transport (an SSE→polling state machine — the live-comment channel); and identity/authorship (the local "tater" nickname behind the `(me)` badge). Two further findings are not extraction work: highlight restoration is coupled to Plannotator's exact markdown renderer, and there is no reply/threading model (which Workspaces wants but Plannotator does not have). + +## Decision + +Make the comment system host-overridable through **three seams**, each a module-level default that reproduces today's behavior plus an optional override; Plannotator passes nothing and stays byte-for-byte unchanged. Land them lowest-risk first, as three separate verify-gated commits. + +1. **Identity (first, lowest risk).** Add a module-level identity provider in `packages/ui/utils/identity.ts` (`setIdentityProvider` / `resetIdentityProvider`) defaulting to today's `getIdentity` / `isCurrentUser`. Route the ~9 author-stamp sites and 2 `(me)`-display sites through it. Workspaces supplies the logged-in user; Plannotator keeps the tater nickname. (Identity already persists via the Phase-2 swappable storage and `configStore.init(serverConfig)`; this closes the last gap.) + +2. **Draft transport (second).** Inject a `DraftTransport` (load/save/remove) into `useAnnotationDraft` and `useCodeAnnotationDraft`, default = today's `/api/draft` fetches verbatim — including the `keepalive` retry and the `visibilitychange`/`pagehide` flush. The generation protocol stays end-to-end: `getDraftGeneration()` still escapes to the host and is still threaded into approve/deny/feedback/exit; the seam's contract documents that a host swapping transport must also honor generation-gated delete-on-submit and tombstoning, or ghost drafts return. The stateful refs, debounce, and pre-increment timing stay in the hook, verbatim. + +3. **External-annotation transport (last, riskiest).** Inject an `ExternalAnnotationTransport` (`subscribe` + optimistic CRUD + `getSnapshot(since)`) into `useExternalAnnotations`, default = the SSE→polling state machine moved verbatim (EventSource primary, 500ms polling fallback, 304 gate, 30s heartbeat, fallback-once). The reducer, optimistic mutators, version-scoping, and `enabled` gate stay in the hook. A Workspaces backend implements the same event contract over Durable Objects instead of SSE; the shared store/validators/encoding in `packages/shared/external-annotation.ts` are unchanged. + +The already-portable comment components and hooks are confirmed no-ops — no work. + +**Two things are explicitly excluded from Phase 5:** + +- **Renderer coupling — document, do not change.** Highlight restoration re-anchors against the rendered DOM and depends on `transformPlainText` matching the renderer. We write down an integration contract: a host must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` as a unit. (Optionally expose `transformPlainText` as overridable later; not now.) + +- **Replies / threading — defer as a new feature.** Comments are flat today. Threading is something Workspaces wants and Plannotator lacks; building it is adding a feature, not making existing behavior reusable. Phase 5 ships the flat model unchanged. Replies are a later, backward-compatible enhancement that Plannotator never populates, tracked in its own spec. + +## Consequences + +- Workspaces can power real-time, multi-person, agent-friendly commenting by implementing three transports/providers, without inheriting Plannotator's `/api/draft`, SSE routes, or tater identity. +- Plannotator is unchanged: every seam defaults to today's literal behavior; the draft generation protocol and the SSE→polling machine move verbatim (the exact failure mode of the reverted attempt is avoided by copying, not re-deriving). +- The parity bar per seam: full `bun test` stays at baseline (1620/0), typecheck and builds pass, `packages/editor/App.tsx` changes stay minimal/empty, and an eyeball confirms the surface — author/`(me)` badge, draft save+restore+no-ghost, live external annotations, and the SSE→polling fallback (kill the stream, confirm polling takes over). +- A new integration constraint is now on record: Workspaces must reuse Plannotator's markdown renderer for comment highlights to land. This narrows Workspaces' freedom on rendering but is required and cheap (it already wants the same look). +- Replies remain unbuilt; Workspaces' full collaborative-thread vision needs a follow-up once the seams land. + +## References + +- Spike: `adr/research/SPIKE-document-ui-comments-system-20260623-084806.md` +- Synthesis: `adr/research/synthesis-document-ui-comments-20260623-084806.md` +- Spec: `adr/specs/document-ui-comments-seam-20260623-084806.md` +- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` From 78b2b8d3298ac67cfcbfac060e90a2034d8de8fa Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 09:12:09 -0700 Subject: [PATCH 15/67] feat(ui): make annotation identity host-overridable (Phase 5 seam 1) Add IdentityProvider + setIdentityProvider/resetIdentityProvider in identity.ts; getIdentity/isCurrentUser now delegate to a module-level provider defaulting to today's ConfigStore tater behavior. The ~9 author-stamp sites and 2 (me)-badge sites delegate with zero call-site edits. No caller overrides => Plannotator byte-unchanged. typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/utils/identity.ts | 51 +++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/ui/utils/identity.ts b/packages/ui/utils/identity.ts index 8b73f7504..b67bb0ba5 100644 --- a/packages/ui/utils/identity.ts +++ b/packages/ui/utils/identity.ts @@ -14,10 +14,52 @@ import { configStore } from '../config'; import { generateIdentity } from './generateIdentity'; /** - * Get current identity from ConfigStore. + * Host-overridable identity provider. + * + * Default = today's tater behavior (ConfigStore-backed nickname + cookie match). + * A host (e.g. Workspaces) calls setIdentityProvider once at startup to stamp its + * logged-in user on comments and drive the `(me)` badge instead. Mirrors the + * swappable storage backend in ./storage.ts (StorageBackend/setStorageBackend). + */ +export interface IdentityProvider { + /** Display name stamped as `author` on new annotations. */ + getIdentity(): string; + /** Whether an annotation's `author` is the current user (drives the `(me)` badge). */ + isCurrentUser(author: string | undefined): boolean; +} + +/** + * Default provider: today's literal Plannotator behavior. + * `displayName` resolution stays in ConfigStore (server config > cookie > tater). + */ +const defaultIdentityProvider: IdentityProvider = { + getIdentity(): string { + return configStore.get('displayName'); + }, + isCurrentUser(author: string | undefined): boolean { + if (!author) return false; + return author === configStore.get('displayName'); + }, +}; + +// Active provider. Defaults to the tater identity so Plannotator is unchanged. +let identityProvider: IdentityProvider = defaultIdentityProvider; + +/** Override the identity provider. Call once at app startup. */ +export function setIdentityProvider(p: IdentityProvider): void { + identityProvider = p; +} + +/** Reset to the default (tater) provider. Mainly for tests. */ +export function resetIdentityProvider(): void { + identityProvider = defaultIdentityProvider; +} + +/** + * Get current identity. Delegates to the active provider (default = ConfigStore tater). */ export function getIdentity(): string { - return configStore.get('displayName'); + return identityProvider.getIdentity(); } /** @@ -42,9 +84,8 @@ export function regenerateIdentity(): string { } /** - * Check if an identity belongs to the current user. + * Check if an identity belongs to the current user. Delegates to the active provider. */ export function isCurrentUser(author: string | undefined): boolean { - if (!author) return false; - return author === configStore.get('displayName'); + return identityProvider.isCurrentUser(author); } From 36bf62005266c1e0c64ed9756fe851d9c3f29529 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 09:16:23 -0700 Subject: [PATCH 16/67] feat(ui): make draft persistence transport host-overridable (Phase 5 seam 2) Add DraftTransport (load/save/remove) + getDraftTransport/setDraftTransport/ resetDraftTransport in useAnnotationDraft.ts, default = today's /api/draft fetches verbatim. useCodeAnnotationDraft reads getDraftTransport() live. The generation pre-increment, 500ms debounce, keepalive retry-gate, and pagehide/visibilitychange flush stay in the hooks; getDraftGeneration() still escapes to the host. save rejects-on-failure so the gated retry is preserved. No caller overrides => Plannotator byte-unchanged. shared/draft.test.ts 10/0, annotationDraftPersistence 13/0, typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/hooks/useAnnotationDraft.ts | 109 ++++++++++++++++---- packages/ui/hooks/useCodeAnnotationDraft.ts | 26 ++--- 2 files changed, 99 insertions(+), 36 deletions(-) diff --git a/packages/ui/hooks/useAnnotationDraft.ts b/packages/ui/hooks/useAnnotationDraft.ts index e744d3693..8e8bcdbc4 100644 --- a/packages/ui/hooks/useAnnotationDraft.ts +++ b/packages/ui/hooks/useAnnotationDraft.ts @@ -22,6 +22,85 @@ import type { ShareableAnnotation } from '../utils/sharing'; const DEBOUNCE_MS = 500; +/** + * Transport for persisting annotation/edit drafts. The default reproduces + * Plannotator's `/api/draft` server protocol verbatim. A host (e.g. Workspaces) + * may override it to persist drafts through its own backend. + * + * CONTRACT — a host overriding this MUST preserve the 3-party generation + * protocol or ghost drafts resurrect: + * - `save` must be best-effort on page close (the default uses `keepalive` + * with a retry-without-keepalive on failure, gated by a generation match). + * - `remove(generation)` is a generation-gated TOMBSTONE: the host's store + * must reject any later `save` whose `draftGeneration` is <= the deleted + * generation (delete-on-submit + tombstoning). The hook pre-increments + * `draftGeneration` and threads `getDraftGeneration()` out to the host, + * which sends it on approve/deny/feedback/exit so the server deletes the + * draft with the right generation. Drop this and a debounced save landing + * after submit re-creates a draft the server just deleted. + * - `load` returns the raw stored body (or null) plus the generation the + * store reports when there is NO draft (the default reads `draftGeneration` + * from the 404 body) so the client can resume past a tombstone. + */ +export interface DraftTransport { + /** GET the draft. `data` is the raw stored body (null if none). `generation` + is the store's reported generation when there is no draft (null otherwise). */ + load(): Promise<{ data: unknown | null; generation: number | null }>; + /** Persist the draft body. `keepalive` requests best-effort delivery on close. */ + save(body: object, opts: { keepalive: boolean }): Promise; + /** Generation-gated tombstone delete. */ + remove(generation: number, opts: { keepalive: boolean }): Promise; +} + +/** + * Default transport — Plannotator's `/api/draft` fetches, moved verbatim. + * `save` rejects on failure (the keepalive retry stays in the hook so its + * generation-match gate is preserved); `remove` always resolves. + */ +const defaultDraftTransport: DraftTransport = { + async load() { + const res = await fetch('/api/draft'); + const data = (await res.json().catch(() => null)) as unknown; + if (!res.ok) { + const generation = readDraftGeneration( + (data as MissingDraftData | null)?.draftGeneration, + ); + return { data: null, generation }; + } + return { data, generation: null }; + }, + save(body, { keepalive }) { + const payload = JSON.stringify(body); + const headers = { 'Content-Type': 'application/json' }; + return fetch('/api/draft', { method: 'POST', headers, body: payload, keepalive }).then( + () => {}, + ); + }, + remove(generation, { keepalive }) { + return fetch(`/api/draft?generation=${generation}`, { method: 'DELETE', keepalive }).then( + () => {}, + () => {}, + ); + }, +}; + +let draftTransport: DraftTransport = defaultDraftTransport; + +/** Read the active draft transport at call time (so a late override is honored). */ +export function getDraftTransport(): DraftTransport { + return draftTransport; +} + +/** Override the draft transport. Call once at app startup. */ +export function setDraftTransport(t: DraftTransport): void { + draftTransport = t; +} + +/** Reset to the default `/api/draft` transport. Mainly for tests. */ +export function resetDraftTransport(): void { + draftTransport = defaultDraftTransport; +} + type DraftSourceSaveCapability = Extract; export interface DraftEditedDocument { @@ -233,17 +312,12 @@ export function useAnnotationDraft({ useEffect(() => { if (!isApiMode || isSharedSession) return; - fetch('/api/draft') - .then(async res => { - const data = await res.json().catch(() => null) as DraftData | LegacyDraftData | MissingDraftData | null; - if (!res.ok) { - const generation = readDraftGeneration((data as MissingDraftData | null)?.draftGeneration); - if (generation !== null) { - draftGenerationRef.current = Math.max(draftGenerationRef.current, generation); - } - return null; + getDraftTransport().load() + .then(({ data, generation }) => { + if (generation !== null) { + draftGenerationRef.current = Math.max(draftGenerationRef.current, generation); } - return data; + return data as DraftData | LegacyDraftData | null; }) .then((data: DraftData | LegacyDraftData | null) => { if (!data) { @@ -335,7 +409,7 @@ export function useAnnotationDraft({ // explicitly threw away. const deletedGeneration = draftGenerationRef.current + 1; draftGenerationRef.current = deletedGeneration; - fetch(`/api/draft?generation=${deletedGeneration}`, { method: 'DELETE', keepalive }).catch(() => {}); + draftTransport.remove(deletedGeneration, { keepalive }).catch(() => {}); return; } @@ -352,13 +426,14 @@ export function useAnnotationDraft({ ts: Date.now(), }; - const body = JSON.stringify(payload); - const headers = { 'Content-Type': 'application/json' }; - fetch('/api/draft', { method: 'POST', headers, body, keepalive }).catch(() => { + // The transport moves the POST behind the seam; the keepalive retry-on-failure + // gate stays in the hook verbatim so a host transport that resolves/rejects on + // failure still won't resurrect a superseded save. + draftTransport.save(payload, { keepalive }).catch(() => { // Chromium caps keepalive bodies (~64KB); retry without it. Completes // fine when the page was only backgrounded, best-effort on close. if (keepalive && canPersistRef.current && draftGenerationRef.current === draftGeneration) { - fetch('/api/draft', { method: 'POST', headers, body }).catch(() => {}); + draftTransport.save(payload, { keepalive: false }).catch(() => {}); } // Otherwise silent failure — draft is best-effort. }); @@ -441,9 +516,7 @@ export function useAnnotationDraft({ setDraftBanner(null); draftDataRef.current = null; - fetch(`/api/draft?generation=${deletedGeneration}`, { method: 'DELETE' }).catch(() => { - // Silent failure - }); + draftTransport.remove(deletedGeneration, { keepalive: false }).catch(() => {}); }, []); return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft }; diff --git a/packages/ui/hooks/useCodeAnnotationDraft.ts b/packages/ui/hooks/useCodeAnnotationDraft.ts index 71f58ec33..5a1d9bfde 100644 --- a/packages/ui/hooks/useCodeAnnotationDraft.ts +++ b/packages/ui/hooks/useCodeAnnotationDraft.ts @@ -7,6 +7,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { CodeAnnotation, Annotation, CommentAnnotation } from '../types'; +import { getDraftTransport } from './useAnnotationDraft'; const DEBOUNCE_MS = 500; @@ -78,17 +79,12 @@ export function useCodeAnnotationDraft({ useEffect(() => { if (!isApiMode) return; - fetch('/api/draft') - .then(async res => { - const data = await res.json().catch(() => null) as DraftData | MissingDraftData | null; - if (!res.ok) { - const generation = readDraftGeneration((data as MissingDraftData | null)?.draftGeneration); - if (generation !== null) { - draftGenerationRef.current = Math.max(draftGenerationRef.current, generation); - } - return null; + getDraftTransport().load() + .then(({ data, generation }) => { + if (generation !== null) { + draftGenerationRef.current = Math.max(draftGenerationRef.current, generation); } - return data; + return data as DraftData | null; }) .then((data: DraftData | null) => { const generation = readDraftGeneration(data?.draftGeneration); @@ -159,13 +155,7 @@ export function useCodeAnnotationDraft({ ts: Date.now(), }; - fetch('/api/draft', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).catch(() => { - // Silent failure - }); + getDraftTransport().save(payload, { keepalive: false }).catch(() => {}); }, DEBOUNCE_MS); return () => { @@ -198,7 +188,7 @@ export function useCodeAnnotationDraft({ draftGenerationRef.current = deletedGeneration; setDraftBanner(null); draftDataRef.current = null; - fetch(`/api/draft?generation=${deletedGeneration}`, { method: 'DELETE' }).catch(() => {}); + getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {}); }, []); return { draftBanner, restoreDraft, getDraftGeneration, dismissDraft }; From 47fcaf54d576aa747ef64a106e81a06e5f3dfc3a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 09:18:47 -0700 Subject: [PATCH 17/67] feat(ui): make external-annotation transport host-overridable (Phase 5 seam 3) Add ExternalAnnotationTransport (subscribe/getSnapshot/CRUD) + setters in useExternalAnnotations.ts; default = today's SSE->polling wire moved verbatim into createDefaultTransport. The reducer (applyEvent), fallback-once gate, 500ms poll, versionRef scoping, optimistic-before-await, and [enabled] gate stay in the hook. A host (Workspaces) can implement the same event contract over Durable Objects. No override caller => Plannotator byte-unchanged. external-annotations test green, typecheck pass, 1620 tests/0 fail, builds OK. Logs Phase 5 completion. --- .../document-ui-phase-0-1-worklog-20260622.md | 20 ++ packages/ui/hooks/useExternalAnnotations.ts | 222 ++++++++++++------ 2 files changed, 166 insertions(+), 76 deletions(-) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index b2e9b3c46..c6d651e83 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -123,6 +123,26 @@ Confirmed transfer-as-is: SidebarContainer/SidebarTabs/CountBadge/FileBrowser/Ve ### Phase 4 status: COMPLETE — sidebar noop, file-tree seam landed. Plannotator byte-unchanged. +## Phase 5 — Comments / annotations / drafts (ADR 005) + +Researched (5-probe spike), specced, ADR-005-accepted, then teed up + multi-lens adversarially reviewed by the `phase5-comments` workflow (4 tee-ups + 3 worktree executes + 12 review lenses + synthesis; all 12 lenses returned safe). Landed + verified by hand on the real tree, lowest-risk first. The already-portable comment UI (panel, popover, toolbar, highlighter, exporters) confirmed noop. + +### Seam 1 — Identity provider (DONE) +- **File:** `packages/ui/utils/identity.ts`. Added `IdentityProvider` + `setIdentityProvider`/`resetIdentityProvider`; `getIdentity`/`isCurrentUser` delegate to a module-level provider defaulting to today's ConfigStore tater behavior. The ~9 author-stamp sites and 2 `(me)`-badge sites delegate with **zero call-site edits**. +- **Parity:** no override caller → tater nickname + `(me)` badge identical. typecheck pass, 1620/0, builds OK. (+46/-5, identity.ts only.) + +### Seam 2 — Draft transport (DONE) +- **Files:** `packages/ui/hooks/useAnnotationDraft.ts` (+ `useCodeAnnotationDraft.ts` reads `getDraftTransport()` live). Added `DraftTransport` (load/save/remove) + setters, default = today's `/api/draft` fetches verbatim; `save` rejects-on-failure so the **keepalive retry-gate stays in the hook**. The generation pre-increment, 500ms debounce, and pagehide/visibilitychange flush stay verbatim; `getDraftGeneration()` still escapes to the host. +- **Landing note:** the workflow diff carried one phantom hunk (a delete-on-clear branch the real code-draft hook never had — it early-returns on empty). `patch` correctly rejected it; the real tree is correct without it. Caught by landing-on-real-tree verification. +- **Parity:** no override caller; App.tsx + `shared/draft.ts` untouched. `shared/draft.test.ts` 10/0, `annotationDraftPersistence` 13/0 (incl. pagehide-flush parity), typecheck pass, 1620/0, builds OK. + +### Seam 3 — External-annotation transport (DONE, riskiest) +- **File:** `packages/ui/hooks/useExternalAnnotations.ts`. Added `ExternalAnnotationTransport` (`subscribe`/`getSnapshot`/CRUD) + setters; default = the SSE→polling wire moved verbatim into `createDefaultTransport`. The reducer (`applyEvent`, byte-identical cases), fallback-once gate, 500ms poll, version-scoping, optimistic-before-await, and the `[enabled]` gate **stay in the hook**. Two micro-divergences (parse-then-cancelled-check; snapshot `[]`/`0` defaults) are provably unreachable for Plannotator (server always returns well-formed `{annotations, version}`; parse-then-discard is side-effect-free). +- **Parity:** no override caller; App.tsx untouched (both apps still call `useExternalAnnotations({enabled})`). external-annotation test green, typecheck pass, 1620/0, builds OK. + +### Phase 5 status: COMPLETE (pending eyeball) — 3 seams landed, comment UI noop. Plannotator byte-unchanged. +Renderer-coupling contract (Workspaces must reuse BlockRenderer+InlineMarkdown+inlineTransforms for highlights) and replies/threading deferral recorded in ADR 005. Remaining: manual eyeball — author/`(me)`, draft save+restore+no-ghost, live SSE add + kill-stream→polling-takes-over. + ### Discovered (PRE-EXISTING, out of scope — not caused by this work) 1. **Edit/save header state leaks across file switches** in annotate-folder mode: editing+saving file A leaves the Saved/Done/wide-focus header showing when you switch to file B without editing it. Reproduced on the **baseline with the Phase 4 change reverted** (A/B confirmed) → pre-existing App.tsx bug, not a regression. Lives in the folder file-switch handler (`handleFileBrowserSelect` / edit-session reset), unrelated to `useFileBrowser`. Worth a separate fix. 2. **Annotating the repo root (`annotate ./`) bogs down** — the file walker + chokidar SSE watcher choke on 1.4GB of node_modules (16 dirs); the code already warns about this. Pre-existing scaling limit; use a bounded folder. Not a code defect introduced here. diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts index 467158a12..8f586caec 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -19,6 +19,98 @@ const POLL_INTERVAL_MS = 500; const STREAM_URL = '/api/external-annotations/stream'; const SNAPSHOT_URL = '/api/external-annotations'; +/** + * Wire transport for external annotations. The hook owns the state machine + * (reducer, fallback-once SSE→polling, version-scoping, optimistic mutation, + * enabled gate); the transport owns ONLY the network/event wire. + * + * Default = Plannotator's SSE→polling behavior, verbatim. A host (Workspaces) + * can implement the same contract over its own backend (e.g. Durable Objects). + */ +export interface ExternalAnnotationTransport { + /** Open the live event stream. Returns an unsubscribe fn that tears it down. */ + subscribe( + onEvent: (event: ExternalAnnotationEvent) => void, + onError: () => void, + ): () => void; + /** Fetch a version-gated snapshot. Resolves null when there are no changes (304). */ + getSnapshot(since: number): Promise<{ annotations: T[]; version: number } | null>; + add(items: T[]): Promise; + remove(id: string): Promise; + update(id: string, fields: Partial): Promise; + clear(source?: string): Promise; +} + +/** + * Default transport — Plannotator's verbatim SSE→polling wire. + * EventSource on /api/external-annotations/stream; GET snapshot honoring 304→null; + * CRUD via DELETE/PATCH fetches (optimistic local mutation stays in the hook). + */ +function createDefaultTransport(): ExternalAnnotationTransport { + return { + subscribe(onEvent, onError) { + const es = new EventSource(STREAM_URL); + es.onmessage = (event) => { + try { + const parsed: ExternalAnnotationEvent = JSON.parse(event.data); + onEvent(parsed); + } catch { + // Ignore malformed events (e.g., heartbeat comments) + } + }; + es.onerror = () => { + onError(); + }; + return () => es.close(); + }, + async getSnapshot(since) { + const url = since > 0 ? `${SNAPSHOT_URL}?since=${since}` : SNAPSHOT_URL; + const res = await fetch(url); + if (res.status === 304) return null; // No changes + if (!res.ok) return null; + const data = await res.json(); + const annotations = Array.isArray(data.annotations) ? data.annotations : []; + const version = typeof data.version === 'number' ? data.version : 0; + return { annotations, version }; + }, + async add(items) { + await fetch(SNAPSHOT_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ annotations: items }), + }); + }, + async remove(id) { + await fetch(`${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); + }, + async update(id, fields) { + await fetch(`${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }); + }, + async clear(source) { + const qs = source ? `?source=${encodeURIComponent(source)}` : ''; + await fetch(`${SNAPSHOT_URL}${qs}`, { method: 'DELETE' }); + }, + }; +} + +let externalAnnotationTransport: ExternalAnnotationTransport = createDefaultTransport(); + +/** Override the external-annotation wire transport. Call once at app startup. */ +export function setExternalAnnotationTransport( + transport: ExternalAnnotationTransport, +): void { + externalAnnotationTransport = transport; +} + +/** Reset to the default (Plannotator SSE→polling) transport. Mainly for tests. */ +export function resetExternalAnnotationTransport(): void { + externalAnnotationTransport = createDefaultTransport(); +} + interface UseExternalAnnotationsReturn { externalAnnotations: T[]; updateExternalAnnotation: (id: string, updates: Partial) => void; @@ -40,55 +132,54 @@ export function useExternalAnnotations { - if (cancelled) return; - - try { - const parsed: ExternalAnnotationEvent = JSON.parse(event.data); - - switch (parsed.type) { - case 'snapshot': - receivedSnapshotRef.current = true; - setAnnotations(parsed.annotations); - break; - case 'add': - setAnnotations((prev) => [...prev, ...parsed.annotations]); - break; - case 'remove': - setAnnotations((prev) => - prev.filter((a) => !parsed.ids.includes(a.id)), - ); - break; - case 'clear': - setAnnotations((prev) => - parsed.source - ? prev.filter((a) => a.source !== parsed.source) - : [], - ); - break; - case 'update': - setAnnotations((prev) => - prev.map((a) => a.id === parsed.id ? (parsed.annotation as T) : a), - ); - break; - } - } catch { - // Ignore malformed events (e.g., heartbeat comments) + const transport = externalAnnotationTransport as ExternalAnnotationTransport; + + // --- Reducer (applies snapshot|add|remove|clear|update), verbatim --- + function applyEvent(parsed: ExternalAnnotationEvent) { + switch (parsed.type) { + case 'snapshot': + receivedSnapshotRef.current = true; + setAnnotations(parsed.annotations); + break; + case 'add': + setAnnotations((prev) => [...prev, ...parsed.annotations]); + break; + case 'remove': + setAnnotations((prev) => + prev.filter((a) => !parsed.ids.includes(a.id)), + ); + break; + case 'clear': + setAnnotations((prev) => + parsed.source + ? prev.filter((a) => a.source !== parsed.source) + : [], + ); + break; + case 'update': + setAnnotations((prev) => + prev.map((a) => a.id === parsed.id ? (parsed.annotation as T) : a), + ); + break; } - }; + } - es.onerror = () => { - // If we never received a snapshot, SSE isn't working — fall back to polling - if (!receivedSnapshotRef.current && !fallbackRef.current) { - fallbackRef.current = true; - es.close(); - startPolling(); - } - // Otherwise, EventSource will auto-reconnect and we'll get a fresh snapshot - }; + // --- SSE primary transport --- + const unsubscribe = transport.subscribe( + (parsed) => { + if (cancelled) return; + applyEvent(parsed); + }, + () => { + // If we never received a snapshot, SSE isn't working — fall back to polling + if (!receivedSnapshotRef.current && !fallbackRef.current) { + fallbackRef.current = true; + unsubscribe(); + startPolling(); + } + // Otherwise, EventSource will auto-reconnect and we'll get a fresh snapshot + }, + ); // --- Polling fallback --- function startPolling() { @@ -105,23 +196,10 @@ export function useExternalAnnotations 0 - ? `${SNAPSHOT_URL}?since=${versionRef.current}` - : SNAPSHOT_URL; - - const res = await fetch(url); - - if (res.status === 304) return; // No changes - if (!res.ok) return; - - const data = await res.json(); - if (Array.isArray(data.annotations)) { - setAnnotations(data.annotations); - } - if (typeof data.version === 'number') { - versionRef.current = data.version; - } + const snap = await transport.getSnapshot(versionRef.current); + if (snap === null) return; // No changes (304) or unavailable + setAnnotations(snap.annotations); + versionRef.current = snap.version; } catch { // Silent — next poll will retry } @@ -129,7 +207,7 @@ export function useExternalAnnotations { cancelled = true; - es.close(); + unsubscribe(); if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; @@ -141,10 +219,7 @@ export function useExternalAnnotations prev.filter((a) => a.id !== id)); try { - await fetch( - `${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, - { method: 'DELETE' }, - ); + await (externalAnnotationTransport as ExternalAnnotationTransport).remove(id); } catch { // SSE will reconcile on next event } @@ -156,8 +231,7 @@ export function useExternalAnnotations a.source !== source) : [], ); try { - const qs = source ? `?source=${encodeURIComponent(source)}` : ''; - await fetch(`${SNAPSHOT_URL}${qs}`, { method: 'DELETE' }); + await (externalAnnotationTransport as ExternalAnnotationTransport).clear(source); } catch { // SSE will reconcile on next event } @@ -166,11 +240,7 @@ export function useExternalAnnotations) => { setAnnotations((prev) => prev.map((a) => (a.id === id ? { ...a, ...updates } : a))); try { - await fetch(`${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), - }); + await (externalAnnotationTransport as ExternalAnnotationTransport).update(id, updates); } catch { // SSE will reconcile on next event } From 935c20bbac1d5914d16d12c3019152f43eb757a6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:10:30 -0700 Subject: [PATCH 18/67] docs(adr): research + synthesis + spec for Phase 6 (versions, settings, sharing, AI) Five-probe code research. Most of the four subsystems is already portable; the real work is 5 seams (version fetchers + vscode-diff, config write-back, obsidian detect, save-to-notes, AI transport) + 1 CSS move (block/raw diff classes from the app shell into the package's theme.css). Fragile do-not-touch: the AI SSE reader loop + epoch guards, and configStore debounce/deepMerge. Five Plannotator-only pieces (OpenInApp, HooksTab, useUpdateCheck, useAgents/useAgentJobs) stay home. --- ...cument-ui-extras-system-20260623-100827.md | 56 +++++++++++++++++++ ...esis-document-ui-extras-20260623-100827.md | 47 ++++++++++++++++ ...document-ui-extras-seam-20260623-100827.md | 53 ++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 adr/research/SPIKE-document-ui-extras-system-20260623-100827.md create mode 100644 adr/research/synthesis-document-ui-extras-20260623-100827.md create mode 100644 adr/specs/document-ui-extras-seam-20260623-100827.md diff --git a/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md b/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md new file mode 100644 index 000000000..6db4c302d --- /dev/null +++ b/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md @@ -0,0 +1,56 @@ +# Spike: Phase 6 Extras — Versions/Diff, Settings, Sharing/Export, Ask AI + +Date: 2026-06-23 + +> Code research for Phase 6 of the `@plannotator/ui` reuse effort (ADR 004; roadmap `adr/implementation/document-ui-extraction-roadmap-20260622.md`). Five parallel probes mapped the four extra subsystems. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. + +## Headline + +Phase 6 is **mostly already portable** — the same pattern as the sidebar/comment-UI noops. The actual work is a small set of seams plus **one CSS wrinkle** (block-level diff styles live in the app shell, not the package). The fragile pieces are narrow: the AI streaming reader loop (do-not-touch) and the configStore write-back batching (keep verbatim). + +## Subsystem 1 — Versions / plan diff + +**Files:** `packages/ui/hooks/usePlanDiff.ts`, `utils/planDiffEngine.ts`, `components/plan-diff/*`. CSS: `packages/editor/index.css` + `packages/ui/theme.css`. + +- **Seam A — version fetchers:** `usePlanDiff` hard-codes `fetch('/api/plan/version?v=N')` (L98) and `fetch('/api/plan/versions')` (L119). Inject optional fetchers, default = today's literals. **Keep error asymmetry verbatim:** `selectBaseVersion` `alert()`s on failure (L100, L107); `fetchVersions` is silent (L127-131). +- **Seam B — VS Code diff:** `PlanDiffViewer.tsx` POSTs `/api/plan/vscode-diff` (L65). Optional `onOpenVscodeDiff?` prop; default = today's fetch (or omit the button when not provided). +- **Already portable:** `planDiffEngine.ts` (pure `computePlanDiff`/`computeInlineDiff`), `PlanDiffBadge`, `PlanCleanDiffView`, `PlanRawDiffView`, `PlanDiffModeSwitcher` — all prop-driven. +- **THE CSS WRINKLE (confirmed):** the *word-level* classes `.plan-diff-word-*` live in the package (`theme.css` L451-480), but the *block-level* and *raw* diff classes — `.plan-diff-added/removed/modified/unchanged` (`editor/index.css` L168-211) and `.plan-diff-line-added/removed` (L213-230) — live in the **app shell**, not the package. Also `.annotation-highlight*` (L119-157) lives in the app shell (used by the regular Viewer too, so this is broader than diff). Without these, a host's diff renders unstyled (no borders/backgrounds → unreadable). Fix: move the `.plan-diff-*` block/raw classes into `packages/ui/theme.css` (co-located with `.plan-diff-word-*`); Plannotator imports `theme.css` so it stays identical. `.annotation-highlight` is a broader CSS-contract item (Viewer needs it in any host). + +## Subsystem 2 — Settings / config + +**Files:** `packages/ui/config/configStore.ts`, `config/settings.ts`, `components/Settings.tsx`, `components/settings/HooksTab.tsx`. + +- **Seam A — config write-back:** `configStore.scheduleServerSync` POSTs `/api/config` (L118) after a 300ms debounce with `deepMerge` batching. Inject **only the final fetch** via `setServerSync(fn)`; **keep singleton construction, eager cookie reads (constructor L44-59), the 300ms debounce, and `deepMerge` byte-identical** — a naive per-`set()` fetch breaks multi-setting batching. +- **Seam B — obsidian vault detect:** `Settings.tsx` `fetch('/api/obsidian/vaults')` (L745-760). Optional `onDetectObsidianVaults?`; **keep the `useEffect [obsidian.enabled]` dep and the auto-select-first-vault branch verbatim** (changing the dep re-triggers or kills auto-select). +- **Already host-controllable:** cookie storage is swappable (Phase 2 `setStorageBackend`, literal `plannotator-*` keys); identity is swappable (Phase 5 `setIdentityProvider`); `settings.ts` is pure (no fetch); server identity seeded via `configStore.init(serverConfig)`. +- **PLANNOTATOR-ONLY (out of scope):** `HooksTab.tsx` (`/api/hooks/status`, `/api/config` pfmReminder) — mounted only `mode==='plan'`, never exported to hosts. + +## Subsystem 3 — Sharing / export / notes + +**Files:** `components/ExportModal.tsx`, `utils/sharing.ts`, `hooks/useSharing.ts`, `components/ImportModal.tsx`, `components/OpenInAppButton.tsx`, `utils/{obsidian,bear,octarine,callback,defaultNotesApp}.ts`. + +- **Seam — save to notes:** `ExportModal.tsx` `fetch('/api/save-notes')` (L150). Optional `onSaveToNotes?` returning `{success, error}`; **keep `showNotesTab = isApiMode && !!markdown` (L83) byte-for-byte** — do not re-base the gate on the new prop. +- **Already portable:** `sharing.ts` is fully parameterized (`shareBaseUrl`/`pasteApiUrl` params, defaults to Plannotator URLs); `useSharing` is prop-driven; `ImportModal` is callback-driven (`onImport`); `obsidian/bear/octarine/callback/defaultNotesApp` are pure storage/format helpers. The `/p/` short-URL routing in `useSharing` is Plannotator's convention but is `pasteApiUrl`-injectable. +- **PLANNOTATOR-ONLY (out of scope):** `OpenInAppButton.tsx` (`/api/open-in`, `/api/open-in/apps`, local-CLI file opening) — host-only, stub/omit for other hosts. + +## Subsystem 4 — Ask AI (riskiest) + +**Files:** `packages/ui/hooks/useAIChat.ts`, `components/ai/*`, `utils/aiProvider.ts`, `utils/aiChatFormat.ts`. + +- **Seam — AI transport:** five `/api/ai/*` fetches in `useAIChat`: `/api/ai/session` (L134), `/api/ai/query` (L213), `/api/ai/abort` (L153 supersede + L350 standalone), `/api/ai/permission` (L365). Inject an `AITransport` (session/query/abort/permission), default = today's fetches. +- **DO NOT TOUCH (verbatim, stays in hook):** the **SSE reader loop** (L233-304 — buffers partial lines, dispatches `text_delta|text|permission_request|error|result`, mutates React state per message); the **epoch/createRequest guards** (refs L109-110; checks L152, L208; resets L376-390); the **supersede-abort fetch position** (L153-158 — must stay inside `createSession` immediately after the epoch check, or the orphaned session leaks). Only the *transport* (the fetch calls) is parametrized; the streaming consumption is not. +- **HOST-OWNED (stays in App.tsx, not the lib):** `/api/ai/capabilities` (only called by App.tsx — editor L2261, review L499); `resolveAIProviderSelection` + cookie `aiConfig` init (`aiProvider.ts`, read by App.tsx). The hook never reads cookies or calls capabilities. +- **Already portable:** `DocumentAIChatPanel`, `AIProviderBar` (fully prop-driven); `aiProvider.ts`, `aiChatFormat.ts` (pure). +- **Existing reuse:** `review-editor` already reuses `useAIChat` via a thin patch-wrapper (`review-editor/hooks/useAIChat.ts` → `context: {mode:'code-review', review:{patch}}`). + +## Explicitly OUT of Phase 6 scope (Plannotator-only / different feature) +- `OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github.com/backnotprop release check — no seam), `useAgents`/`useAgentJobs` (code-review agent jobs — a review-editor feature, not document-UI). These stay host-owned; a reusing host simply doesn't mount them. + +## Per-seam summary +| Subsystem | Seam | Wire | Verbatim-keep | +|---|---|---|---| +| Versions | usePlanDiff fetchers + PlanDiffViewer vscode | `/api/plan/version(s)`, `/api/plan/vscode-diff` | alert/silent error asymmetry; + move block/raw diff CSS into the package | +| Settings | configStore `setServerSync`; Settings obsidian-detect | `/api/config`, `/api/obsidian/vaults` | 300ms debounce + deepMerge + constructor; `[obsidian.enabled]` dep + auto-select | +| Sharing | ExportModal `onSaveToNotes` | `/api/save-notes` | `showNotesTab` gate (L83) | +| Ask AI | useAIChat `AITransport` | 5× `/api/ai/*` | SSE reader loop, epoch guards, supersede-abort position; capabilities/provider-resolution stay host | diff --git a/adr/research/synthesis-document-ui-extras-20260623-100827.md b/adr/research/synthesis-document-ui-extras-20260623-100827.md new file mode 100644 index 000000000..6e3454684 --- /dev/null +++ b/adr/research/synthesis-document-ui-extras-20260623-100827.md @@ -0,0 +1,47 @@ +# Synthesis: Phase 6 Extras + +Date: 2026-06-23 + +> Synthesizes `SPIKE-document-ui-extras-system-20260623-100827.md` against the verified plan and ADR 004. Settles Phase 6's shape. + +## The shape + +Phase 6 follows the now-proven pattern: a handful of module-level/prop seams, each defaulting to today's behavior. The research confirms most of these four subsystems is **already portable** (pure utils, prop-driven components, parameterized sharing). The real work is **five seams + one CSS move**, and a clear list of **Plannotator-only pieces that simply stay home**. + +## What we will do + +### 1. Versions / diff (do first — highest value, has the CSS wrinkle) +- Inject optional version fetchers into `usePlanDiff` (default → `/api/plan/version(s)`), keeping the alert/silent error asymmetry verbatim. +- Optional `onOpenVscodeDiff?` on `PlanDiffViewer` (default → `/api/plan/vscode-diff`). +- **CSS move:** relocate the block-level/raw diff classes (`.plan-diff-added/removed/modified/unchanged`, `.plan-diff-line-*`) from `packages/editor/index.css` into `packages/ui/theme.css`, co-located with the existing `.plan-diff-word-*`. Plannotator imports `theme.css`, so it stays byte-identical; the diff components become reusable without app-shell CSS. (Verify Plannotator's build doesn't double-define / drops the index.css copies.) + +### 2. Settings / config +- `configStore.setServerSync(fn)` injecting only the final `/api/config` POST; keep singleton, eager cookie reads, 300ms debounce, and `deepMerge` verbatim. +- Optional `onDetectObsidianVaults?` on `Settings`; keep the `[obsidian.enabled]` effect dep and auto-select-first-vault verbatim. +- (Storage + identity already swappable; `settings.ts` pure — nothing to do there.) + +### 3. Sharing / export / notes +- Optional `onSaveToNotes?` on `ExportModal`; keep `showNotesTab = isApiMode && !!markdown` verbatim. +- (Sharing utils + `useSharing` + `ImportModal` + the notes-app helpers are already portable — confirm noop.) + +### 4. Ask AI (last, riskiest) +- Inject an `AITransport` (session/query/abort/permission) into `useAIChat`, default = today's five fetches. **Leave the SSE reader loop, epoch/createRequest guards, and the supersede-abort position untouched in the hook.** Capabilities fetch and provider resolution stay host-owned (they already live in App.tsx). + +## What we will NOT do (Plannotator-only — they stay home) +`OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github release check), `useAgents`/`useAgentJobs` (code-review agent jobs). A reusing host doesn't import them. No work. + +## Risk read & ordering +- **Versions/diff** — low logic risk; the CSS move is the only non-trivial bit (verify Plannotator's diff still renders identically after relocating the classes). Do first. +- **Settings** — low; mind the debounce/deepMerge (don't move them) and the obsidian effect dep. +- **Sharing** — small (one seam); rest is noop. +- **Ask AI** — the riskiest by far: a streaming state machine. Treat exactly like the Phase-5 external transport — wrap only the wire, copy nothing, leave the reader loop and epoch guards verbatim. Do last, eyeball the AI panel end-to-end. + +## Open decisions for the spec/ADR +1. **CSS move vs. document-as-contract.** Recommend **move** (`.plan-diff-*` into `theme.css`) — it's a pure relocation that keeps Plannotator identical and makes the diff truly reusable. The broader `.annotation-highlight` CSS (used by the Viewer everywhere) is a related contract; recommend moving it into `theme.css` too in the same pass, since it's required by the already-shipped Viewer in any host (closes a latent gap from Phases 3/5). Confirm in spec. +2. **Scope confirmation:** agree the five Plannotator-only pieces are out (recommended). +3. **One PR or per-seam commits:** recommend per-seam verify-gated commits (versions → settings → sharing → AI), like Phase 5. + +## References +- Spike: `adr/research/SPIKE-document-ui-extras-system-20260623-100827.md` +- Verified plan (Phase 6 / steps 6-8,12): `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md` +- Decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/specs/document-ui-extras-seam-20260623-100827.md b/adr/specs/document-ui-extras-seam-20260623-100827.md new file mode 100644 index 000000000..cea8c1848 --- /dev/null +++ b/adr/specs/document-ui-extras-seam-20260623-100827.md @@ -0,0 +1,53 @@ +# Spec: Phase 6 Extras Seams + +Date: 2026-06-23 · Status: Draft (iterate before implementing) + +> Implementation spec for Phase 6 of the `@plannotator/ui` reuse effort. Grounded in `SPIKE-document-ui-extras-system-20260623-100827.md` + its synthesis. Governed by ADR 004. THE LAW: each seam defaults to today's literal behavior; Plannotator passes nothing and is byte-for-byte unchanged. Move + decouple, never rewrite — especially the AI reader loop and the configStore batching. + +## Scope +**In (5 seams + 1 CSS move):** version fetchers, vscode-diff action, the block/raw diff CSS relocation, config write-back, obsidian-detect, save-to-notes, AI transport. +**Confirmed noop (already portable):** planDiffEngine + all plan-diff render components, sharing.ts/useSharing/ImportModal, obsidian/bear/octarine/callback/defaultNotesApp utils, settings.ts, DocumentAIChatPanel/AIProviderBar, aiProvider/aiChatFormat. +**Out (Plannotator-only, stay home):** OpenInAppButton, HooksTab, useUpdateCheck, useAgents, useAgentJobs. + +## Order of work (lowest risk first; AI last) + +### Step 1 — Versions / diff +**Files:** `packages/ui/hooks/usePlanDiff.ts`, `packages/ui/components/plan-diff/PlanDiffViewer.tsx`, `packages/editor/index.css` → `packages/ui/theme.css`. +- **1a. Version fetchers.** Add an optional `fetchers?: { fetchVersion?, fetchVersions? }` arg to `usePlanDiff` (or module-level setters following the seam pattern). Default = today's `fetch('/api/plan/version?v=N')` and `fetch('/api/plan/versions')` verbatim. **Keep error asymmetry:** `selectBaseVersion` still `alert()`s on failure; `fetchVersions` still silent. +- **1b. VS Code diff.** Add optional `onOpenVscodeDiff?: (baseVersion: number) => Promise<{ ok?: boolean; error?: string }>` to `PlanDiffViewer`; default = today's `fetch('/api/plan/vscode-diff')`. Plannotator passes nothing → unchanged (button still works). +- **1c. CSS move.** Cut `.plan-diff-added/removed/modified/unchanged` and `.plan-diff-line-added/removed` from `editor/index.css` (L168-230) into `packages/ui/theme.css` (next to `.plan-diff-word-*`). Also move `.annotation-highlight*` (L119-157) into `theme.css` (it's required by the shared Viewer in any host — closes a latent gap). Plannotator imports `theme.css`, so it stays identical; verify no double-definition remains in `index.css`. +- **Parity guardrail:** Plannotator's plan diff renders pixel-identical (block borders, raw +/-, word-level, annotation highlights); no caller passes fetchers/onOpenVscodeDiff. Eyeball: deny→resubmit a plan, toggle diff (clean/classic/raw), annotate a diff block, VS Code button still works. + +### Step 2 — Settings / config +**Files:** `packages/ui/config/configStore.ts`, `packages/ui/components/Settings.tsx`. +- **2a. Config write-back.** Add `setServerSync(fn)` (and a default = the current inline `fetch('/api/config', POST)`); `scheduleServerSync` calls the injected fn for the final POST only. **Keep the 300ms debounce, `pendingServerWrites` deepMerge batching, singleton construction, and eager cookie reads byte-identical.** +- **2b. Obsidian detect.** Add optional `onDetectObsidianVaults?: () => Promise` to `Settings`; default = today's `fetch('/api/obsidian/vaults')`. **Keep the `useEffect` dep `[obsidian.enabled]` and the auto-select-first-vault branch verbatim.** +- **Parity guardrail:** settings still POST `/api/config` with identical batching/timing; vault auto-select still fires on enable. No caller overrides. Eyeball: change a setting → it persists; enable Obsidian → vaults detected + first auto-selected. + +### Step 3 — Sharing / export / notes +**Files:** `packages/ui/components/ExportModal.tsx`. +- Add optional `onSaveToNotes?: (payload) => Promise<{ results?: Record }>` (match today's response shape); default = today's `fetch('/api/save-notes')`. **Keep `showNotesTab = isApiMode && !!markdown` (L83) byte-for-byte** — do not re-base on the new prop. +- (sharing.ts/useSharing/ImportModal/notes-app utils confirmed noop.) +- **Parity guardrail:** notes tab visibility unchanged; save returns identical `{success, error}`. Eyeball: Export → Notes tab shows when expected → save to Obsidian works. + +### Step 4 — Ask AI (riskiest, last) +**Files:** `packages/ui/hooks/useAIChat.ts`. +- Add an `AITransport` (session/query/abort/permission) + module-level default reproducing today's five `/api/ai/*` fetches verbatim. Route the fetch calls through it. +- **DO NOT TOUCH:** the SSE reader loop (L233-304), the epoch/createRequest guards (refs L109-110; checks L152/L208; resets L376-390), and the supersede-abort fetch **inside `createSession` immediately after the epoch check** (L153-158). Only the wire is parametrized. +- **Stays host-owned:** `/api/ai/capabilities` and `resolveAIProviderSelection`/cookie `aiConfig` (already in App.tsx — do not pull into the lib). +- **Parity guardrail:** identical AI traffic; streaming, permissions, abort, and session-supersede all behave as today. No caller overrides. Eyeball: ask the AI a question (streams), trigger a permission, switch questions mid-stream (supersede), abort. + +## Definition of done (Phase 6) +- The five seams are host-overridable, each defaulting to today's behavior; the diff CSS lives in the package. +- Plannotator byte-unchanged: full `bun test` ≥ baseline (1620/0); typecheck; builds; `App.tsx` changes minimal/empty (ideally zero — module-level/optional-prop defaults). +- Eyeball: plan diff (all modes + vscode + annotate), settings persist + obsidian detect, save-to-notes, AI chat (stream/permission/abort/supersede). +- The five Plannotator-only pieces remain host-owned (untouched). + +## Per-step parity guardrail (run after each) +`bun run typecheck` · `bun test` ≥ 1620/0 (+ touched suite green) · `bun run --cwd apps/review build && bun run build:hook` · `git diff packages/editor/App.tsx` minimal/empty · the step's manual eyeball. + +## Open questions (resolve in ADR) +1. CSS: move `.plan-diff-*` + `.annotation-highlight` into `theme.css` (recommended) vs. document-as-contract. +2. Confirm the five Plannotator-only exclusions (recommended). +3. Per-seam commits (recommended) vs. one PR. +4. usePlanDiff seam shape: extra hook arg vs. module-level setter (lean: optional arg for the fetchers since usePlanDiff already takes args; module-level setters elsewhere). From 375432302c70ef9d8c97d7b0f427a763883cf740 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:21:42 -0700 Subject: [PATCH 19/67] =?UTF-8?q?docs(adr):=20accept=20ADR=20006=20?= =?UTF-8?q?=E2=80=94=20make=20extras=20(versions/settings/sharing/AI)=20ho?= =?UTF-8?q?st-overridable=20(Phase=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five seams + one CSS move, each defaulting to today's behavior. AI reader loop + epoch guards and configStore debounce/deepMerge stay verbatim. Five Plannotator- only pieces stay home. Locks the recommended choices from the Phase 6 spec. --- ...extras-host-overridable-20260623-102104.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 adr/decisions/006-make-extras-host-overridable-20260623-102104.md diff --git a/adr/decisions/006-make-extras-host-overridable-20260623-102104.md b/adr/decisions/006-make-extras-host-overridable-20260623-102104.md new file mode 100644 index 000000000..2f9637ec1 --- /dev/null +++ b/adr/decisions/006-make-extras-host-overridable-20260623-102104.md @@ -0,0 +1,42 @@ +# 006. Make Extras (Versions, Settings, Sharing, Ask AI) Host-Overridable (Phase 6) + +Date: 2026-06-23 + +## Status + +Accepted + +## Context + +ADR 004 set the plan: make `@plannotator/ui` reusable by Workspaces by lifting each Plannotator wire up to an optional override defaulting to today's behavior, never changing Plannotator. Phases 0–5 did this for packaging, image/storage, rendering, file tree, and comments. Phase 6 is the remaining "extras": versions/plan diff, settings/config, sharing/export/notes, and Ask AI. + +Five-research probes (`adr/research/SPIKE-document-ui-extras-system-20260623-100827.md`, synthesized in `adr/research/synthesis-document-ui-extras-20260623-100827.md`) found these four subsystems are **mostly already portable** — `planDiffEngine` and all plan-diff render components, the sharing utils/`useSharing`/`ImportModal`, the notes-app helpers, `settings.ts`, the AI chat components, and `aiProvider`/`aiChatFormat` are pure or prop-driven. The actual coupling is a small set of wires, plus one CSS wrinkle: the block-level/raw plan-diff CSS lives in the app shell (`packages/editor/index.css`), not the package. + +## Decision + +Make the extras host-overridable through **five seams plus one CSS move**, each defaulting to today's behavior; Plannotator passes nothing and stays byte-for-byte unchanged. Land per-seam, verify-gated, lowest-risk first. + +1. **Versions / diff.** Optional version fetchers on `usePlanDiff` (default → `/api/plan/version(s)`, keeping the `selectBaseVersion` alert vs `fetchVersions` silent error asymmetry verbatim) and an optional `onOpenVscodeDiff?` on `PlanDiffViewer` (default → `/api/plan/vscode-diff`). **CSS move:** relocate the block-level/raw diff classes (`.plan-diff-added/removed/modified/unchanged`, `.plan-diff-line-*`) and `.annotation-highlight*` from `editor/index.css` into `packages/ui/theme.css` (co-located with `.plan-diff-word-*`); Plannotator imports `theme.css`, so its diff and highlights render identically while the components become reusable without app-shell CSS. + +2. **Settings / config.** `configStore.setServerSync(fn)` injecting only the final `/api/config` POST, keeping the singleton construction, eager cookie reads, 300ms debounce, and `deepMerge` batching byte-identical. Optional `onDetectObsidianVaults?` on `Settings`, keeping the `[obsidian.enabled]` effect dep and auto-select-first-vault verbatim. + +3. **Sharing / notes.** Optional `onSaveToNotes?` on `ExportModal` (matching today's `{results:{success,error}}` shape), keeping `showNotesTab = isApiMode && !!markdown` byte-for-byte. (Sharing utils, `useSharing`, `ImportModal`, and the notes-app helpers are confirmed noop.) + +4. **Ask AI (last, riskiest).** Inject an `AITransport` (session/query/abort/permission) into `useAIChat`, default = today's five `/api/ai/*` fetches. **The SSE reader loop, the epoch/createRequest guards, and the supersede-abort fetch position inside `createSession` stay untouched in the hook** — only the wire is parametrized. Capabilities fetch and provider resolution stay host-owned (already in App.tsx). + +**Out of Phase 6 (Plannotator-only — they stay home, no work):** `OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github release check), `useAgents` and `useAgentJobs` (code-review agent jobs). A reusing host simply does not import them. + +## Consequences + +- Workspaces can optionally reuse version-diff review, the settings panel, save-to-notes, and the AI chat by implementing the corresponding fetchers/callbacks/transport — but none of it is required for the already-shipped core (docs, tree, editing, comments). +- Plannotator is unchanged: every seam defaults to today's literal behavior; the AI streaming state machine and configStore batching move nowhere; the CSS relocation is a pure cut-and-paste that Plannotator still imports via `theme.css`. +- The diff components (and the Viewer's annotation highlights) become self-styling from the package — closing a latent CSS-contract gap from earlier phases. +- The parity bar per seam: full `bun test` stays at baseline (1620/0), typecheck and builds pass, `packages/editor/App.tsx` changes stay minimal/empty, and an eyeball confirms the surface — plan diff (all modes + VS Code + diff annotations), settings persistence + obsidian detect, save-to-notes, and AI chat (stream / permission / abort / mid-stream supersede). +- After Phase 6, the document UI is feature-complete for reuse; the remaining work is Phase 7 (publish) and the parked `@plannotator/ai` / `@plannotator/shared` publish-vs-inline decision. + +## References + +- Spike: `adr/research/SPIKE-document-ui-extras-system-20260623-100827.md` +- Synthesis: `adr/research/synthesis-document-ui-extras-20260623-100827.md` +- Spec: `adr/specs/document-ui-extras-seam-20260623-100827.md` +- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` From a9f51ddeb6fffecc0046c4e5458287ac92e5957f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:36:58 -0700 Subject: [PATCH 20/67] feat(ui): make version fetchers + vscode-diff host-overridable; move diff CSS into package (Phase 6 versions) usePlanDiff gains optional fetchers (default /api/plan/version(s), error asymmetry kept: selectBaseVersion alerts, fetchVersions silent). PlanDiffViewer gains optional onOpenVscodeDiff (default /api/plan/vscode-diff). Relocate .annotation-highlight* + .plan-diff-* block/raw CSS from editor/index.css into ui/theme.css (next to .plan-diff-word-*) so the diff/highlights are self-styling from the package. Verified: relocated CSS gone from index.css, present in shipped bundle (33x), diff renders identical; typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched. --- packages/editor/index.css | 69 +---------- .../components/plan-diff/PlanDiffViewer.tsx | 41 ++++--- packages/ui/hooks/usePlanDiff.ts | 61 +++++++--- packages/ui/theme.css | 114 ++++++++++++++++++ 4 files changed, 186 insertions(+), 99 deletions(-) diff --git a/packages/editor/index.css b/packages/editor/index.css index b235527b6..33e8eb81b 100644 --- a/packages/editor/index.css +++ b/packages/editor/index.css @@ -118,74 +118,7 @@ pre code.hljs .hljs-code { /* Annotation highlights moved to packages/ui/theme.css (shared with the code-review description annotations). */ -/* ======================================== - Plan Diff Styles - ======================================== */ - -/* Clean diff view - added content */ -.plan-diff-added { - border-left: 3px solid var(--success); - background: oklch(from var(--success) l c h / 0.06); - padding-left: 0.75rem; - border-radius: 0 0.25rem 0.25rem 0; - margin: 0.25rem 0; -} -.light .plan-diff-added { - background: oklch(from var(--success) l c h / 0.06); -} - -/* Clean diff view - removed content */ -.plan-diff-removed { - border-left: 3px solid var(--destructive); - background: oklch(from var(--destructive) l c h / 0.06); - padding-left: 0.75rem; - border-radius: 0 0.25rem 0.25rem 0; - margin: 0.25rem 0; -} -.light .plan-diff-removed { - background: oklch(from var(--destructive) l c h / 0.06); -} - -/* Clean diff view - modified content (mix of additions and deletions in one - block, rendered inline via word-level diff). - Deliberate asymmetry with added/removed: add/remove are BLOCK-scope events - — the whole block matters, so a loud fill is the right signal. Modify is - a WORD-scope event — the words matter, and the inline red-struck / - green-highlighted word markers already grab attention. A block-level fill - would compete with that inline work; an amber gutter on a normal - background says "look inside, the change is in the text" while staying - consistent with the green/red/yellow diff convention. */ -.plan-diff-modified { - border-left: 3px solid oklch(from var(--warning) l c h / 0.75); - background: transparent; - padding-left: 0.75rem; - border-radius: 0 0.25rem 0.25rem 0; - margin: 0.25rem 0; -} - -/* Clean diff view - unchanged (dimmed) */ -.plan-diff-unchanged { - /* handled via opacity in component */ -} - -/* Raw diff view - line styles */ -.plan-diff-line-added { - background: oklch(from var(--success) l c h / 0.15); - color: var(--success); -} -.plan-diff-line-removed { - background: oklch(from var(--destructive) l c h / 0.15); - color: var(--destructive); - opacity: 0.75; - text-decoration: line-through; - text-decoration-color: oklch(from var(--destructive) l c h / 0.4); -} -.light .plan-diff-line-added { - background: oklch(from var(--success) l c h / 0.12); -} -.light .plan-diff-line-removed { - background: oklch(from var(--destructive) l c h / 0.12); -} +/* Plan Diff Styles also moved to packages/ui/theme.css (imported above). */ /* ======================================== Sidebar diff --git a/packages/ui/components/plan-diff/PlanDiffViewer.tsx b/packages/ui/components/plan-diff/PlanDiffViewer.tsx index 2830e8439..e40558e41 100644 --- a/packages/ui/components/plan-diff/PlanDiffViewer.tsx +++ b/packages/ui/components/plan-diff/PlanDiffViewer.tsx @@ -34,8 +34,28 @@ interface PlanDiffViewerProps { onSelectAnnotation?: (id: string | null) => void; selectedAnnotationId?: string | null; mode?: EditorMode; + onOpenVscodeDiff?: (baseVersion: number) => Promise<{ ok?: boolean; error?: string }>; } +const defaultOpenVscodeDiff = async ( + baseVersion: number +): Promise<{ ok?: boolean; error?: string }> => { + try { + const res = await fetch("/api/plan/vscode-diff", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ baseVersion }), + }); + const data = (await res.json()) as { ok?: boolean; error?: string }; + if (!res.ok || data.error) { + return { error: data.error || "Failed to open VS Code diff" }; + } + return { ok: true }; + } catch { + return { error: "Failed to connect to server" }; + } +}; + export const PlanDiffViewer: React.FC = ({ diffBlocks, diffStats, @@ -51,6 +71,7 @@ export const PlanDiffViewer: React.FC = ({ onSelectAnnotation, selectedAnnotationId, mode, + onOpenVscodeDiff, }) => { const [vscodeDiffLoading, setVscodeDiffLoading] = useState(false); const [vscodeDiffError, setVscodeDiffError] = useState(null); @@ -58,24 +79,14 @@ export const PlanDiffViewer: React.FC = ({ const canOpenVscodeDiff = baseVersion != null; const handleOpenVscodeDiff = async () => { - if (!canOpenVscodeDiff) return; + if (!canOpenVscodeDiff || baseVersion == null) return; setVscodeDiffLoading(true); setVscodeDiffError(null); - try { - const res = await fetch("/api/plan/vscode-diff", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ baseVersion }), - }); - const data = await res.json() as { ok?: boolean; error?: string }; - if (!res.ok || data.error) { - setVscodeDiffError(data.error || "Failed to open VS Code diff"); - } - } catch { - setVscodeDiffError("Failed to connect to server"); - } finally { - setVscodeDiffLoading(false); + const result = await (onOpenVscodeDiff ?? defaultOpenVscodeDiff)(baseVersion); + if (result.error) { + setVscodeDiffError(result.error); } + setVscodeDiffLoading(false); }; return ( diff --git a/packages/ui/hooks/usePlanDiff.ts b/packages/ui/hooks/usePlanDiff.ts index b5632b24b..a569b6874 100644 --- a/packages/ui/hooks/usePlanDiff.ts +++ b/packages/ui/hooks/usePlanDiff.ts @@ -48,11 +48,51 @@ export interface UsePlanDiffReturn { fetchVersions: () => Promise; } +export interface PlanDiffFetchers { + /** Fetch a specific version's plan content. Default → GET /api/plan/version?v=N */ + fetchVersion?: (version: number) => Promise<{ plan: string; version: number }>; + /** Fetch the version list. Default → GET /api/plan/versions */ + fetchVersions?: () => Promise<{ + project: string; + slug: string; + versions: VersionEntry[]; + }>; +} + +const defaultFetchVersion = async ( + version: number +): Promise<{ plan: string; version: number }> => { + const res = await fetch(`/api/plan/version?v=${version}`); + if (!res.ok) { + throw new Error(`Failed to load version ${version}.`); + } + return (await res.json()) as { plan: string; version: number }; +}; + +const defaultFetchVersions = async (): Promise<{ + project: string; + slug: string; + versions: VersionEntry[]; +}> => { + const res = await fetch("/api/plan/versions"); + if (!res.ok) { + throw new Error("Failed to load versions."); + } + return (await res.json()) as { + project: string; + slug: string; + versions: VersionEntry[]; + }; +}; + export function usePlanDiff( currentPlan: string, initialPreviousPlan: string | null, - versionInfo: VersionInfo | null + versionInfo: VersionInfo | null, + fetchers?: PlanDiffFetchers ): UsePlanDiffReturn { + const fetchVersionImpl = fetchers?.fetchVersion ?? defaultFetchVersion; + const fetchVersionsImpl = fetchers?.fetchVersions ?? defaultFetchVersions; const [diffBasePlan, setDiffBasePlan] = useState( initialPreviousPlan ); @@ -95,12 +135,7 @@ export function usePlanDiff( setIsSelectingVersion(true); setFetchingVersion(version); try { - const res = await fetch(`/api/plan/version?v=${version}`); - if (!res.ok) { - alert(`Failed to load version ${version}.`); - return; - } - const data = (await res.json()) as { plan: string; version: number }; + const data = await fetchVersionImpl(version); setDiffBasePlan(data.plan); setDiffBaseVersion(version); } catch { @@ -110,26 +145,20 @@ export function usePlanDiff( setFetchingVersion(null); } }, - [] + [fetchVersionImpl] ); const fetchVersions = useCallback(async () => { setIsLoadingVersions(true); try { - const res = await fetch("/api/plan/versions"); - if (!res.ok) return; - const data = (await res.json()) as { - project: string; - slug: string; - versions: VersionEntry[]; - }; + const data = await fetchVersionsImpl(); setVersions(data.versions); } catch { // Failed to fetch versions } finally { setIsLoadingVersions(false); } - }, []); + }, [fetchVersionsImpl]); return { diffBaseVersion, diff --git a/packages/ui/theme.css b/packages/ui/theme.css index 74ee9d30e..6928333cb 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -520,6 +520,120 @@ html:not(.transitions-ready) * { background-color: color-mix(in oklab, var(--destructive) 20%, var(--muted)); } +/* Annotation highlights */ +.annotation-highlight { + border-radius: 2px; + padding: 0 2px; + margin: 0 -2px; +} + +.annotation-highlight.deletion { + background: oklch(from var(--destructive) l c h / 0.35); + text-decoration: line-through; + text-decoration-color: var(--destructive); + text-decoration-thickness: 2px; +} + +.annotation-highlight.comment { + background: oklch(0.70 0.18 60 / 0.3); + border-bottom: 2px solid var(--accent); +} + +/* Light mode: softer highlights */ +.light .annotation-highlight.deletion { + background: oklch(0.65 0.22 25 / 0.2); +} + +.light .annotation-highlight.comment { + background: oklch(0.70 0.20 60 / 0.15); +} + +.annotation-highlight.focused { + background: oklch(from var(--focus-highlight) l c h / 0.45) !important; + box-shadow: 0 0 8px oklch(from var(--focus-highlight) l c h / 0.4); + border-bottom: 2px solid var(--focus-highlight); + filter: none; +} + +.light .annotation-highlight.focused { + background: oklch(0.70 0.22 200 / 0.3) !important; + box-shadow: 0 0 6px oklch(0.60 0.20 200 / 0.3); +} + +.annotation-highlight:hover { + filter: brightness(1.2); + cursor: pointer; +} + +/* ======================================== + Plan Diff Styles + ======================================== */ + +/* Clean diff view - added content */ +.plan-diff-added { + border-left: 3px solid var(--success); + background: oklch(from var(--success) l c h / 0.06); + padding-left: 0.75rem; + border-radius: 0 0.25rem 0.25rem 0; + margin: 0.25rem 0; +} +.light .plan-diff-added { + background: oklch(from var(--success) l c h / 0.06); +} + +/* Clean diff view - removed content */ +.plan-diff-removed { + border-left: 3px solid var(--destructive); + background: oklch(from var(--destructive) l c h / 0.06); + padding-left: 0.75rem; + border-radius: 0 0.25rem 0.25rem 0; + margin: 0.25rem 0; +} +.light .plan-diff-removed { + background: oklch(from var(--destructive) l c h / 0.06); +} + +/* Clean diff view - modified content (mix of additions and deletions in one + block, rendered inline via word-level diff). + Deliberate asymmetry with added/removed: add/remove are BLOCK-scope events + — the whole block matters, so a loud fill is the right signal. Modify is + a WORD-scope event — the words matter, and the inline red-struck / + green-highlighted word markers already grab attention. A block-level fill + would compete with that inline work; an amber gutter on a normal + background says "look inside, the change is in the text" while staying + consistent with the green/red/yellow diff convention. */ +.plan-diff-modified { + border-left: 3px solid oklch(from var(--warning) l c h / 0.75); + background: transparent; + padding-left: 0.75rem; + border-radius: 0 0.25rem 0.25rem 0; + margin: 0.25rem 0; +} + +/* Clean diff view - unchanged (dimmed) */ +.plan-diff-unchanged { + /* handled via opacity in component */ +} + +/* Raw diff view - line styles */ +.plan-diff-line-added { + background: oklch(from var(--success) l c h / 0.15); + color: var(--success); +} +.plan-diff-line-removed { + background: oklch(from var(--destructive) l c h / 0.15); + color: var(--destructive); + opacity: 0.75; + text-decoration: line-through; + text-decoration-color: oklch(from var(--destructive) l c h / 0.4); +} +.light .plan-diff-line-added { + background: oklch(from var(--success) l c h / 0.12); +} +.light .plan-diff-line-removed { + background: oklch(from var(--destructive) l c h / 0.12); +} + /* Raw HTML blocks (CommonMark Type 6) rendered via dangerouslySetInnerHTML. Minimal typography so
,
, nested lists etc. inherit sensible spacing without fighting the surrounding prose styles. */ From e996241b9e753dd6cb039f7f07cc740cf2d6f659 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:36:58 -0700 Subject: [PATCH 21/67] feat(ui): make config write-back + obsidian-detect host-overridable (Phase 6 settings) configStore.setServerSync(fn) injects only the terminal POST /api/config; the 300ms debounce, deepMerge batching, singleton, and eager cookie reads stay verbatim. Settings gains optional onDetectObsidianVaults (default /api/obsidian/vaults), with the [obsidian.enabled] effect dep + auto-select-first-vault verbatim. No override caller => Plannotator unchanged. typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/components/Settings.tsx | 17 +++++++++------- packages/ui/config/configStore.ts | 30 +++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index c4bb5bd2d..cdbfb8783 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -91,6 +91,8 @@ interface SettingsProps { * (base ref unresolvable) — the Git tab shows a note that the Git-status * preference can't take effect in THIS repo. */ sinceBaseUnavailable?: boolean; + /** Override Obsidian vault detection (default = GET /api/obsidian/vaults). */ + onDetectObsidianVaults?: () => Promise; } // --- Review-mode Display tab (diff display options) --- @@ -649,7 +651,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, onDetectObsidianVaults }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -784,13 +786,14 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange useEffect(() => { if (obsidian.enabled && detectedVaults.length === 0 && !vaultsLoading) { setVaultsLoading(true); - fetch('/api/obsidian/vaults') - .then(res => res.json()) - .then((data: { vaults: string[] }) => { - setDetectedVaults(data.vaults || []); + const detect = onDetectObsidianVaults + ?? (() => fetch('/api/obsidian/vaults').then(res => res.json()).then((data: { vaults: string[] }) => data.vaults || [])); + detect() + .then((vaults: string[]) => { + setDetectedVaults(vaults || []); // Auto-select first vault if none set - if (data.vaults?.length > 0 && !obsidian.vaultPath) { - handleObsidianChange({ vaultPath: data.vaults[0] }); + if (vaults?.length > 0 && !obsidian.vaultPath) { + handleObsidianChange({ vaultPath: vaults[0] }); } }) .catch(() => setDetectedVaults([])) diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index d2c6afd4a..aea9d7f95 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -29,6 +29,20 @@ function deepMerge(target: Record, source: Record) => void; + +/** Default = today's inline POST /api/config (best-effort). + keepalive lets the request outlive page teardown (pagehide flush). */ +const defaultServerSync: ServerSyncFn = (payload) => { + fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + keepalive: true, + }).catch(() => {}); // best-effort +}; + /** Infer the value type from a SettingDef */ type SettingValue = SettingsMap[K] extends { defaultValue: infer D } ? D extends (...args: unknown[]) => infer R ? R : D @@ -41,6 +55,7 @@ class ConfigStore { private pendingServerWrites: Record = {}; private serverSyncTimer: ReturnType | null = null; private pagehideFlushRegistered = false; + private serverSync: ServerSyncFn = defaultServerSync; constructor() { // Eagerly resolve all settings from synchronous sources (cookie > default). @@ -106,6 +121,11 @@ class ConfigStore { return () => this.listeners.delete(listener); } + /** Override the server write-back transport (default = inline POST /api/config). */ + setServerSync(fn: ServerSyncFn): void { + this.serverSync = fn; + } + private notify(): void { this.version++; for (const fn of this.listeners) fn(); @@ -132,15 +152,9 @@ class ConfigStore { this.serverSyncTimer = null; } if (Object.keys(this.pendingServerWrites).length === 0) return; - const payload = JSON.stringify(this.pendingServerWrites); + const payload = { ...this.pendingServerWrites }; this.pendingServerWrites = {}; - // keepalive lets the request outlive page teardown (pagehide flush). - fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: payload, - keepalive: true, - }).catch(() => {}); // best-effort + this.serverSync(payload); } } From efb82e7a1b633503ebed2d7ed47d066f086051b9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:36:58 -0700 Subject: [PATCH 22/67] feat(ui): make save-to-notes host-overridable (Phase 6 sharing) ExportModal gains optional onSaveToNotes (default = verbatim POST /api/save-notes); showNotesTab = isApiMode && !!markdown kept byte-for-byte. Sharing utils already parameterized (noop). No override caller => Plannotator unchanged. typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/components/ExportModal.tsx | 32 +++++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/ui/components/ExportModal.tsx b/packages/ui/components/ExportModal.tsx index 4d86e6fe1..3d1d28cd7 100644 --- a/packages/ui/components/ExportModal.tsx +++ b/packages/ui/components/ExportModal.tsx @@ -13,6 +13,28 @@ import { getOctarineSettings } from '../utils/octarine'; import { wrapFeedbackForAgent } from '../utils/parser'; import { OverlayScrollArea } from './OverlayScrollArea'; +/** POST body shape sent to the notes endpoint (mirrors what the Notes tab builds today). */ +interface SaveToNotesPayload { + obsidian?: object; + bear?: object; + octarine?: object; +} + +/** Parsed response from the notes endpoint. */ +interface SaveToNotesResult { + results?: Record; +} + +/** Default save-to-notes wire: today's literal POST to /api/save-notes. */ +async function defaultSaveToNotes(body: SaveToNotesPayload): Promise { + const res = await fetch('/api/save-notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return res.json(); +} + interface ExportModalProps { isOpen: boolean; onClose: () => void; @@ -33,6 +55,8 @@ interface ExportModalProps { markdown?: string; isApiMode?: boolean; initialTab?: Tab; + /** Override the save-to-notes wire. Default: POST /api/save-notes (today's behavior). */ + onSaveToNotes?: (payload: SaveToNotesPayload) => Promise; } type Tab = 'share' | 'annotations' | 'notes'; @@ -56,6 +80,7 @@ export const ExportModal: React.FC = ({ markdown, isApiMode = false, initialTab, + onSaveToNotes = defaultSaveToNotes, }) => { const defaultTab = initialTab || (sharingEnabled ? 'share' : 'annotations'); const [activeTab, setActiveTab] = useState(defaultTab); @@ -147,12 +172,7 @@ export const ExportModal: React.FC = ({ } try { - const res = await fetch('/api/save-notes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json(); + const data = await onSaveToNotes(body); const result = data.results?.[target]; if (result?.success) { From 95378716395154079f46381ab1d19ec1b289314d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:36:58 -0700 Subject: [PATCH 23/67] feat(ui): make Ask AI transport host-overridable (Phase 6 ai) useAIChat gains a module-level AITransport (session/query/abort/permission) + setAITransport/resetAITransport, default = the five /api/ai/* fetches verbatim. The SSE reader loop, epoch/createRequest guards, and the supersede-abort position inside createSession stay untouched. Capabilities + provider-resolution stay host-owned in App.tsx. No override caller => Plannotator unchanged. ai.test.ts 97/0, typecheck pass, 1620 tests/0 fail, builds OK. --- packages/ui/hooks/useAIChat.ts | 122 ++++++++++++++++++++++----------- 1 file changed, 82 insertions(+), 40 deletions(-) diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index 1a6fd347f..87a61fe76 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -116,6 +116,70 @@ function createAbortError(message: string): Error { return err; } +/** + * Transport for the AI chat wire. Each method maps to one Plannotator + * `/api/ai/*` endpoint. The default reproduces today's fetches verbatim; + * a host (e.g. Workspaces) calls `setAITransport` once at startup to route + * AI traffic through its own backend. The SSE reader loop, epoch guards, and + * supersede-abort position in the hook are unaffected — only the wire is swapped. + */ +export interface AITransport { + /** POST /api/ai/session — create or fork a session. */ + session(body: unknown, signal: AbortSignal): Promise; + /** POST /api/ai/query — send a message; returns the streaming SSE response. */ + query(body: unknown, signal: AbortSignal): Promise; + /** POST /api/ai/abort — abort a session (supersede + standalone). Resolves once + the server acknowledges so a following query can await it (best-effort, never rejects). */ + abort(body: unknown): Promise; + /** POST /api/ai/permission — respond to a permission request. Fire-and-forget. */ + permission(body: unknown): void; +} + +/** Default transport — Plannotator's local `/api/ai/*` fetches, verbatim. */ +const defaultAITransport: AITransport = { + session: (body, signal) => + fetch('/api/ai/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }), + query: (body, signal) => + fetch('/api/ai/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }), + abort: (body) => + fetch('/api/ai/abort', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(() => {}), + permission: (body) => { + fetch('/api/ai/permission', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(() => {}); + }, +}; + +// Module-level transport, stable identity. Defaults to Plannotator's behavior so +// the hook and its callers are unchanged. A host overrides it once at startup. +let aiTransport: AITransport = defaultAITransport; + +/** Override the AI chat transport. Call once at app startup. */ +export const setAITransport = (transport: AITransport): void => { + aiTransport = transport; +}; + +/** Reset to the default (Plannotator local `/api/ai/*`) transport. Mainly for tests. */ +export const resetAITransport = (): void => { + aiTransport = defaultAITransport; +}; + export function useAIChat({ context, providerId, @@ -158,17 +222,12 @@ export function useAIChat({ const requestId = ++createRequestRef.current; setIsCreatingSession(true); try { - const res = await fetch('/api/ai/session', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - context, - ...(providerId && { providerId }), - ...(model && { model }), - ...(reasoningEffort && { reasoningEffort }), - }), - signal, - }); + const res = await aiTransport.session({ + context, + ...(providerId && { providerId }), + ...(model && { model }), + ...(reasoningEffort && { reasoningEffort }), + }, signal); if (!res.ok) { const data = await res.json().catch(() => ({ error: 'Failed to create AI session' })); @@ -177,11 +236,7 @@ export function useAIChat({ const data = await res.json() as { sessionId: string }; if (signal.aborted || epoch !== sessionEpochRef.current) { - fetch('/api/ai/abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: data.sessionId }), - }).catch(() => {}); + aiTransport.abort({ sessionId: data.sessionId }); throw createAbortError('AI session creation was superseded'); } setSessionId(data.sessionId); @@ -201,11 +256,7 @@ export function useAIChat({ // into a session_busy error. Best-effort (never rejects). const postServerAbort = useCallback((): Promise => { if (!sessionIdRef.current) return Promise.resolve(); - return fetch('/api/ai/abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: sessionIdRef.current }), - }).catch(() => {}); + return aiTransport.abort({ sessionId: sessionIdRef.current }); }, []); const ask = useCallback(async (params: AskAIParams) => { @@ -262,16 +313,11 @@ export function useAIChat({ } const fullPrompt = buildPrompt(params); - const res = await fetch('/api/ai/query', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sessionId: sid, - prompt: fullPrompt, - ...(params.contextUpdate && { contextUpdate: params.contextUpdate }), - }), - signal: controller.signal, - }); + const res = await aiTransport.query({ + sessionId: sid, + prompt: fullPrompt, + ...(params.contextUpdate && { contextUpdate: params.contextUpdate }), + }, controller.signal); if (!res.ok || !res.body) { const data = await res.json().catch(() => ({ error: 'Query failed' })); @@ -413,15 +459,11 @@ export function useAIChat({ prev.map(p => p.requestId === requestId ? { ...p, decided: allow ? 'allow' : 'deny' } : p) ); - fetch('/api/ai/permission', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sessionId: sessionIdRef.current, - requestId, - allow, - }), - }).catch(() => {}); + aiTransport.permission({ + sessionId: sessionIdRef.current, + requestId, + allow, + }); }, [updatePermissions]); const resetSession = useCallback(() => { From 9eea8cc0f77e0e53b3c626d03404ff40bbd94ffd Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 10:37:35 -0700 Subject: [PATCH 24/67] docs(adr): log Phase 6 completion (4 seams + diff CSS move) --- .../document-ui-phase-0-1-worklog-20260622.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md index c6d651e83..1701e4fc5 100644 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md @@ -141,6 +141,29 @@ Researched (5-probe spike), specced, ADR-005-accepted, then teed up + multi-lens - **Parity:** no override caller; App.tsx untouched (both apps still call `useExternalAnnotations({enabled})`). external-annotation test green, typecheck pass, 1620/0, builds OK. ### Phase 5 status: COMPLETE (pending eyeball) — 3 seams landed, comment UI noop. Plannotator byte-unchanged. + +## Phase 6 — Extras: versions/diff, settings, sharing, Ask AI (ADR 006) + +Researched (5-probe spike), specced, ADR-006-accepted, teed up + multi-lens adversarially reviewed by the `phase6-extras` workflow (5 tee-ups + 4 worktree executes + 15 review lenses + synthesis; all 15 lenses safe). Landed + verified by hand. Already-portable pieces (planDiffEngine, all diff render components, sharing utils/useSharing/ImportModal, notes-app helpers, settings.ts, AI chat components, aiProvider/aiChatFormat) confirmed noop. Five Plannotator-only pieces (OpenInAppButton, HooksTab, useUpdateCheck, useAgents, useAgentJobs) confirmed out of scope. + +### Seam — Versions/diff + CSS move (DONE) +- **Files:** `usePlanDiff.ts` (optional `fetchers?` 4th arg, default `/api/plan/version(s)`; error asymmetry kept — selectBaseVersion alerts via the existing catch, fetchVersions silent), `PlanDiffViewer.tsx` (optional `onOpenVscodeDiff?`, default `/api/plan/vscode-diff`), and the **CSS move**: `.annotation-highlight*` + `.plan-diff-added/removed/modified/unchanged/line-*` relocated **byte-identical** from `editor/index.css` (−114) into `ui/theme.css` (+114), next to `.plan-diff-word-*`. +- **Parity:** relocated CSS **gone from index.css (0), present in shipped bundle (33×)** since Plannotator imports theme.css → pixel-identical. planDiffEngine 49/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. + +### Seam — Settings/config (DONE) +- **Files:** `configStore.ts` (`setServerSync(fn)` injects only the terminal `/api/config` POST; 300ms debounce + deepMerge batching + singleton + eager cookie reads verbatim), `Settings.tsx` (optional `onDetectObsidianVaults`, default `/api/obsidian/vaults`; `[obsidian.enabled]` dep + auto-select verbatim). +- **Parity:** no override caller; ui 293/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. + +### Seam — Sharing/save-to-notes (DONE) +- **File:** `ExportModal.tsx` (optional `onSaveToNotes`, default = verbatim `/api/save-notes` POST; `showNotesTab = isApiMode && !!markdown` byte-for-byte). Sharing utils already parameterized (noop). +- **Parity:** no override caller; typecheck pass, 1620/0, builds OK, App.tsx untouched. + +### Seam — Ask AI transport (DONE, riskiest) +- **File:** `useAIChat.ts` (module-level `AITransport` session/query/abort/permission + setters, default = the five `/api/ai/*` fetches verbatim). The SSE reader loop, epoch/createRequest guards, and the supersede-abort position inside `createSession` stay untouched. Capabilities + provider-resolution stay host-owned (App.tsx). +- **Parity:** no override caller; `packages/ai/ai.test.ts` 97/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. + +### Phase 6 status: COMPLETE (pending eyeball) — 4 seams landed + diff CSS in the package, extras noop, 5 Plannotator-only pieces out of scope. Plannotator byte-unchanged. +The document UI is now feature-complete for reuse. Remaining: Phase 7 (publish) + the parked `@plannotator/ai`/`@plannotator/shared` publish-vs-inline decision. Renderer-coupling contract (Workspaces must reuse BlockRenderer+InlineMarkdown+inlineTransforms for highlights) and replies/threading deferral recorded in ADR 005. Remaining: manual eyeball — author/`(me)`, draft save+restore+no-ghost, live SSE add + kill-stream→polling-takes-over. ### Discovered (PRE-EXISTING, out of scope — not caused by this work) From 106107e8612c6d7dbf6cce99fcc10a1a4b94211d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 12:57:45 -0700 Subject: [PATCH 25/67] docs(adr): research + synthesis + spec for Phase 7 (carve @plannotator/core + publish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carve a browser-safe @plannotator/core: move the ~15 pure shared modules in, extract types from the 3-4 node-bound ones (config/storage/workspace-status) so nothing duplicates, shim @plannotator/shared so Plannotator's 99 import sites stay unchanged, re-point @plannotator/ui to depend only on core, move wideMode.ts, then publish core+ui (source-only). shared + ai stay private. Open: registry, versions, CI job. Publish is the one outward-facing step — confirm before pushing. --- ...KE-publish-core-package-20260623-125551.md | 54 +++++++++++++++++++ ...is-publish-core-package-20260623-125551.md | 42 +++++++++++++++ .../publish-core-package-20260623-125551.md | 54 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 adr/research/SPIKE-publish-core-package-20260623-125551.md create mode 100644 adr/research/synthesis-publish-core-package-20260623-125551.md create mode 100644 adr/specs/publish-core-package-20260623-125551.md diff --git a/adr/research/SPIKE-publish-core-package-20260623-125551.md b/adr/research/SPIKE-publish-core-package-20260623-125551.md new file mode 100644 index 000000000..bae1b3d33 --- /dev/null +++ b/adr/research/SPIKE-publish-core-package-20260623-125551.md @@ -0,0 +1,54 @@ +# Spike: Carve `@plannotator/core` and Publish (Phase 7) + +Date: 2026-06-23 + +> Code research for Phase 7 of the `@plannotator/ui` reuse effort (ADR 004). Three probes mapped: (1) exact `@plannotator/core` membership, (2) the import blast radius + zero-churn mechanism, (3) publish toolchain. Decision context: **no copying / no duplication** — carve a single browser-safe package everyone shares. THE LAW: Plannotator stays unchanged. + +## The shape + +`@plannotator/ui` can't be published while it depends on the private `@plannotator/shared` and `@plannotator/ai`. The chosen fix: **move the browser-safe slice into a new published `@plannotator/core`; `@plannotator/shared` re-exports from it (so Plannotator's 99 import sites don't change); publish `core` + `ui`.** One copy of everything. + +## 1. What goes in `@plannotator/core` + +The UI references 21 distinct `@plannotator/shared` subpaths (value + type-only). They split three ways: + +**A. Pure modules — move wholesale into core (no node, no npm deps, no cross-deps):** +`code-file`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `extract-code-paths` (→ imports `./code-file`, also moves), `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`. (~15 files.) All verified browser-safe (the apps already bundle them for the browser today). + +**B. Node-bound modules the UI imports TYPES from — extract the types into core (the elegant no-duplication move):** +`config.ts` (node:fs/os/child_process), `storage.ts` (node:fs), `workspace-status.ts` (node:child_process). The UI imports only types from these (`DiffLineBgIntensity`, `DefaultDiffType`, `ArchivedPlan`, `WorkspaceFileChange`, `WorkspaceStatusPayload`). **Do NOT move these files** (they'd drag node into a browser package). Instead: **extract their type definitions into `core` (e.g. `core/config-types.ts`), and have `shared/config.ts` import those types back from core** + keep its node implementation. Result: the types live **once** (in core), the node code stays in shared, no duplication. + +**C. `AIContext` from `@plannotator/ai`** — a pure type union (verified node-free). Re-export it from `core` (e.g. `core/ai-context.ts`) so `ui` imports `@plannotator/core` instead of `@plannotator/ai`. + +**Nuance to verify at implementation:** `shared/types.ts` re-exports from `review-core.ts` / `review-workspace.ts`, which have value-level `node:path` imports. Confirm whether the UI's `@plannotator/shared/types` actually surfaces any of those review types to ui; if so, extract just those types (same technique as B). If not, `core/types.ts` re-exports only the browser-safe set. + +**Proposed core size:** ~15 pure files moved + ~3-4 extracted type files + an `index.ts` barrel + `ai-context.ts`. All source-only, browser-safe, **zero npm/node dependencies.** + +## 2. Blast radius + zero-churn mechanism + +- **99 import sites** across the repo reference these modules: packages/ui (36), packages/server (34), editor (11), review-editor (11), apps/hook (4), opencode (3). Heaviest: `agents` (17), `config` (10), `source-save` (9), `agent-terminal` (8), `types` (8). +- **Re-export shim = zero churn (the key finding):** for each moved module, leave a one-line shim in `shared` — `export * from '@plannotator/core/code-file'`. All 99 sites keep working unchanged; `shared`'s `exports` map stays as-is; works for both `import {}` and `import type`. **Plannotator's server/editor/review-editor/apps need no edits.** (For the type-extracted node modules, `shared/config.ts` etc. import their types from core and re-export — same effect.) +- **Pi-extension `vendor.sh`** copies ~47 shared files at build; with shims it vendors the shim files unchanged → **no vendor.sh change needed.** +- **No tsconfig/build globs** reference `packages/shared/*` — all imports are explicit subpaths. Minimal tooling impact. +- **`wideMode.ts` move** (Phase-7 leftover): `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts`; only 2 import sites (App.tsx + its test); ui doesn't import editor, no cycle. Ultra-low risk. + +## 3. Publish toolchain + +- **Monorepo:** Bun, `workspaces: ["apps/*","packages/*"]`, public npm. Release is tag-triggered CI (`.github/workflows/release.yml`); today it publishes only `@plannotator/opencode` + `@plannotator/pi-extension` via `bun pm pack` + `npm publish --provenance --access public`. **No ui/core/shared publish job exists yet.** +- **workspace:* resolution:** bun replaces `workspace:*` with the real version at pack time. Blocker today: `@plannotator/ai` + `@plannotator/shared` are `private:true` v0.0.1. After the carve, `ui` depends on `@plannotator/core` (published) — `shared`/`ai` stay private (ui no longer needs them directly once `core` covers its imports, **except** any remaining `import type` from shared that we must route through core). +- **Source-only model:** `ui` (and `core`) ship raw `.ts/.tsx` — no build/dist. An external TS consumer (Workspaces) must set: `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: "react-jsx"`, React 19 + Tailwind v4 (`@tailwindcss/vite`), and a Tailwind `@source` glob over `node_modules/@plannotator/ui/**/*.tsx`, plus import `@plannotator/ui/theme`. This works (no build needed) but must be documented for the consumer. +- **`ui` packaging after Phase 1:** peerDeps, dompurify, files allowlist all done. Remaining: the workspace-dep blocker (solved by `core`), a real version, and a CI publish job. + +## Open decisions (for the ADR) +1. **Registry:** public npm (matches existing opencode/pi-extension, simplest) vs private/scoped (if `ui`/`core` should be Workspaces-only). +2. **Versions:** keep 0.0.1 vs assign real (e.g. 0.1.0). `core` + `ui` likely version together. +3. **CI:** add a publish job for `core` + `ui` to `release.yml`, or publish manually the first time. +4. **`@plannotator/ai`:** since the UI only needs the `AIContext` type and `core` re-exports it, `ai` can **stay private/unpublished** — confirm the UI has no other `@plannotator/ai` value import. + +## Per-area summary +| Area | Finding | +|---|---| +| Core membership | ~15 pure files move; 3-4 node-bound modules contribute extracted types; AIContext re-exported. Zero node/npm in core. | +| Churn | Re-export shims in `shared` → 0 changes to Plannotator's 99 sites; vendor.sh unaffected. | +| Publish | Bun + public npm, tag-triggered CI; need a new publish job; source-only ships, consumer needs documented tsconfig/Tailwind. | +| Decisions | registry, versions, CI job, ai-stays-private. | diff --git a/adr/research/synthesis-publish-core-package-20260623-125551.md b/adr/research/synthesis-publish-core-package-20260623-125551.md new file mode 100644 index 000000000..2c9eb9be4 --- /dev/null +++ b/adr/research/synthesis-publish-core-package-20260623-125551.md @@ -0,0 +1,42 @@ +# Synthesis: Carve `@plannotator/core` and Publish (Phase 7) + +Date: 2026-06-23 + +> Synthesizes `SPIKE-publish-core-package-20260623-125551.md` against ADR 004 and the user decision: **no copying / single source of truth.** Settles Phase 7's shape. + +## The decision in one line + +Carve a new browser-safe **`@plannotator/core`** package, move the universal slice into it (extracting just the *types* from node-bound modules so nothing duplicates), make `@plannotator/shared` re-export from it so Plannotator is untouched, then publish `core` + `ui`. `@plannotator/shared` and `@plannotator/ai` stay private. + +## Why this shape + +- **No duplication:** every file/type has exactly one home. Pure modules live in `core`; node-bound *implementations* stay in `shared` but import their *types* from `core`. Today's clean one-copy state is preserved. +- **Plannotator unchanged:** re-export shims in `shared` mean all 99 internal import sites keep working with zero edits. Plannotator's server, editor, review-editor, apps, and the Pi vendor step are untouched. +- **Minimal published surface:** `core` is small, browser-safe, zero-dependency — not the Node/git/PR kitchen sink. Workspaces installs `ui` + `core` and nothing it doesn't run. +- **Naming is honest:** `core` = the universal foundation (runs anywhere), distinct from `ui` (components) and `shared` (the Node/server grab-bag). No `shared`/`ui` stutter. + +## The plan + +1. **Create `packages/core`** — source-only, browser-safe, zero npm/node deps. Move the ~15 pure modules in. Add extracted type files for the 3-4 node-bound modules (`config`, `storage`, `workspace-status`, and any review types `ui` surfaces). Re-export `AIContext`. Add an `index.ts` barrel and a fine-grained `exports` map (mirroring `ui`'s source-only pattern). +2. **Re-point `@plannotator/shared`** — each moved pure module becomes a one-line shim (`export * from '@plannotator/core/X'`); each node-bound module imports its types from `core` and keeps its node implementation. `shared`'s `exports` map and `private:true` stay. Plannotator's imports don't change. +3. **Re-point `@plannotator/ui`** — change `ui`'s `@plannotator/shared/X` and `@plannotator/ai` imports to `@plannotator/core/X`; replace the `workspace:* @plannotator/shared`/`@plannotator/ai` deps with `@plannotator/core` (the only published dep ui needs). Confirm no remaining `@plannotator/shared`/`@plannotator/ai` reference in ui. +4. **Move `wideMode.ts`** `editor → ui/utils` (2 import edits). +5. **Publish** `core` then `ui` (source-only) — add a CI job (or first-time manual publish), real versions, document the consumer tsconfig/Tailwind requirements in `core`/`ui` READMEs. + +## Guardrails (Plannotator stays byte-for-byte unchanged) +- After steps 1-3: full `bun test` stays 1620/0, typecheck passes, all builds byte-identical, **`git diff` touches only `packages/core` (new), `packages/shared` (shims/type-imports), `packages/ui` (import re-points + package.json), and `packages/editor` (wideMode)** — no server/app behavior change. +- The shipped bundle hashes (`apps/hook/dist`, `apps/opencode-plugin`) should stay identical (the re-exports compile to the same code). +- The publish itself is the one outward-facing, hard-to-undo step — **stop and confirm with the user before pushing anything to a registry.** + +## Open decisions to lock in the ADR +1. **Registry:** recommend **public npm** (matches the existing `@plannotator/opencode`/`pi-extension` flow, simplest, and `core`/`ui` contain nothing secret). Switch to a private scope only if there's a reason to keep them off the public registry. +2. **Versions:** recommend `0.1.0` for `core` + `ui`, versioned together. +3. **`@plannotator/ai` stays private** (ui only needs the `AIContext` type, re-exported via `core`) — confirm no value import. +4. **CI:** add `core` + `ui` to the `release.yml` npm-publish job (or publish manually first, automate later). + +## Sequencing +Do the carve + re-point + verify first (all reversible, Plannotator-unchanged), get the green parity run, **then** make the registry/version call and publish as the final, confirmed step. + +## References +- Spike: `adr/research/SPIKE-publish-core-package-20260623-125551.md` +- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180627.md` diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md new file mode 100644 index 000000000..b3a611566 --- /dev/null +++ b/adr/specs/publish-core-package-20260623-125551.md @@ -0,0 +1,54 @@ +# Spec: Carve `@plannotator/core` + Publish (Phase 7) + +Date: 2026-06-23 · Status: Draft (iterate before implementing) + +> Implementation spec for Phase 7. Grounded in `SPIKE-publish-core-package-20260623-125551.md` + its synthesis. Decision: single source of truth (no copying). THE LAW: Plannotator stays byte-for-byte unchanged through the carve; the publish is the one outward-facing step — confirm with the user before pushing to any registry. + +## Scope +**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, prep + (on go-ahead) publish `core` + `ui`. +**Out / stays private:** `@plannotator/shared` (Node/git/server grab-bag) and `@plannotator/ai` (ui only needs the `AIContext` type via core). + +## Step 1 — Create `packages/core` +- `packages/core/package.json`: `name @plannotator/core`, `version 0.1.0`, `type module`, source-only `exports` map (fine-grained subpaths like `ui`), `files` allowlist (`*.ts`, exclude tests), **no dependencies** (peerDeps none — it's pure JS/Web-API). tsconfig mirroring `ui` (bundler resolution, isolatedModules, allowImportingTsExtensions). +- Add `@plannotator/core` to root `workspaces` (already covered by `packages/*`). +- **Move (git mv) the ~15 pure modules** from `packages/shared` → `packages/core`: `code-file`, `extract-code-paths`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`. (Confirm each is node-free at move time.) +- **Extract types** from the node-bound modules into core type files: `core/config-types.ts` (DefaultDiffType, DiffLineBgIntensity, DiffOptions, …), `core/storage-types.ts` (ArchivedPlan), `core/workspace-status-types.ts` (WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo). Plus any review types `ui`'s `@plannotator/shared/types` surfaces (verify `review-core`/`review-workspace` usage). +- `core/ai-context.ts`: re-export `AIContext` (move or re-export the pure type from `packages/ai/types.ts`; confirm it's node-free). +- `core/index.ts`: barrel re-export. + +## Step 2 — Re-point `@plannotator/shared` (keeps Plannotator unchanged) +- For each **moved pure module**, replace its `packages/shared/X.ts` with a one-line shim: `export * from '@plannotator/core/X';`. Keep `shared`'s `exports` map and `private:true` as-is. +- For each **node-bound module** (`config`, `storage`, `workspace-status`), change its in-file type definitions to **import the types from `@plannotator/core/*-types`** and re-export them, keeping the node implementation. (Types now live once, in core.) +- Add `@plannotator/core: "workspace:*"` to `packages/shared` deps. +- **Verify:** all 99 existing `@plannotator/shared/X` import sites still resolve unchanged; Pi `vendor.sh` needs no edit (vendors the shims). + +## Step 3 — Re-point `@plannotator/ui` +- Change every `ui` import of `@plannotator/shared/X` → `@plannotator/core/X`, and `import type { AIContext } from '@plannotator/ai'` → `@plannotator/core`. +- In `packages/ui/package.json`: remove the `@plannotator/shared` and `@plannotator/ai` `workspace:*` deps; add `@plannotator/core: "workspace:*"`. (After publish this becomes a real version range.) +- **Verify:** `grep @plannotator/shared` and `@plannotator/ai` in `packages/ui` (non-test) returns **zero** — ui depends only on `@plannotator/core` internally. + +## Step 4 — Move `wideMode.ts` +- `git mv packages/editor/wideMode.ts packages/ui/utils/wideMode.ts` (+ its test). Update the 2 importers (`editor/App.tsx`, the test) to `@plannotator/ui/utils/wideMode`. + +## Step 5 — Publish (OUTWARD-FACING — confirm first) +- Decide registry (recommend **public npm**, matching existing flow), versions (recommend **0.1.0**, core+ui together). +- Write/READMEs documenting the consumer requirements: `moduleResolution: bundler`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: react-jsx`, React 19 + Tailwind v4 (`@tailwindcss/vite`), Tailwind `@source` over `node_modules/@plannotator/ui/**/*.tsx`, import `@plannotator/ui/theme`. +- Add a publish job to `.github/workflows/release.yml` for `core` + `ui` (or publish manually the first time: `bun pm pack` each, `npm publish *.tgz --access public`). bun resolves `workspace:*` → real versions at pack time. +- **Do not run the publish until the user explicitly approves** the registry + version + go. + +## Definition of done (Phase 7) +- `@plannotator/core` exists, browser-safe, zero deps; the universal slice lives there once. +- `@plannotator/shared` re-exports from core; Plannotator byte-unchanged (full `bun test` 1620/0, typecheck, builds, shipped-bundle hashes identical; `git diff` limited to core/shared/ui/editor packaging + import re-points). +- `@plannotator/ui` depends only on `@plannotator/core` internally; installs standalone (with `core`). +- `wideMode.ts` relocated. +- Consumer requirements documented; publish job ready. +- (On explicit go) `core` + `ui` published; Workspaces can `npm install @plannotator/ui @plannotator/core`. + +## Parity guardrail (run after the carve, before publish) +`bun run typecheck` · `bun test` 1620/0 · `bun run --cwd apps/review build && bun run build:hook && bun run build:opencode` · shipped-bundle hashes vs the Phase-0 baseline (should be identical) · `git diff` confined to the expected packages · Pi `vendor.sh`/typecheck still green. + +## Open questions (resolve in ADR) +1. Registry: public npm (recommended) vs private scope. +2. Versions: 0.1.0 (recommended) vs other; core+ui together vs independent. +3. CI publish job now vs manual first publish. +4. Confirm `@plannotator/ai` stays private (no ui value import) and `review-core`/`review-workspace` type handling. From f7d14fb35907e12b278d77bfc38ad91e22a7db69 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 13:32:11 -0700 Subject: [PATCH 26/67] docs(adr): fold configurePlannotatorUI() front door + precompiled CSS into Phase 7 spec Add the single typed configure() facade over the 9 global host-override setters (zero-risk, additive) and an optional precompiled CSS bundle (smooths the Tailwind-in-shared-lib wrinkle) to the Phase 7 publish scope. Both make the published surface nicer to consume; neither touches Plannotator. --- ...is-publish-core-package-20260623-125551.md | 4 +++- .../publish-core-package-20260623-125551.md | 21 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/adr/research/synthesis-publish-core-package-20260623-125551.md b/adr/research/synthesis-publish-core-package-20260623-125551.md index 2c9eb9be4..4dc8981d2 100644 --- a/adr/research/synthesis-publish-core-package-20260623-125551.md +++ b/adr/research/synthesis-publish-core-package-20260623-125551.md @@ -21,7 +21,9 @@ Carve a new browser-safe **`@plannotator/core`** package, move the universal sli 2. **Re-point `@plannotator/shared`** — each moved pure module becomes a one-line shim (`export * from '@plannotator/core/X'`); each node-bound module imports its types from `core` and keeps its node implementation. `shared`'s `exports` map and `private:true` stay. Plannotator's imports don't change. 3. **Re-point `@plannotator/ui`** — change `ui`'s `@plannotator/shared/X` and `@plannotator/ai` imports to `@plannotator/core/X`; replace the `workspace:* @plannotator/shared`/`@plannotator/ai` deps with `@plannotator/core` (the only published dep ui needs). Confirm no remaining `@plannotator/shared`/`@plannotator/ai` reference in ui. 4. **Move `wideMode.ts`** `editor → ui/utils` (2 import edits). -5. **Publish** `core` then `ui` (source-only) — add a CI job (or first-time manual publish), real versions, document the consumer tsconfig/Tailwind requirements in `core`/`ui` READMEs. +5. **Single config front door** — add `configurePlannotatorUI(config)`: one typed call that fans out to the 9 global host-override setters (image, storage, doc-preview, file-tree, identity, draft, external-annotations, AI, config-sync). Fixes the "scattered switches" ergonomics wart for ~40 lines, zero risk. The render-time prop seams stay as props; a `` is the optional later upgrade. +6. **(Optional) precompiled CSS** — ship `@plannotator/ui/styles.css` so a consumer imports one stylesheet instead of wiring Tailwind `@source` to ui internals. Smooths the one genuine integration wrinkle (Tailwind-in-a-shared-lib); additive, source-ships model unchanged. +7. **Publish** `core` then `ui` (source-only) — add a CI job (or first-time manual publish), real versions, document the consumer tsconfig/Tailwind requirements in `core`/`ui` READMEs. ## Guardrails (Plannotator stays byte-for-byte unchanged) - After steps 1-3: full `bun test` stays 1620/0, typecheck passes, all builds byte-identical, **`git diff` touches only `packages/core` (new), `packages/shared` (shims/type-imports), `packages/ui` (import re-points + package.json), and `packages/editor` (wideMode)** — no server/app behavior change. diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md index b3a611566..1c9ab83ea 100644 --- a/adr/specs/publish-core-package-20260623-125551.md +++ b/adr/specs/publish-core-package-20260623-125551.md @@ -5,7 +5,7 @@ Date: 2026-06-23 · Status: Draft (iterate before implementing) > Implementation spec for Phase 7. Grounded in `SPIKE-publish-core-package-20260623-125551.md` + its synthesis. Decision: single source of truth (no copying). THE LAW: Plannotator stays byte-for-byte unchanged through the carve; the publish is the one outward-facing step — confirm with the user before pushing to any registry. ## Scope -**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, prep + (on go-ahead) publish `core` + `ui`. +**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, add a single `configurePlannotatorUI()` front door, (optionally) ship a precompiled CSS bundle, prep + (on go-ahead) publish `core` + `ui`. **Out / stays private:** `@plannotator/shared` (Node/git/server grab-bag) and `@plannotator/ai` (ui only needs the `AIContext` type via core). ## Step 1 — Create `packages/core` @@ -30,7 +30,20 @@ Date: 2026-06-23 · Status: Draft (iterate before implementing) ## Step 4 — Move `wideMode.ts` - `git mv packages/editor/wideMode.ts packages/ui/utils/wideMode.ts` (+ its test). Update the 2 importers (`editor/App.tsx`, the test) to `@plannotator/ui/utils/wideMode`. -## Step 5 — Publish (OUTWARD-FACING — confirm first) +## Step 5 — Single config front door (`configurePlannotatorUI`) +The reuse surface currently has **9 global host-override switches** scattered across modules: `setImageSrcResolver`, `setStorageBackend`, `setDocPreviewFetcher`, `setFileTreeBackend`, `setIdentityProvider`, `setDraftTransport`, `setExternalAnnotationTransport`, `setAITransport`, and `configStore.setServerSync`. A consumer shouldn't have to discover and call each. +- Add **one new file** `packages/ui/configure.ts` exporting a typed `PlannotatorUIConfig` and `configurePlannotatorUI(config: PlannotatorUIConfig)` that fans out to those 9 setters (each field optional → only the provided ones are applied). Add to the `ui` `exports` map. +- **Zero risk / additive:** Plannotator never calls it, so nothing changes; the existing setters keep working individually. The per-component prop seams (vscode-diff, save-to-notes, obsidian-detect, version fetchers, editor `mode`, code-path toggle, `ScrollViewportProvider`) are intentionally NOT in the global front door — they're passed where the host renders those components. +- **Later (optional):** migrate the render-time seams to a `` (React context) if Workspaces wants per-instance config / SSR. The `configure()` facade is the 80/20 now; the Provider is the door it leaves open. +- **Verify:** typecheck; a tiny test that `configurePlannotatorUI({...})` routes to each setter; Plannotator behavior unchanged (it never calls it). + +## Step 6 — Precompiled CSS bundle (optional friction-reducer) +Sharing Tailwind-utility components forces the consumer to either scan our source (`@source`) or get a ready-made stylesheet. Ship the stylesheet to smooth the worst integration wrinkle. +- Add a build that emits a single precompiled CSS file for `@plannotator/ui` (theme tokens + the component utility classes), exported as e.g. `@plannotator/ui/styles.css`. The consumer imports one stylesheet instead of wiring Tailwind `@source` to ui internals. +- **Additive:** keep the `@source` glob path documented as the alternative; the source-ships-TS model is unchanged. This is the heavier of the two polish items (needs a small CSS build pipeline) — optional; the `@source` approach already works for the first Workspaces integration. +- **Verify:** the precompiled CSS renders Plannotator-identical visuals in a bare consumer; Plannotator's own build/styling untouched. + +## Step 7 — Publish (OUTWARD-FACING — confirm first) - Decide registry (recommend **public npm**, matching existing flow), versions (recommend **0.1.0**, core+ui together). - Write/READMEs documenting the consumer requirements: `moduleResolution: bundler`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: react-jsx`, React 19 + Tailwind v4 (`@tailwindcss/vite`), Tailwind `@source` over `node_modules/@plannotator/ui/**/*.tsx`, import `@plannotator/ui/theme`. - Add a publish job to `.github/workflows/release.yml` for `core` + `ui` (or publish manually the first time: `bun pm pack` each, `npm publish *.tgz --access public`). bun resolves `workspace:*` → real versions at pack time. @@ -41,8 +54,10 @@ Date: 2026-06-23 · Status: Draft (iterate before implementing) - `@plannotator/shared` re-exports from core; Plannotator byte-unchanged (full `bun test` 1620/0, typecheck, builds, shipped-bundle hashes identical; `git diff` limited to core/shared/ui/editor packaging + import re-points). - `@plannotator/ui` depends only on `@plannotator/core` internally; installs standalone (with `core`). - `wideMode.ts` relocated. +- **`configurePlannotatorUI()` exists** as the single typed front door over the 9 global setters; Plannotator unchanged (never calls it). +- **(Optional) precompiled CSS** shipped so a consumer can import one stylesheet instead of wiring Tailwind `@source`. - Consumer requirements documented; publish job ready. -- (On explicit go) `core` + `ui` published; Workspaces can `npm install @plannotator/ui @plannotator/core`. +- (On explicit go) `core` + `ui` published; Workspaces can `npm install @plannotator/ui @plannotator/core`, call `configurePlannotatorUI({...})` once, import `@plannotator/ui/styles.css`, and build. ## Parity guardrail (run after the carve, before publish) `bun run typecheck` · `bun test` 1620/0 · `bun run --cwd apps/review build && bun run build:hook && bun run build:opencode` · shipped-bundle hashes vs the Phase-0 baseline (should be identical) · `git diff` confined to the expected packages · Pi `vendor.sh`/typecheck still green. From 7b947a3f978a2b9822c5e806baaab3bae6fc3b1c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 13:59:19 -0700 Subject: [PATCH 27/67] docs(adr): lock Phase 7 publish decisions + carry over review fixes Decided: ship JS as source (single internal consumer on controlled stack, no build to maintain, no dist drift); precompiled CSS now REQUIRED (the @source glob is fragile under pnpm symlinks); core CI typecheck node-free; pin ui->core exact. Recorded the interrogation's carried-over Phase-5 code fixes (useExternalAnnotations split-transport + fallbackRef reset, per-seam override tests, configStore loadFromBackend) to do before publish. --- .../publish-core-package-20260623-125551.md | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md index 1c9ab83ea..710ef3af0 100644 --- a/adr/specs/publish-core-package-20260623-125551.md +++ b/adr/specs/publish-core-package-20260623-125551.md @@ -37,33 +37,54 @@ The reuse surface currently has **9 global host-override switches** scattered ac - **Later (optional):** migrate the render-time seams to a `` (React context) if Workspaces wants per-instance config / SSR. The `configure()` facade is the 80/20 now; the Provider is the door it leaves open. - **Verify:** typecheck; a tiny test that `configurePlannotatorUI({...})` routes to each setter; Plannotator behavior unchanged (it never calls it). -## Step 6 — Precompiled CSS bundle (optional friction-reducer) -Sharing Tailwind-utility components forces the consumer to either scan our source (`@source`) or get a ready-made stylesheet. Ship the stylesheet to smooth the worst integration wrinkle. -- Add a build that emits a single precompiled CSS file for `@plannotator/ui` (theme tokens + the component utility classes), exported as e.g. `@plannotator/ui/styles.css`. The consumer imports one stylesheet instead of wiring Tailwind `@source` to ui internals. -- **Additive:** keep the `@source` glob path documented as the alternative; the source-ships-TS model is unchanged. This is the heavier of the two polish items (needs a small CSS build pipeline) — optional; the `@source` approach already works for the first Workspaces integration. +## Decisions locked (post-interrogation, 2026-06-23) +- **Ship TS source for the JS, NOT a compiled build.** Rationale: the only consumer (Workspaces) is internal and on a controlled stack (Vite/Cloudflare). A `tsup`/lib build exists only to insulate unknown/arbitrary-toolchain consumers — that insulation buys ~nothing here, and shipping source avoids a build pipeline to maintain and avoids a `dist` artifact that can drift from what Plannotator actually runs. Door stays open: add a build later if/when an external consumer appears. (Contested in review — one reviewer assumed a public lib; this is the deliberate call for the internal case.) +- **Precompiled CSS is REQUIRED, not optional** (Step 6). Even internally, the `@source` glob into `node_modules/@plannotator/ui/**/*.tsx` is fragile (pnpm symlinks break it) and a per-build perf cost. Ship the stylesheet. +- **`@plannotator/core` gets a node-free CI typecheck** (Step 1) so a stray `node:*` import fails the build — turns "confirm node-free by hand" into an enforced invariant. +- **Pin `@plannotator/ui` → `@plannotator/core` to an EXACT version** (not a range) during 0.x, so a consumer can't end up with mismatched copies (and silently diverge the annotation serializers). + +## Step 6 — Precompiled CSS bundle (REQUIRED) +Tailwind-utility components force the consumer to either scan our source (`@source`) or get a ready-made stylesheet. Ship the stylesheet — the `@source` route is fragile (pnpm symlinks) and costs every consumer build time. +- Add a CSS-only build that emits a single precompiled `@plannotator/ui/styles.css` (theme tokens + the component utility classes). This is a CSS pipeline only — the JS still ships as source (per the decision above). +- Keep the `@source` glob documented as the fallback for a consumer who wants to scan source, but the stylesheet is the supported default. - **Verify:** the precompiled CSS renders Plannotator-identical visuals in a bare consumer; Plannotator's own build/styling untouched. ## Step 7 — Publish (OUTWARD-FACING — confirm first) -- Decide registry (recommend **public npm**, matching existing flow), versions (recommend **0.1.0**, core+ui together). -- Write/READMEs documenting the consumer requirements: `moduleResolution: bundler`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: react-jsx`, React 19 + Tailwind v4 (`@tailwindcss/vite`), Tailwind `@source` over `node_modules/@plannotator/ui/**/*.tsx`, import `@plannotator/ui/theme`. +- JS ships as **source** (no build); CSS ships **precompiled** (Step 6). `core` + `ui` `exports` stay source-only for `.ts`/`.tsx`, plus the `styles.css` entry. +- Decide registry (recommend **public npm**, matching existing flow), versions (recommend **0.1.0**, core+ui together), with `ui`→`core` pinned **exact**. +- Write/READMEs documenting consumer requirements: `moduleResolution: bundler`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: react-jsx`, React 19, and **import `@plannotator/ui/styles.css`** (the `@source` glob is the documented fallback, not the default). - Add a publish job to `.github/workflows/release.yml` for `core` + `ui` (or publish manually the first time: `bun pm pack` each, `npm publish *.tgz --access public`). bun resolves `workspace:*` → real versions at pack time. - **Do not run the publish until the user explicitly approves** the registry + version + go. +## Carried-over review fixes (do before publish; NOT Phase-7 architecture) +These are small bugs/gaps the interrogation found in already-committed Phase-5 code. None affect Plannotator (override-path only); fix before a real consumer wires the seams: +1. **`useExternalAnnotations` split-transport** — the effect captures `transport` at mount for subscribe/poll, but the CRUD callbacks read the module global live → reads and writes can hit different backends if the transport is set after mount. Read consistently in both paths. (Check `useFileBrowser` for the same shape.) +2. **`useExternalAnnotations` `fallbackRef`/`receivedSnapshotRef` not reset on effect re-run** — if `enabled` toggles false→true (Workspaces auth/loading), the hook silently stops updating. Reset both at the top of the effect. +3. **Override path untested** — add one small test per seam that calls `setX(fake)`, drives the hook/component, asserts the contract, then `resetX()`. Makes the dead `reset*()` functions live and pins the subtle contracts (draft generation, SSE fallback). +4. (consider) `configStore` only redirects setting *writes* via `setStorageBackend`; the initial *load* already ran against cookies at module-init. If Workspaces needs to load its own settings, add a `loadFromBackend()`. Skip if Workspaces owns its settings entirely. + ## Definition of done (Phase 7) -- `@plannotator/core` exists, browser-safe, zero deps; the universal slice lives there once. +- `@plannotator/core` exists, browser-safe, zero deps; the universal slice lives there once; **CI typechecks it node-free** (no `@types/node`). - `@plannotator/shared` re-exports from core; Plannotator byte-unchanged (full `bun test` 1620/0, typecheck, builds, shipped-bundle hashes identical; `git diff` limited to core/shared/ui/editor packaging + import re-points). -- `@plannotator/ui` depends only on `@plannotator/core` internally; installs standalone (with `core`). +- `@plannotator/ui` depends only on `@plannotator/core` internally, **pinned exact**; JS ships as source; installs standalone (with `core`). - `wideMode.ts` relocated. - **`configurePlannotatorUI()` exists** as the single typed front door over the 9 global setters; Plannotator unchanged (never calls it). -- **(Optional) precompiled CSS** shipped so a consumer can import one stylesheet instead of wiring Tailwind `@source`. +- **Precompiled CSS (`@plannotator/ui/styles.css`) shipped** (required). +- The carried-over review fixes (split-transport, fallbackRef reset, per-seam override tests) are done. - Consumer requirements documented; publish job ready. - (On explicit go) `core` + `ui` published; Workspaces can `npm install @plannotator/ui @plannotator/core`, call `configurePlannotatorUI({...})` once, import `@plannotator/ui/styles.css`, and build. ## Parity guardrail (run after the carve, before publish) `bun run typecheck` · `bun test` 1620/0 · `bun run --cwd apps/review build && bun run build:hook && bun run build:opencode` · shipped-bundle hashes vs the Phase-0 baseline (should be identical) · `git diff` confined to the expected packages · Pi `vendor.sh`/typecheck still green. +## Decided +- **JS ships as source, not a build** (single internal consumer on a controlled stack). — locked +- **Precompiled CSS required.** — locked +- **`core` CI typecheck node-free; `ui`→`core` pinned exact.** — locked + ## Open questions (resolve in ADR) 1. Registry: public npm (recommended) vs private scope. 2. Versions: 0.1.0 (recommended) vs other; core+ui together vs independent. 3. CI publish job now vs manual first publish. 4. Confirm `@plannotator/ai` stays private (no ui value import) and `review-core`/`review-workspace` type handling. +5. In-scope or not: `configStore.loadFromBackend()` (only if Workspaces wants its own settings persistence). From 31fa31e6ed893e4a8fe8120d68329b5a0c9ebade Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 14:06:53 -0700 Subject: [PATCH 28/67] =?UTF-8?q?docs(adr):=20ADR=20007=20=E2=80=94=20carv?= =?UTF-8?q?e=20@plannotator/core,=20complete=20settings=20provider,=20publ?= =?UTF-8?q?ish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks Phase 7 decisions: public npm; lockstep version at repo 0.21.0 (ui->core pinned exact); JS ships as source + required precompiled CSS; core CI node-free; ai stays unpublished-to-npm. Settings provider completed (loadFromBackend, prefetch +sync) is now IN SCOPE — Workspaces uses the same UI settings stored in its own backend. CI publish job wired but artifacts validated on-branch (pack + dry-run) before merge; first publish gated. Carries the 2 override-path bug fixes + per-seam override tests as pre-publish work. --- ...ore-package-and-publish-20260623-140537.md | 66 +++++++++++++++++++ .../publish-core-package-20260623-125551.md | 24 +++---- 2 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 adr/decisions/007-carve-core-package-and-publish-20260623-140537.md diff --git a/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md b/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md new file mode 100644 index 000000000..d2fc2488a --- /dev/null +++ b/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md @@ -0,0 +1,66 @@ +# 007. Carve `@plannotator/core`, complete the settings provider, and publish `core` + `ui` + +Date: 2026-06-23 + +## Status + +Accepted + +## Context + +Phases 0–6 (ADRs 004–006) made Plannotator's document UI (`packages/ui` = `@plannotator/ui`) host-overridable through optional seams that default to today's behavior, with Plannotator verified byte-for-byte unchanged. The remaining work (Phase 7) is to make `@plannotator/ui` actually installable by a separate consumer (the commercial "Workspaces"/Enterprise app) and publish it. + +Two facts force the shape of this phase: + +1. **`@plannotator/ui` can't be published as-is.** It depends on `@plannotator/shared` and `@plannotator/ai`, both unpublished workspace packages. `@plannotator/shared` is a Node/git/server kitchen sink we don't want on npm. An external installer must resolve every dependency from the registry, so the dependency tail has to be dealt with — without copying (the user's hard requirement: single source of truth, no duplication). + +2. **Workspaces will use the same UI settings, stored in its own backend.** The storage seam (`setStorageBackend`, Phase 2) already redirects setting *writes*. But the initial settings *load* runs against cookies at module-init, before a host can install its backend — so Workspaces' saved settings wouldn't load. The settings provider is half-built. + +An adversarial multi-model review (the `interrogate` pass) confirmed Phases 0–6 are sound and proportionate, found no Plannotator-affecting issues, and surfaced a small set of override-path fixes plus publish-toolchain decisions. The one contested decision (ship TS source vs. a compiled build) was resolved deliberately for the internal-consumer case. + +Supporting docs: `adr/research/SPIKE-publish-core-package-20260623-125551.md`, `adr/research/synthesis-publish-core-package-20260623-125551.md`, `adr/specs/publish-core-package-20260623-125551.md`. + +## Decision + +**1. Carve a new browser-safe `@plannotator/core` package (single source of truth).** +- Move the ~15 pure browser-safe modules the UI uses out of `@plannotator/shared` into `@plannotator/core` (`code-file`, `extract-code-paths`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`). +- For the node-bound modules the UI imports only *types* from (`config`, `storage`, `workspace-status`, and any review types `ui` surfaces): extract the type definitions into `core`; the Node implementation stays in `shared` and imports its types back from `core`. Types live once; nothing is duplicated. +- Re-export `AIContext` from `core` so `ui` no longer imports `@plannotator/ai`. +- `@plannotator/shared` re-exports each moved module via one-line shims, so all ~99 internal import sites and the Pi `vendor.sh` step keep working unchanged. Plannotator stays untouched. +- `core` is source-only, browser-safe, zero npm/node deps. **CI typechecks `core` with no `@types/node`** so a stray `node:*` import fails the build. + +**2. Complete the settings provider (in scope — Workspaces needs it).** +- Add a `loadFromBackend()` path so the initial settings load routes through the installed `StorageBackend`, not only cookies. +- Use the **prefetch + synchronous backend** model: a host fetches its settings, installs a sync backend that serves from that prefetched data, then calls `loadFromBackend()`. No async plumbing inside `configStore`; Plannotator's eager cookie default is unchanged (it never calls `loadFromBackend()`). + +**3. Single configuration front door.** +- Add `configurePlannotatorUI(config)`: one typed call that fans out to the 9 global host-override setters (image, storage, doc-preview, file-tree, identity, draft, external-annotations, AI, config-sync) plus the settings load. Render-time prop seams stay as props. A `` (React context) is the documented later upgrade if per-instance/SSR config is ever needed. + +**4. Ship TS source for JS; ship precompiled CSS.** +- Publish `core` + `ui` as TS source (no compiled build). Rationale: the only consumer is internal on a controlled stack (Vite/Cloudflare); a build exists to insulate unknown toolchains and buys ~nothing here, while avoiding a build pipeline and a `dist` artifact that can drift from what Plannotator runs. Revisit only if an external/arbitrary-stack consumer appears. +- Ship a **required** precompiled `@plannotator/ui/styles.css` (CSS-only build). The Tailwind `@source` glob into `node_modules` is fragile (pnpm symlinks) and a per-build cost; the stylesheet is the supported default, `@source` the documented fallback. + +**5. Publishing.** +- **Public npm** (open-source project; matches the existing `@plannotator/opencode` / `@plannotator/pi-extension` flow). +- **Lockstep versioning at the repo version (`0.21.0`)**, consistent with the other published packages; `core` + `ui` move together; `ui` → `core` pinned **exact**. +- `@plannotator/ai` stays unpublished-to-npm (npm `private: true`); the UI doesn't need it. (This is an npm-registry flag only — the code stays open on GitHub like everything else.) +- **Wire a CI publish job** for `core` + `ui` in `release.yml`. Before merging to main, **validate the artifacts on the branch**: `bun pm pack` each, inspect the tarball, and `npm publish --dry-run`. The first real publish goes out only on explicit go. + +**6. Pre-publish fixes (override-path only; none affect Plannotator), from the interrogation:** +- Fix `useExternalAnnotations` split-transport (reads/writes can hit different backends if the transport is set after mount); check `useFileBrowser` for the same shape. +- Reset `fallbackRef`/`receivedSnapshotRef` on effect re-run so a `false→true` `enabled` toggle doesn't silently stop updates. +- Add one override test per seam (`setX(fake)` → drive → assert → `resetX()`), which also makes the `reset*()` functions live. + +## Consequences + +- `@plannotator/ui` becomes installable: a consumer runs `npm install @plannotator/ui @plannotator/core`, calls `configurePlannotatorUI({...})` once, imports `@plannotator/ui/styles.css`, and builds — with the same UI settings persisted through its own backend. +- One copy of every shared module/type remains; `@plannotator/shared` and `@plannotator/ai` stay private to the monorepo. Plannotator's server, apps, editor, review-editor, and Pi build are unchanged. +- The carve + provider completion + fixes are all reversible and keep Plannotator byte-for-byte identical (parity gate: `bun test` 1620/0, typecheck, byte-identical shipped bundles, `git diff` confined to `core`/`shared`/`ui`/`editor`). The publish is the one outward-facing, hard-to-undo step and is gated on explicit approval after branch-validation. +- Shipping source couples consumers to a documented tsconfig/bundler setup; acceptable for the internal consumer, and the door to a compiled build stays open. +- New maintenance surface: a small published `@plannotator/core`, a CSS-only build, exact-version coupling between `core` and `ui`, and a node-free CI check on `core`. + +## References +- Spec: `adr/specs/publish-core-package-20260623-125551.md` +- Synthesis: `adr/research/synthesis-publish-core-package-20260623-125551.md` +- Spike: `adr/research/SPIKE-publish-core-package-20260623-125551.md` +- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md index 710ef3af0..5e8dd73e0 100644 --- a/adr/specs/publish-core-package-20260623-125551.md +++ b/adr/specs/publish-core-package-20260623-125551.md @@ -61,7 +61,7 @@ These are small bugs/gaps the interrogation found in already-committed Phase-5 c 1. **`useExternalAnnotations` split-transport** — the effect captures `transport` at mount for subscribe/poll, but the CRUD callbacks read the module global live → reads and writes can hit different backends if the transport is set after mount. Read consistently in both paths. (Check `useFileBrowser` for the same shape.) 2. **`useExternalAnnotations` `fallbackRef`/`receivedSnapshotRef` not reset on effect re-run** — if `enabled` toggles false→true (Workspaces auth/loading), the hook silently stops updating. Reset both at the top of the effect. 3. **Override path untested** — add one small test per seam that calls `setX(fake)`, drives the hook/component, asserts the contract, then `resetX()`. Makes the dead `reset*()` functions live and pins the subtle contracts (draft generation, SSE fallback). -4. (consider) `configStore` only redirects setting *writes* via `setStorageBackend`; the initial *load* already ran against cookies at module-init. If Workspaces needs to load its own settings, add a `loadFromBackend()`. Skip if Workspaces owns its settings entirely. +4. **(in scope — Workspaces needs it)** Complete the settings provider: `setStorageBackend` only redirects setting *writes*; the initial *load* runs against cookies at module-init. Workspaces uses the same UI settings stored in its own backend → add `loadFromBackend()`. Model: **prefetch + synchronous backend** (host fetches settings → installs a sync backend serving from that data → calls `loadFromBackend()`); no async plumbing in `configStore`; Plannotator's eager cookie default unchanged (never calls it). ## Definition of done (Phase 7) - `@plannotator/core` exists, browser-safe, zero deps; the universal slice lives there once; **CI typechecks it node-free** (no `@types/node`). @@ -77,14 +77,16 @@ These are small bugs/gaps the interrogation found in already-committed Phase-5 c ## Parity guardrail (run after the carve, before publish) `bun run typecheck` · `bun test` 1620/0 · `bun run --cwd apps/review build && bun run build:hook && bun run build:opencode` · shipped-bundle hashes vs the Phase-0 baseline (should be identical) · `git diff` confined to the expected packages · Pi `vendor.sh`/typecheck still green. -## Decided -- **JS ships as source, not a build** (single internal consumer on a controlled stack). — locked -- **Precompiled CSS required.** — locked -- **`core` CI typecheck node-free; `ui`→`core` pinned exact.** — locked - -## Open questions (resolve in ADR) -1. Registry: public npm (recommended) vs private scope. -2. Versions: 0.1.0 (recommended) vs other; core+ui together vs independent. -3. CI publish job now vs manual first publish. -4. Confirm `@plannotator/ai` stays private (no ui value import) and `review-core`/`review-workspace` type handling. +## Decided (locked in ADR 007) +- **Registry: public npm.** +- **Versions: lockstep at repo version `0.21.0`; `ui`→`core` pinned exact.** +- **JS ships as source, not a build** (single internal consumer on a controlled stack). +- **Precompiled CSS required.** +- **`core` CI typecheck node-free.** +- **`@plannotator/ai` stays unpublished-to-npm** (`private:true`; UI doesn't need it — only `AIContext`, re-exported via `core`). +- **Settings provider completed** (`loadFromBackend()`, prefetch+sync) — in scope. +- **CI publish job wired**, but **validate artifacts on the branch first** (`bun pm pack` + inspect + `npm publish --dry-run`) before merge; first real publish gated on explicit go. + +## Still to verify at implementation +- `review-core`/`review-workspace` type handling (whether `ui`'s `@plannotator/shared/types` surfaces any node-bound review types → extract if so). 5. In-scope or not: `configStore.loadFromBackend()` (only if Workspaces wants its own settings persistence). From 5bbecadc7415a4a43cad2296f7823d3ea03f4a2a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 14:16:06 -0700 Subject: [PATCH 29/67] fix(ui): make external-annotation transport reads consistent + reset fallback on re-enable Two override-path bugs found by the interrogation pass (both unreachable on Plannotator's path; harden the host-override path for a real consumer): 1. Split-transport: the effect captured the transport at mount for subscribe/poll while the CRUD callbacks read the module global live, so a host swapping the transport after mount would split reads and writes across two backends. Capture once in a ref and use it in all four spots. 2. fallbackRef/receivedSnapshotRef were not reset on effect re-run, so an enabled false->true toggle inherited a stale 'already fell back' flag and silently stopped updating. Reset both at the top of the effect. Plannotator unchanged: it never overrides the transport (same default singleton captured) and enabled never toggles (reset is a no-op). typecheck clean; full test suite shows zero delta (1605 pass / 45 pre-existing env failures, identical with and without this change). --- packages/ui/hooks/useExternalAnnotations.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts index 8f586caec..da750c7b7 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -127,12 +127,22 @@ export function useExternalAnnotations | null>(null); const receivedSnapshotRef = useRef(false); + // Capture the transport once so subscribe/poll and CRUD always use the same + // backend instance (contract: "set once at startup"). Reading the live module + // global in CRUD while the effect captured at mount would split reads and + // writes across two backends if a host swapped the transport after mount. + const transportRef = useRef(externalAnnotationTransport as ExternalAnnotationTransport); useEffect(() => { if (!enabled) return; let cancelled = false; - const transport = externalAnnotationTransport as ExternalAnnotationTransport; + // Reset fallback state on (re-)enable so a false→true toggle re-attempts SSE + // instead of inheriting a stale "already fell back" flag and silently stalling. + fallbackRef.current = false; + receivedSnapshotRef.current = false; + + const transport = transportRef.current; // --- Reducer (applies snapshot|add|remove|clear|update), verbatim --- function applyEvent(parsed: ExternalAnnotationEvent) { @@ -219,7 +229,7 @@ export function useExternalAnnotations prev.filter((a) => a.id !== id)); try { - await (externalAnnotationTransport as ExternalAnnotationTransport).remove(id); + await transportRef.current.remove(id); } catch { // SSE will reconcile on next event } @@ -231,7 +241,7 @@ export function useExternalAnnotations a.source !== source) : [], ); try { - await (externalAnnotationTransport as ExternalAnnotationTransport).clear(source); + await transportRef.current.clear(source); } catch { // SSE will reconcile on next event } @@ -240,7 +250,7 @@ export function useExternalAnnotations) => { setAnnotations((prev) => prev.map((a) => (a.id === id ? { ...a, ...updates } : a))); try { - await (externalAnnotationTransport as ExternalAnnotationTransport).update(id, updates); + await transportRef.current.update(id, updates); } catch { // SSE will reconcile on next event } From bc0c8124427c2cf0196448521d92f328dfb9b3ab Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 14:42:37 -0700 Subject: [PATCH 30/67] docs(adr): align Phase 7 spec with ADR 007 (version 0.21.0 lockstep, CSS required, scope completeness) --- adr/specs/publish-core-package-20260623-125551.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md index 5e8dd73e0..608e8dc6f 100644 --- a/adr/specs/publish-core-package-20260623-125551.md +++ b/adr/specs/publish-core-package-20260623-125551.md @@ -5,11 +5,11 @@ Date: 2026-06-23 · Status: Draft (iterate before implementing) > Implementation spec for Phase 7. Grounded in `SPIKE-publish-core-package-20260623-125551.md` + its synthesis. Decision: single source of truth (no copying). THE LAW: Plannotator stays byte-for-byte unchanged through the carve; the publish is the one outward-facing step — confirm with the user before pushing to any registry. ## Scope -**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, add a single `configurePlannotatorUI()` front door, (optionally) ship a precompiled CSS bundle, prep + (on go-ahead) publish `core` + `ui`. +**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, complete the settings provider (`loadFromBackend`, prefetch+sync), add a single `configurePlannotatorUI()` front door, ship a (required) precompiled CSS bundle, fix the 2 override-path bugs + add per-seam override tests, prep + (on go-ahead) publish `core` + `ui`. **Out / stays private:** `@plannotator/shared` (Node/git/server grab-bag) and `@plannotator/ai` (ui only needs the `AIContext` type via core). ## Step 1 — Create `packages/core` -- `packages/core/package.json`: `name @plannotator/core`, `version 0.1.0`, `type module`, source-only `exports` map (fine-grained subpaths like `ui`), `files` allowlist (`*.ts`, exclude tests), **no dependencies** (peerDeps none — it's pure JS/Web-API). tsconfig mirroring `ui` (bundler resolution, isolatedModules, allowImportingTsExtensions). +- `packages/core/package.json`: `name @plannotator/core`, `version 0.21.0` (lockstep with the repo, per ADR 007), `type module`, source-only `exports` map (fine-grained subpaths like `ui`), `files` allowlist (`*.ts`, exclude tests), **no dependencies** (peerDeps none — it's pure JS/Web-API). tsconfig mirroring `ui` (bundler resolution, isolatedModules, allowImportingTsExtensions). - Add `@plannotator/core` to root `workspaces` (already covered by `packages/*`). - **Move (git mv) the ~15 pure modules** from `packages/shared` → `packages/core`: `code-file`, `extract-code-paths`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`. (Confirm each is node-free at move time.) - **Extract types** from the node-bound modules into core type files: `core/config-types.ts` (DefaultDiffType, DiffLineBgIntensity, DiffOptions, …), `core/storage-types.ts` (ArchivedPlan), `core/workspace-status-types.ts` (WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo). Plus any review types `ui`'s `@plannotator/shared/types` surfaces (verify `review-core`/`review-workspace` usage). From 20c37db5d768290891bf115527b95029b42a995b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 15:29:00 -0700 Subject: [PATCH 31/67] =?UTF-8?q?feat(core):=20carve=20@plannotator/core?= =?UTF-8?q?=20=E2=80=94=20move=20pure=20modules,=20extract=20node-bound=20?= =?UTF-8?q?types,=20shim=20shared=20(Phase=207=20step=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pi-extension/vendor.sh | 32 +- package.json | 2 +- packages/ai/package.json | 4 +- packages/ai/types.ts | 85 +--- packages/core/agent-jobs.ts | 158 ++++++ .../{shared => core}/agent-terminal.test.ts | 0 packages/core/agent-terminal.ts | 53 ++ packages/core/agents.ts | 53 ++ packages/core/ai-context.ts | 83 ++++ packages/core/browser-paths.ts | 25 + packages/{shared => core}/code-file.test.ts | 0 packages/core/code-file.ts | 41 ++ packages/core/compress.ts | 51 ++ packages/core/config-types.ts | 18 + packages/{shared => core}/crypto.test.ts | 0 packages/core/crypto.ts | 97 ++++ packages/core/external-annotation.ts | 455 +++++++++++++++++ .../extract-code-paths.test.ts | 0 packages/core/extract-code-paths.ts | 66 +++ packages/core/favicon.ts | 5 + .../feedback-templates.test.ts | 0 packages/core/feedback-templates.ts | 45 ++ packages/{shared => core}/goal-setup.test.ts | 0 packages/core/goal-setup.ts | 336 +++++++++++++ packages/core/index.ts | 2 + packages/core/open-in-apps.ts | 189 ++++++++ packages/core/package.json | 33 ++ packages/core/project.ts | 71 +++ packages/{shared => core}/source-save.test.ts | 0 packages/core/source-save.ts | 138 ++++++ packages/core/storage-types.ts | 8 + packages/core/tsconfig.json | 21 + packages/core/types.ts | 10 + packages/core/workspace-status-types.ts | 39 ++ packages/shared/agent-jobs.ts | 159 +----- packages/shared/agent-terminal.ts | 54 +-- packages/shared/agents.ts | 54 +-- packages/shared/browser-paths.ts | 26 +- packages/shared/code-file.ts | 42 +- packages/shared/compress.ts | 52 +- packages/shared/config.ts | 20 +- packages/shared/crypto.ts | 98 +--- packages/shared/external-annotation.ts | 456 +----------------- packages/shared/extract-code-paths.ts | 67 +-- packages/shared/favicon.ts | 6 +- packages/shared/feedback-templates.ts | 46 +- packages/shared/goal-setup.ts | 337 +------------ packages/shared/open-in-apps.ts | 190 +------- packages/shared/package.json | 1 + packages/shared/project.ts | 72 +-- packages/shared/source-save.ts | 139 +----- packages/shared/storage.ts | 10 +- packages/shared/types.ts | 11 +- packages/shared/workspace-status.ts | 41 +- packages/ui/styles.css | 1 + 55 files changed, 2057 insertions(+), 1945 deletions(-) create mode 100644 packages/core/agent-jobs.ts rename packages/{shared => core}/agent-terminal.test.ts (100%) create mode 100644 packages/core/agent-terminal.ts create mode 100644 packages/core/agents.ts create mode 100644 packages/core/ai-context.ts create mode 100644 packages/core/browser-paths.ts rename packages/{shared => core}/code-file.test.ts (100%) create mode 100644 packages/core/code-file.ts create mode 100644 packages/core/compress.ts create mode 100644 packages/core/config-types.ts rename packages/{shared => core}/crypto.test.ts (100%) create mode 100644 packages/core/crypto.ts create mode 100644 packages/core/external-annotation.ts rename packages/{shared => core}/extract-code-paths.test.ts (100%) create mode 100644 packages/core/extract-code-paths.ts create mode 100644 packages/core/favicon.ts rename packages/{shared => core}/feedback-templates.test.ts (100%) create mode 100644 packages/core/feedback-templates.ts rename packages/{shared => core}/goal-setup.test.ts (100%) create mode 100644 packages/core/goal-setup.ts create mode 100644 packages/core/index.ts create mode 100644 packages/core/open-in-apps.ts create mode 100644 packages/core/package.json create mode 100644 packages/core/project.ts rename packages/{shared => core}/source-save.test.ts (100%) create mode 100644 packages/core/source-save.ts create mode 100644 packages/core/storage-types.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/types.ts create mode 100644 packages/core/workspace-status-types.ts create mode 100644 packages/ui/styles.css diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 89ae60d89..680c35fbf 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -7,7 +7,29 @@ cd "$(dirname "$0")" rm -rf generated mkdir -p generated generated/ai/providers -for f in feedback-templates prompts review-core diff-paths cli-pagination jj-core vcs-core review-args storage draft project pr-types pr-context-live pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file annotate-reference-roots-node config external-annotation agent-jobs agent-terminal worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour guide annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save source-save-node workspace-status open-in-apps review-profiles commit-avatars commit-history; do +# Modules that MOVED to @plannotator/core — vendor the real impl from core. +for f in feedback-templates project favicon code-file external-annotation agent-jobs agent-terminal source-save open-in-apps; do + src="../../packages/core/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + +# Node-bound shared modules that now import types from @plannotator/core/*-types — +# vendor from shared, rewrite the bare core specifier to the flat relative path. +for f in config storage workspace-status; do + src="../../packages/shared/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" \ + | sed "s|from ['\"]@plannotator/core/\\([^'\"]*\\)-types['\"]|from './\\1-types.js'|g" \ + > "generated/$f.ts" +done + +# Extracted type files those node-bound modules now depend on — vendor from core. +for f in config-types storage-types workspace-status-types; do + src="../../packages/core/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + +# Everything else in the original flat list stays sourced from packages/shared. +for f in prompts review-core diff-paths cli-pagination jj-core vcs-core review-args draft pr-types pr-context-live pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common resolve-file annotate-reference-roots-node worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save-node review-profiles guide commit-avatars commit-history; do src="../../packages/shared/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done @@ -56,9 +78,15 @@ for f in guide-review; do > "generated/$f.ts" done +# Vendor the moved AI context types from core into generated/ai/. +printf '// @generated — DO NOT EDIT. Source: packages/core/ai-context.ts\n' \ + | cat - "../../packages/core/ai-context.ts" > "generated/ai/ai-context.ts" + for f in index types provider session-manager endpoints context base-session; do src="../../packages/ai/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" > "generated/ai/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" \ + | sed "s|from ['\"]@plannotator/core/ai-context['\"]|from './ai-context.js'|g" \ + > "generated/ai/$f.ts" done for f in claude-agent-sdk codex-app-server opencode-sdk command-path pi-sdk pi-sdk-node pi-events; do diff --git a/package.json b/package.json index ebe39daf1..6f8282ffe 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bun test", - "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" + "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", diff --git a/packages/ai/package.json b/packages/ai/package.json index ab31f15ce..c48de6da2 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -17,5 +17,7 @@ "./providers/opencode-sdk": "./providers/opencode-sdk.ts", "./providers/pi-sdk-node": "./providers/pi-sdk-node.ts" }, - "dependencies": {} + "dependencies": { + "@plannotator/core": "workspace:*" + } } diff --git a/packages/ai/types.ts b/packages/ai/types.ts index f8fa0b3a5..d64e804ea 100644 --- a/packages/ai/types.ts +++ b/packages/ai/types.ts @@ -10,89 +10,8 @@ // Context — what the AI session knows about // --------------------------------------------------------------------------- -/** The surface the user is interacting with when they invoke AI. */ -export type AIContextMode = "plan-review" | "code-review" | "annotate"; - -/** - * Describes the parent agent session that originally produced the plan or diff. - * Used to fork conversations with full history. - */ -export interface ParentSession { - /** Session ID from the host agent (e.g. Claude Code session UUID). */ - sessionId: string; - /** Working directory the parent session was running in. */ - cwd: string; -} - -/** - * Snapshot of plan-review-specific context. - * Passed when AIContextMode is "plan-review". - */ -export interface PlanContext { - /** The full plan markdown as submitted by the agent. */ - plan: string; - /** Previous plan version (if this is a resubmission). */ - previousPlan?: string; - /** The version number in the plan's history. */ - version?: number; - /** Total number of versions in the plan's history. */ - totalVersions?: number; - /** Project/repository label used for plan history. */ - project?: string; - /** Annotations the user has made so far (serialised for the prompt). */ - annotations?: string; -} - -/** - * Snapshot of code-review-specific context. - * Passed when AIContextMode is "code-review". - */ -export interface CodeReviewContext { - /** The unified diff patch. Used as a fallback when the changeset can't be - * reproduced locally with a single VCS command. */ - patch: string; - /** The VCS diff type (e.g. "uncommitted", "branch", "merge-base"). When set - * to a git-reproducible type, the prompt tells the agent how to inspect the - * changes with git instead of pasting the whole diff. */ - diffType?: string; - /** The base branch/ref the diff is computed against (for branch/merge-base). */ - base?: string; - /** The specific file being discussed (if scoped). */ - filePath?: string; - /** The line range being discussed (if scoped). */ - lineRange?: { start: number; end: number; side: "old" | "new" }; - /** The code snippet being discussed (if scoped). */ - selectedCode?: string; - /** Summary of annotations the user has made. */ - annotations?: string; -} - -/** - * Snapshot of annotate-mode context. - * Passed when AIContextMode is "annotate". - */ -export interface AnnotateContext { - /** The markdown file content being annotated. */ - content: string; - /** Path to the file on disk. */ - filePath: string; - /** Source attribution shown in the UI, such as an original URL or filename. */ - sourceInfo?: string; - /** True when the document was converted from HTML or a remote reader result. */ - sourceConverted?: boolean; - /** Render mode for the annotated content. */ - renderAs?: "markdown" | "html"; - /** Summary of annotations the user has made. */ - annotations?: string; -} - -/** - * Union of mode-specific contexts, discriminated by `mode`. - */ -export type AIContext = - | { mode: "plan-review"; plan: PlanContext; parent?: ParentSession } - | { mode: "code-review"; review: CodeReviewContext; parent?: ParentSession } - | { mode: "annotate"; annotate: AnnotateContext; parent?: ParentSession }; +import type { AIContext, AIContextMode, PlanContext, CodeReviewContext, AnnotateContext, ParentSession } from '@plannotator/core/ai-context'; +export type { AIContext, AIContextMode, PlanContext, CodeReviewContext, AnnotateContext, ParentSession }; // --------------------------------------------------------------------------- // Messages — what streams back from the AI diff --git a/packages/core/agent-jobs.ts b/packages/core/agent-jobs.ts new file mode 100644 index 000000000..9463b4d9c --- /dev/null +++ b/packages/core/agent-jobs.ts @@ -0,0 +1,158 @@ +/** + * Agent Jobs — shared types, state machine, and SSE helpers. + * + * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. + * Both the Bun server handler and (future) Node handler import + * this module and wrap it with their respective HTTP transport layers. + * + * Mirrors packages/shared/external-annotation.ts in structure. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AgentJobStatus = "starting" | "running" | "done" | "failed" | "killed"; + +/** + * Snapshot of the diff the reviewer was looking at when this job was launched. + * Carried on the job so downstream UIs (agent-result panel "Copy All") export + * the same `**Diff:** ...` header the job was actually run against — if the + * reviewer switches the UI to a different diff afterwards, the job's snapshot + * still reflects truth. Structurally compatible with the UI-side + * `FeedbackDiffContext` in `packages/review-editor/utils/exportFeedback.ts`. + */ +export interface AgentJobDiffContext { + mode: string; + base?: string; + worktreePath?: string | null; +} + +export interface AgentJobInfo { + /** Unique job identifier (UUID). */ + id: string; + /** Source identifier for external annotations — "agent-{id prefix}". */ + source: string; + /** Provider that spawned this job — "claude", "codex", "tour", "shell", etc. */ + provider: string; + /** Underlying engine used (e.g., "claude" or "codex"). Set when provider is "tour". */ + engine?: string; + /** Model used (e.g., "sonnet", "opus"). Set when provider is "tour" with Claude engine. */ + model?: string; + /** Claude --effort level (e.g., "low", "medium", "high", "xhigh", "max"). */ + effort?: string; + /** Codex reasoning effort level (e.g., "high", "medium"). */ + reasoningEffort?: string; + /** Whether Codex fast mode (service_tier=fast) was enabled. */ + fastMode?: boolean; + /** Pi's unified reasoning level (marker engines only), e.g. "minimal", "high". */ + thinking?: string; + /** Human-readable label for the job. */ + label: string; + /** Current lifecycle status. */ + status: AgentJobStatus; + /** Timestamp when the job was created. */ + startedAt: number; + /** Timestamp when the job reached a terminal state. */ + endedAt?: number; + /** Process exit code (set on done/failed). */ + exitCode?: number; + /** Last ~500 chars of stderr on failure. */ + error?: string; + /** The actual command that was spawned (for display/debug). */ + command: string[]; + /** Working directory where the process was spawned. */ + cwd?: string; + /** The review prompt text (system + user message). Stored separately from command for providers that use stdin. */ + prompt?: string; + /** Review summary set by the agent on completion. */ + summary?: { + correctness: string; + explanation: string; + confidence: number; + }; + /** PR URL at launch time — used to attribute findings to the correct PR. */ + prUrl?: string; + /** PR diff scope at launch time — "layer" or "full-stack". */ + diffScope?: string; + /** Diff context at launch time (see AgentJobDiffContext). */ + diffContext?: AgentJobDiffContext; + /** Resolved review profile id at launch time (e.g. "builtin:default", "user:security"). */ + reviewProfileId?: string; + /** Resolved review profile label — rides on findings so the UI can show a profile tag. */ + reviewProfileLabel?: string; +} + +export interface AgentCapability { + id: string; + name: string; + available: boolean; + /** + * Provider-discovered model catalog (currently only Cursor). Best-effort and + * account-specific — populated from the provider CLI at capability-detection + * time, empty when discovery fails or the CLI is unauthenticated. The UI + * drives its model picker from this instead of a hardcoded list. + */ + models?: { id: string; label: string }[]; +} + +export interface AgentCapabilities { + mode: "plan" | "review" | "annotate"; + providers: AgentCapability[]; + /** True if at least one provider is available. */ + available: boolean; +} + +// --------------------------------------------------------------------------- +// SSE event types +// --------------------------------------------------------------------------- + +export type AgentJobEvent = + | { type: "snapshot"; jobs: AgentJobInfo[] } + | { type: "job:started"; job: AgentJobInfo } + | { type: "job:updated"; job: AgentJobInfo } + | { type: "job:completed"; job: AgentJobInfo } + | { type: "job:log"; jobId: string; delta: string } + | { type: "jobs:cleared" }; + +// --------------------------------------------------------------------------- +// SSE helpers +// --------------------------------------------------------------------------- + +/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ +export const AGENT_HEARTBEAT_COMMENT = ":\n\n"; + +/** Interval in ms between heartbeat comments. */ +export const AGENT_HEARTBEAT_INTERVAL_MS = 30_000; + +/** Encode an event as an SSE `data:` line. */ +export function serializeAgentSSEEvent(event: AgentJobEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Check if a status is terminal (no further transitions). */ +export function isTerminalStatus(status: AgentJobStatus): boolean { + return status === "done" || status === "failed" || status === "killed"; +} + +/** Generate the source identifier for a job from its ID. */ +export function jobSource(id: string): string { + return "agent-" + id.slice(0, 8); +} + +// --------------------------------------------------------------------------- +// Review ingestion completion semantics +// --------------------------------------------------------------------------- + +/** Calm, provider-neutral failure reason. Never leak schema/CLI internals. */ +export const REVIEW_OUTPUT_FAILED = "Review finished but produced no usable findings."; + +/** Flip a job to failed with a calm one-liner (Code Tour precedent). */ +export function markJobReviewFailed(job: AgentJobInfo, error: string): void { + job.status = "failed"; + job.error = error; +} diff --git a/packages/shared/agent-terminal.test.ts b/packages/core/agent-terminal.test.ts similarity index 100% rename from packages/shared/agent-terminal.test.ts rename to packages/core/agent-terminal.test.ts diff --git a/packages/core/agent-terminal.ts b/packages/core/agent-terminal.ts new file mode 100644 index 000000000..42f8339a3 --- /dev/null +++ b/packages/core/agent-terminal.ts @@ -0,0 +1,53 @@ +export const AGENT_TERMINAL_WS_BASE_PATH = "/api/agent-terminal/pty"; + +export function buildAgentTerminalWsPath(token: string): string { + if (!token || token.includes("/") || token.includes("?") || token.includes("#")) { + throw new Error("Agent terminal WebSocket token must be a non-empty path segment."); + } + return `${AGENT_TERMINAL_WS_BASE_PATH}/${encodeURIComponent(token)}`; +} + +export function isAgentTerminalWsRoute(pathname: string): boolean { + return pathname === AGENT_TERMINAL_WS_BASE_PATH || + pathname.startsWith(`${AGENT_TERMINAL_WS_BASE_PATH}/`); +} + +export type AgentTerminalDisabledReason = + | "not-annotate-mode" + | "remote-disabled" + | "runtime-unavailable" + | "webtui-unavailable" + | "pty-unavailable" + | "unsupported-runtime"; + +export type AgentTerminalAgent = { + id: string; + name: string; + available: boolean; +}; + +export type AgentTerminalCapability = + | { + enabled: true; + cwd: string; + wsPath: string; + agents: AgentTerminalAgent[]; + } + | { + enabled: false; + reason: AgentTerminalDisabledReason; + message?: string; + }; + +export type AnnotateAgentTerminalMode = + | "annotate" + | "annotate-last" + | "annotate-folder" + | string + | undefined; + +export function supportsAnnotateAgentTerminalMode( + mode: AnnotateAgentTerminalMode, +): boolean { + return mode === "annotate" || mode === "annotate-folder"; +} diff --git a/packages/core/agents.ts b/packages/core/agents.ts new file mode 100644 index 000000000..cc9994135 --- /dev/null +++ b/packages/core/agents.ts @@ -0,0 +1,53 @@ +/** + * Centralized agent configuration — single source of truth for all supported agents. + * + * To add a new agent: + * 1. Add an entry to AGENT_CONFIG below (origin key, display name, badge CSS classes, + * optional AI provider types) + * 2. If detection is via environment variable, add it to the detection chain + * in apps/hook/server/index.ts (detectedOrigin constant) + * 3. That's it — all UI components read from this config automatically + */ + +type AgentConfigEntry = { + name: string; + badge: string; + /** AI provider type(s) that naturally match this origin, in preference order. */ + aiProviderTypes?: readonly string[]; +}; + +export const AGENT_CONFIG = { + 'claude-code': { name: 'Claude Code', badge: 'bg-orange-500/15 text-orange-400', aiProviderTypes: ['claude-agent-sdk'] }, + 'amp': { name: 'Amp', badge: 'bg-lime-500/15 text-lime-400' }, + 'droid': { name: 'Droid', badge: 'bg-cyan-500/15 text-cyan-400' }, + 'kiro-cli': { name: 'Kiro CLI', badge: 'bg-amber-500/15 text-amber-400' }, + 'opencode': { name: 'OpenCode', badge: 'bg-emerald-500/15 text-emerald-400', aiProviderTypes: ['opencode-sdk'] }, + 'copilot-cli': { name: 'GitHub Copilot', badge: 'bg-blue-500/15 text-blue-400' }, + 'pi': { name: 'Pi', badge: 'bg-violet-500/15 text-violet-400', aiProviderTypes: ['pi-sdk'] }, + 'codex': { name: 'Codex', badge: 'bg-purple-500/15 text-purple-400', aiProviderTypes: ['codex-sdk'] }, + 'gemini-cli': { name: 'Gemini CLI', badge: 'bg-sky-500/15 text-sky-400' }, +} as const satisfies Record; + +/** All recognized origin values. */ +export type Origin = keyof typeof AGENT_CONFIG; + +/** Resolve an origin to a human-readable agent name. */ +export function getAgentName(origin: Origin | null | undefined): string { + if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].name; + return 'Coding Agent'; +} + +/** Resolve an origin to Tailwind badge classes. */ +export function getAgentBadge(origin: Origin | null | undefined): string { + if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].badge; + return 'bg-zinc-500/20 text-zinc-400'; +} + +/** Resolve an origin to matching AI provider types, in preference order. */ +export function getAgentAIProviderTypes(origin: Origin | null | undefined): readonly string[] { + if (origin && origin in AGENT_CONFIG) { + const config = AGENT_CONFIG[origin as Origin]; + return 'aiProviderTypes' in config ? config.aiProviderTypes : []; + } + return []; +} diff --git a/packages/core/ai-context.ts b/packages/core/ai-context.ts new file mode 100644 index 000000000..a3130f72f --- /dev/null +++ b/packages/core/ai-context.ts @@ -0,0 +1,83 @@ +/** The surface the user is interacting with when they invoke AI. */ +export type AIContextMode = "plan-review" | "code-review" | "annotate"; + +/** + * Describes the parent agent session that originally produced the plan or diff. + * Used to fork conversations with full history. + */ +export interface ParentSession { + /** Session ID from the host agent (e.g. Claude Code session UUID). */ + sessionId: string; + /** Working directory the parent session was running in. */ + cwd: string; +} + +/** + * Snapshot of plan-review-specific context. + * Passed when AIContextMode is "plan-review". + */ +export interface PlanContext { + /** The full plan markdown as submitted by the agent. */ + plan: string; + /** Previous plan version (if this is a resubmission). */ + previousPlan?: string; + /** The version number in the plan's history. */ + version?: number; + /** Total number of versions in the plan's history. */ + totalVersions?: number; + /** Project/repository label used for plan history. */ + project?: string; + /** Annotations the user has made so far (serialised for the prompt). */ + annotations?: string; +} + +/** + * Snapshot of code-review-specific context. + * Passed when AIContextMode is "code-review". + */ +export interface CodeReviewContext { + /** The unified diff patch. Used as a fallback when the changeset can't be + * reproduced locally with a single VCS command. */ + patch: string; + /** The VCS diff type (e.g. "uncommitted", "branch", "merge-base"). When set + * to a git-reproducible type, the prompt tells the agent how to inspect the + * changes with git instead of pasting the whole diff. */ + diffType?: string; + /** The base branch/ref the diff is computed against (for branch/merge-base). */ + base?: string; + /** The specific file being discussed (if scoped). */ + filePath?: string; + /** The line range being discussed (if scoped). */ + lineRange?: { start: number; end: number; side: "old" | "new" }; + /** The code snippet being discussed (if scoped). */ + selectedCode?: string; + /** Summary of annotations the user has made. */ + annotations?: string; +} + +/** + * Snapshot of annotate-mode context. + * Passed when AIContextMode is "annotate". + */ +export interface AnnotateContext { + /** The markdown file content being annotated. */ + content: string; + /** Path to the file on disk. */ + filePath: string; + /** Source attribution shown in the UI, such as an original URL or filename. */ + sourceInfo?: string; + /** True when the document was converted from HTML or a remote reader result. */ + sourceConverted?: boolean; + /** Render mode for the annotated content. */ + renderAs?: "markdown" | "html"; + /** Summary of annotations the user has made. */ + annotations?: string; +} + +/** + * Union of mode-specific contexts, discriminated by `mode`. + */ +export type AIContext = + | { mode: "plan-review"; plan: PlanContext; parent?: ParentSession } + | { mode: "code-review"; review: CodeReviewContext; parent?: ParentSession } + | { mode: "annotate"; annotate: AnnotateContext; parent?: ParentSession }; diff --git a/packages/core/browser-paths.ts b/packages/core/browser-paths.ts new file mode 100644 index 000000000..ce867a505 --- /dev/null +++ b/packages/core/browser-paths.ts @@ -0,0 +1,25 @@ +export function normalizeBrowserPath(path: string): string { + const withForwardSlashes = path.replace(/\\/g, "/"); + const prefix = withForwardSlashes.startsWith("//") ? "//" : ""; + const collapsed = prefix + withForwardSlashes.slice(prefix.length).replace(/\/+/g, "/"); + if (collapsed === "/" || /^[A-Za-z]:\/$/.test(collapsed)) return collapsed; + return collapsed.replace(/\/+$/, ""); +} + +export function dirnameBrowserPath(path: string): string { + const normalized = normalizeBrowserPath(path); + const driveRootMatch = normalized.match(/^([A-Za-z]:)\/[^/]+$/); + if (driveRootMatch) return `${driveRootMatch[1]}/`; + const index = normalized.lastIndexOf("/"); + if (index < 0) return normalized; + if (index === 0) return "/"; + return normalized.slice(0, index); +} + +export function pathIsInsideDir(path: string, dir: string): boolean { + const normalizedPath = normalizeBrowserPath(path); + const normalizedDir = normalizeBrowserPath(dir); + if (!normalizedDir) return normalizedPath === ""; + const dirPrefix = normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`; + return normalizedPath === normalizedDir || normalizedPath.startsWith(dirPrefix); +} diff --git a/packages/shared/code-file.test.ts b/packages/core/code-file.test.ts similarity index 100% rename from packages/shared/code-file.test.ts rename to packages/core/code-file.test.ts diff --git a/packages/core/code-file.ts b/packages/core/code-file.ts new file mode 100644 index 000000000..43633a766 --- /dev/null +++ b/packages/core/code-file.ts @@ -0,0 +1,41 @@ +export const CODE_FILE_REGEX = /(?:\.(tsx?|jsx?|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|scala|sh|bash|zsh|sql|graphql|json|ya?ml|toml|ini|css|scss|less|xml|tf|lua|r|dart|ex|exs|vue|svelte|astro|zig|proto)|(?:^|\/)(Dockerfile|Makefile|Rakefile|Gemfile|Procfile|Vagrantfile|Brewfile|Justfile))$/i; + +export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+(?::\d+(?:-\d+)?)?/g; + +const IMPLAUSIBLE_CHARS = /[{},*?\s]/; + +export function isPlausibleCodeFilePath(input: string): boolean { + return !IMPLAUSIBLE_CHARS.test(input); +} + +export interface ParsedCodePath { + filePath: string; + line?: number; + lineEnd?: number; +} + +const LINE_SUFFIX_RE = /:(\d+)(?:-(\d+))?$/; + +export function parseCodePath(input: string): ParsedCodePath { + const clean = input.replace(/#.*$/, ''); + const m = clean.match(LINE_SUFFIX_RE); + if (!m) return { filePath: clean }; + let line = Number.parseInt(m[1], 10); + let lineEnd = m[2] ? Number.parseInt(m[2], 10) : undefined; + if (lineEnd != null && lineEnd < line) { const tmp = line; line = lineEnd; lineEnd = tmp; } + return { filePath: clean.replace(LINE_SUFFIX_RE, ''), line, lineEnd }; +} + +export function stripLineRef(input: string): string { + return input.replace(/#.*$/, '').replace(LINE_SUFFIX_RE, ''); +} + +export function isCodeFilePath(input: string): boolean { + if (!isPlausibleCodeFilePath(input)) return false; + return CODE_FILE_REGEX.test(stripLineRef(input)) + && !input.startsWith('http://') && !input.startsWith('https://'); +} + +export function isCodeFilePathStrict(input: string): boolean { + return input.includes('/') && isCodeFilePath(input); +} diff --git a/packages/core/compress.ts b/packages/core/compress.ts new file mode 100644 index 000000000..70c5099ac --- /dev/null +++ b/packages/core/compress.ts @@ -0,0 +1,51 @@ +/** + * Portable deflate-raw + base64url compression. + * + * Uses only Web APIs (CompressionStream, TextEncoder, btoa) so it works + * in browsers, Bun, and edge runtimes. Both @plannotator/server and + * @plannotator/ui import from here — single source of truth. + */ + +export async function compress(data: unknown): Promise { + const json = JSON.stringify(data); + const byteArray = new TextEncoder().encode(json); + + const stream = new CompressionStream('deflate-raw'); + const writer = stream.writable.getWriter(); + writer.write(byteArray); + writer.close(); + + const buffer = await new Response(stream.readable).arrayBuffer(); + const compressed = new Uint8Array(buffer); + + // Loop instead of spread to avoid RangeError on large payloads + // (String.fromCharCode(...arr) has a ~65K argument limit) + let binary = ''; + for (let i = 0; i < compressed.length; i++) { + binary += String.fromCharCode(compressed[i]); + } + const base64 = btoa(binary); + return base64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +export async function decompress(b64: string): Promise { + const base64 = b64 + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const binary = atob(base64); + const byteArray = Uint8Array.from(binary, c => c.charCodeAt(0)); + + const stream = new DecompressionStream('deflate-raw'); + const writer = stream.writable.getWriter(); + writer.write(byteArray); + writer.close(); + + const buffer = await new Response(stream.readable).arrayBuffer(); + const json = new TextDecoder().decode(buffer); + + return JSON.parse(json); +} diff --git a/packages/core/config-types.ts b/packages/core/config-types.ts new file mode 100644 index 000000000..66372dd4d --- /dev/null +++ b/packages/core/config-types.ts @@ -0,0 +1,18 @@ +export type DefaultDiffType = 'since-base' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; +export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; + +export interface DiffOptions { + diffStyle?: 'split' | 'unified'; + overflow?: 'scroll' | 'wrap'; + diffIndicators?: 'bars' | 'classic' | 'none'; + lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; + showLineNumbers?: boolean; + showDiffBackground?: boolean; + fontFamily?: string; + fontSize?: string; + tabSize?: number; + hideWhitespace?: boolean; + expandUnchanged?: boolean; + defaultDiffType?: DefaultDiffType; + lineBgIntensity?: DiffLineBgIntensity; +} diff --git a/packages/shared/crypto.test.ts b/packages/core/crypto.test.ts similarity index 100% rename from packages/shared/crypto.test.ts rename to packages/core/crypto.test.ts diff --git a/packages/core/crypto.ts b/packages/core/crypto.ts new file mode 100644 index 000000000..0161e6dcd --- /dev/null +++ b/packages/core/crypto.ts @@ -0,0 +1,97 @@ +/** + * AES-256-GCM encryption for zero-knowledge paste storage. + * + * Uses Web Crypto API — works in browsers, Bun, and edge runtimes. + * The key never leaves the client; it lives in the URL fragment. + */ + +/** + * Encrypt a compressed base64url string with a fresh AES-256-GCM key. + * + * Returns { ciphertext, key } where: + * - ciphertext: base64url-encoded (12-byte IV prepended to GCM output) + * - key: base64url-encoded 256-bit key for the URL fragment + */ +export async function encrypt( + compressedData: string +): Promise<{ ciphertext: string; key: string }> { + const cryptoKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt'] + ); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(compressedData); + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + plaintext + ); + + // Prepend IV to ciphertext (IV || ciphertext+tag) + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + + const rawKey = await crypto.subtle.exportKey('raw', cryptoKey); + + return { + ciphertext: bytesToBase64url(combined), + key: bytesToBase64url(new Uint8Array(rawKey)), + }; +} + +/** + * Decrypt a ciphertext string using a base64url-encoded AES-256-GCM key. + * + * Expects ciphertext format: base64url(IV || encrypted+tag) + * Returns the original compressed base64url string. + */ +export async function decrypt( + ciphertext: string, + key: string +): Promise { + const combined = base64urlToBytes(ciphertext); + const rawKey = base64urlToBytes(key); + + const iv = combined.slice(0, 12); + const encrypted = combined.slice(12); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + rawKey.buffer as ArrayBuffer, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + encrypted + ); + + return new TextDecoder().decode(decrypted); +} + +// --- Helpers --- + +function bytesToBase64url(bytes: Uint8Array): string { + // Loop to avoid RangeError on large payloads (same approach as compress.ts) + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +function base64urlToBytes(b64: string): Uint8Array { + const base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + return Uint8Array.from(binary, c => c.charCodeAt(0)); +} diff --git a/packages/core/external-annotation.ts b/packages/core/external-annotation.ts new file mode 100644 index 000000000..2260e3f87 --- /dev/null +++ b/packages/core/external-annotation.ts @@ -0,0 +1,455 @@ +/** + * External Annotations — shared types, store logic, and SSE helpers. + * + * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. + * Both the Bun server handler and Pi server handler import this module + * and wrap it with their respective HTTP transport layers. + * + * The store is generic — plan servers store Annotation objects, + * review servers store CodeAnnotation objects. The mode-specific + * input transformers handle validation and field assignment. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Constraint for any annotation type the store can hold. */ +export type StorableAnnotation = { id: string; source?: string }; + +export type ExternalAnnotationEvent = + | { type: "snapshot"; annotations: T[] } + | { type: "add"; annotations: T[] } + | { type: "remove"; ids: string[] } + | { type: "clear"; source?: string } + | { type: "update"; id: string; annotation: T }; + +// --------------------------------------------------------------------------- +// SSE helpers +// --------------------------------------------------------------------------- + +/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ +export const HEARTBEAT_COMMENT = ":\n\n"; + +/** Interval in ms between heartbeat comments. */ +export const HEARTBEAT_INTERVAL_MS = 30_000; + +/** Encode an event as an SSE `data:` line. */ +export function serializeSSEEvent(event: ExternalAnnotationEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +// --------------------------------------------------------------------------- +// Input validation — shared helpers +// --------------------------------------------------------------------------- + +export interface ParseError { + error: string; +} + +/** + * Unwrap a POST body into an array of raw input objects. + * + * Accepts either: + * - A single annotation object: `{ source: "...", ... }` + * - A batch wrapper: `{ annotations: [{ source: "...", ... }, ...] }` + */ +function unwrapBody(body: unknown): Record[] | ParseError { + if (!body || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record; + + // Batch format: { annotations: [...] } + if (Array.isArray(obj.annotations)) { + if (obj.annotations.length === 0) { + return { error: "annotations array must not be empty" }; + } + const items: Record[] = []; + for (let i = 0; i < obj.annotations.length; i++) { + const item = obj.annotations[i]; + if (!item || typeof item !== "object") { + return { error: `annotations[${i}] must be an object` }; + } + items.push(item as Record); + } + return items; + } + + // Single format: { source: "...", ... } + if (typeof obj.source === "string") { + return [obj as Record]; + } + + return { error: 'Missing required "source" field or "annotations" array' }; +} + +function requireString(obj: Record, field: string, index: number): string | ParseError { + const val = obj[field]; + if (typeof val !== "string" || val.length === 0) { + return { error: `annotations[${index}] missing required "${field}" field` }; + } + return val; +} + +// --------------------------------------------------------------------------- +// Plan mode transformer — produces Annotation objects +// --------------------------------------------------------------------------- + +/** The Annotation type shape for plan mode (mirrors packages/ui/types.ts). */ +interface PlanAnnotation { + id: string; + blockId: string; + startOffset: number; + endOffset: number; + type: string; // AnnotationType value + text?: string; + originalText: string; + createdA: number; + author?: string; + source?: string; +} + +const VALID_PLAN_TYPES = ["DELETION", "COMMENT", "GLOBAL_COMMENT"]; + +export function transformPlanInput( + body: unknown, +): { annotations: PlanAnnotation[] } | ParseError { + const items = unwrapBody(body); + if ("error" in items) return items; + + const annotations: PlanAnnotation[] = []; + for (let i = 0; i < items.length; i++) { + const obj = items[i]; + + const source = requireString(obj, "source", i); + if (typeof source !== "string") return source; + + // Must have text content + if (typeof obj.text !== "string" || obj.text.length === 0) { + return { error: `annotations[${i}] missing required "text" field` }; + } + + // Validate type if provided, default to GLOBAL_COMMENT + const type = typeof obj.type === "string" ? obj.type : "GLOBAL_COMMENT"; + if (!VALID_PLAN_TYPES.includes(type)) { + return { + error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_PLAN_TYPES.join(", ")}`, + }; + } + + // DELETION requires originalText (the text to remove) + if (type === "DELETION" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { + return { error: `annotations[${i}] DELETION type requires non-empty "originalText" field` }; + } + + // COMMENT requires originalText so the renderer can pin it to a phrase. + // External agents that want sidebar-only feedback should use GLOBAL_COMMENT + // instead — without a phrase to anchor to, a COMMENT renders as an empty + // quote bubble in the sidebar and exports as `Feedback on: ""`. + if (type === "COMMENT" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { + return { + error: `annotations[${i}] COMMENT requires non-empty "originalText" field. Use GLOBAL_COMMENT for sidebar-only feedback.`, + }; + } + + annotations.push({ + id: crypto.randomUUID(), + blockId: "external", + startOffset: 0, + endOffset: 0, + type, + text: String(obj.text), + originalText: typeof obj.originalText === "string" ? obj.originalText : "", + createdA: Date.now(), + author: typeof obj.author === "string" ? obj.author : undefined, + source, + }); + } + + return { annotations }; +} + +// --------------------------------------------------------------------------- +// Review mode transformer — produces CodeAnnotation objects +// --------------------------------------------------------------------------- + +/** The CodeAnnotation type shape for review mode (mirrors packages/ui/types.ts). */ +interface ReviewAnnotation { + id: string; + type: string; // CodeAnnotationType value + scope?: string; + filePath: string; + lineStart: number; + lineEnd: number; + side: string; + text?: string; + suggestedCode?: string; + originalCode?: string; + createdAt: number; + author?: string; + source?: string; + // Agent review metadata (optional — only set by agent review findings) + severity?: string; // "important" | "nit" | "pre_existing" + reasoning?: string; // Validation chain explaining how the issue was confirmed + reviewProfileLabel?: string; // Custom review profile that produced this finding +} + +const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"]; +const VALID_SIDES = ["old", "new"]; +const VALID_SCOPES = ["line", "file", "general"]; + +/** A review finding's placement, derived from what it carries. */ +export type FindingPlacement = { + scope: "line" | "file" | "general"; + filePath: string; + lineStart: number; + lineEnd: number; +}; + +/** + * Classify an agent review finding by what it carries, so nothing is dropped: + * file + a usable line → a line comment + * file, no line → a whole-file comment + * neither → a general (review-level) comment + * + * For file and general placements the line is 0; for general the path is "". + * Consumers branch on `scope`, never on the sentinel values. + */ +export function classifyFindingPlacement( + filePath: string, + lineStart: number | null | undefined, + lineEnd: number | null | undefined, +): FindingPlacement { + const hasFile = filePath.length > 0; + const hasLine = typeof lineStart === "number"; + if (hasFile && hasLine) { + return { + scope: "line", + filePath, + lineStart, + lineEnd: typeof lineEnd === "number" ? lineEnd : lineStart, + }; + } + if (hasFile) { + return { scope: "file", filePath, lineStart: 0, lineEnd: 0 }; + } + return { scope: "general", filePath: "", lineStart: 0, lineEnd: 0 }; +} + +export function transformReviewInput( + body: unknown, +): { annotations: ReviewAnnotation[] } | ParseError { + const items = unwrapBody(body); + if ("error" in items) return items; + + const annotations: ReviewAnnotation[] = []; + for (let i = 0; i < items.length; i++) { + const obj = items[i]; + + const source = requireString(obj, "source", i); + if (typeof source !== "string") return source; + + // scope: optional, defaults to "line" + const scope = typeof obj.scope === "string" ? obj.scope : "line"; + if (!VALID_SCOPES.includes(scope)) { + return { + error: `annotations[${i}] invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`, + }; + } + + // Location requirements depend on scope: + // line → filePath + lineStart + lineEnd required. A finding that claims + // a line must carry one, so a broken line finding is rejected + // rather than quietly passing as a vaguer comment. + // file → filePath required; line ignored (defaults to 0). + // general → no file, no line (review-level; defaults to "" / 0). + let filePath = ""; + let lineStart = 0; + let lineEnd = 0; + if (scope !== "general") { + const fp = requireString(obj, "filePath", i); + if (typeof fp !== "string") return fp; + filePath = fp; + if (scope === "line") { + if (typeof obj.lineStart !== "number") { + return { error: `annotations[${i}] missing required "lineStart" field` }; + } + if (typeof obj.lineEnd !== "number") { + return { error: `annotations[${i}] missing required "lineEnd" field` }; + } + lineStart = obj.lineStart; + lineEnd = obj.lineEnd; + } else { + lineStart = typeof obj.lineStart === "number" ? obj.lineStart : 0; + lineEnd = typeof obj.lineEnd === "number" ? obj.lineEnd : 0; + } + } + + // side: optional, defaults to "new" + const side = typeof obj.side === "string" ? obj.side : "new"; + if (!VALID_SIDES.includes(side)) { + return { + error: `annotations[${i}] invalid side "${side}". Must be one of: ${VALID_SIDES.join(", ")}`, + }; + } + + // type: optional, defaults to "comment" + const type = typeof obj.type === "string" ? obj.type : "comment"; + if (!VALID_REVIEW_TYPES.includes(type)) { + return { + error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_REVIEW_TYPES.join(", ")}`, + }; + } + + // Must have at least text or suggestedCode + if (typeof obj.text !== "string" && typeof obj.suggestedCode !== "string") { + return { + error: `annotations[${i}] must have at least one of: text, suggestedCode`, + }; + } + + annotations.push({ + id: crypto.randomUUID(), + type, + scope, + filePath, + lineStart, + lineEnd, + side, + text: typeof obj.text === "string" ? obj.text : undefined, + suggestedCode: typeof obj.suggestedCode === "string" ? obj.suggestedCode : undefined, + originalCode: typeof obj.originalCode === "string" ? obj.originalCode : undefined, + createdAt: Date.now(), + author: typeof obj.author === "string" ? obj.author : undefined, + source, + // Agent review metadata (optional — only set by agent review findings) + ...(typeof obj.severity === "string" && { severity: obj.severity }), + ...(typeof obj.reasoning === "string" && { reasoning: obj.reasoning }), + ...(typeof obj.reviewProfileLabel === "string" && { reviewProfileLabel: obj.reviewProfileLabel }), + }); + } + + return { annotations }; +} + +// --------------------------------------------------------------------------- +// Annotation Store (generic) +// --------------------------------------------------------------------------- + +type MutationListener = (event: ExternalAnnotationEvent) => void; + +export interface AnnotationStore { + /** Add fully-formed annotations. Returns the added annotations. */ + add(items: T[]): T[]; + /** Remove an annotation by ID. Returns true if found. */ + remove(id: string): boolean; + /** Remove all annotations from a specific source. Returns count removed. */ + clearBySource(source: string): number; + /** Update an annotation by ID. Returns the updated annotation, or null if not found. */ + update(id: string, fields: Partial): T | null; + /** Remove all annotations. Returns count removed. */ + clearAll(): number; + /** Get all annotations (snapshot). */ + getAll(): T[]; + /** Monotonic version counter — incremented on every mutation. */ + readonly version: number; + /** Register a listener for mutation events. Returns unsubscribe function. */ + onMutation(listener: MutationListener): () => void; +} + +/** + * Create an in-memory annotation store. + * + * The store is runtime-agnostic — it holds data and emits events. + * HTTP transport (SSE broadcasting, request parsing) is handled by + * the server-specific adapter (Bun or Pi). + */ +export function createAnnotationStore(): AnnotationStore { + const annotations: T[] = []; + const listeners = new Set>(); + let version = 0; + + function emit(event: ExternalAnnotationEvent): void { + for (const listener of listeners) { + try { + listener(event); + } catch { + // Don't let a failing listener break the store + } + } + } + + return { + add(items) { + if (items.length > 0) { + for (const item of items) { + annotations.push(item); + } + version++; + emit({ type: "add", annotations: items }); + } + return items; + }, + + remove(id) { + const idx = annotations.findIndex((a) => a.id === id); + if (idx === -1) return false; + annotations.splice(idx, 1); + version++; + emit({ type: "remove", ids: [id] }); + return true; + }, + + update(id, fields) { + const idx = annotations.findIndex((a) => a.id === id); + if (idx === -1) return null; + const merged = { ...annotations[idx], ...fields, id } as T; + annotations[idx] = merged; + version++; + emit({ type: "update", id, annotation: merged }); + return merged; + }, + + clearBySource(source) { + const before = annotations.length; + for (let i = annotations.length - 1; i >= 0; i--) { + if (annotations[i].source === source) { + annotations.splice(i, 1); + } + } + const removed = before - annotations.length; + if (removed > 0) { + version++; + emit({ type: "clear", source }); + } + return removed; + }, + + clearAll() { + const count = annotations.length; + if (count > 0) { + annotations.length = 0; + version++; + emit({ type: "clear" }); + } + return count; + }, + + getAll() { + return [...annotations]; + }, + + get version() { + return version; + }, + + onMutation(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/shared/extract-code-paths.test.ts b/packages/core/extract-code-paths.test.ts similarity index 100% rename from packages/shared/extract-code-paths.test.ts rename to packages/core/extract-code-paths.test.ts diff --git a/packages/core/extract-code-paths.ts b/packages/core/extract-code-paths.ts new file mode 100644 index 000000000..c4ef380fd --- /dev/null +++ b/packages/core/extract-code-paths.ts @@ -0,0 +1,66 @@ +import { + CODE_PATH_BARE_REGEX, + isCodeFilePath, + isCodeFilePathStrict, +} from "./code-file"; + +const FENCED_CODE_BLOCK = /(^|\n)([ \t]*)(```|~~~)[\s\S]*?\n\2\3/g; +const HTML_COMMENT = //g; +// Match InlineMarkdown.tsx's bare-URL regex exactly so URL ranges excised +// here mirror the ranges the renderer would consume. +const URL_REGEX = /https?:\/\/[^\s<>"']+/g; +const BACKTICK_SPAN = /`([^`\n]+)`/g; + +/** + * Extract candidate code-file paths from markdown text. Mirrors the renderer's + * detection precedence so the validator only sees paths the renderer would + * actually linkify: + * 1. fenced code blocks and HTML comments are stripped first; + * 2. URL ranges are excised before the bare-prose scan (URLs win); + * 3. backtick spans matching `isCodeFilePath` are collected; + * 4. bare-prose paths matching `CODE_PATH_BARE_REGEX` and + * `isCodeFilePathStrict` are collected. + * + * Hash anchors (`#L42`) are stripped from results to match the renderer's + * `cleanPath` transform. Returns deduped candidate strings. + */ +export function extractCandidateCodePaths(markdown: string): string[] { + const stripped = markdown + .replace(FENCED_CODE_BLOCK, "") + .replace(HTML_COMMENT, ""); + + const candidates = new Set(); + + let m: RegExpExecArray | null; + const backtickRe = new RegExp(BACKTICK_SPAN.source, "g"); + while ((m = backtickRe.exec(stripped)) !== null) { + const inner = m[1].trim(); + if (isCodeFilePath(inner)) { + candidates.add(inner.replace(/#.*$/, "")); + } + } + + for (const line of stripped.split("\n")) { + const urlRanges: Array<[number, number]> = []; + const urlRe = new RegExp(URL_REGEX.source, "g"); + while ((m = urlRe.exec(line)) !== null) { + urlRanges.push([m.index, m.index + m[0].length]); + } + + const pathRe = new RegExp(CODE_PATH_BARE_REGEX.source, "g"); + while ((m = pathRe.exec(line)) !== null) { + const start = m.index; + const end = start + m[0].length; + const prev = start === 0 ? "" : line[start - 1]; + if (/\w/.test(prev)) continue; + const overlapsUrl = urlRanges.some( + ([s, e]) => start < e && end > s, + ); + if (overlapsUrl) continue; + if (!isCodeFilePathStrict(m[0])) continue; + candidates.add(m[0].replace(/#.*$/, "")); + } + } + + return Array.from(candidates); +} diff --git a/packages/core/favicon.ts b/packages/core/favicon.ts new file mode 100644 index 000000000..c857b8419 --- /dev/null +++ b/packages/core/favicon.ts @@ -0,0 +1,5 @@ +export const FAVICON_SVG = ` + + + P +`; diff --git a/packages/shared/feedback-templates.test.ts b/packages/core/feedback-templates.test.ts similarity index 100% rename from packages/shared/feedback-templates.test.ts rename to packages/core/feedback-templates.test.ts diff --git a/packages/core/feedback-templates.ts b/packages/core/feedback-templates.ts new file mode 100644 index 000000000..02d9b3217 --- /dev/null +++ b/packages/core/feedback-templates.ts @@ -0,0 +1,45 @@ +/** + * Shared feedback templates for all agent integrations. + * + * The plan deny template was tuned in #224 / commit 3dca977 to use strong + * directive framing — Claude was ignoring softer phrasing. + * + * IMPORTANT: This module is imported by packages/ui/utils/parser.ts which is + * bundled into the browser SPA. It must NOT import from ./prompts or ./config + * (which depend on node:fs, node:os, node:child_process). Keep it self-contained. + * + * Server-side call sites use getPlanDeniedPrompt() from ./prompts directly. + * This module is only kept for the browser's wrapFeedbackForAgent clipboard feature. + */ + +export interface PlanDenyFeedbackOptions { + planFilePath?: string; +} + +export interface AnnotateFileFeedbackOptions { + filePath: string; + fileHeader?: "File" | "Folder" | string; +} + +export const planDenyFeedback = ( + feedback: string, + toolName: string = "ExitPlanMode", + options?: PlanDenyFeedbackOptions, +): string => { + const planFileRule = options?.planFilePath + ? `- Your plan is saved at: ${options.planFilePath}\n You can edit this file to make targeted changes, then pass its path to ${toolName}.\n` + : ""; + + return `YOUR PLAN WAS NOT APPROVED.\n\nYou MUST revise the plan to address ALL of the feedback below before calling ${toolName} again.\n\nRules:\n${planFileRule}- Do not resubmit the same plan unchanged.\n- Do NOT change the plan title (first # heading) unless the user explicitly asks you to.\n\n${feedback || "Plan changes requested"}`; +}; + +export const annotateFileFeedback = ( + feedback: string, + options: AnnotateFileFeedbackOptions, +): string => { + const fileHeader = options.fileHeader ?? "File"; + return `# Markdown Annotations\n\n${fileHeader}: ${options.filePath}\n\n${feedback}\n\nPlease address the annotation feedback above.`; +}; + +export const annotateMessageFeedback = (feedback: string): string => + `# Message Annotations\n\n${feedback}\n\nPlease address the annotation feedback above.`; diff --git a/packages/shared/goal-setup.test.ts b/packages/core/goal-setup.test.ts similarity index 100% rename from packages/shared/goal-setup.test.ts rename to packages/core/goal-setup.test.ts diff --git a/packages/core/goal-setup.ts b/packages/core/goal-setup.ts new file mode 100644 index 000000000..0b07f9c40 --- /dev/null +++ b/packages/core/goal-setup.ts @@ -0,0 +1,336 @@ +export type GoalSetupStage = "interview" | "facts"; + +export type GoalSetupAnswerMode = + | "text" + | "single" + | "multi" + | "single-custom" + | "multi-custom" + | "custom"; + +export interface GoalSetupQuestionOption { + id: string; + label: string; + description?: string; +} + +export interface GoalSetupQuestion { + id: string; + prompt: string; + description?: string; + answerMode?: GoalSetupAnswerMode; + recommendedAnswer?: string; + recommendedOptionIds?: string[]; + options?: GoalSetupQuestionOption[]; + required?: boolean; +} + +export interface GoalSetupQuestionAnswer { + questionId: string; + selectedOptionIds: string[]; + customAnswer: string; + note?: string; + answer: string; + completed: boolean; + skipped?: boolean; +} + +export interface GoalSetupInterviewBundle { + stage: "interview"; + title?: string; + goalSlug?: string; + questions: GoalSetupQuestion[]; +} + +export interface GoalSetupFact { + id: string; + text: string; + accepted: boolean; + removed: boolean; + comment?: string; + recommendedAutomatedVerification?: boolean; + automatedVerification: boolean; + previousText?: string; +} + +export interface GoalSetupFactsBundle { + stage: "facts"; + title?: string; + goalSlug?: string; + facts: GoalSetupFact[]; + showAccepted?: boolean; +} + +export type GoalSetupBundle = GoalSetupInterviewBundle | GoalSetupFactsBundle; + +export interface GoalSetupInterviewResult { + stage: "interview"; + title?: string; + goalSlug?: string; + answers: GoalSetupQuestionAnswer[]; +} + +export interface GoalSetupFactResult { + id: string; + text: string; + accepted: boolean; + removed: boolean; + comment?: string; + automatedVerification: boolean; + recommendedAutomatedVerification?: boolean; +} + +export interface GoalSetupFactsResult { + stage: "facts"; + title?: string; + goalSlug?: string; + facts: GoalSetupFactResult[]; + factsMarkdown: string; +} + +export type GoalSetupResult = GoalSetupInterviewResult | GoalSetupFactsResult; + +function asRecord(value: unknown, context: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${context} must be an object`); + } + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function normalizeId(value: unknown, fallback: string): string { + const raw = asString(value, fallback).trim(); + return raw || fallback; +} + +function normalizeAnswerMode(value: unknown): GoalSetupAnswerMode { + switch (value) { + case "single": + case "multi": + case "single-custom": + case "multi-custom": + case "custom": + case "text": + return value; + default: + return "text"; + } +} + +function normalizeOption(value: unknown, index: number): GoalSetupQuestionOption { + const item = asRecord(value, `questions[].options[${index}]`); + const label = asString(item.label).trim(); + if (!label) { + throw new Error(`questions[].options[${index}].label is required`); + } + return { + id: normalizeId(item.id, `option-${index + 1}`), + label, + ...(asString(item.description).trim() + ? { description: asString(item.description).trim() } + : {}), + }; +} + +function normalizeQuestion(value: unknown, index: number): GoalSetupQuestion { + const item = asRecord(value, `questions[${index}]`); + const prompt = asString(item.prompt).trim(); + if (!prompt) { + throw new Error(`questions[${index}].prompt is required`); + } + const options = Array.isArray(item.options) + ? item.options.map(normalizeOption) + : undefined; + + const recommendedOptionIds = Array.isArray(item.recommendedOptionIds) + ? (item.recommendedOptionIds as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined; + + return { + id: normalizeId(item.id, `question-${index + 1}`), + prompt, + ...(asString(item.description).trim() + ? { description: asString(item.description).trim() } + : {}), + answerMode: normalizeAnswerMode(item.answerMode), + ...(asString(item.recommendedAnswer).trim() + ? { recommendedAnswer: asString(item.recommendedAnswer).trim() } + : {}), + ...(recommendedOptionIds && recommendedOptionIds.length > 0 + ? { recommendedOptionIds } + : {}), + ...(options && options.length > 0 ? { options } : {}), + required: asBoolean(item.required, true), + }; +} + +function normalizeFact(value: unknown, index: number): GoalSetupFact { + const item = asRecord(value, `facts[${index}]`); + const text = asString(item.text).trim(); + if (!text) { + throw new Error(`facts[${index}].text is required`); + } + const recommended = asBoolean(item.recommendedAutomatedVerification, false); + return { + id: normalizeId(item.id, `fact-${index + 1}`), + text, + accepted: asBoolean(item.accepted, false), + removed: asBoolean(item.removed, false), + ...(asString(item.comment).trim() + ? { comment: asString(item.comment).trim() } + : {}), + recommendedAutomatedVerification: recommended, + automatedVerification: asBoolean(item.automatedVerification, recommended), + ...(asString(item.previousText).trim() + ? { previousText: asString(item.previousText).trim() } + : {}), + }; +} + +export function normalizeInterviewBundle(value: unknown): GoalSetupInterviewBundle { + const raw = asRecord(value, "goal setup interview bundle"); + if (!Array.isArray(raw.questions) || raw.questions.length === 0) { + throw new Error("interview bundle requires at least one question"); + } + return { + stage: "interview", + ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), + ...(asString(raw.goalSlug).trim() + ? { goalSlug: asString(raw.goalSlug).trim() } + : {}), + questions: raw.questions.map(normalizeQuestion), + }; +} + +export function normalizeFactsBundle(value: unknown): GoalSetupFactsBundle { + const raw = asRecord(value, "goal setup facts bundle"); + if (!Array.isArray(raw.facts)) { + throw new Error("facts bundle requires a facts array"); + } + return { + stage: "facts", + ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), + ...(asString(raw.goalSlug).trim() + ? { goalSlug: asString(raw.goalSlug).trim() } + : {}), + facts: raw.facts.map(normalizeFact), + showAccepted: asBoolean(raw.showAccepted, false), + }; +} + +export function normalizeGoalSetupBundle( + value: unknown, + expectedStage?: GoalSetupStage +): GoalSetupBundle { + const raw = asRecord(value, "goal setup bundle"); + const stage = expectedStage ?? raw.stage; + if (stage === "interview") return normalizeInterviewBundle(raw); + if (stage === "facts") return normalizeFactsBundle(raw); + throw new Error("goal setup bundle stage must be interview or facts"); +} + +export function hasQuestionAnswer(answer: GoalSetupQuestionAnswer): boolean { + return ( + answer.selectedOptionIds.length > 0 || + answer.customAnswer.trim().length > 0 || + answer.answer.trim().length > 0 + ); +} + +export function createInterviewResult( + bundle: GoalSetupInterviewBundle, + answers: GoalSetupQuestionAnswer[] +): GoalSetupInterviewResult { + const byId = new Map(answers.map((answer) => [answer.questionId, answer])); + return { + stage: "interview", + title: bundle.title, + goalSlug: bundle.goalSlug, + answers: bundle.questions.map((question) => { + const answer = byId.get(question.id); + const normalized: GoalSetupQuestionAnswer = { + questionId: question.id, + selectedOptionIds: Array.isArray(answer?.selectedOptionIds) + ? answer!.selectedOptionIds + : [], + customAnswer: asString(answer?.customAnswer), + ...(asString(answer?.note).trim() + ? { note: asString(answer?.note).trim() } + : {}), + answer: asString(answer?.answer), + completed: asBoolean(answer?.completed, false), + }; + const completed = normalized.completed || hasQuestionAnswer(normalized); + const skipped = asBoolean(answer?.skipped, false) && !completed; + return { + ...normalized, + completed, + ...(skipped ? { skipped: true } : {}), + }; + }), + }; +} + +export function filterReviewableFacts(bundle: GoalSetupFactsBundle): GoalSetupFact[] { + if (bundle.showAccepted) return bundle.facts; + return bundle.facts.filter((fact) => !fact.accepted); +} + +export function createFactsResult( + bundle: GoalSetupFactsBundle, + facts: GoalSetupFactResult[] +): GoalSetupFactsResult { + const byId = new Map(facts.map((fact) => [fact.id, fact])); + const merged = bundle.facts.map((fact) => { + const next = byId.get(fact.id); + const removed = asBoolean(next?.removed, fact.removed); + const text = asString(next?.text, fact.text).trim(); + if (!removed && !text) { + throw new Error(`Fact "${fact.id}" text cannot be empty; edit it or remove the fact.`); + } + const comment = (next && Object.prototype.hasOwnProperty.call(next, "comment") + ? asString(next.comment) + : asString(fact.comment) + ).trim(); + return { + id: fact.id, + text: text || fact.text, + accepted: asBoolean(next?.accepted, fact.accepted), + removed, + ...(comment ? { comment } : {}), + automatedVerification: asBoolean( + next?.automatedVerification, + fact.automatedVerification + ), + recommendedAutomatedVerification: + next?.recommendedAutomatedVerification ?? + fact.recommendedAutomatedVerification, + }; + }); + + return { + stage: "facts", + title: bundle.title, + goalSlug: bundle.goalSlug, + facts: merged, + factsMarkdown: factsResultToMarkdown(merged), + }; +} + +export function factsResultToMarkdown(facts: GoalSetupFactResult[]): string { + const accepted = facts.filter((fact) => fact.accepted && !fact.removed); + if (accepted.length === 0) return "# Facts\n\nNo accepted facts."; + + const lines = ["# Facts", ""]; + for (const fact of accepted) { + lines.push(`- ${fact.text}`); + } + return lines.join("\n"); +} diff --git a/packages/core/index.ts b/packages/core/index.ts new file mode 100644 index 000000000..0f30b3baf --- /dev/null +++ b/packages/core/index.ts @@ -0,0 +1,2 @@ +export * from './ai-context'; +export type { EditorAnnotation } from './types'; diff --git a/packages/core/open-in-apps.ts b/packages/core/open-in-apps.ts new file mode 100644 index 000000000..122af8feb --- /dev/null +++ b/packages/core/open-in-apps.ts @@ -0,0 +1,189 @@ +/** + * Open-in-App Catalog — single source of truth. + * + * Shared between the Bun/Pi servers (which launch the app) and the UI (which + * renders the picker). Runtime-agnostic: no Bun or Node-specific APIs, pure + * data + types only. + * + * Mirrors OpenCode's "Open in" app list. Each entry declares how to launch the + * app per platform: + * - mac.appName -> `open -a "" ` + * - win.bin -> ` ` (resolved against PATH) + * - linux.bin -> ` ` + * + * `kind` drives launch semantics: + * - file-manager -> reveal the file (mac: `open -R`, win: `explorer /select,`, + * linux: open the parent dir) + * - editor -> open the file itself + * - terminal -> open the file's parent directory + * + * One special id has no platform launch fields: + * - 'reveal' (kind file-manager) — uses the OS file manager + */ + +export type OpenInKind = 'file-manager' | 'editor' | 'terminal'; + +export interface OpenInApp { + /** Stable identifier persisted in the cookie + sent to the server. */ + id: string; + /** Human-readable label. For 'reveal' this is resolved per-platform. */ + label: string; + kind: OpenInKind; + /** Icon id understood by AppIcon. For 'reveal' this is resolved per-platform. */ + icon: string; + /** macOS application bundle/display name passed to `open -a`. */ + mac?: { appName: string }; + /** Windows PATH binary. */ + win?: { bin: string }; + /** Linux PATH binary. */ + linux?: { bin: string }; +} + +/** + * The catalog, in menu order. The UI groups by `kind` + * (file-manager + default first, then editors, then terminals). + */ +export const OPEN_IN_APPS: OpenInApp[] = [ + // ── File manager (always available) ──────────────────────────────────── + { + id: 'reveal', + label: 'Finder', // resolved per-platform, see resolveRevealLabel + kind: 'file-manager', + icon: 'finder', // resolved per-platform, see resolveRevealIcon + }, + + // ── Editors ──────────────────────────────────────────────────────────── + { + id: 'vscode', + label: 'VS Code', + kind: 'editor', + icon: 'vscode', + mac: { appName: 'Visual Studio Code' }, + win: { bin: 'code' }, + linux: { bin: 'code' }, + }, + { + id: 'cursor', + label: 'Cursor', + kind: 'editor', + icon: 'cursor', + mac: { appName: 'Cursor' }, + win: { bin: 'cursor' }, + linux: { bin: 'cursor' }, + }, + { + id: 'zed', + label: 'Zed', + kind: 'editor', + icon: 'zed', + mac: { appName: 'Zed' }, + win: { bin: 'zed' }, + linux: { bin: 'zed' }, + }, + { + id: 'sublime-text', + label: 'Sublime Text', + kind: 'editor', + icon: 'sublime-text', + mac: { appName: 'Sublime Text' }, + win: { bin: 'subl' }, + linux: { bin: 'subl' }, + }, + { + id: 'textmate', + label: 'TextMate', + kind: 'editor', + icon: 'textmate', + mac: { appName: 'TextMate' }, + }, + { + id: 'antigravity', + label: 'Antigravity', + kind: 'editor', + icon: 'antigravity', + mac: { appName: 'Antigravity' }, + }, + { + id: 'xcode', + label: 'Xcode', + kind: 'editor', + icon: 'xcode', + mac: { appName: 'Xcode' }, + }, + { + id: 'android-studio', + label: 'Android Studio', + kind: 'editor', + icon: 'android-studio', + mac: { appName: 'Android Studio' }, + }, + + // ── Terminals ────────────────────────────────────────────────────────── + { + id: 'terminal', + label: 'Terminal', + kind: 'terminal', + icon: 'terminal', + mac: { appName: 'Terminal' }, + }, + { + id: 'iterm2', + label: 'iTerm2', + kind: 'terminal', + icon: 'iterm2', + mac: { appName: 'iTerm' }, // bundle name is "iTerm", not "iTerm2" + }, + { + id: 'ghostty', + label: 'Ghostty', + kind: 'terminal', + icon: 'ghostty', + mac: { appName: 'Ghostty' }, + }, + { + id: 'warp', + label: 'Warp', + kind: 'terminal', + icon: 'warp', + mac: { appName: 'Warp' }, + }, + { + id: 'powershell', + label: 'PowerShell', + kind: 'terminal', + icon: 'powershell', + win: { bin: 'powershell' }, + }, +]; + +export type OpenInPlatform = 'mac' | 'win' | 'linux'; + +/** + * Per-platform label for the 'reveal' (file-manager) entry. + */ +export function resolveRevealLabel(platform: OpenInPlatform): string { + switch (platform) { + case 'win': + return 'Explorer'; + case 'linux': + return 'Files'; + case 'mac': + default: + return 'Finder'; + } +} + +/** + * Per-platform icon for the 'reveal' (file-manager) entry: + * finder on mac/linux, file-explorer on win. + */ +export function resolveRevealIcon(platform: OpenInPlatform): string { + return platform === 'win' ? 'file-explorer' : 'finder'; +} + +/** + * Look up a catalog entry by id. + */ +export function getOpenInApp(id: string): OpenInApp | undefined { + return OPEN_IN_APPS.find((app) => app.id === id); +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 000000000..bc5331d5e --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,33 @@ +{ + "name": "@plannotator/core", + "version": "0.21.0", + "type": "module", + "exports": { + "./agents": "./agents.ts", + "./agent-jobs": "./agent-jobs.ts", + "./agent-terminal": "./agent-terminal.ts", + "./browser-paths": "./browser-paths.ts", + "./code-file": "./code-file.ts", + "./compress": "./compress.ts", + "./crypto": "./crypto.ts", + "./external-annotation": "./external-annotation.ts", + "./extract-code-paths": "./extract-code-paths.ts", + "./favicon": "./favicon.ts", + "./feedback-templates": "./feedback-templates.ts", + "./goal-setup": "./goal-setup.ts", + "./open-in-apps": "./open-in-apps.ts", + "./project": "./project.ts", + "./source-save": "./source-save.ts", + "./config-types": "./config-types.ts", + "./storage-types": "./storage-types.ts", + "./workspace-status-types": "./workspace-status-types.ts", + "./ai-context": "./ai-context.ts", + "./types": "./types.ts", + ".": "./index.ts" + }, + "files": ["**/*.ts", "!**/*.test.ts"], + "dependencies": {}, + "devDependencies": { + "typescript": "~5.8.2" + } +} diff --git a/packages/core/project.ts b/packages/core/project.ts new file mode 100644 index 000000000..23c130e81 --- /dev/null +++ b/packages/core/project.ts @@ -0,0 +1,71 @@ +/** + * Project Utility — Pure Functions + * + * String sanitization and path extraction helpers. + * Runtime-agnostic: no Bun or Node-specific APIs. + */ + +/** + * Sanitize a string for use as a tag + * - lowercase + * - replace spaces/underscores with hyphens + * - remove special characters + * - trim to reasonable length + */ +export function sanitizeTag(name: string): string | null { + if (!name || typeof name !== "string") return null; + + const sanitized = name + .toLowerCase() + .trim() + .replace(/[\s_]+/g, "-") // spaces/underscores -> hyphens + .replace(/[^a-z0-9-]/g, "") // remove special chars + .replace(/-+/g, "-") // collapse multiple hyphens + .replace(/^-|-$/g, "") // trim leading/trailing hyphens + .slice(0, 30); // max 30 chars + + return sanitized.length >= 2 ? sanitized : null; +} + +/** + * Extract repo name from a git root path + */ +export function extractRepoName(gitRootPath: string): string | null { + if (!gitRootPath || typeof gitRootPath !== "string") return null; + + const trimmed = gitRootPath.trim().replace(/\/+$/, ""); // remove trailing slashes + const parts = trimmed.split("/"); + const name = parts[parts.length - 1]; + + return sanitizeTag(name); +} + +/** + * Extract directory name from a path + */ +export function extractDirName(path: string): string | null { + if (!path || typeof path !== "string") return null; + + const trimmed = path.trim().replace(/\/+$/, ""); + if (trimmed === "" || trimmed === "/") return null; + + const parts = trimmed.split("/"); + const name = parts[parts.length - 1]; + + // Skip generic names + const skipNames = new Set(["home", "users", "user", "root", "tmp", "var"]); + if (skipNames.has(name.toLowerCase())) return null; + + return sanitizeTag(name); +} + +/** + * Extract hostname from a URL string, or return the original string on failure. + */ +export function hostnameOrFallback(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} diff --git a/packages/shared/source-save.test.ts b/packages/core/source-save.test.ts similarity index 100% rename from packages/shared/source-save.test.ts rename to packages/core/source-save.test.ts diff --git a/packages/core/source-save.ts b/packages/core/source-save.ts new file mode 100644 index 000000000..434f72183 --- /dev/null +++ b/packages/core/source-save.ts @@ -0,0 +1,138 @@ +export type SourceSaveLanguage = "markdown" | "mdx" | "text"; + +export type SourceSaveDisabledReason = + | "not-annotate-mode" + | "not-local-file" + | "unsupported-extension" + | "converted-source" + | "html-render" + | "folder-mode" + | "message-mode" + | "shared-session" + | "missing-file" + | "unreadable-file"; + +export type SourceSaveScope = "single-file" | "folder-file"; + +export type SourceFileEol = "lf" | "crlf" | "mixed" | "none"; + +export interface SourceFileSnapshot { + text: string; + hash: string; + mtimeMs: number; + size: number; + eol: SourceFileEol; +} + +export type SourceSaveCapability = + | { + enabled: true; + kind: "local-text-file"; + scope: SourceSaveScope; + path: string; + basename: string; + language: SourceSaveLanguage; + hash: string; + mtimeMs: number; + size: number; + eol: SourceFileEol; + } + | { + enabled: false; + reason: SourceSaveDisabledReason; + }; + +export interface SourceSaveRequest { + path?: string; + text: string; + baseHash: string; + baseMtimeMs?: number; + baseEol?: SourceFileEol; + allowMissingBase?: boolean; +} + +export type SourceSaveResponse = + | { + ok: true; + hash: string; + mtimeMs: number; + size: number; + eol: SourceFileEol; + } + | { + ok: false; + code: "conflict"; + message: string; + currentText: string; + currentHash: string; + currentMtimeMs: number; + currentSize: number; + currentEol: SourceFileEol; + } + | { + ok: false; + code: "not-writable" | "write-failed" | "invalid-request"; + message: string; + }; + +export type SourceSaveConflictResponse = Extract; + +export function isSourceFileEol(value: unknown): value is SourceFileEol { + return value === "lf" || value === "crlf" || value === "mixed" || value === "none"; +} + +export function hasSourceSaveConflictSnapshot(response: SourceSaveResponse): response is SourceSaveConflictResponse { + if (!("code" in response) || response.code !== "conflict") return false; + const conflict = response as SourceSaveConflictResponse; + return ( + typeof conflict.currentText === "string" && + typeof conflict.currentHash === "string" && + typeof conflict.currentMtimeMs === "number" && + typeof conflict.currentSize === "number" && + isSourceFileEol(conflict.currentEol) + ); +} + +export const SOURCE_SAVE_FILE_REGEX = /\.(md|mdx|txt)$/i; + +export function isSourceSaveFilePath(filePath: string): boolean { + return SOURCE_SAVE_FILE_REGEX.test(filePath); +} + +export function getSourceSaveLanguage(filePath: string): SourceSaveLanguage | null { + const lower = filePath.toLowerCase(); + if (lower.endsWith(".mdx")) return "mdx"; + if (lower.endsWith(".md")) return "markdown"; + if (lower.endsWith(".txt")) return "text"; + return null; +} + +export function basenameFromPath(filePath: string): string { + const normalized = filePath.replace(/\\/g, "/"); + return normalized.split("/").pop() || filePath; +} + +export function disabledSourceSave(reason: SourceSaveDisabledReason): SourceSaveCapability { + return { enabled: false, reason }; +} + +export function enabledSourceSave( + scope: SourceSaveScope, + filePath: string, + snapshot: SourceFileSnapshot, +): SourceSaveCapability { + const language = getSourceSaveLanguage(filePath); + if (!language) return disabledSourceSave("unsupported-extension"); + return { + enabled: true, + kind: "local-text-file", + scope, + path: filePath, + basename: basenameFromPath(filePath), + language, + hash: snapshot.hash, + mtimeMs: snapshot.mtimeMs, + size: snapshot.size, + eol: snapshot.eol, + }; +} diff --git a/packages/core/storage-types.ts b/packages/core/storage-types.ts new file mode 100644 index 000000000..2e73c5811 --- /dev/null +++ b/packages/core/storage-types.ts @@ -0,0 +1,8 @@ +export interface ArchivedPlan { + filename: string; + title: string; + date: string; + timestamp: string; // ISO string from file mtime + status: "approved" | "denied" | "unknown"; + size: number; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 000000000..7d06c95d9 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/core/types.ts b/packages/core/types.ts new file mode 100644 index 000000000..c54515792 --- /dev/null +++ b/packages/core/types.ts @@ -0,0 +1,10 @@ +// Editor annotations from VS Code extension (ephemeral, in-memory only) +export interface EditorAnnotation { + id: string; + filePath: string; // workspace-relative (e.g., "src/auth.ts") + selectedText: string; + lineStart: number; // 1-based + lineEnd: number; // 1-based + comment?: string; + createdAt: number; +} diff --git a/packages/core/workspace-status-types.ts b/packages/core/workspace-status-types.ts new file mode 100644 index 000000000..0ec63af29 --- /dev/null +++ b/packages/core/workspace-status-types.ts @@ -0,0 +1,39 @@ +export type WorkspaceFileStatus = + | "modified" + | "added" + | "deleted" + | "renamed" + | "copied" + | "typechange" + | "conflicted" + | "untracked"; + +export interface WorkspaceFileChange { + path: string; + repoRelativePath: string; + oldPath?: string; + status: WorkspaceFileStatus; + additions: number; + deletions: number; + staged: boolean; + unstaged: boolean; +} + +export interface WorkspaceStatusPayload { + available: boolean; + rootPath: string; + repoRoot?: string; + files: Record; + totals: { + files: number; + additions: number; + deletions: number; + }; + error?: string; +} + +export interface GitRepositoryInfo { + repoRoot: string; + gitDir: string; + gitCommonDir: string; +} diff --git a/packages/shared/agent-jobs.ts b/packages/shared/agent-jobs.ts index 9463b4d9c..f14df7ec7 100644 --- a/packages/shared/agent-jobs.ts +++ b/packages/shared/agent-jobs.ts @@ -1,158 +1 @@ -/** - * Agent Jobs — shared types, state machine, and SSE helpers. - * - * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. - * Both the Bun server handler and (future) Node handler import - * this module and wrap it with their respective HTTP transport layers. - * - * Mirrors packages/shared/external-annotation.ts in structure. - */ - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type AgentJobStatus = "starting" | "running" | "done" | "failed" | "killed"; - -/** - * Snapshot of the diff the reviewer was looking at when this job was launched. - * Carried on the job so downstream UIs (agent-result panel "Copy All") export - * the same `**Diff:** ...` header the job was actually run against — if the - * reviewer switches the UI to a different diff afterwards, the job's snapshot - * still reflects truth. Structurally compatible with the UI-side - * `FeedbackDiffContext` in `packages/review-editor/utils/exportFeedback.ts`. - */ -export interface AgentJobDiffContext { - mode: string; - base?: string; - worktreePath?: string | null; -} - -export interface AgentJobInfo { - /** Unique job identifier (UUID). */ - id: string; - /** Source identifier for external annotations — "agent-{id prefix}". */ - source: string; - /** Provider that spawned this job — "claude", "codex", "tour", "shell", etc. */ - provider: string; - /** Underlying engine used (e.g., "claude" or "codex"). Set when provider is "tour". */ - engine?: string; - /** Model used (e.g., "sonnet", "opus"). Set when provider is "tour" with Claude engine. */ - model?: string; - /** Claude --effort level (e.g., "low", "medium", "high", "xhigh", "max"). */ - effort?: string; - /** Codex reasoning effort level (e.g., "high", "medium"). */ - reasoningEffort?: string; - /** Whether Codex fast mode (service_tier=fast) was enabled. */ - fastMode?: boolean; - /** Pi's unified reasoning level (marker engines only), e.g. "minimal", "high". */ - thinking?: string; - /** Human-readable label for the job. */ - label: string; - /** Current lifecycle status. */ - status: AgentJobStatus; - /** Timestamp when the job was created. */ - startedAt: number; - /** Timestamp when the job reached a terminal state. */ - endedAt?: number; - /** Process exit code (set on done/failed). */ - exitCode?: number; - /** Last ~500 chars of stderr on failure. */ - error?: string; - /** The actual command that was spawned (for display/debug). */ - command: string[]; - /** Working directory where the process was spawned. */ - cwd?: string; - /** The review prompt text (system + user message). Stored separately from command for providers that use stdin. */ - prompt?: string; - /** Review summary set by the agent on completion. */ - summary?: { - correctness: string; - explanation: string; - confidence: number; - }; - /** PR URL at launch time — used to attribute findings to the correct PR. */ - prUrl?: string; - /** PR diff scope at launch time — "layer" or "full-stack". */ - diffScope?: string; - /** Diff context at launch time (see AgentJobDiffContext). */ - diffContext?: AgentJobDiffContext; - /** Resolved review profile id at launch time (e.g. "builtin:default", "user:security"). */ - reviewProfileId?: string; - /** Resolved review profile label — rides on findings so the UI can show a profile tag. */ - reviewProfileLabel?: string; -} - -export interface AgentCapability { - id: string; - name: string; - available: boolean; - /** - * Provider-discovered model catalog (currently only Cursor). Best-effort and - * account-specific — populated from the provider CLI at capability-detection - * time, empty when discovery fails or the CLI is unauthenticated. The UI - * drives its model picker from this instead of a hardcoded list. - */ - models?: { id: string; label: string }[]; -} - -export interface AgentCapabilities { - mode: "plan" | "review" | "annotate"; - providers: AgentCapability[]; - /** True if at least one provider is available. */ - available: boolean; -} - -// --------------------------------------------------------------------------- -// SSE event types -// --------------------------------------------------------------------------- - -export type AgentJobEvent = - | { type: "snapshot"; jobs: AgentJobInfo[] } - | { type: "job:started"; job: AgentJobInfo } - | { type: "job:updated"; job: AgentJobInfo } - | { type: "job:completed"; job: AgentJobInfo } - | { type: "job:log"; jobId: string; delta: string } - | { type: "jobs:cleared" }; - -// --------------------------------------------------------------------------- -// SSE helpers -// --------------------------------------------------------------------------- - -/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ -export const AGENT_HEARTBEAT_COMMENT = ":\n\n"; - -/** Interval in ms between heartbeat comments. */ -export const AGENT_HEARTBEAT_INTERVAL_MS = 30_000; - -/** Encode an event as an SSE `data:` line. */ -export function serializeAgentSSEEvent(event: AgentJobEvent): string { - return `data: ${JSON.stringify(event)}\n\n`; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Check if a status is terminal (no further transitions). */ -export function isTerminalStatus(status: AgentJobStatus): boolean { - return status === "done" || status === "failed" || status === "killed"; -} - -/** Generate the source identifier for a job from its ID. */ -export function jobSource(id: string): string { - return "agent-" + id.slice(0, 8); -} - -// --------------------------------------------------------------------------- -// Review ingestion completion semantics -// --------------------------------------------------------------------------- - -/** Calm, provider-neutral failure reason. Never leak schema/CLI internals. */ -export const REVIEW_OUTPUT_FAILED = "Review finished but produced no usable findings."; - -/** Flip a job to failed with a calm one-liner (Code Tour precedent). */ -export function markJobReviewFailed(job: AgentJobInfo, error: string): void { - job.status = "failed"; - job.error = error; -} +export * from '@plannotator/core/agent-jobs'; diff --git a/packages/shared/agent-terminal.ts b/packages/shared/agent-terminal.ts index 42f8339a3..40f8a1646 100644 --- a/packages/shared/agent-terminal.ts +++ b/packages/shared/agent-terminal.ts @@ -1,53 +1 @@ -export const AGENT_TERMINAL_WS_BASE_PATH = "/api/agent-terminal/pty"; - -export function buildAgentTerminalWsPath(token: string): string { - if (!token || token.includes("/") || token.includes("?") || token.includes("#")) { - throw new Error("Agent terminal WebSocket token must be a non-empty path segment."); - } - return `${AGENT_TERMINAL_WS_BASE_PATH}/${encodeURIComponent(token)}`; -} - -export function isAgentTerminalWsRoute(pathname: string): boolean { - return pathname === AGENT_TERMINAL_WS_BASE_PATH || - pathname.startsWith(`${AGENT_TERMINAL_WS_BASE_PATH}/`); -} - -export type AgentTerminalDisabledReason = - | "not-annotate-mode" - | "remote-disabled" - | "runtime-unavailable" - | "webtui-unavailable" - | "pty-unavailable" - | "unsupported-runtime"; - -export type AgentTerminalAgent = { - id: string; - name: string; - available: boolean; -}; - -export type AgentTerminalCapability = - | { - enabled: true; - cwd: string; - wsPath: string; - agents: AgentTerminalAgent[]; - } - | { - enabled: false; - reason: AgentTerminalDisabledReason; - message?: string; - }; - -export type AnnotateAgentTerminalMode = - | "annotate" - | "annotate-last" - | "annotate-folder" - | string - | undefined; - -export function supportsAnnotateAgentTerminalMode( - mode: AnnotateAgentTerminalMode, -): boolean { - return mode === "annotate" || mode === "annotate-folder"; -} +export * from '@plannotator/core/agent-terminal'; diff --git a/packages/shared/agents.ts b/packages/shared/agents.ts index cc9994135..2945300fb 100644 --- a/packages/shared/agents.ts +++ b/packages/shared/agents.ts @@ -1,53 +1 @@ -/** - * Centralized agent configuration — single source of truth for all supported agents. - * - * To add a new agent: - * 1. Add an entry to AGENT_CONFIG below (origin key, display name, badge CSS classes, - * optional AI provider types) - * 2. If detection is via environment variable, add it to the detection chain - * in apps/hook/server/index.ts (detectedOrigin constant) - * 3. That's it — all UI components read from this config automatically - */ - -type AgentConfigEntry = { - name: string; - badge: string; - /** AI provider type(s) that naturally match this origin, in preference order. */ - aiProviderTypes?: readonly string[]; -}; - -export const AGENT_CONFIG = { - 'claude-code': { name: 'Claude Code', badge: 'bg-orange-500/15 text-orange-400', aiProviderTypes: ['claude-agent-sdk'] }, - 'amp': { name: 'Amp', badge: 'bg-lime-500/15 text-lime-400' }, - 'droid': { name: 'Droid', badge: 'bg-cyan-500/15 text-cyan-400' }, - 'kiro-cli': { name: 'Kiro CLI', badge: 'bg-amber-500/15 text-amber-400' }, - 'opencode': { name: 'OpenCode', badge: 'bg-emerald-500/15 text-emerald-400', aiProviderTypes: ['opencode-sdk'] }, - 'copilot-cli': { name: 'GitHub Copilot', badge: 'bg-blue-500/15 text-blue-400' }, - 'pi': { name: 'Pi', badge: 'bg-violet-500/15 text-violet-400', aiProviderTypes: ['pi-sdk'] }, - 'codex': { name: 'Codex', badge: 'bg-purple-500/15 text-purple-400', aiProviderTypes: ['codex-sdk'] }, - 'gemini-cli': { name: 'Gemini CLI', badge: 'bg-sky-500/15 text-sky-400' }, -} as const satisfies Record; - -/** All recognized origin values. */ -export type Origin = keyof typeof AGENT_CONFIG; - -/** Resolve an origin to a human-readable agent name. */ -export function getAgentName(origin: Origin | null | undefined): string { - if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].name; - return 'Coding Agent'; -} - -/** Resolve an origin to Tailwind badge classes. */ -export function getAgentBadge(origin: Origin | null | undefined): string { - if (origin && origin in AGENT_CONFIG) return AGENT_CONFIG[origin as Origin].badge; - return 'bg-zinc-500/20 text-zinc-400'; -} - -/** Resolve an origin to matching AI provider types, in preference order. */ -export function getAgentAIProviderTypes(origin: Origin | null | undefined): readonly string[] { - if (origin && origin in AGENT_CONFIG) { - const config = AGENT_CONFIG[origin as Origin]; - return 'aiProviderTypes' in config ? config.aiProviderTypes : []; - } - return []; -} +export * from '@plannotator/core/agents'; diff --git a/packages/shared/browser-paths.ts b/packages/shared/browser-paths.ts index ce867a505..f2f297ab6 100644 --- a/packages/shared/browser-paths.ts +++ b/packages/shared/browser-paths.ts @@ -1,25 +1 @@ -export function normalizeBrowserPath(path: string): string { - const withForwardSlashes = path.replace(/\\/g, "/"); - const prefix = withForwardSlashes.startsWith("//") ? "//" : ""; - const collapsed = prefix + withForwardSlashes.slice(prefix.length).replace(/\/+/g, "/"); - if (collapsed === "/" || /^[A-Za-z]:\/$/.test(collapsed)) return collapsed; - return collapsed.replace(/\/+$/, ""); -} - -export function dirnameBrowserPath(path: string): string { - const normalized = normalizeBrowserPath(path); - const driveRootMatch = normalized.match(/^([A-Za-z]:)\/[^/]+$/); - if (driveRootMatch) return `${driveRootMatch[1]}/`; - const index = normalized.lastIndexOf("/"); - if (index < 0) return normalized; - if (index === 0) return "/"; - return normalized.slice(0, index); -} - -export function pathIsInsideDir(path: string, dir: string): boolean { - const normalizedPath = normalizeBrowserPath(path); - const normalizedDir = normalizeBrowserPath(dir); - if (!normalizedDir) return normalizedPath === ""; - const dirPrefix = normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`; - return normalizedPath === normalizedDir || normalizedPath.startsWith(dirPrefix); -} +export * from '@plannotator/core/browser-paths'; diff --git a/packages/shared/code-file.ts b/packages/shared/code-file.ts index 43633a766..dc82b5006 100644 --- a/packages/shared/code-file.ts +++ b/packages/shared/code-file.ts @@ -1,41 +1 @@ -export const CODE_FILE_REGEX = /(?:\.(tsx?|jsx?|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|scala|sh|bash|zsh|sql|graphql|json|ya?ml|toml|ini|css|scss|less|xml|tf|lua|r|dart|ex|exs|vue|svelte|astro|zig|proto)|(?:^|\/)(Dockerfile|Makefile|Rakefile|Gemfile|Procfile|Vagrantfile|Brewfile|Justfile))$/i; - -export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+(?::\d+(?:-\d+)?)?/g; - -const IMPLAUSIBLE_CHARS = /[{},*?\s]/; - -export function isPlausibleCodeFilePath(input: string): boolean { - return !IMPLAUSIBLE_CHARS.test(input); -} - -export interface ParsedCodePath { - filePath: string; - line?: number; - lineEnd?: number; -} - -const LINE_SUFFIX_RE = /:(\d+)(?:-(\d+))?$/; - -export function parseCodePath(input: string): ParsedCodePath { - const clean = input.replace(/#.*$/, ''); - const m = clean.match(LINE_SUFFIX_RE); - if (!m) return { filePath: clean }; - let line = Number.parseInt(m[1], 10); - let lineEnd = m[2] ? Number.parseInt(m[2], 10) : undefined; - if (lineEnd != null && lineEnd < line) { const tmp = line; line = lineEnd; lineEnd = tmp; } - return { filePath: clean.replace(LINE_SUFFIX_RE, ''), line, lineEnd }; -} - -export function stripLineRef(input: string): string { - return input.replace(/#.*$/, '').replace(LINE_SUFFIX_RE, ''); -} - -export function isCodeFilePath(input: string): boolean { - if (!isPlausibleCodeFilePath(input)) return false; - return CODE_FILE_REGEX.test(stripLineRef(input)) - && !input.startsWith('http://') && !input.startsWith('https://'); -} - -export function isCodeFilePathStrict(input: string): boolean { - return input.includes('/') && isCodeFilePath(input); -} +export * from '@plannotator/core/code-file'; diff --git a/packages/shared/compress.ts b/packages/shared/compress.ts index 70c5099ac..723038922 100644 --- a/packages/shared/compress.ts +++ b/packages/shared/compress.ts @@ -1,51 +1 @@ -/** - * Portable deflate-raw + base64url compression. - * - * Uses only Web APIs (CompressionStream, TextEncoder, btoa) so it works - * in browsers, Bun, and edge runtimes. Both @plannotator/server and - * @plannotator/ui import from here — single source of truth. - */ - -export async function compress(data: unknown): Promise { - const json = JSON.stringify(data); - const byteArray = new TextEncoder().encode(json); - - const stream = new CompressionStream('deflate-raw'); - const writer = stream.writable.getWriter(); - writer.write(byteArray); - writer.close(); - - const buffer = await new Response(stream.readable).arrayBuffer(); - const compressed = new Uint8Array(buffer); - - // Loop instead of spread to avoid RangeError on large payloads - // (String.fromCharCode(...arr) has a ~65K argument limit) - let binary = ''; - for (let i = 0; i < compressed.length; i++) { - binary += String.fromCharCode(compressed[i]); - } - const base64 = btoa(binary); - return base64 - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); -} - -export async function decompress(b64: string): Promise { - const base64 = b64 - .replace(/-/g, '+') - .replace(/_/g, '/'); - - const binary = atob(base64); - const byteArray = Uint8Array.from(binary, c => c.charCodeAt(0)); - - const stream = new DecompressionStream('deflate-raw'); - const writer = stream.writable.getWriter(); - writer.write(byteArray); - writer.close(); - - const buffer = await new Response(stream.readable).arrayBuffer(); - const json = new TextDecoder().decode(buffer); - - return JSON.parse(json); -} +export * from '@plannotator/core/compress'; diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 03f3ee070..37d533edb 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -10,24 +10,8 @@ import { getPlannotatorDataDir } from "./data-dir"; import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { execSync } from "child_process"; -export type DefaultDiffType = 'since-base' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; -export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; - -export interface DiffOptions { - diffStyle?: 'split' | 'unified'; - overflow?: 'scroll' | 'wrap'; - diffIndicators?: 'bars' | 'classic' | 'none'; - lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; - showLineNumbers?: boolean; - showDiffBackground?: boolean; - fontFamily?: string; - fontSize?: string; - tabSize?: number; - hideWhitespace?: boolean; - expandUnchanged?: boolean; - defaultDiffType?: DefaultDiffType; - lineBgIntensity?: DiffLineBgIntensity; -} +import type { DefaultDiffType, DiffLineBgIntensity, DiffOptions } from '@plannotator/core/config-types'; +export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions }; /** Single conventional comment label entry stored in config.json */ export interface CCLabelConfig { diff --git a/packages/shared/crypto.ts b/packages/shared/crypto.ts index 0161e6dcd..663340856 100644 --- a/packages/shared/crypto.ts +++ b/packages/shared/crypto.ts @@ -1,97 +1 @@ -/** - * AES-256-GCM encryption for zero-knowledge paste storage. - * - * Uses Web Crypto API — works in browsers, Bun, and edge runtimes. - * The key never leaves the client; it lives in the URL fragment. - */ - -/** - * Encrypt a compressed base64url string with a fresh AES-256-GCM key. - * - * Returns { ciphertext, key } where: - * - ciphertext: base64url-encoded (12-byte IV prepended to GCM output) - * - key: base64url-encoded 256-bit key for the URL fragment - */ -export async function encrypt( - compressedData: string -): Promise<{ ciphertext: string; key: string }> { - const cryptoKey = await crypto.subtle.generateKey( - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt'] - ); - - const iv = crypto.getRandomValues(new Uint8Array(12)); - const plaintext = new TextEncoder().encode(compressedData); - - const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - cryptoKey, - plaintext - ); - - // Prepend IV to ciphertext (IV || ciphertext+tag) - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv, 0); - combined.set(new Uint8Array(encrypted), iv.length); - - const rawKey = await crypto.subtle.exportKey('raw', cryptoKey); - - return { - ciphertext: bytesToBase64url(combined), - key: bytesToBase64url(new Uint8Array(rawKey)), - }; -} - -/** - * Decrypt a ciphertext string using a base64url-encoded AES-256-GCM key. - * - * Expects ciphertext format: base64url(IV || encrypted+tag) - * Returns the original compressed base64url string. - */ -export async function decrypt( - ciphertext: string, - key: string -): Promise { - const combined = base64urlToBytes(ciphertext); - const rawKey = base64urlToBytes(key); - - const iv = combined.slice(0, 12); - const encrypted = combined.slice(12); - - const cryptoKey = await crypto.subtle.importKey( - 'raw', - rawKey.buffer as ArrayBuffer, - { name: 'AES-GCM', length: 256 }, - false, - ['decrypt'] - ); - - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - cryptoKey, - encrypted - ); - - return new TextDecoder().decode(decrypted); -} - -// --- Helpers --- - -function bytesToBase64url(bytes: Uint8Array): string { - // Loop to avoid RangeError on large payloads (same approach as compress.ts) - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); -} - -function base64urlToBytes(b64: string): Uint8Array { - const base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); - const binary = atob(base64); - return Uint8Array.from(binary, c => c.charCodeAt(0)); -} +export * from '@plannotator/core/crypto'; diff --git a/packages/shared/external-annotation.ts b/packages/shared/external-annotation.ts index 2260e3f87..f1d1f2838 100644 --- a/packages/shared/external-annotation.ts +++ b/packages/shared/external-annotation.ts @@ -1,455 +1 @@ -/** - * External Annotations — shared types, store logic, and SSE helpers. - * - * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. - * Both the Bun server handler and Pi server handler import this module - * and wrap it with their respective HTTP transport layers. - * - * The store is generic — plan servers store Annotation objects, - * review servers store CodeAnnotation objects. The mode-specific - * input transformers handle validation and field assignment. - */ - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** Constraint for any annotation type the store can hold. */ -export type StorableAnnotation = { id: string; source?: string }; - -export type ExternalAnnotationEvent = - | { type: "snapshot"; annotations: T[] } - | { type: "add"; annotations: T[] } - | { type: "remove"; ids: string[] } - | { type: "clear"; source?: string } - | { type: "update"; id: string; annotation: T }; - -// --------------------------------------------------------------------------- -// SSE helpers -// --------------------------------------------------------------------------- - -/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ -export const HEARTBEAT_COMMENT = ":\n\n"; - -/** Interval in ms between heartbeat comments. */ -export const HEARTBEAT_INTERVAL_MS = 30_000; - -/** Encode an event as an SSE `data:` line. */ -export function serializeSSEEvent(event: ExternalAnnotationEvent): string { - return `data: ${JSON.stringify(event)}\n\n`; -} - -// --------------------------------------------------------------------------- -// Input validation — shared helpers -// --------------------------------------------------------------------------- - -export interface ParseError { - error: string; -} - -/** - * Unwrap a POST body into an array of raw input objects. - * - * Accepts either: - * - A single annotation object: `{ source: "...", ... }` - * - A batch wrapper: `{ annotations: [{ source: "...", ... }, ...] }` - */ -function unwrapBody(body: unknown): Record[] | ParseError { - if (!body || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - - const obj = body as Record; - - // Batch format: { annotations: [...] } - if (Array.isArray(obj.annotations)) { - if (obj.annotations.length === 0) { - return { error: "annotations array must not be empty" }; - } - const items: Record[] = []; - for (let i = 0; i < obj.annotations.length; i++) { - const item = obj.annotations[i]; - if (!item || typeof item !== "object") { - return { error: `annotations[${i}] must be an object` }; - } - items.push(item as Record); - } - return items; - } - - // Single format: { source: "...", ... } - if (typeof obj.source === "string") { - return [obj as Record]; - } - - return { error: 'Missing required "source" field or "annotations" array' }; -} - -function requireString(obj: Record, field: string, index: number): string | ParseError { - const val = obj[field]; - if (typeof val !== "string" || val.length === 0) { - return { error: `annotations[${index}] missing required "${field}" field` }; - } - return val; -} - -// --------------------------------------------------------------------------- -// Plan mode transformer — produces Annotation objects -// --------------------------------------------------------------------------- - -/** The Annotation type shape for plan mode (mirrors packages/ui/types.ts). */ -interface PlanAnnotation { - id: string; - blockId: string; - startOffset: number; - endOffset: number; - type: string; // AnnotationType value - text?: string; - originalText: string; - createdA: number; - author?: string; - source?: string; -} - -const VALID_PLAN_TYPES = ["DELETION", "COMMENT", "GLOBAL_COMMENT"]; - -export function transformPlanInput( - body: unknown, -): { annotations: PlanAnnotation[] } | ParseError { - const items = unwrapBody(body); - if ("error" in items) return items; - - const annotations: PlanAnnotation[] = []; - for (let i = 0; i < items.length; i++) { - const obj = items[i]; - - const source = requireString(obj, "source", i); - if (typeof source !== "string") return source; - - // Must have text content - if (typeof obj.text !== "string" || obj.text.length === 0) { - return { error: `annotations[${i}] missing required "text" field` }; - } - - // Validate type if provided, default to GLOBAL_COMMENT - const type = typeof obj.type === "string" ? obj.type : "GLOBAL_COMMENT"; - if (!VALID_PLAN_TYPES.includes(type)) { - return { - error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_PLAN_TYPES.join(", ")}`, - }; - } - - // DELETION requires originalText (the text to remove) - if (type === "DELETION" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { - return { error: `annotations[${i}] DELETION type requires non-empty "originalText" field` }; - } - - // COMMENT requires originalText so the renderer can pin it to a phrase. - // External agents that want sidebar-only feedback should use GLOBAL_COMMENT - // instead — without a phrase to anchor to, a COMMENT renders as an empty - // quote bubble in the sidebar and exports as `Feedback on: ""`. - if (type === "COMMENT" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { - return { - error: `annotations[${i}] COMMENT requires non-empty "originalText" field. Use GLOBAL_COMMENT for sidebar-only feedback.`, - }; - } - - annotations.push({ - id: crypto.randomUUID(), - blockId: "external", - startOffset: 0, - endOffset: 0, - type, - text: String(obj.text), - originalText: typeof obj.originalText === "string" ? obj.originalText : "", - createdA: Date.now(), - author: typeof obj.author === "string" ? obj.author : undefined, - source, - }); - } - - return { annotations }; -} - -// --------------------------------------------------------------------------- -// Review mode transformer — produces CodeAnnotation objects -// --------------------------------------------------------------------------- - -/** The CodeAnnotation type shape for review mode (mirrors packages/ui/types.ts). */ -interface ReviewAnnotation { - id: string; - type: string; // CodeAnnotationType value - scope?: string; - filePath: string; - lineStart: number; - lineEnd: number; - side: string; - text?: string; - suggestedCode?: string; - originalCode?: string; - createdAt: number; - author?: string; - source?: string; - // Agent review metadata (optional — only set by agent review findings) - severity?: string; // "important" | "nit" | "pre_existing" - reasoning?: string; // Validation chain explaining how the issue was confirmed - reviewProfileLabel?: string; // Custom review profile that produced this finding -} - -const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"]; -const VALID_SIDES = ["old", "new"]; -const VALID_SCOPES = ["line", "file", "general"]; - -/** A review finding's placement, derived from what it carries. */ -export type FindingPlacement = { - scope: "line" | "file" | "general"; - filePath: string; - lineStart: number; - lineEnd: number; -}; - -/** - * Classify an agent review finding by what it carries, so nothing is dropped: - * file + a usable line → a line comment - * file, no line → a whole-file comment - * neither → a general (review-level) comment - * - * For file and general placements the line is 0; for general the path is "". - * Consumers branch on `scope`, never on the sentinel values. - */ -export function classifyFindingPlacement( - filePath: string, - lineStart: number | null | undefined, - lineEnd: number | null | undefined, -): FindingPlacement { - const hasFile = filePath.length > 0; - const hasLine = typeof lineStart === "number"; - if (hasFile && hasLine) { - return { - scope: "line", - filePath, - lineStart, - lineEnd: typeof lineEnd === "number" ? lineEnd : lineStart, - }; - } - if (hasFile) { - return { scope: "file", filePath, lineStart: 0, lineEnd: 0 }; - } - return { scope: "general", filePath: "", lineStart: 0, lineEnd: 0 }; -} - -export function transformReviewInput( - body: unknown, -): { annotations: ReviewAnnotation[] } | ParseError { - const items = unwrapBody(body); - if ("error" in items) return items; - - const annotations: ReviewAnnotation[] = []; - for (let i = 0; i < items.length; i++) { - const obj = items[i]; - - const source = requireString(obj, "source", i); - if (typeof source !== "string") return source; - - // scope: optional, defaults to "line" - const scope = typeof obj.scope === "string" ? obj.scope : "line"; - if (!VALID_SCOPES.includes(scope)) { - return { - error: `annotations[${i}] invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`, - }; - } - - // Location requirements depend on scope: - // line → filePath + lineStart + lineEnd required. A finding that claims - // a line must carry one, so a broken line finding is rejected - // rather than quietly passing as a vaguer comment. - // file → filePath required; line ignored (defaults to 0). - // general → no file, no line (review-level; defaults to "" / 0). - let filePath = ""; - let lineStart = 0; - let lineEnd = 0; - if (scope !== "general") { - const fp = requireString(obj, "filePath", i); - if (typeof fp !== "string") return fp; - filePath = fp; - if (scope === "line") { - if (typeof obj.lineStart !== "number") { - return { error: `annotations[${i}] missing required "lineStart" field` }; - } - if (typeof obj.lineEnd !== "number") { - return { error: `annotations[${i}] missing required "lineEnd" field` }; - } - lineStart = obj.lineStart; - lineEnd = obj.lineEnd; - } else { - lineStart = typeof obj.lineStart === "number" ? obj.lineStart : 0; - lineEnd = typeof obj.lineEnd === "number" ? obj.lineEnd : 0; - } - } - - // side: optional, defaults to "new" - const side = typeof obj.side === "string" ? obj.side : "new"; - if (!VALID_SIDES.includes(side)) { - return { - error: `annotations[${i}] invalid side "${side}". Must be one of: ${VALID_SIDES.join(", ")}`, - }; - } - - // type: optional, defaults to "comment" - const type = typeof obj.type === "string" ? obj.type : "comment"; - if (!VALID_REVIEW_TYPES.includes(type)) { - return { - error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_REVIEW_TYPES.join(", ")}`, - }; - } - - // Must have at least text or suggestedCode - if (typeof obj.text !== "string" && typeof obj.suggestedCode !== "string") { - return { - error: `annotations[${i}] must have at least one of: text, suggestedCode`, - }; - } - - annotations.push({ - id: crypto.randomUUID(), - type, - scope, - filePath, - lineStart, - lineEnd, - side, - text: typeof obj.text === "string" ? obj.text : undefined, - suggestedCode: typeof obj.suggestedCode === "string" ? obj.suggestedCode : undefined, - originalCode: typeof obj.originalCode === "string" ? obj.originalCode : undefined, - createdAt: Date.now(), - author: typeof obj.author === "string" ? obj.author : undefined, - source, - // Agent review metadata (optional — only set by agent review findings) - ...(typeof obj.severity === "string" && { severity: obj.severity }), - ...(typeof obj.reasoning === "string" && { reasoning: obj.reasoning }), - ...(typeof obj.reviewProfileLabel === "string" && { reviewProfileLabel: obj.reviewProfileLabel }), - }); - } - - return { annotations }; -} - -// --------------------------------------------------------------------------- -// Annotation Store (generic) -// --------------------------------------------------------------------------- - -type MutationListener = (event: ExternalAnnotationEvent) => void; - -export interface AnnotationStore { - /** Add fully-formed annotations. Returns the added annotations. */ - add(items: T[]): T[]; - /** Remove an annotation by ID. Returns true if found. */ - remove(id: string): boolean; - /** Remove all annotations from a specific source. Returns count removed. */ - clearBySource(source: string): number; - /** Update an annotation by ID. Returns the updated annotation, or null if not found. */ - update(id: string, fields: Partial): T | null; - /** Remove all annotations. Returns count removed. */ - clearAll(): number; - /** Get all annotations (snapshot). */ - getAll(): T[]; - /** Monotonic version counter — incremented on every mutation. */ - readonly version: number; - /** Register a listener for mutation events. Returns unsubscribe function. */ - onMutation(listener: MutationListener): () => void; -} - -/** - * Create an in-memory annotation store. - * - * The store is runtime-agnostic — it holds data and emits events. - * HTTP transport (SSE broadcasting, request parsing) is handled by - * the server-specific adapter (Bun or Pi). - */ -export function createAnnotationStore(): AnnotationStore { - const annotations: T[] = []; - const listeners = new Set>(); - let version = 0; - - function emit(event: ExternalAnnotationEvent): void { - for (const listener of listeners) { - try { - listener(event); - } catch { - // Don't let a failing listener break the store - } - } - } - - return { - add(items) { - if (items.length > 0) { - for (const item of items) { - annotations.push(item); - } - version++; - emit({ type: "add", annotations: items }); - } - return items; - }, - - remove(id) { - const idx = annotations.findIndex((a) => a.id === id); - if (idx === -1) return false; - annotations.splice(idx, 1); - version++; - emit({ type: "remove", ids: [id] }); - return true; - }, - - update(id, fields) { - const idx = annotations.findIndex((a) => a.id === id); - if (idx === -1) return null; - const merged = { ...annotations[idx], ...fields, id } as T; - annotations[idx] = merged; - version++; - emit({ type: "update", id, annotation: merged }); - return merged; - }, - - clearBySource(source) { - const before = annotations.length; - for (let i = annotations.length - 1; i >= 0; i--) { - if (annotations[i].source === source) { - annotations.splice(i, 1); - } - } - const removed = before - annotations.length; - if (removed > 0) { - version++; - emit({ type: "clear", source }); - } - return removed; - }, - - clearAll() { - const count = annotations.length; - if (count > 0) { - annotations.length = 0; - version++; - emit({ type: "clear" }); - } - return count; - }, - - getAll() { - return [...annotations]; - }, - - get version() { - return version; - }, - - onMutation(listener) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; -} +export * from '@plannotator/core/external-annotation'; diff --git a/packages/shared/extract-code-paths.ts b/packages/shared/extract-code-paths.ts index c4ef380fd..98aefed88 100644 --- a/packages/shared/extract-code-paths.ts +++ b/packages/shared/extract-code-paths.ts @@ -1,66 +1 @@ -import { - CODE_PATH_BARE_REGEX, - isCodeFilePath, - isCodeFilePathStrict, -} from "./code-file"; - -const FENCED_CODE_BLOCK = /(^|\n)([ \t]*)(```|~~~)[\s\S]*?\n\2\3/g; -const HTML_COMMENT = //g; -// Match InlineMarkdown.tsx's bare-URL regex exactly so URL ranges excised -// here mirror the ranges the renderer would consume. -const URL_REGEX = /https?:\/\/[^\s<>"']+/g; -const BACKTICK_SPAN = /`([^`\n]+)`/g; - -/** - * Extract candidate code-file paths from markdown text. Mirrors the renderer's - * detection precedence so the validator only sees paths the renderer would - * actually linkify: - * 1. fenced code blocks and HTML comments are stripped first; - * 2. URL ranges are excised before the bare-prose scan (URLs win); - * 3. backtick spans matching `isCodeFilePath` are collected; - * 4. bare-prose paths matching `CODE_PATH_BARE_REGEX` and - * `isCodeFilePathStrict` are collected. - * - * Hash anchors (`#L42`) are stripped from results to match the renderer's - * `cleanPath` transform. Returns deduped candidate strings. - */ -export function extractCandidateCodePaths(markdown: string): string[] { - const stripped = markdown - .replace(FENCED_CODE_BLOCK, "") - .replace(HTML_COMMENT, ""); - - const candidates = new Set(); - - let m: RegExpExecArray | null; - const backtickRe = new RegExp(BACKTICK_SPAN.source, "g"); - while ((m = backtickRe.exec(stripped)) !== null) { - const inner = m[1].trim(); - if (isCodeFilePath(inner)) { - candidates.add(inner.replace(/#.*$/, "")); - } - } - - for (const line of stripped.split("\n")) { - const urlRanges: Array<[number, number]> = []; - const urlRe = new RegExp(URL_REGEX.source, "g"); - while ((m = urlRe.exec(line)) !== null) { - urlRanges.push([m.index, m.index + m[0].length]); - } - - const pathRe = new RegExp(CODE_PATH_BARE_REGEX.source, "g"); - while ((m = pathRe.exec(line)) !== null) { - const start = m.index; - const end = start + m[0].length; - const prev = start === 0 ? "" : line[start - 1]; - if (/\w/.test(prev)) continue; - const overlapsUrl = urlRanges.some( - ([s, e]) => start < e && end > s, - ); - if (overlapsUrl) continue; - if (!isCodeFilePathStrict(m[0])) continue; - candidates.add(m[0].replace(/#.*$/, "")); - } - } - - return Array.from(candidates); -} +export * from '@plannotator/core/extract-code-paths'; diff --git a/packages/shared/favicon.ts b/packages/shared/favicon.ts index c857b8419..c5c6f94c0 100644 --- a/packages/shared/favicon.ts +++ b/packages/shared/favicon.ts @@ -1,5 +1 @@ -export const FAVICON_SVG = ` - - - P -`; +export * from '@plannotator/core/favicon'; diff --git a/packages/shared/feedback-templates.ts b/packages/shared/feedback-templates.ts index 02d9b3217..7ba12f70e 100644 --- a/packages/shared/feedback-templates.ts +++ b/packages/shared/feedback-templates.ts @@ -1,45 +1 @@ -/** - * Shared feedback templates for all agent integrations. - * - * The plan deny template was tuned in #224 / commit 3dca977 to use strong - * directive framing — Claude was ignoring softer phrasing. - * - * IMPORTANT: This module is imported by packages/ui/utils/parser.ts which is - * bundled into the browser SPA. It must NOT import from ./prompts or ./config - * (which depend on node:fs, node:os, node:child_process). Keep it self-contained. - * - * Server-side call sites use getPlanDeniedPrompt() from ./prompts directly. - * This module is only kept for the browser's wrapFeedbackForAgent clipboard feature. - */ - -export interface PlanDenyFeedbackOptions { - planFilePath?: string; -} - -export interface AnnotateFileFeedbackOptions { - filePath: string; - fileHeader?: "File" | "Folder" | string; -} - -export const planDenyFeedback = ( - feedback: string, - toolName: string = "ExitPlanMode", - options?: PlanDenyFeedbackOptions, -): string => { - const planFileRule = options?.planFilePath - ? `- Your plan is saved at: ${options.planFilePath}\n You can edit this file to make targeted changes, then pass its path to ${toolName}.\n` - : ""; - - return `YOUR PLAN WAS NOT APPROVED.\n\nYou MUST revise the plan to address ALL of the feedback below before calling ${toolName} again.\n\nRules:\n${planFileRule}- Do not resubmit the same plan unchanged.\n- Do NOT change the plan title (first # heading) unless the user explicitly asks you to.\n\n${feedback || "Plan changes requested"}`; -}; - -export const annotateFileFeedback = ( - feedback: string, - options: AnnotateFileFeedbackOptions, -): string => { - const fileHeader = options.fileHeader ?? "File"; - return `# Markdown Annotations\n\n${fileHeader}: ${options.filePath}\n\n${feedback}\n\nPlease address the annotation feedback above.`; -}; - -export const annotateMessageFeedback = (feedback: string): string => - `# Message Annotations\n\n${feedback}\n\nPlease address the annotation feedback above.`; +export * from '@plannotator/core/feedback-templates'; diff --git a/packages/shared/goal-setup.ts b/packages/shared/goal-setup.ts index 0b07f9c40..bd1dfb09d 100644 --- a/packages/shared/goal-setup.ts +++ b/packages/shared/goal-setup.ts @@ -1,336 +1 @@ -export type GoalSetupStage = "interview" | "facts"; - -export type GoalSetupAnswerMode = - | "text" - | "single" - | "multi" - | "single-custom" - | "multi-custom" - | "custom"; - -export interface GoalSetupQuestionOption { - id: string; - label: string; - description?: string; -} - -export interface GoalSetupQuestion { - id: string; - prompt: string; - description?: string; - answerMode?: GoalSetupAnswerMode; - recommendedAnswer?: string; - recommendedOptionIds?: string[]; - options?: GoalSetupQuestionOption[]; - required?: boolean; -} - -export interface GoalSetupQuestionAnswer { - questionId: string; - selectedOptionIds: string[]; - customAnswer: string; - note?: string; - answer: string; - completed: boolean; - skipped?: boolean; -} - -export interface GoalSetupInterviewBundle { - stage: "interview"; - title?: string; - goalSlug?: string; - questions: GoalSetupQuestion[]; -} - -export interface GoalSetupFact { - id: string; - text: string; - accepted: boolean; - removed: boolean; - comment?: string; - recommendedAutomatedVerification?: boolean; - automatedVerification: boolean; - previousText?: string; -} - -export interface GoalSetupFactsBundle { - stage: "facts"; - title?: string; - goalSlug?: string; - facts: GoalSetupFact[]; - showAccepted?: boolean; -} - -export type GoalSetupBundle = GoalSetupInterviewBundle | GoalSetupFactsBundle; - -export interface GoalSetupInterviewResult { - stage: "interview"; - title?: string; - goalSlug?: string; - answers: GoalSetupQuestionAnswer[]; -} - -export interface GoalSetupFactResult { - id: string; - text: string; - accepted: boolean; - removed: boolean; - comment?: string; - automatedVerification: boolean; - recommendedAutomatedVerification?: boolean; -} - -export interface GoalSetupFactsResult { - stage: "facts"; - title?: string; - goalSlug?: string; - facts: GoalSetupFactResult[]; - factsMarkdown: string; -} - -export type GoalSetupResult = GoalSetupInterviewResult | GoalSetupFactsResult; - -function asRecord(value: unknown, context: string): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`${context} must be an object`); - } - return value as Record; -} - -function asString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function asBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function normalizeId(value: unknown, fallback: string): string { - const raw = asString(value, fallback).trim(); - return raw || fallback; -} - -function normalizeAnswerMode(value: unknown): GoalSetupAnswerMode { - switch (value) { - case "single": - case "multi": - case "single-custom": - case "multi-custom": - case "custom": - case "text": - return value; - default: - return "text"; - } -} - -function normalizeOption(value: unknown, index: number): GoalSetupQuestionOption { - const item = asRecord(value, `questions[].options[${index}]`); - const label = asString(item.label).trim(); - if (!label) { - throw new Error(`questions[].options[${index}].label is required`); - } - return { - id: normalizeId(item.id, `option-${index + 1}`), - label, - ...(asString(item.description).trim() - ? { description: asString(item.description).trim() } - : {}), - }; -} - -function normalizeQuestion(value: unknown, index: number): GoalSetupQuestion { - const item = asRecord(value, `questions[${index}]`); - const prompt = asString(item.prompt).trim(); - if (!prompt) { - throw new Error(`questions[${index}].prompt is required`); - } - const options = Array.isArray(item.options) - ? item.options.map(normalizeOption) - : undefined; - - const recommendedOptionIds = Array.isArray(item.recommendedOptionIds) - ? (item.recommendedOptionIds as unknown[]).filter((id): id is string => typeof id === 'string') - : undefined; - - return { - id: normalizeId(item.id, `question-${index + 1}`), - prompt, - ...(asString(item.description).trim() - ? { description: asString(item.description).trim() } - : {}), - answerMode: normalizeAnswerMode(item.answerMode), - ...(asString(item.recommendedAnswer).trim() - ? { recommendedAnswer: asString(item.recommendedAnswer).trim() } - : {}), - ...(recommendedOptionIds && recommendedOptionIds.length > 0 - ? { recommendedOptionIds } - : {}), - ...(options && options.length > 0 ? { options } : {}), - required: asBoolean(item.required, true), - }; -} - -function normalizeFact(value: unknown, index: number): GoalSetupFact { - const item = asRecord(value, `facts[${index}]`); - const text = asString(item.text).trim(); - if (!text) { - throw new Error(`facts[${index}].text is required`); - } - const recommended = asBoolean(item.recommendedAutomatedVerification, false); - return { - id: normalizeId(item.id, `fact-${index + 1}`), - text, - accepted: asBoolean(item.accepted, false), - removed: asBoolean(item.removed, false), - ...(asString(item.comment).trim() - ? { comment: asString(item.comment).trim() } - : {}), - recommendedAutomatedVerification: recommended, - automatedVerification: asBoolean(item.automatedVerification, recommended), - ...(asString(item.previousText).trim() - ? { previousText: asString(item.previousText).trim() } - : {}), - }; -} - -export function normalizeInterviewBundle(value: unknown): GoalSetupInterviewBundle { - const raw = asRecord(value, "goal setup interview bundle"); - if (!Array.isArray(raw.questions) || raw.questions.length === 0) { - throw new Error("interview bundle requires at least one question"); - } - return { - stage: "interview", - ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), - ...(asString(raw.goalSlug).trim() - ? { goalSlug: asString(raw.goalSlug).trim() } - : {}), - questions: raw.questions.map(normalizeQuestion), - }; -} - -export function normalizeFactsBundle(value: unknown): GoalSetupFactsBundle { - const raw = asRecord(value, "goal setup facts bundle"); - if (!Array.isArray(raw.facts)) { - throw new Error("facts bundle requires a facts array"); - } - return { - stage: "facts", - ...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}), - ...(asString(raw.goalSlug).trim() - ? { goalSlug: asString(raw.goalSlug).trim() } - : {}), - facts: raw.facts.map(normalizeFact), - showAccepted: asBoolean(raw.showAccepted, false), - }; -} - -export function normalizeGoalSetupBundle( - value: unknown, - expectedStage?: GoalSetupStage -): GoalSetupBundle { - const raw = asRecord(value, "goal setup bundle"); - const stage = expectedStage ?? raw.stage; - if (stage === "interview") return normalizeInterviewBundle(raw); - if (stage === "facts") return normalizeFactsBundle(raw); - throw new Error("goal setup bundle stage must be interview or facts"); -} - -export function hasQuestionAnswer(answer: GoalSetupQuestionAnswer): boolean { - return ( - answer.selectedOptionIds.length > 0 || - answer.customAnswer.trim().length > 0 || - answer.answer.trim().length > 0 - ); -} - -export function createInterviewResult( - bundle: GoalSetupInterviewBundle, - answers: GoalSetupQuestionAnswer[] -): GoalSetupInterviewResult { - const byId = new Map(answers.map((answer) => [answer.questionId, answer])); - return { - stage: "interview", - title: bundle.title, - goalSlug: bundle.goalSlug, - answers: bundle.questions.map((question) => { - const answer = byId.get(question.id); - const normalized: GoalSetupQuestionAnswer = { - questionId: question.id, - selectedOptionIds: Array.isArray(answer?.selectedOptionIds) - ? answer!.selectedOptionIds - : [], - customAnswer: asString(answer?.customAnswer), - ...(asString(answer?.note).trim() - ? { note: asString(answer?.note).trim() } - : {}), - answer: asString(answer?.answer), - completed: asBoolean(answer?.completed, false), - }; - const completed = normalized.completed || hasQuestionAnswer(normalized); - const skipped = asBoolean(answer?.skipped, false) && !completed; - return { - ...normalized, - completed, - ...(skipped ? { skipped: true } : {}), - }; - }), - }; -} - -export function filterReviewableFacts(bundle: GoalSetupFactsBundle): GoalSetupFact[] { - if (bundle.showAccepted) return bundle.facts; - return bundle.facts.filter((fact) => !fact.accepted); -} - -export function createFactsResult( - bundle: GoalSetupFactsBundle, - facts: GoalSetupFactResult[] -): GoalSetupFactsResult { - const byId = new Map(facts.map((fact) => [fact.id, fact])); - const merged = bundle.facts.map((fact) => { - const next = byId.get(fact.id); - const removed = asBoolean(next?.removed, fact.removed); - const text = asString(next?.text, fact.text).trim(); - if (!removed && !text) { - throw new Error(`Fact "${fact.id}" text cannot be empty; edit it or remove the fact.`); - } - const comment = (next && Object.prototype.hasOwnProperty.call(next, "comment") - ? asString(next.comment) - : asString(fact.comment) - ).trim(); - return { - id: fact.id, - text: text || fact.text, - accepted: asBoolean(next?.accepted, fact.accepted), - removed, - ...(comment ? { comment } : {}), - automatedVerification: asBoolean( - next?.automatedVerification, - fact.automatedVerification - ), - recommendedAutomatedVerification: - next?.recommendedAutomatedVerification ?? - fact.recommendedAutomatedVerification, - }; - }); - - return { - stage: "facts", - title: bundle.title, - goalSlug: bundle.goalSlug, - facts: merged, - factsMarkdown: factsResultToMarkdown(merged), - }; -} - -export function factsResultToMarkdown(facts: GoalSetupFactResult[]): string { - const accepted = facts.filter((fact) => fact.accepted && !fact.removed); - if (accepted.length === 0) return "# Facts\n\nNo accepted facts."; - - const lines = ["# Facts", ""]; - for (const fact of accepted) { - lines.push(`- ${fact.text}`); - } - return lines.join("\n"); -} +export * from '@plannotator/core/goal-setup'; diff --git a/packages/shared/open-in-apps.ts b/packages/shared/open-in-apps.ts index 122af8feb..25d8ae092 100644 --- a/packages/shared/open-in-apps.ts +++ b/packages/shared/open-in-apps.ts @@ -1,189 +1 @@ -/** - * Open-in-App Catalog — single source of truth. - * - * Shared between the Bun/Pi servers (which launch the app) and the UI (which - * renders the picker). Runtime-agnostic: no Bun or Node-specific APIs, pure - * data + types only. - * - * Mirrors OpenCode's "Open in" app list. Each entry declares how to launch the - * app per platform: - * - mac.appName -> `open -a "" ` - * - win.bin -> ` ` (resolved against PATH) - * - linux.bin -> ` ` - * - * `kind` drives launch semantics: - * - file-manager -> reveal the file (mac: `open -R`, win: `explorer /select,`, - * linux: open the parent dir) - * - editor -> open the file itself - * - terminal -> open the file's parent directory - * - * One special id has no platform launch fields: - * - 'reveal' (kind file-manager) — uses the OS file manager - */ - -export type OpenInKind = 'file-manager' | 'editor' | 'terminal'; - -export interface OpenInApp { - /** Stable identifier persisted in the cookie + sent to the server. */ - id: string; - /** Human-readable label. For 'reveal' this is resolved per-platform. */ - label: string; - kind: OpenInKind; - /** Icon id understood by AppIcon. For 'reveal' this is resolved per-platform. */ - icon: string; - /** macOS application bundle/display name passed to `open -a`. */ - mac?: { appName: string }; - /** Windows PATH binary. */ - win?: { bin: string }; - /** Linux PATH binary. */ - linux?: { bin: string }; -} - -/** - * The catalog, in menu order. The UI groups by `kind` - * (file-manager + default first, then editors, then terminals). - */ -export const OPEN_IN_APPS: OpenInApp[] = [ - // ── File manager (always available) ──────────────────────────────────── - { - id: 'reveal', - label: 'Finder', // resolved per-platform, see resolveRevealLabel - kind: 'file-manager', - icon: 'finder', // resolved per-platform, see resolveRevealIcon - }, - - // ── Editors ──────────────────────────────────────────────────────────── - { - id: 'vscode', - label: 'VS Code', - kind: 'editor', - icon: 'vscode', - mac: { appName: 'Visual Studio Code' }, - win: { bin: 'code' }, - linux: { bin: 'code' }, - }, - { - id: 'cursor', - label: 'Cursor', - kind: 'editor', - icon: 'cursor', - mac: { appName: 'Cursor' }, - win: { bin: 'cursor' }, - linux: { bin: 'cursor' }, - }, - { - id: 'zed', - label: 'Zed', - kind: 'editor', - icon: 'zed', - mac: { appName: 'Zed' }, - win: { bin: 'zed' }, - linux: { bin: 'zed' }, - }, - { - id: 'sublime-text', - label: 'Sublime Text', - kind: 'editor', - icon: 'sublime-text', - mac: { appName: 'Sublime Text' }, - win: { bin: 'subl' }, - linux: { bin: 'subl' }, - }, - { - id: 'textmate', - label: 'TextMate', - kind: 'editor', - icon: 'textmate', - mac: { appName: 'TextMate' }, - }, - { - id: 'antigravity', - label: 'Antigravity', - kind: 'editor', - icon: 'antigravity', - mac: { appName: 'Antigravity' }, - }, - { - id: 'xcode', - label: 'Xcode', - kind: 'editor', - icon: 'xcode', - mac: { appName: 'Xcode' }, - }, - { - id: 'android-studio', - label: 'Android Studio', - kind: 'editor', - icon: 'android-studio', - mac: { appName: 'Android Studio' }, - }, - - // ── Terminals ────────────────────────────────────────────────────────── - { - id: 'terminal', - label: 'Terminal', - kind: 'terminal', - icon: 'terminal', - mac: { appName: 'Terminal' }, - }, - { - id: 'iterm2', - label: 'iTerm2', - kind: 'terminal', - icon: 'iterm2', - mac: { appName: 'iTerm' }, // bundle name is "iTerm", not "iTerm2" - }, - { - id: 'ghostty', - label: 'Ghostty', - kind: 'terminal', - icon: 'ghostty', - mac: { appName: 'Ghostty' }, - }, - { - id: 'warp', - label: 'Warp', - kind: 'terminal', - icon: 'warp', - mac: { appName: 'Warp' }, - }, - { - id: 'powershell', - label: 'PowerShell', - kind: 'terminal', - icon: 'powershell', - win: { bin: 'powershell' }, - }, -]; - -export type OpenInPlatform = 'mac' | 'win' | 'linux'; - -/** - * Per-platform label for the 'reveal' (file-manager) entry. - */ -export function resolveRevealLabel(platform: OpenInPlatform): string { - switch (platform) { - case 'win': - return 'Explorer'; - case 'linux': - return 'Files'; - case 'mac': - default: - return 'Finder'; - } -} - -/** - * Per-platform icon for the 'reveal' (file-manager) entry: - * finder on mac/linux, file-explorer on win. - */ -export function resolveRevealIcon(platform: OpenInPlatform): string { - return platform === 'win' ? 'file-explorer' : 'finder'; -} - -/** - * Look up a catalog entry by id. - */ -export function getOpenInApp(id: string): OpenInApp | undefined { - return OPEN_IN_APPS.find((app) => app.id === id); -} +export * from '@plannotator/core/open-in-apps'; diff --git a/packages/shared/package.json b/packages/shared/package.json index be57c2a1c..4e4af9853 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -64,6 +64,7 @@ "./commit-avatars": "./commit-avatars.ts" }, "dependencies": { + "@plannotator/core": "workspace:*", "@joplin/turndown-plugin-gfm": "^1.0.64", "parse5": "^7.3.0", "turndown": "^7.2.4" diff --git a/packages/shared/project.ts b/packages/shared/project.ts index 23c130e81..ff1bbb489 100644 --- a/packages/shared/project.ts +++ b/packages/shared/project.ts @@ -1,71 +1 @@ -/** - * Project Utility — Pure Functions - * - * String sanitization and path extraction helpers. - * Runtime-agnostic: no Bun or Node-specific APIs. - */ - -/** - * Sanitize a string for use as a tag - * - lowercase - * - replace spaces/underscores with hyphens - * - remove special characters - * - trim to reasonable length - */ -export function sanitizeTag(name: string): string | null { - if (!name || typeof name !== "string") return null; - - const sanitized = name - .toLowerCase() - .trim() - .replace(/[\s_]+/g, "-") // spaces/underscores -> hyphens - .replace(/[^a-z0-9-]/g, "") // remove special chars - .replace(/-+/g, "-") // collapse multiple hyphens - .replace(/^-|-$/g, "") // trim leading/trailing hyphens - .slice(0, 30); // max 30 chars - - return sanitized.length >= 2 ? sanitized : null; -} - -/** - * Extract repo name from a git root path - */ -export function extractRepoName(gitRootPath: string): string | null { - if (!gitRootPath || typeof gitRootPath !== "string") return null; - - const trimmed = gitRootPath.trim().replace(/\/+$/, ""); // remove trailing slashes - const parts = trimmed.split("/"); - const name = parts[parts.length - 1]; - - return sanitizeTag(name); -} - -/** - * Extract directory name from a path - */ -export function extractDirName(path: string): string | null { - if (!path || typeof path !== "string") return null; - - const trimmed = path.trim().replace(/\/+$/, ""); - if (trimmed === "" || trimmed === "/") return null; - - const parts = trimmed.split("/"); - const name = parts[parts.length - 1]; - - // Skip generic names - const skipNames = new Set(["home", "users", "user", "root", "tmp", "var"]); - if (skipNames.has(name.toLowerCase())) return null; - - return sanitizeTag(name); -} - -/** - * Extract hostname from a URL string, or return the original string on failure. - */ -export function hostnameOrFallback(url: string): string { - try { - return new URL(url).hostname; - } catch { - return url; - } -} +export * from '@plannotator/core/project'; diff --git a/packages/shared/source-save.ts b/packages/shared/source-save.ts index 434f72183..d36a116c3 100644 --- a/packages/shared/source-save.ts +++ b/packages/shared/source-save.ts @@ -1,138 +1 @@ -export type SourceSaveLanguage = "markdown" | "mdx" | "text"; - -export type SourceSaveDisabledReason = - | "not-annotate-mode" - | "not-local-file" - | "unsupported-extension" - | "converted-source" - | "html-render" - | "folder-mode" - | "message-mode" - | "shared-session" - | "missing-file" - | "unreadable-file"; - -export type SourceSaveScope = "single-file" | "folder-file"; - -export type SourceFileEol = "lf" | "crlf" | "mixed" | "none"; - -export interface SourceFileSnapshot { - text: string; - hash: string; - mtimeMs: number; - size: number; - eol: SourceFileEol; -} - -export type SourceSaveCapability = - | { - enabled: true; - kind: "local-text-file"; - scope: SourceSaveScope; - path: string; - basename: string; - language: SourceSaveLanguage; - hash: string; - mtimeMs: number; - size: number; - eol: SourceFileEol; - } - | { - enabled: false; - reason: SourceSaveDisabledReason; - }; - -export interface SourceSaveRequest { - path?: string; - text: string; - baseHash: string; - baseMtimeMs?: number; - baseEol?: SourceFileEol; - allowMissingBase?: boolean; -} - -export type SourceSaveResponse = - | { - ok: true; - hash: string; - mtimeMs: number; - size: number; - eol: SourceFileEol; - } - | { - ok: false; - code: "conflict"; - message: string; - currentText: string; - currentHash: string; - currentMtimeMs: number; - currentSize: number; - currentEol: SourceFileEol; - } - | { - ok: false; - code: "not-writable" | "write-failed" | "invalid-request"; - message: string; - }; - -export type SourceSaveConflictResponse = Extract; - -export function isSourceFileEol(value: unknown): value is SourceFileEol { - return value === "lf" || value === "crlf" || value === "mixed" || value === "none"; -} - -export function hasSourceSaveConflictSnapshot(response: SourceSaveResponse): response is SourceSaveConflictResponse { - if (!("code" in response) || response.code !== "conflict") return false; - const conflict = response as SourceSaveConflictResponse; - return ( - typeof conflict.currentText === "string" && - typeof conflict.currentHash === "string" && - typeof conflict.currentMtimeMs === "number" && - typeof conflict.currentSize === "number" && - isSourceFileEol(conflict.currentEol) - ); -} - -export const SOURCE_SAVE_FILE_REGEX = /\.(md|mdx|txt)$/i; - -export function isSourceSaveFilePath(filePath: string): boolean { - return SOURCE_SAVE_FILE_REGEX.test(filePath); -} - -export function getSourceSaveLanguage(filePath: string): SourceSaveLanguage | null { - const lower = filePath.toLowerCase(); - if (lower.endsWith(".mdx")) return "mdx"; - if (lower.endsWith(".md")) return "markdown"; - if (lower.endsWith(".txt")) return "text"; - return null; -} - -export function basenameFromPath(filePath: string): string { - const normalized = filePath.replace(/\\/g, "/"); - return normalized.split("/").pop() || filePath; -} - -export function disabledSourceSave(reason: SourceSaveDisabledReason): SourceSaveCapability { - return { enabled: false, reason }; -} - -export function enabledSourceSave( - scope: SourceSaveScope, - filePath: string, - snapshot: SourceFileSnapshot, -): SourceSaveCapability { - const language = getSourceSaveLanguage(filePath); - if (!language) return disabledSourceSave("unsupported-extension"); - return { - enabled: true, - kind: "local-text-file", - scope, - path: filePath, - basename: basenameFromPath(filePath), - language, - hash: snapshot.hash, - mtimeMs: snapshot.mtimeMs, - size: snapshot.size, - eol: snapshot.eol, - }; -} +export * from '@plannotator/core/source-save'; diff --git a/packages/shared/storage.ts b/packages/shared/storage.ts index df8c958ae..431198d93 100644 --- a/packages/shared/storage.ts +++ b/packages/shared/storage.ts @@ -104,14 +104,8 @@ export function saveFinalSnapshot( // --- Plan Archive --- -export interface ArchivedPlan { - filename: string; - title: string; - date: string; - timestamp: string; // ISO string from file mtime - status: "approved" | "denied" | "unknown"; - size: number; -} +import type { ArchivedPlan } from '@plannotator/core/storage-types'; +export type { ArchivedPlan }; /** * Parse an archive filename into metadata. diff --git a/packages/shared/types.ts b/packages/shared/types.ts index d2a8596bb..a513ff973 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -1,13 +1,4 @@ -// Editor annotations from VS Code extension (ephemeral, in-memory only) -export interface EditorAnnotation { - id: string; - filePath: string; // workspace-relative (e.g., "src/auth.ts") - selectedText: string; - lineStart: number; // 1-based - lineEnd: number; // 1-based - comment?: string; - createdAt: number; -} +export type { EditorAnnotation } from '@plannotator/core/types'; // Git review types shared between server and client export type { diff --git a/packages/shared/workspace-status.ts b/packages/shared/workspace-status.ts index 6d5c9ce62..5dbc2ab4a 100644 --- a/packages/shared/workspace-status.ts +++ b/packages/shared/workspace-status.ts @@ -3,45 +3,8 @@ import { existsSync, realpathSync } from "node:fs"; import { readFile, realpath, stat } from "node:fs/promises"; import { isAbsolute, relative, resolve } from "node:path"; -export type WorkspaceFileStatus = - | "modified" - | "added" - | "deleted" - | "renamed" - | "copied" - | "typechange" - | "conflicted" - | "untracked"; - -export interface WorkspaceFileChange { - path: string; - repoRelativePath: string; - oldPath?: string; - status: WorkspaceFileStatus; - additions: number; - deletions: number; - staged: boolean; - unstaged: boolean; -} - -export interface WorkspaceStatusPayload { - available: boolean; - rootPath: string; - repoRoot?: string; - files: Record; - totals: { - files: number; - additions: number; - deletions: number; - }; - error?: string; -} - -export interface GitRepositoryInfo { - repoRoot: string; - gitDir: string; - gitCommonDir: string; -} +import type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus } from '@plannotator/core/workspace-status-types'; +export type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus }; const TEXT_FILE_MAX_BYTES = 2 * 1024 * 1024; const GIT_MAX_BUFFER = 20 * 1024 * 1024; diff --git a/packages/ui/styles.css b/packages/ui/styles.css new file mode 100644 index 000000000..893ff039d --- /dev/null +++ b/packages/ui/styles.css @@ -0,0 +1 @@ +/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-purple-500:oklch(62.7% .265 303.9);--color-zinc-500:oklch(55.2% .016 285.938);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:1.45;--text-base:1rem;--text-base--line-height:1.55;--text-lg:1.0625rem;--text-lg--line-height:1.45;--text-xl:1.1875rem;--text-xl--line-height:1.4;--text-2xl:1.375rem;--text-2xl--line-height:1.3;--text-4xl:1.875rem;--text-4xl--line-height:1.15;--text-5xl:2rem;--text-5xl--line-height:1.1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:calc(var(--radius) - 4px);--radius-2xl:1rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--blur-md:12px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}:root{--surface-0:var(--muted)}@supports (color:color-mix(in lab,red,red)){:root{--surface-0:color-mix(in oklch, var(--muted) 40%, var(--background))}}:root{--surface-1:var(--muted);--surface-2:var(--muted)}@supports (color:color-mix(in lab,red,red)){:root{--surface-2:color-mix(in oklch, var(--muted) 70%, var(--foreground))}}:root{--sidebar:var(--card);--sidebar-foreground:var(--foreground);--sidebar-primary:var(--primary);--sidebar-primary-foreground:var(--primary-foreground);--sidebar-accent:var(--muted);--sidebar-accent-foreground:var(--foreground);--sidebar-border:var(--border);--sidebar-ring:var(--ring);--annotation-comment:var(--accent);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--card-ring:#ffffff14;--card-shadow:0 0 0 1px var(--card-ring), 0 1px 3px 0 #0003, 0 2px 8px -2px #0000004d}html{--terminal-background:var(--card);--terminal-foreground:var(--foreground);--terminal-cursor:var(--primary);--terminal-cursor-accent:var(--terminal-background);--terminal-selection-background:var(--primary)}@supports (color:color-mix(in lab,red,red)){html{--terminal-selection-background:color-mix(in oklab, var(--primary) 32%, var(--terminal-background))}}html{--terminal-selection-foreground:var(--foreground);--terminal-selection-inactive-background:var(--border);--terminal-black:var(--terminal-background);--terminal-red:var(--destructive);--terminal-green:var(--success);--terminal-yellow:var(--warning);--terminal-blue:var(--primary);--terminal-magenta:var(--accent);--terminal-cyan:var(--secondary);--terminal-white:var(--foreground);--terminal-bright-black:var(--muted-foreground);--terminal-bright-red:var(--destructive)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-red:color-mix(in oklab, var(--destructive) 80%, white)}}html{--terminal-bright-green:var(--success)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-green:color-mix(in oklab, var(--success) 80%, white)}}html{--terminal-bright-yellow:var(--warning)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-yellow:color-mix(in oklab, var(--warning) 80%, white)}}html{--terminal-bright-blue:var(--primary)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-blue:color-mix(in oklab, var(--primary) 80%, white)}}html{--terminal-bright-magenta:var(--accent)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-magenta:color-mix(in oklab, var(--accent) 80%, white)}}html{--terminal-bright-cyan:var(--secondary)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-cyan:color-mix(in oklab, var(--secondary) 80%, white)}}html{--terminal-bright-white:var(--foreground)}.light{--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--terminal-bright-red:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-red:color-mix(in oklab, var(--destructive) 84%, black)}}.light{--terminal-bright-green:var(--success)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-green:color-mix(in oklab, var(--success) 84%, black)}}.light{--terminal-bright-yellow:var(--warning)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-yellow:color-mix(in oklab, var(--warning) 84%, black)}}.light{--terminal-bright-blue:var(--primary)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-blue:color-mix(in oklab, var(--primary) 84%, black)}}.light{--terminal-bright-magenta:var(--accent)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-magenta:color-mix(in oklab, var(--accent) 84%, black)}}.light{--terminal-bright-cyan:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-cyan:color-mix(in oklab, var(--secondary) 84%, black)}}.light{--card-ring:#0000000f;--card-shadow:0 0 0 1px var(--card-ring), 0 1px 3px 0 #0000000a, 0 2px 8px -2px #0000000f}*{border-color:var(--border)}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.-inset-x-2{inset-inline:calc(var(--spacing) * -2)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.-top-0\.5{top:calc(var(--spacing) * -.5)}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:calc(var(--spacing) * 0)}.top-0\.5{top:calc(var(--spacing) * .5)}.top-1{top:calc(var(--spacing) * 1)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-3\.5{top:calc(var(--spacing) * 3.5)}.top-12{top:calc(var(--spacing) * 12)}.top-\[50\%\]{top:50%}.top-\[52px\]{top:52px}.top-full{top:100%}.-right-0\.5{right:calc(var(--spacing) * -.5)}.-right-1{right:calc(var(--spacing) * -1)}.-right-2{right:calc(var(--spacing) * -2)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:calc(var(--spacing) * 0)}.right-0\.5{right:calc(var(--spacing) * .5)}.right-1{right:calc(var(--spacing) * 1)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-3\.5{right:calc(var(--spacing) * 3.5)}.bottom-0{bottom:calc(var(--spacing) * 0)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-4{bottom:calc(var(--spacing) * 4)}.bottom-full{bottom:100%}.-left-0\.5{left:calc(var(--spacing) * -.5)}.-left-1{left:calc(var(--spacing) * -1)}.left-0{left:calc(var(--spacing) * 0)}.left-0\.5{left:calc(var(--spacing) * .5)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-3{left:calc(var(--spacing) * 3)}.left-\[50\%\]{left:50%}.-z-10{z-index:-10}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-50{z-index:50}.z-\[59\]{z-index:59}.z-\[60\]{z-index:60}.z-\[70\]{z-index:70}.z-\[90\]{z-index:90}.z-\[100\]{z-index:100}.z-\[101\]{z-index:101}.z-\[110\]{z-index:110}.z-\[200\]{z-index:200}.z-\[9999\]{z-index:9999}.float-right{float:right}.clear-right{clear:right}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.m-64{margin:calc(var(--spacing) * 64)}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.mx-0\.5{margin-inline:calc(var(--spacing) * .5)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.my-0\.5{margin-block:calc(var(--spacing) * .5)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-1\.5{margin-block:calc(var(--spacing) * 1.5)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-5{margin-block:calc(var(--spacing) * 5)}.my-8{margin-block:calc(var(--spacing) * 8)}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.-mt-4{margin-top:calc(var(--spacing) * -4)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-\[3px\]{margin-top:3px}.-mr-1{margin-right:calc(var(--spacing) * -1)}.-mr-3{margin-right:calc(var(--spacing) * -3)}.-mr-4{margin-right:calc(var(--spacing) * -4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-px{margin-bottom:1px}.-ml-0\.5{margin-left:calc(var(--spacing) * -.5)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.list-item{display:list-item}.table{display:table}.field-sizing-content{field-sizing:content}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.h-0{height:calc(var(--spacing) * 0)}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:calc(var(--spacing) * 1)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-24{height:calc(var(--spacing) * 24)}.h-48{height:calc(var(--spacing) * 48)}.h-80{height:calc(var(--spacing) * 80)}.h-\[2px\]{height:2px}.h-\[13px\]{height:13px}.h-\[16px\]{height:16px}.h-\[18px\]{height:18px}.h-\[22px\]{height:22px}.h-\[760px\]{height:760px}.h-\[calc\(100vh-3rem\)\]{height:calc(100vh - 3rem)}.h-\[calc\(100vh-4rem\)\]{height:calc(100vh - 4rem)}.h-\[min\(65vh\,36rem\)\]{height:min(65vh,36rem)}.h-full{height:100%}.h-px{height:1px}.max-h-24{max-height:calc(var(--spacing) * 24)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[35vh\]{max-height:35vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[80vh\]{max-height:80vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[calc\(100vh-4rem\)\]{max-height:calc(100vh - 4rem)}.max-h-\[min\(640px\,85vh\)\]{max-height:min(640px,85vh)}.max-h-full{max-height:100%}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-12{min-height:calc(var(--spacing) * 12)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-24{min-height:calc(var(--spacing) * 24)}.min-h-48{min-height:calc(var(--spacing) * 48)}.min-h-\[4\.5rem\]{min-height:4.5rem}.min-h-\[20rem\]{min-height:20rem}.min-h-screen{min-height:100vh}.w-0{width:calc(var(--spacing) * 0)}.w-1{width:calc(var(--spacing) * 1)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/2{width:50%}.w-1\/3{width:33.3333%}.w-1\/4{width:25%}.w-1\/6{width:16.6667%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-2\/3{width:66.6667%}.w-2\/5{width:40%}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-3\/4{width:75%}.w-4{width:calc(var(--spacing) * 4)}.w-4\/5{width:80%}.w-5{width:calc(var(--spacing) * 5)}.w-5\/6{width:83.3333%}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-11\/12{width:91.6667%}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-32{width:calc(var(--spacing) * 32)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-52{width:calc(var(--spacing) * 52)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-72{width:calc(var(--spacing) * 72)}.w-\[3px\]{width:3px}.w-\[11px\]{width:11px}.w-\[14px\]{width:14px}.w-\[18px\]{width:18px}.w-\[320px\]{width:320px}.w-\[calc\(100vw-4rem\)\]{width:calc(100vw - 4rem)}.w-\[min\(22rem\,32vw\)\]{width:min(22rem,32vw)}.w-\[min\(520px\,calc\(100vw-4rem\)\)\]{width:min(520px,100vw - 4rem)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-\[3\.5rem\]{max-width:3.5rem}.max-w-\[3rem\]{max-width:3rem}.max-w-\[8rem\]{max-width:8rem}.max-w-\[12rem\]{max-width:12rem}.max-w-\[85vw\]{max-width:85vw}.max-w-\[90vw\]{max-width:90vw}.max-w-\[120px\]{max-width:120px}.max-w-\[140px\]{max-width:140px}.max-w-\[200px\]{max-width:200px}.max-w-\[220px\]{max-width:220px}.max-w-\[260px\]{max-width:260px}.max-w-\[400px\]{max-width:400px}.max-w-\[480px\]{max-width:480px}.max-w-\[min\(96vw\,110rem\)\]{max-width:min(96vw,110rem)}.max-w-\[min\(calc\(100vw-4rem\)\,1500px\)\]{max-width:min(100vw - 4rem,1500px)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-4{min-width:calc(var(--spacing) * 4)}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-10{min-width:calc(var(--spacing) * 10)}.min-w-\[4ch\]{min-width:4ch}.min-w-\[8rem\]{min-width:8rem}.min-w-\[12rem\]{min-width:12rem}.min-w-\[16rem\]{min-width:16rem}.min-w-\[18px\]{min-width:18px}.min-w-\[22px\]{min-width:22px}.min-w-\[80px\]{min-width:80px}.min-w-\[240px\]{min-width:240px}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-\[2\]{flex:2}.flex-\[3\]{flex:3}.flex-none{flex:none}.flex-shrink-0,.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.origin-\[var\(--radix-tooltip-content-transform-origin\)\]{transform-origin:var(--radix-tooltip-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0\.5{--tw-translate-x:calc(var(--spacing) * .5);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-1{--tw-translate-x:calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-4\.5{--tw-translate-x:calc(var(--spacing) * 4.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-6{--tw-translate-x:calc(var(--spacing) * 6);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-110{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.-scale-x-100{--tw-scale-x: -100% ;scale:var(--tw-scale-x) var(--tw-scale-y)}.-rotate-90{rotate:-90deg}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-crosshair{cursor:crosshair}.cursor-default{cursor:default}.cursor-grab{cursor:grab}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-zoom-in{cursor:zoom-in}.cursor-zoom-out{cursor:zoom-out}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-px{gap:1px}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-\[2px\]>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(2px * var(--tw-space-y-reverse));margin-block-end:calc(2px * calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}.self-center{align-self:center}.self-start{align-self:flex-start}.self-stretch{align-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-l-md{border-top-left-radius:calc(var(--radius) - 2px);border-bottom-left-radius:calc(var(--radius) - 2px)}.rounded-r-md{border-top-right-radius:calc(var(--radius) - 2px);border-bottom-right-radius:calc(var(--radius) - 2px)}.rounded-b-lg{border-bottom-right-radius:var(--radius);border-bottom-left-radius:var(--radius)}.rounded-b-none{border-bottom-right-radius:0;border-bottom-left-radius:0}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-\[1\.5px\]{border-style:var(--tw-border-style);border-width:1.5px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.border-b-\[2px\]{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-\[3px\]{border-left-style:var(--tw-border-style);border-left-width:3px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-accent,.border-accent\/30{border-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.border-accent\/30{border-color:color-mix(in oklab,var(--accent) 30%,transparent)}}.border-amber-500\/20{border-color:#f99c0033}@supports (color:color-mix(in lab,red,red)){.border-amber-500\/20{border-color:color-mix(in oklab,var(--color-amber-500) 20%,transparent)}}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab,red,red)){.border-amber-500\/30{border-color:color-mix(in oklab,var(--color-amber-500) 30%,transparent)}}.border-background{border-color:var(--background)}.border-black\/20{border-color:#0003}@supports (color:color-mix(in lab,red,red)){.border-black\/20{border-color:color-mix(in oklab,var(--color-black) 20%,transparent)}}.border-border,.border-border\/20{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/20{border-color:color-mix(in oklab,var(--border) 20%,transparent)}}.border-border\/30{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/30{border-color:color-mix(in oklab,var(--border) 30%,transparent)}}.border-border\/40{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/40{border-color:color-mix(in oklab,var(--border) 40%,transparent)}}.border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/50{border-color:color-mix(in oklab,var(--border) 50%,transparent)}}.border-border\/60{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/60{border-color:color-mix(in oklab,var(--border) 60%,transparent)}}.border-border\/70{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/70{border-color:color-mix(in oklab,var(--border) 70%,transparent)}}.border-destructive\/20{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/20{border-color:color-mix(in oklab,var(--destructive) 20%,transparent)}}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/30{border-color:color-mix(in oklab,var(--destructive) 30%,transparent)}}.border-foreground,.border-foreground\/5{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/5{border-color:color-mix(in oklab,var(--foreground) 5%,transparent)}}.border-foreground\/15{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/15{border-color:color-mix(in oklab,var(--foreground) 15%,transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/20{border-color:color-mix(in oklab,var(--foreground) 20%,transparent)}}.border-input{border-color:var(--input)}.border-muted-foreground\/20{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.border-muted-foreground\/20{border-color:color-mix(in oklab,var(--muted-foreground) 20%,transparent)}}.border-muted-foreground\/30{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.border-muted-foreground\/30{border-color:color-mix(in oklab,var(--muted-foreground) 30%,transparent)}}.border-muted-foreground\/35{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.border-muted-foreground\/35{border-color:color-mix(in oklab,var(--muted-foreground) 35%,transparent)}}.border-muted-foreground\/40{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.border-muted-foreground\/40{border-color:color-mix(in oklab,var(--muted-foreground) 40%,transparent)}}.border-primary,.border-primary\/20{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/20{border-color:color-mix(in oklab,var(--primary) 20%,transparent)}}.border-primary\/25{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/25{border-color:color-mix(in oklab,var(--primary) 25%,transparent)}}.border-primary\/30{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/30{border-color:color-mix(in oklab,var(--primary) 30%,transparent)}}.border-primary\/35{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/35{border-color:color-mix(in oklab,var(--primary) 35%,transparent)}}.border-primary\/40{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/40{border-color:color-mix(in oklab,var(--primary) 40%,transparent)}}.border-primary\/50{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/50{border-color:color-mix(in oklab,var(--primary) 50%,transparent)}}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\/20{border-color:color-mix(in oklab,var(--color-red-500) 20%,transparent)}}.border-success-foreground\/20{border-color:var(--success-foreground)}@supports (color:color-mix(in lab,red,red)){.border-success-foreground\/20{border-color:color-mix(in oklab,var(--success-foreground) 20%,transparent)}}.border-transparent{border-color:#0000}.border-warning\/20{border-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.border-warning\/20{border-color:color-mix(in oklab,var(--warning) 20%,transparent)}}.border-warning\/30{border-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.border-warning\/30{border-color:color-mix(in oklab,var(--warning) 30%,transparent)}}.bg-\[\#7c3aed\]\/10{background-color:#7c3aed1a}.bg-\[\#121011\]{background-color:#121011}.bg-accent,.bg-accent\/5{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/5{background-color:color-mix(in oklab,var(--accent) 5%,transparent)}}.bg-accent\/20{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/20{background-color:color-mix(in oklab,var(--accent) 20%,transparent)}}.bg-accent\/40{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/40{background-color:color-mix(in oklab,var(--accent) 40%,transparent)}}.bg-accent\/60{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/60{background-color:color-mix(in oklab,var(--accent) 60%,transparent)}}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/8{background-color:#f99c0014}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\/8{background-color:color-mix(in oklab,var(--color-amber-500) 8%,transparent)}}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\/10{background-color:color-mix(in oklab,var(--color-amber-500) 10%,transparent)}}.bg-amber-500\/15{background-color:#f99c0026}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\/15{background-color:color-mix(in oklab,var(--color-amber-500) 15%,transparent)}}.bg-annotation-comment\/8{background-color:var(--annotation-comment)}@supports (color:color-mix(in lab,red,red)){.bg-annotation-comment\/8{background-color:color-mix(in oklab,var(--annotation-comment) 8%,transparent)}}.bg-background,.bg-background\/50{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/50{background-color:color-mix(in oklab,var(--background) 50%,transparent)}}.bg-background\/60{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/60{background-color:color-mix(in oklab,var(--background) 60%,transparent)}}.bg-background\/70{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/70{background-color:color-mix(in oklab,var(--background) 70%,transparent)}}.bg-background\/80{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/80{background-color:color-mix(in oklab,var(--background) 80%,transparent)}}.bg-background\/90{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/90{background-color:color-mix(in oklab,var(--background) 90%,transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-black\/55{background-color:#0000008c}@supports (color:color-mix(in lab,red,red)){.bg-black\/55{background-color:color-mix(in oklab,var(--color-black) 55%,transparent)}}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab,red,red)){.bg-black\/80{background-color:color-mix(in oklab,var(--color-black) 80%,transparent)}}.bg-border,.bg-border\/50{background-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.bg-border\/50{background-color:color-mix(in oklab,var(--border) 50%,transparent)}}.bg-card,.bg-card\/30{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/30{background-color:color-mix(in oklab,var(--card) 30%,transparent)}}.bg-card\/50{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/50{background-color:color-mix(in oklab,var(--card) 50%,transparent)}}.bg-card\/70{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/70{background-color:color-mix(in oklab,var(--card) 70%,transparent)}}.bg-card\/80{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/80{background-color:color-mix(in oklab,var(--card) 80%,transparent)}}.bg-card\/95{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/95{background-color:color-mix(in oklab,var(--card) 95%,transparent)}}.bg-destructive,.bg-destructive\/5{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/5{background-color:color-mix(in oklab,var(--destructive) 5%,transparent)}}.bg-destructive\/8{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/8{background-color:color-mix(in oklab,var(--destructive) 8%,transparent)}}.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/10{background-color:color-mix(in oklab,var(--destructive) 10%,transparent)}}.bg-destructive\/15{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/15{background-color:color-mix(in oklab,var(--destructive) 15%,transparent)}}.bg-destructive\/20{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/20{background-color:color-mix(in oklab,var(--destructive) 20%,transparent)}}.bg-destructive\/40{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/40{background-color:color-mix(in oklab,var(--destructive) 40%,transparent)}}.bg-destructive\/60{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/60{background-color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.bg-foreground,.bg-foreground\/10{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/10{background-color:color-mix(in oklab,var(--foreground) 10%,transparent)}}.bg-foreground\/15{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/15{background-color:color-mix(in oklab,var(--foreground) 15%,transparent)}}.bg-foreground\/25{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/25{background-color:color-mix(in oklab,var(--foreground) 25%,transparent)}}.bg-foreground\/50{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/50{background-color:color-mix(in oklab,var(--foreground) 50%,transparent)}}.bg-foreground\/60{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/60{background-color:color-mix(in oklab,var(--foreground) 60%,transparent)}}.bg-foreground\/80{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/80{background-color:color-mix(in oklab,var(--foreground) 80%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/10{background-color:color-mix(in oklab,var(--color-green-500) 10%,transparent)}}.bg-green-500\/15{background-color:#00c75826}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/15{background-color:color-mix(in oklab,var(--color-green-500) 15%,transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground,.bg-muted-foreground\/10{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/10{background-color:color-mix(in oklab,var(--muted-foreground) 10%,transparent)}}.bg-muted-foreground\/15{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/15{background-color:color-mix(in oklab,var(--muted-foreground) 15%,transparent)}}.bg-muted-foreground\/30{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/30{background-color:color-mix(in oklab,var(--muted-foreground) 30%,transparent)}}.bg-muted-foreground\/50{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/50{background-color:color-mix(in oklab,var(--muted-foreground) 50%,transparent)}}.bg-muted\/10{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/10{background-color:color-mix(in oklab,var(--muted) 10%,transparent)}}.bg-muted\/20{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/20{background-color:color-mix(in oklab,var(--muted) 20%,transparent)}}.bg-muted\/25{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/25{background-color:color-mix(in oklab,var(--muted) 25%,transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted) 30%,transparent)}}.bg-muted\/35{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/35{background-color:color-mix(in oklab,var(--muted) 35%,transparent)}}.bg-muted\/40{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/40{background-color:color-mix(in oklab,var(--muted) 40%,transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.bg-muted\/60{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/60{background-color:color-mix(in oklab,var(--muted) 60%,transparent)}}.bg-muted\/70{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/70{background-color:color-mix(in oklab,var(--muted) 70%,transparent)}}.bg-muted\/80{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/80{background-color:color-mix(in oklab,var(--muted) 80%,transparent)}}.bg-muted\/85{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/85{background-color:color-mix(in oklab,var(--muted) 85%,transparent)}}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500) 10%,transparent)}}.bg-popover,.bg-popover\/50{background-color:var(--popover)}@supports (color:color-mix(in lab,red,red)){.bg-popover\/50{background-color:color-mix(in oklab,var(--popover) 50%,transparent)}}.bg-popover\/85{background-color:var(--popover)}@supports (color:color-mix(in lab,red,red)){.bg-popover\/85{background-color:color-mix(in oklab,var(--popover) 85%,transparent)}}.bg-primary,.bg-primary\/5{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/5{background-color:color-mix(in oklab,var(--primary) 5%,transparent)}}.bg-primary\/8{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/8{background-color:color-mix(in oklab,var(--primary) 8%,transparent)}}.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary) 10%,transparent)}}.bg-primary\/15{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/15{background-color:color-mix(in oklab,var(--primary) 15%,transparent)}}.bg-primary\/20{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/20{background-color:color-mix(in oklab,var(--primary) 20%,transparent)}}.bg-primary\/40{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/40{background-color:color-mix(in oklab,var(--primary) 40%,transparent)}}.bg-primary\/60{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/60{background-color:color-mix(in oklab,var(--primary) 60%,transparent)}}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/10{background-color:color-mix(in oklab,var(--color-purple-500) 10%,transparent)}}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/5{background-color:#fb2c360d}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/5{background-color:color-mix(in oklab,var(--color-red-500) 5%,transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500) 10%,transparent)}}.bg-red-500\/15{background-color:#fb2c3626}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/15{background-color:color-mix(in oklab,var(--color-red-500) 15%,transparent)}}.bg-secondary,.bg-secondary\/8{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.bg-secondary\/8{background-color:color-mix(in oklab,var(--secondary) 8%,transparent)}}.bg-secondary\/10{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.bg-secondary\/10{background-color:color-mix(in oklab,var(--secondary) 10%,transparent)}}.bg-secondary\/40{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.bg-secondary\/40{background-color:color-mix(in oklab,var(--secondary) 40%,transparent)}}.bg-secondary\/60{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.bg-secondary\/60{background-color:color-mix(in oklab,var(--secondary) 60%,transparent)}}.bg-success,.bg-success\/10{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.bg-success\/10{background-color:color-mix(in oklab,var(--success) 10%,transparent)}}.bg-success\/15{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.bg-success\/15{background-color:color-mix(in oklab,var(--success) 15%,transparent)}}.bg-success\/20{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.bg-success\/20{background-color:color-mix(in oklab,var(--success) 20%,transparent)}}.bg-success\/50{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.bg-success\/50{background-color:color-mix(in oklab,var(--success) 50%,transparent)}}.bg-surface-1,.bg-surface-1\/30{background-color:var(--surface-1)}@supports (color:color-mix(in lab,red,red)){.bg-surface-1\/30{background-color:color-mix(in oklab,var(--surface-1) 30%,transparent)}}.bg-surface-1\/40{background-color:var(--surface-1)}@supports (color:color-mix(in lab,red,red)){.bg-surface-1\/40{background-color:color-mix(in oklab,var(--surface-1) 40%,transparent)}}.bg-surface-1\/50{background-color:var(--surface-1)}@supports (color:color-mix(in lab,red,red)){.bg-surface-1\/50{background-color:color-mix(in oklab,var(--surface-1) 50%,transparent)}}.bg-transparent{background-color:#0000}.bg-warning,.bg-warning\/5{background-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.bg-warning\/5{background-color:color-mix(in oklab,var(--warning) 5%,transparent)}}.bg-warning\/15{background-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.bg-warning\/15{background-color:color-mix(in oklab,var(--warning) 15%,transparent)}}.bg-warning\/20{background-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.bg-warning\/20{background-color:color-mix(in oklab,var(--warning) 20%,transparent)}}.bg-white{background-color:var(--color-white)}.bg-yellow-500\/10{background-color:#edb2001a}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/10{background-color:color-mix(in oklab,var(--color-yellow-500) 10%,transparent)}}.bg-zinc-500{background-color:var(--color-zinc-500)}.\[mask-image\:linear-gradient\(transparent\,transparent\)\,linear-gradient\(\#000\,\#000\)\]{-webkit-mask-image:linear-gradient(#0000,#0000),linear-gradient(#000,#000);mask-image:linear-gradient(#0000,#0000),linear-gradient(#000,#000)}.bg-\[length\:250\%_100\%\,auto\]{background-size:250% 100%,auto}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.\[background-repeat\:no-repeat\,padding-box\]{background-repeat:no-repeat,padding-box}.\[mask-composite\:intersect\]{-webkit-mask-composite:source-in;mask-composite:intersect}.\[mask-clip\:padding-box\,border-box\]{-webkit-mask-clip:padding-box,border-box;mask-clip:padding-box,border-box}.fill-current{fill:currentColor}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing) * 0)}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-7{padding:calc(var(--spacing) * 7)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-7{padding-inline:calc(var(--spacing) * 7)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-\[5px\]{padding-block:5px}.py-px{padding-block:1px}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-32{padding-top:calc(var(--spacing) * 32)}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-1\.5{padding-right:calc(var(--spacing) * 1.5)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-12{padding-right:calc(var(--spacing) * 12)}.pr-20{padding-right:calc(var(--spacing) * 20)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-20{padding-bottom:calc(var(--spacing) * 20)}.pl-0\.5{padding-left:calc(var(--spacing) * .5)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-8{padding-left:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.6rem\]{font-size:.6rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13\.5px\]{font-size:13.5px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-\[1\.1\]{--tw-leading:1.1;line-height:1.1}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-wrap{text-wrap:wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-ellipsis{text-overflow:ellipsis}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#007aff\]{color:#007aff}.text-\[\#7c3aed\]{color:#7c3aed}.text-accent{color:var(--accent)}.text-amber-500{color:var(--color-amber-500)}.text-amber-500\/80{color:#f99c00cc}@supports (color:color-mix(in lab,red,red)){.text-amber-500\/80{color:color-mix(in oklab,var(--color-amber-500) 80%,transparent)}}.text-amber-600{color:var(--color-amber-600)}.text-annotation-comment,.text-annotation-comment\/80{color:var(--annotation-comment)}@supports (color:color-mix(in lab,red,red)){.text-annotation-comment\/80{color:color-mix(in oklab,var(--annotation-comment) 80%,transparent)}}.text-background{color:var(--background)}.text-card-foreground{color:var(--card-foreground)}.text-destructive{color:var(--destructive)}.text-destructive-foreground{color:var(--destructive-foreground)}.text-destructive\/60{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/60{color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.text-destructive\/70{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/70{color:color-mix(in oklab,var(--destructive) 70%,transparent)}}.text-destructive\/80{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/80{color:color-mix(in oklab,var(--destructive) 80%,transparent)}}.text-foreground,.text-foreground\/70{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.text-foreground\/70{color:color-mix(in oklab,var(--foreground) 70%,transparent)}}.text-foreground\/80{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.text-foreground\/80{color:color-mix(in oklab,var(--foreground) 80%,transparent)}}.text-foreground\/85{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.text-foreground\/85{color:color-mix(in oklab,var(--foreground) 85%,transparent)}}.text-foreground\/90{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.text-foreground\/90{color:color-mix(in oklab,var(--foreground) 90%,transparent)}}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-muted-foreground,.text-muted-foreground\/30{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/30{color:color-mix(in oklab,var(--muted-foreground) 30%,transparent)}}.text-muted-foreground\/35{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/35{color:color-mix(in oklab,var(--muted-foreground) 35%,transparent)}}.text-muted-foreground\/40{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/40{color:color-mix(in oklab,var(--muted-foreground) 40%,transparent)}}.text-muted-foreground\/50{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/50{color:color-mix(in oklab,var(--muted-foreground) 50%,transparent)}}.text-muted-foreground\/60{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/60{color:color-mix(in oklab,var(--muted-foreground) 60%,transparent)}}.text-muted-foreground\/70{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/70{color:color-mix(in oklab,var(--muted-foreground) 70%,transparent)}}.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/80{color:color-mix(in oklab,var(--muted-foreground) 80%,transparent)}}.text-orange-600{color:var(--color-orange-600)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-primary\/60{color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.text-primary\/60{color:color-mix(in oklab,var(--primary) 60%,transparent)}}.text-primary\/70{color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.text-primary\/70{color:color-mix(in oklab,var(--primary) 70%,transparent)}}.text-primary\/80{color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.text-primary\/80{color:color-mix(in oklab,var(--primary) 80%,transparent)}}.text-purple-500{color:var(--color-purple-500)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-secondary{color:var(--secondary)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-secondary\/80{color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.text-secondary\/80{color:color-mix(in oklab,var(--secondary) 80%,transparent)}}.text-success{color:var(--success)}.text-success-foreground,.text-success-foreground\/70{color:var(--success-foreground)}@supports (color:color-mix(in lab,red,red)){.text-success-foreground\/70{color:color-mix(in oklab,var(--success-foreground) 70%,transparent)}}.text-success\/70{color:var(--success)}@supports (color:color-mix(in lab,red,red)){.text-success\/70{color:color-mix(in oklab,var(--success) 70%,transparent)}}.text-success\/85{color:var(--success)}@supports (color:color-mix(in lab,red,red)){.text-success\/85{color:color-mix(in oklab,var(--success) 85%,transparent)}}.text-transparent{color:#0000}.text-warning{color:var(--warning)}.text-warning-foreground{color:var(--warning-foreground)}.text-white{color:var(--color-white)}.text-white\/70{color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.text-white\/70{color:color-mix(in oklab,var(--color-white) 70%,transparent)}}.text-yellow-600{color:var(--color-yellow-600)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.underline{text-decoration-line:underline}.decoration-destructive\/30{-webkit-text-decoration-color:var(--destructive);text-decoration-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.decoration-destructive\/30{-webkit-text-decoration-color:color-mix(in oklab,var(--destructive) 30%,transparent);text-decoration-color:color-mix(in oklab,var(--destructive) 30%,transparent)}}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-primary{accent-color:var(--primary)}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-45{opacity:.45}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_-4px_20px_rgba\(0\,0\,0\,0\.4\)\]{--tw-shadow:0 -4px 20px var(--tw-shadow-color,#0006);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_24px_80px_-36px_rgba\(15\,23\,42\,0\.5\)\]{--tw-shadow:0 24px 80px -36px var(--tw-shadow-color,#0f172a80);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[var\(--card-shadow\)\]{--tw-shadow:var(--card-shadow);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-accent{--tw-ring-color:var(--accent)}.ring-background{--tw-ring-color:var(--background)}.ring-border\/20{--tw-ring-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.ring-border\/20{--tw-ring-color:color-mix(in oklab, var(--border) 20%, transparent)}}.ring-border\/40{--tw-ring-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.ring-border\/40{--tw-ring-color:color-mix(in oklab, var(--border) 40%, transparent)}}.ring-border\/50{--tw-ring-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.ring-border\/50{--tw-ring-color:color-mix(in oklab, var(--border) 50%, transparent)}}.ring-primary\/10{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.ring-primary\/10{--tw-ring-color:color-mix(in oklab, var(--primary) 10%, transparent)}}.ring-primary\/30{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.ring-primary\/30{--tw-ring-color:color-mix(in oklab, var(--primary) 30%, transparent)}}.outline-hidden{--tw-outline-style:none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.outline-offset-2{outline-offset:2px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,background-color\,box-shadow\]{transition-property:color,background-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,transform\]{transition-property:opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[--base-color\:\#a1a1aa\]{--base-color:#a1a1aa}.\[--base-gradient-color\:\#000\]{--base-gradient-color:#000}.\[--bg\:linear-gradient\(90deg\,\#0000_calc\(50\%-var\(--spread\)\)\,var\(--base-gradient-color\)\,\#0000_calc\(50\%\+var\(--spread\)\)\)\]{--bg:linear-gradient(90deg,#0000 calc(50% - var(--spread)),var(--base-gradient-color),#0000 calc(50% + var(--spread)))}.block-1{block-size:calc(var(--spacing) * 1)}.block-2{block-size:calc(var(--spacing) * 2)}.duration-150{animation-duration:.15s}.duration-200{animation-duration:.2s}.duration-300{animation-duration:.3s}.ease-in{animation-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{animation-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{animation-timing-function:cubic-bezier(0,0,.2,1)}.running{animation-play-state:running}@media(hover:hover){.group-hover\:bg-border:is(:where(.group):hover *){background-color:var(--border)}.group-hover\:text-foreground:is(:where(.group):hover *){color:var(--foreground)}.group-hover\:text-muted-foreground:is(:where(.group):hover *),.group-hover\:text-muted-foreground\/60:is(:where(.group):hover *){color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.group-hover\:text-muted-foreground\/60:is(:where(.group):hover *){color:color-mix(in oklab,var(--muted-foreground) 60%,transparent)}}.group-hover\:opacity-100:is(:where(.group):hover *),.group-hover\/sidebar\:opacity-100:is(:where(.group\/sidebar):hover *){opacity:1}}.placeholder\:text-muted-foreground::placeholder,.placeholder\:text-muted-foreground\/30::placeholder{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-muted-foreground\/30::placeholder{color:color-mix(in oklab,var(--muted-foreground) 30%,transparent)}}.placeholder\:text-muted-foreground\/40::placeholder{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-muted-foreground\/40::placeholder{color:color-mix(in oklab,var(--muted-foreground) 40%,transparent)}}.placeholder\:text-muted-foreground\/50::placeholder{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-muted-foreground\/50::placeholder{color:color-mix(in oklab,var(--muted-foreground) 50%,transparent)}}.placeholder\:text-muted-foreground\/70::placeholder{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-muted-foreground\/70::placeholder{color:color-mix(in oklab,var(--muted-foreground) 70%,transparent)}}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:-inset-1\.5:before{content:var(--tw-content);inset:calc(var(--spacing) * -1.5)}.before\:-inset-2:before{content:var(--tw-content);inset:calc(var(--spacing) * -2)}.before\:content-\[\'\'\]:before{--tw-content:"";content:var(--tw-content)}.first\:mt-0:first-child{margin-top:calc(var(--spacing) * 0)}@media(hover:hover){.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-accent:hover{border-color:var(--accent)}.hover\:border-border\/80:hover{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.hover\:border-border\/80:hover{border-color:color-mix(in oklab,var(--border) 80%,transparent)}}.hover\:border-destructive\/50:hover{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:border-destructive\/50:hover{border-color:color-mix(in oklab,var(--destructive) 50%,transparent)}}.hover\:border-foreground\/30:hover{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:border-foreground\/30:hover{border-color:color-mix(in oklab,var(--foreground) 30%,transparent)}}.hover\:border-muted-foreground:hover,.hover\:border-muted-foreground\/30:hover{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:border-muted-foreground\/30:hover{border-color:color-mix(in oklab,var(--muted-foreground) 30%,transparent)}}.hover\:border-muted-foreground\/40:hover{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:border-muted-foreground\/40:hover{border-color:color-mix(in oklab,var(--muted-foreground) 40%,transparent)}}.hover\:border-primary\/40:hover{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:border-primary\/40:hover{border-color:color-mix(in oklab,var(--primary) 40%,transparent)}}.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-amber-500\/10:hover{background-color:#f99c001a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-amber-500\/10:hover{background-color:color-mix(in oklab,var(--color-amber-500) 10%,transparent)}}.hover\:bg-annotation-comment\/10:hover{background-color:var(--annotation-comment)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-annotation-comment\/10:hover{background-color:color-mix(in oklab,var(--annotation-comment) 10%,transparent)}}.hover\:bg-background:hover{background-color:var(--background)}.hover\:bg-card:hover{background-color:var(--card)}.hover\:bg-destructive\/10:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/10:hover{background-color:color-mix(in oklab,var(--destructive) 10%,transparent)}}.hover\:bg-destructive\/25:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/25:hover{background-color:color-mix(in oklab,var(--destructive) 25%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}.hover\:bg-green-500\/10:hover{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-green-500\/10:hover{background-color:color-mix(in oklab,var(--color-green-500) 10%,transparent)}}.hover\:bg-muted:hover,.hover\:bg-muted\/20:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/20:hover{background-color:color-mix(in oklab,var(--muted) 20%,transparent)}}.hover\:bg-muted\/30:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/30:hover{background-color:color-mix(in oklab,var(--muted) 30%,transparent)}}.hover\:bg-muted\/40:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/40:hover{background-color:color-mix(in oklab,var(--muted) 40%,transparent)}}.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.hover\:bg-muted\/60:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/60:hover{background-color:color-mix(in oklab,var(--muted) 60%,transparent)}}.hover\:bg-muted\/80:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/80:hover{background-color:color-mix(in oklab,var(--muted) 80%,transparent)}}.hover\:bg-primary\/10:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/10:hover{background-color:color-mix(in oklab,var(--primary) 10%,transparent)}}.hover\:bg-primary\/20:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/20:hover{background-color:color-mix(in oklab,var(--primary) 20%,transparent)}}.hover\:bg-primary\/25:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/25:hover{background-color:color-mix(in oklab,var(--primary) 25%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}.hover\:bg-red-500\/10:hover{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-red-500\/10:hover{background-color:color-mix(in oklab,var(--color-red-500) 10%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}.hover\:bg-success:hover,.hover\:bg-success\/10:hover{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-success\/10:hover{background-color:color-mix(in oklab,var(--success) 10%,transparent)}}.hover\:bg-success\/90:hover{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-success\/90:hover{background-color:color-mix(in oklab,var(--success) 90%,transparent)}}.hover\:bg-surface-1:hover,.hover\:bg-surface-1\/50:hover{background-color:var(--surface-1)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-surface-1\/50:hover{background-color:color-mix(in oklab,var(--surface-1) 50%,transparent)}}.hover\:bg-surface-1\/70:hover{background-color:var(--surface-1)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-surface-1\/70:hover{background-color:color-mix(in oklab,var(--surface-1) 70%,transparent)}}.hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/5:hover{background-color:color-mix(in oklab,var(--color-white) 5%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-amber-700:hover{color:var(--color-amber-700)}.hover\:text-destructive:hover{color:var(--destructive)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-green-500:hover{color:var(--color-green-500)}.hover\:text-muted-foreground:hover,.hover\:text-muted-foreground\/60:hover{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:text-muted-foreground\/60:hover{color:color-mix(in oklab,var(--muted-foreground) 60%,transparent)}}.hover\:text-primary:hover,.hover\:text-primary\/80:hover{color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:text-primary\/80:hover{color:color-mix(in oklab,var(--primary) 80%,transparent)}}.hover\:text-success:hover{color:var(--success)}.hover\:text-success-foreground:hover{color:var(--success-foreground)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-sm:hover{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:border-primary\/40:focus{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:border-primary\/40:focus{border-color:color-mix(in oklab,var(--primary) 40%,transparent)}}.focus\:border-primary\/50:focus{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:border-primary\/50:focus{border-color:color-mix(in oklab,var(--primary) 50%,transparent)}}.focus\:border-primary\/60:focus{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:border-primary\/60:focus{border-color:color-mix(in oklab,var(--primary) 60%,transparent)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-accent\/50:focus{--tw-ring-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-accent\/50:focus{--tw-ring-color:color-mix(in oklab, var(--accent) 50%, transparent)}}.focus\:ring-primary:focus,.focus\:ring-primary\/15:focus{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-primary\/15:focus{--tw-ring-color:color-mix(in oklab, var(--primary) 15%, transparent)}}.focus\:ring-primary\/20:focus{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-primary\/20:focus{--tw-ring-color:color-mix(in oklab, var(--primary) 20%, transparent)}}.focus\:ring-primary\/40:focus{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-primary\/40:focus{--tw-ring-color:color-mix(in oklab, var(--primary) 40%, transparent)}}.focus\:ring-primary\/50:focus{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-primary\/50:focus{--tw-ring-color:color-mix(in oklab, var(--primary) 50%, transparent)}}.focus\:ring-ring\/50:focus{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-ring\/50:focus{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color:var(--background)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:scale-\[0\.98\]:active{scale:.98}.active\:bg-muted:active{background-color:var(--muted)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-\[orientation\=vertical\]\:flex-col[data-orientation=vertical]{flex-direction:column}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:-.5rem}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:.5rem}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:-.5rem}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:.5rem}.data-\[state\=active\]\:bg-primary\/10[data-state=active]{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.data-\[state\=active\]\:bg-primary\/10[data-state=active]{background-color:color-mix(in oklab,var(--primary) 10%,transparent)}}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=closed\]\:scale-95[data-state=closed]{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[state\=closed\]\:opacity-0[data-state=closed]{opacity:0}.data-\[state\=closed\]\:animate-out[data-state=closed]{--tw-exit-opacity:initial;--tw-exit-scale:initial;--tw-exit-rotate:initial;--tw-exit-translate-x:initial;--tw-exit-translate-y:initial;animation-name:exit;animation-duration:.15s}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=delayed-open\]\:scale-100[data-state=delayed-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[state\=delayed-open\]\:opacity-100[data-state=delayed-open]{opacity:1}.data-\[state\=instant-open\]\:scale-100[data-state=instant-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[state\=instant-open\]\:opacity-100[data-state=instant-open]{opacity:1}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.data-\[state\=open\]\:animate-in[data-state=open]{--tw-enter-opacity:initial;--tw-enter-scale:initial;--tw-enter-rotate:initial;--tw-enter-translate-x:initial;--tw-enter-translate-y:initial;animation-name:enter;animation-duration:.15s}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive) 10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}@media(prefers-reduced-motion:reduce){.motion-reduce\:transform-none{transform:none}}@media(min-width:40rem){.sm\:block{display:block}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(min-width:48rem){.md\:-top-0\.5{top:calc(var(--spacing) * -.5)}.md\:top-4{top:calc(var(--spacing) * 4)}.md\:top-\[60px\]{top:60px}.md\:-right-0\.5{right:calc(var(--spacing) * -.5)}.md\:left-5{left:calc(var(--spacing) * 5)}.md\:-mt-5{margin-top:calc(var(--spacing) * -5)}.md\:-mr-2{margin-right:calc(var(--spacing) * -2)}.md\:-mr-5{margin-right:calc(var(--spacing) * -5)}.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline{display:inline}.md\:h-24{height:calc(var(--spacing) * 24)}.md\:min-h-\[420px\]{min-height:420px}.md\:w-24{width:calc(var(--spacing) * 24)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:gap-2{gap:calc(var(--spacing) * 2)}.md\:p-2{padding:calc(var(--spacing) * 2)}.md\:p-6{padding:calc(var(--spacing) * 6)}.md\:p-8{padding:calc(var(--spacing) * 8)}.md\:px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.md\:px-8{padding-inline:calc(var(--spacing) * 8)}.md\:py-1{padding-block:calc(var(--spacing) * 1)}.md\:py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.md\:py-8{padding-block:calc(var(--spacing) * 8)}.md\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media(min-width:64rem){.lg\:-mt-7{margin-top:calc(var(--spacing) * -7)}.lg\:-mr-7{margin-right:calc(var(--spacing) * -7)}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:inline{display:inline}.lg\:p-10{padding:calc(var(--spacing) * 10)}.lg\:px-10{padding-inline:calc(var(--spacing) * 10)}.lg\:py-10{padding-block:calc(var(--spacing) * 10)}}@media(min-width:80rem){.xl\:-mt-9{margin-top:calc(var(--spacing) * -9)}.xl\:-mr-9{margin-right:calc(var(--spacing) * -9)}.xl\:p-12{padding:calc(var(--spacing) * 12)}.xl\:px-12{padding-inline:calc(var(--spacing) * 12)}.xl\:py-12{padding-block:calc(var(--spacing) * 12)}}@media(prefers-color-scheme:dark){.dark\:border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab,red,red)){.dark\:border-white\/20{border-color:color-mix(in oklab,var(--color-white) 20%,transparent)}}.dark\:text-amber-400{color:var(--color-amber-400)}.dark\:text-green-400{color:var(--color-green-400)}.dark\:text-orange-400{color:var(--color-orange-400)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-yellow-400{color:var(--color-yellow-400)}.dark\:\[--base-color\:\#71717a\]{--base-color:#71717a}.dark\:\[--base-gradient-color\:\#ffffff\]{--base-gradient-color:#fff}.dark\:\[--bg\:linear-gradient\(90deg\,\#0000_calc\(50\%-var\(--spread\)\)\,var\(--base-gradient-color\)\,\#0000_calc\(50\%\+var\(--spread\)\)\)\]{--bg:linear-gradient(90deg,#0000 calc(50% - var(--spread)),var(--base-gradient-color),#0000 calc(50% + var(--spread)))}@media(hover:hover){.dark\:hover\:text-amber-300:hover{color:var(--color-amber-300)}}.dark\:focus-visible\:ring-destructive\/40:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/40[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/40[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive) 40%,transparent)}}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}@media(hover:none){.\[\@media\(hover\:none\)\]\:opacity-100{opacity:1}}@media(hover:hover){a.\[a\&\]\:hover\:bg-accent:hover{background-color:var(--accent)}a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,var(--secondary) 90%,transparent)}}a.\[a\&\]\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}}.theme-plannotator{--background:oklch(15% .02 260);--foreground:oklch(90% .01 260);--card:oklch(22% .02 260);--card-foreground:oklch(90% .01 260);--popover:oklch(28% .025 260);--popover-foreground:oklch(90% .01 260);--primary:oklch(75% .18 280);--primary-foreground:oklch(15% .02 260);--secondary:oklch(65% .15 180);--secondary-foreground:oklch(15% .02 260);--muted:oklch(26% .02 260);--muted-foreground:oklch(72% .02 260);--accent:oklch(70% .2 60);--accent-foreground:oklch(15% .02 260);--destructive:oklch(65% .2 25);--destructive-foreground:oklch(98% 0 0);--border:oklch(35% .02 260);--input:oklch(26% .02 260);--ring:oklch(75% .18 280);--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--font-sans:"Inter", system-ui, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", monospace;--radius:.625rem;--code-bg:oklch(26% .02 260);--focus-highlight:oklch(70% .2 200)}.theme-plannotator.light{--background:oklch(97% .005 260);--foreground:oklch(18% .02 260);--card:oklch(100% 0 0);--card-foreground:oklch(18% .02 260);--popover:oklch(100% 0 0);--popover-foreground:oklch(18% .02 260);--primary:oklch(50% .25 280);--primary-foreground:oklch(100% 0 0);--secondary:oklch(50% .18 180);--secondary-foreground:oklch(100% 0 0);--muted:oklch(92% .01 260);--muted-foreground:oklch(40% .02 260);--accent:oklch(60% .22 50);--accent-foreground:oklch(18% .02 260);--destructive:oklch(50% .25 25);--destructive-foreground:oklch(100% 0 0);--border:oklch(88% .01 260);--input:oklch(92% .01 260);--ring:oklch(50% .25 280);--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(92% .01 260)}.theme-claude-plus{--background:oklch(26.79% .0036 106.643);--foreground:oklch(95.76% .0027 106.449);--card:oklch(32% .004 106.6);--card-foreground:oklch(95.76% .0027 106.449);--popover:oklch(32% .004 106.6);--popover-foreground:oklch(95.76% .0027 106.449);--primary:oklch(67.24% .1308 38.7559);--primary-foreground:oklch(19.08% .002 106.586);--secondary:oklch(98.18% .0054 95.0986);--secondary-foreground:oklch(30.85% .0035 106.604);--muted:oklch(35% .004 106.6);--muted-foreground:oklch(70% .003 106.5);--accent:oklch(67.24% .1308 38.7559);--accent-foreground:oklch(19.08% .002 106.586);--destructive:oklch(63.68% .2078 25.3313);--destructive-foreground:oklch(100% 0 0);--border:oklch(40% .004 106.6);--input:oklch(35% .004 106.6);--ring:oklch(67.24% .1308 38.7559);--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--font-sans:"Outfit", sans-serif;--font-mono:"Geist Mono", ui-monospace, monospace;--radius:.625rem;--code-bg:oklch(22% .003 106.6);--focus-highlight:oklch(70% .2 200)}.theme-claude-plus.light{--background:oklch(98.18% .0054 95.0986);--foreground:oklch(34.38% .0269 95.7226);--card:oklch(100% 0 0);--card-foreground:oklch(34.38% .0269 95.7226);--popover:oklch(100% 0 0);--popover-foreground:oklch(34.38% .0269 95.7226);--primary:oklch(61.71% .1375 39.0427);--primary-foreground:oklch(100% 0 0);--secondary:oklch(92.45% .0138 92.9892);--secondary-foreground:oklch(43.34% .0177 98.6048);--muted:oklch(94% .01 95);--muted-foreground:oklch(50% .02 95);--accent:oklch(61.71% .1375 39.0427);--accent-foreground:oklch(100% 0 0);--destructive:oklch(19.08% .002 106.586);--destructive-foreground:oklch(100% 0 0);--border:oklch(88% .01 95);--input:oklch(94% .01 95);--ring:oklch(61.71% .1375 39.0427);--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 95)}.theme-soft-pop{--background:oklch(0% 0 0);--foreground:oklch(100% 0 0);--card:oklch(15% 0 0);--card-foreground:oklch(100% 0 0);--popover:oklch(15% 0 0);--popover-foreground:oklch(100% 0 0);--primary:oklch(68.01% .1583 276.935);--primary-foreground:oklch(100% 0 0);--secondary:oklch(78.45% .1325 181.912);--secondary-foreground:oklch(0% 0 0);--muted:oklch(32.11% 0 0);--muted-foreground:oklch(70% 0 0);--accent:oklch(87.9% .1534 91.6054);--accent-foreground:oklch(0% 0 0);--destructive:oklch(71.06% .1661 22.2162);--destructive-foreground:oklch(100% 0 0);--border:oklch(44.59% 0 0);--input:oklch(32.11% 0 0);--ring:oklch(68.01% .1583 276.935);--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--font-sans:"DM Sans", sans-serif;--font-mono:"Space Mono", monospace;--radius:.625rem;--code-bg:oklch(8% 0 0);--focus-highlight:oklch(70% .2 200)}.theme-soft-pop.light{--background:oklch(97.89% .0082 121.627);--foreground:oklch(0% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(0% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(0% 0 0);--primary:oklch(51.06% .2301 276.966);--primary-foreground:oklch(100% 0 0);--secondary:oklch(70.38% .123 182.503);--secondary-foreground:oklch(0% 0 0);--muted:oklch(95.51% 0 0);--muted-foreground:oklch(40% 0 0);--accent:oklch(76.86% .1647 70.0804);--accent-foreground:oklch(0% 0 0);--destructive:oklch(63.68% .2078 25.3313);--destructive-foreground:oklch(100% 0 0);--border:oklch(0% 0 0);--input:oklch(95.51% 0 0);--ring:oklch(78.53% .1041 274.713);--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 120)}.theme-adwaita{--background:#1d1d1d;--foreground:#ccc;--card:#303030;--card-foreground:#ccc;--popover:#303030;--popover-foreground:#ccc;--primary:#3584e4;--primary-foreground:#fff;--secondary:#3a3a3a;--secondary-foreground:#ccc;--muted:#2a2a2a;--muted-foreground:#888;--accent:#26a269;--accent-foreground:#fff;--destructive:#c01c28;--destructive-foreground:#fff;--border:#454545;--input:#3a3a3a;--ring:#3584e4;--success:#26a269;--success-foreground:#fff;--warning:#e5a50a;--warning-foreground:#1d1d1d;--font-sans:"Inter", system-ui, sans-serif;--font-mono:"Source Code Pro", ui-monospace, monospace;--radius:.625rem;--code-bg:#2a2a2a;--focus-highlight:#3584e4}.theme-adwaita.light{--background:#fafafa;--foreground:#323232;--card:#fff;--card-foreground:#323232;--popover:#fff;--popover-foreground:#323232;--primary:#3584e4;--primary-foreground:#fff;--secondary:#e6e6e6;--secondary-foreground:#323232;--muted:#ebebeb;--muted-foreground:#6e6e6e;--accent:#26a269;--accent-foreground:#fff;--destructive:#c01c28;--destructive-foreground:#fff;--border:#cfcfcf;--input:#e6e6e6;--ring:#3584e4;--success:#26a269;--success-foreground:#fff;--warning:#e5a50a;--warning-foreground:#323232;--code-bg:#ebebeb;--focus-highlight:#3584e4}.theme-caffeine{--background:#111;--foreground:#eee;--card:#191919;--card-foreground:#eee;--popover:#191919;--popover-foreground:#eee;--primary:#ffe0c2;--primary-foreground:#081a1b;--secondary:#393028;--secondary-foreground:#ffe0c2;--muted:#222;--muted-foreground:#b4b4b4;--accent:#c19a6b;--accent-foreground:#eee;--destructive:#e54d2e;--destructive-foreground:#fff;--border:#201e18;--input:#484848;--ring:#ffe0c2;--chart-1:#ffe0c2;--chart-2:#393028;--chart-3:#2a2a2a;--chart-4:#42382e;--chart-5:#ffe0c1;--sidebar:#18181b;--sidebar-foreground:#f4f4f5;--sidebar-primary:#1d4ed8;--sidebar-primary-foreground:#fff;--sidebar-accent:#27272a;--sidebar-accent-foreground:#f4f4f5;--sidebar-border:#27272a;--sidebar-ring:#d4d4d8;--font-sans:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--radius:.625rem;--shadow-2xs:0 1px 3px 0px #0000000d;--shadow-xs:0 1px 3px 0px #0000000d;--shadow-sm:0 1px 3px 0px #0000001a, 0 1px 2px -1px #0000001a;--shadow:0 1px 3px 0px #0000001a, 0 1px 2px -1px #0000001a;--shadow-md:0 1px 3px 0px #0000001a, 0 2px 4px -1px #0000001a;--shadow-lg:0 1px 3px 0px #0000001a, 0 4px 6px -1px #0000001a;--shadow-xl:0 1px 3px 0px #0000001a, 0 8px 10px -1px #0000001a;--shadow-2xl:0 1px 3px 0px #00000040;--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--code-bg:oklch(13% .015 260);--focus-highlight:oklch(70% .2 200)}.theme-caffeine.light{--background:#f9f9f9;--foreground:#202020;--card:#fcfcfc;--card-foreground:#202020;--popover:#fcfcfc;--popover-foreground:#202020;--primary:#644a40;--primary-foreground:#fff;--secondary:#ffdfb5;--secondary-foreground:#582d1d;--muted:#efefef;--muted-foreground:#646464;--accent:#8b5a2b;--accent-foreground:#fff;--destructive:#e54d2e;--destructive-foreground:#fff;--border:#d8d8d8;--input:#d8d8d8;--ring:#644a40;--chart-1:#644a40;--chart-2:#ffdfb5;--chart-3:#e8e8e8;--chart-4:#ffe6c4;--chart-5:#66493e;--sidebar:#fbfbfb;--sidebar-foreground:#252525;--sidebar-primary:#343434;--sidebar-primary-foreground:#fbfbfb;--sidebar-accent:#f7f7f7;--sidebar-accent-foreground:#343434;--sidebar-border:#ebebeb;--sidebar-ring:#b5b5b5;--font-sans:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--radius:.625rem;--shadow-2xs:0 1px 3px 0px #0000000d;--shadow-xs:0 1px 3px 0px #0000000d;--shadow-sm:0 1px 3px 0px #0000001a, 0 1px 2px -1px #0000001a;--shadow:0 1px 3px 0px #0000001a, 0 1px 2px -1px #0000001a;--shadow-md:0 1px 3px 0px #0000001a, 0 2px 4px -1px #0000001a;--shadow-lg:0 1px 3px 0px #0000001a, 0 4px 6px -1px #0000001a;--shadow-xl:0 1px 3px 0px #0000001a, 0 8px 10px -1px #0000001a;--shadow-2xl:0 1px 3px 0px #00000040;--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 260);--focus-highlight:oklch(70% .2 200)}.theme-synthwave-84,.theme-synthwave-84.light{--background:#262335;--foreground:#fff;--card:#2a2139;--card-foreground:#fff;--popover:#2a2139;--popover-foreground:#fff;--primary:#ff7edb;--primary-foreground:#262335;--secondary:#34294f;--secondary-foreground:#fff;--muted:#37294d;--muted-foreground:#fff9;--accent:#72f1b8;--accent-foreground:#262335;--destructive:#fe4450;--destructive-foreground:#fff;--border:#495495;--input:#2a2139;--ring:#ff7edb;--success:#72f1b8;--success-foreground:#262335;--warning:#fede5d;--warning-foreground:#262335;--font-sans:"Inter", system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#241b2f;--focus-highlight:#36f9f6}.theme-catppuccin{--background:#1e1e2e;--foreground:#cdd6f4;--card:#313244;--card-foreground:#cdd6f4;--popover:#313244;--popover-foreground:#cdd6f4;--primary:#89b4fa;--primary-foreground:#1e1e2e;--secondary:#45475a;--secondary-foreground:#cdd6f4;--muted:#45475a;--muted-foreground:#a6adc8;--accent:#f5c2e7;--accent-foreground:#1e1e2e;--destructive:#f38ba8;--destructive-foreground:#1e1e2e;--border:#585b70;--input:#313244;--ring:#89b4fa;--success:#a6e3a1;--success-foreground:#1e1e2e;--warning:#f9e2af;--warning-foreground:#1e1e2e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#181825;--focus-highlight:#94e2d5}.theme-catppuccin.light{--background:#eff1f5;--foreground:#4c4f69;--card:#e6e9ef;--card-foreground:#4c4f69;--popover:#e6e9ef;--popover-foreground:#4c4f69;--primary:#1e66f5;--primary-foreground:#eff1f5;--secondary:#ccd0da;--secondary-foreground:#4c4f69;--muted:#ccd0da;--muted-foreground:#6c6f85;--accent:#ea76cb;--accent-foreground:#eff1f5;--destructive:#d20f39;--destructive-foreground:#eff1f5;--border:#acb0be;--input:#ccd0da;--ring:#1e66f5;--success:#40a02b;--success-foreground:#eff1f5;--warning:#df8e1d;--warning-foreground:#eff1f5;--code-bg:#e6e9ef;--focus-highlight:#179299}.theme-rose-pine{--background:#191724;--foreground:#e0def4;--card:#26233a;--card-foreground:#e0def4;--popover:#26233a;--popover-foreground:#e0def4;--primary:#c4a7e7;--primary-foreground:#191724;--secondary:#403d52;--secondary-foreground:#e0def4;--muted:#26233a;--muted-foreground:#6e6a86;--accent:#f6c177;--accent-foreground:#191724;--destructive:#eb6f92;--destructive-foreground:#191724;--border:#403d52;--input:#26233a;--ring:#c4a7e7;--success:#31748f;--success-foreground:#e0def4;--warning:#f6c177;--warning-foreground:#191724;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, monospace;--radius:.625rem;--code-bg:#1f1d2e;--focus-highlight:#9ccfd8}.theme-rose-pine.light{--background:#faf4ed;--foreground:#575279;--card:#fffaf3;--card-foreground:#575279;--popover:#fffaf3;--popover-foreground:#575279;--primary:#907aa9;--primary-foreground:#faf4ed;--secondary:#dfdad9;--secondary-foreground:#575279;--muted:#f2e9e1;--muted-foreground:#9893a5;--accent:#ea9d34;--accent-foreground:#faf4ed;--destructive:#b4637a;--destructive-foreground:#faf4ed;--border:#dfdad9;--input:#f2e9e1;--ring:#907aa9;--success:#286983;--success-foreground:#faf4ed;--warning:#ea9d34;--warning-foreground:#faf4ed;--code-bg:#f2e9e1;--focus-highlight:#56949f}.theme-monokai-pro,.theme-monokai-pro.light{--background:#2d2a2e;--foreground:#fcfcfa;--card:#403e41;--card-foreground:#fcfcfa;--popover:#403e41;--popover-foreground:#fcfcfa;--primary:#ffd866;--primary-foreground:#2d2a2e;--secondary:#5b595c;--secondary-foreground:#fcfcfa;--muted:#403e41;--muted-foreground:#727072;--accent:#78dce8;--accent-foreground:#2d2a2e;--destructive:#ff6188;--destructive-foreground:#2d2a2e;--border:#5b595c;--input:#403e41;--ring:#ffd866;--success:#a9dc76;--success-foreground:#2d2a2e;--warning:#fc9867;--warning-foreground:#2d2a2e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#221f22;--focus-highlight:#78dce8}.theme-doom-64{--background:#1a1a1a;--foreground:#e0e0e0;--card:#2a2a2a;--card-foreground:#e0e0e0;--popover:#2a2a2a;--popover-foreground:#e0e0e0;--primary:#e53935;--primary-foreground:#fff;--secondary:#689f38;--secondary-foreground:#000;--muted:#252525;--muted-foreground:#a0a0a0;--accent:#64b5f6;--accent-foreground:#000;--destructive:#ffa000;--destructive-foreground:#000;--border:#4a4a4a;--input:#4a4a4a;--ring:#e53935;--chart-1:#e53935;--chart-2:#689f38;--chart-3:#64b5f6;--chart-4:#ffa000;--chart-5:#a1887f;--sidebar:#141414;--sidebar-foreground:#e0e0e0;--sidebar-primary:#e53935;--sidebar-primary-foreground:#fff;--sidebar-accent:#64b5f6;--sidebar-accent-foreground:#000;--sidebar-border:#4a4a4a;--sidebar-ring:#e53935;--font-sans:"Oxanium", sans-serif;--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:"Source Code Pro", monospace;--radius:0px;--shadow-2xs:0px 2px 5px 0px #0000004d;--shadow-xs:0px 2px 5px 0px #0000004d;--shadow-sm:0px 2px 5px 0px #0009, 0px 1px 2px -1px #0009;--shadow:0px 2px 5px 0px #0009, 0px 1px 2px -1px #0009;--shadow-md:0px 2px 5px 0px #0009, 0px 2px 4px -1px #0009;--shadow-lg:0px 2px 5px 0px #0009, 0px 4px 6px -1px #0009;--shadow-xl:0px 2px 5px 0px #0009, 0px 8px 10px -1px #0009;--shadow-2xl:0px 2px 5px 0px #000;--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--code-bg:oklch(13% .015 260);--focus-highlight:oklch(70% .2 200)}.theme-doom-64.light{--background:#ccc;--foreground:#1f1f1f;--card:#b0b0b0;--card-foreground:#1f1f1f;--popover:#b0b0b0;--popover-foreground:#1f1f1f;--primary:#b71c1c;--primary-foreground:#fff;--secondary:#556b2f;--secondary-foreground:#fff;--muted:#b8b8b8;--muted-foreground:#4a4a4a;--accent:#4682b4;--accent-foreground:#fff;--destructive:#ff6f00;--destructive-foreground:#000;--border:#505050;--input:#505050;--ring:#b71c1c;--chart-1:#b71c1c;--chart-2:#556b2f;--chart-3:#4682b4;--chart-4:#ff6f00;--chart-5:#8d6e63;--sidebar:#b0b0b0;--sidebar-foreground:#1f1f1f;--sidebar-primary:#b71c1c;--sidebar-primary-foreground:#fff;--sidebar-accent:#4682b4;--sidebar-accent-foreground:#fff;--sidebar-border:#505050;--sidebar-ring:#b71c1c;--font-sans:"Oxanium", sans-serif;--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:"Source Code Pro", monospace;--radius:0px;--shadow-2xs:0px 2px 4px 0px #0003;--shadow-xs:0px 2px 4px 0px #0003;--shadow-sm:0px 2px 4px 0px #0006, 0px 1px 2px -1px #0006;--shadow:0px 2px 4px 0px #0006, 0px 1px 2px -1px #0006;--shadow-md:0px 2px 4px 0px #0006, 0px 2px 4px -1px #0006;--shadow-lg:0px 2px 4px 0px #0006, 0px 4px 6px -1px #0006;--shadow-xl:0px 2px 4px 0px #0006, 0px 8px 10px -1px #0006;--shadow-2xl:0px 2px 4px 0px #000;--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 260);--focus-highlight:oklch(70% .2 200)}.theme-dracula,.theme-dracula.light{--background:#282a36;--foreground:#f8f8f2;--card:#44475a;--card-foreground:#f8f8f2;--popover:#44475a;--popover-foreground:#f8f8f2;--primary:#bd93f9;--primary-foreground:#282a36;--secondary:#44475a;--secondary-foreground:#f8f8f2;--muted:#373949;--muted-foreground:#bcbece;--accent:#8be9fd;--accent-foreground:#282a36;--destructive:#f55;--destructive-foreground:#f8f8f2;--border:#44475a;--input:#44475a;--ring:#bd93f9;--success:#50fa7b;--success-foreground:#282a36;--warning:#f1fa8c;--warning-foreground:#282a36;--font-sans:ui-sans-serif, system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;--radius:.625rem;--code-bg:#21222c;--focus-highlight:#8be9fd}.theme-gruvbox{--background:#282828;--foreground:#ebdbb2;--card:#3c3836;--card-foreground:#ebdbb2;--popover:#3c3836;--popover-foreground:#ebdbb2;--primary:#458588;--primary-foreground:#ebdbb2;--secondary:#504945;--secondary-foreground:#ebdbb2;--muted:#504945;--muted-foreground:#a89984;--accent:#b8bb26;--accent-foreground:#282828;--destructive:#fb4934;--destructive-foreground:#282828;--border:#504945;--input:#3c3836;--ring:#458588;--success:#8ec07c;--success-foreground:#282828;--warning:#fabd2f;--warning-foreground:#282828;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#32302f;--focus-highlight:#83a598}.theme-gruvbox.light{--background:#fbf1c7;--foreground:#3c3836;--card:#f2e5bc;--card-foreground:#3c3836;--popover:#f2e5bc;--popover-foreground:#3c3836;--primary:#076678;--primary-foreground:#fbf1c7;--secondary:#d5c4a1;--secondary-foreground:#3c3836;--muted:#ebdbb2;--muted-foreground:#665c54;--accent:#79740e;--accent-foreground:#fbf1c7;--destructive:#9d0006;--destructive-foreground:#fbf1c7;--border:#d5c4a1;--input:#ebdbb2;--ring:#076678;--success:#689d6a;--success-foreground:#fbf1c7;--warning:#b57614;--warning-foreground:#fbf1c7;--code-bg:#ebdbb2;--focus-highlight:#076678}.theme-kanagawa-dragon,.theme-kanagawa-dragon.light{--background:#181616;--foreground:#c8c093;--card:#211e1e;--card-foreground:#c8c093;--popover:#211e1e;--popover-foreground:#c8c093;--primary:#7fb4ca;--primary-foreground:#181616;--secondary:#2a2625;--secondary-foreground:#c8c093;--muted:#252220;--muted-foreground:#a6a69c;--accent:#7aa89f;--accent-foreground:#181616;--destructive:#c4746e;--destructive-foreground:#c8c093;--border:#2e2b28;--input:#252220;--ring:#7fb4ca;--success:#87a987;--success-foreground:#181616;--warning:#e6c384;--warning-foreground:#181616;--font-sans:ui-sans-serif, system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;--radius:.625rem;--code-bg:#0d0c0c;--focus-highlight:#7aa89f}.theme-kanagawa-lotus,.theme-kanagawa-lotus.light{--background:#f2ecbc;--foreground:#545464;--card:#e5ddb0;--card-foreground:#545464;--popover:#e5ddb0;--popover-foreground:#545464;--primary:#4d699b;--primary-foreground:#f2ecbc;--secondary:#dcd5ac;--secondary-foreground:#545464;--muted:#e7dba0;--muted-foreground:#716e61;--accent:#624c83;--accent-foreground:#f2ecbc;--destructive:#c84053;--destructive-foreground:#f2ecbc;--border:#dcd5ac;--input:#e7dba0;--ring:#4d699b;--success:#6f894e;--success-foreground:#f2ecbc;--warning:#836f4a;--warning-foreground:#f2ecbc;--font-sans:ui-sans-serif, system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;--radius:.625rem;--code-bg:#d5cea3;--focus-highlight:#4e8ca2}.theme-kanagawa-wave,.theme-kanagawa-wave.light{--background:#1f1f28;--foreground:#dcd7ba;--card:#2a2a37;--card-foreground:#dcd7ba;--popover:#2a2a37;--popover-foreground:#dcd7ba;--primary:#7e9cd8;--primary-foreground:#1f1f28;--secondary:#363646;--secondary-foreground:#dcd7ba;--muted:#2d2d3b;--muted-foreground:#727169;--accent:#957fb8;--accent-foreground:#1f1f28;--destructive:#c34043;--destructive-foreground:#dcd7ba;--border:#363646;--input:#2d2d3b;--ring:#7fb4ca;--success:#98bb6c;--success-foreground:#1f1f28;--warning:#e6c384;--warning-foreground:#1f1f28;--font-sans:ui-sans-serif, system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;--radius:.625rem;--code-bg:#16161d;--focus-highlight:#7aa89f}.theme-paulmillr,.theme-paulmillr.light{--background:#000;--foreground:#f2f2f2;--card:#2a2a2a;--card-foreground:#f2f2f2;--popover:#2a2a2a;--popover-foreground:#f2f2f2;--primary:#396bd7;--primary-foreground:#fff;--secondary:#414141;--secondary-foreground:#f2f2f2;--muted:#2a2a2a;--muted-foreground:#bbb;--accent:#6cf;--accent-foreground:#000;--destructive:red;--destructive-foreground:#fff;--border:#414141;--input:#2a2a2a;--ring:#396bd7;--success:#79ff0f;--success-foreground:#000;--warning:#e7bf00;--warning-foreground:#000;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, "SF Mono", Consolas, monospace;--radius:.625rem;--code-bg:#1a1a1a;--focus-highlight:#6cf}.theme-quantum-rose{--background:#1a0922;--foreground:#ffb3ff;--card:#2a1435;--card-foreground:#ffb3ff;--popover:#2a1435;--popover-foreground:#ffb3ff;--primary:#ff6bef;--primary-foreground:#180518;--secondary:#46204f;--secondary-foreground:#ffb3ff;--muted:#331941;--muted-foreground:#d67ad6;--accent:#c06ec4;--accent-foreground:#1a0922;--destructive:#ff2876;--destructive-foreground:#f9f9f9;--border:#4a1b5f;--input:#46204f;--ring:#ff6bef;--chart-1:#ff6bef;--chart-2:#c359e3;--chart-3:#9161ff;--chart-4:#6f73e2;--chart-5:#547aff;--sidebar:#1c0d25;--sidebar-foreground:#ffb3ff;--sidebar-primary:#ff6bef;--sidebar-primary-foreground:#180518;--sidebar-accent:#5a1f5d;--sidebar-accent-foreground:#ffb3ff;--sidebar-border:#4a1b5f;--sidebar-ring:#ff6bef;--font-sans:Quicksand, sans-serif;--font-serif:Playfair Display, serif;--font-mono:Space Mono, monospace;--radius:.625rem;--shadow-2xs:0px 3px 0px 0px #e61ae617;--shadow-xs:0px 3px 0px 0px #e61ae617;--shadow-sm:0px 3px 0px 0px #e61ae62e, 0px 1px 2px -1px #e61ae62e;--shadow:0px 3px 0px 0px #e61ae62e, 0px 1px 2px -1px #e61ae62e;--shadow-md:0px 3px 0px 0px #e61ae62e, 0px 2px 4px -1px #e61ae62e;--shadow-lg:0px 3px 0px 0px #e61ae62e, 0px 4px 6px -1px #e61ae62e;--shadow-xl:0px 3px 0px 0px #e61ae62e, 0px 8px 10px -1px #e61ae62e;--shadow-2xl:0px 3px 0px 0px #e61ae673;--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--code-bg:oklch(13% .015 260);--focus-highlight:oklch(70% .2 200)}.theme-quantum-rose.light{--background:#fff0f8;--foreground:#91185c;--card:#fff7fc;--card-foreground:#91185c;--popover:#fff7fc;--popover-foreground:#91185c;--primary:#e6067a;--primary-foreground:#fff;--secondary:#ffd6ff;--secondary-foreground:#91185c;--muted:#ffe3f2;--muted-foreground:#c04283;--accent:#ffc1e3;--accent-foreground:#91185c;--destructive:#d13869;--destructive-foreground:#fff;--border:#ffc7e6;--input:#ffd6ff;--ring:#e6067a;--chart-1:#e6067a;--chart-2:#c44b97;--chart-3:#9969b6;--chart-4:#7371bf;--chart-5:#5e84ff;--sidebar:#ffedf6;--sidebar-foreground:#91185c;--sidebar-primary:#e6067a;--sidebar-primary-foreground:#fff;--sidebar-accent:#ffc1e3;--sidebar-accent-foreground:#91185c;--sidebar-border:#ffddf0;--sidebar-ring:#e6067a;--font-sans:Poppins, sans-serif;--font-serif:Playfair Display, serif;--font-mono:Space Mono, monospace;--radius:.625rem;--shadow-2xs:0px 3px 0px 0px #82174d17;--shadow-xs:0px 3px 0px 0px #82174d17;--shadow-sm:0px 3px 0px 0px #82174d2e, 0px 1px 2px -1px #82174d2e;--shadow:0px 3px 0px 0px #82174d2e, 0px 1px 2px -1px #82174d2e;--shadow-md:0px 3px 0px 0px #82174d2e, 0px 2px 4px -1px #82174d2e;--shadow-lg:0px 3px 0px 0px #82174d2e, 0px 4px 6px -1px #82174d2e;--shadow-xl:0px 3px 0px 0px #82174d2e, 0px 8px 10px -1px #82174d2e;--shadow-2xl:0px 3px 0px 0px #82174d73;--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 260);--focus-highlight:oklch(70% .2 200)}.theme-solar-dusk{--background:oklch(21.61% .0061 56.0434);--foreground:oklch(96.99% .0013 106.424);--card:oklch(26.85% .0063 34.2976);--card-foreground:oklch(96.99% .0013 106.424);--popover:oklch(26.85% .0063 34.2976);--popover-foreground:oklch(96.99% .0013 106.424);--primary:oklch(70.49% .1867 47.6044);--primary-foreground:oklch(100% 0 0);--secondary:oklch(44.44% .0096 73.639);--secondary-foreground:oklch(92.32% .0026 48.7171);--muted:oklch(26.85% .0063 34.2976);--muted-foreground:oklch(71.61% .0091 56.259);--accent:oklch(60% .12 229.32);--accent-foreground:oklch(15% .02 229);--destructive:oklch(57.71% .2152 27.325);--destructive-foreground:oklch(100% 0 0);--border:oklch(37.41% .0087 67.5582);--input:oklch(37.41% .0087 67.5582);--ring:oklch(70.49% .1867 47.6044);--chart-1:oklch(70.49% .1867 47.6044);--chart-2:oklch(68.47% .1479 237.323);--chart-3:oklch(79.52% .1617 86.0468);--chart-4:oklch(71.61% .0091 56.259);--chart-5:oklch(55.34% .0116 58.0708);--sidebar:oklch(26.85% .0063 34.2976);--sidebar-foreground:oklch(96.99% .0013 106.424);--sidebar-primary:oklch(70.49% .1867 47.6044);--sidebar-primary-foreground:oklch(100% 0 0);--sidebar-accent:oklch(68.47% .1479 237.323);--sidebar-accent-foreground:oklch(28.39% .0734 254.538);--sidebar-border:oklch(37.41% .0087 67.5582);--sidebar-ring:oklch(70.49% .1867 47.6044);--font-sans:Oxanium, sans-serif;--font-serif:Merriweather, serif;--font-mono:Fira Code, monospace;--radius:.3rem;--shadow-2xs:0px 2px 3px 0px #0d0d0d17;--shadow-xs:0px 2px 3px 0px #0d0d0d17;--shadow-sm:0px 2px 3px 0px #0d0d0d2e, 0px 1px 2px -1px #0d0d0d2e;--shadow:0px 2px 3px 0px #0d0d0d2e, 0px 1px 2px -1px #0d0d0d2e;--shadow-md:0px 2px 3px 0px #0d0d0d2e, 0px 2px 4px -1px #0d0d0d2e;--shadow-lg:0px 2px 3px 0px #0d0d0d2e, 0px 4px 6px -1px #0d0d0d2e;--shadow-xl:0px 2px 3px 0px #0d0d0d2e, 0px 8px 10px -1px #0d0d0d2e;--shadow-2xl:0px 2px 3px 0px #0d0d0d73;--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--code-bg:oklch(13% .015 260);--focus-highlight:oklch(70% .2 200)}.theme-solar-dusk.light{--background:oklch(98.85% .0057 84.5659);--foreground:oklch(36.6% .0251 49.6085);--card:oklch(96.86% .0091 78.2818);--card-foreground:oklch(36.6% .0251 49.6085);--popover:oklch(96.86% .0091 78.2818);--popover-foreground:oklch(36.6% .0251 49.6085);--primary:oklch(55.53% .1455 48.9975);--primary-foreground:oklch(100% 0 0);--secondary:oklch(82.76% .0752 74.44);--secondary-foreground:oklch(44.44% .0096 73.639);--muted:oklch(93.63% .0218 83.2637);--muted-foreground:oklch(55.34% .0116 58.0708);--accent:oklch(55% .12 229);--accent-foreground:oklch(100% 0 0);--destructive:oklch(44.37% .1613 26.8994);--destructive-foreground:oklch(100% 0 0);--border:oklch(88.66% .0404 89.6994);--input:oklch(88.66% .0404 89.6994);--ring:oklch(55.53% .1455 48.9975);--chart-1:oklch(55.53% .1455 48.9975);--chart-2:oklch(55.34% .0116 58.0708);--chart-3:oklch(55.38% .1207 66.4416);--chart-4:oklch(55.34% .0116 58.0708);--chart-5:oklch(68.06% .1423 75.834);--sidebar:oklch(93.63% .0218 83.2637);--sidebar-foreground:oklch(36.6% .0251 49.6085);--sidebar-primary:oklch(55.53% .1455 48.9975);--sidebar-primary-foreground:oklch(100% 0 0);--sidebar-accent:oklch(55.38% .1207 66.4416);--sidebar-accent-foreground:oklch(100% 0 0);--sidebar-border:oklch(88.66% .0404 89.6994);--sidebar-ring:oklch(55.53% .1455 48.9975);--font-sans:Oxanium, sans-serif;--font-serif:Merriweather, serif;--font-mono:Fira Code, monospace;--radius:.3rem;--shadow-2xs:0px 2px 3px 0px #4b3f3417;--shadow-xs:0px 2px 3px 0px #4b3f3417;--shadow-sm:0px 2px 3px 0px #4b3f342e, 0px 1px 2px -1px #4b3f342e;--shadow:0px 2px 3px 0px #4b3f342e, 0px 1px 2px -1px #4b3f342e;--shadow-md:0px 2px 3px 0px #4b3f342e, 0px 2px 4px -1px #4b3f342e;--shadow-lg:0px 2px 3px 0px #4b3f342e, 0px 4px 6px -1px #4b3f342e;--shadow-xl:0px 2px 3px 0px #4b3f342e, 0px 8px 10px -1px #4b3f342e;--shadow-2xl:0px 2px 3px 0px #4b3f3473;--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 260);--focus-highlight:oklch(70% .2 200)}.theme-terminal,.theme-terminal.light{--background:#000001;--foreground:#eee;--card:#010203;--card-foreground:#eee;--popover:#010203;--popover-foreground:#eee;--primary:#f99100;--primary-foreground:#fff;--secondary:#2e2e2e;--secondary-foreground:#eee;--muted:#202127;--muted-foreground:#9e9e9e;--accent:#00a1ff;--accent-foreground:#fff;--destructive:#f20024;--destructive-foreground:#fff;--border:#2e2e2e;--input:#010203;--ring:#00a1ff;--chart-1:#2dd047;--chart-2:#f20024;--chart-3:#821698;--chart-4:#f6d653;--chart-5:#00a1ff;--sidebar:#010203;--sidebar-foreground:#eee;--sidebar-primary:#f99100;--sidebar-primary-foreground:#fff;--sidebar-accent:#00a1ff;--sidebar-accent-foreground:#fff;--sidebar-border:#2e2e2e;--sidebar-ring:#00a1ff;--font-sans:system-ui;--font-serif:system-ui;--font-mono:system-ui;--radius:4px;--shadow-2xs:0px 2px 4px 0px #0000001a;--shadow-xs:0px 2px 4px 0px #0000001a;--shadow-sm:0px 2px 4px 0px #0003, 0px 1px 2px -1px #0003;--shadow:0px 2px 4px 0px #0003, 0px 1px 2px -1px #0003;--shadow-md:0px 2px 4px 0px #0003, 0px 2px 4px -1px #0003;--shadow-lg:0px 2px 4px 0px #0003, 0px 4px 6px -1px #0003;--shadow-xl:0px 2px 4px 0px #0003, 0px 8px 10px -1px #0003;--shadow-2xl:0px 2px 4px 0px #00000080;--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--code-bg:#000;--focus-highlight:oklch(70% .2 200)}.theme-terminal .bg-grid,.theme-terminal.light .bg-grid{background-image:linear-gradient(90deg,#00ff000a 1px,#0000 1px),linear-gradient(#00ff000a 1px,#0000 1px)}.theme-tokyo-night{--background:#24283b;--foreground:#c0caf5;--card:#1d202f;--card-foreground:#c0caf5;--popover:#1d202f;--popover-foreground:#c0caf5;--primary:#7aa2f7;--primary-foreground:#1d202f;--secondary:#414868;--secondary-foreground:#c0caf5;--muted:#343a52;--muted-foreground:#787c99;--accent:#7dcfff;--accent-foreground:#1d202f;--destructive:#f7768e;--destructive-foreground:#1d202f;--border:#414868;--input:#343a52;--ring:#7aa2f7;--success:#9ece6a;--success-foreground:#1d202f;--warning:#e0af68;--warning-foreground:#1d202f;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, monospace;--radius:.625rem;--code-bg:#1a1b2e;--focus-highlight:#7dcfff}.theme-tokyo-night.light{--background:#e1e2e7;--foreground:#3760bf;--card:#e9e9ed;--card-foreground:#3760bf;--popover:#e9e9ed;--popover-foreground:#3760bf;--primary:#2e7de9;--primary-foreground:#e1e2e7;--secondary:#a1a6c5;--secondary-foreground:#3760bf;--muted:#d0d3e1;--muted-foreground:#848cb5;--accent:#007197;--accent-foreground:#e1e2e7;--destructive:#f52a65;--destructive-foreground:#e1e2e7;--border:#a1a6c5;--input:#d0d3e1;--ring:#2e7de9;--success:#587539;--success-foreground:#e1e2e7;--warning:#8c6c3e;--warning-foreground:#e1e2e7;--code-bg:#d5d6db;--focus-highlight:#007197}.theme-tinacious,.theme-tinacious.light{--background:#f8f8ff;--foreground:#1d1d26;--card:#fff;--card-foreground:#1d1d26;--popover:#fff;--popover-foreground:#1d1d26;--primary:#00cbff;--primary-foreground:#fff;--secondary:#cbcbf0;--secondary-foreground:#1d1d26;--muted:#d5d6f3;--muted-foreground:#636667;--accent:#00ceca;--accent-foreground:#1d1d26;--destructive:#f39;--destructive-foreground:#fff;--border:#d5d6f3;--input:#d5d6f3;--ring:#00cbff;--chart-1:#f39;--chart-2:#00d364;--chart-3:#fc6;--chart-4:#c6f;--chart-5:#00ceca;--sidebar:#f0f0f8;--sidebar-foreground:#1d1d26;--sidebar-primary:#00cbff;--sidebar-primary-foreground:#fff;--sidebar-accent:#00ceca;--sidebar-accent-foreground:#1d1d26;--sidebar-border:#d5d6f3;--sidebar-ring:#00cbff;--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;--radius:.625rem;--shadow-2xs:0 1px 2px 0 #0000000d;--shadow-xs:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--shadow-sm:0 2px 4px -1px #0000001a, 0 1px 2px -1px #0000001a;--shadow:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--shadow-md:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--shadow-lg:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--shadow-xl:0 25px 50px -12px #00000040;--shadow-2xl:0 35px 60px -15px #0000004d;--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:#f0f0f8;--focus-highlight:oklch(70% .2 200)}.theme-cursor{--background:#181818;--foreground:#e4e4e4;--card:#1e1e1e;--card-foreground:#e4e4e4;--popover:#141414;--popover-foreground:#e4e4e4;--primary:#81a1c1;--primary-foreground:#141414;--secondary:#2a2a2a;--secondary-foreground:#e4e4e4;--muted:#252525;--muted-foreground:#a0a0a0;--accent:#88c0d0;--accent-foreground:#141414;--destructive:#e34671;--destructive-foreground:#e4e4e4;--border:#333;--input:#252525;--ring:#81a1c1;--success:#3fa266;--success-foreground:#141414;--warning:#d2943e;--warning-foreground:#141414;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;--radius:.625rem;--code-bg:#141414;--focus-highlight:#88c0d0}.theme-cursor.light{--background:#fcfcfc;--foreground:#141414;--card:#f3f3f3;--card-foreground:#141414;--popover:#f3f3f3;--popover-foreground:#141414;--primary:#3c7cab;--primary-foreground:#fcfcfc;--secondary:#e8e8e8;--secondary-foreground:#141414;--muted:#ededed;--muted-foreground:#6a6a6a;--accent:#4c7f8c;--accent-foreground:#fcfcfc;--destructive:#cf2d56;--destructive-foreground:#fcfcfc;--border:#ddd;--input:#ededed;--ring:#3c7cab;--success:#1f8a65;--success-foreground:#fcfcfc;--warning:#c08532;--warning-foreground:#141414;--code-bg:#f3f3f3;--focus-highlight:#3c7cab}.theme-cursor-hc,.theme-cursor-hc.light{--background:#0a0a0a;--foreground:#d8dee9;--card:#1a1a1a;--card-foreground:#eceff4;--popover:#1a1a1a;--popover-foreground:#eceff4;--primary:#88c0d0;--primary-foreground:#000;--secondary:#434c5e;--secondary-foreground:#eceff4;--muted:#2a2a2a;--muted-foreground:#ccc;--accent:#ebcb8b;--accent-foreground:#000;--destructive:#bf616a;--destructive-foreground:#eceff4;--border:#404040;--input:#2a2a2a;--ring:#88c0d0;--success:#a3be8c;--success-foreground:#000;--warning:#ebcb8b;--warning-foreground:#000;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;--radius:.625rem;--code-bg:#1a1a1a;--focus-highlight:#88c0d0}.theme-cursor-midnight,.theme-cursor-midnight.light{--background:#1e2127;--foreground:#d8dee9;--card:#272c36;--card-foreground:#d8dee9;--popover:#20242c;--popover-foreground:#d8dee9;--primary:#88c0d0;--primary-foreground:#1d2128;--secondary:#434c5e;--secondary-foreground:#d8dee9;--muted:#272c36;--muted-foreground:#7b88a1;--accent:#8fbcbb;--accent-foreground:#1d2128;--destructive:#bf616a;--destructive-foreground:#eceff4;--border:#3a4050;--input:#272c36;--ring:#88c0d0;--success:#a3be8c;--success-foreground:#1d2128;--warning:#ebcb8b;--warning-foreground:#1d2128;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;--radius:.625rem;--code-bg:#191c22;--focus-highlight:#8fbcbb}.theme-everforest{--background:#2d353b;--foreground:#d3c6aa;--card:#343f44;--card-foreground:#d3c6aa;--popover:#3d484d;--popover-foreground:#d3c6aa;--primary:#a7c080;--primary-foreground:#2d353b;--secondary:#475258;--secondary-foreground:#d3c6aa;--muted:#475258;--muted-foreground:#859289;--accent:#83c092;--accent-foreground:#2d353b;--destructive:#e67e80;--destructive-foreground:#2d353b;--border:#475258;--input:#343f44;--ring:#a7c080;--success:#83c092;--success-foreground:#2d353b;--warning:#dbbc7f;--warning-foreground:#2d353b;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#232a2e;--focus-highlight:#7fbbb3}.theme-everforest.light{--background:#fdf6e3;--foreground:#5c6a72;--card:#f4f0d9;--card-foreground:#5c6a72;--popover:#efebd4;--popover-foreground:#5c6a72;--primary:#8da101;--primary-foreground:#fdf6e3;--secondary:#e6e2cc;--secondary-foreground:#5c6a72;--muted:#e6e2cc;--muted-foreground:#939f91;--accent:#35a77c;--accent-foreground:#fdf6e3;--destructive:#f85552;--destructive-foreground:#fdf6e3;--border:#e6e2cc;--input:#f4f0d9;--ring:#8da101;--success:#35a77c;--success-foreground:#fdf6e3;--warning:#dfa000;--warning-foreground:#fdf6e3;--code-bg:#efebd4;--focus-highlight:#3a94c5}.theme-everforest-hard{--background:#272e33;--foreground:#d3c6aa;--card:#2e383c;--card-foreground:#d3c6aa;--popover:#374145;--popover-foreground:#d3c6aa;--primary:#a7c080;--primary-foreground:#272e33;--secondary:#414b50;--secondary-foreground:#d3c6aa;--muted:#414b50;--muted-foreground:#859289;--accent:#83c092;--accent-foreground:#272e33;--destructive:#e67e80;--destructive-foreground:#272e33;--border:#414b50;--input:#2e383c;--ring:#a7c080;--success:#83c092;--success-foreground:#272e33;--warning:#dbbc7f;--warning-foreground:#272e33;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#1e2326;--focus-highlight:#7fbbb3}.theme-everforest-hard.light{--background:#fffbef;--foreground:#5c6a72;--card:#f8f5e4;--card-foreground:#5c6a72;--popover:#f2efdf;--popover-foreground:#5c6a72;--primary:#8da101;--primary-foreground:#fffbef;--secondary:#edeada;--secondary-foreground:#5c6a72;--muted:#edeada;--muted-foreground:#939f91;--accent:#35a77c;--accent-foreground:#fffbef;--destructive:#f85552;--destructive-foreground:#fffbef;--border:#edeada;--input:#f8f5e4;--ring:#8da101;--success:#35a77c;--success-foreground:#fffbef;--warning:#dfa000;--warning-foreground:#fffbef;--code-bg:#f2efdf;--focus-highlight:#3a94c5}.theme-everforest-soft{--background:#333c43;--foreground:#d3c6aa;--card:#3a464c;--card-foreground:#d3c6aa;--popover:#434f55;--popover-foreground:#d3c6aa;--primary:#a7c080;--primary-foreground:#333c43;--secondary:#4d5960;--secondary-foreground:#d3c6aa;--muted:#4d5960;--muted-foreground:#859289;--accent:#83c092;--accent-foreground:#333c43;--destructive:#e67e80;--destructive-foreground:#333c43;--border:#4d5960;--input:#3a464c;--ring:#a7c080;--success:#83c092;--success-foreground:#333c43;--warning:#dbbc7f;--warning-foreground:#333c43;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#293136;--focus-highlight:#7fbbb3}.theme-everforest-soft.light{--background:#f3ead3;--foreground:#5c6a72;--card:#eae4ca;--card-foreground:#5c6a72;--popover:#e5dfc5;--popover-foreground:#5c6a72;--primary:#8da101;--primary-foreground:#f3ead3;--secondary:#ddd8be;--secondary-foreground:#5c6a72;--muted:#ddd8be;--muted-foreground:#939f91;--accent:#35a77c;--accent-foreground:#f3ead3;--destructive:#f85552;--destructive-foreground:#f3ead3;--border:#ddd8be;--input:#eae4ca;--ring:#8da101;--success:#35a77c;--success-foreground:#f3ead3;--warning:#dfa000;--warning-foreground:#f3ead3;--code-bg:#e5dfc5;--focus-highlight:#3a94c5}.theme-nord,.theme-nord.light{--background:#2e3440;--foreground:#d8dee9;--card:#3b4252;--card-foreground:#d8dee9;--popover:#3b4252;--popover-foreground:#d8dee9;--primary:#88c0d0;--primary-foreground:#2e3440;--secondary:#434c5e;--secondary-foreground:#d8dee9;--muted:#3b4252;--muted-foreground:#616e88;--accent:#81a1c1;--accent-foreground:#2e3440;--destructive:#bf616a;--destructive-foreground:#2e3440;--border:#434c5e;--input:#3b4252;--ring:#88c0d0;--success:#a3be8c;--success-foreground:#2e3440;--warning:#ebcb8b;--warning-foreground:#2e3440;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#4c566a;--focus-highlight:#88c0d0}.theme-solarized{--background:#002b36;--foreground:#839496;--card:#073642;--card-foreground:#839496;--popover:#073642;--popover-foreground:#839496;--primary:#268bd2;--primary-foreground:#002b36;--secondary:#003847;--secondary-foreground:#93a1a1;--muted:#003847;--muted-foreground:#586e75;--accent:#2aa198;--accent-foreground:#002b36;--destructive:#dc322f;--destructive-foreground:#002b36;--border:#094e5a;--input:#003847;--ring:#268bd2;--success:#859900;--success-foreground:#002b36;--warning:#b58900;--warning-foreground:#002b36;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#073642;--focus-highlight:#268bd2}.theme-solarized.light{--background:#fdf6e3;--foreground:#657b83;--card:#eee8d5;--card-foreground:#657b83;--popover:#eee8d5;--popover-foreground:#657b83;--primary:#268bd2;--primary-foreground:#fdf6e3;--secondary:#eee8d5;--secondary-foreground:#586e75;--muted:#ddd6c1;--muted-foreground:#93a1a1;--accent:#2aa198;--accent-foreground:#002b36;--destructive:#dc322f;--destructive-foreground:#fdf6e3;--border:#ddd6c1;--input:#ddd6c1;--ring:#268bd2;--success:#859900;--success-foreground:#fdf6e3;--warning:#b58900;--warning-foreground:#fdf6e3;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#eee8d5;--focus-highlight:#268bd2}.theme-github{--background:#24292e;--foreground:#e1e4e8;--card:#1f2428;--card-foreground:#e1e4e8;--popover:#1f2428;--popover-foreground:#e1e4e8;--primary:#58a6ff;--primary-foreground:#24292e;--secondary:#2f363d;--secondary-foreground:#d1d5da;--muted:#2f363d;--muted-foreground:#6a737d;--accent:#79b8ff;--accent-foreground:#24292e;--destructive:#f97583;--destructive-foreground:#24292e;--border:#1b1f23;--input:#2f363d;--ring:#58a6ff;--success:#28a745;--success-foreground:#24292e;--warning:#ffea7f;--warning-foreground:#24292e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2f363d;--focus-highlight:#58a6ff}.theme-github.light{--background:#fff;--foreground:#24292e;--card:#f6f8fa;--card-foreground:#24292e;--popover:#f6f8fa;--popover-foreground:#24292e;--primary:#0366d6;--primary-foreground:#fff;--secondary:#f6f8fa;--secondary-foreground:#586069;--muted:#fafbfc;--muted-foreground:#6a737d;--accent:#0366d6;--accent-foreground:#fff;--destructive:#cb2431;--destructive-foreground:#fff;--border:#e1e4e8;--input:#fafbfc;--ring:#2188ff;--success:#28a745;--success-foreground:#fff;--warning:#f9c513;--warning-foreground:#24292e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#f6f8fa;--focus-highlight:#2188ff}.theme-one-dark-pro,.theme-one-dark-pro.light{--background:#282c34;--foreground:#abb2bf;--card:#21252b;--card-foreground:#abb2bf;--popover:#21252b;--popover-foreground:#abb2bf;--primary:#61afef;--primary-foreground:#282c34;--secondary:#21252b;--secondary-foreground:#abb2bf;--muted:#1d1f23;--muted-foreground:#5c6370;--accent:#c678dd;--accent-foreground:#282c34;--destructive:#e06c75;--destructive-foreground:#282c34;--border:#3e4452;--input:#1d1f23;--ring:#528bff;--success:#98c379;--success-foreground:#282c34;--warning:#e5c07b;--warning-foreground:#282c34;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2c313a;--focus-highlight:#528bff}.theme-night-owl,.theme-night-owl.light{--background:#011627;--foreground:#d6deeb;--card:#0b2942;--card-foreground:#d6deeb;--popover:#0b2942;--popover-foreground:#d6deeb;--primary:#7e57c2;--primary-foreground:#fff;--secondary:#0b253a;--secondary-foreground:#89a4bb;--muted:#0b253a;--muted-foreground:#4b6479;--accent:#82aaff;--accent-foreground:#011627;--destructive:#ef5350;--destructive-foreground:#011627;--border:#5f7e97;--input:#0b253a;--ring:#7e57c2;--success:#addb67;--success-foreground:#011627;--warning:#ffcb6b;--warning-foreground:#011627;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#0b2942;--focus-highlight:#7e57c2}.theme-ayu-dark,.theme-ayu-dark.light{--background:#10141c;--foreground:#bfbdb6;--card:#141821;--card-foreground:#bfbdb6;--popover:#141821;--popover-foreground:#bfbdb6;--primary:#e6b450;--primary-foreground:#805600;--secondary:#0d1017;--secondary-foreground:#bfbdb6;--muted:#141821;--muted-foreground:#6c7380;--accent:#73b8ff;--accent-foreground:#10141c;--destructive:#d95757;--destructive-foreground:#10141c;--border:#1b1f29;--input:#10141c;--ring:#e6b450;--success:#70bf56;--success-foreground:#10141c;--warning:#fdb04c;--warning-foreground:#10141c;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#141821;--focus-highlight:#e6b450}.theme-poimandres,.theme-poimandres.light{--background:#1b1e28;--foreground:#a6accd;--card:#303340;--card-foreground:#a6accd;--popover:#303340;--popover-foreground:#a6accd;--primary:#add7ff;--primary-foreground:#1b1e28;--secondary:#252934;--secondary-foreground:#767c9d;--muted:#252934;--muted-foreground:#767c9d;--accent:#5de4c7;--accent-foreground:#1b1e28;--destructive:#d0679d;--destructive-foreground:#1b1e28;--border:#3b3f4f;--input:#252934;--ring:#add7ff;--success:#5de4c7;--success-foreground:#1b1e28;--warning:#fffac2;--warning-foreground:#1b1e28;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#252934;--focus-highlight:#add7ff}.theme-material{--background:#263238;--foreground:#eff;--card:#2e3c43;--card-foreground:#eff;--popover:#263238;--popover-foreground:#eff;--primary:#80cbc4;--primary-foreground:#263238;--secondary:#37474f;--secondary-foreground:#6c8692;--muted:#303c41;--muted-foreground:#546e7a;--accent:#c3e88d;--accent-foreground:#263238;--destructive:#f07178;--destructive-foreground:#263238;--border:#37474f;--input:#303c41;--ring:#80cbc4;--success:#c3e88d;--success-foreground:#263238;--warning:#ffcb6b;--warning-foreground:#263238;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2d3b41;--focus-highlight:#80cbc4}.theme-material.light{--background:#fafafa;--foreground:#546e7a;--card:#f0f4f5;--card-foreground:#546e7a;--popover:#fafafa;--popover-foreground:#546e7a;--primary:#80cbc4;--primary-foreground:#263238;--secondary:#e8edef;--secondary-foreground:#758a95;--muted:#eee;--muted-foreground:#b0bec5;--accent:#39adb5;--accent-foreground:#fafafa;--destructive:#e53935;--destructive-foreground:#fafafa;--border:#b0bec5;--input:#eee;--ring:#80cbc4;--success:#91b859;--success-foreground:#fafafa;--warning:#e2931d;--warning-foreground:#fafafa;--code-bg:#eee;--focus-highlight:#80cbc4}.theme-vitesse{--background:#121212;--foreground:#dbd7ca;--card:#181818;--card-foreground:#dbd7ca;--popover:#181818;--popover-foreground:#dbd7ca;--primary:#4d9375;--primary-foreground:#121212;--secondary:#1e1e1e;--secondary-foreground:#bfbaaa;--muted:#181818;--muted-foreground:#758575;--accent:#e6cc77;--accent-foreground:#121212;--destructive:#cb7676;--destructive-foreground:#121212;--border:#252525;--input:#181818;--ring:#4d9375;--success:#4d9375;--success-foreground:#121212;--warning:#e6cc77;--warning-foreground:#121212;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#181818;--focus-highlight:#4d9375}.theme-vitesse.light{--background:#fff;--foreground:#393a34;--card:#f7f7f7;--card-foreground:#393a34;--popover:#fff;--popover-foreground:#393a34;--primary:#1c6b48;--primary-foreground:#fff;--secondary:#f0f0f0;--secondary-foreground:#4e4f47;--muted:#f7f7f7;--muted-foreground:#a0ada0;--accent:#bda437;--accent-foreground:#fff;--destructive:#ab5959;--destructive-foreground:#fff;--border:#f0f0f0;--input:#f7f7f7;--ring:#1c6b48;--success:#1e754f;--success-foreground:#fff;--warning:#bda437;--warning-foreground:#fff;--code-bg:#f0f0f0;--focus-highlight:#1c6b48}.theme-vesper{--background:#101010;--foreground:#fff;--card:#161616;--card-foreground:#fff;--popover:#161616;--popover-foreground:#fff;--primary:#ffc799;--primary-foreground:#101010;--secondary:#1c1c1c;--secondary-foreground:#a0a0a0;--muted:#1c1c1c;--muted-foreground:#8b8b8b;--accent:#99ffe4;--accent-foreground:#101010;--destructive:#ff8080;--destructive-foreground:#101010;--border:#282828;--input:#1c1c1c;--ring:#ffc799;--success:#99ffe4;--success-foreground:#101010;--warning:#ffc799;--warning-foreground:#101010;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#1c1c1c;--focus-highlight:#ffc799}.theme-vesper.light{--background:#101010;--foreground:#fff;--card:#161616;--card-foreground:#fff;--popover:#161616;--popover-foreground:#fff;--primary:#ffc799;--primary-foreground:#101010;--secondary:#1c1c1c;--secondary-foreground:#a0a0a0;--muted:#1c1c1c;--muted-foreground:#8b8b8b;--accent:#99ffe4;--accent-foreground:#101010;--destructive:#ff8080;--destructive-foreground:#101010;--border:#282828;--input:#1c1c1c;--ring:#ffc799;--success:#99ffe4;--success-foreground:#101010;--warning:#ffc799;--warning-foreground:#101010;--code-bg:#1c1c1c;--focus-highlight:#ffc799}.theme-andromeeda,.theme-andromeeda.light{--background:#23262e;--foreground:#d5ced9;--card:#2b303b;--card-foreground:#d5ced9;--popover:#2b303b;--popover-foreground:#d5ced9;--primary:#00e8c6;--primary-foreground:#23262e;--secondary:#373941;--secondary-foreground:#d5ced9;--muted:#333844;--muted-foreground:#746f77;--accent:#c74ded;--accent-foreground:#23262e;--destructive:#fc644d;--destructive-foreground:#23262e;--border:#363c49;--input:#363c49;--ring:#00e8c6;--success:#96e072;--success-foreground:#23262e;--warning:#ffe66d;--warning-foreground:#23262e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2e323d;--focus-highlight:#00e8c6}.theme-aurora-x,.theme-aurora-x.light{--background:#07090f;--foreground:#a8beff;--card:#15182b;--card-foreground:#c7d5ff;--popover:#15182b;--popover-foreground:#c7d5ff;--primary:#86a5ff;--primary-foreground:#07090f;--secondary:#262e47;--secondary-foreground:#c7d5ff;--muted:#15182b;--muted-foreground:#546e7a;--accent:#c792ea;--accent-foreground:#07090f;--destructive:#dd5073;--destructive-foreground:#07090f;--border:#262e47;--input:#262e47;--ring:#86a5ff;--success:#63eb90;--success-foreground:#07090f;--warning:#ffcb6b;--warning-foreground:#07090f;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#0c0e19;--focus-highlight:#86a5ff}.theme-dark-plus{--background:#1e1e1e;--foreground:#d4d4d4;--card:#252526;--card-foreground:#d4d4d4;--popover:#252526;--popover-foreground:#d4d4d4;--primary:#007acc;--primary-foreground:#fff;--secondary:#383b3d;--secondary-foreground:#d4d4d4;--muted:#303031;--muted-foreground:#858585;--accent:#4ec9b0;--accent-foreground:#1e1e1e;--destructive:#f44747;--destructive-foreground:#fff;--border:#454545;--input:#454545;--ring:#007acc;--success:#369432;--success-foreground:#fff;--warning:#cca700;--warning-foreground:#1e1e1e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2d2d2d;--focus-highlight:#007acc}.theme-dark-plus.light{--background:#fff;--foreground:#000;--card:#f3f3f3;--card-foreground:#000;--popover:#f3f3f3;--popover-foreground:#000;--primary:#007acc;--primary-foreground:#fff;--secondary:#e8e8e8;--secondary-foreground:#000;--muted:#e5ebf1;--muted-foreground:#6e7781;--accent:#267f99;--accent-foreground:#fff;--destructive:#cd3131;--destructive-foreground:#fff;--border:#d4d4d4;--input:#d4d4d4;--ring:#007acc;--success:#369432;--success-foreground:#fff;--warning:#bf8803;--warning-foreground:#fff;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#f3f3f3;--focus-highlight:#007acc}.theme-houston,.theme-houston.light{--background:#17191e;--foreground:#eef0f9;--card:#23262d;--card-foreground:#eef0f9;--popover:#23262d;--popover-foreground:#eef0f9;--primary:#4bf3c8;--primary-foreground:#17191e;--secondary:#343841;--secondary-foreground:#eef0f9;--muted:#2a2d34;--muted-foreground:#545864;--accent:#54b9ff;--accent-foreground:#17191e;--destructive:#f4587e;--destructive-foreground:#17191e;--border:#343841;--input:#343841;--ring:#4bf3c8;--success:#4bf3c8;--success-foreground:#17191e;--warning:#ffd493;--warning-foreground:#17191e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#23262d;--focus-highlight:#4bf3c8}.theme-laserwave,.theme-laserwave.light{--background:#27212e;--foreground:#fff;--card:#3a3242;--card-foreground:#fff;--popover:#3a3242;--popover-foreground:#fff;--primary:#eb64b9;--primary-foreground:#27212e;--secondary:#3e3549;--secondary-foreground:#fff;--muted:#3a3242;--muted-foreground:#91889b;--accent:#40b4c4;--accent-foreground:#27212e;--destructive:#ff3e7b;--destructive-foreground:#27212e;--border:#4a4055;--input:#4a4055;--ring:#eb64b9;--success:#74dfc4;--success-foreground:#27212e;--warning:#ffe261;--warning-foreground:#27212e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#3e3549;--focus-highlight:#eb64b9}.theme-min{--background:#1f1f1f;--foreground:#b392f0;--card:#242424;--card-foreground:#b392f0;--popover:#242424;--popover-foreground:#b392f0;--primary:#b392f0;--primary-foreground:#1f1f1f;--secondary:#2a2a2a;--secondary-foreground:#bbb;--muted:#2a2a2a;--muted-foreground:#6b737c;--accent:#79b8ff;--accent-foreground:#1f1f1f;--destructive:#ff7a84;--destructive-foreground:#1f1f1f;--border:#383838;--input:#383838;--ring:#b392f0;--success:#3a632a;--success-foreground:#fff;--warning:#cd9731;--warning-foreground:#1f1f1f;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2a2a2a;--focus-highlight:#b392f0}.theme-min.light{--background:#fff;--foreground:#24292e;--card:#f6f6f6;--card-foreground:#24292e;--popover:#f6f6f6;--popover-foreground:#24292e;--primary:#6f42c1;--primary-foreground:#fff;--secondary:#eee;--secondary-foreground:#212121;--muted:#f6f6f6;--muted-foreground:#c2c3c5;--accent:#1976d2;--accent-foreground:#fff;--destructive:#d32f2f;--destructive-foreground:#fff;--border:#e9e9e9;--input:#e9e9e9;--ring:#6f42c1;--success:#7c0;--success-foreground:#fff;--warning:#f29718;--warning-foreground:#fff;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#f3f3f3;--focus-highlight:#6f42c1}.theme-one-light,.theme-one-light.light{--background:#fafafa;--foreground:#383a42;--card:#eaeaeb;--card-foreground:#383a42;--popover:#eaeaeb;--popover-foreground:#383a42;--primary:#526fff;--primary-foreground:#fff;--secondary:#e5e5e6;--secondary-foreground:#383a42;--muted:#eaeaeb;--muted-foreground:#a0a1a7;--accent:#4078f2;--accent-foreground:#fff;--destructive:#e45649;--destructive-foreground:#fff;--border:#dbdbdc;--input:#dbdbdc;--ring:#526fff;--success:#3bba54;--success-foreground:#fff;--warning:#c18401;--warning-foreground:#fff;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#e5e5e6;--focus-highlight:#526fff}.theme-plastic,.theme-plastic.light{--background:#21252b;--foreground:#a9b2c3;--card:#181a1f;--card-foreground:#a9b2c3;--popover:#181a1f;--popover-foreground:#a9b2c3;--primary:#1085ff;--primary-foreground:#fff;--secondary:#0d1117;--secondary-foreground:#c6ccd7;--muted:#181a1f;--muted-foreground:#5f6672;--accent:#61afef;--accent-foreground:#0d1117;--destructive:#d74e42;--destructive-foreground:#fff;--border:#0d1117;--input:#0d1117;--ring:#1085ff;--success:#98c379;--success-foreground:#0d1117;--warning:#e5c07b;--warning-foreground:#0d1117;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#0d1117;--focus-highlight:#1085ff}.theme-red,.theme-red.light{--background:#390000;--foreground:#f8f8f8;--card:#490000;--card-foreground:#f8f8f8;--popover:#490000;--popover-foreground:#f8f8f8;--primary:#c33;--primary-foreground:#fff;--secondary:#580000;--secondary-foreground:#f8f8f8;--muted:#580000;--muted-foreground:#e7c0c0;--accent:#ffd0aa;--accent-foreground:#390000;--destructive:#f12727;--destructive-foreground:#fff;--border:#600;--input:#580000;--ring:#c33;--success:#41a83e;--success-foreground:#390000;--warning:#fec758;--warning-foreground:#390000;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#580000;--focus-highlight:#c33}.theme-slack{--background:#222;--foreground:#e6e6e6;--card:#292929;--card-foreground:#e6e6e6;--popover:#292929;--popover-foreground:#e6e6e6;--primary:#0077b5;--primary-foreground:#fff;--secondary:#141414;--secondary-foreground:#e6e6e6;--muted:#141414;--muted-foreground:#6e7681;--accent:#1d978d;--accent-foreground:#fff;--destructive:#f44747;--destructive-foreground:#fff;--border:#3a3d41;--input:#292929;--ring:#0077b5;--success:#6a9955;--success-foreground:#fff;--warning:#cd9731;--warning-foreground:#222;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#141414;--focus-highlight:#0077b5}.theme-slack.light{--background:#fff;--foreground:#000;--card:#f3f3f3;--card-foreground:#000;--popover:#f3f3f3;--popover-foreground:#000;--primary:#5899c5;--primary-foreground:#fff;--secondary:#eee;--secondary-foreground:#000;--muted:#f3f3f3;--muted-foreground:#6e7681;--accent:#5899c5;--accent-foreground:#fff;--destructive:#f44c5e;--destructive-foreground:#fff;--border:#dcdedf;--input:#dcdedf;--ring:#5899c5;--success:#91b859;--success-foreground:#161f26;--warning:#ffb62c;--warning-foreground:#161f26;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#eee;--focus-highlight:#5899c5}.theme-snazzy-light,.theme-snazzy-light.light{--background:#fafbfc;--foreground:#565869;--card:#f3f4f5;--card-foreground:#565869;--popover:#f3f4f5;--popover-foreground:#565869;--primary:#09a1ed;--primary-foreground:#fff;--secondary:#e9eaeb;--secondary-foreground:#565869;--muted:#e9eaeb;--muted-foreground:#9194a2;--accent:#2dae58;--accent-foreground:#fff;--destructive:#ff5c56;--destructive-foreground:#fff;--border:#dedfe0;--input:#e9eaeb;--ring:#09a1ed;--success:#2dae58;--success-foreground:#fff;--warning:#cf9c00;--warning-foreground:#fff;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#e9eaeb;--focus-highlight:#09a1ed}.theme-vitesse-black,.theme-vitesse-black.light{--background:#000;--foreground:#dbd7ca;--card:#121212;--card-foreground:#dbd7ca;--popover:#121212;--popover-foreground:#dbd7ca;--primary:#4d9375;--primary-foreground:#000;--secondary:#191919;--secondary-foreground:#bfbaaa;--muted:#121212;--muted-foreground:#758575;--accent:#6394bf;--accent-foreground:#000;--destructive:#cb7676;--destructive-foreground:#000;--border:#191919;--input:#191919;--ring:#4d9375;--success:#4d9375;--success-foreground:#000;--warning:#e6cc77;--warning-foreground:#000;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#121212;--focus-highlight:#4d9375}.theme-simple{--background:oklch(14.1% .005 285.823);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% .006 285.885);--card-foreground:oklch(98.5% 0 0);--popover:oklch(22.5% .006 285.885);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(98.5% 0 0);--primary-foreground:oklch(21% .006 285.885);--secondary:oklch(27.4% .006 286.033);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(16% .005 285.823);--muted-foreground:oklch(70.5% .015 286.067);--accent:oklch(27.4% .006 286.033);--accent-foreground:oklch(98.5% 0 0);--annotation-comment:oklch(62% .19 256);--destructive:oklch(39.6% .141 25.723);--destructive-foreground:oklch(63.7% .237 25.331);--border:oklch(27.4% .006 286.033);--input:oklch(27.4% .006 286.033);--ring:oklch(44.2% .017 285.786);--success:oklch(62% .194 149.214);--success-foreground:oklch(98.5% 0 0);--warning:oklch(75% .15 85);--warning-foreground:oklch(21% .006 285.885);--surface-0:oklch(21% .006 286.033);--surface-1:oklch(24.5% .006 286.033);--surface-2:oklch(28% .006 286.033);--sidebar:oklch(21% .006 285.885);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(27.4% .006 286.033);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(27.4% .006 286.033);--sidebar-ring:oklch(44.2% .017 285.786);--font-sans:"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif;--font-mono:"Geist Mono Variable", "SF Mono", ui-monospace, monospace;--radius:.625rem;--code-bg:oklch(24.5% .006 286.033);--brand:oklch(75% .2 150);--brand-dev:oklch(80% .17 85);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439)}.theme-simple.light{--background:oklch(100% 0 0);--foreground:oklch(14.1% .005 285.823);--card:oklch(100% 0 0);--card-foreground:oklch(14.1% .005 285.823);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.1% .005 285.823);--primary:oklch(21% .006 285.885);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(96.7% .001 286.375);--secondary-foreground:oklch(21% .006 285.885);--muted:oklch(96.7% .001 286.375);--muted-foreground:oklch(55.2% .016 285.938);--accent:oklch(96.7% .001 286.375);--accent-foreground:oklch(21% .006 285.885);--annotation-comment:oklch(52% .2 256);--destructive:oklch(57.7% .245 27.325);--destructive-foreground:oklch(57.7% .245 27.325);--border:oklch(92% .004 286.32);--input:oklch(92% .004 286.32);--ring:oklch(87.1% .006 286.286);--success:oklch(62% .194 149.214);--success-foreground:oklch(98.5% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(14.1% .005 285.823);--surface-0:oklch(96.7% .001 286.375);--surface-1:oklch(94.5% .002 286.375);--surface-2:oklch(92.5% .003 286.375);--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.1% .005 285.823);--sidebar-primary:oklch(21% .006 285.885);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(96.7% .001 286.375);--sidebar-accent-foreground:oklch(21% .006 285.885);--sidebar-border:oklch(92% .004 286.32);--sidebar-ring:oklch(87.1% .006 286.286);--code-bg:oklch(96.7% .001 286.375);--brand:oklch(68% .2 150);--brand-dev:oklch(75% .17 85);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08)}.theme-neutral{--background:oklch(14.1% .005 285.823);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% .006 285.885);--card-foreground:oklch(98.5% 0 0);--popover:oklch(22.5% .006 285.885);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(98.5% 0 0);--primary-foreground:oklch(21% .006 285.885);--secondary:oklch(27.4% .006 286.033);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(16% .005 285.823);--muted-foreground:oklch(70.5% .015 286.067);--accent:oklch(27.4% .006 286.033);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(39.6% .141 25.723);--destructive-foreground:oklch(63.7% .237 25.331);--border:oklch(27.4% .006 286.033);--input:oklch(27.4% .006 286.033);--ring:oklch(44.2% .017 285.786);--success:oklch(62% .194 149.214);--success-foreground:oklch(98.5% 0 0);--warning:oklch(75% .15 85);--warning-foreground:oklch(21% .006 285.885);--font-sans:"Inter", system-ui, sans-serif;--font-mono:"Geist Mono", "SF Mono", Consolas, monospace;--radius:.625rem;--code-bg:oklch(20.5% .006 285.885)}.theme-neutral.light{--background:oklch(100% 0 0);--foreground:oklch(14.1% .005 285.823);--card:oklch(100% 0 0);--card-foreground:oklch(14.1% .005 285.823);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.1% .005 285.823);--primary:oklch(21% .006 285.885);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(96.7% .001 286.375);--secondary-foreground:oklch(21% .006 285.885);--muted:oklch(96.7% .001 286.375);--muted-foreground:oklch(55.2% .016 285.938);--accent:oklch(96.7% .001 286.375);--accent-foreground:oklch(21% .006 285.885);--destructive:oklch(57.7% .245 27.325);--destructive-foreground:oklch(57.7% .245 27.325);--border:oklch(92% .004 286.32);--input:oklch(92% .004 286.32);--ring:oklch(87.1% .006 286.286);--success:oklch(62% .194 149.214);--success-foreground:oklch(14.1% .005 285.823);--warning:oklch(55% .18 85);--warning-foreground:oklch(14.1% .005 285.823);--code-bg:oklch(96.7% .001 286.375)}.plannotator-print pre,.plannotator-print pre[class]{color:#1a1a1a!important;background:#f5f5f5!important;border:1px solid #ccc!important;border-left:3px solid #888!important}.plannotator-print pre code,.plannotator-print code.hljs,.plannotator-print pre code.hljs,.plannotator-print .hljs{color:#1a1a1a!important;background:0 0!important}.plannotator-print pre span,.plannotator-print pre code span,.plannotator-print .hljs span,.plannotator-print [class*=hljs-]{color:#1a1a1a!important;background:0 0!important;font-style:normal!important;text-decoration:none!important}.plannotator-print code{color:#1a1a1a!important;background:#e8e8e8!important;border:1px solid #ccc!important}.plannotator-print pre code{background:0 0!important;border:none!important}@media print{*{transition:none!important;animation:none!important}@page{size:A4;margin:.75in}html,body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;font-size:11pt;line-height:1.6;color:#1a1a1a!important;background:#fff!important;height:auto!important;overflow:visible!important}header,aside,.annotation-toolbar,.fixed,[data-resize-handle]{display:none!important}[data-print-hide]{margin:0!important;padding:0!important;display:none!important}.sidebar-tab-flag,pre .absolute,.group>button.absolute,button[title=Settings],button[title="Toggle theme"],button[title="Add global comment"],button[title="Copy plan"],button[title="Copy file"]{display:none!important}[data-print-region=root]{height:auto!important;overflow:visible!important}[data-print-region=content]{overflow:visible!important}main,[data-print-region=document]{background:#fff!important;width:100%!important;padding:0!important;overflow:visible!important}main>div{margin:0!important;padding:0!important}article,[data-print-region=article]{width:100%!important;max-width:100%!important;box-shadow:none!important;background:#fff!important;border:none!important;border-radius:0!important;margin:0!important;padding:0!important}article h1:first-of-type,[data-print-region=article] h1:first-of-type{margin-top:0!important}body,div,span,p,li,td,th,label,strong,em,b,i{color:#1a1a1a!important}strong,b{color:#000!important;font-weight:700!important}em,i{color:#1a1a1a!important;font-style:italic!important}h1{page-break-after:avoid;margin:1.5em 0 .5em;font-size:24pt;font-weight:700;color:#000!important}h2{page-break-after:avoid;margin:1.25em 0 .4em;font-size:18pt;font-weight:700;color:#000!important}h3{page-break-after:avoid;margin:1em 0 .3em;font-size:14pt;font-weight:700;color:#000!important}h4{page-break-after:avoid;margin:.8em 0 .25em;font-size:12pt;font-weight:700;color:#000!important}h5,h6{page-break-after:avoid;margin:.6em 0 .2em;font-size:11pt;font-weight:700;color:#000!important}p{margin:.5em 0;color:#1a1a1a!important}ul,ol{margin:.5em 0;padding-left:2em}li{margin:.25em 0;color:#1a1a1a!important}li p{margin:.25em 0}code,code[class]{color:#1a1a1a!important;background:#e8e8e8!important;border:1px solid #ccc!important;border-radius:3px!important;padding:.1em .4em!important;font-family:Courier New,Monaco,Consolas,monospace!important;font-size:10pt!important}.group{page-break-inside:avoid}pre,pre[class]{page-break-inside:avoid;color:#1a1a1a!important;background:#f5f5f5!important;border:1px solid #ccc!important;border-left:3px solid #888!important;border-radius:4px!important;margin:1em 0!important;padding:1em!important;overflow:visible!important}pre code,pre code[class],pre code.hljs,code.hljs,code[data-highlighted]{color:#1a1a1a!important;white-space:pre-wrap!important;word-wrap:break-word!important;background:0 0!important;border:none!important;padding:0!important;font-size:9pt!important;display:block!important}.hljs{color:#1a1a1a!important;background:0 0!important}pre span,pre code span,.hljs span,code span,[class*=hljs-]{color:#1a1a1a!important;background:0 0!important;font-style:normal!important;text-decoration:none!important}table{border-collapse:collapse;page-break-inside:avoid;width:100%;margin:1em 0}thead{page-break-after:avoid;background:#e8e8e8!important}th{text-align:left;border:1px solid #999;padding:.5em;font-weight:700;color:#000!important;background:#e8e8e8!important}td{border:1px solid #ccc;padding:.5em;color:#1a1a1a!important}tbody tr:nth-child(2n){background:#f9f9f9!important}tbody tr:nth-child(odd){background:#fff!important}blockquote{margin:1em 0;padding:.5em 0 .5em 1em;color:#333!important;background:#f7f7f7!important;border-left:4px solid #999!important}blockquote p{margin:.5em 0}hr{page-break-after:avoid;border:none;border-top:1px solid #999;margin:1.5em 0}a{text-decoration:underline;color:#06c!important}a[href]:after{content:" (" attr(href) ")";word-break:break-all;font-size:9pt;color:#666!important}a[href^="#"]:after{content:""}img{page-break-inside:avoid;max-width:100%;height:auto;margin:1em 0}svg{page-break-inside:avoid;max-width:100%;height:auto!important}h1,h2,h3,h4,h5,h6{page-break-after:avoid}pre,blockquote,table,figure{page-break-inside:avoid}*{-webkit-print-color-adjust:exact;print-color-adjust:exact}}body{font-family:var(--font-sans);background:var(--background);color:var(--foreground);font-feature-settings:"ss01","ss02","cv01"}.math-inline{vertical-align:text-bottom;align-items:center;max-width:100%;line-height:1;display:inline-flex}.math-annotatable{cursor:pointer}.math-block{max-width:100%}.math-inline .katex,.math-block .katex{color:inherit;font-size:1.02em}.math-inline .katex{line-height:inherit}.math-block .katex-display{text-align:center;margin:0}.bg-grid{background-image:linear-gradient(90deg,#2d333d80 1px,#0000 1px),linear-gradient(#2d333d80 1px,#0000 1px);background-size:24px 24px}.light .bg-grid{background-image:linear-gradient(90deg,#dadee599 1px,#0000 1px),linear-gradient(#dadee599 1px,#0000 1px)}.glow-primary{box-shadow:0 0 20px oklch(from var(--primary) l c h / .15),0 0 40px oklch(from var(--primary) l c h / .05)}.glow-sm{box-shadow:0 0 10px oklch(from var(--primary) l c h / .1)}.goal-shell{border:1px solid oklch(from var(--border) l c h / .3);background:oklch(from var(--muted) l c h / .5);border-radius:1rem;padding:1.25rem;position:relative}.goal-row{background:var(--card);border:1px solid oklch(from var(--border) l c h / .3);border-radius:.625rem;transition:background-color .15s,box-shadow .15s,border-color .15s,opacity .12s;position:relative;box-shadow:0 1px 2px -1px #0000001f}.goal-row+.goal-row{margin-top:.375rem}.goal-row.answered{border-color:oklch(from var(--success) l c h / .25)}.goal-row.skipped{border-color:oklch(from var(--warning) l c h / .25)}.goal-row.active{box-shadow:0 0 0 1px oklch(from var(--primary) l c h / .3),0 2px 8px -2px #0000001f}.goal-row-divider{background:oklch(from var(--border) l c h / .2);height:1px;margin:0 .875rem}.goal-question-body{grid-template-rows:0fr;transition:grid-template-rows .18s;display:grid}.goal-question-body.expanded{grid-template-rows:1fr}.goal-question-body>.goal-question-body-inner{min-height:0;overflow:hidden}@keyframes goal-pill-in{0%{opacity:0;transform:translate(-50%,8px)}to{opacity:1;transform:translate(-50%)}}.goal-shortcut-pill{animation:.22s ease-out both goal-pill-in}.goal-shortcut-pill kbd{border:1px solid oklch(from var(--border) l c h / .5);background:oklch(from var(--muted) l c h / .6);min-width:1rem;height:1rem;color:var(--foreground);font-family:var(--font-mono);border-radius:.25rem;justify-content:center;align-items:center;padding:0 .25rem;font-size:.625rem;line-height:1rem;display:inline-flex}.goal-shortcut-pill kbd+kbd{border-top-left-radius:0;border-bottom-left-radius:0;margin-left:-1px}.goal-shortcut-pill kbd:has(+kbd){border-top-right-radius:0;border-bottom-right-radius:0}*{scrollbar-width:thin;scrollbar-color:var(--border) transparent}::selection{background:oklch(from var(--primary) l c h / .3)}html:not(.transitions-ready) *{transition-duration:0s!important}*{transition-property:color,background-color,border-color,box-shadow;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}:focus-visible{outline:2px solid var(--ring);outline-offset:2px}.file-annotation-flash{animation:1.2s ease-out file-flash}@keyframes file-flash{0%{background:0 0}10%{background:var(--muted)}30%{background:0 0}45%{background:var(--muted)}65%{background:0 0}to{background:0 0}}.file-tree-item{border-radius:var(--radius-sm);cursor:pointer;color:var(--muted-foreground);align-items:center;gap:.5rem;padding:.25rem .5rem;font-size:.75rem;display:flex}.file-tree-item:hover{background:var(--muted);color:var(--foreground)}.file-tree-item:disabled{cursor:default}.file-tree-item:disabled:hover{background:0 0}.file-tree-item.active{background:oklch(from var(--primary) l c h / .3);color:var(--primary)}.file-tree-item.has-annotations{background:oklch(from var(--primary) l c h / .08)}.file-tree-item.has-annotations:hover{background:var(--muted)}.file-tree-item.active.has-annotations{background:oklch(from var(--primary) l c h / .3)}.file-tree-item .additions,.file-tree-folder .additions,.file-tree-status-summary .additions{color:var(--success)}.file-tree-item .deletions,.file-tree-folder .deletions,.file-tree-status-summary .deletions{color:var(--destructive)}.plan-diff-word-added,.plan-diff-word-removed{-webkit-box-decoration-break:clone;box-decoration-break:clone;border-radius:2px;padding:0 2px}.plan-diff-word-added{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.plan-diff-word-added{background-color:color-mix(in oklab,var(--success) 20%,transparent)}}.plan-diff-word-added{color:inherit;text-decoration:none}.plan-diff-word-removed{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.plan-diff-word-removed{background-color:color-mix(in oklab,var(--destructive) 15%,transparent)}}.plan-diff-word-removed{text-decoration:line-through;-webkit-text-decoration-color:var(--destructive);text-decoration-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.plan-diff-word-removed{-webkit-text-decoration-color:color-mix(in oklab,var(--destructive) 60%,transparent);text-decoration-color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.plan-diff-word-removed{opacity:.75;color:inherit}.plan-diff-word-added code{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.plan-diff-word-added code{background-color:color-mix(in oklab,var(--success) 25%,var(--muted))}}.plan-diff-word-removed code{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.plan-diff-word-removed code{background-color:color-mix(in oklab,var(--destructive) 20%,var(--muted))}}.plan-diff-added{border-left:3px solid var(--success);background:oklch(from var(--success) l c h / .06);border-radius:0 .25rem .25rem 0;margin:.25rem 0;padding-left:.75rem}.light .plan-diff-added{background:oklch(from var(--success) l c h / .06)}.plan-diff-removed{border-left:3px solid var(--destructive);background:oklch(from var(--destructive) l c h / .06);border-radius:0 .25rem .25rem 0;margin:.25rem 0;padding-left:.75rem}.light .plan-diff-removed{background:oklch(from var(--destructive) l c h / .06)}.plan-diff-modified{border-left:3px solid oklch(from var(--warning) l c h / .75);background:0 0;border-radius:0 .25rem .25rem 0;margin:.25rem 0;padding-left:.75rem}.plan-diff-line-added{background:oklch(from var(--success) l c h / .15);color:var(--success)}.plan-diff-line-removed{background:oklch(from var(--destructive) l c h / .15);color:var(--destructive);opacity:.75;text-decoration:line-through;-webkit-text-decoration-color:oklch(from var(--destructive) l c h / .4);text-decoration-color:oklch(from var(--destructive) l c h / .4)}.light .plan-diff-line-added{background:oklch(from var(--success) l c h / .12)}.light .plan-diff-line-removed{background:oklch(from var(--destructive) l c h / .12)}.html-block details{border:1px solid var(--border);background-color:var(--muted);border-radius:6px;margin:.5rem 0;padding:.5rem .75rem}@supports (color:color-mix(in lab,red,red)){.html-block details{background-color:color-mix(in oklab,var(--muted) 30%,transparent)}}.html-block details[open]{padding-bottom:.75rem}.html-block summary{cursor:pointer;color:var(--foreground);list-style:revert;font-weight:500}.html-block summary::-webkit-details-marker{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.html-block summary::-webkit-details-marker{color:color-mix(in oklab,var(--foreground) 60%,transparent)}}.html-block details[open]>summary{margin-bottom:.5rem}.html-block p{margin:.5rem 0}.html-block ul,.html-block ol{margin:.5rem 0;padding-left:1.5rem}.html-block ul{list-style:outside}.html-block ol{list-style:decimal}.html-block blockquote{border-left:2px solid var(--primary)}@supports (color:color-mix(in lab,red,red)){.html-block blockquote{border-left:2px solid color-mix(in oklab,var(--primary) 50%,transparent)}}.html-block blockquote{color:var(--muted-foreground);margin:.75rem 0;padding-left:1rem;font-style:italic}.html-block code{background-color:var(--muted);border-radius:3px;padding:.1em .35em;font-size:.9em}.html-block pre{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.html-block pre{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.html-block pre{border:1px solid var(--border)}@supports (color:color-mix(in lab,red,red)){.html-block pre{border:1px solid color-mix(in oklab,var(--border) 30%,transparent)}}.html-block pre{border-radius:6px;margin:.75rem 0;padding:.75rem;overflow-x:auto}.html-block pre code{background:0 0;padding:0}.html-block a{color:var(--primary);text-underline-offset:2px;text-decoration:underline}body:has([data-popout=true]) [data-annotation-panel=true],body:has([data-popout=true]) [data-sticky-header-lane=true],body:has([data-popout=true]) [data-app-header=true],body:has([data-popout=true]) [data-sidebar-tabs=true]{z-index:-1}.alert-note{border-left-color:#0969da}.alert-note .alert-title{color:#0969da}.alert-tip{border-left-color:#1a7f37}.alert-tip .alert-title{color:#1a7f37}.alert-warning{border-left-color:#9a6700}.alert-warning .alert-title{color:#9a6700}.alert-caution{border-left-color:#cf222e}.alert-caution .alert-title{color:#cf222e}.alert-important{border-left-color:#8250df}.alert-important .alert-title{color:#8250df}@media(prefers-color-scheme:dark){.alert-note{border-left-color:#4493f8}.alert-note .alert-title{color:#4493f8}.alert-tip{border-left-color:#3fb950}.alert-tip .alert-title{color:#3fb950}.alert-warning{border-left-color:#d29922}.alert-warning .alert-title{color:#d29922}.alert-caution{border-left-color:#f85149}.alert-caution .alert-title{color:#f85149}.alert-important{border-left-color:#ab7df8}.alert-important .alert-title{color:#ab7df8}}.directive{border-color:var(--border);background:var(--muted)}@supports (color:color-mix(in lab,red,red)){.directive{background:color-mix(in srgb,var(--muted) 50%,transparent)}}.directive-title{color:var(--muted-foreground)}.directive-note,.directive-info{background:#3b82f61a;border-color:#3b82f666}.directive-note .directive-title,.directive-info .directive-title{color:#3b82f6}.directive-tip,.directive-success{background:#10b9811a;border-color:#10b98166}.directive-tip .directive-title,.directive-success .directive-title{color:#10b981}.directive-warning{background:#f59e0b1a;border-color:#f59e0b66}.directive-warning .directive-title{color:#f59e0b}.directive-danger,.directive-caution{background:#ef44441a;border-color:#ef444466}.directive-danger .directive-title,.directive-caution .directive-title{color:#ef4444}.ai-streaming-cursor{vertical-align:-.15em;background:currentColor;border-radius:1px;width:.45em;height:1em;animation:1s steps(2,start) infinite ai-cursor-blink;display:inline-block}@keyframes ai-cursor-blink{0%,45%{opacity:1}46%,to{opacity:0}}@media(prefers-reduced-motion:reduce){.ai-streaming-cursor{animation:none}}.ai-markdown{line-height:1.55}.ai-markdown p{margin:.45rem 0}.ai-markdown p:first-child{margin-top:0}.ai-markdown p:last-child{margin-bottom:0}.ai-markdown h1,.ai-markdown h2,.ai-markdown h3,.ai-markdown h4,.ai-markdown h5,.ai-markdown h6{margin:.75rem 0 .35rem;font-weight:600}.ai-markdown h1{font-size:.875rem}.ai-markdown h2{font-size:.8125rem}.ai-markdown h3,.ai-markdown h4,.ai-markdown h5,.ai-markdown h6{font-size:.75rem}.ai-markdown ul,.ai-markdown ol{margin:.45rem 0;padding-left:1rem}.ai-markdown ul{list-style:outside}.ai-markdown ol{list-style:decimal}.ai-markdown li{margin:.15rem 0}.ai-markdown code{background:var(--muted)}@supports (color:color-mix(in lab,red,red)){.ai-markdown code{background:color-mix(in oklab,var(--muted) 70%,transparent)}}.ai-markdown code{border:1px solid var(--border)}@supports (color:color-mix(in lab,red,red)){.ai-markdown code{border:1px solid color-mix(in oklab,var(--border) 45%,transparent)}}.ai-markdown code{border-radius:4px;padding:.05rem .3rem;font-size:.9em}.ai-markdown pre{background:var(--muted)}@supports (color:color-mix(in lab,red,red)){.ai-markdown pre{background:color-mix(in oklab,var(--muted) 55%,transparent)}}.ai-markdown pre{border:1px solid var(--border)}@supports (color:color-mix(in lab,red,red)){.ai-markdown pre{border:1px solid color-mix(in oklab,var(--border) 45%,transparent)}}.ai-markdown pre{border-radius:6px;margin:.5rem 0;padding:.6rem;overflow-x:auto}.ai-markdown pre code{background:0 0;border:0;padding:0}.ai-markdown blockquote{border-left:2px solid var(--primary)}@supports (color:color-mix(in lab,red,red)){.ai-markdown blockquote{border-left:2px solid color-mix(in oklab,var(--primary) 45%,transparent)}}.ai-markdown blockquote{color:var(--muted-foreground);padding-left:.6rem}.annotation-highlight{border-radius:2px;margin:0 -2px;padding:0 2px}.annotation-highlight.math-inline-annotation{align-items:center;line-height:1;display:inline-flex}.annotation-highlight.math-block-annotation{display:block}.annotation-highlight.deletion{background:oklch(from var(--destructive) l c h / .35);text-decoration:line-through;-webkit-text-decoration-color:var(--destructive);text-decoration-color:var(--destructive);text-decoration-thickness:2px}.annotation-highlight.comment{border-bottom:2px solid var(--accent);background:#e681004d;background:oklch(70% .18 60/.3)}.light .annotation-highlight.deletion{background:#f9414433}.light .annotation-highlight.comment{background:#e6810026;background:oklch(70% .2 60/.15)}.annotation-highlight.focused{box-shadow:0 0 8px oklch(from var(--focus-highlight) l c h / .4);border-bottom:2px solid var(--focus-highlight);filter:none;background:oklch(from var(--focus-highlight) l c h / .45)!important}.light .annotation-highlight.focused{box-shadow:0 0 6px #0092984d;box-shadow:0 0 6px oklch(60% .2 200/.3);background:#00b4bb4d!important;background:oklch(70% .22 200/.3)!important}.annotation-highlight:hover{filter:brightness(1.2);cursor:pointer}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0) scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1)) rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0) scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1)) rotate(var(--tw-exit-rotate,0))}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} From d7da177a979fdb2f7f21ebaf869edf9d1fb8dc17 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 15:31:03 -0700 Subject: [PATCH 32/67] =?UTF-8?q?feat(ui):=20depend=20only=20on=20@plannot?= =?UTF-8?q?ator/core=20=E2=80=94=20re-point=20all=20shared/ai=20imports=20?= =?UTF-8?q?(Phase=207=20step=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/components/AISettingsTab.tsx | 2 +- packages/ui/components/AgentsTab.tsx | 2 +- packages/ui/components/DocBadges.tsx | 2 +- packages/ui/components/InlineMarkdown.tsx | 2 +- packages/ui/components/MenuVersionSection.tsx | 2 +- packages/ui/components/OpenInAppButton.tsx | 2 +- packages/ui/components/PlanAIAnnouncementDialog.tsx | 4 ++-- packages/ui/components/PlanHeaderMenu.tsx | 2 +- packages/ui/components/Settings.tsx | 4 ++-- packages/ui/components/blocks/HtmlBlock.tsx | 2 +- packages/ui/components/goal-setup/GoalSetupSurface.tsx | 2 +- packages/ui/components/settings/HooksTab.tsx | 2 +- packages/ui/components/sidebar/ArchiveBrowser.tsx | 2 +- packages/ui/components/sidebar/FileBrowser.tsx | 4 ++-- packages/ui/config/settings.ts | 2 +- packages/ui/hooks/pfm/useCodeFilePopout.ts | 2 +- packages/ui/hooks/useAIChat.ts | 2 +- packages/ui/hooks/useAgents.ts | 2 +- packages/ui/hooks/useAnnotationDraft.ts | 2 +- packages/ui/hooks/useArchive.ts | 2 +- packages/ui/hooks/useFileBrowser.ts | 2 +- packages/ui/hooks/useLinkedDoc.ts | 2 +- packages/ui/hooks/useValidatedCodePaths.ts | 2 +- packages/ui/package.json | 3 +-- packages/ui/tsconfig.json | 4 +++- packages/ui/types.ts | 6 +++--- packages/ui/utils/aiProvider.ts | 2 +- packages/ui/utils/annotateAgentTerminal.ts | 2 +- packages/ui/utils/parser.ts | 2 +- packages/ui/utils/sharing.ts | 4 ++-- 30 files changed, 38 insertions(+), 37 deletions(-) diff --git a/packages/ui/components/AISettingsTab.tsx b/packages/ui/components/AISettingsTab.tsx index 6bd96be98..5010471a5 100644 --- a/packages/ui/components/AISettingsTab.tsx +++ b/packages/ui/components/AISettingsTab.tsx @@ -9,7 +9,7 @@ import { type AIProviderOption, } from '../utils/aiProvider'; import { useState } from 'react'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; interface AIProvider extends AIProviderOption { capabilities: Record; diff --git a/packages/ui/components/AgentsTab.tsx b/packages/ui/components/AgentsTab.tsx index de866069f..cc30a9ed8 100644 --- a/packages/ui/components/AgentsTab.tsx +++ b/packages/ui/components/AgentsTab.tsx @@ -14,7 +14,7 @@ import { Search, } from 'lucide-react'; import type { AgentJobInfo, AgentCapabilities } from '../types'; -import { isTerminalStatus } from '@plannotator/shared/agent-jobs'; +import { isTerminalStatus } from '@plannotator/core/agent-jobs'; import { cn } from '../lib/utils'; import { ReviewAgentsIcon } from './ReviewAgentsIcon'; import { ClaudeIcon, CodexIcon, CopilotIcon, CursorIcon, OpenCodeIcon, PiIcon } from './icons/AgentIcons'; diff --git a/packages/ui/components/DocBadges.tsx b/packages/ui/components/DocBadges.tsx index cc7ecb1e6..2ac075b2e 100644 --- a/packages/ui/components/DocBadges.tsx +++ b/packages/ui/components/DocBadges.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { PlanDiffBadge } from './plan-diff/PlanDiffBadge'; import type { PlanDiffStats } from '../utils/planDiffEngine'; -import { hostnameOrFallback } from '@plannotator/shared/project'; +import { hostnameOrFallback } from '@plannotator/core/project'; import { OpenInAppButton } from './OpenInAppButton'; export interface LinkedDocBadgeInfo { diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 24f49be86..a30a1cde4 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; import hljs from "highlight.js"; -import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/shared/code-file"; +import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file"; import { transformPlainText } from "../utils/inlineTransforms"; import { getImageSrc } from "./ImageThumbnail"; import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext"; diff --git a/packages/ui/components/MenuVersionSection.tsx b/packages/ui/components/MenuVersionSection.tsx index 9b31f9be3..2caa5ff60 100644 --- a/packages/ui/components/MenuVersionSection.tsx +++ b/packages/ui/components/MenuVersionSection.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { TextShimmer } from './TextShimmer'; import type { UpdateInfo } from '../hooks/useUpdateCheck'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; import { isWindows } from '../utils/platform'; const PI_INSTALL_COMMAND = 'pi install npm:@plannotator/pi-extension'; diff --git a/packages/ui/components/OpenInAppButton.tsx b/packages/ui/components/OpenInAppButton.tsx index 88c9d8d8b..4690e8708 100644 --- a/packages/ui/components/OpenInAppButton.tsx +++ b/packages/ui/components/OpenInAppButton.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { ChevronDown, Check, Copy, MoreHorizontal } from 'lucide-react'; import { AppIcon } from './icons/AppIcon'; import { getLastOpenInApp, setLastOpenInApp } from '../utils/storage'; -import type { OpenInKind } from '@plannotator/shared/open-in-apps'; +import type { OpenInKind } from '@plannotator/core/open-in-apps'; import { DropdownMenu, DropdownMenuTrigger, diff --git a/packages/ui/components/PlanAIAnnouncementDialog.tsx b/packages/ui/components/PlanAIAnnouncementDialog.tsx index 59c945ee3..43b7db709 100644 --- a/packages/ui/components/PlanAIAnnouncementDialog.tsx +++ b/packages/ui/components/PlanAIAnnouncementDialog.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import type { Origin } from '@plannotator/shared/agents'; -import { AGENT_CONFIG, getAgentAIProviderTypes, getAgentName } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; +import { AGENT_CONFIG, getAgentAIProviderTypes, getAgentName } from '@plannotator/core/agents'; import { SparklesIcon } from './SparklesIcon'; import { getProviderMeta } from './ProviderIcons'; diff --git a/packages/ui/components/PlanHeaderMenu.tsx b/packages/ui/components/PlanHeaderMenu.tsx index d5243b36e..2855ecfde 100644 --- a/packages/ui/components/PlanHeaderMenu.tsx +++ b/packages/ui/components/PlanHeaderMenu.tsx @@ -11,7 +11,7 @@ import { ReviewAgentsIcon } from './ReviewAgentsIcon'; import { MenuVersionSection } from './MenuVersionSection'; import { TextShimmer } from './TextShimmer'; import type { UpdateInfo } from '../hooks/useUpdateCheck'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; interface PlanHeaderMenuProps { appVersion: string; diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index cdbfb8783..22943c72a 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import type { Origin } from '@plannotator/shared/agents'; -import type { DiffLineBgIntensity } from '@plannotator/shared/config'; +import type { Origin } from '@plannotator/core/agents'; +import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config'; import { loadDiffFont } from '../utils/diffFonts'; import { TaterSpritePullup } from './TaterSpritePullup'; diff --git a/packages/ui/components/blocks/HtmlBlock.tsx b/packages/ui/components/blocks/HtmlBlock.tsx index d4e7ce3be..1fdac8094 100644 --- a/packages/ui/components/blocks/HtmlBlock.tsx +++ b/packages/ui/components/blocks/HtmlBlock.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect } from "react"; -import { isCodeFilePath } from "@plannotator/shared/code-file"; +import { isCodeFilePath } from "@plannotator/core/code-file"; import { Block } from "../../types"; import { sanitizeBlockHtml } from "../../utils/sanitizeHtml"; import { getImageSrc } from "../ImageThumbnail"; diff --git a/packages/ui/components/goal-setup/GoalSetupSurface.tsx b/packages/ui/components/goal-setup/GoalSetupSurface.tsx index fb16a0a81..7547ba468 100644 --- a/packages/ui/components/goal-setup/GoalSetupSurface.tsx +++ b/packages/ui/components/goal-setup/GoalSetupSurface.tsx @@ -17,7 +17,7 @@ import type { GoalSetupInterviewBundle, GoalSetupQuestion, GoalSetupQuestionAnswer, -} from '@plannotator/shared/goal-setup'; +} from '@plannotator/core/goal-setup'; import { ConfirmDialog } from '../ConfirmDialog'; import { CommentPopover } from '../CommentPopover'; import { Button } from '../core/button'; diff --git a/packages/ui/components/settings/HooksTab.tsx b/packages/ui/components/settings/HooksTab.tsx index 683ba3a59..3fb402ce7 100644 --- a/packages/ui/components/settings/HooksTab.tsx +++ b/packages/ui/components/settings/HooksTab.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { FAVICON_SVG } from '@plannotator/shared/favicon'; +import { FAVICON_SVG } from '@plannotator/core/favicon'; interface HooksStatus { pfmReminder: { enabled: boolean }; diff --git a/packages/ui/components/sidebar/ArchiveBrowser.tsx b/packages/ui/components/sidebar/ArchiveBrowser.tsx index 4505895a6..a96630ab4 100644 --- a/packages/ui/components/sidebar/ArchiveBrowser.tsx +++ b/packages/ui/components/sidebar/ArchiveBrowser.tsx @@ -6,7 +6,7 @@ */ import React from "react"; -import type { ArchivedPlan } from "@plannotator/shared/storage"; +import type { ArchivedPlan } from "@plannotator/core/storage-types"; export type { ArchivedPlan }; diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx index 192cbfd1f..9b432c8f4 100644 --- a/packages/ui/components/sidebar/FileBrowser.tsx +++ b/packages/ui/components/sidebar/FileBrowser.tsx @@ -10,8 +10,8 @@ import type { VaultNode } from "../../types"; import type { DirState } from "../../hooks/useFileBrowser"; import { CountBadge } from "./CountBadge"; import { ObsidianIconRaw } from "../icons/ObsidianIcons"; -import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/shared/workspace-status"; -import { normalizeBrowserPath } from "@plannotator/shared/browser-paths"; +import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types"; +import { normalizeBrowserPath } from "@plannotator/core/browser-paths"; interface FileBrowserProps { dirs: DirState[]; diff --git a/packages/ui/config/settings.ts b/packages/ui/config/settings.ts index e2042c34f..8bf581ca7 100644 --- a/packages/ui/config/settings.ts +++ b/packages/ui/config/settings.ts @@ -9,7 +9,7 @@ * Add new settings here. Cookie-only settings omit serverKey. */ -import type { DiffLineBgIntensity } from '@plannotator/shared/config'; +import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; import { storage } from '../utils/storage'; import { generateIdentity } from '../utils/generateIdentity'; diff --git a/packages/ui/hooks/pfm/useCodeFilePopout.ts b/packages/ui/hooks/pfm/useCodeFilePopout.ts index fe7dfa08e..d2f58d4e2 100644 --- a/packages/ui/hooks/pfm/useCodeFilePopout.ts +++ b/packages/ui/hooks/pfm/useCodeFilePopout.ts @@ -1,5 +1,5 @@ import { useState, useCallback } from "react"; -import { parseCodePath } from "@plannotator/shared/code-file"; +import { parseCodePath } from "@plannotator/core/code-file"; interface CodeFileState { filepath: string; diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index 87a61fe76..ecfcb064c 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { AIContext } from '@plannotator/ai'; +import type { AIContext } from '@plannotator/core'; import type { AIQuestion, AIResponse } from '../types'; import { generateId } from '../utils/generateId'; diff --git a/packages/ui/hooks/useAgents.ts b/packages/ui/hooks/useAgents.ts index 73667a8b3..79936584b 100644 --- a/packages/ui/hooks/useAgents.ts +++ b/packages/ui/hooks/useAgents.ts @@ -3,7 +3,7 @@ */ import { useState, useEffect, useCallback } from 'react'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; import { getAgentSwitchSettings } from '../utils/agentSwitch'; export interface Agent { diff --git a/packages/ui/hooks/useAnnotationDraft.ts b/packages/ui/hooks/useAnnotationDraft.ts index 8e8bcdbc4..c563f6785 100644 --- a/packages/ui/hooks/useAnnotationDraft.ts +++ b/packages/ui/hooks/useAnnotationDraft.ts @@ -15,7 +15,7 @@ */ import { useState, useEffect, useCallback, useRef } from 'react'; -import type { SourceSaveCapability } from '@plannotator/shared/source-save'; +import type { SourceSaveCapability } from '@plannotator/core/source-save'; import type { Annotation, CodeAnnotation, ImageAttachment } from '../types'; import { fromShareable, parseShareableImages } from '../utils/sharing'; import type { ShareableAnnotation } from '../utils/sharing'; diff --git a/packages/ui/hooks/useArchive.ts b/packages/ui/hooks/useArchive.ts index 9bcf2e101..808bcaa9a 100644 --- a/packages/ui/hooks/useArchive.ts +++ b/packages/ui/hooks/useArchive.ts @@ -6,7 +6,7 @@ */ import { useState, useRef, useMemo, useCallback } from "react"; -import type { ArchivedPlan } from "@plannotator/shared/storage"; +import type { ArchivedPlan } from "@plannotator/core/storage-types"; import type { UseLinkedDocReturn } from "./useLinkedDoc"; import type { ViewerHandle } from "../components/Viewer"; import type { Annotation } from "../types"; diff --git a/packages/ui/hooks/useFileBrowser.ts b/packages/ui/hooks/useFileBrowser.ts index 5ce29c7ee..eb74ecdd1 100644 --- a/packages/ui/hooks/useFileBrowser.ts +++ b/packages/ui/hooks/useFileBrowser.ts @@ -9,7 +9,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import type { VaultNode } from "../types"; -import type { WorkspaceStatusPayload } from "@plannotator/shared/workspace-status"; +import type { WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types"; export interface DirState { path: string; diff --git a/packages/ui/hooks/useLinkedDoc.ts b/packages/ui/hooks/useLinkedDoc.ts index ef2b63a75..38e739f55 100644 --- a/packages/ui/hooks/useLinkedDoc.ts +++ b/packages/ui/hooks/useLinkedDoc.ts @@ -10,7 +10,7 @@ import { useState, useCallback, useRef } from "react"; import type { Annotation, ImageAttachment } from "../types"; import type { ViewerHandle } from "../components/Viewer"; import type { SidebarTab } from "./useSidebar"; -import type { SourceSaveCapability } from "@plannotator/shared/source-save"; +import type { SourceSaveCapability } from "@plannotator/core/source-save"; export interface LinkedDocLoadData { markdown?: string; diff --git a/packages/ui/hooks/useValidatedCodePaths.ts b/packages/ui/hooks/useValidatedCodePaths.ts index 00b6462a2..a6df5ca45 100644 --- a/packages/ui/hooks/useValidatedCodePaths.ts +++ b/packages/ui/hooks/useValidatedCodePaths.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { extractCandidateCodePaths } from "@plannotator/shared/extract-code-paths"; +import { extractCandidateCodePaths } from "@plannotator/core/extract-code-paths"; export type ValidationEntry = | { status: "found"; resolved: string } diff --git a/packages/ui/package.json b/packages/ui/package.json index 84842cb1e..754e636b5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -60,9 +60,8 @@ "@pierre/diffs": "1.2.8", "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.3", - "@plannotator/ai": "workspace:*", + "@plannotator/core": "workspace:*", "@plannotator/markdown-editor": "^0.2.0", - "@plannotator/shared": "workspace:*", "@plannotator/web-highlighter": "^0.8.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 2076dec37..63df81fa6 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -18,7 +18,9 @@ "esModuleInterop": true, "types": ["bun"], "paths": { - "@plannotator/shared/*": ["../shared/*"] + "@plannotator/shared/*": ["../shared/*"], + "@plannotator/core": ["../core/index.ts"], + "@plannotator/core/*": ["../core/*"] } }, "include": [ diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 48ca291ad..c17526be5 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -249,11 +249,11 @@ export interface VaultNode { children?: VaultNode[]; } -export type { EditorAnnotation } from '@plannotator/shared/types'; +export type { EditorAnnotation } from '@plannotator/core/types'; export type { ExternalAnnotationEvent, -} from '@plannotator/shared/external-annotation'; +} from '@plannotator/core/external-annotation'; export type { AgentJobInfo, @@ -261,4 +261,4 @@ export type { AgentJobStatus, AgentCapability, AgentCapabilities, -} from '@plannotator/shared/agent-jobs'; +} from '@plannotator/core/agent-jobs'; diff --git a/packages/ui/utils/aiProvider.ts b/packages/ui/utils/aiProvider.ts index f0fba4c4c..dbfacc16f 100644 --- a/packages/ui/utils/aiProvider.ts +++ b/packages/ui/utils/aiProvider.ts @@ -7,7 +7,7 @@ */ import { storage } from './storage'; -import { AGENT_CONFIG, getAgentAIProviderTypes, type Origin } from '@plannotator/shared/agents'; +import { AGENT_CONFIG, getAgentAIProviderTypes, type Origin } from '@plannotator/core/agents'; const PROVIDER_KEY = 'plannotator-ai-provider'; const MODELS_KEY = 'plannotator-ai-models'; diff --git a/packages/ui/utils/annotateAgentTerminal.ts b/packages/ui/utils/annotateAgentTerminal.ts index 459f8e9c8..b9b3d4bec 100644 --- a/packages/ui/utils/annotateAgentTerminal.ts +++ b/packages/ui/utils/annotateAgentTerminal.ts @@ -1,4 +1,4 @@ -import type { AgentTerminalAgent } from "@plannotator/shared/agent-terminal"; +import type { AgentTerminalAgent } from "@plannotator/core/agent-terminal"; import { storage } from "./storage"; const DEFAULT_AGENT_KEY = "plannotator-annotate-agent-terminal-default"; diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index f61a80ab0..2305d1e2e 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -1,5 +1,5 @@ import { Block, type Annotation, type CodeAnnotation, type EditorAnnotation, type ImageAttachment } from '../types'; -import { planDenyFeedback } from '@plannotator/shared/feedback-templates'; +import { planDenyFeedback } from '@plannotator/core/feedback-templates'; /** * Parsed YAML frontmatter as key-value pairs. diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts index 8b473452d..12317bc0a 100644 --- a/packages/ui/utils/sharing.ts +++ b/packages/ui/utils/sharing.ts @@ -9,8 +9,8 @@ */ import { Annotation, AnnotationType, type ImageAttachment } from '../types'; -import { compress, decompress } from '@plannotator/shared/compress'; -import { encrypt, decrypt } from '@plannotator/shared/crypto'; +import { compress, decompress } from '@plannotator/core/compress'; +import { encrypt, decrypt } from '@plannotator/core/crypto'; // Image in shareable format: plain string (old) or [path, name] tuple (new) type ShareableImage = string | [string, string]; From 003642b1608b91529b3bef296664142b88ea1989 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 15:32:12 -0700 Subject: [PATCH 33/67] refactor(ui): relocate wideMode helper to @plannotator/ui/utils (Phase 7 step 3) --- packages/editor/App.tsx | 2 +- packages/{editor => ui/utils}/wideMode.test.ts | 0 packages/{editor => ui/utils}/wideMode.ts | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename packages/{editor => ui/utils}/wideMode.test.ts (100%) rename packages/{editor => ui/utils}/wideMode.ts (100%) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 7a205f88a..ad43bf33f 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -101,7 +101,7 @@ import type { AgentTerminalCapability } from '@plannotator/shared/agent-terminal // same env var on the server side so V2/V3 stay paired. import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; -import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; +import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from '@plannotator/ui/utils/wideMode'; import { annotateSidebarShortcuts, useAnnotateSidebarShortcuts, diff --git a/packages/editor/wideMode.test.ts b/packages/ui/utils/wideMode.test.ts similarity index 100% rename from packages/editor/wideMode.test.ts rename to packages/ui/utils/wideMode.test.ts diff --git a/packages/editor/wideMode.ts b/packages/ui/utils/wideMode.ts similarity index 100% rename from packages/editor/wideMode.ts rename to packages/ui/utils/wideMode.ts From 7c8d538fc30bf87c73be2c5e305001f4fdf82cff Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 15:37:05 -0700 Subject: [PATCH 34/67] feat(ui): add loadFromBackend settings rehydration + configurePlannotatorUI front door (Phase 7 step 4) --- packages/ui/config/configStore.ts | 19 +++++ packages/ui/configure.test.ts | 130 ++++++++++++++++++++++++++++++ packages/ui/configure.ts | 46 +++++++++++ packages/ui/package.json | 2 + 4 files changed, 197 insertions(+) create mode 100644 packages/ui/configure.test.ts create mode 100644 packages/ui/configure.ts diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index aea9d7f95..05017d38b 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -74,6 +74,23 @@ class ConfigStore { } } + /** + * Re-hydrate all settings from the currently installed StorageBackend. + * ADDITIVE host hook — Plannotator never calls this (eager cookie default unchanged). + * Host installs a SYNCHRONOUS StorageBackend serving prefetched settings, then calls + * this to route the initial load through that backend. Precedence after a host call: + * server (init) > host backend (loadFromBackend) > cookie/default (constructor). + */ + loadFromBackend(): void { + for (const [name, def] of Object.entries(SETTINGS)) { + const fromBackend = def.fromCookie(); + if (fromBackend !== undefined) { + this.values.set(name, fromBackend); + } + } + this.notify(); + } + /** * Apply server config overrides. * Call once after fetching /api/plan or /api/diff. @@ -126,6 +143,8 @@ class ConfigStore { this.serverSync = fn; } + resetServerSync(): void { this.serverSync = defaultServerSync; } + private notify(): void { this.version++; for (const fn of this.listeners) fn(); diff --git a/packages/ui/configure.test.ts b/packages/ui/configure.test.ts new file mode 100644 index 000000000..625dc6be1 --- /dev/null +++ b/packages/ui/configure.test.ts @@ -0,0 +1,130 @@ +import { afterAll, describe, expect, it, mock, spyOn } from 'bun:test'; + +import * as ImageThumbnail from './components/ImageThumbnail'; +import * as InlineMarkdown from './components/InlineMarkdown'; +import * as storage from './utils/storage'; +import * as identity from './utils/identity'; +import * as useFileBrowser from './hooks/useFileBrowser'; +import * as useAnnotationDraft from './hooks/useAnnotationDraft'; +import * as useExternalAnnotations from './hooks/useExternalAnnotations'; +import * as useAIChat from './hooks/useAIChat'; +import { configStore } from './config'; + +import type { ImageSrcResolver } from './components/ImageThumbnail'; +import type { DocPreviewFetcher } from './components/InlineMarkdown'; +import type { StorageBackend } from './utils/storage'; +import type { IdentityProvider } from './utils/identity'; +import type { FileTreeBackend } from './hooks/useFileBrowser'; +import type { DraftTransport } from './hooks/useAnnotationDraft'; +import type { ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; +import type { AITransport } from './hooks/useAIChat'; + +// Spy on each setter. We re-export the REAL module verbatim and override ONLY the +// setter with a spy, so other test files importing other exports stay intact +// (Bun's mock.module replacement is process-global — dropping exports would break +// sibling suites). The spies route to no-ops; we assert configure wired them. +const setImageSrcResolver = mock((_: ImageSrcResolver) => {}); +const setDocPreviewFetcher = mock((_: DocPreviewFetcher) => {}); +const setStorageBackend = mock((_: StorageBackend) => {}); +const setIdentityProvider = mock((_: IdentityProvider) => {}); +const setFileTreeBackend = mock((_: FileTreeBackend) => {}); +const setDraftTransport = mock((_: DraftTransport) => {}); +const setExternalAnnotationTransport = mock((_: ExternalAnnotationTransport<{ id: string; source?: string }>) => {}); +const setAITransport = mock((_: AITransport) => {}); + +mock.module('./components/ImageThumbnail', () => ({ ...ImageThumbnail, setImageSrcResolver })); +mock.module('./components/InlineMarkdown', () => ({ ...InlineMarkdown, setDocPreviewFetcher })); +mock.module('./utils/storage', () => ({ ...storage, setStorageBackend })); +mock.module('./utils/identity', () => ({ ...identity, setIdentityProvider })); +mock.module('./hooks/useFileBrowser', () => ({ ...useFileBrowser, setFileTreeBackend })); +mock.module('./hooks/useAnnotationDraft', () => ({ ...useAnnotationDraft, setDraftTransport })); +mock.module('./hooks/useExternalAnnotations', () => ({ ...useExternalAnnotations, setExternalAnnotationTransport })); +mock.module('./hooks/useAIChat', () => ({ ...useAIChat, setAITransport })); + +// configStore is shared with sibling suites — spy on the real instance methods +// (restored in afterAll) instead of replacing the ./config module. +const setServerSync = spyOn(configStore, 'setServerSync'); +const loadFromBackend = spyOn(configStore, 'loadFromBackend').mockImplementation(() => {}); + +const { configurePlannotatorUI } = await import('./configure'); + +// Shape-correct fakes (only need to satisfy the front door's optional fields). +const imageSrcResolver: ImageSrcResolver = (path) => path; +const docPreviewFetcher: DocPreviewFetcher = async () => null; +const storageBackend: StorageBackend = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; +const identityProvider: IdentityProvider = { getIdentity: () => 'tater', isCurrentUser: () => false }; +const fileTreeBackend: FileTreeBackend = { + loadTree: async () => new Response('{}'), + loadVaultTree: async () => new Response('{}'), + watchTrees: () => undefined, +}; +const draftTransport: DraftTransport = { + load: async () => ({ data: null, generation: null }), + save: async () => {}, + remove: async () => {}, +}; +const externalAnnotationTransport: ExternalAnnotationTransport<{ id: string; source?: string }> = { + subscribe: () => () => {}, + getSnapshot: async () => null, + add: async () => {}, + remove: async () => {}, + update: async () => {}, + clear: async () => {}, +}; +const aiTransport: AITransport = { + session: async () => new Response(), + query: async () => new Response(), + abort: () => {}, + permission: () => {}, +}; +const serverSync = (_payload: Record) => {}; + +afterAll(() => mock.restore()); + +describe('configurePlannotatorUI routing', () => { + it('routes each provided seam to its underlying setter', () => { + configurePlannotatorUI({ + imageSrcResolver, + storageBackend, + docPreviewFetcher, + fileTreeBackend, + identityProvider, + draftTransport, + externalAnnotationTransport, + aiTransport, + serverSync, + loadSettingsFromBackend: true, + }); + + expect(setImageSrcResolver).toHaveBeenCalledWith(imageSrcResolver); + expect(setDocPreviewFetcher).toHaveBeenCalledWith(docPreviewFetcher); + expect(setStorageBackend).toHaveBeenCalledWith(storageBackend); + expect(setIdentityProvider).toHaveBeenCalledWith(identityProvider); + expect(setFileTreeBackend).toHaveBeenCalledWith(fileTreeBackend); + expect(setDraftTransport).toHaveBeenCalledWith(draftTransport); + expect(setExternalAnnotationTransport).toHaveBeenCalledWith(externalAnnotationTransport); + expect(setAITransport).toHaveBeenCalledWith(aiTransport); + expect(setServerSync).toHaveBeenCalledWith(serverSync); + expect(loadFromBackend).toHaveBeenCalledTimes(1); + + // load-bearing order: storageBackend installed before loadFromBackend re-hydrates. + expect(setStorageBackend.mock.invocationCallOrder[0]).toBeLessThan( + loadFromBackend.mock.invocationCallOrder[0], + ); + }); + + it('skips setters for omitted fields', () => { + [ + setImageSrcResolver, setDocPreviewFetcher, setStorageBackend, setIdentityProvider, + setFileTreeBackend, setDraftTransport, setExternalAnnotationTransport, setAITransport, + setServerSync, loadFromBackend, + ].forEach((m) => m.mockClear()); + + configurePlannotatorUI({ storageBackend }); + + expect(setStorageBackend).toHaveBeenCalledTimes(1); + expect(setImageSrcResolver).not.toHaveBeenCalled(); + expect(setAITransport).not.toHaveBeenCalled(); + expect(loadFromBackend).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/configure.ts b/packages/ui/configure.ts new file mode 100644 index 000000000..4d4ad3c1a --- /dev/null +++ b/packages/ui/configure.ts @@ -0,0 +1,46 @@ +import { setImageSrcResolver, type ImageSrcResolver } from './components/ImageThumbnail'; +import { setDocPreviewFetcher, type DocPreviewFetcher } from './components/InlineMarkdown'; +import { setStorageBackend, type StorageBackend } from './utils/storage'; +import { setIdentityProvider, type IdentityProvider } from './utils/identity'; +import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser'; +import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft'; +import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; +import { setAITransport, type AITransport } from './hooks/useAIChat'; +import { configStore } from './config'; + +type ExternalAnnotationBase = { id: string; source?: string }; +type ServerSyncFn = (payload: Record) => void; + +export interface PlannotatorUIConfig { + imageSrcResolver?: ImageSrcResolver; + storageBackend?: StorageBackend; + docPreviewFetcher?: DocPreviewFetcher; + fileTreeBackend?: FileTreeBackend; + identityProvider?: IdentityProvider; + draftTransport?: DraftTransport; + /** + * Base-constraint transport. If your annotation type extends the base + * constraint ({ id: string; source?: string }) with extra fields, call + * setExternalAnnotationTransport() directly for full type safety — + * this front-door field intentionally pins the base constraint for ergonomics. + */ + externalAnnotationTransport?: ExternalAnnotationTransport; + aiTransport?: AITransport; + serverSync?: ServerSyncFn; + /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */ + loadSettingsFromBackend?: boolean; +} + +export function configurePlannotatorUI(config: PlannotatorUIConfig): void { + if (config.imageSrcResolver) setImageSrcResolver(config.imageSrcResolver); + if (config.storageBackend) setStorageBackend(config.storageBackend); + if (config.docPreviewFetcher) setDocPreviewFetcher(config.docPreviewFetcher); + if (config.fileTreeBackend) setFileTreeBackend(config.fileTreeBackend); + if (config.identityProvider) setIdentityProvider(config.identityProvider); + if (config.draftTransport) setDraftTransport(config.draftTransport); + if (config.externalAnnotationTransport) setExternalAnnotationTransport(config.externalAnnotationTransport); + if (config.aiTransport) setAITransport(config.aiTransport); + if (config.serverSync) configStore.setServerSync(config.serverSync); + // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last). + if (config.loadSettingsFromBackend) configStore.loadFromBackend(); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 754e636b5..6399a0206 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -16,6 +16,7 @@ "./hooks/*": "./hooks/*.ts", "./shortcuts": "./shortcuts/index.ts", "./config": "./config/index.ts", + "./configure": "./configure.ts", "./types": "./types.ts", "./theme": "./theme.css" }, @@ -33,6 +34,7 @@ "sprite_package_new", "sprite_package_pulluphang", "globals.d.ts", + "configure.ts", "types.ts", "theme.css", "print.css", From 7602889caa36f1aae6bd7ddb9f5f3362209f97ec Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 15:40:44 -0700 Subject: [PATCH 35/67] build(ui): precompiled styles.css CSS build + madge circular-dep check (Phase 7 step 5) --- .gitignore | 4 ++++ .madgerc | 1 + package.json | 7 +++++-- packages/ui/package.json | 12 +++++++++--- packages/ui/styles-entry.css | 11 +++++++++++ packages/ui/vite.css.config.ts | 19 +++++++++++++++++++ 6 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 .madgerc create mode 100644 packages/ui/styles-entry.css create mode 100644 packages/ui/vite.css.config.ts diff --git a/.gitignore b/.gitignore index 999c66367..e13177e54 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,7 @@ plannotator-local # Local Pi state/memory (not upstream) /.pi/ + +# @plannotator/ui CSS build artifacts (generated by prepublishOnly — not committed) +packages/ui/styles.css +packages/ui/styles.js diff --git a/.madgerc b/.madgerc new file mode 100644 index 000000000..b86f0df03 --- /dev/null +++ b/.madgerc @@ -0,0 +1 @@ +{ "extensions": ["ts", "tsx"], "fileExtensions": ["ts", "tsx"] } diff --git a/package.json b/package.json index 6f8282ffe..577786872 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bun test", - "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" + "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json", + "build:ui-css": "bun run --cwd packages/ui build:css", + "check:cycles": "madge --circular --extensions ts,tsx --ts-config packages/core/tsconfig.json packages/core && madge --circular --extensions ts,tsx --ts-config packages/ui/tsconfig.json packages/ui" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", @@ -48,6 +50,7 @@ "devDependencies": { "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", - "bun-types": "^1.3.11" + "bun-types": "^1.3.11", + "madge": "^8.0.0" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 6399a0206..30439fea2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,7 +18,8 @@ "./config": "./config/index.ts", "./configure": "./configure.ts", "./types": "./types.ts", - "./theme": "./theme.css" + "./theme": "./theme.css", + "./styles.css": "./styles.css" }, "files": [ "components", @@ -38,6 +39,7 @@ "types.ts", "theme.css", "print.css", + "styles.css", "plannotator.webp", "!**/*.test.ts", "!**/*.test.tsx", @@ -95,6 +97,7 @@ }, "devDependencies": { "@happy-dom/global-registrator": "^20.10.1", + "@tailwindcss/vite": "^4.1.18", "@types/bun": "^1.2.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", @@ -102,9 +105,12 @@ "react-dom": "^19.2.3", "tailwindcss": "^4.1.18", "tailwindcss-animate": "^1.0.7", - "typescript": "~5.8.2" + "typescript": "~5.8.2", + "vite": "^6.2.0" }, "scripts": { - "typecheck": "tsc --noEmit -p tsconfig.json" + "typecheck": "tsc --noEmit -p tsconfig.json", + "build:css": "vite build --config vite.css.config.ts && rm -f styles.js", + "prepublishOnly": "bun run build:css" } } diff --git a/packages/ui/styles-entry.css b/packages/ui/styles-entry.css new file mode 100644 index 000000000..7cba7f178 --- /dev/null +++ b/packages/ui/styles-entry.css @@ -0,0 +1,11 @@ +@import "@fontsource-variable/inter"; +@import "@fontsource-variable/geist-mono"; +@import "tailwindcss"; + +@plugin "tailwindcss-animate"; + +@source "./components/**/*.tsx"; +@source "./hooks/**/*.ts"; +@source "./utils/**/*.ts"; + +@import "./theme.css"; diff --git a/packages/ui/vite.css.config.ts b/packages/ui/vite.css.config.ts new file mode 100644 index 000000000..80300db2b --- /dev/null +++ b/packages/ui/vite.css.config.ts @@ -0,0 +1,19 @@ +import path from 'path'; +import { defineConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [tailwindcss()], + resolve: { alias: { '@plannotator/ui': path.resolve(__dirname, '.') } }, + build: { + lib: { + entry: path.resolve(__dirname, 'styles-entry.css'), + formats: ['es'], + fileName: () => 'styles.js', + }, + outDir: '.', + cssCodeSplit: true, + rollupOptions: { output: { assetFileNames: 'styles.css' } }, + emptyOutDir: false, + }, +}); From 1c072da40d3f26c8033c81c57044af90afe8e88a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 16:05:52 -0700 Subject: [PATCH 36/67] test(ui): per-seam override tests + configure routing test (Phase 7 step 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add one override test per seam (setX(fake)→drive→assert→resetX()) for all 9 seams + loadFromBackend, modeled after the existing seam test pattern. Fix configure.test.ts to defer mock.module() into beforeAll and restore with captured real function references in afterAll so sibling seam test files are not poisoned by spy replacements in the shared Bun worker module registry. --- .../components/ImageThumbnail.seam.test.tsx | 103 +++++++++++ .../components/InlineMarkdown.seam.test.tsx | 121 +++++++++++++ packages/ui/config/configStore.seam.test.ts | 110 ++++++++++++ packages/ui/configure.test.ts | 151 +++++++++++++--- packages/ui/hooks/useAIChat.seam.test.tsx | 151 ++++++++++++++++ .../ui/hooks/useAnnotationDraft.seam.test.tsx | 170 ++++++++++++++++++ .../useExternalAnnotations.seam.test.tsx | 156 ++++++++++++++++ .../ui/hooks/useFileBrowser.seam.test.tsx | 136 ++++++++++++++ packages/ui/utils/identity.seam.test.ts | 77 ++++++++ packages/ui/utils/storage.seam.test.ts | 97 ++++++++++ 10 files changed, 1251 insertions(+), 21 deletions(-) create mode 100644 packages/ui/components/ImageThumbnail.seam.test.tsx create mode 100644 packages/ui/components/InlineMarkdown.seam.test.tsx create mode 100644 packages/ui/config/configStore.seam.test.ts create mode 100644 packages/ui/hooks/useAIChat.seam.test.tsx create mode 100644 packages/ui/hooks/useAnnotationDraft.seam.test.tsx create mode 100644 packages/ui/hooks/useExternalAnnotations.seam.test.tsx create mode 100644 packages/ui/hooks/useFileBrowser.seam.test.tsx create mode 100644 packages/ui/utils/identity.seam.test.ts create mode 100644 packages/ui/utils/storage.seam.test.ts diff --git a/packages/ui/components/ImageThumbnail.seam.test.tsx b/packages/ui/components/ImageThumbnail.seam.test.tsx new file mode 100644 index 000000000..56270357b --- /dev/null +++ b/packages/ui/components/ImageThumbnail.seam.test.tsx @@ -0,0 +1,103 @@ +/** + * Seam test: ImageSrcResolver override (setImageSrcResolver / resetImageSrcResolver). + * + * Contract: after setImageSrcResolver(fake), getImageSrc() (and thus the + * rendered img src) uses the fake resolver instead of the + * default /api/image endpoint. + * + * Primary assertion is via getImageSrc() (no DOM needed for the core contract). + * The DOM render assertion validates that the component wires getImageSrc. + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as ImageThumbnailModule from './ImageThumbnail'; + +// Capture real function references at import time (before configure.test.ts's +// mock.module() runs and replaces setImageSrcResolver with a no-op spy). +const setImageSrcResolver = ImageThumbnailModule.setImageSrcResolver; +const resetImageSrcResolver = ImageThumbnailModule.resetImageSrcResolver; +const getImageSrc = ImageThumbnailModule.getImageSrc; + +const hasDom = typeof document !== 'undefined'; + +afterEach(() => { + resetImageSrcResolver(); + if (hasDom) document.body.innerHTML = ''; +}); + +describe('ImageSrcResolver seam', () => { + test('fake resolver is called with the image path (via getImageSrc)', () => { + const calls: string[] = []; + const fakeResolver = (p: string) => { + calls.push(p); + return `https://cdn.example.com/images/${encodeURIComponent(p)}`; + }; + + setImageSrcResolver(fakeResolver); + + const result = getImageSrc('/foo/img.png'); + + expect(calls).toContain('/foo/img.png'); + expect(result).toContain('cdn.example.com'); + expect(result).toContain(encodeURIComponent('/foo/img.png')); + }); + + test('fake resolver receives the base parameter when provided', () => { + const calls: Array<{ path: string; base?: string }> = []; + const fake = (p: string, b?: string) => { + calls.push({ path: p, base: b }); + return `https://cdn.example.com/${p}`; + }; + + setImageSrcResolver(fake); + getImageSrc('relative/img.png', '/base/dir'); + + expect(calls[0]).toEqual({ path: 'relative/img.png', base: '/base/dir' }); + }); + + test('resetImageSrcResolver restores the default /api/image behavior', () => { + const fake = (p: string) => `https://cdn.example.com/${p}`; + setImageSrcResolver(fake); + resetImageSrcResolver(); + + // After reset, the default resolver builds /api/image?path=... URLs for local paths. + const result = getImageSrc('/my/photo.jpg'); + + expect(result).toContain('/api/image'); + expect(result).toContain(encodeURIComponent('/my/photo.jpg')); + expect(result).not.toContain('cdn.example.com'); + }); + + test('default resolver passes through remote URLs unchanged', () => { + // resetImageSrcResolver already called in afterEach; still on default after reset. + const remote = 'https://upload.example.com/images/foo.png'; + const result = getImageSrc(remote); + expect(result).toBe(remote); + }); + + test.skipIf(!hasDom)('rendered img src reflects the installed fake resolver', async () => { + const React = (await import('react')).default; + const { createRoot } = await import('react-dom/client'); + const { act } = await import('react'); + const { ImageThumbnail } = await import('./ImageThumbnail'); + + const calls: string[] = []; + setImageSrcResolver((p) => { calls.push(p); return `https://cdn.test/${encodeURIComponent(p)}`; }); + + const host = document.createElement('div'); + document.body.appendChild(host); + await act(async () => { + const root = createRoot(host); + root.render(React.createElement(ImageThumbnail, { path: '/foo/img.png' })); + }); + + const img = host.querySelector('img'); + const src = img?.getAttribute('src') ?? ''; + + expect(calls).toContain('/foo/img.png'); + expect(src).toContain('cdn.test'); + }); +}); diff --git a/packages/ui/components/InlineMarkdown.seam.test.tsx b/packages/ui/components/InlineMarkdown.seam.test.tsx new file mode 100644 index 000000000..bd19de17d --- /dev/null +++ b/packages/ui/components/InlineMarkdown.seam.test.tsx @@ -0,0 +1,121 @@ +/** + * Seam test: DocPreviewFetcher override (setDocPreviewFetcher / resetDocPreviewFetcher). + * + * Contract: after setDocPreviewFetcher(fake), code-file hover previews use the + * fake fetcher instead of the default /api/doc. resetDocPreviewFetcher() restores + * the default. + * + * Requires DOM (happy-dom) — runs under bun test (preloaded via bunfig.toml). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import * as InlineMarkdownModule from './InlineMarkdown'; + +// Capture real function references at import time (before configure.test.ts's +// mock.module() runs and replaces setDocPreviewFetcher with a no-op spy). +const setDocPreviewFetcher = InlineMarkdownModule.setDocPreviewFetcher; +const resetDocPreviewFetcher = InlineMarkdownModule.resetDocPreviewFetcher; +const InlineMarkdown = InlineMarkdownModule.InlineMarkdown; + +const hasDom = typeof document !== 'undefined'; + +afterEach(() => { + resetDocPreviewFetcher(); + if (hasDom) document.body.innerHTML = ''; +}); + +// The DocPreviewFetcher seam is exercised by the CodeFileLink component when its +// anchor element receives a mouseenter. Render a code-file path reference +// (src/index.ts:10 — has a line number so the hover is enabled) and fire the event. + +describe('DocPreviewFetcher seam', () => { + test.skipIf(!hasDom)('fake fetcher is called with the code-file path on hover', async () => { + const calls: Array<{ path: string; base?: string }> = []; + const fakeFetcher = async (path: string, base?: string) => { + calls.push({ path, base }); + return { contents: '// fake content', filepath: path }; + }; + + setDocPreviewFetcher(fakeFetcher); + + const host = document.createElement('div'); + document.body.appendChild(host); + let root: Root; + await act(async () => { + root = createRoot(host); + root.render( + {}} + />, + ); + }); + + // Find the rendered code-file link and fire the hover event. + // CodeFileLink renders a element with onMouseEnter. + // React 19 in happy-dom triggers onMouseEnter via mouseover (not mouseenter). + const codeLink = host.querySelector('code[role="button"]') as HTMLElement | null; + if (codeLink) { + await act(async () => { + // mouseover triggers React's onMouseEnter in happy-dom/React 19. + codeLink.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + // The hover delay is 150 ms; wait for it. + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + } + + // The fake fetcher must have been invoked with the code path. + expect(calls.length).toBeGreaterThan(0); + expect(calls[0].path).toContain('src/index.ts'); + }); + + test.skipIf(!hasDom)('resetDocPreviewFetcher restores the /api/doc default (does not call the fake)', async () => { + const calls: string[] = []; + const fake = async (path: string) => { calls.push(path); return null; }; + + setDocPreviewFetcher(fake); + resetDocPreviewFetcher(); + + // After reset, install a fetch spy so the default hits /api/doc + const fetchCalls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(String(input)); + return new Response(JSON.stringify(null), { status: 200 }); + }) as typeof fetch; + + const host = document.createElement('div'); + document.body.appendChild(host); + let root: Root; + await act(async () => { + root = createRoot(host); + root.render( + {}} + />, + ); + }); + + const codeLink = host.querySelector('code[role="button"]') as HTMLElement | null; + if (codeLink) { + await act(async () => { + // Use mouseover — triggers React's onMouseEnter in happy-dom/React 19. + codeLink.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + } + + globalThis.fetch = realFetch; + + // Fake was NOT called (the reset restored the default); + // the default fetcher would have called /api/doc instead. + expect(calls).toHaveLength(0); + }); +}); diff --git a/packages/ui/config/configStore.seam.test.ts b/packages/ui/config/configStore.seam.test.ts new file mode 100644 index 000000000..4d2fb0b90 --- /dev/null +++ b/packages/ui/config/configStore.seam.test.ts @@ -0,0 +1,110 @@ +/** + * Seam tests: ConfigStore server-sync override + loadFromBackend re-hydration. + * + * Test 1 (serverSync seam): configStore.set on a server-synced key calls the + * installed sync fn instead of the default POST /api/config. + * resetServerSync() restores the default fn. + * + * Test 2 (loadFromBackend seam): install a fake StorageBackend with a + * prefetched setting, call loadFromBackend() — the store now returns the + * prefetched value. + * + * No DOM required. + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import { configStore } from './index'; +import * as storageModule from '../utils/storage'; + +// Capture real storage functions at import time (before configure.test.ts's +// mock.module('./utils/storage', ...) replaces them with no-op spies). +const setStorageBackend = storageModule.setStorageBackend; +const resetStorageBackend = storageModule.resetStorageBackend; + +afterEach(() => { + configStore.resetServerSync(); + resetStorageBackend(); +}); + +// --------------------------------------------------------------------------- +// 1. serverSync seam +// --------------------------------------------------------------------------- +describe('configStore.setServerSync seam', () => { + it('routes server write-back through the installed sync fn', async () => { + const synced: Array> = []; + const fakeSync = (payload: Record) => { + synced.push(payload); + }; + + configStore.setServerSync(fakeSync); + + // 'displayName' is a server-synced setting (serverKey: 'displayName') + configStore.set('displayName', 'test-tater'); + + // Server write-back is debounced at 300 ms — wait for the timer to fire. + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(synced.length).toBeGreaterThanOrEqual(1); + // The payload must contain the displayName key from toServer(). + const merged = Object.assign({}, ...synced) as Record; + expect(merged).toHaveProperty('displayName', 'test-tater'); + }); + + it('resetServerSync() restores the default fn (no longer calls the fake)', async () => { + const fakeCalled: boolean[] = []; + configStore.setServerSync((_: Record) => { fakeCalled.push(true); }); + configStore.resetServerSync(); + + configStore.set('displayName', 'another-tater'); + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(fakeCalled).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 2. loadFromBackend seam +// --------------------------------------------------------------------------- +describe('configStore.loadFromBackend seam', () => { + it('re-hydrates settings from the installed StorageBackend', () => { + // Install a fake StorageBackend that returns a specific displayName. + // settings.ts reads 'plannotator-identity' for the displayName setting. + const prefetched = new Map([ + ['plannotator-identity', 'prefetched-workspace-user'], + ]); + const fakeBackend = { + getItem: (key: string) => prefetched.get(key) ?? null, + setItem: () => {}, + removeItem: () => {}, + }; + + setStorageBackend(fakeBackend); + configStore.loadFromBackend(); + + // The store should now reflect the prefetched value. + expect(configStore.get('displayName')).toBe('prefetched-workspace-user'); + }); + + it('keys absent from the backend are left at their prior value (not overwritten with undefined)', () => { + // First set a known value for 'displayName'. + configStore.set('displayName', 'prior-value'); + + // Install a backend that returns null for every key (simulates a backend + // that has no opinion on this setting). + const emptyBackend = { + getItem: (_key: string) => null, + setItem: () => {}, + removeItem: () => {}, + }; + + setStorageBackend(emptyBackend); + configStore.loadFromBackend(); + + // 'prior-value' must be preserved — loadFromBackend only overwrites when + // fromCookie() returns a non-undefined result. + expect(configStore.get('displayName')).toBe('prior-value'); + }); +}); diff --git a/packages/ui/configure.test.ts b/packages/ui/configure.test.ts index 625dc6be1..3423e9b6d 100644 --- a/packages/ui/configure.test.ts +++ b/packages/ui/configure.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, it, mock, spyOn } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, it, mock, spyOn } from 'bun:test'; import * as ImageThumbnail from './components/ImageThumbnail'; import * as InlineMarkdown from './components/InlineMarkdown'; @@ -19,10 +19,28 @@ import type { DraftTransport } from './hooks/useAnnotationDraft'; import type { ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; import type { AITransport } from './hooks/useAIChat'; -// Spy on each setter. We re-export the REAL module verbatim and override ONLY the -// setter with a spy, so other test files importing other exports stay intact -// (Bun's mock.module replacement is process-global — dropping exports would break -// sibling suites). The spies route to no-ops; we assert configure wired them. +// Capture the REAL exports at module-evaluation time (top-level, before any +// mock.module() is installed). These are used to restore the module registry +// in afterAll so that any sibling test files run in the same Bun worker see +// the real exports when THEY evaluate after this file finishes. +const realSetImageSrcResolver = ImageThumbnail.setImageSrcResolver; +const realResetImageSrcResolver = ImageThumbnail.resetImageSrcResolver; +const realSetDocPreviewFetcher = InlineMarkdown.setDocPreviewFetcher; +const realResetDocPreviewFetcher = InlineMarkdown.resetDocPreviewFetcher; +const realSetStorageBackend = storage.setStorageBackend; +const realResetStorageBackend = storage.resetStorageBackend; +const realSetIdentityProvider = identity.setIdentityProvider; +const realResetIdentityProvider = identity.resetIdentityProvider; +const realSetFileTreeBackend = useFileBrowser.setFileTreeBackend; +const realResetFileTreeBackend = useFileBrowser.resetFileTreeBackend; +const realSetDraftTransport = useAnnotationDraft.setDraftTransport; +const realResetDraftTransport = useAnnotationDraft.resetDraftTransport; +const realSetExternalAnnotationTransport = useExternalAnnotations.setExternalAnnotationTransport; +const realResetExternalAnnotationTransport = useExternalAnnotations.resetExternalAnnotationTransport; +const realSetAITransport = useAIChat.setAITransport; +const realResetAITransport = useAIChat.resetAITransport; + +// Spy mocks — will be installed into the module registry in beforeAll. const setImageSrcResolver = mock((_: ImageSrcResolver) => {}); const setDocPreviewFetcher = mock((_: DocPreviewFetcher) => {}); const setStorageBackend = mock((_: StorageBackend) => {}); @@ -32,22 +50,11 @@ const setDraftTransport = mock((_: DraftTransport) => {}); const setExternalAnnotationTransport = mock((_: ExternalAnnotationTransport<{ id: string; source?: string }>) => {}); const setAITransport = mock((_: AITransport) => {}); -mock.module('./components/ImageThumbnail', () => ({ ...ImageThumbnail, setImageSrcResolver })); -mock.module('./components/InlineMarkdown', () => ({ ...InlineMarkdown, setDocPreviewFetcher })); -mock.module('./utils/storage', () => ({ ...storage, setStorageBackend })); -mock.module('./utils/identity', () => ({ ...identity, setIdentityProvider })); -mock.module('./hooks/useFileBrowser', () => ({ ...useFileBrowser, setFileTreeBackend })); -mock.module('./hooks/useAnnotationDraft', () => ({ ...useAnnotationDraft, setDraftTransport })); -mock.module('./hooks/useExternalAnnotations', () => ({ ...useExternalAnnotations, setExternalAnnotationTransport })); -mock.module('./hooks/useAIChat', () => ({ ...useAIChat, setAITransport })); - // configStore is shared with sibling suites — spy on the real instance methods -// (restored in afterAll) instead of replacing the ./config module. +// instead of replacing the ./config module. const setServerSync = spyOn(configStore, 'setServerSync'); const loadFromBackend = spyOn(configStore, 'loadFromBackend').mockImplementation(() => {}); -const { configurePlannotatorUI } = await import('./configure'); - // Shape-correct fakes (only need to satisfy the front door's optional fields). const imageSrcResolver: ImageSrcResolver = (path) => path; const docPreviewFetcher: DocPreviewFetcher = async () => null; @@ -79,10 +86,110 @@ const aiTransport: AITransport = { }; const serverSync = (_payload: Record) => {}; -afterAll(() => mock.restore()); - describe('configurePlannotatorUI routing', () => { - it('routes each provided seam to its underlying setter', () => { + // Install mock.module() replacements HERE (in beforeAll, not at top-level) + // so that sibling seam test files' top-level captures (which happen at module + // evaluation time, BEFORE this beforeAll runs) see the real exports. + // + // Bun runs test files sequentially: file A's top-level → file A's tests + // (including beforeAll/afterAll) → file B's top-level → file B's tests. + // Because configure.test.ts runs before the seam test files (alphabetical), + // the seam files evaluate AFTER this file's afterAll — so they see whatever + // state this afterAll leaves the module registry in. We MUST restore with + // captured real functions (not `{ ...storage }`) because spreading the live + // namespace after mock.module() returns the mocked version. + beforeAll(async () => { + mock.module('./components/ImageThumbnail', () => ({ + ...ImageThumbnail, + setImageSrcResolver, + resetImageSrcResolver: realResetImageSrcResolver, + })); + mock.module('./components/InlineMarkdown', () => ({ + ...InlineMarkdown, + setDocPreviewFetcher, + resetDocPreviewFetcher: realResetDocPreviewFetcher, + })); + mock.module('./utils/storage', () => ({ + ...storage, + setStorageBackend, + resetStorageBackend: realResetStorageBackend, + })); + mock.module('./utils/identity', () => ({ + ...identity, + setIdentityProvider, + resetIdentityProvider: realResetIdentityProvider, + })); + mock.module('./hooks/useFileBrowser', () => ({ + ...useFileBrowser, + setFileTreeBackend, + resetFileTreeBackend: realResetFileTreeBackend, + })); + mock.module('./hooks/useAnnotationDraft', () => ({ + ...useAnnotationDraft, + setDraftTransport, + resetDraftTransport: realResetDraftTransport, + })); + mock.module('./hooks/useExternalAnnotations', () => ({ + ...useExternalAnnotations, + setExternalAnnotationTransport, + resetExternalAnnotationTransport: realResetExternalAnnotationTransport, + })); + mock.module('./hooks/useAIChat', () => ({ + ...useAIChat, + setAITransport, + resetAITransport: realResetAITransport, + })); + }); + + afterAll(() => { + mock.restore(); + // Restore using CAPTURED REAL FUNCTIONS (not `{ ...storage }` which would + // spread the mocked namespace and leave spies in place for sibling files). + mock.module('./components/ImageThumbnail', () => ({ + ...ImageThumbnail, + setImageSrcResolver: realSetImageSrcResolver, + resetImageSrcResolver: realResetImageSrcResolver, + })); + mock.module('./components/InlineMarkdown', () => ({ + ...InlineMarkdown, + setDocPreviewFetcher: realSetDocPreviewFetcher, + resetDocPreviewFetcher: realResetDocPreviewFetcher, + })); + mock.module('./utils/storage', () => ({ + ...storage, + setStorageBackend: realSetStorageBackend, + resetStorageBackend: realResetStorageBackend, + })); + mock.module('./utils/identity', () => ({ + ...identity, + setIdentityProvider: realSetIdentityProvider, + resetIdentityProvider: realResetIdentityProvider, + })); + mock.module('./hooks/useFileBrowser', () => ({ + ...useFileBrowser, + setFileTreeBackend: realSetFileTreeBackend, + resetFileTreeBackend: realResetFileTreeBackend, + })); + mock.module('./hooks/useAnnotationDraft', () => ({ + ...useAnnotationDraft, + setDraftTransport: realSetDraftTransport, + resetDraftTransport: realResetDraftTransport, + })); + mock.module('./hooks/useExternalAnnotations', () => ({ + ...useExternalAnnotations, + setExternalAnnotationTransport: realSetExternalAnnotationTransport, + resetExternalAnnotationTransport: realResetExternalAnnotationTransport, + })); + mock.module('./hooks/useAIChat', () => ({ + ...useAIChat, + setAITransport: realSetAITransport, + resetAITransport: realResetAITransport, + })); + }); + + it('routes each provided seam to its underlying setter', async () => { + const { configurePlannotatorUI } = await import('./configure'); + configurePlannotatorUI({ imageSrcResolver, storageBackend, @@ -113,7 +220,9 @@ describe('configurePlannotatorUI routing', () => { ); }); - it('skips setters for omitted fields', () => { + it('skips setters for omitted fields', async () => { + const { configurePlannotatorUI } = await import('./configure'); + [ setImageSrcResolver, setDocPreviewFetcher, setStorageBackend, setIdentityProvider, setFileTreeBackend, setDraftTransport, setExternalAnnotationTransport, setAITransport, diff --git a/packages/ui/hooks/useAIChat.seam.test.tsx b/packages/ui/hooks/useAIChat.seam.test.tsx new file mode 100644 index 000000000..ba1ee5864 --- /dev/null +++ b/packages/ui/hooks/useAIChat.seam.test.tsx @@ -0,0 +1,151 @@ +/** + * Seam test: AITransport override (setAITransport / resetAITransport). + * + * Contract: + * - After setAITransport(fake), useAIChat.ask() routes the session + query + * calls through the fake transport — NOT through /api/ai/*. + * - resetAITransport() restores the default. + * + * Requires DOM — runs under bun test (preloaded via bunfig.toml). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import * as useAIChatModule from './useAIChat'; +import type { AITransport } from './useAIChat'; +import type { AIContext } from '@plannotator/core'; + +// Capture real function references at import time. +const setAITransport = useAIChatModule.setAITransport; +const resetAITransport = useAIChatModule.resetAITransport; +const useAIChat = useAIChatModule.useAIChat; + +const hasDom = typeof document !== 'undefined'; + +afterEach(() => { + resetAITransport(); + if (hasDom) document.body.innerHTML = ''; +}); + +type HookResult = ReturnType; + +function Harness({ resultRef, context }: { resultRef: { current: HookResult | null }; context: AIContext | null }) { + resultRef.current = useAIChat({ context }); + return null; +} + +const TEST_CONTEXT: AIContext = { + mode: 'plan-review', + plan: { plan: 'Test plan content' }, +}; + +function makeSseResponse(textDelta: string): Response { + const body = `data: {"type":"text_delta","delta":"${textDelta}"}\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); +} + +async function mountHook(context: AIContext | null): Promise<{ + result: { current: HookResult | null }; + unmount: () => Promise; +}> { + const host = document.createElement('div'); + document.body.appendChild(host); + const resultRef: { current: HookResult | null } = { current: null }; + let root: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + return { + result: resultRef, + unmount: async () => { + await act(async () => { root.unmount(); }); + host.remove(); + }, + }; +} + +describe('AITransport seam', () => { + test.skipIf(!hasDom)('fake session + query are called when useAIChat.ask() is invoked', async () => { + const sessionBodies: unknown[] = []; + const queryBodies: unknown[] = []; + + const fakeTransport: AITransport = { + session: async (body, _signal) => { + sessionBodies.push(body); + return new Response(JSON.stringify({ sessionId: 'fake-session-001' }), { status: 200 }); + }, + query: async (body, _signal) => { + queryBodies.push(body); + return makeSseResponse('hello'); + }, + abort: () => {}, + permission: () => {}, + }; + + setAITransport(fakeTransport); + + const session = await mountHook(TEST_CONTEXT); + + await act(async () => { + await session.result.current!.ask({ prompt: 'What is this plan about?' }); + }); + + // Allow SSE reader to drain + await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + expect(sessionBodies.length).toBeGreaterThanOrEqual(1); + expect(queryBodies.length).toBeGreaterThanOrEqual(1); + const qb = queryBodies[0] as Record; + expect(qb.sessionId).toBe('fake-session-001'); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('resetAITransport restores the default (does not call the fake)', async () => { + const fakeCalls: string[] = []; + const fake: AITransport = { + session: async () => { fakeCalls.push('session'); return new Response('{}', { status: 200 }); }, + query: async () => { fakeCalls.push('query'); return new Response('', { status: 200 }); }, + abort: () => { fakeCalls.push('abort'); }, + permission: () => { fakeCalls.push('permission'); }, + }; + + setAITransport(fake); + resetAITransport(); + + // After reset, the default transport issues a real fetch to /api/ai/session. + const fetchCalls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(String(input)); + return new Response(JSON.stringify({ sessionId: 'real-session' }), { status: 200 }); + }) as typeof fetch; + + const session = await mountHook(TEST_CONTEXT); + + await act(async () => { + // Fire-and-forget: we just want to trigger the session creation path. + session.result.current!.ask({ prompt: 'test' }).catch(() => {}); + }); + + // Give the async session call a tick to fire. + await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + globalThis.fetch = realFetch; + + // Fake was NOT called; the default made a fetch to /api/ai/session. + expect(fakeCalls).toHaveLength(0); + expect(fetchCalls.some((u) => u.includes('/api/ai/session'))).toBe(true); + + await session.unmount(); + }); +}); diff --git a/packages/ui/hooks/useAnnotationDraft.seam.test.tsx b/packages/ui/hooks/useAnnotationDraft.seam.test.tsx new file mode 100644 index 000000000..ff1ec18c9 --- /dev/null +++ b/packages/ui/hooks/useAnnotationDraft.seam.test.tsx @@ -0,0 +1,170 @@ +/** + * Seam test: DraftTransport override (setDraftTransport / resetDraftTransport). + * + * Contract: + * - fake.load() is called on mount (isApiMode: true, not shared). + * - fake.save() is called after scheduleDraftSave() fires (annotations non-empty). + * - resetDraftTransport() restores the default transport (does not call the fake). + * + * Requires DOM — runs under bun test (preloaded via bunfig.toml). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import * as useAnnotationDraftModule from './useAnnotationDraft'; +import { AnnotationType, type Annotation } from '../types'; +import type { DraftTransport } from './useAnnotationDraft'; + +// Capture real function references at import time. +const setDraftTransport = useAnnotationDraftModule.setDraftTransport; +const resetDraftTransport = useAnnotationDraftModule.resetDraftTransport; +const useAnnotationDraft = useAnnotationDraftModule.useAnnotationDraft; + +const hasDom = typeof document !== 'undefined'; + +afterEach(() => { + resetDraftTransport(); + if (hasDom) document.body.innerHTML = ''; +}); + +function makeFakeTransport(): { transport: DraftTransport; state: { loaded: number; saved: object[] } } { + const state = { loaded: 0, saved: [] as object[] }; + const transport: DraftTransport = { + load: async () => { + state.loaded++; + return { data: null, generation: null }; + }, + save: async (body: object) => { + state.saved.push(body); + }, + remove: async () => {}, + }; + return { transport, state }; +} + +const ANNOTATION: Annotation = { + id: 'ann-seam-1', + blockId: 'block-1', + startOffset: 0, + endOffset: 4, + type: AnnotationType.COMMENT, + text: 'seam check', + originalText: 'Test', + createdA: Date.now(), +}; + +type HookOptions = Parameters[0]; +type HookResult = ReturnType; + +function Harness({ opts, resultRef }: { opts: HookOptions; resultRef: { current: HookResult | null } }) { + resultRef.current = useAnnotationDraft(opts); + return null; +} + +async function mountHook(opts: HookOptions): Promise<{ + result: { current: HookResult | null }; + unmount: () => Promise; +}> { + const host = document.createElement('div'); + document.body.appendChild(host); + const resultRef: { current: HookResult | null } = { current: null }; + let root: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + return { + result: resultRef, + unmount: async () => { + await act(async () => { root.unmount(); }); + host.remove(); + }, + }; +} + +const tick = (ms: number) => act(async () => new Promise((r) => setTimeout(r, ms))); + +describe('DraftTransport seam', () => { + test.skipIf(!hasDom)('fake.load() is called on mount when isApiMode is true', async () => { + const { transport, state } = makeFakeTransport(); + setDraftTransport(transport); + + const session = await mountHook({ + annotations: [], + globalAttachments: [], + isApiMode: true, + isSharedSession: false, + submitted: false, + }); + + // Give the async load a moment to settle. + await tick(50); + + expect(state.loaded).toBeGreaterThanOrEqual(1); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('fake.save() is called when scheduleDraftSave fires with annotations', async () => { + const { transport, state } = makeFakeTransport(); + setDraftTransport(transport); + + const session = await mountHook({ + annotations: [ANNOTATION], + globalAttachments: [], + isApiMode: true, + isSharedSession: false, + submitted: false, + }); + + await tick(50); // let the mount-load settle + hasMountedRef = true + + await act(async () => { + session.result.current!.scheduleDraftSave(); + }); + + // scheduleDraftSave has a 500 ms debounce; wait for it to fire. + await tick(600); + + expect(state.saved.length).toBeGreaterThanOrEqual(1); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('resetDraftTransport restores the default (does not call the fake)', async () => { + const { transport, state } = makeFakeTransport(); + setDraftTransport(transport); + resetDraftTransport(); + + // After reset, the default transport hits /api/draft — install a fetch spy. + const fetchCalls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(String(input)); + return new Response(JSON.stringify({ found: false }), { status: 404 }); + }) as typeof fetch; + + const session = await mountHook({ + annotations: [], + globalAttachments: [], + isApiMode: true, + isSharedSession: false, + submitted: false, + }); + + await tick(50); + + globalThis.fetch = realFetch; + + // Fake was NOT called; the default transport hit /api/draft. + expect(state.loaded).toBe(0); + expect(fetchCalls.some((u) => u.includes('/api/draft'))).toBe(true); + + await session.unmount(); + }); +}); diff --git a/packages/ui/hooks/useExternalAnnotations.seam.test.tsx b/packages/ui/hooks/useExternalAnnotations.seam.test.tsx new file mode 100644 index 000000000..dcac481a4 --- /dev/null +++ b/packages/ui/hooks/useExternalAnnotations.seam.test.tsx @@ -0,0 +1,156 @@ +/** + * Seam test: ExternalAnnotationTransport override + * (setExternalAnnotationTransport / resetExternalAnnotationTransport). + * + * Contract: + * - On mount (enabled: true) → fake.subscribe() is called. + * - deleteExternalAnnotation() → fake.remove() called on the SAME transport + * instance (pins the already-landed split-transport fix: transportRef captures + * the transport once at mount, so CRUD and subscribe use the same backend). + * - resetExternalAnnotationTransport() restores the default. + * + * Requires DOM — runs under bun test (preloaded via bunfig.toml). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import * as useExternalAnnotationsModule from './useExternalAnnotations'; +import type { ExternalAnnotationTransport } from './useExternalAnnotations'; + +// Capture real function references at import time. +const setExternalAnnotationTransport = useExternalAnnotationsModule.setExternalAnnotationTransport; +const resetExternalAnnotationTransport = useExternalAnnotationsModule.resetExternalAnnotationTransport; +const useExternalAnnotations = useExternalAnnotationsModule.useExternalAnnotations; + +const hasDom = typeof document !== 'undefined'; + +afterEach(() => { + resetExternalAnnotationTransport(); + if (hasDom) document.body.innerHTML = ''; +}); + +type TestAnnotation = { id: string; source?: string }; + +type HookResult = ReturnType>; + +function Harness({ + resultRef, + enabled = true, +}: { + resultRef: { current: HookResult | null }; + enabled?: boolean; +}) { + resultRef.current = useExternalAnnotations({ enabled }); + return null; +} + +async function mountHook(enabled = true): Promise<{ + result: { current: HookResult | null }; + unmount: () => Promise; +}> { + const host = document.createElement('div'); + document.body.appendChild(host); + const resultRef: { current: HookResult | null } = { current: null }; + let root: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + return { + result: resultRef, + unmount: async () => { + await act(async () => { root.unmount(); }); + host.remove(); + }, + }; +} + +describe('ExternalAnnotationTransport seam', () => { + test.skipIf(!hasDom)('fake.subscribe() is called on mount when enabled', async () => { + const subscribeCalls: number[] = []; + + const fakeTransport: ExternalAnnotationTransport = { + subscribe: (_onEvent, _onError) => { + subscribeCalls.push(1); + return () => {}; + }, + getSnapshot: async () => null, + add: async () => {}, + remove: async () => {}, + update: async () => {}, + clear: async () => {}, + }; + + setExternalAnnotationTransport(fakeTransport); + + const session = await mountHook(); + + expect(subscribeCalls.length).toBeGreaterThanOrEqual(1); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('fake.remove() is called on the SAME transport instance (split-transport fix)', async () => { + const removeIds: string[] = []; + + const fakeTransport: ExternalAnnotationTransport = { + subscribe: (_onEvent, _onError) => () => {}, + getSnapshot: async () => null, + add: async () => {}, + remove: async (id) => { removeIds.push(id); }, + update: async () => {}, + clear: async () => {}, + }; + + setExternalAnnotationTransport(fakeTransport); + + const session = await mountHook(); + + await act(async () => { + session.result.current!.deleteExternalAnnotation('annotation-id-1'); + }); + + expect(removeIds).toContain('annotation-id-1'); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('resetExternalAnnotationTransport restores the default (does not call the fake)', async () => { + const subscribeCalls: number[] = []; + const fake: ExternalAnnotationTransport = { + subscribe: () => { subscribeCalls.push(1); return () => {}; }, + getSnapshot: async () => null, + add: async () => {}, + remove: async () => {}, + update: async () => {}, + clear: async () => {}, + }; + + setExternalAnnotationTransport(fake); + resetExternalAnnotationTransport(); + + // Replace EventSource so the default SSE transport does not error. + class FakeEventSource { + onmessage: ((e: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + constructor(public url: string) {} + close() {} + } + const prevES = (globalThis as Record).EventSource; + (globalThis as Record).EventSource = FakeEventSource; + + const session = await mountHook(); + + (globalThis as Record).EventSource = prevES; + + // The fake must NOT have been subscribed; the reset reinstalled the default. + expect(subscribeCalls).toHaveLength(0); + + await session.unmount(); + }); +}); diff --git a/packages/ui/hooks/useFileBrowser.seam.test.tsx b/packages/ui/hooks/useFileBrowser.seam.test.tsx new file mode 100644 index 000000000..daedad8c5 --- /dev/null +++ b/packages/ui/hooks/useFileBrowser.seam.test.tsx @@ -0,0 +1,136 @@ +/** + * Seam test: FileTreeBackend override (setFileTreeBackend / resetFileTreeBackend). + * + * Contract: after setFileTreeBackend(fake), useFileBrowser.fetchTree() calls + * fake.loadTree(dirPath) instead of /api/reference/files. + * resetFileTreeBackend() restores the default. + * + * Requires DOM — runs under bun test (preloaded via bunfig.toml). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import * as useFileBrowserModule from './useFileBrowser'; + +// Capture real function references at import time. +const setFileTreeBackend = useFileBrowserModule.setFileTreeBackend; +const resetFileTreeBackend = useFileBrowserModule.resetFileTreeBackend; +const useFileBrowser = useFileBrowserModule.useFileBrowser; +type UseFileBrowserReturn = useFileBrowserModule.UseFileBrowserReturn; +type FileTreeBackend = useFileBrowserModule.FileTreeBackend; + +const hasDom = typeof document !== 'undefined'; +const realEventSource = (globalThis as Record).EventSource; + +afterEach(() => { + resetFileTreeBackend(); + if (hasDom) document.body.innerHTML = ''; + if (realEventSource !== undefined) { + (globalThis as Record).EventSource = realEventSource; + } else { + delete (globalThis as Record).EventSource; + } +}); + +function Harness({ resultRef }: { resultRef: { current: UseFileBrowserReturn | null } }) { + resultRef.current = useFileBrowser(); + return null; +} + +async function mountHook(): Promise<{ + result: { current: UseFileBrowserReturn | null }; + unmount: () => Promise; +}> { + const host = document.createElement('div'); + document.body.appendChild(host); + const resultRef: { current: UseFileBrowserReturn | null } = { current: null }; + let root: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + return { + result: resultRef, + unmount: async () => { + await act(async () => { root.unmount(); }); + host.remove(); + }, + }; +} + +// Suppress EventSource so the watcher branch doesn't open a live stream +// and cause interference. +function suppressEventSource() { + (globalThis as Record).EventSource = undefined; +} + +describe('FileTreeBackend seam', () => { + test.skipIf(!hasDom)('fake.loadTree is called with the expected dirPath', async () => { + suppressEventSource(); + const loadTreeCalls: string[] = []; + const dirPath = '/repo/docs'; + const fakeBackend: FileTreeBackend = { + loadTree: async (path: string) => { + loadTreeCalls.push(path); + return new Response(JSON.stringify({ tree: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + loadVaultTree: async () => new Response(JSON.stringify({ tree: [] }), { status: 200 }), + watchTrees: () => undefined, + }; + + setFileTreeBackend(fakeBackend); + + const session = await mountHook(); + + await act(async () => { + await (session.result.current!.fetchTree(dirPath) as unknown as Promise); + }); + + expect(loadTreeCalls).toContain(dirPath); + + await session.unmount(); + }); + + test.skipIf(!hasDom)('resetFileTreeBackend restores the default (does not call the fake)', async () => { + suppressEventSource(); + const fakeCalls: string[] = []; + const fakeBackend: FileTreeBackend = { + loadTree: async (path: string) => { fakeCalls.push(path); return new Response('{}', { status: 200 }); }, + loadVaultTree: async () => new Response('{}', { status: 200 }), + watchTrees: () => undefined, + }; + + setFileTreeBackend(fakeBackend); + resetFileTreeBackend(); + + // After reset, the default backend calls fetch(/api/reference/files...). + const fetchCalls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(String(input)); + return new Response(JSON.stringify({ tree: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }) as typeof fetch; + + const session = await mountHook(); + + await act(async () => { + await (session.result.current!.fetchTree('/some/dir') as unknown as Promise); + }); + + globalThis.fetch = realFetch; + + // The fake was NOT consulted; the default backend hit /api/reference/files. + expect(fakeCalls).toHaveLength(0); + expect(fetchCalls.some((u) => u.includes('/api/reference/files'))).toBe(true); + + await session.unmount(); + }); +}); diff --git a/packages/ui/utils/identity.seam.test.ts b/packages/ui/utils/identity.seam.test.ts new file mode 100644 index 000000000..e7a6673e8 --- /dev/null +++ b/packages/ui/utils/identity.seam.test.ts @@ -0,0 +1,77 @@ +/** + * Seam test: IdentityProvider override (setIdentityProvider / resetIdentityProvider). + * + * Contract: after setIdentityProvider(fake), getIdentity() delegates to the + * fake's getIdentity() — not to ConfigStore. resetIdentityProvider() restores + * the tater (ConfigStore-backed) provider. + * + * No DOM required. + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import * as identityModule from './identity'; + +// Capture real function references at import time (before configure.test.ts's +// mock.module() runs and replaces setIdentityProvider/resetIdentityProvider exports). +const setIdentityProvider = identityModule.setIdentityProvider; +const resetIdentityProvider = identityModule.resetIdentityProvider; +const getIdentity = identityModule.getIdentity; +const isCurrentUser = identityModule.isCurrentUser; + +afterEach(() => { + resetIdentityProvider(); +}); + +describe('IdentityProvider seam', () => { + it('routes getIdentity() through the fake provider', () => { + const calls: string[] = []; + const fake = { + getIdentity: () => { calls.push('getIdentity'); return 'workspace-user@example.com'; }, + isCurrentUser: (_author: string | undefined) => false, + }; + + setIdentityProvider(fake); + + const result = getIdentity(); + + expect(calls).toEqual(['getIdentity']); + expect(result).toBe('workspace-user@example.com'); + }); + + it('routes isCurrentUser() through the fake provider', () => { + const checked: Array = []; + const fake = { + getIdentity: () => 'user@example.com', + isCurrentUser: (author: string | undefined) => { + checked.push(author); + return author === 'user@example.com'; + }, + }; + + setIdentityProvider(fake); + + expect(isCurrentUser('user@example.com')).toBe(true); + expect(isCurrentUser('other@example.com')).toBe(false); + expect(checked).toEqual(['user@example.com', 'other@example.com']); + }); + + it('resetIdentityProvider restores the default (ConfigStore-backed) provider', () => { + const fake = { + getIdentity: () => 'should-not-appear', + isCurrentUser: () => false, + }; + + setIdentityProvider(fake); + resetIdentityProvider(); + + // After reset the default provider returns a non-empty tater name from ConfigStore, + // NOT the fake's sentinel value. + const result = getIdentity(); + expect(result).not.toBe('should-not-appear'); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/ui/utils/storage.seam.test.ts b/packages/ui/utils/storage.seam.test.ts new file mode 100644 index 000000000..2c49367a8 --- /dev/null +++ b/packages/ui/utils/storage.seam.test.ts @@ -0,0 +1,97 @@ +/** + * Seam test: StorageBackend override (setStorageBackend / resetStorageBackend). + * + * Contract: after setStorageBackend(fake), getItem/setItem route through the + * fake backend — NOT through document.cookie. resetStorageBackend() restores + * the cookie backend. + * + * No DOM required (the test never touches document.cookie). + * + * IMPORTANT: function references are captured at module-load time (top-level) + * so they remain valid even when configure.test.ts's mock.module() replaces + * the module exports later during test execution. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import * as storageModule from './storage'; + +// Capture real function references at import time (before configure.test.ts's +// mock.module() runs and replaces setStorageBackend/resetStorageBackend exports +// with no-op spies). +const setStorageBackend = storageModule.setStorageBackend; +const resetStorageBackend = storageModule.resetStorageBackend; +const getItem = storageModule.getItem; +const setItem = storageModule.setItem; +const removeItem = storageModule.removeItem; + +afterEach(() => { + resetStorageBackend(); +}); + +describe('StorageBackend seam', () => { + it('routes getItem through the installed fake backend', () => { + const store = new Map([['test-key', 'test-value']]); + const fake = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, value); }, + removeItem: (key: string) => { store.delete(key); }, + }; + + setStorageBackend(fake); + + expect(getItem('test-key')).toBe('test-value'); + expect(getItem('missing-key')).toBeNull(); + }); + + it('routes setItem through the installed fake backend (not document.cookie)', () => { + const written: Array<{ key: string; value: string }> = []; + const read = new Map(); + const fake = { + getItem: (key: string) => read.get(key) ?? null, + setItem: (key: string, value: string) => { written.push({ key, value }); read.set(key, value); }, + removeItem: () => {}, + }; + + setStorageBackend(fake); + + setItem('seam-key', 'seam-value'); + + expect(written).toHaveLength(1); + expect(written[0]).toEqual({ key: 'seam-key', value: 'seam-value' }); + // Confirm read-back goes through the same fake + expect(getItem('seam-key')).toBe('seam-value'); + }); + + it('routes removeItem through the installed fake backend', () => { + const store = new Map([['k', 'v']]); + const removed: string[] = []; + const fake = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, value); }, + removeItem: (key: string) => { removed.push(key); store.delete(key); }, + }; + + setStorageBackend(fake); + + removeItem('k'); + + expect(removed).toEqual(['k']); + expect(getItem('k')).toBeNull(); + }); + + it('resetStorageBackend restores the original behavior (does not use the fake)', () => { + const fake = { + getItem: (_: string) => 'should-not-see-this', + setItem: () => {}, + removeItem: () => {}, + }; + setStorageBackend(fake); + resetStorageBackend(); + + // After reset, reads go to cookies — in this env cookies return null for + // unknown keys (no cookie jar in the non-DOM test environment). + // The key point is that the fake is no longer consulted: if getItem + // returned 'should-not-see-this' the reset did not work. + const result = getItem('any-key'); + expect(result).not.toBe('should-not-see-this'); + }); +}); From 9b93886678aa8c15e4f3c695bafa0e7f9cc0a694 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 16:24:55 -0700 Subject: [PATCH 37/67] =?UTF-8?q?fix(ui):=20apply=20Phase=207=20review=20f?= =?UTF-8?q?indings=20=E2=80=94=20version=20lockstep=20+=20seam=20consisten?= =?UTF-8?q?cy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump @plannotator/ui to 0.21.0 (lockstep with @plannotator/core + repo, per ADR 007) [was the 1 critical review finding] - useAnnotationDraft: route persistNow/dismissDraft save+remove through getDraftTransport() so all paths read the transport consistently (matches the load path; makes the single-global invariant explicit) - configStore.loadFromBackend: document it must be called BEFORE init() or server values get overwritten - packages/core/tsconfig: add explicit types:[] so the node-free invariant is first-class (verified: planted node:fs still fails TS2882) --- packages/core/tsconfig.json | 1 + packages/ui/config/configStore.ts | 2 ++ packages/ui/hooks/useAnnotationDraft.ts | 8 ++++---- packages/ui/package.json | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 7d06c95d9..008df21c7 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ESNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index 05017d38b..ec102324d 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -80,6 +80,8 @@ class ConfigStore { * Host installs a SYNCHRONOUS StorageBackend serving prefetched settings, then calls * this to route the initial load through that backend. Precedence after a host call: * server (init) > host backend (loadFromBackend) > cookie/default (constructor). + * Call this BEFORE init(serverConfig): init() always wins, so calling loadFromBackend() + * after init() would silently overwrite server-supplied settings. */ loadFromBackend(): void { for (const [name, def] of Object.entries(SETTINGS)) { diff --git a/packages/ui/hooks/useAnnotationDraft.ts b/packages/ui/hooks/useAnnotationDraft.ts index c563f6785..f2ac27810 100644 --- a/packages/ui/hooks/useAnnotationDraft.ts +++ b/packages/ui/hooks/useAnnotationDraft.ts @@ -409,7 +409,7 @@ export function useAnnotationDraft({ // explicitly threw away. const deletedGeneration = draftGenerationRef.current + 1; draftGenerationRef.current = deletedGeneration; - draftTransport.remove(deletedGeneration, { keepalive }).catch(() => {}); + getDraftTransport().remove(deletedGeneration, { keepalive }).catch(() => {}); return; } @@ -429,11 +429,11 @@ export function useAnnotationDraft({ // The transport moves the POST behind the seam; the keepalive retry-on-failure // gate stays in the hook verbatim so a host transport that resolves/rejects on // failure still won't resurrect a superseded save. - draftTransport.save(payload, { keepalive }).catch(() => { + getDraftTransport().save(payload, { keepalive }).catch(() => { // Chromium caps keepalive bodies (~64KB); retry without it. Completes // fine when the page was only backgrounded, best-effort on close. if (keepalive && canPersistRef.current && draftGenerationRef.current === draftGeneration) { - draftTransport.save(payload, { keepalive: false }).catch(() => {}); + getDraftTransport().save(payload, { keepalive: false }).catch(() => {}); } // Otherwise silent failure — draft is best-effort. }); @@ -516,7 +516,7 @@ export function useAnnotationDraft({ setDraftBanner(null); draftDataRef.current = null; - draftTransport.remove(deletedGeneration, { keepalive: false }).catch(() => {}); + getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {}); }, []); return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft }; diff --git a/packages/ui/package.json b/packages/ui/package.json index 30439fea2..73b8fbd8d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/ui", - "version": "0.0.1", + "version": "0.21.0", "type": "module", "exports": { "./components/*": "./components/*.tsx", From 733237780c381efad0b783d986633e8ed9a6b5c9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 16:25:34 -0700 Subject: [PATCH 38/67] docs(adr): Phase 7 implementation plan (workflow-generated, durable artifact) --- .../document-ui-phase-7-plan-20260623.md | 679 ++++++++++++++++++ 1 file changed, 679 insertions(+) create mode 100644 adr/implementation/document-ui-phase-7-plan-20260623.md diff --git a/adr/implementation/document-ui-phase-7-plan-20260623.md b/adr/implementation/document-ui-phase-7-plan-20260623.md new file mode 100644 index 000000000..8045c98bc --- /dev/null +++ b/adr/implementation/document-ui-phase-7-plan-20260623.md @@ -0,0 +1,679 @@ +# Phase 7 — Implementation Plan: Carve `@plannotator/core` + Publish Prep + +Branch: `feat/pkg-document-ui`. Authoritative: ADR 007 + spec `publish-core-package-20260623-125551.md`. + +**THE LAW (ADR 004):** Plannotator stays byte-for-byte unchanged. The carve is `git mv` + one-line +re-export shims + type extraction. Single source of truth — NO copying, NO rewriting. `packages/editor`, +`packages/server`, `packages/review-editor`, and all `apps/*` source stays untouched except the ONE +`wideMode` importer in Step 3. + +**Global rules for every step:** +- Each step leaves the tree typecheck-green **for what it touched** and ends with **one local commit** (`git commit`, NO `git push`, NO publish, NO merge). +- Work on the current branch `feat/pkg-document-ui` (already a feature branch — do not branch again). +- Repo version is `0.21.0`. `@plannotator/core` ships lockstep at `0.21.0`; `ui` → `core` pinned EXACT. +- Use `git mv` for all moves so history follows. Never delete a working file until parity is human-confirmed. +- `export *` re-exports values AND types but NOT defaults; all 15 moved modules are named-export-only (confirmed). If a default is ever found, add `export { default } from '@plannotator/core/X';`. +- **Byte-for-byte type moves:** when extracting type declarations into `packages/core/*-types.ts`, copy the source bytes **verbatim** — preserve original indentation (the `workspace-status` cluster uses TAB indentation; do NOT reflow to spaces). A pure move must produce a pure move in the diff; gratuitous reformatting is forbidden because the parity gate (item 4) scrutinizes the diff. +- **Workspace registration before typecheck:** `@plannotator/core` is a BRAND-NEW workspace package. There is no `node_modules/@plannotator` symlink dir in this repo; Bun resolves workspaces through its lockfile catalog (verified: `bun pm ls` lists workspaces, `packages/server/tsconfig.json` has no `paths` map yet resolves `@plannotator/shared/*` purely via that catalog). `packages/shared/tsconfig.json` and `packages/ai/tsconfig.json` have `moduleResolution: bundler` and NO `paths` map, so they will resolve the new `@plannotator/core/*` bare specifiers ONLY after `bun install` registers core in the catalog. Therefore `bun install` is required after Step 1a (and re-run after Step 2c) before any `tsc` verification that touches the new specifiers. + +--- + +## STEP 1 — The carve: create `@plannotator/core`, move modules, extract types, shim `shared` (CRITICAL / opus) + +Goal: `packages/core` exists with the 15 pure modules + 5 extracted type files; `packages/shared` is rewired (15 one-line shims + 4 node-bound/types modules importing types back from core); `ai/types.ts` imports the `AIContext` family (including `AIContextMode`) back from core. End state: `core` typecheck (node-free) green AND `shared` typecheck green AND `ai` typecheck green AND Pi typecheck green. + +### 1a. Create `packages/core/package.json` + register the workspace +New file `packages/core/package.json` (exact): +```json +{ + "name": "@plannotator/core", + "version": "0.21.0", + "type": "module", + "exports": { + "./agents": "./agents.ts", + "./agent-jobs": "./agent-jobs.ts", + "./agent-terminal": "./agent-terminal.ts", + "./browser-paths": "./browser-paths.ts", + "./code-file": "./code-file.ts", + "./compress": "./compress.ts", + "./crypto": "./crypto.ts", + "./external-annotation": "./external-annotation.ts", + "./extract-code-paths": "./extract-code-paths.ts", + "./favicon": "./favicon.ts", + "./feedback-templates": "./feedback-templates.ts", + "./goal-setup": "./goal-setup.ts", + "./open-in-apps": "./open-in-apps.ts", + "./project": "./project.ts", + "./source-save": "./source-save.ts", + "./config-types": "./config-types.ts", + "./storage-types": "./storage-types.ts", + "./workspace-status-types": "./workspace-status-types.ts", + "./ai-context": "./ai-context.ts", + "./types": "./types.ts", + ".": "./index.ts" + }, + "files": ["**/*.ts", "!**/*.test.ts"], + "dependencies": {}, + "devDependencies": { + "typescript": "~5.8.2" + } +} +``` +Constraints: NO `private`, NO `peerDependencies`, NO `@types/node`, NO `@types/bun`. + +**Then wire deps (1h below) and run `bun install`** so the workspace catalog registers `@plannotator/core`. This is load-bearing: without it, the Step 1k `tsc` on `shared`/`ai` cannot resolve the new `@plannotator/core/*` specifiers (no `paths` map on those packages). Run order within Step 1: create core files (1a–1g) → edit deps (1h) → `bun install` → wire typecheck (1i) → fix vendor (1j) → verify (1k). + +### 1b. Create `packages/core/tsconfig.json` (node-free — DOM-only lib, no node/bun types) +New file `packages/core/tsconfig.json` (exact): +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} +``` +Critical: NO `"types": ["bun"]`, NO `"types": ["@types/node"]`, NO `"paths"`. This is the node-free invariant — a stray `node:*` import yields `TS2307`. + +### 1c. `git mv` the 15 pure modules `packages/shared/X.ts` → `packages/core/X.ts` +Exact list (each verified node-free; the only intra-set dep is `extract-code-paths → ./code-file`, which moves together so the relative import stays valid): +``` +code-file extract-code-paths agents agent-jobs compress crypto +external-annotation favicon feedback-templates goal-setup browser-paths +project agent-terminal open-in-apps source-save +``` +Do NOT move `source-save-node.ts` (node-bound — stays in shared). `project` here is the PURE `packages/shared/project.ts`, NOT `packages/server/project.ts`. + +### 1d. Create the 5 extracted type files in core (single source — definitions move here, byte-for-byte) + +`packages/core/config-types.ts` — move the pure type decls from `shared/config.ts:13-30`: +```ts +export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; +export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; +// plus DiffOptions (config.ts:16-30) — self-contained, references the two above; move for the diff-option family +``` +(UI needs only `DefaultDiffType` + `DiffLineBgIntensity`; `DiffOptions` is moved for tidiness, self-contained.) Do NOT move `CCLabelConfig`, `PromptConfig`, `PromptRuntime`, etc. + +`packages/core/storage-types.ts` — move `ArchivedPlan` (shared/storage.ts:107-114): +```ts +export interface ArchivedPlan { + filename: string; title: string; date: string; timestamp: string; + status: "approved" | "denied" | "unknown"; size: number; +} +``` + +`packages/core/workspace-status-types.ts` — move the cluster (shared/workspace-status.ts:6-44): `WorkspaceFileStatus`, `WorkspaceFileChange`, `WorkspaceStatusPayload`, `GitRepositoryInfo`. (`WorkspaceFileStatus` is required transitively by `WorkspaceFileChange`.) Do NOT move `GitResult`, `WorkspaceStatusFlight` (file-private, node-adjacent). **Copy the declarations byte-for-byte — these use TAB indentation in the source; preserve the tabs, do NOT reflow to spaces.** + +`packages/core/types.ts` — move the `EditorAnnotation` interface (shared/types.ts:2-10, pure). This file exports ONLY `EditorAnnotation`. Do NOT re-export anything from `review-core`/`review-workspace` (they import `node:path`). + +`packages/core/ai-context.ts` — MOVE (not re-export) the AI context type family from `packages/ai/types.ts:14-89`. **This family INCLUDES `AIContextMode` (at `ai/types.ts:14`).** Move ALL six names: `AIContextMode`, `ParentSession`, `PlanContext`, `CodeReviewContext`, `AnnotateContext`, `AIContext`. All verified node-free. The literal first line moved is: +```ts +export type AIContextMode = "plan-review" | "code-review" | "annotate"; +``` +Do NOT do `export … from '@plannotator/ai'` — that would give core a dep on private `ai` and break the zero-dep CI gate. + +> **Why `AIContextMode` is mandatory here:** it is consumed inside the `ai` package itself — `ai/index.ts:68` re-exports it from `./types.ts`, and `ai/session-manager.ts:16,26,65` import and use it. If it is moved out of `ai/types.ts` but not re-exported back (see 1g), the `ai` package fails to typecheck (`TS2305 'no exported member AIContextMode'`), cascading to editor/server. UI does NOT import `AIContextMode` (verified zero usage), so no Step 2 change is needed for it. + +### 1e. Create `packages/core/index.ts` (barrel) +Re-export the public surface so `import … from '@plannotator/core'` works (notably `AIContext` for UI): +```ts +export * from './ai-context'; +export type { EditorAnnotation } from './types'; +// (re-export others as convenient; AIContext is the load-bearing one for ui/useAIChat) +``` +`export * from './ai-context'` re-exports `AIContextMode`, `AIContext`, `ParentSession`, `PlanContext`, `CodeReviewContext`, `AnnotateContext` from the barrel. + +### 1f. Replace the 15 moved `shared/X.ts` files with one-line shims +After `git mv`, recreate each `packages/shared/X.ts` containing exactly: +```ts +export * from '@plannotator/core/X'; +``` +(15 files: code-file, extract-code-paths, agents, agent-jobs, compress, crypto, external-annotation, favicon, feedback-templates, goal-setup, browser-paths, project, agent-terminal, open-in-apps, source-save.) `shared`'s `exports` map and `private:true` stay unchanged — every subpath still resolves to `./X.ts`. +Note: the intra-shared relative importers (`shared/resolve-file.ts → ./code-file`, `shared/storage.ts → ./project`, `shared/source-save-node.ts → ./source-save`) resolve to the shim and forward to core — NO edits needed to those three. + +### 1g. Rewire the node-bound shared modules + `ai/types.ts` to import types back from core +`packages/shared/config.ts` — replace the inline `DefaultDiffType`/`DiffLineBgIntensity`/`DiffOptions` decls with: +```ts +export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions } from '@plannotator/core/config-types'; +``` +(keep all node impl/functions unchanged.) + +`packages/shared/storage.ts` — replace the inline `export interface ArchivedPlan {…}` with: +```ts +import type { ArchivedPlan } from '@plannotator/core/storage-types'; +export type { ArchivedPlan }; +``` +(internal functions keep referencing `ArchivedPlan`; node:fs impl unchanged.) + +`packages/shared/workspace-status.ts` — replace the inline cluster with: +```ts +import type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus } from '@plannotator/core/workspace-status-types'; +export type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus }; +``` +(node:child_process/fs impl unchanged.) + +`packages/shared/types.ts` — replace the inline `EditorAnnotation` interface with: +```ts +export type { EditorAnnotation } from '@plannotator/core/types'; +``` +(keep its existing `review-core`/`review-workspace` re-exports as-is.) + +`packages/ai/types.ts` — replace the inline AIContext family with import-back. **This line MUST include `AIContextMode`** (it was moved in 1d and is re-exported by `ai/index.ts:68`): +```ts +export type { AIContext, AIContextMode, PlanContext, CodeReviewContext, AnnotateContext, ParentSession } from '@plannotator/core/ai-context'; +``` +This keeps `ai/index.ts`'s existing re-export (which lists `AIContextMode` at line 68) resolving, keeps `session-manager.ts`'s `import type { ..., AIContextMode } from "./types.ts"` resolving, AND keeps `editor/App.tsx:95`'s `import type { AIContext } from '@plannotator/ai'` resolving — **`ai`, `editor`, and `server` stay byte-for-byte unchanged.** + +### 1h. package.json dep edits for `shared` and `ai` +- `packages/shared/package.json` deps: add `"@plannotator/core": "workspace:*"`. Keep `private:true`, keep the full `exports` map unchanged. +- `packages/ai/package.json` deps: add `"@plannotator/core": "workspace:*"`. Keep `private:true`. + +**After 1h: run `bun install`** (registers `@plannotator/core` in the Bun workspace catalog). This is the resolution mechanism for `shared`/`ai`'s new `@plannotator/core/*` imports — those packages have no `paths` map. Without this, Step 1k's `tsc -p packages/shared/tsconfig.json` / `packages/ai/tsconfig.json` cannot resolve the new bare specifiers. + +### 1i. Wire core into the root typecheck (node-free typecheck FIRST so a node leak fails fast) +Root `package.json` `typecheck` script (line 36) — insert core's typecheck before shared's: +``` +"typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" +``` +(Leave the vendor.sh prefix in place; vendor.sh itself is fixed in 1j.) + +### 1j. Fix Pi `vendor.sh` (the ADR/spec "no change" claim is FALSE — confirmed) + +**Why it breaks (verified against `apps/pi-extension/vendor.sh`):** +1. The main loop (vendor.sh:10) copies file CONTENT verbatim from `packages/shared/$f.ts` for a flat list that INCLUDES 9 moved-pure modules (`code-file`, `agent-jobs`, `external-annotation`, `favicon`, `feedback-templates`, `project`, `agent-terminal`, `open-in-apps`, `source-save`) AND 3 node-bound modules (`config`, `storage`, `workspace-status`). After the carve, the 9 pure files are bare shims (`export * from '@plannotator/core/X'`) and the 3 node-bound files import `@plannotator/core/-types` — all unresolvable in Pi's flat `generated/` layout (no bundler resolution to packages, no `@plannotator/core` dep, `moduleResolution: bundler` with no `paths`/`baseUrl`). +2. The **ai loop** (vendor.sh:40) copies `index types provider session-manager endpoints context base-session` VERBATIM from `packages/ai/$f.ts` with NO sed rewrites. After 1g, `packages/ai/types.ts` contains `export type { … } from '@plannotator/core/ai-context'` — vendored verbatim into `generated/ai/types.ts`, where that bare specifier cannot resolve (`TS2307`). This works today ONLY because `ai/types.ts` currently has zero `@plannotator/*` imports. + +`workspace-status` IS imported by `apps/pi-extension/server/reference.ts` and `file-browser-watch.ts` (confirmed), so a silent break here is a real runtime/typecheck failure. + +**The exact restructured `vendor.sh` (write this as runnable shell — do not infer):** + +(a) In the main loop, split the 9 moved-pure modules out of the `packages/shared/` source and source them from `packages/core/` instead. Replace the single loop (vendor.sh:10-13) so the 9 moved modules read from core, the 3 node-bound modules read from shared **and** get a sed rewrite, and everything else stays as-is: + +```bash +# Modules that MOVED to @plannotator/core — vendor the real impl from core. +for f in feedback-templates project favicon code-file external-annotation agent-jobs agent-terminal source-save open-in-apps; do + src="../../packages/core/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + +# Node-bound shared modules that now import types from @plannotator/core/*-types — +# vendor from shared, rewrite the bare core specifier to the flat relative path. +for f in config storage workspace-status; do + src="../../packages/shared/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" \ + | sed 's|from "@plannotator/core/\([^"]*\)-types"|from "./\1-types.js"|g' \ + > "generated/$f.ts" +done + +# Extracted type files those node-bound modules now depend on — vendor from core. +for f in config-types storage-types workspace-status-types; do + src="../../packages/core/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done + +# Everything else in the original flat list stays sourced from packages/shared. +for f in prompts review-core diff-paths cli-pagination jj-core vcs-core review-args draft pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common resolve-file annotate-reference-roots-node worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save-node; do + src="../../packages/shared/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" +done +``` +> The relative-import chains stay valid: `resolve-file → ./code-file`, `source-save-node → ./source-save`, `storage → ./project` all resolve to the flat `generated/.ts` files, which now hold the real core impl. Confirm the original line-10 list is fully partitioned across the four loops above with no module dropped (diff the old list against the union of the four new lists). + +(b) Extend the **ai loop** (vendor.sh:40-43) to vendor `ai-context` from core and sed-rewrite the `@plannotator/core/ai-context` specifier in `generated/ai/types.ts` to `./ai-context.js`: + +```bash +# Vendor the moved AI context types from core into generated/ai/. +printf '// @generated — DO NOT EDIT. Source: packages/core/ai-context.ts\n' \ + | cat - "../../packages/core/ai-context.ts" > "generated/ai/ai-context.ts" + +for f in index types provider session-manager endpoints context base-session; do + src="../../packages/ai/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" \ + | sed 's|from "@plannotator/core/ai-context"|from "./ai-context.js"|g' \ + > "generated/ai/$f.ts" +done +``` +> Only `generated/ai/types.ts` actually contains the `@plannotator/core/ai-context` specifier today, but applying the sed to all 7 ai files is harmless (no-op where absent) and future-proofs the vendor. + +### 1k. Verify (run at end of Step 1) +``` +bun install # MUST run first — registers @plannotator/core in the workspace catalog +tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json +# Node-free proof: temporarily add `import 'node:fs'` to a core/*.ts → tsc on core MUST fail TS2307 → remove it. +bash apps/pi-extension/vendor.sh && tsc --noEmit -p apps/pi-extension/tsconfig.json # MUST be green — confirms generated/ai/types.ts + generated/{config,storage,workspace-status}.ts resolve their rewritten relative specifiers +git diff --name-only # confined to packages/{core,shared,ai} + apps/pi-extension/vendor.sh + root package.json + bun.lock +``` + +### Commit +``` +feat(core): carve @plannotator/core — move pure modules, extract node-bound types, shim shared (Phase 7 step 1) +``` + +--- + +## STEP 2 — Re-point `@plannotator/ui` to `@plannotator/core` (CRITICAL / opus) + +Goal: every non-test `ui` import of `@plannotator/shared/*` and `@plannotator/ai` becomes `@plannotator/core/*`; ui `package.json` drops shared+ai, adds core EXACT. End: grep returns zero; ui typecheck green. + +### 2a. Re-point the 35 import sites (31 files) +Mechanical rule: `@plannotator/shared/X` → `@plannotator/core/X` (same subpath), with these EXACT remaps for the type-extraction cases: +- `@plannotator/shared/config` → `@plannotator/core/config-types` +- `@plannotator/shared/storage` → `@plannotator/core/storage-types` +- `@plannotator/shared/workspace-status` → `@plannotator/core/workspace-status-types` +- `@plannotator/shared/types` (the `EditorAnnotation` re-export at `ui/types.ts:209`) → `@plannotator/core/types` +- `import type { AIContext } from '@plannotator/ai'` (`ui/hooks/useAIChat.ts:2`) → `from '@plannotator/core'` + +Files + lines (from scope-rewire §1, authoritative): +- `ui/types.ts:209` EditorAnnotation → `@plannotator/core/types` +- `ui/types.ts:211-213` ExternalAnnotationEvent → `@plannotator/core/external-annotation` +- `ui/types.ts:215-221` AgentJob*/AgentCapabilit* → `@plannotator/core/agent-jobs` +- `ui/config/settings.ts:12` DiffLineBgIntensity → `@plannotator/core/config-types` +- `ui/utils/parser.ts:2` planDenyFeedback → `@plannotator/core/feedback-templates` +- `ui/utils/annotateAgentTerminal.ts:1` AgentTerminalAgent → `@plannotator/core/agent-terminal` +- `ui/utils/aiProvider.ts:10` AGENT_CONFIG/getAgentAIProviderTypes/Origin → `@plannotator/core/agents` +- `ui/utils/sharing.ts:12` compress/decompress → `@plannotator/core/compress` +- `ui/utils/sharing.ts:13` encrypt/decrypt → `@plannotator/core/crypto` +- `ui/components/InlineMarkdown.tsx:4` isCodeFilePath/… → `@plannotator/core/code-file` +- `ui/components/OpenInAppButton.tsx:5` OpenInKind → `@plannotator/core/open-in-apps` +- `ui/components/Settings.tsx:3` Origin → `@plannotator/core/agents` +- `ui/components/Settings.tsx:4` DiffLineBgIntensity → `@plannotator/core/config-types` +- `ui/components/DocBadges.tsx:16` hostnameOrFallback → `@plannotator/core/project` +- `ui/components/AISettingsTab.tsx:12` Origin → `@plannotator/core/agents` +- `ui/components/MenuVersionSection.tsx:4` Origin → `@plannotator/core/agents` +- `ui/components/DiffTypeSetupDialog.tsx:3` DefaultDiffType → `@plannotator/core/config-types` +- `ui/components/PlanHeaderMenu.tsx:14` Origin → `@plannotator/core/agents` +- `ui/components/AgentsTab.tsx:16` isTerminalStatus → `@plannotator/core/agent-jobs` +- `ui/components/PlanAIAnnouncementDialog.tsx:3` Origin → `@plannotator/core/agents` +- `ui/components/PlanAIAnnouncementDialog.tsx:4` AGENT_CONFIG/getAgentAIProviderTypes/getAgentName → `@plannotator/core/agents` (all three symbols on this line; `export * from '@plannotator/core/agents'` re-exports all) +- `ui/components/blocks/HtmlBlock.tsx:2` isCodeFilePath → `@plannotator/core/code-file` +- `ui/components/sidebar/FileBrowser.tsx:13` WorkspaceFileChange/WorkspaceStatusPayload → `@plannotator/core/workspace-status-types` +- `ui/components/sidebar/FileBrowser.tsx:14` normalizeBrowserPath → `@plannotator/core/browser-paths` +- `ui/components/sidebar/ArchiveBrowser.tsx:9` ArchivedPlan → `@plannotator/core/storage-types` +- `ui/components/goal-setup/GoalSetupSurface.tsx:13-20` GoalSetup* types → `@plannotator/core/goal-setup` +- `ui/components/settings/HooksTab.tsx:2` FAVICON_SVG → `@plannotator/core/favicon` +- `ui/hooks/useAgents.ts:6` Origin → `@plannotator/core/agents` +- `ui/hooks/useAnnotationDraft.ts:18` SourceSaveCapability → `@plannotator/core/source-save` +- `ui/hooks/useArchive.ts:9` ArchivedPlan → `@plannotator/core/storage-types` +- `ui/hooks/useLinkedDoc.ts:13` SourceSaveCapability → `@plannotator/core/source-save` +- `ui/hooks/useValidatedCodePaths.ts:2` extractCandidateCodePaths → `@plannotator/core/extract-code-paths` +- `ui/hooks/useFileBrowser.ts:12` WorkspaceStatusPayload → `@plannotator/core/workspace-status-types` +- `ui/hooks/pfm/useCodeFilePopout.ts:2` parseCodePath → `@plannotator/core/code-file` +- `ui/hooks/useAIChat.ts:2` AIContext → `@plannotator/core` (bare package root → resolves via `exports['.']` → `index.ts`) + +### 2b. `packages/ui/tsconfig.json` paths +Add BOTH a `@plannotator/core/*` subpath mapping AND a bare `@plannotator/core` mapping alongside the existing shared one (line 21) so tsc resolves core during the transition. **Two entries are required:** the trailing-`/*` glob does NOT match the extensionless bare specifier `@plannotator/core` (used by `useAIChat.ts:2`): +```json +"@plannotator/core": ["../core/index.ts"], +"@plannotator/core/*": ["../core/*"] +``` +(Keep `"@plannotator/shared/*": ["../shared/*"]` — see 2d note; surviving test-file imports still use it.) The bare-specifier path map is authoritative for ui's tsc; `bun install` (already run in Step 1) is the belt-and-suspenders mechanism that also makes the bare specifier resolve via core's `exports['.']`. State both: **path map is authoritative for ui tsc; workspace catalog backs it.** + +### 2c. `packages/ui/package.json` dep edits + re-register +- REMOVE `"@plannotator/ai": "workspace:*"` +- REMOVE `"@plannotator/shared": "workspace:*"` +- ADD `"@plannotator/core": "workspace:*"` (workspace alias in source; resolves to exact `0.21.0` at pack time — ADR mandates EXACT pinning, enforce at pack in Step 5/final gate) + +**After 2c: run `bun install`** so the dependency-graph change (ui → core) is reflected in `bun.lock` before the Step 2d typecheck. + +### 2d. Verify (run at end of Step 2) +``` +bun install +grep -rn '@plannotator/shared\|@plannotator/ai' packages/ui --include='*.ts' --include='*.tsx' | grep -v '\.test\.' # MUST be empty +tsc --noEmit -p packages/ui/tsconfig.json # green +``` +> **Note (intentional, out of scope for the grep-zero gate):** exactly two ui *test* files still import `@plannotator/shared` (`annotateAgentTerminal.test.ts`, `FileBrowser.test.ts`). These are deliberately retained — the grep-zero assertion scopes to non-test files via `grep -v '\.test\.'`, and `@plannotator/shared/*` stays in the ui tsconfig `paths` to keep them resolving. A reviewer should NOT flag these. + +### Commit +``` +feat(ui): depend only on @plannotator/core — re-point all shared/ai imports (Phase 7 step 2) +``` + +--- + +## STEP 3 — Move `wideMode.ts` into `packages/ui/utils` (MECHANICAL / sonnet) + +Goal: relocate the pure `wideMode` helper from `editor` to `ui/utils`; fix the 2 importers. + +### 3a. git mv +``` +git mv packages/editor/wideMode.ts packages/ui/utils/wideMode.ts +git mv packages/editor/wideMode.test.ts packages/ui/utils/wideMode.test.ts +``` + +### 3b. Fix importer 1 — `packages/editor/App.tsx:109` +FROM: +```ts +import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; +``` +TO: +```ts +import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from '@plannotator/ui/utils/wideMode'; +``` +(This is the ONE allowed edit to `packages/editor` source — a single import-specifier change, no behavior change. `wideMode.ts` itself only imports from `@plannotator/ui/types` + `@plannotator/ui/hooks/useSidebar`, so it lands cleanly in ui.) + +### 3c. Fix importer 2 — the moved test +`packages/ui/utils/wideMode.test.ts` imports `./wideMode` (relative) — UNCHANGED after the move (it's now a sibling in `ui/utils`). Verify the line still reads `from './wideMode';`. + +### 3d. exports map +`@plannotator/ui/utils/wideMode` already resolves via the existing `"./utils/*": "./utils/*.ts"` glob in `packages/ui/package.json` — NO new exports entry needed. (Confirm the glob is present at line 14.) + +### 3e. Verify (run at end of Step 3) +``` +grep -rn 'wideMode' packages --include='*.ts' --include='*.tsx' | grep -v 'packages/ui/utils/wideMode' # only editor/App.tsx (the new specifier) shows +tsc --noEmit -p packages/ui/tsconfig.json # green (wideMode + its importer resolve) +bun test packages/ui/utils/wideMode.test.ts +``` + +### Commit +``` +refactor(ui): relocate wideMode helper to @plannotator/ui/utils (Phase 7 step 3) +``` + +--- + +## STEP 4 — Settings provider `loadFromBackend` + `configurePlannotatorUI` front door (CRITICAL / opus) + +Goal: complete the half-built settings provider (initial-load routes through installed backend) and add the single typed configuration front door over the 9 global setters. Both ADDITIVE — Plannotator never calls either, so byte-for-byte parity holds. + +### 4a. Add `loadFromBackend()` to `ConfigStore` — `packages/ui/config/configStore.ts` +Insert this method into the `ConfigStore` class (after the constructor, before `init`): +```ts + /** + * Re-hydrate all settings from the currently installed StorageBackend. + * ADDITIVE host hook — Plannotator never calls this (eager cookie default unchanged). + * Host installs a SYNCHRONOUS StorageBackend serving prefetched settings, then calls + * this to route the initial load through that backend. Precedence after a host call: + * server (init) > host backend (loadFromBackend) > cookie/default (constructor). + */ + loadFromBackend(): void { + for (const [name, def] of Object.entries(SETTINGS)) { + const fromBackend = def.fromCookie(); + if (fromBackend !== undefined) { + this.values.set(name, fromBackend); + } + } + this.notify(); + } +``` +Contract: use `!== undefined` (NOT `??`) so a missing key keeps the constructor default; do NOT call `def.toCookie` (no re-write). Reuses the existing per-setting `fromCookie()` reader, which under a host backend reads the host's prefetched store. + +### 4b. Add `resetServerSync()` to `ConfigStore` (needed by the Step 6 seam test; keeps the seam family symmetric) +Next to `setServerSync` (configStore.ts:122): +```ts + resetServerSync(): void { this.serverSync = defaultServerSync; } +``` +(`defaultServerSync` already exists at configStore.ts:36-42.) + +### 4c. Create `packages/ui/configure.ts` — `configurePlannotatorUI` +New file. Imports the 9 setters from their intra-`ui` relative modules and fans out (every field optional, only provided seams applied): +```ts +import { setImageSrcResolver, type ImageSrcResolver } from './components/ImageThumbnail'; +import { setDocPreviewFetcher, type DocPreviewFetcher } from './components/InlineMarkdown'; +import { setStorageBackend, type StorageBackend } from './utils/storage'; +import { setIdentityProvider, type IdentityProvider } from './utils/identity'; +import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser'; +import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft'; +import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; +import { setAITransport, type AITransport } from './hooks/useAIChat'; +import { configStore } from './config'; + +type ExternalAnnotationBase = { id: string; source?: string }; +type ServerSyncFn = (payload: Record) => void; + +export interface PlannotatorUIConfig { + imageSrcResolver?: ImageSrcResolver; + storageBackend?: StorageBackend; + docPreviewFetcher?: DocPreviewFetcher; + fileTreeBackend?: FileTreeBackend; + identityProvider?: IdentityProvider; + draftTransport?: DraftTransport; + /** + * Base-constraint transport. If your annotation type extends the base + * constraint ({ id: string; source?: string }) with extra fields, call + * setExternalAnnotationTransport() directly for full type safety — + * this front-door field intentionally pins the base constraint for ergonomics. + */ + externalAnnotationTransport?: ExternalAnnotationTransport; + aiTransport?: AITransport; + serverSync?: ServerSyncFn; + /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */ + loadSettingsFromBackend?: boolean; +} + +export function configurePlannotatorUI(config: PlannotatorUIConfig): void { + if (config.imageSrcResolver) setImageSrcResolver(config.imageSrcResolver); + if (config.storageBackend) setStorageBackend(config.storageBackend); + if (config.docPreviewFetcher) setDocPreviewFetcher(config.docPreviewFetcher); + if (config.fileTreeBackend) setFileTreeBackend(config.fileTreeBackend); + if (config.identityProvider) setIdentityProvider(config.identityProvider); + if (config.draftTransport) setDraftTransport(config.draftTransport); + if (config.externalAnnotationTransport) setExternalAnnotationTransport(config.externalAnnotationTransport); + if (config.aiTransport) setAITransport(config.aiTransport); + if (config.serverSync) configStore.setServerSync(config.serverSync); + // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last). + if (config.loadSettingsFromBackend) configStore.loadFromBackend(); +} +``` +Notes: inline `ServerSyncFn` (configStore's type is module-local — do NOT widen configStore's surface). The external-annotation generic is pinned to the base constraint `{ id: string; source?: string }` (the hook's default transport is ``, contract-compatible); the doc comment above the field tells consumers with extended annotation types to call `setExternalAnnotationTransport()` directly. The render-time prop seams (vscode-diff, save-to-notes, obsidian-detect, version fetchers, editor `mode`, code-path toggle, `ScrollViewportProvider`) are intentionally NOT here — they're passed where the host renders those components. + +### 4d. ui exports + files +`packages/ui/package.json`: +- exports: add `"./configure": "./configure.ts"` (alongside `./config`, `./types`). +- files: add `"configure.ts"` (sits at package root like `types.ts`). + +### 4e. Verify (run at end of Step 4) +``` +tsc --noEmit -p packages/ui/tsconfig.json # green +grep -n 'loadFromBackend\|resetServerSync' packages/ui/config/configStore.ts +grep -n 'configurePlannotatorUI' packages/ui/configure.ts +``` + +### Commit +``` +feat(ui): add loadFromBackend settings rehydration + configurePlannotatorUI front door (Phase 7 step 4) +``` + +--- + +## STEP 5 — Precompiled CSS build + madge circular-dep tooling (MECHANICAL / sonnet) + +Goal: ship a required precompiled `@plannotator/ui/styles.css` (CSS-only Vite build); add `madge` + a circular-dependency script. (Core's node-free typecheck wiring already landed in Step 1i — re-verify here.) + +### 5a. CSS entry — `packages/ui/styles-entry.css` (new) +```css +@import "@fontsource-variable/inter"; +@import "@fontsource-variable/geist-mono"; +@import "tailwindcss"; + +@plugin "tailwindcss-animate"; + +@source "./components/**/*.tsx"; +@source "./hooks/**/*.ts"; +@source "./utils/**/*.ts"; + +@import "./theme.css"; +``` +(`@source` globs are relative to this file at `packages/ui/`; they run ONCE at build time on source, baking the utility classes into the output — that's the whole point vs. the fragile consumer-side `@source` into `node_modules`.) Does NOT include `@plannotator/webtui/styles.css` (agent-terminal) or dockview CSS (review-editor) — those are runtime-specific, not exported UI. +> **`print.css` is covered (verified):** `packages/ui/theme.css:55` already does `@import "./print.css";`, and `styles-entry.css` imports `./theme.css`, so the precompiled bundle DOES include print styles transitively. No separate `@import "./print.css"` is needed here. + +### 5b. Vite CSS-only config — `packages/ui/vite.css.config.ts` (new) +```ts +import path from 'path'; +import { defineConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [tailwindcss()], + resolve: { alias: { '@plannotator/ui': path.resolve(__dirname, '.') } }, + build: { + lib: { entry: path.resolve(__dirname, 'styles-entry.css'), formats: ['es'], fileName: () => 'styles.js' }, + outDir: '.', + cssCodeSplit: false, + rollupOptions: { output: { assetFileNames: 'styles.css' } }, + emptyOutDir: false, + }, +}); +``` + +### 5c. `packages/ui/package.json` — CSS build wiring +- scripts: add `"build:css": "vite build --config vite.css.config.ts && rm -f styles.js"` +- scripts: add `"prepublishOnly": "bun run build:css"` — mirrors `apps/pi-extension/package.json`'s `prepublishOnly` pattern. This fires automatically before `bun pm pack` / `npm publish`, guaranteeing `styles.css` is fresh in the tarball even though it is NOT committed. Without it, a publish would ship a tarball missing the (listed-in-`files`) `styles.css` — a silent consumer break. +- exports: add `"./styles.css": "./styles.css"` +- files: add `"styles.css"` +- devDependencies: add `"@tailwindcss/vite": "^4.1.18"` and `"vite": "^6.2.0"` (CSS-only build needs only these two; no react plugin). +- Add a `.gitignore` line (or repo-root ignore) for `packages/ui/styles.js`. **Do NOT commit `styles.css`** — it's a generated artifact produced by `prepublishOnly` at pack/publish time (avoids stale diffs). Also add `packages/ui/styles.css` to `.gitignore`. + +### 5d. Root scripts — `build:ui-css` +Root `package.json` scripts: add `"build:ui-css": "bun run --cwd packages/ui build:css"`. + +### 5e. madge circular-dep tooling +- Root `package.json` devDependencies: add `"madge": "^8.0.0"` (`bun add -d madge` at repo root). +- Root `.madgerc` (new) — TS support: + ```json + { "extensions": ["ts", "tsx"], "fileExtensions": ["ts", "tsx"] } + ``` +- Root `package.json` scripts: add + ```json + "check:cycles": "madge --circular --extensions ts,tsx --ts-config packages/core/tsconfig.json packages/core && madge --circular --extensions ts,tsx --ts-config packages/ui/tsconfig.json packages/ui" + ``` + (Scoped to the two published packages — the strict invariant. `--circular` reports cycles only, not unresolved imports; the `--ts-config` for ui carries the `@plannotator/core/*` + bare-`@plannotator/core` path maps added in Step 2b so madge resolves the aliases. The two surviving `@plannotator/shared` references in ui *.test.ts files are resolved by the retained shared path map and do not affect cycle detection.) + +### 5f. Re-confirm core node-free typecheck wiring (from Step 1i) +Ensure `tsc --noEmit -p packages/core/tsconfig.json` is in the root `typecheck` script (added in 1i). Sanity: a planted `import 'node:fs'` in a core file fails `TS2307`. + +### 5g. Verify (run at end of Step 5) +``` +bun install # picks up madge + vite/@tailwindcss/vite devDeps +bun run build:ui-css # emits packages/ui/styles.css, removes styles.js +test -s packages/ui/styles.css && echo "styles.css non-empty OK" +bun run check:cycles # exits 0 (no cycles in core/ui) +tsc --noEmit -p packages/core/tsconfig.json # node-free green +``` + +### Commit +``` +build(ui): precompiled styles.css CSS build + madge circular-dep check (Phase 7 step 5) +``` +(Commit the configs/scripts/devDeps + `prepublishOnly`; do NOT commit the generated `styles.css`/`styles.js`.) + +--- + +## STEP 6 — Per-seam override tests + a `configurePlannotatorUI` routing test (MECHANICAL / sonnet) + +Goal: one override test per seam (`setX(fake)` → drive → assert → `resetX()`), making the `reset*()` functions live and pinning the subtle contracts; plus one test that `configurePlannotatorUI({...})` routes to each setter. + +### 6a. Verify (DO NOT re-edit) the override-path contracts before writing tests +The two override-path fixes flagged in earlier interrogation passes are **ALREADY landed on this branch** (verified). Step 6a is VERIFICATION-ONLY — do NOT re-apply or "fix" working code (re-editing risks an unintended Plannotator behavior change, a LAW violation). + +1. **Split-transport (already fixed):** `packages/ui/hooks/useExternalAnnotations.ts:134` captures `transportRef = useRef(externalAnnotationTransport …)`; the subscribe/poll effect reads `transportRef.current` (line 145) AND every CRUD callback reads `transportRef.current` (`.remove` line 232, `.clear` line 244, `.update` line 253). Reads and writes already use the same backend instance. **Confirm via grep**: + ``` + grep -n 'transportRef.current' packages/ui/hooks/useExternalAnnotations.ts # expect lines 145, 232, 244, 253 + ``` + If (and only if) these are absent, apply the capture-once pattern; otherwise proceed. +2. **Ref reset on effect re-run (already fixed):** `fallbackRef.current = false` (line 142) and `receivedSnapshotRef.current = false` (line 143) are already reset at the TOP of the effect, so an `enabled` toggle `false→true` re-attempts SSE. **Confirm via grep**: + ``` + grep -n 'fallbackRef.current = false\|receivedSnapshotRef.current = false' packages/ui/hooks/useExternalAnnotations.ts # expect lines 142, 143 + ``` +3. **`useFileBrowser` audit (no change expected):** `useFileBrowser.ts` reads the module global `fileTreeBackend` LIVE (lines 211/316/383) rather than capturing a ref. This is a DIFFERENT but acceptable pattern (no mount-time capture, so no read/write split to fix). Confirm no change is needed; do NOT introduce a ref here. + +Then proceed straight to the seam tests in 6b/6c — they pin the already-correct behavior. + +### 6b. Per-seam override tests (10 files, `.seam.test.ts(x)` naming, colocated) +| Seam | Test file | Assert | +|------|-----------|--------| +| `setImageSrcResolver` / `resetImageSrcResolver` | `packages/ui/components/ImageThumbnail.seam.test.tsx` | render `` → fake resolver called with `"/foo/img.png"` | +| `setStorageBackend` / `resetStorageBackend` | `packages/ui/utils/storage.seam.test.ts` | `setItem`/`getItem` → fake backend's read/write called (not `document.cookie`) | +| `setDocPreviewFetcher` / `resetDocPreviewFetcher` | `packages/ui/components/InlineMarkdown.seam.test.tsx` | trigger doc preview → fake fetcher called with expected path | +| `setFileTreeBackend` / `resetFileTreeBackend` | `packages/ui/hooks/useFileBrowser.seam.test.tsx` | mount `useFileBrowser` → `fetchTree()` → `fake.loadTree` invoked with expected dirPath | +| `setIdentityProvider` / `resetIdentityProvider` | `packages/ui/utils/identity.seam.test.ts` | `getIdentity()` → fake provider invoked | +| `setDraftTransport` / `resetDraftTransport` | `packages/ui/hooks/useAnnotationDraft.seam.test.ts` | `fake.load()` on mount; `fake.save()` on scheduled save | +| `setExternalAnnotationTransport` / `resetExternalAnnotationTransport` | `packages/ui/hooks/useExternalAnnotations.seam.test.ts` | mount → `fake.subscribe` called; delete → `fake.remove` on SAME transport (pins the already-landed split-transport fix) | +| `setAITransport` / `resetAITransport` | `packages/ui/hooks/useAIChat.seam.test.ts` | mount `useAIChat` + send → `fake` session/query called | +| `configStore.setServerSync` / `resetServerSync` | `packages/ui/config/configStore.seam.test.ts` | `configStore.set('', …)` → fake sync fn called with expected payload | +| `loadFromBackend` | `packages/ui/config/configStore.seam.test.ts` (2nd describe) | `setStorageBackend(prefetched)` → `loadFromBackend()` → `configStore.get(key)` returns prefetched value | + +Pattern (template): `afterEach(() => resetXTransport())`; in the test, `setXTransport(fake)`, drive (mount hook harness via React test utils, or call the utility directly), assert recorded calls. Files auto-discovered by `bun test` — no registration. + +### 6c. `configurePlannotatorUI` routing test — `packages/ui/configure.test.ts` (new) +Call `configurePlannotatorUI({ imageSrcResolver, storageBackend, docPreviewFetcher, fileTreeBackend, identityProvider, draftTransport, externalAnnotationTransport, aiTransport, serverSync, loadSettingsFromBackend: true })` with fakes/spies, then assert each underlying setter received its fake (and that `loadFromBackend` ran after `setStorageBackend`). Reset every seam in `afterEach`. + +### 6d. Verify (run at end of Step 6) +``` +bun test packages/ui # all ui tests incl. new .seam.test + configure.test green +tsc --noEmit -p packages/ui/tsconfig.json +``` + +### Commit +``` +test(ui): per-seam override tests + configure routing test (Phase 7 step 6) +``` + +--- + +## FINAL PARITY GATE (run after Step 6, before any publish/push — DO NOT push or publish) + +Run from repo root. ALL must pass; investigate any failure before proceeding. + +1. **Full typecheck (incl. core node-free + Pi vendor):** + ``` + bun install # ensure catalog is current (core registered, ui→core) + bun run typecheck + ``` + Expect green for core, shared, ai, server, ui, pi-extension. Confirm a planted `import 'node:fs'` in a `packages/core/*.ts` fails `TS2307` (node-free invariant), then remove it. + +2. **Test suite — delta vs. main must be ADDITIONS only:** + ``` + bun test + ``` + Expect the Phase-0 baseline pass count PLUS the new Step-6 seam/configure tests — zero regressions, zero failures. The delta should be exactly the new `.seam.test`/`configure.test` files plus the moved `wideMode.test` file. No pre-existing test changed behavior. + +3. **madge clean (no circular deps in published packages):** + ``` + bun run check:cycles + ``` + Exit 0. + +4. **`git diff` confined to expected packages:** + ``` + git diff --name-only main...HEAD + ``` + Must be limited to: `packages/core/**`, `packages/shared/**`, `packages/ai/**`, `packages/ui/**`, the single `packages/editor/App.tsx` import line + the moved `wideMode` files, `apps/pi-extension/vendor.sh`, root `package.json` / `.madgerc` / `.gitignore` / `bun.lock`, and (if added) `.github/workflows/*` CI. NOTHING in `packages/server`, `packages/review-editor`, `apps/hook`, `apps/opencode-plugin`, or any other Plannotator app source. + +5. **ui depends ONLY on core internally (non-test):** + ``` + grep -rn '@plannotator/shared\|@plannotator/ai' packages/ui --include='*.ts' --include='*.tsx' | grep -v '\.test\.' + ``` + Empty. (The two surviving `@plannotator/shared` imports in ui *.test.ts files are intentional — see Step 2d note — and are excluded by `grep -v '\.test\.'`.) + +6. **Apps build green + functional/visual parity (the REAL gate):** + ``` + bun run --cwd apps/review build && bun run build:hook && bun run build:opencode + bun run build:pi # Pi vendors from core now + ``` + All builds MUST succeed. **Parity is confirmed by a human running the plan review and code review UIs in the browser** (ADR 004: human browser verification is the real gate). The shipped bundle should be **functionally identical** to the Phase-0 baseline — the carve is move + re-export only, no runtime logic change. + + **Bundle-hash guidance (NOT a hard gate):** compare shipped bundle hashes against the Phase-0 baseline as a proxy signal, but do NOT treat any hash delta as an automatic STOP. The carve changes the import-resolution graph (e.g. `@plannotator/ui` now resolves `compress` directly from `core/compress.ts` instead of through the `shared/compress.ts` shim; `editor/App.tsx` now imports `wideMode` from `@plannotator/ui/utils/wideMode`). A bundler may emit different hashes purely from changed module ordering, import-path string literals, or source-map metadata while the executed JS logic is byte-identical. + - **Acceptable (proceed):** hash differs only in source-map metadata or import-path string literals, with no change to the JS logic bytes — confirm by diffing the de-minified/normalized bundle output. + - **STOP and investigate:** any difference in the actual JS logic bytes, OR any visible/functional difference in the browser. That is a regression and must be root-caused before proceeding. + +7. **CSS artifact builds:** + ``` + bun run build:ui-css && test -s packages/ui/styles.css + ``` + +**Publish/registry steps are OUT OF SCOPE for these 6 steps** — branch-validation (`bun pm pack` each, inspect tarball, `npm publish --dry-run`), the `release.yml` publish job, and the EXACT-pin substitution of `ui → core@0.21.0` happen only after a human confirms parity in the browser and gives explicit go (ADR 007 §5, THE LAW). Do NOT push these commits. From db57d1c2ef6dd8e4ad3b72a80e19efb599ce4e9c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 18:37:35 -0700 Subject: [PATCH 39/67] fix(ui): reconcile #948 with the draft-transport seam + lockstep 0.21.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto origin/main (picks up #948 draft-deletion fix, the 0.21.1 bump, and the #949/#950 editor fix). The rebase auto-merged #948's code-draft logic (hasHadAnnotationsRef, empty-state tombstone, clearTimeout in restore/dismiss) with the Phase-5 transport refactor cleanly — except the empty-state tombstone delete was left as a raw fetch('/api/draft', DELETE). Route it through getDraftTransport().remove() so a host backend tombstones its own stored draft on clear (the #948 guarantee, for hosts). Plannotator unchanged (default transport hits the same endpoint). Bump @plannotator/core + @plannotator/ui 0.21.0 -> 0.21.1 to match main's version (lockstep per ADR 007). Verified: typecheck clean, madge no-cycles, plain suite 1637 pass / 0 fail, #948 draft-clear test 3/0. (The 45 DOM_TESTS failures are the known server/network integration tests that need a real OS env — same set on main, not regressions.) --- packages/core/package.json | 2 +- packages/ui/hooks/useCodeAnnotationDraft.ts | 7 +++---- packages/ui/package.json | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index bc5331d5e..e729364a8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/core", - "version": "0.21.0", + "version": "0.21.1", "type": "module", "exports": { "./agents": "./agents.ts", diff --git a/packages/ui/hooks/useCodeAnnotationDraft.ts b/packages/ui/hooks/useCodeAnnotationDraft.ts index 5a1d9bfde..8d203a80c 100644 --- a/packages/ui/hooks/useCodeAnnotationDraft.ts +++ b/packages/ui/hooks/useCodeAnnotationDraft.ts @@ -139,10 +139,9 @@ export function useCodeAnnotationDraft({ if (isEmpty) { // The user cleared everything (#948). Delete the draft with a generation // tombstone so it can't resurface on refresh and a late save can't revive - // it. Mirrors useAnnotationDraft.persistNow. - fetch(`/api/draft?generation=${draftGeneration}`, { method: 'DELETE' }).catch(() => { - // Silent failure - }); + // it. Mirrors useAnnotationDraft.persistNow — routed through the draft + // transport seam so a host backend tombstones its own stored draft too. + getDraftTransport().remove(draftGeneration, { keepalive: false }).catch(() => {}); return; } diff --git a/packages/ui/package.json b/packages/ui/package.json index 73b8fbd8d..9ffc02741 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/ui", - "version": "0.21.0", + "version": "0.21.1", "type": "module", "exports": { "./components/*": "./components/*.tsx", From c4604ac116c63c37ec33c9d9b5af3d76408e0f8c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 18:44:50 -0700 Subject: [PATCH 40/67] =?UTF-8?q?fix(ui):=20address=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20host-path=20robustness=20+=20cleanups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlanDiffViewer: wrap onOpenVscodeDiff in try/finally so a host opener that throws can't wedge the VS Code button in a permanent loading state (default unaffected) - useExternalAnnotations: (re-)capture the transport inside the effect on enable so a host that installs a transport before enabling annotations is honored, not the stale default — keeps the split-transport fix (effect + CRUD share one ref) - configure.ts: import ServerSyncFn from configStore instead of duplicating the type - repoint the 2 remaining @plannotator/shared test imports to @plannotator/core - AGENTS.md/CLAUDE.md: document the new packages/core package All host-path only — Plannotator behavior unchanged. typecheck clean, no cycles, full suite green. Skipped (not simple/over-engineering): usePlanDiff prop->module-level (design change), Obsidian late-bind, getSnapshot guard (inert), transport (variance). --- AGENTS.md | 3 ++- .../ui/components/plan-diff/PlanDiffViewer.tsx | 15 +++++++++++---- .../ui/components/sidebar/FileBrowser.test.ts | 2 +- packages/ui/config/configStore.ts | 2 +- packages/ui/configure.ts | 2 +- packages/ui/hooks/useExternalAnnotations.ts | 10 ++++++---- packages/ui/utils/annotateAgentTerminal.test.ts | 2 +- 7 files changed, 23 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d8cfb1ab5..1a0ce5560 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,8 @@ plannotator/ │ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts │ │ └── types.ts │ ├── ai/ # Provider-agnostic AI backbone (providers, sessions, endpoints) -│ ├── shared/ # Shared types, utilities, and cross-runtime logic +│ ├── core/ # @plannotator/core — browser-safe, zero-dep universal slice (pure utils + types) shared by ui + shared; published so @plannotator/ui can be installed standalone. `shared` re-exports the moved modules via one-line shims so Plannotator is unchanged. +│ ├── shared/ # Node/git/server logic + cross-runtime types (re-exports browser-safe modules from @plannotator/core) │ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only) │ │ ├── draft.ts # Annotation draft persistence (node:fs only) │ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) diff --git a/packages/ui/components/plan-diff/PlanDiffViewer.tsx b/packages/ui/components/plan-diff/PlanDiffViewer.tsx index e40558e41..ae456297b 100644 --- a/packages/ui/components/plan-diff/PlanDiffViewer.tsx +++ b/packages/ui/components/plan-diff/PlanDiffViewer.tsx @@ -82,11 +82,18 @@ export const PlanDiffViewer: React.FC = ({ if (!canOpenVscodeDiff || baseVersion == null) return; setVscodeDiffLoading(true); setVscodeDiffError(null); - const result = await (onOpenVscodeDiff ?? defaultOpenVscodeDiff)(baseVersion); - if (result.error) { - setVscodeDiffError(result.error); + try { + const result = await (onOpenVscodeDiff ?? defaultOpenVscodeDiff)(baseVersion); + if (result.error) { + setVscodeDiffError(result.error); + } + } catch { + // A host-supplied opener that throws (instead of returning { error }) must + // not wedge the button in a permanent loading state. + setVscodeDiffError("Failed to open VS Code diff"); + } finally { + setVscodeDiffLoading(false); } - setVscodeDiffLoading(false); }; return ( diff --git a/packages/ui/components/sidebar/FileBrowser.test.ts b/packages/ui/components/sidebar/FileBrowser.test.ts index c7884ab1e..ff00a513f 100644 --- a/packages/ui/components/sidebar/FileBrowser.test.ts +++ b/packages/ui/components/sidebar/FileBrowser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { VaultNode } from "../../types"; -import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/shared/workspace-status"; +import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types"; import { getAggregateWorkspaceChange, getFileEditStatus, getWorkspaceChange, isFileTreeSelectionDisabled, normalizePathForLookup } from "./FileBrowser"; describe("FileBrowser workspace status lookup", () => { diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index ec102324d..c57eed250 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -30,7 +30,7 @@ function deepMerge(target: Record, source: Record) => void; +export type ServerSyncFn = (payload: Record) => void; /** Default = today's inline POST /api/config (best-effort). keepalive lets the request outlive page teardown (pagehide flush). */ diff --git a/packages/ui/configure.ts b/packages/ui/configure.ts index 4d4ad3c1a..0f786e689 100644 --- a/packages/ui/configure.ts +++ b/packages/ui/configure.ts @@ -7,9 +7,9 @@ import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDra import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; import { setAITransport, type AITransport } from './hooks/useAIChat'; import { configStore } from './config'; +import type { ServerSyncFn } from './config/configStore'; type ExternalAnnotationBase = { id: string; source?: string }; -type ServerSyncFn = (payload: Record) => void; export interface PlannotatorUIConfig { imageSrcResolver?: ImageSrcResolver; diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts index da750c7b7..1b54d90a2 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -127,10 +127,10 @@ export function useExternalAnnotations | null>(null); const receivedSnapshotRef = useRef(false); - // Capture the transport once so subscribe/poll and CRUD always use the same - // backend instance (contract: "set once at startup"). Reading the live module - // global in CRUD while the effect captured at mount would split reads and - // writes across two backends if a host swapped the transport after mount. + // Holds the active transport, shared by subscribe/poll AND the CRUD callbacks so + // reads and writes never split across backends. (Re-)captured from the module + // global when the effect runs on enable (below), so a host that installs a + // transport before enabling annotations is honored, not the stale default. const transportRef = useRef(externalAnnotationTransport as ExternalAnnotationTransport); useEffect(() => { @@ -142,6 +142,8 @@ export function useExternalAnnotations; const transport = transportRef.current; // --- Reducer (applies snapshot|add|remove|clear|update), verbatim --- diff --git a/packages/ui/utils/annotateAgentTerminal.test.ts b/packages/ui/utils/annotateAgentTerminal.test.ts index 07dc4c7fd..95e7e323c 100644 --- a/packages/ui/utils/annotateAgentTerminal.test.ts +++ b/packages/ui/utils/annotateAgentTerminal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { AgentTerminalAgent } from "@plannotator/shared/agent-terminal"; +import type { AgentTerminalAgent } from "@plannotator/core/agent-terminal"; import { resolveAnnotateAgentId } from "./annotateAgentTerminal"; const agents: AgentTerminalAgent[] = [ From cd7cc06d829e9c53f75e3953a7abf932e8f10ffb Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 18:57:16 -0700 Subject: [PATCH 41/67] docs: collapse 29 ADR process docs into one packages/ui/README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch had accumulated ~6,200 lines of ADR scaffolding (6 decisions, 7 specs, 10 research spikes/synthesis, 6 worklogs/roadmaps/plans) for this one effort. Replace all of it with a single concise README that ships with the published package: what @plannotator/ui + @plannotator/core are, why they exist (commercial reuse), how the host-override seams work (configurePlannotatorUI), how a consumer installs/builds, and the one rule (don't reimplement from scratch — add a seam). Repoint the CLAUDE.md banner at the README. No code references the deleted docs; main's pre-existing adr/ docs untouched. --- AGENTS.md | 2 +- ...ral-document-ui-package-20260620-083633.md | 133 -- ...ument-ui-parity-cutover-20260621-122015.md | 89 -- ...blished-building-blocks-20260622-180637.md | 82 -- ...mments-host-overridable-20260623-085309.md | 52 - ...extras-host-overridable-20260623-102104.md | 42 - ...ore-package-and-publish-20260623-140537.md | 66 - ...nt-ui-extraction-intent-20260620-085249.md | 287 ----- ...document-ui-extraction-roadmap-20260622.md | 93 -- .../document-ui-parity-checklist-20260622.md | 92 -- ...i-parity-cutover-intent-20260621-122245.md | 268 ----- .../document-ui-phase-0-1-worklog-20260622.md | 171 --- .../document-ui-phase-7-plan-20260623.md | 679 ----------- ...ment-ui-comments-system-20260623-084806.md | 73 -- ...urrent-state-and-parity-20260621-115603.md | 216 ---- ...-ui-extraction-boundary-20260620-082002.md | 744 ------------ ...cument-ui-extras-system-20260623-100827.md | 56 - ...ment-ui-reuse-inventory-20260622-183000.md | 121 -- ...KE-publish-core-package-20260623-125551.md | 54 - ...is-document-ui-comments-20260623-084806.md | 52 - ...-document-ui-extraction-20260620-082343.md | 266 ---- ...esis-document-ui-extras-20260623-100827.md | 47 - ...is-publish-core-package-20260623-125551.md | 44 - ...cument-ui-comments-seam-20260623-084806.md | 85 -- .../document-ui-extraction-20260620-083307.md | 1066 ----------------- ...xtraction-plan-verified-20260622-184500.md | 158 --- ...document-ui-extras-seam-20260623-100827.md | 53 - ...mpleteness-review-fixes-20260622-085528.md | 519 -------- ...ument-ui-parity-cutover-20260621-121115.md | 467 -------- .../publish-core-package-20260623-125551.md | 92 -- packages/ui/README.md | 53 + 31 files changed, 54 insertions(+), 6168 deletions(-) delete mode 100644 adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md delete mode 100644 adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md delete mode 100644 adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md delete mode 100644 adr/decisions/005-make-comments-host-overridable-20260623-085309.md delete mode 100644 adr/decisions/006-make-extras-host-overridable-20260623-102104.md delete mode 100644 adr/decisions/007-carve-core-package-and-publish-20260623-140537.md delete mode 100644 adr/implementation/document-ui-extraction-intent-20260620-085249.md delete mode 100644 adr/implementation/document-ui-extraction-roadmap-20260622.md delete mode 100644 adr/implementation/document-ui-parity-checklist-20260622.md delete mode 100644 adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md delete mode 100644 adr/implementation/document-ui-phase-0-1-worklog-20260622.md delete mode 100644 adr/implementation/document-ui-phase-7-plan-20260623.md delete mode 100644 adr/research/SPIKE-document-ui-comments-system-20260623-084806.md delete mode 100644 adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md delete mode 100644 adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md delete mode 100644 adr/research/SPIKE-document-ui-extras-system-20260623-100827.md delete mode 100644 adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md delete mode 100644 adr/research/SPIKE-publish-core-package-20260623-125551.md delete mode 100644 adr/research/synthesis-document-ui-comments-20260623-084806.md delete mode 100644 adr/research/synthesis-document-ui-extraction-20260620-082343.md delete mode 100644 adr/research/synthesis-document-ui-extras-20260623-100827.md delete mode 100644 adr/research/synthesis-publish-core-package-20260623-125551.md delete mode 100644 adr/specs/document-ui-comments-seam-20260623-084806.md delete mode 100644 adr/specs/document-ui-extraction-20260620-083307.md delete mode 100644 adr/specs/document-ui-extraction-plan-verified-20260622-184500.md delete mode 100644 adr/specs/document-ui-extras-seam-20260623-100827.md delete mode 100644 adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md delete mode 100644 adr/specs/document-ui-parity-cutover-20260621-121115.md delete mode 100644 adr/specs/publish-core-package-20260623-125551.md create mode 100644 packages/ui/README.md diff --git a/AGENTS.md b/AGENTS.md index 1a0ce5560..89948bd43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs and annotation of arbitrary markdown files. -> **Reusing the document UI (theme / markdown / editor / settings / layout) in the commercial Workspaces app? Read `adr/decisions/004-reuse-document-ui-as-published-building-blocks-*.md` FIRST.** ADRs 002 and 003 (and their `document-ui-extraction` / `document-ui-parity-cutover` specs and intents) describe a reverted, failed attempt — a from-scratch reimplementation that broke the app. Do **not** implement them or recreate `packages/document-ui`. The corrected plan in 004 is: share `@plannotator/ui` as published building blocks, keep Plannotator's app unchanged, never delete working code until a human confirms parity in the browser. +> **Reusing the document UI (theme / markdown / editor / settings / comments / layout) in the commercial Workspaces app? Read `packages/ui/README.md` FIRST.** It explains the published `@plannotator/ui` + `@plannotator/core` packages and the host-override seams a host plugs its own backend into via `configurePlannotatorUI()`. A prior from-scratch reimplementation of this UI broke the app and was reverted — do **not** rebuild it or recreate `packages/document-ui`. Add a seam to `@plannotator/ui` instead, keep Plannotator's app unchanged, and never delete working code until a human confirms parity in the browser. ## Project Structure diff --git a/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md b/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md deleted file mode 100644 index 54f3779e1..000000000 --- a/adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md +++ /dev/null @@ -1,133 +0,0 @@ -# 002. Extract a Provider-Neutral Document UI Package - -> ⚠️ **RE-SCOPED / SUPERSEDED BY ADR 004 — DO NOT IMPLEMENT AS WRITTEN.** The provider-neutral `DocumentReviewSurface` / `DocumentHostApi` approach in this ADR was attempted, broke the app, and was reverted on 2026-06-22. The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** — read it first. Kept here as history. - -Date: 2026-06-20 - -## Status - -Accepted - -## Context - -Plannotator began as a plan review UI. In Plan Mode, the Claude Code hook intercepts `ExitPlanMode`, starts the server, opens the browser, and waits for approve or deny. - -The product has since shifted. The main document workflow is now broader than plan review: users run annotate on markdown, text, HTML, URLs, folders, and last assistant messages. The same React app currently handles all of those modes. The annotate server serves document sessions through `/api/plan`, and `packages/editor/App.tsx` switches between plan review, annotate file, annotate folder, annotate message, raw HTML, archive, and goal setup. - -A sister Workspaces repo now needs the same document review experience. It should get the same Plannotator UI patterns for rendering, annotation, comments, file trees, edit state, draft restore, and feedback assembly, but with different provider mechanics: document ids instead of filesystem paths, workspace manifests instead of local directory walks, `If-Match` or version ids instead of disk hashes, and workspace APIs instead of `/api/doc` and `/api/source/save`. - -The current package split does not express this boundary. `@plannotator/ui` contains reusable primitives, but many hooks and components call hard-coded `/api/*` routes. `@plannotator/editor` contains the app shell and important document-domain behavior such as editable document state, source reconciliation, direct edits, draft restore, and agent-terminal integration. Moving only `Viewer` or renderer components would leave the hard product behavior trapped in `App.tsx` and force Workspaces to recreate it. - -The key abstraction is not local source-save. Local source-save is Plannotator's current writeback provider. The reusable concept is provider-neutral document writeback state: - -- clean -- dirty -- saving -- saved -- conflict -- missing -- error - -Plannotator local writeback uses `/api/source/save`, disk hashes, mtime, EOL metadata, file watching, and missing local files. Workspaces writeback uses workspace document APIs, `If-Match`, versions, missing document rows, and workspace restore semantics. The UI state and user experience should be shared; persistence details should belong to the host/provider. - -## Decision - -We will extract one shared package: - -```text -@plannotator/document-ui -``` - -The package will expose a product-level document review surface: - -```tsx - -``` - -The package will own the reusable document review loop: - -- markdown and raw HTML rendering -- markdown parsing and block rendering -- annotation lifecycle and highlight restoration -- comments, attachments, and annotation panel behavior -- linked document navigation -- document/file tree rendering and badges -- edit mode and edit session state -- provider-neutral writeback state -- conflict, missing, and error UI patterns -- draft save and restore behavior -- feedback and saved-change payload assembly -- code path validation UI and inline link handling -- optional Ask AI and agent-delivery integration points - -The package public contract must be provider-neutral. It must not require filesystem paths, `/api/source/save`, disk hashes, or local source-save terminology. Public types should use concepts such as `DocumentRef`, `LoadedDocument`, `DocumentReviewSession`, `DocumentHostApi`, `DocumentWritebackStatus`, `SaveDocumentRequest`, and `SaveDocumentResult`. - -Local Plannotator source-save will become the first provider implementation behind a browser-side adapter: - -```text -createPlannotatorHttpDocumentApi() -``` - -That adapter will map current routes and local source-save metadata into the provider-neutral contract: - -- `GET /api/plan` -- `GET /api/doc` -- `POST /api/doc/exists` -- `GET /api/reference/files` -- `GET /api/reference/files/stream` -- `POST /api/source/save` -- `GET/POST/DELETE /api/draft` -- `POST /api/feedback` -- `POST /api/approve` -- `POST /api/exit` - -The current routes will remain stable during extraction. We will not rename `/api/plan` as part of this work. - -Workspaces will be able to implement its own `DocumentHostApi` using workspace document ids, manifests, annotation APIs, versions, and `If-Match` behavior. That adapter does not need to live in this repository. - -The host app will continue to own runtime and environment policy: - -- CLI/plugin command interception -- server startup and browser opening -- auth -- provider implementation -- plan-mode hook stdout behavior -- plan history -- plan diff -- archive -- goal setup -- permission mode setup -- note-app settings and persistence policy -- terminal runtime, WebTUI sidecar, remote-mode security, and installer logic - -Plan review becomes one host mode that supplies a document, capabilities, and approve/deny behavior to the shared document surface. Annotate remains the reference use case because it exercises the full document experience: arbitrary files, folders, raw HTML, linked docs, writeback, drafts, and optional agent delivery. - -We will extract in phases: - -1. Create `packages/document-ui` with provider-neutral contracts. -2. Add `createPlannotatorHttpDocumentApi()` over current Plannotator routes. -3. Move document-domain state out of `packages/editor`, renaming public concepts from local source-save to provider-neutral writeback where appropriate. -4. Create `DocumentReviewSurface` around the existing viewer, HTML viewer, editor toggle, linked-doc behavior, file tree, annotation panel, drafts, and writeback state. -5. Move provider-neutral feedback assembly into the package while keeping Plannotator's agent-specific markdown wrapping in the host. -6. Shrink `packages/editor/App.tsx` into a host shell that loads the session, configures capabilities, handles plan/annotate policy, and renders `DocumentReviewSurface`. -7. Add contract and surface tests, including an in-memory provider and Bun/Pi route mapping tests. - -## Consequences - -Plannotator's document experience becomes the upstream UI for both Plannotator and Workspaces. - -The first extraction target is not just `Viewer`. The implementation must move the document-domain behavior that makes the UI useful: writeback state, draft restore, linked-doc state, comments, edit state, file tree badges, and feedback assembly. - -The package boundary will force Plannotator-local assumptions behind an adapter. Filesystem paths, disk hashes, mtime, EOL metadata, and `/api/source/save` remain valid implementation details for Plannotator local sessions, but they must not become required public fields. - -The writeback model becomes shared and provider-neutral. This gives Workspaces the same dirty/saving/saved/conflict/missing/error UI without inheriting local filesystem semantics. - -`@plannotator/ui` will likely remain a lower-level UI primitive package at first. `@plannotator/document-ui` can depend on it and gradually pull document-specific components into the new package. We will avoid a giant one-shot component move. - -Plan diff, archive, goal setup, permission mode setup, and terminal runtime stay host-owned in the first boundary. They can be exposed as optional slots or capabilities where needed, but they are not core document package responsibilities. - -Bun/Pi server parity remains required. A frontend package extraction does not remove the need to update both server implementations when route behavior changes. The first implementation should avoid route shape changes and use adapter mapping instead. - -Tests must cover both the provider-neutral package behavior and the Plannotator local adapter behavior. At minimum, we need contract tests for Bun and Pi `/api/plan` and `/api/doc` mapping, source-save-to-writeback mapping, conflict and missing writeback results, in-memory provider surface behavior, linked-doc annotation caching, draft restore, and feedback payload assembly. - -This decision increases near-term implementation work because we are extracting behavior rather than only components. It reduces long-term duplication and prevents the sister repo from reimplementing Plannotator's document state machine under a different backend. diff --git a/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md b/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md deleted file mode 100644 index cb4384dd0..000000000 --- a/adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md +++ /dev/null @@ -1,89 +0,0 @@ -# 003. Complete Document UI Parity Cutover - -> ⚠️ **REVERTED — DO NOT IMPLEMENT.** This cutover was attempted by an AI agent and failed: it produced a ~26,500-line from-scratch reimplementation, deleted the working `App.tsx`, and broke rendering (dead sidebars, wrong experience). Reverted on 2026-06-22. **This ADR is superseded by `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** — read it before doing any document-UI work. Kept here as a post-mortem only. - -Date: 2026-06-21 - -## Status - -Accepted - -## Context - -ADR 002 established `@plannotator/document-ui` as the provider-neutral package for Plannotator's reusable document review experience. The branch has since implemented a substantial package: provider-neutral session/document types, `DocumentHostApi`, `DocumentReviewSurface`, writeback state, drafts, linked documents, document tree state, annotation persistence, feedback assembly, image handling, a Plannotator HTTP adapter, and an in-memory provider test harness. - -The package is real and green, but the app has not yet been cut over. `packages/editor/App.tsx` still owns the default Plan Review / Annotate render path. The new package surface is only mounted through an opt-in bridge behind `VITE_DOCUMENT_SURFACE=1`. - -The old shell still owns several parity-critical workflows: - -- plan diff and version browser -- richer document chrome: toolstrip, sticky controls, sidebars, panels, file/message navigation, code previews, and shortcuts -- full Ask AI panel -- agent terminal shell -- archive mode -- goal setup -- settings, share/export/import, and note integrations -- Plannotator route and environment side effects - -A sister Workspaces repo needs the same document review UI with a different provider. Keeping the shared package as an optional renderer while the hard behavior remains in `App.tsx` would recreate the coupling this extraction is meant to remove. - -## Decision - -We will finish the cutover so `@plannotator/document-ui` becomes the default production document review surface for the Plan Review / Annotate app. The feature-flagged bridge path will be removed after parity is reached. - -The package owns the reusable document review loop: - -- markdown and raw HTML document review -- annotation lifecycle, comments, global comments, image attachments, and annotation persistence hooks -- linked document navigation -- document tree/file tree UI, badges, and provider-neutral document/message navigation -- document editing and provider-neutral writeback states -- draft restore UI and state -- feedback payload assembly -- plan/document version browsing and diff UI -- generic Ask AI document-review surface when a host AI API exists -- code/link preview UI when the host can load or validate targets -- default chrome needed for parity: toolstrip, sticky controls, sidebars, panels, empty states, banners, shortcuts, and action buttons - -The host owns environment and product policy: - -- server routes, auth, browser opening, process lifetime, CLI/plugin/hook integration, and `ExitPlanMode` stdout decisions -- Plannotator settings persistence -- share/paste service policy and import/export modal policy -- Obsidian, Bear, and Octarine integrations -- agent terminal runtime, PTY/WebSocket bridge, installer, and remote security policy -- goal setup business logic -- archive storage, list loading, and archive-specific actions -- provider transport details for documents, comments, versions, and watches - -Version and diff support will move into `@plannotator/document-ui` as an optional provider-neutral capability. The host will load versions; the package will provide the default version browser, base-version selection, markdown diff computation, clean/raw diff render modes, diff annotations, edit-blocking behavior, and feedback inclusion. Plannotator will adapt `/api/plan/versions` and `/api/plan/version`; Workspaces can adapt its own document versions API without inheriting Plannotator route names. - -Archive and goal setup will not become core document-ui concepts for this cutover. Archive may be mounted through host slots or by loading read-only documents into the surface. Goal setup remains host-owned. - -Agent terminal runtime will stay host-owned. The package may provide slots and generic delivery state, but it will not own PTY, WebSocket, runtime install, remote-mode security, or terminal prompt policy. - -Ask AI will be shared only at the document-review surface level. The package may own the panel shell, document context, and in-document ask affordances. The host will own provider/model settings, auth, permission policy, and transport. - -`packages/editor/App.tsx` will be reduced to a Plannotator host shell. It should load the session through the Plannotator adapter, read settings, configure host slots and side effects, render completion/modals that remain Plannotator-owned, and render `DocumentReviewSurface`. It should no longer directly orchestrate the main document viewer, HTML viewer, plan diff viewer, annotation panel, linked-doc state machine, archive document rendering path, file/message navigation state, source-save UI state, or direct document feedback assembly. - -## Consequences - -The branch now has a concrete completion target: there should be one production document-review path, and it should go through `@plannotator/document-ui`. - -The cutover requires more work than the initial extraction because parity gaps must be closed before old code can be deleted. The biggest new package capability is provider-neutral version/diff support. - -The shared package will become larger and more product-shaped. That is intentional: the reusable value is the document review loop, not just renderer components. - -The Plannotator host shell remains necessary. It will still own routes, settings, share/export/note policy, archive storage, goal setup, terminal runtime, and hook/plugin behavior. Those are not shared document UI responsibilities. - -Workspaces gets a clear integration point: implement `DocumentHostApi` for workspace documents, manifests, annotations, writeback, and versions. It should not need to reimplement Plannotator's document state machine or inherit local source-save vocabulary. - -ADR 002 remains valid as the package boundary decision. This ADR extends it by declaring the cutover requirement and moving version/diff into the shared package as an optional capability. - -The implementation is complete only when: - -- the normal Plan Review / Annotate app renders through `@plannotator/document-ui` -- `VITE_DOCUMENT_SURFACE` is gone from production code -- the old document-review render path is removed -- plan review, annotate file, annotate folder, annotate last, raw HTML, linked docs, source-save, drafts, plan diff, Ask AI, terminal slot, archive, export/share, and note integrations still work -- Workspaces can supply a provider without depending on Plannotator local source-save terms diff --git a/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md b/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md deleted file mode 100644 index 28bad179d..000000000 --- a/adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md +++ /dev/null @@ -1,82 +0,0 @@ -# 004. Reuse the Document UI as Published Building Blocks (Reverts 003, Re-scopes 002) - -Date: 2026-06-22 - -## Status - -Accepted. - -**This ADR is the single source of truth for sharing Plannotator's document UI with the commercial Workspaces app.** It **reverts ADR 003** and **re-scopes ADR 002**. - -> If you are an agent or contributor about to work on "document-ui extraction," read this ADR first. Treat ADRs 002 and 003, and their specs/intents (`adr/specs/document-ui-extraction-*`, `adr/specs/document-ui-parity-cutover-*`, `adr/implementation/document-ui-*-intent-*`), as a **post-mortem of a failed attempt** — not a plan to execute. The `packages/document-ui` package they describe was deleted. Do not recreate it. - -## Context - -### What we actually want - -The commercial app, **Workspaces**, needs to reuse Plannotator's *presentation layer*: theme, Markdown rendering, the editor, settings UI, and layout/components — including the comment/annotation *rendering*. - -Workspaces is a separate, Cloudflare-based collaborative document platform. It owns its own world: - -- documents stored as versioned blob history (Git-like), D1 metadata, a raw file-serving worker (`tot.page`) -- workspaces/folders, public/private/open sharing, raw file URLs -- document version history and restore -- anchored comments and replies, shared with teammates -- agents commenting on documents via API keys -- realtime collaboration through Durable Objects -- browser login via WorkOS (hosted) or Cloudflare Access (self-host) -- hosted at `workspaces.plannotator.ai`, raw content at `tot.page` - -The shared thing is therefore **UI building blocks, not an application.** Workspaces renders Plannotator's components and feeds them *its own* data, comments, versions, and realtime sync. Plannotator keeps feeding the same components *its* local hook/file data. Same look; different data and backend behind it. - -### What we tried before (002/003) and why it failed - -The previous attempt built a ~26,500-line, 70-file `packages/document-ui` package containing a provider-neutral `DocumentReviewSurface` + `DocumentHostApi` meant to be *the whole app* for both Plannotator and Workspaces. It then deleted Plannotator's working 4,685-line `packages/editor/App.tsx` and routed the real app through the new surface. The result did not render correctly — dead sidebars, missing chrome, a different experience. The branch was reverted on 2026-06-22 (all of it was uncommitted working-tree changes; a backup patch + archive of the dead code is in the session scratchpad). - -Root causes (these are what this ADR exists to prevent repeating): - -1. **Abstracted for a consumer that didn't exist yet.** The provider-neutral contract was designed against an imagined Workspaces backend that couldn't be run or tested. Premature/speculative generality. -2. **The method was a rewrite, not a move.** Every behavior was re-derived as a new "provider-neutral decision function" with its own unit test. ~80 such steps = a from-scratch reimplementation by construction. -3. **The acceptance bar couldn't see the failure.** Verification was `bun test` / `typecheck` / `build` only. 357 unit tests stayed green while the actual rendered app was broken. Nobody opened it. -4. **Deleted the known-good code before parity existed.** The team's own parity SPIKE measured only ~55–65% app-visible parity, yet the working shell was deleted anyway, with a demo page as the fallback. - -## Decision - -1. **Plannotator's app stays as it is.** No cutover. `packages/editor/App.tsx` and the current experience are the reference to preserve, not a thing to replace. There is no "flip the production app to a new surface" step in this plan. - -2. **Share by publishing `@plannotator/ui` (and, if a slimmer editor package is needed, a small editor package) as versioned npm packages.** Workspaces installs them as a dependency. There is no shared "whole-app surface," no `DocumentReviewSurface`, and no `DocumentHostApi`. - -3. **Shared = presentation building blocks that take their data via props/callbacks.** In scope: - - theme and color tokens (`packages/ui/theme.css`) - - Markdown parser + block renderer (`parser.ts`, `BlockRenderer`, block components) - - document viewer / editor components - - settings UI - - layout / chrome components (toolbars, sidebars, panels) - - comment / annotation **rendering** components (the visual presentation of an anchored comment and its replies) - - The real, *narrow* extraction work is this: where a shared component currently calls a hard-coded `/api/*` route or reaches into Plannotator-only globals, lift that I/O up to a prop or callback so the host supplies it. Make the components backend-agnostic. **Do not rebuild their logic.** - -4. **NOT shared — each app owns its own:** document and comment *data* and state, realtime sync, version storage, feedback/delivery, server routes, auth, and backend. Workspaces wires comments to Durable Objects and its D1/blob store; Plannotator wires them to its local hook/file model. The shared component renders what it is given and emits events; it does not know who stores the data. - -## Hard rules (these are the safeguards we lacked) - -- **Move, don't rewrite.** Relocate existing code and change import paths. If a slice produces a large amount of brand-new code, stop — that is the warning sign that you are reimplementing instead of extracting. -- **No hard-coded routes or backend assumptions in shared packages.** Data comes in via props; actions go out via callbacks. -- **Parity is the gate, and a human verifies it in the browser.** After any change, Plannotator must look and behave identically across every mode: plan review; annotate file / folder / last; raw HTML; archive; goal setup; sidebars; plan diff; keyboard shortcuts; themes; settings; editor. Passing tests are necessary but **not sufficient** — last time they were green the entire time the app was broken. -- **Never delete or replace working code until a human signs off on parity**, mode by mode. Keep the old path until the replacement is proven. -- **Small, reviewed increments.** One component family at a time, eyeballed in the running app. No day-long unattended agent runs. - -## Consequences - -- Plannotator is never at risk during this work; its app keeps running unchanged the whole time. -- Workspaces gets a real, versioned dependency (`@plannotator/ui`) it can build its own product around, without inheriting Plannotator's routes, hooks, or local-file assumptions. -- The boundary is honest and small: **share the look, own the data.** A comment renders the same in both apps; how it syncs and persists is each app's own concern. -- Publishing adds a release/version step for the shared package(s). That is the accepted cost of a clean separate-repo boundary (Workspaces is its own repo on Cloudflare). -- ADRs 002 and 003 and their specs/intents are kept as history. The 2026-06-22 review-fixes spec (`adr/specs/document-ui-feature-completeness-review-fixes-*`) remains useful as a **checklist of behaviors the UI must preserve** — but read it as an inventory, not a build plan. - -## References - -- Reverts: `adr/decisions/003-complete-document-ui-parity-cutover-20260621-122015.md` -- Re-scopes: `adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md` -- Failed-attempt parity inventory (reuse as a checklist only): `adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md` -- Sound research on how the current system works: `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` diff --git a/adr/decisions/005-make-comments-host-overridable-20260623-085309.md b/adr/decisions/005-make-comments-host-overridable-20260623-085309.md deleted file mode 100644 index bd7ff2c8d..000000000 --- a/adr/decisions/005-make-comments-host-overridable-20260623-085309.md +++ /dev/null @@ -1,52 +0,0 @@ -# 005. Make Comments / Annotations / Drafts Host-Overridable (Phase 5) - -Date: 2026-06-23 - -## Status - -Accepted - -## Context - -ADR 004 set the plan: make `@plannotator/ui` reusable by the commercial Workspaces app by lifting each Plannotator-specific wire up to an optional override whose default is today's behavior, never changing Plannotator. Phases 0–4 did this for packaging, image/storage, the rendering stack, and the file tree. - -Phase 5 is comments — the core of Workspaces (teammates and AI agents commenting on documents, live). It was assumed to be the largest, most dangerous phase. Five code-research probes (`adr/research/SPIKE-document-ui-comments-system-20260623-084806.md`, synthesized in `adr/research/synthesis-document-ui-comments-20260623-084806.md`) found a narrower reality: - -- The comment **UI is already portable** — `AnnotationPanel`, `CommentPopover`, `AnnotationToolbar`, `AnnotationToolstrip`, `EditorAnnotationCard`, `useAnnotationHighlighter`, the `export*Annotations` serializers — all prop-driven, no backend wires. -- A **second consumer already proves it**: `review-editor` reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged. -- Annotation **state is host-owned already** (each app's own `useState`), so there is no shared reducer to wrestle. - -The real coupling is three things: the draft transport (`/api/draft`) plus a fragile 3-party "generation" protocol that prevents ghost drafts; the external-annotation transport (an SSE→polling state machine — the live-comment channel); and identity/authorship (the local "tater" nickname behind the `(me)` badge). Two further findings are not extraction work: highlight restoration is coupled to Plannotator's exact markdown renderer, and there is no reply/threading model (which Workspaces wants but Plannotator does not have). - -## Decision - -Make the comment system host-overridable through **three seams**, each a module-level default that reproduces today's behavior plus an optional override; Plannotator passes nothing and stays byte-for-byte unchanged. Land them lowest-risk first, as three separate verify-gated commits. - -1. **Identity (first, lowest risk).** Add a module-level identity provider in `packages/ui/utils/identity.ts` (`setIdentityProvider` / `resetIdentityProvider`) defaulting to today's `getIdentity` / `isCurrentUser`. Route the ~9 author-stamp sites and 2 `(me)`-display sites through it. Workspaces supplies the logged-in user; Plannotator keeps the tater nickname. (Identity already persists via the Phase-2 swappable storage and `configStore.init(serverConfig)`; this closes the last gap.) - -2. **Draft transport (second).** Inject a `DraftTransport` (load/save/remove) into `useAnnotationDraft` and `useCodeAnnotationDraft`, default = today's `/api/draft` fetches verbatim — including the `keepalive` retry and the `visibilitychange`/`pagehide` flush. The generation protocol stays end-to-end: `getDraftGeneration()` still escapes to the host and is still threaded into approve/deny/feedback/exit; the seam's contract documents that a host swapping transport must also honor generation-gated delete-on-submit and tombstoning, or ghost drafts return. The stateful refs, debounce, and pre-increment timing stay in the hook, verbatim. - -3. **External-annotation transport (last, riskiest).** Inject an `ExternalAnnotationTransport` (`subscribe` + optimistic CRUD + `getSnapshot(since)`) into `useExternalAnnotations`, default = the SSE→polling state machine moved verbatim (EventSource primary, 500ms polling fallback, 304 gate, 30s heartbeat, fallback-once). The reducer, optimistic mutators, version-scoping, and `enabled` gate stay in the hook. A Workspaces backend implements the same event contract over Durable Objects instead of SSE; the shared store/validators/encoding in `packages/shared/external-annotation.ts` are unchanged. - -The already-portable comment components and hooks are confirmed no-ops — no work. - -**Two things are explicitly excluded from Phase 5:** - -- **Renderer coupling — document, do not change.** Highlight restoration re-anchors against the rendered DOM and depends on `transformPlainText` matching the renderer. We write down an integration contract: a host must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` as a unit. (Optionally expose `transformPlainText` as overridable later; not now.) - -- **Replies / threading — defer as a new feature.** Comments are flat today. Threading is something Workspaces wants and Plannotator lacks; building it is adding a feature, not making existing behavior reusable. Phase 5 ships the flat model unchanged. Replies are a later, backward-compatible enhancement that Plannotator never populates, tracked in its own spec. - -## Consequences - -- Workspaces can power real-time, multi-person, agent-friendly commenting by implementing three transports/providers, without inheriting Plannotator's `/api/draft`, SSE routes, or tater identity. -- Plannotator is unchanged: every seam defaults to today's literal behavior; the draft generation protocol and the SSE→polling machine move verbatim (the exact failure mode of the reverted attempt is avoided by copying, not re-deriving). -- The parity bar per seam: full `bun test` stays at baseline (1620/0), typecheck and builds pass, `packages/editor/App.tsx` changes stay minimal/empty, and an eyeball confirms the surface — author/`(me)` badge, draft save+restore+no-ghost, live external annotations, and the SSE→polling fallback (kill the stream, confirm polling takes over). -- A new integration constraint is now on record: Workspaces must reuse Plannotator's markdown renderer for comment highlights to land. This narrows Workspaces' freedom on rendering but is required and cheap (it already wants the same look). -- Replies remain unbuilt; Workspaces' full collaborative-thread vision needs a follow-up once the seams land. - -## References - -- Spike: `adr/research/SPIKE-document-ui-comments-system-20260623-084806.md` -- Synthesis: `adr/research/synthesis-document-ui-comments-20260623-084806.md` -- Spec: `adr/specs/document-ui-comments-seam-20260623-084806.md` -- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/decisions/006-make-extras-host-overridable-20260623-102104.md b/adr/decisions/006-make-extras-host-overridable-20260623-102104.md deleted file mode 100644 index 2f9637ec1..000000000 --- a/adr/decisions/006-make-extras-host-overridable-20260623-102104.md +++ /dev/null @@ -1,42 +0,0 @@ -# 006. Make Extras (Versions, Settings, Sharing, Ask AI) Host-Overridable (Phase 6) - -Date: 2026-06-23 - -## Status - -Accepted - -## Context - -ADR 004 set the plan: make `@plannotator/ui` reusable by Workspaces by lifting each Plannotator wire up to an optional override defaulting to today's behavior, never changing Plannotator. Phases 0–5 did this for packaging, image/storage, rendering, file tree, and comments. Phase 6 is the remaining "extras": versions/plan diff, settings/config, sharing/export/notes, and Ask AI. - -Five-research probes (`adr/research/SPIKE-document-ui-extras-system-20260623-100827.md`, synthesized in `adr/research/synthesis-document-ui-extras-20260623-100827.md`) found these four subsystems are **mostly already portable** — `planDiffEngine` and all plan-diff render components, the sharing utils/`useSharing`/`ImportModal`, the notes-app helpers, `settings.ts`, the AI chat components, and `aiProvider`/`aiChatFormat` are pure or prop-driven. The actual coupling is a small set of wires, plus one CSS wrinkle: the block-level/raw plan-diff CSS lives in the app shell (`packages/editor/index.css`), not the package. - -## Decision - -Make the extras host-overridable through **five seams plus one CSS move**, each defaulting to today's behavior; Plannotator passes nothing and stays byte-for-byte unchanged. Land per-seam, verify-gated, lowest-risk first. - -1. **Versions / diff.** Optional version fetchers on `usePlanDiff` (default → `/api/plan/version(s)`, keeping the `selectBaseVersion` alert vs `fetchVersions` silent error asymmetry verbatim) and an optional `onOpenVscodeDiff?` on `PlanDiffViewer` (default → `/api/plan/vscode-diff`). **CSS move:** relocate the block-level/raw diff classes (`.plan-diff-added/removed/modified/unchanged`, `.plan-diff-line-*`) and `.annotation-highlight*` from `editor/index.css` into `packages/ui/theme.css` (co-located with `.plan-diff-word-*`); Plannotator imports `theme.css`, so its diff and highlights render identically while the components become reusable without app-shell CSS. - -2. **Settings / config.** `configStore.setServerSync(fn)` injecting only the final `/api/config` POST, keeping the singleton construction, eager cookie reads, 300ms debounce, and `deepMerge` batching byte-identical. Optional `onDetectObsidianVaults?` on `Settings`, keeping the `[obsidian.enabled]` effect dep and auto-select-first-vault verbatim. - -3. **Sharing / notes.** Optional `onSaveToNotes?` on `ExportModal` (matching today's `{results:{success,error}}` shape), keeping `showNotesTab = isApiMode && !!markdown` byte-for-byte. (Sharing utils, `useSharing`, `ImportModal`, and the notes-app helpers are confirmed noop.) - -4. **Ask AI (last, riskiest).** Inject an `AITransport` (session/query/abort/permission) into `useAIChat`, default = today's five `/api/ai/*` fetches. **The SSE reader loop, the epoch/createRequest guards, and the supersede-abort fetch position inside `createSession` stay untouched in the hook** — only the wire is parametrized. Capabilities fetch and provider resolution stay host-owned (already in App.tsx). - -**Out of Phase 6 (Plannotator-only — they stay home, no work):** `OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github release check), `useAgents` and `useAgentJobs` (code-review agent jobs). A reusing host simply does not import them. - -## Consequences - -- Workspaces can optionally reuse version-diff review, the settings panel, save-to-notes, and the AI chat by implementing the corresponding fetchers/callbacks/transport — but none of it is required for the already-shipped core (docs, tree, editing, comments). -- Plannotator is unchanged: every seam defaults to today's literal behavior; the AI streaming state machine and configStore batching move nowhere; the CSS relocation is a pure cut-and-paste that Plannotator still imports via `theme.css`. -- The diff components (and the Viewer's annotation highlights) become self-styling from the package — closing a latent CSS-contract gap from earlier phases. -- The parity bar per seam: full `bun test` stays at baseline (1620/0), typecheck and builds pass, `packages/editor/App.tsx` changes stay minimal/empty, and an eyeball confirms the surface — plan diff (all modes + VS Code + diff annotations), settings persistence + obsidian detect, save-to-notes, and AI chat (stream / permission / abort / mid-stream supersede). -- After Phase 6, the document UI is feature-complete for reuse; the remaining work is Phase 7 (publish) and the parked `@plannotator/ai` / `@plannotator/shared` publish-vs-inline decision. - -## References - -- Spike: `adr/research/SPIKE-document-ui-extras-system-20260623-100827.md` -- Synthesis: `adr/research/synthesis-document-ui-extras-20260623-100827.md` -- Spec: `adr/specs/document-ui-extras-seam-20260623-100827.md` -- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md b/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md deleted file mode 100644 index d2fc2488a..000000000 --- a/adr/decisions/007-carve-core-package-and-publish-20260623-140537.md +++ /dev/null @@ -1,66 +0,0 @@ -# 007. Carve `@plannotator/core`, complete the settings provider, and publish `core` + `ui` - -Date: 2026-06-23 - -## Status - -Accepted - -## Context - -Phases 0–6 (ADRs 004–006) made Plannotator's document UI (`packages/ui` = `@plannotator/ui`) host-overridable through optional seams that default to today's behavior, with Plannotator verified byte-for-byte unchanged. The remaining work (Phase 7) is to make `@plannotator/ui` actually installable by a separate consumer (the commercial "Workspaces"/Enterprise app) and publish it. - -Two facts force the shape of this phase: - -1. **`@plannotator/ui` can't be published as-is.** It depends on `@plannotator/shared` and `@plannotator/ai`, both unpublished workspace packages. `@plannotator/shared` is a Node/git/server kitchen sink we don't want on npm. An external installer must resolve every dependency from the registry, so the dependency tail has to be dealt with — without copying (the user's hard requirement: single source of truth, no duplication). - -2. **Workspaces will use the same UI settings, stored in its own backend.** The storage seam (`setStorageBackend`, Phase 2) already redirects setting *writes*. But the initial settings *load* runs against cookies at module-init, before a host can install its backend — so Workspaces' saved settings wouldn't load. The settings provider is half-built. - -An adversarial multi-model review (the `interrogate` pass) confirmed Phases 0–6 are sound and proportionate, found no Plannotator-affecting issues, and surfaced a small set of override-path fixes plus publish-toolchain decisions. The one contested decision (ship TS source vs. a compiled build) was resolved deliberately for the internal-consumer case. - -Supporting docs: `adr/research/SPIKE-publish-core-package-20260623-125551.md`, `adr/research/synthesis-publish-core-package-20260623-125551.md`, `adr/specs/publish-core-package-20260623-125551.md`. - -## Decision - -**1. Carve a new browser-safe `@plannotator/core` package (single source of truth).** -- Move the ~15 pure browser-safe modules the UI uses out of `@plannotator/shared` into `@plannotator/core` (`code-file`, `extract-code-paths`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`). -- For the node-bound modules the UI imports only *types* from (`config`, `storage`, `workspace-status`, and any review types `ui` surfaces): extract the type definitions into `core`; the Node implementation stays in `shared` and imports its types back from `core`. Types live once; nothing is duplicated. -- Re-export `AIContext` from `core` so `ui` no longer imports `@plannotator/ai`. -- `@plannotator/shared` re-exports each moved module via one-line shims, so all ~99 internal import sites and the Pi `vendor.sh` step keep working unchanged. Plannotator stays untouched. -- `core` is source-only, browser-safe, zero npm/node deps. **CI typechecks `core` with no `@types/node`** so a stray `node:*` import fails the build. - -**2. Complete the settings provider (in scope — Workspaces needs it).** -- Add a `loadFromBackend()` path so the initial settings load routes through the installed `StorageBackend`, not only cookies. -- Use the **prefetch + synchronous backend** model: a host fetches its settings, installs a sync backend that serves from that prefetched data, then calls `loadFromBackend()`. No async plumbing inside `configStore`; Plannotator's eager cookie default is unchanged (it never calls `loadFromBackend()`). - -**3. Single configuration front door.** -- Add `configurePlannotatorUI(config)`: one typed call that fans out to the 9 global host-override setters (image, storage, doc-preview, file-tree, identity, draft, external-annotations, AI, config-sync) plus the settings load. Render-time prop seams stay as props. A `` (React context) is the documented later upgrade if per-instance/SSR config is ever needed. - -**4. Ship TS source for JS; ship precompiled CSS.** -- Publish `core` + `ui` as TS source (no compiled build). Rationale: the only consumer is internal on a controlled stack (Vite/Cloudflare); a build exists to insulate unknown toolchains and buys ~nothing here, while avoiding a build pipeline and a `dist` artifact that can drift from what Plannotator runs. Revisit only if an external/arbitrary-stack consumer appears. -- Ship a **required** precompiled `@plannotator/ui/styles.css` (CSS-only build). The Tailwind `@source` glob into `node_modules` is fragile (pnpm symlinks) and a per-build cost; the stylesheet is the supported default, `@source` the documented fallback. - -**5. Publishing.** -- **Public npm** (open-source project; matches the existing `@plannotator/opencode` / `@plannotator/pi-extension` flow). -- **Lockstep versioning at the repo version (`0.21.0`)**, consistent with the other published packages; `core` + `ui` move together; `ui` → `core` pinned **exact**. -- `@plannotator/ai` stays unpublished-to-npm (npm `private: true`); the UI doesn't need it. (This is an npm-registry flag only — the code stays open on GitHub like everything else.) -- **Wire a CI publish job** for `core` + `ui` in `release.yml`. Before merging to main, **validate the artifacts on the branch**: `bun pm pack` each, inspect the tarball, and `npm publish --dry-run`. The first real publish goes out only on explicit go. - -**6. Pre-publish fixes (override-path only; none affect Plannotator), from the interrogation:** -- Fix `useExternalAnnotations` split-transport (reads/writes can hit different backends if the transport is set after mount); check `useFileBrowser` for the same shape. -- Reset `fallbackRef`/`receivedSnapshotRef` on effect re-run so a `false→true` `enabled` toggle doesn't silently stop updates. -- Add one override test per seam (`setX(fake)` → drive → assert → `resetX()`), which also makes the `reset*()` functions live. - -## Consequences - -- `@plannotator/ui` becomes installable: a consumer runs `npm install @plannotator/ui @plannotator/core`, calls `configurePlannotatorUI({...})` once, imports `@plannotator/ui/styles.css`, and builds — with the same UI settings persisted through its own backend. -- One copy of every shared module/type remains; `@plannotator/shared` and `@plannotator/ai` stay private to the monorepo. Plannotator's server, apps, editor, review-editor, and Pi build are unchanged. -- The carve + provider completion + fixes are all reversible and keep Plannotator byte-for-byte identical (parity gate: `bun test` 1620/0, typecheck, byte-identical shipped bundles, `git diff` confined to `core`/`shared`/`ui`/`editor`). The publish is the one outward-facing, hard-to-undo step and is gated on explicit approval after branch-validation. -- Shipping source couples consumers to a documented tsconfig/bundler setup; acceptable for the internal consumer, and the door to a compiled build stays open. -- New maintenance surface: a small published `@plannotator/core`, a CSS-only build, exact-version coupling between `core` and `ui`, and a node-free CI check on `core`. - -## References -- Spec: `adr/specs/publish-core-package-20260623-125551.md` -- Synthesis: `adr/research/synthesis-publish-core-package-20260623-125551.md` -- Spike: `adr/research/SPIKE-publish-core-package-20260623-125551.md` -- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/implementation/document-ui-extraction-intent-20260620-085249.md b/adr/implementation/document-ui-extraction-intent-20260620-085249.md deleted file mode 100644 index e0dd7dc78..000000000 --- a/adr/implementation/document-ui-extraction-intent-20260620-085249.md +++ /dev/null @@ -1,287 +0,0 @@ -# Document UI Extraction Intent - -> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Implementation log of the failed `@plannotator/document-ui` extraction (reverted 2026-06-22). The long "implemented slice" list here is a record of the from-scratch rewrite that broke the app. Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. History only. - -Status: active - -Date: 2026-06-20 - -## Intent - -Make Plannotator's document review experience reusable by Plannotator and Workspaces without turning the shared package into either a thin renderer or a local-filesystem abstraction. - -The shared package should own the product behavior users recognize as Plannotator's document review loop: - -- render markdown and raw HTML -- annotate text and blocks -- manage comments, global comments, and image attachments -- navigate linked documents -- browse document trees -- edit documents -- show clean, dirty, saving, saved, conflict, missing, and error writeback states -- restore drafts -- assemble annotation feedback and saved-change context - -The host should own routes, auth, server calls, plan-mode hook behavior, local disk or workspace persistence, and provider-specific policy. - -## Current Read - -The strongest boundary is a single `@plannotator/document-ui` package with a provider-neutral contract: - -```tsx - -``` - -The first extraction target is not `Viewer` alone. `Viewer` is important, but the hard reusable value is the document-domain state around it: loaded document identity, linked docs, editable content, writeback state, saved-change context, draft restore, document tree badges, and feedback assembly. - -Local source-save is Plannotator's first provider. It should stay behind `createPlannotatorHttpDocumentApi()` and map into provider-neutral writeback concepts. Workspaces should be able to implement the same contract with workspace document ids, manifests, versions, `If-Match`, and its own annotation APIs. - -## Execution Path - -1. Establish provider-neutral contracts in `packages/document-ui`. -2. Add a Plannotator HTTP adapter over the current server routes. -3. Move pure document-domain state first: writeback records, draft restore shapes, saved-change tracking, and feedback edit assembly. -4. Keep existing Plannotator UI behavior working through compatibility wrappers while logic moves out of `packages/editor`. -5. Wrap the current render and annotation experience in `DocumentReviewSurface`. -6. Shrink `packages/editor/App.tsx` into a host shell that loads session data, configures capabilities, handles plan/annotate policy, and renders the shared surface. -7. Add an in-memory provider test harness so Workspaces behavior can be exercised without Plannotator-local routes. - -## Guardrails - -- Do not rename `/api/plan` or current Plannotator routes during the extraction. -- Do not expose `/api/source/save`, disk hashes, mtime, or filesystem paths as required shared-package concepts. -- Do not split the package into many small packages yet. -- Do not move plan diff, archive, goal setup, permission-mode setup, or terminal runtime into the first shared surface. -- Preserve Bun/Pi server parity when route behavior changes, but avoid route changes in the first extraction slice. -- Keep Plannotator-specific final message wrapping in the Plannotator host; move provider-neutral feedback assembly into the package. - -## Implemented Slice - -The first implementation slice is intentionally narrow but now covers the reusable document-domain contract and its first Plannotator adapter: - -- create `packages/document-ui` -- define `DocumentRef`, `LoadedDocument`, `DocumentReviewSession`, `DocumentHostApi`, and writeback result types -- add `createPlannotatorHttpDocumentApi()` over current Plannotator routes -- move writeback state into provider-neutral helpers -- move direct-edit and saved-change feedback assembly into provider-neutral helpers -- move annotation/global attachment feedback assembly into provider-neutral helpers -- add document tree state with provider-neutral row identity, expansion, aggregate counts, and writeback badges -- add linked-document cache/navigation state with linked annotation feedback entries -- add provider-neutral draft state that saves/restores annotations, linked-document annotation entries, dirty writeback documents, and saved-change context through `DocumentHostApi` -- add provider-neutral edit/writeback state that drives active edit buffers, save requests, save success, conflicts, missing documents, discard, reload-conflict, unsaved documents, and saved-change entries through `DocumentHostApi.saveDocument` -- add an initial `DocumentReviewSurface` wrapper that resolves the active document, seeds writeback state, exposes render-state, lazy-loads the existing Plannotator markdown/raw-HTML renderers, routes renderer linked-document clicks through `hostApi.resolveLinkedDocument`/`hostApi.loadDocument`, and renders provider-neutral document chrome for writeback status, draft restore, edit/save/discard, conflict overwrite, conflict reload, linked-document back/error controls, document tree/file-row navigation, and annotation/right-panel presentation for current-document comments, attachments, code annotations, linked-document feedback, unsaved writeback edits, and saved-change context -- route raw-HTML iframe local document links through the same linked-document resolver as markdown links while leaving external, anchor, unsafe, and annotation-control clicks alone -- add typed React host slots (`terminalPanel`, left/right extras, header actions, footer), Plannotator-theme layout classes, and real renderer mode options (`selection`, `comment`, `redline`, `quickLabel`, `drag`, `pinpoint`) so the default surface can carry host-specific app chrome without owning host policy -- add `createMemoryDocumentHostApi()` as a Workspaces-like in-memory provider harness with document ids, manifest trees, base-revision saves, conflict/missing results, draft round trips, linked-doc resolution, and watch events -- add provider-neutral review lifecycle actions to `DocumentReviewSurface`: assemble feedback payloads with linked annotations, unsaved direct edits, and saved-change context; call `hostApi.submitFeedback`, `hostApi.approve`, and `hostApi.exit`; clear drafts after successful terminal actions; surface action errors and default action buttons without baking in Plannotator route policy -- add provider-neutral writeback watching through `useDocumentWritebackWatch`: subscribe to `hostApi.watchDocuments`, reload changed open documents through `hostApi.loadDocument`, reconcile clean updates, preserve dirty buffers as conflicts, mark deleted/missing documents, and expose watch state from the shared surface -- add optional provider-neutral annotation persistence through `hostApi.loadAnnotations`/`hostApi.saveAnnotations`, so Workspaces can back the same comment UI with its annotations API while local Plannotator can keep relying on drafts and terminal feedback -- add Plannotator host-session normalization and editor load-plan derivation in the local HTTP adapter: `/api/plan` responses are now translated once into document-session mode flags, render mode, markdown/html payloads, annotate source, sharing settings, source-file paths, root source-save writeback, recent messages, archive/goal setup metadata, version metadata, and the concrete app-shell initialization plan before `packages/editor/App.tsx` applies React side effects -- add an explicit opt-in Plannotator app bridge for `DocumentReviewSurface`: when `VITE_DOCUMENT_SURFACE=1` or `true`, `packages/editor/App.tsx` now hands the normalized session to `PlannotatorDocumentSurfaceBridge`, which mounts `` using the Plannotator HTTP adapter while the default production path keeps the legacy Plannotator shell -- move Plannotator direct-edit feedback compatibility formatting into `@plannotator/document-ui/plannotator-feedback`, including legacy direct-edit wording, saved-file-change wording, edit badge stats, panel item builders, provider-neutral current direct-edit content resolution over live/stored edit buffers, direct-edit commit decisions for stored edits/panel reveal/remapping, direct-edit discard decisions for reset/remap behavior, direct-edit draft restore decisions with CRLF normalization and current-work-wins skipping, direct-edit feedback presence decisions, saved-change-vs-direct-edit panel precedence, and current direct-edit feedback-section gating while writeback buffers are pending; `packages/editor` now imports that behavior from the shared package -- move Plannotator source-save editable-document state and disk reconciliation into `@plannotator/document-ui` adapter exports (`plannotator-source-documents`, `plannotator-source-reconciliation`), preserving local disk hash/missing-file behavior as Plannotator compatibility while keeping it out of the provider-neutral core contract -- move the Plannotator source-document `/api/doc` probe/snapshot client into `@plannotator/document-ui/plannotator-source-client`, so source-save hash refresh, missing-file detection, and markdown snapshot loading sit with the local adapter instead of the editor shell -- move the Plannotator restored single-file draft selection helper into the source-document adapter helpers, so source-save draft restore display policy is shared rather than editor-local -- move reusable feedback text assembly into `@plannotator/document-ui/feedback-text`: current-document annotations, linked-document annotations, editor annotations, code-file annotations, multi-message feedback, empty-feedback sentinels, source-specific titles, converted-source caveats, and linked-document markdown block enrichment now live in the shared package while Plannotator delivery policy stays in the editor shell -- move reusable multi-message feedback entry assembly into `@plannotator/document-ui/feedback-text`: the shared package now converts message picker rows plus linked-session annotation state into parser-ready message feedback entries, including root markdown blocks, linked-document markdown blocks, global attachments, and code annotations; `packages/editor/App.tsx` keeps selecting/saving message state and deciding when message-mode feedback is active -- move provider-neutral feedback submission interpretation into `@plannotator/document-ui/feedback-submission`: the shared package now composes annotation text with direct-edit and saved-change sections, reports whether review content exists, distinguishes saved-change-only context from unsent feedback, produces feedback-loss wording, and decides whether approve-with-notes payloads should include feedback text -- move annotate feedback target selection into `@plannotator/document-ui/feedback-submission`: the shared package now chooses linked document, source file, active file, folder, or current-file fallback targets for annotate feedback while `packages/editor/App.tsx` keeps Plannotator's message/file feedback templates and terminal delivery side effects -- move provider-neutral annotation remapping and highlight-restore decisions into `@plannotator/document-ui/annotation-remap`: markdown edits, reloads, and draft restores can now re-anchor annotations by selected text against newly parsed blocks, preserve diff/global/checkbox annotations, clear stale positional metadata when block ids move, mark missing text with an empty block id, choose which annotations should be restored into document highlights, detect missing restored highlights through a host-provided lookup, and build missing-highlight warning copy; `packages/editor/App.tsx` keeps markdown state, edit generation, DOM lookup, highlight repaint, and toast side effects -- move Plannotator-specific route payload assembly into `@plannotator/document-ui/plannotator-delivery`: approve, deny, annotate feedback, note-integration payloads, plan-save payloads, message-scope fields, and draft-generation URL helpers now sit with the local adapter while the editor shell keeps deciding when to call each route -- add a Plannotator delivery client in `@plannotator/document-ui/plannotator-delivery` and wire `packages/editor/App.tsx` approve, deny, annotate-feedback, annotate-approve, and annotate-exit handlers through it; the editor shell still owns settings lookup, saved-change validation side effects, terminal fallback, and submitted-state UI -- move generic agent-delivery state into `@plannotator/document-ui/agent-delivery`: feedback hashing, delivery records, target matching, duplicate-send decisions, current-delivery derivation, delivered-status visibility, and feedback-to-send flags are now provider-neutral; Plannotator's terminal helper keeps only terminal prompt/target formatting and adapts to the shared record shape -- move saved-change validation decisions into `@plannotator/document-ui/saved-change-validation`: submit-time stale/unverified blocking and draft-restore kept/changed-or-missing/unverified interpretation are now shared, while Plannotator keeps toast, cleanup, and draft-scheduling side effects in the host shell -- move direct-edit begin/change state decisions into `@plannotator/document-ui/edit-feedback`: non-writeback edit sessions now normalize CRLF before seeding the edit baseline, resolve missing original baselines, and report dirty/diff state through shared direct-edit lifecycle decisions while `packages/editor/App.tsx` keeps React state, source-save branching, terminal feedback revision, and draft scheduling side effects -- move direct-edit commit/discard display decisions into `@plannotator/document-ui/edit-feedback`: stored edit content, edit-stat reset/input, edit-panel reveal, editor dirty/diff reset, and remap content now flow through shared direct-edit decisions before `packages/editor/App.tsx` applies refs and annotation repaint -- move direct-edit draft-restore display decisions into `@plannotator/document-ui/edit-feedback`: restored, skipped, and ignored draft edit outcomes now map to stored edit content, edit-stat input, editor diff reset, edit-panel reveal, and remap content before `packages/editor/App.tsx` applies refs, annotation repaint, toasts, and draft scheduling -- move document review action lifecycle state into `@plannotator/document-ui/action-controller`: submitting/exiting lanes, open-session outcomes, submitted completions, and failure recovery are now shared; `packages/editor/App.tsx` approve, deny, annotate feedback, annotate approve, annotate exit, goal-setup exit, and callback delivery paths now use the shared controller while preserving Plannotator route policy and terminal fallback behavior -- move reusable review chrome copy and surface visibility decisions into `@plannotator/document-ui/chrome`: recovered-draft messages, add-feedback prompts, saved-change awareness text, unsaved-edit warnings, unsaved writeback continuation decisions, feedback/approve/exit/primary-submit action-intent decisions, submit-shortcut routing/ignore decisions, print-shortcut routing/ignore decisions, version/diff edit-block decisions, document-navigation edit-block decisions, document layout width state, feedback-loss warnings, completion-overlay title/subtitle decisions, sticky-header visibility, annotation-toolstrip visibility, folder-empty state, normal-document visibility, inline document-control visibility, left-sidebar collapsed/expanded visibility, left-sidebar tab open/toggle/wide-exit decisions, initial/TOC sidebar preference decisions, empty-TOC auto-close decisions, document-area collapsed-sidebar offset, sidebar tab visibility, right-panel tab visibility, right-panel toggle/reveal decisions, AI-panel visibility, panel resize-handle visibility, header action visibility/control state, viewer remount identity, linked-document breadcrumb variants/back labels, document copy labels, open targets, and message-picker count state are now shared; `packages/editor/App.tsx` and `AppHeader` keep the existing dialog/components, Claude Code issue links, warning continuation callbacks, provider capability flags, agent checks, DOM event wiring, callbacks, print side effect, Plannotator-specific linked-document labels, and local storage/path wording -- move provider-neutral Ask AI context assembly into `@plannotator/document-ui/ai-context`: the shared package now derives plan vs document AI context, document targets, source metadata, raw HTML vs markdown content, thread keys/titles, general ask labels, folder-empty blocking, and readable target priority without depending on the AI provider package; `packages/editor/App.tsx` keeps `useAIChat`, provider/model settings, terminal fallback delivery, toasts, and prompt formatting -- move reusable left-sidebar tab/open state into `@plannotator/document-ui/left-sidebar`: the shared generic controller now owns active-tab/open state, raw open/close transitions, review open/toggle transitions, preference-decision application, empty-TOC auto-close application, and wide-mode exit effects; `packages/editor/App.tsx` keeps concrete Plannotator tab content, archive/file/message loading side effects, resize widths, and invokes the host-owned wide-mode exit side effect -- move reusable right-panel tab/open state into `@plannotator/document-ui/right-panel`: the shared controller now owns annotation/AI active-tab state, open/close transitions, toggle/reveal transitions, compact-viewport reveal policy, and wide-mode exit effects; `packages/editor/App.tsx` keeps resize widths, mobile layout, and invokes the host-owned wide-mode exit side effect -- move reusable annotation state, visibility, feedback-presence, and provider-mutation routing into `@plannotator/document-ui/review-state`: the shared package now owns the root annotation/code-annotation/selection/global-attachment reducer, semantic add/select/update/remove actions, opposite-selection clearing, React-style setter adapters for Plannotator compatibility hooks, local/provider annotation merging while preferring live provider copies over draft-restored duplicates, rendered-viewer vs diff annotation partitioning, message/document feedback presence/counts, and provider-vs-local edit/delete routing; `packages/editor/App.tsx` keeps external annotation route calls, DOM highlight repaint, checkbox visual overrides, file-popout opening, and linked-document cache side effects -- move linked-document annotation badge/count summaries into `@plannotator/document-ui/linked-state`: the shared package now derives per-document annotation counts from linked-document caches, scopes those counts with a host-owned containment predicate, and summarizes annotations outside the active document for right-panel badges; `packages/editor/App.tsx` keeps the legacy `useLinkedDoc` cache, Plannotator filesystem path containment predicate, file-browser highlighting timer, and `AnnotationPanel` prop shape -- move linked-document editable-load decisions into `@plannotator/document-ui/linked-state`: the shared package now decides when linked-document navigation suspends an active writeback edit, clears active editability for non-editable/HTML targets, opens a folder-linked markdown document as editable, and resets an already-open editor session from current-vs-baseline content; `packages/editor/App.tsx` keeps the Plannotator editable-document store mutations, source-save keys, and React state side effects -- move linked-document back edit-state decisions into `@plannotator/document-ui/linked-state`: returning from a linked document now gets a shared decision for whether to exit edit mode and reset active editor dirty/diff state while `packages/editor/App.tsx` keeps invoking linked-document back, file-browser active-file clearing, and archive selection clearing -- move linked-document editable snapshot decisions into `@plannotator/document-ui/linked-state`: before linked-document navigation and submission, the shared package now decides whether to snapshot live editor content, displayed content, or nothing while `packages/editor/App.tsx` keeps reading the editor handle and mutating the Plannotator editable-document store -- move reusable linked-message annotation cache helpers into `@plannotator/document-ui/linked-state`: the shared package now counts annotations across root documents, linked documents, attachments, and code comments; creates empty message annotation snapshots; normalizes immutable message root content when picker messages change; and builds per-message badge counts over a linked-session-like shape without depending on Plannotator `/api/doc` or local filesystem paths; `packages/editor/App.tsx` keeps the legacy `useLinkedDoc` hook, message picker state, code-popout side effects, and feedback-entry rendering -- move current-message annotation state and active message badge-count decisions into `@plannotator/document-ui/linked-state`: the shared package now builds the live selected-message snapshot from message rows, linked-document session snapshots, code annotations, and selected code comment ids, and overlays that live state onto cached per-message counts; `packages/editor/App.tsx` keeps storing the cache ref/state and invoking the legacy linked-doc restore side effects -- move message-state cache merge/count recomputation and annotate feedback message-scope decisions into `@plannotator/document-ui/linked-state`: the shared package now folds the live selected-message state into cached message states, produces refreshed per-message annotation counts, and decides selected-message vs multi-message feedback scope for submissions; `packages/editor/App.tsx` keeps cache refs/state setters and Plannotator route body construction -- move message selection decisions into `@plannotator/document-ui/linked-state`: the shared package now decides whether a message picker request should be ignored or should select a normalized target message state, using cached message state when present and empty message state otherwise; `packages/editor/App.tsx` keeps the actual selected-message state update, legacy linked-doc restore, and code annotation restoration side effects -- move active message annotation count summaries into `@plannotator/document-ui/linked-state`: the shared package now derives total message feedback count, annotated message ids, and has-annotation flags from active per-message counts; `packages/editor/App.tsx` keeps rendering those values and passing them to sidebar/submission policy -- move wide/focus layout mode decisions into `@plannotator/document-ui/wide-mode`: wide-mode availability, enter/toggle/forced-exit decisions, sidebar/panel snapshot capture, sidebar/panel snapshot restore, explicit sidebar reopen, explicit panel reopen, and no-restore exit behavior are now shared; `packages/editor/wideMode.ts` remains a compatibility wrapper for the old Plannotator option names -- move reusable edit/writeback chrome decisions into `@plannotator/document-ui/edit-chrome`: markdown edit availability/reason classification, save button labels/disabled/tone state, edit/done/cancel/discard labels, edit-exit click transitions, stale discard-confirmation reset decisions, dirty/failed writeback status predicates, save-shortcut document/host/ignore routing, conflict banner copy, and missing-document banner copy are now shared; `packages/editor/App.tsx` maps those neutral states to existing Tailwind classes, passes Plannotator's disk wording and surface-mode facts, and keeps host notes/export fallback behavior, while the default `DocumentReviewSurface` uses the same save-label helper -- move reusable writeback edit-session chrome state into `@plannotator/document-ui/edit-chrome`: active-buffer dirtiness, conflict overwrite availability, and cancel-mode derivation now come from provider-neutral writeback content/status inputs while `packages/editor/App.tsx` keeps Plannotator source-document field mapping and button rendering -- move Plannotator local source-save request and response mapping into `@plannotator/document-ui/plannotator-source-client`: the adapter now builds `/api/source/save` bodies, maps success metadata back to source-save capabilities, preserves conflict snapshots, and normalizes local write errors; `packages/editor/App.tsx` keeps applying those mapped results to its compatibility store, repainting annotations, and showing Plannotator toasts -- move Plannotator local source-save result application into `@plannotator/document-ui/plannotator-source-documents`: mapped save results now update the source-document compatibility store for saved, live-dirty-after-save, conflict, clean-updated, conflict-unavailable, and error outcomes; `packages/editor/App.tsx` keeps repaint, toast, panel, and draft-scheduling side effects -- move Plannotator source-save display classification into `@plannotator/document-ui/plannotator-source-documents`: saved, clean-updated, conflict, conflict-unavailable, error, and noop outcomes now map to active editor state, edit-stat inputs, repaint/reset text, panel reveal, draft-save intent, edited-buffer clearing, and notification intent before `packages/editor/App.tsx` applies React effects and toasts -- move Plannotator source-backed edit-session begin/change classification into `@plannotator/document-ui/plannotator-source-documents`: entering edit mode now normalizes displayed source text, seeds the source edit buffer, and reports disk-baseline diff state, while live editor changes update source state and report edit-session/disk-baseline dirtiness; `packages/editor/App.tsx` keeps React UI flags and draft scheduling -- move Plannotator source-backed edit-session begin/change display classification into `@plannotator/document-ui/plannotator-source-documents`: source edit begin/change outcomes now map to edit-session reset text and active editor dirty/diff state before `packages/editor/App.tsx` applies refs, React editing flags, terminal feedback revision, and draft scheduling -- move Plannotator source-backed edit-commit classification into `@plannotator/document-ui/plannotator-source-documents`: committing the editor buffer now updates the source-document compatibility store, normalizes editor line endings, and reports disk-baseline diff state; `packages/editor/App.tsx` keeps edit-stat rendering, panel opening, markdown repaint, and draft-scheduling side effects -- move Plannotator source-backed edit-commit display classification into `@plannotator/document-ui/plannotator-source-documents`: committed, clean, and ignored source-edit outcomes now map to edited-buffer clearing, edit-stat reset/input, edit-panel reveal, and normalized markdown remap content before the host applies the shared edit-display effect plan -- move Plannotator source-file discard and reload-conflict outcome/display classification into `@plannotator/document-ui/plannotator-source-documents`: source-backed discard now reports active/non-active, removed-file, and replacement-text outcomes, then maps them to active editor reset, repaint text, root empty-document reset, linked-document back-navigation intent, active-file cleanup intent, and draft-save intent; reload-conflict reports the reloaded snapshot and maps it to repaint/reset, clean edit state, draft-save intent, and notification intent; `packages/editor/App.tsx` keeps applying React state, highlights, linked-doc/file-browser effects, toasts, and draft scheduling -- move Plannotator missing source-file selection display classification into `@plannotator/document-ui/plannotator-source-documents`: selecting a missing source-backed file now maps to reopened markdown content, active source key, optional edit-session reset text, and active editor dirty/diff/stat input before `packages/editor/App.tsx` applies linked-document, file-browser, and React side effects -- move Plannotator source-backed draft restore display classification into `@plannotator/document-ui/plannotator-source-documents`: restored source drafts now decide single-file vs active-folder display, active-key selection, repaint text, edit-stat inputs, and panel reveal in the local adapter; `packages/editor/App.tsx` keeps applying React state, highlights, and draft-scheduling side effects -- move Plannotator source-backed draft restore edit-display classification into `@plannotator/document-ui/plannotator-source-documents`: restored source draft display outcomes now map to shared active editor dirty/diff/stat state and edit-panel reveal intent while `packages/editor/App.tsx` keeps remapping the newly restored annotation list before applying the shared edit-display effects -- move Plannotator source-document reconcile event classification into `@plannotator/document-ui/plannotator-source-reconciliation`: file-missing, clean-update, status-update, and conflict events now map to active-document repaint/reset, edit-state, edit-stat, and notification outcomes in the local adapter; `packages/editor/App.tsx` keeps the actual React state updates, highlight repaint, toasts, and draft scheduling -- move the default `DocumentReviewSurface` editor-session lifecycle into `@plannotator/document-ui/documentEditorSession`: begin/change/save/overwrite/discard/reload-conflict/draft restore now coordinate through the provider-neutral writeback and draft controllers instead of living inline in the renderer -- move reusable edit-display effect planning into `@plannotator/document-ui/edit-display`: repaint text, edit-session reset text, active editor dirty/diff/stat state, edit-panel reveal, draft-save intent, and edited-buffer clearing now normalize through one provider-neutral plan before the Plannotator shell applies DOM repaint, refs, toasts, and local linked-file cleanup -- move the default `DocumentReviewSurface` toolbar/sidebar chrome state into `@plannotator/document-ui/chrome`: edit/save/conflict visibility, pending action labels, document-tree sidebar visibility, and annotation-persistence badge visibility now come from a pure shared helper before the surface renders its default JSX -- make shared document-surface image upload provider-owned: `DocumentReviewSurface` now exposes and passes `hostApi.uploadImage` into markdown, raw-HTML, global, and per-comment attachment controls, while legacy `@plannotator/ui` callers still keep the `/api/upload` fallback and providers without upload support disable file upload rather than silently calling Plannotator-local routes -- make shared document-surface image display provider-owned: `DocumentReviewSurface` now passes a host-owned image URL resolver through markdown images, raw HTML blocks, attachment thumbnails, and re-edit previews; the Plannotator HTTP adapter maps local paths to `/api/image`, while generic providers can return workspace asset URLs without inheriting Plannotator-local routes -- self-review tightened the image contract: the Plannotator adapter now populates `LoadedDocument.imageBase` for root and linked documents, derives image bases from `documentRef.path` when needed, resets thumbnail load/error state when image URLs change, treats Windows absolute paths as absolute in the legacy image fallback, and treats `allowImageAttachments: false` as disabling attachment controls rather than only disabling file upload -- map Plannotator renderer linked-doc hrefs into neutral refs in the local HTTP adapter, preserving `/api/doc` base resolution without exposing local source-save as a shared-package requirement -- wire `packages/editor/App.tsx` through the Plannotator HTTP document adapter for initial session loading -- map legacy Plannotator `/api/draft` source-save records into neutral draft/writeback records in the local adapter -- keep Plannotator compatibility wrappers in `packages/editor` so current feedback wording and saved-file validation behavior stay stable - -This gives both repositories a concrete contract to evaluate before the larger React surface move. - -## Remaining Work - -- Continue visual parity work where the existing editor still owns sticky toolstrips, sidebars, plan diff, archive, goal setup, Ask AI, and other app-shell policy outside `DocumentReviewSurface`. -- Shrink `packages/editor/App.tsx` into a Plannotator host shell for plan/annotate policy, route handling, settings, and legacy plan-mode behavior. -- Decide whether threaded comment/reply history and version history live in this package contract now or stay as host-provided optional capabilities until Workspaces integration exercises them. - -## Current Verification - -Validated after the latest document-ui extraction: - -- `bun test` for the focused `packages/document-ui` suite: 101 passing tests across 17 files. -- `bun test` for the focused `packages/document-ui` suite after feedback assembly extraction: 105 passing tests across 18 files. -- `bun test` for the focused `packages/document-ui` suite after feedback submission extraction: 110 passing tests across 19 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator delivery extraction: 115 passing tests across 20 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator delivery client wiring: 117 passing tests across 20 files. -- `bun test` for the focused `packages/document-ui` suite after generic agent-delivery extraction: 120 passing tests across 21 files. -- `bun test` for the focused `packages/document-ui` suite after saved-change validation decision extraction: 123 passing tests across 21 files. -- `bun test` for the focused `packages/document-ui` suite after action-controller extraction: 128 passing tests across 22 files. -- `bun test` for the focused `packages/document-ui` suite after chrome-copy extraction: 134 passing tests across 23 files. -- `bun test` for the focused `packages/document-ui` suite after edit-chrome extraction: 138 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-save client extraction: 141 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-save result application extraction: 145 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-file discard/reload outcome extraction: 151 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-backed edit-commit extraction: 155 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-backed edit-session begin/change extraction: 161 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator source-document reconcile event classification extraction: 165 passing tests across 24 files. -- `bun test` for the focused `packages/document-ui` suite after annotation-remap extraction: 170 passing tests across 25 files. -- `bun test` for the focused `packages/document-ui` suite after annotation highlight-restore helper extraction: 173 passing tests across 25 files. -- `bun test` for the focused `packages/document-ui` suite after edit-availability extraction: 176 passing tests across 25 files. -- `bun test` for the focused `packages/document-ui` suite after viewport-state extraction: 179 passing tests across 25 files. -- `bun test` for the focused `packages/document-ui` suite after wide-mode extraction: 185 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after right-panel state extraction: 187 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after left-sidebar state extraction: 189 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after sidebar-tab state extraction: 191 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after unsaved writeback continuation extraction: 193 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after review action-intent extraction: 197 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after header action-state extraction: 200 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after submit-shortcut gate extraction: 203 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after save-shortcut decision extraction: 205 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after right-panel toggle extraction: 207 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after left-sidebar tab decision extraction: 209 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after sidebar preference decision extraction: 213 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after wide-mode enter/toggle decision extraction: 219 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after right-panel reveal decision extraction: 221 passing tests across 26 files. -- `bun test` for the focused `packages/document-ui` suite after right-panel controller extraction: 226 passing tests across 27 files. -- `bun test` for the focused `packages/document-ui` suite after left-sidebar controller extraction: 231 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after annotation visibility/count extraction: 235 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after annotation mutation-routing extraction: 238 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after root annotation-state hook wiring: 240 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after annotation reducer-action wiring: 241 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after linked-message annotation cache extraction: 243 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after multi-message feedback entry extraction: 244 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after current-message state/count extraction: 247 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after message cache/scope extraction: 249 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after message selection decision extraction: 251 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after message count-summary extraction: 252 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after linked-document file badge-count extraction: 255 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after document chrome identity/label extraction: 257 passing tests across 28 files. -- `bun test` for the focused `packages/document-ui` suite after Ask AI context extraction: 262 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after print shortcut decision extraction: 263 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after version/diff edit-block extraction: 264 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after document-navigation edit-block extraction: 265 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after document layout width extraction: 266 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after edit-exit transition extraction: 268 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after current direct-edit content resolver extraction: 269 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit feedback/panel extraction: 271 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after current direct-edit feedback-section gate extraction: 273 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after linked-document editable-load decision extraction: 275 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit commit decision extraction: 276 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit discard decision extraction: 277 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit draft restore decision extraction: 278 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after source-backed draft restore display extraction: 282 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after source-save display classification extraction: 287 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after source discard/reload display classification extraction: 293 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after shared edit-panel presentation extraction: 295 passing tests across 29 files. -- `bun test` for the focused `packages/document-ui` suite after shared default editor-session extraction: 300 passing tests across 30 files. -- `bun test` for the focused `packages/document-ui` suite after shared edit-display effect planning extraction: 305 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after source-backed edit-commit display classification extraction: 309 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit commit/discard display decision extraction: 312 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit draft-restore display decision extraction: 313 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after direct-edit begin/change state decision extraction: 315 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after source-backed edit-session begin/change display classification extraction: 317 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after writeback edit-session chrome-state extraction: 318 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after missing source-file selection display classification extraction: 321 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after source-backed draft restore edit-display classification extraction: 322 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after linked-document back edit-state decision extraction: 323 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after linked-document editable snapshot decision extraction: 324 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after annotate feedback target selection extraction: 325 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after agent-delivery state derivation extraction: 326 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after Plannotator editor load-plan extraction: 326 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after default surface chrome-state extraction: 327 passing tests across 31 files. -- `bun test` for the focused `packages/document-ui` suite after provider-owned image upload threading: 328 passing tests across 31 files. -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/ui/components/html-viewer/bridge-script.test.ts`: 29 passing tests. -- `bun test` for the focused `packages/document-ui` suite after provider-owned image display threading: 329 passing tests across 31 files. -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/ui/components/html-viewer/bridge-script.test.ts` after ADR self-review fixes: 29 passing tests. -- `bun test` for the focused `packages/document-ui` suite after ADR self-review fixes: 329 passing tests across 31 files. -- `bun run typecheck` after ADR self-review fixes. -- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build` after ADR self-review fixes. -- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build` after ADR self-review fixes. -- `bun run --cwd apps/hook build` after ADR self-review fixes. -- `bun run --cwd apps/review build` after ADR self-review fixes. -- `git diff --check` after ADR self-review fixes. -- `bun run typecheck` after provider-owned image display threading. -- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. -- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build`. -- `bun run --cwd apps/hook build`. -- `bun run --cwd apps/review build`. -- `git diff --check`. -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/ui/components/html-viewer/bridge-script.test.ts`: 16 passing tests. -- `bun test packages/document-ui/documentReviewChrome.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 55 passing tests. -- `bun test packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 3 passing tests. -- `bun test packages/editor/documentSurfaceBridge.test.ts`: 2 passing tests. -- `bun test packages/editor/documentSurfaceBridge.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 13 passing tests. -- `bun test packages/document-ui/documentAIContext.test.ts`: 5 passing tests. -- `bun test packages/document-ui/documentFeedbackText.test.ts`: 5 passing tests. -- `bun test packages/document-ui/documentFeedbackSubmission.test.ts`: 6 passing tests. -- `bun test packages/document-ui/documentAgentDelivery.test.ts`: 4 passing tests. -- `bun test packages/document-ui/plannotatorHttpApi.test.ts`: 12 passing tests. -- `bun test packages/document-ui/documentEditDisplay.test.ts`: 6 passing tests. -- `bun test packages/document-ui/documentEditorSession.test.ts`: 5 passing tests. -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx`: 11 passing tests. -- `bun test packages/document-ui/editFeedback.test.ts packages/document-ui/plannotatorFeedback.test.ts`: 29 passing tests. -- `bun test packages/document-ui/plannotatorSourceDocuments.test.ts`: 60 passing tests. -- `bun test packages/document-ui/documentLinkedState.test.ts`: 20 passing tests. -- `bun test packages/document-ui/documentReviewState.test.ts`: 14 passing tests. -- `bun test packages/document-ui/documentEditChrome.test.ts`: 12 passing tests. -- `bun test packages/document-ui/documentReviewChrome.test.ts`: 43 passing tests. -- `bun test packages/document-ui/documentReviewLeftSidebar.test.ts`: 5 passing tests. -- `bun test packages/document-ui/documentReviewRightPanel.test.ts`: 5 passing tests. -- `bun test packages/document-ui/documentWideMode.test.ts packages/editor/wideMode.test.ts`: 18 passing tests. -- `bun test packages/ui/components/html-viewer/bridge-script.test.ts`: 4 passing tests. -- `bun run --cwd apps/review build`. -- `bun run --cwd apps/hook build`. -- `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build`. -- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. -- `bun run typecheck`. -- `git diff --check`. - -## Next Slice - -The next highest-value extraction is the remaining app-shell coupling around the review surface: sticky toolstrip components, sidebars, plan diff/archive/goal setup, source-file discard/reload side effects, and actual editor/toolbar layout still live directly in `packages/editor/App.tsx`. Text assembly, submission interpretation, annotate feedback target selection, root annotation state, annotation visibility/counting, annotation provider-mutation routing, linked-document file badge/count summaries, linked-document editable-load decisions, linked-document back edit-state decisions, linked-document editable snapshot decisions, linked-message annotation cache/counting/current-state/scope/selection/count-summary decisions, multi-message feedback entry assembly, annotation remapping/highlight-restore decisions, Ask AI context assembly, Plannotator route payload shapes, Plannotator route calls, Plannotator host-session/editor load-plan mapping, opt-in `DocumentReviewSurface` app bridge, saved-change validation decisions, action lifecycle state, review chrome copy, document chrome identity/labels, direct-edit content resolution, direct-edit feedback/panel decisions, direct-edit begin/change state decisions, direct-edit commit/discard/draft-restore decisions, direct-edit commit/discard/draft-restore display decisions, current direct-edit feedback-section gating, unsaved writeback continuation decisions, review action-intent decisions, submit/print shortcut gate decisions, version/diff edit-block decisions, document-navigation edit-block decisions, document layout width state, header action-state decisions, viewport visibility, left-sidebar state/layout/tab visibility/tab open-toggle decisions/sidebar preference decisions, right-panel state/visibility/toggle/reveal decisions, wide/focus layout mode enter/toggle/exit decisions, edit/writeback chrome decisions, writeback edit-session chrome state, edit-exit transition decisions, save-shortcut writeback routing, markdown edit availability, shared edit-panel presentation for unsaved/saved writeback edits, shared default renderer editor-session lifecycle, shared edit-display effect planning, Plannotator local source-save request/response mapping, source-save result application/display classification, source-backed edit-session begin/change/commit classification, source-backed edit-session begin/change display classification, source-backed edit-commit display classification, source-file discard/reload outcome/display classification, missing source-file selection display classification, source-backed draft restore display/edit-display classification, source-document reconcile event classification, and generic agent-delivery state now sit in `@plannotator/document-ui`; the terminal runtime, prompt formatting, plan-mode warnings, Claude Code issue-link markup, route policy, provider capability flags, agent checks, DOM event wiring, host notes/export fallback behavior, external annotation route calls, checkbox visual overrides, and Plannotator compatibility-store/toast/panel side effects remain host-owned. - -## References - -- Research: `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` -- Synthesis: `adr/research/synthesis-document-ui-extraction-20260620-082343.md` -- Spec: `adr/specs/document-ui-extraction-20260620-083307.md` -- Decision: `adr/decisions/002-provider-neutral-document-ui-package-20260620-083633.md` diff --git a/adr/implementation/document-ui-extraction-roadmap-20260622.md b/adr/implementation/document-ui-extraction-roadmap-20260622.md deleted file mode 100644 index 9de0af989..000000000 --- a/adr/implementation/document-ui-extraction-roadmap-20260622.md +++ /dev/null @@ -1,93 +0,0 @@ -# Document UI Extraction — Phased Roadmap - -Date: 2026-06-22 · Baseline commit: `30cfcebb` - -> The order of operations for making `packages/ui` reusable by the commercial Workspaces app. Governed by **ADR 004**; the per-step detail (exact seams, files, line numbers) lives in the verified plan `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`; the safety net is `adr/implementation/document-ui-parity-checklist-20260622.md`. -> -> **THE LAW:** move + decouple, never rewrite. Plannotator's experience cannot change. Every step = lift a URL/global to an optional prop whose **default is today's literal**, logic untouched. -> -> **The rhythm, every step:** make one small change → run the parity checklist → confirm identical → commit → next. Small steps, a human eyeballing the result. No multi-day unattended runs. - -## Phase ordering at a glance - -| Phase | What | Risk to Plannotator | Can Workspaces start? | -|---|---|---|---| -| 0 | Safety net (parity baseline + checklist) | none (no code change) | — | -| 1 | Packaging unblock | none (invisible) | — | -| 2 | Three foundation seams (storage, image, scroll-context) | low | — | -| 3 | Rendering stack (theme, markdown, viewer, html, editor) | low | **Yes — after this** | -| 4 | Navigation (sidebar + file tree) | medium (file tree SSE) | builds on it | -| 5 | Comments / annotations / drafts | medium (move verbatim) | builds on it | -| 6 | Optional extras (versions, settings, sharing, AI) | low–medium | as needed | -| 7 | Glue cleanup + publish | low | consumes the published package | - ---- - -## Phase 0 — Safety net (do once, before any code change) -**Goal:** be able to prove Plannotator didn't change after every step. **Risk:** none. -- [ ] Capture the automated baseline (Part A of the checklist): `typecheck`, `bun test` count, all three builds, bundle fingerprint → save to `scratchpad/parity-baseline.txt`. -- [ ] Keep a baseline build (and/or screenshots of each mode) to diff against. -- [ ] Confirm the checklist covers every mode you ship. - -## Phase 1 — Packaging unblock (invisible; gates external install) -**Goal:** make `@plannotator/ui` installable by an outside repo, with zero runtime change. **Risk:** none (no pixel changes). See verified plan "Step 0." -- [ ] Add the missing `dompurify` dependency at the root's exact `^3.3.3`. -- [ ] Resolve the two internal `workspace:* / private` packages (`@plannotator/ai`, `@plannotator/shared`) — publish them, or inline the ~11 verified browser-safe subpaths the UI value-imports. -- [ ] Add a `peerDependencies` block (react, react-dom, tailwindcss, tailwindcss-animate, radix set, lucide-react); keep as devDeps for in-repo typecheck. -- [ ] Fix the stale `tsconfig.json:21` alias (points at a nonexistent file); align the `diff` version (`^8.0.3` → `^8.0.4`). -- [ ] Add a `files` allowlist (assets, sprites, themes; exclude `*.test.*`). -- [ ] Keep source-only exports (no dist build); document required consumer bundler settings. -- **Guardrail:** builds byte-identical; in-repo React still resolves to one copy. - -## Phase 2 — Three foundation seams (everything else leans on these) -**Goal:** decouple the three cross-cutting pieces first so later phases are clean. **Risk:** low. -- [ ] **Storage adapter** — inject a `{getItem,setItem,removeItem}` into the cookie layer (`utils/storage.ts`); default = current cookie impl; **keep literal `plannotator-*` keys**. (Underlies ~24 modules — theme, layout prefs, identity.) -- [ ] **Image resolver** — the single `getImageSrc` shared by 5 consumers; module-level override, default = today's `/api/image` body verbatim, stable identity. -- [ ] **Scroll/layout context** — ship a `ScrollViewportContext` provider with the package (today its only provider lives in the glue at `App.tsx:3888`). -- **Guardrail:** identical cookie keys; all images emit identical URLs; sticky headers / TOC scroll / pinpoint unchanged. - -## Phase 3 — Rendering stack (the first visible win) -**Goal:** a document renders with the Plannotator look outside the app. **Risk:** low (Viewer is the one "risky" item; gate its validation call). -- [ ] Theme & tokens (`theme.css` + 51 `themes/*.css` + `print.css` as one atomic move). -- [ ] Markdown parsing + block rendering (BlockRenderer, blocks, inline transforms) — mostly transfer-as-is. -- [ ] Document Viewer — gate the unconditional `/api/doc/exists` validation (`Viewer.tsx:532`); default on. -- [ ] Doc-fetch seam for InlineMarkdown hover preview (`/api/doc`). -- [ ] Raw HTML viewer. -- [ ] Markdown editor (41-line shim over the published editor packages). -- **Milestone:** 👉 **Workspaces can start building in parallel here** — render docs while the rest proceeds. - -## Phase 4 — Navigation (sidebar + file tree) -**Goal:** the file-tree experience Workspaces is built around. **Risk:** medium (file-tree live updates). -- [ ] Sidebar shell + tabs (`SidebarContainer`/`SidebarTabs`/`useSidebar`) — already prop-driven, transfer-as-is. -- [ ] File tree: lift `useFileBrowser`'s fetch URLs **and the entire SSE watcher effect verbatim** into a default object; `useFileBrowser()` stays callable with zero args. -- **Guardrail:** existing `useFileBrowser.test.tsx` stays green **without modification** (if it needs rewriting, the default changed). - -## Phase 5 — Comments / annotations / drafts (the big one) -**Goal:** the core collaborative piece (teammates + agents commenting). **Risk:** medium — move the timing-sensitive parts verbatim. Last among core work because it touches the most. -- [ ] Draft transport seam (5 `/api/draft` fetches) — **document the 3-party draft-generation protocol** (escapes into approve/deny bodies; server tombstone-gates). -- [ ] External-annotations transport — move the **entire** SSE + polling-fallback effect verbatim into a default `subscribe()`. -- [ ] Identity seam — `author?`/`isCurrentUser?` props defaulting to the live `identity.ts` functions at the call site. -- **Guardrail:** approve/deny still carry the draft generation; live updates + fallback identical; `(me)` badge + author stamping intact. Note: highlight restoration is renderer-coupled — Workspaces must reuse BlockRenderer+InlineMarkdown as a unit. - -## Phase 6 — Optional extras (only when Workspaces needs them) -**Risk:** low–medium. Do not build preemptively. -- [ ] Versions / plan diff (inject fetchers; optional `onOpenVscodeDiff`; resolve the diff CSS that lives in the app shell). -- [ ] Settings / config (configStore write-back seam; obsidian-detect seam; storage adapter from Phase 2). -- [ ] Sharing / export / notes (`onSaveToNotes` seam; keep notes-tab gate verbatim). -- [ ] Ask AI (extract only the 5 fetch literals behind a `transport`; **do not touch** the SSE reader loop or epoch guards; capabilities/provider-resolution stay in the shell). - -## Phase 7 — Glue cleanup + publish -**Risk:** low. -- [ ] Move `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts` (pure move + one import-path edit). -- [ ] **Leave the coordinators in the glue** — right-panel/wide-mode/agent-terminal teardown, auto-open/close sidebar policy, tab-visibility + archive lazy-fetch, AI capabilities/provider init, panel-resize CSS-var writes. Workspaces writes its **own** thin coordinator over the same prop-driven primitives. (Re-deriving these generically is the forbidden path.) -- [ ] Publish `@plannotator/ui`; Workspaces installs and builds its own app/glue against it. - ---- - -## Hard guardrails (never violate) -1. **Default === today's literal.** Every seam ships with the current behavior as the default; Plannotator passes nothing and is unchanged. -2. **Move verbatim, never re-derive.** Especially the SSE transports, draft-generation protocol, configStore batching, and AI reader loop — copy them; do not "simplify." -3. **Never change the storage default** — inject per host; keep `plannotator-*` keys. -4. **Keep glue coordinators opaque** — they entangle side effects; genericizing them is how the last attempt broke. -5. **Run the parity checklist after every step.** Green automated checks are not enough — eyeball the app. -6. **Never delete a working path until parity is confirmed by a human**, mode by mode. diff --git a/adr/implementation/document-ui-parity-checklist-20260622.md b/adr/implementation/document-ui-parity-checklist-20260622.md deleted file mode 100644 index 013ffe3d9..000000000 --- a/adr/implementation/document-ui-parity-checklist-20260622.md +++ /dev/null @@ -1,92 +0,0 @@ -# Document UI — Parity Checklist ("did it break?") - -Date: 2026-06-22 · Baseline commit: `30cfcebb` - -> The safety net for the document-ui extraction (see ADR 004 + the verified plan `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`). **THE LAW: Plannotator's experience cannot change.** Run this checklist after *every* extraction step. If anything below differs from baseline, the step changed behavior — stop and fix before continuing. Passing automated checks are necessary but NOT sufficient; the manual click-through is the part that actually catches regressions (last time, tests were green while the app was broken). - -## How to use this -1. Before starting work, capture the **baseline** (Part A) once. Save the outputs. -2. After each extraction step: re-run Part A and compare, then walk Part B for the surfaces the step could touch (when in doubt, walk all of it). -3. Green automated + identical manual = the step preserved parity. Proceed. - ---- - -## Part A — Automated baseline (fast, run every step) - -Run from repo root. Record PASS/FAIL + the bundle fingerprint. - -```bash -bun run typecheck # must pass -bun test # record pass count; must not drop vs baseline -bun run build:hook # must succeed -bun run build:review # must succeed -bun run build:opencode # must succeed -# fingerprint the shipped artifacts — compare hash before/after a step: -find apps/hook/dist apps/opencode-plugin -name '*.html' -type f -exec shasum {} \; | sort -``` - -- [ ] `typecheck` passes -- [ ] `bun test` pass count ≥ baseline (note the number) -- [ ] all three builds succeed -- [ ] **bundle fingerprint recorded** (for a pure code-move step with defaults intact, the hashes should be byte-identical; if they change, understand exactly why) - -> Baseline capture (do once, fill in): typecheck ____ · test count ____ · build ____ · hashes saved to `scratchpad/parity-baseline.txt` - ---- - -## Part B — Manual click-through (the real test) - -Launch the relevant surface and confirm each item looks and behaves **identically to baseline**. Tip: keep a baseline build/screenshots of each screen to diff against. - -### Plan Review (`ExitPlanMode` flow, or `bun run dev:hook`) -- [ ] Plan renders: headings, code blocks, tables, callouts, alerts, task lists, images, links -- [ ] Theme correct on first paint (no flash/FOUC), theme switch works -- [ ] Select text → annotation toolbar appears → add comment / deletion / global comment -- [ ] Comment shows the right author, `(me)` badge on your own -- [ ] Sidebar: Table of Contents, Version Browser, Archive tabs all open and work -- [ ] Plan diff: `+N/-M` badge → toggle diff → rendered + raw modes → annotate a diff block -- [ ] Approve, and Deny-with-feedback both deliver correctly -- [ ] Export / Share (copy link + short URL) / Import round-trips -- [ ] Settings opens; AI providers, theme, identity all present -- [ ] Keyboard shortcuts: `Mod+Enter` submit, `Mod+P` print, sidebar toggles, wide mode - -### Annotate file (`plannotator annotate `) -- [ ] File renders with full markdown/PFM support -- [ ] Edit the doc → Save → source file on disk updates; saved-change banner correct -- [ ] Draft autosave/restore survives a reload -- [ ] Code-file links open the code popout; code annotations create + submit -- [ ] Send annotations delivers feedback - -### Annotate folder (`plannotator annotate /`) -- [ ] File tree renders with badges + writeback status -- [ ] Expand/collapse folders; open files; **live updates** when a file changes on disk -- [ ] Per-file annotations stay associated; multi-file feedback assembles correctly - -### Annotate last message / raw HTML -- [ ] Annotate-last: recent message(s) show; switching messages restores their annotations; feedback carries the message id/scope -- [ ] Raw HTML: renders, annotate, share produces portable HTML with assets - -### Archive / Goal setup -- [ ] Archive view lists saved decisions with approved/denied badges; read-only render -- [ ] Goal setup surface submits and closes - -### External / editor annotations (if applicable) -- [ ] External annotations posted to the API appear live (and update/delete reflect) -- [ ] VS Code editor annotations appear and are included in feedback (VS Code mode) - -### Cross-cutting visual -- [ ] All themes render (spot-check a few); print mode CSS intact -- [ ] Wide/focus mode hides/restores panels correctly -- [ ] Panel resize + sidebar collapse behave the same -- [ ] Images load everywhere (markdown body, inline, HTML blocks, comment attachments, re-edit previews) - ---- - -## What "fail" looks like (high-risk regressions to watch — from the audit) -- Theme/layout/identity **forgets settings** → the cookie-storage default got swapped instead of injected. -- **Some images load, some don't** → a `getImageSrc` call site was missed. -- **`(me)` badge or comment author missing** → identity default became empty instead of the live function. -- **Open `
` collapse on re-render** → a non-stable callback was threaded into a memoized block. -- **Sticky headers / scroll-to-anchor / TOC scroll broken** → ScrollViewport provider not mounted. -- **Plan-diff blocks render unstyled** → CSS that lives in the app shell wasn't accounted for. -- **Live updates stop / ghost drafts reappear** → an SSE or draft-generation protocol was re-derived instead of moved verbatim. diff --git a/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md b/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md deleted file mode 100644 index 8d4593514..000000000 --- a/adr/implementation/document-ui-parity-cutover-intent-20260621-122245.md +++ /dev/null @@ -1,268 +0,0 @@ -# Document UI Parity Cutover Intent - -> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Implementation log of the failed cutover (reverted 2026-06-22). Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. History only. - -Date: 2026-06-21 - -## What - -We are finishing the `@plannotator/document-ui` extraction by making it the production document-review surface for Plan Review and Annotate. The package already contains much of the provider-neutral document state, rendering, writeback, draft, linked-document, annotation, feedback, image, and Plannotator adapter work. The remaining intent is to close the parity gaps, remove the `VITE_DOCUMENT_SURFACE` opt-in path, and shrink `packages/editor/App.tsx` into a Plannotator host shell instead of keeping it as a second document-review implementation. - -The shared package should own the reusable experience that Workspaces also needs: markdown and raw HTML review, annotations, attachments, linked documents, document trees, document/message navigation, edit/writeback states, drafts, feedback assembly, version/diff browsing, generic Ask AI surface behavior, code/link previews, and the default chrome around those workflows. Plannotator-specific policy should remain outside the package: routes, settings, share/export/note behavior, archive storage, goal setup, terminal runtime, plugin/hook behavior, and local source-save transport details. - -## Why - -The point of this branch is not to create an optional renderer beside the old app. The point is to make the Plannotator document review experience reusable by a sister Workspaces repo without forcing Workspaces to reimplement the hard state machine in `App.tsx`. If the package stays opt-in and the old shell remains the real product path, the extraction fails in practice: Workspaces gets components, but not the product behavior users recognize. - -The provider boundary matters because Plannotator local source-save and Workspaces document writeback are different implementations of the same user-facing states. Plannotator uses `/api/source/save`, disk hashes, mtime, file watches, missing local files, and local drafts. Workspaces will use document ids, manifests, versions, `If-Match`, annotation APIs, and workspace-specific missing/conflict behavior. The UI should share clean, dirty, saving, saved, conflict, missing, error, draft restore, feedback assembly, and version diff behavior without requiring either provider to pretend it is the other. - -## How - -The implementation should proceed by closing package parity first, then cutting over the app. The first major missing package capability is provider-neutral version and diff support: add host API methods for listing and loading document versions, map Plannotator's `/api/plan/versions` and `/api/plan/version`, and move the version browser, diff view modes, diff annotations, and edit-blocking behavior into `@plannotator/document-ui`. This should be optional capability, so Workspaces can implement it with its own versions API and hosts without versions do not see the UI. - -After version/diff, the default `DocumentReviewSurface` chrome needs to reach visible parity with the old shell for the generic document workflows: toolstrip, sticky controls, wide/focus controls, sidebars, panel resize/collapse behavior, folder empty state, file/message navigation, linked-document chrome, code/link preview, shortcuts, and raw HTML controls. File and message browsing should become provider-neutral document navigation through `DocumentRef` and `DocumentTreeNode`, while local filesystem containment, vault retry behavior, and Plannotator route details stay in the Plannotator adapter or host. - -Writeback should then become authoritative inside the package. The old app should stop owning duplicate source-save UI state, edit/save/discard/reload-conflict behavior, draft restore decisions, and direct feedback assembly. Plannotator source-save remains first-class in the Plannotator adapter, but the shared contract remains writeback-oriented rather than disk-oriented. - -Ask AI should move only as far as the reusable document-review surface. The package can own document context, an AI panel shell, and in-document ask affordances when `hostApi.askAI` exists. The host keeps provider/model settings, auth, permission handling, terminal fallback behavior, and provider-specific transport. - -Terminal, archive, goal setup, settings, export/share/import, and note integrations should be mounted around or beside the package surface through host shell code and slots. The terminal runtime, PTY bridge, installer, remote-mode security, archive storage, goal setup semantics, and note-app policy are not shared document-ui responsibilities. - -The final cutover is to remove the feature flag and delete the old document render path. At that point `packages/editor/App.tsx` should load the Plannotator session, configure the Plannotator document API and host slots, render completion/modals that remain Plannotator-owned, and mount `DocumentReviewSurface`. It should no longer directly orchestrate `Viewer`, `HtmlViewer`, `PlanDiffViewer`, `AnnotationPanel`, `usePlanDiff`, `useLinkedDoc`, `useArchive`, file/message navigation, source-save UI state, or feedback assembly for the main document path. - -Completion means the normal app renders through `@plannotator/document-ui` with no `VITE_DOCUMENT_SURFACE` flag, the old document-review path is gone, existing Plannotator workflows still work, and Workspaces can implement the same UI through `DocumentHostApi` without inheriting local source-save vocabulary. - -## Implemented Slice: Provider-Neutral Version/Diff - -The first cutover slice moved version/diff support into the package boundary instead of leaving it only in the old editor shell. - -`@plannotator/document-ui` now defines provider-neutral version types and host API methods for listing and loading document versions. `DocumentReviewSurface` owns version state through `useDocumentVersions`, computes markdown diffs through the shared diff engine, exposes version state through the render state, renders a default version navigator, and can switch the document body into a package-owned clean/classic/raw diff view. - -The Plannotator HTTP adapter maps the existing `/api/plan/versions` and `/api/plan/version` routes into the provider-neutral contract. It also seeds the session with `previousPlan` and `versionInfo`, so the package can show previous-version changes without forcing an immediate extra fetch. The memory host API now supports version seeds, version listing, and version loading so Workspaces-like behavior can be tested without local Plannotator routes. - -This slice deliberately does not move Plannotator's VS Code diff route into the shared package. That route is local host policy. The package now owns the reusable document diff surface; richer route-specific actions can be added later through host actions or slots. - -Verification: - -- `bun test packages/document-ui`: 332 passing tests. -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts packages/document-ui/memoryDocumentHostApi.test.ts`: 37 passing tests. -- `bun run --cwd packages/document-ui typecheck`. -- `bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build`. -- `git diff --check`. - -Full repo `bun run typecheck` is still blocked before `packages/document-ui` by existing `packages/ui` type errors in `AnnotationToolbar.tsx` and `config/settings.ts`; the document-ui package typecheck itself passes. - -## Implemented Slice: Default Surface Routing and Delivery Parity - -The next cutover slice removed the runtime `VITE_DOCUMENT_SURFACE` gate from the editor app. `packages/editor/App.tsx` now routes normal Plan Review and Annotate document sessions through `PlannotatorDocumentSurfaceBridge` by default when a `DocumentReviewSession` is available. The bridge eligibility is now mode-based (`plan-review`, `annotate`, `annotate-folder`, `annotate-message`) and explicitly leaves shared sessions, archive, goal setup, and demo fallback on the host shell for now. - -The package action contract now carries draft generation and approve feedback. `DocumentReviewSurface` passes those values to the host API for submit, approve, and exit actions. The Plannotator HTTP adapter maps those provider-neutral actions through the existing production delivery helpers: - -- Plan Review feedback uses `/api/deny` with plan-save settings and draft generation. -- Plan Review approval uses `/api/approve` with plan-save, permission mode, agent switch, note-app settings, optional approve-with-feedback text, document text, and draft generation. -- Annotate feedback uses `/api/feedback` with annotations, code annotations, message scope, and draft generation. -- Annotate approve/exit use the existing draft-generation query parameter routes. - -The editor bridge remains the owner of Plannotator host policy. It supplies plan-save settings, permission mode, agent-switch preference, note-app configuration, and note auto-save status through the adapter context instead of moving those settings into `@plannotator/document-ui`. - -Shared feedback rendering now includes direct-edit and saved-file-change sections in addition to annotations, linked-document feedback, and code annotations. That keeps edit feedback intact when the package surface submits through the Plannotator adapter. - -This slice also fixed the previous full-repo typecheck blockers in `packages/ui/components/AnnotationToolbar.tsx` and `packages/ui/config/settings.ts`. - -Verification: - -- `bun test packages/editor/documentSurfaceBridge.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/plannotatorHttpApi.test.ts`: 32 passing tests. -- `bun test packages/document-ui packages/editor/documentSurfaceBridge.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx`: 337 passing tests. -- `bun test packages/editor`: 51 passing tests, 7 skipped existing hook tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. -- `rg -n "VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no code matches. - -Remaining cutover work: the old `packages/editor/App.tsx` document-review implementation is still present for archive, goal setup, shared sessions, and fallback/demo paths, and much of the old main-path code still exists in the file even though normal Plan Review and Annotate sessions now route through the package bridge. The final cleanup still needs host-slot parity for settings/share/export/note/archive/goal/terminal surfaces and then deletion of the duplicate old document-review orchestration. - -## Implemented Slice: Host Router Before Legacy Shell - -The default app no longer enters the legacy `App.tsx` hook graph before mounting the shared document surface. `packages/editor/App.tsx` now exports a thin router that renders `PlannotatorDocumentSurfaceHost` first, with the renamed legacy shell (`LegacyPlannotatorApp`) only as a fallback. - -`PlannotatorDocumentSurfaceHost` owns the Plannotator host bootstrap for normal document sessions: it loads `/api/plan` through the Plannotator document adapter, initializes config, chooses package-surface eligibility from the `DocumentReviewSession`, handles first-time Claude permission setup, preserves plan-arrival note auto-save behavior, computes completion copy, and mounts `PlannotatorDocumentSurfaceBridge`. Shared URL shapes (`/p/` and share-looking hash payloads) bypass package preloading so the existing legacy share loader can still restore shared documents without an API session. - -The legacy shell no longer stores a `documentSurfaceSession` or contains a second `PlannotatorDocumentSurfaceBridge` early return. That means normal Plan Review and Annotate sessions reach `@plannotator/document-ui` before legacy annotation, edit, diff, sidebar, and viewer hooks mount. Archive, goal setup, shared sessions, and demo/API-failure fallback still use the legacy shell. - -Verification: - -- `bun test packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 5 passing tests. -- `bun test packages/editor`: 53 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 334 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. - -Remaining cutover work: move or slot the still-host-owned archive, goal setup, shared-session, settings/share/export/note, and terminal surfaces so the legacy shell can be deleted instead of retained as fallback. The renamed `LegacyPlannotatorApp` still contains the old document-review orchestration for those fallback paths. - -## Implemented Slice: Thin Editor Entrypoint - -The editor entrypoint is now a real host shell instead of the giant legacy implementation. The previous `packages/editor/App.tsx` body was moved to `packages/editor/LegacyPlannotatorApp.tsx`, and `packages/editor/App.tsx` is now a small wrapper that mounts `PlannotatorDocumentSurfaceHost` with `LegacyPlannotatorApp` as fallback. - -This does not delete the old implementation yet, but it makes the production entrypoint shape match ADR 003: the app entry configures a Plannotator host route and the package surface is tried first. The old document-review orchestration is isolated behind a legacy fallback module for archive, goal setup, shared URLs, and demo/API-failure cases. - -Verification: - -- `wc -l packages/editor/App.tsx packages/editor/LegacyPlannotatorApp.tsx packages/editor/PlannotatorDocumentSurfaceHost.tsx` showed `App.tsx` at 9 lines and the legacy fallback isolated in `LegacyPlannotatorApp.tsx`. -- `bun test packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 5 passing tests. -- `bun test packages/editor`: 53 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 334 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. - -Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with package-owned document review behavior plus small host-owned shells/slots for archive, goal setup, shared-session loading, settings/share/export/note, and terminal runtime. - -## Implemented Slice: Goal Setup Host Cutover - -Goal setup is now routed before the legacy shell. `PlannotatorDocumentSurfaceHost` recognizes `mode: "goal-setup"` from the Plannotator adapter, normalizes the goal setup bundle, initializes host config, and renders a new `PlannotatorGoalSetupHost` instead of falling through to `LegacyPlannotatorApp`. - -This keeps the ADR boundary intact: goal setup remains host-owned environment workflow, not package-owned document review behavior. The new host shell wraps the existing `GoalSetupSurface`, supplies the top-level Submit and Close actions, posts close through the existing `/api/exit` endpoint, and uses the same completion overlay copy as the document surface. - -Verification: - -- `bun test packages/editor/PlannotatorGoalSetupHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 6 passing tests. -- `bun test packages/editor`: 54 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 334 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. - -Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with package-owned document review behavior plus small host-owned shells/slots for archive, shared-session loading, settings/share/export/note, and terminal runtime. Goal setup no longer needs the legacy shell on the normal API path. - -## Implemented Slice: Archive Host Cutover - -Standalone archive mode is now routed before the legacy shell. `PlannotatorDocumentSurfaceHost` recognizes archive content from the Plannotator adapter and renders a new `PlannotatorArchiveHost` instead of entering `LegacyPlannotatorApp`. - -The archive shell keeps archive storage and lifecycle host-owned. It uses the existing archive API routes (`/api/archive/plans`, `/api/archive/plan`, `/api/done`), reuses `ArchiveBrowser` for the saved-plan list, reuses the existing markdown `Viewer` for rendering archived plans, and keeps the archive completion overlay behavior. The `Viewer` import is browser-lazy so non-browser host imports and unit tests do not load `web-highlighter` before `window` exists. - -Verification: - -- `bun test packages/editor/PlannotatorArchiveHost.test.tsx packages/editor/PlannotatorGoalSetupHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/editor/documentSurfaceBridge.test.ts`: 7 passing tests. -- `bun test packages/editor`: 55 passing tests, 7 skipped existing hook tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. - -Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with shared-session loading, demo/API-failure fallback, settings/share/export/note host actions, and terminal runtime slots. Goal setup and standalone archive no longer need the legacy shell on their normal API paths. - -## Implemented Slice: Shared Session Host Cutover - -Shared URL sessions now route before the legacy shell. `PlannotatorDocumentSurfaceHost` still bypasses `/api/plan` preloading for share-shaped URLs, but it now renders `PlannotatorSharedSessionHost` instead of falling through to `LegacyPlannotatorApp`. - -The shared host keeps share/callback policy host-owned while using the package document surface. It decodes hash payloads and short `/p/` paste-service links into a provider-neutral in-memory document session, seeds shared annotations and global attachments into `DocumentReviewSurface`, and disables provider persistence/drafts for the portable shared context. Shared sessions expose a host-owned `Copy Link` header action that assembles an updated share URL from the current package feedback payload. Bot callback links (`cb`/`ct`) are handled as host delivery: package submit/approve actions call back with an updated annotated share URL, but the package remains unaware of Plannotator's share URL format. - -To support host-owned share/export actions without coupling them into the package, `DocumentReviewSurface` header action slots can now be render functions that receive the current feedback payload and action helpers. Existing static slot nodes continue to work. - -Verification: - -- `bun test packages/editor/sharedDocumentSession.test.ts packages/editor/PlannotatorSharedSessionHost.test.tsx packages/editor/PlannotatorDocumentSurfaceHost.test.ts packages/document-ui/DocumentReviewSurface.test.tsx`: 19 passing tests. -- `bun test packages/editor`: 58 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 335 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. - -Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with demo/API-failure fallback, fuller settings/share/export/note host actions, and terminal runtime slots. Normal document review, annotate, goal setup, standalone archive, and shared URL sessions no longer need the legacy shell on their normal paths. - -## Implemented Slice: Annotate Agent Terminal Host Slot Cutover - -Annotate agent terminal delivery is now wired into the package-backed production document surface without moving the terminal runtime into `@plannotator/document-ui`. - -`PlannotatorDocumentSurfaceHost` passes the Plannotator terminal capability from the loaded `/api/plan` session into `PlannotatorDocumentSurfaceBridge`. The bridge keeps the terminal runtime host-owned: it lazy-loads `AnnotateAgentTerminalPanel`, mounts it through the existing `DocumentReviewSurface` terminal slot, and exposes host header actions for opening, hiding, stopping, and sending feedback to the agent terminal. - -The default package submit action now preserves the legacy annotate behavior when a terminal session is ready. The bridge renders the current package feedback payload, wraps it with the Plannotator annotate feedback template, sends it to the terminal, records the delivery key, clears the draft through `DocumentReviewSurface`, and keeps the review session open. Duplicate sends of the same feedback/body/target in the same terminal session are treated as already delivered. If terminal delivery fails, the bridge falls back to the original `/api/feedback` submit path. - -The package contract gained a small provider-neutral submit result flag, `keepSessionOpen`, so host-owned delivery channels can clear drafts without forcing the completion overlay. The package still does not own PTY/WebSocket setup, runtime install, remote-mode security, agent selection, or terminal prompt policy. - -Verification: - -- `bun test packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx packages/document-ui/DocumentReviewSurface.test.tsx`: 17 passing tests. -- `bun run --cwd packages/document-ui typecheck`. -- `bun test packages/editor`: 59 passing tests, 7 skipped existing hook tests. -- `bun run typecheck`. -- `bun test packages/document-ui`: 336 passing tests. -- `bun run --cwd apps/hook build`. -- `git diff --check`. - -Remaining cutover work: delete `LegacyPlannotatorApp.tsx` by replacing its remaining fallback responsibilities with demo/API-failure fallback and fuller settings/share/export/note host actions. The annotate terminal no longer needs the legacy shell on the normal package-backed annotate file/folder paths. - -## Implemented Slice: Legacy Shell Deletion And Package Fallback - -The editor app no longer imports or mounts `LegacyPlannotatorApp`. `packages/editor/App.tsx` now mounts `PlannotatorDocumentSurfaceHost` directly, and the previous legacy fallback branch in `PlannotatorDocumentSurfaceHost` has been replaced by a package-backed fallback route. - -The fallback route is intentionally small and host-owned. If there is no active `/api/plan` session, or if a future unsupported session mode reaches the host, `PlannotatorDocumentSurfaceFallback` renders the existing demo plan through `DocumentReviewSurface` with an in-memory provider. This keeps development/no-server behavior available without preserving a second document-review implementation. - -`packages/editor/LegacyPlannotatorApp.tsx` has been deleted. Source checks now confirm there are no remaining `LegacyPlannotatorApp`, `status: 'legacy'`, `VITE_DOCUMENT_SURFACE`, `USE_DOCUMENT_SURFACE`, or `documentSurfaceSession` references in production packages/apps outside built artifacts. - -Verification: - -- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 336 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. -- `test ! -e packages/editor/LegacyPlannotatorApp.tsx`. -- `rg -n "LegacyPlannotatorApp|status: 'legacy'|VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE|documentSurfaceSession" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no matches. - -Remaining cutover work: fuller Plannotator host actions for settings, share/export/import, and quick note saves still need to be mounted around the package surface. The duplicate legacy document-review implementation is no longer present. - -## Implemented Slice: Plannotator Host Actions Around Package Surface - -Normal package-backed Plan Review and Annotate sessions now have Plannotator host actions mounted through the `DocumentReviewSurface` header slot. `PlannotatorDocumentSurfaceBridge` renders the existing host-owned `PlanHeaderMenu`, `Settings`, `ExportModal`, and `ImportModal` components around the package surface. - -The bridge prepares export/share/note data from the current package feedback payload rather than from legacy app state. It renders annotation output through the shared feedback assembler, uses the active document text or current edit/save payload for exported markdown, generates hash share URLs and paste-service short URLs through the existing Plannotator sharing utilities, downloads annotations, prints, and posts quick note saves to `/api/save-notes` for Obsidian, Bear, and Octarine. Settings remain Plannotator host policy and are conditionally mounted only when opened so server-rendered tests do not load browser-only settings stores. - -The package boundary remains intact: `@plannotator/document-ui` still receives only generic header slots, feedback payload callbacks, and provider-neutral annotation import actions. Plannotator share URLs, paste-service policy, note-app settings, and the settings UI stay in `packages/editor`/`@plannotator/ui`. - -Import review now decodes Plannotator hash links and paste-service short links in the host bridge, converts share payload annotations through the existing sharing utilities, and merges them into the package surface through the provider-neutral annotation import slot action. The host still owns Plannotator URL formats; the package owns only the annotation merge. - -Verification: - -- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. -- `bun test packages/document-ui`: 339 passing tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. - -Remaining cutover work at this point: package-owned generic Ask AI surface behavior still needs the package default panel and Plannotator HTTP adapter wiring. - -## Implemented Slice: Generic Ask AI And Import Parity - -`DocumentReviewSurface` now renders a provider-neutral Ask AI panel when the session exposes `canUseAskAI` and the host implements `hostApi.askAI`. The package owns document/plan context assembly, the panel shell, and streamed text rendering. The host still owns AI provider/model selection, auth, permission handling, and transport. - -The Plannotator HTTP adapter now implements `hostApi.askAI` over the existing `/api/ai/session` and `/api/ai/query` endpoints. It creates/reuses an AI server session per document review context, forwards context updates when the package context changes, and maps the server SSE stream into package-level `DocumentAskAIEvent` messages. - -Import review parity is also complete for normal package-backed sessions. `DocumentReviewSurface` exposes a provider-neutral `importAnnotations` slot action. `PlannotatorDocumentSurfaceBridge` keeps Plannotator share URL parsing host-owned, decodes hash and short links with the existing sharing utilities, and merges imported annotations/global attachments into the package annotation state without reaching into viewer DOM or highlighter internals. - -Verification: - -- `bun test packages/document-ui`: 339 passing tests. -- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. -- `test ! -e packages/editor/LegacyPlannotatorApp.tsx`. -- `rg -n "LegacyPlannotatorApp|status: 'legacy'|VITE_DOCUMENT_SURFACE|USE_DOCUMENT_SURFACE|documentSurfaceSession|Import review is not available" packages apps --glob '!**/dist/**' --glob '!**/node_modules/**'` returned no matches. - -Cutover status: the normal app entry no longer keeps a legacy document-review shell or feature-flag path. Plan Review, Annotate file/folder/message, shared sessions, archive, goal setup, fallback/demo, terminal delivery, settings/share/export/import/note actions, version/diff, writeback, drafts, annotation state, feedback assembly, and generic Ask AI now route through the package-backed document surface plus small Plannotator host shells/slots. - -## Self-Review Follow-Up - -The ADR self-review found and fixed two issues after the initial green run. - -First, `PlannotatorDocumentSurfaceBridge` could create a copied short share link from stale prepared export state when the current document required the paste-service short-link path. The bridge now generates short links from an explicit prepared export payload, so copy-share fallback uses the current package feedback payload. - -Second, the Plannotator HTTP adapter showed the generic Ask AI panel for every writable session. The old shell checked `/api/ai/capabilities` and hid AI when no provider was registered. `createPlannotatorHttpDocumentApi().loadSession()` now checks that capabilities route and maps `canUseAskAI` from actual provider availability. - -The self-review also cleaned tab-indented JSX in the touched surface file. - -Verification after self-review: - -- `bun test packages/document-ui`: 339 passing tests. -- `bun test packages/editor`: 60 passing tests, 7 skipped existing hook tests. -- `bun run typecheck`. -- `bun run --cwd apps/hook build`. -- `git diff --check`. diff --git a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md b/adr/implementation/document-ui-phase-0-1-worklog-20260622.md deleted file mode 100644 index 1701e4fc5..000000000 --- a/adr/implementation/document-ui-phase-0-1-worklog-20260622.md +++ /dev/null @@ -1,171 +0,0 @@ -# Document UI Extraction — Phase 0 & 1 Work Log - -Date: 2026-06-22 · Baseline commit: `30cfcebb` - -> Execution record for Phase 0 (safety net) and Phase 1 (packaging unblock) of the roadmap (`adr/implementation/document-ui-extraction-roadmap-20260622.md`). Law: move + decouple, never rewrite; **Plannotator's experience cannot change** — proven below by byte-identical shipped bundles. - -## Phase 0 — Safety net (DONE) - -Captured the known-good baseline at commit `30cfcebb` (saved to `scratchpad/parity-baseline.txt`): - -| Check | Baseline result | -|---|---| -| `bun run typecheck` | PASS (exit 0) | -| `bun test` | **1620 pass / 0 fail**, 1650 ran across 123 files | -| `bun run build:review` / `build:hook` / `build:opencode` | all OK | -| Shipped plan UI hash (`apps/hook/dist/index.html`, `redline.html`, `opencode-plugin/plannotator.html`) | `4ca0cbe9dd85c3674e6122f1e830704076efa129` | -| Shipped review UI hash (`apps/hook/dist/review.html`, `opencode-plugin/review-editor.html`) | `f404d00d9a47785ca925776d48b7a67b2b30b9dd` | - -The reusable click-through checklist lives at `adr/implementation/document-ui-parity-checklist-20260622.md`. The bundle-hash compare is the automated half; the manual click-through is run before any *behavioral* (non-packaging) step. - -## Phase 1 — Packaging unblock (DONE except one decision) - -All changes are package metadata only — no source/runtime change. Files touched: `packages/ui/package.json`, `packages/ui/tsconfig.json`, `bun.lock`. - -### Findings (verified against source before editing) -- **Phantom `dompurify` dependency (real latent bug).** Imported in `packages/ui/utils/sanitizeHtml.ts:1` and `utils/aiChatFormat.ts:3` but absent from `packages/ui/package.json`. Worked in-repo only via root hoisting; would break a standalone install. Root pins `dompurify ^3.3.3`. -- **`diff` version drift.** ui had `^8.0.3`; root has `^8.0.4`. -- **Stale tsconfig alias (dead).** `tsconfig.json:21` mapped bare `@plannotator/shared` → `../shared/index.ts`, which does not exist. Verified **no file in ui imports the bare specifier** — only `@plannotator/shared/*` subpaths (handled by the other, correct alias on the next line). The dead line was inert (typecheck passed with it) but removed for correctness. -- **No `peerDependencies`.** react/react-dom/tailwindcss were plain `dependencies`, risking duplicate-React when consumed by an external app. -- **No `files` allowlist.** A publish would have shipped test files and could have missed assets/themes. - -### Changes made -1. **Added `dompurify ^3.3.3`** to `dependencies` (matches root exactly — a version mismatch could change sanitization output). -2. **Aligned `diff` `^8.0.3` → `^8.0.4`** (matches root). -3. **Added a `peerDependencies` block** — `react`, `react-dom`, `tailwindcss`, `tailwindcss-animate` — and removed them from `dependencies`. Also added the same four to `devDependencies` so in-repo typecheck/build still resolve them. (Scope decision: only the singleton/build-time packages were made peers. Radix, lucide, cva, clsx, tailwind-merge, etc. stay as regular `dependencies` — they are owned by the library and have no duplicate-instance hazard. This is the conventional, lower-risk choice and diverges deliberately from the audit's broader "radix→peer" suggestion.) -4. **Added a `files` allowlist** (source dirs + assets/themes/sprites + `theme.css`/`print.css`/`types.ts`/`globals.d.ts`), excluding `**/*.test.*` and `test-setup`. Preparatory — only affects a future publish. -5. **Removed the dead `tsconfig.json` alias line.** -6. **Kept the source-only `exports` model — no dist build added** (a build could change what Plannotator ships). Consumer bundler requirements to document for Workspaces: `isolatedModules`, the automatic JSX runtime, `allowImportingTsExtensions`, and Tailwind v4 (`@theme inline` is v4-only). - -### Verification (post-change, vs Phase 0 baseline) -| Check | Result | Matches baseline? | -|---|---|---| -| `bun install` | clean, "no changes" to install tree (confirms dep moves didn't perturb resolution) | — | -| `bun run typecheck` | PASS (exit 0) | ✅ | -| `bun test` | **1620 pass / 0 fail** | ✅ identical | -| 3 builds | all OK | ✅ | -| plan UI bundle hash | `4ca0cbe9dd85c3674e6122f1e830704076efa129` | ✅ **byte-identical** | -| review UI bundle hash | `f404d00d9a47785ca925776d48b7a67b2b30b9dd` | ✅ **byte-identical** | - -**Conclusion: Plannotator's shipped app is byte-for-byte unchanged.** The packaging box is now cleaner and closer to installable, with zero impact on the open-source experience. - -## Remaining Phase 1 item — ONE decision required (not done) - -**`@plannotator/ai` and `@plannotator/shared` are `workspace:* / private / 0.0.1`.** This is the one genuine blocker to an external `@plannotator/ui` install (an outside repo cannot resolve `workspace:*` private packages). It was **deliberately not actioned** because it is a strategic fork, and one path (publishing) is outward-facing and needs explicit authorization. Two options: - -- **Option A — Publish `@plannotator/ai` + `@plannotator/shared`** (drop `private`, real versions, push to the registry). Cleanest dependency graph; lets Workspaces also reuse shared logic directly. Cost: two more published packages to maintain/version; needs registry auth. **Outward-facing — requires explicit go-ahead before any publish.** -- **Option B — Inline the browser-safe subpaths ui actually value-imports** into `@plannotator/ui` (verified Web-API-only: compress, crypto, agents, code-file, feedback-templates, project, favicon, agent-jobs, browser-paths, extract-code-paths, goal-setup). Keeps `@plannotator/ui` self-contained, no extra published packages. Cost: code duplication vs `@plannotator/shared`, and it is a real code change (must re-run the full parity verification). - -When this is decided, also revisit the `tsconfig.json` `@plannotator/shared/*` alias (currently correct for in-repo; changes if shared is published/inlined). - -> Note: the `@plannotator/ai` import is `import type` only (erased at compile). Most `@plannotator/shared` imports are also type-only or Web-API-only; verified no `node:*` value imports reach a bundle. So this blocker is about *package resolution for external install*, not about node code leaking into the browser. - -## Phase 2 — Foundation seams (in progress) - -Three cross-cutting seams that later phases depend on. Each: lift the backend wire to an optional override, default = today's behavior. For these *code* changes the bundle hash legitimately changes; parity is proven by behavior tests (+ eyeball where there's something visual to see). - -### Seam 1 — Image resolver (DONE) -- **File:** `packages/ui/components/ImageThumbnail.tsx` (the single `getImageSrc`, shared by 5 consumers: ImageThumbnail, InlineMarkdown, HtmlBlock, AttachmentsButton, Viewer). -- **Change:** extracted the body into `defaultImageSrcResolver` and a module-level `imageSrcResolver` (stable identity); added `setImageSrcResolver(fn)` for a host to override once at startup, and `resetImageSrcResolver()` for tests. `getImageSrc(path, base?)` signature unchanged; it now delegates to the active resolver, default = the verbatim old `/api/image` logic. -- **Why no Viewer-level prop:** a prop can't reach InlineMarkdown/HtmlBlock; the module-level override is the only thing all 5 consumers share. -- **Verified:** default output byte-identical across remote-passthrough, base-append, and absolute-path cases (URL probe); override + reset work; typecheck pass; 1620 tests pass / 0 fail; all 3 builds OK. Dev-mode eyeball N/A — the mock serves no images and this change only affects the URL string (proven identical), so there is nothing visual to regress. - -### Seam 2 — Storage backend (DONE) -- **File:** `packages/ui/utils/storage.ts` (the cookie `getItem`/`setItem`/`removeItem`, sole persistence for ~24 modules: theme, layout/TOC/width prefs, identity, auto-close, etc.). -- **Change:** moved the cookie implementation into a default `cookieBackend: StorageBackend`; added a module-level `backend` (default = cookies), `setStorageBackend(b)` for a host to swap, and `resetStorageBackend()` for tests. `getItem`/`setItem`/`removeItem` now delegate to the active backend; signatures and the `storage` object unchanged. Literal `plannotator-*` keys preserved. -- **Consumers untouched:** the ~24 modules keep calling `getItem`/`setItem` exactly as before. -- **Verified:** seam routes to an injected backend and `resetStorageBackend` restores cookies (in-memory probe); typecheck pass; 1620 tests pass / 0 fail (suite exercises storage through a real DOM); all 3 builds OK; manual eyeball — theme/settings persist across reload (cookie round-trip intact). - -## Phase 3 — Rendering stack (in progress) - -Teed up + adversarially reviewed by the `phase3-rendering-stack` workflow (36→22 agents; tee-up → execute-in-isolated-worktree → parity review → synthesis). Workflow verdicts: **3 noop** (theme, markdown, html-viewer — already decoupled by Phase 2 / already prop-driven, nothing to land), **3 safe** (editor, viewer, scroll), **1 "blocked"** (docfetch — false alarm: the execute worktrees were auto-removed, so the reviewer saw the clean real tree; the spec is sound, just needs real application). Note: the workflow's in-worktree `typecheck`/`tests` were unreliable (missing deps in throwaway worktrees) — landings are verified authoritatively on the real tree against the Phase 0 baseline. All landings done by hand on the real tree with the parity suite. - -### Seam — Markdown editor theme mode (DONE) -- **File:** `packages/ui/components/MarkdownEditor.tsx`. Added optional `mode?` prop; `mode={resolvedMode}` → `mode={mode ?? resolvedMode}`; destructured `mode` out of `...props`. -- **Parity:** Plannotator's only `` call (App.tsx:4261) passes no `mode` → falls to `resolvedMode` → identical. Verified: typecheck pass, 1620 tests / 0 fail, builds OK, App.tsx untouched, no `mode=` caller. - -### Seam — Viewer code-path validation gate (DONE) -- **Files:** `packages/ui/components/Viewer.tsx` + `packages/ui/hooks/useValidatedCodePaths.ts`. Added optional `disableCodePathValidation?` prop threaded to a new `disabled?` arg on the hook; when set, the `/api/doc/exists` probe is skipped (`ready: true`, empty map). Default undefined for Plannotator → validation stays on. Added `disabled` to the effect deps (always undefined for Plannotator → no behavior change). -- **Parity:** no `disableCodePathValidation` caller in editor/apps → Viewer still fires `/api/doc/exists` exactly as today. Verified: typecheck pass, 1620 tests / 0 fail, builds OK, App.tsx untouched. - -### Seam — Doc-fetch (code-file hover preview) (DONE) -- **File:** `packages/ui/components/InlineMarkdown.tsx`. Added `DocPreviewResult`/`DocPreviewFetcher` + module-level `docPreviewFetcher` (default = verbatim `/api/doc` fetch) + `setDocPreviewFetcher`/`resetDocPreviewFetcher`; routed `handleMouseEnter` through it. `useCallback` deps unchanged. -- **Parity:** no `setDocPreviewFetcher` caller → Plannotator still fetches `/api/doc?path=&base=` identically. Verified: typecheck pass, 1620 tests / 0 fail, builds OK. (Hover popover not visible in dev mock — same caveat as images; call is provably identical.) - -### Seam — Scroll viewport provider (DONE) -- **Files:** `packages/ui/hooks/useScrollViewport.ts` (added render-transparent `ScrollViewportProvider` via `createElement` — kept `.ts`, no JSX; fixed the stale OverlayScrollbars doc-comment) + `packages/editor/App.tsx` (import + the two provider tags at 3888/4427: `ScrollViewportContext.Provider value=` → `ScrollViewportProvider viewport=`). -- **Parity:** `ScrollViewportProvider` renders exactly `ScrollViewportContext.Provider value={viewport}` — identical tree/value/position; App.tsx delta is 3 lines. Sidebar TOC still resolves to the MAIN viewport. Verified: typecheck pass, 1620 tests / 0 fail, builds OK; **manual eyeball — TOC active-section tracks main-content scroll, click-to-scroll works.** - -### Self-review fix — viewer `disabled` path (DONE) -- **Found:** the Phase-3 viewer seam's `disabled` branch set `ready=true` with an empty map, which makes `gateCodePath` demote every code link to **plain text** (since ready+no-entry => 'plain'). Wrong for the seam's purpose (a host disabling validation wants links to stay clickable). Did NOT affect Plannotator (never disables) but the seam was incorrect. -- **Fix:** `useValidatedCodePaths.ts` disabled branch now just `return;` (leaves `ready=false`), so `gateCodePath`'s no-validation fallback renders code links **optimistically (clickable)**. Re-verified: typecheck pass, 1620 tests / 0 fail, builds OK. - -### Noops (nothing to land — verified already reusable) -theme, markdown, html-viewer — decoupled by Phase 2 / already prop-driven. - -### Reusability note (intentional, not a defect) -Three seams now share the shape `defaultX` + module-level `x` + `setX`/`resetX` (image resolver, storage backend, doc-preview fetcher). NOT abstracted into a generic helper: they live in different files, have different call ergonomics (a bare function vs. a `{getItem,setItem,removeItem}` object vs. an async fetcher), and the duplication is ~4 trivial lines each. A shared `createOverridable()` would add indirection for little gain and churn three already-verified files. Revisit if a 4th/5th appears. - -## Phase 3 status: COMPLETE -All 7 pieces resolved — 4 landed (editor, viewer, doc-fetch, scroll), 3 noop. Plannotator byte-unchanged throughout (shipped behavior verified; App.tsx touched only by the 3-line scroll rewire). Scroll provider (the "announcer") now ships in `@plannotator/ui`, closing the Phase-2 deferred seam. - -## Phase 4 — Navigation (sidebar + file tree) - -Teed up + multi-lens adversarially reviewed by the `phase4-navigation` workflow (tee-up → execute-in-worktree → 4 parity lenses → synthesis), then landed + verified by hand on the real tree. - -### Sidebar (NOOP — nothing to land) -Confirmed transfer-as-is: SidebarContainer/SidebarTabs/CountBadge/FileBrowser/VersionBrowser/ArchiveBrowser/MessagesBrowser and `useSidebar` have **zero** backend wires — all backend interaction arrives as injected callback props, or a pre-built `fileBrowser` prop. Already reused by `packages/review-editor/App.tsx` (`useSidebar`), a second consumer with a different tab union. No edit. - -### Seam — File tree backend (DONE) -- **File:** `packages/ui/hooks/useFileBrowser.ts` only. Lifted the three backend wires into an injectable `FileTreeBackend` (`loadTree`/`loadVaultTree`/`watchTrees`) with a `defaultFileTreeBackend` + `setFileTreeBackend`/`resetFileTreeBackend`, same module-level pattern as the image/storage seams. -- **The SSE live-watch effect moved VERBATIM** into `watchTrees` — EventSource URL, 120ms debounce timers, `readyPaths` dedup, `onmessage` ready/changed dispatch, and `clearTimeout`+`source.close()` cleanup byte-identical. The only substitution is `fetchTreeRef.current(path,{quiet:true})` → injected `onChange(path)` (the hook passes exactly that). The `typeof EventSource === "undefined"` guard relocated into `watchTrees` (returns `undefined` → no cleanup), behavior-identical. `useFileBrowser()` stays zero-arg; default fetch/SSE URLs unchanged. -- **Parity:** no `setFileTreeBackend` caller in editor/apps → Plannotator uses the default. Verified: **`useFileBrowser.test.tsx` passes 6/0 UNMODIFIED** (the strongest guardrail — it asserts the URLs, timer, and SSE behavior via fake `fetch`/`EventSource`; run with `DOM_TESTS=1`); typecheck pass; full `bun test` 1620/0; builds OK; App.tsx untouched. **Manual eyeball** (real `annotate adr/` session): tree loads, file-switching works, new file appears live via SSE without reload. - -### Phase 4 status: COMPLETE — sidebar noop, file-tree seam landed. Plannotator byte-unchanged. - -## Phase 5 — Comments / annotations / drafts (ADR 005) - -Researched (5-probe spike), specced, ADR-005-accepted, then teed up + multi-lens adversarially reviewed by the `phase5-comments` workflow (4 tee-ups + 3 worktree executes + 12 review lenses + synthesis; all 12 lenses returned safe). Landed + verified by hand on the real tree, lowest-risk first. The already-portable comment UI (panel, popover, toolbar, highlighter, exporters) confirmed noop. - -### Seam 1 — Identity provider (DONE) -- **File:** `packages/ui/utils/identity.ts`. Added `IdentityProvider` + `setIdentityProvider`/`resetIdentityProvider`; `getIdentity`/`isCurrentUser` delegate to a module-level provider defaulting to today's ConfigStore tater behavior. The ~9 author-stamp sites and 2 `(me)`-badge sites delegate with **zero call-site edits**. -- **Parity:** no override caller → tater nickname + `(me)` badge identical. typecheck pass, 1620/0, builds OK. (+46/-5, identity.ts only.) - -### Seam 2 — Draft transport (DONE) -- **Files:** `packages/ui/hooks/useAnnotationDraft.ts` (+ `useCodeAnnotationDraft.ts` reads `getDraftTransport()` live). Added `DraftTransport` (load/save/remove) + setters, default = today's `/api/draft` fetches verbatim; `save` rejects-on-failure so the **keepalive retry-gate stays in the hook**. The generation pre-increment, 500ms debounce, and pagehide/visibilitychange flush stay verbatim; `getDraftGeneration()` still escapes to the host. -- **Landing note:** the workflow diff carried one phantom hunk (a delete-on-clear branch the real code-draft hook never had — it early-returns on empty). `patch` correctly rejected it; the real tree is correct without it. Caught by landing-on-real-tree verification. -- **Parity:** no override caller; App.tsx + `shared/draft.ts` untouched. `shared/draft.test.ts` 10/0, `annotationDraftPersistence` 13/0 (incl. pagehide-flush parity), typecheck pass, 1620/0, builds OK. - -### Seam 3 — External-annotation transport (DONE, riskiest) -- **File:** `packages/ui/hooks/useExternalAnnotations.ts`. Added `ExternalAnnotationTransport` (`subscribe`/`getSnapshot`/CRUD) + setters; default = the SSE→polling wire moved verbatim into `createDefaultTransport`. The reducer (`applyEvent`, byte-identical cases), fallback-once gate, 500ms poll, version-scoping, optimistic-before-await, and the `[enabled]` gate **stay in the hook**. Two micro-divergences (parse-then-cancelled-check; snapshot `[]`/`0` defaults) are provably unreachable for Plannotator (server always returns well-formed `{annotations, version}`; parse-then-discard is side-effect-free). -- **Parity:** no override caller; App.tsx untouched (both apps still call `useExternalAnnotations({enabled})`). external-annotation test green, typecheck pass, 1620/0, builds OK. - -### Phase 5 status: COMPLETE (pending eyeball) — 3 seams landed, comment UI noop. Plannotator byte-unchanged. - -## Phase 6 — Extras: versions/diff, settings, sharing, Ask AI (ADR 006) - -Researched (5-probe spike), specced, ADR-006-accepted, teed up + multi-lens adversarially reviewed by the `phase6-extras` workflow (5 tee-ups + 4 worktree executes + 15 review lenses + synthesis; all 15 lenses safe). Landed + verified by hand. Already-portable pieces (planDiffEngine, all diff render components, sharing utils/useSharing/ImportModal, notes-app helpers, settings.ts, AI chat components, aiProvider/aiChatFormat) confirmed noop. Five Plannotator-only pieces (OpenInAppButton, HooksTab, useUpdateCheck, useAgents, useAgentJobs) confirmed out of scope. - -### Seam — Versions/diff + CSS move (DONE) -- **Files:** `usePlanDiff.ts` (optional `fetchers?` 4th arg, default `/api/plan/version(s)`; error asymmetry kept — selectBaseVersion alerts via the existing catch, fetchVersions silent), `PlanDiffViewer.tsx` (optional `onOpenVscodeDiff?`, default `/api/plan/vscode-diff`), and the **CSS move**: `.annotation-highlight*` + `.plan-diff-added/removed/modified/unchanged/line-*` relocated **byte-identical** from `editor/index.css` (−114) into `ui/theme.css` (+114), next to `.plan-diff-word-*`. -- **Parity:** relocated CSS **gone from index.css (0), present in shipped bundle (33×)** since Plannotator imports theme.css → pixel-identical. planDiffEngine 49/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. - -### Seam — Settings/config (DONE) -- **Files:** `configStore.ts` (`setServerSync(fn)` injects only the terminal `/api/config` POST; 300ms debounce + deepMerge batching + singleton + eager cookie reads verbatim), `Settings.tsx` (optional `onDetectObsidianVaults`, default `/api/obsidian/vaults`; `[obsidian.enabled]` dep + auto-select verbatim). -- **Parity:** no override caller; ui 293/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. - -### Seam — Sharing/save-to-notes (DONE) -- **File:** `ExportModal.tsx` (optional `onSaveToNotes`, default = verbatim `/api/save-notes` POST; `showNotesTab = isApiMode && !!markdown` byte-for-byte). Sharing utils already parameterized (noop). -- **Parity:** no override caller; typecheck pass, 1620/0, builds OK, App.tsx untouched. - -### Seam — Ask AI transport (DONE, riskiest) -- **File:** `useAIChat.ts` (module-level `AITransport` session/query/abort/permission + setters, default = the five `/api/ai/*` fetches verbatim). The SSE reader loop, epoch/createRequest guards, and the supersede-abort position inside `createSession` stay untouched. Capabilities + provider-resolution stay host-owned (App.tsx). -- **Parity:** no override caller; `packages/ai/ai.test.ts` 97/0, typecheck pass, 1620/0, builds OK, App.tsx untouched. - -### Phase 6 status: COMPLETE (pending eyeball) — 4 seams landed + diff CSS in the package, extras noop, 5 Plannotator-only pieces out of scope. Plannotator byte-unchanged. -The document UI is now feature-complete for reuse. Remaining: Phase 7 (publish) + the parked `@plannotator/ai`/`@plannotator/shared` publish-vs-inline decision. -Renderer-coupling contract (Workspaces must reuse BlockRenderer+InlineMarkdown+inlineTransforms for highlights) and replies/threading deferral recorded in ADR 005. Remaining: manual eyeball — author/`(me)`, draft save+restore+no-ghost, live SSE add + kill-stream→polling-takes-over. - -### Discovered (PRE-EXISTING, out of scope — not caused by this work) -1. **Edit/save header state leaks across file switches** in annotate-folder mode: editing+saving file A leaves the Saved/Done/wide-focus header showing when you switch to file B without editing it. Reproduced on the **baseline with the Phase 4 change reverted** (A/B confirmed) → pre-existing App.tsx bug, not a regression. Lives in the folder file-switch handler (`handleFileBrowserSelect` / edit-session reset), unrelated to `useFileBrowser`. Worth a separate fix. -2. **Annotating the repo root (`annotate ./`) bogs down** — the file walker + chokidar SSE watcher choke on 1.4GB of node_modules (16 dirs); the code already warns about this. Pre-existing scaling limit; use a bounded folder. Not a code defect introduced here. diff --git a/adr/implementation/document-ui-phase-7-plan-20260623.md b/adr/implementation/document-ui-phase-7-plan-20260623.md deleted file mode 100644 index 8045c98bc..000000000 --- a/adr/implementation/document-ui-phase-7-plan-20260623.md +++ /dev/null @@ -1,679 +0,0 @@ -# Phase 7 — Implementation Plan: Carve `@plannotator/core` + Publish Prep - -Branch: `feat/pkg-document-ui`. Authoritative: ADR 007 + spec `publish-core-package-20260623-125551.md`. - -**THE LAW (ADR 004):** Plannotator stays byte-for-byte unchanged. The carve is `git mv` + one-line -re-export shims + type extraction. Single source of truth — NO copying, NO rewriting. `packages/editor`, -`packages/server`, `packages/review-editor`, and all `apps/*` source stays untouched except the ONE -`wideMode` importer in Step 3. - -**Global rules for every step:** -- Each step leaves the tree typecheck-green **for what it touched** and ends with **one local commit** (`git commit`, NO `git push`, NO publish, NO merge). -- Work on the current branch `feat/pkg-document-ui` (already a feature branch — do not branch again). -- Repo version is `0.21.0`. `@plannotator/core` ships lockstep at `0.21.0`; `ui` → `core` pinned EXACT. -- Use `git mv` for all moves so history follows. Never delete a working file until parity is human-confirmed. -- `export *` re-exports values AND types but NOT defaults; all 15 moved modules are named-export-only (confirmed). If a default is ever found, add `export { default } from '@plannotator/core/X';`. -- **Byte-for-byte type moves:** when extracting type declarations into `packages/core/*-types.ts`, copy the source bytes **verbatim** — preserve original indentation (the `workspace-status` cluster uses TAB indentation; do NOT reflow to spaces). A pure move must produce a pure move in the diff; gratuitous reformatting is forbidden because the parity gate (item 4) scrutinizes the diff. -- **Workspace registration before typecheck:** `@plannotator/core` is a BRAND-NEW workspace package. There is no `node_modules/@plannotator` symlink dir in this repo; Bun resolves workspaces through its lockfile catalog (verified: `bun pm ls` lists workspaces, `packages/server/tsconfig.json` has no `paths` map yet resolves `@plannotator/shared/*` purely via that catalog). `packages/shared/tsconfig.json` and `packages/ai/tsconfig.json` have `moduleResolution: bundler` and NO `paths` map, so they will resolve the new `@plannotator/core/*` bare specifiers ONLY after `bun install` registers core in the catalog. Therefore `bun install` is required after Step 1a (and re-run after Step 2c) before any `tsc` verification that touches the new specifiers. - ---- - -## STEP 1 — The carve: create `@plannotator/core`, move modules, extract types, shim `shared` (CRITICAL / opus) - -Goal: `packages/core` exists with the 15 pure modules + 5 extracted type files; `packages/shared` is rewired (15 one-line shims + 4 node-bound/types modules importing types back from core); `ai/types.ts` imports the `AIContext` family (including `AIContextMode`) back from core. End state: `core` typecheck (node-free) green AND `shared` typecheck green AND `ai` typecheck green AND Pi typecheck green. - -### 1a. Create `packages/core/package.json` + register the workspace -New file `packages/core/package.json` (exact): -```json -{ - "name": "@plannotator/core", - "version": "0.21.0", - "type": "module", - "exports": { - "./agents": "./agents.ts", - "./agent-jobs": "./agent-jobs.ts", - "./agent-terminal": "./agent-terminal.ts", - "./browser-paths": "./browser-paths.ts", - "./code-file": "./code-file.ts", - "./compress": "./compress.ts", - "./crypto": "./crypto.ts", - "./external-annotation": "./external-annotation.ts", - "./extract-code-paths": "./extract-code-paths.ts", - "./favicon": "./favicon.ts", - "./feedback-templates": "./feedback-templates.ts", - "./goal-setup": "./goal-setup.ts", - "./open-in-apps": "./open-in-apps.ts", - "./project": "./project.ts", - "./source-save": "./source-save.ts", - "./config-types": "./config-types.ts", - "./storage-types": "./storage-types.ts", - "./workspace-status-types": "./workspace-status-types.ts", - "./ai-context": "./ai-context.ts", - "./types": "./types.ts", - ".": "./index.ts" - }, - "files": ["**/*.ts", "!**/*.test.ts"], - "dependencies": {}, - "devDependencies": { - "typescript": "~5.8.2" - } -} -``` -Constraints: NO `private`, NO `peerDependencies`, NO `@types/node`, NO `@types/bun`. - -**Then wire deps (1h below) and run `bun install`** so the workspace catalog registers `@plannotator/core`. This is load-bearing: without it, the Step 1k `tsc` on `shared`/`ai` cannot resolve the new `@plannotator/core/*` specifiers (no `paths` map on those packages). Run order within Step 1: create core files (1a–1g) → edit deps (1h) → `bun install` → wire typecheck (1i) → fix vendor (1j) → verify (1k). - -### 1b. Create `packages/core/tsconfig.json` (node-free — DOM-only lib, no node/bun types) -New file `packages/core/tsconfig.json` (exact): -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "resolveJsonModule": true, - "skipLibCheck": true, - "noEmit": true, - "strict": false, - "noImplicitAny": false, - "strictNullChecks": false, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true - }, - "include": ["**/*.ts"], - "exclude": ["node_modules", "dist"] -} -``` -Critical: NO `"types": ["bun"]`, NO `"types": ["@types/node"]`, NO `"paths"`. This is the node-free invariant — a stray `node:*` import yields `TS2307`. - -### 1c. `git mv` the 15 pure modules `packages/shared/X.ts` → `packages/core/X.ts` -Exact list (each verified node-free; the only intra-set dep is `extract-code-paths → ./code-file`, which moves together so the relative import stays valid): -``` -code-file extract-code-paths agents agent-jobs compress crypto -external-annotation favicon feedback-templates goal-setup browser-paths -project agent-terminal open-in-apps source-save -``` -Do NOT move `source-save-node.ts` (node-bound — stays in shared). `project` here is the PURE `packages/shared/project.ts`, NOT `packages/server/project.ts`. - -### 1d. Create the 5 extracted type files in core (single source — definitions move here, byte-for-byte) - -`packages/core/config-types.ts` — move the pure type decls from `shared/config.ts:13-30`: -```ts -export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; -export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; -// plus DiffOptions (config.ts:16-30) — self-contained, references the two above; move for the diff-option family -``` -(UI needs only `DefaultDiffType` + `DiffLineBgIntensity`; `DiffOptions` is moved for tidiness, self-contained.) Do NOT move `CCLabelConfig`, `PromptConfig`, `PromptRuntime`, etc. - -`packages/core/storage-types.ts` — move `ArchivedPlan` (shared/storage.ts:107-114): -```ts -export interface ArchivedPlan { - filename: string; title: string; date: string; timestamp: string; - status: "approved" | "denied" | "unknown"; size: number; -} -``` - -`packages/core/workspace-status-types.ts` — move the cluster (shared/workspace-status.ts:6-44): `WorkspaceFileStatus`, `WorkspaceFileChange`, `WorkspaceStatusPayload`, `GitRepositoryInfo`. (`WorkspaceFileStatus` is required transitively by `WorkspaceFileChange`.) Do NOT move `GitResult`, `WorkspaceStatusFlight` (file-private, node-adjacent). **Copy the declarations byte-for-byte — these use TAB indentation in the source; preserve the tabs, do NOT reflow to spaces.** - -`packages/core/types.ts` — move the `EditorAnnotation` interface (shared/types.ts:2-10, pure). This file exports ONLY `EditorAnnotation`. Do NOT re-export anything from `review-core`/`review-workspace` (they import `node:path`). - -`packages/core/ai-context.ts` — MOVE (not re-export) the AI context type family from `packages/ai/types.ts:14-89`. **This family INCLUDES `AIContextMode` (at `ai/types.ts:14`).** Move ALL six names: `AIContextMode`, `ParentSession`, `PlanContext`, `CodeReviewContext`, `AnnotateContext`, `AIContext`. All verified node-free. The literal first line moved is: -```ts -export type AIContextMode = "plan-review" | "code-review" | "annotate"; -``` -Do NOT do `export … from '@plannotator/ai'` — that would give core a dep on private `ai` and break the zero-dep CI gate. - -> **Why `AIContextMode` is mandatory here:** it is consumed inside the `ai` package itself — `ai/index.ts:68` re-exports it from `./types.ts`, and `ai/session-manager.ts:16,26,65` import and use it. If it is moved out of `ai/types.ts` but not re-exported back (see 1g), the `ai` package fails to typecheck (`TS2305 'no exported member AIContextMode'`), cascading to editor/server. UI does NOT import `AIContextMode` (verified zero usage), so no Step 2 change is needed for it. - -### 1e. Create `packages/core/index.ts` (barrel) -Re-export the public surface so `import … from '@plannotator/core'` works (notably `AIContext` for UI): -```ts -export * from './ai-context'; -export type { EditorAnnotation } from './types'; -// (re-export others as convenient; AIContext is the load-bearing one for ui/useAIChat) -``` -`export * from './ai-context'` re-exports `AIContextMode`, `AIContext`, `ParentSession`, `PlanContext`, `CodeReviewContext`, `AnnotateContext` from the barrel. - -### 1f. Replace the 15 moved `shared/X.ts` files with one-line shims -After `git mv`, recreate each `packages/shared/X.ts` containing exactly: -```ts -export * from '@plannotator/core/X'; -``` -(15 files: code-file, extract-code-paths, agents, agent-jobs, compress, crypto, external-annotation, favicon, feedback-templates, goal-setup, browser-paths, project, agent-terminal, open-in-apps, source-save.) `shared`'s `exports` map and `private:true` stay unchanged — every subpath still resolves to `./X.ts`. -Note: the intra-shared relative importers (`shared/resolve-file.ts → ./code-file`, `shared/storage.ts → ./project`, `shared/source-save-node.ts → ./source-save`) resolve to the shim and forward to core — NO edits needed to those three. - -### 1g. Rewire the node-bound shared modules + `ai/types.ts` to import types back from core -`packages/shared/config.ts` — replace the inline `DefaultDiffType`/`DiffLineBgIntensity`/`DiffOptions` decls with: -```ts -export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions } from '@plannotator/core/config-types'; -``` -(keep all node impl/functions unchanged.) - -`packages/shared/storage.ts` — replace the inline `export interface ArchivedPlan {…}` with: -```ts -import type { ArchivedPlan } from '@plannotator/core/storage-types'; -export type { ArchivedPlan }; -``` -(internal functions keep referencing `ArchivedPlan`; node:fs impl unchanged.) - -`packages/shared/workspace-status.ts` — replace the inline cluster with: -```ts -import type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus } from '@plannotator/core/workspace-status-types'; -export type { WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo, WorkspaceFileStatus }; -``` -(node:child_process/fs impl unchanged.) - -`packages/shared/types.ts` — replace the inline `EditorAnnotation` interface with: -```ts -export type { EditorAnnotation } from '@plannotator/core/types'; -``` -(keep its existing `review-core`/`review-workspace` re-exports as-is.) - -`packages/ai/types.ts` — replace the inline AIContext family with import-back. **This line MUST include `AIContextMode`** (it was moved in 1d and is re-exported by `ai/index.ts:68`): -```ts -export type { AIContext, AIContextMode, PlanContext, CodeReviewContext, AnnotateContext, ParentSession } from '@plannotator/core/ai-context'; -``` -This keeps `ai/index.ts`'s existing re-export (which lists `AIContextMode` at line 68) resolving, keeps `session-manager.ts`'s `import type { ..., AIContextMode } from "./types.ts"` resolving, AND keeps `editor/App.tsx:95`'s `import type { AIContext } from '@plannotator/ai'` resolving — **`ai`, `editor`, and `server` stay byte-for-byte unchanged.** - -### 1h. package.json dep edits for `shared` and `ai` -- `packages/shared/package.json` deps: add `"@plannotator/core": "workspace:*"`. Keep `private:true`, keep the full `exports` map unchanged. -- `packages/ai/package.json` deps: add `"@plannotator/core": "workspace:*"`. Keep `private:true`. - -**After 1h: run `bun install`** (registers `@plannotator/core` in the Bun workspace catalog). This is the resolution mechanism for `shared`/`ai`'s new `@plannotator/core/*` imports — those packages have no `paths` map. Without this, Step 1k's `tsc -p packages/shared/tsconfig.json` / `packages/ai/tsconfig.json` cannot resolve the new bare specifiers. - -### 1i. Wire core into the root typecheck (node-free typecheck FIRST so a node leak fails fast) -Root `package.json` `typecheck` script (line 36) — insert core's typecheck before shared's: -``` -"typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" -``` -(Leave the vendor.sh prefix in place; vendor.sh itself is fixed in 1j.) - -### 1j. Fix Pi `vendor.sh` (the ADR/spec "no change" claim is FALSE — confirmed) - -**Why it breaks (verified against `apps/pi-extension/vendor.sh`):** -1. The main loop (vendor.sh:10) copies file CONTENT verbatim from `packages/shared/$f.ts` for a flat list that INCLUDES 9 moved-pure modules (`code-file`, `agent-jobs`, `external-annotation`, `favicon`, `feedback-templates`, `project`, `agent-terminal`, `open-in-apps`, `source-save`) AND 3 node-bound modules (`config`, `storage`, `workspace-status`). After the carve, the 9 pure files are bare shims (`export * from '@plannotator/core/X'`) and the 3 node-bound files import `@plannotator/core/-types` — all unresolvable in Pi's flat `generated/` layout (no bundler resolution to packages, no `@plannotator/core` dep, `moduleResolution: bundler` with no `paths`/`baseUrl`). -2. The **ai loop** (vendor.sh:40) copies `index types provider session-manager endpoints context base-session` VERBATIM from `packages/ai/$f.ts` with NO sed rewrites. After 1g, `packages/ai/types.ts` contains `export type { … } from '@plannotator/core/ai-context'` — vendored verbatim into `generated/ai/types.ts`, where that bare specifier cannot resolve (`TS2307`). This works today ONLY because `ai/types.ts` currently has zero `@plannotator/*` imports. - -`workspace-status` IS imported by `apps/pi-extension/server/reference.ts` and `file-browser-watch.ts` (confirmed), so a silent break here is a real runtime/typecheck failure. - -**The exact restructured `vendor.sh` (write this as runnable shell — do not infer):** - -(a) In the main loop, split the 9 moved-pure modules out of the `packages/shared/` source and source them from `packages/core/` instead. Replace the single loop (vendor.sh:10-13) so the 9 moved modules read from core, the 3 node-bound modules read from shared **and** get a sed rewrite, and everything else stays as-is: - -```bash -# Modules that MOVED to @plannotator/core — vendor the real impl from core. -for f in feedback-templates project favicon code-file external-annotation agent-jobs agent-terminal source-save open-in-apps; do - src="../../packages/core/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" -done - -# Node-bound shared modules that now import types from @plannotator/core/*-types — -# vendor from shared, rewrite the bare core specifier to the flat relative path. -for f in config storage workspace-status; do - src="../../packages/shared/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" \ - | sed 's|from "@plannotator/core/\([^"]*\)-types"|from "./\1-types.js"|g' \ - > "generated/$f.ts" -done - -# Extracted type files those node-bound modules now depend on — vendor from core. -for f in config-types storage-types workspace-status-types; do - src="../../packages/core/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" -done - -# Everything else in the original flat list stays sourced from packages/shared. -for f in prompts review-core diff-paths cli-pagination jj-core vcs-core review-args draft pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common resolve-file annotate-reference-roots-node worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save-node; do - src="../../packages/shared/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" -done -``` -> The relative-import chains stay valid: `resolve-file → ./code-file`, `source-save-node → ./source-save`, `storage → ./project` all resolve to the flat `generated/.ts` files, which now hold the real core impl. Confirm the original line-10 list is fully partitioned across the four loops above with no module dropped (diff the old list against the union of the four new lists). - -(b) Extend the **ai loop** (vendor.sh:40-43) to vendor `ai-context` from core and sed-rewrite the `@plannotator/core/ai-context` specifier in `generated/ai/types.ts` to `./ai-context.js`: - -```bash -# Vendor the moved AI context types from core into generated/ai/. -printf '// @generated — DO NOT EDIT. Source: packages/core/ai-context.ts\n' \ - | cat - "../../packages/core/ai-context.ts" > "generated/ai/ai-context.ts" - -for f in index types provider session-manager endpoints context base-session; do - src="../../packages/ai/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" \ - | sed 's|from "@plannotator/core/ai-context"|from "./ai-context.js"|g' \ - > "generated/ai/$f.ts" -done -``` -> Only `generated/ai/types.ts` actually contains the `@plannotator/core/ai-context` specifier today, but applying the sed to all 7 ai files is harmless (no-op where absent) and future-proofs the vendor. - -### 1k. Verify (run at end of Step 1) -``` -bun install # MUST run first — registers @plannotator/core in the workspace catalog -tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json -# Node-free proof: temporarily add `import 'node:fs'` to a core/*.ts → tsc on core MUST fail TS2307 → remove it. -bash apps/pi-extension/vendor.sh && tsc --noEmit -p apps/pi-extension/tsconfig.json # MUST be green — confirms generated/ai/types.ts + generated/{config,storage,workspace-status}.ts resolve their rewritten relative specifiers -git diff --name-only # confined to packages/{core,shared,ai} + apps/pi-extension/vendor.sh + root package.json + bun.lock -``` - -### Commit -``` -feat(core): carve @plannotator/core — move pure modules, extract node-bound types, shim shared (Phase 7 step 1) -``` - ---- - -## STEP 2 — Re-point `@plannotator/ui` to `@plannotator/core` (CRITICAL / opus) - -Goal: every non-test `ui` import of `@plannotator/shared/*` and `@plannotator/ai` becomes `@plannotator/core/*`; ui `package.json` drops shared+ai, adds core EXACT. End: grep returns zero; ui typecheck green. - -### 2a. Re-point the 35 import sites (31 files) -Mechanical rule: `@plannotator/shared/X` → `@plannotator/core/X` (same subpath), with these EXACT remaps for the type-extraction cases: -- `@plannotator/shared/config` → `@plannotator/core/config-types` -- `@plannotator/shared/storage` → `@plannotator/core/storage-types` -- `@plannotator/shared/workspace-status` → `@plannotator/core/workspace-status-types` -- `@plannotator/shared/types` (the `EditorAnnotation` re-export at `ui/types.ts:209`) → `@plannotator/core/types` -- `import type { AIContext } from '@plannotator/ai'` (`ui/hooks/useAIChat.ts:2`) → `from '@plannotator/core'` - -Files + lines (from scope-rewire §1, authoritative): -- `ui/types.ts:209` EditorAnnotation → `@plannotator/core/types` -- `ui/types.ts:211-213` ExternalAnnotationEvent → `@plannotator/core/external-annotation` -- `ui/types.ts:215-221` AgentJob*/AgentCapabilit* → `@plannotator/core/agent-jobs` -- `ui/config/settings.ts:12` DiffLineBgIntensity → `@plannotator/core/config-types` -- `ui/utils/parser.ts:2` planDenyFeedback → `@plannotator/core/feedback-templates` -- `ui/utils/annotateAgentTerminal.ts:1` AgentTerminalAgent → `@plannotator/core/agent-terminal` -- `ui/utils/aiProvider.ts:10` AGENT_CONFIG/getAgentAIProviderTypes/Origin → `@plannotator/core/agents` -- `ui/utils/sharing.ts:12` compress/decompress → `@plannotator/core/compress` -- `ui/utils/sharing.ts:13` encrypt/decrypt → `@plannotator/core/crypto` -- `ui/components/InlineMarkdown.tsx:4` isCodeFilePath/… → `@plannotator/core/code-file` -- `ui/components/OpenInAppButton.tsx:5` OpenInKind → `@plannotator/core/open-in-apps` -- `ui/components/Settings.tsx:3` Origin → `@plannotator/core/agents` -- `ui/components/Settings.tsx:4` DiffLineBgIntensity → `@plannotator/core/config-types` -- `ui/components/DocBadges.tsx:16` hostnameOrFallback → `@plannotator/core/project` -- `ui/components/AISettingsTab.tsx:12` Origin → `@plannotator/core/agents` -- `ui/components/MenuVersionSection.tsx:4` Origin → `@plannotator/core/agents` -- `ui/components/DiffTypeSetupDialog.tsx:3` DefaultDiffType → `@plannotator/core/config-types` -- `ui/components/PlanHeaderMenu.tsx:14` Origin → `@plannotator/core/agents` -- `ui/components/AgentsTab.tsx:16` isTerminalStatus → `@plannotator/core/agent-jobs` -- `ui/components/PlanAIAnnouncementDialog.tsx:3` Origin → `@plannotator/core/agents` -- `ui/components/PlanAIAnnouncementDialog.tsx:4` AGENT_CONFIG/getAgentAIProviderTypes/getAgentName → `@plannotator/core/agents` (all three symbols on this line; `export * from '@plannotator/core/agents'` re-exports all) -- `ui/components/blocks/HtmlBlock.tsx:2` isCodeFilePath → `@plannotator/core/code-file` -- `ui/components/sidebar/FileBrowser.tsx:13` WorkspaceFileChange/WorkspaceStatusPayload → `@plannotator/core/workspace-status-types` -- `ui/components/sidebar/FileBrowser.tsx:14` normalizeBrowserPath → `@plannotator/core/browser-paths` -- `ui/components/sidebar/ArchiveBrowser.tsx:9` ArchivedPlan → `@plannotator/core/storage-types` -- `ui/components/goal-setup/GoalSetupSurface.tsx:13-20` GoalSetup* types → `@plannotator/core/goal-setup` -- `ui/components/settings/HooksTab.tsx:2` FAVICON_SVG → `@plannotator/core/favicon` -- `ui/hooks/useAgents.ts:6` Origin → `@plannotator/core/agents` -- `ui/hooks/useAnnotationDraft.ts:18` SourceSaveCapability → `@plannotator/core/source-save` -- `ui/hooks/useArchive.ts:9` ArchivedPlan → `@plannotator/core/storage-types` -- `ui/hooks/useLinkedDoc.ts:13` SourceSaveCapability → `@plannotator/core/source-save` -- `ui/hooks/useValidatedCodePaths.ts:2` extractCandidateCodePaths → `@plannotator/core/extract-code-paths` -- `ui/hooks/useFileBrowser.ts:12` WorkspaceStatusPayload → `@plannotator/core/workspace-status-types` -- `ui/hooks/pfm/useCodeFilePopout.ts:2` parseCodePath → `@plannotator/core/code-file` -- `ui/hooks/useAIChat.ts:2` AIContext → `@plannotator/core` (bare package root → resolves via `exports['.']` → `index.ts`) - -### 2b. `packages/ui/tsconfig.json` paths -Add BOTH a `@plannotator/core/*` subpath mapping AND a bare `@plannotator/core` mapping alongside the existing shared one (line 21) so tsc resolves core during the transition. **Two entries are required:** the trailing-`/*` glob does NOT match the extensionless bare specifier `@plannotator/core` (used by `useAIChat.ts:2`): -```json -"@plannotator/core": ["../core/index.ts"], -"@plannotator/core/*": ["../core/*"] -``` -(Keep `"@plannotator/shared/*": ["../shared/*"]` — see 2d note; surviving test-file imports still use it.) The bare-specifier path map is authoritative for ui's tsc; `bun install` (already run in Step 1) is the belt-and-suspenders mechanism that also makes the bare specifier resolve via core's `exports['.']`. State both: **path map is authoritative for ui tsc; workspace catalog backs it.** - -### 2c. `packages/ui/package.json` dep edits + re-register -- REMOVE `"@plannotator/ai": "workspace:*"` -- REMOVE `"@plannotator/shared": "workspace:*"` -- ADD `"@plannotator/core": "workspace:*"` (workspace alias in source; resolves to exact `0.21.0` at pack time — ADR mandates EXACT pinning, enforce at pack in Step 5/final gate) - -**After 2c: run `bun install`** so the dependency-graph change (ui → core) is reflected in `bun.lock` before the Step 2d typecheck. - -### 2d. Verify (run at end of Step 2) -``` -bun install -grep -rn '@plannotator/shared\|@plannotator/ai' packages/ui --include='*.ts' --include='*.tsx' | grep -v '\.test\.' # MUST be empty -tsc --noEmit -p packages/ui/tsconfig.json # green -``` -> **Note (intentional, out of scope for the grep-zero gate):** exactly two ui *test* files still import `@plannotator/shared` (`annotateAgentTerminal.test.ts`, `FileBrowser.test.ts`). These are deliberately retained — the grep-zero assertion scopes to non-test files via `grep -v '\.test\.'`, and `@plannotator/shared/*` stays in the ui tsconfig `paths` to keep them resolving. A reviewer should NOT flag these. - -### Commit -``` -feat(ui): depend only on @plannotator/core — re-point all shared/ai imports (Phase 7 step 2) -``` - ---- - -## STEP 3 — Move `wideMode.ts` into `packages/ui/utils` (MECHANICAL / sonnet) - -Goal: relocate the pure `wideMode` helper from `editor` to `ui/utils`; fix the 2 importers. - -### 3a. git mv -``` -git mv packages/editor/wideMode.ts packages/ui/utils/wideMode.ts -git mv packages/editor/wideMode.test.ts packages/ui/utils/wideMode.test.ts -``` - -### 3b. Fix importer 1 — `packages/editor/App.tsx:109` -FROM: -```ts -import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -``` -TO: -```ts -import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from '@plannotator/ui/utils/wideMode'; -``` -(This is the ONE allowed edit to `packages/editor` source — a single import-specifier change, no behavior change. `wideMode.ts` itself only imports from `@plannotator/ui/types` + `@plannotator/ui/hooks/useSidebar`, so it lands cleanly in ui.) - -### 3c. Fix importer 2 — the moved test -`packages/ui/utils/wideMode.test.ts` imports `./wideMode` (relative) — UNCHANGED after the move (it's now a sibling in `ui/utils`). Verify the line still reads `from './wideMode';`. - -### 3d. exports map -`@plannotator/ui/utils/wideMode` already resolves via the existing `"./utils/*": "./utils/*.ts"` glob in `packages/ui/package.json` — NO new exports entry needed. (Confirm the glob is present at line 14.) - -### 3e. Verify (run at end of Step 3) -``` -grep -rn 'wideMode' packages --include='*.ts' --include='*.tsx' | grep -v 'packages/ui/utils/wideMode' # only editor/App.tsx (the new specifier) shows -tsc --noEmit -p packages/ui/tsconfig.json # green (wideMode + its importer resolve) -bun test packages/ui/utils/wideMode.test.ts -``` - -### Commit -``` -refactor(ui): relocate wideMode helper to @plannotator/ui/utils (Phase 7 step 3) -``` - ---- - -## STEP 4 — Settings provider `loadFromBackend` + `configurePlannotatorUI` front door (CRITICAL / opus) - -Goal: complete the half-built settings provider (initial-load routes through installed backend) and add the single typed configuration front door over the 9 global setters. Both ADDITIVE — Plannotator never calls either, so byte-for-byte parity holds. - -### 4a. Add `loadFromBackend()` to `ConfigStore` — `packages/ui/config/configStore.ts` -Insert this method into the `ConfigStore` class (after the constructor, before `init`): -```ts - /** - * Re-hydrate all settings from the currently installed StorageBackend. - * ADDITIVE host hook — Plannotator never calls this (eager cookie default unchanged). - * Host installs a SYNCHRONOUS StorageBackend serving prefetched settings, then calls - * this to route the initial load through that backend. Precedence after a host call: - * server (init) > host backend (loadFromBackend) > cookie/default (constructor). - */ - loadFromBackend(): void { - for (const [name, def] of Object.entries(SETTINGS)) { - const fromBackend = def.fromCookie(); - if (fromBackend !== undefined) { - this.values.set(name, fromBackend); - } - } - this.notify(); - } -``` -Contract: use `!== undefined` (NOT `??`) so a missing key keeps the constructor default; do NOT call `def.toCookie` (no re-write). Reuses the existing per-setting `fromCookie()` reader, which under a host backend reads the host's prefetched store. - -### 4b. Add `resetServerSync()` to `ConfigStore` (needed by the Step 6 seam test; keeps the seam family symmetric) -Next to `setServerSync` (configStore.ts:122): -```ts - resetServerSync(): void { this.serverSync = defaultServerSync; } -``` -(`defaultServerSync` already exists at configStore.ts:36-42.) - -### 4c. Create `packages/ui/configure.ts` — `configurePlannotatorUI` -New file. Imports the 9 setters from their intra-`ui` relative modules and fans out (every field optional, only provided seams applied): -```ts -import { setImageSrcResolver, type ImageSrcResolver } from './components/ImageThumbnail'; -import { setDocPreviewFetcher, type DocPreviewFetcher } from './components/InlineMarkdown'; -import { setStorageBackend, type StorageBackend } from './utils/storage'; -import { setIdentityProvider, type IdentityProvider } from './utils/identity'; -import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser'; -import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft'; -import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations'; -import { setAITransport, type AITransport } from './hooks/useAIChat'; -import { configStore } from './config'; - -type ExternalAnnotationBase = { id: string; source?: string }; -type ServerSyncFn = (payload: Record) => void; - -export interface PlannotatorUIConfig { - imageSrcResolver?: ImageSrcResolver; - storageBackend?: StorageBackend; - docPreviewFetcher?: DocPreviewFetcher; - fileTreeBackend?: FileTreeBackend; - identityProvider?: IdentityProvider; - draftTransport?: DraftTransport; - /** - * Base-constraint transport. If your annotation type extends the base - * constraint ({ id: string; source?: string }) with extra fields, call - * setExternalAnnotationTransport() directly for full type safety — - * this front-door field intentionally pins the base constraint for ergonomics. - */ - externalAnnotationTransport?: ExternalAnnotationTransport; - aiTransport?: AITransport; - serverSync?: ServerSyncFn; - /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */ - loadSettingsFromBackend?: boolean; -} - -export function configurePlannotatorUI(config: PlannotatorUIConfig): void { - if (config.imageSrcResolver) setImageSrcResolver(config.imageSrcResolver); - if (config.storageBackend) setStorageBackend(config.storageBackend); - if (config.docPreviewFetcher) setDocPreviewFetcher(config.docPreviewFetcher); - if (config.fileTreeBackend) setFileTreeBackend(config.fileTreeBackend); - if (config.identityProvider) setIdentityProvider(config.identityProvider); - if (config.draftTransport) setDraftTransport(config.draftTransport); - if (config.externalAnnotationTransport) setExternalAnnotationTransport(config.externalAnnotationTransport); - if (config.aiTransport) setAITransport(config.aiTransport); - if (config.serverSync) configStore.setServerSync(config.serverSync); - // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last). - if (config.loadSettingsFromBackend) configStore.loadFromBackend(); -} -``` -Notes: inline `ServerSyncFn` (configStore's type is module-local — do NOT widen configStore's surface). The external-annotation generic is pinned to the base constraint `{ id: string; source?: string }` (the hook's default transport is ``, contract-compatible); the doc comment above the field tells consumers with extended annotation types to call `setExternalAnnotationTransport()` directly. The render-time prop seams (vscode-diff, save-to-notes, obsidian-detect, version fetchers, editor `mode`, code-path toggle, `ScrollViewportProvider`) are intentionally NOT here — they're passed where the host renders those components. - -### 4d. ui exports + files -`packages/ui/package.json`: -- exports: add `"./configure": "./configure.ts"` (alongside `./config`, `./types`). -- files: add `"configure.ts"` (sits at package root like `types.ts`). - -### 4e. Verify (run at end of Step 4) -``` -tsc --noEmit -p packages/ui/tsconfig.json # green -grep -n 'loadFromBackend\|resetServerSync' packages/ui/config/configStore.ts -grep -n 'configurePlannotatorUI' packages/ui/configure.ts -``` - -### Commit -``` -feat(ui): add loadFromBackend settings rehydration + configurePlannotatorUI front door (Phase 7 step 4) -``` - ---- - -## STEP 5 — Precompiled CSS build + madge circular-dep tooling (MECHANICAL / sonnet) - -Goal: ship a required precompiled `@plannotator/ui/styles.css` (CSS-only Vite build); add `madge` + a circular-dependency script. (Core's node-free typecheck wiring already landed in Step 1i — re-verify here.) - -### 5a. CSS entry — `packages/ui/styles-entry.css` (new) -```css -@import "@fontsource-variable/inter"; -@import "@fontsource-variable/geist-mono"; -@import "tailwindcss"; - -@plugin "tailwindcss-animate"; - -@source "./components/**/*.tsx"; -@source "./hooks/**/*.ts"; -@source "./utils/**/*.ts"; - -@import "./theme.css"; -``` -(`@source` globs are relative to this file at `packages/ui/`; they run ONCE at build time on source, baking the utility classes into the output — that's the whole point vs. the fragile consumer-side `@source` into `node_modules`.) Does NOT include `@plannotator/webtui/styles.css` (agent-terminal) or dockview CSS (review-editor) — those are runtime-specific, not exported UI. -> **`print.css` is covered (verified):** `packages/ui/theme.css:55` already does `@import "./print.css";`, and `styles-entry.css` imports `./theme.css`, so the precompiled bundle DOES include print styles transitively. No separate `@import "./print.css"` is needed here. - -### 5b. Vite CSS-only config — `packages/ui/vite.css.config.ts` (new) -```ts -import path from 'path'; -import { defineConfig } from 'vite'; -import tailwindcss from '@tailwindcss/vite'; - -export default defineConfig({ - plugins: [tailwindcss()], - resolve: { alias: { '@plannotator/ui': path.resolve(__dirname, '.') } }, - build: { - lib: { entry: path.resolve(__dirname, 'styles-entry.css'), formats: ['es'], fileName: () => 'styles.js' }, - outDir: '.', - cssCodeSplit: false, - rollupOptions: { output: { assetFileNames: 'styles.css' } }, - emptyOutDir: false, - }, -}); -``` - -### 5c. `packages/ui/package.json` — CSS build wiring -- scripts: add `"build:css": "vite build --config vite.css.config.ts && rm -f styles.js"` -- scripts: add `"prepublishOnly": "bun run build:css"` — mirrors `apps/pi-extension/package.json`'s `prepublishOnly` pattern. This fires automatically before `bun pm pack` / `npm publish`, guaranteeing `styles.css` is fresh in the tarball even though it is NOT committed. Without it, a publish would ship a tarball missing the (listed-in-`files`) `styles.css` — a silent consumer break. -- exports: add `"./styles.css": "./styles.css"` -- files: add `"styles.css"` -- devDependencies: add `"@tailwindcss/vite": "^4.1.18"` and `"vite": "^6.2.0"` (CSS-only build needs only these two; no react plugin). -- Add a `.gitignore` line (or repo-root ignore) for `packages/ui/styles.js`. **Do NOT commit `styles.css`** — it's a generated artifact produced by `prepublishOnly` at pack/publish time (avoids stale diffs). Also add `packages/ui/styles.css` to `.gitignore`. - -### 5d. Root scripts — `build:ui-css` -Root `package.json` scripts: add `"build:ui-css": "bun run --cwd packages/ui build:css"`. - -### 5e. madge circular-dep tooling -- Root `package.json` devDependencies: add `"madge": "^8.0.0"` (`bun add -d madge` at repo root). -- Root `.madgerc` (new) — TS support: - ```json - { "extensions": ["ts", "tsx"], "fileExtensions": ["ts", "tsx"] } - ``` -- Root `package.json` scripts: add - ```json - "check:cycles": "madge --circular --extensions ts,tsx --ts-config packages/core/tsconfig.json packages/core && madge --circular --extensions ts,tsx --ts-config packages/ui/tsconfig.json packages/ui" - ``` - (Scoped to the two published packages — the strict invariant. `--circular` reports cycles only, not unresolved imports; the `--ts-config` for ui carries the `@plannotator/core/*` + bare-`@plannotator/core` path maps added in Step 2b so madge resolves the aliases. The two surviving `@plannotator/shared` references in ui *.test.ts files are resolved by the retained shared path map and do not affect cycle detection.) - -### 5f. Re-confirm core node-free typecheck wiring (from Step 1i) -Ensure `tsc --noEmit -p packages/core/tsconfig.json` is in the root `typecheck` script (added in 1i). Sanity: a planted `import 'node:fs'` in a core file fails `TS2307`. - -### 5g. Verify (run at end of Step 5) -``` -bun install # picks up madge + vite/@tailwindcss/vite devDeps -bun run build:ui-css # emits packages/ui/styles.css, removes styles.js -test -s packages/ui/styles.css && echo "styles.css non-empty OK" -bun run check:cycles # exits 0 (no cycles in core/ui) -tsc --noEmit -p packages/core/tsconfig.json # node-free green -``` - -### Commit -``` -build(ui): precompiled styles.css CSS build + madge circular-dep check (Phase 7 step 5) -``` -(Commit the configs/scripts/devDeps + `prepublishOnly`; do NOT commit the generated `styles.css`/`styles.js`.) - ---- - -## STEP 6 — Per-seam override tests + a `configurePlannotatorUI` routing test (MECHANICAL / sonnet) - -Goal: one override test per seam (`setX(fake)` → drive → assert → `resetX()`), making the `reset*()` functions live and pinning the subtle contracts; plus one test that `configurePlannotatorUI({...})` routes to each setter. - -### 6a. Verify (DO NOT re-edit) the override-path contracts before writing tests -The two override-path fixes flagged in earlier interrogation passes are **ALREADY landed on this branch** (verified). Step 6a is VERIFICATION-ONLY — do NOT re-apply or "fix" working code (re-editing risks an unintended Plannotator behavior change, a LAW violation). - -1. **Split-transport (already fixed):** `packages/ui/hooks/useExternalAnnotations.ts:134` captures `transportRef = useRef(externalAnnotationTransport …)`; the subscribe/poll effect reads `transportRef.current` (line 145) AND every CRUD callback reads `transportRef.current` (`.remove` line 232, `.clear` line 244, `.update` line 253). Reads and writes already use the same backend instance. **Confirm via grep**: - ``` - grep -n 'transportRef.current' packages/ui/hooks/useExternalAnnotations.ts # expect lines 145, 232, 244, 253 - ``` - If (and only if) these are absent, apply the capture-once pattern; otherwise proceed. -2. **Ref reset on effect re-run (already fixed):** `fallbackRef.current = false` (line 142) and `receivedSnapshotRef.current = false` (line 143) are already reset at the TOP of the effect, so an `enabled` toggle `false→true` re-attempts SSE. **Confirm via grep**: - ``` - grep -n 'fallbackRef.current = false\|receivedSnapshotRef.current = false' packages/ui/hooks/useExternalAnnotations.ts # expect lines 142, 143 - ``` -3. **`useFileBrowser` audit (no change expected):** `useFileBrowser.ts` reads the module global `fileTreeBackend` LIVE (lines 211/316/383) rather than capturing a ref. This is a DIFFERENT but acceptable pattern (no mount-time capture, so no read/write split to fix). Confirm no change is needed; do NOT introduce a ref here. - -Then proceed straight to the seam tests in 6b/6c — they pin the already-correct behavior. - -### 6b. Per-seam override tests (10 files, `.seam.test.ts(x)` naming, colocated) -| Seam | Test file | Assert | -|------|-----------|--------| -| `setImageSrcResolver` / `resetImageSrcResolver` | `packages/ui/components/ImageThumbnail.seam.test.tsx` | render `` → fake resolver called with `"/foo/img.png"` | -| `setStorageBackend` / `resetStorageBackend` | `packages/ui/utils/storage.seam.test.ts` | `setItem`/`getItem` → fake backend's read/write called (not `document.cookie`) | -| `setDocPreviewFetcher` / `resetDocPreviewFetcher` | `packages/ui/components/InlineMarkdown.seam.test.tsx` | trigger doc preview → fake fetcher called with expected path | -| `setFileTreeBackend` / `resetFileTreeBackend` | `packages/ui/hooks/useFileBrowser.seam.test.tsx` | mount `useFileBrowser` → `fetchTree()` → `fake.loadTree` invoked with expected dirPath | -| `setIdentityProvider` / `resetIdentityProvider` | `packages/ui/utils/identity.seam.test.ts` | `getIdentity()` → fake provider invoked | -| `setDraftTransport` / `resetDraftTransport` | `packages/ui/hooks/useAnnotationDraft.seam.test.ts` | `fake.load()` on mount; `fake.save()` on scheduled save | -| `setExternalAnnotationTransport` / `resetExternalAnnotationTransport` | `packages/ui/hooks/useExternalAnnotations.seam.test.ts` | mount → `fake.subscribe` called; delete → `fake.remove` on SAME transport (pins the already-landed split-transport fix) | -| `setAITransport` / `resetAITransport` | `packages/ui/hooks/useAIChat.seam.test.ts` | mount `useAIChat` + send → `fake` session/query called | -| `configStore.setServerSync` / `resetServerSync` | `packages/ui/config/configStore.seam.test.ts` | `configStore.set('', …)` → fake sync fn called with expected payload | -| `loadFromBackend` | `packages/ui/config/configStore.seam.test.ts` (2nd describe) | `setStorageBackend(prefetched)` → `loadFromBackend()` → `configStore.get(key)` returns prefetched value | - -Pattern (template): `afterEach(() => resetXTransport())`; in the test, `setXTransport(fake)`, drive (mount hook harness via React test utils, or call the utility directly), assert recorded calls. Files auto-discovered by `bun test` — no registration. - -### 6c. `configurePlannotatorUI` routing test — `packages/ui/configure.test.ts` (new) -Call `configurePlannotatorUI({ imageSrcResolver, storageBackend, docPreviewFetcher, fileTreeBackend, identityProvider, draftTransport, externalAnnotationTransport, aiTransport, serverSync, loadSettingsFromBackend: true })` with fakes/spies, then assert each underlying setter received its fake (and that `loadFromBackend` ran after `setStorageBackend`). Reset every seam in `afterEach`. - -### 6d. Verify (run at end of Step 6) -``` -bun test packages/ui # all ui tests incl. new .seam.test + configure.test green -tsc --noEmit -p packages/ui/tsconfig.json -``` - -### Commit -``` -test(ui): per-seam override tests + configure routing test (Phase 7 step 6) -``` - ---- - -## FINAL PARITY GATE (run after Step 6, before any publish/push — DO NOT push or publish) - -Run from repo root. ALL must pass; investigate any failure before proceeding. - -1. **Full typecheck (incl. core node-free + Pi vendor):** - ``` - bun install # ensure catalog is current (core registered, ui→core) - bun run typecheck - ``` - Expect green for core, shared, ai, server, ui, pi-extension. Confirm a planted `import 'node:fs'` in a `packages/core/*.ts` fails `TS2307` (node-free invariant), then remove it. - -2. **Test suite — delta vs. main must be ADDITIONS only:** - ``` - bun test - ``` - Expect the Phase-0 baseline pass count PLUS the new Step-6 seam/configure tests — zero regressions, zero failures. The delta should be exactly the new `.seam.test`/`configure.test` files plus the moved `wideMode.test` file. No pre-existing test changed behavior. - -3. **madge clean (no circular deps in published packages):** - ``` - bun run check:cycles - ``` - Exit 0. - -4. **`git diff` confined to expected packages:** - ``` - git diff --name-only main...HEAD - ``` - Must be limited to: `packages/core/**`, `packages/shared/**`, `packages/ai/**`, `packages/ui/**`, the single `packages/editor/App.tsx` import line + the moved `wideMode` files, `apps/pi-extension/vendor.sh`, root `package.json` / `.madgerc` / `.gitignore` / `bun.lock`, and (if added) `.github/workflows/*` CI. NOTHING in `packages/server`, `packages/review-editor`, `apps/hook`, `apps/opencode-plugin`, or any other Plannotator app source. - -5. **ui depends ONLY on core internally (non-test):** - ``` - grep -rn '@plannotator/shared\|@plannotator/ai' packages/ui --include='*.ts' --include='*.tsx' | grep -v '\.test\.' - ``` - Empty. (The two surviving `@plannotator/shared` imports in ui *.test.ts files are intentional — see Step 2d note — and are excluded by `grep -v '\.test\.'`.) - -6. **Apps build green + functional/visual parity (the REAL gate):** - ``` - bun run --cwd apps/review build && bun run build:hook && bun run build:opencode - bun run build:pi # Pi vendors from core now - ``` - All builds MUST succeed. **Parity is confirmed by a human running the plan review and code review UIs in the browser** (ADR 004: human browser verification is the real gate). The shipped bundle should be **functionally identical** to the Phase-0 baseline — the carve is move + re-export only, no runtime logic change. - - **Bundle-hash guidance (NOT a hard gate):** compare shipped bundle hashes against the Phase-0 baseline as a proxy signal, but do NOT treat any hash delta as an automatic STOP. The carve changes the import-resolution graph (e.g. `@plannotator/ui` now resolves `compress` directly from `core/compress.ts` instead of through the `shared/compress.ts` shim; `editor/App.tsx` now imports `wideMode` from `@plannotator/ui/utils/wideMode`). A bundler may emit different hashes purely from changed module ordering, import-path string literals, or source-map metadata while the executed JS logic is byte-identical. - - **Acceptable (proceed):** hash differs only in source-map metadata or import-path string literals, with no change to the JS logic bytes — confirm by diffing the de-minified/normalized bundle output. - - **STOP and investigate:** any difference in the actual JS logic bytes, OR any visible/functional difference in the browser. That is a regression and must be root-caused before proceeding. - -7. **CSS artifact builds:** - ``` - bun run build:ui-css && test -s packages/ui/styles.css - ``` - -**Publish/registry steps are OUT OF SCOPE for these 6 steps** — branch-validation (`bun pm pack` each, inspect tarball, `npm publish --dry-run`), the `release.yml` publish job, and the EXACT-pin substitution of `ui → core@0.21.0` happen only after a human confirms parity in the browser and gives explicit go (ADR 007 §5, THE LAW). Do NOT push these commits. diff --git a/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md b/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md deleted file mode 100644 index 0bedd2039..000000000 --- a/adr/research/SPIKE-document-ui-comments-system-20260623-084806.md +++ /dev/null @@ -1,73 +0,0 @@ -# Spike: Comments / Annotations / Drafts System (Phase 5) - -Date: 2026-06-23 - -> Code research for Phase 5 of the `@plannotator/ui` reuse effort (governed by ADR 004; roadmap `adr/implementation/document-ui-extraction-roadmap-20260622.md`). Five parallel probes mapped the comment/annotation/draft system on the real tree. Goal: know every backend wire and every timing-sensitive invariant before speccing the seams. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. - -## Headline - -**Most of the comment UI is already portable.** The comment *components* (`AnnotationPanel`, `CommentPopover`, `AnnotationToolbar`, `AnnotationToolstrip`, `EditorAnnotationCard`) and the highlighter hook (`useAnnotationHighlighter`) are prop-driven with no backend wires. `review-editor` already reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged — a second consumer proving portability. So Phase 5 is **narrower than its "big one" reputation on the UI side**; the work is concentrated in **three seams** (draft transport, external-annotation transport, identity) plus **two structural constraints** (renderer coupling, no reply model). - -## Annotation state lives in the host, not a shared reducer -- Plan: `packages/editor/App.tsx:255` `useState`; Review: `packages/review-editor/App.tsx:121` `useState`. There is **no shared annotation reducer** in `packages/ui` — each host owns its annotation array. So Workspaces will own its annotation state too; the shared package supplies the components/hooks that operate on it. (This is fine and matches review-editor.) - -## Seam 1 — Draft transport (`/api/draft`) + the generation protocol - -**Files:** `packages/ui/hooks/useAnnotationDraft.ts` (plan, full-featured), `packages/ui/hooks/useCodeAnnotationDraft.ts` (review, simpler). Server: `packages/shared/draft.ts`, `packages/server/shared-handlers.ts`, approve/deny in `packages/server/index.ts` + `annotate.ts`. - -- **Wires:** `GET/POST/DELETE /api/draft`. POST body carries `{annotations, codeAnnotations, globalAttachments, editedMarkdown, editedDocuments, savedFileChanges, draftGeneration, ts}`. DELETE uses `?generation=N`. 500ms debounce (`DEBOUNCE_MS`). -- **The 3-party generation protocol (the fragile part):** - 1. Client keeps `draftGenerationRef` (starts 0), **pre-increments before each POST** (`++draftGenerationRef.current`); `getDraftGeneration()` returns the *next* gen (`ref.current + 1`) — `useAnnotationDraft.ts:383`. - 2. That value **escapes the hook** and is threaded into submit by the host: `App.tsx:1960-1963` `withDraftGeneration(path)` appends `?draftGeneration=`; used on `/api/approve` (App.tsx:2704), `/api/exit` (2715); `/api/deny` and `/api/feedback` carry it in the **body** (2626, 2683). **Per-endpoint source differs:** plan approve/deny read from body; annotate approve/exit read from **URL** (`annotate.ts:557,550`), feedback from body (573). - 3. Server **tombstone-gates** (`shared/draft.ts`): `saveDraft` rejects if `draftGeneration <= deletedGeneration` (L98) or `< storedGeneration` (L102); `deleteDraft` writes a tombstone at the deletion generation (L150); ignores stale deletes (L146). This is what prevents a late async draft-save from **resurrecting a draft after submit** (ghost drafts). -- **Timing-sensitive, must move VERBATIM:** the `keepalive: true` POST with **retry-without-keepalive on failure** gated by generation match (L357-364); the `visibilitychange`/`pagehide` **flush** that fires a final keepalive save on tab close (L389-405); the refs (`draftGenerationRef`, `timerRef`, `latestRef` non-reactive getters, `canPersistRef`, `hasMountedRef`). `canPersist = isApiMode && !isSharedSession && !submitted`. -- **Already portable:** the hooks are pure (no host imports); `shared/draft.ts` is runtime-agnostic node:fs. The wires are the only coupling. - -## Seam 2 — External-annotation transport (the live-comment channel) - -**Files:** `packages/ui/hooks/useExternalAnnotations.ts`, `useExternalAnnotationHighlights.ts`. Server: `packages/server/external-annotations.ts` (+ Pi mirror), `packages/shared/external-annotation.ts` (store, validators, event types). - -- **This is the "teammates + agents commenting live" channel.** External tools/agents `POST /api/external-annotations`; the UI shows them live. -- **Transport state machine (move VERBATIM):** primary `EventSource('/api/external-annotations/stream')` delivers `snapshot|add|remove|clear|update` events into an internal reducer with **optimistic mutators** (delete/clear/update update local state, then call the server; SSE reconciles). On SSE error **before first snapshot**, fall back to **polling** `GET /api/external-annotations?since=` every **500ms** (`POLL_INTERVAL_MS`), honoring **304 Not Modified** when `since === store.version`. 30s SSE heartbeat (`:` comment). Version is session-scoped (`versionRef` starts 0). Fallback triggers once (`!receivedSnapshotRef && !fallbackRef`) and doesn't switch back. -- **Already generic + gated:** `useExternalAnnotations` is shape-generic and takes an `enabled` flag. Plan: `enabled: isApiMode && !goalSetupMode` (App.tsx:1135). **Review already reuses it** for `CodeAnnotation` with `enabled: !!origin` (App.tsx:284). `useExternalAnnotationHighlights` paints them via the Viewer handle (filters out global/diff, 100ms mount delay, fingerprint dedup). -- **Merge policy is host-owned:** App.tsx dedups local vs external by `source+type+originalText` (plan) / `source+type+filePath+lineStart+lineEnd+side` (review). -- **Seam = inject the transport** (a `subscribe()` + CRUD + `getSnapshot(since)` object) whose default reproduces the SSE→polling machine exactly. Server store/validators/SSE encoding (`shared/external-annotation.ts`) move wholesale. - -## Seam 3 — Identity / authorship ("which comments are mine") - -**Files:** `packages/ui/utils/identity.ts`, `generateIdentity.ts`, `config/configStore.ts`, `config/settings.ts`. - -- `getIdentity()` reads `configStore.get('displayName')`; resolution **server config > cookie (`plannotator-identity`) > generated `{adj}-{noun}-tater`**. `isCurrentUser(author)` compares `author === configStore.get('displayName')` (`identity.ts:47-50`). -- **Stamp sites (9 hardcoded `getIdentity()`):** `Viewer.tsx:456,518`, `useAnnotationHighlighter.ts:273`, `html-viewer/HtmlViewer.tsx:210`, `html-viewer/useHtmlAnnotation.ts:142,258,296,333`, `plan-diff/PlanCleanDiffView.tsx:169`. **Display sites (2 `isCurrentUser()`):** `AnnotationPanel.tsx:194,204` → renders the `(me)` badge (518, 651). -- **Partly already host-controllable:** identity persists via the **swappable storage backend** (Phase 2 `setStorageBackend`) and can be seeded from server config via `configStore.init(serverConfig)`. So a host can already set the identity *value*. The remaining seam is making the **stamp/display callable** overridable: optional `author?` / `isCurrentUser?` defaulting to the existing functions, so Workspaces (real WorkOS logins) supplies the logged-in user instead of a tater name. -- `Annotation.author` / `CodeAnnotation.author` are optional fields; `sharing.ts` preserves author across share/import (already collaborative). - -## Constraint A — Renderer coupling (structural, not a seam) - -**Files:** `useAnnotationHighlighter.ts` (`findTextInDOM` L106-235, `applyAnnotationsInternal` L293-403), `utils/inlineTransforms.ts` (`transformPlainText` = emoji + smartypants), `BlockRenderer.tsx`, `InlineMarkdown.tsx`, `@plannotator/web-highlighter@0.8.1`. - -- Highlight **restoration** re-anchors a saved annotation by searching the rendered DOM for `originalText`, with a fallback that applies `transformPlainText` (because the renderer turns `:smile:`→😄, `---`→—, straight→curly quotes). So restoration **only works if the host renders markdown to the same text** the transforms produce. -- Code blocks use **manual `` wrapping** (web-highlighter can't sit inside hljs spans); removal re-runs `hljs.highlightElement`. -- **Implication:** Workspaces must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` **as a unit** for highlights to land. This is a documented integration contract, not a wire to cut. (Optional future: expose `transformPlainText` as overridable, but default stays.) - -## Constraint B — No reply / threading model (a gap, not a regression) - -- `Annotation` and `CodeAnnotation` are **flat**: a comment is one `text` field. No `parentCommentId`, `replies`, `threadId`. `CommentPopover` has a module-level draft cache but composes single comments. -- Workspaces wants **replies/threads** (teammates discussing on a doc). That is a **new feature**, not part of "make today's behavior reusable." Adding threading touches the type, the panel, and the popover — and must NOT change Plannotator's flat experience. **Out of scope for Phase 5's parity-preserving extraction**; flag as a Workspaces-side addition (build replies as a host-layer on top of, or a backward-compatible extension of, the shared components later). - -## Already-portable inventory (no Phase-5 work needed) -`AnnotationPanel.tsx`, `AnnotationToolbar.tsx`, `AnnotationToolstrip.tsx`, `CommentPopover.tsx`, `EditorAnnotationCard.tsx`, `AnnotationSidebar.tsx`, `useAnnotationHighlighter.ts`, `useExternalAnnotationHighlights.ts`, `utils/commentContent.ts`, `utils/annotationHelpers.ts`, `utils/anchors.ts`, and the `exportAnnotations`/`exportCodeFileAnnotations`/`exportEditorAnnotations` serializers in `parser.ts` (pure, no API). `AnnotationPanel` only touches identity via the display-only `isCurrentUser` (Seam 3). - -## Out of scope / host-owned (confirmed) -- `useEditorAnnotations` (`/api/editor-annotation(s)`, gated by `window.__PLANNOTATOR_VSCODE`) — VS Code IPC, host-only, not a document-UI seam. -- Feedback/submit routes (`/api/feedback`, `/api/approve`, `/api/deny`, `/api/exit`) and their payload policy — host-owned (Workspaces has its own). -- Annotation state ownership and the external-merge/dedup policy — host-owned. - -## Per-seam evidence map -| Seam | Key files | Backend wires | Move-verbatim invariants | -|---|---|---|---| -| 1 Drafts | useAnnotationDraft.ts, useCodeAnnotationDraft.ts, shared/draft.ts | `GET/POST/DELETE /api/draft`; generation in approve/deny/feedback/exit | generation pre-increment + tombstone gate; keepalive retry; visibility/pagehide flush; the 5 refs | -| 2 External | useExternalAnnotations.ts, useExternalAnnotationHighlights.ts, shared/external-annotation.ts, server/external-annotations.ts | SSE `/stream`; `GET ?since=`; `POST/PATCH/DELETE` | SSE→polling fallback machine; 500ms poll; 304 gate; 30s heartbeat; optimistic mutators; version-scoping | -| 3 Identity | identity.ts, configStore.ts, settings.ts | (none directly; via configStore→storage, swappable) | resolution order server>cookie>tater; 9 stamp sites; 2 `(me)` sites | -| A Renderer | useAnnotationHighlighter.ts, inlineTransforms.ts, BlockRenderer/InlineMarkdown | none | restoration depends on exact rendered text; manual code-block `` | -| B Replies | types.ts, AnnotationPanel, CommentPopover | none | flat model today; threading is a NEW feature, keep Plannotator flat | diff --git a/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md b/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md deleted file mode 100644 index eb510cf51..000000000 --- a/adr/research/SPIKE-document-ui-current-state-and-parity-20260621-115603.md +++ /dev/null @@ -1,216 +0,0 @@ -# Spike: Document UI Current State and Parity - -> ℹ️ **Context still useful; the direction it informed was reverted.** This honestly reported the failed cutover was only ~55–65% at parity. The cutover was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting. - -Date: 2026-06-21 - -## Question - -What exactly is the state of the `@plannotator/document-ui` extraction now, how close is it to parity with the current Plan Review / Annotate app, and what should be finalized inside the shared package versus left to Plannotator or other hosts? - -## Scope - -This spike reads the current branch code. It does not change product code. - -Primary files inspected: - -- `packages/document-ui/package.json` -- `packages/document-ui/index.ts` -- `packages/document-ui/types.ts` -- `packages/document-ui/DocumentReviewSurface.tsx` -- `packages/document-ui/plannotatorHttpApi.ts` -- `packages/document-ui/memoryDocumentHostApi.ts` -- `packages/document-ui/documentReviewChrome.ts` -- `packages/editor/App.tsx` -- `packages/editor/PlannotatorDocumentSurfaceBridge.tsx` -- `packages/editor/documentSurfaceBridge.ts` -- `adr/implementation/document-ui-extraction-intent-20260620-085249.md` - -Verification run: - -- `bun test packages/document-ui` -- Result: 329 passing tests across 31 files. - -## Executive Read - -The extraction is real and substantial. It is accurate to say that much of Plannotator's document-review domain behavior has been moved into a new `@plannotator/document-ui` package. - -It is not accurate to say the app has been cut over to the package yet. - -The current production app still defaults to `packages/editor/App.tsx`. The new surface is mounted only when `VITE_DOCUMENT_SURFACE` is `1` or `true`, through `PlannotatorDocumentSurfaceBridge`. The old editor shell is still the main render path. - -My current read: - -- Package capability: roughly 70-80 percent of the hard reusable document-domain logic is extracted. -- Current-app parity: roughly 55-65 percent, depending on whether plan diff, archive, goal setup, Ask AI panel, and terminal are considered part of the required reusable surface. -- Cutover/no-legacy readiness: roughly 40-50 percent. The shared package is green, but the app still depends on a large legacy shell for important visible workflows. - -The branch is past "prototype contract" and into "candidate package," but it still needs a deliberate parity/cutover pass before deleting the old UI. - -## What Exists Now - -### Package Footprint - -`packages/document-ui` is a real package with explicit exports, not a single component dump. It exports the surface, provider-neutral types, memory provider, Plannotator HTTP adapter, feedback assembly, edit/writeback helpers, annotation persistence, draft state, linked state, tree state, chrome decisions, panel/sidebar state, Ask AI context, delivery helpers, and Plannotator compatibility helpers. - -The package is currently about 28.6k lines including tests. The main app shell is still about 4.8k lines: - -- `packages/document-ui/DocumentReviewSurface.tsx`: 1,437 lines. -- `packages/document-ui/*.ts/*.tsx`: 28,643 total lines including tests. -- `packages/editor/App.tsx`: 4,773 lines. - -### Provider-Neutral Contract - -The core types are now provider-neutral: - -- `DocumentRef` is provider/document identity, not local file identity (`packages/document-ui/types.ts:77`). -- `DocumentWritebackStatus` is `clean | dirty | saving | saved | conflict | missing | error` (`packages/document-ui/types.ts:90`). -- `LoadedDocument` carries content, render mode, image base, and optional writeback capability (`packages/document-ui/types.ts:129`). -- `DocumentReviewSession` carries mode, root document/ref, root tree ref, capabilities, and UI labels (`packages/document-ui/types.ts:177`). -- `SubmitDocumentFeedbackPayload` contains annotations, linked annotations, code annotations, attachments, direct edits, saved changes, and message scope (`packages/document-ui/types.ts:378`). -- `DocumentHostApi` abstracts load, linked-doc resolution, tree listing, document watching, save, drafts, annotation persistence, uploads, image URLs, feedback, approve, exit, Ask AI, and agent delivery (`packages/document-ui/types.ts:437`). - -This is the right conceptual center. Workspaces can implement the same contract with workspace document ids, manifests, versions, `If-Match`, and its annotation APIs. Plannotator implements it with `/api/plan`, `/api/doc`, `/api/source/save`, `/api/draft`, `/api/upload`, and `/api/image`. - -### Default Surface - -`DocumentReviewSurface` is no longer just a render prop wrapper. It now owns substantial product behavior: - -- Resolves the initial document from session/root/ref (`packages/document-ui/DocumentReviewSurface.tsx:144`). -- Seeds and tracks writeback state (`packages/document-ui/DocumentReviewSurface.tsx:171`). -- Owns root annotation state (`packages/document-ui/DocumentReviewSurface.tsx:195`). -- Owns linked-document state (`packages/document-ui/DocumentReviewSurface.tsx:216`). -- Owns optional annotation persistence (`packages/document-ui/DocumentReviewSurface.tsx:223`). -- Owns document tree state (`packages/document-ui/DocumentReviewSurface.tsx:242`). -- Owns edit/writeback controller state (`packages/document-ui/DocumentReviewSurface.tsx:250`). -- Owns provider watch reconciliation (`packages/document-ui/DocumentReviewSurface.tsx:259`). -- Owns draft save/restore state (`packages/document-ui/DocumentReviewSurface.tsx:269`). -- Builds feedback payloads with linked annotations, direct edits, and saved changes (`packages/document-ui/DocumentReviewSurface.tsx:384`). -- Calls host feedback, approve, and exit APIs (`packages/document-ui/DocumentReviewSurface.tsx:419`). -- Renders a default chrome with header, writeback badges, annotation-persistence badges, edit/save/discard/conflict buttons, submit/approve/close buttons, document navigator, feedback panel, draft banners, and error banners (`packages/document-ui/DocumentReviewSurface.tsx:551`, `packages/document-ui/DocumentReviewSurface.tsx:613`, `packages/document-ui/DocumentReviewSurface.tsx:693`). -- Renders markdown and raw HTML through the existing Plannotator renderer modules while routing image upload/image display and linked-doc opens through the provider API (`packages/document-ui/DocumentReviewSurface.tsx:1240`, `packages/document-ui/DocumentReviewSurface.tsx:1292`). - -This means the package already owns a meaningful document review loop. - -### Plannotator Adapter - -The Plannotator adapter is also substantial: - -- `createPlannotatorHttpDocumentApi()` maps current server routes into `DocumentHostApi`. -- `createPlannotatorHostSessionState()` normalizes `/api/plan` responses into document-session and host-session state (`packages/document-ui/plannotatorHttpApi.ts:408`). -- `createPlannotatorEditorLoadPlan()` derives the legacy editor load plan from normalized session state (`packages/document-ui/plannotatorHttpApi.ts:497`). -- Capabilities are mapped from local server data, including raw HTML, folder browsing, source-save writeback, share, Ask AI, agent terminal availability, and version support (`packages/document-ui/plannotatorHttpApi.ts:877`). - -This is good layering: local source-save details remain in Plannotator adapter exports, not in the provider-neutral `DocumentHostApi`. - -### Opt-In Bridge - -The bridge exists and is thin: - -- `packages/editor/documentSurfaceBridge.ts` decides the flag and renders feedback text through shared feedback assembly (`packages/editor/documentSurfaceBridge.ts:18`, `packages/editor/documentSurfaceBridge.ts:22`). -- `packages/editor/PlannotatorDocumentSurfaceBridge.tsx` creates the Plannotator HTTP API and mounts `` (`packages/editor/PlannotatorDocumentSurfaceBridge.tsx:44`, `packages/editor/PlannotatorDocumentSurfaceBridge.tsx:55`). -- `packages/editor/App.tsx` only uses the bridge behind `USE_DOCUMENT_SURFACE` (`packages/editor/App.tsx:130`, `packages/editor/App.tsx:3905`). - -This is the clearest evidence that the package is not yet the default app path. - -## What Still Lives In The Old App - -The old editor shell still owns major parity features and side effects: - -- Plan diff/version behavior: `usePlanDiff`, base-version selection, diff activation, and `PlanDiffViewer` render path remain in `App.tsx` (`packages/editor/App.tsx:817`, `packages/editor/App.tsx:4267`). -- Legacy linked-doc hook and Plannotator editable-source side effects remain in `App.tsx` (`packages/editor/App.tsx:888`, `packages/editor/App.tsx:938`). -- Archive browser state and archive selection remain in `App.tsx` (`packages/editor/App.tsx:968`, `packages/editor/App.tsx:4166`). -- External/editor annotation route integration remains in `App.tsx` (`packages/editor/App.tsx:1346`). -- Sticky header lane, annotation toolstrip, wide/focus inline controls, HTML tools toggle, checkbox overrides, code-file popout, message picker chrome, and Plannotator-specific viewer props remain in `App.tsx` (`packages/editor/App.tsx:4213`, `packages/editor/App.tsx:4235`, `packages/editor/App.tsx:4298`, `packages/editor/App.tsx:4411`). -- Goal setup is rendered from the old shell (`packages/editor/App.tsx:4255`). -- Agent terminal panel and resize shell remain in the old shell (`packages/editor/App.tsx:4078`). -- Ask AI panel and provider settings remain in the old shell (`packages/editor/App.tsx:4509`). -- Export/share/import modals and note integrations remain in the old shell (`packages/editor/App.tsx:4577`). -- The old `AppHeader` still controls Plannotator-specific top-level actions, settings, archive actions, callback actions, note-app actions, and AI/sidebar toggles (`packages/editor/App.tsx:3929`). - -Some of these should remain host-owned. Others are parity gaps if the package is meant to become the default document-review capability. - -## Parity Matrix - -| Area | Current State | Parity Read | -| --- | --- | --- | -| Provider-neutral document/session contract | In package | Strong | -| Markdown render and annotate | In package through existing `@plannotator/ui` renderer | Mostly there | -| Raw HTML render and annotate | In package through `HtmlViewer`; bridge-script tests exist | Mostly there | -| Image attachments and image display | Provider-owned in package | Strong | -| Linked document navigation | Package has provider-neutral state; old app still owns Plannotator filesystem side effects | Partial | -| Document tree/file browser | Package has tree state/default navigator; old app still owns richer file-browser tab and watchers | Partial | -| Writeback state | Provider-neutral core and Plannotator adapter exist | Strong | -| Local source-save compatibility | In package under Plannotator-specific exports | Strong for Plannotator, acceptable as adapter-specific | -| Draft restore | Provider-neutral core exists; old app still owns some display and side effects | Mostly there | -| Annotation persistence | Provider-neutral load/save contract exists | Mostly there | -| Feedback text/payload assembly | Shared package owns most assembly | Strong | -| Submit/approve/exit lifecycle | Package has default lifecycle; host still owns route policy in legacy path | Mostly there | -| External/editor annotations | Feedback text supports them; route/SSE integration remains old-app owned | Partial | -| Ask AI | Context helpers and host API type exist; full panel/session UI remains old-app owned | Partial | -| Plan versions/diff | Capability flag exists, but no generic host API and default surface does not render version browser/diff | Gap | -| Archive browser | Adapter carries archive metadata; default package surface does not provide archive browser parity | Gap or host-owned, depending decision | -| Goal setup | Old-app owned | Host-owned or package slot, not core document review | -| Agent terminal | Old-app owned; package has a slot and delivery helpers | Correctly host-owned runtime, partial UI slot | -| Sticky toolstrips/wide/focus polish | Decisions extracted, but package default chrome is simpler | Partial | -| Settings/share/import/export/note apps | Old-app owned | Correctly host-owned | -| Plugin/server routes/auth/browser open | Host/server owned | Correctly outside package | - -## What Should Be Finalized Inside The Package - -The package should own the reusable document-review loop end to end: - -1. `DocumentReviewSurface` as the default production surface for plan review, annotate file, annotate folder, annotate message, and workspace document review. -2. Provider-neutral document identity, loading, linked-doc navigation, tree navigation, annotation state, annotation persistence, draft restore, image upload/display, edit/writeback, conflict/missing/saving/saved chrome, feedback payload assembly, and submit/approve/exit actions. -3. A real default chrome that reaches parity with the current visible document experience: annotation toolstrip, sticky controls where applicable, feedback panel behavior, document navigation, file/tree badges, writeback badges, draft banners, and polished markdown/raw-HTML render behavior. -4. Optional document version/diff capability. This is the biggest missing reusable feature. The package already has `supportsVersions`, but it needs provider-neutral methods such as `listDocumentVersions`, `loadDocumentVersion`, and maybe `compareDocumentVersions`. Plannotator would adapt `/api/plan/versions` and `/api/plan/version`; Workspaces would adapt its versions API. The package should own the diff toggle/viewer because Workspaces explicitly needs the same review experience with a different provider. -5. Optional Ask AI surface behavior when `hostApi.askAI` exists. The package should own document target/context assembly and the in-document ask affordance. The host should still own provider/model config, auth, permission policy, and transport. -6. Optional annotation-provider watch/poll capability if Workspaces needs live comment updates. The current `loadAnnotations`/`saveAnnotations` contract is a good base, but route/SSE details should stay adapter-owned. -7. Plannotator local adapter as a first-class adapter, not as core vocabulary. Keep source-save, disk hash, mtime, missing local files, `/api/source/save`, and current draft compatibility in `plannotator-*` exports. -8. A memory/provider test harness that proves Workspaces-like behavior without local filesystem assumptions. -9. Contract tests for parity behavior. The package tests are green now, but cutover needs tests that assert the default surface can handle markdown, raw HTML, folder tree navigation, linked docs, writeback conflict/missing, drafts, version diff, and feedback assembly without the old `App.tsx` state machine. - -## What Should Stay Out Of The Package - -The package should not own host environment policy: - -1. Server route implementation, auth, process lifetime, browser launching, remote/local port behavior, and plugin command/hook handling. -2. Plan-mode `ExitPlanMode` hook behavior and stdout decision shape. -3. Plannotator note integrations: Obsidian, Bear, Octarine. -4. Share/paste service policy, short URL generation, import/export modal policy, and hosted share URLs. -5. Agent terminal runtime, PTY/WebSocket bridge, terminal installation, and terminal provider policy. The package should keep slots/state helpers, not own the terminal runtime. -6. Workspaces server calls and auth. Workspaces should provide an adapter implementing `DocumentHostApi`. -7. Local filesystem source-save internals as generic concepts. Those belong in the Plannotator adapter namespace. -8. The code-review/diff app in `packages/review-editor`; that is a different product surface. -9. Product settings UI and Plannotator-specific header menu policy. -10. External annotation transport details. The package can define optional annotation persistence/watch contracts, but SSE route names and provider mutation routes belong in adapters. - -## Cutover Work To Delete The Old UI - -If the branch goal is "the app uses the package and old/legacy code goes away," the remaining work is not another broad extraction pass. It is a focused parity and cutover pass: - -1. Make a crisp scope decision for plan diff, archive, goal setup, Ask AI, and terminal. - - My recommendation: move version/diff into the package as optional document capability. - - Keep archive as either a Plannotator host tab/slot or an optional adapter-provided document collection, not mandatory core. - - Keep goal setup host-owned or slot-based unless Workspaces needs it. - - Keep terminal runtime host-owned; use slots and delivery state. - - Move Ask AI UI only up to the provider-neutral level; host owns provider config and permissions. -2. Bring `DocumentReviewSurface` default chrome to parity for annotate/file/folder/message and plan review. -3. Add the missing generic version/diff API and render path in the package. -4. Wire Plannotator production path to the bridge without `VITE_DOCUMENT_SURFACE`. -5. Delete or collapse duplicate `App.tsx` document-domain state once the package owns it. -6. Leave `App.tsx` as a Plannotator host shell: load session, read settings, configure adapters/slots, handle route/policy side effects, render package surface. -7. Add a cutover test matrix: - - `bun test packages/document-ui` - - `bun run typecheck` - - `VITE_DOCUMENT_SURFACE=1 bun run --cwd apps/hook build` - - `bun run --cwd apps/hook build` - - targeted browser smoke for annotate markdown, annotate raw HTML, annotate folder, plan review, linked docs, source-save conflict/missing, and plan diff. - -## Bottom Line - -We have extracted much of the Plannotator document UI and domain behavior into its own package. That is a proper thing to say. - -We have not yet made the package the app. The old shell is still the default path and still owns visible parity-critical workflows. - -The clean path is to finish the package around the actual document-review loop, especially version/diff and default chrome parity, then flip the production app to the package and remove the duplicate editor state. Keeping the old shell around indefinitely would defeat the point of this branch; deleting it now would cut out real behavior users still rely on. diff --git a/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md b/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md deleted file mode 100644 index ea5b037da..000000000 --- a/adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md +++ /dev/null @@ -1,744 +0,0 @@ -# Spike: Document UI Extraction Boundary - -> ℹ️ **Research still useful; the direction it informed was reverted.** This accurately describes how the current document UI works, but the extraction approach it fed into (ADRs 002/003) was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting on any recommendation here. - -Date: 2026-06-20 - -## Question - -Build a concrete understanding of how the Plan Review app now powers annotate-mode document review, and identify the boundary for extracting that document experience into a shared UI package. - -The historical center of gravity was Plan Mode: Claude Code intercepted `ExitPlanMode`, opened Plannotator, and waited for approve or deny. The current primary document workflow is broader: - -- run annotate on a markdown, text, HTML, URL, or folder target -- run last-message annotation -- optionally review-gate an artifact with approve or feedback -- sometimes keep an agent terminal running inside the annotate session - -## Scope - -This spike only reads the current branch code. It does not change product code. - -Primary files inspected: - -- `packages/editor/App.tsx` -- `packages/editor/components/AppHeader.tsx` -- `packages/editor/components/AnnotateAgentTerminalPanel.tsx` -- `packages/editor/agentTerminalIntegration.ts` -- `packages/editor/directEdits.ts` -- `packages/editor/editableDocuments.ts` -- `packages/editor/sourceDocumentClient.ts` -- `packages/editor/sourceDocumentReconciliation.ts` -- `packages/editor/savedFileChangeValidation.ts` -- `packages/ui/components/Viewer.tsx` -- `packages/ui/components/BlockRenderer.tsx` -- `packages/ui/components/InlineMarkdown.tsx` -- `packages/ui/components/MarkdownEditor.tsx` -- `packages/ui/components/html-viewer/HtmlViewer.tsx` -- `packages/ui/components/html-viewer/useHtmlAnnotation.ts` -- `packages/ui/components/AnnotationPanel.tsx` -- `packages/ui/components/sidebar/FileBrowser.tsx` -- `packages/ui/components/sidebar/SidebarContainer.tsx` -- `packages/ui/hooks/useAnnotationDraft.ts` -- `packages/ui/hooks/useAnnotationHighlighter.ts` -- `packages/ui/hooks/useFileBrowser.ts` -- `packages/ui/hooks/useLinkedDoc.ts` -- `packages/ui/hooks/usePlanDiff.ts` -- `packages/ui/hooks/useArchive.ts` -- `packages/ui/hooks/useAIChat.ts` -- `packages/ui/hooks/useExternalAnnotations.ts` -- `packages/ui/hooks/useValidatedCodePaths.ts` -- `packages/ui/utils/parser.ts` -- `packages/ui/types.ts` -- `packages/server/annotate.ts` -- `packages/server/index.ts` -- `packages/server/reference-handlers.ts` -- `packages/server/reference-watch.ts` -- `packages/server/agent-terminal.ts` -- `apps/hook/server/index.ts` -- `apps/opencode-plugin/index.ts` -- `apps/pi-extension/index.ts` -- `apps/pi-extension/server/serverAnnotate.ts` -- `apps/pi-extension/server/reference.ts` -- `apps/pi-extension/server/file-browser-watch.ts` - -## Short Answer - -There is not a separate "Annotate app" today. Annotate mode is the Plan Review app running in a different server-provided mode. - -The Bun annotate server deliberately serves document content through `/api/plan` so the existing plan editor bundle can render it. `packages/editor/App.tsx` is the real composition root for plan review, annotate, annotate-last, annotate-folder, archive, goal setup, linked docs, direct edits, AI, drafts, external annotations, file browser, raw HTML, and agent terminal. - -The reusable pieces are already partly in `@plannotator/ui`, but that package is not a clean document UI package. Many components and hooks inside it fetch hard-coded `/api/*` routes. The actual document-product state machine is split between `@plannotator/ui` and `@plannotator/editor`, with the largest orchestration still in `App.tsx`. - -A good extraction should not start by moving `App.tsx` wholesale. The safer boundary is a document-review surface with an explicit host API adapter and optional capabilities. - -## Current Runtime Shape - -### Entry points - -Claude Code, Droid, OpenCode, and Pi all route manual document review into annotate mode. - -Claude Code and Droid run the CLI-style commands: - -- `plannotator annotate ` -- `plannotator annotate-last` / `plannotator last` - -OpenCode intercepts `plannotator-annotate`, `plannotator-last`, and `plannotator-review` before the agent sees the command. This is important: OpenCode clears command prompt output so a large file path is not auto-attached to the agent context before Plannotator opens. - -Pi implements native command handlers, but converges on the same server/UI contract. - -The CLI annotate command does input detection before starting the server: - -- `https://...`: fetch with Jina Reader by default, or fetch plus Turndown with `--no-jina` -- folder: open annotate-folder mode and show the file browser -- `.html` / `.htm`: render raw HTML by default, or convert to markdown with `--markdown` -- `.md`, `.mdx`, `.txt`: read file text directly - -Annotate-last resolves recent assistant messages from each agent's transcript or session store. It can pass a picker list of recent messages to the frontend. - -### Servers - -There are two server implementations with matching API surfaces: - -- Bun server in `packages/server/*`, used by Claude Code, Droid, and OpenCode paths. -- Pi server in `apps/pi-extension/server/*`, using Node HTTP primitives and generated shared files. - -The annotate Bun server is `startAnnotateServer(options)` in `packages/server/annotate.ts`. The Pi mirror is `apps/pi-extension/server/serverAnnotate.ts`. - -The annotate server intentionally reuses `/api/plan`: - -```text -GET /api/plan -> { - plan, - origin, - mode, - filePath, - sourceInfo, - sourceConverted, - sourceSave, - gate, - renderAs, - rawHtml?, - convertHtml, - sharingEnabled, - shareBaseUrl, - pasteApiUrl, - repoInfo, - projectRoot, - isWSL, - serverConfig, - agentTerminal?, - recentMessages? -} -``` - -That endpoint is the switch that turns the plan editor bundle into annotate mode. - -Other annotate-mode endpoints used by the document UI: - -- `GET /api/doc`: open linked docs, folder files, and code-file previews. -- `POST /api/doc/exists`: validate code-file links discovered in markdown. -- `GET /api/reference/files`: build the folder file browser tree. -- `GET /api/reference/files/stream`: SSE watch for folder tree, git status, and open source file changes. -- `POST /api/source/save`: atomically save source-backed markdown, mdx, or text files. -- `GET /api/share-html`: lazily prepare portable raw HTML for sharing. -- `GET /api/html-assets//`: serve relative HTML support assets. -- `GET/POST/DELETE /api/draft`: persist annotations, attachments, and direct edits. -- `POST /api/feedback`: return annotated feedback to the invoking session. -- `POST /api/approve`: approve a review-gated annotate session. -- `POST /api/exit`: close without feedback. -- `GET/POST /api/ai/*`: Ask AI sessions. -- `GET/POST/PATCH/DELETE /api/external-annotations*`: live annotations from external tools. -- `WebSocket /api/agent-terminal/pty/`: optional annotate-mode agent terminal. - -The server also owns the security boundary for document access. `getAnnotateReferenceRootPaths()` scopes file access to the folder target, current working directory, the source file directory, and realpath equivalents. `/api/doc` and `/api/doc/exists` resolve within those roots. - -### Source-save capability - -Source save is negotiated by the server and carried to the UI as `sourceSave`. - -Enabled only for local `.md`, `.mdx`, or `.txt` documents. Disabled for: - -- message mode -- folder root before a file is selected -- raw HTML rendering -- converted HTML/URL content -- non-local URLs -- unsupported extensions -- missing or unreadable files - -The capability includes: - -- scope: `single-file` or `folder-file` -- path, basename, language -- content hash, mtime, size, and EOL style - -The UI uses these fields as optimistic concurrency metadata when calling `/api/source/save`. - -## Current UI Shape - -### Composition root - -`packages/editor/App.tsx` is 4,685 lines and owns the product state machine. - -It initializes from `/api/plan`, then branches across: - -- normal plan review -- annotate single file -- annotate last message -- annotate folder -- raw HTML annotate -- archive -- goal setup -- shared sessions - -Core state clusters in `App.tsx`: - -- document content: `markdown`, `renderAs`, `rawHtml`, `shareHtml`, `sourceInfo`, `sourceConverted`, `sourceFilePath`, `imageBaseDir`, `projectRoot` -- parsed document: `displayedMarkdown`, `frontmatter`, `blocks` -- annotations: document annotations, code annotations, external annotations, editor annotations, linked-doc annotation cache, global image attachments -- editor state: markdown edit mode, direct-edit stats, dirty flags, editable document records -- mode flags: `annotateMode`, `gate`, `annotateSource`, archive mode, goal setup, message picker -- layout: left sidebar, right annotation panel, wide mode, resizable panes, agent terminal pane -- server/session capabilities: origin, sharing URLs, repo info, AI providers, agent terminal capability - -The key render switch is: - -- `renderAs === "html"`: render `HtmlViewer` -- `isEditingMarkdown`: render `MarkdownEditor` -- otherwise: render `Viewer` - -This means markdown, editable markdown, and raw HTML are different render surfaces inside the same app shell. - -### Markdown parser and block model - -`parseMarkdownToBlocks(markdown)` in `packages/ui/utils/parser.ts` creates `Block[]`. - -The parser is intentionally simple and stable for annotation anchoring. It handles: - -- headings with deterministic ids -- paragraphs -- blockquotes and GitHub alert callouts -- list items and task checkboxes -- fenced code blocks -- tables -- horizontal rules -- raw HTML blocks -- directive containers -- inline enhancements through render components - -`Block.startLine` is part of the feedback contract. `exportAnnotations()` uses it to generate human-readable feedback with line labels. - -This creates a strong coupling: - -```text -markdown -> parseMarkdownToBlocks -> Block ids and startLine - -> Viewer highlights and annotation blockId - -> exportAnnotations feedback -``` - -Any extracted package must preserve this chain or own a replacement end to end. - -### Viewer - -`packages/ui/components/Viewer.tsx` is 970 lines. It is not just a presentational renderer. - -It owns: - -- `useAnnotationHighlighter` -- web-highlighter lifecycle -- code-block annotation path -- sticky headers and scroll behavior -- code path validation through `useValidatedCodePaths` -- heading anchors and hash navigation -- global comments and image attachments -- quick labels -- table and code popouts -- doc badges -- Ask AI hooks at comment and document level - -`Viewer` delegates block rendering to `BlockRenderer`, `CodeBlock`, `TableBlock`, `MermaidBlock`, `GraphvizBlock`, and related components. - -`InlineMarkdown` is another important coupling point. It linkifies code-file references and wiki/doc links, fetches `/api/doc` for hover previews, and relies on `CodePathValidationContext`. - -### Raw HTML viewer - -`HtmlViewer` renders raw HTML in an iframe through `srcdoc`. The server rewrites relative assets to `/api/html-assets/...`. - -Annotation inside raw HTML uses `useHtmlAnnotation` and an injected bridge script. It communicates selection, comments, deletions, and quick labels with `postMessage`. - -HTML annotations do not use markdown blocks the same way markdown annotations do. They carry text and bridge mark ids, with `blockId` effectively empty. This is a separate annotation path hidden behind the same `ViewerHandle` contract: - -- `removeHighlight` -- `clearAllHighlights` -- `applySharedAnnotations` - -### Markdown editing and direct edits - -`MarkdownEditor` in `@plannotator/ui` is a thin Plannotator-themed wrapper around `@plannotator/markdown-editor`. - -The editing state is not in that component. It lives in `App.tsx` plus `packages/editor/editableDocuments.ts`. - -For normal plan review, editing produces a Direct Edits feedback section. For source-backed annotate files, editing can save back to disk through `/api/source/save`. - -After edits, `App.tsx` calls `applyEditedDocument(next)`: - -- reparse markdown -- remap annotations by original selected text -- clear positional metadata when a block changes -- update markdown -- bump `editGeneration` -- repaint highlights - -This annotation remapping is a critical behavior. It is easy to lose if editing is extracted separately from rendering and export. - -### Source-backed folder files - -`useEditableDocuments()` tracks one record per source-backed document: - -- session-open text and hash -- disk baseline -- current text -- dirty/saving/saved/conflict/error/missing status -- saved change context for feedback -- conflict snapshots when disk changed - -The source document reconciliation loop watches directories containing open source docs through `/api/reference/files/stream`. On SSE events, it refetches snapshots through `/api/doc` and reconciles: - -- clean file changed on disk: update UI to disk -- dirty file changed on disk: mark conflict -- file disappeared: mark missing -- stale async snapshot: ignore by sequence/hash guard - -The file browser uses `useFileBrowser()` plus `FileBrowser.tsx`. It displays: - -- markdown/text/html file tree -- workspace status from git metadata -- annotation counts by file -- edit status markers from `editableDocuments` - -Folder mode selection opens files through linked-doc machinery, but source-backed folder files can become editable and saveable. - -### Linked docs - -`useLinkedDoc()` is central to the document experience. - -It handles same-surface navigation to another markdown or HTML document: - -- snapshot current root or linked document -- cache annotations and attachments per filepath -- clear and restore highlights -- switch `markdown`, `renderAs`, `rawHtml`, and `shareHtml` -- restore cached doc state on back -- keep annotation counts for file browser/sidebar - -Linked docs can be raw HTML or markdown. This means `renderAs` is not only a session-level mode; it is active-document scoped. - -### Drafts - -`useAnnotationDraft()` persists: - -- full `Annotation[]` -- code annotations -- global attachments -- direct edited markdown -- dirty source-backed edited documents -- already-saved source-backed file changes -- draft generation number - -The hook is intentionally best-effort and uses debounced `/api/draft` writes with `keepalive` flushes on page hide. Draft generation prevents a late save from resurrecting a draft after submit. - -Draft restore in `App.tsx` is complex because it has to validate saved file changes against disk, restore dirty source documents, maybe reopen a single restored file, remap annotations, and repaint highlights. - -### Feedback and approval - -Plan mode: - -- Approve posts `/api/approve`. -- Deny posts `/api/deny`. -- Feedback may include annotations, editor annotations, linked-doc annotations, code-file annotations, direct edits, saved file changes, and note-app settings. - -Annotate mode: - -- Feedback normally posts `/api/feedback`. -- Gate approval posts `/api/approve`. -- Close posts `/api/exit`. -- If the agent terminal is ready, feedback is sent directly to the terminal instead of `/api/feedback`. - -`getCurrentFeedbackPayload()` is the important document feedback seam in `App.tsx`. It composes exported annotations plus direct-edit and saved-file-change sections, then wraps them for the target agent/file/message context. - -### Agent terminal - -Agent terminal is annotate-only for `annotate` and `annotate-folder`, not `annotate-last`. - -Server side: - -- Bun uses `createBunAgentTerminalBridge()`. -- Pi mirrors it with `createNodeAgentTerminalBridge()`. -- The server advertises `agentTerminal` capability in `/api/plan`. -- A tokenized WebSocket path is generated under `/api/agent-terminal/pty/`. -- Remote sessions disable terminal by default unless `PLANNOTATOR_AGENT_TERMINAL_REMOTE=1`. -- The Bun bridge starts a Node sidecar for WebTUI and proxies browser WebSocket traffic to the sidecar. - -UI side: - -- `AnnotateAgentTerminalPanel` uses `@plannotator/webtui/browser` and `@plannotator/webtui/react`. -- It stores the preferred agent id and terminal display settings locally. -- It exposes `sendMessage()` and `stop()` through an imperative ref. -- `App.tsx` tracks whether the terminal is running, open, ready, and whether the current feedback payload was already delivered. - -When terminal delivery succeeds, the browser does not close the annotate session. It marks the feedback as delivered and keeps the terminal workflow live. - -### AI - -Ask AI is a server-backed capability exposed by `/api/ai/capabilities` and used through `useAIChat()`. - -When the annotate agent terminal is ready, `App.tsx` hides normal AI chat streaming and routes Ask AI prompts to the terminal instead. - -This is another package boundary concern: AI can be a document-surface capability, but the provider registry and terminal fallback are host/session concerns. - -## Package Boundary Today - -`@plannotator/ui` already contains a lot of reusable document primitives: - -- parser and feedback export utilities -- `Viewer` -- `HtmlViewer` -- `MarkdownEditor` -- annotation toolbar/panel pieces -- file browser UI and hook -- linked-doc hook -- draft hook -- sidebar shell -- AI chat hook and UI -- external annotation hooks -- plan diff and archive pieces - -`@plannotator/editor` contains the app shell and several document-domain state modules: - -- `App.tsx` -- source edit state and reconciliation -- direct-edit feedback sections -- source document client -- source document path helpers -- agent terminal panel and integration -- app header -- shortcuts surface - -This split is historical, not architectural. `@plannotator/ui` is broad and route-aware. `@plannotator/editor` is a product shell that imports almost every document primitive and wires them into server APIs. - -Line counts that indicate the current extraction pressure: - -- `packages/editor/App.tsx`: 4,685 -- `packages/ui/components/Viewer.tsx`: 970 -- `packages/editor/components/AnnotateAgentTerminalPanel.tsx`: 746 -- `packages/ui/components/AnnotationPanel.tsx`: 731 -- `packages/editor/editableDocuments.ts`: 666 -- `packages/ui/hooks/useLinkedDoc.ts`: 494 -- `packages/ui/hooks/useFileBrowser.ts`: 358 -- `packages/server/annotate.ts`: 661 -- `apps/pi-extension/server/serverAnnotate.ts`: 561 - -## Extraction Risks - -### 1. Direct `/api/*` fetches inside reusable UI - -Many `@plannotator/ui` hooks and components call API routes directly: - -- `useAnnotationDraft`: `/api/draft` -- `useFileBrowser`: `/api/reference/files`, `/api/reference/files/stream`, `/api/reference/obsidian/files` -- `useLinkedDoc`: default `/api/doc` -- `usePlanDiff`: `/api/plan/version`, `/api/plan/versions` -- `useAIChat`: `/api/ai/*` -- `useExternalAnnotations`: `/api/external-annotations*` -- `useEditorAnnotations`: `/api/editor-annotations`, `/api/editor-annotation` -- `useValidatedCodePaths`: `/api/doc/exists` -- `InlineMarkdown`: `/api/doc` -- `OpenInAppButton`: `/api/open-in/apps`, `/api/open-in` -- `ExportModal`: `/api/save-notes` - -That is acceptable for an app-local UI package, but not for a reusable document package unless the package declares those routes as its required host API. - -### 2. `App.tsx` mixes mode policy with document mechanics - -Examples: - -- Plan approve/deny and annotate feedback live beside editor remapping. -- Archive/goal setup branches live beside folder annotation. -- AI provider defaults live beside source-file conflict handling. -- Agent terminal delivery status affects whether feedback buttons are enabled. -- Sidebar auto-open rules depend on archive, goal, folder, HTML, and TOC state. - -Moving this all at once would preserve complexity under a new package name. - -### 3. Parser, highlight anchors, and feedback export are one contract - -The markdown renderer cannot be extracted independently from: - -- `Block` ids -- `Block.startLine` -- annotation `blockId` -- text-search restoration -- direct-edit remapping -- feedback export - -These should move or remain together. - -### 4. Source-save behavior is part of the document product - -Source save is not a small add-on. It includes optimistic concurrency, file watch reconciliation, conflict UX, missing-file UX, draft restore, saved file change feedback, and file browser edit badges. - -If source save stays outside an extracted package, the package still needs extension points for all those statuses and actions. - -### 5. Raw HTML is a parallel rendering and annotation stack - -The raw HTML path uses iframe bridge annotations, rewritten asset URLs, and share HTML preparation. It is not just another markdown block type. - -### 6. Dual server parity remains required - -Endpoint changes must be made in both: - -- `packages/server/*` -- `apps/pi-extension/server/*` - -A frontend extraction can reduce UI duplication, but it does not remove this server parity requirement. - -## Candidate Package Boundary - -The practical package is not "all of `App.tsx`." It is a document review surface. - -Working name: - -```text -@plannotator/document-ui -``` - -Primary exported component: - -```text -DocumentReviewSurface -``` - -It should own: - -- markdown/raw-HTML render switch -- annotation lifecycle -- annotation panel integration -- linked document navigation -- file browser document picking -- markdown edit mode -- source-save document state -- draft persistence integration -- feedback payload assembly - -It should not own directly: - -- Claude/OpenCode/Pi command interception -- server startup -- browser opening -- note-app integration persistence policy -- plan version history -- archive browsing -- goal setup -- agent-specific transcript lookup -- exact terminal sidecar implementation - -Those should remain host/app concerns passed as data, capabilities, callbacks, or optional slots. - -## Suggested Host API Adapter - -Instead of hard-coded route fetches in the surface, define a `DocumentHostApi` adapter. - -Shape at a high level: - -```ts -interface DocumentHostApi { - loadSession(): Promise; - loadDocument(request: LoadDocumentRequest): Promise; - validateCodePaths(request: ValidateCodePathsRequest): Promise; - listFiles(request: ListFilesRequest): Promise; - watchFiles?(request: WatchFilesRequest): EventSourceLike; - saveSource?(request: SourceSaveRequest): Promise; - loadDraft(): Promise; - saveDraft(draft: DraftPayload): Promise; - deleteDraft(generation: number): Promise; - submitFeedback(payload: SubmitFeedbackPayload): Promise; - approve?(): Promise; - exit?(): Promise; - uploadImage?(file: File): Promise; - loadShareHtml?(path?: string): Promise; -} -``` - -The current Bun/Pi HTTP routes can be one implementation of that adapter: - -```text -createPlannotatorHttpDocumentApi() -``` - -This avoids baking `/api/plan` into every reusable hook. It also makes local storybook/unit tests easier because the surface can run against an in-memory adapter. - -## Suggested Extraction Sequence - -### Step 1. Define contracts without moving UI - -Create shared document session types around the current `/api/plan` annotate shape and `/api/doc` loaded-document shape. - -Do not rename server routes yet. Keep route compatibility. - -Useful contracts: - -- `DocumentSession` -- `DocumentMode` -- `LoadedDocument` -- `DocumentSourceInfo` -- `DocumentFeedbackPayload` -- `DocumentHostApi` -- `DocumentCapabilities` - -This gives the code a vocabulary before package movement. - -### Step 2. Extract API client wrappers - -Move route fetches behind a client object used by `App.tsx`. - -Good first candidates: - -- `/api/doc` -- `/api/doc/exists` -- `/api/reference/files` -- `/api/reference/files/stream` -- `/api/source/save` -- `/api/draft` -- `/api/share-html` - -The goal is not abstraction for its own sake. The goal is to make the eventual package boundary explicit and testable. - -### Step 3. Move source document state out of `@plannotator/editor` - -The source-edit modules are already cohesive: - -- `editableDocuments.ts` -- `sourceDocumentClient.ts` -- `sourceDocumentReconciliation.ts` -- `savedFileChangeValidation.ts` -- `sourceDocumentPaths.ts` -- `directEdits.ts` -- `draftRestoreSelection.ts` - -These are document-domain modules. They are stronger candidates for `@plannotator/document-ui` than plan-specific code. - -### Step 4. Extract document surface around existing components - -Create a component that accepts: - -- initial document session -- host API adapter -- current origin/config info -- optional capability slots: AI, terminal, notes/export, sharing, archive, plan diff -- callbacks for submit/approve/exit - -At this step, `packages/editor/App.tsx` becomes a host shell that still handles plan-specific behavior, but delegates document mechanics. - -### Step 5. Split plan-only features from annotate-first features - -Plan-only or mostly plan-only: - -- `/api/approve` and `/api/deny` behavior for `ExitPlanMode` -- plan save settings -- plan version history -- plan diff browser -- archive sidebar -- permission mode setup - -Annotate/document-first: - -- render markdown/raw HTML -- annotations and feedback -- linked docs -- folder browser -- source save -- direct edits -- drafts -- external annotations -- image attachments - -The extracted package should make plan-only features optional instead of requiring them in every document session. - -## Recommended Initial Decision - -Extract a document-review package, not a plan-review package. - -The package should treat plan review as one host mode that supplies a document and approve/deny callbacks. Annotate should be the reference use case for the package because it exercises the full document surface: arbitrary files, folders, raw HTML, linked docs, source save, drafts, and optional terminal delivery. - -Do not make `@plannotator/ui` the final boundary by default. It is currently a mixed component library plus route-aware app helpers. Either: - -- create `@plannotator/document-ui` and move document-domain pieces there, or -- carve a `/document` export surface inside `@plannotator/ui` with a documented host API contract. - -The separate package is cleaner if the goal is reuse across applications. - -## Verification Map - -Existing tests that cover likely extraction-sensitive behavior: - -- `packages/ui/utils/parser.test.ts` -- `packages/ui/components/InlineMarkdown.test.ts` -- `packages/ui/markdownEditorFidelity.test.tsx` -- `packages/ui/annotationDraftPersistence.test.tsx` -- `packages/ui/hooks/useFileBrowser.test.tsx` -- `packages/ui/components/sidebar/FileBrowser.test.ts` -- `packages/editor/directEdits.test.ts` -- `packages/editor/editableDocuments.test.ts` -- `packages/editor/editableDocumentsHook.test.tsx` -- `packages/editor/sourceDocumentClient.test.ts` -- `packages/editor/sourceDocumentReconciliation.test.ts` -- `packages/editor/savedFileChangeValidation.test.ts` -- `packages/editor/agentTerminalIntegration.test.ts` -- `packages/server/annotate.test.ts` -- `packages/server/annotate-doc-url.test.ts` -- `packages/server/annotate-html-assets.test.ts` -- `packages/server/reference-handlers.test.ts` -- `packages/server/reference-watch.test.ts` -- `packages/server/agent-terminal.test.ts` -- `apps/pi-extension/server.test.ts` -- `apps/pi-extension/server/agent-terminal.test.ts` -- `apps/pi-extension/server/file-browser-watch.test.ts` - -Tests to add before or during extraction: - -- a contract test for the Bun and Pi annotate `/api/plan` shapes -- a contract test for `/api/doc` loaded markdown, raw HTML, converted HTML, code-file preview, and source-save metadata -- a component test for the document surface with an in-memory host API adapter -- an integration test that edits a folder file, saves it, restores draft state, and sends feedback with saved file change context -- an integration test that switches linked docs between markdown and raw HTML and preserves per-document annotations - -## Open Questions - -1. Should the extracted package include the right annotation panel, or only the document renderer plus hooks? - -Recommendation: include it. The panel is part of the annotation product, and feedback export depends on the same state. - -2. Should the agent terminal live in the document package? - -Recommendation: keep terminal transport and sidecar out. Include only an optional "agent delivery" capability or slot. The current panel can move later if the package is intended to ship the full annotate workspace. - -3. Should plan diff and archive move with the package? - -Recommendation: no for the first extraction. They depend on plan history endpoints and plan-specific mental models. Leave them as host-provided optional sidebar tabs. - -4. Should route names change away from `/api/plan` for annotate? - -Recommendation: not during extraction. The route name is historical but functional. Put a typed client over it first, then rename only if there is a separate compatibility plan for Bun and Pi. - -5. Should raw HTML and markdown be separate surfaces? - -Recommendation: keep one document surface with two render engines. Users experience them as one annotate workflow, and linked docs can switch between them. - -## Bottom Line - -The current architecture works because the annotate server impersonates the plan server enough for `packages/editor/App.tsx` to boot the same React app in annotate mode. - -The extraction should preserve the successful part of that design: one document review experience across plan, file, folder, HTML, URL, and last-message workflows. - -The extraction should remove the fragile part: document behavior is currently spread across a massive app shell and route-aware shared UI hooks. The next architectural boundary should be a document surface plus a typed host API adapter. diff --git a/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md b/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md deleted file mode 100644 index 6db4c302d..000000000 --- a/adr/research/SPIKE-document-ui-extras-system-20260623-100827.md +++ /dev/null @@ -1,56 +0,0 @@ -# Spike: Phase 6 Extras — Versions/Diff, Settings, Sharing/Export, Ask AI - -Date: 2026-06-23 - -> Code research for Phase 6 of the `@plannotator/ui` reuse effort (ADR 004; roadmap `adr/implementation/document-ui-extraction-roadmap-20260622.md`). Five parallel probes mapped the four extra subsystems. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. - -## Headline - -Phase 6 is **mostly already portable** — the same pattern as the sidebar/comment-UI noops. The actual work is a small set of seams plus **one CSS wrinkle** (block-level diff styles live in the app shell, not the package). The fragile pieces are narrow: the AI streaming reader loop (do-not-touch) and the configStore write-back batching (keep verbatim). - -## Subsystem 1 — Versions / plan diff - -**Files:** `packages/ui/hooks/usePlanDiff.ts`, `utils/planDiffEngine.ts`, `components/plan-diff/*`. CSS: `packages/editor/index.css` + `packages/ui/theme.css`. - -- **Seam A — version fetchers:** `usePlanDiff` hard-codes `fetch('/api/plan/version?v=N')` (L98) and `fetch('/api/plan/versions')` (L119). Inject optional fetchers, default = today's literals. **Keep error asymmetry verbatim:** `selectBaseVersion` `alert()`s on failure (L100, L107); `fetchVersions` is silent (L127-131). -- **Seam B — VS Code diff:** `PlanDiffViewer.tsx` POSTs `/api/plan/vscode-diff` (L65). Optional `onOpenVscodeDiff?` prop; default = today's fetch (or omit the button when not provided). -- **Already portable:** `planDiffEngine.ts` (pure `computePlanDiff`/`computeInlineDiff`), `PlanDiffBadge`, `PlanCleanDiffView`, `PlanRawDiffView`, `PlanDiffModeSwitcher` — all prop-driven. -- **THE CSS WRINKLE (confirmed):** the *word-level* classes `.plan-diff-word-*` live in the package (`theme.css` L451-480), but the *block-level* and *raw* diff classes — `.plan-diff-added/removed/modified/unchanged` (`editor/index.css` L168-211) and `.plan-diff-line-added/removed` (L213-230) — live in the **app shell**, not the package. Also `.annotation-highlight*` (L119-157) lives in the app shell (used by the regular Viewer too, so this is broader than diff). Without these, a host's diff renders unstyled (no borders/backgrounds → unreadable). Fix: move the `.plan-diff-*` block/raw classes into `packages/ui/theme.css` (co-located with `.plan-diff-word-*`); Plannotator imports `theme.css` so it stays identical. `.annotation-highlight` is a broader CSS-contract item (Viewer needs it in any host). - -## Subsystem 2 — Settings / config - -**Files:** `packages/ui/config/configStore.ts`, `config/settings.ts`, `components/Settings.tsx`, `components/settings/HooksTab.tsx`. - -- **Seam A — config write-back:** `configStore.scheduleServerSync` POSTs `/api/config` (L118) after a 300ms debounce with `deepMerge` batching. Inject **only the final fetch** via `setServerSync(fn)`; **keep singleton construction, eager cookie reads (constructor L44-59), the 300ms debounce, and `deepMerge` byte-identical** — a naive per-`set()` fetch breaks multi-setting batching. -- **Seam B — obsidian vault detect:** `Settings.tsx` `fetch('/api/obsidian/vaults')` (L745-760). Optional `onDetectObsidianVaults?`; **keep the `useEffect [obsidian.enabled]` dep and the auto-select-first-vault branch verbatim** (changing the dep re-triggers or kills auto-select). -- **Already host-controllable:** cookie storage is swappable (Phase 2 `setStorageBackend`, literal `plannotator-*` keys); identity is swappable (Phase 5 `setIdentityProvider`); `settings.ts` is pure (no fetch); server identity seeded via `configStore.init(serverConfig)`. -- **PLANNOTATOR-ONLY (out of scope):** `HooksTab.tsx` (`/api/hooks/status`, `/api/config` pfmReminder) — mounted only `mode==='plan'`, never exported to hosts. - -## Subsystem 3 — Sharing / export / notes - -**Files:** `components/ExportModal.tsx`, `utils/sharing.ts`, `hooks/useSharing.ts`, `components/ImportModal.tsx`, `components/OpenInAppButton.tsx`, `utils/{obsidian,bear,octarine,callback,defaultNotesApp}.ts`. - -- **Seam — save to notes:** `ExportModal.tsx` `fetch('/api/save-notes')` (L150). Optional `onSaveToNotes?` returning `{success, error}`; **keep `showNotesTab = isApiMode && !!markdown` (L83) byte-for-byte** — do not re-base the gate on the new prop. -- **Already portable:** `sharing.ts` is fully parameterized (`shareBaseUrl`/`pasteApiUrl` params, defaults to Plannotator URLs); `useSharing` is prop-driven; `ImportModal` is callback-driven (`onImport`); `obsidian/bear/octarine/callback/defaultNotesApp` are pure storage/format helpers. The `/p/` short-URL routing in `useSharing` is Plannotator's convention but is `pasteApiUrl`-injectable. -- **PLANNOTATOR-ONLY (out of scope):** `OpenInAppButton.tsx` (`/api/open-in`, `/api/open-in/apps`, local-CLI file opening) — host-only, stub/omit for other hosts. - -## Subsystem 4 — Ask AI (riskiest) - -**Files:** `packages/ui/hooks/useAIChat.ts`, `components/ai/*`, `utils/aiProvider.ts`, `utils/aiChatFormat.ts`. - -- **Seam — AI transport:** five `/api/ai/*` fetches in `useAIChat`: `/api/ai/session` (L134), `/api/ai/query` (L213), `/api/ai/abort` (L153 supersede + L350 standalone), `/api/ai/permission` (L365). Inject an `AITransport` (session/query/abort/permission), default = today's fetches. -- **DO NOT TOUCH (verbatim, stays in hook):** the **SSE reader loop** (L233-304 — buffers partial lines, dispatches `text_delta|text|permission_request|error|result`, mutates React state per message); the **epoch/createRequest guards** (refs L109-110; checks L152, L208; resets L376-390); the **supersede-abort fetch position** (L153-158 — must stay inside `createSession` immediately after the epoch check, or the orphaned session leaks). Only the *transport* (the fetch calls) is parametrized; the streaming consumption is not. -- **HOST-OWNED (stays in App.tsx, not the lib):** `/api/ai/capabilities` (only called by App.tsx — editor L2261, review L499); `resolveAIProviderSelection` + cookie `aiConfig` init (`aiProvider.ts`, read by App.tsx). The hook never reads cookies or calls capabilities. -- **Already portable:** `DocumentAIChatPanel`, `AIProviderBar` (fully prop-driven); `aiProvider.ts`, `aiChatFormat.ts` (pure). -- **Existing reuse:** `review-editor` already reuses `useAIChat` via a thin patch-wrapper (`review-editor/hooks/useAIChat.ts` → `context: {mode:'code-review', review:{patch}}`). - -## Explicitly OUT of Phase 6 scope (Plannotator-only / different feature) -- `OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github.com/backnotprop release check — no seam), `useAgents`/`useAgentJobs` (code-review agent jobs — a review-editor feature, not document-UI). These stay host-owned; a reusing host simply doesn't mount them. - -## Per-seam summary -| Subsystem | Seam | Wire | Verbatim-keep | -|---|---|---|---| -| Versions | usePlanDiff fetchers + PlanDiffViewer vscode | `/api/plan/version(s)`, `/api/plan/vscode-diff` | alert/silent error asymmetry; + move block/raw diff CSS into the package | -| Settings | configStore `setServerSync`; Settings obsidian-detect | `/api/config`, `/api/obsidian/vaults` | 300ms debounce + deepMerge + constructor; `[obsidian.enabled]` dep + auto-select | -| Sharing | ExportModal `onSaveToNotes` | `/api/save-notes` | `showNotesTab` gate (L83) | -| Ask AI | useAIChat `AITransport` | 5× `/api/ai/*` | SSE reader loop, epoch guards, supersede-abort position; capabilities/provider-resolution stay host | diff --git a/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md b/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md deleted file mode 100644 index c81de1aef..000000000 --- a/adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md +++ /dev/null @@ -1,121 +0,0 @@ -# Spike: Document UI Reuse Inventory (for Workspaces) - -Date: 2026-06-22 - -> ⚠️ **SUPERSEDED — first draft, materially incomplete.** A 36-agent verification found this audited only one coupling axis (literal `/api/` strings) and missed five others — most importantly: **Viewer is not actually "clean"** (fires `/api/doc/exists` on mount), an **uncounted cookie-persistence layer** (`storage.ts`, ~24 modules), **3 React contexts + a global identity singleton**, **SSE/EventSource** transports, and harder-than-stated **packaging blockers**. Use the verified version instead: **`adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`**. Kept here as the first-pass record. - -> Companion to **ADR 004** (`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`). This is the concrete lay-of-the-land for sharing Plannotator's document UI with the commercial Workspaces app: what exists, what is reusable as-is, what is wired to Plannotator's backend, and a first-cut order of work. Read 004 first for the *why* and the safety rules. - -## The shape in one paragraph - -The document UI is two buckets. **Bucket 1 — `packages/ui`** (~39,400 lines, 108 components + 31 hooks + ~45 utils) is the reusable library: rendering, file browser, sidebar, editor, theme. **Bucket 2 — `packages/editor/App.tsx`** (4,685 lines, ~276 stateful hooks, 13 server fetches) is the Plannotator-specific *glue* that fetches Plannotator data and assembles it into the app. **Bucket 1 is what we share. Bucket 2 stays Plannotator's; Workspaces writes its own equivalent glue.** Of the 108 components, only **26 files** call Plannotator's server directly — those are the wires to cut. The other ~82 already take their data from the outside and are reusable as-is. - -## Bucket 1: `packages/ui` component library - -| Folder | Count | What it is | Workspaces relevance | -| --- | --- | --- | --- | -| `components/` (top level) | 65 | Viewer, MarkdownEditor, AnnotationPanel, modals, toolbars, etc. | Core | -| `components/blocks/` | 8 | Markdown block renderers (code, table, callout, alert, HTML…) | Core (doc rendering) | -| `components/sidebar/` | 7 | SidebarContainer, SidebarTabs, FileBrowser, VersionBrowser, ArchiveBrowser, MessagesBrowser, CountBadge | Core (sidebar + file tree) | -| `components/html-viewer/` | 1 | Raw HTML viewer | Core | -| `components/plan-diff/` | 6 | Plan version diff viewer | Maybe (Workspaces has version history) | -| `components/ImageAnnotator/` | 3 | Annotate images | Maybe | -| `components/ai/` | 2 | Ask-AI chat panel | Maybe (own AI) | -| `components/ui/` | 8 | Low-level primitives (buttons, dialogs…) | Core | -| `components/core/` | 2 | Shared core | Core | -| `components/icons/` | 4 | SVG icons | Core | -| `components/settings/` | 1 | Settings tab(s) | Partial | -| `components/goal-setup/` | 1 | Plannotator goal workflow | Plannotator-only | - -Plus `packages/ui/theme.css` (the theme/color tokens — pure, fully reusable), 31 hooks, ~45 utils, and `shortcuts/` (keyboard registry). - -## The 26 backend-coupled files (the wires to cut) - -Grouped by purpose. "WS" = does Workspaces need it. - -### Document rendering — WS: YES (do first) -| File | Calls | Note | -| --- | --- | --- | -| `components/blocks/HtmlBlock.tsx` | `/api/image` | image src in markdown | -| `components/ImageThumbnail.tsx` | `/api/image` | image thumbnails | -| `components/InlineMarkdown.tsx` | `/api/doc` | inline linked-doc loads | -| `hooks/useLinkedDoc.ts` | `/api/doc` | navigate doc → doc | -| `hooks/useValidatedCodePaths.ts` | `/api/doc/exists` | validate code-file links | -| `components/AttachmentsButton.tsx` | `/api/upload` | attach images to comments | - -### File tree / browser — WS: YES (core to Workspaces) -| File | Calls | Note | -| --- | --- | --- | -| `hooks/useFileBrowser.ts` | `/api/reference/files`, `/api/reference/files/stream`, `/api/reference/obsidian/*` | the file tree data source | - -### Comments / annotations / drafts — WS: YES (agents + teammates commenting) -| File | Calls | Note | -| --- | --- | --- | -| `hooks/useAnnotationDraft.ts` | `/api/draft` | autosave annotation drafts | -| `hooks/useCodeAnnotationDraft.ts` | `/api/draft` | autosave code annotations | -| `hooks/useExternalAnnotations.ts` | `/api/external-annotations`, `/api/external-annotations/stream` | **agents posting comments** — directly relevant to Workspaces | - -### Versions / diff — WS: MAYBE (Workspaces has version history) -| File | Calls | Note | -| --- | --- | --- | -| `hooks/usePlanDiff.ts` | `/api/plan/version`, `/api/plan/versions` | version list + fetch | -| `components/plan-diff/PlanDiffViewer.tsx` | `/api/plan/vscode-diff` | opens VS Code (Plannotator-local; WS would drop this one button) | - -### Settings / config — WS: PARTIAL (Workspaces feeds its own config) -| File | Calls | Note | -| --- | --- | --- | -| `config/configStore.ts` | `/api/config`, `/api/diff`, `/api/plan` | app config bootstrap | -| `config/settings.ts` | `/api/config` | settings load/save | -| `components/Settings.tsx` | `/api/ai/capabilities`, `/api/config`, `/api/obsidian/vaults` | settings panel | -| `components/settings/HooksTab.tsx` | `/api/config`, `/api/hooks/status` | Plannotator hooks tab (WS drops) | - -### Sharing / export / open-in — WS: PARTIAL (Workspaces has its own sharing) -| File | Calls | Note | -| --- | --- | --- | -| `utils/sharing.ts` | `/api/paste`, `/api/paste/` | short-URL share | -| `components/ExportModal.tsx` | `/api/save-notes` | save to Obsidian/Bear/Octarine | -| `components/OpenInAppButton.tsx` | `/api/open-in`, `/api/open-in/apps` | open in local app (local-only; WS drops) | - -### Ask AI / code-review agents — WS: NO / OWN -| File | Calls | Note | -| --- | --- | --- | -| `hooks/useAIChat.ts` | `/api/ai/*` | Ask-AI streaming (WS would wire its own AI) | -| `hooks/useAgents.ts` | `/api/agents` | agent provider detection | -| `hooks/useAgentJobs.ts` | `/api/agents/*` | code-review agent jobs (review feature, not docs) | - -### Archive / editor-annotations / plan-injection — WS: NO (Plannotator-only) -| File | Calls | Note | -| --- | --- | --- | -| `hooks/useArchive.ts` | `/api/archive/*`, `/api/done`, `/api/plan` | Plannotator plan archive | -| `hooks/useEditorAnnotations.ts` | `/api/editor-annotation(s)` | VS Code editor annotations | -| `components/goal-setup/GoalSetupSurface.tsx` | `/api/goal-setup/submit` | Plannotator goal workflow | -| `utils/planAgentInstructions.ts` | `/api/external-annotations`, `/api/plan` | plan-time prompt injection | - -### Tally -- **~10 coupled files Workspaces clearly needs** (rendering + file tree + comments). -- **~6 partial** (settings/config/sharing — Workspaces supplies its own source through the same shape). -- **~10 Plannotator-only** (archive, goal-setup, hooks, VS Code, code-review agents, open-in) — Workspaces simply won't mount these; no work needed beyond not importing them. - -## Bucket 2: the glue (`packages/editor/App.tsx`) - -4,685 lines, ~276 stateful hooks, 13 fetches. This is **not shared.** It is Plannotator's assembly layer: it bootstraps from `/api/plan`, runs the approve/deny hook flow, owns sidebar/panel/wide-mode layout state, and feeds everything into the Bucket-1 components. Workspaces writes its own (smaller) equivalent that bootstraps from its Cloudflare APIs and feeds the same components. - -**Caveat that matters:** some of "the experience" (which sidebar tab is open, file-tree expansion, panel resize, wide/focus mode) currently lives *inside this glue file*, not inside the reusable components. Part of the work is pushing that behavior **down into the components** (e.g. `SidebarContainer` owns its own open/close) so Workspaces' glue stays thin and doesn't have to re-derive layout logic. (Re-deriving that logic generically is exactly what the reverted attempt did wrong — push it into the real components instead.) - -## Packaging state - -`packages/ui/package.json` already declares `@plannotator/ui` with a fine-grained `exports` map (components, hooks, utils, config, types, theme). But it is `version: 0.0.1`, `type: module`, source-only (no build step, no publish). To be installable by an outside repo it needs: a real version, a build (or confirmed source-export consumption), peer-deps sorted (React, CodeMirror, Radix, etc.), and a publish target. Moderate, not hard. - -## First-cut order of work (the safe path from ADR 004) - -Each step: lift the server call out to a prop/callback, leave the component's logic intact, confirm Plannotator still looks identical, then move on. One item at a time. - -1. **Rendering core** — `/api/image`, `/api/doc`, `/api/doc/exists`, `/api/upload` (HtmlBlock, ImageThumbnail, InlineMarkdown, useLinkedDoc, useValidatedCodePaths, AttachmentsButton). Makes a doc render anywhere. -2. **File tree** — `useFileBrowser`. Makes the tree take a data source. -3. **Comments/drafts** — `useAnnotationDraft`, `useCodeAnnotationDraft`, `useExternalAnnotations`. Makes comments (incl. agent-posted) provider-driven. -4. **Versions** — `usePlanDiff` (keep the VS Code button as an optional prop Workspaces omits). -5. **Config/settings shape** — let `configStore`/`settings` take their source from the host instead of `/api/config`. -6. **Packaging** — turn `@plannotator/ui` into a real publishable package. -7. **Push layout state into components** — sidebar/panel/wide-mode behavior currently in `App.tsx` moves into the sidebar/layout components so Workspaces' glue stays thin. - -Steps 1–5 are independent and can be done in any order / in parallel by different people. Step 6 can start anytime. Step 7 is the largest and is best done last, informed by what Workspaces actually needs. diff --git a/adr/research/SPIKE-publish-core-package-20260623-125551.md b/adr/research/SPIKE-publish-core-package-20260623-125551.md deleted file mode 100644 index bae1b3d33..000000000 --- a/adr/research/SPIKE-publish-core-package-20260623-125551.md +++ /dev/null @@ -1,54 +0,0 @@ -# Spike: Carve `@plannotator/core` and Publish (Phase 7) - -Date: 2026-06-23 - -> Code research for Phase 7 of the `@plannotator/ui` reuse effort (ADR 004). Three probes mapped: (1) exact `@plannotator/core` membership, (2) the import blast radius + zero-churn mechanism, (3) publish toolchain. Decision context: **no copying / no duplication** — carve a single browser-safe package everyone shares. THE LAW: Plannotator stays unchanged. - -## The shape - -`@plannotator/ui` can't be published while it depends on the private `@plannotator/shared` and `@plannotator/ai`. The chosen fix: **move the browser-safe slice into a new published `@plannotator/core`; `@plannotator/shared` re-exports from it (so Plannotator's 99 import sites don't change); publish `core` + `ui`.** One copy of everything. - -## 1. What goes in `@plannotator/core` - -The UI references 21 distinct `@plannotator/shared` subpaths (value + type-only). They split three ways: - -**A. Pure modules — move wholesale into core (no node, no npm deps, no cross-deps):** -`code-file`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `extract-code-paths` (→ imports `./code-file`, also moves), `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`. (~15 files.) All verified browser-safe (the apps already bundle them for the browser today). - -**B. Node-bound modules the UI imports TYPES from — extract the types into core (the elegant no-duplication move):** -`config.ts` (node:fs/os/child_process), `storage.ts` (node:fs), `workspace-status.ts` (node:child_process). The UI imports only types from these (`DiffLineBgIntensity`, `DefaultDiffType`, `ArchivedPlan`, `WorkspaceFileChange`, `WorkspaceStatusPayload`). **Do NOT move these files** (they'd drag node into a browser package). Instead: **extract their type definitions into `core` (e.g. `core/config-types.ts`), and have `shared/config.ts` import those types back from core** + keep its node implementation. Result: the types live **once** (in core), the node code stays in shared, no duplication. - -**C. `AIContext` from `@plannotator/ai`** — a pure type union (verified node-free). Re-export it from `core` (e.g. `core/ai-context.ts`) so `ui` imports `@plannotator/core` instead of `@plannotator/ai`. - -**Nuance to verify at implementation:** `shared/types.ts` re-exports from `review-core.ts` / `review-workspace.ts`, which have value-level `node:path` imports. Confirm whether the UI's `@plannotator/shared/types` actually surfaces any of those review types to ui; if so, extract just those types (same technique as B). If not, `core/types.ts` re-exports only the browser-safe set. - -**Proposed core size:** ~15 pure files moved + ~3-4 extracted type files + an `index.ts` barrel + `ai-context.ts`. All source-only, browser-safe, **zero npm/node dependencies.** - -## 2. Blast radius + zero-churn mechanism - -- **99 import sites** across the repo reference these modules: packages/ui (36), packages/server (34), editor (11), review-editor (11), apps/hook (4), opencode (3). Heaviest: `agents` (17), `config` (10), `source-save` (9), `agent-terminal` (8), `types` (8). -- **Re-export shim = zero churn (the key finding):** for each moved module, leave a one-line shim in `shared` — `export * from '@plannotator/core/code-file'`. All 99 sites keep working unchanged; `shared`'s `exports` map stays as-is; works for both `import {}` and `import type`. **Plannotator's server/editor/review-editor/apps need no edits.** (For the type-extracted node modules, `shared/config.ts` etc. import their types from core and re-export — same effect.) -- **Pi-extension `vendor.sh`** copies ~47 shared files at build; with shims it vendors the shim files unchanged → **no vendor.sh change needed.** -- **No tsconfig/build globs** reference `packages/shared/*` — all imports are explicit subpaths. Minimal tooling impact. -- **`wideMode.ts` move** (Phase-7 leftover): `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts`; only 2 import sites (App.tsx + its test); ui doesn't import editor, no cycle. Ultra-low risk. - -## 3. Publish toolchain - -- **Monorepo:** Bun, `workspaces: ["apps/*","packages/*"]`, public npm. Release is tag-triggered CI (`.github/workflows/release.yml`); today it publishes only `@plannotator/opencode` + `@plannotator/pi-extension` via `bun pm pack` + `npm publish --provenance --access public`. **No ui/core/shared publish job exists yet.** -- **workspace:* resolution:** bun replaces `workspace:*` with the real version at pack time. Blocker today: `@plannotator/ai` + `@plannotator/shared` are `private:true` v0.0.1. After the carve, `ui` depends on `@plannotator/core` (published) — `shared`/`ai` stay private (ui no longer needs them directly once `core` covers its imports, **except** any remaining `import type` from shared that we must route through core). -- **Source-only model:** `ui` (and `core`) ship raw `.ts/.tsx` — no build/dist. An external TS consumer (Workspaces) must set: `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: "react-jsx"`, React 19 + Tailwind v4 (`@tailwindcss/vite`), and a Tailwind `@source` glob over `node_modules/@plannotator/ui/**/*.tsx`, plus import `@plannotator/ui/theme`. This works (no build needed) but must be documented for the consumer. -- **`ui` packaging after Phase 1:** peerDeps, dompurify, files allowlist all done. Remaining: the workspace-dep blocker (solved by `core`), a real version, and a CI publish job. - -## Open decisions (for the ADR) -1. **Registry:** public npm (matches existing opencode/pi-extension, simplest) vs private/scoped (if `ui`/`core` should be Workspaces-only). -2. **Versions:** keep 0.0.1 vs assign real (e.g. 0.1.0). `core` + `ui` likely version together. -3. **CI:** add a publish job for `core` + `ui` to `release.yml`, or publish manually the first time. -4. **`@plannotator/ai`:** since the UI only needs the `AIContext` type and `core` re-exports it, `ai` can **stay private/unpublished** — confirm the UI has no other `@plannotator/ai` value import. - -## Per-area summary -| Area | Finding | -|---|---| -| Core membership | ~15 pure files move; 3-4 node-bound modules contribute extracted types; AIContext re-exported. Zero node/npm in core. | -| Churn | Re-export shims in `shared` → 0 changes to Plannotator's 99 sites; vendor.sh unaffected. | -| Publish | Bun + public npm, tag-triggered CI; need a new publish job; source-only ships, consumer needs documented tsconfig/Tailwind. | -| Decisions | registry, versions, CI job, ai-stays-private. | diff --git a/adr/research/synthesis-document-ui-comments-20260623-084806.md b/adr/research/synthesis-document-ui-comments-20260623-084806.md deleted file mode 100644 index 552383a41..000000000 --- a/adr/research/synthesis-document-ui-comments-20260623-084806.md +++ /dev/null @@ -1,52 +0,0 @@ -# Synthesis: Comments / Annotations / Drafts (Phase 5) - -Date: 2026-06-23 - -> Synthesizes `SPIKE-document-ui-comments-system-20260623-084806.md` against the verified plan (`adr/specs/document-ui-extraction-plan-verified-20260622-184500.md`) and ADR 004. Settles the shape of Phase 5. - -## The reframing - -Phase 5 has been called "the big one." The research **confirms it's the most interconnected subsystem but narrows the actual work.** Three facts change the picture: - -1. **The comment UI is already portable.** Panel, popover, toolbar, highlighter hook — all prop-driven, no backend wires. Nothing to extract. -2. **A second consumer already proves it.** `review-editor` reuses `useExternalAnnotations`, `useEditorAnnotations`, and `useCodeAnnotationDraft` unchanged, with `enabled` gates and shape-generics already in place. The portability pattern exists; we extend it, we don't invent it. -3. **Annotation state is host-owned already.** Each app holds its own `useState` array; there is no shared reducer to wrestle. Workspaces owns its state too. - -So Phase 5 = **three transport/identity seams** + **two things that are NOT extraction work** (a renderer constraint to document, and a replies feature to defer). - -## What we will do: three seams (same pattern as Phases 2–4) - -Each is the proven shape: a module-level default that reproduces today's literal behavior, plus an optional `setX` override; Plannotator passes nothing and is byte-unchanged. - -### Seam 1 — Draft transport -Inject a `DraftTransport` (load/save/delete) into `useAnnotationDraft` and `useCodeAnnotationDraft`, default = today's `/api/draft` fetches **verbatim**, including the `keepalive` retry and the `visibilitychange`/`pagehide` flush. **The generation protocol is the hard part and must be preserved end-to-end:** `getDraftGeneration()` still escapes the hook and the host still threads it into submit (`withDraftGeneration`), and the seam must document that a host swapping transport also has to honor generation-gated delete-on-submit (or ghost drafts return). The refs and pre-increment timing move verbatim. - -### Seam 2 — External-annotation transport -Inject an `ExternalAnnotationTransport` (`subscribe(onEvent,onError)` + optimistic CRUD + `getSnapshot(since)`) into `useExternalAnnotations`, default = the SSE→polling state machine **moved verbatim** (EventSource primary, 500ms polling fallback, 304 gate, 30s heartbeat, fallback-once semantics). The reducer and optimistic mutators stay in the hook. The `enabled` gate is already host-suppliable. The server store/validators/SSE encoding in `shared/external-annotation.ts` are already shared; a Workspaces backend implements the same event contract over Durable Objects instead of SSE. - -### Seam 3 — Identity -Make authorship overridable: optional `author?` (or an injected `getIdentity`) at the ~9 stamp sites and an optional `isCurrentUser?` at the 2 `(me)` display sites, **defaulting to the existing `identity.ts` functions**. Storage is already swappable (Phase 2) and `configStore.init(serverConfig)` already seeds identity from the server, so much of identity is host-controllable today; this seam closes the last gap so Workspaces' real logins (WorkOS) drive authorship instead of tater names. - -## What we will NOT do in Phase 5 - -### Constraint A — Renderer coupling: document it, don't fight it -Highlight restoration re-anchors against the rendered DOM and depends on `transformPlainText` (emoji + smart punctuation) matching the renderer's output. **Workspaces must reuse `BlockRenderer` + `InlineMarkdown` + `inlineTransforms` as a unit.** This is an integration contract we write down, not a wire we cut. (Optional later: expose `transformPlainText` as overridable with today's default — but not required for Phase 5.) - -### Constraint B — Replies/threading: defer as a new feature -Comments are flat today. Threading is something **Workspaces wants but Plannotator does not have** — so building it is *adding a feature*, which is explicitly outside "make today's behavior reusable without changing Plannotator." Phase 5 ships the flat model unchanged. Replies become a later, backward-compatible enhancement (a host layer over the shared components, or an optional `replies?` extension that Plannotator never populates), planned on its own once the seams land. - -## Why this ordering and risk read -- **Identity (Seam 3) is the lowest-risk** and partly done — do it first to warm up. -- **Drafts (Seam 1) is medium** — the generation protocol is fiddly but well-understood; the guardrail is that approve/deny/feedback/exit still carry the generation and ghost drafts don't return. -- **External (Seam 2) is the riskiest** — a timing-sensitive state machine that must move verbatim (the exact trap that sank the reverted attempt). Do it last, with the SSE→polling fallback proven by an eyeball (kill the stream, confirm polling takes over). -- Everything else (panel, popover, toolbar, highlighter, exporters) is already portable — **no work, just confirm noop** like the sidebar in Phase 4. - -## Open decisions for the spec/ADR -1. **Identity injection mechanism:** optional `author?` prop threaded to stamp sites vs. an injected `getIdentity`/`isCurrentUser` pair (module-level setter, like the other seams). Lean: module-level setter (`setIdentityProvider`) for consistency and to avoid threading props through 9 sites. -2. **Replies:** confirm it's deferred (recommended) vs. scoped into Phase 5. Recommend defer. -3. **Renderer constraint:** document-only (recommended) vs. also extract `transformPlainText` as overridable now. Recommend document-only. - -## References -- Spike: `adr/research/SPIKE-document-ui-comments-system-20260623-084806.md` -- Verified plan (Phase 5 / step 5): `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md` -- Decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/research/synthesis-document-ui-extraction-20260620-082343.md b/adr/research/synthesis-document-ui-extraction-20260620-082343.md deleted file mode 100644 index 69bbd4b8d..000000000 --- a/adr/research/synthesis-document-ui-extraction-20260620-082343.md +++ /dev/null @@ -1,266 +0,0 @@ -# Synthesis: Document UI Extraction - -> ℹ️ **Context still useful; the direction it informed was reverted.** The extraction approach synthesized here (ADRs 002/003) was reverted on 2026-06-22. Read **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`** before acting. - -Date: 2026-06-20 - -Status: Synthesis - -## Research Reviewed - -- `adr/research/SPIKE-document-ui-extraction-boundary-20260620-082002.md` -- `adr/research/SPIKE-source-edit-reliability-20260618-090850.md` -- `adr/research/SPIKE-source-edit-race-and-conflict-20260618-095558.md` -- `adr/research/SPIKE-annotate-agent-terminal-production-runtime-20260618-212101.md` -- `adr/research/synthesis-annotate-agent-terminal-production-runtime-20260618-212604.md` - -## What The Research Says - -Annotate is not a separate frontend today. It is the Plan Review app booted in another mode. The annotate server serves document sessions through `/api/plan`, and `packages/editor/App.tsx` decides whether the session is plan review, annotate file, annotate folder, annotate last message, raw HTML, archive, or goal setup. - -The desired extraction is therefore not a renderer extraction. The reusable product is the document review experience: - -- markdown and raw HTML viewing -- markdown block rendering -- annotations and comments -- annotation panel -- linked document navigation -- file browser rows and status badges -- editor toggle -- source-backed save state -- draft persistence -- feedback payload assembly - -Those pieces are currently split across `@plannotator/ui` and `@plannotator/editor`. `@plannotator/ui` has many reusable primitives, but several of them call hard-coded `/api/*` routes. `@plannotator/editor` has the main app shell plus document-domain state like editable documents, source reconciliation, direct edits, and terminal integration. - -So the current split is historical, not a clean package boundary. - -## Recommended Direction - -Create one shared document package: - -```text -@plannotator/document-ui -``` - -Do not split into renderer, tree, comments, editor, and sidebar packages now. The seams are not stable enough. The first useful boundary is one document surface with a typed host API. - -The primary export should be something like: - -```text -DocumentReviewSurface -``` - -It should own the document product mechanics: - -- render markdown or raw HTML -- manage annotation state and highlight restoration -- render the comments/annotation panel -- support linked docs -- support folder file picking -- support markdown edit mode -- manage source-save document state -- integrate draft save/restore -- assemble document feedback - -The host app should keep runtime and mode policy: - -- Claude/OpenCode/Droid/Pi command interception -- server startup and browser opening -- plan-mode approve/deny hook behavior -- plan history, plan diff, archive, and goal setup -- note app policy and settings persistence -- transcript lookup for annotate-last -- terminal runtime/sidecar implementation - -## Main Architectural Move - -Introduce a host API adapter before moving the UI. - -The document surface should not directly know that the current Plannotator server uses `/api/plan`, `/api/doc`, `/api/source/save`, or `/api/draft`. It should depend on an interface such as: - -```ts -interface DocumentHostApi { - loadSession(): Promise; - loadDocument(request: LoadDocumentRequest): Promise; - validateCodePaths(request: ValidateCodePathsRequest): Promise; - listFiles(request: ListFilesRequest): Promise; - watchFiles?(request: WatchFilesRequest): EventSourceLike; - saveSource?(request: SourceSaveRequest): Promise; - loadDraft(): Promise; - saveDraft(draft: DraftPayload): Promise; - deleteDraft(generation: number): Promise; - submitFeedback(payload: SubmitFeedbackPayload): Promise; - approve?(): Promise; - exit?(): Promise; -} -``` - -The current Bun and Pi HTTP routes can be implemented as: - -```text -createPlannotatorHttpDocumentApi() -``` - -This keeps the existing routes stable while giving the UI a real boundary. - -## Package Contents - -Move or expose these as document-domain code: - -- `Viewer` -- `HtmlViewer` -- `MarkdownEditor` -- markdown parser and block types -- annotation highlighter integration -- annotation/comment panel patterns -- linked-doc hook -- file browser hook and rows -- draft hook -- editable document state -- source document client and reconciliation -- saved file change validation -- direct edits feedback builder -- code path validation and inline link handling - -Keep these outside for the first extraction: - -- plan diff -- archive browser -- goal setup -- permission mode setup -- server implementations -- agent terminal sidecar/runtime resolver -- CLI/plugin integrations - -The annotation panel should be included. It is part of the document product, not a peripheral widget. Feedback export and annotation state depend on it. - -Raw HTML should stay in the same document surface. It is a separate render engine, but users experience it as the same annotate workflow, and linked docs can switch between markdown and raw HTML. - -## Source Save Is Core - -The source-edit research matters for extraction. - -Source save is not just "write file on Save." It includes: - -- optimistic concurrency through hash and mtime metadata -- file watch reconciliation -- stale snapshot guards -- conflict recovery with current disk snapshots -- missing-file state -- draft restore -- saved file change feedback -- file browser edit badges - -If the extracted package does not own this state, it will need so many extension points that the package will not actually own the document experience. - -Recommendation: move the source document state modules into the document package early. - -## Terminal And AI Boundary - -Agent terminal is part of the annotate workspace, but the terminal runtime is not part of document UI. - -The package can support an optional "agent delivery" capability: - -- send feedback to a running agent -- report whether the current feedback has already been delivered -- route Ask AI prompts to an agent when provided -- render an optional slot or panel if the host supplies one - -The host should keep: - -- WebTUI sidecar -- tokenized WebSocket path -- remote-mode gating -- runtime install/preflight -- agent discovery - -Normal Ask AI should also be capability-driven. Provider detection and server sessions are host concerns; document UI can consume an abstract ask/send interface. - -## Bun/Pi Constraint - -Frontend extraction does not remove server parity work. - -The Bun server and Pi server both expose the document endpoints. Any route shape change still needs both implementations updated. - -For the extraction, avoid route renames. Keep `/api/plan` for compatibility and put a typed adapter over it. Rename only later if there is a deliberate migration plan. - -## Implementation Order - -1. Define document contracts. - -Create shared types for `DocumentSession`, `LoadedDocument`, `DocumentCapabilities`, `DocumentFeedbackPayload`, and `DocumentHostApi`. Model the current annotate `/api/plan` and `/api/doc` shapes without changing routes. - -2. Add an HTTP adapter. - -Move route calls behind `createPlannotatorHttpDocumentApi()`. Start with `/api/doc`, `/api/doc/exists`, `/api/reference/files`, `/api/reference/files/stream`, `/api/source/save`, `/api/draft`, and `/api/share-html`. - -3. Move source document modules. - -Move the cohesive source-edit modules out of `@plannotator/editor` and into the document package. This reduces `App.tsx` before the main surface extraction. - -4. Create `DocumentReviewSurface`. - -Wrap the existing viewer, HTML viewer, editor toggle, linked docs, file browser, annotation panel, drafts, and source-save state behind one component. - -5. Turn `packages/editor/App.tsx` into a host shell. - -The app should load session data, configure host capabilities, handle plan-specific flows, and delegate document mechanics to `DocumentReviewSurface`. - -6. Add contract and surface tests. - -Add Bun/Pi contract tests for `/api/plan` and `/api/doc`, plus a component test using an in-memory `DocumentHostApi`. - -## What Counts As A Good First Result - -The first successful extraction should make this true: - -- annotate file/folder/HTML/last still work -- plan review still works through the same document surface -- source save and drafts still work -- `App.tsx` no longer owns document mechanics directly -- document UI can run in tests without a live Plannotator server -- no endpoint rename is required -- no extra package split is introduced - -## Keep Out Of Scope - -Do not redesign the UI. - -Do not split into multiple small UI packages yet. - -Do not move the terminal runtime into the document package. - -Do not rename `/api/plan` during extraction. - -Do not fold plan diff, archive, or goal setup into the first document package boundary. - -Do not try to solve all Pi/Bun server duplication as part of the frontend package extraction. - -## Open Decisions - -1. Should the package be a new workspace package or a documented `/document` export inside `@plannotator/ui`? - -Recommendation: new package. `@plannotator/ui` is already broad and route-aware; a new package makes the boundary visible. - -2. Should source save be mandatory or optional? - -Recommendation: optional capability, but first-class inside the package. Sessions without source save should degrade cleanly. - -3. Should plan review be implemented as a document mode or a host wrapper? - -Recommendation: host wrapper. Plan review supplies a document plus approve/deny callbacks. The document package should not know about hook stdout decisions. - -4. Should the terminal panel move later? - -Recommendation: maybe, but only after the document surface exists. The runtime stays host-owned either way. - -## Recommendation - -Proceed toward an ADR that accepts one shared document UI package with a host API adapter. - -The key decision is not "move components to another folder." The key decision is to make Plannotator's document review experience the upstream surface and make plan review one consumer of that surface. - -That preserves the best part of the current architecture: one rich review experience across plans, files, folders, HTML, URLs, and last messages. - -It fixes the weak part: the document product is currently trapped inside a very large app shell and route-aware UI helpers. diff --git a/adr/research/synthesis-document-ui-extras-20260623-100827.md b/adr/research/synthesis-document-ui-extras-20260623-100827.md deleted file mode 100644 index 6e3454684..000000000 --- a/adr/research/synthesis-document-ui-extras-20260623-100827.md +++ /dev/null @@ -1,47 +0,0 @@ -# Synthesis: Phase 6 Extras - -Date: 2026-06-23 - -> Synthesizes `SPIKE-document-ui-extras-system-20260623-100827.md` against the verified plan and ADR 004. Settles Phase 6's shape. - -## The shape - -Phase 6 follows the now-proven pattern: a handful of module-level/prop seams, each defaulting to today's behavior. The research confirms most of these four subsystems is **already portable** (pure utils, prop-driven components, parameterized sharing). The real work is **five seams + one CSS move**, and a clear list of **Plannotator-only pieces that simply stay home**. - -## What we will do - -### 1. Versions / diff (do first — highest value, has the CSS wrinkle) -- Inject optional version fetchers into `usePlanDiff` (default → `/api/plan/version(s)`), keeping the alert/silent error asymmetry verbatim. -- Optional `onOpenVscodeDiff?` on `PlanDiffViewer` (default → `/api/plan/vscode-diff`). -- **CSS move:** relocate the block-level/raw diff classes (`.plan-diff-added/removed/modified/unchanged`, `.plan-diff-line-*`) from `packages/editor/index.css` into `packages/ui/theme.css`, co-located with the existing `.plan-diff-word-*`. Plannotator imports `theme.css`, so it stays byte-identical; the diff components become reusable without app-shell CSS. (Verify Plannotator's build doesn't double-define / drops the index.css copies.) - -### 2. Settings / config -- `configStore.setServerSync(fn)` injecting only the final `/api/config` POST; keep singleton, eager cookie reads, 300ms debounce, and `deepMerge` verbatim. -- Optional `onDetectObsidianVaults?` on `Settings`; keep the `[obsidian.enabled]` effect dep and auto-select-first-vault verbatim. -- (Storage + identity already swappable; `settings.ts` pure — nothing to do there.) - -### 3. Sharing / export / notes -- Optional `onSaveToNotes?` on `ExportModal`; keep `showNotesTab = isApiMode && !!markdown` verbatim. -- (Sharing utils + `useSharing` + `ImportModal` + the notes-app helpers are already portable — confirm noop.) - -### 4. Ask AI (last, riskiest) -- Inject an `AITransport` (session/query/abort/permission) into `useAIChat`, default = today's five fetches. **Leave the SSE reader loop, epoch/createRequest guards, and the supersede-abort position untouched in the hook.** Capabilities fetch and provider resolution stay host-owned (they already live in App.tsx). - -## What we will NOT do (Plannotator-only — they stay home) -`OpenInAppButton` (local CLI), `HooksTab` (plan-mode hooks), `useUpdateCheck` (hardcoded github release check), `useAgents`/`useAgentJobs` (code-review agent jobs). A reusing host doesn't import them. No work. - -## Risk read & ordering -- **Versions/diff** — low logic risk; the CSS move is the only non-trivial bit (verify Plannotator's diff still renders identically after relocating the classes). Do first. -- **Settings** — low; mind the debounce/deepMerge (don't move them) and the obsidian effect dep. -- **Sharing** — small (one seam); rest is noop. -- **Ask AI** — the riskiest by far: a streaming state machine. Treat exactly like the Phase-5 external transport — wrap only the wire, copy nothing, leave the reader loop and epoch guards verbatim. Do last, eyeball the AI panel end-to-end. - -## Open decisions for the spec/ADR -1. **CSS move vs. document-as-contract.** Recommend **move** (`.plan-diff-*` into `theme.css`) — it's a pure relocation that keeps Plannotator identical and makes the diff truly reusable. The broader `.annotation-highlight` CSS (used by the Viewer everywhere) is a related contract; recommend moving it into `theme.css` too in the same pass, since it's required by the already-shipped Viewer in any host (closes a latent gap from Phases 3/5). Confirm in spec. -2. **Scope confirmation:** agree the five Plannotator-only pieces are out (recommended). -3. **One PR or per-seam commits:** recommend per-seam verify-gated commits (versions → settings → sharing → AI), like Phase 5. - -## References -- Spike: `adr/research/SPIKE-document-ui-extras-system-20260623-100827.md` -- Verified plan (Phase 6 / steps 6-8,12): `adr/specs/document-ui-extraction-plan-verified-20260622-184500.md` -- Decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md` diff --git a/adr/research/synthesis-publish-core-package-20260623-125551.md b/adr/research/synthesis-publish-core-package-20260623-125551.md deleted file mode 100644 index 4dc8981d2..000000000 --- a/adr/research/synthesis-publish-core-package-20260623-125551.md +++ /dev/null @@ -1,44 +0,0 @@ -# Synthesis: Carve `@plannotator/core` and Publish (Phase 7) - -Date: 2026-06-23 - -> Synthesizes `SPIKE-publish-core-package-20260623-125551.md` against ADR 004 and the user decision: **no copying / single source of truth.** Settles Phase 7's shape. - -## The decision in one line - -Carve a new browser-safe **`@plannotator/core`** package, move the universal slice into it (extracting just the *types* from node-bound modules so nothing duplicates), make `@plannotator/shared` re-export from it so Plannotator is untouched, then publish `core` + `ui`. `@plannotator/shared` and `@plannotator/ai` stay private. - -## Why this shape - -- **No duplication:** every file/type has exactly one home. Pure modules live in `core`; node-bound *implementations* stay in `shared` but import their *types* from `core`. Today's clean one-copy state is preserved. -- **Plannotator unchanged:** re-export shims in `shared` mean all 99 internal import sites keep working with zero edits. Plannotator's server, editor, review-editor, apps, and the Pi vendor step are untouched. -- **Minimal published surface:** `core` is small, browser-safe, zero-dependency — not the Node/git/PR kitchen sink. Workspaces installs `ui` + `core` and nothing it doesn't run. -- **Naming is honest:** `core` = the universal foundation (runs anywhere), distinct from `ui` (components) and `shared` (the Node/server grab-bag). No `shared`/`ui` stutter. - -## The plan - -1. **Create `packages/core`** — source-only, browser-safe, zero npm/node deps. Move the ~15 pure modules in. Add extracted type files for the 3-4 node-bound modules (`config`, `storage`, `workspace-status`, and any review types `ui` surfaces). Re-export `AIContext`. Add an `index.ts` barrel and a fine-grained `exports` map (mirroring `ui`'s source-only pattern). -2. **Re-point `@plannotator/shared`** — each moved pure module becomes a one-line shim (`export * from '@plannotator/core/X'`); each node-bound module imports its types from `core` and keeps its node implementation. `shared`'s `exports` map and `private:true` stay. Plannotator's imports don't change. -3. **Re-point `@plannotator/ui`** — change `ui`'s `@plannotator/shared/X` and `@plannotator/ai` imports to `@plannotator/core/X`; replace the `workspace:* @plannotator/shared`/`@plannotator/ai` deps with `@plannotator/core` (the only published dep ui needs). Confirm no remaining `@plannotator/shared`/`@plannotator/ai` reference in ui. -4. **Move `wideMode.ts`** `editor → ui/utils` (2 import edits). -5. **Single config front door** — add `configurePlannotatorUI(config)`: one typed call that fans out to the 9 global host-override setters (image, storage, doc-preview, file-tree, identity, draft, external-annotations, AI, config-sync). Fixes the "scattered switches" ergonomics wart for ~40 lines, zero risk. The render-time prop seams stay as props; a `` is the optional later upgrade. -6. **(Optional) precompiled CSS** — ship `@plannotator/ui/styles.css` so a consumer imports one stylesheet instead of wiring Tailwind `@source` to ui internals. Smooths the one genuine integration wrinkle (Tailwind-in-a-shared-lib); additive, source-ships model unchanged. -7. **Publish** `core` then `ui` (source-only) — add a CI job (or first-time manual publish), real versions, document the consumer tsconfig/Tailwind requirements in `core`/`ui` READMEs. - -## Guardrails (Plannotator stays byte-for-byte unchanged) -- After steps 1-3: full `bun test` stays 1620/0, typecheck passes, all builds byte-identical, **`git diff` touches only `packages/core` (new), `packages/shared` (shims/type-imports), `packages/ui` (import re-points + package.json), and `packages/editor` (wideMode)** — no server/app behavior change. -- The shipped bundle hashes (`apps/hook/dist`, `apps/opencode-plugin`) should stay identical (the re-exports compile to the same code). -- The publish itself is the one outward-facing, hard-to-undo step — **stop and confirm with the user before pushing anything to a registry.** - -## Open decisions to lock in the ADR -1. **Registry:** recommend **public npm** (matches the existing `@plannotator/opencode`/`pi-extension` flow, simplest, and `core`/`ui` contain nothing secret). Switch to a private scope only if there's a reason to keep them off the public registry. -2. **Versions:** recommend `0.1.0` for `core` + `ui`, versioned together. -3. **`@plannotator/ai` stays private** (ui only needs the `AIContext` type, re-exported via `core`) — confirm no value import. -4. **CI:** add `core` + `ui` to the `release.yml` npm-publish job (or publish manually first, automate later). - -## Sequencing -Do the carve + re-point + verify first (all reversible, Plannotator-unchanged), get the green parity run, **then** make the registry/version call and publish as the final, confirmed step. - -## References -- Spike: `adr/research/SPIKE-publish-core-package-20260623-125551.md` -- Governing decision: `adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180627.md` diff --git a/adr/specs/document-ui-comments-seam-20260623-084806.md b/adr/specs/document-ui-comments-seam-20260623-084806.md deleted file mode 100644 index f64519d83..000000000 --- a/adr/specs/document-ui-comments-seam-20260623-084806.md +++ /dev/null @@ -1,85 +0,0 @@ -# Spec: Comments / Annotations / Drafts Seam (Phase 5) - -Date: 2026-06-23 · Status: Draft (iterate before implementing) - -> Implementation spec for Phase 5 of the `@plannotator/ui` reuse effort. Grounded in `SPIKE-document-ui-comments-system-20260623-084806.md` + `synthesis-document-ui-comments-20260623-084806.md`. Governed by ADR 004. THE LAW: each seam is a module-level default reproducing today's literal behavior + an optional override; Plannotator passes nothing and is byte-for-byte unchanged. Move verbatim, never rewrite — especially the SSE machine and the draft generation protocol. - -## Scope - -**In scope (3 seams):** draft transport, external-annotation transport, identity/authorship. -**Confirmed noop (already portable):** AnnotationPanel, CommentPopover, AnnotationToolbar, AnnotationToolstrip, EditorAnnotationCard, AnnotationSidebar, useAnnotationHighlighter, useExternalAnnotationHighlights, commentContent/annotationHelpers/anchors, the `export*Annotations` serializers. -**Out of scope:** replies/threading (new feature — defer), renderer coupling (document as a contract), `useEditorAnnotations` / VS Code IPC (host-only), feedback/submit routes and merge/dedup policy (host-owned). - -## Order of work (lowest risk first) - -### Step 1 — Identity / authorship seam (effort S, lowest risk; partly already done) -**Files:** `packages/ui/utils/identity.ts` and the stamp/display sites. -- Add a module-level identity provider with default = today's functions, matching the Phase 2–4 pattern: - ```ts - // identity.ts - export interface IdentityProvider { - getIdentity(): string; - isCurrentUser(author: string | undefined): boolean; - } - const defaultIdentityProvider: IdentityProvider = { getIdentity, isCurrentUser }; // existing impls - let identityProvider = defaultIdentityProvider; - export function setIdentityProvider(p: IdentityProvider): void { identityProvider = p; } - export function resetIdentityProvider(): void { identityProvider = defaultIdentityProvider; } - ``` - Then route the 9 stamp sites and 2 display sites through `identityProvider.getIdentity()` / `identityProvider.isCurrentUser()`. Keep the existing `getIdentity`/`isCurrentUser` exports working (they remain the default). -- **Alternative considered:** thread an optional `author?`/`isCurrentUser?` prop through Viewer/panel. Rejected for now — 9 stamp sites across Viewer + html-viewer + diff make a module-level provider cleaner and lower-churn. (Decide in review.) -- **Parity guardrail:** no caller sets the provider → tater identity + `(me)` badge behave exactly as today. Verify: existing identity/annotation tests green; eyeball a comment shows the tater name + `(me)`. - -### Step 2 — Draft transport seam (effort M) -**Files:** `packages/ui/hooks/useAnnotationDraft.ts`, `packages/ui/hooks/useCodeAnnotationDraft.ts`. -- Introduce a `DraftTransport` and module-level default reproducing today's fetches verbatim: - ```ts - export interface DraftTransport { - load(): Promise<{ data: unknown | null; generation: number | null }>; - save(body: object, opts: { keepalive?: boolean }): Promise; - remove(generation: number, opts?: { keepalive?: boolean }): Promise; - } - ``` - Default `save` keeps the **keepalive-true POST with retry-without-keepalive on failure gated by generation match**; default `remove` does `DELETE /api/draft?generation=N`; default `load` does `GET /api/draft` + reads `draftGeneration` from the (404) body. -- **Keep inside the hook (do not move into the transport):** the `draftGenerationRef` pre-increment, the 500ms debounce, the `latestRef` non-reactive getters, `canPersistRef`/`hasMountedRef` gates, and the `visibilitychange`/`pagehide` flush effect. These are stateful/timing-sensitive — verbatim. -- **Document the 3-party protocol in the seam's doc comment:** `getDraftGeneration()` still escapes to the host; the host still threads it into submit (`withDraftGeneration` → `/api/approve`,`/api/exit` URL; `/api/deny`,`/api/feedback` body; annotate reads approve/exit from URL). A host swapping transport **must** replicate generation-gated delete-on-submit and tombstoning, or ghost drafts resurrect. -- **Parity guardrail:** no caller overrides transport → identical `/api/draft` traffic and identical generation in approve/deny/feedback/exit. Verify: existing draft tests green (esp. `packages/shared/draft.test.ts` generation invariants — server side is untouched); typecheck; full `bun test` ≥ baseline; eyeball: type a comment, reload → draft restores; submit → draft gone, doesn't reappear. - -### Step 3 — External-annotation transport seam (effort M–L, riskiest; do last) -**Files:** `packages/ui/hooks/useExternalAnnotations.ts` (+ `useExternalAnnotationHighlights.ts` stays as-is). -- Introduce an `ExternalAnnotationTransport` and module-level default reproducing the SSE→polling machine verbatim: - ```ts - export interface ExternalAnnotationTransport { - subscribe(onEvent: (e: ExternalAnnotationEvent) => void, onError: () => void): () => void; - getSnapshot(since: number): Promise<{ annotations: T[]; version: number } | null>; // null on 304 - add(items: T[]): Promise; - remove(id: string): Promise; - update(id: string, fields: Partial): Promise; - clear(source?: string): Promise; - } - ``` - Default `subscribe` = `new EventSource('/api/external-annotations/stream')` wiring; default `getSnapshot` = `GET /api/external-annotations?since=` with 304→null; CRUD = today's optimistic-then-fetch calls. -- **Keep inside the hook (verbatim):** the reducer that applies `snapshot|add|remove|clear|update`, the **fallback-once** logic (`!receivedSnapshotRef && !fallbackRef`), the **500ms** poll interval, the version-scoped `versionRef`, and the optimistic local mutation before the network call. The default transport owns the EventSource/heartbeat/304 wire; the hook owns the state machine that drives it. -- The `enabled` flag stays host-suppliable (plan: `isApiMode && !goalSetupMode`; review: `!!origin`). Server `shared/external-annotation.ts` (store, validators, event types, SSE encoding) is already shared and unchanged. -- **Parity guardrail:** no caller overrides → identical SSE connection, identical 500ms/304 polling, identical optimistic CRUD. Verify: existing external-annotation tests green; **eyeball both paths** — (a) POST to `/api/external-annotations` shows live without reload (SSE); (b) kill/black-hole the stream → confirm polling takes over and still updates. App.tsx merge/dedup untouched. - -## Renderer-coupling contract (document, no code change) -Write a short integration note (in the package README or a `docs` doc) stating: a host consuming the annotation UI must render markdown through `@plannotator/ui` `BlockRenderer` + `InlineMarkdown` + `utils/inlineTransforms` (which applies `transformPlainText`), because highlight restoration re-anchors against that exact rendered text. Optional future work: expose `transformPlainText` as overridable with today's default. - -## Replies/threading (explicitly deferred) -Not built in Phase 5. When scoped later, do it backward-compatibly (Plannotator keeps the flat single-comment experience; threading is additive and Plannotator never populates it). Tracked as a separate spec. - -## Definition of done (Phase 5) -- Identity, draft, and external-annotation transports are host-overridable, each defaulting to today's behavior. -- Plannotator byte-unchanged: shipped bundles behave identically; full `bun test` ≥ baseline (1620/0); typecheck; builds; App.tsx changes limited to (at most) wiring the defaults at call sites if needed (ideally zero — module-level defaults mean Plannotator passes nothing). -- Eyeball confirmed: comment author/`(me)`, draft save+restore+no-ghost, live external annotations via SSE, and SSE→polling fallback. -- Renderer-coupling contract written down. Replies deferred with a note. - -## Per-step parity guardrail (run after each) -`bun run typecheck` · `bun test` must stay ≥ 1620/0 (+ the touched suite green, unmodified where a guardrail test exists) · `bun run --cwd apps/review build && bun run build:hook` · `git diff packages/editor/App.tsx` minimal/empty · manual eyeball for the step's surface. - -## Open questions (resolve before/within ADR) -1. Identity: module-level `setIdentityProvider` (recommended) vs. props. -2. Replies: deferred (recommended) vs. in-scope. -3. Renderer `transformPlainText`: document-only (recommended) vs. extract overridable now. -4. Whether to ship the three seams as one Phase-5 PR or three small verify-gated commits (recommended: three commits, identity → drafts → external, like Phase 3). diff --git a/adr/specs/document-ui-extraction-20260620-083307.md b/adr/specs/document-ui-extraction-20260620-083307.md deleted file mode 100644 index be176d17f..000000000 --- a/adr/specs/document-ui-extraction-20260620-083307.md +++ /dev/null @@ -1,1066 +0,0 @@ -# Spec: Shared Document UI Package - -> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Spec for the failed `@plannotator/document-ui` extraction (reverted 2026-06-22). The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. Kept here as history only. - -Date: 2026-06-20 - -Status: Draft - -## Intent - -Extract Plannotator's document review experience into one shared UI package that can be used by Plannotator and a sister Workspaces repo. - -The package should not be a thin markdown renderer. It should own the reusable document review loop: - -- render markdown and raw HTML documents -- annotate text and blocks -- show comments and attachments -- navigate linked documents -- browse document/file trees -- edit documents -- show dirty/saving/saved/conflict/missing/error states -- restore drafts -- assemble feedback and saved-change context - -The host app should own routing, auth, server calls, environment capabilities, and provider-specific persistence. - -## Package - -Create one package: - -```text -@plannotator/document-ui -``` - -Do not split into renderer/tree/comments/editor packages yet. The first stable boundary is the full document review surface. - -Primary export: - -```tsx - -``` - -The package may later expose lower-level hooks and components, but those are secondary. The product-level export is the surface. - -## Design Principle - -The core contract must be provider-neutral. - -Do not name the shared contract around Plannotator's local source-save implementation. Local source-save is one provider. The reusable concept is document writeback state: - -```ts -type DocumentWritebackStatus = - | "clean" - | "dirty" - | "saving" - | "saved" - | "conflict" - | "missing" - | "error"; -``` - -Plannotator local implements writeback with: - -- `/api/source/save` -- disk hashes -- mtime -- EOL metadata -- missing local files -- source-save draft restore - -Workspaces implements writeback with: - -- `/v1/workspaces/{workspace}/documents/{document}` -- `If-Match` -- document versions -- missing document rows -- workspace restore semantics - -The package should own the common UI and state behavior. Providers own the persistence details. - -## Goals - -1. Make Plannotator's document experience the upstream UI for both repos. - -2. Keep one shared package, not many narrowly split packages. - -3. Move document-domain behavior out of `packages/editor/App.tsx`. - -4. Preserve current Plannotator behavior for: - -- plan review -- annotate file -- annotate folder -- annotate last message -- raw HTML annotation -- linked docs -- source-backed file editing -- drafts -- feedback submission - -5. Let Workspaces provide a different backend with the same document UI: - -- workspace manifest based document tree -- document ids instead of filesystem paths -- workspace versions instead of disk hashes -- workspace annotations and replies API -- workspace auth and routes - -6. Keep current Plannotator server routes stable during extraction. - -7. Make the document surface testable with an in-memory host API. - -## Non-Goals - -Do not redesign the user interface as part of this extraction. - -Do not rename `/api/plan` during the extraction. - -Do not solve Bun/Pi server duplication as part of the frontend package boundary. - -Do not move CLI/plugin command interception into the package. - -Do not move server startup or browser opening into the package. - -Do not move the agent terminal runtime, WebTUI sidecar, or runtime installer into the package. - -Do not make plan diff, archive, goal setup, or permission mode setup first-class parts of the first document package boundary. - -Do not require a filesystem path for every document. - -## Package Responsibilities - -`@plannotator/document-ui` owns: - -- `DocumentReviewSurface` -- document render mode switch: markdown, raw HTML, editing -- markdown parsing and block model -- markdown block rendering -- raw HTML iframe annotation bridge -- annotation lifecycle -- highlight restoration -- comments and annotation panel -- image attachments -- linked document navigation -- document tree/file tree rendering and status badges -- edit toggle and edit session state -- provider-neutral writeback state -- conflict/missing/error UI patterns -- draft save and restore state -- feedback payload assembly -- saved-change section assembly -- code path validation UI -- inline link handling -- optional Ask AI integration points -- optional agent-delivery integration points - -The host app owns: - -- app routing -- server endpoints -- auth -- current user/session identity -- provider implementation -- browser opening -- plugin/CLI integration -- local filesystem access -- workspace API access -- plan-mode hook stdout behavior -- plan history -- plan diff -- archive -- goal setup -- note-app settings and persistence policy -- terminal runtime and sidecar - -## Terminology - -### Host App - -The application that embeds `DocumentReviewSurface`. - -Examples: - -- Plannotator plan/annotate app -- Workspaces web app - -### Provider - -The host-side implementation of document loading, saving, tree listing, draft persistence, annotations, versions, and feedback submission. - -Examples: - -- Plannotator local provider -- Workspaces provider -- in-memory test provider - -### DocumentRef - -Provider-neutral identity for a document. - -A document may have a filesystem path, but the package must not require one. - -### Writeback - -Provider-neutral document edit persistence. - -Local Plannotator's current `sourceSave` is one writeback implementation. Workspaces document save is another. - -### Feedback - -The review output assembled from annotations, global comments, image attachments, direct edits, saved changes, linked-document comments, and provider-specific metadata. - -## Core Types - -This is a draft interface shape. Exact names can change during implementation, but the concepts should remain. - -```ts -export type DocumentProviderId = string; - -export interface DocumentRef { - id: string; - providerId?: DocumentProviderId; - label: string; - title?: string; - path?: string; - parentId?: string; - kind?: "document" | "folder" | "message" | "url" | "html" | string; - metadata?: Record; -} - -export type DocumentRenderMode = "markdown" | "html"; - -export interface LoadedDocument { - ref: DocumentRef; - content: string; - renderMode: DocumentRenderMode; - rawHtml?: string; - shareHtml?: string; - sourceInfo?: string; - converted?: boolean; - baseForRelativeLinks?: DocumentRef | string; - imageBase?: string; - writeback?: DocumentWritebackCapability; -} - -export type DocumentWritebackStatus = - | "clean" - | "dirty" - | "saving" - | "saved" - | "conflict" - | "missing" - | "error"; - -export interface DocumentWritebackCapability { - writable: boolean; - status: DocumentWritebackStatus; - revision?: string; - language?: "markdown" | "mdx" | "text" | string; - reason?: string; - message?: string; - providerState?: unknown; -} - -export interface DocumentWritebackState { - ref: DocumentRef; - status: DocumentWritebackStatus; - dirty: boolean; - revision?: string; - sessionOpenContent?: string; - savedContent?: string; - currentContent?: string; - conflict?: DocumentConflict; - error?: string; - providerState?: unknown; -} - -export interface DocumentConflict { - latestContent: string; - latestRevision?: string; - message?: string; - providerState?: unknown; -} - -export interface SaveDocumentRequest { - ref: DocumentRef; - content: string; - baseRevision?: string; - providerState?: unknown; - overwriteConflict?: boolean; -} - -export type SaveDocumentResult = - | { - ok: true; - ref?: DocumentRef; - revision?: string; - providerState?: unknown; - } - | { - ok: false; - code: "conflict"; - message: string; - latestContent: string; - latestRevision?: string; - providerState?: unknown; - } - | { - ok: false; - code: "missing" | "not-writable" | "validation" | "network" | "unknown"; - message: string; - providerState?: unknown; - }; -``` - -## Session Types - -```ts -export type DocumentSessionMode = - | "plan-review" - | "annotate" - | "annotate-folder" - | "annotate-message" - | "workspace-review" - | string; - -export interface DocumentReviewSession { - id: string; - mode: DocumentSessionMode; - origin?: string; - rootDocument?: LoadedDocument; - initialDocumentRef?: DocumentRef; - rootTreeRef?: DocumentRef; - capabilities: DocumentCapabilities; - ui?: DocumentSessionUi; - providerState?: unknown; -} - -export interface DocumentCapabilities { - canAnnotate: boolean; - canEdit: boolean; - canWriteback: boolean; - canApprove?: boolean; - canExit?: boolean; - canShare?: boolean; - canUploadImages?: boolean; - canOpenLinkedDocuments?: boolean; - canBrowseDocuments?: boolean; - canUseAskAI?: boolean; - canDeliverToAgent?: boolean; - supportsRawHtml?: boolean; - supportsVersions?: boolean; -} - -export interface DocumentSessionUi { - title?: string; - subtitle?: string; - primaryActionLabel?: string; - approveLabel?: string; - exitLabel?: string; -} -``` - -## Host API - -The surface talks to a provider through `DocumentHostApi`. - -```ts -export interface DocumentHostApi { - loadSession?(): Promise; - - loadDocument(request: LoadDocumentRequest): Promise; - - resolveLinkedDocument?(request: ResolveLinkedDocumentRequest): Promise; - - validateCodePaths?( - request: ValidateCodePathsRequest, - ): Promise; - - listDocuments?( - request: ListDocumentsRequest, - ): Promise; - - watchDocuments?( - request: WatchDocumentsRequest, - ): DocumentWatchSubscription; - - saveDocument?( - request: SaveDocumentRequest, - ): Promise; - - loadDraft?( - request: LoadDraftRequest, - ): Promise; - - saveDraft?( - request: SaveDraftRequest, - ): Promise; - - deleteDraft?( - request: DeleteDraftRequest, - ): Promise; - - uploadImage?( - file: File, - ): Promise; - - submitFeedback?( - payload: SubmitDocumentFeedbackPayload, - ): Promise; - - approve?( - payload: ApproveDocumentPayload, - ): Promise; - - exit?( - payload: ExitDocumentPayload, - ): Promise; - - askAI?( - request: DocumentAskAIRequest, - ): Promise | AsyncIterable; - - deliverToAgent?( - payload: DocumentAgentDeliveryPayload, - ): Promise; -} -``` - -### LoadDocumentRequest - -```ts -export interface LoadDocumentRequest { - ref: DocumentRef; - baseRef?: DocumentRef; - preferredRenderMode?: DocumentRenderMode; -} -``` - -### Tree Types - -```ts -export interface DocumentTreeNode { - ref: DocumentRef; - kind: "folder" | "document"; - children?: DocumentTreeNode[]; - annotationCount?: number; - writebackStatus?: DocumentWritebackStatus; - disabled?: boolean; - metadata?: Record; -} - -export interface DocumentTreeResult { - root: DocumentTreeNode; - workspaceStatus?: unknown; -} -``` - -### Watch Types - -```ts -export interface DocumentWatchSubscription { - close(): void; - onEvent(callback: (event: DocumentWatchEvent) => void): () => void; -} - -export type DocumentWatchEvent = - | { type: "ready"; ref?: DocumentRef } - | { type: "changed"; ref?: DocumentRef; reason?: string } - | { type: "deleted"; ref: DocumentRef } - | { type: "error"; message: string }; -``` - -The Plannotator local adapter can implement this over `EventSource('/api/reference/files/stream')`. - -The Workspaces adapter can implement it over its own workspace document event system, polling, or no-op watches. - -## DocumentReviewSurface Props - -```ts -export interface DocumentReviewSurfaceProps { - session: DocumentReviewSession; - hostApi: DocumentHostApi; - initialDocument?: LoadedDocument; - initialAnnotations?: Annotation[]; - initialCodeAnnotations?: CodeAnnotation[]; - initialGlobalAttachments?: ImageAttachment[]; - className?: string; - slots?: DocumentReviewSlots; - options?: DocumentReviewOptions; - onSubmitted?: (result: SubmitFeedbackResult) => void; - onApproved?: () => void; - onExited?: () => void; - onError?: (error: DocumentReviewError) => void; -} - -export interface DocumentReviewSlots { - leftSidebarExtraTabs?: React.ReactNode; - rightPanelExtraTabs?: React.ReactNode; - terminalPanel?: React.ReactNode; - headerActions?: React.ReactNode; - footer?: React.ReactNode; -} - -export interface DocumentReviewOptions { - defaultEditorMode?: "selection" | "comment" | "redline" | "quickLabel"; - defaultInputMethod?: "drag" | "pinpoint"; - allowRawHtml?: boolean; - allowWideMode?: boolean; - allowImageAttachments?: boolean; - persistUiPreferences?: boolean; - disableDrafts?: boolean; - hideDocumentNavigator?: boolean; - hideAnnotationPanel?: boolean; -} -``` - -## State Ownership - -The package owns frontend state for: - -- active document -- linked document stack -- per-document annotation cache -- selected annotation -- global comments and attachments -- parsed blocks -- markdown edit session -- writeback status map -- dirty document set -- conflict/missing/error display -- draft payload -- saved-change records -- file/document tree badges -- feedback payload assembly - -The provider owns durable state for: - -- document content -- revisions or hashes -- versions -- saved annotations, if the host persists them -- draft storage backend -- auth and permissions -- server-side conflict detection -- route shape - -## Writeback State Machine - -The package should implement the common frontend state machine. - -### clean - -The current editor buffer matches the loaded baseline. - -Allowed actions: - -- edit -- annotate -- navigate away -- submit feedback - -### dirty - -The user changed editable content but has not saved it. - -Allowed actions: - -- save -- discard -- continue editing -- submit only if the product policy allows unsaved direct edits - -Plannotator local should continue to include direct edits in feedback where appropriate. - -Workspaces may choose to require save before submit or include unsaved edits as proposed changes. - -This policy should be configurable by the session or provider. - -### saving - -A writeback request is in flight. - -Allowed actions: - -- show saving state -- prevent duplicate save -- avoid applying stale watch snapshots - -### saved - -The user saved a change during this review session. - -Allowed actions: - -- show saved state -- include saved-change context in feedback where the session requests it -- treat current saved content as the new baseline - -### conflict - -The provider rejected save because the remote/local document changed. - -Required provider data: - -- latest content -- latest revision or provider-specific conflict state - -Allowed actions: - -- reload latest -- overwrite, when policy allows and the edit buffer is available -- keep editing -- discard - -The package should not show overwrite if the provider or current state cannot perform it. - -### missing - -The document no longer exists or cannot be resolved. - -Allowed actions: - -- show missing row/state -- preserve annotations and draft context when possible -- allow discard/close -- allow restore/recreate only if provider advertises support - -### error - -An operation failed without a recoverable conflict or missing state. - -Allowed actions: - -- retry if operation is retryable -- discard local edits -- submit only if policy allows - -## Provider Policies - -Some behavior must be provider/session configurable: - -```ts -export interface DocumentWritebackPolicy { - submitWithUnsavedEdits: - | "allow-as-direct-edits" - | "block" - | "ask"; - submitWithUnverifiedSavedChanges: - | "allow" - | "block" - | "ask"; - conflictOverwrite: - | "allowed" - | "disallowed" - | "provider"; - missingDocumentRestore: - | "none" - | "recreate" - | "provider"; -} -``` - -Plannotator local likely uses: - -- `submitWithUnsavedEdits: "allow-as-direct-edits"` for plan edits -- stricter behavior for source-backed saved-change verification -- `conflictOverwrite: "allowed"` only when live editor buffer is available -- `missingDocumentRestore: "none"` for first extraction - -Workspaces likely uses: - -- `submitWithUnsavedEdits: "block"` or `"ask"` depending on product choice -- `submitWithUnverifiedSavedChanges: "block"` if version checks fail -- `conflictOverwrite: "provider"` -- `missingDocumentRestore: "provider"` if workspace restore exists - -## Drafts - -The package owns draft shape and restore behavior, but the host owns persistence. - -Drafts should store provider-neutral data: - -```ts -export interface DocumentReviewDraft { - annotations: Annotation[]; - codeAnnotations?: CodeAnnotation[]; - globalAttachments: ImageAttachment[]; - editedDocuments?: DraftEditedDocument[]; - savedChanges?: DraftSavedDocumentChange[]; - activeDocumentRef?: DocumentRef; - selectedAnnotationId?: string; - generation: number; - timestamp: number; -} - -export interface DraftEditedDocument { - ref: DocumentRef; - baseRevision?: string; - baseContent: string; - currentContent: string; - providerState?: unknown; -} - -export interface DraftSavedDocumentChange { - ref: DocumentRef; - beforeContent: string; - afterContent: string; - beforeRevision?: string; - afterRevision?: string; - providerState?: unknown; -} -``` - -Plannotator local can map existing draft fields to this shape. - -Workspaces can persist drafts in its own storage and map workspace revisions into `baseRevision` / `afterRevision`. - -Draft generation remains important. It prevents late saves from resurrecting cleared drafts after submit. - -## Feedback Assembly - -The package should assemble a provider-neutral feedback payload: - -```ts -export interface SubmitDocumentFeedbackPayload { - sessionId: string; - mode: DocumentSessionMode; - activeDocument?: DocumentRef; - annotations: Annotation[]; - linkedDocumentAnnotations?: LinkedDocumentAnnotationEntry[]; - codeAnnotations?: CodeAnnotation[]; - globalAttachments?: ImageAttachment[]; - directEdits?: DirectEditEntry[]; - savedChanges?: SavedDocumentChangeEntry[]; - messageScope?: unknown; - providerState?: unknown; -} -``` - -The package can also expose a renderer for human-readable markdown feedback, but the host decides what to do with the payload. - -Plannotator local host behavior: - -- convert payload into current agent feedback text -- call `/api/feedback`, `/api/approve`, or `/api/deny` -- optionally route feedback to the agent terminal - -Workspaces host behavior: - -- save comments/replies through annotation APIs -- save review state through workspace APIs -- submit or share feedback according to workspace product rules - -## Linked Documents - -Linked document navigation must use `DocumentRef`, not filesystem path as the only identity. - -The package should handle: - -- opening linked docs -- preserving root document state -- caching annotations per document -- switching between markdown and raw HTML render modes -- returning to the prior document -- showing annotation counts in the tree - -The provider handles: - -- resolving link text/path/id to `DocumentRef` -- loading document content -- enforcing access and auth -- choosing whether relative links are path-based, manifest-based, or id-based - -## Document Tree - -The package should render a tree of `DocumentTreeNode`. - -Tree rows should support: - -- folders -- documents -- active document indicator -- annotation count -- writeback status badge -- workspace/git/provider status metadata -- missing/deleted rows -- disabled rows - -The package should not assume git status. It can accept provider metadata and render known generic status patterns. Plannotator local can map git workspace status into this metadata. - -## Raw HTML - -Raw HTML support remains part of the package. - -The package owns: - -- iframe rendering -- annotation bridge integration -- shared viewer handle behavior -- raw HTML annotation state - -The provider owns: - -- asset rewriting -- raw HTML sanitization/permission policy if needed -- portable/share HTML generation - -## Ask AI - -Ask AI should be an optional capability. - -The package owns: - -- where Ask AI affordances appear -- how selected document context is gathered -- how annotation context is included - -The host owns: - -- provider selection -- auth -- session creation -- streaming implementation -- terminal fallback - -## Agent Delivery - -Agent delivery is optional and host-owned. - -The package can accept: - -```ts -export interface DocumentAgentDeliveryCapability { - available: boolean; - deliveredKey?: string; - send(payload: DocumentAgentDeliveryPayload): Promise; -} -``` - -The package can use this to: - -- show delivered/current feedback state -- avoid duplicate sends -- route Ask AI prompts to an agent when configured - -The package must not own: - -- WebTUI runtime -- sidecar process -- WebSocket URL creation -- remote-mode security -- runtime installation - -## Plannotator Local Adapter - -Add a browser-side adapter around current routes: - -```ts -createPlannotatorHttpDocumentApi(options?: { - baseUrl?: string; -}): DocumentHostApi -``` - -Initial route mapping: - -- `loadSession` -> `GET /api/plan` -- `loadDocument` -> `GET /api/doc` -- `validateCodePaths` -> `POST /api/doc/exists` -- `listDocuments` -> `GET /api/reference/files` -- `watchDocuments` -> `GET /api/reference/files/stream` -- `saveDocument` -> `POST /api/source/save` -- `loadDraft` -> `GET /api/draft` -- `saveDraft` -> `POST /api/draft` -- `deleteDraft` -> `DELETE /api/draft` -- `uploadImage` -> `POST /api/upload` -- `submitFeedback` -> `POST /api/feedback` -- `approve` -> `POST /api/approve` -- `exit` -> `POST /api/exit` - -The adapter should map local `sourceSave` into provider-neutral writeback fields. - -The core package should not leak `sourceSave` into its public contract except through local adapter internals. - -## Workspaces Adapter - -The sister repo should be able to implement: - -```ts -createWorkspaceDocumentApi(options: { - workspaceId: string; - auth: WorkspaceAuth; - baseUrl: string; -}): DocumentHostApi -``` - -Expected mapping: - -- load tree from workspace manifest -- resolve linked docs from manifest/document ids -- load documents by document id -- save with `If-Match` or version id -- map versions/ETags to `revision` -- map deleted/unavailable docs to `missing` -- load comments/replies from annotations API -- persist drafts through workspace draft storage -- load versions through versions API - -This adapter does not need to live in the Plannotator repo if the package contract is stable. - -## Migration Plan - -### Phase 1. Contracts - -Create `packages/document-ui`. - -Add: - -- core types -- `DocumentHostApi` -- provider-neutral writeback types -- draft types -- feedback payload types - -No product behavior changes. - -### Phase 2. Local HTTP Adapter - -Implement `createPlannotatorHttpDocumentApi()` over current routes. - -Use it from `packages/editor/App.tsx` where possible without moving major UI yet. - -Goal: route calls begin moving behind the adapter. - -### Phase 3. Move Document Domain Modules - -Move or re-export: - -- editable document state -- source document client/reconciliation, renamed toward writeback where public -- saved file change validation, generalized to saved document change validation -- direct edits -- draft restore selection -- path helpers only where local-specific - -Rename public concepts from source-save to writeback. Keep local source-save names inside the local adapter. - -### Phase 4. Extract Surface Shell - -Create `DocumentReviewSurface` around existing: - -- `Viewer` -- `HtmlViewer` -- `MarkdownEditor` -- annotation panel -- linked doc hook -- file browser hook -- draft hook -- writeback state - -`packages/editor/App.tsx` still owns mode decisions and passes session/capabilities. - -### Phase 5. Move Feedback Assembly - -Move provider-neutral feedback assembly into document-ui. - -Keep Plannotator-specific final text wrapping in the Plannotator host. - -### Phase 6. Host Cleanup - -Shrink `packages/editor/App.tsx` into: - -- load session -- create Plannotator host API -- configure plan/annotate actions -- render `DocumentReviewSurface` -- render plan-only sidecars such as plan diff/archive when needed - -### Phase 7. Workspaces Integration - -Workspaces implements its adapter and embeds the surface. - -Any missing extension points should be added to the contract, not patched through Plannotator-local assumptions. - -## Testing - -Add contract tests: - -- Plannotator Bun `/api/plan` maps to `DocumentReviewSession` -- Pi `/api/plan` maps to the same `DocumentReviewSession` -- `/api/doc` markdown maps to `LoadedDocument` -- `/api/doc` raw HTML maps to `LoadedDocument` -- `/api/doc` converted HTML maps to non-writable document -- `/api/doc` source-save maps to writeback capability -- `/api/source/save` conflict maps to provider-neutral conflict - -Add package tests: - -- in-memory provider loads a document -- annotate and restore highlights -- edit document and transition clean -> dirty -> saving -> saved -- conflict result transitions to conflict and shows conflict controls -- missing document transition keeps row and draft context -- linked document navigation preserves annotation cache -- raw HTML document can create annotations through the common surface handle -- draft restore rehydrates edited documents and saved changes -- feedback payload includes annotations, linked doc comments, direct edits, and saved changes - -Keep existing tests active: - -- parser tests -- markdown editor fidelity tests -- annotation draft persistence tests -- file browser tests -- editable document tests -- source reconciliation tests -- server annotate/reference tests -- Pi server parity tests - -## Acceptance Criteria - -The spec is satisfied when: - -- `@plannotator/document-ui` exists as one package. -- It exports provider-neutral contracts. -- It exports `DocumentReviewSurface`. -- Plannotator uses the surface for plan review and annotate flows. -- Plannotator local behavior is preserved. -- Local source-save is represented as writeback in the package contract. -- The package public API does not require filesystem paths. -- The package public API does not expose `/api/source/save`. -- The package public API does not expose local disk hash semantics as required fields. -- Workspaces can implement a provider using document ids, manifests, `If-Match`, and versions. -- The document surface can run in tests with an in-memory provider. -- Plan diff/archive/goal setup remain host-owned. -- Agent terminal runtime remains host-owned. - -## Open Questions - -1. Should `@plannotator/document-ui` depend on `@plannotator/ui`, or should document components move out of `@plannotator/ui`? - -Working recommendation: start with `@plannotator/document-ui` depending on `@plannotator/ui` for primitives and gradually move document-specific components into the new package. Avoid a giant one-shot move. - -2. Should the Plannotator local HTTP adapter live in `@plannotator/document-ui` or `@plannotator/editor`? - -Working recommendation: put the browser-side adapter in `@plannotator/document-ui` if it only uses fetch and public routes. Keep server-only code out. - -3. Should feedback assembly produce markdown text or structured data? - -Working recommendation: produce structured data first and expose a markdown formatter. Plannotator can keep its agent-specific markdown wrapper. - -4. Should unsaved edits be allowed in Workspaces feedback? - -Working recommendation: make this a provider policy. Do not bake Plannotator's direct-edit behavior into all providers. - -5. Should comments/replies persistence be part of the host API now? - -Working recommendation: include extension points now, but do not require persistent comments for the first Plannotator extraction. Workspaces can implement persistence through the adapter. - -## Decision Draft - -Adopt one provider-neutral shared document UI package. - -The core abstraction is not local source-save. The core abstraction is document writeback state plus a host API. - -Plannotator local source-save becomes the first provider implementation. Workspaces becomes a second provider implementation. Plan review becomes one host mode that supplies a document, capabilities, and approve/deny behavior to the shared document surface. diff --git a/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md b/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md deleted file mode 100644 index 47abf8646..000000000 --- a/adr/specs/document-ui-extraction-plan-verified-20260622-184500.md +++ /dev/null @@ -1,158 +0,0 @@ -# Spec: Document UI Extraction Plan — Verified - -Date: 2026-06-22 - -> Produced by a 36-agent verification workflow (5 coupling-sweep lenses + 15 subsystem analyses + 15 adversarial parity reviews + synthesis). It **verifies and supersedes** the draft inventory `adr/research/SPIKE-document-ui-reuse-inventory-20260622-183000.md`, which was directionally correct but materially incomplete. Governed by **ADR 004**. THE LAW: move + decouple, never rewrite; Plannotator's experience cannot change. Every seam below is "lift the URL/global to an optional prop, with **today's literal as the verbatim default**." - -**Repo:** `/Users/ramos/plannotator/feat-pkg-document-ui` · **Library:** `packages/ui` · **Glue:** `packages/editor/App.tsx` - -## Subsystem parity verdicts - -`safe` = extract via straightforward seams. `risky` = extractable, but contains timing-sensitive/stateful code that must be **moved verbatim**, not re-derived (the reverted attempt's failure mode). - -| Subsystem | Verdict | Effort | WS | -|---|---|---|---| -| Theme & tokens | safe | S | core | -| Markdown parsing + block rendering | safe | S | core | -| Document Viewer + annotation highlighting | **risky** | S–M | core | -| Raw HTML viewer | safe | S | core | -| Markdown editor | safe | S | core | -| File tree / browser | **risky** | M | core | -| Sidebar shell + tabs | safe | S | core | -| Comments / annotations / drafts | **risky** | L | core | -| Versions / plan diff | safe | M | maybe | -| Settings / config | safe | M | partial | -| Sharing / export / notes | safe | S | partial | -| Ask AI / agents | **risky** | M | maybe/own | -| Images: upload / thumbnail / annotate | safe | S | core | -| The glue (App.tsx layout) | safe | S | n/a | -| Packaging @plannotator/ui | safe | M | gate | - -## 1. What the draft inventory missed (verified corrections) - -The draft audited only one coupling axis — literal `/api/` strings at call sites — and was blind to five others. - -- **A. Viewer is NOT clean (most consequential).** `Viewer.tsx:532` calls `useValidatedCodePaths(...)` **unconditionally**, which POSTs `/api/doc/exists`. Mounting Viewer fires a Plannotator backend call. Viewer is **NEEDS_SEAM**, not one of the "clean 82." -- **B. An uncounted cookie-persistence subsystem.** `utils/storage.ts` (`document.cookie`) is the **sole** settings backend, imported by ~24 modules (theme, TOC/plan-width/sticky prefs, panel resize, editor mode, agent switch, AI provider, identity). NEEDS_SEAM (inject a `{getItem,setItem,removeItem}` adapter; it's already localStorage-shaped). -- **C. Three React contexts + one global singleton, never mentioned.** - - `ScrollViewportContext` — consumed in `packages/ui` (Viewer, StickyHeaderLane, PinpointOverlay, TableOfContents) but its **only Provider lives in the glue** (`App.tsx:3888`). Mounted elsewhere, sticky headers / pinpoint / scroll-to-anchor / TOC scrolling silently break. NEEDS_SEAM. - - `configStore` singleton — module-level, eager cookie reads, hardcoded `fetch('/api/config')` write-back (L118). Reached **transitively** via `identity.ts` by Viewer, AnnotationPanel, HtmlViewer, `useAnnotationHighlighter`, diff views, Settings. This is **annotation authorship** ("which comments are mine") — core to Workspaces commenting. NEEDS_SEAM (host-supplied `currentUser`/`isCurrentUser`). - - `CodePathValidationContext` — intra-library, null-tolerant. TRANSFER_AS_IS. -- **D. SSE (EventSource) is a distinct transport** the draft collapsed into REST siblings: `useFileBrowser.ts:297`, `useExternalAnnotations.ts:44`, `useAgentJobs.ts:66`. Workspaces' backend must speak SSE or supply a `subscribe` callback. -- **E. `useFileBrowser` is NOT transfer-as-is** — hard-codes `/api/reference/files` (L116), `/api/reference/obsidian/files` (L224), and the SSE watcher (L297). Only its expansion state is pure. -- **F. Packaging is harder than "moderate."** Hard blockers: `@plannotator/ai` + `@plannotator/shared` are `workspace:*` + `private` + `0.0.1`; a **phantom `dompurify`** dep (imported, not declared); **no `peerDependencies`** (React-duplication risk); `exports` point at raw `.tsx`/`.ts` with no `main`/`module`/`types`. -- **G. Small factual fixes** (so we don't act on bad data): theme count is a clean **51:51** (the "53/52" was a grep artifact); `useTheme()` does **not** throw without a provider (seeded default); `getImageSrc` is **one** shared seam across 5 consumers, not 3 separate wires; `utils/sharing.ts` calls the **external** paste service (base URLs parameterized), not a Plannotator `/api/paste` route. - -## 2. Master extraction plan (dependency-ordered) - -Each step: **default === today's literal**, additive optional prop/callback, logic untouched. The guardrail is how you prove Plannotator didn't change. - -### Step 0 — Packaging unblock (do first; gates external install, zero runtime effect). Effort M. -- Add `dompurify` to `packages/ui` deps at the root's exact `^3.3.3` (version mismatch could change sanitization output). -- Resolve the two `workspace:* / private` deps: publish `@plannotator/ai` + `@plannotator/shared` with real versions, **or** inline the ~11 verified browser-safe subpaths ui value-imports (all Web-API-only — Web Crypto, CompressionStream — no `node:*`). -- Add `peerDependencies` (react, react-dom, tailwindcss, tailwindcss-animate, radix set, lucide-react); keep as devDeps for in-repo typecheck. -- Fix stale `tsconfig.json:21` alias `@plannotator/shared` → nonexistent `../shared/index.ts`; align `diff` range (`^8.0.3` vs root `^8.0.4`). -- Keep the **source-only** export model (no dist build — a build could change what Plannotator ships); document required consumer bundler settings (`isolatedModules`, JSX runtime, `allowImportingTsExtensions`). -- Add a `files` allowlist incl. `assets/`, `sprite_package_*/`, themes; exclude `*.test.*` (the only upward `ui→editor` import is `shortcuts.test.ts`). -- **Guardrail:** `bun run build:hook` + `build:opencode` produce byte-identical bundles; in-repo React still resolves to one copy. - -### 1. Rendering core — images. Effort S. -- *As-is:* BlockRenderer, sanitizeHtml, inlineTransforms, parser render path. -- *Seam:* the single `getImageSrc` (ImageThumbnail.tsx:6) shared by 5 consumers (ImageThumbnail, InlineMarkdown, HtmlBlock, AttachmentsButton, Viewer). Introduce a module-level/context override whose default is the current body verbatim (http passthrough + conditional `&base`). Do **not** thread a Viewer-level prop — it can't reach InlineMarkdown/HtmlBlock. -- **Guardrail:** all 5 importers emit identical `/api/image?path=…&base=…`. Keep the default resolver **module-level (stable identity)** so HtmlBlock's `React.memo` + effect deps are untouched (otherwise `
` collapse on re-render). - -### 2. Rendering core — doc fetch + code-path validation. Effort S. -- *Seam A:* InlineMarkdown hover preview `fetch('/api/doc?…')` (L154) → optional `fetchCodeFileContents` defaulting to the literal (same `{path, base?}`, **no `convert=1`** — that's glue). `useLinkedDoc` already accepts `buildUrl`; `useCodeFilePopout` is already prop-driven. -- *Seam B:* gate Viewer's validation — add `disableCodePathValidation`/inject result; default = today (on). -- **Guardrail:** Plannotator passes nothing → hover previews + code-link rendering identical; `/api/doc/exists` still fires. - -### 3. Image upload + attachments. Effort S. -- *Seam:* `AttachmentsButton` `fetch('/api/upload')` (L140) → `onUpload(file) => Promise<{path}>`. Preserve multipart field name `'file'` and `{path}` return shape. Keep `deriveImageName` export stable. -- **Guardrail:** capture-phase paste listener + `stopPropagation` unchanged (no double-attach with App.tsx's bubble-phase paste). - -### 4. File tree. Effort M (highest-risk SSE move). -- *As-is:* `FileBrowser.tsx` helpers + CountBadge, expansion state. -- *Seam:* lift `useFileBrowser`'s three fetch URLs + the **entire** SSE watcher effect (L289-342: EventSource, 120ms debounce, ready-dedup, cleanup) into a default `loadTree`/`loadVaultTree`/`watchTrees` object — moved **verbatim**, URL literals the only relocatable part. `useFileBrowser()` must stay callable with zero args. -- **Guardrail:** existing `useFileBrowser.test.tsx` stays green **without modification**. If it needs rewriting, the default changed → regression. - -### 5. Comments / annotations / drafts. Effort L (risky). -- *As-is:* AnnotationSidebar, EditorAnnotationCard, commentContent, anchors, annotationHelpers, useExternalAnnotationHighlights. -- *Seam A — draftTransport:* wrap the 5 `/api/draft` fetches; `save` rejects on failure (preserves keepalive-retry). Keep generation bookkeeping in the hook. **Document the 3-party protocol:** `getDraftGeneration()` escapes into App.tsx and rides `/api/approve`/`/api/deny` bodies; server tombstone-gates in `shared/draft.ts`. A host swapping transport must replicate generation-gated delete-on-submit or ghost drafts resurrect. -- *Seam B — external-annotations transport:* move the **entire** effect body (EventSource + snapshot-gated fallback + `?since`/304 polling at 500ms) verbatim into a default `subscribe()`. Keep reducer + optimistic mutators. `enabled` flag already host-suppliable. -- *Seam C — identity:* `isCurrentUser(author)` + `getIdentity()` author-stamping (3 creation sites) → optional `author?`/`isCurrentUser?` props defaulting at the App.tsx call site to existing `identity.ts` functions. -- **Guardrail:** approve/deny payloads still carry `getDraftGeneration()`; SSE→polling fallback identical; `(me)` badge renders; every annotation stamped. Note: web-highlighter restoration is **renderer-coupled** — Workspaces must reuse BlockRenderer+InlineMarkdown+inlineTransforms as a unit. - -### 6. Versions / plan diff. Effort M. -- *As-is:* `planDiffEngine.ts`, Badge, ModeSwitcher, RawDiffView. -- *Seam:* inject fetchers into `usePlanDiff` (default → `/api/plan/version(s)`); optional `onOpenVscodeDiff` in `PlanDiffViewer` (default → `/api/plan/vscode-diff`). Keep error handling in the hook (asymmetric: selectBaseVersion `alert()`s, fetchVersions silent). -- *CSS gap:* block/raw-diff + `.annotation-highlight` rules live in **`packages/editor/index.css` (L119-219)**, not the package. Move into `packages/ui/theme.css` (pure move) or document as a host CSS contract. -- **Guardrail:** App.tsx calls with no opts → identical traffic + alert behavior. - -### 7. Settings / config. Effort M. -- *As-is:* `config/settings.ts` (pure cookie+default+mappers). -- *Seam A:* inject only the final `fetch('/api/config')` write-back (L118) via `setServerSync(fn)`. **Keep singleton construction, eager cookie reads, 300ms debounce, deepMerge byte-identical** (a naive per-`set()` fetch changes batching/timing). -- *Seam B:* `Settings.tsx` `fetch('/api/obsidian/vaults')` (L748) → `onDetectObsidianVaults?` default = real fetch; keep `useEffect [obsidian.enabled]` + auto-select-first-vault verbatim (a `[]` no-op default kills auto-select). -- *Seam C:* storage adapter (shared with steps 9/10). Keep literal keys (`plannotator-theme`, `plannotator-toc-enabled`, `plannotator-plan-width`, …) so existing cookies still read. -- *PLANNOTATOR_ONLY:* `HooksTab.tsx`. -- **Guardrail:** Plannotator passes nothing → identical cookie keys, merged `/api/config` POST, vault auto-select. - -### 8. Sharing / export / notes. Effort S. -- *As-is:* `sharing.ts`, `useSharing`, obsidian/bear/octarine wrappers, `callback.ts`. -- *Seam:* `ExportModal` `fetch('/api/save-notes')` (L150) → `onSaveToNotes` → `{success, error}`. Keep `showNotesTab = isApiMode && !!markdown` byte-for-byte. -- *PLANNOTATOR_ONLY:* `OpenInAppButton`. - -### 9. Theme & tokens. Effort S (safe). -- *As-is:* `theme.css` + 51 `themes/*.css` + `print.css` as **one atomic commit**. `themeRegistry` + `ThemeProvider` together. -- *Seam:* inject `storage` into `ThemeProvider` + `uiPreferences`; optional `mode?` on `MarkdownEditor`. -- **Guardrail:** do not touch synchronous `applyThemeClasses` (L96-98) or the rAF `transitions-ready` toggle (L107-111) — reordering causes FOUC. Keep `@source` globs in lockstep if files move. - -### 10. Markdown editor. Effort S (lowest-coupled). -- `MarkdownEditor.tsx` is a 41-line theme-bridge over published `@plannotator/markdown-editor@0.1.0` + `@atomic-editor/editor@0.4.3`. `editorMode.ts` is glue (App.tsx-only). -- **Guardrail:** keep `GRID_CARD_CLASSES` under a `@source`-scanned path (else grid card loses border/shadow). - -### 11. PLANNOTATOR_ONLY — never imported by Workspaces (no work). -`useAutoClose` (Glimpse), `useEditorAnnotations` (`window.__PLANNOTATOR_VSCODE`), `useUpdateCheck` (hardcoded github releases), `useArchive`/`ArchiveBrowser`, `useAgents`/`useAgentJobs`, `GoalSetupSurface`, `planAgentInstructions`, `annotateAgentTerminal` (ws:// derivation), `useSharing` `/p/` routing. They stay in the app shell. - -### 12. Ask AI. Effort M (risky — mechanical-move-only). -- *Seam:* extract **exactly** the 5 fetch literals in `useAIChat` behind a default `transport`. **Do NOT touch** the SSE reader loop (L233-304), epoch/createRequest guards, or the supersede-abort fetch position (L153-158). Capabilities fetch + provider resolution + cookie `aiConfig` init stay in the **shell** (pulling them into the lib is the forbidden re-derivation). - -## 3. Top cross-cutting parity risks - -1. **Cookie-storage swapped globally.** `storage.ts` underlies ~24 modules. Inject per-host; never change the default; keep literal `plannotator-*` keys. Otherwise Plannotator loses theme/layout/identity persistence across random-port hook invocations. -2. **`getImageSrc` resolved per-component** instead of the one shared resolver → some images break with no type error. Single override over the existing default. -3. **Over-extracting glue coordinators (the reverted-approach trap).** App.tsx's panel toggles entangle wide-mode exit + agent-terminal teardown; sidebar auto-open/close is policy keyed on `tocEnabled`/`hasTocEntries`/`isPlanDiffActive`. Keep these as opaque PLANNOTATOR_ONLY glue. -4. **Identity drift.** If `author`/`isCurrentUser` default to `undefined`/`''` instead of live `getIdentity()`/`isCurrentUser`, annotations lose author + `(me)` ownership silently. -5. **CSS that ships in the app shell, not the package** (plan-diff rules, font `@import`s, Tailwind `@source`, `GRID_CARD_CLASSES`). Move files without updating `@source` in lockstep → utilities render unstyled. Silent visual breakage in Plannotator's own build. -6. **Re-render instability from non-stable injected callbacks** (HtmlBlock memo/deps) → collapses open `
`. Keep defaults module-level. -7. **SSE→polling fallback / draft-generation protocols** are timing-sensitive state machines — move as **copies**, not re-derivations. - -## 4. Glue guidance (App.tsx) — be conservative - -**Push DOWN (default = today):** -- The seam defaults (image resolver, doc fetch, upload, draft/external transports, configStore write-back, obsidian detect, save-notes) — defaults live at the App.tsx call site wrapping the current literal. -- `packages/editor/wideMode.ts` → `packages/ui/utils/wideMode.ts`: two pure functions, no relative/circular deps — byte-identical move + one import-path edit (App.tsx:109). Effort S. -- Ship a `ScrollViewportContext` provider/wrapper with the package. - -**LEAVE in the glue (PLANNOTATOR_ONLY — do NOT genericize):** -- Bootstrap from `/api/plan`, approve/deny hook flow, `getDraftGeneration` submit-body wiring. -- Right-panel/wide-mode/agent-terminal coordinators + auto-open/close sidebar policy (risk #3). -- `fileBrowserDirs` derivation + `showFilesTab` + load-orchestration; tab-visibility `show*Tab` + archive lazy-fetch. -- AI capabilities fetch + provider resolution + cookie `aiConfig` init. -- Panel-resize CSS-var writes (`--rpanel-w`/`--toc-w`/`--agent-terminal-w`). - -**Hard rule for the draft's "step 7" (push layout into components):** keep `show*Tab`, `width`, `onTabChange` (with its archive side effect) as **opaque props/callbacks**. `useSidebar`/`useResizablePanel`/`SidebarContainer` are already prop-driven and already reused by `review-editor/App.tsx` — Workspaces writes its **own** coordinator over the same primitives. Re-deriving the coordinator generically is the forbidden path. - -## 5. Packaging blockers (verified) - -| Blocker | Severity | Fix (no logic change) | -|---|---|---| -| `@plannotator/ai` + `@plannotator/shared` are `workspace:* / private / 0.0.1` | HARD | Publish both (real version) or inline the ~11 browser-safe value-imported subpaths | -| Phantom `dompurify` dep (imported, not declared) | HARD | Add to ui deps at exact `^3.3.3` | -| No `peerDependencies` block | MED | Move react/react-dom/tailwindcss(-animate)/radix/lucide to peers; keep devDeps | -| Fonts + Tailwind `@source` live in consumer `index.css` | MED | Ship a documented CSS entry; host on Tailwind v4 | -| Source-only `exports` (no `main`/`module`/`types`) | MED | Keep source model + document bundler settings; no dist build | -| `diff` version drift (`^8.0.3` vs `^8.0.4`) | LOW | Align to `^8.0.4` | -| Stale tsconfig alias → nonexistent `../shared/index.ts` | LOW | Fix when converting shared off `workspace:*` | -| Static asset imports + no `files` allowlist | LOW | Add `files` incl. assets/sprites/themes; exclude tests | - -**Non-blockers (verified — do not "fix"):** all `@plannotator/shared` value imports are Web-API-only; `@plannotator/ai` is `import type` only; `@plannotator/shared/storage` (node:fs) is `import type` only (erased under `isolatedModules`). `theme.css` is pure. diff --git a/adr/specs/document-ui-extras-seam-20260623-100827.md b/adr/specs/document-ui-extras-seam-20260623-100827.md deleted file mode 100644 index cea8c1848..000000000 --- a/adr/specs/document-ui-extras-seam-20260623-100827.md +++ /dev/null @@ -1,53 +0,0 @@ -# Spec: Phase 6 Extras Seams - -Date: 2026-06-23 · Status: Draft (iterate before implementing) - -> Implementation spec for Phase 6 of the `@plannotator/ui` reuse effort. Grounded in `SPIKE-document-ui-extras-system-20260623-100827.md` + its synthesis. Governed by ADR 004. THE LAW: each seam defaults to today's literal behavior; Plannotator passes nothing and is byte-for-byte unchanged. Move + decouple, never rewrite — especially the AI reader loop and the configStore batching. - -## Scope -**In (5 seams + 1 CSS move):** version fetchers, vscode-diff action, the block/raw diff CSS relocation, config write-back, obsidian-detect, save-to-notes, AI transport. -**Confirmed noop (already portable):** planDiffEngine + all plan-diff render components, sharing.ts/useSharing/ImportModal, obsidian/bear/octarine/callback/defaultNotesApp utils, settings.ts, DocumentAIChatPanel/AIProviderBar, aiProvider/aiChatFormat. -**Out (Plannotator-only, stay home):** OpenInAppButton, HooksTab, useUpdateCheck, useAgents, useAgentJobs. - -## Order of work (lowest risk first; AI last) - -### Step 1 — Versions / diff -**Files:** `packages/ui/hooks/usePlanDiff.ts`, `packages/ui/components/plan-diff/PlanDiffViewer.tsx`, `packages/editor/index.css` → `packages/ui/theme.css`. -- **1a. Version fetchers.** Add an optional `fetchers?: { fetchVersion?, fetchVersions? }` arg to `usePlanDiff` (or module-level setters following the seam pattern). Default = today's `fetch('/api/plan/version?v=N')` and `fetch('/api/plan/versions')` verbatim. **Keep error asymmetry:** `selectBaseVersion` still `alert()`s on failure; `fetchVersions` still silent. -- **1b. VS Code diff.** Add optional `onOpenVscodeDiff?: (baseVersion: number) => Promise<{ ok?: boolean; error?: string }>` to `PlanDiffViewer`; default = today's `fetch('/api/plan/vscode-diff')`. Plannotator passes nothing → unchanged (button still works). -- **1c. CSS move.** Cut `.plan-diff-added/removed/modified/unchanged` and `.plan-diff-line-added/removed` from `editor/index.css` (L168-230) into `packages/ui/theme.css` (next to `.plan-diff-word-*`). Also move `.annotation-highlight*` (L119-157) into `theme.css` (it's required by the shared Viewer in any host — closes a latent gap). Plannotator imports `theme.css`, so it stays identical; verify no double-definition remains in `index.css`. -- **Parity guardrail:** Plannotator's plan diff renders pixel-identical (block borders, raw +/-, word-level, annotation highlights); no caller passes fetchers/onOpenVscodeDiff. Eyeball: deny→resubmit a plan, toggle diff (clean/classic/raw), annotate a diff block, VS Code button still works. - -### Step 2 — Settings / config -**Files:** `packages/ui/config/configStore.ts`, `packages/ui/components/Settings.tsx`. -- **2a. Config write-back.** Add `setServerSync(fn)` (and a default = the current inline `fetch('/api/config', POST)`); `scheduleServerSync` calls the injected fn for the final POST only. **Keep the 300ms debounce, `pendingServerWrites` deepMerge batching, singleton construction, and eager cookie reads byte-identical.** -- **2b. Obsidian detect.** Add optional `onDetectObsidianVaults?: () => Promise` to `Settings`; default = today's `fetch('/api/obsidian/vaults')`. **Keep the `useEffect` dep `[obsidian.enabled]` and the auto-select-first-vault branch verbatim.** -- **Parity guardrail:** settings still POST `/api/config` with identical batching/timing; vault auto-select still fires on enable. No caller overrides. Eyeball: change a setting → it persists; enable Obsidian → vaults detected + first auto-selected. - -### Step 3 — Sharing / export / notes -**Files:** `packages/ui/components/ExportModal.tsx`. -- Add optional `onSaveToNotes?: (payload) => Promise<{ results?: Record }>` (match today's response shape); default = today's `fetch('/api/save-notes')`. **Keep `showNotesTab = isApiMode && !!markdown` (L83) byte-for-byte** — do not re-base on the new prop. -- (sharing.ts/useSharing/ImportModal/notes-app utils confirmed noop.) -- **Parity guardrail:** notes tab visibility unchanged; save returns identical `{success, error}`. Eyeball: Export → Notes tab shows when expected → save to Obsidian works. - -### Step 4 — Ask AI (riskiest, last) -**Files:** `packages/ui/hooks/useAIChat.ts`. -- Add an `AITransport` (session/query/abort/permission) + module-level default reproducing today's five `/api/ai/*` fetches verbatim. Route the fetch calls through it. -- **DO NOT TOUCH:** the SSE reader loop (L233-304), the epoch/createRequest guards (refs L109-110; checks L152/L208; resets L376-390), and the supersede-abort fetch **inside `createSession` immediately after the epoch check** (L153-158). Only the wire is parametrized. -- **Stays host-owned:** `/api/ai/capabilities` and `resolveAIProviderSelection`/cookie `aiConfig` (already in App.tsx — do not pull into the lib). -- **Parity guardrail:** identical AI traffic; streaming, permissions, abort, and session-supersede all behave as today. No caller overrides. Eyeball: ask the AI a question (streams), trigger a permission, switch questions mid-stream (supersede), abort. - -## Definition of done (Phase 6) -- The five seams are host-overridable, each defaulting to today's behavior; the diff CSS lives in the package. -- Plannotator byte-unchanged: full `bun test` ≥ baseline (1620/0); typecheck; builds; `App.tsx` changes minimal/empty (ideally zero — module-level/optional-prop defaults). -- Eyeball: plan diff (all modes + vscode + annotate), settings persist + obsidian detect, save-to-notes, AI chat (stream/permission/abort/supersede). -- The five Plannotator-only pieces remain host-owned (untouched). - -## Per-step parity guardrail (run after each) -`bun run typecheck` · `bun test` ≥ 1620/0 (+ touched suite green) · `bun run --cwd apps/review build && bun run build:hook` · `git diff packages/editor/App.tsx` minimal/empty · the step's manual eyeball. - -## Open questions (resolve in ADR) -1. CSS: move `.plan-diff-*` + `.annotation-highlight` into `theme.css` (recommended) vs. document-as-contract. -2. Confirm the five Plannotator-only exclusions (recommended). -3. Per-seam commits (recommended) vs. one PR. -4. usePlanDiff seam shape: extra hook arg vs. module-level setter (lean: optional arg for the fetchers since usePlanDiff already takes args; module-level setters elsewhere). diff --git a/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md b/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md deleted file mode 100644 index 6f2a03b55..000000000 --- a/adr/specs/document-ui-feature-completeness-review-fixes-20260622-085528.md +++ /dev/null @@ -1,519 +0,0 @@ -# Spec: Document UI Feature Completeness Review Fixes - -> ⚠️ **FAILED ATTEMPT — USE AS A CHECKLIST ONLY, NOT A BUILD PLAN.** This catalogs the parity gaps in the reverted `@plannotator/document-ui` cutover (reverted 2026-06-22). It is a useful *inventory of behaviors the UI must preserve*, but do NOT implement it as written — it patches a reimplementation that was thrown away. Corrected plan: **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. - -Date: 2026-06-22 - -Status: In Progress - -## Intent - -Close the verified post-review parity gaps in the `@plannotator/document-ui` cutover so the package surface is feature-complete for Plannotator's Plan Review and Annotate app. - -The branch already performs a real architecture cutover: `packages/editor/App.tsx` mounts the new host, and `@plannotator/document-ui` owns much of the document-review state. The remaining work is not to restart the extraction. The remaining work is to wire the production surface to the behavior that was extracted, or to port the few still-missing UI entry points. - -Feature-complete means the normal Plan Review and Annotate production path can ship without the old App shell and without knowingly dropping user-facing behavior from the pre-cutover app. - -## Current Read - -The review findings are mostly valid. The package has many of the right helpers and tests, but the mounted production path does not yet call or expose several of them. - -Confirmed high-impact gaps: - -- Header actions are hidden in Plan Review because the whole `slots` object is gated on agent terminal availability. -- Submit, approve, and close call host APIs directly instead of routing through the extracted safety decisions. -- Annotate-last message data is created by the adapter but not rendered or sent back as an active message scope. -- External annotations and VS Code editor annotations still have server endpoints and shared hooks, but the document surface does not subscribe to them. -- Plan diff uses a new read-only renderer instead of the existing interactive diff viewer with block annotations and VS Code diff support. - -Confirmed secondary gaps: - -- Saved source-change validation exists but is not called before submit/approve. -- Shortcut registries exist but are not registered in the new surface. -- Code-file popout and code-file annotation entry points are not wired from `Viewer`. -- Raw HTML share does not call `/api/share-html` to build portable HTML with inlined relative assets. -- Wide/focus mode helpers exist but are unreachable. -- The new sidebar is versions plus file tree, without old TOC/archive/vault/reference parity. -- Settings are partially stubbed after the menu is restored. -- Print, checkbox task toggles, product announcements, and dead old header cleanup remain smaller follow-ups. - -## Definition Of Done - -The document UI cutover is feature-complete when: - -- Plan Review has Settings, Export, Share, Import, print, note integrations, and any host header actions visible without requiring agent terminal support. -- Approve, Send Feedback, Close, gate-mode approve, and submit shortcuts all run the same safety checks as the old app. -- Unsaved writeback edits, feedback-loss cases, stale saved source changes, missing files, and no-op saved changes are handled before delivery. -- Annotate file, annotate folder, annotate raw HTML, and annotate-last all preserve their feedback target and navigation behavior. -- External annotations and editor annotations appear in the UI and are included in exported/submitted feedback. -- Plan diff/version review supports interactive diff annotations and the VS Code diff affordance. -- The expected keyboard shortcuts work from the production surface. -- Code-file links can open the code-file popout and create code-file annotations where supported. -- Portable sharing of raw HTML sessions preserves relative assets. -- Existing package and editor tests pass, and targeted regression tests cover the formerly missing wiring. - -## P0 Required Fixes - -### 1. Always Mount Header Actions - -Problem: - -`PlannotatorDocumentSurfaceBridge` returns `undefined` slots when `terminalAvailable` or `agentTerminal` is false. This hides `PlanHeaderMenu`, Settings, Export, Import, Share, print, and note actions in normal Plan Review. Agent terminal is only available for annotate file/folder sessions, so the primary Plan Review flow loses its menu. - -Required behavior: - -- `headerActions` must always be provided for supported Plannotator sessions. -- Only `terminalPanel` and terminal-specific header buttons should be gated by terminal availability. -- Plan Review and Annotate should both receive the common Plannotator host actions. - -Implementation shape: - -- Split `hostHeaderActions` from terminal slots in `PlannotatorDocumentSurfaceBridge`. -- Return a slots object unconditionally, with `terminalPanel` set to `null` when unavailable. -- Keep terminal delivery logic unchanged. - -Acceptance: - -- Plan Review shows the options menu with Settings, Export, Share, Import, and print. -- Annotate sessions still show terminal controls only when terminal capability is available. -- Add a render test that plan-review mode includes `data-document-review-header-actions` and the menu trigger when terminal is unavailable. - -### 2. Wire Action Safety Decisions - -Problem: - -The package contains decision helpers for feedback loss, unsaved writeback edits, gate-mode primary action, print shortcut behavior, and submit shortcut behavior. The production buttons call `state.submitFeedback()`, `state.approve()`, and `state.exit()` directly. - -Required behavior: - -- Before Send Feedback, Approve, or Close, the surface must evaluate extracted chrome/action decisions. -- Unsaved writeback edits must warn before close, approve, or send feedback. -- Approve must warn when feedback would be lost. -- Close must warn when feedback would be lost. -- Gate-mode annotate should approve when there is no feedback and send feedback when feedback exists. -- Submit shortcut behavior must use the same action decision as the primary button. - -Implementation shape: - -- Add a small action coordinator inside `DocumentReviewSurface` or as a package hook. -- Render a package-owned confirmation dialog using copy from `documentReviewChrome.ts`. -- Keep host APIs simple: hosts should receive the final approved submit/approve/exit call, not own these generic decisions. -- Use existing `buildUnsavedDocumentEditContinuationDecision`, `decideDocumentReviewFeedbackAction`, `decideDocumentReviewApproveAction`, `decideDocumentReviewExitAction`, `decideDocumentReviewPrimarySubmitAction`, and `buildUnsavedDocumentEditWarningCopy`. - -Acceptance: - -- Tests prove dirty writeback documents block direct submit/approve/close until confirmed. -- Tests prove approve warns when feedback would be lost. -- Tests prove close warns when feedback would be lost. -- Tests prove gate-mode empty annotate primary action calls approve. -- Manual Plan Review: add annotation, click Approve, see feedback-loss warning where old app warned. - -### 3. Reconnect Annotate-Last Message Workflow - -Problem: - -`createPlannotatorEditorLoadPlan()` builds `messages.recentMessages` and `messages.selectedMessageId`, and the Plannotator delivery layer can submit `selectedMessageId` and `feedbackScope`. The new production host does not pass or render `loadPlan.messages`, and `createFeedbackPayload()` only includes `messageScope` when it is manually injected. - -Required behavior: - -- Annotate-last must show the recent message set or an equivalent message navigation UI. -- The selected message must be visible and changeable. -- Annotations must stay associated with their selected message. -- Feedback must include `selectedMessageId`. -- If multiple messages are annotated, feedback must include `feedbackScope: "messages"` or the provider-neutral equivalent expected by the adapter. - -Implementation shape: - -- Prefer modeling messages as provider-neutral documents, if that can be done without distorting the contract. -- Otherwise add a narrow message session controller owned by `DocumentReviewSurface` or the Plannotator bridge. -- Cache annotations per message using existing linked-document/message state helpers where possible. -- Pass the resolved `messageScope` into `createFeedbackPayload()`. - -Acceptance: - -- Annotate-last opens with the selected recent message. -- Switching messages restores that message's annotations. -- Annotating one message sends that message id. -- Annotating multiple messages sends multi-message scope. -- Existing message feedback formatting remains unchanged. - -### 4. Reconnect External And Editor Annotations - -Problem: - -`useExternalAnnotations` and `useEditorAnnotations` still exist in `@plannotator/ui`, and server endpoints still exist. The document surface does not subscribe to them, does not show them in the panel, and does not include editor annotations in feedback. - -Required behavior: - -- External annotations posted to `/api/external-annotations` appear in Plan Review and Annotate where applicable. -- VS Code editor annotations appear when running inside VS Code. -- External annotation updates and deletes are reflected in the UI. -- Editor annotations can be deleted from the UI. -- Feedback/export includes editor annotations and external annotations in the same wording as before. - -Implementation shape: - -- Add optional host/provider annotation channels to `DocumentHostApi`, or provide Plannotator host hooks through surface slots if route names must remain host-owned. -- Keep route names and SSE transport Plannotator-owned. -- Merge external annotations into surface annotation state without duplicating persisted local annotations. -- Pass editor annotations into feedback text assembly. -- Reuse existing `AnnotationPanel`/`EditorAnnotationCard` behavior where possible. - -Candidate host API: - -```ts -interface DocumentHostApi { - watchExternalAnnotations?( - request: WatchExternalAnnotationsRequest, - ): ExternalAnnotationSubscription; - deleteExternalAnnotation?(request: DeleteExternalAnnotationRequest): Promise; - updateExternalAnnotation?(request: UpdateExternalAnnotationRequest): Promise; - loadEditorAnnotations?(request: LoadEditorAnnotationsRequest): Promise; - deleteEditorAnnotation?(request: DeleteEditorAnnotationRequest): Promise; -} -``` - -Acceptance: - -- Posting a plan-review annotation through `/api/external-annotations` shows it without reload. -- Deleting an external annotation removes it. -- VS Code editor annotations appear inside the document review feedback panel. -- Submitted feedback includes editor annotations. - -### 5. Restore Interactive Plan Diff Parity - -Problem: - -The new `DocumentVersionDiffViewer` renders read-only diff blocks. The old `PlanDiffViewer` supports clean/raw modes, block-level diff annotation, selected diff annotations, and `/api/plan/vscode-diff`. - -Required behavior: - -- Version diff supports diff annotations with `diffContext`. -- Diff annotations appear in the feedback panel and exported/submitted feedback. -- The VS Code diff button works for Plannotator plan versions. -- Provider-neutral hosts can choose whether an external diff action is available. - -Implementation shape: - -- Prefer reusing `@plannotator/ui/components/plan-diff/PlanDiffViewer` in the package renderer module. -- If direct reuse is too coupled, port the interaction model into `DocumentVersionDiffViewer`. -- Replace direct `fetch("/api/plan/vscode-diff")` with an optional host API action. - -Candidate host API: - -```ts -interface DocumentHostApi { - openDocumentVersionDiff?(request: OpenDocumentVersionDiffRequest): Promise; -} -``` - -Acceptance: - -- Plan Review with prior versions shows interactive diff blocks. -- Hovering/clicking changed diff blocks can create comments/deletions/quick labels. -- Diff annotations include `[In diff content]` in submitted feedback. -- VS Code diff opens through the Plannotator host when a base version exists. -- Workspaces can omit the external diff action without breaking diff review. - -### 6. Validate Saved Source Changes Before Delivery - -Problem: - -Saved source-change validation exists, but submit/approve does not call it. Old behavior protected against stale disk state, missing files, and no-op saved edits before feedback delivery. - -Required behavior: - -- Before submit or approve, saved changes must be validated when the provider supports probing. -- Stale, missing, and no-op saved changes must be dropped or warned according to existing decisions. -- Unverified changes must be preserved when validation cannot prove they are stale. - -Implementation shape: - -- Keep generic validation in `@plannotator/document-ui`. -- Keep local source-save probe logic in the Plannotator host/adapter. -- Route Plannotator `validateSavedFileChanges()` into the surface action coordinator before delivery. - -Acceptance: - -- Tests cover valid, stale, missing, no-op, and unavailable saved-change probes. -- Submit payload only includes valid/unverified saved changes. -- UI reports dropped saved changes clearly enough for the user to understand what happened. - -## P1 Feature Completeness Fixes - -### 7. Register Keyboard Shortcuts - -Required behavior: - -- `Mod+Enter` submits the primary action. -- `Mod+P` opens print while preserving print-mode CSS behavior. -- `Escape` exits plan diff when diff is active. -- Input-method double-tap shortcuts work where supported. -- Shortcuts respect dialogs, text input focus, editing state, and submitted/exiting states. - -Implementation shape: - -- Register the existing `planReviewSurface` and `annotateSurface` shortcut scopes in the new surface/host. -- Use extracted `decideDocumentReviewSubmitShortcut` and `decideDocumentReviewPrintShortcut`. -- Wire `usePrintMode()` in the mounted app. - -Acceptance: - -- Shortcut tests cover disabled states and text input focus. -- Manual smoke confirms `Mod+Enter` and `Mod+P`. - -### 8. Restore Code-File Popout And Code Annotations - -Required behavior: - -- Markdown/PFM code-file links can open the code-file popout. -- Code-file annotations can be created and submitted. -- Code path validation continues to run through the host. - -Implementation shape: - -- Pass `onOpenCodeFile` into `Viewer`. -- Mount `CodeFilePopout` from `@plannotator/ui`. -- Use existing `useCodeFilePopout()` and host API code-file loading. -- Keep local filesystem route details in Plannotator host/adapter. - -Acceptance: - -- Clicking a code-file link opens the popout. -- Creating a code annotation adds it to the panel. -- Submitted feedback includes code-file annotations. - -### 9. Restore Portable Raw HTML Sharing - -Required behavior: - -- Raw HTML annotation sessions shared through Export/Share use portable HTML with relative assets inlined. -- The display HTML used by the review iframe should not be assumed to be the share HTML. - -Implementation shape: - -- Add `prepareShareHtml?` to the host API, or keep it as a Plannotator header action helper. -- Plannotator implementation calls `/api/share-html`. -- Cache prepared share HTML per active HTML document where sensible. - -Acceptance: - -- Sharing an HTML file with relative images/styles produces a share that renders correctly outside the local server. -- Markdown sharing remains unchanged. - -### 10. Restore Wide/Focus And Chrome Polish Needed For Parity - -Required behavior: - -- Wide/focus mode is reachable when `allowWideMode` is enabled and unavailable in archive/diff states. -- Left and right panels behave consistently with wide/focus transitions. -- Sticky controls, panel collapse, resize behavior, and visible document max-width are close enough to old Plan Review/Annotate behavior for normal use. - -Implementation shape: - -- Wire `documentWideMode.ts`, `documentReviewLeftSidebar.ts`, and `documentReviewRightPanel.ts` into `DocumentReviewSurface`. -- Keep user preference persistence host-owned or option-driven. -- Avoid making Plannotator-only settings required by core document UI. - -Acceptance: - -- Wide/focus controls exist when enabled. -- Entering wide/focus hides panels and can restore previous layout. -- Diff/archive states do not leave the layout stuck. - -### 11. Sidebar And Reference Parity - -Required behavior: - -- The left sidebar should cover the core old navigation workflows: TOC, versions, file tree, and in-session archive/reference access if those remain expected in Plan Review. -- Folder annotate should show the file tree with badges and writeback status. -- `openSidebarTab` from the load plan must be honored. - -Implementation shape: - -- Keep generic sidebar mechanics in the package. -- Keep Obsidian vault discovery and archive storage Plannotator-host owned. -- Use slots for Plannotator-only archive/vault/reference tabs if they are not generic. - -Acceptance: - -- Folder annotate opens the files tab by default. -- Archive mode or archive tab behavior matches the decided scope. -- TOC is available for long markdown documents if parity requires it. - -### 12. Finish Settings/Header Integration - -Required behavior: - -- Settings opened from the restored header should have real AI provider data. -- App version should come from package/app metadata, not `0.0.1`. -- Agent instruction copy should be enabled if that feature remains supported. -- Tater/grid/user display settings should either work or be explicitly declared out of scope. - -Implementation shape: - -- Plannotator host owns these values and passes them to the header slot. -- The package only exposes slot props and surface state needed by host actions. - -Acceptance: - -- Settings AI tab shows available providers. -- Header About/version is correct. -- Agent instructions copy works or is intentionally removed with tests/docs updated. - -## P2 Cleanup And Explicit Non-Goals - -These items should not block the feature-complete cutover unless the user/product bar says otherwise: - -- Product announcement dialogs for Plan AI and Look & Feel. These are product-owned notices, not core document review behavior. -- Moving Plannotator adapter subpath exports out of `@plannotator/document-ui`. This is boundary cleanup, not a Plannotator parity blocker. -- Deleting dead `AppHeader.tsx` and other old shell remnants once no imports remain. -- Re-adding `VITE_DIFF_DEMO` fallback behavior. This is dev/demo-only. -- Full old visual chrome parity for every ornamental detail. Preserve workflow capability first, then polish. - -## Callback Scope Decision - -Shared/hash session callback support exists in `PlannotatorSharedSessionHost`. Normal API-mode callback query support was not found in the new production host path. - -Decision needed: - -- If `?cb=&ct=` was only a shared-session workflow, no P0 work is needed. -- If API-mode sessions still need callback approval/feedback, add callback config parsing to `PlannotatorDocumentSurfaceHost` and route submit/approve through the same callback utility. - -Acceptance if in scope: - -- API-mode callback URLs preserve feedback and approval behavior. -- Shared-session callback behavior remains unchanged. - -## Package Boundary Requirements - -The package should own generic document review behavior: - -- annotation lifecycle -- feedback assembly -- writeback state and writeback warnings -- draft restore -- document tree/navigation -- version diff and diff annotations -- shortcuts and generic chrome decisions -- generic code-file preview hooks if a host can load targets - -The host should own environment behavior: - -- Plannotator route names -- note apps -- share/paste policy -- app version and settings data -- agent terminal runtime -- local source-save probing -- VS Code diff route -- external annotation transport route names -- archive/vault storage mechanics - -The Workspaces integration should be able to implement `DocumentHostApi` without importing Plannotator local source-save concepts. Any new host API should use provider-neutral names such as writeback, versions, annotations, external diff, and prepared share HTML. - -## Test Plan - -Unit and integration tests: - -- `bun test packages/document-ui` -- `bun test packages/editor` -- `bun run typecheck` -- `bun run --cwd apps/hook build` -- `git diff --check` - -New or updated tests should cover: - -- Header actions visible without terminal. -- Submit/approve/close safety warnings. -- Gate-mode primary action decision. -- Annotate-last selected and multi-message feedback scope. -- External annotation subscription/update/delete merge behavior. -- Editor annotation feedback inclusion. -- Diff annotation creation and feedback inclusion. -- Optional external version diff host action. -- Saved-change validation before delivery. -- Shortcut registration and blocking states. -- Code-file popout open path and code annotation feedback. -- Raw HTML share HTML preparation. - -Manual smoke tests: - -- Plan Review from `ExitPlanMode`: menu, approve, deny/send feedback, diff, settings, export/share/import. -- Annotate markdown file: annotations, source save, saved-change validation, close warnings. -- Annotate folder: file tree, badges, open files, writeback statuses. -- Annotate raw HTML with relative assets: render, annotate, share. -- Annotate-last: select messages and submit one-message and multi-message feedback. -- VS Code mode if available: editor annotations and VS Code diff. -- External annotation API: post, update, delete while UI is open. - -## Implementation Order - -1. Fix header slots so Plan Review has the menu again. -2. Wire the action coordinator and confirmation dialogs. -3. Add saved-change validation into the same action path. -4. Reconnect annotate-last message state and `messageScope`. -5. Reconnect external/editor annotations. -6. Restore interactive plan diff and VS Code diff host action. -7. Register shortcuts and print mode. -8. Restore code-file popout/code annotations. -9. Restore portable raw HTML sharing. -10. Wire wide/focus/sidebar parity and settings polish. -11. Remove dead old shell leftovers after the feature-complete path is verified. - -## Scope Decisions - -- Recent messages stay as provider-neutral message review state for this PR, not as regular `DocumentRef` entries. The surface owns message navigation/cache behavior and the Plannotator adapter maps it into annotate-last delivery. -- Standalone archive host is enough for this PR. In-session archive/vault/reference sidebar tabs remain P1/product-scope work. -- API-mode callback support is not required for this cutover. The old header only rendered callback actions for non-API shared sessions, and shared/hash callback support remains preserved. -- Restore workflow parity, not pixel-perfect old chrome. -- External/editor annotations are generic optional `DocumentHostApi` watch/delete capabilities, with Plannotator route names kept inside the Plannotator HTTP adapter. - -## Implementation Status: 2026-06-22 - -Completed in the current worktree: - -- Header actions are mounted in Plan Review without requiring annotate terminal support. -- Send Feedback, Approve, Close, and primary submit paths now route through package-owned action safety checks and confirmation dialogs. -- Saved source-file changes are validated before submit/approve, with stale, missing, and no-op changes filtered before delivery. -- Annotate-last message state is surfaced through a message navigator, cached per message, and submitted with `messageScope` and `messageAnnotations`. -- External annotations and VS Code editor annotations are exposed through a provider-neutral annotation watch/delete host API, rendered in the surface, and included in feedback assembly. -- Plan version diffs use the interactive plan diff viewer again, restoring block annotations and the Plannotator VS Code diff affordance. -- Basic production shortcuts and print behavior are restored for `Mod+Enter`, `Mod+P`, and diff `Escape`. -- Input-method switching is package-owned again: the surface owns mutable `drag`/`pinpoint` state and registers the existing Alt hold / Alt Alt input-method hook. -- Code-file links can open the code-file popout and create code-file annotations through the package surface. -- Raw HTML export/share preparation calls the Plannotator `/api/share-html` route before falling back to display HTML. -- Wide/focus controls are exposed by the package surface when `allowWideMode` is enabled, Plannotator enables them in the production bridge, and active wide/focus mode hides side panels until exit. -- Header settings now receive real AI provider capability data, the app version value, and enabled agent-instruction copy behavior. -- The unused old-shell `packages/editor/components/AppHeader.tsx` file and stale read-only diff renderer helpers were removed. -- Callback compatibility was audited: the old header only rendered bot callback actions for non-API shared sessions, and shared/hash callback sessions remain handled by `PlannotatorSharedSessionHost`. - -Verified: - -- `bun test packages/document-ui` -> 357 pass, 0 fail. -- `bun test packages/editor` -> 64 pass, 7 skip, 0 fail. -- `bun run typecheck` -> pass. -- `bun run --cwd apps/hook build` -> pass. -- `git diff --check` -> pass. -- Vite dev server smoke: `bun run --cwd apps/hook dev --host 127.0.0.1` served `http://127.0.0.1:3000/`, `/api/plan`, and `/api/plan/versions` successfully. -- Playwright Chromium smoke against the Vite-rendered production app path passed for: - - Plan Review header menu, Settings menu item, interactive diff view, wide-mode toggle, external annotation display, editor annotation display, print shortcut, and approve delivery. - - Plan Review share-link copy, global-comment creation, and deny/send-feedback delivery through `/api/deny`. - - Annotate markdown source-save edit/save via `/api/source/save`. - - Annotate folder document-tree expansion and `/api/doc` navigation. - - Annotate raw HTML share/export preparation via `/api/share-html`. - - Annotate-last multi-message navigator rendering. -- Playwright Chromium SSE smoke passed for browser consumption of `/api/external-annotations/stream` snapshot events. - -Additional focused checks after wide/focus, input-method, raw HTML share, code-file URL, and cleanup wiring: - -- `bun test packages/document-ui/DocumentReviewSurface.test.tsx packages/document-ui/DocumentReviewSurface.interaction.test.tsx packages/editor/PlannotatorDocumentSurfaceBridge.test.tsx` -> 36 pass, 0 fail. -- Interaction coverage now dispatches `Mod+Enter`, `Mod+P`, `Alt`, and wide-mode clicks against a mounted DOM surface. -- Bridge coverage now verifies `/api/share-html` is called for raw HTML export/share and falls back safely when portable HTML is unavailable. -- Code-file coverage now verifies the popout `/api/doc` URL boundary uses the target path and active document base. -- `bun run typecheck` -> pass. - -Still open or requiring manual confirmation: - -- Old sidebar/reference parity is intentionally not a P0 blocker for this PR under the current scope decision: standalone archive host is enough, while in-session archive/vault/reference tabs remain P1/product-scope work. -- Host-only integrations still need manual confirmation in their native environments: the VS Code extension/editor-annotation producer and real external-annotation producers. The browser-rendered consumer paths are covered by the Playwright smoke above. diff --git a/adr/specs/document-ui-parity-cutover-20260621-121115.md b/adr/specs/document-ui-parity-cutover-20260621-121115.md deleted file mode 100644 index 85c96046a..000000000 --- a/adr/specs/document-ui-parity-cutover-20260621-121115.md +++ /dev/null @@ -1,467 +0,0 @@ -# Spec: Document UI Parity Cutover - -> ⚠️ **REVERTED — DO NOT IMPLEMENT.** Spec for the failed cutover (reverted 2026-06-22). The corrected plan is **`adr/decisions/004-reuse-document-ui-as-published-building-blocks-20260622-180637.md`**. Kept here as history only. - -Date: 2026-06-21 - -Status: Draft - -## Intent - -Finish the `@plannotator/document-ui` extraction so the Plan Review / Annotate app uses the package as the real production document surface, with no parallel legacy document UI path left behind. - -The target is not to move every Plannotator feature into the shared package. The target is to move the reusable document-review experience into the package, then leave Plannotator-specific environment behavior in a small host shell. - -## Target State - -`packages/editor/App.tsx` should stop being the document-review product. It should become a Plannotator host shell that: - -- loads the session through the Plannotator adapter -- reads Plannotator settings and environment capabilities -- wires Plannotator-only routes and side effects -- provides host slots for settings, export/share, note integrations, archive, goal setup, and terminal -- renders `DocumentReviewSurface` - -The normal production path should not require: - -```text -VITE_DOCUMENT_SURFACE=1 -``` - -`VITE_DOCUMENT_SURFACE` should be removed once parity is reached. - -## Ownership Rule - -The package owns the document review loop. - -The host owns environment policy. - -### Package owns - -- markdown and raw HTML document review -- annotation creation, editing, deletion, selection, and persistence hooks -- global comments and image attachments -- linked document navigation -- document tree/file tree UI and badges -- message/document navigation when messages are represented as documents -- source/document editing UI -- writeback states: clean, dirty, saving, saved, conflict, missing, error -- draft restore UI and state -- feedback payload assembly -- plan/document version browsing and diff UI -- generic Ask AI panel and in-document ask affordances when a host AI API exists -- code/link preview UI when the host can load or validate targets -- generic shortcuts for document review actions -- default chrome needed for parity: toolstrip, sticky controls, sidebars, panels, empty states, banners, and action buttons - -### Host owns - -- server routes -- auth -- browser opening and process lifetime -- CLI/plugin/hook integration -- `ExitPlanMode` stdout decisions -- Plannotator settings persistence -- share/paste service policy -- import/export modal policy -- Obsidian, Bear, and Octarine integrations -- agent terminal runtime, PTY/WebSocket bridge, installer, and remote security policy -- goal setup business logic -- archive storage and list loading -- provider transport details for comments, versions, documents, and watches - -## Required Work - -### 1. Make `DocumentReviewSurface` the default app surface - -Remove the feature-flagged bridge as a separate product path. - -Current state: - -- `packages/editor/App.tsx` computes `USE_DOCUMENT_SURFACE`. -- The package surface is only rendered when the flag is enabled. -- The old app shell remains the default render path. - -Required changes: - -- Replace the default editor render path with the package surface. -- Keep a thin Plannotator host shell, but do not keep both document-review implementations. -- Delete `shouldUseDocumentSurfaceBridge()` and the `VITE_DOCUMENT_SURFACE` runtime branch after parity is green. -- Move or delete old `App.tsx` document-domain state that duplicates package state. - -Acceptance: - -- Running the normal app with no env flag renders `DocumentReviewSurface`. -- `rg VITE_DOCUMENT_SURFACE packages apps` returns no production code hits. -- `packages/editor/App.tsx` no longer imports or directly orchestrates `Viewer`, `HtmlViewer`, `PlanDiffViewer`, `AnnotationPanel`, `usePlanDiff`, `useLinkedDoc`, or `useArchive` for the main document path. - -### 2. Add provider-neutral versions and diff - -Plan diff/version browser is the biggest package gap. It should move into `@plannotator/document-ui` as optional document version capability. - -Add host API methods: - -```ts -interface DocumentHostApi { - listDocumentVersions?(request: ListDocumentVersionsRequest): Promise; - loadDocumentVersion?(request: LoadDocumentVersionRequest): Promise; -} -``` - -Draft types: - -```ts -interface DocumentVersionRef { - id: string; - label: string; - createdAt?: number; - revision?: string; - providerState?: unknown; -} - -interface DocumentVersionsResult { - versions: DocumentVersionRef[]; - currentVersionId?: string; - previousVersionId?: string; - providerState?: unknown; -} - -interface LoadedDocumentVersion { - version: DocumentVersionRef; - document: LoadedDocument; -} -``` - -Package behavior: - -- fetch and show versions when `session.capabilities.supportsVersions` is true -- select a base version -- compute markdown diffs in the package, using existing diff utilities -- render clean/raw diff modes -- support diff annotations -- block version/diff actions while document editing is dirty -- expose version state through render props for custom hosts - -Plannotator adapter: - -- map `/api/plan/versions` -- map `/api/plan/version` -- use existing `previousPlan` and `versionInfo` as initial version data when available - -Workspaces adapter expectation: - -- map workspace document versions API -- use workspace document ids and versions, not local history paths - -Acceptance: - -- Plan review with previous versions shows the same diff affordance as the old app. -- Version browser works from the package surface. -- Diff annotations are included in feedback with the current legacy wording. -- Workspaces can implement the version API without Plannotator route names. - -### 3. Bring default chrome to visible parity - -The current default `DocumentReviewSurface` chrome works, but it is simpler than the old shell. The package surface needs parity for the generic document review experience. - -Move or recreate in package: - -- annotation toolstrip -- sticky header lane behavior -- wide/focus document controls -- document max-width behavior -- raw HTML tool visibility toggle -- folder empty state -- linked document breadcrumb/back chrome -- message picker as document navigation, if message mode remains supported -- feedback panel count and delete/edit behavior -- right panel resize/collapse behavior -- left sidebar collapsed rail and tab behavior -- keyboard shortcuts for submit, print, diff exit, save, and panel/sidebar toggles -- code-file/link preview when the host can load the target -- checkbox override behavior if editable checkboxes remain part of rendered markdown review - -Keep host-owned: - -- user preference storage -- Plannotator-specific issue/help links -- product-specific header menu -- print side effect -- settings modal - -Acceptance: - -- Annotate markdown, annotate raw HTML, annotate folder, annotate last message, and plan review do not visibly regress from the old app for core review actions. -- Default package UI has no obvious missing document controls compared with the old app. -- Package surface remains usable without Plannotator-specific settings or note integrations. - -### 4. Turn file/message browsing into provider-neutral document navigation - -The package already has document tree state. It needs to become the real default file/message navigation path. - -Required changes: - -- Treat folders, files, and recent messages as `DocumentTreeNode` / `DocumentRef` data. -- Let Plannotator adapter map `/api/reference/files` and `/api/reference/files/stream` to `listDocuments` and optional watch behavior. -- Let Workspaces adapter map workspace manifest rows to the same tree. -- Preserve annotation counts and writeback status badges in the tree. -- Preserve highlighted/annotated file behavior where it is generic. -- Keep local filesystem containment and vault retry mechanics in the Plannotator adapter/host. - -Acceptance: - -- Annotate-folder can select markdown, text, and raw HTML files through the package surface. -- File annotation counts survive navigation. -- Writeback statuses show on tree rows. -- Message mode can navigate recent assistant messages without bespoke `App.tsx` state. - -### 5. Finalize writeback and local source-save cutover - -The provider-neutral writeback core is mostly done. The remaining work is to stop the old shell from applying separate source-save state. - -Required changes: - -- Route all active document edit/save/discard/reload-conflict behavior through package writeback state. -- Keep Plannotator source-save behavior inside `plannotator-*` adapter helpers. -- Ensure missing local files, disk conflicts, stale saved changes, and draft-restored edits behave the same as the old path. -- Remove duplicate editor/source-save state from `App.tsx` after package behavior is authoritative. - -Acceptance: - -- Saving source-backed markdown/text files works from the package surface. -- Dirty, saving, saved, conflict, missing, and error states match current semantics. -- Draft restore preserves dirty writeback buffers and saved-change context. -- No generic shared type requires disk hash, mtime, EOL, or filesystem path. - -### 6. Move Ask AI surface behavior into the package - -The package already has Ask AI context helpers and `hostApi.askAI`. It needs the UI path if parity requires the package surface to replace the old shell. - -Required changes: - -- Add a default AI panel when `session.capabilities.canUseAskAI` and `hostApi.askAI` are available. -- Use package-owned document context assembly. -- Support document-targeted ask from comments or selected document regions. -- Let the host provide provider/model settings and permission handling. -- Keep terminal fallback and agent-specific prompt policy host-owned. - -Possible host API extension: - -```ts -interface DocumentHostApi { - askAI?(request: DocumentAskAIRequest): Promise | AsyncIterable; - listAIProviders?(): Promise; - respondToAIPermission?(response: DocumentAIPermissionResponse): Promise; -} -``` - -Acceptance: - -- The old Ask AI panel can be replaced for document review sessions. -- Hosts without AI do not see AI UI. -- Provider/model/auth policy does not leak into core document types. - -### 7. Keep agent terminal as a host slot, but finish integration points - -Do not move the terminal runtime into `@plannotator/document-ui`. - -Required changes: - -- Keep `terminalPanel` or a refined terminal slot in `DocumentReviewSlots`. -- Let package chrome show/hide terminal entry points when the host provides a terminal slot/capability. -- Keep generic agent-delivery state in the package. -- Keep PTY, WebSocket, runtime install, remote-mode security, and shell prompt construction in Plannotator host code. - -Acceptance: - -- Annotate-mode terminal can be mounted beside the package document surface. -- Package can show delivered-to-agent status without knowing terminal transport details. -- Workspaces is not forced to implement a terminal. - -### 8. Handle archive without making it core document review - -Archive is Plannotator-specific storage, but it still needs a path after `App.tsx` is shrunk. - -Required changes: - -- Do not make archive mandatory in `DocumentHostApi`. -- Expose enough slot support for a host archive tab or collection browser. -- Plannotator host owns archive plan loading, selection, copy, and done behavior. -- Archive selection can load a read-only `LoadedDocument` into the package surface or render through a host-provided archive mode. - -Acceptance: - -- Plannotator archive mode still works after old `App.tsx` document shell is gone. -- Archive does not appear in Workspaces unless Workspaces opts into a comparable collection provider. - -### 9. Keep goal setup host-owned - -Goal setup is not document review. It should not become core package behavior. - -Required changes: - -- Render goal setup from the Plannotator host shell, not the legacy document shell. -- Keep `GoalSetupSurface` in its current package unless a later decision moves it. -- Ensure goal setup submit/exit still uses shared action-controller helpers only where useful. - -Acceptance: - -- Goal setup works without the old document-review render path. -- `@plannotator/document-ui` does not need goal setup-specific public types. - -### 10. Keep settings, share, export, and note integrations host-owned - -These are Plannotator product policies, not shared document review behavior. - -Required changes: - -- Package exposes current feedback payload/rendered feedback through callbacks or render state. -- Host uses that payload for export/share/import and note integrations. -- Host injects header/menu actions through slots. -- Package does not call paste service, Obsidian, Bear, or Octarine routes. - -Acceptance: - -- Export/share/import still work in Plannotator. -- Note-app saves still work in Plannotator. -- Workspaces can ignore these features or provide its own host actions. - -### 11. Finalize annotation provider integration - -Current package annotation persistence is a good base. Full parity needs live provider annotations to stop being old-app-specific. - -Required changes: - -- Keep `loadAnnotations` and `saveAnnotations`. -- Add optional watch/subscribe support if live updates are required: - -```ts -interface DocumentHostApi { - watchAnnotations?(request: WatchDocumentAnnotationsRequest): DocumentAnnotationSubscription; -} -``` - -- Package owns merging local draft annotations with provider-owned annotations. -- Host/provider owns external transport, SSE route names, VS Code editor annotation routes, and permission policy. - -Acceptance: - -- External/provider annotations can appear in the package surface. -- Editing or deleting provider annotations routes through the provider where appropriate. -- Hosts without live annotations still work through load/save/draft behavior. - -### 12. Cut down `packages/editor/App.tsx` - -After parity lands, remove old document-product orchestration. - -Keep in editor host shell: - -- load session -- build Plannotator host API -- read settings -- wire Plannotator host slots -- render completion overlay -- render modals owned by Plannotator -- handle plan-mode and annotate route policy - -Remove from editor host shell: - -- document annotation reducer -- linked-doc state machine -- plan diff state machine -- archive document rendering path -- file/message document navigation state -- markdown/html viewer rendering -- document edit/writeback UI state -- direct document feedback assembly -- duplicate draft restore logic - -Acceptance: - -- The old document body path is gone. -- The file is understandable as a host shell, not a product state machine. -- Any remaining Plannotator-specific code has a clear reason to stay host-owned. - -## Dependency Order - -1. Add missing package contracts: versions/diff, optional annotation watch, refined slots. -2. Move version/diff state and rendering into the package. -3. Bring package chrome to visible parity for toolstrip, sidebars, panels, file/message navigation, and code previews. -4. Wire Plannotator adapter to the new contracts. -5. Move Ask AI surface behavior into the package, keeping provider config host-owned. -6. Mount terminal/archive/settings/export/note integrations through host slots. -7. Flip default app path to `DocumentReviewSurface`. -8. Delete old duplicate editor document state. -9. Run parity verification and fix regressions. - -## Verification - -Minimum automated checks: - -```text -bun test packages/document-ui -bun run typecheck -bun build packages/document-ui/DocumentReviewSurface.tsx --target browser --outdir /tmp/plannotator-document-ui-build -bun run --cwd apps/hook build -bun run --cwd apps/review build -git diff --check -``` - -Browser smoke checks: - -- plan review approve -- plan review deny with annotations -- plan diff/version browser -- annotate markdown file -- annotate raw HTML file -- annotate folder and switch files -- annotate last message and switch messages -- linked markdown document navigation -- code-file/link preview -- image upload and image display -- source-save success -- source-save conflict -- source file missing and save/recreate behavior -- draft restore after reload -- Ask AI open/ask/permission if enabled -- agent terminal slot open/close/delivered status -- archive browse/done -- export/share/note actions - -## Non-Goals - -- Do not redesign Plannotator's visual language. -- Do not move server route implementations into the package. -- Do not rename current Plannotator routes as part of this cutover. -- Do not make Workspaces adapter code live in this repo. -- Do not make local source-save terms part of the provider-neutral core. -- Do not move terminal runtime or note-app policy into the package. -- Do not keep both old and new document UI paths after cutover. - -## Open Decisions - -1. Should archive be represented as a host-provided document collection API, or only as a Plannotator host slot? - - Recommendation: host slot for this cutover. Add a collection API later only if Workspaces has a matching need. - -2. Should Ask AI provider/model settings be shown inside the shared package panel or injected by host slot? - - Recommendation: package owns the panel shell and messages; host injects provider settings/actions. - -3. Should goal setup remain in `@plannotator/ui` or move to another host package? - - Recommendation: leave it where it is for this cutover. The important thing is that it no longer depends on the old document shell. - -4. Should package version/diff compare be host-computed or package-computed? - - Recommendation: host loads versions; package computes markdown diff by default. Add optional host-computed diff only if Workspaces needs semantic/version-specific compare results. - -## Completion Criteria - -This work is complete when: - -- The normal Plan Review / Annotate app renders through `@plannotator/document-ui`. -- There is no `VITE_DOCUMENT_SURFACE` cutover flag. -- The old document-review render path is removed. -- Plan review, annotate file, annotate folder, annotate last, raw HTML, linked docs, source-save, drafts, plan diff, Ask AI, terminal slot, archive, export/share, and note integrations all still work. -- Workspaces can implement the same UI by supplying a `DocumentHostApi` without inheriting Plannotator local source-save vocabulary. diff --git a/adr/specs/publish-core-package-20260623-125551.md b/adr/specs/publish-core-package-20260623-125551.md deleted file mode 100644 index 608e8dc6f..000000000 --- a/adr/specs/publish-core-package-20260623-125551.md +++ /dev/null @@ -1,92 +0,0 @@ -# Spec: Carve `@plannotator/core` + Publish (Phase 7) - -Date: 2026-06-23 · Status: Draft (iterate before implementing) - -> Implementation spec for Phase 7. Grounded in `SPIKE-publish-core-package-20260623-125551.md` + its synthesis. Decision: single source of truth (no copying). THE LAW: Plannotator stays byte-for-byte unchanged through the carve; the publish is the one outward-facing step — confirm with the user before pushing to any registry. - -## Scope -**In:** create `@plannotator/core`, move the universal slice + extract types from node-bound modules, shim `@plannotator/shared`, re-point `@plannotator/ui`, move `wideMode.ts`, complete the settings provider (`loadFromBackend`, prefetch+sync), add a single `configurePlannotatorUI()` front door, ship a (required) precompiled CSS bundle, fix the 2 override-path bugs + add per-seam override tests, prep + (on go-ahead) publish `core` + `ui`. -**Out / stays private:** `@plannotator/shared` (Node/git/server grab-bag) and `@plannotator/ai` (ui only needs the `AIContext` type via core). - -## Step 1 — Create `packages/core` -- `packages/core/package.json`: `name @plannotator/core`, `version 0.21.0` (lockstep with the repo, per ADR 007), `type module`, source-only `exports` map (fine-grained subpaths like `ui`), `files` allowlist (`*.ts`, exclude tests), **no dependencies** (peerDeps none — it's pure JS/Web-API). tsconfig mirroring `ui` (bundler resolution, isolatedModules, allowImportingTsExtensions). -- Add `@plannotator/core` to root `workspaces` (already covered by `packages/*`). -- **Move (git mv) the ~15 pure modules** from `packages/shared` → `packages/core`: `code-file`, `extract-code-paths`, `agents`, `agent-jobs`, `compress`, `crypto`, `external-annotation`, `favicon`, `feedback-templates`, `goal-setup`, `browser-paths`, `project`, `agent-terminal`, `open-in-apps`, `source-save`. (Confirm each is node-free at move time.) -- **Extract types** from the node-bound modules into core type files: `core/config-types.ts` (DefaultDiffType, DiffLineBgIntensity, DiffOptions, …), `core/storage-types.ts` (ArchivedPlan), `core/workspace-status-types.ts` (WorkspaceFileChange, WorkspaceStatusPayload, GitRepositoryInfo). Plus any review types `ui`'s `@plannotator/shared/types` surfaces (verify `review-core`/`review-workspace` usage). -- `core/ai-context.ts`: re-export `AIContext` (move or re-export the pure type from `packages/ai/types.ts`; confirm it's node-free). -- `core/index.ts`: barrel re-export. - -## Step 2 — Re-point `@plannotator/shared` (keeps Plannotator unchanged) -- For each **moved pure module**, replace its `packages/shared/X.ts` with a one-line shim: `export * from '@plannotator/core/X';`. Keep `shared`'s `exports` map and `private:true` as-is. -- For each **node-bound module** (`config`, `storage`, `workspace-status`), change its in-file type definitions to **import the types from `@plannotator/core/*-types`** and re-export them, keeping the node implementation. (Types now live once, in core.) -- Add `@plannotator/core: "workspace:*"` to `packages/shared` deps. -- **Verify:** all 99 existing `@plannotator/shared/X` import sites still resolve unchanged; Pi `vendor.sh` needs no edit (vendors the shims). - -## Step 3 — Re-point `@plannotator/ui` -- Change every `ui` import of `@plannotator/shared/X` → `@plannotator/core/X`, and `import type { AIContext } from '@plannotator/ai'` → `@plannotator/core`. -- In `packages/ui/package.json`: remove the `@plannotator/shared` and `@plannotator/ai` `workspace:*` deps; add `@plannotator/core: "workspace:*"`. (After publish this becomes a real version range.) -- **Verify:** `grep @plannotator/shared` and `@plannotator/ai` in `packages/ui` (non-test) returns **zero** — ui depends only on `@plannotator/core` internally. - -## Step 4 — Move `wideMode.ts` -- `git mv packages/editor/wideMode.ts packages/ui/utils/wideMode.ts` (+ its test). Update the 2 importers (`editor/App.tsx`, the test) to `@plannotator/ui/utils/wideMode`. - -## Step 5 — Single config front door (`configurePlannotatorUI`) -The reuse surface currently has **9 global host-override switches** scattered across modules: `setImageSrcResolver`, `setStorageBackend`, `setDocPreviewFetcher`, `setFileTreeBackend`, `setIdentityProvider`, `setDraftTransport`, `setExternalAnnotationTransport`, `setAITransport`, and `configStore.setServerSync`. A consumer shouldn't have to discover and call each. -- Add **one new file** `packages/ui/configure.ts` exporting a typed `PlannotatorUIConfig` and `configurePlannotatorUI(config: PlannotatorUIConfig)` that fans out to those 9 setters (each field optional → only the provided ones are applied). Add to the `ui` `exports` map. -- **Zero risk / additive:** Plannotator never calls it, so nothing changes; the existing setters keep working individually. The per-component prop seams (vscode-diff, save-to-notes, obsidian-detect, version fetchers, editor `mode`, code-path toggle, `ScrollViewportProvider`) are intentionally NOT in the global front door — they're passed where the host renders those components. -- **Later (optional):** migrate the render-time seams to a `` (React context) if Workspaces wants per-instance config / SSR. The `configure()` facade is the 80/20 now; the Provider is the door it leaves open. -- **Verify:** typecheck; a tiny test that `configurePlannotatorUI({...})` routes to each setter; Plannotator behavior unchanged (it never calls it). - -## Decisions locked (post-interrogation, 2026-06-23) -- **Ship TS source for the JS, NOT a compiled build.** Rationale: the only consumer (Workspaces) is internal and on a controlled stack (Vite/Cloudflare). A `tsup`/lib build exists only to insulate unknown/arbitrary-toolchain consumers — that insulation buys ~nothing here, and shipping source avoids a build pipeline to maintain and avoids a `dist` artifact that can drift from what Plannotator actually runs. Door stays open: add a build later if/when an external consumer appears. (Contested in review — one reviewer assumed a public lib; this is the deliberate call for the internal case.) -- **Precompiled CSS is REQUIRED, not optional** (Step 6). Even internally, the `@source` glob into `node_modules/@plannotator/ui/**/*.tsx` is fragile (pnpm symlinks break it) and a per-build perf cost. Ship the stylesheet. -- **`@plannotator/core` gets a node-free CI typecheck** (Step 1) so a stray `node:*` import fails the build — turns "confirm node-free by hand" into an enforced invariant. -- **Pin `@plannotator/ui` → `@plannotator/core` to an EXACT version** (not a range) during 0.x, so a consumer can't end up with mismatched copies (and silently diverge the annotation serializers). - -## Step 6 — Precompiled CSS bundle (REQUIRED) -Tailwind-utility components force the consumer to either scan our source (`@source`) or get a ready-made stylesheet. Ship the stylesheet — the `@source` route is fragile (pnpm symlinks) and costs every consumer build time. -- Add a CSS-only build that emits a single precompiled `@plannotator/ui/styles.css` (theme tokens + the component utility classes). This is a CSS pipeline only — the JS still ships as source (per the decision above). -- Keep the `@source` glob documented as the fallback for a consumer who wants to scan source, but the stylesheet is the supported default. -- **Verify:** the precompiled CSS renders Plannotator-identical visuals in a bare consumer; Plannotator's own build/styling untouched. - -## Step 7 — Publish (OUTWARD-FACING — confirm first) -- JS ships as **source** (no build); CSS ships **precompiled** (Step 6). `core` + `ui` `exports` stay source-only for `.ts`/`.tsx`, plus the `styles.css` entry. -- Decide registry (recommend **public npm**, matching existing flow), versions (recommend **0.1.0**, core+ui together), with `ui`→`core` pinned **exact**. -- Write/READMEs documenting consumer requirements: `moduleResolution: bundler`, `allowImportingTsExtensions`, `isolatedModules`, `jsx: react-jsx`, React 19, and **import `@plannotator/ui/styles.css`** (the `@source` glob is the documented fallback, not the default). -- Add a publish job to `.github/workflows/release.yml` for `core` + `ui` (or publish manually the first time: `bun pm pack` each, `npm publish *.tgz --access public`). bun resolves `workspace:*` → real versions at pack time. -- **Do not run the publish until the user explicitly approves** the registry + version + go. - -## Carried-over review fixes (do before publish; NOT Phase-7 architecture) -These are small bugs/gaps the interrogation found in already-committed Phase-5 code. None affect Plannotator (override-path only); fix before a real consumer wires the seams: -1. **`useExternalAnnotations` split-transport** — the effect captures `transport` at mount for subscribe/poll, but the CRUD callbacks read the module global live → reads and writes can hit different backends if the transport is set after mount. Read consistently in both paths. (Check `useFileBrowser` for the same shape.) -2. **`useExternalAnnotations` `fallbackRef`/`receivedSnapshotRef` not reset on effect re-run** — if `enabled` toggles false→true (Workspaces auth/loading), the hook silently stops updating. Reset both at the top of the effect. -3. **Override path untested** — add one small test per seam that calls `setX(fake)`, drives the hook/component, asserts the contract, then `resetX()`. Makes the dead `reset*()` functions live and pins the subtle contracts (draft generation, SSE fallback). -4. **(in scope — Workspaces needs it)** Complete the settings provider: `setStorageBackend` only redirects setting *writes*; the initial *load* runs against cookies at module-init. Workspaces uses the same UI settings stored in its own backend → add `loadFromBackend()`. Model: **prefetch + synchronous backend** (host fetches settings → installs a sync backend serving from that data → calls `loadFromBackend()`); no async plumbing in `configStore`; Plannotator's eager cookie default unchanged (never calls it). - -## Definition of done (Phase 7) -- `@plannotator/core` exists, browser-safe, zero deps; the universal slice lives there once; **CI typechecks it node-free** (no `@types/node`). -- `@plannotator/shared` re-exports from core; Plannotator byte-unchanged (full `bun test` 1620/0, typecheck, builds, shipped-bundle hashes identical; `git diff` limited to core/shared/ui/editor packaging + import re-points). -- `@plannotator/ui` depends only on `@plannotator/core` internally, **pinned exact**; JS ships as source; installs standalone (with `core`). -- `wideMode.ts` relocated. -- **`configurePlannotatorUI()` exists** as the single typed front door over the 9 global setters; Plannotator unchanged (never calls it). -- **Precompiled CSS (`@plannotator/ui/styles.css`) shipped** (required). -- The carried-over review fixes (split-transport, fallbackRef reset, per-seam override tests) are done. -- Consumer requirements documented; publish job ready. -- (On explicit go) `core` + `ui` published; Workspaces can `npm install @plannotator/ui @plannotator/core`, call `configurePlannotatorUI({...})` once, import `@plannotator/ui/styles.css`, and build. - -## Parity guardrail (run after the carve, before publish) -`bun run typecheck` · `bun test` 1620/0 · `bun run --cwd apps/review build && bun run build:hook && bun run build:opencode` · shipped-bundle hashes vs the Phase-0 baseline (should be identical) · `git diff` confined to the expected packages · Pi `vendor.sh`/typecheck still green. - -## Decided (locked in ADR 007) -- **Registry: public npm.** -- **Versions: lockstep at repo version `0.21.0`; `ui`→`core` pinned exact.** -- **JS ships as source, not a build** (single internal consumer on a controlled stack). -- **Precompiled CSS required.** -- **`core` CI typecheck node-free.** -- **`@plannotator/ai` stays unpublished-to-npm** (`private:true`; UI doesn't need it — only `AIContext`, re-exported via `core`). -- **Settings provider completed** (`loadFromBackend()`, prefetch+sync) — in scope. -- **CI publish job wired**, but **validate artifacts on the branch first** (`bun pm pack` + inspect + `npm publish --dry-run`) before merge; first real publish gated on explicit go. - -## Still to verify at implementation -- `review-core`/`review-workspace` type handling (whether `ui`'s `@plannotator/shared/types` surfaces any node-bound review types → extract if so). -5. In-scope or not: `configStore.loadFromBackend()` (only if Workspaces wants its own settings persistence). diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 000000000..13af7c39f --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,53 @@ +# @plannotator/ui + +Plannotator's document UI — markdown rendering, themes, the annotation editor, settings, comments, and layout — as installable building blocks. Published so a separate app (the commercial Workspaces app) can reuse the exact same experience, while Plannotator itself stays unchanged. + +Ships with **`@plannotator/core`**: a small, browser-safe, zero-dependency package of the pure utilities and types `ui` builds on (carved out so `ui` can be installed standalone without Plannotator's server code). + +## Why this exists + +Workspaces needs the same document experience Plannotator has — render docs, annotate, comment, theme, edit — but backed by its own infrastructure (its own storage, auth, realtime, AI). Rather than fork or rebuild, it **installs these packages and plugs in its own backend.** Plannotator passes nothing and behaves exactly as before. + +## How it works: host-override seams + +Every place the UI talks to a backend (loading a doc preview, saving settings, persisting drafts, streaming comments, listing files, calling AI, etc.) is an **optional seam** that defaults to Plannotator's behavior. A host swaps in its own implementations through **one call at startup**: + +```ts +import { configurePlannotatorUI } from "@plannotator/ui/configure"; + +configurePlannotatorUI({ + storage, // where settings persist + identity, // who the current user is + imageResolver, // how image paths resolve to URLs + docPreviewFetcher, + fileTreeBackend, + draftTransport, + externalAnnotations, // live/agent comments + aiTransport, + serverSync, +}); +``` + +Anything you don't pass keeps Plannotator's default. A few component-specific overrides (e.g. an "open in editor" diff action) are passed as props where you render that component. + +## Consuming it (e.g. from Workspaces) + +```bash +npm install @plannotator/ui @plannotator/core +``` + +1. Call `configurePlannotatorUI({ ... })` once at startup with your backend. +2. Import the stylesheet: `import "@plannotator/ui/styles.css";` (precompiled — no Tailwind wiring needed; the `@source` glob is the fallback if you'd rather scan source). +3. Import components: `import { Viewer } from "@plannotator/ui/components/Viewer";` +4. Build with a bundler that compiles TS/TSX (Vite + React 19 + Tailwind v4). The packages ship **source**, so your bundler compiles them — set `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `jsx: "react-jsx"`. + +## Packages & publishing + +- `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published. +- `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published. +- `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched. +- Versioned in lockstep with the repo. Publish `core` then `ui` together with **`bun publish`** (not `npm` — bun resolves `workspace:*` to the exact version at pack time). + +## The one rule + +**Do not reimplement the document UI from scratch.** A prior from-scratch rewrite broke the app and was reverted. The supported path is always: keep these components as-is and add a seam where a host needs different backend behavior. Never delete working Plannotator code until a human has confirmed parity in the browser. From b14596bb9cada0e4dfece509df66c5bc2d60e405 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 19:03:31 -0700 Subject: [PATCH 42/67] docs(ui): add packages/ui/AGENTS.md guardrail + CLAUDE.md symlink Directory-scoped agent guidance for anyone editing @plannotator/ui: don't rewrite from scratch, add a seam (default = today's behavior, Plannotator byte-for-byte unchanged), core stays node-free, never delete working code until human parity. Points to README.md for the architecture. CLAUDE.md -> AGENTS.md symlink mirrors the repo root convention. --- packages/ui/AGENTS.md | 10 ++++++++++ packages/ui/CLAUDE.md | 1 + 2 files changed, 11 insertions(+) create mode 100644 packages/ui/AGENTS.md create mode 120000 packages/ui/CLAUDE.md diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md new file mode 100644 index 000000000..62823cb12 --- /dev/null +++ b/packages/ui/AGENTS.md @@ -0,0 +1,10 @@ +# Working on `@plannotator/ui` + +This is the **published, reusable document UI** (`@plannotator/ui` + `@plannotator/core`). The commercial Workspaces app installs it and plugs in its own backend; Plannotator uses the defaults. See **`README.md`** in this directory for the architecture (packages, seams, `configurePlannotatorUI`, publishing). + +**The rules when editing here:** + +- **Do not reimplement the document UI from scratch.** A prior from-scratch rewrite broke the app and was reverted. +- To support a host's different backend, **add an optional seam** (a module-level `setX`/`resetX` default, or an optional prop) whose default reproduces today's behavior. Plannotator passes nothing and stays **byte-for-byte unchanged**. +- `@plannotator/core` is browser-safe and zero-dep — **no `node:` imports** (CI enforces it). `@plannotator/shared`/`@plannotator/ai` stay private; `shared` re-exports `core` via shims. +- **Never delete working Plannotator code until a human confirms parity in the browser.** diff --git a/packages/ui/CLAUDE.md b/packages/ui/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/packages/ui/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 014ad5f980596f2680abcb570acf741164619475 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 19:20:19 -0700 Subject: [PATCH 43/67] build: remove madge circular-dep check (unmaintained) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit madge is unmaintained (~3 years stale) and the check was never wired into CI, so it was a dormant script + devDependency on a load-bearing path. Drop it: remove the check:cycles script, the madge devDependency, and .madgerc. The no-cycle invariant still holds by construction — @plannotator/core imports nothing (zero @plannotator deps in its package.json), so any accidental core->shared/ui import fails at publish-time bun pm pack (and review). No automated tripwire, but no stale unmaintained tooling either. --- .madgerc | 1 - package.json | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) delete mode 100644 .madgerc diff --git a/.madgerc b/.madgerc deleted file mode 100644 index b86f0df03..000000000 --- a/.madgerc +++ /dev/null @@ -1 +0,0 @@ -{ "extensions": ["ts", "tsx"], "fileExtensions": ["ts", "tsx"] } diff --git a/package.json b/package.json index 577786872..45c3df40d 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,7 @@ "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bun test", "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json", - "build:ui-css": "bun run --cwd packages/ui build:css", - "check:cycles": "madge --circular --extensions ts,tsx --ts-config packages/core/tsconfig.json packages/core && madge --circular --extensions ts,tsx --ts-config packages/ui/tsconfig.json packages/ui" + "build:ui-css": "bun run --cwd packages/ui build:css" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", @@ -50,7 +49,6 @@ "devDependencies": { "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", - "bun-types": "^1.3.11", - "madge": "^8.0.0" + "bun-types": "^1.3.11" } } From c3431120a9acbd2f2e0ee08b841d07039cd0263d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 23 Jun 2026 20:37:48 -0700 Subject: [PATCH 44/67] =?UTF-8?q?fix(ui):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20TDZ=20guard,=20html-viewer=20export,=20doc=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useExternalAnnotations: declare unsubscribe as let (not const) + guard calls, so a host transport that fires onError synchronously during subscribe falls back to polling instead of throwing a TDZ ReferenceError (Plannotator's EventSource fires async, never hit) - package.json: add explicit ./components/html-viewer export (dir has index.ts; the ./components/* -> *.tsx wildcard can't resolve it, so external installers would fail) - README: fix configurePlannotatorUI sample keys to the real option names (storageBackend/identityProvider/imageSrcResolver/externalAnnotationTransport) - AGENTS.md: point the Ask-AI mapping at packages/core/agents.ts (shared/agents.ts is a shim now) All publish/host-path/doc only — Plannotator unchanged. (#1 CSS-build font collision deferred to publish-prep — it needs the asset pipeline + files allowlist, not a one-liner.) --- AGENTS.md | 2 +- packages/ui/README.md | 8 ++++---- packages/ui/hooks/useExternalAnnotations.ts | 10 +++++++--- packages/ui/package.json | 1 + 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 89948bd43..4119c31a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,7 +212,7 @@ Ask AI's "changes under review" context for **code review** is generated by the ## Ask AI Provider Defaults -Ask AI providers are detected independently from installed/authenticated local CLIs, then the UI picks a default from the detected Plannotator origin. The mapping lives in `packages/shared/agents.ts` and is applied by `packages/ui/utils/aiProvider.ts`: +Ask AI providers are detected independently from installed/authenticated local CLIs, then the UI picks a default from the detected Plannotator origin. The mapping lives in `packages/core/agents.ts` (re-exported via the `packages/shared/agents.ts` shim) and is applied by `packages/ui/utils/aiProvider.ts`: | Origin | Preferred Ask AI provider | |--------|---------------------------| diff --git a/packages/ui/README.md b/packages/ui/README.md index 13af7c39f..6758b7654 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -16,13 +16,13 @@ Every place the UI talks to a backend (loading a doc preview, saving settings, p import { configurePlannotatorUI } from "@plannotator/ui/configure"; configurePlannotatorUI({ - storage, // where settings persist - identity, // who the current user is - imageResolver, // how image paths resolve to URLs + storageBackend, // where settings persist + identityProvider, // who the current user is + imageSrcResolver, // how image paths resolve to URLs docPreviewFetcher, fileTreeBackend, draftTransport, - externalAnnotations, // live/agent comments + externalAnnotationTransport, // live/agent comments aiTransport, serverSync, }); diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts index 1b54d90a2..8d3d98385 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -177,7 +177,11 @@ export function useExternalAnnotations void) | undefined; + unsubscribe = transport.subscribe( (parsed) => { if (cancelled) return; applyEvent(parsed); @@ -186,7 +190,7 @@ export function useExternalAnnotations { cancelled = true; - unsubscribe(); + unsubscribe?.(); if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; diff --git a/packages/ui/package.json b/packages/ui/package.json index 9ffc02741..746bde90e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,6 +9,7 @@ "./components/core/*": "./components/core/*.tsx", "./components/goal-setup/*": "./components/goal-setup/*.tsx", "./components/ImageAnnotator": "./components/ImageAnnotator/index.tsx", + "./components/html-viewer": "./components/html-viewer/index.ts", "./components/sidebar/*": "./components/sidebar/*.tsx", "./components/plan-diff/*": "./components/plan-diff/*.tsx", "./utils/*": "./utils/*.ts", From 547bd2a57acaaffcd20d58628bfebb35613bff6d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 24 Jun 2026 04:58:13 -0700 Subject: [PATCH 45/67] =?UTF-8?q?build(ui):=20don't=20bundle=20fonts=20in?= =?UTF-8?q?=20published=20styles.css=20=E2=80=94=20app=20loads=20fonts=20(?= =?UTF-8?q?review=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Industry standard for a shared UI package: ship theme + component CSS, let the consuming app load fonts. Drop the @fontsource imports from styles-entry.css (the publish CSS entry); the theme still defines --font-sans/--font-mono, and the app provides those families. Fixes the asset-name collision (every emitted .woff2 was renamed styles.css) and shrinks the published stylesheet 555kB -> 185kB. README documents the two-line @fontsource install. Plannotator unaffected: its apps (editor/review-editor index.css) load fonts via their own entry CSS — styles-entry.css is consumed ONLY by the publish CSS build. --- packages/ui/README.md | 10 ++++++++-- packages/ui/styles-entry.css | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/ui/README.md b/packages/ui/README.md index 6758b7654..240172ac2 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -38,8 +38,14 @@ npm install @plannotator/ui @plannotator/core 1. Call `configurePlannotatorUI({ ... })` once at startup with your backend. 2. Import the stylesheet: `import "@plannotator/ui/styles.css";` (precompiled — no Tailwind wiring needed; the `@source` glob is the fallback if you'd rather scan source). -3. Import components: `import { Viewer } from "@plannotator/ui/components/Viewer";` -4. Build with a bundler that compiles TS/TSX (Vite + React 19 + Tailwind v4). The packages ship **source**, so your bundler compiles them — set `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `jsx: "react-jsx"`. +3. **Load the fonts in your app entry** — the stylesheet references `--font-sans` / `--font-mono` but does not ship font binaries (standard for a shared UI package; your app owns font loading). Plannotator uses Inter + Geist Mono: + ```ts + import "@fontsource-variable/inter"; + import "@fontsource-variable/geist-mono"; + ``` + Or provide your own fonts and set `--font-sans` / `--font-mono` to match. +4. Import components: `import { Viewer } from "@plannotator/ui/components/Viewer";` +5. Build with a bundler that compiles TS/TSX (Vite + React 19 + Tailwind v4). The packages ship **source**, so your bundler compiles them — set `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `jsx: "react-jsx"`. ## Packages & publishing diff --git a/packages/ui/styles-entry.css b/packages/ui/styles-entry.css index 7cba7f178..143bb1034 100644 --- a/packages/ui/styles-entry.css +++ b/packages/ui/styles-entry.css @@ -1,5 +1,7 @@ -@import "@fontsource-variable/inter"; -@import "@fontsource-variable/geist-mono"; +/* Fonts are NOT bundled here. The published styles.css ships only theme + component + styles; the consuming app loads the fonts (see README). The theme defines the + font-family tokens (--font-sans / --font-mono); the app makes those families + available. This keeps styles.css small and avoids shipping font binaries. */ @import "tailwindcss"; @plugin "tailwindcss-animate"; From a5752c79758671995c4179e11a600ef6285c7580 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 24 Jun 2026 07:00:45 -0700 Subject: [PATCH 46/67] fix(ui): build styles.css on prepack, not prepublishOnly (review #4) prepublishOnly doesn't run for npm pack / bun pm pack / git / file: installs, so the package exported ./styles.css without shipping it. prepack runs on any pack, so the stylesheet is always present. Verified: bun pm pack now emits styles.css. --- packages/ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 746bde90e..9a0a60840 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -112,6 +112,6 @@ "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", "build:css": "vite build --config vite.css.config.ts && rm -f styles.js", - "prepublishOnly": "bun run build:css" + "prepack": "bun run build:css" } } From c70906c4ff6d5941dee63a3b1666cc4104d0f8b0 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 29 Jun 2026 11:42:14 -0700 Subject: [PATCH 47/67] =?UTF-8?q?chore(ui):=20post-rebase=20reconciliation?= =?UTF-8?q?=20=E2=80=94=20version=20lockstep=200.21.3,=20awaitable=20AI=20?= =?UTF-8?q?abort=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto main (0.21.3). Bump @plannotator/core + @plannotator/ui to 0.21.3 to stay in lockstep with the repo version. Resolve the useAIChat conflict: main added postServerAbort (an awaitable abort that prevents session-busy races) using a raw fetch. Route it through the AITransport seam by making AITransport.abort return Promise instead of void, so the host override is honored AND main's await-the-abort behavior is preserved. Update the abort mocks in the seam/configure tests accordingly. --- packages/core/package.json | 2 +- packages/ui/configure.test.ts | 2 +- packages/ui/hooks/useAIChat.seam.test.tsx | 4 ++-- packages/ui/package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index e729364a8..01cf256ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/core", - "version": "0.21.1", + "version": "0.21.3", "type": "module", "exports": { "./agents": "./agents.ts", diff --git a/packages/ui/configure.test.ts b/packages/ui/configure.test.ts index 3423e9b6d..92dde22be 100644 --- a/packages/ui/configure.test.ts +++ b/packages/ui/configure.test.ts @@ -81,7 +81,7 @@ const externalAnnotationTransport: ExternalAnnotationTransport<{ id: string; sou const aiTransport: AITransport = { session: async () => new Response(), query: async () => new Response(), - abort: () => {}, + abort: async () => {}, permission: () => {}, }; const serverSync = (_payload: Record) => {}; diff --git a/packages/ui/hooks/useAIChat.seam.test.tsx b/packages/ui/hooks/useAIChat.seam.test.tsx index ba1ee5864..6466eaa01 100644 --- a/packages/ui/hooks/useAIChat.seam.test.tsx +++ b/packages/ui/hooks/useAIChat.seam.test.tsx @@ -87,7 +87,7 @@ describe('AITransport seam', () => { queryBodies.push(body); return makeSseResponse('hello'); }, - abort: () => {}, + abort: async () => {}, permission: () => {}, }; @@ -115,7 +115,7 @@ describe('AITransport seam', () => { const fake: AITransport = { session: async () => { fakeCalls.push('session'); return new Response('{}', { status: 200 }); }, query: async () => { fakeCalls.push('query'); return new Response('', { status: 200 }); }, - abort: () => { fakeCalls.push('abort'); }, + abort: async () => { fakeCalls.push('abort'); }, permission: () => { fakeCalls.push('permission'); }, }; diff --git a/packages/ui/package.json b/packages/ui/package.json index 9a0a60840..e46b5871a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/ui", - "version": "0.21.1", + "version": "0.21.3", "type": "module", "exports": { "./components/*": "./components/*.tsx", From d2ce029cf6772209b85d5f77aac0e277b87a78ad Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 29 Jun 2026 11:54:27 -0700 Subject: [PATCH 48/67] fix(ui): make postServerAbort never reject regardless of AI transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The await site in ask() relies on postServerAbort resolving so a superseding query can proceed. main's original guaranteed this with its own .catch on the fetch; routing through the AITransport seam delegated that guarantee to the transport. Restore it at the call site (Promise.resolve(...).catch) so a host override that rejects — or returns void at runtime — can't throw out of ask(). --- packages/ui/hooks/useAIChat.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index ecfcb064c..e3de22255 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -256,7 +256,9 @@ export function useAIChat({ // into a session_busy error. Best-effort (never rejects). const postServerAbort = useCallback((): Promise => { if (!sessionIdRef.current) return Promise.resolve(); - return aiTransport.abort({ sessionId: sessionIdRef.current }); + // Never reject: the await site (in ask()) relies on this resolving so a + // superseding query can proceed even if the transport's abort throws. + return Promise.resolve(aiTransport.abort({ sessionId: sessionIdRef.current })).catch(() => {}); }, []); const ask = useCallback(async (params: AskAIParams) => { From 3151ae399847b5caccf8ac1c87dac172c9f7cf02 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 29 Jun 2026 15:22:48 -0700 Subject: [PATCH 49/67] =?UTF-8?q?fix(ui):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20core=20import,=20abort=20sync-throw,=20snapshot=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useAIProviderConfig: import Origin from @plannotator/core/agents (was the only ui file still importing @plannotator/shared); drop the masking shared/* path alias from ui/tsconfig.json so a stray shared import now fails typecheck. The hook is part of the published surface — a standalone install had no @plannotator/shared to resolve. - useAIChat.postServerAbort: defer the transport call into .then so a host abort that throws *synchronously* also can't reject (the .catch only caught async). - useExternalAnnotations: default getSnapshot returns null (skip) on a malformed 200 instead of coercing to []/0, so it can't clear annotations or reset the version cursor — restoring the pre-seam behavior. --- packages/ui/hooks/useAIChat.ts | 7 +++++-- packages/ui/hooks/useAIProviderConfig.ts | 2 +- packages/ui/hooks/useExternalAnnotations.ts | 8 +++++--- packages/ui/tsconfig.json | 1 - 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index e3de22255..6d317475f 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -257,8 +257,11 @@ export function useAIChat({ const postServerAbort = useCallback((): Promise => { if (!sessionIdRef.current) return Promise.resolve(); // Never reject: the await site (in ask()) relies on this resolving so a - // superseding query can proceed even if the transport's abort throws. - return Promise.resolve(aiTransport.abort({ sessionId: sessionIdRef.current })).catch(() => {}); + // superseding query can proceed even if the transport's abort throws — + // synchronously (call deferred into .then) or asynchronously (.catch). + return Promise.resolve() + .then(() => aiTransport.abort({ sessionId: sessionIdRef.current })) + .catch(() => {}); }, []); const ask = useCallback(async (params: AskAIParams) => { diff --git a/packages/ui/hooks/useAIProviderConfig.ts b/packages/ui/hooks/useAIProviderConfig.ts index 72f67e620..4ad3243d4 100644 --- a/packages/ui/hooks/useAIProviderConfig.ts +++ b/packages/ui/hooks/useAIProviderConfig.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import type { Origin } from '@plannotator/shared/agents'; +import type { Origin } from '@plannotator/core/agents'; import { getAIProviderSettings, resolveAIProviderSelection, diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts index 8d3d98385..6e4fe865c 100644 --- a/packages/ui/hooks/useExternalAnnotations.ts +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -69,9 +69,11 @@ function createDefaultTransport(): Ex if (res.status === 304) return null; // No changes if (!res.ok) return null; const data = await res.json(); - const annotations = Array.isArray(data.annotations) ? data.annotations : []; - const version = typeof data.version === 'number' ? data.version : 0; - return { annotations, version }; + // Skip (don't apply) on a malformed 200 — preserves existing annotations + // and the version cursor instead of clearing them, matching the + // pre-seam fetchSnapshot which only updated on well-formed payloads. + if (!Array.isArray(data.annotations) || typeof data.version !== 'number') return null; + return { annotations: data.annotations, version: data.version }; }, async add(items) { await fetch(SNAPSHOT_URL, { diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 63df81fa6..4aad6668f 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -18,7 +18,6 @@ "esModuleInterop": true, "types": ["bun"], "paths": { - "@plannotator/shared/*": ["../shared/*"], "@plannotator/core": ["../core/index.ts"], "@plannotator/core/*": ["../core/*"] } From b5d0d965f91de356903b87cd71b2535af01ef8fc Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 29 Jun 2026 15:22:48 -0700 Subject: [PATCH 50/67] feat(ui): add upload + identity-editable seams for host backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two override points the Workspaces app needs that had no seam: - UploadTransport (utils/upload.ts): image attachments hardcoded POST /api/upload with no override. Add a setX/resetX/getX seam (default = today's /api/upload, verbatim) and route AttachmentsButton through it. Workspaces sends bytes to its R2 asset API and returns the content-addressed URL. - IdentityProvider.isEditable() (utils/identity.ts): the Settings rename/regenerate controls wrote to the cookie store, bypassing a host identity provider — so a host with server-owned identity could split one user across two author names. Add an optional isEditable() (default true) and hide the rename controls when a host returns false. Plannotator's cookie identity stays editable — unchanged. Both wired into configurePlannotatorUI(); seam tests added; configure routing test covers uploadTransport. HANDOFF.md updated with the Workspaces seam mapping from the repo research (asset layer, identity, realtime, no-AI-infra, the Me display-name backend follow-up). README publish command corrected to bun pm pack + npm publish. --- packages/ui/HANDOFF.md | 201 +++++++++++++++++++ packages/ui/README.md | 2 +- packages/ui/components/AttachmentsButton.tsx | 34 ++-- packages/ui/components/Settings.tsx | 66 +++--- packages/ui/configure.test.ts | 24 ++- packages/ui/configure.ts | 3 + packages/ui/utils/identity.seam.test.ts | 23 +++ packages/ui/utils/identity.ts | 21 ++ packages/ui/utils/upload.seam.test.ts | 72 +++++++ packages/ui/utils/upload.ts | 56 ++++++ 10 files changed, 455 insertions(+), 47 deletions(-) create mode 100644 packages/ui/HANDOFF.md create mode 100644 packages/ui/utils/upload.seam.test.ts create mode 100644 packages/ui/utils/upload.ts diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md new file mode 100644 index 000000000..6d2d73a23 --- /dev/null +++ b/packages/ui/HANDOFF.md @@ -0,0 +1,201 @@ +# 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"** 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` (185KB) 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). +- `wideMode.ts` moved from `packages/editor` into `ui/utils` (it was UI-layer state). + +Net: `159 files changed, +7342 / −2723`. 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"`. + +### 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: + +```ts +// 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 `` 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` | 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` | 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. + +**`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`). + +**`ExternalAnnotationTransport`** — `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()` 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`, `loadTree/loadVaultTree` return `Promise` 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 + +```bash +npm install @plannotator/ui @plannotator/core +``` + +```ts +// 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 +}); +``` + +```ts +// 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). + +--- + +## 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`, `{ 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. + +None of these block adoption. They're the honest "here's what we'd polish next" list. + +--- + +## Publishing & versioning + +- `@plannotator/core` and `@plannotator/ui` are versioned **in lockstep with the repo** (currently `0.21.3`). +- 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 --provenance --access public`). Publish **`core` first, then `ui`**. +- `styles.css` is built by the `prepack` script (`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 `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. diff --git a/packages/ui/README.md b/packages/ui/README.md index 240172ac2..b8802d317 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -52,7 +52,7 @@ npm install @plannotator/ui @plannotator/core - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published. - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published. - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched. -- Versioned in lockstep with the repo. Publish `core` then `ui` together with **`bun publish`** (not `npm` — bun resolves `workspace:*` to the exact version at pack time). +- Versioned in lockstep with the repo. Publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow. ## The one rule diff --git a/packages/ui/components/AttachmentsButton.tsx b/packages/ui/components/AttachmentsButton.tsx index 3d0f1876f..b74a67ffd 100644 --- a/packages/ui/components/AttachmentsButton.tsx +++ b/packages/ui/components/AttachmentsButton.tsx @@ -4,6 +4,7 @@ import { ImageThumbnail, getImageSrc } from './ImageThumbnail'; import { ImageAnnotator } from './ImageAnnotator'; import type { ImageAttachment } from '../types'; import { modKey } from '../utils/platform'; +import { getUploadTransport } from '../utils/upload'; /** * Derive a clean, human-readable name from an original filename. @@ -125,27 +126,26 @@ export const AttachmentsButton: React.FC = ({ const handleAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => { setUploading(true); try { - const formData = new FormData(); // Use annotated blob if drawings exist, otherwise original file - if (annotatorImage) { - const fileToUpload = hasDrawings + const fileToUpload = annotatorImage + ? (hasDrawings + ? new File([blob], 'annotated.png', { type: 'image/png' }) + : annotatorImage.file) + : editingImage + // Re-editing: always upload the new blob ? new File([blob], 'annotated.png', { type: 'image/png' }) - : annotatorImage.file; - formData.append('file', fileToUpload); - } else if (editingImage) { - // Re-editing: always upload the new blob - formData.append('file', new File([blob], 'annotated.png', { type: 'image/png' })); - } + : null; - const res = await fetch('/api/upload', { method: 'POST', body: formData }); - const data = await res.json(); - if (data.path) { - // If re-editing, remove old path first - if (editingImage) { - onRemove(editingImage.path); + if (fileToUpload) { + const data = await getUploadTransport().upload(fileToUpload); + if (data.path) { + // If re-editing, remove old path first + if (editingImage) { + onRemove(editingImage.path); + } + // Use the name from the annotator (user may have edited it) + onAdd({ path: data.path, name }); } - // Use the name from the annotator (user may have edited it) - onAdd({ path: data.path, name }); } } catch (err) { console.error('Upload failed:', err); diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 22943c72a..e895acefa 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -5,7 +5,7 @@ import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config'; import { loadDiffFont } from '../utils/diffFonts'; import { TaterSpritePullup } from './TaterSpritePullup'; -import { getIdentity, regenerateIdentity, setCustomIdentity } from '../utils/identity'; +import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity'; import { GitUser } from '../icons/GitUser'; import { getObsidianSettings, @@ -888,6 +888,12 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange handleIdentitySave(gitUser); }; + // When a host owns identity (e.g. a logged-in user whose author is server-stamped), + // hide the rename/regenerate controls so a local name can't diverge from it. + // Default (Plannotator cookie identity) is editable, so this is true and the + // controls render exactly as before. + const identityEditable = isIdentityEditable(); + return ( <> )} - + {identityEditable && ( + + )} diff --git a/packages/ui/configure.test.ts b/packages/ui/configure.test.ts index 92dde22be..17d6d3cc7 100644 --- a/packages/ui/configure.test.ts +++ b/packages/ui/configure.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, mock, spyOn } from 'bun:test import * as ImageThumbnail from './components/ImageThumbnail'; import * as InlineMarkdown from './components/InlineMarkdown'; import * as storage from './utils/storage'; +import * as upload from './utils/upload'; import * as identity from './utils/identity'; import * as useFileBrowser from './hooks/useFileBrowser'; import * as useAnnotationDraft from './hooks/useAnnotationDraft'; @@ -13,6 +14,7 @@ import { configStore } from './config'; import type { ImageSrcResolver } from './components/ImageThumbnail'; import type { DocPreviewFetcher } from './components/InlineMarkdown'; import type { StorageBackend } from './utils/storage'; +import type { UploadTransport } from './utils/upload'; import type { IdentityProvider } from './utils/identity'; import type { FileTreeBackend } from './hooks/useFileBrowser'; import type { DraftTransport } from './hooks/useAnnotationDraft'; @@ -29,6 +31,8 @@ const realSetDocPreviewFetcher = InlineMarkdown.setDocPreviewFetcher; const realResetDocPreviewFetcher = InlineMarkdown.resetDocPreviewFetcher; const realSetStorageBackend = storage.setStorageBackend; const realResetStorageBackend = storage.resetStorageBackend; +const realSetUploadTransport = upload.setUploadTransport; +const realResetUploadTransport = upload.resetUploadTransport; const realSetIdentityProvider = identity.setIdentityProvider; const realResetIdentityProvider = identity.resetIdentityProvider; const realSetFileTreeBackend = useFileBrowser.setFileTreeBackend; @@ -44,6 +48,7 @@ const realResetAITransport = useAIChat.resetAITransport; const setImageSrcResolver = mock((_: ImageSrcResolver) => {}); const setDocPreviewFetcher = mock((_: DocPreviewFetcher) => {}); const setStorageBackend = mock((_: StorageBackend) => {}); +const setUploadTransport = mock((_: UploadTransport) => {}); const setIdentityProvider = mock((_: IdentityProvider) => {}); const setFileTreeBackend = mock((_: FileTreeBackend) => {}); const setDraftTransport = mock((_: DraftTransport) => {}); @@ -59,6 +64,7 @@ const loadFromBackend = spyOn(configStore, 'loadFromBackend').mockImplementation const imageSrcResolver: ImageSrcResolver = (path) => path; const docPreviewFetcher: DocPreviewFetcher = async () => null; const storageBackend: StorageBackend = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; +const uploadTransport: UploadTransport = { upload: async () => ({ path: '/tmp/x.png' }) }; const identityProvider: IdentityProvider = { getIdentity: () => 'tater', isCurrentUser: () => false }; const fileTreeBackend: FileTreeBackend = { loadTree: async () => new Response('{}'), @@ -114,6 +120,11 @@ describe('configurePlannotatorUI routing', () => { setStorageBackend, resetStorageBackend: realResetStorageBackend, })); + mock.module('./utils/upload', () => ({ + ...upload, + setUploadTransport, + resetUploadTransport: realResetUploadTransport, + })); mock.module('./utils/identity', () => ({ ...identity, setIdentityProvider, @@ -160,6 +171,11 @@ describe('configurePlannotatorUI routing', () => { setStorageBackend: realSetStorageBackend, resetStorageBackend: realResetStorageBackend, })); + mock.module('./utils/upload', () => ({ + ...upload, + setUploadTransport: realSetUploadTransport, + resetUploadTransport: realResetUploadTransport, + })); mock.module('./utils/identity', () => ({ ...identity, setIdentityProvider: realSetIdentityProvider, @@ -193,6 +209,7 @@ describe('configurePlannotatorUI routing', () => { configurePlannotatorUI({ imageSrcResolver, storageBackend, + uploadTransport, docPreviewFetcher, fileTreeBackend, identityProvider, @@ -206,6 +223,7 @@ describe('configurePlannotatorUI routing', () => { expect(setImageSrcResolver).toHaveBeenCalledWith(imageSrcResolver); expect(setDocPreviewFetcher).toHaveBeenCalledWith(docPreviewFetcher); expect(setStorageBackend).toHaveBeenCalledWith(storageBackend); + expect(setUploadTransport).toHaveBeenCalledWith(uploadTransport); expect(setIdentityProvider).toHaveBeenCalledWith(identityProvider); expect(setFileTreeBackend).toHaveBeenCalledWith(fileTreeBackend); expect(setDraftTransport).toHaveBeenCalledWith(draftTransport); @@ -224,9 +242,9 @@ describe('configurePlannotatorUI routing', () => { const { configurePlannotatorUI } = await import('./configure'); [ - setImageSrcResolver, setDocPreviewFetcher, setStorageBackend, setIdentityProvider, - setFileTreeBackend, setDraftTransport, setExternalAnnotationTransport, setAITransport, - setServerSync, loadFromBackend, + setImageSrcResolver, setDocPreviewFetcher, setStorageBackend, setUploadTransport, + setIdentityProvider, setFileTreeBackend, setDraftTransport, setExternalAnnotationTransport, + setAITransport, setServerSync, loadFromBackend, ].forEach((m) => m.mockClear()); configurePlannotatorUI({ storageBackend }); diff --git a/packages/ui/configure.ts b/packages/ui/configure.ts index 0f786e689..0172c3fc2 100644 --- a/packages/ui/configure.ts +++ b/packages/ui/configure.ts @@ -1,6 +1,7 @@ import { setImageSrcResolver, type ImageSrcResolver } from './components/ImageThumbnail'; import { setDocPreviewFetcher, type DocPreviewFetcher } from './components/InlineMarkdown'; import { setStorageBackend, type StorageBackend } from './utils/storage'; +import { setUploadTransport, type UploadTransport } from './utils/upload'; import { setIdentityProvider, type IdentityProvider } from './utils/identity'; import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser'; import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft'; @@ -14,6 +15,7 @@ type ExternalAnnotationBase = { id: string; source?: string }; export interface PlannotatorUIConfig { imageSrcResolver?: ImageSrcResolver; storageBackend?: StorageBackend; + uploadTransport?: UploadTransport; docPreviewFetcher?: DocPreviewFetcher; fileTreeBackend?: FileTreeBackend; identityProvider?: IdentityProvider; @@ -34,6 +36,7 @@ export interface PlannotatorUIConfig { export function configurePlannotatorUI(config: PlannotatorUIConfig): void { if (config.imageSrcResolver) setImageSrcResolver(config.imageSrcResolver); if (config.storageBackend) setStorageBackend(config.storageBackend); + if (config.uploadTransport) setUploadTransport(config.uploadTransport); if (config.docPreviewFetcher) setDocPreviewFetcher(config.docPreviewFetcher); if (config.fileTreeBackend) setFileTreeBackend(config.fileTreeBackend); if (config.identityProvider) setIdentityProvider(config.identityProvider); diff --git a/packages/ui/utils/identity.seam.test.ts b/packages/ui/utils/identity.seam.test.ts index e7a6673e8..a5de0f043 100644 --- a/packages/ui/utils/identity.seam.test.ts +++ b/packages/ui/utils/identity.seam.test.ts @@ -20,6 +20,7 @@ const setIdentityProvider = identityModule.setIdentityProvider; const resetIdentityProvider = identityModule.resetIdentityProvider; const getIdentity = identityModule.getIdentity; const isCurrentUser = identityModule.isCurrentUser; +const isIdentityEditable = identityModule.isIdentityEditable; afterEach(() => { resetIdentityProvider(); @@ -58,6 +59,28 @@ describe('IdentityProvider seam', () => { expect(checked).toEqual(['user@example.com', 'other@example.com']); }); + it('isIdentityEditable() reflects the fake provider (host owns identity ⇒ false)', () => { + setIdentityProvider({ + getIdentity: () => 'workspace-user', + isCurrentUser: () => false, + isEditable: () => false, + }); + expect(isIdentityEditable()).toBe(false); + }); + + it('isIdentityEditable() defaults to true when the provider omits isEditable', () => { + setIdentityProvider({ + getIdentity: () => 'legacy-host', + isCurrentUser: () => false, + }); + expect(isIdentityEditable()).toBe(true); + }); + + it('default (tater) provider is editable', () => { + resetIdentityProvider(); + expect(isIdentityEditable()).toBe(true); + }); + it('resetIdentityProvider restores the default (ConfigStore-backed) provider', () => { const fake = { getIdentity: () => 'should-not-appear', diff --git a/packages/ui/utils/identity.ts b/packages/ui/utils/identity.ts index b67bb0ba5..1cc895161 100644 --- a/packages/ui/utils/identity.ts +++ b/packages/ui/utils/identity.ts @@ -26,6 +26,15 @@ export interface IdentityProvider { getIdentity(): string; /** Whether an annotation's `author` is the current user (drives the `(me)` badge). */ isCurrentUser(author: string | undefined): boolean; + /** + * Whether the user may change their own display name from the Settings UI. + * Default (Plannotator's cookie identity) is editable. A host whose identity + * is owned by its auth system (e.g. a logged-in Workspaces user, whose author + * is the server-stamped account id) returns `false` so the rename/regenerate + * controls are hidden — preventing a locally-chosen name from diverging from + * the server-stamped author. Optional for backward-compat: absent ⇒ editable. + */ + isEditable?(): boolean; } /** @@ -40,6 +49,9 @@ const defaultIdentityProvider: IdentityProvider = { if (!author) return false; return author === configStore.get('displayName'); }, + isEditable(): boolean { + return true; + }, }; // Active provider. Defaults to the tater identity so Plannotator is unchanged. @@ -89,3 +101,12 @@ export function regenerateIdentity(): string { export function isCurrentUser(author: string | undefined): boolean { return identityProvider.isCurrentUser(author); } + +/** + * Whether the current identity is user-editable (drives whether the Settings + * rename/regenerate controls are shown). Delegates to the active provider; + * defaults to editable when the provider doesn't implement `isEditable`. + */ +export function isIdentityEditable(): boolean { + return identityProvider.isEditable ? identityProvider.isEditable() : true; +} diff --git a/packages/ui/utils/upload.seam.test.ts b/packages/ui/utils/upload.seam.test.ts new file mode 100644 index 000000000..3a921e454 --- /dev/null +++ b/packages/ui/utils/upload.seam.test.ts @@ -0,0 +1,72 @@ +/** + * Seam test: UploadTransport override (setUploadTransport / resetUploadTransport). + * + * Contract: after setUploadTransport(fake), getUploadTransport().upload routes + * through the fake — NOT through `fetch('/api/upload')`. resetUploadTransport() + * restores the default `/api/upload` transport. + * + * Function references are captured at module-load time so they remain valid even + * when configure.test.ts's mock.module() replaces the module exports later. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import * as uploadModule from './upload'; + +const setUploadTransport = uploadModule.setUploadTransport; +const resetUploadTransport = uploadModule.resetUploadTransport; +const getUploadTransport = uploadModule.getUploadTransport; + +afterEach(() => { + resetUploadTransport(); +}); + +function makeFile(name = 'pic.png'): File { + return new File([new Uint8Array([1, 2, 3])], name, { type: 'image/png' }); +} + +describe('UploadTransport seam', () => { + it('routes upload through the installed fake transport (not /api/upload)', async () => { + const uploaded: File[] = []; + setUploadTransport({ + async upload(file) { + uploaded.push(file); + return { path: 'r2://workspace/abc123', originalName: file.name }; + }, + }); + + const result = await getUploadTransport().upload(makeFile('login.png')); + + expect(uploaded).toHaveLength(1); + expect(uploaded[0].name).toBe('login.png'); + expect(result.path).toBe('r2://workspace/abc123'); + expect(result.originalName).toBe('login.png'); + }); + + it('resetUploadTransport restores the default (does not use the fake)', async () => { + let fakeCalled = false; + setUploadTransport({ + async upload() { + fakeCalled = true; + return { path: 'should-not-see-this' }; + }, + }); + resetUploadTransport(); + + // The default transport calls fetch('/api/upload'); stub fetch so the default + // path is exercised without a real server, and assert the fake is gone. + const originalFetch = globalThis.fetch; + let hitUrl: string | undefined; + // @ts-expect-error minimal fetch stub for the test + globalThis.fetch = async (url: string) => { + hitUrl = String(url); + return { json: async () => ({ path: '/tmp/upload-123.png', originalName: 'pic.png' }) }; + }; + try { + const result = await getUploadTransport().upload(makeFile()); + expect(fakeCalled).toBe(false); + expect(hitUrl).toBe('/api/upload'); + expect(result.path).toBe('/tmp/upload-123.png'); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/ui/utils/upload.ts b/packages/ui/utils/upload.ts new file mode 100644 index 000000000..317d1ccb8 --- /dev/null +++ b/packages/ui/utils/upload.ts @@ -0,0 +1,56 @@ +/** + * Host-overridable image upload transport. + * + * Default = today's literal Plannotator behavior (POST /api/upload with the + * file as multipart form-data, response `{ path, originalName }`). A host + * (e.g. Workspaces) calls `setUploadTransport` once at startup to send the + * bytes to its own asset backend instead. Mirrors the swappable transports in + * ./storage.ts and ../hooks/useAnnotationDraft.ts. + */ + +export interface UploadResult { + /** + * Stored reference the UI round-trips and feeds to the image-src resolver. + * Plannotator returns the server file path. A host may return its own opaque + * ref or a fully-resolved URL (the default image-src resolver passes http(s) + * URLs through unchanged, so a returned URL renders directly). + */ + path: string; + /** Original file name, when the backend echoes it. */ + originalName?: string; +} + +export interface UploadTransport { + /** Upload one image file and resolve to its stored reference. */ + upload(file: File): Promise; +} + +/** Default transport — Plannotator's `/api/upload` multipart POST, verbatim. */ +const defaultUploadTransport: UploadTransport = { + async upload(file) { + const formData = new FormData(); + formData.append('file', file); + const res = await fetch('/api/upload', { method: 'POST', body: formData }); + const data = await res.json(); + return { path: data.path, originalName: data.originalName }; + }, +}; + +// Module-level transport, stable identity. Defaults to Plannotator's behavior so +// callers are unchanged. A host overrides it once at startup. +let uploadTransport: UploadTransport = defaultUploadTransport; + +/** Override how image attachments are uploaded. Call once at app startup. */ +export function setUploadTransport(t: UploadTransport): void { + uploadTransport = t; +} + +/** Reset to the default (Plannotator `/api/upload`) transport. Mainly for tests. */ +export function resetUploadTransport(): void { + uploadTransport = defaultUploadTransport; +} + +/** Read the active upload transport at call time (so a late override is honored). */ +export function getUploadTransport(): UploadTransport { + return uploadTransport; +} From 9e4f5d17b07f3a53a1411b5e764f6e966c4f9ab2 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 29 Jun 2026 15:26:45 -0700 Subject: [PATCH 51/67] refactor(ui): capture sessionId synchronously in postServerAbort Self-review: the deferred .then read sessionIdRef.current a microtask after the guard checked it. Capture the id synchronously so the abort always targets the session current at call time and there's no double-read. --- packages/ui/hooks/useAIChat.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index 6d317475f..8ae691c08 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -255,12 +255,13 @@ export function useAIChat({ // it responds, so awaiting this guarantees a following query won't race it // into a session_busy error. Best-effort (never rejects). const postServerAbort = useCallback((): Promise => { - if (!sessionIdRef.current) return Promise.resolve(); + const sessionId = sessionIdRef.current; // capture the session being aborted now + if (!sessionId) return Promise.resolve(); // Never reject: the await site (in ask()) relies on this resolving so a // superseding query can proceed even if the transport's abort throws — // synchronously (call deferred into .then) or asynchronously (.catch). return Promise.resolve() - .then(() => aiTransport.abort({ sessionId: sessionIdRef.current })) + .then(() => aiTransport.abort({ sessionId })) .catch(() => {}); }, []); From d7f4b8f1d554b0507b9b5ef2dcc4fd177368e43c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 29 Jun 2026 16:32:07 -0700 Subject: [PATCH 52/67] =?UTF-8?q?fix(ui):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20seed=20host=20store,=20browser-safe=20timer=20type,=20harden?= =?UTF-8?q?=20abort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - configStore.loadFromBackend: seed the host StorageBackend with resolved defaults for keys it lacks. The constructor runs at module load (before a host installs its backend), so its default-seeding writes went to the cookie backend; without this a fresh host store was never populated and generated defaults (e.g. displayName) regenerated every reload. [P1, host path] - Viewer.tsx: replace NodeJS.Timeout with ReturnType (2 refs) so a browser-only consumer compiling the published source doesn't need @types/node. Matches the pattern already used in configStore. [P1, published path] - useAIChat: harden the create-session supersede abort the same way as postServerAbort, so a host transport that throws can't surface an unhandled rejection. No impact on Plannotator (default self-catches). [nit] - .gitignore: correct stale 'prepublishOnly' comment to 'prepack'. [nit] Plannotator behavior unchanged (it never calls loadFromBackend; the timer/abort changes are behavior-preserving). Strengthened configStore seam test to assert first-run seeding. typecheck clean, 1773 pass / 0 fail. --- .gitignore | 2 +- packages/ui/components/Viewer.tsx | 4 ++-- packages/ui/config/configStore.seam.test.ts | 22 +++++++++++---------- packages/ui/config/configStore.ts | 7 +++++++ packages/ui/hooks/useAIChat.ts | 6 +++++- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index e13177e54..1cc98da2c 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,6 @@ plannotator-local # Local Pi state/memory (not upstream) /.pi/ -# @plannotator/ui CSS build artifacts (generated by prepublishOnly — not committed) +# @plannotator/ui CSS build artifacts (generated by prepack — not committed) packages/ui/styles.css packages/ui/styles.js diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index a76156edc..ce8fd9fe1 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -238,7 +238,7 @@ export const Viewer = forwardRef(({ const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false); const [hoveredTable, setHoveredTable] = useState<{ block: Block; element: HTMLElement } | null>(null); const [isTableToolbarExiting, setIsTableToolbarExiting] = useState(false); - const tableHoverTimeoutRef = useRef(null); + const tableHoverTimeoutRef = useRef | null>(null); const [popoutTable, setPopoutTable] = useState(null); // Viewer-specific comment popover state (global comments + code blocks) const [viewerCommentPopover, setViewerCommentPopover] = useState<{ @@ -254,7 +254,7 @@ export const Viewer = forwardRef(({ anchorEl: HTMLElement; codeBlock: { block: Block; element: HTMLElement }; } | null>(null); - const hoverTimeoutRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); const stickySentinelRef = useRef(null); const lastAutoScrolledHashRef = useRef(null); const [isStuck, setIsStuck] = useState(false); diff --git a/packages/ui/config/configStore.seam.test.ts b/packages/ui/config/configStore.seam.test.ts index 4d2fb0b90..0e2bb4538 100644 --- a/packages/ui/config/configStore.seam.test.ts +++ b/packages/ui/config/configStore.seam.test.ts @@ -88,23 +88,25 @@ describe('configStore.loadFromBackend seam', () => { expect(configStore.get('displayName')).toBe('prefetched-workspace-user'); }); - it('keys absent from the backend are left at their prior value (not overwritten with undefined)', () => { + it('seeds the backend with resolved defaults for keys it lacks (first-run host store)', () => { // First set a known value for 'displayName'. configStore.set('displayName', 'prior-value'); - // Install a backend that returns null for every key (simulates a backend - // that has no opinion on this setting). - const emptyBackend = { - getItem: (_key: string) => null, - setItem: () => {}, - removeItem: () => {}, + // Install a fresh, empty host store (first-run host: nothing prefetched). + const store = new Map(); + const seedBackend = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, value); }, + removeItem: (key: string) => { store.delete(key); }, }; - setStorageBackend(emptyBackend); + setStorageBackend(seedBackend); configStore.loadFromBackend(); - // 'prior-value' must be preserved — loadFromBackend only overwrites when - // fromCookie() returns a non-undefined result. + // In-memory value is preserved (not overwritten with undefined)... expect(configStore.get('displayName')).toBe('prior-value'); + // ...AND the empty host backend is now seeded with the resolved default, so a + // reload reads it back from this backend instead of regenerating it. + expect(store.get('plannotator-identity')).toBe('prior-value'); }); }); diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index c57eed250..8997bc77a 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -88,6 +88,13 @@ class ConfigStore { const fromBackend = def.fromCookie(); if (fromBackend !== undefined) { this.values.set(name, fromBackend); + } else { + // Seed the host backend with the resolved default. The constructor ran + // at module load — before the host installed its StorageBackend — so its + // default-seeding writes went to the pre-install (cookie) backend, not + // this one. Without this, a fresh host store is never populated, so + // generated defaults (e.g. displayName) regenerate on every reload. + def.toCookie(this.values.get(name) as never); } } this.notify(); diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index 8ae691c08..dafcafbbe 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -236,7 +236,11 @@ export function useAIChat({ const data = await res.json() as { sessionId: string }; if (signal.aborted || epoch !== sessionEpochRef.current) { - aiTransport.abort({ sessionId: data.sessionId }); + // Fire-and-forget, hardened like postServerAbort: a host transport whose + // abort throws (sync or async) must not surface as an unhandled rejection. + Promise.resolve() + .then(() => aiTransport.abort({ sessionId: data.sessionId })) + .catch(() => {}); throw createAbortError('AI session creation was superseded'); } setSessionId(data.sessionId); From 82bb3fc5cd863f54372db8ece1a408b228cae6ab Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 29 Jun 2026 16:47:21 -0700 Subject: [PATCH 53/67] refactor(ui): single-source the never-reject abort via safeAbort helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review: the hardened abort pattern (defer into .then + .catch so a host transport that throws can't reject) was duplicated across postServerAbort and the create-session supersede site — the exact drift the review flagged. Extract a module-level safeAbort(sessionId) so both call sites share one hardened implementation and can't diverge again. Behavior unchanged; reads aiTransport at call time so a late override is honored. --- packages/ui/hooks/useAIChat.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/ui/hooks/useAIChat.ts b/packages/ui/hooks/useAIChat.ts index dafcafbbe..ec85fc28f 100644 --- a/packages/ui/hooks/useAIChat.ts +++ b/packages/ui/hooks/useAIChat.ts @@ -180,6 +180,19 @@ export const resetAITransport = (): void => { aiTransport = defaultAITransport; }; +/** + * Dispatch the abort transport without ever rejecting. A host transport whose + * `abort` throws — synchronously (deferred into `.then`) or asynchronously + * (`.catch`) — must not surface as an unhandled rejection, and the await site in + * ask() relies on this resolving. Single-sourced so every abort call site stays + * hardened. Reads `aiTransport` at call time so a late override is honored. + */ +function safeAbort(sessionId: string): Promise { + return Promise.resolve() + .then(() => aiTransport.abort({ sessionId })) + .catch(() => {}); +} + export function useAIChat({ context, providerId, @@ -236,11 +249,7 @@ export function useAIChat({ const data = await res.json() as { sessionId: string }; if (signal.aborted || epoch !== sessionEpochRef.current) { - // Fire-and-forget, hardened like postServerAbort: a host transport whose - // abort throws (sync or async) must not surface as an unhandled rejection. - Promise.resolve() - .then(() => aiTransport.abort({ sessionId: data.sessionId })) - .catch(() => {}); + void safeAbort(data.sessionId); // fire-and-forget cleanup of the orphaned session throw createAbortError('AI session creation was superseded'); } setSessionId(data.sessionId); @@ -261,12 +270,7 @@ export function useAIChat({ const postServerAbort = useCallback((): Promise => { const sessionId = sessionIdRef.current; // capture the session being aborted now if (!sessionId) return Promise.resolve(); - // Never reject: the await site (in ask()) relies on this resolving so a - // superseding query can proceed even if the transport's abort throws — - // synchronously (call deferred into .then) or asynchronously (.catch). - return Promise.resolve() - .then(() => aiTransport.abort({ sessionId })) - .catch(() => {}); + return safeAbort(sessionId); }, []); const ask = useCallback(async (params: AskAIParams) => { From 0230a36a77e0a25711590bf6bd511026dcccc048 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 1 Jul 2026 13:30:10 -0700 Subject: [PATCH 54/67] chore(ui): post-rebase version lockstep to 0.21.4 Rebased onto main (0.21.4, adds markdown math #878 + parser hardening). Bump @plannotator/core + @plannotator/ui to 0.21.4 to stay in lockstep with the repo. katex (main's math dep) merged into ui; typecheck clean, 1810 pass / 0 fail. --- packages/core/package.json | 2 +- packages/ui/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 01cf256ae..01d788d44 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/core", - "version": "0.21.3", + "version": "0.21.4", "type": "module", "exports": { "./agents": "./agents.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index e46b5871a..2b879126d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/ui", - "version": "0.21.3", + "version": "0.21.4", "type": "module", "exports": { "./components/*": "./components/*.tsx", From aac7587284e7d9de151607caa8f11e1415281cb9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 1 Jul 2026 15:09:05 -0700 Subject: [PATCH 55/67] docs(ui): consumer-lens handoff hardening + ADR 005 - HANDOFF.md: add supported-imports allowlist vs unsupported (hardcoded /api/*) list; document the annotation anchor schema, reattachment order, and untested stale-anchor degradation; state that the markdown editor cannot take CM6/Yjs extensions yet and the plan of record; note AI avoidability re-verified post-rebase; fix stale 0.21.3 ref. - adr/decisions/005: record the publish-as-packages decision (packages over copy/vendor, core/ui split, seam-singleton pattern + SSR revisit condition, the law, lockstep publish model). --- ...document-ui-as-packages-20260701-150551.md | 58 +++++++++++++ packages/ui/HANDOFF.md | 86 ++++++++++++++++++- 2 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 adr/decisions/005-publish-document-ui-as-packages-20260701-150551.md diff --git a/adr/decisions/005-publish-document-ui-as-packages-20260701-150551.md b/adr/decisions/005-publish-document-ui-as-packages-20260701-150551.md new file mode 100644 index 000000000..57dfa532a --- /dev/null +++ b/adr/decisions/005-publish-document-ui-as-packages-20260701-150551.md @@ -0,0 +1,58 @@ +# 005. Publish the document UI as `@plannotator/ui` + `@plannotator/core` + +Date: 2026-07-01 + +## Status + +Accepted + +## Context + +The commercial Workspaces app (Cloudflare Workers + D1 + R2 + Durable Objects, WorkOS identity, Yjs realtime) needs Plannotator's document UI: the markdown rendering engine, the annotation/comment machinery, the markdown editor, and the theme. Plannotator's open-source experience must not change while making that possible. + +Two approaches were on the table: + +1. **Copy/vendor** — fork the UI source into the Workspaces repo (an earlier from-scratch reimplementation of this UI was attempted, broke the app, and was reverted). +2. **Publish** — make the UI an installable package with host-override seams. + +Copy/vendor loses on two facts we've since measured: + +- **The reusable pieces sit deep in the package graph.** `packages/ui` imported freely from `@plannotator/shared`, which is Node-bound (fs/git/server). A copy would have to sever those imports by hand and re-sever them on every sync. +- **Main moves too fast for a fork.** During the packaging PR (#957) alone, the branch was rebased onto main **four times** (46 commits, then #979/#980, #981/#983, #878 + the 0.21.4 release). A vendored copy would already be four syncs behind; every UI feature landing on main (math rendering, parser hardening, PR-context work) would need manual porting. + +Seams beat surgery for the same reason: an override point whose default *is* today's code keeps one source of truth, whereas editing call sites per-consumer creates two. + +## Decision + +Publish two packages, keep one private, and decouple via seams — never rewrite: + +- **`@plannotator/core`** — browser-safe, zero-dependency pure utils + types, carved out of `@plannotator/shared` with `git mv`. CI typechecks it without `@types/node` so a `node:` import cannot sneak in. Published. +- **`@plannotator/ui`** — the React components, hooks, theme, and `configurePlannotatorUI()`. Depends on `core`. Published. +- **`@plannotator/shared`** — stays private; re-exports the moved core modules via one-line shims (`export * from '@plannotator/core/x'`), so none of Plannotator's ~99 internal import sites changed. + +**The seam pattern:** every place the UI touches a backend is a module-level singleton with a setter — `let impl = default; setX(); resetX(); getX()` — where the default reproduces Plannotator's literal behavior (`/api/*` over fetch). Plannotator passes nothing and stays byte-for-byte unchanged; a host calls `configurePlannotatorUI({...})` once at startup. Seams shipped: storage, identity (incl. `isEditable`), image resolving, uploads, doc previews, file tree, drafts, external annotations, AI transport, server sync. + +**Singleton, not a React Provider — with a named revisit condition:** module-level state is safe for client-side apps (one user per browser). It is wrong under SSR, where one server process renders for many users. Neither Plannotator nor Workspaces does SSR. **If SSR is ever added for this UI, that is the trigger to introduce a `` provider** and turn `configurePlannotatorUI` into a compatibility shim over it. Until then the complexity is not paid. + +**The law** (guardrails, enforced socially and by CI where possible): + +1. Don't reimplement the document UI from scratch — add a seam. +2. Every seam's default must reproduce today's Plannotator behavior. +3. `@plannotator/core` stays browser-safe and zero-dep — no `node:` imports. +4. Never delete working Plannotator code until a human confirms parity in the browser. + +**Publish model:** both packages version **in lockstep with the repo** (currently 0.21.4). They ship TypeScript source (consumers bundle it); `styles.css` is precompiled by `prepack`. `workspace:*` inter-deps must resolve to exact versions at publish time, so the flow is `bun pm pack` then `npm publish *.tgz --provenance --access public`, **`core` first, then `ui`**. First publish is manual from `main` after merge; a CI publish job is a follow-up. + +## Consequences + +- Workspaces installs the packages and plugs in its Cloudflare backend (localStorage settings, WorkOS identity with `isEditable() => false`, R2 uploads, D1 doc previews, DO-backed annotation transport) without forking. Its repo writes the matching consumer-side ADR. +- The published exports map is broad; **importable ≠ supported**. The supported-import allowlist and the unsupported "calls Plannotator's local server" list live in `packages/ui/HANDOFF.md`, alongside the annotation anchor schema and the editor/Yjs status. Restructuring exports was deliberately deferred. +- Known rough edges accepted for v1 (documented in HANDOFF.md): `AITransport`/`FileTreeBackend` leak `Response` objects; the markdown editor doesn't yet accept CM6 extensions (live collab lands via a later atomic PR threading `extensions?` through both wrapper layers after `@plannotator/markdown-editor` is imported into the monorepo). +- Every future "the host needs different behavior" request has one sanctioned shape: add an optional seam in a Plannotator PR whose default is today's behavior. +- Version lockstep means UI releases are repo releases; publishing stays owner-authorized. + +## References + +- PR #957 (`feat/pkg-document-ui`) +- `packages/ui/HANDOFF.md` — consumer handoff (seam catalog, supported imports, anchor schema) +- `packages/ui/README.md`, `packages/ui/CLAUDE.md` — the short version next to the code diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index 6d2d73a23..7fb2a2251 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -2,7 +2,7 @@ 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"** and **"The seam catalog"**. +If you read nothing else, read **"The 60-second version"**, **"Supported imports"**, and **"The seam catalog"**. --- @@ -168,6 +168,86 @@ Grounded in a read of the Workspaces repo (`apps/app`, `apps/usercontent`, `apps --- +## Supported imports (the allowlist) + +The exports map is broad (`./components/*`, `./hooks/*`, `./utils/*` — everything is importable), 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. + +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. | +| `theme` / `styles.css` | Theme tokens + precompiled stylesheet. | +| `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. | +| `components/Viewer` | The full annotatable document. **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`. See the Yjs note below. | +| `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. | +| `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`. | + +**AI is fully avoidable** (re-verified after the final rebase): there is no top-level barrel export, no supported import above pulls in `useAIChat` (it 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. Don't import `components/ai/*` and you carry 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. + +--- + +## 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`: + +```ts +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. + +--- + ## 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`, `{ 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. @@ -176,13 +256,15 @@ Grounded in a read of the Workspaces repo (`apps/app`, `apps/usercontent`, `apps 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.** Live multi-user editing is a hard requirement for Workspaces (its ADR 0010), and the underlying editor (`@atomic-editor/editor`, CodeMirror 6) supports extensions — but **neither wrapper layer exposes them**: `@plannotator/markdown-editor`'s `MarkdownEditorProps` has no `extensions` prop, and `@plannotator/ui`'s `MarkdownEditor` wrapper therefore can't pass one. So today you cannot thread `y-codemirror.next` (or any CM6 extension) into the editor. **Do not treat live editing as available in this release.** The plan of record: (1) merge this PR; (2) import `@plannotator/markdown-editor` into this monorepo; (3) one atomic PR threading an optional `extensions?` prop through both layers. Single-user editing works today; the first Workspaces UI slice doesn't need live collab. + None of these block adoption. They're the honest "here's what we'd polish next" list. --- ## Publishing & versioning -- `@plannotator/core` and `@plannotator/ui` are versioned **in lockstep with the repo** (currently `0.21.3`). +- `@plannotator/core` and `@plannotator/ui` are versioned **in lockstep with the repo** (currently `0.21.4`). - 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 --provenance --access public`). Publish **`core` first, then `ui`**. - `styles.css` is built by the `prepack` script (`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 `main` after merge. (Wiring a CI publish job is a follow-up.) From 92bc20feb54ffa102f317df6c687a17388107119 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 1 Jul 2026 15:56:20 -0700 Subject: [PATCH 56/67] fix(ui): make shipped source strict-TS clean for consumers + seam type barrel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumers compile the published TS source with their own compiler options, and strict mode failed with 35 errors inside the package: - settings.ts: satisfies SettingDef is contravariantly illegal under strictFunctionTypes (33 errors) — use SettingDef - useDismissOnOutsideAndEscape: RefObject rejects React 19's useRef(null) refs — widen to HTMLElement | null - globals.d.ts: declare *.png / *.webp modules, referenced from each asset-importing component so any consumer program that includes one gets the ambient declarations Also unscatter the seam contract types: configure.ts re-exports every seam type next to configurePlannotatorUI, and ServerSyncFn is now exported from config/index.ts (it was unreachable through the exports map). Verified: standalone Vite consumer importing the full supported surface passes tsc --noEmit under full strict (was 35 errors). --- packages/ui/components/Landing.tsx | 1 + .../LookAndFeelAnnouncementDialog.tsx | 1 + packages/ui/components/ProviderIcons.tsx | 1 + packages/ui/components/TaterSpritePullup.tsx | 1 + packages/ui/components/TaterSpriteRunning.tsx | 1 + packages/ui/components/TaterSpriteSitting.tsx | 1 + packages/ui/config/index.ts | 1 + packages/ui/config/settings.ts | 5 +++- packages/ui/configure.ts | 24 +++++++++++++++++-- packages/ui/globals.d.ts | 12 ++++++++++ .../ui/hooks/useDismissOnOutsideAndEscape.ts | 2 +- 11 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/ui/components/Landing.tsx b/packages/ui/components/Landing.tsx index f01d7b160..ad40e123f 100644 --- a/packages/ui/components/Landing.tsx +++ b/packages/ui/components/Landing.tsx @@ -1,3 +1,4 @@ +/// import React from "react"; import logoImage from "../plannotator.webp"; import { ModeToggle } from "./ModeToggle"; diff --git a/packages/ui/components/LookAndFeelAnnouncementDialog.tsx b/packages/ui/components/LookAndFeelAnnouncementDialog.tsx index 9cec2d302..ff6182907 100644 --- a/packages/ui/components/LookAndFeelAnnouncementDialog.tsx +++ b/packages/ui/components/LookAndFeelAnnouncementDialog.tsx @@ -1,3 +1,4 @@ +/// import React, { useState } from 'react'; import { createPortal } from 'react-dom'; import { TextShimmer } from './TextShimmer'; diff --git a/packages/ui/components/ProviderIcons.tsx b/packages/ui/components/ProviderIcons.tsx index 77f9dabb8..aaf7503ba 100644 --- a/packages/ui/components/ProviderIcons.tsx +++ b/packages/ui/components/ProviderIcons.tsx @@ -1,3 +1,4 @@ +/// import type React from 'react'; import codexPng from '../assets/icon-codex.png'; diff --git a/packages/ui/components/TaterSpritePullup.tsx b/packages/ui/components/TaterSpritePullup.tsx index bb1e045de..892761d56 100644 --- a/packages/ui/components/TaterSpritePullup.tsx +++ b/packages/ui/components/TaterSpritePullup.tsx @@ -1,3 +1,4 @@ +/// import React from 'react'; import spriteSheet from '../sprite_package_pulluphang/sprite.png'; diff --git a/packages/ui/components/TaterSpriteRunning.tsx b/packages/ui/components/TaterSpriteRunning.tsx index a50a50d90..4ab3e7efd 100644 --- a/packages/ui/components/TaterSpriteRunning.tsx +++ b/packages/ui/components/TaterSpriteRunning.tsx @@ -1,3 +1,4 @@ +/// import React from 'react'; import spriteSheet from '../sprite_package_additional/sprite.png'; diff --git a/packages/ui/components/TaterSpriteSitting.tsx b/packages/ui/components/TaterSpriteSitting.tsx index 89be0d28f..610f6e601 100644 --- a/packages/ui/components/TaterSpriteSitting.tsx +++ b/packages/ui/components/TaterSpriteSitting.tsx @@ -1,3 +1,4 @@ +/// import React from 'react'; import spriteSheet from '../sprite_package_new/sprite.png'; diff --git a/packages/ui/config/index.ts b/packages/ui/config/index.ts index 458bb8a1d..4c7f9ecad 100644 --- a/packages/ui/config/index.ts +++ b/packages/ui/config/index.ts @@ -1,3 +1,4 @@ export { configStore } from './configStore'; +export type { ServerSyncFn } from './configStore'; export { useConfigValue } from './useConfig'; export { setReviewPanelView, setReviewDefaultDiffType, type ReviewDefaultDiffType } from './reviewView'; diff --git a/packages/ui/config/settings.ts b/packages/ui/config/settings.ts index 8bf581ca7..7615962d7 100644 --- a/packages/ui/config/settings.ts +++ b/packages/ui/config/settings.ts @@ -301,7 +301,10 @@ export const SETTINGS = { } }, }, -} satisfies Record>; + /* SettingDef, not : consumers compile this shipped source under + their own strictFunctionTypes, where a narrow `toCookie: (v: string) => void` + is contravariantly incompatible with `(value: unknown) => void`. */ +} satisfies Record>; export type SettingsMap = typeof SETTINGS; export type SettingName = keyof SettingsMap; diff --git a/packages/ui/configure.ts b/packages/ui/configure.ts index 0172c3fc2..2d412c83d 100644 --- a/packages/ui/configure.ts +++ b/packages/ui/configure.ts @@ -1,7 +1,7 @@ import { setImageSrcResolver, type ImageSrcResolver } from './components/ImageThumbnail'; -import { setDocPreviewFetcher, type DocPreviewFetcher } from './components/InlineMarkdown'; +import { setDocPreviewFetcher, type DocPreviewFetcher, type DocPreviewResult } from './components/InlineMarkdown'; import { setStorageBackend, type StorageBackend } from './utils/storage'; -import { setUploadTransport, type UploadTransport } from './utils/upload'; +import { setUploadTransport, type UploadTransport, type UploadResult } from './utils/upload'; import { setIdentityProvider, type IdentityProvider } from './utils/identity'; import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser'; import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft'; @@ -9,6 +9,26 @@ import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from import { setAITransport, type AITransport } from './hooks/useAIChat'; import { configStore } from './config'; import type { ServerSyncFn } from './config/configStore'; +import type { ExternalAnnotationEvent, VaultNode } from './types'; + +// One-stop type barrel: every seam contract a host implements is importable +// from this module, next to configurePlannotatorUI itself. +export type { + ImageSrcResolver, + DocPreviewFetcher, + DocPreviewResult, + StorageBackend, + UploadTransport, + UploadResult, + IdentityProvider, + FileTreeBackend, + VaultNode, + DraftTransport, + ExternalAnnotationTransport, + ExternalAnnotationEvent, + AITransport, + ServerSyncFn, +}; type ExternalAnnotationBase = { id: string; source?: string }; diff --git a/packages/ui/globals.d.ts b/packages/ui/globals.d.ts index fedbc23de..d6264f74f 100644 --- a/packages/ui/globals.d.ts +++ b/packages/ui/globals.d.ts @@ -1,2 +1,14 @@ // Allow side-effect CSS imports (highlight.js themes, overlayscrollbars, etc.) declare module '*.css'; + +// Image asset imports (sprites, screenshots). Consumers compiling this shipped +// source need these ambient declarations too — each asset-importing component +// carries a /// to this file so any program that includes it gets them. +declare module '*.png' { + const src: string; + export default src; +} +declare module '*.webp' { + const src: string; + export default src; +} diff --git a/packages/ui/hooks/useDismissOnOutsideAndEscape.ts b/packages/ui/hooks/useDismissOnOutsideAndEscape.ts index d0df6ed08..5c3eb5441 100644 --- a/packages/ui/hooks/useDismissOnOutsideAndEscape.ts +++ b/packages/ui/hooks/useDismissOnOutsideAndEscape.ts @@ -6,7 +6,7 @@ export function useDismissOnOutsideAndEscape({ onDismiss, }: { enabled: boolean; - ref: React.RefObject; + ref: React.RefObject; onDismiss: () => void; }) { useEffect(() => { From 3d49949265f8cd2539093b167d2b7507f5024e40 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 1 Jul 2026 15:56:36 -0700 Subject: [PATCH 57/67] fix(ui): keep KaTeX fonts out of published styles.css (back to ~187KB, was 1.6MB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's math PR imports katex/dist/katex.min.css in theme.css; the publish build (Vite lib mode) force-inlines all 60 KaTeX math fonts as data URIs, ballooning styles.css to 1.6MB (977KB gzip) and breaking the package's consumer-owns-fonts policy. Alias the katex stylesheet to an empty stub in vite.css.config.ts only — theme.css stays untouched (no rebase surface) and Plannotator's own apps, which import theme.css directly, still bundle KaTeX as before. Hosts that render math load katex.min.css themselves (bundler import, CDN tag, or self-hosted copy per HANDOFF.md), which also gets them lazy font loading. Verified: fresh build is 186.9KB / 30.8KB gzip with zero @font-face data URIs; consumer vite build CSS drops 1.66MB -> 200KB. --- packages/ui/build-stubs/katex-css-stub.css | 7 +++++++ packages/ui/styles-entry.css | 5 ++++- packages/ui/vite.css.config.ts | 10 +++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 packages/ui/build-stubs/katex-css-stub.css diff --git a/packages/ui/build-stubs/katex-css-stub.css b/packages/ui/build-stubs/katex-css-stub.css new file mode 100644 index 000000000..9d839812b --- /dev/null +++ b/packages/ui/build-stubs/katex-css-stub.css @@ -0,0 +1,7 @@ +/* Build-time stub. The published styles.css build (vite.css.config.ts) aliases + katex/dist/katex.min.css to this empty file: Vite's library mode inlines every + font a stylesheet references as a base64 data URI, and KaTeX's 60 math fonts + (~1.1MB) would balloon the published CSS ~9x. Hosts that render math import + KaTeX's stylesheet themselves (bundler import, CDN tag, or self-hosted copy — + see HANDOFF.md), which also gets them lazy font loading. Plannotator's own + apps import theme.css directly and are untouched by this alias. */ diff --git a/packages/ui/styles-entry.css b/packages/ui/styles-entry.css index 143bb1034..5ae981072 100644 --- a/packages/ui/styles-entry.css +++ b/packages/ui/styles-entry.css @@ -1,7 +1,10 @@ /* Fonts are NOT bundled here. The published styles.css ships only theme + component styles; the consuming app loads the fonts (see README). The theme defines the font-family tokens (--font-sans / --font-mono); the app makes those families - available. This keeps styles.css small and avoids shipping font binaries. */ + available. This keeps styles.css small and avoids shipping font binaries. + That includes KaTeX: theme.css imports katex's stylesheet for Plannotator's own + apps, but the published build aliases it to an empty stub (vite.css.config.ts) — + hosts that render math load katex/dist/katex.min.css themselves (see HANDOFF.md). */ @import "tailwindcss"; @plugin "tailwindcss-animate"; diff --git a/packages/ui/vite.css.config.ts b/packages/ui/vite.css.config.ts index 80300db2b..c2d15c1c5 100644 --- a/packages/ui/vite.css.config.ts +++ b/packages/ui/vite.css.config.ts @@ -4,7 +4,15 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [tailwindcss()], - resolve: { alias: { '@plannotator/ui': path.resolve(__dirname, '.') } }, + resolve: { + alias: { + '@plannotator/ui': path.resolve(__dirname, '.'), + // Keep KaTeX (and its ~1.1MB of math fonts, which lib mode would inline + // as data URIs) out of the published styles.css. Hosts that render math + // load katex/dist/katex.min.css themselves — see build-stubs/ and HANDOFF.md. + 'katex/dist/katex.min.css': path.resolve(__dirname, 'build-stubs/katex-css-stub.css'), + }, + }, build: { lib: { entry: path.resolve(__dirname, 'styles-entry.css'), From 71095e0bffaf145181b50ff6c9fdb9092c1f8deb Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 1 Jul 2026 15:56:37 -0700 Subject: [PATCH 58/67] docs(ui): HANDOFF corrections from adversarial consumer review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Math rendering section: KaTeX css/fonts excluded from styles.css by design; three one-time host setup options (self-hosted recommended, CDN tag, bundler import) - styles.css size claim corrected (~187KB / ~31KB gzip) + strict-TS guarantee documented (verified against a standalone consumer) - AI-avoidability claim made precise: configure.ts statically imports useAIChat for its setter; unused AI code tree-shakes to zero (bundle- verified) — the runtime claim holds, the static wording was wrong - Loud warning on the loadSettingsFromBackend ordering footgun: configuring before hydration seeds generated defaults into the host backend and nothing re-runs hydration - DraftTransport.load() tombstone-generation contract spelled out - Seam-type barrel documented on the configure row; 'everything is importable' softened (some components/*.ts don't resolve via the *.tsx wildcard); stale diff stats refreshed --- packages/ui/HANDOFF.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index 7fb2a2251..c96858b97 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -27,10 +27,10 @@ Core modules: `agents`, `agent-jobs`, `agent-terminal`, `browser-paths`, `code-f - `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` (185KB) 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). +- 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: `159 files changed, +7342 / −2723`. 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. +Net: `129 files changed, +5234 / −2423` (vs current main). 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. --- @@ -48,7 +48,7 @@ Net: `159 files changed, +7342 / −2723`. Most of the deletions are the `git mv - **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"`. +- **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` — verified by compiling the full supported surface in a standalone Vite consumer. ### The seam pattern (how an override works) @@ -97,11 +97,13 @@ Pass any subset of these to `configurePlannotatorUI({ ... })`. Anything omitted **`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. +> **⚠️ 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`). +**`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`** — `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()` directly for full type safety (the `configure` front door pins the base type for ergonomics). @@ -170,7 +172,7 @@ Grounded in a read of the Workspaces repo (`apps/app`, `apps/usercontent`, `apps ## Supported imports (the allowlist) -The exports map is broad (`./components/*`, `./hooks/*`, `./utils/*` — everything is importable), 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 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. @@ -178,7 +180,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r | Import | Notes | |---|---| -| `configure` (`configurePlannotatorUI`) | The front door. | +| `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. | | `types` | `Annotation`, `Block`, `AnnotationType`, etc. | | `utils/parser` (`parseMarkdownToBlocks`, `exportAnnotations`) | Pure — no backend. | @@ -194,7 +196,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r | Seam-backed hooks: `useAnnotationHighlighter`, `useAnnotationDraft`, `useCodeAnnotationDraft`, `useExternalAnnotations`, `useFileBrowser` | Their network access goes through the seams in the catalog above. | | `config` (`ConfigStore`) | Persists through `storageBackend`. | -**AI is fully avoidable** (re-verified after the final rebase): there is no top-level barrel export, no supported import above pulls in `useAIChat` (it 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. Don't import `components/ai/*` and you carry no AI code. +**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 @@ -215,6 +217,16 @@ Don't import these in a host. Each hits hardcoded Plannotator endpoints: 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 ``. No third-party dependency in your serving path; fonts download lazily, only on pages that actually render math. +2. **CDN tag:** `` in your HTML. Same lazy-font behavior; adds a third-party origin. +3. **Bundler import:** `import 'katex/dist/katex.min.css';` next to your `styles.css` import — resolves out of the box (`katex` is already a dependency of `@plannotator/ui`) and your bundler ships the fonts as separate lazy-loaded files. + +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) From 80b6afe51f375b55ded9005c7371d7c906e9db2b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 1 Jul 2026 16:55:14 -0700 Subject: [PATCH 59/67] docs(ui): math setup pointer in README + pnpm caveat on the katex bundler-import option --- packages/ui/HANDOFF.md | 2 +- packages/ui/README.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index c96858b97..86579ad7f 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -223,7 +223,7 @@ The renderer's `MathBlock` (and inline math) uses KaTeX. **KaTeX's stylesheet an 1. **Self-hosted (recommended for production):** copy `katex/dist/katex.min.css` + `katex/dist/fonts/` to your own asset origin and add one ``. No third-party dependency in your serving path; fonts download lazily, only on pages that actually render math. 2. **CDN tag:** `` in your HTML. Same lazy-font behavior; adds a third-party origin. -3. **Bundler import:** `import 'katex/dist/katex.min.css';` next to your `styles.css` import — resolves out of the box (`katex` is already a dependency of `@plannotator/ui`) and your bundler ships the fonts as separate lazy-loaded files. +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. diff --git a/packages/ui/README.md b/packages/ui/README.md index b8802d317..0bd7d7db3 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -44,6 +44,7 @@ npm install @plannotator/ui @plannotator/core import "@fontsource-variable/geist-mono"; ``` Or provide your own fonts and set `--font-sans` / `--font-mono` to match. + The same policy covers math: KaTeX's stylesheet + fonts are deliberately not in `styles.css` — if you render math, load `katex/dist/katex.min.css` yourself (import, CDN tag, or self-hosted copy; see HANDOFF.md "Math rendering"). 4. Import components: `import { Viewer } from "@plannotator/ui/components/Viewer";` 5. Build with a bundler that compiles TS/TSX (Vite + React 19 + Tailwind v4). The packages ship **source**, so your bundler compiles them — set `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `jsx: "react-jsx"`. From d2b234e55f9f6929d67ffc5cbc8ff118b922dda7 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 2 Jul 2026 07:26:26 -0700 Subject: [PATCH 60/67] =?UTF-8?q?fix(ui):=20lazy=20settings=20resolution?= =?UTF-8?q?=20=E2=80=94=20zero=20cookies=20on=20a=20configured=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The configStore resolved all settings eagerly in its constructor, at module import — before a host's configurePlannotatorUI() could install its StorageBackend — writing 17 plannotator-* cookies (including a generated identity) onto the host origin. Resolution now runs lazily on first settings access (get/set/init/loadFromBackend): by then the host backend is live, so the initial reads AND default-seeding writes route through it. A configured host gets zero cookies, ever. Plannotator unchanged: same resolution, same cookie seeding, same values — on first settings read (same page load) instead of at import. New configStore.lazyInit.seam.test.ts proves the contract from a fresh module graph; full suite + consumer strict tsc green. --- packages/ui/HANDOFF.md | 2 + .../config/configStore.lazyInit.seam.test.ts | 61 +++++++++++++++++++ packages/ui/config/configStore.ts | 35 ++++++++--- 3 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 packages/ui/config/configStore.lazyInit.seam.test.ts diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index 86579ad7f..e32c11af1 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -97,6 +97,8 @@ Pass any subset of these to `configurePlannotatorUI({ ... })`. Anything omitted **`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. diff --git a/packages/ui/config/configStore.lazyInit.seam.test.ts b/packages/ui/config/configStore.lazyInit.seam.test.ts new file mode 100644 index 000000000..45abe09f1 --- /dev/null +++ b/packages/ui/config/configStore.lazyInit.seam.test.ts @@ -0,0 +1,61 @@ +/** + * Lazy-resolution contract for the configStore singleton. + * + * Bun evaluates a fresh module graph per test file, so the store imported here + * has never been touched: this file can observe first-use behavior. The + * contract under test: importing the module resolves NOTHING — a host + * StorageBackend installed before the first settings access receives the + * initial resolution reads and the default-seeding writes. A configured host + * therefore never gets plannotator-* cookies written to its origin. + */ +import { describe, test, expect, afterAll } from 'bun:test'; +import { setStorageBackend, resetStorageBackend, type StorageBackend } from '../utils/storage'; +import { configStore } from './configStore'; + +describe('configStore lazy resolution', () => { + const stored = new Map(); + const reads: string[] = []; + const writes: string[] = []; + const hostBackend: StorageBackend = { + getItem(key) { + reads.push(key); + return stored.get(key) ?? null; + }, + setItem(key, value) { + writes.push(key); + stored.set(key, value); + }, + removeItem(key) { + stored.delete(key); + }, + }; + + afterAll(() => { + resetStorageBackend(); + }); + + test('backend installed before first access gets the initial resolution and seeding writes', () => { + // Install the host backend BEFORE anything reads a setting. If the store + // had resolved eagerly at module import, none of this traffic would reach + // the host backend (and the seeding writes would have gone to cookies). + setStorageBackend(hostBackend); + + const identity = configStore.get('displayName'); + + expect(identity.length).toBeGreaterThan(0); + // Resolution happened lazily, through the live (host) backend: + expect(reads.length).toBeGreaterThan(0); + // Missing defaults were seeded into the host backend — including the + // generated identity — not into document.cookie: + expect(writes).toContain('plannotator-identity'); + expect(stored.has('plannotator-identity')).toBe(true); + }); + + test('resolution runs once; later reads are served from memory', () => { + const readsBefore = reads.length; + const first = configStore.get('displayName'); + const second = configStore.get('displayName'); + expect(second).toBe(first); + expect(reads.length).toBe(readsBefore); + }); +}); diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index 8997bc77a..0dc020193 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -56,10 +56,22 @@ class ConfigStore { private serverSyncTimer: ReturnType | null = null; private pagehideFlushRegistered = false; private serverSync: ServerSyncFn = defaultServerSync; + private loaded = false; - constructor() { - // Eagerly resolve all settings from synchronous sources (cookie > default). - // The store is safe to read from the moment it's created. + /** + * Resolve all settings from the LIVE storage backend (cookie > default) on + * first use — deliberately not in the constructor. The singleton is created + * at module import, which for a host app is before configurePlannotatorUI() + * can install its StorageBackend; resolving eagerly there would write every + * missing default (including a generated identity) as cookies onto the host's + * origin. Deferring to first use means a host that configures at startup gets + * its own backend for the initial resolution too — no cookies are ever + * written on a configured host. Plannotator is unchanged: same resolution, + * same default-seeding writes, on first settings access instead of at import. + */ + private ensureLoaded(): void { + if (this.loaded) return; + this.loaded = true; for (const [name, def] of Object.entries(SETTINGS)) { const fromCookie = def.fromCookie(); const defaultVal = typeof def.defaultValue === 'function' @@ -84,16 +96,20 @@ class ConfigStore { * after init() would silently overwrite server-supplied settings. */ loadFromBackend(): void { + this.ensureLoaded(); for (const [name, def] of Object.entries(SETTINGS)) { const fromBackend = def.fromCookie(); if (fromBackend !== undefined) { this.values.set(name, fromBackend); } else { - // Seed the host backend with the resolved default. The constructor ran - // at module load — before the host installed its StorageBackend — so its - // default-seeding writes went to the pre-install (cookie) backend, not - // this one. Without this, a fresh host store is never populated, so - // generated defaults (e.g. displayName) regenerate on every reload. + // Seed the host backend with the resolved default. This matters when + // the store was already resolved BEFORE the host installed its + // StorageBackend (e.g. something read a setting pre-configure): those + // default-seeding writes went to the earlier backend, not this one. + // Without this, a fresh host store is never populated, so generated + // defaults (e.g. displayName) regenerate on every reload. In the normal + // configure-at-startup flow ensureLoaded() above already resolved + // through the host backend and this loop is a no-op re-read. def.toCookie(this.values.get(name) as never); } } @@ -108,6 +124,7 @@ class ConfigStore { * by the constructor. Settings without a server value are left untouched. */ init(serverConfig?: Record): void { + this.ensureLoaded(); if (serverConfig) { for (const [name, def] of Object.entries(SETTINGS)) { if (def.serverKey && def.fromServer) { @@ -124,11 +141,13 @@ class ConfigStore { /** Get a resolved config value. Works outside React. */ get(key: K): SettingValue { + this.ensureLoaded(); return this.values.get(key) as SettingValue; } /** Set a config value. Writes cookie (sync), queues server write-back if applicable. */ set(key: K, value: SettingValue): void { + this.ensureLoaded(); const def = SETTINGS[key]; this.values.set(key, value); def.toCookie(value as never); From 9c10c970e2c674aef49abd9798cead6eededd6c4 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 5 Jul 2026 16:31:15 -0700 Subject: [PATCH 61/67] chore(ui): post-rebase version lockstep to 0.22.0 --- packages/core/package.json | 2 +- packages/ui/HANDOFF.md | 2 +- packages/ui/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 01d788d44..039691993 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/core", - "version": "0.21.4", + "version": "0.22.0", "type": "module", "exports": { "./agents": "./agents.ts", diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index e32c11af1..1d6012a85 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -278,7 +278,7 @@ None of these block adoption. They're the honest "here's what we'd polish next" ## Publishing & versioning -- `@plannotator/core` and `@plannotator/ui` are versioned **in lockstep with the repo** (currently `0.21.4`). +- `@plannotator/core` and `@plannotator/ui` are versioned **in lockstep with the repo** (currently `0.22.0`). - 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 --provenance --access public`). Publish **`core` first, then `ui`**. - `styles.css` is built by the `prepack` script (`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 `main` after merge. (Wiring a CI publish job is a follow-up.) diff --git a/packages/ui/package.json b/packages/ui/package.json index 2b879126d..5956d94f5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/ui", - "version": "0.21.4", + "version": "0.22.0", "type": "module", "exports": { "./components/*": "./components/*.tsx", From 903f5e13c81f06842bdc7b71f9124dc29c3ba9fb Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 5 Jul 2026 22:56:55 -0700 Subject: [PATCH 62/67] =?UTF-8?q?fix(ui):=20round-2=20review=20batch=20?= =?UTF-8?q?=E2=80=94=20dedupe=20asset=20declarations,=20CI=20seam=20tests,?= =?UTF-8?q?=20strict=20consumer=20gate,=20doc=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - components/types.d.ts: drop the *.png/*.webp declarations that globals.d.ts now owns — both shipping was a duplicate-identifier error for any consumer with skipLibCheck: false - untrack packages/ui/styles.css (generated by prepack, gitignored; got scooped into the carve commit during the rebase by git add -A before the ignore entry existed in the replay) - CI: the DOM test step now runs ALL packages/ui tests, so the seam contract tests (AI/draft/external-annotations/file-tree/inline- markdown) actually execute in CI instead of skipping - new packages/ui/tsconfig.strict-consumer.json wired into root typecheck: type-checks the supported-import surface under full strict, so the consumer strict-TS guarantee can't silently rot - HANDOFF: rot-proofed the diff stat, strict guarantee now cites the CI gate, CDN katex pinned-version wording, theme-vs-styles.css caveats (theme still imports KaTeX + needs Tailwind), Viewer required props, Yjs plan-of-record updated to the atomic-editor fork - README: @source fallback wording (build entry isn't shipped) --- .github/workflows/test.yml | 4 +-- package.json | 2 +- packages/ui/HANDOFF.md | 12 +++---- packages/ui/README.md | 2 +- packages/ui/components/types.d.ts | 15 +++----- packages/ui/styles.css | 1 - packages/ui/tsconfig.strict-consumer.json | 42 +++++++++++++++++++++++ 7 files changed, 56 insertions(+), 22 deletions(-) delete mode 100644 packages/ui/styles.css create mode 100644 packages/ui/tsconfig.strict-consumer.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c7df2f7e..068e6a867 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,8 +31,8 @@ jobs: - name: Run tests run: bun test - - name: Run file browser DOM tests - run: DOM_TESTS=1 bun test packages/ui/hooks/useFileBrowser.test.tsx + - name: Run UI DOM tests (incl. seam contracts) + run: DOM_TESTS=1 bun test packages/ui pi-extension-ai-runtime-windows: # Exercises the Pi extension's Node/jiti server mirror on Windows with an diff --git a/package.json b/package.json index 45c3df40d..44a635f35 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bun test", - "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json", + "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.strict-consumer.json && tsc --noEmit -p apps/pi-extension/tsconfig.json", "build:ui-css": "bun run --cwd packages/ui build:css" }, "dependencies": { diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index 1d6012a85..324c0be6d 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -30,7 +30,7 @@ Core modules: `agents`, `agent-jobs`, `agent-terminal`, `browser-paths`, `code-f - 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: `129 files changed, +5234 / −2423` (vs current main). 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. +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. --- @@ -48,7 +48,7 @@ Net: `129 files changed, +5234 / −2423` (vs current main). Most of the deletio - **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` — verified by compiling the full supported surface in a standalone Vite consumer. +- **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) @@ -183,12 +183,12 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r | 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. | +| `theme` / `styles.css` | Theme tokens + precompiled stylesheet. **Prefer `styles.css`.** The raw `theme` export still `@import`s 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. | -| `components/Viewer` | The full annotatable document. **Pass `disableCodePathValidation` unless you implement `/api/doc/exists`** — code-path validation is a prop-level opt-out, not a `configure` seam. | +| `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`. See the Yjs note below. | | `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. | @@ -224,7 +224,7 @@ If Workspaces ever wants one of these surfaces, the path is the same as everythi 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 ``. No third-party dependency in your serving path; fonts download lazily, only on pages that actually render math. -2. **CDN tag:** `` in your HTML. Same lazy-font behavior; adds a third-party origin. +2. **CDN tag:** `` in your HTML — pin `` 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. @@ -270,7 +270,7 @@ interface Annotation { 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.** Live multi-user editing is a hard requirement for Workspaces (its ADR 0010), and the underlying editor (`@atomic-editor/editor`, CodeMirror 6) supports extensions — but **neither wrapper layer exposes them**: `@plannotator/markdown-editor`'s `MarkdownEditorProps` has no `extensions` prop, and `@plannotator/ui`'s `MarkdownEditor` wrapper therefore can't pass one. So today you cannot thread `y-codemirror.next` (or any CM6 extension) into the editor. **Do not treat live editing as available in this release.** The plan of record: (1) merge this PR; (2) import `@plannotator/markdown-editor` into this monorepo; (3) one atomic PR threading an optional `extensions?` prop through both layers. Single-user editing works today; the first Workspaces UI slice doesn't need live collab. +4. **The markdown editor can't take live-collab extensions yet.** Live multi-user editing is a hard requirement for Workspaces (its ADR 0010), and the underlying editor (`@atomic-editor/editor`, CodeMirror 6) supports extensions — but **neither wrapper layer exposes them**: `@plannotator/markdown-editor`'s `MarkdownEditorProps` has no `extensions` prop, and `@plannotator/ui`'s `MarkdownEditor` wrapper therefore can't pass one. So today you cannot thread `y-codemirror.next` (or any CM6 extension) into the editor. **Do not treat live editing as available in this release.** The plan of record (updated now that the editor is forked as `github.com/plannotator/atomic-editor`, published as `@plannotator/atomic-editor`): one atomic change threading an optional `extensions?` prop through `@plannotator/atomic-editor` and `@plannotator/markdown-editor`, then a version bump here — no monorepo import needed. Single-user editing works today; the first Workspaces UI slice doesn't need live collab. None of these block adoption. They're the honest "here's what we'd polish next" list. diff --git a/packages/ui/README.md b/packages/ui/README.md index 0bd7d7db3..676fbc989 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -37,7 +37,7 @@ npm install @plannotator/ui @plannotator/core ``` 1. Call `configurePlannotatorUI({ ... })` once at startup with your backend. -2. Import the stylesheet: `import "@plannotator/ui/styles.css";` (precompiled — no Tailwind wiring needed; the `@source` glob is the fallback if you'd rather scan source). +2. Import the stylesheet: `import "@plannotator/ui/styles.css";` (precompiled — no Tailwind wiring needed; if you'd rather run your own Tailwind over the package source, add `@source` globs for `@plannotator/ui`'s `components/`, `hooks/`, and `utils/` dirs in your own CSS — the package doesn't ship its build entry). 3. **Load the fonts in your app entry** — the stylesheet references `--font-sans` / `--font-mono` but does not ship font binaries (standard for a shared UI package; your app owns font loading). Plannotator uses Inter + Geist Mono: ```ts import "@fontsource-variable/inter"; diff --git a/packages/ui/components/types.d.ts b/packages/ui/components/types.d.ts index 76c6549de..b79978b5b 100644 --- a/packages/ui/components/types.d.ts +++ b/packages/ui/components/types.d.ts @@ -1,14 +1,7 @@ // Vite globals injected at build time declare const __APP_VERSION__: string; -// declare webp - -declare module "*.webp" { - const content: string; - export default content; -} - -declare module "*.png" { - const content: string; - export default content; -} +// Image asset module declarations live in ../globals.d.ts (the package's +// ambient-declaration home, /// 'd by each asset-importing +// component so consumer compilers load them too). Declaring them here as +// well would be a duplicate-identifier error under skipLibCheck: false. diff --git a/packages/ui/styles.css b/packages/ui/styles.css deleted file mode 100644 index 893ff039d..000000000 --- a/packages/ui/styles.css +++ /dev/null @@ -1 +0,0 @@ -/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-purple-500:oklch(62.7% .265 303.9);--color-zinc-500:oklch(55.2% .016 285.938);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:1.45;--text-base:1rem;--text-base--line-height:1.55;--text-lg:1.0625rem;--text-lg--line-height:1.45;--text-xl:1.1875rem;--text-xl--line-height:1.4;--text-2xl:1.375rem;--text-2xl--line-height:1.3;--text-4xl:1.875rem;--text-4xl--line-height:1.15;--text-5xl:2rem;--text-5xl--line-height:1.1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:calc(var(--radius) - 4px);--radius-2xl:1rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--blur-md:12px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}:root{--surface-0:var(--muted)}@supports (color:color-mix(in lab,red,red)){:root{--surface-0:color-mix(in oklch, var(--muted) 40%, var(--background))}}:root{--surface-1:var(--muted);--surface-2:var(--muted)}@supports (color:color-mix(in lab,red,red)){:root{--surface-2:color-mix(in oklch, var(--muted) 70%, var(--foreground))}}:root{--sidebar:var(--card);--sidebar-foreground:var(--foreground);--sidebar-primary:var(--primary);--sidebar-primary-foreground:var(--primary-foreground);--sidebar-accent:var(--muted);--sidebar-accent-foreground:var(--foreground);--sidebar-border:var(--border);--sidebar-ring:var(--ring);--annotation-comment:var(--accent);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--card-ring:#ffffff14;--card-shadow:0 0 0 1px var(--card-ring), 0 1px 3px 0 #0003, 0 2px 8px -2px #0000004d}html{--terminal-background:var(--card);--terminal-foreground:var(--foreground);--terminal-cursor:var(--primary);--terminal-cursor-accent:var(--terminal-background);--terminal-selection-background:var(--primary)}@supports (color:color-mix(in lab,red,red)){html{--terminal-selection-background:color-mix(in oklab, var(--primary) 32%, var(--terminal-background))}}html{--terminal-selection-foreground:var(--foreground);--terminal-selection-inactive-background:var(--border);--terminal-black:var(--terminal-background);--terminal-red:var(--destructive);--terminal-green:var(--success);--terminal-yellow:var(--warning);--terminal-blue:var(--primary);--terminal-magenta:var(--accent);--terminal-cyan:var(--secondary);--terminal-white:var(--foreground);--terminal-bright-black:var(--muted-foreground);--terminal-bright-red:var(--destructive)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-red:color-mix(in oklab, var(--destructive) 80%, white)}}html{--terminal-bright-green:var(--success)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-green:color-mix(in oklab, var(--success) 80%, white)}}html{--terminal-bright-yellow:var(--warning)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-yellow:color-mix(in oklab, var(--warning) 80%, white)}}html{--terminal-bright-blue:var(--primary)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-blue:color-mix(in oklab, var(--primary) 80%, white)}}html{--terminal-bright-magenta:var(--accent)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-magenta:color-mix(in oklab, var(--accent) 80%, white)}}html{--terminal-bright-cyan:var(--secondary)}@supports (color:color-mix(in lab,red,red)){html{--terminal-bright-cyan:color-mix(in oklab, var(--secondary) 80%, white)}}html{--terminal-bright-white:var(--foreground)}.light{--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--terminal-bright-red:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-red:color-mix(in oklab, var(--destructive) 84%, black)}}.light{--terminal-bright-green:var(--success)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-green:color-mix(in oklab, var(--success) 84%, black)}}.light{--terminal-bright-yellow:var(--warning)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-yellow:color-mix(in oklab, var(--warning) 84%, black)}}.light{--terminal-bright-blue:var(--primary)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-blue:color-mix(in oklab, var(--primary) 84%, black)}}.light{--terminal-bright-magenta:var(--accent)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-magenta:color-mix(in oklab, var(--accent) 84%, black)}}.light{--terminal-bright-cyan:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.light{--terminal-bright-cyan:color-mix(in oklab, var(--secondary) 84%, black)}}.light{--card-ring:#0000000f;--card-shadow:0 0 0 1px var(--card-ring), 0 1px 3px 0 #0000000a, 0 2px 8px -2px #0000000f}*{border-color:var(--border)}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.-inset-x-2{inset-inline:calc(var(--spacing) * -2)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.-top-0\.5{top:calc(var(--spacing) * -.5)}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:calc(var(--spacing) * 0)}.top-0\.5{top:calc(var(--spacing) * .5)}.top-1{top:calc(var(--spacing) * 1)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-3\.5{top:calc(var(--spacing) * 3.5)}.top-12{top:calc(var(--spacing) * 12)}.top-\[50\%\]{top:50%}.top-\[52px\]{top:52px}.top-full{top:100%}.-right-0\.5{right:calc(var(--spacing) * -.5)}.-right-1{right:calc(var(--spacing) * -1)}.-right-2{right:calc(var(--spacing) * -2)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:calc(var(--spacing) * 0)}.right-0\.5{right:calc(var(--spacing) * .5)}.right-1{right:calc(var(--spacing) * 1)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-3\.5{right:calc(var(--spacing) * 3.5)}.bottom-0{bottom:calc(var(--spacing) * 0)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-4{bottom:calc(var(--spacing) * 4)}.bottom-full{bottom:100%}.-left-0\.5{left:calc(var(--spacing) * -.5)}.-left-1{left:calc(var(--spacing) * -1)}.left-0{left:calc(var(--spacing) * 0)}.left-0\.5{left:calc(var(--spacing) * .5)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-3{left:calc(var(--spacing) * 3)}.left-\[50\%\]{left:50%}.-z-10{z-index:-10}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-50{z-index:50}.z-\[59\]{z-index:59}.z-\[60\]{z-index:60}.z-\[70\]{z-index:70}.z-\[90\]{z-index:90}.z-\[100\]{z-index:100}.z-\[101\]{z-index:101}.z-\[110\]{z-index:110}.z-\[200\]{z-index:200}.z-\[9999\]{z-index:9999}.float-right{float:right}.clear-right{clear:right}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.m-64{margin:calc(var(--spacing) * 64)}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.mx-0\.5{margin-inline:calc(var(--spacing) * .5)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.my-0\.5{margin-block:calc(var(--spacing) * .5)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-1\.5{margin-block:calc(var(--spacing) * 1.5)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-5{margin-block:calc(var(--spacing) * 5)}.my-8{margin-block:calc(var(--spacing) * 8)}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.-mt-4{margin-top:calc(var(--spacing) * -4)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-\[3px\]{margin-top:3px}.-mr-1{margin-right:calc(var(--spacing) * -1)}.-mr-3{margin-right:calc(var(--spacing) * -3)}.-mr-4{margin-right:calc(var(--spacing) * -4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-px{margin-bottom:1px}.-ml-0\.5{margin-left:calc(var(--spacing) * -.5)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.list-item{display:list-item}.table{display:table}.field-sizing-content{field-sizing:content}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.h-0{height:calc(var(--spacing) * 0)}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:calc(var(--spacing) * 1)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-24{height:calc(var(--spacing) * 24)}.h-48{height:calc(var(--spacing) * 48)}.h-80{height:calc(var(--spacing) * 80)}.h-\[2px\]{height:2px}.h-\[13px\]{height:13px}.h-\[16px\]{height:16px}.h-\[18px\]{height:18px}.h-\[22px\]{height:22px}.h-\[760px\]{height:760px}.h-\[calc\(100vh-3rem\)\]{height:calc(100vh - 3rem)}.h-\[calc\(100vh-4rem\)\]{height:calc(100vh - 4rem)}.h-\[min\(65vh\,36rem\)\]{height:min(65vh,36rem)}.h-full{height:100%}.h-px{height:1px}.max-h-24{max-height:calc(var(--spacing) * 24)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[35vh\]{max-height:35vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[80vh\]{max-height:80vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[calc\(100vh-4rem\)\]{max-height:calc(100vh - 4rem)}.max-h-\[min\(640px\,85vh\)\]{max-height:min(640px,85vh)}.max-h-full{max-height:100%}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-12{min-height:calc(var(--spacing) * 12)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-24{min-height:calc(var(--spacing) * 24)}.min-h-48{min-height:calc(var(--spacing) * 48)}.min-h-\[4\.5rem\]{min-height:4.5rem}.min-h-\[20rem\]{min-height:20rem}.min-h-screen{min-height:100vh}.w-0{width:calc(var(--spacing) * 0)}.w-1{width:calc(var(--spacing) * 1)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/2{width:50%}.w-1\/3{width:33.3333%}.w-1\/4{width:25%}.w-1\/6{width:16.6667%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-2\/3{width:66.6667%}.w-2\/5{width:40%}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-3\/4{width:75%}.w-4{width:calc(var(--spacing) * 4)}.w-4\/5{width:80%}.w-5{width:calc(var(--spacing) * 5)}.w-5\/6{width:83.3333%}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-11\/12{width:91.6667%}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-32{width:calc(var(--spacing) * 32)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-52{width:calc(var(--spacing) * 52)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-72{width:calc(var(--spacing) * 72)}.w-\[3px\]{width:3px}.w-\[11px\]{width:11px}.w-\[14px\]{width:14px}.w-\[18px\]{width:18px}.w-\[320px\]{width:320px}.w-\[calc\(100vw-4rem\)\]{width:calc(100vw - 4rem)}.w-\[min\(22rem\,32vw\)\]{width:min(22rem,32vw)}.w-\[min\(520px\,calc\(100vw-4rem\)\)\]{width:min(520px,100vw - 4rem)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-\[3\.5rem\]{max-width:3.5rem}.max-w-\[3rem\]{max-width:3rem}.max-w-\[8rem\]{max-width:8rem}.max-w-\[12rem\]{max-width:12rem}.max-w-\[85vw\]{max-width:85vw}.max-w-\[90vw\]{max-width:90vw}.max-w-\[120px\]{max-width:120px}.max-w-\[140px\]{max-width:140px}.max-w-\[200px\]{max-width:200px}.max-w-\[220px\]{max-width:220px}.max-w-\[260px\]{max-width:260px}.max-w-\[400px\]{max-width:400px}.max-w-\[480px\]{max-width:480px}.max-w-\[min\(96vw\,110rem\)\]{max-width:min(96vw,110rem)}.max-w-\[min\(calc\(100vw-4rem\)\,1500px\)\]{max-width:min(100vw - 4rem,1500px)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-4{min-width:calc(var(--spacing) * 4)}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-10{min-width:calc(var(--spacing) * 10)}.min-w-\[4ch\]{min-width:4ch}.min-w-\[8rem\]{min-width:8rem}.min-w-\[12rem\]{min-width:12rem}.min-w-\[16rem\]{min-width:16rem}.min-w-\[18px\]{min-width:18px}.min-w-\[22px\]{min-width:22px}.min-w-\[80px\]{min-width:80px}.min-w-\[240px\]{min-width:240px}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-\[2\]{flex:2}.flex-\[3\]{flex:3}.flex-none{flex:none}.flex-shrink-0,.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.origin-\[var\(--radix-tooltip-content-transform-origin\)\]{transform-origin:var(--radix-tooltip-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0\.5{--tw-translate-x:calc(var(--spacing) * .5);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-1{--tw-translate-x:calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-4\.5{--tw-translate-x:calc(var(--spacing) * 4.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-6{--tw-translate-x:calc(var(--spacing) * 6);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-110{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.-scale-x-100{--tw-scale-x: -100% ;scale:var(--tw-scale-x) var(--tw-scale-y)}.-rotate-90{rotate:-90deg}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-crosshair{cursor:crosshair}.cursor-default{cursor:default}.cursor-grab{cursor:grab}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-zoom-in{cursor:zoom-in}.cursor-zoom-out{cursor:zoom-out}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-px{gap:1px}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-\[2px\]>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(2px * var(--tw-space-y-reverse));margin-block-end:calc(2px * calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}.self-center{align-self:center}.self-start{align-self:flex-start}.self-stretch{align-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-l-md{border-top-left-radius:calc(var(--radius) - 2px);border-bottom-left-radius:calc(var(--radius) - 2px)}.rounded-r-md{border-top-right-radius:calc(var(--radius) - 2px);border-bottom-right-radius:calc(var(--radius) - 2px)}.rounded-b-lg{border-bottom-right-radius:var(--radius);border-bottom-left-radius:var(--radius)}.rounded-b-none{border-bottom-right-radius:0;border-bottom-left-radius:0}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-\[1\.5px\]{border-style:var(--tw-border-style);border-width:1.5px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.border-b-\[2px\]{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-\[3px\]{border-left-style:var(--tw-border-style);border-left-width:3px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-accent,.border-accent\/30{border-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.border-accent\/30{border-color:color-mix(in oklab,var(--accent) 30%,transparent)}}.border-amber-500\/20{border-color:#f99c0033}@supports (color:color-mix(in lab,red,red)){.border-amber-500\/20{border-color:color-mix(in oklab,var(--color-amber-500) 20%,transparent)}}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab,red,red)){.border-amber-500\/30{border-color:color-mix(in oklab,var(--color-amber-500) 30%,transparent)}}.border-background{border-color:var(--background)}.border-black\/20{border-color:#0003}@supports (color:color-mix(in lab,red,red)){.border-black\/20{border-color:color-mix(in oklab,var(--color-black) 20%,transparent)}}.border-border,.border-border\/20{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/20{border-color:color-mix(in oklab,var(--border) 20%,transparent)}}.border-border\/30{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/30{border-color:color-mix(in oklab,var(--border) 30%,transparent)}}.border-border\/40{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/40{border-color:color-mix(in oklab,var(--border) 40%,transparent)}}.border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/50{border-color:color-mix(in oklab,var(--border) 50%,transparent)}}.border-border\/60{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/60{border-color:color-mix(in oklab,var(--border) 60%,transparent)}}.border-border\/70{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/70{border-color:color-mix(in oklab,var(--border) 70%,transparent)}}.border-destructive\/20{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/20{border-color:color-mix(in oklab,var(--destructive) 20%,transparent)}}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/30{border-color:color-mix(in oklab,var(--destructive) 30%,transparent)}}.border-foreground,.border-foreground\/5{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/5{border-color:color-mix(in oklab,var(--foreground) 5%,transparent)}}.border-foreground\/15{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/15{border-color:color-mix(in oklab,var(--foreground) 15%,transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/20{border-color:color-mix(in oklab,var(--foreground) 20%,transparent)}}.border-input{border-color:var(--input)}.border-muted-foreground\/20{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.border-muted-foreground\/20{border-color:color-mix(in oklab,var(--muted-foreground) 20%,transparent)}}.border-muted-foreground\/30{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.border-muted-foreground\/30{border-color:color-mix(in oklab,var(--muted-foreground) 30%,transparent)}}.border-muted-foreground\/35{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.border-muted-foreground\/35{border-color:color-mix(in oklab,var(--muted-foreground) 35%,transparent)}}.border-muted-foreground\/40{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.border-muted-foreground\/40{border-color:color-mix(in oklab,var(--muted-foreground) 40%,transparent)}}.border-primary,.border-primary\/20{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/20{border-color:color-mix(in oklab,var(--primary) 20%,transparent)}}.border-primary\/25{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/25{border-color:color-mix(in oklab,var(--primary) 25%,transparent)}}.border-primary\/30{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/30{border-color:color-mix(in oklab,var(--primary) 30%,transparent)}}.border-primary\/35{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/35{border-color:color-mix(in oklab,var(--primary) 35%,transparent)}}.border-primary\/40{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/40{border-color:color-mix(in oklab,var(--primary) 40%,transparent)}}.border-primary\/50{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/50{border-color:color-mix(in oklab,var(--primary) 50%,transparent)}}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\/20{border-color:color-mix(in oklab,var(--color-red-500) 20%,transparent)}}.border-success-foreground\/20{border-color:var(--success-foreground)}@supports (color:color-mix(in lab,red,red)){.border-success-foreground\/20{border-color:color-mix(in oklab,var(--success-foreground) 20%,transparent)}}.border-transparent{border-color:#0000}.border-warning\/20{border-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.border-warning\/20{border-color:color-mix(in oklab,var(--warning) 20%,transparent)}}.border-warning\/30{border-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.border-warning\/30{border-color:color-mix(in oklab,var(--warning) 30%,transparent)}}.bg-\[\#7c3aed\]\/10{background-color:#7c3aed1a}.bg-\[\#121011\]{background-color:#121011}.bg-accent,.bg-accent\/5{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/5{background-color:color-mix(in oklab,var(--accent) 5%,transparent)}}.bg-accent\/20{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/20{background-color:color-mix(in oklab,var(--accent) 20%,transparent)}}.bg-accent\/40{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/40{background-color:color-mix(in oklab,var(--accent) 40%,transparent)}}.bg-accent\/60{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/60{background-color:color-mix(in oklab,var(--accent) 60%,transparent)}}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/8{background-color:#f99c0014}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\/8{background-color:color-mix(in oklab,var(--color-amber-500) 8%,transparent)}}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\/10{background-color:color-mix(in oklab,var(--color-amber-500) 10%,transparent)}}.bg-amber-500\/15{background-color:#f99c0026}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\/15{background-color:color-mix(in oklab,var(--color-amber-500) 15%,transparent)}}.bg-annotation-comment\/8{background-color:var(--annotation-comment)}@supports (color:color-mix(in lab,red,red)){.bg-annotation-comment\/8{background-color:color-mix(in oklab,var(--annotation-comment) 8%,transparent)}}.bg-background,.bg-background\/50{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/50{background-color:color-mix(in oklab,var(--background) 50%,transparent)}}.bg-background\/60{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/60{background-color:color-mix(in oklab,var(--background) 60%,transparent)}}.bg-background\/70{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/70{background-color:color-mix(in oklab,var(--background) 70%,transparent)}}.bg-background\/80{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/80{background-color:color-mix(in oklab,var(--background) 80%,transparent)}}.bg-background\/90{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/90{background-color:color-mix(in oklab,var(--background) 90%,transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-black\/55{background-color:#0000008c}@supports (color:color-mix(in lab,red,red)){.bg-black\/55{background-color:color-mix(in oklab,var(--color-black) 55%,transparent)}}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab,red,red)){.bg-black\/80{background-color:color-mix(in oklab,var(--color-black) 80%,transparent)}}.bg-border,.bg-border\/50{background-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.bg-border\/50{background-color:color-mix(in oklab,var(--border) 50%,transparent)}}.bg-card,.bg-card\/30{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/30{background-color:color-mix(in oklab,var(--card) 30%,transparent)}}.bg-card\/50{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/50{background-color:color-mix(in oklab,var(--card) 50%,transparent)}}.bg-card\/70{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/70{background-color:color-mix(in oklab,var(--card) 70%,transparent)}}.bg-card\/80{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/80{background-color:color-mix(in oklab,var(--card) 80%,transparent)}}.bg-card\/95{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/95{background-color:color-mix(in oklab,var(--card) 95%,transparent)}}.bg-destructive,.bg-destructive\/5{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/5{background-color:color-mix(in oklab,var(--destructive) 5%,transparent)}}.bg-destructive\/8{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/8{background-color:color-mix(in oklab,var(--destructive) 8%,transparent)}}.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/10{background-color:color-mix(in oklab,var(--destructive) 10%,transparent)}}.bg-destructive\/15{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/15{background-color:color-mix(in oklab,var(--destructive) 15%,transparent)}}.bg-destructive\/20{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/20{background-color:color-mix(in oklab,var(--destructive) 20%,transparent)}}.bg-destructive\/40{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/40{background-color:color-mix(in oklab,var(--destructive) 40%,transparent)}}.bg-destructive\/60{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/60{background-color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.bg-foreground,.bg-foreground\/10{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/10{background-color:color-mix(in oklab,var(--foreground) 10%,transparent)}}.bg-foreground\/15{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/15{background-color:color-mix(in oklab,var(--foreground) 15%,transparent)}}.bg-foreground\/25{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/25{background-color:color-mix(in oklab,var(--foreground) 25%,transparent)}}.bg-foreground\/50{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/50{background-color:color-mix(in oklab,var(--foreground) 50%,transparent)}}.bg-foreground\/60{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/60{background-color:color-mix(in oklab,var(--foreground) 60%,transparent)}}.bg-foreground\/80{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/80{background-color:color-mix(in oklab,var(--foreground) 80%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/10{background-color:color-mix(in oklab,var(--color-green-500) 10%,transparent)}}.bg-green-500\/15{background-color:#00c75826}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/15{background-color:color-mix(in oklab,var(--color-green-500) 15%,transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground,.bg-muted-foreground\/10{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/10{background-color:color-mix(in oklab,var(--muted-foreground) 10%,transparent)}}.bg-muted-foreground\/15{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/15{background-color:color-mix(in oklab,var(--muted-foreground) 15%,transparent)}}.bg-muted-foreground\/30{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/30{background-color:color-mix(in oklab,var(--muted-foreground) 30%,transparent)}}.bg-muted-foreground\/50{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/50{background-color:color-mix(in oklab,var(--muted-foreground) 50%,transparent)}}.bg-muted\/10{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/10{background-color:color-mix(in oklab,var(--muted) 10%,transparent)}}.bg-muted\/20{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/20{background-color:color-mix(in oklab,var(--muted) 20%,transparent)}}.bg-muted\/25{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/25{background-color:color-mix(in oklab,var(--muted) 25%,transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted) 30%,transparent)}}.bg-muted\/35{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/35{background-color:color-mix(in oklab,var(--muted) 35%,transparent)}}.bg-muted\/40{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/40{background-color:color-mix(in oklab,var(--muted) 40%,transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.bg-muted\/60{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/60{background-color:color-mix(in oklab,var(--muted) 60%,transparent)}}.bg-muted\/70{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/70{background-color:color-mix(in oklab,var(--muted) 70%,transparent)}}.bg-muted\/80{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/80{background-color:color-mix(in oklab,var(--muted) 80%,transparent)}}.bg-muted\/85{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/85{background-color:color-mix(in oklab,var(--muted) 85%,transparent)}}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500) 10%,transparent)}}.bg-popover,.bg-popover\/50{background-color:var(--popover)}@supports (color:color-mix(in lab,red,red)){.bg-popover\/50{background-color:color-mix(in oklab,var(--popover) 50%,transparent)}}.bg-popover\/85{background-color:var(--popover)}@supports (color:color-mix(in lab,red,red)){.bg-popover\/85{background-color:color-mix(in oklab,var(--popover) 85%,transparent)}}.bg-primary,.bg-primary\/5{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/5{background-color:color-mix(in oklab,var(--primary) 5%,transparent)}}.bg-primary\/8{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/8{background-color:color-mix(in oklab,var(--primary) 8%,transparent)}}.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary) 10%,transparent)}}.bg-primary\/15{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/15{background-color:color-mix(in oklab,var(--primary) 15%,transparent)}}.bg-primary\/20{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/20{background-color:color-mix(in oklab,var(--primary) 20%,transparent)}}.bg-primary\/40{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/40{background-color:color-mix(in oklab,var(--primary) 40%,transparent)}}.bg-primary\/60{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/60{background-color:color-mix(in oklab,var(--primary) 60%,transparent)}}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/10{background-color:color-mix(in oklab,var(--color-purple-500) 10%,transparent)}}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/5{background-color:#fb2c360d}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/5{background-color:color-mix(in oklab,var(--color-red-500) 5%,transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500) 10%,transparent)}}.bg-red-500\/15{background-color:#fb2c3626}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/15{background-color:color-mix(in oklab,var(--color-red-500) 15%,transparent)}}.bg-secondary,.bg-secondary\/8{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.bg-secondary\/8{background-color:color-mix(in oklab,var(--secondary) 8%,transparent)}}.bg-secondary\/10{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.bg-secondary\/10{background-color:color-mix(in oklab,var(--secondary) 10%,transparent)}}.bg-secondary\/40{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.bg-secondary\/40{background-color:color-mix(in oklab,var(--secondary) 40%,transparent)}}.bg-secondary\/60{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.bg-secondary\/60{background-color:color-mix(in oklab,var(--secondary) 60%,transparent)}}.bg-success,.bg-success\/10{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.bg-success\/10{background-color:color-mix(in oklab,var(--success) 10%,transparent)}}.bg-success\/15{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.bg-success\/15{background-color:color-mix(in oklab,var(--success) 15%,transparent)}}.bg-success\/20{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.bg-success\/20{background-color:color-mix(in oklab,var(--success) 20%,transparent)}}.bg-success\/50{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.bg-success\/50{background-color:color-mix(in oklab,var(--success) 50%,transparent)}}.bg-surface-1,.bg-surface-1\/30{background-color:var(--surface-1)}@supports (color:color-mix(in lab,red,red)){.bg-surface-1\/30{background-color:color-mix(in oklab,var(--surface-1) 30%,transparent)}}.bg-surface-1\/40{background-color:var(--surface-1)}@supports (color:color-mix(in lab,red,red)){.bg-surface-1\/40{background-color:color-mix(in oklab,var(--surface-1) 40%,transparent)}}.bg-surface-1\/50{background-color:var(--surface-1)}@supports (color:color-mix(in lab,red,red)){.bg-surface-1\/50{background-color:color-mix(in oklab,var(--surface-1) 50%,transparent)}}.bg-transparent{background-color:#0000}.bg-warning,.bg-warning\/5{background-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.bg-warning\/5{background-color:color-mix(in oklab,var(--warning) 5%,transparent)}}.bg-warning\/15{background-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.bg-warning\/15{background-color:color-mix(in oklab,var(--warning) 15%,transparent)}}.bg-warning\/20{background-color:var(--warning)}@supports (color:color-mix(in lab,red,red)){.bg-warning\/20{background-color:color-mix(in oklab,var(--warning) 20%,transparent)}}.bg-white{background-color:var(--color-white)}.bg-yellow-500\/10{background-color:#edb2001a}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/10{background-color:color-mix(in oklab,var(--color-yellow-500) 10%,transparent)}}.bg-zinc-500{background-color:var(--color-zinc-500)}.\[mask-image\:linear-gradient\(transparent\,transparent\)\,linear-gradient\(\#000\,\#000\)\]{-webkit-mask-image:linear-gradient(#0000,#0000),linear-gradient(#000,#000);mask-image:linear-gradient(#0000,#0000),linear-gradient(#000,#000)}.bg-\[length\:250\%_100\%\,auto\]{background-size:250% 100%,auto}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.\[background-repeat\:no-repeat\,padding-box\]{background-repeat:no-repeat,padding-box}.\[mask-composite\:intersect\]{-webkit-mask-composite:source-in;mask-composite:intersect}.\[mask-clip\:padding-box\,border-box\]{-webkit-mask-clip:padding-box,border-box;mask-clip:padding-box,border-box}.fill-current{fill:currentColor}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing) * 0)}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-7{padding:calc(var(--spacing) * 7)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-7{padding-inline:calc(var(--spacing) * 7)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-\[5px\]{padding-block:5px}.py-px{padding-block:1px}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-32{padding-top:calc(var(--spacing) * 32)}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-1\.5{padding-right:calc(var(--spacing) * 1.5)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-12{padding-right:calc(var(--spacing) * 12)}.pr-20{padding-right:calc(var(--spacing) * 20)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-20{padding-bottom:calc(var(--spacing) * 20)}.pl-0\.5{padding-left:calc(var(--spacing) * .5)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-8{padding-left:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.6rem\]{font-size:.6rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13\.5px\]{font-size:13.5px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-\[1\.1\]{--tw-leading:1.1;line-height:1.1}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-wrap{text-wrap:wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-ellipsis{text-overflow:ellipsis}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#007aff\]{color:#007aff}.text-\[\#7c3aed\]{color:#7c3aed}.text-accent{color:var(--accent)}.text-amber-500{color:var(--color-amber-500)}.text-amber-500\/80{color:#f99c00cc}@supports (color:color-mix(in lab,red,red)){.text-amber-500\/80{color:color-mix(in oklab,var(--color-amber-500) 80%,transparent)}}.text-amber-600{color:var(--color-amber-600)}.text-annotation-comment,.text-annotation-comment\/80{color:var(--annotation-comment)}@supports (color:color-mix(in lab,red,red)){.text-annotation-comment\/80{color:color-mix(in oklab,var(--annotation-comment) 80%,transparent)}}.text-background{color:var(--background)}.text-card-foreground{color:var(--card-foreground)}.text-destructive{color:var(--destructive)}.text-destructive-foreground{color:var(--destructive-foreground)}.text-destructive\/60{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/60{color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.text-destructive\/70{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/70{color:color-mix(in oklab,var(--destructive) 70%,transparent)}}.text-destructive\/80{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/80{color:color-mix(in oklab,var(--destructive) 80%,transparent)}}.text-foreground,.text-foreground\/70{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.text-foreground\/70{color:color-mix(in oklab,var(--foreground) 70%,transparent)}}.text-foreground\/80{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.text-foreground\/80{color:color-mix(in oklab,var(--foreground) 80%,transparent)}}.text-foreground\/85{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.text-foreground\/85{color:color-mix(in oklab,var(--foreground) 85%,transparent)}}.text-foreground\/90{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.text-foreground\/90{color:color-mix(in oklab,var(--foreground) 90%,transparent)}}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-muted-foreground,.text-muted-foreground\/30{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/30{color:color-mix(in oklab,var(--muted-foreground) 30%,transparent)}}.text-muted-foreground\/35{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/35{color:color-mix(in oklab,var(--muted-foreground) 35%,transparent)}}.text-muted-foreground\/40{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/40{color:color-mix(in oklab,var(--muted-foreground) 40%,transparent)}}.text-muted-foreground\/50{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/50{color:color-mix(in oklab,var(--muted-foreground) 50%,transparent)}}.text-muted-foreground\/60{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/60{color:color-mix(in oklab,var(--muted-foreground) 60%,transparent)}}.text-muted-foreground\/70{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/70{color:color-mix(in oklab,var(--muted-foreground) 70%,transparent)}}.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/80{color:color-mix(in oklab,var(--muted-foreground) 80%,transparent)}}.text-orange-600{color:var(--color-orange-600)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-primary\/60{color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.text-primary\/60{color:color-mix(in oklab,var(--primary) 60%,transparent)}}.text-primary\/70{color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.text-primary\/70{color:color-mix(in oklab,var(--primary) 70%,transparent)}}.text-primary\/80{color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.text-primary\/80{color:color-mix(in oklab,var(--primary) 80%,transparent)}}.text-purple-500{color:var(--color-purple-500)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-secondary{color:var(--secondary)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-secondary\/80{color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.text-secondary\/80{color:color-mix(in oklab,var(--secondary) 80%,transparent)}}.text-success{color:var(--success)}.text-success-foreground,.text-success-foreground\/70{color:var(--success-foreground)}@supports (color:color-mix(in lab,red,red)){.text-success-foreground\/70{color:color-mix(in oklab,var(--success-foreground) 70%,transparent)}}.text-success\/70{color:var(--success)}@supports (color:color-mix(in lab,red,red)){.text-success\/70{color:color-mix(in oklab,var(--success) 70%,transparent)}}.text-success\/85{color:var(--success)}@supports (color:color-mix(in lab,red,red)){.text-success\/85{color:color-mix(in oklab,var(--success) 85%,transparent)}}.text-transparent{color:#0000}.text-warning{color:var(--warning)}.text-warning-foreground{color:var(--warning-foreground)}.text-white{color:var(--color-white)}.text-white\/70{color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.text-white\/70{color:color-mix(in oklab,var(--color-white) 70%,transparent)}}.text-yellow-600{color:var(--color-yellow-600)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.underline{text-decoration-line:underline}.decoration-destructive\/30{-webkit-text-decoration-color:var(--destructive);text-decoration-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.decoration-destructive\/30{-webkit-text-decoration-color:color-mix(in oklab,var(--destructive) 30%,transparent);text-decoration-color:color-mix(in oklab,var(--destructive) 30%,transparent)}}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-primary{accent-color:var(--primary)}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-45{opacity:.45}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_-4px_20px_rgba\(0\,0\,0\,0\.4\)\]{--tw-shadow:0 -4px 20px var(--tw-shadow-color,#0006);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_24px_80px_-36px_rgba\(15\,23\,42\,0\.5\)\]{--tw-shadow:0 24px 80px -36px var(--tw-shadow-color,#0f172a80);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[var\(--card-shadow\)\]{--tw-shadow:var(--card-shadow);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-accent{--tw-ring-color:var(--accent)}.ring-background{--tw-ring-color:var(--background)}.ring-border\/20{--tw-ring-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.ring-border\/20{--tw-ring-color:color-mix(in oklab, var(--border) 20%, transparent)}}.ring-border\/40{--tw-ring-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.ring-border\/40{--tw-ring-color:color-mix(in oklab, var(--border) 40%, transparent)}}.ring-border\/50{--tw-ring-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.ring-border\/50{--tw-ring-color:color-mix(in oklab, var(--border) 50%, transparent)}}.ring-primary\/10{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.ring-primary\/10{--tw-ring-color:color-mix(in oklab, var(--primary) 10%, transparent)}}.ring-primary\/30{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.ring-primary\/30{--tw-ring-color:color-mix(in oklab, var(--primary) 30%, transparent)}}.outline-hidden{--tw-outline-style:none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.outline-offset-2{outline-offset:2px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,background-color\,box-shadow\]{transition-property:color,background-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,transform\]{transition-property:opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[--base-color\:\#a1a1aa\]{--base-color:#a1a1aa}.\[--base-gradient-color\:\#000\]{--base-gradient-color:#000}.\[--bg\:linear-gradient\(90deg\,\#0000_calc\(50\%-var\(--spread\)\)\,var\(--base-gradient-color\)\,\#0000_calc\(50\%\+var\(--spread\)\)\)\]{--bg:linear-gradient(90deg,#0000 calc(50% - var(--spread)),var(--base-gradient-color),#0000 calc(50% + var(--spread)))}.block-1{block-size:calc(var(--spacing) * 1)}.block-2{block-size:calc(var(--spacing) * 2)}.duration-150{animation-duration:.15s}.duration-200{animation-duration:.2s}.duration-300{animation-duration:.3s}.ease-in{animation-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{animation-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{animation-timing-function:cubic-bezier(0,0,.2,1)}.running{animation-play-state:running}@media(hover:hover){.group-hover\:bg-border:is(:where(.group):hover *){background-color:var(--border)}.group-hover\:text-foreground:is(:where(.group):hover *){color:var(--foreground)}.group-hover\:text-muted-foreground:is(:where(.group):hover *),.group-hover\:text-muted-foreground\/60:is(:where(.group):hover *){color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.group-hover\:text-muted-foreground\/60:is(:where(.group):hover *){color:color-mix(in oklab,var(--muted-foreground) 60%,transparent)}}.group-hover\:opacity-100:is(:where(.group):hover *),.group-hover\/sidebar\:opacity-100:is(:where(.group\/sidebar):hover *){opacity:1}}.placeholder\:text-muted-foreground::placeholder,.placeholder\:text-muted-foreground\/30::placeholder{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-muted-foreground\/30::placeholder{color:color-mix(in oklab,var(--muted-foreground) 30%,transparent)}}.placeholder\:text-muted-foreground\/40::placeholder{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-muted-foreground\/40::placeholder{color:color-mix(in oklab,var(--muted-foreground) 40%,transparent)}}.placeholder\:text-muted-foreground\/50::placeholder{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-muted-foreground\/50::placeholder{color:color-mix(in oklab,var(--muted-foreground) 50%,transparent)}}.placeholder\:text-muted-foreground\/70::placeholder{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-muted-foreground\/70::placeholder{color:color-mix(in oklab,var(--muted-foreground) 70%,transparent)}}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:-inset-1\.5:before{content:var(--tw-content);inset:calc(var(--spacing) * -1.5)}.before\:-inset-2:before{content:var(--tw-content);inset:calc(var(--spacing) * -2)}.before\:content-\[\'\'\]:before{--tw-content:"";content:var(--tw-content)}.first\:mt-0:first-child{margin-top:calc(var(--spacing) * 0)}@media(hover:hover){.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-accent:hover{border-color:var(--accent)}.hover\:border-border\/80:hover{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.hover\:border-border\/80:hover{border-color:color-mix(in oklab,var(--border) 80%,transparent)}}.hover\:border-destructive\/50:hover{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:border-destructive\/50:hover{border-color:color-mix(in oklab,var(--destructive) 50%,transparent)}}.hover\:border-foreground\/30:hover{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:border-foreground\/30:hover{border-color:color-mix(in oklab,var(--foreground) 30%,transparent)}}.hover\:border-muted-foreground:hover,.hover\:border-muted-foreground\/30:hover{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:border-muted-foreground\/30:hover{border-color:color-mix(in oklab,var(--muted-foreground) 30%,transparent)}}.hover\:border-muted-foreground\/40:hover{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:border-muted-foreground\/40:hover{border-color:color-mix(in oklab,var(--muted-foreground) 40%,transparent)}}.hover\:border-primary\/40:hover{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:border-primary\/40:hover{border-color:color-mix(in oklab,var(--primary) 40%,transparent)}}.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-amber-500\/10:hover{background-color:#f99c001a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-amber-500\/10:hover{background-color:color-mix(in oklab,var(--color-amber-500) 10%,transparent)}}.hover\:bg-annotation-comment\/10:hover{background-color:var(--annotation-comment)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-annotation-comment\/10:hover{background-color:color-mix(in oklab,var(--annotation-comment) 10%,transparent)}}.hover\:bg-background:hover{background-color:var(--background)}.hover\:bg-card:hover{background-color:var(--card)}.hover\:bg-destructive\/10:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/10:hover{background-color:color-mix(in oklab,var(--destructive) 10%,transparent)}}.hover\:bg-destructive\/25:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/25:hover{background-color:color-mix(in oklab,var(--destructive) 25%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}.hover\:bg-green-500\/10:hover{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-green-500\/10:hover{background-color:color-mix(in oklab,var(--color-green-500) 10%,transparent)}}.hover\:bg-muted:hover,.hover\:bg-muted\/20:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/20:hover{background-color:color-mix(in oklab,var(--muted) 20%,transparent)}}.hover\:bg-muted\/30:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/30:hover{background-color:color-mix(in oklab,var(--muted) 30%,transparent)}}.hover\:bg-muted\/40:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/40:hover{background-color:color-mix(in oklab,var(--muted) 40%,transparent)}}.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.hover\:bg-muted\/60:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/60:hover{background-color:color-mix(in oklab,var(--muted) 60%,transparent)}}.hover\:bg-muted\/80:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/80:hover{background-color:color-mix(in oklab,var(--muted) 80%,transparent)}}.hover\:bg-primary\/10:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/10:hover{background-color:color-mix(in oklab,var(--primary) 10%,transparent)}}.hover\:bg-primary\/20:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/20:hover{background-color:color-mix(in oklab,var(--primary) 20%,transparent)}}.hover\:bg-primary\/25:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/25:hover{background-color:color-mix(in oklab,var(--primary) 25%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}.hover\:bg-red-500\/10:hover{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-red-500\/10:hover{background-color:color-mix(in oklab,var(--color-red-500) 10%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}.hover\:bg-success:hover,.hover\:bg-success\/10:hover{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-success\/10:hover{background-color:color-mix(in oklab,var(--success) 10%,transparent)}}.hover\:bg-success\/90:hover{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-success\/90:hover{background-color:color-mix(in oklab,var(--success) 90%,transparent)}}.hover\:bg-surface-1:hover,.hover\:bg-surface-1\/50:hover{background-color:var(--surface-1)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-surface-1\/50:hover{background-color:color-mix(in oklab,var(--surface-1) 50%,transparent)}}.hover\:bg-surface-1\/70:hover{background-color:var(--surface-1)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-surface-1\/70:hover{background-color:color-mix(in oklab,var(--surface-1) 70%,transparent)}}.hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/5:hover{background-color:color-mix(in oklab,var(--color-white) 5%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-amber-700:hover{color:var(--color-amber-700)}.hover\:text-destructive:hover{color:var(--destructive)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-green-500:hover{color:var(--color-green-500)}.hover\:text-muted-foreground:hover,.hover\:text-muted-foreground\/60:hover{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:text-muted-foreground\/60:hover{color:color-mix(in oklab,var(--muted-foreground) 60%,transparent)}}.hover\:text-primary:hover,.hover\:text-primary\/80:hover{color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:text-primary\/80:hover{color:color-mix(in oklab,var(--primary) 80%,transparent)}}.hover\:text-success:hover{color:var(--success)}.hover\:text-success-foreground:hover{color:var(--success-foreground)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-sm:hover{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:border-primary\/40:focus{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:border-primary\/40:focus{border-color:color-mix(in oklab,var(--primary) 40%,transparent)}}.focus\:border-primary\/50:focus{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:border-primary\/50:focus{border-color:color-mix(in oklab,var(--primary) 50%,transparent)}}.focus\:border-primary\/60:focus{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:border-primary\/60:focus{border-color:color-mix(in oklab,var(--primary) 60%,transparent)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-accent\/50:focus{--tw-ring-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-accent\/50:focus{--tw-ring-color:color-mix(in oklab, var(--accent) 50%, transparent)}}.focus\:ring-primary:focus,.focus\:ring-primary\/15:focus{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-primary\/15:focus{--tw-ring-color:color-mix(in oklab, var(--primary) 15%, transparent)}}.focus\:ring-primary\/20:focus{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-primary\/20:focus{--tw-ring-color:color-mix(in oklab, var(--primary) 20%, transparent)}}.focus\:ring-primary\/40:focus{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-primary\/40:focus{--tw-ring-color:color-mix(in oklab, var(--primary) 40%, transparent)}}.focus\:ring-primary\/50:focus{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-primary\/50:focus{--tw-ring-color:color-mix(in oklab, var(--primary) 50%, transparent)}}.focus\:ring-ring\/50:focus{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus\:ring-ring\/50:focus{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color:var(--background)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:scale-\[0\.98\]:active{scale:.98}.active\:bg-muted:active{background-color:var(--muted)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-\[orientation\=vertical\]\:flex-col[data-orientation=vertical]{flex-direction:column}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:-.5rem}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:.5rem}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:-.5rem}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:.5rem}.data-\[state\=active\]\:bg-primary\/10[data-state=active]{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.data-\[state\=active\]\:bg-primary\/10[data-state=active]{background-color:color-mix(in oklab,var(--primary) 10%,transparent)}}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=closed\]\:scale-95[data-state=closed]{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[state\=closed\]\:opacity-0[data-state=closed]{opacity:0}.data-\[state\=closed\]\:animate-out[data-state=closed]{--tw-exit-opacity:initial;--tw-exit-scale:initial;--tw-exit-rotate:initial;--tw-exit-translate-x:initial;--tw-exit-translate-y:initial;animation-name:exit;animation-duration:.15s}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=delayed-open\]\:scale-100[data-state=delayed-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[state\=delayed-open\]\:opacity-100[data-state=delayed-open]{opacity:1}.data-\[state\=instant-open\]\:scale-100[data-state=instant-open]{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-\[state\=instant-open\]\:opacity-100[data-state=instant-open]{opacity:1}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.data-\[state\=open\]\:animate-in[data-state=open]{--tw-enter-opacity:initial;--tw-enter-scale:initial;--tw-enter-rotate:initial;--tw-enter-translate-x:initial;--tw-enter-translate-y:initial;animation-name:enter;animation-duration:.15s}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive) 10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}@media(prefers-reduced-motion:reduce){.motion-reduce\:transform-none{transform:none}}@media(min-width:40rem){.sm\:block{display:block}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(min-width:48rem){.md\:-top-0\.5{top:calc(var(--spacing) * -.5)}.md\:top-4{top:calc(var(--spacing) * 4)}.md\:top-\[60px\]{top:60px}.md\:-right-0\.5{right:calc(var(--spacing) * -.5)}.md\:left-5{left:calc(var(--spacing) * 5)}.md\:-mt-5{margin-top:calc(var(--spacing) * -5)}.md\:-mr-2{margin-right:calc(var(--spacing) * -2)}.md\:-mr-5{margin-right:calc(var(--spacing) * -5)}.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline{display:inline}.md\:h-24{height:calc(var(--spacing) * 24)}.md\:min-h-\[420px\]{min-height:420px}.md\:w-24{width:calc(var(--spacing) * 24)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:gap-2{gap:calc(var(--spacing) * 2)}.md\:p-2{padding:calc(var(--spacing) * 2)}.md\:p-6{padding:calc(var(--spacing) * 6)}.md\:p-8{padding:calc(var(--spacing) * 8)}.md\:px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.md\:px-8{padding-inline:calc(var(--spacing) * 8)}.md\:py-1{padding-block:calc(var(--spacing) * 1)}.md\:py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.md\:py-8{padding-block:calc(var(--spacing) * 8)}.md\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media(min-width:64rem){.lg\:-mt-7{margin-top:calc(var(--spacing) * -7)}.lg\:-mr-7{margin-right:calc(var(--spacing) * -7)}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:inline{display:inline}.lg\:p-10{padding:calc(var(--spacing) * 10)}.lg\:px-10{padding-inline:calc(var(--spacing) * 10)}.lg\:py-10{padding-block:calc(var(--spacing) * 10)}}@media(min-width:80rem){.xl\:-mt-9{margin-top:calc(var(--spacing) * -9)}.xl\:-mr-9{margin-right:calc(var(--spacing) * -9)}.xl\:p-12{padding:calc(var(--spacing) * 12)}.xl\:px-12{padding-inline:calc(var(--spacing) * 12)}.xl\:py-12{padding-block:calc(var(--spacing) * 12)}}@media(prefers-color-scheme:dark){.dark\:border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab,red,red)){.dark\:border-white\/20{border-color:color-mix(in oklab,var(--color-white) 20%,transparent)}}.dark\:text-amber-400{color:var(--color-amber-400)}.dark\:text-green-400{color:var(--color-green-400)}.dark\:text-orange-400{color:var(--color-orange-400)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-yellow-400{color:var(--color-yellow-400)}.dark\:\[--base-color\:\#71717a\]{--base-color:#71717a}.dark\:\[--base-gradient-color\:\#ffffff\]{--base-gradient-color:#fff}.dark\:\[--bg\:linear-gradient\(90deg\,\#0000_calc\(50\%-var\(--spread\)\)\,var\(--base-gradient-color\)\,\#0000_calc\(50\%\+var\(--spread\)\)\)\]{--bg:linear-gradient(90deg,#0000 calc(50% - var(--spread)),var(--base-gradient-color),#0000 calc(50% + var(--spread)))}@media(hover:hover){.dark\:hover\:text-amber-300:hover{color:var(--color-amber-300)}}.dark\:focus-visible\:ring-destructive\/40:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/40[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/40[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive) 40%,transparent)}}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}@media(hover:none){.\[\@media\(hover\:none\)\]\:opacity-100{opacity:1}}@media(hover:hover){a.\[a\&\]\:hover\:bg-accent:hover{background-color:var(--accent)}a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,var(--secondary) 90%,transparent)}}a.\[a\&\]\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}}.theme-plannotator{--background:oklch(15% .02 260);--foreground:oklch(90% .01 260);--card:oklch(22% .02 260);--card-foreground:oklch(90% .01 260);--popover:oklch(28% .025 260);--popover-foreground:oklch(90% .01 260);--primary:oklch(75% .18 280);--primary-foreground:oklch(15% .02 260);--secondary:oklch(65% .15 180);--secondary-foreground:oklch(15% .02 260);--muted:oklch(26% .02 260);--muted-foreground:oklch(72% .02 260);--accent:oklch(70% .2 60);--accent-foreground:oklch(15% .02 260);--destructive:oklch(65% .2 25);--destructive-foreground:oklch(98% 0 0);--border:oklch(35% .02 260);--input:oklch(26% .02 260);--ring:oklch(75% .18 280);--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--font-sans:"Inter", system-ui, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", monospace;--radius:.625rem;--code-bg:oklch(26% .02 260);--focus-highlight:oklch(70% .2 200)}.theme-plannotator.light{--background:oklch(97% .005 260);--foreground:oklch(18% .02 260);--card:oklch(100% 0 0);--card-foreground:oklch(18% .02 260);--popover:oklch(100% 0 0);--popover-foreground:oklch(18% .02 260);--primary:oklch(50% .25 280);--primary-foreground:oklch(100% 0 0);--secondary:oklch(50% .18 180);--secondary-foreground:oklch(100% 0 0);--muted:oklch(92% .01 260);--muted-foreground:oklch(40% .02 260);--accent:oklch(60% .22 50);--accent-foreground:oklch(18% .02 260);--destructive:oklch(50% .25 25);--destructive-foreground:oklch(100% 0 0);--border:oklch(88% .01 260);--input:oklch(92% .01 260);--ring:oklch(50% .25 280);--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(92% .01 260)}.theme-claude-plus{--background:oklch(26.79% .0036 106.643);--foreground:oklch(95.76% .0027 106.449);--card:oklch(32% .004 106.6);--card-foreground:oklch(95.76% .0027 106.449);--popover:oklch(32% .004 106.6);--popover-foreground:oklch(95.76% .0027 106.449);--primary:oklch(67.24% .1308 38.7559);--primary-foreground:oklch(19.08% .002 106.586);--secondary:oklch(98.18% .0054 95.0986);--secondary-foreground:oklch(30.85% .0035 106.604);--muted:oklch(35% .004 106.6);--muted-foreground:oklch(70% .003 106.5);--accent:oklch(67.24% .1308 38.7559);--accent-foreground:oklch(19.08% .002 106.586);--destructive:oklch(63.68% .2078 25.3313);--destructive-foreground:oklch(100% 0 0);--border:oklch(40% .004 106.6);--input:oklch(35% .004 106.6);--ring:oklch(67.24% .1308 38.7559);--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--font-sans:"Outfit", sans-serif;--font-mono:"Geist Mono", ui-monospace, monospace;--radius:.625rem;--code-bg:oklch(22% .003 106.6);--focus-highlight:oklch(70% .2 200)}.theme-claude-plus.light{--background:oklch(98.18% .0054 95.0986);--foreground:oklch(34.38% .0269 95.7226);--card:oklch(100% 0 0);--card-foreground:oklch(34.38% .0269 95.7226);--popover:oklch(100% 0 0);--popover-foreground:oklch(34.38% .0269 95.7226);--primary:oklch(61.71% .1375 39.0427);--primary-foreground:oklch(100% 0 0);--secondary:oklch(92.45% .0138 92.9892);--secondary-foreground:oklch(43.34% .0177 98.6048);--muted:oklch(94% .01 95);--muted-foreground:oklch(50% .02 95);--accent:oklch(61.71% .1375 39.0427);--accent-foreground:oklch(100% 0 0);--destructive:oklch(19.08% .002 106.586);--destructive-foreground:oklch(100% 0 0);--border:oklch(88% .01 95);--input:oklch(94% .01 95);--ring:oklch(61.71% .1375 39.0427);--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 95)}.theme-soft-pop{--background:oklch(0% 0 0);--foreground:oklch(100% 0 0);--card:oklch(15% 0 0);--card-foreground:oklch(100% 0 0);--popover:oklch(15% 0 0);--popover-foreground:oklch(100% 0 0);--primary:oklch(68.01% .1583 276.935);--primary-foreground:oklch(100% 0 0);--secondary:oklch(78.45% .1325 181.912);--secondary-foreground:oklch(0% 0 0);--muted:oklch(32.11% 0 0);--muted-foreground:oklch(70% 0 0);--accent:oklch(87.9% .1534 91.6054);--accent-foreground:oklch(0% 0 0);--destructive:oklch(71.06% .1661 22.2162);--destructive-foreground:oklch(100% 0 0);--border:oklch(44.59% 0 0);--input:oklch(32.11% 0 0);--ring:oklch(68.01% .1583 276.935);--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--font-sans:"DM Sans", sans-serif;--font-mono:"Space Mono", monospace;--radius:.625rem;--code-bg:oklch(8% 0 0);--focus-highlight:oklch(70% .2 200)}.theme-soft-pop.light{--background:oklch(97.89% .0082 121.627);--foreground:oklch(0% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(0% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(0% 0 0);--primary:oklch(51.06% .2301 276.966);--primary-foreground:oklch(100% 0 0);--secondary:oklch(70.38% .123 182.503);--secondary-foreground:oklch(0% 0 0);--muted:oklch(95.51% 0 0);--muted-foreground:oklch(40% 0 0);--accent:oklch(76.86% .1647 70.0804);--accent-foreground:oklch(0% 0 0);--destructive:oklch(63.68% .2078 25.3313);--destructive-foreground:oklch(100% 0 0);--border:oklch(0% 0 0);--input:oklch(95.51% 0 0);--ring:oklch(78.53% .1041 274.713);--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 120)}.theme-adwaita{--background:#1d1d1d;--foreground:#ccc;--card:#303030;--card-foreground:#ccc;--popover:#303030;--popover-foreground:#ccc;--primary:#3584e4;--primary-foreground:#fff;--secondary:#3a3a3a;--secondary-foreground:#ccc;--muted:#2a2a2a;--muted-foreground:#888;--accent:#26a269;--accent-foreground:#fff;--destructive:#c01c28;--destructive-foreground:#fff;--border:#454545;--input:#3a3a3a;--ring:#3584e4;--success:#26a269;--success-foreground:#fff;--warning:#e5a50a;--warning-foreground:#1d1d1d;--font-sans:"Inter", system-ui, sans-serif;--font-mono:"Source Code Pro", ui-monospace, monospace;--radius:.625rem;--code-bg:#2a2a2a;--focus-highlight:#3584e4}.theme-adwaita.light{--background:#fafafa;--foreground:#323232;--card:#fff;--card-foreground:#323232;--popover:#fff;--popover-foreground:#323232;--primary:#3584e4;--primary-foreground:#fff;--secondary:#e6e6e6;--secondary-foreground:#323232;--muted:#ebebeb;--muted-foreground:#6e6e6e;--accent:#26a269;--accent-foreground:#fff;--destructive:#c01c28;--destructive-foreground:#fff;--border:#cfcfcf;--input:#e6e6e6;--ring:#3584e4;--success:#26a269;--success-foreground:#fff;--warning:#e5a50a;--warning-foreground:#323232;--code-bg:#ebebeb;--focus-highlight:#3584e4}.theme-caffeine{--background:#111;--foreground:#eee;--card:#191919;--card-foreground:#eee;--popover:#191919;--popover-foreground:#eee;--primary:#ffe0c2;--primary-foreground:#081a1b;--secondary:#393028;--secondary-foreground:#ffe0c2;--muted:#222;--muted-foreground:#b4b4b4;--accent:#c19a6b;--accent-foreground:#eee;--destructive:#e54d2e;--destructive-foreground:#fff;--border:#201e18;--input:#484848;--ring:#ffe0c2;--chart-1:#ffe0c2;--chart-2:#393028;--chart-3:#2a2a2a;--chart-4:#42382e;--chart-5:#ffe0c1;--sidebar:#18181b;--sidebar-foreground:#f4f4f5;--sidebar-primary:#1d4ed8;--sidebar-primary-foreground:#fff;--sidebar-accent:#27272a;--sidebar-accent-foreground:#f4f4f5;--sidebar-border:#27272a;--sidebar-ring:#d4d4d8;--font-sans:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--radius:.625rem;--shadow-2xs:0 1px 3px 0px #0000000d;--shadow-xs:0 1px 3px 0px #0000000d;--shadow-sm:0 1px 3px 0px #0000001a, 0 1px 2px -1px #0000001a;--shadow:0 1px 3px 0px #0000001a, 0 1px 2px -1px #0000001a;--shadow-md:0 1px 3px 0px #0000001a, 0 2px 4px -1px #0000001a;--shadow-lg:0 1px 3px 0px #0000001a, 0 4px 6px -1px #0000001a;--shadow-xl:0 1px 3px 0px #0000001a, 0 8px 10px -1px #0000001a;--shadow-2xl:0 1px 3px 0px #00000040;--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--code-bg:oklch(13% .015 260);--focus-highlight:oklch(70% .2 200)}.theme-caffeine.light{--background:#f9f9f9;--foreground:#202020;--card:#fcfcfc;--card-foreground:#202020;--popover:#fcfcfc;--popover-foreground:#202020;--primary:#644a40;--primary-foreground:#fff;--secondary:#ffdfb5;--secondary-foreground:#582d1d;--muted:#efefef;--muted-foreground:#646464;--accent:#8b5a2b;--accent-foreground:#fff;--destructive:#e54d2e;--destructive-foreground:#fff;--border:#d8d8d8;--input:#d8d8d8;--ring:#644a40;--chart-1:#644a40;--chart-2:#ffdfb5;--chart-3:#e8e8e8;--chart-4:#ffe6c4;--chart-5:#66493e;--sidebar:#fbfbfb;--sidebar-foreground:#252525;--sidebar-primary:#343434;--sidebar-primary-foreground:#fbfbfb;--sidebar-accent:#f7f7f7;--sidebar-accent-foreground:#343434;--sidebar-border:#ebebeb;--sidebar-ring:#b5b5b5;--font-sans:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--radius:.625rem;--shadow-2xs:0 1px 3px 0px #0000000d;--shadow-xs:0 1px 3px 0px #0000000d;--shadow-sm:0 1px 3px 0px #0000001a, 0 1px 2px -1px #0000001a;--shadow:0 1px 3px 0px #0000001a, 0 1px 2px -1px #0000001a;--shadow-md:0 1px 3px 0px #0000001a, 0 2px 4px -1px #0000001a;--shadow-lg:0 1px 3px 0px #0000001a, 0 4px 6px -1px #0000001a;--shadow-xl:0 1px 3px 0px #0000001a, 0 8px 10px -1px #0000001a;--shadow-2xl:0 1px 3px 0px #00000040;--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 260);--focus-highlight:oklch(70% .2 200)}.theme-synthwave-84,.theme-synthwave-84.light{--background:#262335;--foreground:#fff;--card:#2a2139;--card-foreground:#fff;--popover:#2a2139;--popover-foreground:#fff;--primary:#ff7edb;--primary-foreground:#262335;--secondary:#34294f;--secondary-foreground:#fff;--muted:#37294d;--muted-foreground:#fff9;--accent:#72f1b8;--accent-foreground:#262335;--destructive:#fe4450;--destructive-foreground:#fff;--border:#495495;--input:#2a2139;--ring:#ff7edb;--success:#72f1b8;--success-foreground:#262335;--warning:#fede5d;--warning-foreground:#262335;--font-sans:"Inter", system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#241b2f;--focus-highlight:#36f9f6}.theme-catppuccin{--background:#1e1e2e;--foreground:#cdd6f4;--card:#313244;--card-foreground:#cdd6f4;--popover:#313244;--popover-foreground:#cdd6f4;--primary:#89b4fa;--primary-foreground:#1e1e2e;--secondary:#45475a;--secondary-foreground:#cdd6f4;--muted:#45475a;--muted-foreground:#a6adc8;--accent:#f5c2e7;--accent-foreground:#1e1e2e;--destructive:#f38ba8;--destructive-foreground:#1e1e2e;--border:#585b70;--input:#313244;--ring:#89b4fa;--success:#a6e3a1;--success-foreground:#1e1e2e;--warning:#f9e2af;--warning-foreground:#1e1e2e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#181825;--focus-highlight:#94e2d5}.theme-catppuccin.light{--background:#eff1f5;--foreground:#4c4f69;--card:#e6e9ef;--card-foreground:#4c4f69;--popover:#e6e9ef;--popover-foreground:#4c4f69;--primary:#1e66f5;--primary-foreground:#eff1f5;--secondary:#ccd0da;--secondary-foreground:#4c4f69;--muted:#ccd0da;--muted-foreground:#6c6f85;--accent:#ea76cb;--accent-foreground:#eff1f5;--destructive:#d20f39;--destructive-foreground:#eff1f5;--border:#acb0be;--input:#ccd0da;--ring:#1e66f5;--success:#40a02b;--success-foreground:#eff1f5;--warning:#df8e1d;--warning-foreground:#eff1f5;--code-bg:#e6e9ef;--focus-highlight:#179299}.theme-rose-pine{--background:#191724;--foreground:#e0def4;--card:#26233a;--card-foreground:#e0def4;--popover:#26233a;--popover-foreground:#e0def4;--primary:#c4a7e7;--primary-foreground:#191724;--secondary:#403d52;--secondary-foreground:#e0def4;--muted:#26233a;--muted-foreground:#6e6a86;--accent:#f6c177;--accent-foreground:#191724;--destructive:#eb6f92;--destructive-foreground:#191724;--border:#403d52;--input:#26233a;--ring:#c4a7e7;--success:#31748f;--success-foreground:#e0def4;--warning:#f6c177;--warning-foreground:#191724;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, monospace;--radius:.625rem;--code-bg:#1f1d2e;--focus-highlight:#9ccfd8}.theme-rose-pine.light{--background:#faf4ed;--foreground:#575279;--card:#fffaf3;--card-foreground:#575279;--popover:#fffaf3;--popover-foreground:#575279;--primary:#907aa9;--primary-foreground:#faf4ed;--secondary:#dfdad9;--secondary-foreground:#575279;--muted:#f2e9e1;--muted-foreground:#9893a5;--accent:#ea9d34;--accent-foreground:#faf4ed;--destructive:#b4637a;--destructive-foreground:#faf4ed;--border:#dfdad9;--input:#f2e9e1;--ring:#907aa9;--success:#286983;--success-foreground:#faf4ed;--warning:#ea9d34;--warning-foreground:#faf4ed;--code-bg:#f2e9e1;--focus-highlight:#56949f}.theme-monokai-pro,.theme-monokai-pro.light{--background:#2d2a2e;--foreground:#fcfcfa;--card:#403e41;--card-foreground:#fcfcfa;--popover:#403e41;--popover-foreground:#fcfcfa;--primary:#ffd866;--primary-foreground:#2d2a2e;--secondary:#5b595c;--secondary-foreground:#fcfcfa;--muted:#403e41;--muted-foreground:#727072;--accent:#78dce8;--accent-foreground:#2d2a2e;--destructive:#ff6188;--destructive-foreground:#2d2a2e;--border:#5b595c;--input:#403e41;--ring:#ffd866;--success:#a9dc76;--success-foreground:#2d2a2e;--warning:#fc9867;--warning-foreground:#2d2a2e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#221f22;--focus-highlight:#78dce8}.theme-doom-64{--background:#1a1a1a;--foreground:#e0e0e0;--card:#2a2a2a;--card-foreground:#e0e0e0;--popover:#2a2a2a;--popover-foreground:#e0e0e0;--primary:#e53935;--primary-foreground:#fff;--secondary:#689f38;--secondary-foreground:#000;--muted:#252525;--muted-foreground:#a0a0a0;--accent:#64b5f6;--accent-foreground:#000;--destructive:#ffa000;--destructive-foreground:#000;--border:#4a4a4a;--input:#4a4a4a;--ring:#e53935;--chart-1:#e53935;--chart-2:#689f38;--chart-3:#64b5f6;--chart-4:#ffa000;--chart-5:#a1887f;--sidebar:#141414;--sidebar-foreground:#e0e0e0;--sidebar-primary:#e53935;--sidebar-primary-foreground:#fff;--sidebar-accent:#64b5f6;--sidebar-accent-foreground:#000;--sidebar-border:#4a4a4a;--sidebar-ring:#e53935;--font-sans:"Oxanium", sans-serif;--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:"Source Code Pro", monospace;--radius:0px;--shadow-2xs:0px 2px 5px 0px #0000004d;--shadow-xs:0px 2px 5px 0px #0000004d;--shadow-sm:0px 2px 5px 0px #0009, 0px 1px 2px -1px #0009;--shadow:0px 2px 5px 0px #0009, 0px 1px 2px -1px #0009;--shadow-md:0px 2px 5px 0px #0009, 0px 2px 4px -1px #0009;--shadow-lg:0px 2px 5px 0px #0009, 0px 4px 6px -1px #0009;--shadow-xl:0px 2px 5px 0px #0009, 0px 8px 10px -1px #0009;--shadow-2xl:0px 2px 5px 0px #000;--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--code-bg:oklch(13% .015 260);--focus-highlight:oklch(70% .2 200)}.theme-doom-64.light{--background:#ccc;--foreground:#1f1f1f;--card:#b0b0b0;--card-foreground:#1f1f1f;--popover:#b0b0b0;--popover-foreground:#1f1f1f;--primary:#b71c1c;--primary-foreground:#fff;--secondary:#556b2f;--secondary-foreground:#fff;--muted:#b8b8b8;--muted-foreground:#4a4a4a;--accent:#4682b4;--accent-foreground:#fff;--destructive:#ff6f00;--destructive-foreground:#000;--border:#505050;--input:#505050;--ring:#b71c1c;--chart-1:#b71c1c;--chart-2:#556b2f;--chart-3:#4682b4;--chart-4:#ff6f00;--chart-5:#8d6e63;--sidebar:#b0b0b0;--sidebar-foreground:#1f1f1f;--sidebar-primary:#b71c1c;--sidebar-primary-foreground:#fff;--sidebar-accent:#4682b4;--sidebar-accent-foreground:#fff;--sidebar-border:#505050;--sidebar-ring:#b71c1c;--font-sans:"Oxanium", sans-serif;--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:"Source Code Pro", monospace;--radius:0px;--shadow-2xs:0px 2px 4px 0px #0003;--shadow-xs:0px 2px 4px 0px #0003;--shadow-sm:0px 2px 4px 0px #0006, 0px 1px 2px -1px #0006;--shadow:0px 2px 4px 0px #0006, 0px 1px 2px -1px #0006;--shadow-md:0px 2px 4px 0px #0006, 0px 2px 4px -1px #0006;--shadow-lg:0px 2px 4px 0px #0006, 0px 4px 6px -1px #0006;--shadow-xl:0px 2px 4px 0px #0006, 0px 8px 10px -1px #0006;--shadow-2xl:0px 2px 4px 0px #000;--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 260);--focus-highlight:oklch(70% .2 200)}.theme-dracula,.theme-dracula.light{--background:#282a36;--foreground:#f8f8f2;--card:#44475a;--card-foreground:#f8f8f2;--popover:#44475a;--popover-foreground:#f8f8f2;--primary:#bd93f9;--primary-foreground:#282a36;--secondary:#44475a;--secondary-foreground:#f8f8f2;--muted:#373949;--muted-foreground:#bcbece;--accent:#8be9fd;--accent-foreground:#282a36;--destructive:#f55;--destructive-foreground:#f8f8f2;--border:#44475a;--input:#44475a;--ring:#bd93f9;--success:#50fa7b;--success-foreground:#282a36;--warning:#f1fa8c;--warning-foreground:#282a36;--font-sans:ui-sans-serif, system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;--radius:.625rem;--code-bg:#21222c;--focus-highlight:#8be9fd}.theme-gruvbox{--background:#282828;--foreground:#ebdbb2;--card:#3c3836;--card-foreground:#ebdbb2;--popover:#3c3836;--popover-foreground:#ebdbb2;--primary:#458588;--primary-foreground:#ebdbb2;--secondary:#504945;--secondary-foreground:#ebdbb2;--muted:#504945;--muted-foreground:#a89984;--accent:#b8bb26;--accent-foreground:#282828;--destructive:#fb4934;--destructive-foreground:#282828;--border:#504945;--input:#3c3836;--ring:#458588;--success:#8ec07c;--success-foreground:#282828;--warning:#fabd2f;--warning-foreground:#282828;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#32302f;--focus-highlight:#83a598}.theme-gruvbox.light{--background:#fbf1c7;--foreground:#3c3836;--card:#f2e5bc;--card-foreground:#3c3836;--popover:#f2e5bc;--popover-foreground:#3c3836;--primary:#076678;--primary-foreground:#fbf1c7;--secondary:#d5c4a1;--secondary-foreground:#3c3836;--muted:#ebdbb2;--muted-foreground:#665c54;--accent:#79740e;--accent-foreground:#fbf1c7;--destructive:#9d0006;--destructive-foreground:#fbf1c7;--border:#d5c4a1;--input:#ebdbb2;--ring:#076678;--success:#689d6a;--success-foreground:#fbf1c7;--warning:#b57614;--warning-foreground:#fbf1c7;--code-bg:#ebdbb2;--focus-highlight:#076678}.theme-kanagawa-dragon,.theme-kanagawa-dragon.light{--background:#181616;--foreground:#c8c093;--card:#211e1e;--card-foreground:#c8c093;--popover:#211e1e;--popover-foreground:#c8c093;--primary:#7fb4ca;--primary-foreground:#181616;--secondary:#2a2625;--secondary-foreground:#c8c093;--muted:#252220;--muted-foreground:#a6a69c;--accent:#7aa89f;--accent-foreground:#181616;--destructive:#c4746e;--destructive-foreground:#c8c093;--border:#2e2b28;--input:#252220;--ring:#7fb4ca;--success:#87a987;--success-foreground:#181616;--warning:#e6c384;--warning-foreground:#181616;--font-sans:ui-sans-serif, system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;--radius:.625rem;--code-bg:#0d0c0c;--focus-highlight:#7aa89f}.theme-kanagawa-lotus,.theme-kanagawa-lotus.light{--background:#f2ecbc;--foreground:#545464;--card:#e5ddb0;--card-foreground:#545464;--popover:#e5ddb0;--popover-foreground:#545464;--primary:#4d699b;--primary-foreground:#f2ecbc;--secondary:#dcd5ac;--secondary-foreground:#545464;--muted:#e7dba0;--muted-foreground:#716e61;--accent:#624c83;--accent-foreground:#f2ecbc;--destructive:#c84053;--destructive-foreground:#f2ecbc;--border:#dcd5ac;--input:#e7dba0;--ring:#4d699b;--success:#6f894e;--success-foreground:#f2ecbc;--warning:#836f4a;--warning-foreground:#f2ecbc;--font-sans:ui-sans-serif, system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;--radius:.625rem;--code-bg:#d5cea3;--focus-highlight:#4e8ca2}.theme-kanagawa-wave,.theme-kanagawa-wave.light{--background:#1f1f28;--foreground:#dcd7ba;--card:#2a2a37;--card-foreground:#dcd7ba;--popover:#2a2a37;--popover-foreground:#dcd7ba;--primary:#7e9cd8;--primary-foreground:#1f1f28;--secondary:#363646;--secondary-foreground:#dcd7ba;--muted:#2d2d3b;--muted-foreground:#727169;--accent:#957fb8;--accent-foreground:#1f1f28;--destructive:#c34043;--destructive-foreground:#dcd7ba;--border:#363646;--input:#2d2d3b;--ring:#7fb4ca;--success:#98bb6c;--success-foreground:#1f1f28;--warning:#e6c384;--warning-foreground:#1f1f28;--font-sans:ui-sans-serif, system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;--radius:.625rem;--code-bg:#16161d;--focus-highlight:#7aa89f}.theme-paulmillr,.theme-paulmillr.light{--background:#000;--foreground:#f2f2f2;--card:#2a2a2a;--card-foreground:#f2f2f2;--popover:#2a2a2a;--popover-foreground:#f2f2f2;--primary:#396bd7;--primary-foreground:#fff;--secondary:#414141;--secondary-foreground:#f2f2f2;--muted:#2a2a2a;--muted-foreground:#bbb;--accent:#6cf;--accent-foreground:#000;--destructive:red;--destructive-foreground:#fff;--border:#414141;--input:#2a2a2a;--ring:#396bd7;--success:#79ff0f;--success-foreground:#000;--warning:#e7bf00;--warning-foreground:#000;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, "SF Mono", Consolas, monospace;--radius:.625rem;--code-bg:#1a1a1a;--focus-highlight:#6cf}.theme-quantum-rose{--background:#1a0922;--foreground:#ffb3ff;--card:#2a1435;--card-foreground:#ffb3ff;--popover:#2a1435;--popover-foreground:#ffb3ff;--primary:#ff6bef;--primary-foreground:#180518;--secondary:#46204f;--secondary-foreground:#ffb3ff;--muted:#331941;--muted-foreground:#d67ad6;--accent:#c06ec4;--accent-foreground:#1a0922;--destructive:#ff2876;--destructive-foreground:#f9f9f9;--border:#4a1b5f;--input:#46204f;--ring:#ff6bef;--chart-1:#ff6bef;--chart-2:#c359e3;--chart-3:#9161ff;--chart-4:#6f73e2;--chart-5:#547aff;--sidebar:#1c0d25;--sidebar-foreground:#ffb3ff;--sidebar-primary:#ff6bef;--sidebar-primary-foreground:#180518;--sidebar-accent:#5a1f5d;--sidebar-accent-foreground:#ffb3ff;--sidebar-border:#4a1b5f;--sidebar-ring:#ff6bef;--font-sans:Quicksand, sans-serif;--font-serif:Playfair Display, serif;--font-mono:Space Mono, monospace;--radius:.625rem;--shadow-2xs:0px 3px 0px 0px #e61ae617;--shadow-xs:0px 3px 0px 0px #e61ae617;--shadow-sm:0px 3px 0px 0px #e61ae62e, 0px 1px 2px -1px #e61ae62e;--shadow:0px 3px 0px 0px #e61ae62e, 0px 1px 2px -1px #e61ae62e;--shadow-md:0px 3px 0px 0px #e61ae62e, 0px 2px 4px -1px #e61ae62e;--shadow-lg:0px 3px 0px 0px #e61ae62e, 0px 4px 6px -1px #e61ae62e;--shadow-xl:0px 3px 0px 0px #e61ae62e, 0px 8px 10px -1px #e61ae62e;--shadow-2xl:0px 3px 0px 0px #e61ae673;--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--code-bg:oklch(13% .015 260);--focus-highlight:oklch(70% .2 200)}.theme-quantum-rose.light{--background:#fff0f8;--foreground:#91185c;--card:#fff7fc;--card-foreground:#91185c;--popover:#fff7fc;--popover-foreground:#91185c;--primary:#e6067a;--primary-foreground:#fff;--secondary:#ffd6ff;--secondary-foreground:#91185c;--muted:#ffe3f2;--muted-foreground:#c04283;--accent:#ffc1e3;--accent-foreground:#91185c;--destructive:#d13869;--destructive-foreground:#fff;--border:#ffc7e6;--input:#ffd6ff;--ring:#e6067a;--chart-1:#e6067a;--chart-2:#c44b97;--chart-3:#9969b6;--chart-4:#7371bf;--chart-5:#5e84ff;--sidebar:#ffedf6;--sidebar-foreground:#91185c;--sidebar-primary:#e6067a;--sidebar-primary-foreground:#fff;--sidebar-accent:#ffc1e3;--sidebar-accent-foreground:#91185c;--sidebar-border:#ffddf0;--sidebar-ring:#e6067a;--font-sans:Poppins, sans-serif;--font-serif:Playfair Display, serif;--font-mono:Space Mono, monospace;--radius:.625rem;--shadow-2xs:0px 3px 0px 0px #82174d17;--shadow-xs:0px 3px 0px 0px #82174d17;--shadow-sm:0px 3px 0px 0px #82174d2e, 0px 1px 2px -1px #82174d2e;--shadow:0px 3px 0px 0px #82174d2e, 0px 1px 2px -1px #82174d2e;--shadow-md:0px 3px 0px 0px #82174d2e, 0px 2px 4px -1px #82174d2e;--shadow-lg:0px 3px 0px 0px #82174d2e, 0px 4px 6px -1px #82174d2e;--shadow-xl:0px 3px 0px 0px #82174d2e, 0px 8px 10px -1px #82174d2e;--shadow-2xl:0px 3px 0px 0px #82174d73;--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 260);--focus-highlight:oklch(70% .2 200)}.theme-solar-dusk{--background:oklch(21.61% .0061 56.0434);--foreground:oklch(96.99% .0013 106.424);--card:oklch(26.85% .0063 34.2976);--card-foreground:oklch(96.99% .0013 106.424);--popover:oklch(26.85% .0063 34.2976);--popover-foreground:oklch(96.99% .0013 106.424);--primary:oklch(70.49% .1867 47.6044);--primary-foreground:oklch(100% 0 0);--secondary:oklch(44.44% .0096 73.639);--secondary-foreground:oklch(92.32% .0026 48.7171);--muted:oklch(26.85% .0063 34.2976);--muted-foreground:oklch(71.61% .0091 56.259);--accent:oklch(60% .12 229.32);--accent-foreground:oklch(15% .02 229);--destructive:oklch(57.71% .2152 27.325);--destructive-foreground:oklch(100% 0 0);--border:oklch(37.41% .0087 67.5582);--input:oklch(37.41% .0087 67.5582);--ring:oklch(70.49% .1867 47.6044);--chart-1:oklch(70.49% .1867 47.6044);--chart-2:oklch(68.47% .1479 237.323);--chart-3:oklch(79.52% .1617 86.0468);--chart-4:oklch(71.61% .0091 56.259);--chart-5:oklch(55.34% .0116 58.0708);--sidebar:oklch(26.85% .0063 34.2976);--sidebar-foreground:oklch(96.99% .0013 106.424);--sidebar-primary:oklch(70.49% .1867 47.6044);--sidebar-primary-foreground:oklch(100% 0 0);--sidebar-accent:oklch(68.47% .1479 237.323);--sidebar-accent-foreground:oklch(28.39% .0734 254.538);--sidebar-border:oklch(37.41% .0087 67.5582);--sidebar-ring:oklch(70.49% .1867 47.6044);--font-sans:Oxanium, sans-serif;--font-serif:Merriweather, serif;--font-mono:Fira Code, monospace;--radius:.3rem;--shadow-2xs:0px 2px 3px 0px #0d0d0d17;--shadow-xs:0px 2px 3px 0px #0d0d0d17;--shadow-sm:0px 2px 3px 0px #0d0d0d2e, 0px 1px 2px -1px #0d0d0d2e;--shadow:0px 2px 3px 0px #0d0d0d2e, 0px 1px 2px -1px #0d0d0d2e;--shadow-md:0px 2px 3px 0px #0d0d0d2e, 0px 2px 4px -1px #0d0d0d2e;--shadow-lg:0px 2px 3px 0px #0d0d0d2e, 0px 4px 6px -1px #0d0d0d2e;--shadow-xl:0px 2px 3px 0px #0d0d0d2e, 0px 8px 10px -1px #0d0d0d2e;--shadow-2xl:0px 2px 3px 0px #0d0d0d73;--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--code-bg:oklch(13% .015 260);--focus-highlight:oklch(70% .2 200)}.theme-solar-dusk.light{--background:oklch(98.85% .0057 84.5659);--foreground:oklch(36.6% .0251 49.6085);--card:oklch(96.86% .0091 78.2818);--card-foreground:oklch(36.6% .0251 49.6085);--popover:oklch(96.86% .0091 78.2818);--popover-foreground:oklch(36.6% .0251 49.6085);--primary:oklch(55.53% .1455 48.9975);--primary-foreground:oklch(100% 0 0);--secondary:oklch(82.76% .0752 74.44);--secondary-foreground:oklch(44.44% .0096 73.639);--muted:oklch(93.63% .0218 83.2637);--muted-foreground:oklch(55.34% .0116 58.0708);--accent:oklch(55% .12 229);--accent-foreground:oklch(100% 0 0);--destructive:oklch(44.37% .1613 26.8994);--destructive-foreground:oklch(100% 0 0);--border:oklch(88.66% .0404 89.6994);--input:oklch(88.66% .0404 89.6994);--ring:oklch(55.53% .1455 48.9975);--chart-1:oklch(55.53% .1455 48.9975);--chart-2:oklch(55.34% .0116 58.0708);--chart-3:oklch(55.38% .1207 66.4416);--chart-4:oklch(55.34% .0116 58.0708);--chart-5:oklch(68.06% .1423 75.834);--sidebar:oklch(93.63% .0218 83.2637);--sidebar-foreground:oklch(36.6% .0251 49.6085);--sidebar-primary:oklch(55.53% .1455 48.9975);--sidebar-primary-foreground:oklch(100% 0 0);--sidebar-accent:oklch(55.38% .1207 66.4416);--sidebar-accent-foreground:oklch(100% 0 0);--sidebar-border:oklch(88.66% .0404 89.6994);--sidebar-ring:oklch(55.53% .1455 48.9975);--font-sans:Oxanium, sans-serif;--font-serif:Merriweather, serif;--font-mono:Fira Code, monospace;--radius:.3rem;--shadow-2xs:0px 2px 3px 0px #4b3f3417;--shadow-xs:0px 2px 3px 0px #4b3f3417;--shadow-sm:0px 2px 3px 0px #4b3f342e, 0px 1px 2px -1px #4b3f342e;--shadow:0px 2px 3px 0px #4b3f342e, 0px 1px 2px -1px #4b3f342e;--shadow-md:0px 2px 3px 0px #4b3f342e, 0px 2px 4px -1px #4b3f342e;--shadow-lg:0px 2px 3px 0px #4b3f342e, 0px 4px 6px -1px #4b3f342e;--shadow-xl:0px 2px 3px 0px #4b3f342e, 0px 8px 10px -1px #4b3f342e;--shadow-2xl:0px 2px 3px 0px #4b3f3473;--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:oklch(96% .005 260);--focus-highlight:oklch(70% .2 200)}.theme-terminal,.theme-terminal.light{--background:#000001;--foreground:#eee;--card:#010203;--card-foreground:#eee;--popover:#010203;--popover-foreground:#eee;--primary:#f99100;--primary-foreground:#fff;--secondary:#2e2e2e;--secondary-foreground:#eee;--muted:#202127;--muted-foreground:#9e9e9e;--accent:#00a1ff;--accent-foreground:#fff;--destructive:#f20024;--destructive-foreground:#fff;--border:#2e2e2e;--input:#010203;--ring:#00a1ff;--chart-1:#2dd047;--chart-2:#f20024;--chart-3:#821698;--chart-4:#f6d653;--chart-5:#00a1ff;--sidebar:#010203;--sidebar-foreground:#eee;--sidebar-primary:#f99100;--sidebar-primary-foreground:#fff;--sidebar-accent:#00a1ff;--sidebar-accent-foreground:#fff;--sidebar-border:#2e2e2e;--sidebar-ring:#00a1ff;--font-sans:system-ui;--font-serif:system-ui;--font-mono:system-ui;--radius:4px;--shadow-2xs:0px 2px 4px 0px #0000001a;--shadow-xs:0px 2px 4px 0px #0000001a;--shadow-sm:0px 2px 4px 0px #0003, 0px 1px 2px -1px #0003;--shadow:0px 2px 4px 0px #0003, 0px 1px 2px -1px #0003;--shadow-md:0px 2px 4px 0px #0003, 0px 2px 4px -1px #0003;--shadow-lg:0px 2px 4px 0px #0003, 0px 4px 6px -1px #0003;--shadow-xl:0px 2px 4px 0px #0003, 0px 8px 10px -1px #0003;--shadow-2xl:0px 2px 4px 0px #00000080;--success:oklch(72% .17 150);--success-foreground:oklch(15% .02 260);--warning:oklch(75% .15 85);--warning-foreground:oklch(20% .02 260);--code-bg:#000;--focus-highlight:oklch(70% .2 200)}.theme-terminal .bg-grid,.theme-terminal.light .bg-grid{background-image:linear-gradient(90deg,#00ff000a 1px,#0000 1px),linear-gradient(#00ff000a 1px,#0000 1px)}.theme-tokyo-night{--background:#24283b;--foreground:#c0caf5;--card:#1d202f;--card-foreground:#c0caf5;--popover:#1d202f;--popover-foreground:#c0caf5;--primary:#7aa2f7;--primary-foreground:#1d202f;--secondary:#414868;--secondary-foreground:#c0caf5;--muted:#343a52;--muted-foreground:#787c99;--accent:#7dcfff;--accent-foreground:#1d202f;--destructive:#f7768e;--destructive-foreground:#1d202f;--border:#414868;--input:#343a52;--ring:#7aa2f7;--success:#9ece6a;--success-foreground:#1d202f;--warning:#e0af68;--warning-foreground:#1d202f;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, monospace;--radius:.625rem;--code-bg:#1a1b2e;--focus-highlight:#7dcfff}.theme-tokyo-night.light{--background:#e1e2e7;--foreground:#3760bf;--card:#e9e9ed;--card-foreground:#3760bf;--popover:#e9e9ed;--popover-foreground:#3760bf;--primary:#2e7de9;--primary-foreground:#e1e2e7;--secondary:#a1a6c5;--secondary-foreground:#3760bf;--muted:#d0d3e1;--muted-foreground:#848cb5;--accent:#007197;--accent-foreground:#e1e2e7;--destructive:#f52a65;--destructive-foreground:#e1e2e7;--border:#a1a6c5;--input:#d0d3e1;--ring:#2e7de9;--success:#587539;--success-foreground:#e1e2e7;--warning:#8c6c3e;--warning-foreground:#e1e2e7;--code-bg:#d5d6db;--focus-highlight:#007197}.theme-tinacious,.theme-tinacious.light{--background:#f8f8ff;--foreground:#1d1d26;--card:#fff;--card-foreground:#1d1d26;--popover:#fff;--popover-foreground:#1d1d26;--primary:#00cbff;--primary-foreground:#fff;--secondary:#cbcbf0;--secondary-foreground:#1d1d26;--muted:#d5d6f3;--muted-foreground:#636667;--accent:#00ceca;--accent-foreground:#1d1d26;--destructive:#f39;--destructive-foreground:#fff;--border:#d5d6f3;--input:#d5d6f3;--ring:#00cbff;--chart-1:#f39;--chart-2:#00d364;--chart-3:#fc6;--chart-4:#c6f;--chart-5:#00ceca;--sidebar:#f0f0f8;--sidebar-foreground:#1d1d26;--sidebar-primary:#00cbff;--sidebar-primary-foreground:#fff;--sidebar-accent:#00ceca;--sidebar-accent-foreground:#1d1d26;--sidebar-border:#d5d6f3;--sidebar-ring:#00cbff;--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;--radius:.625rem;--shadow-2xs:0 1px 2px 0 #0000000d;--shadow-xs:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--shadow-sm:0 2px 4px -1px #0000001a, 0 1px 2px -1px #0000001a;--shadow:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--shadow-md:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--shadow-lg:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--shadow-xl:0 25px 50px -12px #00000040;--shadow-2xl:0 35px 60px -15px #0000004d;--success:oklch(45% .2 150);--success-foreground:oklch(100% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(18% .02 260);--code-bg:#f0f0f8;--focus-highlight:oklch(70% .2 200)}.theme-cursor{--background:#181818;--foreground:#e4e4e4;--card:#1e1e1e;--card-foreground:#e4e4e4;--popover:#141414;--popover-foreground:#e4e4e4;--primary:#81a1c1;--primary-foreground:#141414;--secondary:#2a2a2a;--secondary-foreground:#e4e4e4;--muted:#252525;--muted-foreground:#a0a0a0;--accent:#88c0d0;--accent-foreground:#141414;--destructive:#e34671;--destructive-foreground:#e4e4e4;--border:#333;--input:#252525;--ring:#81a1c1;--success:#3fa266;--success-foreground:#141414;--warning:#d2943e;--warning-foreground:#141414;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;--radius:.625rem;--code-bg:#141414;--focus-highlight:#88c0d0}.theme-cursor.light{--background:#fcfcfc;--foreground:#141414;--card:#f3f3f3;--card-foreground:#141414;--popover:#f3f3f3;--popover-foreground:#141414;--primary:#3c7cab;--primary-foreground:#fcfcfc;--secondary:#e8e8e8;--secondary-foreground:#141414;--muted:#ededed;--muted-foreground:#6a6a6a;--accent:#4c7f8c;--accent-foreground:#fcfcfc;--destructive:#cf2d56;--destructive-foreground:#fcfcfc;--border:#ddd;--input:#ededed;--ring:#3c7cab;--success:#1f8a65;--success-foreground:#fcfcfc;--warning:#c08532;--warning-foreground:#141414;--code-bg:#f3f3f3;--focus-highlight:#3c7cab}.theme-cursor-hc,.theme-cursor-hc.light{--background:#0a0a0a;--foreground:#d8dee9;--card:#1a1a1a;--card-foreground:#eceff4;--popover:#1a1a1a;--popover-foreground:#eceff4;--primary:#88c0d0;--primary-foreground:#000;--secondary:#434c5e;--secondary-foreground:#eceff4;--muted:#2a2a2a;--muted-foreground:#ccc;--accent:#ebcb8b;--accent-foreground:#000;--destructive:#bf616a;--destructive-foreground:#eceff4;--border:#404040;--input:#2a2a2a;--ring:#88c0d0;--success:#a3be8c;--success-foreground:#000;--warning:#ebcb8b;--warning-foreground:#000;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;--radius:.625rem;--code-bg:#1a1a1a;--focus-highlight:#88c0d0}.theme-cursor-midnight,.theme-cursor-midnight.light{--background:#1e2127;--foreground:#d8dee9;--card:#272c36;--card-foreground:#d8dee9;--popover:#20242c;--popover-foreground:#d8dee9;--primary:#88c0d0;--primary-foreground:#1d2128;--secondary:#434c5e;--secondary-foreground:#d8dee9;--muted:#272c36;--muted-foreground:#7b88a1;--accent:#8fbcbb;--accent-foreground:#1d2128;--destructive:#bf616a;--destructive-foreground:#eceff4;--border:#3a4050;--input:#272c36;--ring:#88c0d0;--success:#a3be8c;--success-foreground:#1d2128;--warning:#ebcb8b;--warning-foreground:#1d2128;--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;--radius:.625rem;--code-bg:#191c22;--focus-highlight:#8fbcbb}.theme-everforest{--background:#2d353b;--foreground:#d3c6aa;--card:#343f44;--card-foreground:#d3c6aa;--popover:#3d484d;--popover-foreground:#d3c6aa;--primary:#a7c080;--primary-foreground:#2d353b;--secondary:#475258;--secondary-foreground:#d3c6aa;--muted:#475258;--muted-foreground:#859289;--accent:#83c092;--accent-foreground:#2d353b;--destructive:#e67e80;--destructive-foreground:#2d353b;--border:#475258;--input:#343f44;--ring:#a7c080;--success:#83c092;--success-foreground:#2d353b;--warning:#dbbc7f;--warning-foreground:#2d353b;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#232a2e;--focus-highlight:#7fbbb3}.theme-everforest.light{--background:#fdf6e3;--foreground:#5c6a72;--card:#f4f0d9;--card-foreground:#5c6a72;--popover:#efebd4;--popover-foreground:#5c6a72;--primary:#8da101;--primary-foreground:#fdf6e3;--secondary:#e6e2cc;--secondary-foreground:#5c6a72;--muted:#e6e2cc;--muted-foreground:#939f91;--accent:#35a77c;--accent-foreground:#fdf6e3;--destructive:#f85552;--destructive-foreground:#fdf6e3;--border:#e6e2cc;--input:#f4f0d9;--ring:#8da101;--success:#35a77c;--success-foreground:#fdf6e3;--warning:#dfa000;--warning-foreground:#fdf6e3;--code-bg:#efebd4;--focus-highlight:#3a94c5}.theme-everforest-hard{--background:#272e33;--foreground:#d3c6aa;--card:#2e383c;--card-foreground:#d3c6aa;--popover:#374145;--popover-foreground:#d3c6aa;--primary:#a7c080;--primary-foreground:#272e33;--secondary:#414b50;--secondary-foreground:#d3c6aa;--muted:#414b50;--muted-foreground:#859289;--accent:#83c092;--accent-foreground:#272e33;--destructive:#e67e80;--destructive-foreground:#272e33;--border:#414b50;--input:#2e383c;--ring:#a7c080;--success:#83c092;--success-foreground:#272e33;--warning:#dbbc7f;--warning-foreground:#272e33;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#1e2326;--focus-highlight:#7fbbb3}.theme-everforest-hard.light{--background:#fffbef;--foreground:#5c6a72;--card:#f8f5e4;--card-foreground:#5c6a72;--popover:#f2efdf;--popover-foreground:#5c6a72;--primary:#8da101;--primary-foreground:#fffbef;--secondary:#edeada;--secondary-foreground:#5c6a72;--muted:#edeada;--muted-foreground:#939f91;--accent:#35a77c;--accent-foreground:#fffbef;--destructive:#f85552;--destructive-foreground:#fffbef;--border:#edeada;--input:#f8f5e4;--ring:#8da101;--success:#35a77c;--success-foreground:#fffbef;--warning:#dfa000;--warning-foreground:#fffbef;--code-bg:#f2efdf;--focus-highlight:#3a94c5}.theme-everforest-soft{--background:#333c43;--foreground:#d3c6aa;--card:#3a464c;--card-foreground:#d3c6aa;--popover:#434f55;--popover-foreground:#d3c6aa;--primary:#a7c080;--primary-foreground:#333c43;--secondary:#4d5960;--secondary-foreground:#d3c6aa;--muted:#4d5960;--muted-foreground:#859289;--accent:#83c092;--accent-foreground:#333c43;--destructive:#e67e80;--destructive-foreground:#333c43;--border:#4d5960;--input:#3a464c;--ring:#a7c080;--success:#83c092;--success-foreground:#333c43;--warning:#dbbc7f;--warning-foreground:#333c43;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#293136;--focus-highlight:#7fbbb3}.theme-everforest-soft.light{--background:#f3ead3;--foreground:#5c6a72;--card:#eae4ca;--card-foreground:#5c6a72;--popover:#e5dfc5;--popover-foreground:#5c6a72;--primary:#8da101;--primary-foreground:#f3ead3;--secondary:#ddd8be;--secondary-foreground:#5c6a72;--muted:#ddd8be;--muted-foreground:#939f91;--accent:#35a77c;--accent-foreground:#f3ead3;--destructive:#f85552;--destructive-foreground:#f3ead3;--border:#ddd8be;--input:#eae4ca;--ring:#8da101;--success:#35a77c;--success-foreground:#f3ead3;--warning:#dfa000;--warning-foreground:#f3ead3;--code-bg:#e5dfc5;--focus-highlight:#3a94c5}.theme-nord,.theme-nord.light{--background:#2e3440;--foreground:#d8dee9;--card:#3b4252;--card-foreground:#d8dee9;--popover:#3b4252;--popover-foreground:#d8dee9;--primary:#88c0d0;--primary-foreground:#2e3440;--secondary:#434c5e;--secondary-foreground:#d8dee9;--muted:#3b4252;--muted-foreground:#616e88;--accent:#81a1c1;--accent-foreground:#2e3440;--destructive:#bf616a;--destructive-foreground:#2e3440;--border:#434c5e;--input:#3b4252;--ring:#88c0d0;--success:#a3be8c;--success-foreground:#2e3440;--warning:#ebcb8b;--warning-foreground:#2e3440;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#4c566a;--focus-highlight:#88c0d0}.theme-solarized{--background:#002b36;--foreground:#839496;--card:#073642;--card-foreground:#839496;--popover:#073642;--popover-foreground:#839496;--primary:#268bd2;--primary-foreground:#002b36;--secondary:#003847;--secondary-foreground:#93a1a1;--muted:#003847;--muted-foreground:#586e75;--accent:#2aa198;--accent-foreground:#002b36;--destructive:#dc322f;--destructive-foreground:#002b36;--border:#094e5a;--input:#003847;--ring:#268bd2;--success:#859900;--success-foreground:#002b36;--warning:#b58900;--warning-foreground:#002b36;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#073642;--focus-highlight:#268bd2}.theme-solarized.light{--background:#fdf6e3;--foreground:#657b83;--card:#eee8d5;--card-foreground:#657b83;--popover:#eee8d5;--popover-foreground:#657b83;--primary:#268bd2;--primary-foreground:#fdf6e3;--secondary:#eee8d5;--secondary-foreground:#586e75;--muted:#ddd6c1;--muted-foreground:#93a1a1;--accent:#2aa198;--accent-foreground:#002b36;--destructive:#dc322f;--destructive-foreground:#fdf6e3;--border:#ddd6c1;--input:#ddd6c1;--ring:#268bd2;--success:#859900;--success-foreground:#fdf6e3;--warning:#b58900;--warning-foreground:#fdf6e3;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#eee8d5;--focus-highlight:#268bd2}.theme-github{--background:#24292e;--foreground:#e1e4e8;--card:#1f2428;--card-foreground:#e1e4e8;--popover:#1f2428;--popover-foreground:#e1e4e8;--primary:#58a6ff;--primary-foreground:#24292e;--secondary:#2f363d;--secondary-foreground:#d1d5da;--muted:#2f363d;--muted-foreground:#6a737d;--accent:#79b8ff;--accent-foreground:#24292e;--destructive:#f97583;--destructive-foreground:#24292e;--border:#1b1f23;--input:#2f363d;--ring:#58a6ff;--success:#28a745;--success-foreground:#24292e;--warning:#ffea7f;--warning-foreground:#24292e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2f363d;--focus-highlight:#58a6ff}.theme-github.light{--background:#fff;--foreground:#24292e;--card:#f6f8fa;--card-foreground:#24292e;--popover:#f6f8fa;--popover-foreground:#24292e;--primary:#0366d6;--primary-foreground:#fff;--secondary:#f6f8fa;--secondary-foreground:#586069;--muted:#fafbfc;--muted-foreground:#6a737d;--accent:#0366d6;--accent-foreground:#fff;--destructive:#cb2431;--destructive-foreground:#fff;--border:#e1e4e8;--input:#fafbfc;--ring:#2188ff;--success:#28a745;--success-foreground:#fff;--warning:#f9c513;--warning-foreground:#24292e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#f6f8fa;--focus-highlight:#2188ff}.theme-one-dark-pro,.theme-one-dark-pro.light{--background:#282c34;--foreground:#abb2bf;--card:#21252b;--card-foreground:#abb2bf;--popover:#21252b;--popover-foreground:#abb2bf;--primary:#61afef;--primary-foreground:#282c34;--secondary:#21252b;--secondary-foreground:#abb2bf;--muted:#1d1f23;--muted-foreground:#5c6370;--accent:#c678dd;--accent-foreground:#282c34;--destructive:#e06c75;--destructive-foreground:#282c34;--border:#3e4452;--input:#1d1f23;--ring:#528bff;--success:#98c379;--success-foreground:#282c34;--warning:#e5c07b;--warning-foreground:#282c34;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2c313a;--focus-highlight:#528bff}.theme-night-owl,.theme-night-owl.light{--background:#011627;--foreground:#d6deeb;--card:#0b2942;--card-foreground:#d6deeb;--popover:#0b2942;--popover-foreground:#d6deeb;--primary:#7e57c2;--primary-foreground:#fff;--secondary:#0b253a;--secondary-foreground:#89a4bb;--muted:#0b253a;--muted-foreground:#4b6479;--accent:#82aaff;--accent-foreground:#011627;--destructive:#ef5350;--destructive-foreground:#011627;--border:#5f7e97;--input:#0b253a;--ring:#7e57c2;--success:#addb67;--success-foreground:#011627;--warning:#ffcb6b;--warning-foreground:#011627;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#0b2942;--focus-highlight:#7e57c2}.theme-ayu-dark,.theme-ayu-dark.light{--background:#10141c;--foreground:#bfbdb6;--card:#141821;--card-foreground:#bfbdb6;--popover:#141821;--popover-foreground:#bfbdb6;--primary:#e6b450;--primary-foreground:#805600;--secondary:#0d1017;--secondary-foreground:#bfbdb6;--muted:#141821;--muted-foreground:#6c7380;--accent:#73b8ff;--accent-foreground:#10141c;--destructive:#d95757;--destructive-foreground:#10141c;--border:#1b1f29;--input:#10141c;--ring:#e6b450;--success:#70bf56;--success-foreground:#10141c;--warning:#fdb04c;--warning-foreground:#10141c;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#141821;--focus-highlight:#e6b450}.theme-poimandres,.theme-poimandres.light{--background:#1b1e28;--foreground:#a6accd;--card:#303340;--card-foreground:#a6accd;--popover:#303340;--popover-foreground:#a6accd;--primary:#add7ff;--primary-foreground:#1b1e28;--secondary:#252934;--secondary-foreground:#767c9d;--muted:#252934;--muted-foreground:#767c9d;--accent:#5de4c7;--accent-foreground:#1b1e28;--destructive:#d0679d;--destructive-foreground:#1b1e28;--border:#3b3f4f;--input:#252934;--ring:#add7ff;--success:#5de4c7;--success-foreground:#1b1e28;--warning:#fffac2;--warning-foreground:#1b1e28;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#252934;--focus-highlight:#add7ff}.theme-material{--background:#263238;--foreground:#eff;--card:#2e3c43;--card-foreground:#eff;--popover:#263238;--popover-foreground:#eff;--primary:#80cbc4;--primary-foreground:#263238;--secondary:#37474f;--secondary-foreground:#6c8692;--muted:#303c41;--muted-foreground:#546e7a;--accent:#c3e88d;--accent-foreground:#263238;--destructive:#f07178;--destructive-foreground:#263238;--border:#37474f;--input:#303c41;--ring:#80cbc4;--success:#c3e88d;--success-foreground:#263238;--warning:#ffcb6b;--warning-foreground:#263238;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2d3b41;--focus-highlight:#80cbc4}.theme-material.light{--background:#fafafa;--foreground:#546e7a;--card:#f0f4f5;--card-foreground:#546e7a;--popover:#fafafa;--popover-foreground:#546e7a;--primary:#80cbc4;--primary-foreground:#263238;--secondary:#e8edef;--secondary-foreground:#758a95;--muted:#eee;--muted-foreground:#b0bec5;--accent:#39adb5;--accent-foreground:#fafafa;--destructive:#e53935;--destructive-foreground:#fafafa;--border:#b0bec5;--input:#eee;--ring:#80cbc4;--success:#91b859;--success-foreground:#fafafa;--warning:#e2931d;--warning-foreground:#fafafa;--code-bg:#eee;--focus-highlight:#80cbc4}.theme-vitesse{--background:#121212;--foreground:#dbd7ca;--card:#181818;--card-foreground:#dbd7ca;--popover:#181818;--popover-foreground:#dbd7ca;--primary:#4d9375;--primary-foreground:#121212;--secondary:#1e1e1e;--secondary-foreground:#bfbaaa;--muted:#181818;--muted-foreground:#758575;--accent:#e6cc77;--accent-foreground:#121212;--destructive:#cb7676;--destructive-foreground:#121212;--border:#252525;--input:#181818;--ring:#4d9375;--success:#4d9375;--success-foreground:#121212;--warning:#e6cc77;--warning-foreground:#121212;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#181818;--focus-highlight:#4d9375}.theme-vitesse.light{--background:#fff;--foreground:#393a34;--card:#f7f7f7;--card-foreground:#393a34;--popover:#fff;--popover-foreground:#393a34;--primary:#1c6b48;--primary-foreground:#fff;--secondary:#f0f0f0;--secondary-foreground:#4e4f47;--muted:#f7f7f7;--muted-foreground:#a0ada0;--accent:#bda437;--accent-foreground:#fff;--destructive:#ab5959;--destructive-foreground:#fff;--border:#f0f0f0;--input:#f7f7f7;--ring:#1c6b48;--success:#1e754f;--success-foreground:#fff;--warning:#bda437;--warning-foreground:#fff;--code-bg:#f0f0f0;--focus-highlight:#1c6b48}.theme-vesper{--background:#101010;--foreground:#fff;--card:#161616;--card-foreground:#fff;--popover:#161616;--popover-foreground:#fff;--primary:#ffc799;--primary-foreground:#101010;--secondary:#1c1c1c;--secondary-foreground:#a0a0a0;--muted:#1c1c1c;--muted-foreground:#8b8b8b;--accent:#99ffe4;--accent-foreground:#101010;--destructive:#ff8080;--destructive-foreground:#101010;--border:#282828;--input:#1c1c1c;--ring:#ffc799;--success:#99ffe4;--success-foreground:#101010;--warning:#ffc799;--warning-foreground:#101010;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#1c1c1c;--focus-highlight:#ffc799}.theme-vesper.light{--background:#101010;--foreground:#fff;--card:#161616;--card-foreground:#fff;--popover:#161616;--popover-foreground:#fff;--primary:#ffc799;--primary-foreground:#101010;--secondary:#1c1c1c;--secondary-foreground:#a0a0a0;--muted:#1c1c1c;--muted-foreground:#8b8b8b;--accent:#99ffe4;--accent-foreground:#101010;--destructive:#ff8080;--destructive-foreground:#101010;--border:#282828;--input:#1c1c1c;--ring:#ffc799;--success:#99ffe4;--success-foreground:#101010;--warning:#ffc799;--warning-foreground:#101010;--code-bg:#1c1c1c;--focus-highlight:#ffc799}.theme-andromeeda,.theme-andromeeda.light{--background:#23262e;--foreground:#d5ced9;--card:#2b303b;--card-foreground:#d5ced9;--popover:#2b303b;--popover-foreground:#d5ced9;--primary:#00e8c6;--primary-foreground:#23262e;--secondary:#373941;--secondary-foreground:#d5ced9;--muted:#333844;--muted-foreground:#746f77;--accent:#c74ded;--accent-foreground:#23262e;--destructive:#fc644d;--destructive-foreground:#23262e;--border:#363c49;--input:#363c49;--ring:#00e8c6;--success:#96e072;--success-foreground:#23262e;--warning:#ffe66d;--warning-foreground:#23262e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2e323d;--focus-highlight:#00e8c6}.theme-aurora-x,.theme-aurora-x.light{--background:#07090f;--foreground:#a8beff;--card:#15182b;--card-foreground:#c7d5ff;--popover:#15182b;--popover-foreground:#c7d5ff;--primary:#86a5ff;--primary-foreground:#07090f;--secondary:#262e47;--secondary-foreground:#c7d5ff;--muted:#15182b;--muted-foreground:#546e7a;--accent:#c792ea;--accent-foreground:#07090f;--destructive:#dd5073;--destructive-foreground:#07090f;--border:#262e47;--input:#262e47;--ring:#86a5ff;--success:#63eb90;--success-foreground:#07090f;--warning:#ffcb6b;--warning-foreground:#07090f;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#0c0e19;--focus-highlight:#86a5ff}.theme-dark-plus{--background:#1e1e1e;--foreground:#d4d4d4;--card:#252526;--card-foreground:#d4d4d4;--popover:#252526;--popover-foreground:#d4d4d4;--primary:#007acc;--primary-foreground:#fff;--secondary:#383b3d;--secondary-foreground:#d4d4d4;--muted:#303031;--muted-foreground:#858585;--accent:#4ec9b0;--accent-foreground:#1e1e1e;--destructive:#f44747;--destructive-foreground:#fff;--border:#454545;--input:#454545;--ring:#007acc;--success:#369432;--success-foreground:#fff;--warning:#cca700;--warning-foreground:#1e1e1e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2d2d2d;--focus-highlight:#007acc}.theme-dark-plus.light{--background:#fff;--foreground:#000;--card:#f3f3f3;--card-foreground:#000;--popover:#f3f3f3;--popover-foreground:#000;--primary:#007acc;--primary-foreground:#fff;--secondary:#e8e8e8;--secondary-foreground:#000;--muted:#e5ebf1;--muted-foreground:#6e7781;--accent:#267f99;--accent-foreground:#fff;--destructive:#cd3131;--destructive-foreground:#fff;--border:#d4d4d4;--input:#d4d4d4;--ring:#007acc;--success:#369432;--success-foreground:#fff;--warning:#bf8803;--warning-foreground:#fff;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#f3f3f3;--focus-highlight:#007acc}.theme-houston,.theme-houston.light{--background:#17191e;--foreground:#eef0f9;--card:#23262d;--card-foreground:#eef0f9;--popover:#23262d;--popover-foreground:#eef0f9;--primary:#4bf3c8;--primary-foreground:#17191e;--secondary:#343841;--secondary-foreground:#eef0f9;--muted:#2a2d34;--muted-foreground:#545864;--accent:#54b9ff;--accent-foreground:#17191e;--destructive:#f4587e;--destructive-foreground:#17191e;--border:#343841;--input:#343841;--ring:#4bf3c8;--success:#4bf3c8;--success-foreground:#17191e;--warning:#ffd493;--warning-foreground:#17191e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#23262d;--focus-highlight:#4bf3c8}.theme-laserwave,.theme-laserwave.light{--background:#27212e;--foreground:#fff;--card:#3a3242;--card-foreground:#fff;--popover:#3a3242;--popover-foreground:#fff;--primary:#eb64b9;--primary-foreground:#27212e;--secondary:#3e3549;--secondary-foreground:#fff;--muted:#3a3242;--muted-foreground:#91889b;--accent:#40b4c4;--accent-foreground:#27212e;--destructive:#ff3e7b;--destructive-foreground:#27212e;--border:#4a4055;--input:#4a4055;--ring:#eb64b9;--success:#74dfc4;--success-foreground:#27212e;--warning:#ffe261;--warning-foreground:#27212e;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#3e3549;--focus-highlight:#eb64b9}.theme-min{--background:#1f1f1f;--foreground:#b392f0;--card:#242424;--card-foreground:#b392f0;--popover:#242424;--popover-foreground:#b392f0;--primary:#b392f0;--primary-foreground:#1f1f1f;--secondary:#2a2a2a;--secondary-foreground:#bbb;--muted:#2a2a2a;--muted-foreground:#6b737c;--accent:#79b8ff;--accent-foreground:#1f1f1f;--destructive:#ff7a84;--destructive-foreground:#1f1f1f;--border:#383838;--input:#383838;--ring:#b392f0;--success:#3a632a;--success-foreground:#fff;--warning:#cd9731;--warning-foreground:#1f1f1f;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#2a2a2a;--focus-highlight:#b392f0}.theme-min.light{--background:#fff;--foreground:#24292e;--card:#f6f6f6;--card-foreground:#24292e;--popover:#f6f6f6;--popover-foreground:#24292e;--primary:#6f42c1;--primary-foreground:#fff;--secondary:#eee;--secondary-foreground:#212121;--muted:#f6f6f6;--muted-foreground:#c2c3c5;--accent:#1976d2;--accent-foreground:#fff;--destructive:#d32f2f;--destructive-foreground:#fff;--border:#e9e9e9;--input:#e9e9e9;--ring:#6f42c1;--success:#7c0;--success-foreground:#fff;--warning:#f29718;--warning-foreground:#fff;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#f3f3f3;--focus-highlight:#6f42c1}.theme-one-light,.theme-one-light.light{--background:#fafafa;--foreground:#383a42;--card:#eaeaeb;--card-foreground:#383a42;--popover:#eaeaeb;--popover-foreground:#383a42;--primary:#526fff;--primary-foreground:#fff;--secondary:#e5e5e6;--secondary-foreground:#383a42;--muted:#eaeaeb;--muted-foreground:#a0a1a7;--accent:#4078f2;--accent-foreground:#fff;--destructive:#e45649;--destructive-foreground:#fff;--border:#dbdbdc;--input:#dbdbdc;--ring:#526fff;--success:#3bba54;--success-foreground:#fff;--warning:#c18401;--warning-foreground:#fff;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#e5e5e6;--focus-highlight:#526fff}.theme-plastic,.theme-plastic.light{--background:#21252b;--foreground:#a9b2c3;--card:#181a1f;--card-foreground:#a9b2c3;--popover:#181a1f;--popover-foreground:#a9b2c3;--primary:#1085ff;--primary-foreground:#fff;--secondary:#0d1117;--secondary-foreground:#c6ccd7;--muted:#181a1f;--muted-foreground:#5f6672;--accent:#61afef;--accent-foreground:#0d1117;--destructive:#d74e42;--destructive-foreground:#fff;--border:#0d1117;--input:#0d1117;--ring:#1085ff;--success:#98c379;--success-foreground:#0d1117;--warning:#e5c07b;--warning-foreground:#0d1117;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#0d1117;--focus-highlight:#1085ff}.theme-red,.theme-red.light{--background:#390000;--foreground:#f8f8f8;--card:#490000;--card-foreground:#f8f8f8;--popover:#490000;--popover-foreground:#f8f8f8;--primary:#c33;--primary-foreground:#fff;--secondary:#580000;--secondary-foreground:#f8f8f8;--muted:#580000;--muted-foreground:#e7c0c0;--accent:#ffd0aa;--accent-foreground:#390000;--destructive:#f12727;--destructive-foreground:#fff;--border:#600;--input:#580000;--ring:#c33;--success:#41a83e;--success-foreground:#390000;--warning:#fec758;--warning-foreground:#390000;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#580000;--focus-highlight:#c33}.theme-slack{--background:#222;--foreground:#e6e6e6;--card:#292929;--card-foreground:#e6e6e6;--popover:#292929;--popover-foreground:#e6e6e6;--primary:#0077b5;--primary-foreground:#fff;--secondary:#141414;--secondary-foreground:#e6e6e6;--muted:#141414;--muted-foreground:#6e7681;--accent:#1d978d;--accent-foreground:#fff;--destructive:#f44747;--destructive-foreground:#fff;--border:#3a3d41;--input:#292929;--ring:#0077b5;--success:#6a9955;--success-foreground:#fff;--warning:#cd9731;--warning-foreground:#222;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#141414;--focus-highlight:#0077b5}.theme-slack.light{--background:#fff;--foreground:#000;--card:#f3f3f3;--card-foreground:#000;--popover:#f3f3f3;--popover-foreground:#000;--primary:#5899c5;--primary-foreground:#fff;--secondary:#eee;--secondary-foreground:#000;--muted:#f3f3f3;--muted-foreground:#6e7681;--accent:#5899c5;--accent-foreground:#fff;--destructive:#f44c5e;--destructive-foreground:#fff;--border:#dcdedf;--input:#dcdedf;--ring:#5899c5;--success:#91b859;--success-foreground:#161f26;--warning:#ffb62c;--warning-foreground:#161f26;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#eee;--focus-highlight:#5899c5}.theme-snazzy-light,.theme-snazzy-light.light{--background:#fafbfc;--foreground:#565869;--card:#f3f4f5;--card-foreground:#565869;--popover:#f3f4f5;--popover-foreground:#565869;--primary:#09a1ed;--primary-foreground:#fff;--secondary:#e9eaeb;--secondary-foreground:#565869;--muted:#e9eaeb;--muted-foreground:#9194a2;--accent:#2dae58;--accent-foreground:#fff;--destructive:#ff5c56;--destructive-foreground:#fff;--border:#dedfe0;--input:#e9eaeb;--ring:#09a1ed;--success:#2dae58;--success-foreground:#fff;--warning:#cf9c00;--warning-foreground:#fff;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#e9eaeb;--focus-highlight:#09a1ed}.theme-vitesse-black,.theme-vitesse-black.light{--background:#000;--foreground:#dbd7ca;--card:#121212;--card-foreground:#dbd7ca;--popover:#121212;--popover-foreground:#dbd7ca;--primary:#4d9375;--primary-foreground:#000;--secondary:#191919;--secondary-foreground:#bfbaaa;--muted:#121212;--muted-foreground:#758575;--accent:#6394bf;--accent-foreground:#000;--destructive:#cb7676;--destructive-foreground:#000;--border:#191919;--input:#191919;--ring:#4d9375;--success:#4d9375;--success-foreground:#000;--warning:#e6cc77;--warning-foreground:#000;--font-sans:system-ui, sans-serif;--font-mono:"Fira Code", ui-monospace, monospace;--radius:.625rem;--code-bg:#121212;--focus-highlight:#4d9375}.theme-simple{--background:oklch(14.1% .005 285.823);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% .006 285.885);--card-foreground:oklch(98.5% 0 0);--popover:oklch(22.5% .006 285.885);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(98.5% 0 0);--primary-foreground:oklch(21% .006 285.885);--secondary:oklch(27.4% .006 286.033);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(16% .005 285.823);--muted-foreground:oklch(70.5% .015 286.067);--accent:oklch(27.4% .006 286.033);--accent-foreground:oklch(98.5% 0 0);--annotation-comment:oklch(62% .19 256);--destructive:oklch(39.6% .141 25.723);--destructive-foreground:oklch(63.7% .237 25.331);--border:oklch(27.4% .006 286.033);--input:oklch(27.4% .006 286.033);--ring:oklch(44.2% .017 285.786);--success:oklch(62% .194 149.214);--success-foreground:oklch(98.5% 0 0);--warning:oklch(75% .15 85);--warning-foreground:oklch(21% .006 285.885);--surface-0:oklch(21% .006 286.033);--surface-1:oklch(24.5% .006 286.033);--surface-2:oklch(28% .006 286.033);--sidebar:oklch(21% .006 285.885);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(27.4% .006 286.033);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(27.4% .006 286.033);--sidebar-ring:oklch(44.2% .017 285.786);--font-sans:"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif;--font-mono:"Geist Mono Variable", "SF Mono", ui-monospace, monospace;--radius:.625rem;--code-bg:oklch(24.5% .006 286.033);--brand:oklch(75% .2 150);--brand-dev:oklch(80% .17 85);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439)}.theme-simple.light{--background:oklch(100% 0 0);--foreground:oklch(14.1% .005 285.823);--card:oklch(100% 0 0);--card-foreground:oklch(14.1% .005 285.823);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.1% .005 285.823);--primary:oklch(21% .006 285.885);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(96.7% .001 286.375);--secondary-foreground:oklch(21% .006 285.885);--muted:oklch(96.7% .001 286.375);--muted-foreground:oklch(55.2% .016 285.938);--accent:oklch(96.7% .001 286.375);--accent-foreground:oklch(21% .006 285.885);--annotation-comment:oklch(52% .2 256);--destructive:oklch(57.7% .245 27.325);--destructive-foreground:oklch(57.7% .245 27.325);--border:oklch(92% .004 286.32);--input:oklch(92% .004 286.32);--ring:oklch(87.1% .006 286.286);--success:oklch(62% .194 149.214);--success-foreground:oklch(98.5% 0 0);--warning:oklch(55% .18 85);--warning-foreground:oklch(14.1% .005 285.823);--surface-0:oklch(96.7% .001 286.375);--surface-1:oklch(94.5% .002 286.375);--surface-2:oklch(92.5% .003 286.375);--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.1% .005 285.823);--sidebar-primary:oklch(21% .006 285.885);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(96.7% .001 286.375);--sidebar-accent-foreground:oklch(21% .006 285.885);--sidebar-border:oklch(92% .004 286.32);--sidebar-ring:oklch(87.1% .006 286.286);--code-bg:oklch(96.7% .001 286.375);--brand:oklch(68% .2 150);--brand-dev:oklch(75% .17 85);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08)}.theme-neutral{--background:oklch(14.1% .005 285.823);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% .006 285.885);--card-foreground:oklch(98.5% 0 0);--popover:oklch(22.5% .006 285.885);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(98.5% 0 0);--primary-foreground:oklch(21% .006 285.885);--secondary:oklch(27.4% .006 286.033);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(16% .005 285.823);--muted-foreground:oklch(70.5% .015 286.067);--accent:oklch(27.4% .006 286.033);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(39.6% .141 25.723);--destructive-foreground:oklch(63.7% .237 25.331);--border:oklch(27.4% .006 286.033);--input:oklch(27.4% .006 286.033);--ring:oklch(44.2% .017 285.786);--success:oklch(62% .194 149.214);--success-foreground:oklch(98.5% 0 0);--warning:oklch(75% .15 85);--warning-foreground:oklch(21% .006 285.885);--font-sans:"Inter", system-ui, sans-serif;--font-mono:"Geist Mono", "SF Mono", Consolas, monospace;--radius:.625rem;--code-bg:oklch(20.5% .006 285.885)}.theme-neutral.light{--background:oklch(100% 0 0);--foreground:oklch(14.1% .005 285.823);--card:oklch(100% 0 0);--card-foreground:oklch(14.1% .005 285.823);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.1% .005 285.823);--primary:oklch(21% .006 285.885);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(96.7% .001 286.375);--secondary-foreground:oklch(21% .006 285.885);--muted:oklch(96.7% .001 286.375);--muted-foreground:oklch(55.2% .016 285.938);--accent:oklch(96.7% .001 286.375);--accent-foreground:oklch(21% .006 285.885);--destructive:oklch(57.7% .245 27.325);--destructive-foreground:oklch(57.7% .245 27.325);--border:oklch(92% .004 286.32);--input:oklch(92% .004 286.32);--ring:oklch(87.1% .006 286.286);--success:oklch(62% .194 149.214);--success-foreground:oklch(14.1% .005 285.823);--warning:oklch(55% .18 85);--warning-foreground:oklch(14.1% .005 285.823);--code-bg:oklch(96.7% .001 286.375)}.plannotator-print pre,.plannotator-print pre[class]{color:#1a1a1a!important;background:#f5f5f5!important;border:1px solid #ccc!important;border-left:3px solid #888!important}.plannotator-print pre code,.plannotator-print code.hljs,.plannotator-print pre code.hljs,.plannotator-print .hljs{color:#1a1a1a!important;background:0 0!important}.plannotator-print pre span,.plannotator-print pre code span,.plannotator-print .hljs span,.plannotator-print [class*=hljs-]{color:#1a1a1a!important;background:0 0!important;font-style:normal!important;text-decoration:none!important}.plannotator-print code{color:#1a1a1a!important;background:#e8e8e8!important;border:1px solid #ccc!important}.plannotator-print pre code{background:0 0!important;border:none!important}@media print{*{transition:none!important;animation:none!important}@page{size:A4;margin:.75in}html,body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;font-size:11pt;line-height:1.6;color:#1a1a1a!important;background:#fff!important;height:auto!important;overflow:visible!important}header,aside,.annotation-toolbar,.fixed,[data-resize-handle]{display:none!important}[data-print-hide]{margin:0!important;padding:0!important;display:none!important}.sidebar-tab-flag,pre .absolute,.group>button.absolute,button[title=Settings],button[title="Toggle theme"],button[title="Add global comment"],button[title="Copy plan"],button[title="Copy file"]{display:none!important}[data-print-region=root]{height:auto!important;overflow:visible!important}[data-print-region=content]{overflow:visible!important}main,[data-print-region=document]{background:#fff!important;width:100%!important;padding:0!important;overflow:visible!important}main>div{margin:0!important;padding:0!important}article,[data-print-region=article]{width:100%!important;max-width:100%!important;box-shadow:none!important;background:#fff!important;border:none!important;border-radius:0!important;margin:0!important;padding:0!important}article h1:first-of-type,[data-print-region=article] h1:first-of-type{margin-top:0!important}body,div,span,p,li,td,th,label,strong,em,b,i{color:#1a1a1a!important}strong,b{color:#000!important;font-weight:700!important}em,i{color:#1a1a1a!important;font-style:italic!important}h1{page-break-after:avoid;margin:1.5em 0 .5em;font-size:24pt;font-weight:700;color:#000!important}h2{page-break-after:avoid;margin:1.25em 0 .4em;font-size:18pt;font-weight:700;color:#000!important}h3{page-break-after:avoid;margin:1em 0 .3em;font-size:14pt;font-weight:700;color:#000!important}h4{page-break-after:avoid;margin:.8em 0 .25em;font-size:12pt;font-weight:700;color:#000!important}h5,h6{page-break-after:avoid;margin:.6em 0 .2em;font-size:11pt;font-weight:700;color:#000!important}p{margin:.5em 0;color:#1a1a1a!important}ul,ol{margin:.5em 0;padding-left:2em}li{margin:.25em 0;color:#1a1a1a!important}li p{margin:.25em 0}code,code[class]{color:#1a1a1a!important;background:#e8e8e8!important;border:1px solid #ccc!important;border-radius:3px!important;padding:.1em .4em!important;font-family:Courier New,Monaco,Consolas,monospace!important;font-size:10pt!important}.group{page-break-inside:avoid}pre,pre[class]{page-break-inside:avoid;color:#1a1a1a!important;background:#f5f5f5!important;border:1px solid #ccc!important;border-left:3px solid #888!important;border-radius:4px!important;margin:1em 0!important;padding:1em!important;overflow:visible!important}pre code,pre code[class],pre code.hljs,code.hljs,code[data-highlighted]{color:#1a1a1a!important;white-space:pre-wrap!important;word-wrap:break-word!important;background:0 0!important;border:none!important;padding:0!important;font-size:9pt!important;display:block!important}.hljs{color:#1a1a1a!important;background:0 0!important}pre span,pre code span,.hljs span,code span,[class*=hljs-]{color:#1a1a1a!important;background:0 0!important;font-style:normal!important;text-decoration:none!important}table{border-collapse:collapse;page-break-inside:avoid;width:100%;margin:1em 0}thead{page-break-after:avoid;background:#e8e8e8!important}th{text-align:left;border:1px solid #999;padding:.5em;font-weight:700;color:#000!important;background:#e8e8e8!important}td{border:1px solid #ccc;padding:.5em;color:#1a1a1a!important}tbody tr:nth-child(2n){background:#f9f9f9!important}tbody tr:nth-child(odd){background:#fff!important}blockquote{margin:1em 0;padding:.5em 0 .5em 1em;color:#333!important;background:#f7f7f7!important;border-left:4px solid #999!important}blockquote p{margin:.5em 0}hr{page-break-after:avoid;border:none;border-top:1px solid #999;margin:1.5em 0}a{text-decoration:underline;color:#06c!important}a[href]:after{content:" (" attr(href) ")";word-break:break-all;font-size:9pt;color:#666!important}a[href^="#"]:after{content:""}img{page-break-inside:avoid;max-width:100%;height:auto;margin:1em 0}svg{page-break-inside:avoid;max-width:100%;height:auto!important}h1,h2,h3,h4,h5,h6{page-break-after:avoid}pre,blockquote,table,figure{page-break-inside:avoid}*{-webkit-print-color-adjust:exact;print-color-adjust:exact}}body{font-family:var(--font-sans);background:var(--background);color:var(--foreground);font-feature-settings:"ss01","ss02","cv01"}.math-inline{vertical-align:text-bottom;align-items:center;max-width:100%;line-height:1;display:inline-flex}.math-annotatable{cursor:pointer}.math-block{max-width:100%}.math-inline .katex,.math-block .katex{color:inherit;font-size:1.02em}.math-inline .katex{line-height:inherit}.math-block .katex-display{text-align:center;margin:0}.bg-grid{background-image:linear-gradient(90deg,#2d333d80 1px,#0000 1px),linear-gradient(#2d333d80 1px,#0000 1px);background-size:24px 24px}.light .bg-grid{background-image:linear-gradient(90deg,#dadee599 1px,#0000 1px),linear-gradient(#dadee599 1px,#0000 1px)}.glow-primary{box-shadow:0 0 20px oklch(from var(--primary) l c h / .15),0 0 40px oklch(from var(--primary) l c h / .05)}.glow-sm{box-shadow:0 0 10px oklch(from var(--primary) l c h / .1)}.goal-shell{border:1px solid oklch(from var(--border) l c h / .3);background:oklch(from var(--muted) l c h / .5);border-radius:1rem;padding:1.25rem;position:relative}.goal-row{background:var(--card);border:1px solid oklch(from var(--border) l c h / .3);border-radius:.625rem;transition:background-color .15s,box-shadow .15s,border-color .15s,opacity .12s;position:relative;box-shadow:0 1px 2px -1px #0000001f}.goal-row+.goal-row{margin-top:.375rem}.goal-row.answered{border-color:oklch(from var(--success) l c h / .25)}.goal-row.skipped{border-color:oklch(from var(--warning) l c h / .25)}.goal-row.active{box-shadow:0 0 0 1px oklch(from var(--primary) l c h / .3),0 2px 8px -2px #0000001f}.goal-row-divider{background:oklch(from var(--border) l c h / .2);height:1px;margin:0 .875rem}.goal-question-body{grid-template-rows:0fr;transition:grid-template-rows .18s;display:grid}.goal-question-body.expanded{grid-template-rows:1fr}.goal-question-body>.goal-question-body-inner{min-height:0;overflow:hidden}@keyframes goal-pill-in{0%{opacity:0;transform:translate(-50%,8px)}to{opacity:1;transform:translate(-50%)}}.goal-shortcut-pill{animation:.22s ease-out both goal-pill-in}.goal-shortcut-pill kbd{border:1px solid oklch(from var(--border) l c h / .5);background:oklch(from var(--muted) l c h / .6);min-width:1rem;height:1rem;color:var(--foreground);font-family:var(--font-mono);border-radius:.25rem;justify-content:center;align-items:center;padding:0 .25rem;font-size:.625rem;line-height:1rem;display:inline-flex}.goal-shortcut-pill kbd+kbd{border-top-left-radius:0;border-bottom-left-radius:0;margin-left:-1px}.goal-shortcut-pill kbd:has(+kbd){border-top-right-radius:0;border-bottom-right-radius:0}*{scrollbar-width:thin;scrollbar-color:var(--border) transparent}::selection{background:oklch(from var(--primary) l c h / .3)}html:not(.transitions-ready) *{transition-duration:0s!important}*{transition-property:color,background-color,border-color,box-shadow;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}:focus-visible{outline:2px solid var(--ring);outline-offset:2px}.file-annotation-flash{animation:1.2s ease-out file-flash}@keyframes file-flash{0%{background:0 0}10%{background:var(--muted)}30%{background:0 0}45%{background:var(--muted)}65%{background:0 0}to{background:0 0}}.file-tree-item{border-radius:var(--radius-sm);cursor:pointer;color:var(--muted-foreground);align-items:center;gap:.5rem;padding:.25rem .5rem;font-size:.75rem;display:flex}.file-tree-item:hover{background:var(--muted);color:var(--foreground)}.file-tree-item:disabled{cursor:default}.file-tree-item:disabled:hover{background:0 0}.file-tree-item.active{background:oklch(from var(--primary) l c h / .3);color:var(--primary)}.file-tree-item.has-annotations{background:oklch(from var(--primary) l c h / .08)}.file-tree-item.has-annotations:hover{background:var(--muted)}.file-tree-item.active.has-annotations{background:oklch(from var(--primary) l c h / .3)}.file-tree-item .additions,.file-tree-folder .additions,.file-tree-status-summary .additions{color:var(--success)}.file-tree-item .deletions,.file-tree-folder .deletions,.file-tree-status-summary .deletions{color:var(--destructive)}.plan-diff-word-added,.plan-diff-word-removed{-webkit-box-decoration-break:clone;box-decoration-break:clone;border-radius:2px;padding:0 2px}.plan-diff-word-added{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.plan-diff-word-added{background-color:color-mix(in oklab,var(--success) 20%,transparent)}}.plan-diff-word-added{color:inherit;text-decoration:none}.plan-diff-word-removed{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.plan-diff-word-removed{background-color:color-mix(in oklab,var(--destructive) 15%,transparent)}}.plan-diff-word-removed{text-decoration:line-through;-webkit-text-decoration-color:var(--destructive);text-decoration-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.plan-diff-word-removed{-webkit-text-decoration-color:color-mix(in oklab,var(--destructive) 60%,transparent);text-decoration-color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.plan-diff-word-removed{opacity:.75;color:inherit}.plan-diff-word-added code{background-color:var(--success)}@supports (color:color-mix(in lab,red,red)){.plan-diff-word-added code{background-color:color-mix(in oklab,var(--success) 25%,var(--muted))}}.plan-diff-word-removed code{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.plan-diff-word-removed code{background-color:color-mix(in oklab,var(--destructive) 20%,var(--muted))}}.plan-diff-added{border-left:3px solid var(--success);background:oklch(from var(--success) l c h / .06);border-radius:0 .25rem .25rem 0;margin:.25rem 0;padding-left:.75rem}.light .plan-diff-added{background:oklch(from var(--success) l c h / .06)}.plan-diff-removed{border-left:3px solid var(--destructive);background:oklch(from var(--destructive) l c h / .06);border-radius:0 .25rem .25rem 0;margin:.25rem 0;padding-left:.75rem}.light .plan-diff-removed{background:oklch(from var(--destructive) l c h / .06)}.plan-diff-modified{border-left:3px solid oklch(from var(--warning) l c h / .75);background:0 0;border-radius:0 .25rem .25rem 0;margin:.25rem 0;padding-left:.75rem}.plan-diff-line-added{background:oklch(from var(--success) l c h / .15);color:var(--success)}.plan-diff-line-removed{background:oklch(from var(--destructive) l c h / .15);color:var(--destructive);opacity:.75;text-decoration:line-through;-webkit-text-decoration-color:oklch(from var(--destructive) l c h / .4);text-decoration-color:oklch(from var(--destructive) l c h / .4)}.light .plan-diff-line-added{background:oklch(from var(--success) l c h / .12)}.light .plan-diff-line-removed{background:oklch(from var(--destructive) l c h / .12)}.html-block details{border:1px solid var(--border);background-color:var(--muted);border-radius:6px;margin:.5rem 0;padding:.5rem .75rem}@supports (color:color-mix(in lab,red,red)){.html-block details{background-color:color-mix(in oklab,var(--muted) 30%,transparent)}}.html-block details[open]{padding-bottom:.75rem}.html-block summary{cursor:pointer;color:var(--foreground);list-style:revert;font-weight:500}.html-block summary::-webkit-details-marker{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.html-block summary::-webkit-details-marker{color:color-mix(in oklab,var(--foreground) 60%,transparent)}}.html-block details[open]>summary{margin-bottom:.5rem}.html-block p{margin:.5rem 0}.html-block ul,.html-block ol{margin:.5rem 0;padding-left:1.5rem}.html-block ul{list-style:outside}.html-block ol{list-style:decimal}.html-block blockquote{border-left:2px solid var(--primary)}@supports (color:color-mix(in lab,red,red)){.html-block blockquote{border-left:2px solid color-mix(in oklab,var(--primary) 50%,transparent)}}.html-block blockquote{color:var(--muted-foreground);margin:.75rem 0;padding-left:1rem;font-style:italic}.html-block code{background-color:var(--muted);border-radius:3px;padding:.1em .35em;font-size:.9em}.html-block pre{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.html-block pre{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.html-block pre{border:1px solid var(--border)}@supports (color:color-mix(in lab,red,red)){.html-block pre{border:1px solid color-mix(in oklab,var(--border) 30%,transparent)}}.html-block pre{border-radius:6px;margin:.75rem 0;padding:.75rem;overflow-x:auto}.html-block pre code{background:0 0;padding:0}.html-block a{color:var(--primary);text-underline-offset:2px;text-decoration:underline}body:has([data-popout=true]) [data-annotation-panel=true],body:has([data-popout=true]) [data-sticky-header-lane=true],body:has([data-popout=true]) [data-app-header=true],body:has([data-popout=true]) [data-sidebar-tabs=true]{z-index:-1}.alert-note{border-left-color:#0969da}.alert-note .alert-title{color:#0969da}.alert-tip{border-left-color:#1a7f37}.alert-tip .alert-title{color:#1a7f37}.alert-warning{border-left-color:#9a6700}.alert-warning .alert-title{color:#9a6700}.alert-caution{border-left-color:#cf222e}.alert-caution .alert-title{color:#cf222e}.alert-important{border-left-color:#8250df}.alert-important .alert-title{color:#8250df}@media(prefers-color-scheme:dark){.alert-note{border-left-color:#4493f8}.alert-note .alert-title{color:#4493f8}.alert-tip{border-left-color:#3fb950}.alert-tip .alert-title{color:#3fb950}.alert-warning{border-left-color:#d29922}.alert-warning .alert-title{color:#d29922}.alert-caution{border-left-color:#f85149}.alert-caution .alert-title{color:#f85149}.alert-important{border-left-color:#ab7df8}.alert-important .alert-title{color:#ab7df8}}.directive{border-color:var(--border);background:var(--muted)}@supports (color:color-mix(in lab,red,red)){.directive{background:color-mix(in srgb,var(--muted) 50%,transparent)}}.directive-title{color:var(--muted-foreground)}.directive-note,.directive-info{background:#3b82f61a;border-color:#3b82f666}.directive-note .directive-title,.directive-info .directive-title{color:#3b82f6}.directive-tip,.directive-success{background:#10b9811a;border-color:#10b98166}.directive-tip .directive-title,.directive-success .directive-title{color:#10b981}.directive-warning{background:#f59e0b1a;border-color:#f59e0b66}.directive-warning .directive-title{color:#f59e0b}.directive-danger,.directive-caution{background:#ef44441a;border-color:#ef444466}.directive-danger .directive-title,.directive-caution .directive-title{color:#ef4444}.ai-streaming-cursor{vertical-align:-.15em;background:currentColor;border-radius:1px;width:.45em;height:1em;animation:1s steps(2,start) infinite ai-cursor-blink;display:inline-block}@keyframes ai-cursor-blink{0%,45%{opacity:1}46%,to{opacity:0}}@media(prefers-reduced-motion:reduce){.ai-streaming-cursor{animation:none}}.ai-markdown{line-height:1.55}.ai-markdown p{margin:.45rem 0}.ai-markdown p:first-child{margin-top:0}.ai-markdown p:last-child{margin-bottom:0}.ai-markdown h1,.ai-markdown h2,.ai-markdown h3,.ai-markdown h4,.ai-markdown h5,.ai-markdown h6{margin:.75rem 0 .35rem;font-weight:600}.ai-markdown h1{font-size:.875rem}.ai-markdown h2{font-size:.8125rem}.ai-markdown h3,.ai-markdown h4,.ai-markdown h5,.ai-markdown h6{font-size:.75rem}.ai-markdown ul,.ai-markdown ol{margin:.45rem 0;padding-left:1rem}.ai-markdown ul{list-style:outside}.ai-markdown ol{list-style:decimal}.ai-markdown li{margin:.15rem 0}.ai-markdown code{background:var(--muted)}@supports (color:color-mix(in lab,red,red)){.ai-markdown code{background:color-mix(in oklab,var(--muted) 70%,transparent)}}.ai-markdown code{border:1px solid var(--border)}@supports (color:color-mix(in lab,red,red)){.ai-markdown code{border:1px solid color-mix(in oklab,var(--border) 45%,transparent)}}.ai-markdown code{border-radius:4px;padding:.05rem .3rem;font-size:.9em}.ai-markdown pre{background:var(--muted)}@supports (color:color-mix(in lab,red,red)){.ai-markdown pre{background:color-mix(in oklab,var(--muted) 55%,transparent)}}.ai-markdown pre{border:1px solid var(--border)}@supports (color:color-mix(in lab,red,red)){.ai-markdown pre{border:1px solid color-mix(in oklab,var(--border) 45%,transparent)}}.ai-markdown pre{border-radius:6px;margin:.5rem 0;padding:.6rem;overflow-x:auto}.ai-markdown pre code{background:0 0;border:0;padding:0}.ai-markdown blockquote{border-left:2px solid var(--primary)}@supports (color:color-mix(in lab,red,red)){.ai-markdown blockquote{border-left:2px solid color-mix(in oklab,var(--primary) 45%,transparent)}}.ai-markdown blockquote{color:var(--muted-foreground);padding-left:.6rem}.annotation-highlight{border-radius:2px;margin:0 -2px;padding:0 2px}.annotation-highlight.math-inline-annotation{align-items:center;line-height:1;display:inline-flex}.annotation-highlight.math-block-annotation{display:block}.annotation-highlight.deletion{background:oklch(from var(--destructive) l c h / .35);text-decoration:line-through;-webkit-text-decoration-color:var(--destructive);text-decoration-color:var(--destructive);text-decoration-thickness:2px}.annotation-highlight.comment{border-bottom:2px solid var(--accent);background:#e681004d;background:oklch(70% .18 60/.3)}.light .annotation-highlight.deletion{background:#f9414433}.light .annotation-highlight.comment{background:#e6810026;background:oklch(70% .2 60/.15)}.annotation-highlight.focused{box-shadow:0 0 8px oklch(from var(--focus-highlight) l c h / .4);border-bottom:2px solid var(--focus-highlight);filter:none;background:oklch(from var(--focus-highlight) l c h / .45)!important}.light .annotation-highlight.focused{box-shadow:0 0 6px #0092984d;box-shadow:0 0 6px oklch(60% .2 200/.3);background:#00b4bb4d!important;background:oklch(70% .22 200/.3)!important}.annotation-highlight:hover{filter:brightness(1.2);cursor:pointer}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0) scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1)) rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0) scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1)) rotate(var(--tw-exit-rotate,0))}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/packages/ui/tsconfig.strict-consumer.json b/packages/ui/tsconfig.strict-consumer.json new file mode 100644 index 000000000..c3166bf0a --- /dev/null +++ b/packages/ui/tsconfig.strict-consumer.json @@ -0,0 +1,42 @@ +{ + // Consumer-surface strict gate. External consumers (Workspaces) compile the + // shipped TS source with THEIR compiler options — typically strict: true — + // and skipLibCheck does not exempt .ts files. This config type-checks the + // supported-import surface (see HANDOFF.md "Supported imports") plus its + // transitive graph under full strict, mirroring a standalone Vite consumer. + // If this fails, a consumer's `tsc --noEmit` fails the same way. + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "files": [ + "configure.ts", + "types.ts", + "utils/parser.ts", + "components/BlockRenderer.tsx", + "components/InlineMarkdown.tsx", + "components/Viewer.tsx", + "components/MarkdownEditor.tsx", + "components/CommentPopover.tsx", + "components/AnnotationPanel.tsx", + "components/ThemeProvider.tsx", + "components/ImageThumbnail.tsx", + "components/AttachmentsButton.tsx", + "hooks/useAnnotationHighlighter.ts", + "hooks/useAnnotationDraft.ts", + "hooks/useCodeAnnotationDraft.ts", + "hooks/useExternalAnnotations.ts", + "hooks/useFileBrowser.ts", + "config/index.ts" + ] +} From 66d9dd14f62093c6788a3e8a3e5d9616565a0b65 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 6 Jul 2026 14:32:17 -0700 Subject: [PATCH 63/67] test(ui): make the lazy-resolution seam test deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test asserted lazy resolution on the module singleton and relied on its test file getting a fresh module graph — an isolation assumption that doesn't hold under all bun test orderings (CI failed with zero observed reads because another file had already resolved the store). Test the contract on a fresh instance instead: ConfigStore is exported as @internal ConfigStoreForTest, the spy backend is installed before construction, and the test asserts construction reads nothing while the first get() resolves and seeds through the live backend. Deterministic by construction. --- .../config/configStore.lazyInit.seam.test.ts | 52 ++++++++++++------- packages/ui/config/configStore.ts | 5 ++ 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/ui/config/configStore.lazyInit.seam.test.ts b/packages/ui/config/configStore.lazyInit.seam.test.ts index 45abe09f1..5b5aa90ed 100644 --- a/packages/ui/config/configStore.lazyInit.seam.test.ts +++ b/packages/ui/config/configStore.lazyInit.seam.test.ts @@ -1,21 +1,23 @@ /** - * Lazy-resolution contract for the configStore singleton. + * Lazy-resolution contract for ConfigStore. * - * Bun evaluates a fresh module graph per test file, so the store imported here - * has never been touched: this file can observe first-use behavior. The - * contract under test: importing the module resolves NOTHING — a host - * StorageBackend installed before the first settings access receives the - * initial resolution reads and the default-seeding writes. A configured host - * therefore never gets plannotator-* cookies written to its origin. + * Tested on a FRESH instance (ConfigStoreForTest) rather than the module + * singleton: whether the singleton has already resolved depends on which test + * files ran first in the process, which made a singleton-based version of this + * test flaky in CI. The contract under test is the mechanism itself: + * construction resolves NOTHING — a host StorageBackend installed before the + * first settings access receives the initial resolution reads and the + * default-seeding writes. A configured host therefore never gets + * plannotator-* cookies written to its origin. */ -import { describe, test, expect, afterAll } from 'bun:test'; +import { describe, test, expect, afterAll, beforeEach } from 'bun:test'; import { setStorageBackend, resetStorageBackend, type StorageBackend } from '../utils/storage'; -import { configStore } from './configStore'; +import { ConfigStoreForTest } from './configStore'; describe('configStore lazy resolution', () => { const stored = new Map(); - const reads: string[] = []; - const writes: string[] = []; + let reads: string[] = []; + let writes: string[] = []; const hostBackend: StorageBackend = { getItem(key) { reads.push(key); @@ -30,17 +32,25 @@ describe('configStore lazy resolution', () => { }, }; + beforeEach(() => { + stored.clear(); + reads = []; + writes = []; + }); + afterAll(() => { resetStorageBackend(); }); - test('backend installed before first access gets the initial resolution and seeding writes', () => { - // Install the host backend BEFORE anything reads a setting. If the store - // had resolved eagerly at module import, none of this traffic would reach - // the host backend (and the seeding writes would have gone to cookies). + test('construction resolves nothing; first access resolves through the live backend', () => { + // Install the host backend, THEN construct. If the constructor resolved + // eagerly, the reads would happen here; the lazy contract says none do. setStorageBackend(hostBackend); + const store = new ConfigStoreForTest(); + expect(reads.length).toBe(0); + expect(writes.length).toBe(0); - const identity = configStore.get('displayName'); + const identity = store.get('displayName'); expect(identity.length).toBeGreaterThan(0); // Resolution happened lazily, through the live (host) backend: @@ -52,10 +62,12 @@ describe('configStore lazy resolution', () => { }); test('resolution runs once; later reads are served from memory', () => { - const readsBefore = reads.length; - const first = configStore.get('displayName'); - const second = configStore.get('displayName'); + setStorageBackend(hostBackend); + const store = new ConfigStoreForTest(); + const first = store.get('displayName'); + const readsAfterLoad = reads.length; + const second = store.get('displayName'); expect(second).toBe(first); - expect(reads.length).toBe(readsBefore); + expect(reads.length).toBe(readsAfterLoad); }); }); diff --git a/packages/ui/config/configStore.ts b/packages/ui/config/configStore.ts index 0dc020193..b1f523deb 100644 --- a/packages/ui/config/configStore.ts +++ b/packages/ui/config/configStore.ts @@ -207,3 +207,8 @@ class ConfigStore { export const configStore = new ConfigStore(); export type { SettingValue }; + +/** @internal Exported for tests only — lets the lazy-resolution contract be + verified on a fresh instance without depending on module-graph isolation + (the singleton may already be resolved by the time a given test file runs). */ +export { ConfigStore as ConfigStoreForTest }; From 0594489598a1bd5f39b09df981376ae092efd992 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 6 Jul 2026 20:03:36 -0700 Subject: [PATCH 64/67] test(ui): poll for the debounced reconnect refetch instead of a fixed sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconnect-refresh assertion waited a fixed 150ms against the SSE watcher's 120ms debounce — a 30ms margin that slower CI runners lose, flaking 'refreshes after an SSE ready event from reconnect'. The watched logic is unchanged (verified byte-identical to main's inline version — the seam only relocated it into the default watchTrees and added the onChange indirection). Poll for calls.length===2 up to 1s so the pass/fail is hardware-independent. --- packages/ui/hooks/useFileBrowser.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/hooks/useFileBrowser.test.tsx b/packages/ui/hooks/useFileBrowser.test.tsx index 22d0c380b..e1650c259 100644 --- a/packages/ui/hooks/useFileBrowser.test.tsx +++ b/packages/ui/hooks/useFileBrowser.test.tsx @@ -294,7 +294,9 @@ describe("useFileBrowser", () => { expect(session.result.current!.dirs[0]?.tree).toEqual(initialTree); source!.emit({ type: "ready", dirPath }); - await tick(150); + // The reconnect refetch is debounced (120ms). Poll for it rather than + // sleeping a fixed margin, so a slow CI runner can't lose the race. + for (let i = 0; i < 40 && calls.length < 2; i++) await tick(25); expect(calls).toHaveLength(2); expect(session.result.current!.dirs[0]?.tree).toEqual(reconnectedTree); From c4e27a26f32d604aa1518e8c91463e752d0b28b8 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 6 Jul 2026 20:11:32 -0700 Subject: [PATCH 65/67] test(ui): poll the committed tree state, not the fetch call count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior fix polled calls.length===2, but the fetch call is counted one tick before its result commits to React state — so the poll exited early and the next assertion (dirs[0].tree === reconnectedTree) lost the race on slow CI (toEqual failure). Poll on the committed tree itself, which is exactly what the assertion checks: now the only way to fail is a genuine no-refresh, not a timing margin. --- packages/ui/hooks/useFileBrowser.test.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ui/hooks/useFileBrowser.test.tsx b/packages/ui/hooks/useFileBrowser.test.tsx index e1650c259..f30209209 100644 --- a/packages/ui/hooks/useFileBrowser.test.tsx +++ b/packages/ui/hooks/useFileBrowser.test.tsx @@ -294,9 +294,14 @@ describe("useFileBrowser", () => { expect(session.result.current!.dirs[0]?.tree).toEqual(initialTree); source!.emit({ type: "ready", dirPath }); - // The reconnect refetch is debounced (120ms). Poll for it rather than - // sleeping a fixed margin, so a slow CI runner can't lose the race. - for (let i = 0; i < 40 && calls.length < 2; i++) await tick(25); + // The reconnect refetch is debounced (120ms) AND its result commits to + // state a tick after the fetch call is counted. Poll on the end state (the + // committed tree), not the call count, so a slow CI runner can't lose either + // race — waiting on calls===2 alone exits before setState commits. + const reconnected = () => + JSON.stringify(session.result.current!.dirs[0]?.tree) === + JSON.stringify(reconnectedTree); + for (let i = 0; i < 60 && !reconnected(); i++) await tick(25); expect(calls).toHaveLength(2); expect(session.result.current!.dirs[0]?.tree).toEqual(reconnectedTree); From 2af58452ffbdc52c8314613173ddc5c0cc60e16c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 6 Jul 2026 20:21:07 -0700 Subject: [PATCH 66/67] test(ui): give the reconnect-refetch poll a 10s ceiling + 20s test timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CI runner was measured at 6x normal speed (1676ms for a ~275ms test), blowing through the 1.5s poll ceiling before the 120ms debounce fired — same commit passed on a faster runner. Raise the poll to ~10s and set an explicit 20s test timeout (bun's 5s default would otherwise kill the poll). Root cause is load, not logic: this timing-sensitive test only started flaking when the CI DOM step was broadened to run the whole ui suite in one process. --- packages/ui/hooks/useFileBrowser.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ui/hooks/useFileBrowser.test.tsx b/packages/ui/hooks/useFileBrowser.test.tsx index f30209209..049d71d9c 100644 --- a/packages/ui/hooks/useFileBrowser.test.tsx +++ b/packages/ui/hooks/useFileBrowser.test.tsx @@ -301,12 +301,15 @@ describe("useFileBrowser", () => { const reconnected = () => JSON.stringify(session.result.current!.dirs[0]?.tree) === JSON.stringify(reconnectedTree); - for (let i = 0; i < 60 && !reconnected(); i++) await tick(25); + // Poll up to ~10s: a contended CI runner was measured at 6x normal speed, + // blowing through a 1.5s ceiling on the 120ms debounce. The explicit test + // timeout below keeps the poll from being killed at bun's 5s default. + for (let i = 0; i < 400 && !reconnected(); i++) await tick(25); expect(calls).toHaveLength(2); expect(session.result.current!.dirs[0]?.tree).toEqual(reconnectedTree); await session.unmount(); - }); + }, 20000); test.skipIf(!hasDom)("waits for the first tree snapshot before opening the live stream", async () => { installMockEventSource(); From 7ce6ad631745c17132fc2029b30eb3301a60d699 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 6 Jul 2026 20:32:19 -0700 Subject: [PATCH 67/67] ci: run the file-browser DOM test isolated; scope the DOM step to DOM files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-causes the intermittent 'refreshes after an SSE ready event from reconnect' failure. The round-2 change ran the ENTIRE ui suite under DOM_TESTS=1 to catch the seam contracts; that load intermittently starved the test's 120ms real-timer debounce so the reconnect refetch never fired (observed failing after a full 10s poll — not a margin issue). The hook logic is byte-identical to main, and main runs this test in its own process (green for months). Fix at the CI layer, not the test: run useFileBrowser.test.tsx isolated (matching main), and run the seam contracts + remaining DOM-gated tests as an explicitly-scoped light batch. The test file is reverted to main verbatim (today's timing-poll experiments dropped). Follow-up issue to file: the underlying re-subscription race the load exposed. --- .github/workflows/test.yml | 25 +++++++++++++++++++++-- packages/ui/hooks/useFileBrowser.test.tsx | 14 ++----------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 068e6a867..4e77cf5d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,8 +31,29 @@ jobs: - name: Run tests run: bun test - - name: Run UI DOM tests (incl. seam contracts) - run: DOM_TESTS=1 bun test packages/ui + # The file-browser watcher test is timing-sensitive (a 120ms SSE debounce + # asserted with real timers). Run it in its own process — matching the + # historical config — so it isn't destabilized by sharing a process with + # the rest of the ui suite (heavy load intermittently starves the debounce + # timer and the reconnect refetch is missed). See useFileBrowser.test.tsx. + - name: Run file-browser DOM test (isolated) + run: DOM_TESTS=1 bun test packages/ui/hooks/useFileBrowser.test.tsx + + # Seam contracts + the remaining DOM-gated tests. Scoped to the DOM files + # (not the whole ui suite) to keep this process light. + - name: Run UI seam-contract + DOM tests + run: >- + DOM_TESTS=1 bun test + packages/ui/markdownEditorFidelity.test.tsx + packages/ui/annotationDraftPersistence.test.tsx + packages/ui/codeAnnotationDraftPersistence.test.tsx + packages/ui/components/InlineMarkdown.seam.test.tsx + packages/ui/components/ImageThumbnail.seam.test.tsx + packages/ui/hooks/useAnnotationHighlighter.test.tsx + packages/ui/hooks/useAnnotationDraft.seam.test.tsx + packages/ui/hooks/useExternalAnnotations.seam.test.tsx + packages/ui/hooks/useAIChat.seam.test.tsx + packages/ui/hooks/useFileBrowser.seam.test.tsx pi-extension-ai-runtime-windows: # Exercises the Pi extension's Node/jiti server mirror on Windows with an diff --git a/packages/ui/hooks/useFileBrowser.test.tsx b/packages/ui/hooks/useFileBrowser.test.tsx index 049d71d9c..22d0c380b 100644 --- a/packages/ui/hooks/useFileBrowser.test.tsx +++ b/packages/ui/hooks/useFileBrowser.test.tsx @@ -294,22 +294,12 @@ describe("useFileBrowser", () => { expect(session.result.current!.dirs[0]?.tree).toEqual(initialTree); source!.emit({ type: "ready", dirPath }); - // The reconnect refetch is debounced (120ms) AND its result commits to - // state a tick after the fetch call is counted. Poll on the end state (the - // committed tree), not the call count, so a slow CI runner can't lose either - // race — waiting on calls===2 alone exits before setState commits. - const reconnected = () => - JSON.stringify(session.result.current!.dirs[0]?.tree) === - JSON.stringify(reconnectedTree); - // Poll up to ~10s: a contended CI runner was measured at 6x normal speed, - // blowing through a 1.5s ceiling on the 120ms debounce. The explicit test - // timeout below keeps the poll from being killed at bun's 5s default. - for (let i = 0; i < 400 && !reconnected(); i++) await tick(25); + await tick(150); expect(calls).toHaveLength(2); expect(session.result.current!.dirs[0]?.tree).toEqual(reconnectedTree); await session.unmount(); - }, 20000); + }); test.skipIf(!hasDom)("waits for the first tree snapshot before opening the live stream", async () => { installMockEventSource();