Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ jobs:
packages/ui/components/html-viewer/htmlPinpointProtocol.test.tsx
packages/ui/components/html-viewer/htmlLiveProtocol.test.tsx
packages/ui/components/html-viewer/HtmlViewer.vimHud.test.tsx
packages/ui/components/html-viewer/HtmlViewer.bridgeAsset.test.tsx
packages/ui/components/Viewer.vimMode.integration.test.tsx
packages/ui/hooks/useVimSelection.test.tsx
packages/ui/utils/codeHighlight.test.ts
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,7 @@ security-results/
# @plannotator/ui CSS build artifacts (generated by prepack — not committed)
packages/ui/styles.css
packages/ui/styles.js
# HTML viewer bridge assets, generated at prepack from bridge-script.ts
packages/ui/components/html-viewer/bridge-script.asset.js
packages/ui/components/html-viewer/bridge-script.lite.ts
.wrangler/
61 changes: 61 additions & 0 deletions packages/shared/live-proxy-bridge-inline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Live app annotation is untouched by the HtmlViewer `bridgeScriptUrl` seam:
* the proxy keeps composing the INLINE bridge body it serves from its own
* route and injecting that route's tag into proxied HTML. A later change
* that routed the live path through the package asset (or its URL prop)
* would move a per-session token-bearing body onto a host-served file, so
* this pins the current shape at source level, on both transports and both
* runtimes' composers.
*/
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
// Relative on purpose: @plannotator/ui is not a dependency of shared; only the
// CLI and Pi import the bridge module, and this test reads the same file.
import { BRIDGE_SCRIPT, LIVE_BRIDGE_BOOTSTRAP } from "../ui/components/html-viewer/bridge-script";
import {
LIVE_PROXY_BRIDGE_PATH,
LIVE_PROXY_BRIDGE_TAG,
composeLiveBridgeJs,
} from "./live-proxy-core";

const root = resolve(import.meta.dir, "../..");
const read = (path: string) => readFileSync(resolve(root, path), "utf8");

describe("live proxy bridge delivery", () => {
test("the proxy-served body carries the inline bridge verbatim", () => {
const body = composeLiveBridgeJs({
token: "tok",
editorOrigins: ["http://localhost:1"],
annotationCss: ".x{}",
bridgeBootstrap: LIVE_BRIDGE_BOOTSTRAP,
bridgeScript: BRIDGE_SCRIPT,
});
expect(body.endsWith(BRIDGE_SCRIPT)).toBe(true);
expect(body).toContain(LIVE_BRIDGE_BOOTSTRAP);
// The injected tag names the proxy's own route, never a host asset.
expect(LIVE_PROXY_BRIDGE_TAG).toBe(`<script src="${LIVE_PROXY_BRIDGE_PATH}"></script>`);
expect(LIVE_PROXY_BRIDGE_PATH).toBe("/__plannotator__/bridge.js");
});

test.each([
"packages/shared/live-proxy-core.ts",
"packages/shared/live-proxy-node.ts",
"packages/server/live-proxy.ts",
"packages/server/annotate.ts",
"apps/hook/server/index.ts",
"apps/pi-extension/plannotator-browser.ts",
"apps/pi-extension/server/serverAnnotate.ts",
])("%s never references the srcdoc URL seam or the generated asset", (path) => {
const source = read(path);
expect(source).not.toContain("bridgeScriptUrl");
expect(source).not.toContain("bridge-script.asset");
expect(source).not.toContain("bridge-script.lite");
});

test("both runtimes still hand the inline string exports to the composer", () => {
expect(read("apps/hook/server/index.ts")).toContain("bridgeScript: BRIDGE_SCRIPT,");
expect(read("apps/pi-extension/plannotator-browser.ts")).toContain("bridgeScript: bridge.BRIDGE_SCRIPT,");
expect(read("packages/shared/live-proxy-core.ts")).toContain("+ sources.bridgeScript");
});
});
51 changes: 50 additions & 1 deletion packages/ui/HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,12 +471,61 @@ Four modules that used to ride every document read for a host that bundles by ro

