@beyondwork/docx-react-component 1.0.77 → 1.0.79

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.77",
4
+ "version": "1.0.79",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -1566,6 +1566,22 @@ export type SurfaceBlockSnapshot =
1566
1566
  * See CLAUDE.md (lane status table)
1567
1567
  */
1568
1568
  placeholderSize?: number;
1569
+ /**
1570
+ * Visual height of the block the placeholder stands in for, in twips,
1571
+ * as computed by the L04 paginator against the fully-realized surface.
1572
+ * When present, the PM placeholder emits a fixed-height `<div>` so the
1573
+ * scrollable canvas matches what the realized block will occupy once
1574
+ * it re-enters the viewport — eliminating the "paragraphs jump around
1575
+ * pagination gaps" flicker that plagued culled regions rendered as
1576
+ * single-line ZWSP stubs.
1577
+ *
1578
+ * Populated by `DocumentRuntime.buildRenderSnapshot()` after each
1579
+ * pagination pass; surface-projection itself does not set it (L03 has
1580
+ * no layout data). Consumers that build a surface without a runtime
1581
+ * (tests, plain-text preview) safely leave it `undefined` and fall
1582
+ * back to the `min-height: 20px` behaviour.
1583
+ */
1584
+ placeholderHeightTwips?: number;
1569
1585
  state: "locked-preserve-only" | "placeholder-culled";
1570
1586
  };
1571
1587
 
@@ -2487,9 +2503,43 @@ export interface AddScopeParams {
2487
2503
  }
2488
2504
 
2489
2505
  export interface AddScopeResult {
2506
+ /**
2507
+ * Minted scope id. Empty string (`""`) iff the marker plant failed —
2508
+ * in that case `anchor.kind === "detached"` + `plantStatus.planted`
2509
+ * is `false`. Pre-2026-04-24 a plant failure silently returned a
2510
+ * non-empty scopeId that later resolved to `scope-not-resolvable`;
2511
+ * callers must now check `scopeId.length > 0` (or equivalently
2512
+ * `plantStatus?.planted !== false`) before using the id.
2513
+ */
2490
2514
  scopeId: string;
2491
- /** Range anchor derived from the just-inserted marker positions. */
2515
+ /** Range anchor derived from the just-inserted marker positions, or a detached placeholder on plant failure. */
2492
2516
  anchor: EditorAnchorProjection;
2517
+ /**
2518
+ * Structured diagnostic for the marker-plant attempt. Omitted on the
2519
+ * happy path for back-compat; populated with `planted: false` + a
2520
+ * typed reason when the plant was refused (cross-paragraph range,
2521
+ * non-paragraph target, out-of-bounds). Callers that previously
2522
+ * assumed every returned `scopeId` was live should migrate to
2523
+ * checking this field.
2524
+ */
2525
+ plantStatus?: {
2526
+ readonly planted: false;
2527
+ readonly reason:
2528
+ | "cross-paragraph-range"
2529
+ | "non-paragraph-target"
2530
+ | "range-out-of-bounds"
2531
+ | "empty-document";
2532
+ readonly requestedFrom: number;
2533
+ readonly requestedTo: number;
2534
+ /** Present on `cross-paragraph-range`. */
2535
+ readonly fromBlockIndex?: number;
2536
+ readonly toBlockIndex?: number;
2537
+ /** Present on `non-paragraph-target`. */
2538
+ readonly blockIndex?: number;
2539
+ readonly blockKind?: string;
2540
+ /** Present on `range-out-of-bounds`. */
2541
+ readonly storyLength?: number;
2542
+ };
2493
2543
  }
2494
2544
 
