@beyondwork/docx-react-component 1.0.70 → 1.0.72

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.
Files changed (75) hide show
  1. package/README.md +964 -75
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +243 -1
  4. package/src/api/v3/_create.ts +16 -1
  5. package/src/api/v3/_runtime-handle.ts +2 -0
  6. package/src/api/v3/ai/evaluate.ts +113 -0
  7. package/src/api/v3/ai/outline.ts +140 -0
  8. package/src/api/v3/ai/replacement.ts +8 -0
  9. package/src/api/v3/ai/review.ts +342 -0
  10. package/src/api/v3/ai/stats.ts +62 -0
  11. package/src/api/v3/runtime/viewport.ts +181 -0
  12. package/src/api/v3/runtime/workflow.ts +114 -1
  13. package/src/api/v3/ui/_types.ts +35 -0
  14. package/src/api/v3/ui/index.ts +1 -0
  15. package/src/api/v3/ui/viewport.ts +112 -0
  16. package/src/compare/diff-engine.ts +2 -0
  17. package/src/core/commands/formatting-commands.ts +1 -0
  18. package/src/core/commands/table-structure-commands.ts +1 -0
  19. package/src/io/export/serialize-headers-footers.ts +1 -0
  20. package/src/io/export/serialize-main-document.ts +13 -0
  21. package/src/io/export/serialize-paragraph-formatting.ts +34 -0
  22. package/src/io/export/split-review-boundaries.ts +1 -0
  23. package/src/io/normalize/normalize-text.ts +11 -0
  24. package/src/io/ooxml/parse-main-document.ts +21 -5
  25. package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
  26. package/src/model/canonical-document.ts +401 -1
  27. package/src/runtime/formatting/formatting-context.ts +2 -1
  28. package/src/runtime/geometry/overlay-rects.ts +7 -10
  29. package/src/runtime/layout/layout-engine-version.ts +257 -1
  30. package/src/runtime/layout/paginated-layout-engine.ts +134 -8
  31. package/src/runtime/layout/resolved-formatting-state.ts +108 -13
  32. package/src/runtime/markdown-sanitizer.ts +21 -4
  33. package/src/runtime/render/render-kernel.ts +21 -1
  34. package/src/runtime/scopes/audit-bundle.ts +8 -0
  35. package/src/runtime/scopes/compiler-service.ts +1 -0
  36. package/src/runtime/scopes/enumerate-scopes.ts +61 -3
  37. package/src/runtime/scopes/replacement/apply.ts +49 -3
  38. package/src/runtime/scopes/semantic-scope-types.ts +8 -0
  39. package/src/runtime/surface-projection.ts +22 -0
  40. package/src/runtime/workflow/coordinator.ts +3 -0
  41. package/src/runtime/workflow/scope-writer.ts +34 -0
  42. package/src/session/export/embedded-reconstitute.ts +37 -3
  43. package/src/session/import/embedded-offload.ts +26 -1
  44. package/src/shell/media-previews.ts +8 -6
  45. package/src/ui/WordReviewEditor.tsx +1 -0
  46. package/src/ui/editor-surface-controller.tsx +11 -0
  47. package/src/ui/headless/selection-helpers.ts +2 -2
  48. package/src/ui/runtime-shortcut-dispatch.ts +4 -4
  49. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
  50. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
  51. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
  52. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
  53. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
  54. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
  55. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
  56. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
  57. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -4
  58. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
  59. package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
  60. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +37 -0
  61. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
  62. package/src/ui-tailwind/index.ts +4 -2
  63. package/src/ui-tailwind/page-chrome-model.ts +5 -7
  64. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +5 -2
  65. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
  66. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
  67. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
  68. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +4 -1
  69. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +7 -1
  70. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
  71. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +73 -8
  72. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
  73. package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
  74. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
  75. package/src/ui-tailwind/tw-review-workspace.tsx +1 -0
@@ -68,6 +68,34 @@ export async function reconstituteEmbeddedDocuments(
68
68
  }
69
69
  }
70
70
 