3. **Identity: a generator slot, filled eagerly by Plannotator.** `utils/generateIdentity` no longer imports `unique-username-generator`. It holds a synchronous generator slot (`setIdentityGenerator`, `getIdentityGenerator`) with a built-in fallback that produces the same `adjective-noun-tater` shape from a 16 x 16 pool. `utils/identity-tater` registers the full dictionary as a side effect and is what Plannotator's entries import. A host with `identityProvider` never calls the generator and, with the static import gone, no longer ships the word lists; delete any dictionary shim. A host that wants the full dictionary without its own provider imports `@plannotator/ui/utils/identity-tater`, or passes its own `identityGenerator` to `configurePlannotatorUI`. The slot is synchronous on purpose: `configStore` persists the first generated name to the identity cookie during the first render-time settings read, so a name that arrived later would be a visible identity change.

4. **What did not ship (deliberately).** The raw-HTML bridge script as a separately served asset and a lazy table popout are not in this release; both are tracked in the design record for a follow-up.
4. **What did not ship (deliberately).** A lazy table popout is not in this release; it is tracked in the design record for a follow-up. The raw-HTML bridge script as a separately served asset shipped afterwards, see "HTML viewer bridge as an asset" below.

Pinned by `utils/math.test.ts`, `components/MathBlock.firstPaint.test.tsx`, `utils/generateIdentity.test.ts`, `components/MermaidBlock.test.ts`, and the eager-entry and built-HTML marker guards in `tests/entry-assets.test.ts`.

---

## HTML viewer bridge as an asset (0.33.0)

`HtmlViewer` injects a 185 KB bridge script (`BRIDGE_SCRIPT`, `components/html-viewer/bridge-script.ts`) into every srcdoc document it renders. For a host that bundles by route that literal rode in the viewer chunk and was re-parsed by the browser per document. This release adds an opt-in, `bridgeScriptUrl`, and leaves the default untouched: Plannotator passes nothing, every Plannotator surface (the annotate srcdoc path, the version diff, PR HTML artifacts, linked `.html` docs, the share portal) still inlines the string, the live-app proxy still serves the same inline bridge from its own `/__plannotator__/bridge.js` route, the Pi and OpenCode copies are built from the same code, and the single-file bundles carry the literal exactly once as before (`tests/entry-assets.test.ts` counts it; the A/B of a Plannotator HTML annotate session on a main build against this build found identical DOM, requests and console).

**What the package ships.** `prepack` now also runs `scripts/build-bridge-assets.ts`, which derives two gitignored files beside the source module, both deterministic and both verified against the module's exports by `components/html-viewer/bridgeAsset.test.ts`:

- `components/html-viewer/bridge-script.asset.js`: byte-for-byte `BRIDGE_SCRIPT`, the runnable IIFE. Export subpath `@plannotator/ui/components/html-viewer/bridge-script.asset.js`. The `.asset.js` name is deliberate: a plain `bridge-script.js` next to `bridge-script.ts` would be picked first by Vite's extension probe for the package's own `./bridge-script` imports and break every consumer build.
- `components/html-viewer/bridge-script.lite.ts`: the same `ANNOTATION_HIGHLIGHT_CSS`, `BRIDGE_PROTOCOL_VERSION` and `LIVE_BRIDGE_BOOTSTRAP` with `BRIDGE_SCRIPT = ""`. Export subpath `@plannotator/ui/components/html-viewer/bridge-script.lite`. An alias target only (below).

The TS module stays the source of truth because the Plannotator CLI and the Pi extension import its string exports under Bun.

**Host wiring (Workspaces).** Serve the asset same-origin as a hashed file through a Vite `?url` import and pass the URL to the viewer:

```ts
import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-script.asset.js?url";

<HtmlViewer
rawHtml={html}
bridgeScriptUrl={bridgeScriptUrl}
bridgeReadyTimeoutMs={5000} // default; the wait for `ready` per document load
onBridgeUnavailable={(info) => ...} // { kind: 'timeout' | 'version-mismatch', url, ... }
...
/>
```