2495
2545
  export interface ExportDocxOptions {
@@ -160,13 +160,20 @@ export type CreateScopeFromAnchorResult =
160
160
  readonly reason:
161
161
  | "from-negative"
162
162
  | "to-less-than-from"
163
- | "range-exceeds-story-length";
163
+ | "range-exceeds-story-length"
164
+ | "cross-paragraph-range"
165
+ | "non-paragraph-target"
166
+ | "empty-document";
164
167
  readonly from: number;
165
168
  readonly to: number;
166
169
  readonly storyLength: number;
170
+ readonly fromBlockIndex?: number;
171
+ readonly toBlockIndex?: number;
172
+ readonly blockIndex?: number;
173
+ readonly blockKind?: string;
167
174
  /** Agent-actionable single-sentence explanation. Safe to surface to LLM tool replies as-is. */
168
175
  readonly message: string;
169
- /** Machine-routable next-step hint (`"clamp-from-to-zero"` / `"swap-from-and-to"` / `"clamp-to-to-storyLength-or-pick-a-different-range"`). */
176
+ /** Machine-routable next-step hint. */
170
177
  readonly nextStep: string;
171
178
  };
172
179
 
@@ -533,6 +540,18 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
533
540
  from: adapterResult.from,
534
541
  to: adapterResult.to,
535
542
  storyLength: adapterResult.storyLength,
543
+ ...(adapterResult.fromBlockIndex !== undefined
544
+ ? { fromBlockIndex: adapterResult.fromBlockIndex }
545
+ : {}),
546
+ ...(adapterResult.toBlockIndex !== undefined
547
+ ? { toBlockIndex: adapterResult.toBlockIndex }
548
+ : {}),
549
+ ...(adapterResult.blockIndex !== undefined
550
+ ? { blockIndex: adapterResult.blockIndex }
551
+ : {}),
552
+ ...(adapterResult.blockKind !== undefined
553
+ ? { blockKind: adapterResult.blockKind }
554
+ : {}),
536
555
  message: adapterResult.message,
537
556
  nextStep: adapterResult.nextStep,
538
557
  };
@@ -7,18 +7,72 @@ import type {
7
7
  } from "../../model/canonical-document.ts";
8
8
  import type { CanonicalDocumentEnvelope } from "../state/editor-state.ts";
9
9
 
10
- export interface InsertScopeMarkersResult {
11
- document: CanonicalDocumentEnvelope;
12
- scopeId: string;
13
- }
10
+ /**
11
+ * Discriminated return — pre-2026-04-24 this helper silently returned
12
+ * the input document unchanged on every failure mode, which left
13
+ * callers holding a minted `scopeId` that referenced nothing in the
14
+ * canonical tree. `compileScopeById` later returned null and the v3
15
+ * surface emitted `scope-not-resolvable:<scopeId>` without the caller
16
+ * knowing whether the scope was ever live. Every non-`"planted"`
17
+ * status is now typed so callers can discriminate + surface
18
+ * agent-actionable recovery hints.
19
+ */
20
+ export type InsertScopeMarkersResult =
21
+ | {
22
+ readonly status: "planted";
23
+ readonly document: CanonicalDocumentEnvelope;
24
+ readonly scopeId: string;
25
+ /** Absolute span that got planted (after from/to normalization). */
26
+ readonly plantedRange: { readonly from: number; readonly to: number };
27
+ }
28
+ | {
29
+ readonly status: "cross-paragraph-range";
30
+ readonly scopeId: string;
31
+ readonly from: number;
32
+ readonly to: number;
33
+ readonly fromBlockIndex: number;
34
+ readonly toBlockIndex: number;
35
+ }
36
+ | {
37
+ readonly status: "non-paragraph-target";
38
+ readonly scopeId: string;
39
+ readonly from: number;
40
+ readonly to: number;
41
+ readonly blockIndex: number;
42
+ readonly blockKind: string;
43
+ }
44
+ | {
45
+ readonly status: "range-out-of-bounds";
46
+ readonly scopeId: string;
47
+ readonly from: number;
48
+ readonly to: number;
49
+ readonly storyLength: number;
50
+ }
51
+ | {
52
+ readonly status: "empty-document";
53
+ readonly scopeId: string;
54
+ readonly from: number;
55
+ readonly to: number;
56
+ };
14
57
 
