Commit Graph

309 Commits

Author SHA1 Message Date
Michael Ramos 4465950f0c feat: add bounded annotation undo and redo (#1426)
* feat: add bounded undo and redo history

* fix: address undo redo review feedback

* chore: refresh guide viewer manifest

* fix undo history review regressions

* chore: refresh guide viewer manifest
2026-08-31 09:12:13 -07:00
Michael Ramos c2950e709f fix: pre-release QA findings for 0.27.9 (#1405)
Fixes from the 0.27.9 pre-release review. Servers: an unreadable rendered-HTML root falls back to the startup snapshot on both runtimes with a once-per-process warning instead of hanging (Pi) or answering 500 (Bun); the version diff is recomputed against current bytes on reload and carried through the in-app Refresh instead of being dropped, with no history write on a GET. Client: a Refresh action on the compact touch shell; HtmlSurfaceControls renders Refresh independently of the eye; the dead HtmlSurfaceActions removed. Threading: one linear, cycle-safe reply resolution shared by the annotations panel, its sort, and the export (5,000-chain tests), PATCH ingest on both runtimes rejects self-references and cycles, nothing is ever dropped from feedback. WebMCP and viewer hygiene: bounded tombstone and request memories, per-instance minted ids, nudge id caps, waiter cleanup on unmount, a shared retry epoch for diagram blocks. Docs: HTML Refresh documented, the WebMCP design pointer fixed, marketing pages updated.

AI-assisted (Claude) under maintainer direction.
2026-08-27 15:23:28 -07:00
Michael Ramos 8e0c51f5ff fix(ui): 0.33.0 adoption feedback and bump @plannotator/ui to 0.34.0 (#1402)
Follow-ups from the Workspaces adoption of 0.33.0: a math-slot module hosts can redirect Mermaid's own katex import to (importer-scoped resolveId recipe in HANDOFF) so a math document fetches one KaTeX chunk owned by the host; HtmlViewer bridgeErrorDisplay ('banner' default, 'none' lets a host own the failure banner while onBridgeUnavailable still fires); the documented bridge alias narrowed to relative sibling imports; resetMathRenderer keeps a registered loader and discards stale in-flight loads via an epoch, with setMathRendererLoader(null) and getMathRendererLoader added. Plannotator's own bundles unchanged (markers and sizes within noise of main). Bumps @plannotator/ui to 0.34.0; core stays 0.25.0.

AI-assisted (Claude) under maintainer direction.
2026-08-27 12:08:00 -07:00
Michael Ramos 7d6dd29c08 perf(ui): load the HTML viewer bridge by URL for hosts, with a protocol version and ready timeout (#1398)
Opt-in bridgeScriptUrl on HtmlViewer so multi-chunk hosts can serve the 185 KB bridge as a hashed asset instead of an inlined string; the inline bridge stays the default and Plannotator's own builds, the Pi and OpenCode copies, and the live-app proxy are unchanged apart from a protocolVersion field on the bridge's ready message. The parent checks the version (one warning naming both versions; on the URL path a dismissible banner plus onBridgeUnavailable while the old bridge keeps working), arms a ready timeout on the URL path only, and resolves the URL against the parent document before it reaches the frame so a page's own base href cannot redirect the load. A prepack-generated bridge-script.asset.js (byte-for-byte the inline string) and a bridge-script.lite.ts alias target ship in the tarball. CSP and CORP requirements for hosts are documented.

AI-assisted (Claude) under maintainer direction.
2026-08-27 08:45:22 -07:00
Michael Ramos 0b167cc478 perf(ui): lazy diagram and math renderers with eager entries for Plannotator (#1394)
Bundle-weight optimization of @plannotator/ui for multi-chunk hosts, requested by Workspaces: the Mermaid runtime and Graphviz engine load inside the render effect, the username dictionary sits behind a synchronous identity generator slot, and KaTeX sits behind a math renderer slot with a loader seam on configurePlannotatorUI. Plannotator's own apps import eager entries (math, identity, and Mermaid for the plan editor) so their behavior is unchanged: single-file builds within noise of main, math typeset on first paint, identities from the full dictionary, and the share portal keeps Mermaid in its entry chunk so its failure surface matches main. Built-HTML registration markers guard the eager imports. Hosts that omit the eager entries get the lazy paths, a one-shot automatic re-attempt, and a Retry affordance on the diagram error panel; the module-map limitation of in-page retries is documented.

AI-assisted (Claude) under maintainer direction.
2026-08-27 07:35:49 -07:00
Michael Ramos 44611e5300 feat(ui): publish the HTML annotation seams hosts were hand-rolling (#1395)
Parity seams for hosts of @plannotator/ui, requested by Workspaces after the HTML annotation handoff: projectHostThreads and buildPersistedHtmlAnchor in @plannotator/core; HtmlViewer onUnanchoredChange completed over the annotations prop with a restore-keyed report so hosts can drop their mark-applied listeners; published useHtmlRefresh with a fetchSnapshot adapter; published HtmlSurfaceControls (eye, refresh, pen) with label overrides; AnnotationPanel unanchoredIds chip (wired for Plannotator too); HtmlViewer maxAdditionalTargets and scrollBehavior carried on the bridge; shortcuts and utils/inputMethod blessed as consumer exports. Plannotator's behavior is unchanged apart from the new Unanchored chip, verified by a real-browser A/B including the orphan and re-anchor cycle and by a combined cross-surface verification with #1394.

AI-assisted (Claude) under maintainer direction.
2026-08-27 07:35:22 -07:00
Leonardo Reis 6407ef5d97 feat(annotate): manual refresh of rendered HTML from disk (#1232)
Local rendered-HTML annotate sessions get a Refresh action beside Hide tools: the document is re-fetched through /api/doc, the sandboxed viewer remounts, annotations are re-anchored and the ones that no longer match are reported while their comments are kept, and stale diff and share state is reset. Maintainer additions on top of the contributor's work: share-link invalidation no longer keys on the resolver's identity, /api/plan and /api/share-html serve a local root HTML file from its current bytes on both runtimes so a reload does not revert the page under the annotations, the Refresh button keeps keyboard focus via aria-disabled, and the tests were hardened. Verified end to end in a real browser.

Thanks @leoreisdias.

AI-assisted (Claude) under maintainer direction.
2026-08-26 14:53:28 -07:00
Michael Ramos 6903d7a3dd feat(webmcp): expose plan review and annotate as WebMCP tools for browser agents (#1393)
Phase 1 of WebMCP support: a zero-dependency, feature-detected engine in packages/ui/webmcp plus a read-and-comment tool catalog for plan review and annotate (read_document, add_comments, update_comment, remove_comments, reveal, nudge_user, list_documents). No decision tools; the human approves. Zero footprint in browsers without document.modelContext (DOM, network, console, timers, and cookies identical to main), idle until called where the API exists, and never registered inside the annotate iframes. Adds an optional inReplyTo field on annotations for threaded replies. Client-only; no server changes.

AI-assisted (Claude) under maintainer direction.
2026-08-26 14:39:37 -07:00
Michael Ramos 0ae40e73a4 feat(annotate): restore a restricted thumbs-up on comment-only HTML surfaces
The v0.27.5 comment-only ruling removed every label affordance from HTML
and live-app annotate surfaces, leaving no one-click positive feedback:
the only path was opening the composer and typing prose. Restore exactly
ONE affordance, the hardcoded 'Looks good' thumbs-up, on both input
routes:

- selection toolbar: commentOnly + a provided onQuickLabel now renders
  only the thumbs-up (no Delete, no Zap picker, Alt+digit suppressed);
  HtmlViewer passes a handler that filters by label id as defense in
  depth
- pinpoint: the composer gains an optional one-click 'Looks good'
  footer action (disabled once anything is typed, so it can never
  discard a draft), emitting the same isQuickLabel comment shape with
  the draft's multi-select targets

The trust-boundary clamp is untouched: redline/quickLabel modes stay
collapsed to selection, so a hostile page still cannot force a DELETION
or an arbitrary label. THUMBS_UP_LABEL moves to utils/quickLabels as
the canonical definition.
2026-08-24 09:27:43 -07:00
Michael Ramos aa816b231c feat(ui): add embed media picker seam (#1382) 2026-08-23 10:35:06 -07:00
Michael Ramos 67f47dbac1 fix(annotate): armed-mode interaction fixes from the v0.27.5 QA gate (#1363)
* fix(annotate): pre-release QA fixes for the armed-mode interaction seams

Six confirmed QA findings on the HTML/live annotate surface plus missing
pi-extension resync coverage:

1. Armed pinpoint drifted click (>4px, no selection) was swallowed AND
   leaked to the page: the always-on drag work armed the trailing-click
   suppression on drift alone. The mouseup arming site now requires the
   drag to have actually produced a text selection; drifted clicks pin
   normally and never reach the page. Bridge tests for armed drift,
   armed real drag, and Interact drift.
2. Esc ladder: hover-clear is no longer its own rung; clearing the
   pinpoint outline and posting annotate-exit happen on the same press
   when no draft is open. Draft-close keeps its own press.
3. Compact touch layouts no longer apply a restored toolsHidden:true
   chrome cookie (both header toggles are desktop-only, so applying it
   stranded the user); the cookie value is preserved for desktop.
4. The live-app probe now announces the static-conversion downgrade on
   stderr when a loopback probe fails, naming --app to force live mode.
5. Live-app export: page group headers are now '## Page:' with '### N.'
   entries nested below them; exports without pageUrl stay byte-identical.
6. Shift+1-4 mode shortcuts no longer fire while the annotation
   toolbar's type-to-comment listener owns printable keys, so typing
   ! @ # $ into a starting comment cannot silently switch modes.

Also adds the missing tests for the two resyncPhaseFromSession
executing->idle fallbacks that arm idleNoticePending (verified by
mutation: flipping either arm fails its test).

* fix(annotate): compact arm/disarm affordance, guarded shutdown, restored chrome guards

Follow-up scope from the forensics sweep, same surface:

- Compact touch layouts get Options-menu actions for the HTML/live
  surface: 'Annotate page'/'Interact with page' (the desktop pen and
  Mod+Shift+A were unreachable on touch, so every tap annotated with no
  way out) and 'Show tools'/'Hide tools' (the desktop eye). With the
  menu as the way back, the toolsHidden cookie now applies on compact
  again (desktop parity) instead of being ignored.
- The annotate servers' stop() now guards every disposal step
  individually (Bun: runGuardedShutdown, mirrored inline in Pi): a
  throwing agent-terminal teardown (#1314-class) no longer skips
  liveProxy.stop() and the other disposals after it. Unit-tested with a
  throwing disposer.
- Re-added the two regression guards dropped in the htmlHideTools ->
  htmlChrome test rename: the restore commit never writes stale
  pre-restore chrome values to the cookie, and the sidebar stays
  reachable via Mod+B while tools are hidden.

* fix(annotate): scope the Agent TUI display reset to display settings only

The Display popover's 'Reset terminal display settings' button also called
onSideChange('left'), durably overwriting a user's chosen right/hidden
placement in config.json with no disclosure — the label scopes the reset
to font/appearance. Position is a layout preference with its own explicit
segmented control right below, so the reset no longer touches it: the
button now resets exactly the display settings through the panel's one
sanitized update path, and the popover no longer has any code path from
reset to the side.

AgentTerminalDisplayPopover is now exported with a defaultOpen test seam
(the surrounding panel needs a live WebTUI session to render it); tests
assert reset restores the display defaults without firing onSideChange,
and that the Position control remains the explicit way to change
placement.
2026-08-21 08:55:30 -07:00
Leonardo Reis 81ecd67e75 feat(annotate): configurable Agent TUI placement with durable config and Hidden state (#1050)
* Allow annotate terminal to dock on either side

* Allow annotate terminal to dock on either side

* fix(annotate): persist Agent TUI preferences through the settings registry

The Position control introduced in #1050 stored its choice in a cookie via
hand-rolled helpers that bypassed the settings registry. Every annotate
session runs on its own random port, so a cookie is scoped to one session:
the placement silently reset on the next annotate. The sibling
`plannotator-annotate-agent-terminal-default` cookie (preferred agent) had
the same gap.

Both now follow the `conventionalComments` precedent exactly:

* `agentTerminalSide` and `agentTerminalDefaultAgent` join `SETTINGS` with
  serverKey/fromServer/toServer, reusing their existing cookie keys so a
  user who already picked a side keeps it across the upgrade.
* `PlannotatorConfig` gains both as flat keys (only diffOptions, theme,
  reviewAnalysis and prompts deep-merge in saveConfig), emitted from
  `getServerConfig()` behind an `isAgentTerminalSide` guard so a
  hand-edited config.json cannot advertise a side that does not exist.
* Both keys are added to the two /api/config allowlists: the Bun annotate
  server and the hand-mirrored Pi one.

The side vocabulary moves to @plannotator/core/agent-terminal (widened to
include the `hidden` state added next) so the registry can reach it without
closing an import cycle through ConfigStore; the ui util keeps its public
API by re-exporting.

Regenerates the pinned guide viewer manifest, which shifts by 0.1 KB gz
because the settings registry now reaches into core/agent-terminal.

AI-assisted (Claude) under maintainer direction.

* feat(annotate): add a Hidden Agent TUI position and extract its layout

Builds on the Left/Right Position control from #1050.

Hidden (third state of the Position control)

  Hidden is a durable preference that the Agent TUI is not part of this
  user's layout: nothing is docked, and choosing Hidden while the terminal
  is open closes it (from either surface that offers the control). It is a
  default, not a lock. The rail toggle, the Shift Shift shortcut and a
  message routed to the agent all still open the panel for the session, and
  none of them rewrites the preference, so explicit intent wins now without
  changing what happens next session. A `hidden` preference owns no dock
  edge, so a session open falls back to the historic left placement.

  Because the Position control lives inside the terminal's own popover, and
  Hidden closes that popover along with the terminal, the same control is
  now also in the Settings dialog (General tab, annotate mode). That is the
  way back from Hidden, and it also answers the review note that Position
  could not be preconfigured before the terminal was ever opened. It is
  gated on the terminal actually being available in the session so a remote
  or runtime-less annotate never offers a dead control. Both surfaces write
  the same `agentTerminalSide` config value and read it through ConfigStore,
  so they cannot drift.

  The existing transient hide affordances (header X, resize handle click and
  drag-snap, rail toggle, Shift Shift) are unchanged and stay session
  scoped. A running agent still stays mounted off-layout when collapsed, so
  hiding the panel never kills the PTY.

Review fixes

* Extract `getAgentTerminalLayout` from App.tsx into
  packages/editor/agentTerminalLayout.ts with a table test over
  {side including hidden} x {open} x {running} x {wideMode} x
  {belowBreakpoint} x {rightPanelOpen}, asserting the invariants that can
  actually regress: never docked on both edges, never visible below `lg` or
  in wide mode, a collapsed running terminal stays mounted zero-width on its
  own edge, and the right panel is suppressed exactly when a VISIBLE
  right-docked terminal holds the slot.
* Fix `aiSurfaceOpen`, which still read `effectivePanelOpen &&
  rightSidebarTab === 'ai'` after its siblings moved to
  `isRightPanelVisible`. A right-docked terminal visually suppresses the
  panel but left the Ask AI model-discovery effect firing for an invisible
  surface, which is exactly the eager provider work that gate exists to
  avoid. The layout computation is hoisted above the consumer so it can use
  the same fact the JSX does.
* Document the right-slot invariant at both coordination sites. The
  asymmetry is deliberate: the panel evicts the terminal (which keeps
  running off-layout, so reopening resumes the same session), while the
  terminal only suppresses the panel visually so dismissing it restores the
  user's place. Symmetry would make every short terminal detour cost the
  reviewer their open surface.
* Name the `useIsMobile(1024)` literal `AGENT_TERMINAL_LG_BREAKPOINT`, tied
  to the panel's own `hidden lg:flex`.
* Restore `hideAgentTerminal()` in the resize hook instead of the raw
  setter, and point the handle at the resolved placement.

AI-assisted (Claude) under maintainer direction.

---------

Co-authored-by: Michael Ramos <backnotprop@gmail.com>
2026-08-20 17:00:22 -07:00
Michael Ramos 2ca55c8332 feat(annotate): live local app annotation through a loopback reverse proxy (#1352)
* feat(bridge): additive live-mode gate + LIVE_BRIDGE_BOOTSTRAP

Adds the config-gated live branch to BRIDGE_SCRIPT: frame gate, pinned
parent origin, token-stamped postToParent, origin+token checks on both
inbound handlers, pinpoint-only clamp, vim and resize off, pageUrl on
ready, and coalesced page-change reporting for SPA history navigation.
With no config present (srcdoc) every branch is inert and behavior is
unchanged; the existing html-viewer suites pass unmodified as the
regression proof. LIVE_BRIDGE_BOOTSTRAP installs the annotation CSS
from the JSON config prelude before the IIFE runs. New package export
exposes the string constants without the React barrel.

* feat(ui): live-session parent side for proxied app annotation

useHtmlAnnotation gains a live option (origin + token validated before
parseBridgeMessage; token + concrete targetOrigin on every outbound
post) and a validated page-change message with onPageChange. HtmlViewer
gains src/liveSession/currentPageUrl/onPageChange: src-mode iframe with
no sandbox and no srcdoc, ready pageUrl handling, per-page restore
filtering with explicit clear-marks + re-sync on navigation, and one
postToBridge choke point for its direct posts. Annotation.pageUrl is
additive; exportAnnotations groups by page (with global numbering kept)
only when a pageUrl is present, byte-identical otherwise. AnnotationPanel
shows the page label; AnnotationToolstrip can hide the input switch.
The editor app wires mode annotate-app: full-viewport live surface,
forced pinpoint, vim off, diff/share hidden, pageUrl stamping.

* feat(server): loopback reverse proxy for live app annotation

Whole-origin mirror of a local dev server on a dedicated 127.0.0.1
port: streaming bridge injection (after the head open tag, before a
bare </head>, or appended; exactly one per document; 8-byte holdback
plus a state machine for tags split across chunks), header hygiene
(upstream Host rewrite, X-Forwarded-*, identity Accept-Encoding on
document intent only, hop-by-hop strip), CSP drop-and-replace with
frame-ancestors listing the editor origins, X-Frame-Options removal,
target-origin Location rewrite, byte-identical passthrough for assets
and encoded HTML (no injection, once-per-session diagnostic), SSE
streaming, and WebSocket passthrough with a bounded pending queue for
HMR. Host header validation runs before any upstream contact; the bind
is the literal loopback constant and the advertised-URL override is
never applied. Tests boot a fake dev server and cover injection,
hygiene, fidelity, WS echo, and the security posture.

* feat(annotate): annotate-app server mode + CLI live probe with remote hard-off

startAnnotateServer gains mode annotate-app and a liveApp option: it
throws under PLANNOTATOR_REMOTE, generates the per-session token,
composes the proxy-served bridge body (JSON config prelude with both
editor origin forms, localhost first, plus bootstrap and bridge
supplied by the caller so packages/server never imports
@plannotator/ui), starts the loopback proxy after the annotate port is
known, serves the live /api/plan payload (no rawHtml, no version
fields, sharing off), and stops the proxy with the server. Version
history and durable submission records stay excluded via the explicit
mode gate.

The CLI resolution probes loopback http URLs (3s, accept text/html)
and defaults them to live mode when the probe returns HTML; --static
forces conversion, --app forces live and fails loudly on non-loopback,
https, unreachable, or non-HTML targets; both flags are mutually
exclusive transport-shape flags never echoed in the tolerant handoff.
A live resolution under PLANNOTATOR_REMOTE is a startup failure
suggesting --static. OpenCode and Pi parsers are untouched this phase.

* test(live-annotate): protocol, server, and probe suites + smoke script + docs

htmlLiveProtocol.test.tsx covers the parent trust boundary (origin and
token rejection before parseBridgeMessage, token + targetOrigin on
every outbound post, validated page-change and ready pageUrl, per-page
restore filtering with full-list numbering) and the bridge live gate,
executed as the composed config + bootstrap + bridge body inside a
dedicated harness iframe so the srcdoc suites keep running the same
script uncontaminated in this process. annotate.test.ts gains
annotate-app cases (live payload shape, composed bridge served by the
proxy, no-history version endpoints, proxy stopped with the server,
remote rejection); annotate-live-resolution.test.ts covers the probe
matrix. The two post helpers now drop unmatched-targetOrigin posts
silently, matching browser semantics where some DOM environments throw.
Adds the manual Vite/Next smoke script and the AGENTS.md live app
annotation section (phase gate, security posture, limitations).

* test(annotate-cli): cover the CLI layer of the live app remote hard-off

Spawns the real CLI entry (async, so the in-process fake app can answer
the live probe) with PLANNOTATOR_REMOTE=1 against a loopback HTML
server and asserts the startup-failure exit with the --static hint.
Completes per-layer coverage of the three-layer hard-off (CLI exit,
server throw, unconditional loopback proxy bind).

* fix(live-annotate): harden the loopback trust boundary end to end

- isLoopbackHostname (now canonical in live-proxy.ts, re-exported by the
  CLI resolution) requires localhost, ::1, or a LITERAL 127/8 IPv4
  address: DNS names like 127.0.0.1.evil.example no longer classify as
  loopback, so neither the default probe nor --app can start a live
  proxy against an off-box origin.
- The live-eligibility probe judges the FINAL response URL: a target
  that redirects off its loopback origin falls back to the static
  pipeline (or fails loudly under --app) instead of opening a live
  session whose iframe immediately leaves the proxy.
- WS upgrades with a browser Origin not naming the proxy itself are
  refused, so a hostile page's cross-site connect is never laundered
  into the origin-less shape dev servers trust as a non-browser client
  (Vite CVE-2025-24010 class).
- /__plannotator__/bridge.js refuses cross-site/same-site
  Sec-Fetch-Site fetches: the per-session token is no longer readable
  via an off-origin script include on modern browsers.
- X-Frame-Options is stripped only on HTML responses (where
  frame-ancestors replaces it); non-HTML responses keep the app's own
  framing protection.
- Redirect Locations are re-anchored by loopback-host + port
  equivalence instead of a string prefix: alternate loopback spellings
  are now caught and lookalike ports (5173 vs 51730) pass through
  untouched.
- --app on a non-URL target fails loudly instead of being silently
  swallowed.

* fix(live-annotate): session correctness for SPA restores, origins, and pathful targets

- A live find-and-mark that resolves nothing keeps its record, seeded
  with unresolved placeholder targets from the durable anchor/text
  params, so the mutation-driven reconcile re-acquires the pin once a
  lazy route or data-dependent tree renders (SPA navigation no longer
  permanently drops pins). Srcdoc restores keep the fail-closed drop.
- The bridge posts every outbound message once per listed editor
  origin; the browser delivers only the one matching the parent
  document, so an editor opened at 127.0.0.1 instead of localhost no
  longer silently loses ready and every subsequent message.
- The advertised appUrl is the proxy under its localhost spelling with
  the target URL's own path and query: the framed app stays same-site
  with the editor, shares the dev app's host-only localhost cookies
  and storage, and a pathful target opens its page instead of the app
  root. The proxy still binds the 127.0.0.1 literal.

* ci(live-annotate): run the live protocol DOM suite; document the hardened posture

htmlLiveProtocol.test.tsx is DOM-gated and was absent from the
workflow's DOM_TESTS file list, so none of its trust-boundary
assertions ran in CI. Add it, and update the live-app section of the
project docs: literal-loopback gate, probe redirect rule, WS Origin
check, bridge.js delivery gate, localhost appUrl advertisement, live
restore resilience, and the remote-mode behavior change (loopback URL
annotate under PLANNOTATOR_REMOTE now exits asking for --static
instead of silently converting).

* fix(live-annotate): absorb the v0.27 mainline into the live session surface

Post-rebase seam work after replaying the branch onto main (v0.27.4 era):

- Route the bridge's unanchored-transparency report through postToParent so
  live sessions deliver it token-stamped to the listed editor origins; the
  raw '*' post main introduced for srcdoc would be dropped by the live
  parent's message authentication exactly where restores fail most. New
  live-harness test pins the contract.
- Extend the live remote hard-off to --tailscale sessions (flag postdates
  the branch): CLI startup failure + startAnnotateServer throw keyed on
  tailnetPublished, matching how the annotate agent terminal treats tailnet
  publication. Covered in annotate.test.ts and documented in AGENTS.md.
- Keep main's compact-touch input controls and effective mode/input values
  on the HTML surface while preserving the live pinpoint-only clamps.
- Regenerate the pinned guide-viewer manifest (CSS hash moved with the new
  UI classes; JS unchanged).

* feat(live-annotate): Interact/Annotate mode toggle for live app and raw HTML sessions

A live app session used to be unusable: the pinpoint capture-phase click
handler owned every click, so buttons, checkboxes, inputs, and links never
fired. One boolean mode now governs the HTML/live viewer surface:

- Interact: the bridge is fully passive. Pinpoint capture, hover outline,
  drag-selection toolbar, [data-annotate] clicks, and committed-highlight
  click interception are all gated behind annotateModeActive, so clicks,
  forms, text selection, and SPA navigation reach the page natively.
  Committed markers and highlights stay VISIBLE, and marker buttons keep
  their clicks (a marker click still opens its comment).
- Annotate: classic behavior, unchanged. Live sessions annotate exclusively
  via pinpoint while armed.

Control: a single bubble icon button in the editor header (icon never
changes; armed = accent + visible border, idle = transparent border of the
same width, so the box is pixel-identical in both states), plus a subtle
inset accent ring floated over the viewer while armed (pointer-transparent,
no layout shift). Keyboard: Mod+Shift+A through the shortcut registry
(html-annotate scope; the bridge mirrors the chord inside the iframe and
forwards it over the authenticated postToParent path). Esc gains a final
ladder rung: draft closes first, then the hover outline clears, then Esc
exits Annotate back to Interact (bridge posts annotate-exit; a parent-side
listener covers Esc with editor focus). The parent owns the mode and pushes
it with the same re-post-on-ready pattern as set-input-method, so it
survives live page changes, HMR reloads, and bridge re-injection without
ever reloading the iframe.

Defaults: live app sessions START in Interact; static/raw HTML sessions
START in Annotate (today's behavior preserved, and the srcdoc bridge default
keeps behavior byte-identical when no set-annotate-mode ever arrives).
Session-only state, no persistence. Vim navigation is available only while
Annotate is armed.

Covered by new bridge-harness and parent-side DOM tests in
htmlLiveProtocol.test.tsx and htmlPinpointProtocol.test.tsx: Interact
pass-through, armed capture, the Esc ladder order, mode survival across
re-injection, marker clicks in Interact, and both defaults.

* feat(live-annotate): pinpoint-armed default, always-on drag comments, comment-only HTML surfaces

Simplifies the Interact/Annotate design after live review. The new
contract replaces the previous one where they conflict:

- BOTH surfaces (raw HTML and live app) now START ARMED with pinpoint;
  the live-session Interact default is gone. Esc keeps the ladder
  (close draft, clear hover, then exit to Interact) and the header
  toggle re-arms. The bridge also paints the pinpoint cursor at init
  instead of waiting for the parent's first round trip.
- The header toggle is a PEN icon: the old bubble sat next to the
  annotations-panel bubble and the two were indistinguishable. Same
  box geometry (armed = accent + visible border, idle = transparent
  border of identical width), aria-pressed, Mod+Shift+A, and the
  armed ring over the viewer are all unchanged.
- Text drag-selection commenting is ALWAYS live on HTML/live surfaces,
  in BOTH states: the selection pass is ungated from annotateModeActive
  and from the pinpoint input method. In armed pinpoint, click = pin an
  element and drag = select text, simultaneously; the >4px drag arming
  decides which one a gesture was, a completed drag's trailing click
  never re-pins (one-shot dragEndedClick), and a plain click is never
  swallowed (the pass only acts on a real selection and never
  preventDefaults). Esc in Interact still closes an open drag draft
  before yielding to the page.
- HTML/live surfaces are COMMENT-ONLY: useHtmlAnnotation clamps
  redline/quickLabel (both the host mode and a bridge-posted
  modeOverride, so a hostile page cannot force a DELETION), the
  selection toolbar drops Delete and quick labels behind a new
  commentOnly seam on AnnotationToolbar, and the quick-label picker
  portal is gone from HtmlViewer. Markdown surfaces keep the full
  toolbar, and persisted DELETION annotations still restore.
- The "Show tools"/"Hide tools" header button is removed. It hid the
  floating toolstrip (now gone from HTML surfaces entirely: with
  comment-only plus both input paths live there is nothing left to
  switch), the collapsed sidebar tab flags, and the viewer's floating
  action cluster (attachments + global comment + version-diff toggle),
  all of which are now always visible. htmlChrome persistence keeps
  only the sidebar/panel state; an old cookie's toolsHidden flag is
  read tolerantly and ignored, so a stale record cannot strand a user
  with hidden chrome and no way back.
- HTML surfaces pin the viewer input method to pinpoint (the drag/
  pinpoint switch is meaningless when both are live); the Alt input
  switch no-ops there. Vim stays armed-only, as built.

No server, proxy, or protocol-security changes; the armed flag stays
session-only.

Tests: the live-bridge harness is reworked around the armed default
(forged-DISARM posture, drag-selection passes in armed and Interact,
the trailing-click guard), the pinpoint suite covers the comment-only
toolbar and the redline/quickLabel clamp at the trust boundary, a new
AnnotationToolbar.commentOnly seam test guards both surfaces'
toolbars, App.htmlChrome.test.tsx replaces App.htmlHideTools.test.tsx
(no tools button, stale-cookie tolerance, pen armed default), and the
htmlChrome tests cover the narrowed persisted shape.

* feat(live-annotate): collapsible floating controls cluster

The simplification removed the Hide tools toggle, which left the floating
comment/attachments cluster permanently over the page. Restore a hide
affordance on the cluster itself: a collapse chevron shrinks it to a small
expand pill in the same corner, so the page is never obstructed without a
way back. Collapsed state persists with the rest of the HTML chrome cookie
(sidebar/panel), tolerantly read. Hosts that do not wire the toggle
(readOnly viewers, review-editor panels) are unchanged.

* feat(live-annotate): header Show/Hide tools replaces the collapse pill

The collapse pill was a half measure: it left its own artifact over the
page and the sidebar tongue tabs stayed. Revert it and restore the real
thing as a header control: an eye toggle immediately left of the pen that
removes ALL floating chrome over the page from the DOM (sidebar tongue
tabs + the comment/attachments cluster), leaving nothing behind. The
toggle lives in the header, so a hidden state always has a way back,
which also makes honoring a persisted (or pre-existing) toolsHidden
cookie safe again.
2026-08-19 10:44:21 -07:00
galmadar c8da6345b1 feat(ui): Shift+1-4 shortcuts to switch annotation mode (#1244)
Choosing the annotation mode was mouse-only: the four toolstrip buttons
were the only way to move between Markup, Comment, Redline and Label.

Shift+digit rather than mnemonic letters, because most of the obvious
keys are already claimed: bare letters are swallowed by type-to-comment,
bare digits by the label picker, Mod+digit by the browser's tab
switching, and Mod+Shift+3/4 by the macOS screenshot shortcuts, which
the OS captures above the browser. Shift+1-4 is free on macOS, Windows
and Linux, and follows the toolstrip left to right.

The handlers write through the existing handleEditorModeChange, so the
mode persists and every consumer re-renders from one call. They share
the chrome-level typing and modal guard, and additionally gate on the
memo that decides whether the toolstrip renders at all — otherwise the
mode would change silently during plan diff or with the HTML tools
hidden, with no pill to report it.

Also adds aria-pressed to the toolstrip buttons, so a keyboard-driven
mode change is announced. The labels collapse by opacity rather than
display, so they were already carrying the accessible name.

Refs #1242

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:03:00 -07:00
FND e1ce7dabe1 feat(ui): Totman/Classic P favicon style switcher (#1325)
Favicon style switcher in Settings > Theme: the Totman mascot or the historical dark-navy P tile (byte-identical to the pre-Totman asset, sha256 pinned). Served server-side from first paint in both runtimes; opt-in for hosts of the published UI package. Contributed by @FNDEVVE
2026-08-16 21:58:46 -07:00
Michael Ramos 94f8d45daa guide-viewer: readable on phones and tablets, desktop untouched (#1329)
* fix(guide-viewer): readable on phones and tablets, desktop untouched

Every change is behind a breakpoint; 1440px and 1024px renders of the same
guide are byte-identical before and after (screenshot MD5s match).

- Split diffs below lg (1024px) are forced unified in the portable viewer's
  diff renderer (matchMedia; the setting is untouched, so a wider window
  gets split back). A phone has ~350px of pane and a portrait tablet ~430px,
  so two columns were under 220px each.
- Padding scales: page px-3/sm:px-6/lg:px-10, chapter column px-4/md:px-6,
  diff column px-1.5/md:px-4. Code pane on a 390px phone: 276px → 352px.
- Tablets: the chapter column is proportional (minmax(260px,36%)) from md
  and the fixed 440px only from lg. Pane at 768px: 214px → 426px.
- Header actions (Download, theme) sit in a right-aligned row above the
  title below md instead of floating into it.

Viewer rebuilt and published (viewer.dWt7KCum.js), manifest synced.

* fix(guide-viewer): touch targets, labels, and no tap delay on coarse pointers

Only under `pointer: coarse` (Tailwind's `pointer-coarse:` variant), so mouse
layouts are unchanged:
- Reviewed checkbox and the collapse chevron get an invisible ::before hit
  area (visual 15–17px, hit ≥ 44px); the "Reviewed" text button and file
  chips get taller padding; the theme toggle and hosted Download button grow
  to a 44px hit box.
- `touch-action: manipulation` on controls in the portable viewer and the
  landing page (no double-tap-to-zoom delay; the page still pinch-zooms).
- `aria-label` on the two icon-only buttons (theme toggle, collapse chevron).
- Landing page: the GitHub link and the Copy button are 44px tall on touch.

Tailwind v4 already gates `hover:` behind `@media (hover: hover)`, so no
false hover states on tap. Viewer rebuilt and published, manifest synced.

* guide-viewer: manifest for the combined build (labels + mobile), viewer.sFtOnb1i.js published
2026-08-16 13:30:58 -07:00
Michael Ramos 64062af9a1 feat: Portable Guided Reviews — export, share links, agent-authored guides, guides.show (#1324)
A Guided Review can now leave Plannotator: as a single self-contained HTML file that renders exactly like the in-app guide, as an encrypted-by-default share link on guides.show, or authored by any agent through the new guide CLI.

Highlights: packages/guide-viewer extracted from review-editor at the injection seam (read-only host, no third renderer); guides.show Worker with R2-backed share storage, per-IP rate limiting on creation, delete tokens hashed at rest, and 128-bit ids; portable exports pin the viewer by SRI hash with budget and manifest gates in PR CI and at deploy; two-runtime parity across Bun and Pi verified; v0.27.x saved guides load unchanged. Retention is indefinite by explicit decision, to revisit with the lean sharing refactor.

Decision record: adr/decisions/007-portable-guided-reviews-20260815.md
2026-08-16 12:17:13 -07:00
Ashish Huddar 59ef54be44 fix(comments): expose skill picker semantics to assistive tech (#1316)
Adds the ARIA autocomplete contract to the comment composer skill picker: aria-autocomplete=list + aria-haspopup=listbox on the textarea, aria-controls/aria-owns while the menu is open, role=option rows with aria-activedescendant tracking. Preserves the no-preselection keyboard state machine and the untouched plain composer when skill references are off.

Closes #1233

Co-authored-by: ashish921998
2026-08-14 10:17:35 -07:00
Michael Ramos 8e88dcec8c fix: v0.27.2 pre-release QA batch (mobile TOC, dialog bounds, seed guard) (#1311)
* fix(plan): make the compact TOC scroll the document again

The compact navigator overlay rendered outside App's ScrollViewportProvider,
so the TableOfContents it hosts resolved a null viewport and every "jump to
heading" tap was a silent no-op on phones. The provider is context-only, so
hoisting it above the overlay fixes the lookup without touching desktop DOM
structure or order.

* fix(plan): scope the permission-mode chooser to plan review and bound its card

The one-time chooser fired in every non-goal-setup Claude Code session, so
annotate, annotate-last, annotate-folder and archive reviewers got a blocking
dialog about what happens after plan approval. Gate it on plan review, which
is the absence of a mode field in the /api/plan payload.

The card itself was hand-rolled with no height cap and no internal scroll, so
on a short landscape phone it overflowed both edges of a modal that has no
dismiss control. Give it the same bounded shell the sibling one-time dialogs
use: safe-area padding, a visible-viewport max height, and the option list as
the only scrolling region. Content and cookie behavior are unchanged.

* fix(review): never seed Tree over a persisted panel view

The first-run initializer gated only on the setup-seen cookie, but sessions
that never reach it (non-git, workspace, PR, no since-base) still let Settings
persist a panel view. A reviewer could hold an explicit Git status choice with
"seen" unset, and the next plain git session seeded Tree over it. Treat a
persisted view as the decision: consume the one-time setup and write nothing.

* fix(comments): give the geometry-forced composer a working Escape

When the anchor has no room the position tracker forces dialog mode. On a
fine-pointer viewport Escape took the collapse branch, the tracker instantly
re-forced the dialog, and the keystroke was eaten; the Collapse button bounced
the same way. Track forced expansion separately from the preferred kind: in
that state Escape closes (draft-preserving) and Collapse is hidden, because
collapsing is geometrically impossible.

* fix(review): stop the compact Editor tab editing the desktop diff style

The dock's Split/Unified control returns null under the compact touch layout,
but the Settings copy of it kept rendering while the phone showed the
session-only unified diff. It looked dead and silently rewrote the persisted
desktop preference. Hide it on compact and state what the session is doing;
the prop defaults to false, so the plan editor and desktop are untouched.

* fix(portal): give the share portal the mobile app shell

The portal mounts the same plan editor App as the hook but kept the pre-mobile
entry document: no viewport-fit=cover (so every safe-area token was inert) and
a min-h-screen body without the shell's scroll ownership. Mirror the hook's
body class, root class, and viewport meta, and extend the entry-asset pin to
cover the portal alongside them.

* fix(plan): keep compact overlays out of the printed document

The compact plan stage and the compact navigator are full-viewport transient
surfaces with no print-hide marker, so printing on a touch device with
Annotations, Ask AI, Versions or Archive open clipped the document behind
them. Mark both with data-print-hide, which print.css already hides. The
desktop rail is untouched.

* fix(plan): give the selection toolbar real touch targets

Copy / Delete / Comment / quick label / looks-good / Cancel measured 28x28
with 2px gaps on a phone because the toolbar never got the touch-target
markers the rest of the stack uses. Stamp them on its buttons and add a
compact-scoped gap so adjacent destructive and comment actions are not a
mis-tap apart. Both are inert outside the compact scope, so desktop geometry
is unchanged.

* docs: keep the new QA-batch comments free of em dashes
2026-08-13 11:35:00 -07:00
Michael Ramos d2d2dba7fa feat(annotate): configurable extra markdown extensions (#1309)
* feat(annotate): configurable extra markdown extensions (#1307)

Adds a config-only `markdownExtensions` key to ~/.plannotator/config.json,
e.g. { "markdownExtensions": [".livemd"] } for Livebook notebooks. A listed
extension is accepted everywhere .md is on the annotate path: CLI target
resolution, folder discovery and the file browser, /api/doc plus relative and
wiki-link navigation between sibling docs, the 2MB size cap, and per-file
version history. Listed extensions render as markdown with frontmatter
stripped, never as raw HTML, and they only widen the accepted set.

Design:
- packages/core/annotatable.ts stays browser-safe and zero-dep. Its regexes
  and predicates now take an optional, defaulted-empty list of extra
  extensions, plus a normalizer and regex builders.
- packages/shared/markdown-extensions.ts is the node-side seam: it reads
  config.json once per process through the existing loadConfig() and threads
  the normalized list into those pure functions. resolve-file re-exports the
  config-aware predicates so both runtimes pick them up; the Bun server, the
  Pi mirror, the OpenCode plugin and the CLI all go through them.
- The annotate /api/plan payload ships the resolved list so the renderer can
  linkify links to sibling documents (module-level UI registry, empty by
  default, so nothing changes without config).

Validation: entries must be dot-led, lowercase-normalized, and free of path
separators, globs and whitespace. Invalid entries are dropped silently,
built-ins are deduplicated, and `.env` is denylisted so config can never
register it (annotate copies file contents into the data dir).

Deliberately unchanged: the Pi plan-write allowlist (ALLOWED_PLAN_EXTENSIONS
in tool-scope.ts) and Edit Mode source save (SOURCE_SAVE_FILE_REGEX), which
keep their own narrower allowlists.

* fix(annotate): deny the dotenv family and sandbox config-aware tests

Review follow-ups on #1309:
- deny the whole dotenv family (.prod.env, .env.local, ...) in
  normalizeMarkdownExtensions, not just the exact .env name
- resolve config.json path per call instead of at module scope so
  PLANNOTATOR_DATA_DIR sandboxing works in single-process test runs
- stop resolve-file.test.ts reading the real user config: pure
  predicate imports plus pinned empty extras on every resolve call
- add the config.json -> memo -> predicate integration test using
  resetMarkdownExtensionsCache under a temp data dir

* test(call-flow): make the stale-read advert test self-sufficient

The read-only GET only probes the node runtime while Call flow is
enabled. The stale-read test relied on earlier tests' settings POSTs
leaking callFlow=true through the process-frozen config path; with lazy
config resolution each sandbox is genuinely isolated, so the test now
enables Call flow in its own data dir. Locally the dependency was
masked by an fnm-shimmed sem sidecar spawning node coincidentally.
2026-08-13 09:47:18 -07:00
Michael Ramos 27791a6fba Mobile Phase 2B: Plan shell and navigation (#1303)
* feat(editor): add compact plan navigator

* feat(editor): simplify compact plan chrome

* fix(editor): close compact navigator after file selection

* feat(editor): add compact plan review surfaces

* fix(editor): hold navigator through cold file loads

* fix(editor): preserve desktop diff activation
2026-08-13 09:38:14 -07:00
Michael Ramos 64e3fa7762 feat(review): add compact touch review shell (#1301)
* feat(review): add compact touch review shell

* fix(review): let submission dialog own initial focus

* fix(review): refine compact mobile review chrome

* fix(review): restore reliable mobile diff scrolling

* fix(review): preserve mobile file identity

* docs(mobile): specify phase 2b plan shell

* fix(review): close mobile shell regressions

* fix(review): restore narrow overview stacking

* test(review): preserve real syntax theme resolver
2026-08-13 09:29:24 -07:00
Michael Ramos f387cdabde feat(ui): add mobile-safe touch and dialog primitives (#1300)
* feat(ui): add mobile-safe touch and dialog primitives

* fix(ui): scope touch targets to compact shell
2026-08-13 09:06:51 -07:00
Michael Ramos e181b824cc Mobile-safe plan and code comment composition (#1297)
* feat: harden mobile comment composition

* fix(ui): keep mobile app inside Safari viewport

* docs: record physical mobile triage

* fix(ui): extend plan canvas behind Safari controls

* fix(ui): let mobile plans drive Safari chrome

* fix(ui): release Safari top edge on mobile plans

* docs: triage mobile feedback and close phase 1b

* fix(ui): harden compact touch behavior
2026-08-13 08:58:55 -07:00
Michael Ramos d3633c9c52 Mobile foundation and quieter first run (#1295)
* feat: establish mobile foundation and simplify onboarding

* fix(ui): finish mobile foundation cleanup
2026-08-13 08:45:00 -07:00
Michael Ramos 356b628b6f ci(security): add Semgrep CE and Trivy monitoring (#1294)
* ci(security): add Semgrep CE and Trivy monitoring

* fix(ci): diagnose Trivy coverage assertions

* fix(ci): accept Trivy repository scan metadata

* fix(ci): harden scanner failure diagnostics
2026-08-12 20:40:08 -07:00
Michael Ramos 1bf90a2357 feat(editor): focus-mode shortcut to toggle both sidebars (#1279)
* feat(editor): focus-mode shortcut to toggle both sidebars (#1276)

Keyboard-first reviewers had to reach for the mouse to collapse the
Contents sidebar and the right-hand panel every time they wanted to read
a document. Bind that to one key.

Mod+. now toggles the existing `focus` view mode from the keyboard, on
both the plan review and annotate surfaces. The first press remembers
whatever was open and closes both panels; the next press restores that
exact arrangement, so a session with only one panel open comes back the
same way. Any manual open (a sidebar tab, the annotation panel, the
agent terminal) already exits the view mode and clears the memory, so
the shortcut cannot leave the layout stuck.

The binding was picked after auditing every plan-review and annotate
binding: Mod+B / Mod+Shift+B are the sidebar toggles, Mod+S, Mod+P,
Mod+Enter, Mod+C and Mod+Z are taken, and code review already uses
Mod+. for its own "collapse the chrome" toggle. It is unshifted on every
keyboard layout and claimed by no browser or OS default.

The dispatcher gate is shared with the annotate sidebar shortcuts, so
the key is inert while a dialog, an overlay, a submission, or a text
field owns the keystroke. HTML surfaces are excluded because they own
their own persisted chrome and never render the Focus control.

* fix(editor): keep focus-mode exit reachable on HTML surfaces; note the HTML caveat in help
2026-08-12 11:54:31 -07:00
Michael Ramos 8e7b5ce300 feat(review): refine Call Flow navigation and annotations (#1277)
* feat(review): refine Call Flow navigation and annotations

* fix(review): align viewed controls with panel navigation

* feat(review): add Call Flow path search controls

* fix(ui): wrap long tooltip identifiers

* feat(review): annotate raw Call Flow output

* feat(review): refine call flow lens context

* fix(review): keep call flow lens search accessible

* fix(review): scope call flow find shortcuts
2026-08-12 11:18:29 -07:00
Michael Ramos fc348687bf fix(review): contain /api/call-flow analysis throws as JSON error responses (#1272)
* fix(review): contain /api/call-flow analysis throws as JSON error responses

A hard VCS failure during patch materialization escaped the handler in
both runtimes. On Pi the unhandled rejection reached the process-level
handler and killed the user's session; on Bun it surfaced as a non-JSON
500 the client's quiet-failure UX could not parse. Both handlers now
return the standard { status: "error", reason: "analysis-failed" }
envelope.

* fix(review): cut the Call Flow consent copy down to the three facts that matter

Six sentences of disclosure read as noise. The dialog and Settings now
say: what it does, what it installs (languages + size), Node 22+, and
that other languages install as needed. Nothing consent-relevant was
removed.

* test(review): pin consent-copy facts, not prose

The presentation test now asserts the server-derived facts (languages,
size, Node floor); the dialog and Settings tests assert only that the
disclosure prop renders, via a sentinel string. Copy edits no longer
break three test files.

* docs: add Testing Rules to AGENTS.md (no prose-pinning, no round-trip prop tests)

* docs: refine copy-pinning rule — deliberate locks allowed, incidental snapshots banned

* fix(review): use the maintainer's Call flow description in the intro dialog and Settings

* fix(review): Call flow description is the maintainer's exact copy; remove the dynamic disclosure plumbing

The intro dialog and Settings now show only: 'Diffs for function call
stacks across git commits. 22 languages supported (AST-based, built
using Tree-sitter).' The callFlowEnableDescription prop, its App wiring,
and getCallFlowEnableDescription are removed; install size and Node
requirements remain visible in the Call Flow panel itself.

* fix(review): reject empty-path worktree diff types; clean up QA findings

- parseWorktreeDiffType returns null for a worktree diff type with no path.
  An empty path resolved to an empty cwd, and Bun.spawn({ cwd: "" }) runs
  git in the server's own directory instead of the target repo, so a
  malformed 'worktree:' switch returned an unrelated checkout's diff.
  Fail closed to the caller's real cwd. (Pre-existing; surfaced by QA.)
- Remove an orphaned JSDoc comment left by the callFlowEnableDescription
  prop removal in Settings.tsx.
- Add useCallFlowAnalysis.test.tsx to the CI DOM_TESTS list; its two
  tests were silently skipping on every run.
2026-08-11 22:55:18 -07:00
Michael Ramos caf7ce1ccd feat(review): install Call Flow automatically in the background on opt-in (#1271) 2026-08-11 17:48:18 -07:00
Michael Ramos 9ee2e83287 feat(review): make the CallDiff runtime a strictly opt-in, in-UI install (#1270)
* feat(review): make the CallDiff runtime a strictly opt-in, in-UI install

The merged CallDiff integration eagerly installed a ~784MB runtime for
every user at install time, for a feature that is off by default. The
runtime is now strictly opt-in and the opt-in lives in the review UI:
toggle Call flow, click Install in the panel, watch staged progress, and
use the analysis in the same session.

Installers: the default sequence no longer installs the runtime. Opt in
with --with-call-flow (PowerShell: -WithCallFlow),
PLANNOTATOR_INSTALL_CALLDIFF=1, or { "installCallFlow": true } in
config.json (flag > env > config). PLANNOTATOR_SKIP_CALLDIFF_INSTALL is
deleted; --minimal keeps excluding the runtime; the installer prints an
honest note pointing at the in-app install. The headless CLI path
(plannotator install-runtime call-flow) is unchanged.

Server (both runtimes, contract-identical): POST /api/call-flow/install
starts installCallFlowRuntime() in the background via a single-flighted
coordinator (concurrent POSTs join the in-flight install), runs a
Node 22+ preflight before any download (distinct node-unavailable
error), and rejects cross-origin POSTs with 403. GET
/api/call-flow/install-status reports idle/running/done/error with
stage: downloading, verifying, installing-deps, building. Install
completion invalidates the 30s runtime probe cache so the next
capability advert resolves available without a server restart.

Client: the Call flow Dock's runtime-missing state is now the opt-in
funnel with an honest disclosure (about 800 MB on disk, Node 22+,
one-time), staged reduced-motion-safe progress, and error + retry with
a no-node hint. On done the advert is refreshed through
POST /api/review-analysis and the existing available-change refetch
starts the analysis for the current snapshot with no reload. The intro
dialog and Settings toggle note the separate first-use runtime.

Docs: AGENTS.md env table + Review Server API table, marketing
environment-variables / installation / ui-settings / code-review /
api-endpoints pages, and the CallDiff ADR runtime-boundary and server
contract sections.

* test(review): stop leaking PLANNOTATOR_DATA_DIR from the install endpoint tests

The call-flow install endpoint tests overrode PLANNOTATOR_DATA_DIR at
module-eval time and never restored it. bun runs CI's full suite in one
process and evaluates every test file's module before running tests,
while Pi's generated/storage.ts caches its data dir at import time; the
override therefore made storage's cached dir and later files' live
getPlannotatorDataDir() calls disagree, failing the Pi annotate-history
unwritable-dir test and both durable-submit-record tests.

An afterAll restore alone is not enough: it reproduces the same three
failures with the mismatch inverted (storage caches the leaked dir at
module eval, tests then run against the restored one). The env var is
now never touched at module-eval time at all; it changes only inside
tests and is restored to its original value in afterEach, exactly like
the PORT/PATH pattern. The config writes the advert tests persist
through the process's frozen config module are snapshotted at load and
restored in afterAll so a standalone run never flips a real
config.json setting, and the process-global scope of the mock.module
seams is documented.

Regression proof (previously failing in either mismatch direction, now
green in both orderings):

  bun test packages/server/call-flow-install-endpoint.test.ts \
    apps/pi-extension/server/annotate-history.test.ts \
    apps/pi-extension/server/annotate-submission.test.ts

* feat(review): install CallDiff grammars selectively

* fix(review): harden CallDiff worker environment

* fix(review): close CallDiff verification gaps
2026-08-11 16:28:08 -07:00
Michael Ramos 3245310aa8 feat(review): add optional CallDiff call-flow analysis (#1268)
* feat(review): add optional CallDiff call-flow analysis

* fix(review): harden CallDiff integration
2026-08-11 13:18:35 -07:00
Michael Ramos 98113182b5 feat(guide): reviewer-supplied extra instructions for Guided Review (#1267)
* feat(guide): reviewer-supplied extra instructions for Guided Review (#1265)

Adds a quiet, collapsed-by-default Custom instructions affordance to the
guide launch page. The text is APPENDED to the built-in organizer
methodology as a clearly delimited section (composeGuideMethodology) and
never replaces it; absent or blank instructions produce byte-identical
prompts to before. Persisted in a dedicated cookie
(plannotator-guide-instructions) so a standing team preference survives
sessions without bloating the plannotator.agents blob past the browser's
per-cookie limit.

Server side, the launch body gains an optional guide-only instructions
field (both the Bun and Pi node:http agent-jobs handlers accept and
thread it); prompt composition lives in the shared guide-review.ts that
vendor.sh already vendors to Pi, so both runtimes compose identically.
Text is capped at GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS (2000) server-side
and mirrored by the textarea maxLength. Repair launches deliberately
ignore instructions: a repair is a mechanical JSON fix, not a rewrite.

Tests pin the regression contract (empty input keeps prior prompt bytes),
appended-not-replacing composition, the length cap, repair isolation, and
the cookie round-trip via the storage backend seam.

* refactor(guide): store standing instructions server-side, not in a cookie

Review findings on the cookie approach (silent write failure past the
encoded 4KB per-cookie limit for multi-byte text) pointed at the real
design problem: the instructions are consumed by the SERVER at launch
time, so they belong in the data dir like review-skills.json, where no
size ceiling or encoding inflation exists and the preference follows
the machine instead of one browser profile.

New GET/PUT /api/agents/guide-instructions in both runtimes backed by
shared guide-instructions-store (vendored to Pi). Guide launches apply
the stored text when the body carries none; the launch page still sends
its live textarea value (explicit wins), so a just-typed preference can
never race the debounced save. The sidebar surface sends nothing and
inherits the stored text server-side. All cookie machinery removed.

Also folds in the review fixes: marker-tag-shaped strings in
instructions are defanged so first-match nonce recovery cannot be
hijacked by pasted examples.
2026-08-11 10:24:39 -07:00
Michael Ramos ab8d2581eb feat(ui): onUnanchoredChange report + readOnly keeps the host footer slot (0.30.0) (#1263)
The bridge now names WHICH annotations have no live representation on
the page (every target dead, or the restore never resolved), reported
on change through a new validated message and the HtmlViewer
onUnanchoredChange prop, delivered in readOnly mode too. Fail-closed
anchors previously hid markers silently.

AnnotationPanel readOnly no longer suppresses the renderCardFooter
slot: its contents are host-owned and may be read affordances (replies,
links), so the host gates what belongs in it. Built-in delete/edit and
direct-edit discard stay hidden.
2026-08-10 22:46:55 -07:00
Michael Ramos 96ad52cd90 fix(ui): null-guard vim focus reassert + publish/migration doc corrections (#1262)
* fix(ui): null-guard vim focus reassert + publish/migration doc corrections

The vim focus-reassert guard compared a nullable iframe ref to the
nullable document.activeElement: with both null the branch runs and
iframe.contentWindow throws. Also flagged by the first strict-TS
consumer of @plannotator/ui 0.29.0. HANDOFF gains the CI-only
provenance note, the highlight.js bundler-alias removal warning, and
the known @pierre/theming peer-range warning.

* chore(ui): bump to 0.29.1 for the null-guard fix
2026-08-10 22:10:22 -07:00
Michael Ramos acb4979c59 chore(ui): prep @plannotator/ui 0.29.0 + core 0.23.0 publish (#1222) (#1261)
Bless components/html-viewer as supported host surface, document the
.hljs to pn-code migration, pin the readOnly view-only contract with
tests, and bump both published packages for the next manual publish.
Core must publish first: ui 0.29.0 imports @plannotator/core/annotatable,
which is absent from published core 0.22.0.

Closes #1222
2026-08-10 20:21:45 -07:00
Michael Ramos c4fc79f0aa fix(annotate): neutralize document-authored CSP meta tags so the bridge can run (#1259)
A document carrying its own <meta http-equiv=Content-Security-Policy> (including
Plannotator's own portable guided-review exports, which embed default-src 'none')
blocked the injected inline bridge script, disabling annotation entirely for that
file. The iframe sandbox attribute is the annotate surface's security boundary;
the page's CSP was authored for its standalone context, so injectIntoHead now
strips CSP meta tags (order/quote/case tolerant) before splicing the bridge.

Pre-existing bug, surfaced during v0.26.8 manual QA. Mutation-verified test.
2026-08-10 16:00:45 -07:00
Michael Ramos 121082430e fix: QA-gate hardening for the v0.26.8 feature set (overlay perf, numbering, OpenCode 2 parity) (#1258)
* fix(opencode): consolidate V2 system parts into one composed prompt (#1114)

The OpenCode 2 adapter still shipped the pre-#1114 multi-part system
injection: replacePlanningSystemParts kept one part per source and the
generic reminder pushed a separate part, so Qwen3.x Jinja template
corruption persisted for OpenCode 2 users. Mirror the V1 entry exactly:
compose the stripped existing text plus additions into a single system
part via composeSystemPrompt, and compose the generic reminder into the
existing text instead of appending a second part.

Also adds the regression tests for the bug class flagged in #1114's
review: both helpers must read/compose the existing system text BEFORE
truncating the array (a reorder to 'system.length = 0' first drops the
host prompt and goes red here).

* perf(annotate): harden the raw-HTML overlay reconcile (dead-target backoff, cull, batching)

Bridge-script hardening for mutation-heavy pages and large annotation
sets, plus the lost click-to-select hover affordance:

- A: dead-target re-search now carries a wall-clock backoff (300ms
  doubling to a 5s cap, reset on success) ON TOP of the generation gate,
  plus a 2-searches-per-reconcile-pass budget with a scheduled follow-up
  pass for budget-skipped eligible targets. A page that mutates every
  frame advances domGeneration every frame, so the generation gate alone
  re-ran the whole-document TreeWalker sweep (and anchor re-resolution)
  per frame forever for permanently unresolvable targets.
- B1: early viewport cull (64px margin) for element and range targets:
  wholly offscreen targets skip targetStyleHidden / getComputedStyle /
  clipBoundsFor / client-rect collection entirely and just omit their
  markers, which is what the visible pipeline produced anyway.
- B2: read/write batching in renderAnnotationOverlay: highlight rects are
  queued during the read phase and flushed as one write phase, so the
  pass no longer forces a synchronous layout per record.
- B3: restoreAnnotation defers its render through the existing
  rAF-coalesced reconcile scheduler; restoring N annotations now renders
  once instead of N full passes (searches stay synchronous for the
  mark-applied reply). DOM tests flush the frame via the suite's
  standard macrotask flush.
- B4: zero-work observer gate: page mutations with no records, no
  pending draft, and pinpoint inactive still bump domGeneration but no
  longer schedule a reconcile frame.
- D: hover affordance for click-to-select: the rAF-throttled mousemove
  hit-tests the pointer against the CACHED rendered committed rects and
  toggles a brightness class on that annotation's rect divs inside the
  shadow root. No page-DOM writes, rects stay pointer-transparent, and
  shadow-root writes are unobserved so there is no reconcile loop.
- G: while a text drag is in progress in drag mode, placed markers yield
  pointer input (data-pn-hittest) so the 25px bubble cannot capture a
  selection drag; armed only by a >4px primary-button move from a
  non-overlay mousedown, so marker clicks and click-to-select paths are
  untouched. withMarkersYielded now restores (not clears) the attribute.

New regression tests for A, B1, B3, B4, D; A/B1/B3 mutation-verified
(fix reverted, test observed failing, fix restored).

* fix(annotate): make on-page marker numbers match exportAnnotations numbering

The HtmlViewer sync excluded GLOBAL_COMMENT annotations before numbering
while exportAnnotations numbers '## N.' sections across the FULL list
including globals — so an on-page 'Comment 2' could be '## 3.' in the
feedback the agent reads. The sync now derives each marker's number from
its position in the full createdA-sorted list (globals occupy a number
but ship no entry, leaving the correct gaps on-page). Export format is
unchanged.

New buildSyncNumbering helper + tests asserting a mixed list yields
identical numbers between the sync payload and exportAnnotations output
(mutation-verified against the pre-fix ordering).

* chore: sync stale workspace versions in bun.lock (0.26.1 -> 0.26.7)

* docs: document raw-HTML overlay model, multi-target types, and known limitations

- Data Types: add htmlAdditionalTargets to the Annotation listing plus
  the HtmlElementAnchor (including the optional normalized point used by
  placed markers) and HtmlAnnotationTarget shapes.
- Annotation System: describe the post-#1257 raw-HTML surface (placed
  comment markers + overlay-projected highlights, no inline mark
  mutation; durable anchors persisted, disposable markers projected) and
  the print-parity limitation.
- URL Sharing: note that share links intentionally drop HTML element
  anchors and additional targets (restore is text-search based, per
  sharing.multiTarget.test.ts).

* test: fix Range.getClientRects stub typing in the B1 cull test

* fix(annotate): hover-race teardown and unbounded one-shot dead-search passes

Polish round on the overlay hardening:

- Hover race (1): switching into pinpoint mode (or opening a draft) now
  tears hover down fully via clearHoverHighlight() — cancels the pending
  rAF hit test and clears the tracked position and id — and the rAF
  callback itself refuses to paint outside drag mode / with an open
  draft. Previously the pending callback re-applied the class after the
  mode switch and every flushQueuedHighlights re-painted it from the
  stale hoverHighlightId, leaving a permanent phantom hover.
- One-shot budgets (3): beginDeadSearchPass takes a per-pass budget.
  Reconcile passes keep 2 (they repeat, skipped targets get follow-up
  frames); print and scroll-to are user-initiated one-shots with no
  follow-up and now run unbounded (backoff and generation gates still
  apply), so printing with 3+ dead-but-recoverable targets no longer
  silently prints fewer highlights.

Both changes carry new regression tests, mutation-verified (fix
reverted, test observed failing, fix restored).

* fix(annotate): number markers by array position and cap entries after dropping globals

The createdA sort made the export-match invariant false with external
annotations: exportAnnotations' sort keys tie for every raw-HTML
annotation (blockId '', startOffset 0), so its stable sort numbers the
combined [...local, ...external] list in ARRAY order — and external
annotations arrive appended with server-stamped createdA values that can
interleave with local timestamps. buildSyncNumbering now numbers by
array position of the input (verified to be the same combined list both
consumers receive from packages/editor/App.tsx allAnnotations; the
viewerAnnotations diffContext filter is order-preserving and vacuous on
the raw-HTML surface).

Also reorders the cap: number the full list, drop globals, THEN slice
512 entries — globals no longer waste sync capacity and a non-global the
export numbers past position 512 still syncs while slots remain. Numbers
may now exceed 512 (array positions); the bridge's own bound (100000)
accepts them and its 512-entry cap still agrees with the sender.

Tests updated: interleaved-external agreement with exportAnnotations
(mutation-verified against the createdA sort) and slice-after-filter
capacity.

* docs(opencode): note the accepted cache-hint flattening trade-off in V2 consolidation
2026-08-10 15:27:04 -07:00
Michael Ramos be0b1f185d feat(annotate): placed comment markers for raw-HTML annotation (#1257)
* feat(annotate): overlay-projected placed comment markers for raw HTML

Annotation state is no longer written into the visited page's DOM. A
fixed, pointer-transparent, shadow-rooted overlay host (appended to the
root element, outside page layout) now owns every committed annotation
visual:

- numbered placed-marker buttons (product-owned SVG speech bubble,
  accent-token colors, accessible 'Comment N' labels) projected at the
  user's selected relative point, re-resolved from durable anchors and
  reprojected on scroll/resize/mutation/animation via rAF-coalesced
  invalidation (never polled)
- persistent highlight rectangles for text-range annotations (built
  from Range.getClientRects), replacing inline <mark> wrapping
- the focused (blue) treatment as overlay rects covering EVERY rect of
  EVERY target, replacing the .focused class that querySelector applied
  to only the first mark of a multi-paragraph selection
- the draft selection highlight as overlay rects from the live pending
  range

Markers omit rather than guess: unresolved anchors, zero-size targets,
viewport/clip-scrolled-away targets, and clamped points no longer
visibly associated with their target all hide the marker. Viewport-edge
clamping keeps the full marker reachable; coincident markers spread
horizontally and deterministically. Numbering is parent-authoritative:
HtmlViewer syncs the ordered saved-annotation list (panel order,
index+1) to the bridge; grouped multi-select targets all carry their
one annotation's number. The anchor DTO additively gains the normalized
selected point (validated and clamped at the trust boundary), captured
from pinpoint/shift/data-annotate clicks.

Fixes the partial/auto blue highlight (focus-mark and scroll-to touched
only the first mark) and page layout breakage (surroundContents plus
padding/negative-margin marks mutated author content).

* test(annotate): migrate bridge DOM suite to the overlay marker contract

Mark/badge assertions become overlay assertions: markers are queried
through the shadow overlay host, restoration binding is proven via
scroll-to targets instead of inline mark containment, and page-DOM
purity is asserted byte-for-byte across restores.

* test(annotate): regression coverage for the placed-marker overlay contract

Bridge-side (srcdoc.test.ts): layout neutrality (byte-identical page DOM
across restore/sync/focus/scroll, host outside body, pointer-transparent),
relative-point capture and reprojection with fresh geometry, unresolved-
anchor and scrolled/clipped omission, viewport edge clamping with
visually-detached omission, deterministic coincident spreading, parent-
synced numbering override and renumbering, malformed sync rejection,
full-coverage focus rects (partial-blue regression), overlay draft
highlight, and hit-test yielding beneath markers.

Parent-side (htmlPinpointProtocol.test.tsx): anchor-point validation
(clamping, hostile-point dropping without losing the anchor), point
propagation onto committed annotations, multi-target anchor points, and
the ordered saved-annotation number sync (createdA order, index+1,
globals excluded, never before bridge-ready).

* test(annotate): fix strict typings in new overlay tests

* fix(annotate): clip-test overlay highlights, gate dead-target re-search, honest edge clamping

- M1: every painted highlight rect (committed comment/deletion, focus flash,
  draft selection) is now intersected with the target's clip-ancestor chain
  via shared clipBoundsFor()/clipRect(); rects with no visible remainder are
  dropped, so inner-scroll-container content scrolled out of its box no
  longer paints stripes over unrelated content.
- M2: computed-style visibility gate (visibility:hidden/collapse,
  display:none, opacity:0) treats targets as unresolved-for-display, so
  markers/focus rects stop rendering over visible content stacked in the
  same box (e.g. visibility-toggled carousel slides).
- M3: dead-target re-search (whole-document findTextRange + anchor
  re-resolution) is generation-gated: the generation advances only on
  text-capable signals (page mutations, settle events, frame loads), never
  on scroll/resize, and each target caches its last failed generation.
- m3: clipBoundsFor skips plain static overflow clippers for position:fixed
  targets until a fixed containing block (transform/perspective/filter/
  backdrop-filter/will-change) is reached.
- m4: marker association is tested against the UNCLAMPED point; the 29px
  viewport inset is rendering-only, so fully visible edge-flush elements
  keep their markers (dead band removed; sliver test updated — it conflated
  viewport-edge clamping with clip-container omission).
- m5: reconcile on document.fonts.ready and capture-phase subresource load
  (geometry-only: they never unlock dead-target re-search).
- m7: Range.getClientRects containment filter drops border boxes that
  duplicate their own line rects (redline double-paint).
- m9: settle events from viewer overlay nodes are identity-filtered out.
- m10: one marker per resolved element per record during refresh.
- m12: painting caps at 48 rects but the marker anchors to the TRUE last
  client rect.

* fix(annotate): restore print parity for committed highlights (M4)

The branch's '@media print { .pn-layer { display:none } }' hid ALL
annotation visuals in print, but pre-overlay the inline highlight marks
stayed visible in print on purpose (only pin badges were print-hidden).
On beforeprint (plus a matchMedia('print') mirror for Safari), committed
comment/deletion rects are re-projected into a temporary absolute-positioned
light-DOM layer in document coordinates so it paginates with content, and
torn down on afterprint. Markers remain print-hidden (parity: highlights
print, markers don't). A screen media guard keeps the layer from ever
flashing on screen, and any build error fails safe into printing without
visuals.

* fix(annotate): restore highlight click-to-select; marker-consistent pinpoint hover (M5, m6, m8, m11)

- M5: clicking anywhere on a committed range highlight posts mark-click
  again (pre-overlay parity). Rects stay pointer-transparent — the document
  bubble click handler hit-tests the point against the painted committed
  rects (smallest wins on overlap, ties to the topmost/later annotation).
  Coexistence matches the old '.annotation-highlight' handler: capture-phase
  pinpoint annotate clicks stopPropagation() first, marker buttons stop
  propagation, shift-clicks and drag-selection tails are skipped, and
  [data-annotate] elements defer to a highlight under the click point.
- m6: when the raw (pre-yield) pinpoint hit is a placed marker, the hover
  advertises the MARKER's identity (label 'Comment N', no annotate box)
  instead of labeling the element beneath — click and hover now agree.
  Annotating beneath still works by moving off the 25px bubble.
- m8: the pinpoint hover label is kept AFTER the overlay host on the root
  element (re-appended when not last), so marker bubbles can never occlude
  it at equal z-index.
- m11: clear-marks also clears the parent-synced number map so stale
  numbers cannot leak onto future records reusing an id.

* fix(annotate): parent-side bounds — cap mark-click ids, truncate the sync feed at 512 (m1, m2)

- m1: parseBridgeMessage rejects mark-click ids longer than 256 chars — the
  one page-controlled string in the changed path that lacked a length cap
  (the bridge's own sync validation already caps ids at 256).
- m2: the HtmlViewer sync-annotations effect slices the ordered collection
  to 512 entries AFTER the stable sort, mirroring the bridge-side
  MAX_SYNC_ANNOTATIONS bound so both sides agree on the first 512 numbers.

* test(annotate): committed-range extent assertions + fix-round regression coverage

- M6: restores the migration-dropped EXTENT assertions via a test-only
  bridge introspection hook (committedRanges): the vim Visual commit binds
  exactly 'Alpha ', the visual-block commit exactly 'Whole block target',
  and the pin-restore scoped range binds inside the anchored element
  covering exactly 'Anchor target text' (the scroll proxy only checked the
  element target).
- Regression tests for every behavioral fix: clip-tested highlight rects +
  focus flash (M1), style-hidden visibility gate (M2), generation-gated
  dead-target re-search (M3), print-parity layer lifecycle (M4),
  highlight click-to-select with smallest-wins overlap (M5), fixed-position
  clip exemption (m3) plus preserved clip-container omission, containment
  filter (m7), refresh dedup (m10), clear-marks numbering reset (m11),
  true-last-rect marker anchoring past the paint cap (m12), and
  marker-consistent pinpoint hover with label paint order (m6/m8).
- Parent-side: sync feed truncation at 512 after the stable sort (m2) and
  the 256-char mark-click id cap (m1).
- Test honesty: the hit-test-yield test now string-asserts the exact
  ':host([data-pn-hittest]) .pn-marker' pointer-events rule, since the
  elementFromPoint mock implements the yield itself.
- Tests advance the re-search generation via the settle-event signal:
  happy-dom stops delivering body MutationObserver callbacks once the
  overlay host holds an SVG marker button (environment bug; isolated repro
  without any bridge code — real browsers are unaffected).

* fix(annotate): bound range-rect collection at 48 with a by-index true-last read

The m12 fix had removed the collection cap: rangeClientRects materialized
EVERY client rect and the O(n^2) containment filter ran over the full list,
per range target per rAF reconcile and synchronously per click hit-test. A
large drag-selection or redline (the Range extent is uncapped — only the
selection text is capped at 10k chars) yields thousands of rects, i.e. tens
of millions of iterations per scroll frame.

Collection now breaks at MAX_HIGHLIGHT_RECTS again, so the zero-size and
containment (m7) filters operate on at most 48 entries, and the m12
requirement is met by reading the DOMRectList's final entry directly by
index (with the marker-association union extended to that tail rect, and a
zero-size tail falling back to the last paintable rect). Regression test:
60 mocked rects with a containing border box paint 47 (cap + containment)
while the marker anchors at the true 60th rect; mutation-verified against
both an uncapped collection and a capped-prefix last-rect read.

* fix(annotate): drop the opacity:0 display gate; contain-aware fixed clipping; dedup among placed markers

- The M2 gate's opacity:0 leg hid markers for the legitimate
  invisible-hit-target pattern (transparent input stretched over a styled
  control — the pinpoint hit resolves the input and pre-overlay the marker
  rendered exactly over the visible control), and failed its own carousel
  motivation anyway: computed opacity does not inherit, so a container
  faded to 0 leaves descendants at computed 1. visibility:hidden/collapse
  and display:none remain the gate.
- establishesFixedContainingBlock also treats contain layout/paint/strict/
  content and container-type size/inline-size as establishing a fixed
  containing block, so such clippers correctly apply to fixed targets.
- The per-record element dedup now runs among PLACED (visible) markers
  only: a target whose stored point is clipped away no longer consumes the
  element's slot and suppresses a sibling target whose point is visible.

Tests updated/added and mutation-verified: opacity keeps the marker,
contain:layout re-applies the clipper, and the visible sibling survives
the dedup.

* fix(annotate): watch documentElement for page mutations; print layer on the root element

- The M3 generation gate could lock out re-search forever when a page swaps
  the <body> element itself: the observer watched document.body, so
  documentElement.replaceChild(newBody, oldBody) produced no record, no
  generation bump, and a dead target whose one free retry ran against the
  interim skeleton never retried again. The observer now watches
  document.documentElement (same config), so body swaps and the new body's
  content are in-subtree.
- Consequence handled: childList mutations on the root/body whose
  added/removed nodes are ALL overlay-registered (host append, hover-label
  re-append, print-layer insert/remove) are filtered out via
  isOverlayOnlyMutation, so overlay writes neither bump the generation nor
  schedule the reconcile frame that caused them. The print layer stays
  overlay-registered through its async removal record (retired-layer
  deregistration is deferred to the next lifecycle step).
- The print layer is appended to documentElement instead of body: a page
  styling body { position: relative } made body's padding box the containing
  block and shifted every stripe by body's document offset. On <html> the
  containing block is the ICB, matching the viewport+scroll coordinates; a
  positioned documentElement is accepted as out of scope (commented).

Tests: the bridge's observer is captured at load (happy-dom stops
delivering records once the overlay host holds an SVG marker — environment
bug, so scope tests assert the observed target and drive the callback with
synthetic records): body-swap unlock, overlay-only no-bump, and the
print-layer parent are all covered and mutation-verified.
2026-08-10 13:16:27 -07:00
Michael Ramos f8951cd3c6 feat(annotate): shift-click multi-element selection for raw-HTML pinpoint (#1254)
* feat(annotate): shift-click multi-element selection for raw-HTML pinpoint drafts

One comment covering multiple targets: shift-clicking elements while a
pinpoint draft composer is open toggles them in/out of the SAME draft.

- bridge: pendingMultiTargets registry with per-target pinned outline boxes,
  DOM-identity + anchor-equality toggle dedup, primary promotion on removal,
  draft cancel on last removal, shift-hover preview, rAF pointer relay for
  the composer yield, remove-target/flash-target parent messages, capped at
  16 additional targets at the source
- pins: registerPin now allows several elements per annotation id (deduped
  by (id, element)/(id, anchor)); badges number by first-seen id so all
  targets of one annotation share one number; find-and-mark restores
  additionalAnchors as same-numbered pins (anchor-only, fail-closed)
- parent hook: multi-target-added/removed/pointer messages validated and
  capped at the trust boundary (key<=64, label<=64 truncated, text via the
  10k surrogate-safe cap, anchors via parseHtmlElementAnchor, array cap 16);
  draftTargets state, chip removal with deterministic promotion mirrored on
  both sides, composerFocusToken for focus return
- types: additive Annotation.htmlAdditionalTargets (label/text/anchor per
  extra target; anchor optional so fail-closed targets still export)
- CommentPopover (all seams optional, default off): horizontally scrollable
  target chips with remove buttons and hover-to-flash, refocusToken,
  captureStrayKeys first-keystroke guard, yieldState fade/click-through with
  180ms transition and prefers-reduced-motion fallback
- HtmlViewer: composer-yield state machine (composerYield.ts) fed by parent
  mousemoves plus bridge-relayed pointer positions, with 48px/96px hysteresis
- export: multi-target comments append an 'Also applies to N more elements'
  block (label + excerpt per target); single-target output byte-identical
- share URLs: additional targets are dropped exactly like htmlAnchor (the
  compact tuple format never carried anchors); drafts carry them verbatim

* test(annotate): cover shift-click multi-select across bridge, DTO, composer, export, drafts, sharing

- srcdoc.test.ts (bridge DOM): shift-click add + toggle-off with echoed
  removals and per-target pinned boxes; create-mark commits all targets under
  one id with one badge number (second annotation numbers 2); primary
  promotion and last-removal cancel; parent remove-target mirrors without
  echo; flash-target; 16-target cap at the source; find-and-mark restores
  additionalAnchors as same-numbered pins with stale anchors failing closed
- htmlPinpointProtocol.test.tsx: multi-target-added/removed/pointer DTO
  validation (key/label/text/anchor caps, hostile payloads), selection
  targetKey/targetLabel validation; mounted-composer flows — primary chip,
  shift-adds into ONE submitted comment carrying htmlAnchor + 2 additional
  targets, single-target submit shape unchanged, promotion onto the comment,
  last-removal closes the composer, chip removal, 16-cap at the trust
  boundary, drag selections never arm multi-select
- CommentPopover.multiTarget.test.tsx: chips render primary-first with
  remove/hover handlers, refocusToken focus return, captureStrayKeys stray
  keydown routing (and non-interference when focused), yieldState classes +
  reduced-motion-aware 180ms style; default composer renders none of it
- composerYield.test.ts: distance + hysteresis state machine (48px over-exit,
  80/96px near enter/exit)
- parser.test.ts: multi-target export block (labels, excerpt clipping,
  fail-closed targets) and byte-identical single-target output
- useAnnotationDraft.seam.test.tsx: multi-target annotations round-trip the
  draft transport verbatim (save body + restoreDraft)
- sharing.multiTarget.test.ts: share tuples drop anchors AND additional
  targets while the comment itself still shares

* fix(annotate): keep pinpoint drafts alive when the pinned element scrolls out of view

Found by the real-browser signoff harness: reaching a second element to
shift-click often scrolls the pinned primary out of the viewport BEFORE any
additional target exists, and the scroll-out teardown then cleared the
bridge's pin state mid-compose — the shift-click landed on a dead draft and
started a new one instead of adding to it (and even a single-target pinpoint
draft silently lost its visual pin on commit after scrolling).

Pinpoint drafts (pendingPinViaPinpoint) now survive scroll-out; drag
selections keep the existing close-on-scroll-out behavior unchanged.

* fix(annotate): address adversarial review of multi-select (arm handshake, label sanitization, iframe shift relay, removal resync)

D1 (blocker): the bridge accepted shift-toggles for ANY pinpoint draft while
the parent only mirrors targets when the comment composer owns it — in
quickLabel mode the user could pin elements the saved annotation would never
carry. Multi-select is now ARMED EXPLICITLY: the parent posts
arm-multi-select (keyed to the primary, so a stale arm can never arm a new
draft) only from the composer flow, and the bridge refuses the toggle —
shift-click behaves as a plain click — until armed.

D2: target labels derive from page-controlled attributes (aria-label), so
newlines could smuggle real markdown structure (fake headings) into
agent-read feedback. parseTargetLabel now collapses all whitespace at the
trust boundary, and the exporter collapses again (defense in depth for
persisted pre-fix drafts).

D3: the composer yield armed Shift only from parent-window keydown/mousemove,
but window blur (focus entering the iframe) cleared it and modifier keydowns
don't reach the parent from the sandbox — from the second shift-click on,
the composer never yielded. The bridge pointer relay now carries the
observed shiftKey (validated strict boolean) and arms/disarms yield directly.

D4: a forged multi-target-removed desynced parent (promotes) from bridge
(keeps original). applyTargetRemoval now ALWAYS echoes remove-target —
idempotent for legit bridge-side removals, forcing convergence after forgery.

D5: the 'Also applies to N more elements' block gains a leading blank line so
markdown lazy continuation cannot fold it into the preceding blockquote.

D6: the stray-key guard registers in capture phase (a bubbling global
shortcut can no longer both fire and have its character appended), inserts at
the textarea's remembered caret instead of end-of-text, and refocusToken now
preserves the caret rather than jumping to the end.

D7: the expanded dialog no longer carries the dead yield class/style — its
data-comment-popover wrapper spans the viewport, making proximity
meaningless; the dialog deliberately does not yield.

Also reverts the incidental bun.lock version-catch-up churn.

Tests: unarmed/stale-arm refusal, quickLabel non-arming and non-mirroring,
newline-label collapse at both layers, bridge-shift-driven yield, forged
removal echo + bridge-side idempotent resync, caret-preserving stray keys,
blockquote separation. Signoff harness re-run green (14/14) on
rules-ui-signoff.html including the arm handshake.

* fix(annotate): reset multi-select arm on every new pinpoint draft (D1-R)

The re-review caught that multiSelectArmed was never cleared when
annotateElement started a fresh draft — only clearPendingPin reset it.
So a comment-mode draft (armed) followed by a mode switch the parent
doesn't mirror (quick label posts no arm) and a new pinpoint click left
the stale arm live: the bridge accepted shift-clicks and pinned elements
the saved annotation would never carry. Reset the flag at the top of
annotateElement alongside clearMultiTargets.

Regression test reproduces the exact sequence (armed draft -> new
unarmed draft -> shift-click must not add a target); mutation-verified
that removing only this reset fails it.

* docs(annotate): correct the first-keystroke guard comment reasoning

The comment claimed capture phase prevents a global shortcut from also
firing; preventDefault does not stop the dispatcher (it ignores
defaultPrevented by design). Restate the actual invariant: the guard is
safe only because no bare printable single-key binding exists on this
surface, and flag that as a constraint for future bindings. Comment only.
2026-08-10 10:19:21 -07:00
Michael Ramos 608a8003aa feat(annotate): hit-test pinpoint targeting for raw-HTML sessions (#1251)
* feat(annotate): hit-test pinpoint hover instead of the semantic whitelist

Pinpoint hover in raw-HTML sessions now resolves the real element under
the cursor via deep elementFromPoint (piercing open shadow roots) instead
of the SEMANTIC_* tag-whitelist graph, so styled div/span prototypes
(chips, icon buttons, cards) are individually targetable. Scope control
stays mouse-only and geometric, matching the markdown surface: the
deepest element under the pointer wins; pointing at a container's padding
or any area not covered by a child selects the container.

- Exclusions are identity-based: html/body, script/style, and an identity
  Set of viewer overlay nodes (pin badges, pinpoint box/label, vim UI) so
  page markup cannot spoof its way out of targeting. Annotation marks are
  transparent (resolve to their parent).
- Tiny-element promotion: a leaf under 16px on both axes climbs to the
  nearest ancestor at least 16px on one axis (MIN_CAPTURE_SIZE floor,
  not a whitelist). SVG shape primitives still promote to their <g>.
- Hover no longer builds the semantic target graph per pointer frame
  (graphForPointerFrame removed); it is per-event hit-testing with a
  2px/16ms last-position cache that the scroll reconcile invalidates.
  The graph survives untouched as the vim-navigation vocabulary.
- Hover labels for generic containers use a cascade: aria-label -> role
  -> 1-2 meaningful class tokens (hash-ish tokens stripped) -> own text
  when <= 40 chars -> 'container'. Known semantic tags keep their names.
- Click annotates exactly what the hover box shows; text-less elements
  post '[element: <label>]' instead of being untargetable.
- Anchors stay fail-closed: data-test-id/data-cy/data-qa join the
  author-controlled identity attrs, and text-less elements store a
  '[[pn-shape]]' signature (tag + sorted classes + child count + 16px
  size buckets) verified whole-string on restore.

* test(annotate): cover the pinpoint hit-testing contract

New DOM regression tests modeled on the signoff prototype pages:
chip/button targeting on styled div/span markup, container selection via
uncovered-area pointing, tiny-element promotion (mocked 8x8 dot promotes
to its 200x100 card), the generic-container label cascade, the text-less
shape-signature anchor round trip (verify + fail-closed rejection on
shape drift), data-cy identity-attr trust, and an instrumented assertion
that hover hit-testing performs zero document-wide querySelectorAll
calls across 30 mousemoves. Also reattaches the cached hover label
element when the body was replaced (parity with the hover box), and
accepts the shape-prefixed snapshot through the parent-side DTO test.

* fix(annotate): drop shape-signature anchors; fail closed on text-less weak selectors

Review findings on the pinpoint hit-testing branch:

- D1 (blocker): structure-derived shape signatures are identical across
  identical siblings by construction, so a positional selector whose
  sibling was removed resolved the WRONG element while the signature
  still verified — the exact wrong-binding failure the anchor system
  exists to prevent. The mechanism is removed entirely: text-less
  elements now anchor ONLY when the element itself carries a
  stable-identity rung (#id or the data-* identity attrs); otherwise no
  anchor ships and the pin simply does not restore (same as before the
  branch, fail closed). A wrong-binding anchor is worse than no anchor.
  D2-D4 (in-band prefix discriminator, uncapped class-list component,
  viewport-dependent size bucket) die with it.
- D5: the pinpoint click handler gated pin-badge ownership on a
  [data-plannotator-pin-badge] selector match, contradicting the
  identity-only overlay rule. It now gates on the overlay identity set:
  a page element spoofing the attribute hovers and annotates like any
  other element, while real badges keep owning their clicks.
- D6: class-token hover labels now route through the 40-char label cap
  (two long tokens could reach 73 chars), and the '[element: ...]'
  posted text goes through capSelectionText for consistency.
- D7: the pointer-hit cache invalidation moved to the top of the rAF
  reconcile, ahead of its guards, so every reconcile pass (scroll,
  resize, and body ResizeObserver — i.e. layout-changing mutations)
  clears it; the 2px/16ms TTL bounds any remaining staleness to one frame.

Tests updated to the new contract, with regressions for D1 (identical
text-less siblings ship no anchor; crafted positional anchors with empty
snapshots resolve nothing after sibling removal), D5 (spoofed badge is
annotatable, real badge posts mark-click), and the D6 label cap.
2026-08-09 23:01:31 -07:00
Michael Ramos 2d61b76c44 fix: address the three pre-release sweep findings (#1246)
Three real, new-in-range bugs from the free-hunt regression sweep:

- The pinpoint anchor builder ran a document-wide uniqueness query per
  ancestor against a growing selector with no depth cap, so one click on
  a deeply wrapped document froze the tab synchronously (measured 58s at
  depth 800). The walk now abandons the anchor past 40 ancestors (fail
  closed: text-search restoration takes over), bounding the work to
  interactive time. Regression test uses two identical depth-60 chains
  so uniqueness cannot short-circuit before the cap.

- The 10k selection-text cap sliced UTF-16 code units and could split a
  surrogate pair at the boundary, silently corrupting the annotation
  tail into U+FFFD downstream. Both sides of the bridge now back the cut
  off one unit when it would land mid-pair.

- The old-git sparse fallback matched git's "unknown option" error
  literally, which localized git builds translate, so non-English users
  on old git hit the exact hard failure #1239 was written to fix. All
  three installers now pin LC_ALL=C around the probe clone (saved and
  restored; kept single-line in install.ps1 for the scoped-call
  scanner, whose expectation is updated to the new prefix).
2026-08-09 17:48:05 -07:00
Michael Ramos 9b14b19a7b chore: fold in pre-release QA findings (print overlays, doc alignment) (#1245)
* chore: fold in pre-release QA findings (print overlays, doc alignment)

From the 25-item QA sweep over the v0.26.4..main range (all items passed;
these were the three real minors worth folding into the release):

- The raw-HTML iframe's injected CSS had no print rules, so pin badges
  (and, in a narrow window, the pinpoint outline box) printed into
  hard copies of annotated HTML pages. The overlay elements now carry an
  explicit @media print hide inside the iframe document, where the outer
  print.css cannot reach. Inline annotation marks stay printable on
  purpose, matching markdown documents.
- AGENTS.md/CLAUDE.md now state that PLANNOTATOR_ANNOTATE_HISTORY also
  gates the durable submitted-feedback records from #1237, and the
  Annotation interface listing includes the htmlAnchor field from #1243.
- apps/codex/README.md aligns with the #1241 top-level wording (Windows
  Codex hooks are experimental with printed manual steps, not disabled).
- Marketing docs: annotate page documents the pinpoint-first default and
  minimal-first chrome for raw-HTML sessions; installation page notes
  the old-git plain-clone fallback from #1239.

* chore: drop deprecated marketing-docs edits (canonical docs are Mintlify)
2026-08-09 17:19:50 -07:00
Michael Ramos 9ab5392bad feat(annotate): pinpoint-first raw-HTML sessions with element anchors and a minimal-first render (#1243)
* feat(annotate): rebuild HTML pinpoint mode with element anchors, pin badges, and a minimal-first render

Raw-HTML annotate sessions now open as just the page: first-ever run hides
all annotation chrome (toolstrip, tongue tabs, action cluster, sidebar) and
the user's last chrome state is restored on every later session via the
plannotator-html-chrome cookie. Pinpoint becomes the default input method on
the HTML surface, persisted separately from the markdown preference.

Pinpoint mechanics are rebuilt in the sandbox bridge, modeled on
app-notes-extension and agentation: a fixed-position outline box + label
replaces class writes on author elements, hover is identity-gated and
re-hit-tested on scroll/resize via a rAF-coalesced reconcile pass, clicks
pin the element (crosshair cursor, pinned outline while composing) and go
straight to the comment popover. Each pin serializes a verified-unique CSS
selector anchor (id > identity attrs > meaningful classes > nth-of-type
path) with a text-snapshot fingerprint; restoration is anchor-first with
fail-closed validation and falls back to document-wide text search. Element
pins that cannot take an inline mark (SVG etc.) get numbered pin badges that
track their element and re-acquire it after re-renders.

All DOM inspection stays inside the bridge; only validated, size-capped DTOs
cross postMessage. Annotation model is extended additively (htmlAnchor), so
exported feedback, drafts, and share links keep working.

* fix(annotate): harden HTML element anchors per adversarial review

Four fixes from the pre-merge adversarial review of the pinpoint round:

- Stable identity is now only the element's own #id or data-* rung,
  re-derived from the resolved element and compared whole instead of
  parsed out of the selector string. Behavioral attributes (role, href,
  aria-label, name, alt) no longer exempt an anchor from the text check,
  so a regenerated page can no longer bind an annotation to the wrong
  element. A weak anchor with a missing or empty snapshot is rejected,
  not exempted.
- Restoration order: the document-wide text search now runs before the
  pin-badge fallback, so text that moved elsewhere in a regenerated page
  is followed rather than badging the stale container. Resolved SVG
  anchors still pin directly.
- Selection text is capped at 10k chars on both sides of the bridge
  (truncated, not rejected); one pinpoint click on a huge pre/table no
  longer ships an unbounded page-controlled string into drafts,
  feedback, or share URLs.
- buildAnchorSelector returns null instead of a terminal fallback path
  already proven non-unique; no anchor beats a known-ambiguous one.

Also moves the HTML chrome restore out of the one-shot mount effect into
a surface-transition effect, so a linked .html doc opened from a
markdown session gets the minimal-first chrome and persists its state,
and a markdown surface's sidebar use never leaks into the HTML cookie.

* fix(annotate): skip the chrome save in the restore commit itself

The reviewer's residual finding: htmlChromeRestoredRef flips synchronously
inside the restore effect, but the restored state lands a commit later, so
the save effect's run in the restore commit wrote pre-restore values over
the remembered state (self-corrected next flush, but a page ending between
the two writes would freeze the inverted value). The restore now arms a
skip-one flag the writer consumes, so the stale write never happens. A
follow-up save fires from the changed deps when the restore changed
anything; when it changed nothing the cookie already holds those values.

Regression test instruments every chrome cookie write on a returning-user
mount and asserts none ever differs from the remembered state (mutation
tested: removing the skip fails it).
2026-08-09 16:17:06 -07:00
Raúl 183cae2c44 fix(vim): keep j/k cursor clear of HUD bands when scrolling (#1154)
Document Vim navigation moved the cursor with
`Element.scrollIntoView({ block: 'nearest' })`, which parks the target
flush against the nearest viewport edge — exactly where the sticky action
bar (top) and the key HUD / status pill (bottom) float. Motion to the top
or bottom of a document then hid the caret behind an overlay, while a
mouse wheel (which the browser lets overshoot) kept the same line nearer
centre.

Add vimScroll.ts: a pure computeVimScrollDelta returning the signed
scrollTop delta needed to clear a HUD band at each edge (0 when the
target is already inside the safe band; a target taller than the band
aligns to its top edge so reading order wins), resolveVimScrollMargin
sizing the fallback band as clamp(20% of viewport height, 24px..160px),
and a scrollVimTargetIntoView wrapper.

Both bands are measured from live geometry instead of guessed constants:
the top band widens past the ratio margin to clear the sticky action
bar, and the bottom band derives from the portaled key HUD / mode pill
rects ([data-vim-key-hud] / [data-vim-mode-badge]). The opt-in key HUD
(fixed bottom: 150, height: 88 — a ~238px band, past the 160px clamp) is
actually cleared, while the default pill reserves ~49px instead of
over-reserving 160px. An expanded key HUD is a deliberate modal state
that can outgrow the viewport, so it keeps the ratio margin.

The scrolling element is the native-scroll host fed through
ScrollViewportContext, so the wrapper takes that element from the caller
(useScrollViewport() in Viewer, the same node the reticle measures
against) and falls back to the historical scrollIntoView when it is
absent, so behaviour never regresses.

Route every cursor/target move in useVimSelection through it, and add
vimScroll.test.ts to the DOM allowlist in test.yml so its integration
tests run in CI.
2026-08-09 16:16:59 -07:00
Michael Ramos 3dd80f37f0 fix(skills): remove the human-only menu treatment and its hover jitter (#1236) 2026-08-07 15:12:35 -07:00
Michael Ramos ffd49080ee fix(skills): harden skill references before first release (#1235) 2026-08-07 14:22:58 -07:00
Michael Ramos 7ad4d39ed9 feat(comments): reference agent skills with / or $ in plan review and annotate comments (#1229)
* feat(comments): reference agent skills with / or $ in plan and annotate comments

Typing / or $ at the start of a word in the document-UI comment composer
opens a picker of the user's global agent skills (~/.claude/skills,
~/.codex/skills, ~/.agents skills roots), served by a new GET /api/skills
on the plan and annotate servers in both runtimes (Bun + Pi mirror).
Multiple references per comment are supported; references live in the
comment text itself and are appended to exported feedback as a
'Skills referenced' block so the acting agent knows which skills to apply.

Human-invocation-only skills (disable-model-invocation: true frontmatter)
stay listed and selectable but render dimmed with a badge, warn in the
menu and composer, and are marked in the export so the agent is never
asked to invoke something it cannot.

Discovery reuses the review-skill loader (same roots, precedence, and
skip-and-log discipline), reads only an 8KB head per SKILL.md, caps the
catalog at 500 skills, takes no client input, and is never persisted;
any failure degrades to plain typing.

* fix(comments): harden skill references per review (trigger, IME, seam, fail-closed frontmatter)

Blockers:
- B1: a trigger now requires at least one query character. A bare / or $
  no longer opens the catalog, so Enter stays a newline and Tab still
  leaves the field ("This costs $" + Enter, "cd /" + Tab, bullets).
- B2: the menu ignores keys mid-IME-composition (nativeEvent.isComposing),
  matching the 16 existing guards; Enter committing a Pinyin/Telex/Korean
  candidate can no longer insert a skill.
- B3: the catalog request is a host seam (skillCatalogTransport via
  configurePlannotatorUI), defaulting to the existing GET /api/skills.
- B4: resetSkillCatalogCache() invalidates outstanding requests
  (generation counter), and a late-resolving stale request can no longer
  overwrite a newer cached value or the export registry. The catalog
  tests reset in beforeEach, so they hold in any file order.

Also:
- F1: skillReferences={false} is fully inert — the human-only notice memo
  and the cache seed are gated on the prop.
- F4: frontmatter flag parsing no longer fails open: trailing YAML
  comments are stripped, on/1 (and TRUE/yes etc.) read as true, the head
  read is 64KB, and truncated unterminated frontmatter fails CLOSED on
  disable-model-invocation.
- F5: extraction ignores markdown link destinations ([x](/name)), shell
  redirects (cat /x > out), and /-triggered FHS root names (/run, /tmp);
  menu insertion switches / to $ for those names so inserted references
  always survive extraction.
- F6: the 500-skill cap slices after sorting, so which skills survive no
  longer depends on readdir order.
- F3: /api/skills wiring guards for the Bun and Pi plan + annotate
  servers (skills-endpoint.test.ts).
- Keyboard state machine tests against the real CommentPopover in
  happy-dom (bare trigger, insertion, composition, Escape, highlight
  bounding, opt-out inertness), added to the CI DOM step.
- The insertion path dismisses the trigger start so the menu close is
  ordering-safe against React's select-plugin re-reading a stale caret.

* feat(comments): redesign the skill reference menu (bare triggers, no preselection, highlighted tokens)

Per maintainer direction, reversing the earlier bare-trigger opt-out
deliberately: typing a bare / or $ at the start of a word now opens the
full skill catalog immediately, and the safety story moves from the
trigger to the menu itself.

No preselection (the load-bearing rule): the menu opens with NO row
active, and while nothing is active every key behaves exactly as if the
menu were closed. "This costs $" + Enter is a newline; "cd /" + Tab
leaves the field (the proven regression that must never return). A row
activates only via ArrowDown/ArrowUp (Down from none lands on the first
row, Up on the last); only then do Enter/Tab insert. Pointer hover never
activates a row, because the menu floats exactly where the mouse rests
over the composer; a click inserts directly and never arms Enter.
Continuing to type re-filters and disarms any active row. Escape clears
the active row and dismisses when the user engaged (query typed or row
active); an unengaged bare-trigger menu passes Escape through so closing
the composer still costs one press.

Menu redesign to the reference look: icon, bold name, dimmed inline
description with ellipsis, right-aligned source column (Agents / Claude
/ Codex from the discovery roots), rounded generously padded rows, and a
subtle active-row background; human-only rows stay dimmed with their
badge and the warning now shows while such a row is ACTIVE.

Inserted references render highlighted in the composer via a mirrored
aria-hidden overlay behind a transparent-text textarea (identical font,
padding and wrapping metrics; scroll synced; tokens change color and
background only, drawn from the --primary theme token so every palette
works in light and dark). The caret keeps --foreground, selection uses a
translucent primary wash, and IME composition temporarily restores
native textarea text so composition underlines render normally.
skillReferences={false} still renders the plain pre-feature textarea.

Also, per review:
- extraction: dropped the over-broad shell-redirect exclusion (false
  negatives on prose like "use /animate <- this one"; the motivating
  case stays covered by the reserved-path rule)
- frontmatter: an unterminated frontmatter block now fails CLOSED on
  disable-model-invocation even in complete (untruncated) files
- the reserved-path / to $ insertion switch stays: extraction still
  reads /run as a path, and the new token highlight makes the switch
  self-explanatory (an unhighlighted insert would look broken)

The composition guard, transport seam, catalog generation counter,
enabled gating, and export rules are unchanged and re-covered by the
rewritten DOM test matrix.

* fix(comments): give the skill reference menu adaptive, viewport-clamped placement

The menu rendered bottom-full with a fixed max-h-64: always upward, up to
256px, with no viewport awareness. With the comment popover near the top of
the viewport (annotating near the top of a document), typing a trigger ran
the menu off the top of the screen with its upper rows unreachable.

Placement now mirrors the popover's own computePosition idiom: measure the
space above and below the composer wrapper against window.innerHeight,
prefer above (the shipped direction; keeps the action row and human-only
notice visible), flip below when the list fits below but not above, and when
neither side fits pick the roomier side. The list's max height is clamped to
the available space (still capped at the former 256px), so the menu never
extends past a viewport edge. Recomputes on every commit (drag moves,
popover flips, filtering changing the item count, warning-footer toggles)
plus capture-phase scroll and resize listeners, matching the popover's
tracking. Visual design of the menu and rows is unchanged.

* feat(comments): inject human-only skill instructions into exported feedback

A human-only skill (disable-model-invocation: true) referenced in a review
comment used to export as a dead name the agent could do nothing with. A
human referencing a human-only skill IS the human invocation, so the export
now injects the skill's SKILL.md body verbatim (frontmatter stripped) inside
clearly delimited BEGIN/END SKILL INSTRUCTIONS markers, with the absolute
skill directory and the resolve-relative-paths pointer so references/,
scripts/, and assets/ stay actionable. Model-invocable skills keep exporting
as names the agent can invoke itself.

Transport is lazy: a new GET /api/skills/content?name= endpoint (Bun and Pi)
serves one discovered skill's body, capped at 20k chars with an explicit
truncation notice pointing at the file; the client fetches contents only for
the human-only skills actually referenced, keyed off comment state, and the
catalog now carries each skill's absolute dir so every failure path (deleted
skill, unreadable file, race with submit) degrades to naming the skill plus
its directory. Names are matched against discovery only and never used as
paths, so traversal cannot escape the skill roots. A per-export dedupe
injects each skill once even when several comments reference it, and
GLOBAL_COMMENT annotations run through the same block.

The referenced-skills header now says the reviewer is asking for the
invocation, and the human-only menu footer and composer notice explain that
the skill's instructions will be included with the feedback instead of
warning that the reference will not work.

* polish(comments): quiet, progressive human-only skill treatment

The human-only surfaces shipped with too much emphasis: a dimmed row plus
a bordered uppercase badge, an amber warning footer, and a persistent
amber notice in the composer after insertion. Human-only is a property of
a skill, not an error state, so the treatment is now quiet and
progressively disclosed:

- Menu rows render at full strength with a small muted 'human-only' pill
  (bg-muted / muted-foreground tokens; no border, no dimming).
- The plain-language explanation (a model cannot invoke it, so its
  instructions will be included with your feedback) appears as a muted
  footer only while a human-only row is active (keyboard) or hovered
  (pointer). Hover disclosure is purely visual state local to the menu;
  it never touches activeIndex, so the no-preselection invariant and the
  hover-never-arms-Enter rule are unchanged and re-asserted by a new test.
- When not disclosed, the same sentence stays in the DOM sr-only and
  human-only rows point at it with aria-describedby, so the state reaches
  assistive tech as text rather than as a purely visual badge (this does
  not attempt the #1233 combobox semantics, and does not worsen them).
- After insertion, the highlighted token itself carries the quiet inline
  marker (a dotted primary underline; text-decoration cannot move glyphs,
  so overlay alignment is untouched) and the standing amber notice is
  replaced by a native <details> disclosure: a single muted 'Includes
  skill instructions' summary line that expands to the full accurate
  sentence, operable by pointer, keyboard, and AT alike.

No amber remains; every color is a theme token (muted, muted-foreground,
border, primary, ring), so the treatment follows every palette in light
and dark. Copy is unchanged where it was accurate. Behavior is unchanged:
human-only skills stay selectable and injection still happens.

* fix(comments): harden human-only skill injection per adversarial review

Three findings on the injection path, each with tests that fail pre-fix:

1. Marker forgery: an injected SKILL.md body containing our own
   `--- BEGIN/END SKILL INSTRUCTIONS ---` markers (or an
   `[Instructions truncated:` notice) could close the block early — making
   everything after it read as the reviewer's own words — forge a block for
   a skill nobody referenced, or forge a truncation notice pointing at an
   attacker-chosen path. Body lines matching the structural marker forms
   (leading-whitespace and case variants included) are now visibly
   neutralized before injection: kept verbatim but prefixed, never silently
   deleted (neutralizeSkillMarkerLines).

2. Forged human invocation: POST /api/external-annotations is
   unauthenticated on localhost, so any local process could submit a
   comment referencing a human-only skill and cause its instructions to be
   injected "at the reviewer's request". Annotations carrying a `source`
   now still LIST their skill references but never cause verbatim
   injection — human-only references fall back to naming the skill plus
   its directory, with an honest reason. The content-prime effect skips
   external texts for the same reason. A human referencing a human-only
   skill IS the human invocation; a tool is not.

3. Unbounded read: readReferenceSkillContent read the whole SKILL.md
   before slicing to the 20k cap, so an unauthenticated no-cors fetch loop
   could balloon RSS by file size per request (measured +64.4MB for a 64MB
   file). It now uses the same bounded readFileHead as the catalog,
   reading only frontmatter allowance + 4 bytes per capped char + slack;
   truncation detection is unchanged for any file whose frontmatter fits
   the catalog bound, and frontmatter that overflows the read falls back
   to null rather than serving raw YAML. Measured: 12 reads of a 64MB
   SKILL.md now cost +5.1MB total.

Also: the fast-fail guard no longer rejects legitimately discovered names —
`name.includes("..")` 404'd a real `v1..2` skill dir forever (and `\` is
legal in POSIX names) while defending nothing, since the name is only ever
matched against discovery output and never joined into a path. It now
rejects exactly the names that can never be a readdir entry: empty, `.`,
`..`.
2026-08-07 09:50:42 -07:00
Michael Ramos c08b188812 perf(ui): single Shiki highlighter, palette-matched code blocks, drop highlight.js (#1218)
* perf(build): stub out the dead Oniguruma WASM in every bundle

@pierre/diffs picks its Shiki engine with a runtime ternary:

    engine: preferredHighlighter === "shiki-wasm"
      ? createOnigurumaEngine(import("shiki/wasm"))
      : createJavaScriptRegexEngine()

Plannotator pins `preferredHighlighter: 'shiki-js'` (and Pierre's own
default is 'shiki-js'), so the Oniguruma branch never executes. Because
the choice is a runtime ternary, bundlers keep the `import("shiki/wasm")`
edge anyway and inline `@shikijs/engine-oniguruma/wasm-inlined`, a
~622 KB base64 blob, into the single-file HTML builds. The review app
paid for it twice: once on the main thread (via
`highlighter/shared_highlighter.js`) and once inside the `?worker&inline`
Pierre worker.

Alias `shiki/wasm` to a stub that throws if it is ever reached. Wired via
`resolve.alias` rather than a plugin because `resolve.alias` is shared
with Vite's worker build and `plugins` are not.

Highlighting output is unchanged: the JS regex engine was already the one
doing the work. Opting back into 'shiki-wasm' now fails loudly instead of
silently costing every user a megabyte of dead bytes.

    apps/review/dist/index.html  19,424,646 -> 18,180,545  (-1,244,101 raw / -463,348 gzip)
    apps/hook/dist/index.html    23,032,467 -> 22,410,416    (-622,051 raw / -233,485 gzip)

* perf(ui): consolidate code highlighting onto Shiki, drop highlight.js

The app shipped two highlighters. Shiki already tokenised the code-review
diff pane (via @pierre/diffs, JavaScript regex engine); highlight.js
separately coloured markdown fences and review suggestion snippets at
~982 KB minified for a full build of ~190 grammars. That second
highlighter is now gone.

Every call site moves onto `packages/ui/utils/codeHighlight.ts`, a thin
wrapper over Pierre's SHARED Shiki instance:

  CodeBlock, Viewer, PlanCleanDiffView   markdown fences
  InlineMarkdown                          code-file hover preview
  HighlightedCode                         review suggestion snippets

Reusing Pierre's instance rather than standing up a second fine-grained
one is deliberate. Pierre imports Shiki's full bundle, so every grammar
and theme is ALREADY inlined in the single-file builds: a separate
highlighter with a curated language list would have duplicated a subset
of bytes that are already there. Sharing costs nothing, gives every
language Shiki bundles instead of a shortlist, and — the point of the
change — guarantees fences resolve the exact same theme the diff pane
resolves.

Theming. `SHIKI_THEME_MAP` / `resolveSyntaxTheme` move from
`packages/review-editor/hooks/usePierreTheme.ts` to
`packages/ui/utils/syntaxTheme.ts`; usePierreTheme re-exports them, so
the review editor's imports are unchanged. `useFenceTheme()` feeds the
components and re-highlights on palette or mode change. Code blocks now
follow the active palette across all ~52 themes in both light and dark,
instead of always rendering github-dark and relying on hand-written
`.hljs-*` override stacks to stay legible. Those stacks are deleted:
`packages/editor/index.css`'s light-mode token palette, and
`colorblind.css`'s hand-tuned tokens which existed to APPROXIMATE
@pierre/theme's protanopia-deuteranopia themes that are now simply used.

Behaviour held fixed:

  - Language-less fences stay plain text (#1212). No auto-detection
    anywhere, including the hover preview, which previously called
    `hljs.highlightAuto`. `HighlightedCode` derives its language from
    the caller's file path; an unknown extension renders plain.
  - `applyHighlight(el, ...)` keeps the imperative `hljs.highlightElement`
    DOM contract the annotation layer reaches into, and writes plain text
    at final size first so async highlighting causes no layout shift.
    Already-attached grammars highlight synchronously — no flicker on
    cached highlights.
  - It also verifies the rendered text is byte-identical to the source
    and falls back to plain otherwise, because annotations address code
    blocks by text offset.
  - `@plannotator/ui`'s public API is unchanged: the highlighter is a
    module-level default like the package's other seams, no new props.

The `hljs` class on fenced `<code>` becomes `pn-code` (it is a
structural hook for blockTargeting, vim navigation and print.css, and it
named a library we no longer ship). `language-*` stays.

    apps/review/dist/index.html  18,180,545 -> 17,270,889  (-909,656 raw / -291,921 gzip)
    apps/hook/dist/index.html    22,410,416 -> 21,704,434  (-705,982 raw / -238,096 gzip)

Verified the diff pane is untouched: the rendered Pierre shadow-DOM
markup is byte-for-byte identical between an origin/main build and this
one (SHA-256 aa1ee88a…).

* fix(ui): strip stray NUL bytes from the code-highlight source

Two U+0000 bytes slipped into comments in the previous commit, which made
git treat the file as binary. Replaced with spaces; no behaviour change.

* fix(ui): keep code-block annotation marks across highlight swaps

Fenced code is annotated by hand: one `<mark data-bind-id>` inside the
`<code>` element, which `applyHighlight` also owns. Every highlight swap
(palette change, dark/light toggle, or the first async grammar attach
after load) replaces that element's children, so the mark was silently
wiped and nothing put it back. Annotation state, the sidebar panel and
exports were unaffected; the loss was purely visual, and deterministic.

`applyHighlight` now publishes every write through `onCodeHighlightSwap`,
synchronously, immediately after it. `Viewer` subscribes and re-paints the
fence's mark, so a swapped block ends up with BOTH the new theme's tokens
and its annotation. The shared painter (`paintCodeBlockMark`) moves the
token spans into the mark instead of flattening them to text, so creating
an annotation no longer costs a block its colours either.

Being driven by the swap also fixes the cousin race by ordering rather
than timing: share/draft restore runs on a timer after load, and on a slow
machine the first async swap could land after it and wipe the restored
marks per block. A restore that painted before the swap is now
re-established in the same task the swap ran in, and one that runs after
finds the mark already there.

Removal tombstones the id before re-highlighting, because the host drops
the annotation from state a tick later — without it the swap listener
would paint the just-removed annotation back in, and a fence carrying a
second annotation would end up bare.

Also closes the named gap in the WASM coverage: entry-assets only grepped
source, so a future @pierre/diffs bump could reintroduce the inlined blob
through a different import specifier unnoticed. It now greps the built
`apps/{review,hook}/dist/index.html` for the base64 WASM magic, skipping
on an unbuilt checkout and running for real in the CI job that builds the
bundles.
2026-08-05 21:54:40 -07:00
Michael Ramos 2d65c65596 feat(ui): pair a light theme and a dark theme, switched by mode (#1217)
* feat(ui): pair a light theme and a dark theme, switched by mode

ThemeProvider stored one palette plus a mode, so picking a dark-only
palette pinned the mode and greyed out the Light/System buttons. Store a
pair instead: { mode, light, dark }, resolved as pair[preferredMode], so
System flips between the two choices as the OS scheme changes.

The Settings Theme tab now assigns one half at a time. A Light/Dark
switch decides which half the grid is filling, the grid lists only the
palettes that can render that half (from the registry's modeSupport), and
a summary line names both halves with each side clickable. Every mode
button is permanently enabled: a dark-only palette simply never occupies
the light slot, so no mode coercion is left to do.

The pair round-trips through the SETTINGS registry to the `theme` key in
~/.plannotator/config.json the way diffOptions does. A user upgrading
seeds both halves from their stored single palette, and the legacy
plannotator-color-theme key keeps tracking the active palette so a
downgrade never lands on an unstyled first frame.

Addresses part 1 of #1211.

* fix(ui): make the theme pair seed local, and keep the legacy API non-destructive

Review of #1217 found a data-loss path and three published-API regressions.

Seeding: ThemeProvider handed its resolved pair to the config store through
set(), which queues a debounced POST. configStore.init() applies the server
config but never cancelled that queued write, so a single cookie-less visit
(fresh profile, incognito, cleared cookies) flushed a default pair to
~/.plannotator/config.json AFTER the real one had arrived, and the next
session restored those defaults over the user's cookies. The provider now
uses a new configStore.seed(): memory plus cookie, never the server, and
never over a value init() already applied. init() additionally retracts
queued writes for the leaves the server just spoke for, which closes the
same race for every server-synced setting rather than this one key.

Deprecated APIs: isThemeModeAvailable() and normalizeThemeMode() are back as
one-line wrappers with @deprecated notes, since packages/ui exports utils/*.

setColorTheme: assigns exactly one half and nothing else. A both-mode palette
goes to the half on screen instead of clobbering both; a mode-restricted one
goes to its half without yanking a System user to an explicit mode (render
time already resolves that). It persists through configStore.setLocal(), so
it stays cookie-only as it was before the pair, unless a host installed its
own serverSync transport.

storageKey / colorThemeStorageKey are honored on the read path, so a host's
stored pre-pair preference is migrated rather than discarded. The two halves
have no pre-pair equivalent and stay on fixed keys, documented on the props.

Tests: a fresh-mount case that pins zero POSTs (the previous helper pre-seeded
cookies, which is why this was invisible), a case that pins a real choice
still reaching config.json, direct setColorTheme cases for all three
semantics, a host-storage-keys migration case, and configStore seed/retract
unit tests. All of them fail against the code they replace.
2026-08-05 21:51:55 -07:00