@plannotator/ui 0.54.0 → 0.55.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
@@ -220,6 +220,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
220
220
  | `components/TableOfContents` | Pure — renders from `blocks`; pair with `useActiveSection` for scroll-spy. *(Blessed in 0.24.0.)* |
221
221
  | `components/ResizeHandle` + `hooks/useResizablePanel` | Layout pair for draggable panel widths; persists the width through the `storageBackend` seam. *(Blessed in 0.24.0.)* |
222
222
  | `hooks/useActiveSection` | Scroll-spy over rendered headings; no backend. *(Blessed in 0.24.0.)* |
223
+ | `components/MarkdownContentsRail` + `components/DeferredMarkdownContentsRail` | Workspaces' document contents rail (ticks in the right gutter, the Contents panel on click), moved here; no backend. Its styles are their own sheet, `@plannotator/ui/contents-rail.css`: import it outside any CSS layer. Its scroll math is `utils/headingScrollSpy` (+ `hooks/useHeadingScrollSpy` for a list, `components/DocumentContents`). See README "Document contents rail" and "Scroll-spies: one per surface". |
223
224
  | `hooks/useScrollViewport` | Resolves the scrolling element for viewport-aware UI; no backend. *(Blessed in 0.24.0.)* |
224
225
  | `utils/annotationHelpers` | Pure annotation utilities (`getAnnotationCountBySection`, `buildTocHierarchy` + `TocItem`). *(Blessed in 0.24.0.)* |
225
226
  | `components/html-viewer` (`HtmlViewer`, `projectHostThreads`, `buildPersistedHtmlAnchor`) | The raw-HTML annotation viewer: overlay-projected placed markers, pinpoint anchors, multi-target comments. Props + validated bridge protocol; no backend of its own. See "Raw-HTML annotation viewer + syntax-highlighting migration (0.29.0)" and "HTML annotation parity seams". *(Blessed in 0.29.0.)* |
@@ -264,7 +265,7 @@ If Workspaces ever wants one of these surfaces, the path is the same as everythi
264
265
  The renderer's `MathBlock` (and inline math) uses KaTeX. **KaTeX's stylesheet and its ~1.1MB of math fonts are deliberately NOT in the published `styles.css`** — bundling them would 9x the CSS for every page load, math or not. This is app-developer setup, done once; end users never touch it. Pick one:
265
266
 
266
267
  1. **Self-hosted (recommended for production):** copy `katex/dist/katex.min.css` + `katex/dist/fonts/` to your own asset origin and add one `<link rel="stylesheet">`. No third-party dependency in your serving path; fonts download lazily, only on pages that actually render math.
267
- 2. **CDN tag:** `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@<version>/dist/katex.min.css">` in your HTML — pin `<version>` to the `katex` version in `@plannotator/ui`'s package.json (`^0.18.11` since ui 0.54.0; it was `^0.16.47`) so CSS and the bundled KaTeX JS stay in step. A stylesheet from another KaTeX minor does not match the markup: 0.18 renamed 21 internal classes (see "Dependency bumps: KaTeX 0.18, @pierre/diffs 1.5.2, Mermaid 12.2 (unreleased, 0.54.0)"). The same goes for a self-hosted copy (option 1). Same lazy-font behavior; adds a third-party origin.
268
+ 2. **CDN tag:** `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@<version>/dist/katex.min.css">` in your HTML — pin `<version>` to the `katex` version in `@plannotator/ui`'s package.json (`^0.19.0` from ui 0.55.0, `^0.18.11` in ui 0.54.0, `^0.16.47` before) so CSS and the bundled KaTeX JS stay in step. A stylesheet from another KaTeX minor does not match the markup: 0.18 renamed 21 internal classes (see "Dependency bumps: KaTeX 0.18, @pierre/diffs 1.5.2, Mermaid 12.2 (published in ui 0.54.0)"). The same goes for a self-hosted copy (option 1). Same lazy-font behavior; adds a third-party origin.
268
269
  3. **Bundler import:** `import 'katex/dist/katex.min.css';` next to your `styles.css` import — your bundler ships the fonts as separate lazy-loaded files. With npm/bun this resolves out of the box (`katex` is a dependency of `@plannotator/ui` and gets hoisted); under pnpm's strict `node_modules`, add `katex` to your own dependencies to import it directly.
269
270
 
270
271
  If you skip all three and render math, equations appear as broken-looking raw HTML — that's the symptom to recognize. If you never render math, do nothing.
@@ -1844,6 +1845,10 @@ Everything below is opt-in and byte-identical when a host passes none of it.
1844
1845
 
1845
1846
  ## Publishing & versioning
1846
1847
 
1848
+ - **ui 0.55.0: ui only. `@plannotator/core` is unchanged at 0.25.14** (nothing under `packages/core` moved since v0.28.37), so ui publishes alone and keeps pinning core `0.25.14` exactly. No guides.show deploy is needed. It is cut from `main` at `f103b6703` plus the version bump, and packed with `bun pm pack` from a clean `bun install --frozen-lockfile` at Bun 1.4.4. **No export name is removed** (checked name by name against ui 0.54.0 on npm), and peer ranges are unchanged. Hosts must act on these:
1849
+ 1. **KaTeX 0.19.** `katex` is `^0.19.0` (was `^0.18.11`, which npm marks deprecated). Rendering is unchanged, but a host that serves its own `katex.min.css` should move it to 0.19. Mermaid 12.2.0 still declares `katex ^0.18.0`, so without action the install holds two KaTeX copies. Add a root override `"katex": "^0.19.0"` (`pnpm.overrides`, `overrides` or `resolutions`), or point Mermaid's `katex` at `utils/mermaid-math-slot`. See "KaTeX 0.19".
1850
+ 2. **`HtmlViewer` `sandbox?: string`, for Workspaces.** The srcdoc frame's whole `sandbox` attribute. Workspaces passes `sandbox="allow-scripts"` so a shared document's `alert` / `confirm` / `prompt` cannot draw over its origin, and can drop its pnpm patch. Absent, the default stays `"allow-scripts allow-modals"`. See "`HtmlViewer` `sandbox`".
1851
+ 3. **Smaller changes with no action.** A `Viewer`'s `#anchor` link looks the id up inside its own container, so a matching id elsewhere on the page no longer hides the target (#2316). Additive and inert until a host passes it (#2315): `Viewer` `onQuestionPictureZoom` (`BlockRenderer` `onQuestionPictureZoom`, `QuestionBlock` `onZoomChoice`), `ImageLightbox` `swipe` and `LightboxImage.name`, `imageZoom`'s `canPanToward`, and the new `hooks/useSwipeStep`.
1847
1852
  - **core 0.25.14 / ui 0.54.0: both change. Order: `core` 0.25.14, then `ui` 0.54.0. guides.show is already deployed.** Both are cut from the v0.28.37 release tag, `1722a14de`, and packed with `bun pm pack` from a clean `bun install --frozen-lockfile` at Bun 1.4.4. The previous pairs were ui 0.53.0 and ui 0.53.1 on core 0.25.13 (0.53.1 was published from `b68f0be02`). ui pins core `0.25.14` exactly and imports four core subpaths that 0.25.13 lacks: `agent-switch`, `forge-ref-card`, `html-embed` and `image-region`. So core goes first. core 0.25.14 moves the guides.show viewer pin from `viewer.DERExLls.js` + `viewer.BjyNeslq.css` (0.25.13) to `viewer.SgPt8W-S.js` + `viewer.DejL7_DK.css`. guides.show deployed on the v0.28.37 tag: both files and all 59 `langs` chunks answer 200 on `https://guides.show/v1/`, and the two viewer files match the manifest's SRI hashes, so no manual deploy is needed. **No export name is removed from either package** (checked name by name against core 0.25.13 and ui 0.53.1). Peer ranges are unchanged. Some defaults and outputs change, so Workspaces should check these before it upgrades:
