@beyondwork/docx-react-component 1.0.78 → 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.78",
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
 
@@ -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
@@ -569,6 +569,19 @@ export interface WordReviewEditorLayoutFacet {
569
569
  */
570
570
  getTableBodyYOffsetOnPage(blockId: string, pageIndex: number): number | null;
571
571
 
572
+ /**
573
+ * Viewport-cull height resolver — returns total rendered height (twips)
574
+ * for every block in the current page graph, computed as the sum of each
575
+ * fragment's `heightTwips` grouped by `blockId`. Consumers (the render
576
+ * surface builder in particular) use this to size `placeholder-culled`
577
+ * opaque stubs so the scrollable canvas does not change height when a
578
+ * block realizes during scroll.
579
+ *
580
+ * Returns an empty map on the inert facet or before the first successful
581
+ * pagination pass. Cached per `graph.revision`.
582
+ */
583
+ getBlockHeightsTwips(): ReadonlyMap<string, number>;
584
+
572
585
  // Fields ---------------------------------------------------------------
573
586
  getDirtyFieldFamilies(): readonly string[];
574
587
  getFieldDirtinessReport(): PublicFieldDirtinessReport;
@@ -707,6 +720,13 @@ export function createLayoutFacet(
707
720
  revision: number;
708
721
  blocks: readonly PublicRegionBlock[] | null;
709
722
  } = { revision: -1, blocks: null };
723
+ // Viewport-cull flicker fix — per-revision cache for getBlockHeightsTwips.
724
+ // One entry per blockId; value is the sum of that block's fragments'
725
+ // `heightTwips`. Busts on `graph.revision` change.
726
+ let blockHeightsCache: {
727
+ revision: number;
728
+ map: ReadonlyMap<string, number> | null;
729
+ } = { revision: -1, map: null };
710
730
 
711
731
  function resolveCanonicalDocument(): CanonicalDocumentEnvelope {
712
732
  if (input.canonicalDocument) {
@@ -1234,6 +1254,21 @@ export function createLayoutFacet(
1234
1254
  return null;
1235
1255
  },
1236
1256
 
1257
+ getBlockHeightsTwips() {
1258
+ const graph = currentGraph();
1259
+ if (blockHeightsCache.revision === graph.revision && blockHeightsCache.map) {
1260
+ return blockHeightsCache.map;
1261
+ }
1262
+ const map = new Map<string, number>();
1263
+ for (const frag of graph.fragments) {
1264
+ const prev = map.get(frag.blockId) ?? 0;
1265
+ map.set(frag.blockId, prev + frag.heightTwips);
1266
+ }
1267
+ const frozen: ReadonlyMap<string, number> = map;
1268
+ blockHeightsCache = { revision: graph.revision, map: frozen };
1269
+ return frozen;
1270
+ },
1271
+
1237
1272
  getDirtyFieldFamilies() {
1238
1273
  return engine.getDirtyFieldFamilies();
1239
1274
  },
@@ -791,19 +791,72 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
791
791
  return { scopeId, anchor: params.anchor };
792
792
  }
793
793
 
794
- const { document: nextDocument } = insertScopeMarkers(
795
- deps.getDocument(),
796
- { scopeId, from: anchor.from, to: anchor.to },
797
- );
794
+ const plantResult = insertScopeMarkers(deps.getDocument(), {
795
+ scopeId,
796
+ from: anchor.from,
797
+ to: anchor.to,
798
+ });
798
799
 
799
- if (nextDocument !== deps.getDocument()) {
800
- deps.dispatch({
801
- type: "document.replace",
802
- document: nextDocument,
803
- origin: { source: "api", at: clock() },
804
- });
800
+ // Plant failed pre-2026-04-24 this returned silently with a dead
801
+ // scopeId; callers later saw `scope-not-resolvable`. Now surface
802
+ // the typed failure on the AddScopeResult so consumers can detect
803
+ // the plant-failed path without a round-trip through
804
+ // resolveReference.
805
+ if (plantResult.status !== "planted") {
806
+ const callerAssoc: { readonly start: -1 | 1; readonly end: -1 | 1 } =
807
+ params.anchor.kind === "range"
808
+ ? params.anchor.assoc
809
+ : { start: -1, end: 1 };
810
+ // Return the caller's input range as an informational range
811
+ // anchor. The authoritative failure signal is `scopeId: ""` +
812
+ // `plantStatus.planted === false`. The detached-anchor shape has
813
+ // a fixed reason enum (`deleted|invalidatedByStructureChange|
814
+ // importAmbiguity`) that doesn't cover plant-refused, so we keep
815
+ // the range kind and let callers discriminate via plantStatus.
816
+ return {
817
+ scopeId: "",
818
+ anchor: {
819
+ kind: "range",
820
+ from: anchor.from,
821
+ to: anchor.to,
822
+ assoc: callerAssoc,
823
+ },
824
+ plantStatus: {
825
+ planted: false,
826
+ reason: plantResult.status,
827
+ ...(plantResult.status === "cross-paragraph-range"
828
+ ? {
829
+ fromBlockIndex: plantResult.fromBlockIndex,
830
+ toBlockIndex: plantResult.toBlockIndex,
831
+ }
832
+ : {}),
833
+ ...(plantResult.status === "non-paragraph-target"
834
+ ? {
835
+ blockIndex: plantResult.blockIndex,
836
+ blockKind: plantResult.blockKind,
837
+ }
838
+ : {}),
839
+ ...(plantResult.status === "range-out-of-bounds"
840
+ ? { storyLength: plantResult.storyLength }
841
+ : {}),
842
+ requestedFrom: plantResult.from,
843
+ requestedTo: plantResult.to,
844
+ },
845
+ };
846
+ // Intentionally NOT dispatching document.replace or workflow.set-overlay —
847
+ // a failed plant must not leave a half-registered scope. Prevents the
848
+ // pre-fix "overlay carries scopeId but canonical tree has no markers"
849
+ // state that produced `scope-not-resolvable` on every follow-up call.
805
850
  }
806
851
 
852
+ const nextDocument = plantResult.document;
853
+
854
+ deps.dispatch({
855
+ type: "document.replace",
856
+ document: nextDocument,
857
+ origin: { source: "api", at: clock() },
858
+ });
859
+
807
860
  // Coord-06 §13d — preserve the caller's assoc on the public anchor.
808
861
  // resolveScope re-derives the range from the inserted markers but emits
809
862
  // a hardcoded { start: -1, end: 1 }; without this override the caller's
@@ -276,10 +276,19 @@ export type CreateScopeFromAnchorResult =
276
276
  readonly reason:
277
277
  | "from-negative"
278
278
  | "to-less-than-from"
279
- | "range-exceeds-story-length";
279
+ | "range-exceeds-story-length"
280
+ | "cross-paragraph-range"
281
+ | "non-paragraph-target"
282
+ | "empty-document";
280
283
  readonly from: number;
281
284
  readonly to: number;
282
285
  readonly storyLength: number;
286
+ /** Cross-paragraph only — the two block indices the range straddled. */
287
+ readonly fromBlockIndex?: number;
288
+ readonly toBlockIndex?: number;
289
+ /** Non-paragraph target only — the offending block's index and kind. */
290
+ readonly blockIndex?: number;
291
+ readonly blockKind?: string;
283
292
  /**
284
293
  * Single-sentence, agent-actionable explanation. Tells the caller
285
294
  * what the failure was and the concrete next step — no guesswork
@@ -291,7 +300,9 @@ export type CreateScopeFromAnchorResult =
291
300
  * Short machine-routable next-step hint for thin consumers that
292
301
  * don't want to pattern-match on `reason`. Examples:
293
302
  * "clamp-from-to-zero", "swap-from-and-to",
294
- * "clamp-to-to-storyLength-or-pick-a-different-range".
303
+ * "clamp-to-to-storyLength-or-pick-a-different-range",
304
+ * "narrow-to-single-paragraph",
305
+ * "pick-a-paragraph-target".
295
306
  */
296
307
  readonly nextStep: string;
297
308
  };
@@ -420,5 +431,82 @@ export function createScopeFromAnchor(
420
431
  ...(scopeMetadataFields.length > 0 ? { scopeMetadataFields } : {}),
421
432
  });
422
433
 
434
+ // Pre-2026-04-24 the coordinator silently returned a minted scopeId
435
+ // even when insertScopeMarkers refused to plant (cross-paragraph
436
+ // range, non-paragraph target, out-of-bounds after the story-length
437
+ // check passed). Now the coordinator surfaces `plantStatus.planted:
438
+ // false`; translate each reason into the same `range-invalid` shape
439
+ // used by the bounds checks above so the caller gets one uniform
440
+ // discriminator to branch on.
441
+ if (result.plantStatus && result.plantStatus.planted === false) {
442
+ const ps = result.plantStatus;
443
+ if (ps.reason === "cross-paragraph-range") {
444
+ return {
445
+ status: "range-invalid",
446
+ reason: "cross-paragraph-range",
447
+ from,
448
+ to,
449
+ storyLength,
450
+ fromBlockIndex: ps.fromBlockIndex ?? -1,
451
+ toBlockIndex: ps.toBlockIndex ?? -1,
452
+ message:
453
+ `createScopeFromAnchor refused: range [${from}, ${to}] straddles ` +
454
+ `paragraphs ${ps.fromBlockIndex} and ${ps.toBlockIndex}. Marker-backed ` +
455
+ `scopes only plant inside a single paragraph today. Narrow the range to ` +
456
+ `land inside one paragraph, or create two separate scopes.`,
457
+ nextStep: "narrow-to-single-paragraph",
458
+ };
459
+ }
460
+ if (ps.reason === "non-paragraph-target") {
461
+ return {
462
+ status: "range-invalid",
463
+ reason: "non-paragraph-target",
464
+ from,
465
+ to,
466
+ storyLength,
467
+ blockIndex: ps.blockIndex ?? -1,
468
+ blockKind: ps.blockKind ?? "unknown",
469
+ message:
470
+ `createScopeFromAnchor refused: range [${from}, ${to}] targets a ` +
471
+ `${ps.blockKind ?? "non-paragraph"} block (index ${ps.blockIndex}). ` +
472
+ `Marker scopes only plant inside paragraphs today. Pick a paragraph ` +
473
+ `target, or use runtime.workflow.createScope({blockId}) for ` +
474
+ `whole-block scopes on the containing structure.`,
475
+ nextStep: "pick-a-paragraph-target",
476
+ };
477
+ }
478
+ if (ps.reason === "range-out-of-bounds") {
479
+ // Shouldn't happen — storyLength was checked above — but surface
480
+ // it as a first-class failure in case the underlying length math
481
+ // drifts from our bounds check.
482
+ return {
483
+ status: "range-invalid",
484
+ reason: "range-exceeds-story-length",
485
+ from,
486
+ to,
487
+ storyLength: ps.storyLength ?? storyLength,
488
+ message:
489
+ `createScopeFromAnchor refused: coordinator reports range [${from}, ${to}] ` +
490
+ `is out of bounds (storyLength=${ps.storyLength}). This is usually a ` +
491
+ `stale-offset bug (KI-P9) — re-derive positions from the current ` +
492
+ `document and retry.`,
493
+ nextStep: "clamp-to-to-storyLength-or-pick-a-different-range",
494
+ };
495
+ }
496
+ // empty-document — target has no canonical blocks.
497
+ return {
498
+ status: "range-invalid",
499
+ reason: "empty-document",
500
+ from,
501
+ to,
502
+ storyLength,
503
+ message:
504
+ `createScopeFromAnchor refused: the target document has no blocks; ` +
505
+ `cannot plant scope markers. Open or initialize a document before ` +
506
+ `creating sub-block scopes.`,
507
+ nextStep: "initialize-document-before-creating-scopes",
508
+ };
509
+ }
510
+
423
511
  return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
424
512
  }
