@beyondwork/docx-react-component 1.0.72 → 1.0.74

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/api/anchor-conversion.ts +2 -2
  3. package/src/api/public-types.ts +70 -6
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/ai/policy.ts +31 -0
  6. package/src/api/v3/ui/_types.ts +21 -0
  7. package/src/api/v3/ui/chrome-preset-model.ts +6 -0
  8. package/src/api/v3/ui/overlays.ts +276 -2
  9. package/src/api/v3/ui/scope.ts +113 -1
  10. package/src/api/v3/ui/viewport.ts +1 -1
  11. package/src/compare/diff-engine.ts +1 -2
  12. package/src/core/commands/index.ts +14 -15
  13. package/src/core/selection/anchor-conversion.ts +2 -2
  14. package/src/core/selection/mapping.ts +10 -8
  15. package/src/core/selection/review-anchors.ts +3 -3
  16. package/src/core/state/editor-state.ts +49 -6
  17. package/src/io/export/export-session.ts +53 -0
  18. package/src/io/export/serialize-comments.ts +4 -4
  19. package/src/io/export/serialize-footnotes.ts +6 -0
  20. package/src/io/export/serialize-headers-footers.ts +6 -0
  21. package/src/io/export/serialize-main-document.ts +7 -0
  22. package/src/io/export/serialize-paragraph-formatting.ts +1 -1
  23. package/src/io/export/serialize-runtime-revisions.ts +10 -10
  24. package/src/io/export/split-review-boundaries.ts +4 -4
  25. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
  26. package/src/io/normalize/normalize-text.ts +38 -2
  27. package/src/io/ooxml/parse-comments.ts +2 -2
  28. package/src/io/ooxml/parse-headers-footers.ts +31 -0
  29. package/src/io/ooxml/parse-main-document.ts +127 -2
  30. package/src/io/ooxml/parse-paragraph-formatting.ts +1 -1
  31. package/src/model/anchor.ts +9 -1
  32. package/src/model/canonical-document.ts +76 -3
  33. package/src/preservation/store.ts +24 -0
  34. package/src/review/store/comment-anchors.ts +1 -1
  35. package/src/review/store/comment-remapping.ts +1 -1
  36. package/src/review/store/revision-actions.ts +4 -4
  37. package/src/review/store/revision-types.ts +1 -1
  38. package/src/review/store/scope-tag-diff.ts +1 -1
  39. package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
  40. package/src/runtime/document-runtime.ts +205 -37
  41. package/src/runtime/formatting/formatting-context.ts +1 -1
  42. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  43. package/src/runtime/layout/layout-engine-version.ts +30 -1
  44. package/src/runtime/layout/paginated-layout-engine.ts +47 -0
  45. package/src/runtime/layout/public-facet.ts +27 -0
  46. package/src/runtime/scopes/action-validation.ts +30 -4
  47. package/src/runtime/scopes/evidence.ts +1 -1
  48. package/src/runtime/scopes/replacement/apply.ts +1 -0
  49. package/src/runtime/scopes/review-bundle.ts +1 -1
  50. package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
  51. package/src/runtime/scopes/scope-range.ts +1 -1
  52. package/src/runtime/scopes/semantic-scope-types.ts +19 -0
  53. package/src/runtime/selection/post-edit-validator.ts +4 -4
  54. package/src/runtime/surface-projection.ts +94 -4
  55. package/src/session/import/loader-types.ts +18 -0
  56. package/src/session/import/loader.ts +2 -0
  57. package/src/session/import/review-import.ts +12 -12
  58. package/src/session/import/workflow-scope-import.ts +9 -8
  59. package/src/shell/session-bootstrap.ts +4 -0
  60. package/src/ui-tailwind/editor-surface/pm-schema.ts +32 -0
  61. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
  62. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
  63. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +77 -0
  64. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
  65. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +49 -32
  66. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +5 -1
  67. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +5 -1
  68. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +71 -7
  69. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
  70. package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
  71. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +8 -8
  72. package/src/ui-tailwind/theme/editor-theme.css +15 -16
  73. package/src/ui-tailwind/tw-review-workspace.tsx +34 -49
  74. package/src/validation/compatibility-engine.ts +1 -1
  75. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
  76. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
@@ -171,7 +171,12 @@ import {
171
171
  seedMarkerBackedScopeIds,
172
172
  type OverlayStore,
173
173
  } from "./workflow/overlay-store.ts";
174
- import type { RuntimeOperationPlan, ScopeBundle } from "./scopes/semantic-scope-types.ts";
174
+ import type {
175
+ RuntimeOperationPlan,
176
+ ScopeBundle,
177
+ SemanticScope,
178
+ SemanticScopeKind,
179
+ } from "./scopes/semantic-scope-types.ts";
175
180
  import { createScopeCompilerService } from "./scopes/compiler-service.ts";
