Adds two session-only flags to plannotator review, parsed in the shared
parser so every host inherits them together:
- --base <ref> opens the session against a caller-chosen compare target
(branch, origin/<branch>, tag, SHA, HEAD~N), probed with git rev-parse
--verify --end-of-options before the server starts so a typo'd ref is a
startup error with near-match suggestions instead of a silently
mislabelled merge-base->HEAD diff.
- --diff-type <id> opens the session in one of the nine flat git diff
modes (REVIEW_OPEN_DIFF_TYPES, pinned against GIT_DIFF_TYPES).
The flags are a seed, never a setting: nothing writes config.json or any
review cookie, and the UI stays fully mutable. Validation is pure in
packages/shared/review-open-state.ts (provider matrix errors on
jj/GitButler/P4/workspace/PR mode, promote-with-notice when the saved
default is base-irrelevant, fatal explicit contradiction).
A flagged base rides explicitBase semantics: the new initialBaseExplicit
server option (both runtimes) seeds baseExplicitlyChosen, suppressing the
startup origin/* upgrade and canonicalization, and openStatePinned rides
/api/diff so the client neither offers the first-run setup dialog (its
one-time cookie is NOT consumed) nor runs the panel-pair self-heal for a
pinned session. The since-base dropdown label now renders from the live
active base, matching the adjacent base picker.
Coverage: Bun CLI, opencode-review bridge, OpenCode embedded plugin, and
the Pi extension (re-vendored; strict validation on the slash-command
path only, programmatic callers unchanged). Skills, command stubs, help
text, and docs updated across every host surface.
parseReviewArgs now reports argument-shape problems through an always-present
errors[] field instead of letting unknown dash-prefixed tokens fall into the
ignored positional list. All four host surfaces refuse to start a session on
a parse error: the CLI and the opencode-review bridge exit 1 with the errors
on stderr, and the Pi and OpenCode plugins notify through their hosts. Plain
non-dashed words stay tolerated for slash-command hosts that forward raw user
prose. The loop is index-based so value-taking flags can consume their value
token.
Closes#1456. Adds plannotator review --json emitting one { decision, message } record from the same builder as the plaintext output, routes Amp solely on the decision field, and removes the substring classifier that could silently drop feedback mentioning phrases like 'no feedback'. Invalid or legacy CLI output produces a recoverable update notice, never a guessed decision.
Claude-Session: https://claude.ai/code/session_019GV6EKtzh8Pf9GA2rrBLNf
- Anonymize the real project paths and ticket prefix in session-log
test fixtures (slug expectations updated to match).
- Untrack scripts/convert-themes.ts: a one-shot migration script
hardcoding a path into an unrelated private project; its output in
packages/ui/themes/ is already committed.
- Delete the three unreferenced sprite_package_*/index.html preview
pages, which also shipped in the @plannotator/ui npm tarball via the
wholesale directory entries in files.
Applies the PR5 stage-review rulings:
M0: approve-time feedback is no longer appended raw after the approved
prompt ("no changes requested" beside a change-request-shaped export read as
a contradiction). composeReviewApprovedMessage now resolves the new
DEFAULT_REVIEW_APPROVED_WITH_NOTES_PROMPT (configurable as
prompts.review.approvedWithNotes; field added to the config review section),
which frames the notes as non-blocking guidance and says not to revise or
reopen. Signature is now (runtime, feedback, config) so one function fixes
all four consumers; re-vendored to Pi; prompts.test.ts pins the framing,
the config override, the byte-identical bare approval, and the legacy
placeholder filter; cli-bridge.test.ts asserts the bridge routes through
the composer.
M1: fail-closed approval-notes handshake for the OpenCode CLI bridge. The
plugin declares supportsApprovalNotes: true on the opencode-review stdin
JSON; the binary adverts approvalNotesSupported for opencode ONLY when the
declaration is present, so a new binary + old plugin (advert in the binary,
delivery in the independently-versioned plugin) renders no approve-carrying
items instead of silently dropping the reviewer's note.
supportsReviewApprovalNotes stays the seam; documented at both ends beside
the existing version-skew reasoning. Pinned end to end by
apps/hook/server/opencode-review-advert.test.ts, which spawns the real
entrypoint and reads /api/diff: stdin without the declaration serves false,
with it true.
m1: applyPRResponse re-applies the advert from the PR-family payloads
(pr-diff-scope, pr-switch, load-full-diff), so the client stays in lockstep
with whatever diff payload it last applied — the "whole diff family" comment
is now literally true.
m2: buildReviewApprovalBody folds a note in ahead of the export when
annotations also ride, so a future combined item cannot lose data; pinned
with a pure assertion.
i1: AGENTS.md corrected — the standalone dev server emits raw decision JSON
with unfiltered feedback and does not route through the composer; the
consumer list, framing, and handshake are now described accurately.
Claude-Session: https://claude.ai/code/session_01Drrzd1x4EfnH9N3z7nNwo9
The four waitForDecision consumers that threw result.feedback away on the
approved branch (spec §6.3) now emit composeReviewApprovedMessage — the
approved prompt, then the note when one rides the decision:
1. Claude Code CLI `plannotator review` (apps/hook/server/index.ts); the
amp/droid plugins relay its stdout and inherit the delivery.
2. OpenCode native (apps/opencode-plugin/commands.ts) — also fixes the
delivery gate: it rode on the LGTM placeholder making feedback truthy, so
with the placeholder gone a bare approval would have been silently dropped;
the gate is now `feedback || approved`.
3. OpenCode CLI bridge (buildReviewPromptFromBridgeOutcome) — the CLI's JSON
record always carried the feedback; the bridge stops discarding it.
4. Pi (apps/pi-extension/index.ts) via the vendored prompts module.
Each consumer's startReviewServer call now passes the matching advert:
supportsReviewApprovalNotes(origin) for the hook CLI (new seam in
apps/hook/server/review-output.ts — every origin shares the one stdout
relay today), Boolean(sessionId) for OpenCode native (no session, no
delivery — the annotate precedent), unconditional true for Pi and the
standalone dev server (which already emitted feedback on approve).
Claude-Session: https://claude.ai/code/session_01Drrzd1x4EfnH9N3z7nNwo9
* feat(server): archive every submitted review to a durable local feedback store
Submitted feedback was only as durable as the agent session that asked for
it. Code review persisted nothing at all: /api/feedback deleted the draft,
settled the decision promise, and if the invoking agent had already timed
out the review existed nowhere (the failure #678 fixed for annotate). Plan
decisions only reached plans/ while the client-side planSave setting was on,
and repeat decisions on one plan overwrote each other. Annotate kept the
#678 record for single local files only.
Every submission now appends one record to
${PLANNOTATOR_DATA_DIR}/feedback/{project}/index.jsonl, plus a
records/{stamp}-{surface}-{decision}.md sidecar when it carries content,
written at decision settlement time inside the servers so all nine agent
frontends are covered by two implementations.
Surfaces wired in both runtimes: plan approve and deny, code review
/api/feedback (Send Feedback, Approve, LGTM) and /api/exit, annotate submit,
approve and exit. Bare approvals, LGTMs and dismissals are decision-only
JSONL lines with no sidecar.
Records are cheap by design. Code review carries diff identity (vcsType,
diffType, base, gitRef, snapshotId, cwd, PR metadata, changed-file count,
patch byte count) and never the patch bytes; plan records carry the decision
text plus a reference to the history/{project}/{slug}/NNN.md version the
decision was made on rather than a second copy of the plan. Annotation
provenance (source, author) is preserved, so external, review-agent and
WebMCP findings stay tagged and source == null selects the reviewer's own
comments.
The shared module never throws: an archive failure is logged, degrades
silently for the user, and keeps the annotation draft as the recovery copy.
The append happens before deleteDraft, generalizing the #678 ordering.
Controlled by PLANNOTATOR_FEEDBACK_HISTORY / feedbackHistory (default on).
PLANNOTATOR_ANNOTATE_HISTORY=0 additionally suppresses records for every
annotate surface, so the documented stateless-annotate promise still holds.
"feedback" is added to PURGE_OWNED_TOP_LEVEL so uninstall purge removes it.
AI-assisted (Claude) under maintainer direction.
* fix(server): stop the feedback archive from writing into the real data dir in tests
Review findings on the durable feedback archive.
1. The archive is default-on, and most server tests boot a real plan, review,
or annotate server without redirecting PLANNOTATOR_DATA_DIR, so `bun test`
deposited records in the contributor's own ~/.plannotator/feedback (24 files
across 12 buckets from two test files alone) on CI and every machine. A new
bunfig test preload, tests/setup/feedback-archive-off.ts, turns the archive
off for the suite; the archive's own tests opt back in inside their test
bodies, which is also how they exercise the opt-out. Those tests now use
distinctive project names and remove the annotate history they leave in the
real data dir, since storage.ts fixes its data directory at import time.
2. PR reviews bucketed under feedback/pr-<n>/. PR mode never sets gitContext
and --local points agentCwd at a pool/pr-<n> checkout, so deriving the
project from the review cwd was wrong. ReviewServerOptions now takes a
`project` option, mirroring the annotate server, preferred over the cwd
derivation on both runtimes; the Claude Code, OpenCode, and Pi entry points
pass their already-computed detectProjectName() result.
3. changedFiles overcounted renames: extractChangedFiles unions the a/ and b/
sides so a reader can resolve either path. The record now counts b-side
paths through countChangedFiles, so a rename is one file.
4. Docs: the feedback archive is added to the privacy page and
PLANNOTATOR_FEEDBACK_HISTORY (plus PLANNOTATOR_ANNOTATE_HISTORY) to the
environment variables reference. The overclaim that every submitted review
is archived is corrected: a review posted straight to GitHub or GitLab
through /api/pr-action is not archived locally yet. Three behaviors are now
written down: O_APPEND is not atomic on NFS or SMB and a genuine interleave
damages both records that raced, folder-session records carry the folder
path rather than the open document, and URL-session records store the full
URL including its query string.
5. Pi parity: the Node mirror now has the failed-archive-write test (the one
invariant its handler copies by hand) and the PR-mode bucketing test.
Comments only, no behavior change: the pool checkout recorded in
target.review.cwd can be cleaned up before anyone reads the record, and
getPlanVersionPath resolves the data directory storage.ts captured at import
while the archive resolves it per call.
AI-assisted (Claude) under maintainer direction.
* docs(server): make the feedback index an explicit multi-client contract
plannotator-tui will append to the same feedback/{project}/index.jsonl with
client "plannotator-tui", so the module's stance of "a client tool may emit
this shape under its own clients/ namespace" is out of date. The index is one
shared source of records, labeled by client.
1. The module docstring and the FEEDBACK_RECORD_CLIENT comment now describe the
shared index: several tools append to the same file, separated by `client`;
plannotator-tui is a known second writer, herdr-annotate is reserved, and
`client` is an open set rather than an enum to validate against.
2. Two optional fields are declared so v1 reserves their names across clients:
target.agent ({ host, session, transcript }) for surfaces whose subject is
an agent session rather than a file or a diff, and top-level clientVersion.
Neither is populated here. clientVersion stays unset deliberately: there is
no runtime-agnostic version constant in packages/shared, and reading
package.json from a vendored module would be a new filesystem dependency
for cosmetic data.
3. Sidecar naming is documented at the naming site and in AGENTS.md: other
clients suffix their id ({stamp}-{surface}-{decision}-plannotator-tui.md),
so recordFile values carrying such suffixes are valid and nothing may parse
a sidecar name. Nothing in this repo did: every consumer treats recordFile
as an opaque handle and no test pins a filename pattern. A new test appends
a foreign line (unknown client, unknown fields, suffixed recordFile) and
pins that the reader keeps it.
4. Honesty fix to the atomicity comments, in code and in AGENTS.md:
appendFileSync loops internally, so "one write syscall" was wrong even on a
local filesystem. The real model is that a line-sized buffer handed to a
single append-mode write completes without interleaving in practice
locally, with the reader's skip-unparsable tolerance as the backstop and
the NFS/SMB caveat unchanged.
5. Exhausting the sidecar collision counter now throws a named error instead of
re-throwing a bare EEXIST, so the server log says what actually happened:
100 taken names in one millisecond means a stopped clock or a runaway
writer, not a transient disk problem.
6. AGENTS.md and the parseFeedbackIndex doc state the reader contract: lines
are gated on a numeric `v` and unparsable ones are skipped, so analyzers
that depend on v1 semantics should filter v <= 1 themselves. Fields are
added, never repurposed, so a v2 would mean a real shape change.
AI-assisted (Claude) under maintainer direction.
The e2e case from #1444 spawns the real CLI, whose module load imports
../dist/index.html and ../dist/review.html. Dev machines always have
those built, but CI's test job does not build the apps, so the spawn
died at import time with 'Cannot find module' before the guard ran and
the assertion on the unknown-command message failed, breaking main.
The test now drops empty placeholders for any missing artifact before
spawning and removes exactly what it created afterwards; a real dist is
never touched (verified with sentinel content). The guard under test
exits before either page is served, so placeholder content is
irrelevant.
A typo'd subcommand fell through the dispatcher to the plan-hook path,
which reads the hook payload from stdin. From a terminal that stdin never
closes, so `plannotator annotatte README.md` hung with no output instead
of reporting the typo.
Reject an unrecognized first token before the dispatcher runs: print the
offending word, the nearest documented command, and a pointer to --help,
then exit 1. Flags and the no-argument hook invocation are left alone so
the real hook path is untouched.
* feat(skills): add the plannotator knowledge-layer skill with a CLI freshness guard
A new model-invocable core skill (apps/skills/core/plannotator) that teaches
an agent the whole CLI surface: decision guide, per-command reference with
flags and exit codes, env vars, the external-annotations API, and a do-not
list. The existing plannotator-* core skills stay lightweight action stubs.
A freshness test (apps/hook/server/plannotator-skill-reference.test.ts)
parses the skill's documented subcommands and flags and diffs them against
cli.ts usage text plus the CLI arg-parsing sources, in both directions, so
the reference cannot drift from the real CLI without failing the suite.
Installers copy the single-sourced core body into ~/.claude/skills and
~/.agents/skills on all three platforms; uninstall removes it from both
scopes. The skill ships model-invocable as a documented exception to the
locked-by-default rule, asserted both ways in install.test.ts.
* feat(marketing): serve the plannotator knowledge skill as /llms.txt
Single-sourced at build time from apps/skills/core/plannotator/SKILL.md
per the llmstxt.org spec (H1, blockquote, detail sections, Docs link
list), so the CLI freshness guard transitively keeps llms.txt current.
* fix(skills): reach every install path with the plannotator knowledge skill
The knowledge skill reached Claude Code and ~/.agents but was missing from
three install paths. Six fixes from the install-reach review of #1377.
Kiro: the installer's Kiro leg copied only the two action skills, so Kiro
users got launchers and no CLI reference. One copy line per installer, and
"plannotator" joins uninstall.ts's KIRO_SKILLS.
OpenCode npm: @plannotator/opencode's postinstall copied only commands/*.md.
The package now ships the skill (copied at build time like the HTML assets,
gitignored so the shipped copy cannot drift) and postinstall places it under
${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills/plannotator/, which is a
path OpenCode really scans ({skill,skills}/**/SKILL.md under xdgConfig/
opencode). Uninstall sweeps it, skills only, so a user's own
opencode/commands/plannotator.md stays out of scope.
Pi npm: vendor.sh copies the skill to apps/pi-extension/skills/plannotator/
and package.json declares it under pi.skills, which Pi resolves relative to
the package root. Neither vendored copy carries the // @generated header the
.ts files use: a SKILL.md must open with its frontmatter on line 1.
llms.txt: the endpoint resolved the skill through process.cwd(), which breaks
under any invocation but --cwd apps/marketing. new URL(import.meta.url) does
not fix it either, because Vite rewrites import.meta.url to the emitted SSR
chunk's location. Inlined with Vite's ?raw, resolved by the bundler relative
to the source file. Also drops the summary paragraph the required blockquote
already carries; SKILL.md itself is unchanged.
Uninstall: KNOWLEDGE_SKILLS is a separate list from CORE_SKILLS precisely so
the bare name "plannotator" cannot leak into LEGACY_COMMAND_NAMES or
STALE_CODEX_SKILLS and delete a user's own files. Nothing tested that; now a
test proves the five installed scopes are removed and commands/plannotator.md
(Claude and OpenCode) plus ~/.codex/skills/plannotator survive. Also
cleanupStaleSkillLayout now knows KNOWLEDGE_SKILLS.
Origins: oh-my-pi (#1373) was missing from SKILL.md's PLANNOTATOR_ORIGIN row.
The guard now imports AGENT_CONFIG and asserts the row names every key and
invents none, and its header comment is narrowed to what it actually proves:
bidirectional for subcommands and origins, one-directional for flags.
AI-assisted (Claude) under maintainer direction.
* 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.
Phase 2 of live app annotation: full parity on Pi over one shared
implementation instead of drifting copies.
- Extract every proxy decision into packages/shared/live-proxy-core.ts
(HTML injector state machine, loopback/Host/Origin predicates,
CSP/X-Frame-Options policy, redirect rewrite, WS origin gate, bridge
assembly, liveAppDraftIdentity) and the CLI probe + live-mode messages
into packages/shared/live-probe.ts. packages/server/live-proxy.ts is
now a thin Bun transport over the core; its test suite passes
unmodified.
- Add packages/shared/live-proxy-node.ts, the node:http transport the Pi
extension runs: streaming request/response piping through the shared
injector, and WebSocket (HMR) passthrough that replays the client's
handshake upstream over raw TCP and pipes the sockets byte-for-byte.
Transport tests run the proxy in a real node child process, because
Bun's node:http shim drops writes to an upgrade event's socket.
- Wire Pi: /plannotator-annotate probes loopback URLs live-first with
the shared probe (same 3s timeout, same <500 gate, same messages),
recognizes --app/--static via parseAnnotateArgs's liveFlags opt-in
(OpenCode deliberately does not opt in), and serves mode annotate-app
from serverAnnotate.ts with the shared per-target draft identity,
live sessions excluded from history/submissions, the remote hard-off
throw, and guarded live-proxy shutdown.
- Vendor live-proxy-core/live-probe/live-proxy-node plus the
dependency-free bridge-script constants to generated/.
- Docs: AGENTS.md phase-gate passages, marketing annotate page, Pi
README.
* 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.
* 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.
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
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
* 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.
* 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
- annotate --tailscale publish failures now exit through
annotateStartupFailureExitCode: exit 2 under a strict gate
(--require-approval / --result-file), where exit 1 is reserved for "the
reviewer did not approve, decision record published". Non-strict annotate
and review keep the documented exit 1.
- the annotate client lease (auto-dismiss on abandonment) is forced off
while tailnetPublished is set: --tailscale reads as local to the CLI
predicate, but clients connect through the serve proxy, and a proxy
disconnect longer than the grace would dismiss a live review. Same
rationale as remote/shared sessions; decided at the single point both
the /api/plan advert and the SSE endpoint read.
- the SIGHUP-to-process.exit route moved from an unconditional CLI-entry
listener into enableTailscaleServe's success path, installed only once a
serve mapping exists. Any SIGHUP listener overrides the ignored
disposition nohup depends on, so plain sessions now keep zero listeners
and "nohup plannotator review &" survives terminal close again;
--tailscale sessions still tear their mapping down on HUP (exit 129).
- validateGuideOutput explains a fully-invalidated guide whose refs named
files outside the changeset (count plus up to 3 example paths, with a
pointer to the Commits panel) instead of the bare generic message; the
generic message stays for genuinely structural emptiness. The informative
error now flows through onJobComplete to the job failure card; Pi picks
the change up via the vendored guide-review copy.
* feat(remote): resolve urlHost auto from Tailscale for advertised URLs
PLANNOTATOR_URL_HOST=auto (or config urlHost: "auto") detects this
machine's tailnet host at first use in a remote session: MagicDNS name
from tailscale status --json, falling back to the single tailscale ip -4
CGNAT address. Detection is cached per process, never spawns in local
sessions, warns once and falls back to localhost on failure, and stays
strictly display-only: binding remains governed by PLANNOTATOR_REMOTE.
Pure parsers live in the new @plannotator/shared/tailscale module,
vendored to the Pi extension; both runtimes mirror the resolution.
* feat(remote): render a terminal QR code for remote-ready session URLs
Remote sessions print their advertised URL as the lifeline; the usual
next step is opening it on another device (iPad, phone, laptop off the
VPS). handleServerReady now also renders a compact unicode QR of that
URL via the zero-dependency uqr package, TTY-gated so piped stderr and
hook transcripts keep only the plain URL line.
Pi keeps URL-only parity: its ready surface is an in-chat notification,
not a TTY stream, so a QR block would not render there.
* feat(cli): first-class --tailscale mode for review and annotate sessions
plannotator review --tailscale (also annotate and annotate-last/last)
publishes the session over the user's tailnet: the server stays
loopback-bound and the CLI orchestrates tailscale serve --bg
--https=<port> http://127.0.0.1:<port>, then advertises the HTTPS
tailnet URL with a terminal QR code. Nothing listens beyond localhost
and nothing is ever public (serve, never funnel).
Guarantees: preconditions fail with actionable errors (CLI missing,
daemon down or logged out); a pre-existing serve mapping on the chosen
port aborts instead of being stolen and other ports are never touched;
every mapping the process creates is torn down on normal completion,
SIGINT/SIGTERM, and errors via the exit-routed cleanup handler. When
combined with PLANNOTATOR_REMOTE or SSH detection, --tailscale wins and
forces local mode with a stderr notice, which also restores the random
local port so simultaneous sessions get distinct serve mappings.
* fix(remote): await tailscale-ready failures, harden serve teardown and conflict detection
Review fixes for #1280 (external review plus internal security review).
Startup failures no longer hang the session: startReviewServer and
startAnnotateServer now await async ready handlers and stop the server
on rejection, and the CLI's --tailscale ready path resolves publishing
failures itself with an actionable stderr message and exit 1. Under the
bang-prefix skill a hanging loopback server blocked the whole Claude
Code prompt.
Serve teardown is checked, not assumed: a failed off retries once, then
warns with the exact manual command, and a port is only forgotten after
a successful off. SIGHUP (terminal close) is now routed through
process.exit like SIGINT/SIGTERM so exit-time cleanup runs. Docs no
longer claim guaranteed cleanup: --bg mappings survive SIGKILL and
reboots, and the manual removal command is documented.
Conflict detection sees foreground serve sessions (Foreground.*.TCP),
which Tailscale prefers over background mappings, and fails CLOSED on
unrecognizable serve status output instead of assuming the port is
free. The extracted serve URL must match the requested port, so a
version-dependent output shape cannot advertise another mapping's URL.
The annotate agent terminal is gated off by default under --tailscale
behind the existing PLANNOTATOR_AGENT_TERMINAL_REMOTE opt-in: the PTY
token is not an auth boundary against network peers, and tailnet
reachability implies terminal reachability.
Also: --tailscale is rejected with a clear error on unsupported
subcommands and documented in review/annotate/annotate-last and
top-level help; the remote-ready QR renders only for URLs actually
reachable off-machine (never localhost); urlHost is suppressed for
--tailscale runs so the local-session warning cannot mislead; the
duplicated auto-host resolution moved into the shared vendored module;
tailscale-serve tests restore module and process state via a reset
seam.
* 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.