@plannotator/ui 0.33.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/HANDOFF.md CHANGED
@@ -92,7 +92,7 @@ Pass any subset of these to `configurePlannotatorUI({ ... })`. Anything omitted
92
92
  | `aiTransport` | `AITransport` | The "Ask AI" chat session/query/abort/permission | `POST /api/ai/{session,query,abort,permission}` |
93
93
  | `serverSync` | `ServerSyncFn` | Push a settings change back to the server | No-op-ish (Plannotator's local sync) |
94
94
  | `loadSettingsFromBackend` | `boolean` | After install, re-hydrate settings from your `storageBackend` | off |
95
- | `mathRendererLoader` | `() => Promise<MathRenderer>` | How KaTeX is loaded when no renderer is registered before the first math node renders (see "Lazy renderers and eager entries"). Once registered, the package default is never called, not even as a fallback after a rejected load; a default load already in flight at registration still fills the slot (pre-existing, see `setMathRendererLoader`), so register before the first math render | `utils/math-default-loader`'s `import('katex')`, JS only; CSS stays yours |
95
+ | `mathRendererLoader` | `() => Promise<MathRenderer>` | How KaTeX is loaded when no renderer is registered before the first math node renders (see "Lazy renderers and eager entries"). Once registered, the package default is never called, not even as a fallback after a rejected load, and `resetMathRenderer()` keeps the registration (0.34.0); a default load already in flight at registration still fills the slot (pre-existing, see `setMathRendererLoader`), so register before the first math render | `utils/math-default-loader`'s `import('katex')`, JS only; CSS stays yours |
96
96
  | `identityGenerator` | `() => string` | The synchronous generator behind the default "tater" display name when no `identityProvider` is installed | A built-in 16 x 16 word pool of the same `adjective-noun-tater` shape; Plannotator registers the full dictionary via `utils/identity-tater` |
97
97
 
98
98
  ### Interface details worth knowing
@@ -212,7 +212,8 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
212
212
  | `shortcuts` (`useHtmlAnnotateShortcuts`, `defineShortcutScope`, the scope registry) | The declarative keyboard-shortcut engine and the per-surface scopes, including the HTML annotate scope (Mod+Shift+A). Pure: React plus `utils/platform`; no backend. |
213
213
  | `utils/inputMethod` (`getInputMethod`, `saveInputMethod`, `refreshInputMethodStamp`) | The per-surface pinpoint/drag input-method preference with its TTL. Persists through the `storageBackend` seam; no backend of its own. |
214
214
  | `utils/codeHighlight` / `utils/codeBlockMark` / `utils/syntaxTheme` | The Shiki-based fence highlighter, swap-surviving annotation marks, and palette→Shiki theme mapping. Replaces all `.hljs` styling. *(Blessed in 0.29.0.)* |
215
- | `utils/math` (`loadMathRenderer`, `getMathRenderer`, `getMathRendererSource`, `setMathRenderer`) and `utils/math-eager` | The math renderer slot and its eager KaTeX registration. Import `utils/math-eager` for synchronous typesetting on the first commit; call `loadMathRenderer()` to pre-warm the lazy path. See "Lazy renderers and eager entries". |
215
+ | `utils/math` (`loadMathRenderer`, `getMathRenderer`, `getMathRendererSource`, `setMathRenderer`, `setMathRendererLoader`, `getMathRendererLoader`, `resetMathRenderer`) and `utils/math-eager` | The math renderer slot and its eager KaTeX registration. Import `utils/math-eager` for synchronous typesetting on the first commit; call `loadMathRenderer()` to pre-warm the lazy path. `resetMathRenderer()` empties the slot and keeps the registered loader; `setMathRendererLoader(null)` drops it. See "Lazy renderers and eager entries". |
216
+ | `utils/mermaid-math-slot` | Alias target only: what a host redirects Mermaid's own `katex` import to, so `$$` labels in diagrams typeset through the math slot and the host build carries one KaTeX chunk. Never import it yourself. See "Lazy renderers and eager entries", item 2. |
216
217
  | `utils/identity-tater` | Side-effect entry that registers the full username dictionary into the identity generator slot. Import it only if you rely on the default tater names and want the full dictionary; a host with `identityProvider` should not. |
217
218
  | `utils/mermaid` (`loadMermaidRuntime`, `getMermaidRuntime`, `getMermaidRuntimeSource`, `setMermaidRuntime`, `MERMAID_CONFIG`) and `utils/mermaid-eager` | The Mermaid runtime slot and its eager registration. Import `utils/mermaid-eager` to keep Mermaid in your entry chunk as Plannotator does; omit it for the lazy path with retry. See "Lazy renderers and eager entries". |
218
219
 
@@ -478,13 +479,38 @@ Four modules that used to ride every document read for a host that bundles by ro
478
479
  export function loadDefaultMathRenderer(): Promise<never> { return Promise.reject(new Error('default math loader aliased out')); }
479
480
  ```
480
481
 
481
- With the alias the same consumer build emits zero chunks carrying the KaTeX body and the entry's only `import()` in that area is the host's own loader chunk. Do not alias without registering a loader: math would then render as TeX text forever. Plannotator's entries import `math-eager`, so the slot is filled before the first render and this branch is never reached there; the single-file builds inline the default through `inlineDynamicImports` as before (`tests/entry-assets.test.ts` pins the split: `utils/math` has no `import('katex')` site, `utils/math-default-loader` has the only one).
482
+ With the alias the same consumer build emits zero chunks carrying the KaTeX body out of the package and the entry's only `import()` in that area is the host's own loader chunk. Do not alias without registering a loader: math would then render as TeX text forever. Plannotator's entries import `math-eager`, so the slot is filled before the first render and this branch is never reached there; the single-file builds inline the default through `inlineDynamicImports` as before (`tests/entry-assets.test.ts` pins the split: `utils/math` has no `import('katex')` site, `utils/math-default-loader` has the only one).
483
+
484
+ **Mermaid's own KaTeX, and the last shared chunk (0.34.0, from 0.33.0 adoption feedback).** The alias above is not the whole story once a page can render a Mermaid diagram. The Mermaid runtime (11.15.0 in this checkout) typesets `$$...$$` labels through its own `import("katex")`, inside `renderKatexUnsanitized`, and it offers nothing to turn that off: `legacyMathML` / `forceLegacyMathML` only choose the output mode, the guard around the import is the `@mermaid-js/tiny` build marker, and there is no hook to hand it a renderer. The import only *runs* for a label that matches Mermaid's `$$` test, but it is *emitted* regardless, so a host that registered a loader and aliased the default still built a `katex-*.js` chunk, and because that chunk then had two dynamic importers (the host's loader module and the Mermaid runtime) Rollup kept it separate from the host's loader chunk: a math document fetched two files, the 57-byte loader chunk plus the shared 261 KB KaTeX chunk. Measured on the scratch Vite 6 consumer of this checkout (loader registered, default aliased, a document with inline math, a display block and a flowchart with a `$$` label): one chunk carried the KaTeX body before, `katex-*.js`, imported by `mermaid.core-*.js` and by the host's loader chunk; 367 JS chunks in all.
485
+
486
+ The fix is a bundler-facing redirect to a package module, `utils/mermaid-math-slot`, whose default export has the one method Mermaid calls (`renderToString`) and delegates to whatever fills the math slot, with Mermaid's own options (`throwOnError: true`, `displayMode: true`, the MathML `output` mode) passed through untouched, so a KaTeX renderer produces exactly the markup Mermaid produced from its direct import. Redirect the `katex` specifier for importers inside the `mermaid` package ONLY; a plain `resolve.alias` on `katex` would also rewrite your own loader's import and break math everywhere:
487
+
488
+ ```ts
489
+ // vite.config.ts of a host that registers mathRendererLoader (beside the math-default-loader alias)
490
+ import { fileURLToPath } from 'node:url';
491
+ const configFile = fileURLToPath(import.meta.url);
492
+ const mermaidKatexToSlot: Plugin = {
493
+ name: 'mermaid-katex-to-plannotator-slot',
494
+ enforce: 'pre',
495
+ resolveId(source, importer) {
496
+ if (source !== 'katex' || !importer || !/[\\/]node_modules[\\/]mermaid[\\/]/.test(importer)) return null;
497
+ return this.resolve('@plannotator/ui/utils/mermaid-math-slot', configFile, { skipSelf: true });
498
+ },
499
+ };
500
+ // plugins: [mermaidKatexToSlot, react(), ...]
501
+ ```
502
+
503
+ Two details of that snippet are layout-proofing. The importer test is `node_modules/mermaid/` anywhere in the path, not a pattern for one install layout: a hoisted install puts the runtime at `node_modules/mermaid/`, Bun's isolated layout at `node_modules/.bun/mermaid@11.15.0/node_modules/mermaid/`, and pnpm's at `node_modules/.pnpm/mermaid@11.15.0/node_modules/mermaid/`; every one of them ends in that segment, and the trailing separator keeps `mermaid-something` packages out. The slot module is resolved from the host's own config file (`configFile`), not from the Mermaid importer: resolving from the importer walks up from Mermaid's location, which finds `@plannotator/ui` on hoisted and Bun-isolated installs but not under pnpm's strict `node_modules`, where the package is only visible from the host root. Resolving from the config file is the same lookup the host's own imports use; passing an absolute path to the file (`path.resolve(...)` of the installed `utils/mermaid-math-slot.ts`) works too.
504
+
505
+ With the redirect the same consumer build emits one chunk carrying the KaTeX body, the host's own loader chunk (`host-katex-*.js`, 261 KB, reached only by the entry's `import()`), `mermaid.core-*.js` has no KaTeX import left, and the chunk count drops to 366: one KaTeX chunk, owned by the host, one file fetched. The slot must be filled by the time Mermaid asks, so `MermaidBlock` awaits `loadMathRenderer()` before rendering a diagram whose source carries a `$$` label (`hasMermaidMath`, Mermaid's own regex); on a filled slot that resolves at once, on the lazy path it runs your loader, and if that load fails the label throws a message naming the cause (`MERMAID_MATH_SLOT_EMPTY_MESSAGE`) which the block's error panel shows with the source. Do not import the module yourself; it exists to be resolved to. Plannotator does not redirect: its Mermaid keeps its direct KaTeX, inlined by the single-file builds with everything else, and the pre-render wait is a resolved promise there because `math-eager` filled the slot at startup. No test in the repo renders a real Mermaid diagram with a math label (Mermaid does not render under happy-dom), and nothing in Plannotator's own documents exercises `$$` labels; the bridge is pinned by `utils/mermaid-math-slot.test.ts` (delegation with Mermaid's exact options, KaTeX parity, the empty-slot error, the label regex) and the pre-render warm by the "Mermaid math labels warm the math slot" cases in `components/DiagramBlock.lazyRetry.test.tsx`.
506
+
507
+ **`resetMathRenderer()` keeps the loader (0.34.0).** Through 0.33.0 the reset hook also nulled the registered loader, so a host test harness that reset the slot between cases silently fell back to the package default `import('katex')` on the next render. Reset now empties the renderer and its source, forgets a load in flight (its late result no longer fills the slot; the next `loadMathRenderer()` invokes the registered loader afresh) and leaves the loader registered. `setMathRendererLoader(null)` is the explicit way back to the package default, and `getMathRendererLoader()` reads the registration; `configurePlannotatorUI` cannot unregister a loader (a `null` or absent `mathRendererLoader` is a no-op there), so only a direct `setMathRendererLoader(null)` does. `setMathRendererLoader` itself is unchanged: a load already in flight at registration still fills the slot, because the component that started it is waiting on that result. The other reset-style helpers were reviewed and are consistent with their names: `resetIdentityProvider` and `resetIdentityGenerator` reset exactly the thing they name (the provider, the generator), and Mermaid's `__setMermaidRuntimeLoaderForTests` is a stand-in by name. Pinned in `utils/math.test.ts`.
482
508
 
483
509
  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.
484
510
 
485
- 4. **Scope, as of 0.33.0.** 0.32.0 shipped items 1 to 3 and deliberately left two things out of the design record's list: the raw-HTML bridge script as a separately served asset, and a lazy table popout. 0.33.0 ships the first (see "HTML viewer bridge as an asset" below) and, from adoption feedback, the `utils/math-default-loader` split in item 2. The lazy table popout is still not shipped and stays tracked in the design record for a follow-up.
511
+ 4. **Scope, as of 0.34.0.** 0.32.0 shipped items 1 to 3 and deliberately left two things out of the design record's list: the raw-HTML bridge script as a separately served asset, and a lazy table popout. 0.33.0 ships the first (see "HTML viewer bridge as an asset" below) and, from adoption feedback, the `utils/math-default-loader` split in item 2. 0.34.0 adds the Mermaid KaTeX redirect and the `resetMathRenderer` fix in item 2, both from 0.33.0 adoption feedback. The lazy table popout is still not shipped and stays tracked in the design record for a follow-up.
486
512
 
487
- 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`.
513
+ Pinned by `utils/math.test.ts`, `utils/mermaid-math-slot.test.ts`, `components/MathBlock.firstPaint.test.tsx`, `utils/generateIdentity.test.ts`, `components/MermaidBlock.test.ts`, `components/DiagramBlock.lazyRetry.test.tsx`, and the eager-entry and built-HTML marker guards in `tests/entry-assets.test.ts`.
488
514
 
489
515
  ---
490
516
 
@@ -519,16 +545,23 @@ With the prop set, `buildSrcdocInjection` emits `<script src="…"></script>` in
519
545
 
520
546
  **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.
521
547
 
548
+ **Who renders the strip (`bridgeErrorDisplay`; 0.34.0, from 0.33.0 adoption feedback).** The package owns the failure strip by default: `bridgeErrorDisplay="banner"` renders the `[data-bridge-error]` element for both the mismatch and the timeout states exactly as 0.33.0 did, so Plannotator and every existing host are unchanged. A host that renders its own notice from `onBridgeUnavailable` passes `bridgeErrorDisplay="none"`: no strip and no dismiss button are rendered for either state, while `onBridgeUnavailable` fires exactly as before and a version mismatch still logs its one console warning. Through 0.33.0 the strip could not be suppressed, so such a host showed two banners. The prop is meaningless on the inline path, which never shows a strip. Pinned by the two `bridgeErrorDisplay` cases in `components/html-viewer/HtmlViewer.bridgeAsset.test.tsx`.
549
+
522
550
  **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.
523
551
 
524
552
  **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:
525
553
 
526
554
  ```ts
527
555
  resolve: {
528
- alias: [{ find: /\/bridge-script$/, replacement: "/bridge-script.lite" }],
556
+ alias: [{
557
+ find: /^\.\/bridge-script$/,
558
+ replacement: "@plannotator/ui/components/html-viewer/bridge-script.lite",
559
+ }],
529
560
  }
530
561
  ```
531
562
 
563
+ The `find` is anchored on purpose (0.34.0; 0.33.0 documented `/\/bridge-script$/`). Every import of the module inside the package is the relative sibling form, `./bridge-script` (`srcdoc.ts`, `useHtmlAnnotation.ts`, `index.ts`), so `/^\.\/bridge-script$/` matches exactly those. The unanchored form matched any specifier ENDING in `/bridge-script`, which is also the shape of another package's entry point (`some-dependency/bridge-script`) or of a deeper import in your own tree (`../vendor/bridge-script`), and would have silently swapped those for the lite module too. If your own source has a sibling module named `bridge-script`, add an importer check (a `customResolver` on the alias entry, or a `resolveId` plugin that tests `importer` for `@plannotator/ui/components/html-viewer/`) rather than widening the pattern.
564
+
532
565
  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).