176
181
  import {
177
182
  createWorkflowCoordinator,
@@ -527,6 +532,42 @@ export interface DocumentRuntime {
527
532
  * handle (e.g. `getScope`).
528
533
  */
529
534
  compileScopeBundleById(scopeId: string, nowUtc: string): ScopeBundle | null;
535
+ /**
536
+ * KI-008 (2026-04-24) — batch enumeration primitive for L10
537
+ * `ui.scope.list(filter?)`. Returns the compiled `SemanticScope[]`
538
+ * through the existing `createScopeCompilerService` facade, applies
539
+ * the optional kind filter + limit over the result, and hands back a
540
+ * fresh array (callers may sort / splice freely without leaking
541
+ * into internal compiler state). L10 `src/api/v3/ui/**` can't import
542
+ * `src/runtime/scopes/**` per its purity guard; this handle method
543
+ * is the narrow seam through which `ui.scope.list` reaches
544
+ * enumeration. `ai.listScopes` delegates through the same primitive
545
+ * so both surfaces project the same identity set.
546
+ */
547
+ compileScopeList(filter?: {
548
+ readonly kind?: SemanticScopeKind;
549
+ readonly limit?: number;
550
+ }): readonly SemanticScope[];
551
+ /**
552
+ * KI-008 close (2026-04-24) — scopeId-keyed scope-card projection
553
+ * for L10 `ui.scope.card(scopeId)`. Delegates to
554
+ * `runtime.workflow.getAllScopeCardModels()` + filters to the
555
+ * matching scope. Returns `null` when the id doesn't project a
556
+ * card (not on overlay / no matching segment).
557
+ */
558
+ compileScopeCardById(
559
+ scopeId: string,
560
+ ): import("../api/public-types.ts").ScopeCardModel | null;
561
+ /**
562
+ * KI-008 close (2026-04-24) — scope-rail snapshot for L10
563
+ * `ui.scope.rail(options?)`. Wraps `getRailSegments(pageIndex)`
564
+ * (narrowed) or `getAllRailSegments()` (span) into a
565
+ * `ScopeRailSnapshot` envelope so the UI surface has a stable
566
+ * shape regardless of page filtering.
567
+ */
568
+ compileScopeRailSnapshot(options?: {
569
+ readonly pageIndex?: number;
570
+ }): import("../api/public-types.ts").ScopeRailSnapshot;
530
571
  /**
531
572
  * Debug projector support — readonly view of scope ids the runtime
532
573
  * considers marker-backed (present in both `collectScopeLocations(doc)`
@@ -837,6 +878,20 @@ export interface DocumentRuntime {
837
878
  * Safe to call at any frequency; identical ranges are a no-op.
838
879
  */
839
880
  setVisibleBlockRange(range: { start: number; end: number }): void;
881
+ /**
882
+ * Multi-interval variant of `setVisibleBlockRange`. The surface projection
883
+ * treats a block as real if its index falls in ANY of the supplied
884
+ * intervals. Use this when the editor's visible window and the caret's
885
+ * page are disjoint on a long document — emit the viewport + the caret's
886
+ * page as two ranges instead of one merged range so the gap between them
887
+ * stays virtualized (placeholder-culled) instead of being realized.
888
+ *
889
+ * The array is stored by reference; callers are responsible for passing
890
+ * non-overlapping intervals sorted ascending by `start`. An empty array
891
+ * is equivalent to passing a single full-document range (legacy
892
+ * "all-real" behavior). Identical ranges are a no-op.
893
+ */
894
+ setVisibleBlockRanges(ranges: readonly { start: number; end: number }[]): void;
840
895
  /**
841
896
  * Triggers a surface-only refresh that applies the latest visible block range
842
897
  * without running the full commit pipeline. Used by scroll handlers.
@@ -959,6 +1014,46 @@ interface HistoryState {
959
1014
  * stale `items[].anchor` never reaches a consumer. The direct
960
1015
  * `runtime.getReviewWorkSnapshot()` API does not use this cache.
961
1016
  */
1017
+ /**
1018
+ * Normalize incoming viewport intervals: drop degenerate ranges (end <= start),
1019
+ * sort by `start`, merge overlapping / touching intervals. Returns a frozen
1020
+ * array so callers can't mutate the cached state.
1021
+ *
1022
+ * The sort + merge is important: `refreshSurfaceOnly`'s idempotency key is
1023
+ * a string serialization, so `[{0,5},{100,105}]` and `[{100,105},{0,5}]`
1024
+ * must compare equal; likewise `[{0,5},{4,10}]` and `[{0,10}]` represent
1025
+ * identical realization sets and should key identically.
1026
+ */
1027
+ function normalizeViewportRanges(
1028
+ ranges: readonly { start: number; end: number }[],
1029
+ ): readonly { start: number; end: number }[] {
1030
+ const cleaned = ranges
1031
+ .filter((r) => Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start)
1032
+ .map((r) => ({ start: r.start, end: r.end }));
1033
+ if (cleaned.length === 0) return Object.freeze([]);
1034
+ cleaned.sort((a, b) => a.start - b.start);
1035
+ const merged: { start: number; end: number }[] = [cleaned[0]!];
1036
+ for (let i = 1; i < cleaned.length; i += 1) {
1037
+ const next = cleaned[i]!;
1038
+ const last = merged[merged.length - 1]!;
1039
+ if (next.start <= last.end) {
1040
+ if (next.end > last.end) last.end = next.end;
1041
+ } else {
1042
+ merged.push(next);
1043
+ }
1044
+ }
1045
+ return Object.freeze(merged.map((r) => Object.freeze(r)));
1046
+ }
1047
+
1048
+ /** Stable serialization of normalized viewport ranges for fingerprinting. */
1049
+ function serializeViewportRanges(
1050
+ ranges: readonly { start: number; end: number }[] | null,
1051
+ ): string {
1052
+ if (ranges === null) return "null";
1053
+ if (ranges.length === 0) return "empty";
1054
+ return ranges.map((r) => `${r.start}:${r.end}`).join("|");
1055
+ }
1056
+
962
1057
  function computeWorkflowMarkupStructuralHash(
963
1058
  wfMarkup: WorkflowMarkupSnapshot,
964
1059
  ): string {
@@ -1215,19 +1310,14 @@ export function createDocumentRuntime(
1215
1310
  // so the facet forwards to the runtime method. Forward-reference pattern matches
1216
1311
  // `renderKernelRef` — see that example. See spec-review notes on commit 0b03bfa7.
1217
1312
  setVisibleBlockRange: (range) => {
1218
- if (
1219
- viewportBlockRange &&
1220
- viewportBlockRange.start === range.start &&
1221
- viewportBlockRange.end === range.end
1222
- ) {
1223
- return;
1224
- }
1225
- viewportBlockRange = { start: range.start, end: range.end };
1226
- perfCounters.increment("runtime.viewport.updates");
1313
+ applyViewportRanges([range]);
1314
+ },
1315
+ setVisibleBlockRanges: (ranges) => {
1316
+ applyViewportRanges(ranges);
1227
1317
  },
1228
1318
  requestViewportRefresh: () => {
1229
1319
  perfCounters.increment("runtime.viewport.refreshes");
1230
- refreshSurfaceOnly();
1320
+ maybeRefreshSurfaceForViewport();
1231
1321
  },
1232
1322
  });
1233
1323
  // D1 — wire the layout-guard warning emitter so `return []` guard sites
@@ -1255,8 +1345,28 @@ export function createDocumentRuntime(
1255
1345
  renderKernel: () => renderKernelRef,
1256
1346
  getCanonicalDocument: () => state.document,
1257
1347
  });
1258
- // L7 Phase 2 — viewport block range for surface culling.
1259
- let viewportBlockRange: { start: number; end: number } | null = null;
1348
+ // L7 Phase 2 — viewport block range(s) for surface culling. Multi-interval
1349
+ // canonical state: a block is real if its index falls in ANY interval.
1350
+ // Stored as a frozen, sorted, non-overlapping list so `applyViewportRanges`
1351
+ // can identity-compare for fast no-ops; `null` = "all blocks real" (legacy).
1352
+ let viewportBlockRanges: readonly { start: number; end: number }[] | null = null;
1353
+ /** Serialized fingerprint of the active ranges — used as the idempotency key for `refreshSurfaceOnly`. */
1354
+ let viewportRangesKey: string = serializeViewportRanges(null);
1355
+
1356
+ function applyViewportRanges(
1357
+ incoming: readonly { start: number; end: number }[] | null,
1358
+ ): void {
1359
+ // Normalize: empty array / null both mean "no culling".
1360
+ const normalized =
1361
+ incoming == null || incoming.length === 0
1362
+ ? null
1363
+ : normalizeViewportRanges(incoming);
1364
+ const nextKey = serializeViewportRanges(normalized);
1365
+ if (nextKey === viewportRangesKey) return;
1366
+ viewportBlockRanges = normalized;
1367
+ viewportRangesKey = nextKey;
1368
+ perfCounters.increment("runtime.viewport.updates");
1369
+ }
1260
1370
 
1261
1371
  let cachedSurface:
1262
1372
  | {
@@ -1429,7 +1539,7 @@ export function createDocumentRuntime(
1429
1539
  }
1430
1540
 
1431
1541
  const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory, {
1432
- viewportBlockRange,
1542
+ viewportBlockRanges,
1433
1543
  ...(effectiveMarkupModeProvider
1434
1544
  ? { getEffectiveMarkupMode: effectiveMarkupModeProvider }
1435
1545
  : {}),
@@ -2241,10 +2351,28 @@ export function createDocumentRuntime(
2241
2351
  }
2242
2352
 
2243
2353
  /**
2244
- * L7 Phase 2 surface-only refresh triggered by viewport scroll.
2245
- * Rebuilds only the surface facet (with the current viewportBlockRange) and
2246
- * splices it into cachedRenderSnapshot. Does NOT rebuild layout, comments,
2247
- * trackedChanges, or compatibility. Does NOT increment "refresh.all".
2354
+ * Fingerprint over the inputs that the viewport-refresh fast-path knows
2355
+ * about. Used to short-circuit {@link maybeRefreshSurfaceForViewport}
2356
+ * when nothing that affects the scroll-driven projection has changed
2357
+ * since the last build. Scroll handlers fire effectively-identical
2358
+ * requests on every tick (IO coalescing, overscan nudges that don't
2359
+ * flip any page's visible state); without this gate every tick would
2360
+ * rebuild the surface + notify listeners + force PM `view.updateState()`.
2361
+ *
2362
+ * NOT suitable for callers that invalidate external projection inputs
2363
+ * (markup-mode provider, etc.) because those inputs are read at
2364
+ * projection time and are not captured in the fingerprint. Those callers
2365
+ * use {@link refreshSurfaceOnly} directly (unconditional rebuild).
2366
+ */
2367
+ let cachedSurfaceFingerprint: string | null = null;
2368
+
2369
+ /**
2370
+ * L7 Phase 2 — surface-only refresh. Rebuilds the surface facet (with
2371
+ * the current viewportBlockRanges) and splices it into
2372
+ * cachedRenderSnapshot. Does NOT rebuild layout, comments, trackedChanges,
2373
+ * or compatibility. Does NOT increment "refresh.all". Callers that
2374
+ * know an input outside the fingerprint changed (markup-mode provider,
2375
+ * etc.) use this directly.
2248
2376
  */
2249
2377
  function refreshSurfaceOnly(): void {
2250
2378
  const newSurface = createEditorSurfaceSnapshot(
@@ -2252,12 +2380,13 @@ export function createDocumentRuntime(
2252
2380
  state.selection,
2253
2381
  activeStory,
2254
2382
  {
2255
- viewportBlockRange,
2383
+ viewportBlockRanges,
2256
2384
  ...(effectiveMarkupModeProvider
2257
2385
  ? { getEffectiveMarkupMode: effectiveMarkupModeProvider }
2258
2386
  : {}),
2259
2387
  },
2260
2388
  );
2389
+ cachedSurfaceFingerprint = `${state.revisionToken}|${storyTargetKey(activeStory)}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
2261
2390
  // Refresh the cache with the just-built snapshot so a subsequent
2262
2391
  // refreshRenderSnapshot (same revisionToken + activeStoryKey) hits cache.
2263
2392
  // Without this repopulate, the next non-mutation refresh wastes a second
@@ -2277,6 +2406,25 @@ export function createDocumentRuntime(
2277
2406
  }
2278
2407
  }
2279
2408
 
2409
+ /**
2410
+ * Viewport-refresh fast path — short-circuits when the tracked
2411
+ * fingerprint matches, otherwise delegates to {@link refreshSurfaceOnly}.
2412
+ * Used by `requestViewportRefresh` so steady-state scroll doesn't pay
2413
+ * a full projection + listener fan-out on every tick.
2414
+ *
2415
+ * Safe because every external input that can change the projection
2416
+ * output without ticking revisionToken / selection / viewportRanges /
2417
+ * activeStory is wired to call `refreshSurfaceOnly` directly (see
2418
+ * `setEffectiveMarkupModeProvider`, `invalidateForMarkupModeChange`).
2419
+ */
2420
+ function maybeRefreshSurfaceForViewport(): void {
2421
+ const fingerprint = `${state.revisionToken}|${storyTargetKey(activeStory)}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
2422
+ if (cachedSurfaceFingerprint === fingerprint && cachedSurface) {
2423
+ return;
2424
+ }
2425
+ refreshSurfaceOnly();
2426
+ }
2427
+
2280
2428
  function invalidateDerivedRuntimeCaches(): void {
2281
2429
  cachedSurface = undefined;
2282
2430
  cachedCompatibility = undefined;
@@ -3289,6 +3437,31 @@ export function createDocumentRuntime(
3289
3437
  compileScopeBundleById(scopeId, nowUtc) {
3290
3438
  return createScopeCompilerService(runtime).compileBundleById(scopeId, nowUtc);
3291
3439
  },
3440
+ compileScopeList(filter) {
3441
+ const all = createScopeCompilerService(runtime).compileAllScopes();
3442
+ const byKind = filter?.kind
3443
+ ? all.filter((s) => s.kind === filter.kind)
3444
+ : all.slice();
3445
+ if (typeof filter?.limit === "number") {
3446
+ return byKind.slice(0, filter.limit);
3447
+ }
3448
+ return byKind;
3449
+ },
3450
+ compileScopeCardById(scopeId) {
3451
+ const cards = workflowCoordinator.getAllScopeCardModels();
3452
+ return cards.find((card) => card.scopeId === scopeId) ?? null;
3453
+ },
3454
+ compileScopeRailSnapshot(options) {
3455
+ const pageIndex = options?.pageIndex;
3456
+ const segments =
3457
+ typeof pageIndex === "number"
3458
+ ? workflowCoordinator.getRailSegments(pageIndex)
3459
+ : workflowCoordinator.getAllRailSegments();
3460
+ return {
3461
+ segments: Object.freeze([...segments]),
3462
+ ...(typeof pageIndex === "number" ? { pageIndex } : {}),
3463
+ };
3464
+ },
3292
3465
  getMarkerBackedScopeIds() {
3293
3466
  return workflowCoordinator.getMarkerBackedScopeIds();
3294
3467
  },
@@ -4219,19 +4392,14 @@ export function createDocumentRuntime(
4219
4392
  perfCounters.reset();
4220
4393
  },
4221
4394
  setVisibleBlockRange(range) {
4222
- if (
4223
- viewportBlockRange &&
4224
- viewportBlockRange.start === range.start &&
4225
- viewportBlockRange.end === range.end
4226
- ) {
4227
- return;
4228
- }
4229
- viewportBlockRange = { start: range.start, end: range.end };
4230
- perfCounters.increment("runtime.viewport.updates");
4395
+ applyViewportRanges([range]);
4396
+ },
4397
+ setVisibleBlockRanges(ranges) {
4398
+ applyViewportRanges(ranges);
4231
4399
  },
4232
4400
  requestViewportRefresh() {
4233
4401
  perfCounters.increment("runtime.viewport.refreshes");
4234
- refreshSurfaceOnly();
4402
+ maybeRefreshSurfaceForViewport();
4235
4403
  },
4236
4404
  };
4237
4405
 
@@ -5668,8 +5836,8 @@ function semanticallyEqualCommentThreads(
5668
5836
  if (prevAnchor.kind !== nextAnchor.kind) return false;
5669
5837
  if (prevAnchor.kind === "range" && nextAnchor.kind === "range") {
5670
5838
  return (
5671
- prevAnchor.range.from === nextAnchor.range.from &&
5672
- prevAnchor.range.to === nextAnchor.range.to
5839
+ prevAnchor.from === nextAnchor.from &&
5840
+ prevAnchor.to === nextAnchor.to
5673
5841
  );
5674
5842
  }
5675
5843
  if (prevAnchor.kind === "node" && nextAnchor.kind === "node") {
@@ -6038,8 +6206,8 @@ function toAnchorBounds(anchor: InternalEditorAnchorProjection): { from: number;
6038
6206
  switch (anchor.kind) {
6039
6207
  case "range":
6040
6208
  return {
6041
- from: Math.min(anchor.range.from, anchor.range.to),
6042
- to: Math.max(anchor.range.from, anchor.range.to),
6209
+ from: Math.min(anchor.from, anchor.to),
6210
+ to: Math.max(anchor.from, anchor.to),
6043
6211
  };
6044
6212
  case "node":
6045
6213
  return {
@@ -7212,12 +7380,12 @@ function remapProtectionSnapshot(
7212
7380
  "preserve-only: permission range could not be remapped after runtime edits",
7213
7381
  };
7214
7382
  }
7215
- if (mapped.range.from !== range.start || mapped.range.to !== range.end) {
7383
+ if (mapped.from !== range.start || mapped.to !== range.end) {
7216
7384
  changed = true;
7217
7385
  return {
7218
7386
  ...range,
7219
- start: mapped.range.from,
7220
- end: mapped.range.to,
7387
+ start: mapped.from,
7388
+ end: mapped.to,
7221
7389
  };
7222
7390
  }
7223
7391
  return range;
@@ -7366,7 +7534,7 @@ function resolveClearHighlightRange(
7366
7534
  }
7367
7535
  const active = selection.activeRange;
7368
7536
  if (active.kind !== "range") return null;
7369
- return { from: active.range.from, to: active.range.to };
7537
+ return { from: active.from, to: active.to };
7370
7538
  }
7371
7539
 
7372
7540
  function expandRangeToHighlightExtent(
@@ -891,7 +891,7 @@ function buildRevisionRangeIndex(
891
891
  const anchor = revision.anchor;
892
892
  let range: { from: number; to: number } | undefined;
893
893
  if (anchor.kind === "range") {
894
- range = { from: anchor.range.from, to: anchor.range.to };
894
+ range = { from: anchor.from, to: anchor.to };
895
895
  } else if (anchor.kind === "node") {
896
896
  range = { from: anchor.at, to: anchor.at };
897
897
  } else {
@@ -68,6 +68,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
68
68
  getDirtyFieldFamilies: () => [],
69
69
  getFieldDirtinessReport: () => emptyReport,
70
70
  setVisibleBlockRange: () => undefined,
71
+ setVisibleBlockRanges: () => undefined,
71
72
  requestViewportRefresh: () => undefined,
72
73
  subscribe: (_listener: (event: LayoutFacetEvent) => void) => () => undefined,
73
74
  };
@@ -926,8 +926,37 @@
926
926
  * Cache envelopes from v53 invalidate because any document with
927
927
  * `<w:br w:type="page"/>` (common in CCEP templates with explicit
928
928
  * schedule/appendix boundaries) now paginates differently.
929
+ *
930
+ * 55 — coord-04 §1.19.d. L03 (`ca553b1c` 2026-04-23) graduated
931
+ * `SurfaceBlockSnapshot.paragraph.frameProperties` from the canonical
932
+ * `<w:framePr>` model (L01 parse + L02 domain shape already shipped).
933
+ * L04 now honors it: paragraphs carrying out-of-flow frame properties
934
+ * (ECMA-376 §17.3.1.11 — `hAnchor` / `vAnchor` / `xAlign` / `yAlign` /
935
+ * `xTwips` / `yTwips` positioning with text wrapping around) return
936
+ * 0 from `measureBlockHeight` so the pagination flow does not
937
+ * double-count them. The `dropCap="drop"` / `dropCap="margin"` case
938
+ * is excluded — those frame only the initial letter, leaving the
939
+ * rest of the paragraph in the main flow. L11 owns the absolute-
940
+ * positioned render.
941
+ *
942
+ * CCEP parity-lock corpus unaffected: no body-level `<w:framePr>`
943
+ * exists in the 6-doc lock (`EnvelopeAddress` style carries a
944
+ * framePr in `EU & Global Consultancy Services Agreement` but is
945
+ * never referenced by a paragraph). Change is a forward-looking
946
+ * correctness fix for the cross-layer chain now that L01→L02→L03→L04
947
+ * all emit/honor framePr end-to-end. Cache envelopes from v54
948
+ * invalidate because any future document with a real body framePr
949
+ * paginates differently.
950
+ *
951
+ * 56 — perf(11b,07) `8d07de1b` multi-range viewport realization.
952
+ * `WordReviewEditorLayoutFacet` gained `setVisibleBlockRanges` for
953
+ * multi-range viewport realization (paired with coord-07 §2.9
954
+ * `runtime.viewport.subscribe`). The contract widened but the
955
+ * underlying layout algorithm is unchanged — persisted envelopes
956
+ * remain shape-compatible. Bump is defensive so any consumer that
957
+ * keyed on the facet contract refreshes the cache.
929
958
  */
930
- export const LAYOUT_ENGINE_VERSION = 54 as const;
959
+ export const LAYOUT_ENGINE_VERSION = 56 as const;
931
960
 
932
961
  /**
933
962
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -988,6 +988,11 @@ function measureBlockHeight(
988
988
  const compute = (): number => {
989
989
  switch (block.kind) {
990
990
  case "paragraph": {
991
+ // §1.19.d — out-of-flow framed paragraphs (ECMA-376 §17.3.1.11)
992
+ // contribute 0 to inline flow height; L11 owns the positioned render.
993
+ if (isOutOfFlowFrame(block.frameProperties)) {
994
+ return 0;
995
+ }
991
996
  const formatting = resolveBlockFormatting(block, defaultTabInterval, themeFonts);
992
997
  if (formatting) {
993
998
  // Provider path: sum per-line heights so canvas-backed measurements
@@ -1168,6 +1173,17 @@ export function __resolveCellWidth(
1168
1173
  return resolveCellWidth(gridColumns, startColumn, columnSpan, fallbackColumnWidth, gridScale);
1169
1174
  }
1170
1175
 
1176
+ /**
1177
+ * Exposed for coord §1.19.d framePr unit tests; not part of the
1178
+ * stable surface.
1179
+ */
1180
+ export function __test_measureBlockHeight(
1181
+ block: SurfaceBlockSnapshot | undefined,
1182
+ columnWidth: number,
1183
+ ): number {
1184
+ return measureBlockHeight(block, columnWidth);
1185
+ }
1186
+
1171
1187
  function resolveCellWidth(
1172
1188
  gridColumns: readonly number[],
1173
1189
  startColumn: number,
@@ -2054,6 +2070,37 @@ function currentPageNoteIds(
2054
2070
  return notes;
2055
2071
  }
2056
2072
 
2073
+ /**
2074
+ * OOXML §17.3.1.11 — `<w:framePr>`.
2075
+ *
2076
+ * A paragraph carrying frame properties is rendered as a text frame:
2077
+ * it is positioned relative to the page/margin/text (per `hAnchor` /
2078
+ * `vAnchor` / `xAlign` / `yAlign` / `xTwips` / `yTwips`) and the main
2079
+ * text flow wraps around it (per `wrap`). Consequently the framed
2080
+ * paragraph's height MUST NOT contribute to inline block-height
2081
+ * accounting on the page — otherwise the paginator double-counts the
2082
+ * frame (once as inline block, once as the positioned render).
2083
+ *
2084
+ * The exception is `dropCap` (`drop` / `margin`): the frame wraps
2085
+ * only the initial letter; the rest of the paragraph remains in the
2086
+ * main flow, so the paragraph must continue to contribute its
2087
+ * (non-dropped) height. `dropCap="none"` or absent is a full-frame.
2088
+ *
2089
+ * L11 owns the absolute-positioned render; L04 only needs to keep
2090
+ * the frame out of the flow accounting.
2091
+ */
2092
+ function isOutOfFlowFrame(
2093
+ frameProperties:
2094
+ | Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>["frameProperties"]
2095
+ | undefined,
2096
+ ): boolean {
2097
+ if (!frameProperties) return false;
2098
+ if (frameProperties.dropCap === "drop" || frameProperties.dropCap === "margin") {
2099
+ return false;
2100
+ }
2101
+ return true;
2102
+ }
2103
+
2057
2104
  function hasColumnBreak(block: SurfaceBlockSnapshot): boolean {
2058
2105
  return block.kind === "paragraph" && block.segments.some(
2059
2106
  (segment) =>
@@ -580,6 +580,18 @@ export interface WordReviewEditorLayoutFacet {
580
580
  * identical ranges are a no-op inside the runtime.
581
581
  */
582
582
  setVisibleBlockRange(range: { start: number; end: number }): void;
583
+ /**
584
+ * Multi-interval variant of `setVisibleBlockRange`. Delegates to
585
+ * `DocumentRuntime.setVisibleBlockRanges`. Use when the editor's
586
+ * visible viewport and the caret's page are disjoint — emitting the
587
+ * two ranges separately keeps the gap between them virtualized
588
+ * instead of being realized as real blocks, which was the dominant
589
+ * scroll-cost regression on long documents (see the perf wiki
590
+ * "Viewport realization").
591
+ */
592
+ setVisibleBlockRanges(
593
+ ranges: readonly { start: number; end: number }[],
594
+ ): void;
583
595
  /**
584
596
  * Triggers a surface-only refresh applying the latest visible block range.
585
597
  * Delegates to `DocumentRuntime.requestViewportRefresh`.
@@ -625,6 +637,9 @@ export interface CreateLayoutFacetInput {
625
637
  * (e.g. tests, inert facet, standalone use) both methods are no-ops.
626
638
  */
627
639
  setVisibleBlockRange?: (range: { start: number; end: number }) => void;
640
+ setVisibleBlockRanges?: (
641
+ ranges: readonly { start: number; end: number }[],
642
+ ) => void;
628
643
  requestViewportRefresh?: () => void;
629
644
  }
630
645
 
@@ -1236,6 +1251,18 @@ export function createLayoutFacet(
1236
1251
  input.setVisibleBlockRange?.(range);
1237
1252
  },
1238
1253
 
1254
+ setVisibleBlockRanges(ranges) {
1255
+ // Prefer the multi-range delegate when the wiring supplies it;
1256
+ // otherwise fall back to the scalar delegate with the first range so
1257
+ // callers that only wire the legacy path still receive something
1258
+ // actionable (degrades to pre-multi-range behavior).
1259
+ if (input.setVisibleBlockRanges) {
1260
+ input.setVisibleBlockRanges(ranges);
1261
+ } else if (input.setVisibleBlockRange && ranges.length > 0) {
1262
+ input.setVisibleBlockRange(ranges[0]!);
1263
+ }
1264
+ },
1265
+
1239
1266
  requestViewportRefresh() {
1240
1267
  input.requestViewportRefresh?.();
1241
1268
  },
@@ -111,6 +111,14 @@ export interface ComposeScopeValidationInputs {
111
111
  * no blockers / warnings / approval.
112
112
  */
113
113
  readonly actionId?: AIAction;
114
+ /**
115
+ * Caller-opt-in preservation policy. Honored at the preservation step
116
+ * only — when `opaqueFragments === true`, opaque-fragment findings
117
+ * downgrade from blockers to warnings (still surfaced on
118
+ * `validation.warnings` + audit). Other policy flags are advisory
119
+ * here; the compile step consumes them to drive per-step behavior.
120
+ */
121
+ readonly preservePolicy?: ReplacementScope["preserve"];
114
122
  }
115
123
 
116
124
  /**
@@ -240,6 +248,7 @@ function collectGuardVerdict(
240
248
  function collectPreservationVerdict(
241
249
  inputs: ComposeScopeValidationInputs,
242
250
  blockedReasons: string[],
251
+ warnings: ValidationIssue[],
243
252
  ): void {
244
253
  const { document, scope, positionMap } = inputs;
245
254
  if (!document) return;
@@ -250,10 +259,27 @@ function collectPreservationVerdict(
250
259
  ? (pm.markerScopes.get(scope.handle.stableRef.value) ?? null)
251
260
  : null;
252
261
  const verdict = computePreservationVerdict(document, range, pm);
253
- if (!verdict.replaceable) {
254
- for (const reason of verdict.reasons) {
255
- blockedReasons.push(`preserve:${reason}`);
262
+ if (verdict.replaceable) return;
263
+ const opaqueOptIn = inputs.preservePolicy?.opaqueFragments === true;
264
+ for (const reason of verdict.reasons) {
265
+ const code = `preserve:${reason}`;
266
+ // Opt-in downgrade (2026-04-24): opaque-fragment reasons move from
267
+ // blockers → warnings when the caller opted in via
268
+ // `preservePolicy.opaqueFragments === true`. Scope-marker-inside
269
+ // reasons stay as blockers — they'd require the sibling scope to be
270
+ // destroyed, which is a different kind of safety (scope-identity,
271
+ // not preserve-only payload). Other preserve reasons keep blocker
272
+ // semantics until they grow their own opt-in knob.
273
+ if (opaqueOptIn && reason.startsWith("opaque-fragment:")) {
274
+ warnings.push({
275
+ code,
276
+ message:
277
+ "Opaque fragment present in target range; caller opted in to preserve — compile will narrow the replace range to text-only.",
278
+ source: "preserve",
279
+ });
280
+ continue;
256
281
  }
282
+ blockedReasons.push(code);
257
283
  }
258
284
  }
259
285
 
@@ -339,7 +365,7 @@ export function composeScopeValidation(
339
365
  const warnings: ValidationIssue[] = [];
340
366
 
341
367
  collectGuardVerdict(inputs.scope, inputs.runtime, blockedReasons, warnings);
342
- collectPreservationVerdict(inputs, blockedReasons);
368
+ collectPreservationVerdict(inputs, blockedReasons, warnings);
343
369
  collectCompatibilityVerdict(inputs.runtime, blockedReasons, warnings);
344
370
 
345
371
  const actionId =
@@ -38,7 +38,7 @@ import type {
38
38
  function anchorRange(anchor: CanonicalAnchor): ScopePositionRange | null {
39
39
  switch (anchor.kind) {
40
40
  case "range":
41
- return { from: anchor.range.from, to: anchor.range.to };
41
+ return { from: anchor.from, to: anchor.to };
42
42
  case "node":
43
43
  return { from: anchor.at, to: anchor.at };
44
44
  case "detached":
@@ -147,6 +147,7 @@ export function applyScopeReplacement(
147
147
  document: docBefore,
148
148
  enumeratedScope: resolvedEnumerated,
149
149
  ...(inputs.actionId ? { actionId: inputs.actionId } : {}),
150
+ ...(proposed.preserve ? { preservePolicy: proposed.preserve } : {}),
150
151
  });
151
152
 
152
153
  if (!verdict.safe) {
@@ -57,7 +57,7 @@ function anchorToRange(
57
57
  ): ScopePositionRange | null {
58
58
  switch (anchor.kind) {
59
59
  case "range":
60
- return { from: anchor.range.from, to: anchor.range.to };
60
+ return { from: anchor.from, to: anchor.to };
61
61
  case "node":
62
62
  return { from: anchor.at, to: anchor.at };
63
63
  case "detached":