With the prop set, `buildSrcdocInjection` emits `<script src="…"></script>` in the exact position the inline `<script>` occupied (there is one injection point, `buildBridgeScriptTag` in `srcdoc.ts`, for both paths), so placement is unchanged: at the end of `<head>`, before the body, on both paths (the page's head scripts run before the bridge, its body scripts after). The URL is resolved against the PARENT document (`resolveBridgeScriptUrl(url, document.baseURI)`) before it is written into the srcdoc, never against the framed page: the injection follows any `<base href>` the page declares, so a relative URL left unresolved would let a hostile document point the viewer at an attacker-served bridge and defeat the version check. The srcdoc is rebuilt on `rawHtml`, theme and diff changes and the browser then re-fetches the asset from cache, so serve it with normal immutable-asset cache headers. An empty string counts as absent (inline). The prop is ignored in live (`src`) mode, where the proxy injects the bridge.

**CSP.** Confirmed by grep and pinned by test: the package never writes a CSP `<meta>` into the srcdoc document (the injection is one `<style>` and one `<script>`; an author-written CSP meta is still neutralized as before), and the bridge sets none at runtime. The srcdoc frame is an opaque origin, and a classic `<script src>` executes without CORS; no `crossorigin` attribute is set, so do not expect one. A `Content-Security-Policy` HTTP header on the host page IS inherited by the srcdoc document: a host with its own CSP must allow `script-src` for the origin the asset is served from (same-origin `'self'` in the wiring above). Note the asset form is easier under CSP than the inline form, which would need `'unsafe-inline'` or a nonce. One more header to check: an asset served with `Cross-Origin-Resource-Policy: same-origin` (common with COEP) is blocked for the opaque-origin frame; serve the bridge asset with a CORP that admits cross-origin loads (`cross-origin`) or without CORP.

**Protocol version.** `BRIDGE_PROTOCOL_VERSION` (exported from `components/html-viewer/bridge-script` and re-exported from `components/html-viewer`) is embedded in the bridge text and stamped on its `ready` message as `protocolVersion`. Note for the design record's "current state": `BRIDGE_SCRIPT` now carries its first `${}` interpolation (that constant, evaluated at module load); it remains a plain string export with no per-session values, so the CLI, Pi and the live proxy consume it exactly as before. The parent (`checkBridgeProtocolVersion`, `HtmlViewer`'s ready branch) compares it: on the inline path and in live sessions the two sides come from one bundle and always match; on the URL path a cached asset from a previous package version answers with an older stamp, or none, and the viewer logs one console warning naming both versions, shows a dismissible error banner over the top of the frame (`[data-bridge-error="version-mismatch"]`, `role="alert"`, a `[data-bridge-error-dismiss]` button; the page stays visible) and calls `onBridgeUnavailable` once. The ready is still honored (an older bridge answers every message shape it knows), so this is a loud diagnostic, not a refusal. Bump the constant whenever a bridge message shape changes in a way an older bridge or parent would misread; a bump forces a warning against any not-yet-redeployed asset, which is the point.

**Ready timeout.** On the URL path only, `bridgeReadyTimeoutMs` (default 5000) is armed once per document load (URL or srcdoc change), read through a ref, so changing the prop after the bridge is ready never re-arms it; with no `ready` in time the surface shows `[data-bridge-error="timeout"]` naming the URL and the wait (not dismissible: the surface is dead), and `onBridgeUnavailable({ kind: 'timeout', url, timeoutMs })` fires. A late `ready` clears it. The inline path arms no timer and can never show a banner.

**Dropping the literal from the host chunk (optional).** The URL path alone leaves the inline string in the chunk unused, because `srcdoc.ts` imports it statically (the default must stay synchronous). To remove it, alias the package's `./bridge-script` resolution to the generated lite module in your bundler; with Vite:

```ts
resolve: {
alias: [{ find: /\/bridge-script$/, replacement: "/bridge-script.lite" }],
}
```

