@plannotator/ui 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,7 +54,7 @@ Building your own tooltip and removing the built-in double-click reset are host-
54
54
 
55
55
  The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz needs nothing from you (the block imports the engine inside its render effect and shows the source fence until the SVG lands, as it always did). Mermaid, KaTeX and the dictionary sit behind synchronous slots:
56
56
 
57
- - **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3).
57
+ - **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3). The default `import('katex')` is the only runtime mention of `katex` in the package and lives in `utils/math-default-loader` (0.33.0), called only while no loader is registered; a registered loader is never backfilled by it, though a default load already in flight at registration still fills the slot (pre-existing), so register the loader before the first math render. Chunk emission is static, so a bundler still emits that chunk (never requested) unless you alias the module away; see HANDOFF.md "Lazy renderers and eager entries" for the two-line alias. The Mermaid runtime has its own `import("katex")` for `$$` labels, which leaves a second, shared KaTeX chunk in a host build even with the alias; since 0.34.0 a host redirects that one import (for importers inside the `mermaid` package only) to `@plannotator/ui/utils/mermaid-math-slot`, which typesets the labels through your registered renderer, so one KaTeX chunk remains and it is yours. Recipe and measurement in HANDOFF.md, same section. `resetMathRenderer()` empties the slot only and keeps a registered loader (0.34.0); `setMathRendererLoader(null)` is the explicit way back to the package default.
58
58
  - **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')`; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. Plannotator keeps Mermaid eager by policy so it can never fail separately from the app: `import "@plannotator/ui/utils/mermaid-eager";` in your entry does the same for your bundle. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level.
59
59
  - **Identity.** With an `identityProvider` the generator is never called and the word lists stay out of your bundle. Without one, default names come from a small built-in pool of the same `adjective-noun-tater` shape; `import "@plannotator/ui/utils/identity-tater";` registers the full dictionary, or pass your own `identityGenerator`.
60
60
 
@@ -102,16 +102,28 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^
102
102
 
103
103
  Everything a host needs around `HtmlViewer` to match Plannotator's HTML annotation experience, all additive and all defaulting to today's behavior. Requires `@plannotator/core` 0.25.0 (the `html-anchor` subpath), so install and publish core before ui:
104
104
 
105
- - **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names.
106
- - **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side.
105
+ - **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names. **HTML-only:** the projection carries `originalText`, `htmlAnchor` and `htmlAdditionalTargets`, and pins `blockId` to `""`, offsets to `0` and no `startMeta` / `endMeta`; on the markdown `Viewer` a projected `COMMENT` with quoted text still re-anchors by whole-document text search, but with `blockId` `""` and offsets `0` it loses export ordering (every such row sorts first and ties), the "lines N-M" location label, disambiguation when the same text repeats (first match wins), and the no-flash meta restore; a host that needs those carries `blockId`, the offsets and the web-highlighter metas in its own projection.
106
+ - **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side. **Nothing is delivered before the bridge's first post-restore report for a document (per reload generation):** a prop-side change before that point does not fire the callback, so do not gate host state on a prop-side delivery arriving first; treat the first call as the restore's verdict.
107
107
  - **`hooks/useHtmlRefresh({ fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`**: the refresh cycle with the stale-response and document-change guards, backend behind `fetchSnapshot`.
108
108
  - **`components/HtmlSurfaceControls`**: the eye / refresh / pen header controls with Plannotator's markup and `labels` overrides.
109
109
  - **`AnnotationPanel` `unanchoredIds`**: an "Unanchored" chip on the listed cards.
110
- - **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too).
110
+ - **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too). With the cap enforced upstream (bridge toggle, parent trust boundary on submit and on restore, `projectHostThreads` `maxTargets` on read), a composed comment never reaches the host with more targets than the cap, so a host's own cap-dropped handling (`capDroppedTargets` from `buildPersistedHtmlAnchor`, or a message-counting listener) is unreachable in normal operation; keep it only as a backstop for rows written by an older host build or by another writer. Byte-budget drops (`sizeDroppedTargets`) are a separate path and remain reachable.
111
111
  - An `ExternalAnnotationTransport` whose `subscribe` emits `snapshot` on a host push keeps `useExternalAnnotations` off its fallback poll.