533
566
 
534
567
  **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.
@@ -641,8 +674,8 @@ Additive only, but required: `@plannotator/ui` 0.32.0 imports the new `@plannota
641
674
 
642
675
  ## Publishing & versioning
643
676
 
644
- - The current pair is `@plannotator/ui` `0.33.0` on `@plannotator/core` `0.25.0`. **No lockstep this time**: nothing under `packages/core` changed since the 0.32.0 pair, so core is not republished and ui 0.33.0 pins the already published core 0.25.0 (only the ui tarball is built and published). 0.33.0 carries the bridge-script asset (`bridge-script.asset.js`, `bridge-script.lite`, both generated by `prepack`), the `utils/math-default-loader` split, and `HANDOFF.md` inside the tarball so the README's section references resolve for a consumer.
645
- - Recent pairs, for the consumer's install matrix: ui 0.31.0 on core 0.24.0 (lockstep), ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 on core 0.25.0 (ui only, core unchanged).
677
+ - The current pair is `@plannotator/ui` `0.34.0` on `@plannotator/core` `0.25.0`. **No lockstep again**: nothing under `packages/core` changed since the 0.32.0 pair, so core is not republished and ui 0.34.0 pins the already published core 0.25.0 (only the ui tarball is built and published). 0.34.0 carries the 0.33.0 adoption feedback: the Mermaid KaTeX redirect target `utils/mermaid-math-slot`, `HtmlViewer` `bridgeErrorDisplay`, the anchored bridge-script alias, and `resetMathRenderer` keeping the registered loader (with `setMathRendererLoader(null)` and `getMathRendererLoader`). 0.33.0 carried the bridge-script asset (`bridge-script.asset.js`, `bridge-script.lite`, both generated by `prepack`), the `utils/math-default-loader` split, and `HANDOFF.md` inside the tarball so the README's section references resolve for a consumer.
678
+ - Recent pairs, for the consumer's install matrix: ui 0.31.0 on core 0.24.0 (lockstep), ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only, core unchanged).
646
679
  - When a pair IS lockstep, **publish `core` first**: ui 0.32.0 imports the new `@plannotator/core/html-anchor` subpath, which no earlier published core (0.24.0 and before) has, just as ui 0.29.0 needed core 0.23.0 for `@plannotator/core/annotatable`. The ui→core dependency resolves exactly at pack time, from the lockfile: after a version bump, run `bun install` so `bun.lock` carries the new workspace versions, or `bun pm pack` will still stamp the previous core version into ui's tarball (the 0.31.0 lesson).
647
680
  - The HTML annotation seams also changed the guides.show viewer **stylesheet** (five utility rules from `HtmlSurfaceControls`; the viewer JS is unchanged), so `packages/core/guide-viewer-manifest.ts` now pins a CSS hash that exists on guides.show only after the deploy workflow has published this build's `/v1/` assets. A guide exported from this build before that deploy would pin a stylesheet the host does not serve yet: **deploy guides.show before any release that ships this manifest.**
648
681
  - They depend on each other via `workspace:*`. At publish time that must resolve to the **exact** version in the tarball, so publish with a tool that does that resolution (the repo's existing flow uses `bun pm pack` to build the tarball, then `npm publish *.tgz --access public`). Publish **`core` first, then `ui`**.
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). 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.
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
 
@@ -122,7 +122,7 @@ import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-scrip
122
122
  <HtmlViewer rawHtml={html} bridgeScriptUrl={bridgeScriptUrl} … />
123
123
  ```