@@ -199,6 +199,17 @@ export const editorSchema = new Schema({
199
199
  pageBreakBefore: { default: null },
200
200
  hiddenTextOnly: { default: null },
201
201
  placeholderCulled: { default: null },
202
+ /**
203
+ * Rendered height (in twips) of the block that this placeholder
204
+ * stands in for, supplied by `DocumentRuntime` from L04's page
205
+ * graph. When present on a `placeholderCulled` paragraph, `toDOM`
206
+ * emits a fixed-height `<div>` (`${twips/20}pt`) instead of the
207
+ * `min-height: 20px` fallback, eliminating the scroll-path
208
+ * "paragraphs jump around pagination gaps" flicker that occurred
209
+ * when blocks realized at real heights larger than one line.
210
+ * Null / undefined preserves the pre-existing 20 px minimum.
211
+ */
212
+ placeholderHeightTwips: { default: null },
202
213
  blockId: { default: null },
203
214
  /**
204
215
  * `<w:framePr>` projection from `SurfaceBlockFragment.frameProperties`
@@ -214,6 +225,11 @@ export const editorSchema = new Schema({
214
225
  toDOM(node) {
215
226
  // Viewport-culled placeholder paragraph — cheap size-preserving leaf.
216
227
  if (node.attrs.placeholderCulled) {
228
+ const heightTwips = node.attrs.placeholderHeightTwips as number | null;
229
+ const heightStyle =
230
+ typeof heightTwips === "number" && heightTwips > 0
231
+ ? `height: ${heightTwips / 20}pt`
232
+ : "min-height: 20px";
217
233
  return [
218
234
  "div",
219
235
  {
@@ -221,7 +237,10 @@ export const editorSchema = new Schema({
221
237
  "data-placeholder-culled": "true",
222
238
  "data-placeholder-size": String(node.nodeSize),
223
239
  "data-placeholder-block-id": node.attrs.blockId ?? "",
224
- style: "min-height: 20px; contain: strict;",
240
+ ...(typeof heightTwips === "number" && heightTwips > 0
241
+ ? { "data-placeholder-height-twips": String(heightTwips) }
242
+ : {}),
243
+ style: `${heightStyle}; contain: strict;`,
225
244
  "aria-hidden": "true",
226
245
  },
227
246
  0,
@@ -867,10 +867,25 @@ function buildOpaqueBlock(
867
867
  const placeholderSize = block.placeholderSize ?? null;
868
868
  if (placeholderSize !== null) {
869
869
  const targetSize = placeholderSize as number;
870
+ // Flicker fix — when DocumentRuntime has enriched the placeholder with
871
+ // the block's known rendered height (from L04's page graph), thread it
872
+ // onto the paragraph node so `pm-schema.ts::toDOM` emits a fixed
873
+ // `height` style matching the real block. Without this, the placeholder
874
+ // renders at `min-height: 20px` and inflates to its real height when
875
+ // the block realizes on scroll, dragging content below the scroll
876
+ // pointer ("paragraphs jump around pagination gaps").
877
+ const placeholderHeightTwips = block.placeholderHeightTwips ?? null;
878
+ const placeholderAttrs: Record<string, unknown> = {
879
+ blockId: block.blockId,
880
+ placeholderCulled: true,
881
+ };
882
+ if (placeholderHeightTwips !== null) {
883
+ placeholderAttrs.placeholderHeightTwips = placeholderHeightTwips;
884
+ }
870
885
  if (targetSize <= 2) {
871
886
  // Edge case: bare empty paragraph claims exactly 2 positions.
872
887
  return editorSchema.nodes.paragraph.create(
873
- { blockId: block.blockId, placeholderCulled: true },
888
+ placeholderAttrs,
874
889
  Fragment.empty,
875
890
  );
876
891
  }
@@ -878,7 +893,7 @@ function buildOpaqueBlock(
878
893
  // total PM positions = 1 (open) + (targetSize - 2) (text) + 1 (close) = targetSize.
879
894
  const filler = "\u200b".repeat(targetSize - 2);
880
895
  return editorSchema.nodes.paragraph.create(
881
- { blockId: block.blockId, placeholderCulled: true },
896
+ placeholderAttrs,
882
897
  editorSchema.text(filler),
883
898
  );
884
899
  }