112
112
 
113
113
  See HANDOFF.md § "HTML annotation parity seams".
114
114
 
115
+ #### The bridge script as an asset (`bridgeScriptUrl`; 0.33.0)
116
+
117
+ 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:
118
+
119
+ ```ts
120
+ import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-script.asset.js?url";
121
+
122
+ <HtmlViewer rawHtml={html} bridgeScriptUrl={bridgeScriptUrl} … />
123
+ ```
124
+
125
+ 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 package owns that banner by default (`bridgeErrorDisplay="banner"`); a host that renders its own notice from `onBridgeUnavailable` passes `bridgeErrorDisplay="none"` (0.34.0) and no strip is rendered, while the callback and the console warning are unchanged. 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 relative `./bridge-script` import (match `/^\.\/bridge-script$/`, never a bare `/\/bridge-script$/`, which would also catch another package's `bridge-script` entry) to the generated `bridge-script.lite` module (see HANDOFF.md § "HTML viewer bridge as an asset").
126
+
115
127
  #### Also blessed in 0.32.0: `shortcuts` and `utils/inputMethod`
116
128
 
117
129
  - **`@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.
@@ -152,7 +164,7 @@ npm install @plannotator/ui @plannotator/core
152
164
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
153
165
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published.
154
166
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
155
- - Versioned in lockstep with the repo (currently `@plannotator/core` 0.25.0 with `@plannotator/ui` 0.32.0). Publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").
167
+ - Versioned together (currently `@plannotator/ui` 0.34.0 on `@plannotator/core` 0.25.0). `core` is bumped only when something under `packages/core` changed, so `ui` can advance alone: 0.33.0 and 0.34.0 are such releases, published on the already available core 0.25.0. When both change, publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`**, the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").
156
168
 
157
169
  ## The one rule
158
170
 
@@ -9,6 +9,8 @@ import {
9
9
  loadMermaidRuntime,
10
10
  __setMermaidRuntimeLoaderForTests,
11
11
  } from '../utils/mermaid';
12
+ import { loadMathRenderer } from '../utils/math';
13
+ import { hasMermaidMath } from '../utils/mermaid-math-slot';
12
14
 
13
15
  // Re-exported: the config pin test and the lazy-retry test import them from here.
14
16
  export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests };
@@ -208,6 +210,20 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
208
210
  return;
209
211
  }