71
+ /**
72
+ * SEC-IO-01 (2026-04-23): tamper-verified resolution.
73
+ *
74
+ * Two sources are consulted in order:
75
+ * 1. `hostAdapter.loadEmbeddedDocument(storageReference)` — verified against
76
+ * `entry.sha256`. Mismatch falls through to inline bytes, treated as a
77
+ * storage failure.
78
+ * 2. `entry.inlineBytes` base64 fallback — also verified against
79
+ * `entry.sha256`. If the inline bytes have been tampered with (attacker-
80
+ * modified customXml payload carrying altered bytes with the original
81
+ * declared sha), this check fail-closes: export throws a specific error
82
+ * rather than shipping attacker-chosen bytes into the OPC package.
83
+ *
84
+ * Before the fix, the inline-bytes path returned the decoded payload without
85
+ * verification — a crafted DOCX could inject attacker-controlled bytes into
86
+ * every exported package part whose host-adapter lookup happened to fail.
87
+ */
88
+ class EmbeddedOffloadTamperError extends Error {
89
+ constructor(public readonly entry: { sourcePartPath: string; sha256: string }) {
90
+ super(
91
+ `Embedded offload entry '${entry.sourcePartPath}' failed sha256 integrity ` +
92
+ `check against inline-bytes fallback (expected sha256=${entry.sha256.slice(0, 16)}…). ` +
93
+ `Source customXml payload is tampered.`,
94
+ );
95
+ this.name = "EmbeddedOffloadTamperError";
96
+ }
97
+ }
98
+
71
99
  async function resolveEntryBytes(
72
100
  hostAdapter: EditorHostAdapter | undefined,
73
101
  entry: EmbeddingOffloadEntry,
@@ -89,11 +117,17 @@ async function resolveEntryBytes(
89
117
  }
90
118
  } catch {
91
119
  // Treat identically to `null` — fall through to the inline-
92
- // bytes fallback. The architecture guarantees export never
93
- // fails on storage unavailability.
120
+ // bytes fallback.
94
121
  }
95
122
  }
96
- return decodeInlineBytes(entry.inlineBytes);
123
+ // SEC-IO-01 verification on the inline fallback. Unlike storage
124
+ // unavailability (which is benign), sha mismatch here means the
125
+ // source customXml payload was tampered with. Fail closed.
126
+ const decoded = decodeInlineBytes(entry.inlineBytes);
127
+ if (sha256Hex(decoded) !== entry.sha256) {
128
+ throw new EmbeddedOffloadTamperError(entry);
129
+ }
130
+ return decoded;
97
131
  }
98
132
 
