Commit Graph

31 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 bafd1f5f5f chore(packages): bump @plannotator/core to 0.25.0, @plannotator/ui to 0.32.0 (#1396)
Version bumps, lockfile refresh, and docs for the lockstep npm release carrying the HTML annotation seams (#1395), the lazy renderers with eager entries (#1394), and WebMCP phase 1 (#1393). Core publishes first because ui now imports @plannotator/core/html-anchor.

AI-assisted (Claude) under maintainer direction.
2026-08-27 07:55:19 -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
Michael Ramos d257f7fae2 chore(packages): bump @plannotator/core to 0.24.0, @plannotator/ui to 0.31.0 2026-08-23 10:35:21 -07:00
Michael Ramos aa816b231c feat(ui): add embed media picker seam (#1382) 2026-08-23 10:35:06 -07:00
FND 8a8d0544c6 feat: detect the oh-my-pi harness as its own agent origin (#1373)
* feat: detect the oh-my-pi harness as its own agent origin

- omp injects OMPCODE=1 (+ a CLAUDECODE=1 compat shim) into every Bash-tool child; the env chain now maps it to a dedicated oh-my-pi origin labeled "Oh My Pi".
- Distinct origin rather than aliasing claude-code, because the claude-code-only gates (permission-mode setup, permissionMode in approve) presuppose a PermissionRequest hook that a bash-invoked plannotator never has; omp has no approve support and no planning integration yet.
- Fallback deliberately left "claude-code"; wording unchanged.
- No dedicated Ask AI provider for oh-my-pi.

* chore: sync lockfile workspace versions

* fix: check OMPCODE last so runtimes inside an OMP session keep their label

OMP exports OMPCODE into every shell it spawns. With the check at the top of the chain, opencode/codex/... launched from an OMP session inherited OMPCODE and were mislabeled "Oh My Pi". Moving it just above the terminal fallback lets specific runtime env vars win; OMPCODE still beats the claude-code fallback for bare shells.

Reported by backnotprop in #1373.
2026-08-21 18:06:23 -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
Michael Ramos 15f8d4fe4c feat(review): collapse generated files by default in the all-files view (#1346)
* feat(review): collapse linguist-generated files by default (#1317)

Code review now respects linguist-generated (and linguist-generated=true)
from .gitattributes, collapsing those diffs by default the way GitHub does.

Server (Bun + Pi mirror): a generatedFiles sidecar rides /api/diff and
/api/diff/switch, resolved through git's own attribute machinery — one
batched 'git check-attr --stdin -z' over the served patch's paths at the
review cwd, so stacked and negated rules land exactly as git resolves
them. Plain local git sessions only; PR worktrees, workspace multi-repo,
jj, GitButler, and P4 omit the sidecar (degrade to no-collapse). Shared
logic in packages/shared/generated-files.ts, vendored to Pi.

Client: generated files SEED their CodeView item collapsed (the existing
Pierre collapse state — same mechanism as commit-diff folding), render
the one-line FileHeader bar with a 'generated' tag next to the +/- counts,
and expand per file on click. Expansion is session-local App state so it
survives remounts and diff switches. Presentation-only: the diff data,
annotations, search, and Edit Mode are untouched; the file tree and
single-file tabs list generated files normally (tag, no auto-collapse).

Guide viewer manifest pin regenerated (AllFilesCodeView/FileHeader are
bundled into the guides.show viewer) from a clean frozen-lockfile install.

* feat(review): built-in generated defaults, visible collapsed strip, review-round fixes (#1317)

Round 2 on PR #1346, per maintainer review.

Built-in generated defaults (industry-standard two-layer detection):
packages/shared/generated-files.ts (vendored to Pi) now carries
DEFAULT_GENERATED_PATTERNS — lockfiles (package-lock.json, yarn.lock,
bun.lock, Cargo.lock, go.sum, ...) plus *.min.js / *.min.css / *.map —
matched against the path's last segment only. Explicit .gitattributes wins
in BOTH directions: linguist-generated (set/true) marks any file,
-linguist-generated / =false un-marks even a built-in name, unspecified
falls through to the defaults. In plain local git sessions check-attr
refines the defaults; the non-git degrade modes (piped patches, PR
worktrees, workspace, jj, GitButler, P4) now emit the sidecar from the
name-based defaults alone instead of omitting it.

Visible collapsed state: a collapsed generated card no longer renders as a
bare header — a GeneratedFileNotice strip ('Generated file collapsed',
+N/-N, 'Click to view') styled like the other below-header notices sits in
the card, and clicking it expands through the SAME reportFileCollapsed
funnel as the chevron.

Review findings:
- F1: search-match and sidebar-comment navigation expanded items without
  reporting through the funnel, so those expansions died on diff switch.
  Both now call syncAllCollapsedMirror + reportFileCollapsed; the funnel
  invariant comment lists the navigation-driven sites.
- F2: the check-attr call gets the same 5000ms timeout as review-core's
  stdin git callers, and Pi's vcs.ts stdin write gets the one-line EPIPE
  guard (call-flow.ts shape) a timeout kill makes reachable.
- F3: removed the dead prevGeneratedRef + collectSetDelta leg — a changed
  generated set always remounts via fileSetKey, so the delta path was
  unreachable.

Tests: default-list matching (glob + directory-named-bun.lock), both-
direction precedence, non-git sidecar from defaults (dual-runtime), the
placeholder strip through the funnel, and search expansion surviving a
re-seed round-trip. AGENTS.md payload docs updated. Guide viewer manifest
pin regenerated from this clean frozen-lockfile worktree.
2026-08-17 23:32:29 -07:00
Michael Ramos b07fc11323 guides-show: "Made with Plannotator" link in the viewer header (portable + hosted) (#1340) 2026-08-17 12:19:00 -07:00
Michael Ramos a899ea657f fix(review): stop the call-flow Lens closing mid-scroll and opening on drive-by hovers (#1338)
* fix(review): stop the call-flow Lens closing mid-scroll and opening on drive-by hovers

The Lens popup scrolls internally but never contained overscroll, so momentum
that hit its edge chained to the page; the page scroll moved the anchor, the
popup tracked it out from under a stationary pointer, and the resulting
mouseleave closed the Lens mid-scroll (worst under Safari rubber-banding).
Scrolling the diff also dragged file headers under the cursor, and the
zero-delay hover open popped the Lens for every badge that passed.

- overscroll-behavior: contain on .call-flow-popover
- 100ms hover-intent delay before opening; a pointer that leaves first never opens it
- close grace 140ms -> 250ms
- while a scroll is in flight, pending hover-closes are held; once it settles
  the Lens closes only if the pointer really ended up outside

Reported by @acewhocares on X (Safari).

* ci(guides-show): publish the built viewer manifest as an artifact

The viewer build is deterministic per platform but not across platforms, and
the committed pin must match the Linux CI build. When the manifest check fails
on a PR, a maintainer on macOS previously had no way to obtain the
authoritative hashes. The uploaded manifest.json closes that: download, drop
into apps/guides-show/dist/viewer/, run sync:manifest, commit.

* fix(guides): regenerate viewer pin for the Lens changes; correct the manifest-artifact comment

The cross-platform nondeterminism claim in the previous commit was wrong. A
clean install builds identical viewer hashes on macOS and Linux; the divergence
observed here came from a drifted local bun store carrying stale duplicate
package versions (rm -rf node_modules + frozen install reproduces CI exactly,
verified against the deploy run's published names). The artifact upload stays:
it is the authoritative recovery path when a local environment is the thing
that is wrong. Pin regenerated from a verified-clean build for the Lens
bundle changes.
2026-08-17 10:37:05 -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 d0c32c8863 fix(review): preserve dragged diff ranges on compact touch before commenting (#1333)
* feat: add mobile touch range selection prototypes

* docs: redirect mobile range selection spike

* revert: remove command-mediated touch range prototype

* docs: align mobile line selection with DiffsHub

* feat: preserve mobile diff ranges before commenting

* fix(review): repaint a preserved mobile range on a second drag

A preserved range leaves pendingSelection non-null, so DiffViewer hands
Pierre a defined selectedLines and Pierre switches to controlled
selection: updateSelection then only records a proposed range and leaves
painting to the host. With no change handler wired, a second drag never
repainted, so the old highlight stayed put and the finger was untracked
until release. Wire onLineSelectionChange back into app state, only on
compact touch, so desktop keeps an options object with no such key.

Also route a null range to the toolbar host instead of swallowing it in
the preserve branch, mirroring AllFilesCodeView's early return so an open
composer (Ask AI included) closes with the selection it was written for.

* fix(review): 44px hit area for Pierre's gutter comment button on touch

With a dragged range now preserved instead of opening the composer, that
button is the only way to start writing about it, and it is roughly 20px
square: below the data-pn-touch-target standard the rest of the compact
shell holds. Grow its invisible ::before hit area to the 44px token,
leaving the glyph alone.

The rule ships through the same unsafeCSS both diff surfaces already
inject, which lands in Pierre's shadow root inside @layer unsafe (last in
the library's layer order, so no !important). It is injected only when
the shell is compact rather than gated in CSS: html:has() matches nothing
from inside a shadow root, and @media (pointer: coarse) would wrongly
claim a desktop with a touchscreen.

* test(review): cover the compact-touch preserved range on both diff surfaces

DiffViewer.compactTouchSelection.test.tsx drives the FileDiff options the
component hands Pierre: a drag preserves the range instead of opening the
composer and paints it through selectedLines, a second drag repaints
through onLineSelectionChange, the gutter action opens the composer, a
cleared range still reaches the toolbar host, and desktop keeps routing
completed drags straight to the composer with no change handler at all.
The last three assertions fail against the pre-fix component.

The AllFilesCodeView compact test only checked what was published upward,
which a range nothing paints would also satisfy; it now asserts the range
reaches the CodeView props. That needs the App loop, so the mount feeds
published selections back down as pendingSelection: without it the
reconcile effect clears the highlight the preserve branch just painted.

* chore(guides-show): regenerate viewer manifest pin for the touch selection changes
2026-08-16 21:47:43 -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 cb2b264303 guide-viewer: label agent harnesses in the generated-by line (#1328)
An authored guide's generator.engine is the harness the agent ran in
(claude-code, codex-cli, …), which the in-app engine map does not know, so
the header printed the raw id. Map the common ones; unknown ids still render
as written. Viewer rebuilt and published, manifest synced.
2026-08-16 13:15:26 -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
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 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 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 ffd49080ee fix(skills): harden skill references before first release (#1235) 2026-08-07 14:22:58 -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
Michael Ramos 5d716e2271 feat(core): add Zed Preview as an Open In App target (#1130)
Zed Preview installs as a separate app bundle on macOS (Zed Preview.app),
so the stable 'zed' entry never detects or launches it there. On Linux and
Windows the preview channel installs its CLI as plain 'zed' (verified in
Zed's install.sh and bundle-windows.ps1), so the existing entry already
covers preview on those platforms and the new entry is mac-only.

Closes #1119

Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS
2026-07-26 13:26:21 -07:00
Michael Ramos 99d11dca04 Persist Guided Reviews across sessions (#1115)
* feat(guide): add durable guide store with repo-scoped keys and opt-out

Runtime-agnostic guide persistence for #1112: packages/shared/guide-store.ts
writes validated guides to ${PLANNOTATOR_DATA_DIR}/guides/{repo-key}/{id}.json
with atomic tmp+rename writes and graceful corrupt-file handling. The repo key
is a sanitized host__owner__repo from the origin remote (or the PR url), with
a dir-name+hash8 fallback when no remote parses, so PR and branch sessions of
one repository share a shelf and same-named branches in different repos never
collide. Includes the session glue (repo-key/headSha/label resolution plus the
jobId-to-savedId map) shared by both server runtimes, the guideHistory config
key with resolveGuideHistory (PLANNOTATOR_GUIDE_HISTORY, coerced booleans),
and the browser-safe SavedGuideListEntry/CodeGuideData extensions.

* feat(guide): autosave guides and serve saved: ids in both server runtimes

Both packages/server/review.ts and the Pi mirror serverReview.ts now: autosave
a guide the moment it passes the existing validateGuideOutput gate (including
manual-repair submits); write reviewed-state changes on a live job id through
to that job's saved file; serve persisted guides through the existing guide
endpoints as saved:{id} pseudo job ids (GET guide + PUT reviewed); and expose
GET /api/guides (repo-scoped list with progress and a moved flag comparing the
stored head sha to the current head) and DELETE /api/guides/:id. guide-store
joins vendor.sh's flat copy list; cross-runtime endpoint wiring is covered by
packages/server/guide-persistence.test.ts against both servers, including
reviewed-state persistence across a server restart and traversal-id rejection.

* feat(guide): previous-guides list, Saved chip, and outdated Regenerate hint

GuideEmptyState grows a Previous guides section under the Generate controls:
rows show the target label chip, title, age, reviewed progress, a quiet diff
changed flag when the stored head no longer matches, and a per-row delete;
clicking a row loads the guide via its saved:{id} pseudo job id (the existing
useGuideData/GuideScreen id plumbing already treats ids as opaque). GuideView
shows a small Saved chip once the active guide is persisted and, for an
outdated saved guide, one muted hint line whose Regenerate action launches a
fresh guide with the persisted defaults. The engine/model resolution and
launch-param shapes move into the shared useGuideLaunch hook so the empty
state and the hint stay in lockstep. DOM tests cover the new GuideView states.

* docs: document guide persistence endpoints and PLANNOTATOR_GUIDE_HISTORY

* fix(guide): label saved envelopes with launch-time context, not completion-time state

Review finding on #1115: saveForJob read the live session getters when the
job COMPLETED, but guide jobs run for minutes while the session supports
mid-generation PR switching (/api/pr-switch) and diff switches. Launch on PR
A, switch to B, complete: the envelope permanently carried A's content
labeled with B's PR label/url/headSha (and could even land on B's repo
shelf). The review-target context (pr url/label/head, branch label, head
sha) is now snapshotted at job LAUNCH via guideStore.captureLaunchContext()
in the guide buildCommand branch of both runtimes and carried on the job
itself as AgentJobInfo.guideContext, the same discipline as
changedFilesSnapshot, so it is garbage-collected with the job and needs no
separate cleanup. saveForJob prefers the snapshot (falling back to the live
getters only for jobs launched without one), derives the shelf from the
launch-time PR url, and records the shelf alongside the saved id so reviewed
write-through follows the file wherever it landed. Repair jobs reuse the
FAILED job's own snapshot. Covered by new session tests that mutate the
injected getters between launch capture and completion.

* fix(guide): close a saved guide when the review context switches

Review finding on #1115: a saved:{id} guide has no AgentJobInfo, so
GuideScreen's context match passes trivially (unknown ids are tolerated for
the demo path). Switching PRs or worktrees while a saved guide was open left
it mounted over the new context's diff with a stale moved flag. App.tsx now
clears activeGuideJobId on any prMetadata.url / activeWorktreePath change
when it points at a saved: id; the user reopens it from the Previous guides
list. Live job ids are untouched, GuideScreen's own matching handles those.
2026-07-23 15:32:43 -07:00
Michael Ramos 3f3b3514a0 fix(annotate): use annotate feedback template for clipboard copy, not plan-deny (#1109)
Clipboard Copy paths (ExportModal Copy and the annotation panel quick
copy) unconditionally wrapped exported annotations with the plan-deny
template, so annotate sessions copied text starting with "YOUR PLAN WAS
NOT APPROVED." while Send Feedback used the annotate template (including
custom prompts.annotate.* config).

The annotate servers (Bun and Pi) now ship the resolved, unsubstituted
copy-wrapper templates in the /api/plan payload (feedbackTemplates), and
the plan editor wraps copied annotations mode-aware: server template when
present, browser-safe default annotate template otherwise, plan-deny only
in actual plan review. Send Feedback behavior is unchanged.

Closes #1107
2026-07-22 08:13:57 -07:00
Michael Ramos f9a6c1e39d feat: annotate accepts YAML, JSON, TOML and other plain-text files (#1099)
* feat(annotate): accept common plain-text config formats (.yaml, .json, .toml, …)

Annotate previously rejected every file that wasn't .md/.mdx/.txt (or
.html/.htm), even though the pipeline reads files as UTF-8 text and
renders anything. Widen the accepted set to unambiguously plain-text
config/data formats: .yaml .yml .json .jsonc .json5 .toml .ini .cfg
.conf .properties .csv .tsv .log .xml .env.example. They render exactly
the way .txt renders today.

- New single source of truth: packages/core/annotatable.ts
  (ANNOTATABLE_TEXT_REGEX / ANNOTATABLE_DOC_REGEX + predicates),
  re-exported through @plannotator/shared/resolve-file and vendored into
  the Pi extension.
- .env stays excluded (commonly holds secrets; annotate history copies
  file contents into the data dir). Source-code extensions stay with
  code review.
- Single-file accept + bare-filename fuzzy search widen in
  resolveMarkdownFile; folder discovery and the file-browser listing
  widen in all three runtimes (hook CLI, OpenCode, Pi).
- /api/doc gains a `doc=1` param set by the file browser so extensions
  that overlap CODE_FILE_REGEX (.yaml/.json/.toml/.ini/.xml) render as
  annotatable documents there while code-file links inside documents
  keep the syntax-highlighted popout.
- Error messages now list the wider set; docs updated (AGENTS.md,
  marketing annotate page).

Closes #1029

Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk

* fix(annotate): frontmatter, size caps, edit-guard, and skill docs from review

Review fixes for #1099:

- Frontmatter: `--- … ---` stripping is a markdown convention; for
  non-markdown annotatable sources (multi-document YAML, .txt starting
  with ---) the delimiters are real content. parseMarkdownToBlocks gains
  a { frontmatter } option and the editor keys it off the active
  document's path via shouldStripFrontmatter() (strip for .md/.mdx and
  pathless/converted sources; keep raw for other annotatable text).
- Size caps: new shared MAX_ANNOTATABLE_FILE_BYTES (2MB — same limit the
  code-file popout always had) now guards the annotate CLI single-file
  read in all three runtimes and the /api/doc document branches in both
  servers. Also applies to .md/.txt (behavior change for pathological
  inputs; previously unbounded).
- Editing guard: mid-edit file opens gate on isSourceSaveFilePath
  (.md/.mdx/.txt) instead of the wider annotatable set — config files
  are view-only, so switching to one mid-edit no longer silently
  downgrades "Done editing" to feedback-only edits.
- Skill docs: plannotator-annotate SKILL.md (core + Kiro) now mention
  the plain-text config formats.

Claude-Session: https://claude.ai/code/session_01YXkgsNucxDwAL4GdR4XYRk
2026-07-20 15:33:42 -07:00
Michael Ramos 98fa3b2173 Restore production Totman favicon (#1081) 2026-07-19 13:07:34 -07:00
Michael Ramos 8597ca3563 Use the tight-fit Totman favicon (#1071) 2026-07-17 19:14:23 -07:00
Michael Ramos 56df64c751 Add modern GitButler review support (#1067)
Adds current-architecture GitButler workspace, stack, and branch review support across Bun and Pi while preserving the existing Git, JJ, and P4 paths.

Co-authored-by: Dan Susman <56033661+dansusman@users.noreply.github.com>
2026-07-17 07:37:50 -07:00
Michael Ramos 95ac6cd2f8 Use the production Totman favicon (#1066) 2026-07-16 16:04:01 -07:00
Michael Ramos 070d9a5f6d Make the document UI reusable as published building blocks (#957)
* docs(adr): revert failed document-ui cutover, add ADR 004 with corrected reuse plan

The document-ui extraction/cutover (ADRs 002/003) was an AI-driven rewrite that
broke the app; the code was reverted. Add ADR 004 as the source of truth: share
@plannotator/ui as published building blocks for the Workspaces app, keep
Plannotator's app unchanged, gate on human-verified parity. Banner the reverted
ADRs and point AGENTS.md/CLAUDE.md at 004 so future agents don't rebuild the mess.

* docs(adr): add verified document-ui extraction plan, supersede draft inventory

36-agent verification of the reuse inventory: confirmed the /api coupling but
found the draft missed Viewer's transitive backend call, the cookie settings
layer, 3 React contexts + identity singleton, SSE transports, and harder
packaging blockers. Adds the verified per-subsystem extraction plan with a
parity guardrail on every step; flags the draft inventory as superseded.

* docs(adr): add document-ui extraction roadmap + parity checklist

Phase 0-7 execution roadmap (safety net -> packaging -> foundation seams ->
rendering -> navigation -> comments -> extras -> publish) and the reusable
'did it break?' parity checklist run after every step. Both enforce the law:
move + decouple, never rewrite; Plannotator's experience cannot change.

* build(ui): packaging unblock for external install (Phase 1) — no runtime change

Phase 0: captured parity baseline (typecheck/test/build + shipped-bundle hashes).
Phase 1 packaging fixes to packages/ui, metadata only:
- add phantom dompurify ^3.3.3 dep (imported in sanitizeHtml/aiChatFormat, was undeclared)
- align diff ^8.0.3 -> ^8.0.4 with root
- add peerDependencies (react, react-dom, tailwindcss, tailwindcss-animate); keep as devDeps
- add files allowlist (excludes tests); remove dead tsconfig @plannotator/shared alias

Verified byte-identical: typecheck pass, 1620 tests pass/0 fail, all 3 builds OK,
shipped plan+review bundle hashes unchanged from baseline. Remaining Phase 1
blocker (@plannotator/ai + @plannotator/shared workspace:* deps) deferred pending
a publish-vs-inline decision; logged in worklog.

* feat(ui): make image URL resolution host-overridable (Phase 2, seam 1)

getImageSrc now delegates to a module-level resolver defaulting to the verbatim
Plannotator /api/image logic; add setImageSrcResolver/resetImageSrcResolver so a
host (Workspaces) can resolve images via its own backend. All 5 consumers and the
signature unchanged. Verified: default URLs byte-identical, typecheck pass, 1620
tests pass/0 fail, builds OK. No Plannotator behavior change.

* feat(ui): make settings storage backend host-overridable (Phase 2, seam 2)

storage.ts cookie impl is now the default 'cookieBackend'; add setStorageBackend/
resetStorageBackend so a host (Workspaces) can persist settings via its own
storage. getItem/setItem/removeItem delegate to the active backend; the ~24
consumers and literal plannotator-* keys are unchanged. Verified: swap works,
typecheck pass, 1620 tests pass/0 fail, builds OK, theme persists across reload.

* feat(ui): make MarkdownEditor theme mode host-supplyable (Phase 3)

Add optional mode? prop; mode now mode ?? resolvedMode. Plannotator passes no
mode (App.tsx:4261) so it keeps using ThemeProvider's resolvedMode unchanged. A
host without ThemeProvider can supply mode directly. Verified: typecheck pass,
1620 tests/0 fail, builds OK, App.tsx untouched.

* feat(ui): allow hosts to opt out of code-path validation (Phase 3)

Viewer gains optional disableCodePathValidation? threaded to a new disabled? arg
on useValidatedCodePaths; when set, the /api/doc/exists probe is skipped. Default
undefined for Plannotator => validation stays on, /api/doc/exists fires exactly as
today. Verified: typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched.
Also logs Phase 3 workflow outcome + remaining scroll/docfetch pieces.

* feat(ui): make code-file hover preview fetch host-overridable (Phase 3)

Add DocPreviewFetcher seam (default = verbatim /api/doc fetch) +
setDocPreviewFetcher/resetDocPreviewFetcher; route handleMouseEnter through it,
useCallback deps unchanged. No caller overrides it => Plannotator fetches /api/doc
identically. typecheck pass, 1620 tests/0 fail, builds OK.

* feat(ui): ship ScrollViewportProvider with the library (Phase 3 scroll)

Add render-transparent ScrollViewportProvider (createElement, keeps .ts) so the
scroll-viewport context travels with @plannotator/ui instead of living only in
App.tsx. Rewire App.tsx provider tags (3-line delta); identical tree/value/
position, sidebar TOC still reads the MAIN viewport. Fix stale OverlayScrollbars
doc-comment. typecheck pass, 1620 tests/0 fail, builds OK, eyeball: TOC tracks.

* fix(ui): disabled code-path validation should keep links clickable (self-review)

The Phase-3 disabled branch set ready=true with an empty map, which makes
gateCodePath demote every code link to plain text. Leave ready=false so the
no-validation fallback renders links optimistically. No Plannotator impact
(never disables). Logs Phase 3 completion + reusability note. typecheck pass,
1620 tests/0 fail, builds OK.

* feat(ui): make file-tree backend host-overridable (Phase 4)

Lift useFileBrowser's three backend wires (load-dir fetch, obsidian-vault fetch,
and the SSE live-watch effect moved VERBATIM) into an injectable FileTreeBackend
with default + setFileTreeBackend/resetFileTreeBackend, same pattern as the image
/storage seams. useFileBrowser() stays zero-arg; default fetch/SSE URLs identical.
Sidebar confirmed noop (zero backend wires, already reused by review-editor).

Verified: useFileBrowser.test.tsx passes 6/0 UNMODIFIED (DOM_TESTS=1), typecheck
pass, 1620 tests/0 fail, builds OK, App.tsx untouched, manual eyeball (annotate
adr/: tree loads, file-switch works, new file appears live via SSE). Plannotator
byte-unchanged. Logs two pre-existing bugs found during testing (not regressions).

* docs(adr): research + synthesis + spec for Phase 5 (comments/annotations/drafts)

Five-probe code research of the comment system. Key finding: most comment UI is
already portable (panel/popover/toolbar/highlighter prop-driven; review-editor
already reuses the hooks). Phase 5 narrows to 3 seams — draft transport (+ the
3-party generation protocol), external-annotation transport (SSE->polling, move
verbatim), and identity/authorship — plus 2 non-extraction items: renderer
coupling (document as a contract) and replies/threading (defer as a new feature).

* docs(adr): accept ADR 005 — make comments/annotations/drafts host-overridable (Phase 5)

Three seams (identity, draft transport, external-annotation transport), each
defaulting to today's behavior; renderer coupling documented as a contract;
replies/threading deferred as a new feature. Locks in the recommended choices
from the Phase 5 spec/synthesis.

* feat(ui): make annotation identity host-overridable (Phase 5 seam 1)

Add IdentityProvider + setIdentityProvider/resetIdentityProvider in identity.ts;
getIdentity/isCurrentUser now delegate to a module-level provider defaulting to
today's ConfigStore tater behavior. The ~9 author-stamp sites and 2 (me)-badge
sites delegate with zero call-site edits. No caller overrides => Plannotator
byte-unchanged. typecheck pass, 1620 tests/0 fail, builds OK.

* feat(ui): make draft persistence transport host-overridable (Phase 5 seam 2)

Add DraftTransport (load/save/remove) + getDraftTransport/setDraftTransport/
resetDraftTransport in useAnnotationDraft.ts, default = today's /api/draft fetches
verbatim. useCodeAnnotationDraft reads getDraftTransport() live. The generation
pre-increment, 500ms debounce, keepalive retry-gate, and pagehide/visibilitychange
flush stay in the hooks; getDraftGeneration() still escapes to the host. save
rejects-on-failure so the gated retry is preserved. No caller overrides =>
Plannotator byte-unchanged. shared/draft.test.ts 10/0, annotationDraftPersistence
13/0, typecheck pass, 1620 tests/0 fail, builds OK.

* feat(ui): make external-annotation transport host-overridable (Phase 5 seam 3)

Add ExternalAnnotationTransport<T> (subscribe/getSnapshot/CRUD) + setters in
useExternalAnnotations.ts; default = today's SSE->polling wire moved verbatim into
createDefaultTransport. The reducer (applyEvent), fallback-once gate, 500ms poll,
versionRef scoping, optimistic-before-await, and [enabled] gate stay in the hook.
A host (Workspaces) can implement the same event contract over Durable Objects.
No override caller => Plannotator byte-unchanged. external-annotations test green,
typecheck pass, 1620 tests/0 fail, builds OK. Logs Phase 5 completion.

* docs(adr): research + synthesis + spec for Phase 6 (versions, settings, sharing, AI)

Five-probe code research. Most of the four subsystems is already portable; the
real work is 5 seams (version fetchers + vscode-diff, config write-back, obsidian
detect, save-to-notes, AI transport) + 1 CSS move (block/raw diff classes from the
app shell into the package's theme.css). Fragile do-not-touch: the AI SSE reader
loop + epoch guards, and configStore debounce/deepMerge. Five Plannotator-only
pieces (OpenInApp, HooksTab, useUpdateCheck, useAgents/useAgentJobs) stay home.

* docs(adr): accept ADR 006 — make extras (versions/settings/sharing/AI) host-overridable (Phase 6)

Five seams + one CSS move, each defaulting to today's behavior. AI reader loop +
epoch guards and configStore debounce/deepMerge stay verbatim. Five Plannotator-
only pieces stay home. Locks the recommended choices from the Phase 6 spec.

* feat(ui): make version fetchers + vscode-diff host-overridable; move diff CSS into package (Phase 6 versions)

usePlanDiff gains optional fetchers (default /api/plan/version(s), error asymmetry
kept: selectBaseVersion alerts, fetchVersions silent). PlanDiffViewer gains optional
onOpenVscodeDiff (default /api/plan/vscode-diff). Relocate .annotation-highlight* +
.plan-diff-* block/raw CSS from editor/index.css into ui/theme.css (next to
.plan-diff-word-*) so the diff/highlights are self-styling from the package.
Verified: relocated CSS gone from index.css, present in shipped bundle (33x), diff
renders identical; typecheck pass, 1620 tests/0 fail, builds OK, App.tsx untouched.

* feat(ui): make config write-back + obsidian-detect host-overridable (Phase 6 settings)

configStore.setServerSync(fn) injects only the terminal POST /api/config; the 300ms
debounce, deepMerge batching, singleton, and eager cookie reads stay verbatim.
Settings gains optional onDetectObsidianVaults (default /api/obsidian/vaults), with
the [obsidian.enabled] effect dep + auto-select-first-vault verbatim. No override
caller => Plannotator unchanged. typecheck pass, 1620 tests/0 fail, builds OK.

* feat(ui): make save-to-notes host-overridable (Phase 6 sharing)

ExportModal gains optional onSaveToNotes (default = verbatim POST /api/save-notes);
showNotesTab = isApiMode && !!markdown kept byte-for-byte. Sharing utils already
parameterized (noop). No override caller => Plannotator unchanged. typecheck pass,
1620 tests/0 fail, builds OK.

* feat(ui): make Ask AI transport host-overridable (Phase 6 ai)

useAIChat gains a module-level AITransport (session/query/abort/permission) +
setAITransport/resetAITransport, default = the five /api/ai/* fetches verbatim. The
SSE reader loop, epoch/createRequest guards, and the supersede-abort position inside
createSession stay untouched. Capabilities + provider-resolution stay host-owned in
App.tsx. No override caller => Plannotator unchanged. ai.test.ts 97/0, typecheck
pass, 1620 tests/0 fail, builds OK.

* docs(adr): log Phase 6 completion (4 seams + diff CSS move)

* docs(adr): research + synthesis + spec for Phase 7 (carve @plannotator/core + publish)

Carve a browser-safe @plannotator/core: move the ~15 pure shared modules in,
extract types from the 3-4 node-bound ones (config/storage/workspace-status) so
nothing duplicates, shim @plannotator/shared so Plannotator's 99 import sites stay
unchanged, re-point @plannotator/ui to depend only on core, move wideMode.ts, then
publish core+ui (source-only). shared + ai stay private. Open: registry, versions,
CI job. Publish is the one outward-facing step — confirm before pushing.

* docs(adr): fold configurePlannotatorUI() front door + precompiled CSS into Phase 7 spec

Add the single typed configure() facade over the 9 global host-override setters
(zero-risk, additive) and an optional precompiled CSS bundle (smooths the
Tailwind-in-shared-lib wrinkle) to the Phase 7 publish scope. Both make the
published surface nicer to consume; neither touches Plannotator.

* docs(adr): lock Phase 7 publish decisions + carry over review fixes

Decided: ship JS as source (single internal consumer on controlled stack, no
build to maintain, no dist drift); precompiled CSS now REQUIRED (the @source glob
is fragile under pnpm symlinks); core CI typecheck node-free; pin ui->core exact.
Recorded the interrogation's carried-over Phase-5 code fixes (useExternalAnnotations
split-transport + fallbackRef reset, per-seam override tests, configStore loadFromBackend)
to do before publish.

* docs(adr): ADR 007 — carve @plannotator/core, complete settings provider, publish

Locks Phase 7 decisions: public npm; lockstep version at repo 0.21.0 (ui->core
pinned exact); JS ships as source + required precompiled CSS; core CI node-free;
ai stays unpublished-to-npm. Settings provider completed (loadFromBackend, prefetch
+sync) is now IN SCOPE — Workspaces uses the same UI settings stored in its own
backend. CI publish job wired but artifacts validated on-branch (pack + dry-run)
before merge; first publish gated. Carries the 2 override-path bug fixes + per-seam
override tests as pre-publish work.

* fix(ui): make external-annotation transport reads consistent + reset fallback on re-enable

Two override-path bugs found by the interrogation pass (both unreachable on
Plannotator's path; harden the host-override path for a real consumer):

1. Split-transport: the effect captured the transport at mount for subscribe/poll
   while the CRUD callbacks read the module global live, so a host swapping the
   transport after mount would split reads and writes across two backends. Capture
   once in a ref and use it in all four spots.

2. fallbackRef/receivedSnapshotRef were not reset on effect re-run, so an
   enabled false->true toggle inherited a stale 'already fell back' flag and
   silently stopped updating. Reset both at the top of the effect.

Plannotator unchanged: it never overrides the transport (same default singleton
captured) and enabled never toggles (reset is a no-op). typecheck clean; full
test suite shows zero delta (1605 pass / 45 pre-existing env failures, identical
with and without this change).

* docs(adr): align Phase 7 spec with ADR 007 (version 0.21.0 lockstep, CSS required, scope completeness)

* feat(core): carve @plannotator/core — move pure modules, extract node-bound types, shim shared (Phase 7 step 1)

* feat(ui): depend only on @plannotator/core — re-point all shared/ai imports (Phase 7 step 2)

* refactor(ui): relocate wideMode helper to @plannotator/ui/utils (Phase 7 step 3)

* feat(ui): add loadFromBackend settings rehydration + configurePlannotatorUI front door (Phase 7 step 4)

* build(ui): precompiled styles.css CSS build + madge circular-dep check (Phase 7 step 5)

* test(ui): per-seam override tests + configure routing test (Phase 7 step 6)

Add one override test per seam (setX(fake)→drive→assert→resetX()) for all
9 seams + loadFromBackend, modeled after the existing seam test pattern.
Fix configure.test.ts to defer mock.module() into beforeAll and restore with
captured real function references in afterAll so sibling seam test files are
not poisoned by spy replacements in the shared Bun worker module registry.

* fix(ui): apply Phase 7 review findings — version lockstep + seam consistency

- Bump @plannotator/ui to 0.21.0 (lockstep with @plannotator/core + repo, per ADR 007) [was the 1 critical review finding]
- useAnnotationDraft: route persistNow/dismissDraft save+remove through getDraftTransport() so all paths read the transport consistently (matches the load path; makes the single-global invariant explicit)
- configStore.loadFromBackend: document it must be called BEFORE init() or server values get overwritten
- packages/core/tsconfig: add explicit types:[] so the node-free invariant is first-class (verified: planted node:fs still fails TS2882)

* docs(adr): Phase 7 implementation plan (workflow-generated, durable artifact)

* fix(ui): reconcile #948 with the draft-transport seam + lockstep 0.21.1

Rebased onto origin/main (picks up #948 draft-deletion fix, the 0.21.1 bump, and
the #949/#950 editor fix). The rebase auto-merged #948's code-draft logic
(hasHadAnnotationsRef, empty-state tombstone, clearTimeout in restore/dismiss) with
the Phase-5 transport refactor cleanly — except the empty-state tombstone delete was
left as a raw fetch('/api/draft', DELETE). Route it through getDraftTransport().remove()
so a host backend tombstones its own stored draft on clear (the #948 guarantee, for
hosts). Plannotator unchanged (default transport hits the same endpoint).

Bump @plannotator/core + @plannotator/ui 0.21.0 -> 0.21.1 to match main's version
(lockstep per ADR 007).

Verified: typecheck clean, madge no-cycles, plain suite 1637 pass / 0 fail, #948
draft-clear test 3/0. (The 45 DOM_TESTS failures are the known server/network
integration tests that need a real OS env — same set on main, not regressions.)

* fix(ui): address review nits — host-path robustness + cleanups

- PlanDiffViewer: wrap onOpenVscodeDiff in try/finally so a host opener that throws
  can't wedge the VS Code button in a permanent loading state (default unaffected)
- useExternalAnnotations: (re-)capture the transport inside the effect on enable so a
  host that installs a transport before enabling annotations is honored, not the stale
  default — keeps the split-transport fix (effect + CRUD share one ref)
- configure.ts: import ServerSyncFn from configStore instead of duplicating the type
- repoint the 2 remaining @plannotator/shared test imports to @plannotator/core
- AGENTS.md/CLAUDE.md: document the new packages/core package

All host-path only — Plannotator behavior unchanged. typecheck clean, no cycles,
full suite green. Skipped (not simple/over-engineering): usePlanDiff prop->module-level
(design change), Obsidian late-bind, getSnapshot guard (inert), transport <any> (variance).

* docs: collapse 29 ADR process docs into one packages/ui/README.md

The branch had accumulated ~6,200 lines of ADR scaffolding (6 decisions, 7 specs,
10 research spikes/synthesis, 6 worklogs/roadmaps/plans) for this one effort. Replace
all of it with a single concise README that ships with the published package: what
@plannotator/ui + @plannotator/core are, why they exist (commercial reuse), how the
host-override seams work (configurePlannotatorUI), how a consumer installs/builds, and
the one rule (don't reimplement from scratch — add a seam). Repoint the CLAUDE.md banner
at the README. No code references the deleted docs; main's pre-existing adr/ docs untouched.

* docs(ui): add packages/ui/AGENTS.md guardrail + CLAUDE.md symlink

Directory-scoped agent guidance for anyone editing @plannotator/ui: don't rewrite from
scratch, add a seam (default = today's behavior, Plannotator byte-for-byte unchanged),
core stays node-free, never delete working code until human parity. Points to README.md
for the architecture. CLAUDE.md -> AGENTS.md symlink mirrors the repo root convention.

* build: remove madge circular-dep check (unmaintained)

madge is unmaintained (~3 years stale) and the check was never wired into CI, so it
was a dormant script + devDependency on a load-bearing path. Drop it: remove the
check:cycles script, the madge devDependency, and .madgerc.

The no-cycle invariant still holds by construction — @plannotator/core imports nothing
(zero @plannotator deps in its package.json), so any accidental core->shared/ui import
fails at publish-time bun pm pack (and review). No automated tripwire, but no stale
unmaintained tooling either.

* fix(ui): address review — TDZ guard, html-viewer export, doc corrections

- useExternalAnnotations: declare unsubscribe as let (not const) + guard calls, so a
  host transport that fires onError synchronously during subscribe falls back to polling
  instead of throwing a TDZ ReferenceError (Plannotator's EventSource fires async, never hit)
- package.json: add explicit ./components/html-viewer export (dir has index.ts; the
  ./components/* -> *.tsx wildcard can't resolve it, so external installers would fail)
- README: fix configurePlannotatorUI sample keys to the real option names
  (storageBackend/identityProvider/imageSrcResolver/externalAnnotationTransport)
- AGENTS.md: point the Ask-AI mapping at packages/core/agents.ts (shared/agents.ts is a shim now)

All publish/host-path/doc only — Plannotator unchanged. (#1 CSS-build font collision
deferred to publish-prep — it needs the asset pipeline + files allowlist, not a one-liner.)

* build(ui): don't bundle fonts in published styles.css — app loads fonts (review #1)

Industry standard for a shared UI package: ship theme + component CSS, let the consuming
app load fonts. Drop the @fontsource imports from styles-entry.css (the publish CSS entry);
the theme still defines --font-sans/--font-mono, and the app provides those families. Fixes
the asset-name collision (every emitted .woff2 was renamed styles.css) and shrinks the
published stylesheet 555kB -> 185kB. README documents the two-line @fontsource install.

Plannotator unaffected: its apps (editor/review-editor index.css) load fonts via their own
entry CSS — styles-entry.css is consumed ONLY by the publish CSS build.

* fix(ui): build styles.css on prepack, not prepublishOnly (review #4)

prepublishOnly doesn't run for npm pack / bun pm pack / git / file: installs, so the
package exported ./styles.css without shipping it. prepack runs on any pack, so the
stylesheet is always present. Verified: bun pm pack now emits styles.css.

* chore(ui): post-rebase reconciliation — version lockstep 0.21.3, awaitable AI abort seam

Rebased onto main (0.21.3). Bump @plannotator/core + @plannotator/ui to 0.21.3
to stay in lockstep with the repo version.

Resolve the useAIChat conflict: main added postServerAbort (an awaitable abort
that prevents session-busy races) using a raw fetch. Route it through the
AITransport seam by making AITransport.abort return Promise<unknown> instead of
void, so the host override is honored AND main's await-the-abort behavior is
preserved. Update the abort mocks in the seam/configure tests accordingly.

* fix(ui): make postServerAbort never reject regardless of AI transport

The await site in ask() relies on postServerAbort resolving so a superseding
query can proceed. main's original guaranteed this with its own .catch on the
fetch; routing through the AITransport seam delegated that guarantee to the
transport. Restore it at the call site (Promise.resolve(...).catch) so a host
override that rejects — or returns void at runtime — can't throw out of ask().

* fix(ui): address review — core import, abort sync-throw, snapshot guards

- useAIProviderConfig: import Origin from @plannotator/core/agents (was the only
  ui file still importing @plannotator/shared); drop the masking shared/* path
  alias from ui/tsconfig.json so a stray shared import now fails typecheck. The
  hook is part of the published surface — a standalone install had no
  @plannotator/shared to resolve.
- useAIChat.postServerAbort: defer the transport call into .then so a host abort
  that throws *synchronously* also can't reject (the .catch only caught async).
- useExternalAnnotations: default getSnapshot returns null (skip) on a malformed
  200 instead of coercing to []/0, so it can't clear annotations or reset the
  version cursor — restoring the pre-seam behavior.

* feat(ui): add upload + identity-editable seams for host backends

Two override points the Workspaces app needs that had no seam:

- UploadTransport (utils/upload.ts): image attachments hardcoded POST /api/upload
  with no override. Add a setX/resetX/getX seam (default = today's /api/upload,
  verbatim) and route AttachmentsButton through it. Workspaces sends bytes to its
  R2 asset API and returns the content-addressed URL.
- IdentityProvider.isEditable() (utils/identity.ts): the Settings rename/regenerate
  controls wrote to the cookie store, bypassing a host identity provider — so a
  host with server-owned identity could split one user across two author names.
  Add an optional isEditable() (default true) and hide the rename controls when a
  host returns false. Plannotator's cookie identity stays editable — unchanged.

Both wired into configurePlannotatorUI(); seam tests added; configure routing test
covers uploadTransport. HANDOFF.md updated with the Workspaces seam mapping from
the repo research (asset layer, identity, realtime, no-AI-infra, the Me
display-name backend follow-up). README publish command corrected to bun pm pack
+ npm publish.

* refactor(ui): capture sessionId synchronously in postServerAbort

Self-review: the deferred .then read sessionIdRef.current a microtask after the
guard checked it. Capture the id synchronously so the abort always targets the
session current at call time and there's no double-read.

* fix(ui): address review — seed host store, browser-safe timer type, harden abort

- configStore.loadFromBackend: seed the host StorageBackend with resolved defaults
  for keys it lacks. The constructor runs at module load (before a host installs
  its backend), so its default-seeding writes went to the cookie backend; without
  this a fresh host store was never populated and generated defaults (e.g.
  displayName) regenerated every reload. [P1, host path]
- Viewer.tsx: replace NodeJS.Timeout with ReturnType<typeof setTimeout> (2 refs)
  so a browser-only consumer compiling the published source doesn't need
  @types/node. Matches the pattern already used in configStore. [P1, published path]
- useAIChat: harden the create-session supersede abort the same way as
  postServerAbort, so a host transport that throws can't surface an unhandled
  rejection. No impact on Plannotator (default self-catches). [nit]
- .gitignore: correct stale 'prepublishOnly' comment to 'prepack'. [nit]

Plannotator behavior unchanged (it never calls loadFromBackend; the timer/abort
changes are behavior-preserving). Strengthened configStore seam test to assert
first-run seeding. typecheck clean, 1773 pass / 0 fail.

* refactor(ui): single-source the never-reject abort via safeAbort helper

Self-review: the hardened abort pattern (defer into .then + .catch so a host
transport that throws can't reject) was duplicated across postServerAbort and the
create-session supersede site — the exact drift the review flagged. Extract a
module-level safeAbort(sessionId) so both call sites share one hardened
implementation and can't diverge again. Behavior unchanged; reads aiTransport at
call time so a late override is honored.

* chore(ui): post-rebase version lockstep to 0.21.4

Rebased onto main (0.21.4, adds markdown math #878 + parser hardening). Bump
@plannotator/core + @plannotator/ui to 0.21.4 to stay in lockstep with the repo.
katex (main's math dep) merged into ui; typecheck clean, 1810 pass / 0 fail.

* docs(ui): consumer-lens handoff hardening + ADR 005

- HANDOFF.md: add supported-imports allowlist vs unsupported (hardcoded
  /api/*) list; document the annotation anchor schema, reattachment
  order, and untested stale-anchor degradation; state that the markdown
  editor cannot take CM6/Yjs extensions yet and the plan of record;
  note AI avoidability re-verified post-rebase; fix stale 0.21.3 ref.
- adr/decisions/005: record the publish-as-packages decision (packages
  over copy/vendor, core/ui split, seam-singleton pattern + SSR revisit
  condition, the law, lockstep publish model).

* fix(ui): make shipped source strict-TS clean for consumers + seam type barrel

Consumers compile the published TS source with their own compiler options,
and strict mode failed with 35 errors inside the package:
- settings.ts: satisfies SettingDef<unknown> is contravariantly illegal
  under strictFunctionTypes (33 errors) — use SettingDef<any>
- useDismissOnOutsideAndEscape: RefObject<HTMLElement> rejects React 19's
  useRef<T>(null) refs — widen to HTMLElement | null
- globals.d.ts: declare *.png / *.webp modules, referenced from each
  asset-importing component so any consumer program that includes one
  gets the ambient declarations

Also unscatter the seam contract types: configure.ts re-exports every
seam type next to configurePlannotatorUI, and ServerSyncFn is now
exported from config/index.ts (it was unreachable through the exports
map). Verified: standalone Vite consumer importing the full supported
surface passes tsc --noEmit under full strict (was 35 errors).

* fix(ui): keep KaTeX fonts out of published styles.css (back to ~187KB, was 1.6MB)

Main's math PR imports katex/dist/katex.min.css in theme.css; the
publish build (Vite lib mode) force-inlines all 60 KaTeX math fonts as
data URIs, ballooning styles.css to 1.6MB (977KB gzip) and breaking the
package's consumer-owns-fonts policy. Alias the katex stylesheet to an
empty stub in vite.css.config.ts only — theme.css stays untouched (no
rebase surface) and Plannotator's own apps, which import theme.css
directly, still bundle KaTeX as before. Hosts that render math load
katex.min.css themselves (bundler import, CDN tag, or self-hosted copy
per HANDOFF.md), which also gets them lazy font loading. Verified:
fresh build is 186.9KB / 30.8KB gzip with zero @font-face data URIs;
consumer vite build CSS drops 1.66MB -> 200KB.

* docs(ui): HANDOFF corrections from adversarial consumer review

- Math rendering section: KaTeX css/fonts excluded from styles.css by
  design; three one-time host setup options (self-hosted recommended,
  CDN tag, bundler import)
- styles.css size claim corrected (~187KB / ~31KB gzip) + strict-TS
  guarantee documented (verified against a standalone consumer)
- AI-avoidability claim made precise: configure.ts statically imports
  useAIChat for its setter; unused AI code tree-shakes to zero (bundle-
  verified) — the runtime claim holds, the static wording was wrong
- Loud warning on the loadSettingsFromBackend ordering footgun:
  configuring before hydration seeds generated defaults into the host
  backend and nothing re-runs hydration
- DraftTransport.load() tombstone-generation contract spelled out
- Seam-type barrel documented on the configure row; 'everything is
  importable' softened (some components/*.ts don't resolve via the
  *.tsx wildcard); stale diff stats refreshed

* docs(ui): math setup pointer in README + pnpm caveat on the katex bundler-import option

* fix(ui): lazy settings resolution — zero cookies on a configured host

The configStore resolved all settings eagerly in its constructor, at
module import — before a host's configurePlannotatorUI() could install
its StorageBackend — writing 17 plannotator-* cookies (including a
generated identity) onto the host origin. Resolution now runs lazily on
first settings access (get/set/init/loadFromBackend): by then the host
backend is live, so the initial reads AND default-seeding writes route
through it. A configured host gets zero cookies, ever.

Plannotator unchanged: same resolution, same cookie seeding, same
values — on first settings read (same page load) instead of at import.
New configStore.lazyInit.seam.test.ts proves the contract from a fresh
module graph; full suite + consumer strict tsc green.

* chore(ui): post-rebase version lockstep to 0.22.0

* fix(ui): round-2 review batch — dedupe asset declarations, CI seam tests, strict consumer gate, doc corrections

- components/types.d.ts: drop the *.png/*.webp declarations that
  globals.d.ts now owns — both shipping was a duplicate-identifier
  error for any consumer with skipLibCheck: false
- untrack packages/ui/styles.css (generated by prepack, gitignored;
  got scooped into the carve commit during the rebase by git add -A
  before the ignore entry existed in the replay)
- CI: the DOM test step now runs ALL packages/ui tests, so the seam
  contract tests (AI/draft/external-annotations/file-tree/inline-
  markdown) actually execute in CI instead of skipping
- new packages/ui/tsconfig.strict-consumer.json wired into root
  typecheck: type-checks the supported-import surface under full
  strict, so the consumer strict-TS guarantee can't silently rot
- HANDOFF: rot-proofed the diff stat, strict guarantee now cites the
  CI gate, CDN katex pinned-version wording, theme-vs-styles.css
  caveats (theme still imports KaTeX + needs Tailwind), Viewer
  required props, Yjs plan-of-record updated to the atomic-editor fork
- README: @source fallback wording (build entry isn't shipped)

* test(ui): make the lazy-resolution seam test deterministic

The test asserted lazy resolution on the module singleton and relied on
its test file getting a fresh module graph — an isolation assumption
that doesn't hold under all bun test orderings (CI failed with zero
observed reads because another file had already resolved the store).
Test the contract on a fresh instance instead: ConfigStore is exported
as @internal ConfigStoreForTest, the spy backend is installed before
construction, and the test asserts construction reads nothing while the
first get() resolves and seeds through the live backend. Deterministic
by construction.

* test(ui): poll for the debounced reconnect refetch instead of a fixed sleep

The reconnect-refresh assertion waited a fixed 150ms against the SSE
watcher's 120ms debounce — a 30ms margin that slower CI runners lose,
flaking 'refreshes after an SSE ready event from reconnect'. The
watched logic is unchanged (verified byte-identical to main's inline
version — the seam only relocated it into the default watchTrees and
added the onChange indirection). Poll for calls.length===2 up to 1s so
the pass/fail is hardware-independent.

* test(ui): poll the committed tree state, not the fetch call count

Prior fix polled calls.length===2, but the fetch call is counted one
tick before its result commits to React state — so the poll exited
early and the next assertion (dirs[0].tree === reconnectedTree) lost the
race on slow CI (toEqual failure). Poll on the committed tree itself,
which is exactly what the assertion checks: now the only way to fail is
a genuine no-refresh, not a timing margin.

* test(ui): give the reconnect-refetch poll a 10s ceiling + 20s test timeout

A CI runner was measured at 6x normal speed (1676ms for a ~275ms test),
blowing through the 1.5s poll ceiling before the 120ms debounce fired —
same commit passed on a faster runner. Raise the poll to ~10s and set an
explicit 20s test timeout (bun's 5s default would otherwise kill the
poll). Root cause is load, not logic: this timing-sensitive test only
started flaking when the CI DOM step was broadened to run the whole ui
suite in one process.

* ci: run the file-browser DOM test isolated; scope the DOM step to DOM files

Root-causes the intermittent 'refreshes after an SSE ready event from
reconnect' failure. The round-2 change ran the ENTIRE ui suite under
DOM_TESTS=1 to catch the seam contracts; that load intermittently
starved the test's 120ms real-timer debounce so the reconnect refetch
never fired (observed failing after a full 10s poll — not a margin
issue). The hook logic is byte-identical to main, and main runs this
test in its own process (green for months).

Fix at the CI layer, not the test: run useFileBrowser.test.tsx isolated
(matching main), and run the seam contracts + remaining DOM-gated tests
as an explicitly-scoped light batch. The test file is reverted to main
verbatim (today's timing-poll experiments dropped). Follow-up issue to
file: the underlying re-subscription race the load exposed.
2026-07-06 20:39:09 -07:00