124
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 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").
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
126
 
127
127
  #### Also blessed in 0.32.0: `shortcuts` and `utils/inputMethod`
128
128
 
@@ -164,7 +164,7 @@ npm install @plannotator/ui @plannotator/core
164
164
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
165
165
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published.
166
166
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
167
- - Versioned together (currently `@plannotator/ui` 0.33.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 is such a release, 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").
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").
168
168
 
169
169
  ## The one rule
170
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) {
@@ -281,9 +281,19 @@ export interface HtmlViewerProps {
281
281
  bridgeReadyTimeoutMs?: number;
282
282
  /** The bridge could not be established on the `bridgeScriptUrl` path (no
283
283
  * ready within the timeout, or a protocol version mismatch). The surface
284
- * shows its own banner as well; this lets the host react (telemetry, a
285
- * retry affordance). Never called on the inline path. */
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. */
286
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";
287
297
  }
288
298
 
289
299
  /**
@@ -329,6 +339,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
329
339
  bridgeScriptUrl,
330
340
  bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
331
341
  onBridgeUnavailable,
342
+ bridgeErrorDisplay = "banner",
332
343
  },
333
344
  ref,
334
345
  ) => {
@@ -1061,8 +1072,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1061
1072
  {/* bridgeScriptUrl path only: the bridge did not come up (no
1062
1073
  ready within the timeout, or a stale asset's version). Floated
1063
1074
  over the top of the iframe so it never changes the layout the
1064
- page renders in; the page itself stays visible. */}
1065
- {bridgeError && !bridgeErrorDismissed && (
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" && (
1066
1079
  <div
1067
1080
  role="alert"
1068
1081
  data-print-hide
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",
package/utils/math.ts CHANGED
@@ -48,6 +48,12 @@ let rendererSource: MathRendererSource | null = null;
48
48
  */
49
49
  let loader: MathRendererLoader | null = null;
50
50
  let pending: Promise<MathRenderer> | null = null;
51
+ /**
52
+ * Bumped by `resetMathRenderer()`. A load in flight across a reset must not
53
+ * fill the slot when it lands: the reset promised an empty slot, and the next
54
+ * `loadMathRenderer()` re-invokes the registered loader instead.
55
+ */
56
+ let resetEpoch = 0;
51
57
  const listeners = new Set<() => void>();
52
58
 
53
59
  function notify(): void {
@@ -84,14 +90,22 @@ export function subscribeMathRenderer(listener: () => void): () => void {
84
90
  * Swap the loader `loadMathRenderer()` uses. Host seam
85
91
  * (`configurePlannotatorUI({ mathRendererLoader })`): a host may return a
86
92
  * module that imports katex AND its stylesheet in one chunk. A load already in
87
- * flight keeps going; the new loader is used from the next `loadMathRenderer()`
88
- * call that finds the slot empty.
93
+ * flight keeps going and still fills the slot when it lands (the component
94
+ * that started it is waiting on that result and would otherwise never
95
+ * typeset); the new loader is used from the next `loadMathRenderer()` call
96
+ * that finds the slot empty. Passing `null` unregisters the host loader and
97
+ * restores the package default.
89
98
  */
90
- export function setMathRendererLoader(next: MathRendererLoader): void {
99
+ export function setMathRendererLoader(next: MathRendererLoader | null): void {
91
100
  loader = next;
92
101
  pending = null;
93
102
  }
94
103
 
104
+ /** The registered host loader, or `null` while the package default applies. */
105
+ export function getMathRendererLoader(): MathRendererLoader | null {
106
+ return loader;
107
+ }
108
+
95
109
  /**
96
110
  * Load and register the renderer. Idempotent: a filled slot resolves at once,
97
111
  * a load in flight is shared, and a rejected load is dropped so the next call
@@ -100,9 +114,12 @@ export function setMathRendererLoader(next: MathRendererLoader): void {
100
114
  export function loadMathRenderer(): Promise<MathRenderer> {
101
115
  if (renderer) return Promise.resolve(renderer);
102
116
  if (!pending) {
117
+ const epoch = resetEpoch;
103
118
  const attempt = (loader ? loader() : loadDefaultMathRenderer()).then(
104
119
  (loaded) => {
105
- setMathRenderer(loaded, 'loader');
120
+ // A reset since this load started wants the slot empty: hand the
121
+ // result to the caller that awaited it, but do not register it.
122
+ if (epoch === resetEpoch) setMathRenderer(loaded, 'loader');
106
123
  return loaded;
107
124
  },
108
125
  (err: unknown) => {
@@ -115,12 +132,19 @@ export function loadMathRenderer(): Promise<MathRenderer> {
115
132
  return pending;
116
133
  }
117
134
 
118
- /** Test hook: clear the slot, the loader override and any pending load. */
135
+ /**
136
+ * Test hook: empty the slot (renderer and source) and forget any load in
137
+ * flight, so the next `loadMathRenderer()` invokes the loader afresh and a
138
+ * stale in-flight result cannot fill the slot after the reset. The registered
139
+ * loader is KEPT: resetting the renderer is not unregistering the host seam
140
+ * (a host's `configurePlannotatorUI` runs once, before any reset a test issues
141
+ * later). To drop the loader too, call `setMathRendererLoader(null)`.
142
+ */
119
143
  export function resetMathRenderer(): void {
120
144
  renderer = null;
121
145
  rendererSource = null;
122
- loader = null;
123
146
  pending = null;
147
+ resetEpoch += 1;
124
148
  notify();
125
149
  }
126
150
 
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Mermaid's KaTeX, served from the math renderer slot.
3
+ *
4
+ * Mermaid renders `$$...$$` labels through its own `import("katex")`
5
+ * (`renderKatexUnsanitized` in the runtime; there is no config flag and no
6
+ * hook for it, and chunk emission is static), so a host that registers a
7
+ * `mathRendererLoader` and aliases `./math-default-loader` away still gets a
8
+ * shared `katex-*.js` chunk out of the Mermaid runtime, and a math document
9
+ * fetches two files: the host's loader chunk plus that shared chunk.
10
+ *
11
+ * This module is the alias target that removes it. A host redirects the
12
+ * `katex` specifier, for importers inside the `mermaid` package ONLY, to
13
+ * `@plannotator/ui/utils/mermaid-math-slot` (HANDOFF.md "Lazy renderers and
14
+ * eager entries", item 2). Its default export has the one method Mermaid
15
+ * calls, `renderToString`, and delegates to whatever renderer fills the slot
16
+ * in `./math`: the host's loader result, or the eager KaTeX registration.
17
+ * Mermaid's own options (`throwOnError: true`, `displayMode: true`, the
18
+ * MathML `output` mode) are passed through untouched, so a KaTeX renderer
19
+ * produces exactly the markup Mermaid produced from its direct import.
20
+ *
21
+ * The slot must be filled by the time Mermaid asks: `MermaidBlock` awaits
22
+ * `loadMathRenderer()` before rendering a diagram whose source carries a
23
+ * `$$` label (`hasMermaidMath`), which is a no-op resolve on a filled slot
24
+ * and the host's loader otherwise. An empty slot here means that load
25
+ * failed, and the error below surfaces in the block's error panel with the
26
+ * source, instead of a silently unlabeled node.
27
+ *
28
+ * Nothing imports this module by default: Plannotator never aliases, so its
29
+ * Mermaid keeps its direct KaTeX (inlined by the single-file builds), and
30
+ * this file must never import `katex` itself, or the redirect would re-create
31
+ * the chunk it exists to remove (`tests/entry-assets.test.ts` pins that).
32
+ */
33
+
34
+ import { getMathRenderer, type MathRenderer } from './math';
35
+
36
+ /** Mermaid's own test for a math label (`katexRegex` in the runtime). */
37
+ const MERMAID_MATH_REGEX = /\$\$(.*)\$\$/;
38
+
39
+ /** True when a diagram source carries at least one `$$...$$` label. */
40
+ export function hasMermaidMath(source: string): boolean {
41
+ return MERMAID_MATH_REGEX.test(source);
42
+ }
43
+
44
+ /** Thrown when Mermaid asks for KaTeX while the math slot is still empty. */
45
+ export const MERMAID_MATH_SLOT_EMPTY_MESSAGE =
46
+ 'Math label: no math renderer is registered (the mathRendererLoader did not resolve before the diagram rendered)';
47
+
48
+ const slotRenderer: MathRenderer = {
49
+ renderToString(tex, options) {
50
+ const renderer = getMathRenderer();
51
+ if (!renderer) throw new Error(MERMAID_MATH_SLOT_EMPTY_MESSAGE);
52
+ return renderer.renderToString(tex, options);
53
+ },
54
+ };
55
+
56
+ export default slotRenderer;