Under that alias an `HtmlViewer` rendered WITHOUT `bridgeScriptUrl` throws at render (`buildBridgeScriptTag` refuses to emit an empty inline script), so the misconfiguration cannot ship as a silently dead surface. Measured on the proof harness (PR #1398's description): the viewer chunk shrinks by the size of the literal, 557 kB to 371 kB (168 kB to 118 kB gzip).

**Live app annotation is unaffected.** `packages/shared/live-proxy-bridge-inline.test.ts` pins at source level that both proxy transports and both runtimes' composers still ship the inline bridge from the proxy route and never reference `bridgeScriptUrl` or the generated files.

Pinned by `components/html-viewer/bridgeAsset.test.ts` (generator bytes, manifest wiring, the single injection point, no CSP meta, the real bridge's stamped ready), `components/html-viewer/HtmlViewer.bridgeAsset.test.tsx` (URL srcdoc, stale-asset warning and banner, timeout and late ready, inline path unchanged), `packages/shared/live-proxy-bridge-inline.test.ts` and the bridge marker count in `tests/entry-assets.test.ts`.

---

## Frozen markdown diff (0.28.0)

One additive component for the Workspaces versions/approvals surface: `components/MarkdownDiff`, a theme-bridging shim over `@plannotator/markdown-editor@0.4.0`'s `MarkdownDiff` — a **frozen two-revision markdown comparison**. The newer revision renders as the real document (uncollapsed, full length); deletions are projected struck-through at their original positions; changed spans get character/word emphasis; a toolbar shows the change count with prev/next navigation; a clickable, keyboard-accessible overview rail and a changed-line gutter complete the review chrome. Every 0.27.0 surface is unchanged.
Expand Down
12 changes: 12 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,18 @@ Everything a host needs around `HtmlViewer` to match Plannotator's HTML annotati

See HANDOFF.md § "HTML annotation parity seams".

#### The bridge script as an asset (`bridgeScriptUrl`; 0.33.0)

By default `HtmlViewer` inlines its 185 KB in-page bridge script into every srcdoc document. A host that serves the package's generated `components/html-viewer/bridge-script.asset.js` as a static file can pass its URL instead:

```ts
import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-script.asset.js?url";

<HtmlViewer rawHtml={html} bridgeScriptUrl={bridgeScriptUrl} … />
```

The srcdoc then carries one classic `<script src>` in the exact place the inline script sat (at the end of `<head>`, before the body), the browser caches the asset across documents, and the bridge's `ready` message carries `BRIDGE_PROTOCOL_VERSION`, which the viewer checks: a stale cached asset (no stamp, or another version) logs one console warning naming both versions and shows a dismissible error banner in the surface (`onBridgeUnavailable` fires too); no `ready` within `bridgeReadyTimeoutMs` (default 5000) shows a timeout banner. The URL is resolved against your document's base (`document.baseURI`) before it is written into the srcdoc, never against the framed page, so a page's own `<base href>` cannot redirect it. Plannotator passes nothing and stays inline; none of this runs on the inline path. **CSP:** the package sets no CSP `<meta>` in the srcdoc document, and the frame is an opaque origin so the classic script needs no CORS (no `crossorigin` is set), but a CSP delivered as a header on your page is inherited by the frame: allow `script-src` for the asset's origin. Because the frame is an opaque origin, an asset served with `Cross-Origin-Resource-Policy: same-origin` (common alongside COEP) is blocked; serve it with a CORP that admits cross-origin loads, or without CORP. To also drop the inline literal from your viewer chunk, alias the package's `./bridge-script` resolution to the generated `bridge-script.lite` module (see HANDOFF.md § "HTML viewer bridge as an asset").

#### Also blessed in 0.32.0: `shortcuts` and `utils/inputMethod`

- **`@plannotator/ui/shortcuts`**: the declarative keyboard-shortcut engine (`defineShortcutScope`, `useShortcutScope`) and the per-surface scopes, including `useHtmlAnnotateShortcuts` for the Mod+Shift+A Annotate/Interact chord on HTML surfaces. Pure React plus `utils/platform`; no backend.
Expand Down
Loading