15
58
  /**
16
- * Pure helper — returns a new CanonicalDocumentEnvelope with a pair of
17
- * scope-marker inline nodes inserted at the given position range.
59
+ * Pure helper — returns a discriminated result describing whether a
60
+ * pair of `scope_marker_start` / `scope_marker_end` inline nodes got
61
+ * inserted at `[from, to]`.
62
+ *
63
+ * Failure modes (all previously silent — pre-2026-04-24):
64
+ * - `cross-paragraph-range` — `from` and `to` resolve to different
65
+ * top-level paragraph blocks. Multi-block marker scopes are not
66
+ * yet wired; narrow the range to land inside one paragraph.
67
+ * - `non-paragraph-target` — the target range lands inside a
68
+ * non-paragraph block (table / SDT / section_break). Marker
69
+ * scopes only plant inside paragraphs today.
70
+ * - `range-out-of-bounds` — `to` exceeds the main story length.
71
+ * - `empty-document` — the document has no root / no children.
18
72
  *
19
- * Supports the common case: `from` and `to` land in the same top-level
20
- * paragraph. Cross-paragraph ranges are currently a no-op and return the
21
- * document unchanged multi-block scopes ship in a follow-up slice.
73
+ * Each failure variant names the scopeId the caller had minted but
74
+ * does NOT mutate the document callers treat it as a hard failure
75
+ * and must not register the scopeId on the overlay.
22
76
  */