99
133
  /**
@@ -263,6 +263,31 @@ const EMPTY_RESULT: EmbeddedOffloadResult = Object.freeze({
263
263
  // (Buffer) and browser (btoa/atob) contexts. Direct call; the earlier
264
264
  // local `encodeBase64` alias was unnecessary indirection.
265
265
 
266
+ /**
267
+ * SEC-IO-02 (2026-04-23): allowlist check for offload sourcePartPath.
268
+ *
269
+ * Offload entries come from customXml — untrusted content. The export-time
270
+ * `reconstituteEmbeddedDocuments` writes to `entry.sourcePartPath` via
271
+ * `exportSession.replaceOwnedPart`, which accepts arbitrary OPC paths. A
272
+ * malicious `sourcePartPath` could overwrite `/[Content_Types].xml`,
273
+ * `/_rels/.rels`, `/word/document.xml`, or any other sensitive part of the
274
+ * exported package.
275
+ *
276
+ * The offload feature covers EMBEDDED documents only — sub-documents Word
277
+ * stores under `/word/embeddings/embedded{N}.bin` (OLE) or similar. Narrow
278
+ * the allowlist to that canonical prefix. Entries with any other path,
279
+ * any traversal segment, or any control char silently drop.
280
+ */
281
+ const SAFE_EMBEDDING_PATH = /^\/word\/embeddings\/[A-Za-z0-9_.-]+$/;
282
+
283
+ function isSafeSourcePartPath(path: unknown): path is string {
284
+ if (typeof path !== "string") return false;
285
+ if (path.length === 0 || path.length > 256) return false;
286
+ if (path.includes("..")) return false;
287
+ if (path.includes("\0") || path.includes("\n") || path.includes("\r")) return false;
288
+ return SAFE_EMBEDDING_PATH.test(path);
289
+ }
290
+
266
291
  function parseEmbeddingsInline(
267
292
  inline: unknown,
268
293
  ): EmbeddingOffloadEntry[] | undefined {
@@ -293,7 +318,7 @@ function parseEmbeddingsInline(
293
318
  if (
294
319
  typeof r.embeddedDocId !== "string" ||
295
320
  typeof r.relationshipId !== "string" ||
296
- typeof r.sourcePartPath !== "string" ||
321
+ !isSafeSourcePartPath(r.sourcePartPath) ||
297
322
  typeof r.contentType !== "string" ||
298
323
  typeof r.sha256 !== "string" ||
299
324
  typeof r.inlineBytes !== "string" ||
@@ -6,11 +6,13 @@
6
6
  * digest, walks the OPC parts table, and projects each canonical media
7
7
  * item into a `MediaPreviewDescriptor`.
8
8
  *
9
- * SVG is included in the browser-safe content-type allowlist Chromium
10
- * sandboxes SVGs loaded via `<img src="data:image/svg+xml;base64,…">`
11
- * (no script execution, no external refs), so XSS surface matches PNG.
12
- * Required for CCEP-synthesized chart previews (Stage 0B) and hosts
13
- * that ship `.svg` inside `word/media/`.
9
+ * SVG is EXCLUDED from the content-type allowlist (SEC-UI-03, 2026-04-23).
10
+ * While Chromium's `<img src="data:image/svg+xml;base64,…">` path does block
11
+ * script execution on modern browsers, user-authored SVGs in DOCX `word/media/`
12
+ * can still leak user presence via `<image xlink:href="http://...">` trackers
13
+ * and have historically had CSP-bypass gaps. Chart-preview SVGs (Stage 0B)
14
+ * come from our own renderer via `chart-preview-resolver.ts`, not from
15
+ * `word/media/*`, so dropping SVG here does not regress charts.
14
16
  *
15
17
  * Lives in `src/shell/` so the three `io/**` substrate imports stay
16
18
  * outside the L11 boundary register — package decoding is a shell
@@ -33,7 +35,7 @@ const BROWSER_SAFE_PREVIEW_TYPES = new Set([
33
35
  "image/gif",
34
36
  "image/webp",
35
37
  "image/bmp",
36
- "image/svg+xml",
38
+ // SVG intentionally EXCLUDED — see SEC-UI-03 header comment.
37
39
  ]);
38
40
 
39
41
  export function buildMediaPreviews(
@@ -3321,6 +3321,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3321
3321
  pasteFragmentParser={SHELL_PASTE_FRAGMENT_PARSER}
3322
3322
  runtimeSearchDocument={api.runtime.search.searchDocument}
3323
3323
  runtimeGetTableSelectionDescriptor={api.runtime.table.getSelectionDescriptor}
3324
+ scopeTagRegistryFactory={api.runtime.workflow.createScopeTagRegistry}
3324
3325
  snapshot={snapshot}
3325
3326
  canonicalDocument={canonicalDocument}
3326
3327
  documentNavigation={documentNavigation}
@@ -84,6 +84,17 @@ export interface EditorSurfaceControllerProps {
84
84
  runtimeGetTableSelectionDescriptor?: (
85
85
  state: import("prosemirror-state").EditorState,
86
86
  ) => TableSelectionDescriptor | null;
87
+ /**
88
+ * Coord-10 L11-4 — forwarded to `TwProseMirrorSurface`. Threaded
89
+ * from `api.runtime.workflow.createScopeTagRegistry` (shipped by
90
+ * L06 in react-refactor `534f2b97`). Replaces the pre-L06-catalog
91
+ * pragmatic public-types re-export (refactor/11 §4.17, 2026-04-24
92
+ * `7a2d2fc0`). When absent, `TwProseMirrorSurface` falls back to
93
+ * the public-types re-export for test / headless mount back-compat.
94
+ */
95
+ scopeTagRegistryFactory?: () => import(
96
+ "../api/public-types.ts"
97
+ ).ScopeTagRegistry;
87
98
  onPasteApplied?: (meta: {
88
99
  segmentCount: number;
89
100
  charCount: number;
@@ -1,8 +1,8 @@
1
- import type { SelectionSnapshot } from "../../api/public-types";
2
1
  import {
2
+ type SelectionSnapshot,
3
3
  createPublicNodeAnchor,
4
4
  createPublicRangeAnchor,
5
- } from "../../core/selection/anchor-conversion.ts";
5
+ } from "../../api/public-types";
6
6
 
7
7
  /**
8
8
  * Headless-UI-side `createSelectionSnapshot` that produces the **public**
@@ -1,8 +1,8 @@
1
- import type {
2
- StyleCatalogSnapshot,
3
- WorkflowBlockedCommandReason,
1
+ import {
2
+ CAPABILITY_BY_ID,
3
+ type StyleCatalogSnapshot,
4
+ type WorkflowBlockedCommandReason,
4
5
  } from "../api/public-types.ts";
5
- import { CAPABILITY_BY_ID } from "../runtime/editor-surface/capabilities.ts";
6
6
 
7
7
  export interface ShortcutKeyInput {
8
8
  key: string;
@@ -6,10 +6,28 @@ import React, {
6
6
  } from "react";
7
7
 
8
8
  import type { WordReviewEditorRef } from "../../api/public-types";
9
- import type { DocumentRuntime } from "../../runtime/document-runtime";
9
+
10
+ /**
11
+ * Opaque runtime handle for the REPL. The REPL hands the value to
12
+ * `new Function("runtime", ...)` for user-supplied JS evaluation and
13
+ * does not introspect its shape — so the dialog's prop type is
14
+ * deliberately structurally loose (`object | null`) rather than the
15
+ * non-public `DocumentRuntime`.
16
+ *
17
+ * Callers inside the component tree that actually hold a
18
+ * `DocumentRuntime` / `RuntimeApiHandle` / `ApiV3` can pass it here
19
+ * unchanged; the widening only affects compile-time autocomplete for
20
+ * the REPL's internal use, not the live eval target.
21
+ *
22
+ * Retired handoff §4.8 residual: the previous `DocumentRuntime`
23
+ * prop type was the last direct import of the non-public runtime
24
+ * class name in `src/ui-tailwind/**` per refactor/11 handoff §4.8
25
+ * remainder row.
26
+ */
27
+ export type TwRuntimeReplTarget = object;
10
28
 
11
29
  export interface TwRuntimeReplDialogProps {
12
- runtime: DocumentRuntime | null;
30
+ runtime: TwRuntimeReplTarget | null;
13
31
  /**
14
32
  * Optional editor ref. When provided, the REPL exposes it to evaluated
15
33
  * expressions as `ref` — e.g. `ref.getRenderSnapshot()`. The REPL reads
@@ -370,11 +388,11 @@ export function isReplToggleShortcut(event: KeyboardEvent): boolean {
370
388
 
371
389
  export async function evaluateReplExpression(
372
390
  code: string,
373
- runtime: DocumentRuntime,
391
+ runtime: TwRuntimeReplTarget,
374
392
  ref: WordReviewEditorRef | null = null,
375
393
  ): Promise<unknown> {
376
394
  type ReplFn = (
377
- runtime: DocumentRuntime,
395
+ runtime: TwRuntimeReplTarget,
378
396
  ref: WordReviewEditorRef | null,
379
397
  ) => Promise<unknown>;
380
398
  let fn: ReplFn | null = null;
@@ -214,17 +214,17 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
214
214
  capability={tableContext?.operations.setTableAlignment}
215
215
  disabled={props.disabled}
216
216
  onClick={() => props.onSetTableAlignment?.(align)}
217
- // Chrome Closure Pass · Task 3 pre-fix bug:
218
- // `active` was hardcoded to `align === "left"`, lighting
219
- // up the Left button regardless of the actual table
220
- // alignment. `TableStructureContextSnapshot` does not
221
- // currently surface table-level alignment (the field
222
- // lives on `RuntimeTableRenderPlanSnapshot`); until the
223
- // runtime exposes it on the structure context, treat
224
- // alignment buttons as plain action buttons (no toggle).
225
- // Fix scope: presentation-only correction; runtime
226
- // enrichment is a follow-up issue against L07.
227
- active={false}
217
+ // Refactor/11 handoff §4.13L07 shipped
218
+ // `TableStructureContextSnapshot.alignment` in
219
+ // `4cfe52a3` (2026-04-24), landing alignment on the
220
+ // structure context alongside the existing
221
+ // `PublicTableSummary.alignment`. Toggle the active
222
+ // state against the live value. `null` means no
223
+ // explicit alignment declared (parent default, typically
224
+ // left) all three buttons remain inactive in that
225
+ // case; setting any alignment via the callback produces
226
+ // a subsequent snapshot with a non-null value.
227
+ active={tableContext?.alignment === align}
228
228
  >
229
229
  {align[0]!.toUpperCase()}
230
230
  </ToolbarButton>
@@ -28,9 +28,9 @@ import type {
28
28
  WordReviewEditorLayoutFacet,
29
29
  } from "../../api/public-types";
30
30
  import type { GeometryFacet } from "../../api/public-types";
31
+ import { DEFAULT_PX_PER_TWIP } from "../../api/public-types";
31
32
  import type { OverlayCoordinateSpace } from "../chrome-overlay/chrome-overlay-projector";
32
33
  import { projectRectToOverlay } from "../chrome-overlay/chrome-overlay-projector";
33
- import { DEFAULT_PX_PER_TWIP } from "../../runtime/render/render-frame-types";
34
34
  import { forwardNonDragClick } from "./forward-non-drag-click";
35
35
 
36
36
  const GRIP_PX = 2;
@@ -183,6 +183,9 @@ export interface TwChromeOverlayProps {
183
183
  * See `useVisiblePageIndexRange` in `src/ui-tailwind/page-stack/use-visible-block-range.ts`.
184
184
  */
185
185
  visiblePageIndexRange?: { start: number; end: number } | null;
186
+ /** Preview catalog threaded into the page-stack chrome so header /
187
+ * footer / footnote / endnote regions render real <img>s. */
188
+ mediaPreviews?: Record<string, import("../editor-surface/pm-state-from-snapshot").MediaPreviewDescriptor>;
186
189
  }
187
190
 
188
191
  /**
@@ -226,6 +229,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
226
229
  pmSurfaceElement,
227
230
  pmView,
228
231
  visiblePageIndexRange,
232
+ mediaPreviews,
229
233
  }) => {
230
234
  return (
231
235
  <div
@@ -243,6 +247,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
243
247
  pmSurfaceElement={pmSurfaceElement}
244
248
  pmView={pmView}
245
249
  visiblePageIndexRange={visiblePageIndexRange ?? null}
250
+ mediaPreviews={mediaPreviews}
246
251
  />
247
252
  ) : null}
248
253
  <TwScopeRailLayer
@@ -1,6 +1,10 @@
1
1
  import * as React from "react";
2
2
  import type { RenderBlockDecoration } from "../../api/public-types.ts";
3
3
  import { TwCommentPreview } from "../chrome/tw-comment-preview";
4
+ import {
5
+ projectRectToOverlay,
6
+ type OverlayCoordinateSpace,
7
+ } from "./chrome-overlay-projector.ts";
4
8
 
5
9
  const WIDE_BREAKPOINT_PX = 1024;
6
10
  const CONNECTOR_GAP_PX = 16;
@@ -24,14 +28,26 @@ export interface TwCommentBalloonLayerProps {
24
28
  viewportWidthPx: number;
25
29
  /** Right edge of the page frame in overlay coordinates (px). */
26
30
  pageRightEdgePx: number;
31
+ /**
32
+ * Overlay coordinate space — subtracted from each decoration rect so
33
+ * balloon `top` is relative to the overlay's own top-left, not the
34
+ * document column. Matches `TwScopeCardLayer` / `TwTableGripLayer`
35
+ * precedent. Defaults to the zero-origin space so existing callers
36
+ * that mount the layer at the document column's origin continue to
37
+ * work unchanged.
38
+ */
39
+ space?: OverlayCoordinateSpace;
27
40
  onOpenThread?: (commentId: string) => void;
28
41
  }
29
42
 
43
+ const ZERO_SPACE: OverlayCoordinateSpace = { originLeftPx: 0, originTopPx: 0 };
44
+
30
45
  export const TwCommentBalloonLayer = React.memo(function TwCommentBalloonLayer({
31
46
  commentDecorations,
32
47
  commentDataById,
33
48
  viewportWidthPx,
34
49
  pageRightEdgePx,
50
+ space = ZERO_SPACE,
35
51
  onOpenThread,
36
52
  }: TwCommentBalloonLayerProps) {
37
53
  if (viewportWidthPx < WIDE_BREAKPOINT_PX) return null;
@@ -42,12 +58,13 @@ export const TwCommentBalloonLayer = React.memo(function TwCommentBalloonLayer({
42
58
  {commentDecorations.map((dec) => {
43
59
  const data = commentDataById.get(dec.refId);
44
60
  if (!data) return null;
61
+ const projected = projectRectToOverlay(dec.frame, space);
45
62
  return (
46
63
  <div
47
64
  key={dec.refId}
48
65
  style={{
49
66
  position: "absolute",
50
- top: dec.frame.topPx,
67
+ top: projected.top,
51
68
  left: pageRightEdgePx + CONNECTOR_GAP_PX,
52
69
  width: BALLOON_MAX_WIDTH_PX,
53
70
  pointerEvents: "auto",
@@ -176,6 +176,7 @@ export function resolvePageOverlayRects(
176
176
  if (!scrollRoot || count <= 0) return [];
177
177
  widgets = measureWidgetsViaOffsetChain(scrollRoot);
178
178
  pageCount = count;
179
+ // geometry:allow-dom-fallback
179
180
  scrollHeight = scrollRoot.clientHeight;
180
181
  } else if (
181
182
  input !== null &&
@@ -195,6 +196,7 @@ export function resolvePageOverlayRects(
195
196
  if (legacyPageCount <= 0) return [];
196
197
  widgets = measureWidgetsViaOffsetChain(scrollRoot);
197
198
  pageCount = legacyPageCount;
199
+ // geometry:allow-dom-fallback
198
200
  scrollHeight = scrollRoot.clientHeight;
199
201
  } else {
200
202
  return [];
@@ -275,6 +277,10 @@ export function measureWidgetsViaBoundingRect(
275
277
  },
276
278
  ): PageBoundaryMeasurement[] {
277
279
  if (!queryRoot || !originElement) return [];
280
+ // Cold-open / pre-paint DOM fallback — warm path flows through
281
+ // `geometryFacet.getPage(i)` at the caller; this branch fires only
282
+ // before the first render frame.
283
+ // geometry:allow-dom-fallback
278
284
  const originRect = originElement.getBoundingClientRect();
279
285
  const normalizedVisiblePageIndexRange = normalizeVisiblePageIndexRange(
280
286
  options?.visiblePageIndexRange,
@@ -301,6 +307,7 @@ export function measureWidgetsViaBoundingRect(
301
307
  const prevPageId = widget.getAttribute("data-page-frame-end");
302
308
  const nextPageId = widget.getAttribute("data-page-frame-start");
303
309
  if (!prevPageId || !nextPageId) continue;
310
+ // geometry:allow-dom-fallback
304
311
  const rect = widget.getBoundingClientRect();
305
312
  out.push({
306
313
  prevPageId,
@@ -380,10 +387,14 @@ function resolveOffsetTop(
380
387
  // still defined and defaults to 0. Browsers set it relative to the
381
388
  // offsetParent. We walk up the offset chain until we reach the scroll
382
389
  // root (or exit the document) so the result is scroll-root-relative.
390
+ // Cold-open / pre-paint DOM fallback — warm path flows through
391
+ // `geometryFacet.getPage(i)` at the caller.
383
392
  let node: HTMLElement | null = widget;
384
393
  let top = 0;
385
394
  while (node) {
395
+ // geometry:allow-dom-fallback
386
396
  top += node.offsetTop ?? 0;
397
+ // geometry:allow-dom-fallback
387
398
  const parent = node.offsetParent as HTMLElement | null;
388
399
  if (parent === scrollRoot || parent === null) break;
389
400
  node = parent;
@@ -392,6 +403,7 @@ function resolveOffsetTop(
392
403
  }
393
404
 
394
405
  function resolveOffsetHeight(widget: HTMLElement): number {
406
+ // geometry:allow-dom-fallback
395
407
  return widget.offsetHeight ?? 0;
396
408
  }
397
409
 
@@ -437,12 +449,10 @@ export interface TwPageStackOverlayLayerProps {
437
449
  * owns the projection math; the overlay component re-exports from this
438
450
  * module for back-compat with existing imports.
439
451
  *
440
- * Known coordinate-space caveat documented in
441
- * `src/runtime/geometry/overlay-rects.ts` the kernel's page-stacking
442
- * gap (16 px) diverges from the DOM chrome's inter-page gap (48 px).
443
- * Until Slice 3c reconciles them, production consumers should not pass
444
- * a `geometryFacet` to this overlay — the DOM-measurement path remains
445
- * the default.
452
+ * **Slice 3c reconciliation shipped 2026-04-23** kernel `PAGE_GAP_PX`
453
+ * (16 48) now matches the DOM chrome's `interGapPx`, so `geometryFacet`
454
+ * is the production warm path. The DOM-measurement branch below stays as
455
+ * the cold-open fallback only.
446
456
  */
447
457
  export const resolvePageOverlayRectsFromGeometry =
448
458
  resolvePageOverlayRectsFromGeometryImpl;
@@ -628,17 +638,22 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
628
638
  }
629
639
  }
630
640
 
641
+ // Cold-open / pre-paint DOM fallback — warm path early-returned
642
+ // above via `geometryFacet` or the UI-API resolver. Lines below
643
+ // fire only before the first render frame.
631
644
  if (origin) {
632
645
  const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
633
646
  pageCount,
634
647
  visiblePageIndexRange,
635
648
  });
649
+ // geometry:allow-dom-fallback
636
650
  const originRect = origin.getBoundingClientRect();
637
651
  setRects(
638
652
  resolvePageOverlayRects({
639
653
  widgets,
640
654
  pageCount,
641
655
  scrollHeight:
656
+ // geometry:allow-dom-fallback
642
657
  origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
643
658
  visiblePageIndexRange,
644
659
  }),
@@ -652,6 +667,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
652
667
  resolvePageOverlayRects({
653
668
  widgets,
654
669
  pageCount,
670
+ // geometry:allow-dom-fallback
655
671
  scrollHeight: scrollRoot.clientHeight,
656
672
  visiblePageIndexRange,
657
673
  }),
@@ -1,6 +1,10 @@
1
1
  import * as React from "react";
2
2
  import type { RenderBlockDecoration } from "../../api/public-types.ts";
3
3
  import { AUTHOR_PALETTE } from "../../ui/headless/revision-decoration-model";
4
+ import {
5
+ projectRectToOverlay,
6
+ type OverlayCoordinateSpace,
7
+ } from "./chrome-overlay-projector.ts";
4
8
 
5
9
  const BAR_WIDTH_PX = 3;
6
10
  const BAR_LEFT_OFFSET_PX = 8;
@@ -15,12 +19,24 @@ export interface TwRevisionMarginBarLayerProps {
15
19
  authorPaletteIndexById: ReadonlyMap<string, number>;
16
20
  /** Left edge of the page body in overlay coordinates (px). */
17
21
  pageBodyLeftPx: number;
22
+ /**
23
+ * Overlay coordinate space — subtracted from each decoration rect so
24
+ * bar `top` is relative to the overlay's own top-left, not the
25
+ * document column. Matches `TwScopeCardLayer` / `TwTableGripLayer`
26
+ * precedent. Defaults to the zero-origin space so existing callers
27
+ * that mount the layer at the document column's origin continue to
28
+ * work unchanged.
29
+ */
30
+ space?: OverlayCoordinateSpace;
18
31
  }
19
32
 
33
+ const ZERO_SPACE: OverlayCoordinateSpace = { originLeftPx: 0, originTopPx: 0 };
34
+
20
35
  export const TwRevisionMarginBarLayer = React.memo(function TwRevisionMarginBarLayer({
21
36
  revisionDecorations,
22
37
  authorPaletteIndexById,
23
38
  pageBodyLeftPx,
39
+ space = ZERO_SPACE,
24
40
  }: TwRevisionMarginBarLayerProps) {
25
41
  if (revisionDecorations.length === 0) return null;
26
42
 
@@ -29,13 +45,14 @@ export const TwRevisionMarginBarLayer = React.memo(function TwRevisionMarginBarL
29
45
  {revisionDecorations.map((dec, idx) => {
30
46
  const paletteIdx = authorPaletteIndexById.get(dec.refId) ?? 0;
31
47
  const color = AUTHOR_PALETTE[paletteIdx % AUTHOR_PALETTE.length];
48
+ const projected = projectRectToOverlay(dec.frame, space);
32
49
  return (
33
50
  <div
34
51
  key={`rev-bar-${dec.refId}-${idx}`}
35
52
  aria-hidden
36
53
  style={{
37
54
  position: "absolute",
38
- top: dec.frame.topPx,
55
+ top: projected.top,
39
56
  left: pageBodyLeftPx - BAR_LEFT_OFFSET_PX - BAR_WIDTH_PX,
40
57
  width: BAR_WIDTH_PX,
41
58
  height: dec.frame.heightPx,
@@ -24,8 +24,10 @@
24
24
  */
25
25
 
26
26
  import { Decoration } from "prosemirror-view";
27
- import type { RuntimePageGraph } from "../../api/public-types.ts";
28
- import { resolvePageFieldDisplayText } from "../../runtime/layout/resolve-page-fields.ts";
27
+ import {
28
+ type RuntimePageGraph,
29
+ resolvePageFieldDisplayText,
30
+ } from "../../api/public-types.ts";
29
31
 
30
32
  export const PAGE_CHROME_DEFAULTS = {
31
33
  headerBandPx: 32,
@@ -93,7 +95,7 @@ export function buildPageBreakDecorations(
93
95
  input: PageBreakDecorationInput,
94
96
  ): Decoration[] {
95
97
  const { graph, posture, runtimeToPmOffset } = input;
96
- if (!graph || graph.pages.length < 2) return [];
98
+ if (!graph) return [];
97
99
 
98
100
  const headerBandPx =
99
101
  input.headerBandPx ?? PAGE_CHROME_DEFAULTS.headerBandPx;
@@ -103,6 +105,15 @@ export function buildPageBreakDecorations(
103
105
 
104
106
  const decorations: Decoration[] = [];
105
107
 
108
+ // Inter-page boundary widgets (existing) — emitted first so
109
+ // `decorations[0..N-2]` remain boundary widgets for any legacy
110
+ // callers that index by position. Per-page content-anchor widgets
111
+ // (coord-11 §19) are emitted in a second pass at the end of this
112
+ // function.
113
+ if (graph.pages.length < 2) {
114
+ return buildPageAnchorDecorationsInto(decorations, graph, posture, runtimeToPmOffset);
115
+ }
116
+
106
117
  for (let i = 1; i < graph.pages.length; i += 1) {
107
118
  const prev = graph.pages[i - 1]!;
108
119
  const next = graph.pages[i]!;
@@ -160,6 +171,58 @@ export function buildPageBreakDecorations(
160
171
  ),
161
172
  );
162
173
  }
174
+ return buildPageAnchorDecorationsInto(decorations, graph, posture, runtimeToPmOffset);
175
+ }
176
+
177
+ /**
178
+ * Append per-page content-anchor widgets to `decorations` for coord-11
179
+ * §19. One zero-height anchor per non-filler page at the page's content-
180
+ * start offset. Chrome-preset-independent (PM widget = content layer) +
181
+ * markup-mode stable (document-offset-anchored, not chrome-dependent).
182
+ *
183
+ * Each anchor carries `data-page-content-wrapper` + `data-page-number`
184
+ * (1-based, skipping blank fillers) + `data-page-id` (from
185
+ * `RuntimePageNode.pageId` — stable across chrome/markup transitions).
186
+ * Consumed by `runtime.viewport.getPageAnchor(n)` (coord-07 §2.9) and
187
+ * `ui.viewport.scrollToPage(n)` (coord-10 §γ); also by the visual-
188
+ * fidelity harness at `test/visual-fidelity/` per coord-11 §20.
189
+ *
190
+ * Anchors use `side: 1` so they sit AFTER the document position
191
+ * (inside page N's content). Boundary widgets use `side: -1` so they
192
+ * sit BEFORE the same offset (at the end of page N-1's content + gap).
193
+ * Ordering places each anchor at the top of its page's content area.
194
+ */
195
+ function buildPageAnchorDecorationsInto(
196
+ decorations: Decoration[],
197
+ graph: RuntimePageGraph,
198
+ posture: "page" | "canvas",
199
+ runtimeToPmOffset: ((runtimeOffset: number) => number | null) | undefined,
200
+ ): Decoration[] {
201
+ let contentPageOrdinal = 0;
202
+ for (const page of graph.pages) {
203
+ if (page.isBlankFiller) continue;
204
+ contentPageOrdinal += 1;
205
+ const pagePmOffset = runtimeToPmOffset
206
+ ? runtimeToPmOffset(page.startOffset)
207
+ : page.startOffset;
208
+ if (pagePmOffset === null || pagePmOffset === undefined) continue;
209
+ const anchorPageNumber = contentPageOrdinal;
210
+ const anchorPageId = page.pageId;
211
+ decorations.push(
212
+ Decoration.widget(
213
+ pagePmOffset,
214
+ () => buildPageAnchorWidgetDom({
215
+ pageNumber: anchorPageNumber,
216
+ pageId: anchorPageId,
217
+ }),
218
+ {
219
+ side: 1,
220
+ key: `pa-${page.pageId}-${posture}`,
221
+ ignoreSelection: true,
222
+ },
223
+ ),
224
+ );
225
+ }
163
226
  return decorations;
164
227
  }
165
228
 
@@ -262,6 +325,38 @@ export function __resetPageBreakWidgetCache(): void {
262
325
  widgetDomCache.clear();
263
326
  }
264
327
 
328
+ /**
329
+ * Build the per-page content-anchor widget DOM for coord-11 §19.
330
+ *
331
+ * Zero-height, non-visual marker carrying `data-page-content-wrapper`,
332
+ * `data-page-number` (1-based), and `data-page-id` (stable across
333
+ * chrome preset + markup mode transitions — sourced from
334
+ * `RuntimePageNode.pageId`). Consumed by `runtime.viewport.getPageAnchor(n)`
335
+ * (coord-07 §2.9) + `ui.viewport.scrollToPage(n)` (coord-10 §γ) and by
336
+ * the visual-fidelity harness.
337
+ *
338
+ * Emitted as a PM widget via `buildPageBreakDecorations` so it lives
339
+ * on the content layer (present under chrome=none) rather than on an
340
+ * absolute-positioned chrome overlay.
341
+ */
342
+ function buildPageAnchorWidgetDom(input: {
343
+ pageNumber: number;
344
+ pageId: string;
345
+ }): HTMLElement {
346
+ const root = document.createElement("span");
347
+ root.setAttribute("data-kind", "page-content-anchor");
348
+ root.setAttribute("data-page-content-wrapper", "");
349
+ root.setAttribute("data-page-number", String(input.pageNumber));
350
+ root.setAttribute("data-page-id", input.pageId);
351
+ root.setAttribute("aria-hidden", "true");
352
+ root.contentEditable = "false";
353
+ root.style.display = "block";
354
+ root.style.height = "0";
355
+ root.style.width = "100%";
356
+ root.style.userSelect = "none";
357
+ return root;
358
+ }
359
+
265
360
  function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
266
361
  const root = document.createElement("div");
267
362
  root.className = "wre-page-chrome-widget";