diff --git a/apps/hook/vite.config.ts b/apps/hook/vite.config.ts index 5577f88e..f9bcbb2e 100644 --- a/apps/hook/vite.config.ts +++ b/apps/hook/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, '.'), + '@plannotator/shared': path.resolve(__dirname, '../../packages/shared'), '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), '@plannotator/editor/styles': path.resolve(__dirname, '../../packages/editor/index.css'), '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/App.tsx'), diff --git a/apps/review/vite.config.ts b/apps/review/vite.config.ts index 72bedcb6..24d90a6b 100644 --- a/apps/review/vite.config.ts +++ b/apps/review/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, '.'), + '@plannotator/shared': path.resolve(__dirname, '../../packages/shared'), '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), '@plannotator/review-editor/styles': path.resolve(__dirname, '../../packages/review-editor/index.css'), '@plannotator/review-editor': path.resolve(__dirname, '../../packages/review-editor/App.tsx'), diff --git a/bun.lock b/bun.lock index 633e9ebd..b9bbd137 100644 --- a/bun.lock +++ b/bun.lock @@ -199,6 +199,7 @@ "dependencies": { "@plannotator/shared": "workspace:*", "@plannotator/web-highlighter": "^0.8.1", + "@radix-ui/react-tooltip": "^1.2.8", "@viz-js/viz": "^3.25.0", "diff": "^8.0.3", "highlight.js": "^11.11.1", @@ -465,6 +466,14 @@ "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@google/genai": ["@google/genai@1.42.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw=="], "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], @@ -683,6 +692,48 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], diff --git a/docs/adversarial_rubric.md b/docs/adversarial_rubric.md new file mode 100644 index 00000000..1dd4fd6a --- /dev/null +++ b/docs/adversarial_rubric.md @@ -0,0 +1,61 @@ +# Adversarial Rubric + +Last Updated: 2026-04-17 + +This rubric captures the main adversarial and drift vectors for Plannotator's review and annotation surfaces. It is intended for milestone reviews, especially for UI state changes that can unintentionally cross plan, annotate, archive, and review modes. + +## Data Boundaries + +| Boundary | Format | Validation | Failure Mode | +| --- | --- | --- | --- | +| `/api/plan`, `/api/feedback`, `/api/draft`, `/api/upload` between browser and Bun server | JSON, multipart form data, markdown text | Per-endpoint parsing in `packages/server/index.ts`, `packages/server/annotate.ts`, `packages/server/shared-handlers.ts` | Invalid payloads can silently fall back to demo/empty state or reject late in the flow | +| Linked-doc file resolution via `/api/doc` and Obsidian doc endpoints | Relative/absolute markdown paths | `packages/server/reference-handlers.ts`, `packages/shared/resolve-file.ts` normalize and constrain paths | Path confusion can open the wrong file or expose unintended content if guards drift | +| Share/import URLs and paste payloads | URL hash, compressed JSON, encrypted blobs | `packages/ui/utils/sharing.ts` parses, decompresses, decrypts, and reconstructs annotations | Malformed share payloads can break annotation restore or produce partial state | +| External annotations stream and snapshot APIs | SSE + JSON annotations | `packages/server/external-annotations.ts`, shared annotation types in `packages/shared/external-annotation.ts` | Unsanitized/invalid annotation payloads can corrupt UI state or highlight bookkeeping | +| Cookie-backed UI preferences | Strings in `document.cookie` | `packages/ui/utils/storage.ts`, `packages/ui/utils/uiPreferences.ts`, `packages/ui/config/settings.ts` coerce to enums/bools | Invalid cookie values can create inconsistent mode/layout defaults across sessions | + +## Type Coercion Vectors + +| Coercion | Location | Risk | Test Exists? | +| --- | --- | --- | --- | +| Cookie string → boolean / enum | `packages/ui/utils/uiPreferences.ts`, `packages/ui/config/settings.ts` | Invalid values can silently select unsafe defaults or inconsistent layout state | Partial | +| URL hash / paste payload → structured annotations | `packages/ui/utils/sharing.ts` | Malformed arrays or unexpected tuple shapes can restore incomplete/shifted annotations | Partial | +| Query/path input → resolved markdown path | `packages/shared/resolve-file.ts` | Separator normalization and basename fallback can drift from intended trust boundary | Yes | +| External annotation JSON → internal annotation model | `packages/shared/external-annotation.ts` | Missing/extra fields can degrade rendering or selection restoration | Partial | +| Resize/cap values → persisted panel widths | `packages/ui/hooks/useResizablePanel.ts` | Invalid saved widths can distort layout or hide controls | No | + +## Trust Assumptions + +| Assumption | What Breaks | Severity | Test Exists? | +| --- | --- | --- | --- | +| Annotate-only UI changes will not leak into plan/review/archive modes | Hidden controls or layout regressions in other surfaces | HIGH | No | +| Session-scoped UI modes restore the user’s prior layout exactly | Users lose sidebar/panel context or hidden state drifts | HIGH | No | +| Shared workspace aliases stay aligned across app Vite configs | Local builds fail even though workspace packages compile | MEDIUM | No | +| Linked-doc navigation only needs the sidebar capabilities it declares | Runtime mismatches if hook expectations drift | MEDIUM | No | +| Cookie defaults are benign when malformed or missing | Surprising startup state, especially around sidebar and panel behavior | LOW | Partial | + +## Cascade Risks + +| Cascade Point | Blast Radius | Isolation | Test Exists? | +| --- | --- | --- | --- | +| Viewer/layout mode toggles in `packages/editor/App.tsx` | Can affect annotate, plan, linked-doc, archive, and sticky-header behavior at once | Manual branching by `annotateMode`, `archiveMode`, `isPlanDiffActive` | No | +| Sticky header lane width calculations | Reader chrome can diverge from document width and overlay controls incorrectly | Separate `StickyHeaderLane` component with measured widths | No | +| Linked-doc state swap and cached annotations | Annotation state can leak between source doc and linked doc | `useLinkedDoc` caches/restores per file | No | +| External annotation highlight replay | DOM highlights can desync when switching linked docs or diff mode | `useExternalAnnotationHighlights` and explicit reset hooks | Partial | + +## Registry Drift Risks + +| Registry | Code Location | Drift Detection | Last Verified | +| --- | --- | --- | --- | +| Hook/review app workspace aliases | `apps/hook/vite.config.ts`, `apps/review/vite.config.ts` | Manual build of both apps | 2026-04-17 | +| Public API endpoint docs vs runtime endpoints | `AGENTS.md`, marketing docs, `packages/server/*.ts` | Manual review + endpoint additions in PR review | 2026-04-17 | +| Shared package exports vs app imports | `packages/shared/package.json` and app/package imports | Typecheck/build | 2026-04-17 | +| UI preference keys vs Settings UI | `packages/ui/utils/uiPreferences.ts`, `packages/ui/components/Settings.tsx` | Manual review | 2026-04-17 | + +## Learned Vectors + +| Vector | Source Milestone | Category | Recurrence | +| --- | --- | --- | --- | +| Session-scoped layout modes can mutate hidden panel state unless every reopen path exits the mode first | `feat/annotate-wide-mode` | Cascade / Trust Assumption | Likely | +| Annotate-only controls must be explicitly gated to avoid leaking into plan/review surfaces through shared components | `feat/annotate-wide-mode` | Trust Assumption | Likely | +| Build-time alias drift can look like a feature regression even when the code change is correct | `feat/annotate-wide-mode` | Registry Drift | Likely | diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index fdead657..f20c3dba 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -8,6 +8,7 @@ import { ImportModal } from '@plannotator/ui/components/ImportModal'; import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; import { Annotation, Block, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '@plannotator/ui/types'; import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; +import { Tooltip, TooltipProvider } from '@plannotator/ui/components/Tooltip'; import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolstrip'; import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane'; import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning'; @@ -38,6 +39,7 @@ import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; import { ScrollViewportContext } from '@plannotator/ui/hooks/useScrollViewport'; import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport'; +import { useIsMobile } from '@plannotator/ui/hooks/useIsMobile'; import { PlanHeaderMenu } from '@plannotator/ui/components/PlanHeaderMenu'; import { getPermissionModeSettings, @@ -47,7 +49,7 @@ import { import { PermissionModeSetup } from '@plannotator/ui/components/PermissionModeSetup'; import { ImageAnnotator } from '@plannotator/ui/components/ImageAnnotator'; import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton'; -import { useSidebar } from '@plannotator/ui/hooks/useSidebar'; +import { useSidebar, type SidebarTab } from '@plannotator/ui/hooks/useSidebar'; import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff'; import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc'; import { useAnnotationDraft } from '@plannotator/ui/hooks/useAnnotationDraft'; @@ -71,6 +73,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff // 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'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -160,6 +163,9 @@ const App: React.FC = () => { const [pasteApiUrl, setPasteApiUrl] = useState(undefined); const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null); const [projectRoot, setProjectRoot] = useState(null); + const [wideModeType, setWideModeType] = useState(null); + const wideModeSnapshotRef = useRef(null); + const lastAppliedTocEnabledRef = useRef(uiPrefs.tocEnabled); useEffect(() => { document.title = repoInfo ? `${repoInfo.display} · Plannotator` : "Plannotator"; @@ -171,6 +177,7 @@ const App: React.FC = () => { const [planDiffMode, setPlanDiffMode] = useState('clean'); const [previousPlan, setPreviousPlan] = useState(null); const [versionInfo, setVersionInfo] = useState(null); + const isMobile = useIsMobile(); const viewerRef = useRef(null); // containerRef + scrollViewport both point at the OverlayScrollbars @@ -196,14 +203,67 @@ const App: React.FC = () => { // Sidebar (shared TOC + Version Browser) const sidebar = useSidebar(getUIPreferences().tocEnabled); - // Sync sidebar open state when preference changes in Settings - useEffect(() => { - if (uiPrefs.tocEnabled) { - sidebar.open('toc'); + const exitWideMode = useCallback((options?: { + restore?: boolean; + sidebarTab?: SidebarTab; + panelOpen?: boolean; + }) => { + if (wideModeType === null) { + if (options?.sidebarTab) sidebar.open(options.sidebarTab); + if (options?.panelOpen === true) setIsPanelOpen(true); + else if (options?.panelOpen === false) setIsPanelOpen(false); + return; + } + + const snapshot = wideModeSnapshotRef.current; + const layout = resolveWideModeExitLayout(snapshot, options); + + setWideModeType(null); + wideModeSnapshotRef.current = null; + + if (layout.sidebarOpen && layout.sidebarTab) { + sidebar.open(layout.sidebarTab); } else { sidebar.close(); } - }, [uiPrefs.tocEnabled]); + + if (layout.panelOpen !== undefined) { + setIsPanelOpen(layout.panelOpen); + } + }, [wideModeType, sidebar.close, sidebar.open]); + + const openSidebarTab = useCallback((tab: SidebarTab) => { + if (wideModeType !== null) { + exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false }); + return; + } + sidebar.open(tab); + }, [exitWideMode, wideModeType, sidebar.open]); + + const toggleSidebarTab = useCallback((tab: SidebarTab) => { + if (wideModeType !== null) { + exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false }); + return; + } + sidebar.toggleTab(tab); + }, [exitWideMode, wideModeType, sidebar.toggleTab]); + + const handleAnnotationPanelToggle = useCallback(() => { + if (wideModeType !== null) { + exitWideMode({ restore: false, panelOpen: true }); + return; + } + setIsPanelOpen(prev => !prev); + }, [exitWideMode, wideModeType]); + + // Sync sidebar open state when preference changes in Settings + useEffect(() => { + if (wideModeType !== null) return; + if (lastAppliedTocEnabledRef.current === uiPrefs.tocEnabled) return; + lastAppliedTocEnabledRef.current = uiPrefs.tocEnabled; + if (uiPrefs.tocEnabled) sidebar.open('toc'); + else sidebar.close(); + }, [wideModeType, sidebar.close, sidebar.open, uiPrefs.tocEnabled]); // Clear diff view when switching away from versions tab useEffect(() => { @@ -227,11 +287,23 @@ const App: React.FC = () => { // Plan diff computation const planDiff = usePlanDiff(markdown, previousPlan, versionInfo); + const linkedDocSidebar = useMemo(() => ({ + ...sidebar, + open: openSidebarTab, + toggleTab: toggleSidebarTab, + }), [ + openSidebarTab, + sidebar.activeTab, + sidebar.close, + sidebar.isOpen, + toggleSidebarTab, + ]); + // Linked document navigation const linkedDocHook = useLinkedDoc({ markdown, annotations, selectedAnnotationId, globalAttachments, setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments, - viewerRef, sidebar, sourceFilePath, + viewerRef, sidebar: linkedDocSidebar, sourceFilePath, }); // Archive browser @@ -240,6 +312,39 @@ const App: React.FC = () => { setMarkdown, setAnnotations, setSelectedAnnotationId, setSubmitted, }); + const canUseWideMode = useMemo(() => canUseAnnotateWideMode({ + archiveMode: archive.archiveMode, + isPlanDiffActive, + }), [archive.archiveMode, isPlanDiffActive]); + + const enterViewMode = useCallback((type: WideModeType) => { + if (!canUseWideMode) return; + if (wideModeType === null) { + wideModeSnapshotRef.current = { + sidebarIsOpen: sidebar.isOpen, + sidebarTab: sidebar.activeTab, + panelOpen: isPanelOpen, + }; + } + setWideModeType(type); + sidebar.close(); + setIsPanelOpen(false); + }, [canUseWideMode, isPanelOpen, wideModeType, sidebar.activeTab, sidebar.close, sidebar.isOpen]); + + const toggleViewMode = useCallback((type: WideModeType) => { + if (wideModeType === type) { + exitWideMode(); + } else { + enterViewMode(type); + } + }, [enterViewMode, exitWideMode, wideModeType]); + + useEffect(() => { + if (!canUseWideMode && wideModeType !== null) { + exitWideMode(); + } + }, [canUseWideMode, exitWideMode, wideModeType]); + // Markdown file browser (also handles vault dirs via isVault flag) const fileBrowser = useFileBrowser(); const vaultPath = useMemo(() => { @@ -378,7 +483,7 @@ const App: React.FC = () => { if (filePaths.size === 0) return; // Open sidebar to the files tab so the flash is visible if (!sidebar.isOpen || sidebar.activeTab !== 'files') { - sidebar.open('files'); + openSidebarTab('files'); } // Cancel any pending clear from a previous flash if (flashTimerRef.current) clearTimeout(flashTimerRef.current); @@ -388,7 +493,7 @@ const App: React.FC = () => { setHighlightedFiles(filePaths); flashTimerRef.current = setTimeout(() => setHighlightedFiles(undefined), 1200); }); - }, [allAnnotationCounts, sidebar, hasFileAnnotations]); + }, [allAnnotationCounts, openSidebarTab, sidebar, hasFileAnnotations]); // Context-aware back label for linked doc navigation const backLabel = annotateSource === 'folder' ? 'file list' @@ -963,14 +1068,16 @@ const App: React.FC = () => { const handleAddAnnotation = (ann: Annotation) => { setAnnotations(prev => [...prev, ann]); setSelectedAnnotationId(ann.id); - setIsPanelOpen(true); + if (wideModeType === null) { + setIsPanelOpen(true); + } }; - // Stable reference — the Viewer's highlighter useEffect depends on this + // Keep selection behavior explicit across mobile/wide-mode transitions. const handleSelectAnnotation = React.useCallback((id: string | null) => { setSelectedAnnotationId(id); - if (id && window.innerWidth < 768) setIsPanelOpen(true); - }, []); + if (id && isMobile && wideModeType === null) setIsPanelOpen(true); + }, [isMobile, wideModeType]); // Core annotation removal — highlight cleanup + state filter + selection clear const removeAnnotation = (id: string) => { @@ -1250,10 +1357,12 @@ const App: React.FC = () => { const widths: Record = { compact: 832, default: 1040, wide: 1280 }; return widths[uiPrefs.planWidth] ?? 832; }, [uiPrefs.planWidth]); + const annotateReaderMaxWidth = canUseWideMode && wideModeType === 'wide' ? null : planMaxWidth; return ( +
{/* Minimal Header */}
@@ -1387,7 +1496,7 @@ const App: React.FC = () => { {/* Annotations panel toggle — top-level header button */} + + + ))} +
+ + )} { onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)} hasPreviousVersion={!linkedDocHook.isActive && planDiff.hasPreviousVersion} showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession} - maxWidth={planMaxWidth} + maxWidth={annotateReaderMaxWidth} onOpenLinkedDoc={handleOpenLinkedDoc} linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath)?.isVault ? 'Vault File' : fileBrowser.activeFile ? 'File' : undefined, backLabel } : null} imageBaseDir={imageBaseDir} @@ -1648,11 +1790,11 @@ const App: React.FC = () => { {/* Resize Handle */} - {isPanelOpen && } + {isPanelOpen && wideModeType === null && } {/* Annotation Panel */} { }} /> +
); }; diff --git a/packages/editor/wideMode.test.ts b/packages/editor/wideMode.test.ts new file mode 100644 index 00000000..4e8a56bc --- /dev/null +++ b/packages/editor/wideMode.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test'; +import { + canUseAnnotateWideMode, + resolveWideModeExitLayout, + type WideModeLayoutSnapshot, +} from './wideMode'; + +const snapshot: WideModeLayoutSnapshot = { + sidebarIsOpen: true, + sidebarTab: 'files', + panelOpen: true, +}; + +describe('canUseAnnotateWideMode', () => { + test('enables wide mode outside archive and diff', () => { + expect(canUseAnnotateWideMode({ + archiveMode: false, + isPlanDiffActive: false, + })).toBe(true); + + expect(canUseAnnotateWideMode({ + archiveMode: true, + isPlanDiffActive: false, + })).toBe(false); + + expect(canUseAnnotateWideMode({ + archiveMode: false, + isPlanDiffActive: true, + })).toBe(false); + + expect(canUseAnnotateWideMode({ + archiveMode: true, + isPlanDiffActive: true, + })).toBe(false); + }); +}); + +describe('resolveWideModeExitLayout', () => { + test('restores the saved sidebar tab and panel by default', () => { + expect(resolveWideModeExitLayout(snapshot)).toEqual({ + sidebarOpen: true, + sidebarTab: 'files', + panelOpen: true, + }); + }); + + test('opens an explicit sidebar target and can keep the panel closed', () => { + expect(resolveWideModeExitLayout(snapshot, { + restore: false, + sidebarTab: 'toc', + panelOpen: false, + })).toEqual({ + sidebarOpen: true, + sidebarTab: 'toc', + panelOpen: false, + }); + }); + + test('honors an explicit panel reopen without restoring the sidebar snapshot', () => { + expect(resolveWideModeExitLayout(snapshot, { + restore: false, + panelOpen: true, + })).toEqual({ + sidebarOpen: false, + sidebarTab: null, + panelOpen: true, + }); + }); + + test('keeps the panel closed when leaving wide mode without restore', () => { + expect(resolveWideModeExitLayout(snapshot, { + restore: false, + })).toEqual({ + sidebarOpen: false, + sidebarTab: null, + panelOpen: undefined, + }); + }); + + test('falls back to a closed layout when the snapshot is missing', () => { + expect(resolveWideModeExitLayout(null)).toEqual({ + sidebarOpen: false, + sidebarTab: null, + panelOpen: false, + }); + }); +}); diff --git a/packages/editor/wideMode.ts b/packages/editor/wideMode.ts new file mode 100644 index 00000000..6c3d7402 --- /dev/null +++ b/packages/editor/wideMode.ts @@ -0,0 +1,48 @@ +import type { SidebarTab } from '@plannotator/ui/hooks/useSidebar'; +export type { WideModeType } from '@plannotator/ui/types'; + +export type WideModeLayoutSnapshot = { + sidebarIsOpen: boolean; + sidebarTab: SidebarTab; + panelOpen: boolean; +}; + +export type WideModeExitOptions = { + restore?: boolean; + sidebarTab?: SidebarTab; + panelOpen?: boolean; +}; + +export type WideModeExitLayout = { + sidebarOpen: boolean; + sidebarTab: SidebarTab | null; + panelOpen?: boolean; +}; + +export function canUseAnnotateWideMode(options: { + archiveMode: boolean; + isPlanDiffActive: boolean; +}): boolean { + return !options.archiveMode && !options.isPlanDiffActive; +} + +export function resolveWideModeExitLayout( + snapshot: WideModeLayoutSnapshot | null, + options?: WideModeExitOptions, +): WideModeExitLayout { + const restore = options?.restore !== false; + + if (options?.sidebarTab) { + return { + sidebarOpen: true, + sidebarTab: options.sidebarTab, + panelOpen: options.panelOpen, + }; + } + + return { + sidebarOpen: restore ? (snapshot?.sidebarIsOpen ?? false) : false, + sidebarTab: restore && snapshot?.sidebarIsOpen ? snapshot.sidebarTab : null, + panelOpen: options?.panelOpen ?? (restore ? (snapshot?.panelOpen ?? false) : undefined), + }; +} diff --git a/packages/ui/components/StickyHeaderLane.tsx b/packages/ui/components/StickyHeaderLane.tsx index 3c9216d9..2088af69 100644 --- a/packages/ui/components/StickyHeaderLane.tsx +++ b/packages/ui/components/StickyHeaderLane.tsx @@ -72,7 +72,7 @@ interface StickyHeaderLaneProps { archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null; // Layout - maxWidth?: number; + maxWidth?: number | null; // Re-query token for the [data-sticky-actions] ResizeObserver. When the // Viewer remounts (e.g., toggling a linked doc), its `data-sticky-actions` @@ -196,7 +196,7 @@ export const StickyHeaderLane: React.FC = ({ className={`sticky z-[60] w-full self-center pointer-events-none ${ isNarrow ? 'top-[52px] md:top-[60px]' : 'top-3' }`} - style={{ maxWidth, height: 0 }} + style={maxWidth == null ? { height: 0 } : { maxWidth, height: 0 }} > {/* Responsive bar. diff --git a/packages/ui/components/Tooltip.tsx b/packages/ui/components/Tooltip.tsx new file mode 100644 index 00000000..782fbf2b --- /dev/null +++ b/packages/ui/components/Tooltip.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import * as RadixTooltip from '@radix-ui/react-tooltip'; + +export const TooltipProvider = RadixTooltip.Provider; + +interface TooltipProps { + content: React.ReactNode; + children: React.ReactNode; + side?: 'top' | 'right' | 'bottom' | 'left'; + align?: 'start' | 'center' | 'end'; + delayDuration?: number; + sideOffset?: number; +} + +export const Tooltip: React.FC = ({ + content, + children, + side = 'top', + align = 'center', + delayDuration, + sideOffset = 8, +}) => ( + + {children} + + + {content} + + + +); diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index ecbb8a4f..2e12dc04 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -66,8 +66,8 @@ interface ViewerProps { hasPreviousVersion?: boolean; /** Show amber "Demo" badge (portal mode, no shared content loaded) */ showDemoBadge?: boolean; - /** Max width in px for the plan card (from plan width setting) */ - maxWidth?: number; + /** Max width in px for the plan card; null removes the cap entirely. */ + maxWidth?: number | null; /** Label for the copy button (default: "Copy plan") */ copyLabel?: string; /** @@ -443,7 +443,7 @@ export const Viewer = forwardRef(({ }, []); return ( -
+
{taterMode && }