210
212
  try {
213
+ // A `$$` label makes Mermaid render KaTeX. On a host that redirects
214
+ // Mermaid's `katex` import to `utils/mermaid-math-slot` the label is
215
+ // typeset through the math slot, which must be filled by then: warm
216
+ // it with the registered loader first. A filled slot (Plannotator's
217
+ // eager entry) resolves at once; a load failure is left to the
218
+ // render, whose error panel names it with the source.
219
+ if (hasMermaidMath(block.content)) {
220
+ try {
221
+ await loadMathRenderer();
222
+ } catch {
223
+ // Reported by the render below.
224
+ }
225
+ if (cancelled) return;
226
+ }
211
227
  const id = `mermaid-${block.id}`;
212
228
  const { svg: renderedSvg } = await mermaid.render(id, block.content);
213
229
  if (!cancelled) {
@@ -41,6 +41,8 @@ import { buildSyncNumbering } from "./annotationNumbering";
41
41
  import { mergeUnanchoredIds } from "./unanchored";
42
42
  import {
43
43
  MAX_PAGE_URL_LENGTH,
44
+ checkBridgeProtocolVersion,
45
+ formatBridgeProtocolWarning,
44
46
  rejectsLiveMessage,
45
47
  useHtmlAnnotation,
46
48
  type HtmlLiveSession,
@@ -51,6 +53,7 @@ import {
51
53
  buildThemeTokenPayload,
52
54
  hasHostThemeOptIn,
53
55
  injectIntoHead,
56
+ resolveBridgeScriptUrl,
54
57
  } from "./srcdoc";
55
58
 
56
59
  const PREFIX = "plannotator-bridge-";
@@ -131,6 +134,38 @@ function parseVimBridgeHelp(value: unknown): boolean | null {
131
134
 
132
135
  const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024;
133
136
 
137
+ /** Default wait for the bridge's `ready` on the `bridgeScriptUrl` path. */
138
+ export const DEFAULT_BRIDGE_READY_TIMEOUT_MS = 5000;
139
+
140
+ /**
141
+ * Why the bridge could not be established on the `bridgeScriptUrl` path.
142
+ * Never produced on the inline path (the bridge and the parent are one
143
+ * bundle there, and no ready timer runs).
144
+ */
145
+ export type BridgeUnavailableInfo =
146
+ | {
147
+ kind: "timeout";
148
+ url: string;
149
+ /** The wait that elapsed without a `ready`. */
150
+ timeoutMs: number;
151
+ }
152
+ | {
153
+ kind: "version-mismatch";
154
+ url: string;
155
+ expectedVersion: number;
156
+ /** Absent when the ready carried no stamp (a pre-stamp asset). */
157
+ reportedVersion?: number;
158
+ };
159
+
160
+ /** User-facing message for the in-surface error banner. */
161
+ export function formatBridgeUnavailableMessage(info: BridgeUnavailableInfo): string {
162
+ if (info.kind === "timeout") {
163
+ return `Annotation tools did not load: the bridge script at ${info.url} sent no ready signal within ${info.timeoutMs} ms. The page is shown without annotation. Check that the URL is reachable and that your Content Security Policy allows script-src for that origin.`;
164
+ }
165
+ const reported = info.reportedVersion === undefined ? "no version" : `version ${info.reportedVersion}`;
166
+ return `Annotation tools may not work: this viewer expects bridge protocol version ${info.expectedVersion}, but the script at ${info.url} reported ${reported}. Serve the bridge-script asset from the same @plannotator/ui version as the viewer.`;
167
+ }
168
+
134
169
  function parseVimBridgeCopy(value: unknown): string | null {
135
170
  return isRecord(value)
136
171
  && value.type === `${PREFIX}vim-copy`
@@ -221,6 +256,44 @@ export interface HtmlViewerProps {
221
256
  scrollBehavior?: 'smooth' | 'auto';
222
257
  /** Accessible iframe title. */
223
258
  title?: string;
259
+ /**
260
+ * Opt-in: load the annotation bridge into the srcdoc document through a
261
+ * classic `<script src>` from this URL (the package's generated
262
+ * `components/html-viewer/bridge-script.asset.js`, served by the host)
263
+ * instead of inlining the 185 KB script into every document. Absent (the
264
+ * default, and Plannotator's only path): inline, unchanged. The tag lands
265
+ * where the inline script does, at the end of `<head>`, before the body.
266
+ * The URL is resolved against THIS document's base (`document.baseURI`)
267
+ * before it is written, never against the framed page, so a page's own
268
+ * `<base href>` cannot redirect it. The srcdoc frame is an opaque origin,
269
+ * so the script needs no CORS and no `crossorigin` attribute is set; a CSP
270
+ * header on the host page is inherited by the frame and must allow
271
+ * `script-src` for the asset origin, and the asset must not be served with
272
+ * `Cross-Origin-Resource-Policy: same-origin`. Ignored in live (`src`)
273
+ * mode, where the proxy injects the bridge.
274
+ */
275
+ bridgeScriptUrl?: string;
276
+ /**
277
+ * How long to wait for the bridge's `ready` after each document load on
278
+ * the `bridgeScriptUrl` path before the surface shows an error state.
279
+ * Default 5000 ms. No timer runs on the inline path.
280
+ */
281
+ bridgeReadyTimeoutMs?: number;
282
+ /** The bridge could not be established on the `bridgeScriptUrl` path (no
283
+ * ready within the timeout, or a protocol version mismatch). The surface
284
+ * shows its own banner as well unless `bridgeErrorDisplay` is `'none'`;
285
+ * this lets the host react (telemetry, a retry affordance). Never called
286
+ * on the inline path. */
287
+ onBridgeUnavailable?: (info: BridgeUnavailableInfo) => void;
288
+ /**
289
+ * Who renders the bridge-failure strip on the `bridgeScriptUrl` path.
290
+ * `'banner'` (default): the package renders its `[data-bridge-error]`
291
+ * strip over the frame, as in 0.33.0. `'none'`: no strip is rendered and
292
+ * the host owns the display through `onBridgeUnavailable`, which fires
293
+ * exactly as before (and a version mismatch still logs its one console
294
+ * warning). Meaningless on the inline path, which never shows a strip.
295
+ */
296
+ bridgeErrorDisplay?: "banner" | "none";
224
297
  }
225
298
 
226
299
  /**
@@ -263,6 +336,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
263
336
  maxAdditionalTargets,
264
337
  scrollBehavior,
265
338
  title = "HTML Plan Viewer",
339
+ bridgeScriptUrl,
340
+ bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
341
+ onBridgeUnavailable,
342
+ bridgeErrorDisplay = "banner",
266
343
  },
267
344
  ref,
268
345
  ) => {
@@ -320,6 +397,18 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
320
397
  // themselves); arbitrary HTML renders untouched, like a standalone tab.
321
398
  const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
322
399
 
400
+ // The URL path is srcdoc-only: live mode has the proxy inject the bridge.
401
+ // Resolved against THIS document's base before it is written into the
402
+ // srcdoc, so a framed page's own <base href> can never re-anchor it.
403
+ const bridgeUrl = useMemo(
404
+ () => (!liveMode && bridgeScriptUrl
405
+ ? resolveBridgeScriptUrl(bridgeScriptUrl, document.baseURI)
406
+ : undefined),
407
+ [liveMode, bridgeScriptUrl],
408
+ );
409
+ const bridgeUrlRef = useRef(bridgeUrl);
410
+ bridgeUrlRef.current = bridgeUrl;
411
+
323
412
  const srcdoc = useMemo(() => {
324
413
  if (liveMode) return undefined; // src mode: the proxy injects the bridge
325
414
  const injection = buildSrcdocInjection({
@@ -327,9 +416,46 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
327
416
  isLight: isLightTheme(),
328
417
  hostTheme,
329
418
  diffActive: !!diffActive,
419
+ bridgeScriptUrl: bridgeUrl,
330
420
  });
331
421
  return injectIntoHead(rawHtml, injection);
332
- }, [liveMode, rawHtml, hostTheme, diffActive]);
422
+ }, [liveMode, rawHtml, hostTheme, diffActive, bridgeUrl]);
423
+
424
+ // Error state for the bridgeScriptUrl path only: the inline path never
425
+ // sets it (no timer, and a version mismatch there can only be a forged
426
+ // message, which is warned about and otherwise ignored).
427
+ const [bridgeError, setBridgeError] = useState<BridgeUnavailableInfo | null>(null);
428
+ // A version-mismatch banner is dismissible (the older bridge keeps
429
+ // working); reset whenever the error itself changes.
430
+ const [bridgeErrorDismissed, setBridgeErrorDismissed] = useState(false);
431
+ const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
432
+ const onBridgeUnavailableRef = useRef(onBridgeUnavailable);
433
+ onBridgeUnavailableRef.current = onBridgeUnavailable;
434
+ // The timeout is read through a ref at arming time: the timer is armed
435
+ // once per document load (URL or srcdoc change), never re-armed by a
436
+ // later prop change, so a host adjusting bridgeReadyTimeoutMs after the
437
+ // bridge is ready can never produce a false timeout.
438
+ const bridgeReadyTimeoutMsRef = useRef(bridgeReadyTimeoutMs);
439
+ bridgeReadyTimeoutMsRef.current = bridgeReadyTimeoutMs;
440
+ useEffect(() => {
441
+ if (!bridgeUrl || srcdoc === undefined) return;
442
+ setBridgeError(null);
443
+ setBridgeErrorDismissed(false);
444
+ const url = bridgeUrl;
445
+ const timeoutMs = bridgeReadyTimeoutMsRef.current;
446
+ readyTimerRef.current = setTimeout(() => {
447
+ readyTimerRef.current = null;
448
+ setBridgeError({ kind: "timeout", url, timeoutMs });
449
+ }, timeoutMs);
450
+ return () => {
451
+ if (readyTimerRef.current !== null) clearTimeout(readyTimerRef.current);
452
+ readyTimerRef.current = null;
453
+ };
454
+ }, [bridgeUrl, srcdoc]);
455
+ useEffect(() => {
456
+ setBridgeErrorDismissed(false);
457
+ if (bridgeError) onBridgeUnavailableRef.current?.(bridgeError);
458
+ }, [bridgeError]);
333
459
 
334
460
  const handleResize = useCallback((height: number) => {
335
461
  if (liveMode) return; // live surfaces are full-viewport; height is ignored
@@ -516,6 +642,33 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
516
642
  const live = liveSessionRef.current;
517
643
  if (live && rejectsLiveMessage(live, e.origin, e.data)) return;
518
644
  if (isBridgeReadyMessage(e.data)) {
645
+ // Protocol stamp: one console warning on drift, naming both
646
+ // versions. The ready is still honored (an older bridge answers
647
+ // every message shape it knows); on the bridgeScriptUrl path the
648
+ // surface additionally shows its error banner, because there the
649
+ // drift is a real deployment state (a cached asset from a previous
650
+ // package version) rather than a forged message.
651
+ const verdict = checkBridgeProtocolVersion(e.data);
652
+ const url = bridgeUrlRef.current;
653
+ if (!verdict.ok) {
654
+ console.warn(formatBridgeProtocolWarning(verdict, url));
655
+ }
656
+ if (url) {
657
+ if (readyTimerRef.current !== null) {
658
+ clearTimeout(readyTimerRef.current);
659
+ readyTimerRef.current = null;
660
+ }
661
+ setBridgeError(
662
+ verdict.ok
663
+ ? null
664
+ : {
665
+ kind: "version-mismatch",
666
+ url,
667
+ expectedVersion: verdict.expected,
668
+ reportedVersion: verdict.reported,
669
+ },
670
+ );
671
+ }
519
672
  setIframeReadyVersion((version) => version + 1);
520
673
  setVimBridgePhase("inactive");
521
674
  setVimHudCommand(null);
@@ -916,6 +1069,39 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
916
1069
  {actionButtons}
917
1070
  </div>
918
1071
  )}
1072
+ {/* bridgeScriptUrl path only: the bridge did not come up (no
1073
+ ready within the timeout, or a stale asset's version). Floated
1074
+ over the top of the iframe so it never changes the layout the
1075
+ page renders in; the page itself stays visible. A host that
1076
+ renders its own notice from onBridgeUnavailable passes
1077
+ bridgeErrorDisplay="none" and no strip is rendered at all. */}
1078
+ {bridgeError && !bridgeErrorDismissed && bridgeErrorDisplay !== "none" && (
1079
+ <div
1080
+ role="alert"
1081
+ data-print-hide
1082
+ data-bridge-error={bridgeError.kind}
1083
+ className="absolute inset-x-0 top-0 z-20 border-b border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive backdrop-blur-sm"
1084
+ style={{ display: "flex", alignItems: "flex-start", gap: 8 }}
1085
+ >
1086
+ <span style={{ flex: 1 }}>{formatBridgeUnavailableMessage(bridgeError)}</span>
1087
+ {/* Only the mismatch state is dismissible: the older bridge
1088
+ still works there. A timeout leaves a dead surface, so
1089
+ that banner stays. Inline styles on purpose: hosts that
1090
+ build the guides.show viewer scan this file for utility
1091
+ classes, and this banner must not grow that stylesheet. */}
1092
+ {bridgeError.kind === "version-mismatch" && (
1093
+ <button
1094
+ type="button"
1095
+ data-bridge-error-dismiss
1096
+ aria-label="Dismiss"
1097
+ style={{ flexShrink: 0, borderRadius: 4, padding: "2px 6px", fontWeight: 500, cursor: "pointer", background: "transparent", border: "1px solid currentColor", color: "inherit" }}
1098
+ onClick={() => setBridgeErrorDismissed(true)}
1099
+ >
1100
+ Dismiss
1101
+ </button>
1102
+ )}
1103
+ </div>
1104
+ )}
919
1105
  {/* Live proxied-app mode navigates a real loopback origin: no
920
1106
  sandbox (the user's own app needs cookies, storage, and
921
1107
  same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}