1848
1853
  1. **KaTeX 0.18 (ui dependency).** A host that loads `katex.min.css` itself must move it to 0.18, or equations lay out wrong (21 classes were renamed). `@pierre/diffs` 1.5.2 now logs `console.error` for a miscounted hunk. `mermaid` is 12.2.0 and `motion` is `^14.4.0` (was `^12.38.0`). See "Dependency bumps: KaTeX 0.18, @pierre/diffs 1.5.2, Mermaid 12.2".
1849
1854
  2. **Plans open in the Clean look.** `gridEnabled` now defaults to `false`, and a stored value counts only with the v2 marker `plannotator-plan-look-v2 = 'resolved'`. A host that renders no chooser (Workspaces) opens every user in Clean. To keep stored values, call `markLookAndFeelChoiceResolved()` before the first settings read. A host that writes `gridEnabled` itself must also call it, or the value is lost on reload. See the README's "Upgrade note: the plan look defaults to Clean".
@@ -1881,7 +1886,7 @@ Everything below is opt-in and byte-identical when a host passes none of it.
1881
1886
  - The previous pair was `@plannotator/ui` `0.40.0` on `@plannotator/core` `0.25.3` (publish order the same). Three things shipped in 0.40.0: (1) **Mermaid 12.0.0**, pinned exactly (was `^11.17.2`): ELK layout by default for flowchart/state/class/ER/requirement, Safari 17.4+ / ES2024 floor, SVG ids byte-identical to 11 but `g.edgePaths` children now in declaration order, and the plan editor no longer imports `utils/mermaid-eager` (the lazy path is the default for everyone; hosts that want startup registration import the eager entry themselves) — see "Mermaid 12 (0.40.0)"; (2) **theme-aware Mermaid diagrams**: New additive exports `utils/mermaidTheme` (`buildMermaidThemeVariables`, `readThemeTokens`, `applyMermaidTheme`, `mermaidThemeKey`, `buildMermaidConfig`, `ensureContrast`, `isDarkBackground`, `MERMAID_THEME_TOKEN_NAMES`) and `utils/cssColor` (parser + OKLab/contrast toolkit). `MermaidBlock` now calls `useTheme()` and `applyMermaidTheme` before each render; `MERMAID_CONFIG`, `loadMermaidRuntime`, `mermaid-eager` and `securityLevel: 'strict'` are unchanged. A host whose document carries no theme tokens renders diagrams byte-identically to 0.39.0; a host that mounts `ThemeProvider` with `theme.css` gets diagrams in its palette and mode with no configuration. No new peer dependencies; core unchanged. See "Theme-aware Mermaid diagrams (0.40.0)".; (3) **element context through the host seam (#1521, #1549), which is what moves `core` to 0.25.3:** `@plannotator/core/html-anchor` gains `parseHtmlElementContext`, `MAX_ELEMENT_CONTEXT_BYTES` and `MAX_PAGE_URL_LENGTH`; `PersistedHtmlAnchor.elementContext?` and `HtmlAnnotationTarget.context?` now round-trip through `buildPersistedHtmlAnchor` and `projectHostThreads`. **Core changes here, so bump and publish `core` first** and update UI's exact core dependency before packing ui — a ui build that imports these from an older published core fails to compile in a consumer, the 0.38.0 failure mode. `@plannotator/ui/components/html-viewer` re-exports the validator, so 0.39.0's import site is unchanged, and rows without context stay byte-identical on the wire. `utils/parser` gains `includeOutline` on `elementContextExportBlock` / `exportAnnotationEntry`, and `exportAnnotationEntry`'s `includeRoute` now defaults to true per field. See "Element context through the host seam".
1882
1887
  - The previous pair was `@plannotator/ui` `0.39.0` on `@plannotator/core` `0.25.2` (core unchanged; nothing under `packages/core` moved). UI 0.39.0 adds **element context** to raw-HTML and live-app pinpoint annotations (#1517, #1520): a new optional `Annotation.elementContext` (`HtmlElementContext` in `@plannotator/ui/types`) and `HtmlAnnotationTarget.context`, captured by the bridge at click time (tag, id, author classes, ancestor `path`, `role`, accessible `name`, an allowlisted `attrs` set with href/src scrubbed of query and fragment, rendered `text`, an adaptive collapsed HTML `outline`, child count, viewport `rect`, nearest `landmark` and `heading`, a `component` hint, and in live-app sessions `page`), hard-capped at 2 KiB serialized per primary and 1 KiB per extra target, and re-validated at the parent trust boundary by the new `parseHtmlElementContext` export of `@plannotator/ui/components/html-viewer`. New helpers on `@plannotator/ui/utils/parser`: `elementContextExportBlock(ann, { includeRoute })` (the fenced skeleton plus selector/path/role/name/attrs/text/box/near lines the full export now prints under a context-bearing comment) and `exportAnnotationEntry(ann, { includeRoute })` (one annotation as a standalone feedback entry, a pure helper for hosts; `AnnotationPanel`'s card chrome is unchanged from 0.38.2). The field is purely descriptive: `HtmlElementAnchor` and restore are untouched, no `BRIDGE_PROTOCOL_VERSION` bump, share links drop it like anchors, annotations without it export byte-identically, and the repaint path posts only anchors to the bridge. **Host persistence gap in 0.39.0 itself, closed in the next publish (#1521, #1549)**: as shipped, 0.39.0's `@plannotator/core/html-anchor` (`buildPersistedHtmlAnchor`, `projectHostThreads`) does not carry `elementContext`, so a host pinned to 0.39.0 that persists through those helpers drops it on save and must persist and project the field itself. The next publish carries it end to end — see "Element context through the host seam". Peer ranges are unchanged from 0.38.2: `react` / `react-dom` `^19.2.3`, `tailwindcss` as before, and `@codemirror/state ^6.7.2` beside `@codemirror/view ^6.43.10`. Decision-control change in the same window (#1516): the header primary reads `Send Feedback` / `Post Comments` with no inline count (`DecisionPrimary.count` removed; internal, not host-supported surface).
1883
1888
  - Before that, `@plannotator/ui` `0.38.2` on `@plannotator/core` `0.25.2`. UI 0.38.2 keeps the type word in a titled alert's accessible name through a visually hidden `sr-only` span before the title instead of an `aria-label` on the title row (naming a generic `div` is prohibited by ARIA and WebKit drops it, so VoiceOver on Safari read only the bold title in 0.38.1), and loosens the React peer back to `^19.2.3` (0.38.1 declared `^19.2.8` only because the dependency batch moved it; nothing in the package needs a newer API). **Do not consume ui 0.38.0**: it imports `@plannotator/core/token-hover` (the hover-card trigger settings, #1462) but pins core 0.25.1, which never exported that subpath, so it fails to compile in any consumer; 0.38.1 is the same UI pinning core 0.25.2, which publishes `./token-hover`, the rotated `guide-viewer-manifest` pin, and the `config-types` hover fields (core 0.25.2 is the first core publish since 0.25.1 even though those changes landed over several releases; the package smoke now diffs the UI's core imports against the registry so an unpublished core subpath fails preflight instead of the consumer). UI 0.38.1 also aligns `@codemirror/state` to `^6.7.2` beside `@codemirror/view ^6.43.10`, so a consumer can no longer resolve two state copies. UI 0.38.0 also renders a GitHub alert's bold-only first body line as its title on the icon row (an emoji on that line becomes the icon; `<!-- icon: name -->` is stripped and resolved through the new `alertIconRenderer` seam, null by default; grammar in `utils/alertTitle`, importable by a host editor so it writes the bytes the reader parses; a fenced code block inside an alert body still renders as text, deferred because nesting a `CodeBlock` inside a block interacts with the positional annotation anchors and needs its own design). UI 0.38.0 carries the whole unified decision-control stack: the internal primitives (`DecisionControl`, `utils/decisionSpec`, `hooks/useDismissablePopover` — not host-supported surface, see the unsupported list; `useDismissablePopover` also replaced the hand-rolled dismissal inside `ActionMenu`/`ApproveDropdown`, both likewise unsupported) plus one blessed-barrel addition: `decisionControlShortcuts` on `@plannotator/ui/shortcuts` (pure scope data, fetch-free, same contract as the other scopes). The removal of `ToolbarButtons`' platform-mode `muted` prop is internal — `ToolbarButtons` is not host-supported surface. UI 0.37.0 added the Viewer-owned document-header seam (a new public API, hence the minor bump; 0.36.1 was reserved for it but never published) while retaining the `hideQuickLabel` and `StickyHeaderLane` seams from the 0.35.x and 0.36.0 releases; core 0.25.1 publishes the `annotation-threads` subpath already used by `AnnotationPanel` and `utils/parser`, and UI pins that corrected core exactly.
1884
- - Recent pairs, for the consumer's install matrix: 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), and ui 0.35.2, ui 0.36.0, and ui 0.37.0 on core 0.25.1 (0.36.1 was never published), ui 0.38.1, ui 0.38.2, and ui 0.39.0 on core 0.25.2, and ui 0.40.0 on core 0.25.3 (lockstep, `html-anchor` element context). Since then, from the registry: ui 0.41.0, 0.41.1 and 0.41.2 on core 0.25.4; ui 0.42.0, 0.43.0 to 0.43.2, 0.44.0, 0.45.0 and 0.45.1 on core 0.25.5; ui 0.46.0 and 0.46.1 on core 0.25.6; ui 0.47.0 on core 0.25.7; ui 0.49.0 on core 0.25.9 (core 0.25.8 and ui 0.48.0 were never published; their changes first shipped in ui 0.49.0 / core 0.25.9); ui 0.50.0 on core 0.25.10; ui 0.51.0 on core 0.25.11; ui 0.52.0 and 0.52.1 on core 0.25.12; ui 0.53.0 and 0.53.1 on core 0.25.13; ui 0.54.0 on core 0.25.14 (the current `latest` of both once published). Do not consume ui 0.35.0 externally because its published manifest contains `workspace:*`; do not consume ui 0.35.1 because its exact core 0.25.0 dependency lacks the `annotation-threads` export. Do not consume ui 0.38.0 because its exact core 0.25.1 dependency lacks the `token-hover` export.
1889
+ - Recent pairs, for the consumer's install matrix: 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), and ui 0.35.2, ui 0.36.0, and ui 0.37.0 on core 0.25.1 (0.36.1 was never published), ui 0.38.1, ui 0.38.2, and ui 0.39.0 on core 0.25.2, and ui 0.40.0 on core 0.25.3 (lockstep, `html-anchor` element context). Since then, from the registry: ui 0.41.0, 0.41.1 and 0.41.2 on core 0.25.4; ui 0.42.0, 0.43.0 to 0.43.2, 0.44.0, 0.45.0 and 0.45.1 on core 0.25.5; ui 0.46.0 and 0.46.1 on core 0.25.6; ui 0.47.0 on core 0.25.7; ui 0.49.0 on core 0.25.9 (core 0.25.8 and ui 0.48.0 were never published; their changes first shipped in ui 0.49.0 / core 0.25.9); ui 0.50.0 on core 0.25.10; ui 0.51.0 on core 0.25.11; ui 0.52.0 and 0.52.1 on core 0.25.12; ui 0.53.0 and 0.53.1 on core 0.25.13; ui 0.54.0 and 0.55.0 on core 0.25.14 (ui 0.55.0 and core 0.25.14 are the current `latest` once published). Do not consume ui 0.35.0 externally because its published manifest contains `workspace:*`; do not consume ui 0.35.1 because its exact core 0.25.0 dependency lacks the `annotation-threads` export. Do not consume ui 0.38.0 because its exact core 0.25.1 dependency lacks the `token-hover` export.
1885
1890
  - When both packages change, **publish `core` first**: ui 0.32.0 imports the `@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`. Bump core, update UI's exact core dependency to the same new version, and run `bun install` so `bun.lock` records the new workspace versions before packing either package.
1886
1891
  - 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.**
1887
1892
  - UI declares the already published core version exactly in its source manifest. Do not replace it with `workspace:*`: direct publication can preserve that protocol and make the package impossible to install outside this repository. Bun links the local core workspace whenever its version matches the exact dependency. Before publishing, run `bun run --cwd packages/ui smoke:package`; it checks the source and packed manifests, required tarball subpaths, local Bun linking, and a real pnpm install in an external temporary consumer. When both packages change, publish **`core` first, then `ui`**.
@@ -1930,8 +1935,18 @@ was rebuilt.
1930
1935
  The `file-tree-item` CSS was already in `theme.css`. The new pieces are the
1931
1936
  `DiffFileTree` component itself and its ARIA/keyboard handling.
1932
1937
 
1938
+ **Since the aligned sidebar (approved design record "review-sidebar-aligned"),
1939
+ the review draws its own rows.** Its Changes tree has its own folder row and
1940
+ file row (`FileTreeNode.tsx`: 12 px indent steps on the panel's edges, `+`/`−`
1941
+ in two aligned columns, `CountColumns` in `FileRowBits.tsx`); it no longer
1942
+ renders `DiffFileTreeFolderRow`, `DiffFileTreeFileRowContent` or `DiffCounts`.
1943
+ It still shares `buildDiffFileTree`, `useDiffFileTreeExpansion`,
1944
+ `ChangeTypeLetter`, and `diffFileTreeIndent` (All files keeps that indent).
1945
+ The atoms below are unchanged and stay the host's starting point for a custom
1946
+ row; they no longer show what the review's rows look like.
1947
+
1933
1948
  **Review-only, and why.** The review's `FileTree` keeps its own wrapper and
1934
- renders its rows from the shared atoms, adding: the viewed checkbox and the
1949
+ rows, adding: the viewed checkbox and the
1935
1950
  hide-viewed filter, stage controls and the staged/committed/untracked marks,
1936
1951
  the annotation badge and `has-annotations` tint, the scroll-highlight row, the
1937
1952
  "Copy path" context menu, the double-click handler, the panel header, view toggle,
@@ -1939,8 +1954,8 @@ diff-type / base / worktree / evolog pickers, search results, the nav rows
1939
1954
  (PR overview, artifacts, call flow, semantic diff, all files), and its
1940
1955
  **window-wide** j/k/arrow/Home/End shortcut (an app shortcut, gated on inputs,
1941
1956
  menus and dialogs). None of those is a host concern, so `DiffFileTree` takes no
1942
- props for them; a host that needs any of them builds its own row from the atoms,
1943
- as the review does. The review rows also keep their current DOM: no ARIA tree
1957
+ props for them; a host that needs any of them builds its own row from the atoms
1958
+ (the review now goes further and draws its own rows, above). The review rows also keep their current DOM: no ARIA tree
1944
1959
  roles were added there (that would be a behavior change to Plannotator's app,
1945
1960
  out of scope here).
1946
1961
 
@@ -2586,3 +2601,42 @@ See `packages/ui/README.md` and `packages/ui/AGENTS.md` (CLAUDE.md symlink) for
2586
2601
  - **`onTokenNavigate?: (target: CodeFileTokenTarget) => void`** is a Cmd/Ctrl-click (or Alt-click) on a token.
2587
2602
 
2588
2603
  `CodeFileTokenTarget` is `{ tokenText, tokenElement, lineNumber, lineCharStart }`. Absent: today's view, byte for byte (`CodeFilePopout` and the Inbox pass none).
2604
+
2605
+ ## `HtmlViewer` `sandbox` (published in ui 0.55.0; additive, no core change)
2606
+
2607
+ **`HtmlViewer`** (`components/html-viewer`) gains **`sandbox?: string`**, the srcdoc frame's whole `sandbox` attribute. Added for Workspaces: #2063 changed the default from `allow-scripts` to `allow-scripts allow-modals` with no way to opt out, and in Workspaces a `prompt()` or `confirm()` from a shared, user-written document would draw a dialog over its origin.
2608
+
2609
+ - **Absent:** `HTML_VIEWER_SANDBOX`, `"allow-scripts allow-modals"`, as before. Plannotator passes none, so the Inbox, annotate and review frames are unchanged.
2610
+ - **Set:** the value is the attribute as given. Workspaces passes `sandbox="allow-scripts"` and can drop its pnpm patch.
2611
+ - **Not a string** (`null` from an unset config field, for example): the default, never a frame without a sandbox. React drops a `null` attribute, and a destructuring default does not catch `null`, so `HtmlViewer` checks the type itself.
2612
+ - **`""`** (or only spaces): kept as given. That is the strictest frame, with no scripts, so the annotation bridge never starts and the page cannot be annotated. It never widens to the default. With `hostNavigates` it becomes `allow-popups` alone.
2613
+ - **A changed value** applies when the frame next loads a document (a new `rawHtml`, for example): browsers read `sandbox` at navigation. To apply it to the document already shown, remount the viewer (for example, key it on the value).
2614
+ - **With `hostNavigates`:** `allow-popups` is still added when the value lacks it, because that mode opens links with `window.open`.
2615
+ - **Never add `allow-same-origin`** beside `allow-scripts`: the page could then remove its own sandbox and reach the host's origin.
2616
+ - Live-app mode (`src`) has no sandbox and ignores the prop. `HtmlViewer` draws one frame, so the prop covers every frame of the document.
2617
+ - Pinned by `components/html-viewer/HtmlViewer.sandbox.test.tsx`: no prop gives `allow-scripts allow-modals`, `sandbox="allow-scripts"` gives exactly `allow-scripts`, `hostNavigates` adds `allow-popups` to either, `null` gives the default (with and without `hostNavigates`), `""` gives an empty attribute, and `src` mode has no sandbox.
2618
+
2619
+ **Note for hosts that pin CodeMirror (from ui 0.54.0).** ui 0.54.0 raised its CodeMirror and Lezer minimums. A host that pins these packages lower must raise its pins to at least these, or its build ships two copies (two `@codemirror/state` copies break editor extensions at runtime):
2620
+
2621
+ | Package | ui 0.54.0 range | ui 0.53.1 range |
2622
+ |---|---|---|
2623
+ | `@codemirror/commands` | `^6.11.1` | `^6.11.0` |
2624
+ | `@codemirror/language` | `^6.13.1` | `^6.12.4` |
2625
+ | `@codemirror/legacy-modes` | `^6.5.5` | `^6.5.3` |
2626
+ | `@codemirror/state` | `^6.7.6` | `^6.7.2` |
2627
+ | `@codemirror/view` | `^6.43.14` | `^6.43.10` |
2628
+ | `@lezer/common` | `^1.5.3` | `^1.5.2` |
2629
+ | `@lezer/highlight` | `^1.2.5` | `^1.2.3` |
2630
+
2631
+ These did not change: `@codemirror/autocomplete` `^6.20.3`, `lang-javascript` `^6.2.5`, `lang-json` `^6.0.2`, `lang-markdown` `^6.5.2`, `lang-python` `^6.2.1`, `lang-yaml` `^6.1.3`, `merge` `^6.12.2`, `search` `^6.7.2`.
2632
+
2633
+ ## KaTeX 0.19 (published in ui 0.55.0; dependency bump, no API change, no core change)
2634
+
2635
+ **`katex` is now `^0.19.0`** (was `^0.18.11`). npm marks 0.18.11 deprecated ("Accidentally published with breaking changes. Use 0.19.0 instead."), and `^0.18.11` resolves only to 0.18.x.
2636
+
2637
+ - **Rendering is unchanged.** The 0.19.0 tarball matches 0.18.11 file for file, except for the version string (`katex.version` and the `.katex-version::after` content) and the READMEs. The fonts are identical. The breaking change named in the 0.19.0 notes shipped in 0.18.11: a missing character metric is reported through `strict` (code `symbolNotInFont`), and a `strict` callback that returns `undefined` or `null` now warns. ui calls KaTeX with `strict: 'warn'` and no callback, so ui is not affected. Plan review screenshots of a math fixture (inline, display, `aligned`, `\text`, `\def` / `\newcommand`, `\tag`, `cases`, and errors) show 0 changed pixels between 0.18.11 and 0.19.0, in dark and light.
2638
+ - **Hosts that serve their own KaTeX stylesheet should move it to 0.19** (CDN or self-hosted; see "Math rendering (KaTeX)" above). A 0.18.11 stylesheet still works, because its rules are the same as 0.19's. But 0.18.x is deprecated, and the stylesheet and the bundled JS should stay on one version.
2639
+ - **Mermaid 12.2.0 still declares `katex ^0.18.0`.** ^0.18.0 does not accept 0.19, so without action the install holds two KaTeX copies: 0.19 for ui and 0.18.11 (deprecated, with an npm warning) for Mermaid's `$$` labels. A bundle that imports Mermaid with no `katex` redirect then carries both copies. In Plannotator these are the plan page, the Inbox and the phone surface: without the override, each carried Mermaid's 0.18.11 too, about 262 KB more on the plan page. Code review's diagram engine is not affected, because `apps/review/diagram-engine/build.ts` (`mermaidKatexFromPage`) already sends Mermaid's `katex` import to the page's copy. Plannotator's root `package.json` has the override `"katex": "^0.19.0"`, so the install and those bundles hold one copy. **Remove that override when ui moves to a Mermaid that declares `katex ^0.19`** (package.json cannot hold a comment, so the note is here). A host has two ways to keep one copy:
2640
+ - Add the same override: `pnpm.overrides` for pnpm, `overrides` for npm, or `resolutions` for Yarn.
2641
+ - Point Mermaid's `katex` import at `utils/mermaid-math-slot` (see "Lazy renderers and eager entries", item 2).
2642
+ - **0.19.1** (2026-10-23) fixes a `%` inside a raw argument. `^0.19.0` accepts it. This repo's lockfile records 0.19.0, because `bunfig.toml`'s seven-day `minimumReleaseAge` holds 0.19.1 back until 2026-10-30.
package/README.md CHANGED
@@ -114,6 +114,8 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^
114
114
 
115
115
  `components/html-viewer` is supported host surface as of 0.29.0: the overlay-projection annotation viewer for raw HTML (placed comment markers, pinpoint element anchors, shift-click multi-target comments). Its contract is **props plus the validated iframe message protocol** — not `configurePlannotatorUI()`, which only governs the backend surfaces around it. Drive the `annotations` prop (marker numbering derives from its array order); `readOnly` keeps markers painted and clickable while disabling all authoring. **0.29.0 also carries a breaking migration:** highlight.js is gone and `.hljs` selectors are inert — style code via the exported `pn-code` class (`CODE_BLOCK_CLASS` in `utils/codeHighlight`). See HANDOFF.md § "Raw-HTML annotation viewer + syntax-highlighting migration (0.29.0)" before upgrading from 0.28.0.
116
116
 
117
+ **`sandbox`** (published in ui 0.55.0): the srcdoc frame's whole `sandbox` attribute. The default is `"allow-scripts allow-modals"`, so a page's `alert`, `confirm` and `prompt` open. A host that shows documents other people wrote passes `sandbox="allow-scripts"`, so no dialog draws over its origin. `hostNavigates` still adds `allow-popups`. A non-string such as `null` gets the default. `""` is the strictest frame: no scripts, so nothing on the page can be annotated. A changed value applies when the frame next loads a document. Do not add `allow-same-origin`. See HANDOFF.md § "`HtmlViewer` `sandbox`".
118
+
117
119
  #### HTML annotation parity seams (0.32.0)
118
120
 
119
121
  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:
@@ -512,7 +514,53 @@ import { DiffFileTree } from "@plannotator/ui/components/DiffFileTree";
512
514
  - `role="tree"` with `treeitem` rows (`aria-level`, `aria-expanded`, `aria-selected`) and one tab stop. While focus is in the tree: ArrowDown/`j`, ArrowUp/`k`, Home, End select files in tree order (the review's keys, opening folders as needed); ArrowRight/ArrowLeft open/close a focused folder, ArrowLeft from a file focuses its folder. Unlike the review, nothing listens on `window`.
513
515
  - Styling is the `file-tree-item` rules in `styles.css` plus ThemeProvider tokens; no fetches, no seams.
514
516
 
515
- For a custom row, use the atoms (`DiffFileTreeFolderRow`, `DiffFileTreeFileRowContent`, `ChangeTypeLetter`, `DiffCounts`, `diffFileTreeIndent`) with `buildDiffFileTree` and `useDiffFileTreeExpansion`, as the review does. See HANDOFF.md § "Diff file tree".
517
+ For a custom row, use the atoms (`DiffFileTreeFolderRow`, `DiffFileTreeFileRowContent`, `ChangeTypeLetter`, `DiffCounts`, `diffFileTreeIndent`) with `buildDiffFileTree` and `useDiffFileTreeExpansion`. Plannotator's review shares the tree builder, the expansion hook and `ChangeTypeLetter`, but since its aligned sidebar it draws its own folder and file rows, so it is no longer an example of these atoms. See HANDOFF.md § "Diff file tree".
518
+
519
+ ### Document contents rail (`components/MarkdownContentsRail`; unreleased)
520
+
521
+ Workspaces' contents rail, moved here (approved 2026-10-03 in Workspaces; the move approved 2026-10-30) so Workspaces and the Plannotator Inbox draw the same one. Plan review does not use it.
522
+
523
+ ```tsx
524
+ import { DeferredMarkdownContentsRail } from "@plannotator/ui/components/DeferredMarkdownContentsRail";
525
+
526
+ // In the document pane, beside the scroll element; it positions itself (fixed) from that element.
527
+ <DeferredMarkdownContentsRail markdown={markdown} scrollViewport={scrollEl} />
528
+ ```
529
+
530
+ ```css
531
+ /* Its own stylesheet: import it OUTSIDE any cascade layer, after the host's base reset. */
532
+ @import "@plannotator/ui/contents-rail.css";
533
+ ```
534
+
535
+ - **Shape.** A 36 px dock of tick marks 24 px right of the reading column, vertically centred on the scroll element; it never changes the document's width. At most 10 ticks: a longer document is condensed into 10 spans, each shown by its shallowest heading nearest the span's middle. The current tick has `aria-current="location"`. A tick (or ↑ ↓ ← → Home End, Enter, while a tick has focus) opens a 256 px Contents panel to the LEFT of the dock, over the prose: every heading of levels 1 to 4, its breadcrumb its accessible name (`buildMarkdownContentsModel` also gives each heading a one-line excerpt, the first text after it cut at 180 characters). A pick is marked at once, smooth-scrolls the heading to the 80 px activation line (instantly under reduced motion) and flashes it. Escape on a tick closes the panel; Escape anywhere else closes a panel opened by pointer too (focus stays where it was), except while focus is in an editable control, a dialog, a menu or a listbox, whose Escape it is. It is `preventDefault`-ed and never stopped, so a host's own Escape handler sees it was used. An outside press, or focus leaving the panel, closes it too. No hover reveal, no left/right setting.
536
+ - **When it shows.** At least 4 headings of levels 1 to 4 (`hasContentsRailHeadings` in `utils/annotationHelpers`, over `tocHeadings(blocks, 4)`; a host can ask without loading the rail), a scroll element at least 480 px wide, and 68 px free between the reading column's right edge and the scroll element's right edge (its scrollbar excluded): `canShowContentsRail`, `resolveContentsRailGeometry`. A docked column outside the scroll element (comments) therefore makes it yield, never overlap.
537
+ - **Contract with the page.** Positions come from the rendered headings (`[data-block-type="heading"][data-block-id]`, `BlockRenderer`'s); only the rail's own heading ids are read, so an embedded document's headings (namespaced ids) never move the mark. The reading column is `[data-document-read-content]` inside the scroll element unless `contentSelector` names another (the Inbox passes the Viewer's `[data-doc-text-root]`). Any change to the scroll element's DOM re-binds the headings (a host that remounts its reading column, the same headings at new places, is followed).
538
+ - **Which heading is current.** The rail's own scroll math, moved with it into `utils/headingScrollSpy`: each heading's top is cached when the rail measures; every scroll frame a binary search finds the last heading at or above the activation line (`CONTENTS_ACTIVATION_OFFSET`, 80 px below the scroll element's top; Workspaces' was 52), and at the document's end the last heading is current. It needs no band, so a short pane, a jump (scrollbar drag, scroll restore, Home) and scrolling up all mark the right heading.
539
+ - **Loading.** `DeferredMarkdownContentsRail` mounts it after the page is interactive (`requestIdleCallback`, 2 s cap; a zero-delay timer where that is missing) from a lazy chunk.
540
+ - **Styles.** `pn-contents-rail-*` rules in their own stylesheet, `@plannotator/ui/contents-rail.css` (not in `theme.css` or `styles.css`). Import it outside any cascade layer: a later unlayered base reset beats layered rules and puts the ticks over the prose. Plain CSS on the host's tokens, each with a fallback: `--popover`, `--popover-foreground`, `--shadow-menu`, `--geometry-floating-radius`, `--geometry-structure-radius`, `--surface-hover`, `--motion-duration-fast|basic|relaxed`, `--ease-enter|exit`, `--primary`, `--foreground`, `--muted-foreground`. The labels inherit the font size.
541
+ - **Optional host props** (defaults reproduce Workspaces' rail): `blocks` (the host's parsed blocks instead of `markdown`; otherwise one cached parse per body, `utils/blockParse` `parseBlocksShared`), `contentSelector`, `hidden` (a host's own hide choice: draws nothing, still measures), `onAvailableChange(fits)` (whether it can show, after each measure, so a host can offer another way to Contents where it yields), `panelAction` (one control at the right of the open panel's title; the Inbox's Hide).
542
+ - Also moved: `hooks/usePrefersReducedMotion`.
543
+
544
+ ### Scroll-spies: one per surface
545
+
546
+ Two, both in the package, never two on one surface:
547
+
548
+ - **Plan review** keeps `hooks/useActiveSection`, unchanged: an IntersectionObserver band from 80 px below the scroll element's top to 20% of its height. `useActiveSection.positions.test.tsx` pins its answers at scroll positions that tell 80 from 52 or 100 px and 20% from 30%.
549
+ - **The contents rail's surface** (the rail, and `DocumentContents` where the rail yields) uses the rail's scroll math, `utils/headingScrollSpy` (above); `hooks/useHeadingScrollSpy(scrollViewport, blockIds)` is it for a list. Both scroll a pick to 80 px, so a pick lands where plan review's does.
550
+
551
+ ### Document contents (`components/DocumentContents`; unreleased)
552
+
553
+ A Contents list on the contents rail's surface, for where the rail yields: `TableOfContents` bound to one scroll element, with the rail's headings (levels 1 to 4) and the rail's scroll math (`useHeadingScrollSpy`) marking the heading being read, so the list and the rail never disagree. It reads the current heading at once on mount (a sheet opened mid-document). It draws no chrome; the host titles and places it. It keeps the marked entry in view inside the list's own scroll box.
554
+
555
+ ```tsx
556
+ import { DocumentContents } from "@plannotator/ui/components/DocumentContents";
557
+
558
+ <DocumentContents blocks={blocks} annotations={annotations} scrollViewport={scrollEl} onNavigate={closeSheet} />
559
+ ```
560
+
561
+ - The headings come from `tocHeadings(blocks, 4)` (`utils/annotationHelpers`), the function `buildTocHierarchy`, plan review's "has entries" check (levels 1 to 3, the default) and the contents rail use.
562
+ - `TableOfContents` takes two optional seams: `scrollViewport`, the element a click scrolls (default: the `ScrollViewportProvider` context, as before), and `maxLevel` (default 3). Plan review passes neither and is unchanged.
563
+ - Used by the Inbox's sheet (a phone, or where the rail yields) and the phone surface; plan review keeps `SidebarContainer`.
516
564
 
517
565
  ## Consuming it (e.g. from Workspaces)
518
566
 
@@ -539,7 +587,7 @@ npm install @plannotator/ui @plannotator/core
539
587
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
540
588
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
541
589
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
542
- - Currently `@plannotator/ui` 0.54.0 depends exactly on `@plannotator/core` 0.25.14. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. `bun run check:sibling-deps @plannotator/ui` (from the repo root; it reads npm) fails while the pinned core isn't on npm. See HANDOFF.md "Publishing & versioning" for the verification command.
590
+ - Currently `@plannotator/ui` 0.55.0 depends exactly on `@plannotator/core` 0.25.14. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. `bun run check:sibling-deps @plannotator/ui` (from the repo root; it reads npm) fails while the pinned core isn't on npm. See HANDOFF.md "Publishing & versioning" for the verification command.
543
591
 
544
592
  ## The one rule
545
593
 
@@ -74,7 +74,9 @@ export const BlockRenderer: React.FC<{
74
74
  questionPictureChoices?: QuestionBlockProps['pictureChoices'];
75
75
  /** A label over each picture tile (see `QuestionBlock` `pictureBadge`). */
76
76
  questionPictureBadge?: QuestionBlockProps['pictureBadge'];
77
- }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, questionDecisionScope, onToggleQuestionDecisionRecording, onOpenQuestionDecision, questionStatusTag, questionStatusLabel, renderQuestionBelowPrompt, questionContractLines = false, questionPictureChoices, questionPictureBadge }) => {
77
+ /** A picture tile's zoom, with its question and choice (see `QuestionBlock` `onZoomChoice`). */
78
+ onQuestionPictureZoom?: QuestionBlockProps['onZoomChoice'];
79
+ }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, questionDecisionScope, onToggleQuestionDecisionRecording, onOpenQuestionDecision, questionStatusTag, questionStatusLabel, renderQuestionBelowPrompt, questionContractLines = false, questionPictureChoices, questionPictureBadge, onQuestionPictureZoom }) => {
78
80
  switch (block.type) {
79
81
  case 'heading': {
80
82
  const Tag = `h${block.level || 1}` as React.ElementType;
@@ -213,6 +215,7 @@ export const BlockRenderer: React.FC<{
213
215
  renderBelowPrompt={renderQuestionBelowPrompt}
214
216
  pictureChoices={questionPictureChoices}
215
217
  pictureBadge={questionPictureBadge}
218
+ onZoomChoice={onQuestionPictureZoom}
216
219
  onOpenLinkedDoc={onOpenLinkedDoc}
217
220
  onOpenCodeFile={onOpenCodeFile}
218
221
  imageBaseDir={imageBaseDir}
@@ -0,0 +1,174 @@
1
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
4
+ import { useFenceTheme } from "../hooks/useFenceTheme";
5
+
6
+ /**
7
+ * The floating code window a code-file reference opens on hover: a few lines
8
+ * of one file, syntax highlighted, with line numbers. Portalled to the body
9
+ * and positioned under (or above) its anchor.
10
+ *
11
+ * Used by plan / annotate code-file links (`CodeFileLink` in InlineMarkdown,
12
+ * the exact range only) and by code review's Call Flow locations
13
+ * (`CallFlowTreeView`, with `contextLines` around the target).
14
+ */
15
+
16
+ /** Lines highlighted above the shown ones so a range opening inside a block comment or string tokenises as one. */
17
+ const HIGHLIGHT_LEAD_IN_LINES = 40;
18
+ /** Gap kept between the window and the viewport edge. */
19
+ const VIEWPORT_MARGIN = 8;
20
+
21
+ /** The highlighter language for a file path, by extension; undefined renders plain text. */
22
+ export function codeSnippetLanguage(filepath: string): string | undefined {
23
+ const ext = filepath.split('.').pop()?.toLowerCase();
24
+ const map: Record<string, string> = {
25
+ ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
26
+ mts: 'typescript', cts: 'typescript', mjs: 'javascript', cjs: 'javascript',
27
+ py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
28
+ css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml',
29
+ sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown',
30
+ html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin',
31
+ lua: 'lua', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cs: 'csharp',
32
+ scala: 'scala', dart: 'dart', ex: 'elixir', exs: 'elixir', zig: 'zig',
33
+ vue: 'vue', svelte: 'svelte', astro: 'astro', php: 'php',
34
+ };
35
+ return ext ? map[ext] : undefined;
36
+ }
37
+
38
+ export interface CodeSnippetPreviewProps {
39
+ anchorEl: HTMLElement | null;
40
+ /** The whole file; the preview slices the lines it shows. */
41
+ contents: string;
42
+ filepath: string;
43
+ /** One-based first line of the target range. */
44
+ line: number;
45
+ lineEnd?: number;
46
+ /** Lines shown above and below the target range, the target rows marked. 0 (default) shows the range alone. */
47
+ contextLines?: number;
48
+ /** Header label; defaults to the file's basename. */
49
+ title?: string;
50
+ /** Called when the page or a scroller scrolls: the window is placed once and would drift from its anchor. */
51
+ onDismiss?: () => void;
52
+ onMouseEnter?: () => void;
53
+ onMouseLeave?: () => void;
54
+ }
55
+
56
+ export const CodeSnippetPreview: React.FC<CodeSnippetPreviewProps> = ({
57
+ anchorEl,
58
+ contents,
59
+ filepath,
60
+ line,
61
+ lineEnd,
62
+ contextLines = 0,
63
+ title,
64
+ onDismiss,
65
+ onMouseEnter,
66
+ onMouseLeave,
67
+ }) => {
68
+ const allLines = contents.split('\n');
69
+ const targetStart = Math.max(0, line - 1);
70
+ const targetEnd = Math.min(allLines.length, lineEnd ?? line);
71
+ const start = Math.max(0, targetStart - contextLines);
72
+ const end = Math.min(allLines.length, targetEnd + contextLines);
73
+ const snippet = allLines.slice(start, end).join('\n');
74
+ // Highlight from a little earlier than the first shown line: a range that
75
+ // starts inside a block comment or template literal would otherwise colour
76
+ // that text as code.
77
+ const highlightStart = Math.max(0, start - HIGHLIGHT_LEAD_IN_LINES);
78
+ const highlightSource = allLines.slice(highlightStart, end).join('\n');
79
+ const marksTarget = contextLines > 0;
80
+
81
+ const fenceTheme = useFenceTheme();
82
+ // Bumped once the grammar is attached, to re-run the memo below with the
83
+ // highlighter warm. Until then the snippet renders as plain text — no
84
+ // auto-detection, and an unknown extension simply stays plain.
85
+ const [highlighterGeneration, setHighlighterGeneration] = useState(0);
86
+ const lang = codeSnippetLanguage(filepath);
87
+
88
+ useEffect(() => {
89
+ if (!lang) return;
90
+ let cancelled = false;
91
+ void ensureHighlight(lang, fenceTheme).then((ok) => {
92
+ if (ok && !cancelled) setHighlighterGeneration((n) => n + 1);
93
+ });
94
+ return () => { cancelled = true; };
95
+ }, [lang, fenceTheme]);
96
+
97
+ useEffect(() => {
98
+ if (!onDismiss) return;
99
+ const dismiss = () => onDismiss();
100
+ window.addEventListener('scroll', dismiss, { capture: true, passive: true });
101
+ return () => window.removeEventListener('scroll', dismiss, { capture: true });
102
+ }, [onDismiss]);
103
+
104
+ const highlightedLines = useMemo(() => {
105
+ // Highlight the snippet as one unit so multi-line constructs (block
106
+ // comments, template literals) tokenise correctly, then split back into
107
+ // rows: the highlighter joins lines with "\n" and never emits one inside a
108
+ // span, so the split is exact.
109
+ const html = lang ? highlightToHtml(highlightSource, lang, fenceTheme) : null;
110
+ if (html !== null) return html.split('\n').slice(start - highlightStart);
111
+ return snippet.split('\n').map(text =>
112
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
113
+ // highlighterGeneration is the "grammar just became available" signal.
114
+ // eslint-disable-next-line react-hooks/exhaustive-deps
115
+ }, [snippet, highlightSource, start, highlightStart, lang, fenceTheme, highlighterGeneration]);
116
+
117
+ // Clamp against the window's rendered width (it sizes to its longest line,
118
+ // up to its max width) so a wide snippet never runs off the right edge.
119
+ const windowRef = useRef<HTMLDivElement | null>(null);
120
+ const [renderedWidth, setRenderedWidth] = useState<number | null>(null);
121
+ useLayoutEffect(() => {
122
+ const width = windowRef.current?.offsetWidth ?? null;
123
+ if (width !== renderedWidth) setRenderedWidth(width);
124
+ });
125
+
126
+ if (!anchorEl) return null;
127
+
128
+ const rect = anchorEl.getBoundingClientRect();
129
+ const spaceBelow = window.innerHeight - rect.bottom;
130
+ const showAbove = spaceBelow < 200 && rect.top > spaceBelow;
131
+ const top = showAbove ? undefined : rect.bottom + 4;
132
+ const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined;
133
+ const left = Math.max(VIEWPORT_MARGIN, Math.min(rect.left, window.innerWidth - (renderedWidth ?? 520) - VIEWPORT_MARGIN));
134
+
135
+ return createPortal(
136
+ <div
137
+ ref={windowRef}
138
+ className="code-snippet-preview-window fixed z-[9999] rounded-lg border border-border bg-card shadow-xl flex flex-col"
139
+ style={{ top, bottom, left, maxWidth: `min(600px, calc(100vw - ${VIEWPORT_MARGIN * 2}px))`, maxHeight: '300px' }}
140
+ data-code-snippet-preview={filepath}
141
+ onMouseEnter={onMouseEnter}
142
+ onMouseLeave={onMouseLeave}
143
+ >
144
+ <div className="px-3 py-1.5 border-b border-border/50 text-[10px] text-muted-foreground font-mono flex items-center justify-between gap-4 flex-shrink-0">
145
+ <span>{title ?? filepath.split('/').pop()}</span>
146
+ <span className="opacity-60">{lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}</span>
147
+ </div>
148
+ <div className="code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
149
+ <table className="border-collapse w-full">
150
+ <tbody>
151
+ {snippet.split('\n').map((_, i) => {
152
+ const index = start + i;
153
+ const target = marksTarget && index >= targetStart && index < targetEnd;
154
+ return (
155
+ <tr
156
+ key={index}
157
+ className={target ? 'bg-primary/15' : 'hover:bg-white/5'}
158
+ data-target-line={target ? 'true' : undefined}
159
+ >
160
+ <td className={`select-none text-right pr-3 pl-3 py-0 align-top font-mono w-8 whitespace-nowrap ${target ? 'text-primary' : 'text-muted-foreground/40'}`} style={{ userSelect: 'none' }}>{index + 1}</td>
161
+ <td
162
+ className="font-mono pr-3 py-0 whitespace-pre"
163
+ dangerouslySetInnerHTML={{ __html: highlightedLines[i] ?? '' }}
164
+ />
165
+ </tr>
166
+ );
167
+ })}
168
+ </tbody>
169
+ </table>
170
+ </div>
171
+ </div>,
172
+ document.body,
173
+ );
174
+ };
@@ -0,0 +1,38 @@
1
+ // Moved from the Workspaces app's DeferredMarkdownContentsRail with its
2
+ // contents rail (MarkdownContentsRail, approved 2026-10-03). Port: the props
3
+ // are the rail's own (`MarkdownContentsRailProps`), passed through.
4
+
5
+ import { lazy, Suspense, useEffect, useState } from 'react';
6
+
7
+ import type { MarkdownContentsRailProps } from './MarkdownContentsRail';
8
+
9
+ const MarkdownContentsRail = lazy(() =>
10
+ import('./MarkdownContentsRail').then((module) => ({
11
+ default: module.MarkdownContentsRail,
12
+ })),
13
+ );
14
+
15
+ export function scheduleMarkdownContentsRail(callback: () => void): () => void {
16
+ if (typeof window.requestIdleCallback === 'function') {
17
+ const handle = window.requestIdleCallback(callback, { timeout: 2000 });
18
+ return () => window.cancelIdleCallback(handle);
19
+ }
20
+ const handle = window.setTimeout(callback, 0);
21
+ return () => window.clearTimeout(handle);
22
+ }
23
+
24
+ /** Load the reading aid after the document is interactive, never in the
25
+ * critical document-paint path. The timeout still guarantees it appears on
26
+ * a busy page; Safari and tests use the zero-delay fallback. */
27
+ export function DeferredMarkdownContentsRail(props: MarkdownContentsRailProps) {
28
+ const [ready, setReady] = useState(false);
29
+
30
+ useEffect(() => scheduleMarkdownContentsRail(() => setReady(true)), []);
31
+
32
+ if (!ready) return null;
33
+ return (
34
+ <Suspense fallback={null}>
35
+ <MarkdownContentsRail {...props} />
36
+ </Suspense>
37
+ );
38
+ }
@@ -11,12 +11,13 @@ export type { DiffFileTreeFile, DiffFileTreeNode, DiffFileTreeStatus } from '../
11
11
  export type { DiffFileTreeDefaultExpanded } from '../hooks/useDiffFileTreeExpansion';
12
12
 
13
13
  /*
14
- * The diff file tree's presentational parts. Plannotator's code review renders
15
- * its tree rows from the atoms below (folder row, file-row content, change
16
- * letter, +/- counts, indentation), adding its review-only decorations
17
- * (viewed/stage controls, annotation badge, context menu) around them; the
18
- * `DiffFileTree` component at the bottom is the read-only, embeddable tree a
19
- * host drops beside its own diff list.
14
+ * The diff file tree's presentational parts: the atoms below (folder row,
15
+ * file-row content, change letter, +/- counts, indentation) for a host's own
16
+ * rows, and the `DiffFileTree` component at the bottom, the read-only,
17
+ * embeddable tree a host drops beside its own diff list. Plannotator's code
18
+ * review shares `ChangeTypeLetter` and `diffFileTreeIndent` (its All files
19
+ * tree) but draws its own Changes rows since its aligned sidebar (see
20
+ * HANDOFF.md § "Diff file tree").
20
21
  */
21
22
 
22
23
  /** Left padding of a row at `depth` (px). */
@@ -0,0 +1,74 @@
1
+ import { useLayoutEffect, useMemo, useRef } from 'react';
2
+ import type { Annotation, Block } from '../types';
3
+ import { CONTENTS_RAIL_MAX_LEVEL, tocHeadings } from '../utils/annotationHelpers';
4
+ import { useHeadingScrollSpy } from '../hooks/useHeadingScrollSpy';
5
+ import { TableOfContents } from './TableOfContents';
6
+
7
+ interface DocumentContentsProps {
8
+ blocks: Block[];
9
+ annotations: Annotation[];
10
+ /**
11
+ * The element the document scrolls in. The current heading is read from it
12
+ * and a click scrolls it. `null` until it has mounted.
13
+ */
14
+ scrollViewport: HTMLElement | null;
15
+ /** Called with the heading's block id after a click (a sheet closes itself here). */
16
+ onNavigate?: (blockId: string) => void;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * A document's Contents list for a host without plan review's sidebar, on the
22
+ * contents rail's surface (the Inbox's sheet, where the rail yields): the same
23
+ * `TableOfContents` (click to scroll, the current heading highlighted) with
24
+ * the rail's headings (levels 1 to 4) and the rail's scroll math
25
+ * (`useHeadingScrollSpy`), so the list and the rail never disagree. It draws
26
+ * no chrome; the host places it and titles it.
27
+ *
28
+ * The host mounts it afresh (a sheet that opens), often mid-document: the
29
+ * current heading is read at once. The marked entry is kept in view inside the
30
+ * list's own scroll box (a sheet).
31
+ */
32
+ export function DocumentContents({ blocks, annotations, scrollViewport, onNavigate, className = '' }: DocumentContentsProps) {
33
+ const headingIds = useMemo(
34
+ () => tocHeadings(blocks, CONTENTS_RAIL_MAX_LEVEL).map((heading) => heading.id),
35
+ [blocks],
36
+ );
37
+ const activeId = useHeadingScrollSpy(scrollViewport, headingIds);
38
+
39
+ const wrapRef = useRef<HTMLDivElement>(null);
40
+ useLayoutEffect(() => {
41
+ const item = wrapRef.current?.querySelector<HTMLElement>('[aria-current="location"]');
42
+ if (item) revealInList(item);
43
+ }, [activeId]);
44
+
45
+ return (
46
+ <div ref={wrapRef} style={{ display: 'contents' }}>
47
+ <TableOfContents
48
+ blocks={blocks}
49
+ annotations={annotations}
50
+ activeId={activeId}
51
+ onNavigate={onNavigate ?? noop}
52
+ scrollViewport={scrollViewport}
53
+ maxLevel={CONTENTS_RAIL_MAX_LEVEL}
54
+ className={className}
55
+ />
56
+ </div>
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Scroll the list's own scroll box (the nearest ancestor that scrolls) just
62
+ * enough to show `item`, at once. Never the page or the document behind it.
63
+ */
64
+ function revealInList(item: HTMLElement) {
65
+ let box = item.parentElement;
66
+ while (box && !/(auto|scroll)/.test(getComputedStyle(box).overflowY)) box = box.parentElement;
67
+ if (!box || box.scrollHeight <= box.clientHeight) return;
68
+ const area = box.getBoundingClientRect();
69
+ const rect = item.getBoundingClientRect();
70
+ if (rect.top < area.top) box.scrollTop -= area.top - rect.top;
71
+ else if (rect.bottom > area.bottom) box.scrollTop += Math.min(rect.bottom - area.bottom, rect.top - area.top);
72
+ }
73
+
74
+ function noop() {}