23
77
  export function insertScopeMarkers(
24
78
  document: CanonicalDocumentEnvelope,
@@ -30,57 +84,130 @@ export function insertScopeMarkers(
30
84
  ): InsertScopeMarkersResult {
31
85
  const { scopeId, from, to } = params;
32
86
  const root = document.content as DocumentRootNode;
33
- if (!root || root.type !== "doc") return { document, scopeId };
34
-
35
87
  const normalizedFrom = Math.min(from, to);
36
88
  const normalizedTo = Math.max(from, to);
37
89
 
90
+ if (!root || root.type !== "doc" || root.children.length === 0) {
91
+ return {
92
+ status: "empty-document",
93
+ scopeId,
94
+ from: normalizedFrom,
95
+ to: normalizedTo,
96
+ };
97
+ }
98
+
99
+ // First pass — locate which block each of `from` and `to` resolves to,
100
+ // so we can distinguish cross-paragraph / non-paragraph / out-of-bounds
101
+ // failures before attempting the plant.
38
102
  let cursor = 0;
39
- let inserted = false;
40
- const children = root.children.map((block, blockIndex) => {
41
- if (inserted) return block;
42
- if (block.type !== "paragraph") {
43
- cursor += 1;
44
- if (blockIndex < root.children.length - 1) cursor += 1;
45
- return block;
103
+ let fromBlockIndex = -1;
104
+ let fromBlockKind: string | null = null;
105
+ let toBlockIndex = -1;
106
+ let toBlockKind: string | null = null;
107
+ let storyLength = 0;
108
+
109
+ for (let i = 0; i < root.children.length; i += 1) {
110
+ const block = root.children[i]!;
111
+ const blockFrom = cursor;
112
+ let blockLength: number;
113
+ if (block.type === "paragraph") {
114
+ blockLength = block.children.reduce(
115
+ (total, child) => total + inlineLength(child as InlineNode),
116
+ 0,
117
+ );
118
+ } else {
119
+ blockLength = 1;
120
+ }
121
+ const blockTo = blockFrom + blockLength;
122
+ if (fromBlockIndex === -1 && normalizedFrom >= blockFrom && normalizedFrom <= blockTo) {
123
+ fromBlockIndex = i;
124
+ fromBlockKind = block.type;
46
125
  }
126
+ if (toBlockIndex === -1 && normalizedTo >= blockFrom && normalizedTo <= blockTo) {
127
+ toBlockIndex = i;
128
+ toBlockKind = block.type;
129
+ }
130
+ cursor = blockTo;
131
+ if (i < root.children.length - 1) cursor += 1;
132
+ storyLength = cursor;
133
+ }
47
134
 
48
- const paragraphFrom = cursor;
49
- const paragraphLength = block.children.reduce(
50
- (total, child) => total + inlineLength(child as InlineNode),
51
- 0,
52
- );
53
- const paragraphTo = paragraphFrom + paragraphLength;
54
- cursor = paragraphTo + 1;
135
+ if (normalizedFrom < 0 || normalizedTo > storyLength) {
136
+ return {
137
+ status: "range-out-of-bounds",
138
+ scopeId,
139
+ from: normalizedFrom,
140
+ to: normalizedTo,
141
+ storyLength,
142
+ };
143
+ }
144
+ if (fromBlockIndex === -1 || toBlockIndex === -1) {
145
+ return {
146
+ status: "range-out-of-bounds",
147
+ scopeId,
148
+ from: normalizedFrom,
149
+ to: normalizedTo,
150
+ storyLength,
151
+ };
152
+ }
153
+ if (fromBlockIndex !== toBlockIndex) {
154
+ return {
155
+ status: "cross-paragraph-range",
156
+ scopeId,
157
+ from: normalizedFrom,
158
+ to: normalizedTo,
159
+ fromBlockIndex,
160
+ toBlockIndex,
161
+ };
162
+ }
163
+ if (fromBlockKind !== "paragraph") {
164
+ return {
165
+ status: "non-paragraph-target",
166
+ scopeId,
167
+ from: normalizedFrom,
168
+ to: normalizedTo,
169
+ blockIndex: fromBlockIndex,
170
+ blockKind: fromBlockKind ?? "unknown",
171
+ };
172
+ }
55
173
 
56
- if (
57
- normalizedFrom < paragraphFrom ||
58
- normalizedTo > paragraphTo ||
59
- normalizedFrom > paragraphTo
60
- ) {
174
+ // Plant — we've validated the range lands inside a single paragraph.
175
+ cursor = 0;
176
+ const children = root.children.map((block, blockIndex) => {
177
+ if (blockIndex !== fromBlockIndex) {
178
+ if (block.type === "paragraph") {
179
+ const len = block.children.reduce(
180
+ (total, child) => total + inlineLength(child as InlineNode),
181
+ 0,
182
+ );
183
+ cursor += len;
184
+ } else {
185
+ cursor += 1;
186
+ }
187
+ if (blockIndex < root.children.length - 1) cursor += 1;
61
188
  return block;
62
189
  }
63
-
64
- inserted = true;
190
+ const paragraphFrom = cursor;
191
+ const paragraph = block as ParagraphNode;
65
192
  const startOffset = normalizedFrom - paragraphFrom;
66
193
  const endOffset = normalizedTo - paragraphFrom;
67
194
  const newChildren = injectMarkersIntoInlineList(
68
- block.children as InlineNode[],
195
+ paragraph.children as InlineNode[],
69
196
  scopeId,
70
197
  startOffset,
71
198
  endOffset,
72
199
  );
73
- return { ...block, children: newChildren } as ParagraphNode;
200
+ return { ...paragraph, children: newChildren };
74
201
  });
75
202
 
76
- if (!inserted) return { document, scopeId };
77
-
78
203
  return {
204
+ status: "planted",
79
205
  document: {
80
206
  ...document,
81
207
  content: { ...root, children },
82
208
  },
83
209
  scopeId,
210
+ plantedRange: { from: normalizedFrom, to: normalizedTo },
84
211
  };
85
212
  }
86
213
 
@@ -10,7 +10,7 @@
10
10
  * preserved in the canonical node's rawXml field for lossless round-trip export.
11
11
  */
12
12
 
13
- import type { ShapeContent } from "../../model/canonical-document.ts";
13
+ import type { BlockNode, ShapeContent } from "../../model/canonical-document.ts";
14
14
  import { parseFill } from "./parse-fill.ts";
15
15
  import {
16
16
  type XmlElementNode,
@@ -40,7 +40,7 @@ export interface ParsedWpsShape {
40
40
  * shape-textbox paragraphs). Same shape + semantics as
41
41
  * `ShapeContent.txbxBlocks` on the drawing-frame path.
42
42
  */
43
- txbxBlocks?: ReadonlyArray<{ type: string; [key: string]: unknown }>;
43
+ txbxBlocks?: ReadonlyArray<BlockNode>;
44
44
  /** DrawML geometry preset, e.g. "rect", "roundRect". */
45
45
  geometry?: string;
46
46
  /** Original drawing XML for lossless round-trip export. */
@@ -122,10 +122,20 @@ export function parseShapeXml(
122
122
  // content (CCEP "Copyright CCEP STRICTLY CONFIDENTIAL" footer band)
123
123
  // is reachable only via the `.text` summary string — L03 cascade +
124
124
  // L11 render can't walk runs/marks.
125
- let txbxBlocks: ReadonlyArray<{ type: string; [key: string]: unknown }> | undefined;
125
+ let txbxBlocks: ReadonlyArray<BlockNode> | undefined;
126
126
  if (txbxContentXml && blockParser) {
127
127
  try {
128
- txbxBlocks = blockParser(txbxContentXml);
128
+ // The `blockParser` callback is supplied by parse-main-document.ts
129
+ // as a thin wrapper over `parseBlockStreamFromXml`. That function
130
+ // returns `ParsedBlockNode[]` — structurally identical to canonical
131
+ // `BlockNode[]` at runtime for shape-textbox content (verified on
132
+ // CCEP SOW footer fixture 2026-04-24: paragraph + text + TextMark
133
+ // shapes land end-to-end with zero `ParsedBlockNode`-only fields
134
+ // surfaced). The cast is safe here because the runtime output IS
135
+ // canonical; a structural `as unknown as BlockNode[]` preserves
136
+ // type safety at every consumer site (L03 cascade, L11 render,
137
+ // validator walk).
138
+ txbxBlocks = blockParser(txbxContentXml) as unknown as ReadonlyArray<BlockNode>;
129
139
  } catch {
130
140
  txbxBlocks = undefined;
131
141
  }
@@ -214,6 +224,17 @@ function extractAllText(node: XmlElementNode): string {
214
224
  // txbxContentXml, optional recursive txbxBlocks).
215
225
  // ───────────────────────────────────────────────────────────────────────────
216
226
 
227
+ /**
228
+ * Callback signature for the txbx-content block parser supplied by
229
+ * parse-main-document.ts / parse-headers-footers.ts. The actual
230
+ * implementation wraps `parseBlockStreamFromXml` which returns
231
+ * `ParsedBlockNode[]`; its runtime output is canonical `BlockNode[]`
232
+ * for shape-textbox content (no `ParsedBlockNode`-only fields surface
233
+ * at the shape boundary — verified on CCEP SOW footer fixture
234
+ * 2026-04-24). The structural `unknown` return keeps the parse layer
235
+ * layer-pure; `parseShapeContent` + `parseShapeXml` cast to canonical
236
+ * `BlockNode[]` at the assembly seam.
237
+ */
217
238
  export type TxbxBlockParser = (xml: string) => ReadonlyArray<{ type: string; [key: string]: unknown }>;
218
239
 
219
240
  export function parseShapeContent(
@@ -240,10 +261,15 @@ export function parseShapeContent(
240
261
  const txbxContent = txbx ? findFirstDescendant(txbx, "txbxContent") : undefined;
241
262
  const txbxContentXml = txbxContent ? extractRawXml(txbxContent) : undefined;
242
263
 
243
- let txbxBlocks: ReadonlyArray<{ type: string; [key: string]: unknown }> | undefined;
264
+ let txbxBlocks: ReadonlyArray<BlockNode> | undefined;
244
265
  if (txbxContentXml && blockParser) {
245
266
  try {
246
- txbxBlocks = blockParser(txbxContentXml);
267
+ // See `TxbxBlockParser` doc above: runtime output is canonical
268
+ // `BlockNode[]` for shape-textbox content (verified on CCEP SOW
269
+ // footer fixture 2026-04-24). Cast at the assembly seam so
270
+ // downstream consumers (L03, L11, validator) get canonical types
271
+ // without local `as unknown` ceremony.
272
+ txbxBlocks = blockParser(txbxContentXml) as unknown as ReadonlyArray<BlockNode>;
247
273
  } catch {
248
274
  // Preserve-only fallback: keep txbxContentXml for serialization; leave
249
275
  // txbxBlocks undefined so consumers know recursion did not succeed.
@@ -1786,12 +1786,29 @@ export interface SmartArtPreviewNode {
1786
1786
  /**
1787
1787
  * Read-only rendering of a wps:wsp WordprocessingShape. Text content is
1788
1788
  * extracted for display. The original drawing XML is preserved in rawXml.
1789
+ *
1790
+ * When the shape is a text-box (`isTextBox: true`), the raw textbox XML
1791
+ * is preserved in `txbxContentXml` for lossless round-trip, and the
1792
+ * parsed block structure lands in `txbxBlocks` — canonical `BlockNode[]`
1793
+ * with styles already resolved (coord-02 §14 / coord-11 §22 closed L01
1794
+ * side 2026-04-24 in `7d87f1189`; L02 type-promoted 2026-04-24 once the
1795
+ * runtime contract was confirmed canonical).
1789
1796
  */
1790
1797
  export interface ShapeNode {
1791
1798
  type: "shape";
1792
1799
  text?: string;
1793
1800
  geometry?: string;
1794
1801
  isTextBox?: boolean;
1802
+ /** Raw `<w:txbxContent>` XML, preserved for serialization + round-trip. */
1803
+ txbxContentXml?: string;
1804
+ /**
1805
+ * Parsed canonical block-level structure from `<w:txbxContent>`,
1806
+ * populated when the parse path supplies a `blockParser` callback
1807
+ * (headers/footers via `src/io/ooxml/parse-headers-footers.ts`;
1808
+ * body via `src/io/ooxml/parse-main-document.ts`). Shape + semantics
1809
+ * identical to `ShapeContent.txbxBlocks` on the drawing-frame path.
1810
+ */
1811
+ txbxBlocks?: ReadonlyArray<BlockNode>;
1795
1812
  rawXml: string;
1796
1813
  }
1797
1814
 
@@ -1971,14 +1988,16 @@ export interface ShapeContent {
1971
1988
  * Parsed block-level structure from `w:txbxContent`, populated when a
1972
1989
  * `blockParser` callback is supplied during parse (CO4 F3.3).
1973
1990
  *
1974
- * Type is deliberately structural (`{ type: string; ... }`) rather than
1975
- * canonical `BlockNode[]` because the recursion stops at the parse layer
1976
- * before the style + numbering normalization pass that converts
1977
- * `ParsedBlockNode` canonical `BlockNode`. Consumers that need the fully
1978
- * normalized form run normalization on this subtree explicitly. Testing
1979
- * that `txbxBlocks.length > 0` proves the recursion executed.
1991
+ * Canonical `BlockNode[]` the parse path produces fully-normalized
1992
+ * blocks (styles resolved, marks attached, no `ParsedBlockNode`-only
1993
+ * fields at runtime). Verified on the CCEP SOW footer fixture 2026-04-24:
1994
+ * paragraph + text + `TextMark` shapes land end-to-end. Type promoted
1995
+ * 2026-04-24 from the earlier weakly-typed escape hatch once the L01
1996
+ * shape-textbox parse (commit `7d87f1189`) confirmed the runtime
1997
+ * contract — unblocks L03 cascade + L11 render walking `txbxBlocks`
1998
+ * without `as unknown as BlockNode[]` casts at the consumer site.
1980
1999
  */
1981
- txbxBlocks?: ReadonlyArray<{ type: string; [key: string]: unknown }>;
2000
+ txbxBlocks?: ReadonlyArray<BlockNode>;
1982
2001
  rawXml: string;
1983
2002
  }
1984
2003
 
@@ -2860,11 +2879,29 @@ function validateDocumentNode(
2860
2879
  return;
2861
2880
  case "chart_preview":
2862
2881
  case "smartart_preview":
2863
- case "shape":
2864
2882
  case "wordart":
2865
2883
  case "vml_shape":
2866
2884
  expectString(record.rawXml, `${path}.rawXml`, issues);
2867
2885
  return;
2886
+ case "shape":
2887
+ expectString(record.rawXml, `${path}.rawXml`, issues);
2888
+ if (record.txbxBlocks !== undefined) {
2889
+ if (!Array.isArray(record.txbxBlocks)) {
2890
+ issues.push({
2891
+ path: `${path}.txbxBlocks`,
2892
+ message: "shape.txbxBlocks must be an array when present.",
2893
+ });
2894
+ } else {
2895
+ // coord-02 §14 follow-up (2026-04-24): `ShapeNode.txbxBlocks`
2896
+ // is canonical `BlockNode[]`. Walk it with the same validator
2897
+ // used for top-level document content so run marks / paragraph
2898
+ // structure / nested shapes all enforce the normal rules.
2899
+ record.txbxBlocks.forEach((child, index) => {
2900
+ validateDocumentNode(child, `${path}.txbxBlocks[${index}]`, issues);
2901
+ });
2902
+ }
2903
+ }
2904
+ return;
2868
2905
  case "drawing_frame": {
2869
2906
  const anchor = asPlainObject(record.anchor, `${path}.anchor`, issues);
2870
2907
  const content = asPlainObject(record.content, `${path}.content`, issues);
@@ -57,6 +57,7 @@ import type {
57
57
  RestoreResult,
58
58
  ReviewWorkSnapshot,
59
59
  RuntimeContextAnalyticsQuery,
60
+ EditorSurfaceSnapshot,
60
61
  RuntimeContextAnalyticsSnapshot,
61
62
  RuntimeRenderSnapshot,
62
63
  ScopeTagTouch,
@@ -1561,17 +1562,91 @@ export function createDocumentRuntime(
1561
1562
  });
1562
1563
  recordPerfSample("snapshot.surface");
1563
1564
  incrementInvalidationCounter("runtime.snapshot.surfaceMisses");
1565
+ // Viewport-cull flicker fix — enrich `placeholder-culled` opaque
1566
+ // blocks with the block's known rendered height (twips) so the PM
1567
+ // placeholder emits a fixed-height `<div>` sized to match what the
1568
+ // realized block will occupy once it re-enters the viewport. Without
1569
+ // this, culled stubs render at `min-height: 20px` regardless of their
1570
+ // real visual size; scrolling into a culled region inflates every
1571
+ // block to its real height in succession and drags content below
1572
+ // the scroll pointer (the "paragraphs jump around pagination gaps"
1573
+ // flicker).
1574
+ //
1575
+ // The heights come from L04's page graph, which is already computed
1576
+ // on a FULLY-REALIZED surface inside `layout-engine-instance.ts` —
1577
+ // i.e. pagination is independent of the viewport cull, so every
1578
+ // blockId has an authoritative height regardless of whether its
1579
+ // surface block is real or a placeholder. `getBlockHeightsTwips()`
1580
+ // sums per-block fragments and caches per `graph.revision`.
1581
+ //
1582
+ // No-op on pre-pagination calls (engine not yet ready → empty map)
1583
+ // and on the inert facet.
1584
+ const enrichedSnapshot = enrichCulledPlaceholdersWithHeights(snapshot);
1564
1585
  cachedSurface = {
1565
1586
  revisionToken: state.revisionToken,
1566
1587
  activeStoryKey,
1567
1588
  viewportRangesKey,
1568
- snapshot,
1589
+ snapshot: enrichedSnapshot,
1569
1590
  };
1570
1591
  // Keep the scroll-path fingerprint in lockstep so a subsequent
1571
1592
  // `maybeRefreshSurfaceForViewport` sees the freshly-built snapshot
1572
1593
  // and short-circuits instead of paying a redundant projection.
1573
1594
  cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
1574
- return snapshot;
1595
+ return enrichedSnapshot;
1596
+ }
1597
+
1598
+ function enrichCulledPlaceholdersWithHeights(
1599
+ snapshot: EditorSurfaceSnapshot,
1600
+ ): EditorSurfaceSnapshot {
1601
+ let heights: ReadonlyMap<string, number>;
1602
+ try {
1603
+ heights = layoutFacet.getBlockHeightsTwips();
1604
+ } catch {
1605
+ return snapshot;
1606
+ }
1607
+ if (heights.size === 0) return snapshot;
1608
+ let changed = false;
1609
+ const enrichedBlocks = snapshot.blocks.map((block) => {
1610
+ if (
1611
+ block.kind !== "opaque_block" ||
1612
+ block.state !== "placeholder-culled"
1613
+ ) {
1614
+ return block;
1615
+ }
1616
+ // The culled placeholder's blockId is `placeholder-culled-<index>`;
1617
+ // the underlying ParagraphNode kept its own id which is NOT carried
1618
+ // on the placeholder. Instead, identify the real block by its
1619
+ // `from` offset — surface-projection guarantees `from` on the
1620
+ // placeholder mirrors the real block's `from`, and fragment records
1621
+ // on the page graph record runtime offsets.
1622
+ const realBlockIdFromOffset = resolveBlockIdFromRuntimeOffset(
1623
+ layoutFacet,
1624
+ block.from,
1625
+ );
1626
+ if (!realBlockIdFromOffset) return block;
1627
+ const heightTwips = heights.get(realBlockIdFromOffset);
1628
+ if (typeof heightTwips !== "number" || heightTwips <= 0) return block;
1629
+ changed = true;
1630
+ return { ...block, placeholderHeightTwips: heightTwips };
1631
+ });
1632
+ if (!changed) return snapshot;
1633
+ return { ...snapshot, blocks: enrichedBlocks };
1634
+ }
1635
+
1636
+ function resolveBlockIdFromRuntimeOffset(
1637
+ facet: WordReviewEditorLayoutFacet,
1638
+ runtimeOffset: number,
1639
+ ): string | null {
1640
+ // `getFragmentForOffset` exists on the facet; use it to recover the
1641
+ // real blockId for a given offset without having to walk all
1642
+ // fragments. Returns the fragment whose `[from, to)` contains the
1643
+ // offset, or null.
1644
+ try {
1645
+ const frag = facet.getFragmentForOffset?.(runtimeOffset);
1646
+ return frag?.blockId ?? null;
1647
+ } catch {
1648
+ return null;
1649
+ }
1575
1650
  }
1576
1651
 
1577
1652
  function getCachedFieldSnapshot(document: CanonicalDocumentEnvelope): FieldSnapshot {
@@ -65,6 +65,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
65
65
  invalidateMeasurementCache: () => undefined,
66
66
  getTableRenderPlan: () => null,
67
67
  getTableBodyYOffsetOnPage: () => null,
68
+ getBlockHeightsTwips: () => new Map(),
68
69
  getDirtyFieldFamilies: () => [],
69
70
  getFieldDirtinessReport: () => emptyReport,
70
71
  setVisibleBlockRange: () => undefined,
@@ -955,8 +955,34 @@
955
955
  * underlying layout algorithm is unchanged — persisted envelopes
956
956
  * remain shape-compatible. Bump is defensive so any consumer that
957
957
  * keyed on the facet contract refreshes the cache.
958
+ *
959
+ * 57 — viewport-cull flicker fix. The pre-v57 PM placeholder emitted
960
+ * for `placeholder-culled` opaque blocks rendered at
961
+ * `min-height: 20px` regardless of the real block's visual height
962
+ * (`src/ui-tailwind/editor-surface/pm-schema.ts`), because neither
963
+ * L03 surface-projection nor L11 PM state-build had access to
964
+ * layout-derived heights. Scrolling into a culled region inflated
965
+ * every block in turn, dragging content below the scroll pointer —
966
+ * the long-standing "paragraphs jump around pagination gaps"
967
+ * flicker.
968
+ *
969
+ * L04 now exposes `WordReviewEditorLayoutFacet.getBlockHeightsTwips():
970
+ * ReadonlyMap<string, number>` — one entry per blockId, value = sum
971
+ * of that block's fragments' `heightTwips`. Cached per
972
+ * `graph.revision`. `DocumentRuntime` reads the map after each
973
+ * surface-projection pass and enriches every
974
+ * `placeholder-culled` `opaque_block` with
975
+ * `placeholderHeightTwips`. The PM schema's paragraph node gained
976
+ * a `placeholderHeightTwips` attr; `toDOM` emits
977
+ * `height: ${twips/20}pt` instead of the `min-height: 20px`
978
+ * fallback when the attr is set.
979
+ *
980
+ * Pagination itself is untouched — this is purely a render-surface
981
+ * fix. Cache envelopes from v56 invalidate because the exposed
982
+ * facet surface grew one public method; any consumer relying on
983
+ * the prior interface shape re-derives its cache key under v57.
958
984
  */
959
- export const LAYOUT_ENGINE_VERSION = 56 as const;
985
+ export const LAYOUT_ENGINE_VERSION = 57 as const;
960
986
 
961
987
  /**
962
988
  * Serialization schema version for the LayCache payload (the cache envelope