@beyondwork/docx-react-component 1.0.58 → 1.0.59

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 (134) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +978 -10
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +2 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +72 -42
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  69. package/src/io/ooxml/xml-parser.ts +183 -0
  70. package/src/legal/bookmarks.ts +1 -1
  71. package/src/legal/cross-references.ts +1 -1
  72. package/src/legal/defined-terms.ts +1 -1
  73. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  74. package/src/legal/signature-blocks.ts +1 -1
  75. package/src/model/canonical-document.ts +159 -6
  76. package/src/model/chart-types.ts +439 -0
  77. package/src/model/snapshot.ts +3 -1
  78. package/src/review/store/comment-remapping.ts +24 -11
  79. package/src/review/store/revision-actions.ts +482 -2
  80. package/src/review/store/revision-store.ts +15 -0
  81. package/src/review/store/revision-types.ts +76 -0
  82. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  83. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  84. package/src/runtime/diagnostics/build-diagnostic.ts +151 -0
  85. package/src/runtime/diagnostics/code-metadata-table.ts +221 -0
  86. package/src/runtime/document-runtime.ts +476 -34
  87. package/src/runtime/document-search.ts +115 -0
  88. package/src/runtime/edit-ops/index.ts +18 -2
  89. package/src/runtime/footnote-resolver.ts +130 -0
  90. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  91. package/src/runtime/layout/layout-engine-version.ts +37 -1
  92. package/src/runtime/layout/page-graph.ts +14 -1
  93. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  94. package/src/runtime/numbering-prefix.ts +17 -0
  95. package/src/runtime/query-scopes.ts +5 -8
  96. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  97. package/src/runtime/revision-runtime.ts +27 -1
  98. package/src/runtime/selection/post-edit-validator.ts +60 -6
  99. package/src/runtime/structure-ops/index.ts +20 -4
  100. package/src/runtime/surface-projection.ts +290 -21
  101. package/src/runtime/table-schema.ts +6 -0
  102. package/src/runtime/theme-color-resolver.ts +2 -2
  103. package/src/runtime/units.ts +9 -0
  104. package/src/runtime/workflow-rail-segments.ts +4 -0
  105. package/src/ui/WordReviewEditor.tsx +187 -43
  106. package/src/ui/editor-runtime-boundary.ts +10 -0
  107. package/src/ui/editor-shell-view.tsx +4 -1
  108. package/src/ui/headless/chrome-registry.ts +53 -0
  109. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  110. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  111. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  112. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  113. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  114. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  115. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  116. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +0 -9
  117. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -0
  118. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  119. package/src/ui-tailwind/editor-surface/pm-schema.ts +87 -25
  120. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -0
  121. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  122. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  124. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  125. package/src/ui-tailwind/index.ts +9 -0
  126. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  127. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  128. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  129. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  130. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  131. package/src/ui-tailwind/theme/tokens.ts +14 -0
  132. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  133. package/src/ui-tailwind/tw-review-workspace.tsx +29 -87
  134. package/src/validation/diagnostics.ts +1 -0
@@ -88,6 +88,11 @@ import type {
88
88
  WorkflowScopeSnapshot,
89
89
  ScopeQueryFilter,
90
90
  ScopeQueryResult,
91
+ ScopeVisibility,
92
+ ScopeChromeVisibilityState,
93
+ SearchOptions,
94
+ TextStyleFilter,
95
+ WorkflowScopeMode,
91
96
  WorkspaceMode,
92
97
  WordReviewEditorEvent,
93
98
  ZoomLevel,
@@ -138,6 +143,7 @@ import {
138
143
  } from "../review/store/revision-store.ts";
139
144
  import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
140
145
  import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
146
+ import { createSurfaceNodeSelectionProbe } from "./selection/post-edit-validator.ts";
141
147
  import {
142
148
  collectScopeLocations,
143
149
  findAllScopesAt,
@@ -155,6 +161,10 @@ import {
155
161
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
156
162
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
157
163
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
164
+ import {
165
+ findTextMatches,
166
+ findTextWithStyleMatches,
167
+ } from "./document-search.ts";
158
168
  import {
159
169
  collectWorkflowMarkupSnapshot,
160
170
  deriveWorkflowCandidateRangesFromMarkup,
@@ -420,6 +430,15 @@ export interface DocumentRuntime {
420
430
  */
421
431
  applyRemoteCommandBatch(envelopes: ReadonlyArray<RemoteCommandEnvelope>): void;
422
432
  emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
433
+ /**
434
+ * Emit a fire-and-forget `warning_added` event + `onWarning` callback for
435
+ * a host-layer no-op (e.g. `applyRuntimeDeleteComment` invoked with an
436
+ * unknown id). The warning is NOT persisted to `state.warnings` or the
437
+ * compatibility report — same semantics as a reducer-emitted
438
+ * `effects.transientWarnings` entry, exposed here for host-side paths
439
+ * that never reach a reducer.
440
+ */
441
+ emitTransientWarning(warning: InternalEditorWarning): void;
423
442
  undo(): void;
424
443
  redo(): void;
425
444
  focus(): void;
@@ -430,11 +449,33 @@ export interface DocumentRuntime {
430
449
  openComment(commentId: string): void;
431
450
  resolveComment(commentId: string): void;
432
451
  reopenComment(commentId: string): void;
433
- addCommentReply(commentId: string, body: string, authorId?: string): AddCommentReplyResult;
452
+ /**
453
+ * Append a reply entry to an existing thread. Returns the minted
454
+ * `{ commentId, entryId }` on success. Returns `null` when the thread
455
+ * is unknown, resolved, or detached — in that case a
456
+ * `review_target_not_found` warning fires on `onWarning` / `warning_added`
457
+ * with structured `details.op = "addCommentReply"` and `details.reason`
458
+ * ∈ `{ "comment_unknown", "comment_status" }`.
459
+ */
460
+ addCommentReply(
461
+ commentId: string,
462
+ body: string,
463
+ authorId?: string,
464
+ ): AddCommentReplyResult | null;
434
465
  editCommentBody(commentId: string, body: string): void;
435
466
  addScope(params: AddScopeParams): AddScopeResult;
436
467
  getScope(scopeId: string): WorkflowScope | null;
437
468
  removeScope(scopeId: string): void;
469
+ /** §C8 — Add a scope with visibility: "invisible" atomically. */
470
+ addInvisibleScope(params: Omit<AddScopeParams, "mode"> & { mode?: WorkflowScopeMode }): AddScopeResult;
471
+ /** §C8 — Set a scope's visibility (collab-replicated). */
472
+ setScopeVisibility(scopeId: string, visibility: ScopeVisibility): void;
473
+ /** §C8 — Get a scope's current visibility (absent = "visible"). */
474
+ getScopeVisibility(scopeId: string): ScopeVisibility;
475
+ /** §C7 — Set local chrome visibility state (never collab-replicated). */
476
+ setScopeChromeVisibility(state: ScopeChromeVisibilityState): void;
477
+ /** §C7 — Get local chrome visibility state (default: { mode: "all" }). */
478
+ getScopeChromeVisibility(): ScopeChromeVisibilityState;
438
479
  acceptChange(changeId: string): void;
439
480
  rejectChange(changeId: string): void;
440
481
  acceptAllChanges(): void;
@@ -520,6 +561,23 @@ export interface DocumentRuntime {
520
561
  * `WordReviewEditorRef.queryScopes` for contract.
521
562
  */
522
563
  queryScopes(filter?: ScopeQueryFilter): ScopeQueryResult[];
564
+ /** Phase C §C1 — live subscription; returns unsubscribe fn. */
565
+ subscribeToScopeQuery(
566
+ filter: ScopeQueryFilter,
567
+ callback: (results: ScopeQueryResult[]) => void,
568
+ ): () => void;
569
+ /** Phase C §C4 — text search + style filter. */
570
+ findAllText(query: string, options?: SearchOptions): EditorAnchorProjection[];
571
+ findTextWithStyle(
572
+ query: string,
573
+ filter: TextStyleFilter,
574
+ options?: SearchOptions,
575
+ ): EditorAnchorProjection[];
576
+ selectTextWithStyle(
577
+ query: string,
578
+ filter: TextStyleFilter,
579
+ options?: SearchOptions,
580
+ ): number;
523
581
  /**
524
582
  * Phase C §C2 — geometric scope queries. See `WordReviewEditorRef`
525
583
  * for contract. Non-range anchors yield `[]`.
@@ -815,6 +873,8 @@ export function createDocumentRuntime(
815
873
  ?? options.initialSnapshot?.workflowMetadata?.entries
816
874
  ?? [];
817
875
  let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
876
+ // §C7 — local view-state for scope chrome visibility; never collab-replicated.
877
+ let scopeChromeVisibilityState: ScopeChromeVisibilityState = { mode: "all" };
818
878
  // P13 Slice B: shared workflow state from the collab Y.Map "workflow".
819
879
  // Set via setSharedWorkflowState(); observed by evaluateWorkflowBlockedReasons.
820
880
  let sharedWorkflowState: SharedWorkflowState | null = null;
@@ -957,6 +1017,10 @@ export function createDocumentRuntime(
957
1017
  let cachedCompatibility:
958
1018
  | {
959
1019
  revisionToken: string;
1020
+ /** Block count at the time of the last full rebuild. O(1) proxy for structural stability. */
1021
+ blockCount: number;
1022
+ /** Warning count at time of rebuild — warnings array gets remapped on every edit. */
1023
+ warningCount: number;
960
1024
  warnings: EditorState["warnings"];
961
1025
  fatalError: EditorState["fatalError"];
962
1026
  report: RuntimeRenderSnapshot["compatibility"];
@@ -1107,6 +1171,14 @@ export function createDocumentRuntime(
1107
1171
  snapshot: WorkflowMarkupSnapshot;
1108
1172
  }
1109
1173
  | undefined;
1174
+ // Keyed on block count + subParts identity (not revisionToken) — fields only
1175
+ // change when blocks are inserted/deleted or subParts (headers/footers) change,
1176
+ // NOT on text-only edits. Cleared explicitly by updateFields() for field-refresh.
1177
+ let cachedFieldSnapshotEntry: {
1178
+ blockCount: number;
1179
+ subParts: CanonicalDocumentEnvelope["subParts"];
1180
+ snapshot: FieldSnapshot;
1181
+ } | null = null;
1110
1182
  const cachedContextAnalyticsSnapshots = new Map<
1111
1183
  string,
1112
1184
  {
@@ -1152,16 +1224,53 @@ export function createDocumentRuntime(
1152
1224
  return snapshot;
1153
1225
  }
1154
1226
 
1227
+ function getCachedFieldSnapshot(document: CanonicalDocumentEnvelope): FieldSnapshot {
1228
+ const blockCount = document.content.children.length;
1229
+ if (
1230
+ cachedFieldSnapshotEntry &&
1231
+ cachedFieldSnapshotEntry.blockCount === blockCount &&
1232
+ cachedFieldSnapshotEntry.subParts === document.subParts
1233
+ ) {
1234
+ return cachedFieldSnapshotEntry.snapshot;
1235
+ }
1236
+ const snapshot = buildFieldSnapshot(document);
1237
+ cachedFieldSnapshotEntry = { blockCount, subParts: document.subParts, snapshot };
1238
+ return snapshot;
1239
+ }
1240
+
1155
1241
  function getCachedCompatibilityReport(
1156
1242
  nextState: EditorState,
1157
1243
  ): RuntimeRenderSnapshot["compatibility"] {
1158
- if (
1159
- cachedCompatibility &&
1160
- cachedCompatibility.revisionToken === nextState.revisionToken &&
1161
- cachedCompatibility.warnings === nextState.warnings &&
1162
- cachedCompatibility.fatalError === nextState.fatalError
1163
- ) {
1164
- return cachedCompatibility.report;
1244
+ const blockCount = nextState.document.content.children.length;
1245
+ const warningCount = nextState.warnings?.length ?? 0;
1246
+ if (cachedCompatibility) {
1247
+ // Fast path 1: same revisionToken (selection move, surface-only refresh).
1248
+ if (
1249
+ cachedCompatibility.revisionToken === nextState.revisionToken &&
1250
+ cachedCompatibility.warnings === nextState.warnings &&
1251
+ cachedCompatibility.fatalError === nextState.fatalError
1252
+ ) {
1253
+ return cachedCompatibility.report;
1254
+ }
1255
+ // Fast path 2: revisionToken changed but block structure and warning
1256
+ // count are stable (text-only edit). buildCompatibilityReport reads
1257
+ // block types, review counts, preservation, and warnings — NOT run text.
1258
+ // Block count + warning count + fatalError identity is an O(1) proxy:
1259
+ // if these are stable, the compatibility report output is unchanged.
1260
+ // `warnings` array gets remapped to a new reference on every commit via
1261
+ // remapReviewStateAfterContentChange, so we can't use reference equality.
1262
+ if (
1263
+ cachedCompatibility.blockCount === blockCount &&
1264
+ cachedCompatibility.warningCount === warningCount &&
1265
+ cachedCompatibility.fatalError === nextState.fatalError
1266
+ ) {
1267
+ cachedCompatibility = {
1268
+ ...cachedCompatibility,
1269
+ revisionToken: nextState.revisionToken,
1270
+ warnings: nextState.warnings,
1271
+ };
1272
+ return cachedCompatibility.report;
1273
+ }
1165
1274
  }
1166
1275
 
1167
1276
  const derived = createDerivedCompatibility(nextState);
@@ -1178,6 +1287,8 @@ export function createDocumentRuntime(
1178
1287
  };
1179
1288
  cachedCompatibility = {
1180
1289
  revisionToken: nextState.revisionToken,
1290
+ blockCount,
1291
+ warningCount,
1181
1292
  warnings: nextState.warnings,
1182
1293
  fatalError: nextState.fatalError,
1183
1294
  report,
@@ -1424,8 +1535,13 @@ export function createDocumentRuntime(
1424
1535
  if (normalizedWorkflowOverlay) {
1425
1536
  const matchingScope = getMatchingWorkflowScope(selection);
1426
1537
  const activeScopes = getEffectiveWorkflowScopes(normalizedWorkflowOverlay);
1538
+ // §C8: invisible non-view scopes are transparent to the interaction guard.
1539
+ // Don't count them toward the "outside_workflow_scope" threshold.
1540
+ const guardingScopes = activeScopes.filter(
1541
+ (s) => !(s.visibility === "invisible" && s.mode !== "view"),
1542
+ );
1427
1543
 
1428
- if (!matchingScope && activeScopes.length > 0) {
1544
+ if (!matchingScope && guardingScopes.length > 0) {
1429
1545
  reasons.push({
1430
1546
  code: "outside_workflow_scope",
1431
1547
  message: "Selection is outside any active workflow scope.",
@@ -1456,24 +1572,60 @@ export function createDocumentRuntime(
1456
1572
  return reasons;
1457
1573
  }
1458
1574
 
1459
- function getMatchingWorkflowScope(
1460
- selection: EditorState["selection"],
1461
- ): WorkflowOverlay["scopes"][number] | null {
1462
- if (!workflowOverlay) {
1463
- return null;
1464
- }
1575
+ // §C6 — most-restrictive-wins ordering for overlap layering.
1576
+ const MODE_RESTRICTIVENESS: Record<WorkflowScopeMode, number> = {
1577
+ edit: 0,
1578
+ suggest: 1,
1579
+ comment: 2,
1580
+ view: 3,
1581
+ };
1465
1582
 
1583
+ /**
1584
+ * §C6 — Collect all guard-eligible scopes that contain `selection`,
1585
+ * sorted outermost→innermost (startPos ASC, endPos DESC, scopeId ASC).
1586
+ * Excludes invisible non-view scopes per §C8.
1587
+ */
1588
+ function buildMatchingScopeStack(
1589
+ selection: EditorState["selection"],
1590
+ ): WorkflowOverlay["scopes"] {
1591
+ if (!workflowOverlay) return [];
1466
1592
  const selectionBounds = {
1467
1593
  from: Math.min(selection.anchor, selection.head),
1468
1594
  to: Math.max(selection.anchor, selection.head),
1469
1595
  };
1470
1596
  const activeScopes = getEffectiveWorkflowScopes(workflowOverlay);
1471
- return activeScopes.find((scope) => {
1597
+ const matching = activeScopes.filter((scope) => {
1598
+ // §C8
1599
+ if (scope.visibility === "invisible" && scope.mode !== "view") return false;
1472
1600
  if (scope.anchor.kind === "detached") return false;
1473
1601
  const scopeFrom = scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
1474
1602
  const scopeTo = scope.anchor.kind === "range" ? scope.anchor.to : scope.anchor.at;
1475
1603
  return selectionBounds.from >= scopeFrom && selectionBounds.to <= scopeTo;
1476
- }) ?? null;
1604
+ });
1605
+ // Outermost first: startPos ASC, endPos DESC (wider span = outer), scopeId ASC tiebreak
1606
+ matching.sort((a, b) => {
1607
+ const aFrom = a.anchor.kind === "range" ? a.anchor.from : (a.anchor as { at: number }).at;
1608
+ const bFrom = b.anchor.kind === "range" ? b.anchor.from : (b.anchor as { at: number }).at;
1609
+ if (aFrom !== bFrom) return aFrom - bFrom;
1610
+ const aTo = a.anchor.kind === "range" ? a.anchor.to : (a.anchor as { at: number }).at;
1611
+ const bTo = b.anchor.kind === "range" ? b.anchor.to : (b.anchor as { at: number }).at;
1612
+ if (aTo !== bTo) return bTo - aTo; // wider first
1613
+ return a.scopeId < b.scopeId ? -1 : a.scopeId > b.scopeId ? 1 : 0;
1614
+ });
1615
+ return matching;
1616
+ }
1617
+
1618
+ function getMatchingWorkflowScope(
1619
+ selection: EditorState["selection"],
1620
+ ): WorkflowOverlay["scopes"][number] | null {
1621
+ const stack = buildMatchingScopeStack(selection);
1622
+ if (stack.length === 0) return null;
1623
+ // §C6 — most-restrictive scope wins across all overlapping scopes.
1624
+ return stack.reduce((best, scope) =>
1625
+ (MODE_RESTRICTIVENESS[scope.mode] ?? 0) > (MODE_RESTRICTIVENESS[best.mode] ?? 0)
1626
+ ? scope
1627
+ : best,
1628
+ );
1477
1629
  }
1478
1630
 
1479
1631
  function getEffectiveDocumentMode(
@@ -1892,6 +2044,7 @@ export function createDocumentRuntime(
1892
2044
 
1893
2045
  const blockedReasons = evaluateWorkflowBlockedReasons(state.selection);
1894
2046
  const matchingScope = getMatchingWorkflowScope(state.selection);
2047
+ const scopeStack = buildMatchingScopeStack(state.selection);
1895
2048
  const primaryBlockedReason = blockedReasons[0];
1896
2049
  const effectiveMode = primaryBlockedReason
1897
2050
  ? (
@@ -1904,10 +2057,19 @@ export function createDocumentRuntime(
1904
2057
  : getEffectiveDocumentMode(state.selection) === "suggesting"
1905
2058
  ? "suggest"
1906
2059
  : matchingScope?.mode ?? "edit";
2060
+ const matchedScopeStack: InteractionGuardSnapshot["matchedScopeStack"] =
2061
+ scopeStack.length > 0
2062
+ ? scopeStack.map((s) => ({
2063
+ scopeId: s.scopeId,
2064
+ mode: s.mode,
2065
+ visibility: s.visibility ?? "visible",
2066
+ }))
2067
+ : undefined;
1907
2068
  const snapshot: InteractionGuardSnapshot = {
1908
2069
  effectiveMode,
1909
2070
  ...(matchingScope?.scopeId ? { matchedScopeId: matchingScope.scopeId } : {}),
1910
2071
  ...(matchingScope?.mode ? { matchedScopeMode: matchingScope.mode } : {}),
2072
+ ...(matchedScopeStack ? { matchedScopeStack } : {}),
1911
2073
  targetAccess:
1912
2074
  effectiveMode === "edit"
1913
2075
  ? "direct-edit"
@@ -1992,7 +2154,7 @@ export function createDocumentRuntime(
1992
2154
 
1993
2155
  const snapshot = collectWorkflowMarkupSnapshot({
1994
2156
  renderSnapshot: cachedRenderSnapshot,
1995
- fieldSnapshot: buildFieldSnapshot(state.document),
2157
+ fieldSnapshot: getCachedFieldSnapshot(state.document),
1996
2158
  protectionSnapshot,
1997
2159
  preservation: state.document.preservation,
1998
2160
  workflowMetadataSnapshot: deriveWorkflowMetadataSnapshot(),
@@ -2297,6 +2459,7 @@ export function createDocumentRuntime(
2297
2459
  cachedWorkflowScopeSnapshot = undefined;
2298
2460
  cachedNormalizedWorkflowOverlay = undefined;
2299
2461
  cachedWorkflowMarkupSnapshot = undefined;
2462
+ cachedFieldSnapshotEntry = null;
2300
2463
  cachedContextAnalyticsSnapshots.clear();
2301
2464
  lastEmittedContextAnalyticsSnapshots = undefined;
2302
2465
  }
@@ -2313,8 +2476,11 @@ export function createDocumentRuntime(
2313
2476
  document,
2314
2477
  };
2315
2478
  if (previousDocument.subParts !== document.subParts) {
2316
- fontLoader.refresh(collectFontLoaderInput(document));
2317
- layoutEngine.invalidateMeasurementCache();
2479
+ const fontInput = collectFontLoaderInput(document);
2480
+ if (!fontFamiliesEqual(fontInput.families, collectFontLoaderInput(previousDocument).families)) {
2481
+ fontLoader.refresh(fontInput);
2482
+ layoutEngine.invalidateMeasurementCache();
2483
+ }
2318
2484
  }
2319
2485
  invalidateDerivedRuntimeCaches();
2320
2486
  cachedRenderSnapshot = refreshRenderSnapshot();
@@ -2473,6 +2639,15 @@ export function createDocumentRuntime(
2473
2639
  reasons,
2474
2640
  });
2475
2641
  },
2642
+ emitTransientWarning(warning) {
2643
+ const publicWarning = toPublicWarning(warning);
2644
+ emit({
2645
+ type: "warning_added",
2646
+ documentId: state.documentId,
2647
+ warning: publicWarning,
2648
+ });
2649
+ options.onWarning?.(publicWarning);
2650
+ },
2476
2651
  dispatch(command) {
2477
2652
  const commandSelection = getCommandSelection(command, state.selection);
2478
2653
  if (isMutationCommand(command)) {
@@ -3083,7 +3258,7 @@ export function createDocumentRuntime(
3083
3258
  });
3084
3259
  },
3085
3260
  addCommentReply(commentId, body, authorId) {
3086
- const priorEntryCount =
3261
+ const priorCount =
3087
3262
  state.document.review.comments[commentId]?.entries?.length ?? 0;
3088
3263
  this.dispatch({
3089
3264
  type: "comment.add-reply",
@@ -3092,8 +3267,16 @@ export function createDocumentRuntime(
3092
3267
  authorId: authorId ?? defaultAuthorId,
3093
3268
  origin: createOrigin("api", clock()),
3094
3269
  });
3095
- const entryId = `${commentId}-entry-${priorEntryCount + 1}`;
3096
- return { commentId, entryId };
3270
+ // Read post-dispatch state. The reducer skips silently on unknown /
3271
+ // resolved / detached threads and emits a `review_target_not_found`
3272
+ // transient warning from `effects.transientWarnings` — callers who
3273
+ // need to know about the skip listen on `onWarning`.
3274
+ const entries = state.document.review.comments[commentId]?.entries ?? [];
3275
+ if (entries.length <= priorCount) {
3276
+ return null;
3277
+ }
3278
+ const last = entries[entries.length - 1]!;
3279
+ return { commentId, entryId: last.entryId };
3097
3280
  },
3098
3281
  editCommentBody(commentId, body) {
3099
3282
  this.dispatch({
@@ -3250,6 +3433,38 @@ export function createDocumentRuntime(
3250
3433
  }
3251
3434
  }
3252
3435
  },
3436
+ addInvisibleScope(params) {
3437
+ const result = this.addScope({
3438
+ ...params,
3439
+ mode: params.mode ?? "comment",
3440
+ });
3441
+ this.setScopeVisibility(result.scopeId, "invisible");
3442
+ return result;
3443
+ },
3444
+ setScopeVisibility(scopeId, visibility) {
3445
+ if (!workflowOverlay) return;
3446
+ const idx = workflowOverlay.scopes.findIndex((s) => s.scopeId === scopeId);
3447
+ if (idx === -1) return;
3448
+ const nextScopes = workflowOverlay.scopes.map((s) =>
3449
+ s.scopeId === scopeId ? { ...s, visibility } : s,
3450
+ );
3451
+ this.dispatch({
3452
+ type: "workflow.set-overlay",
3453
+ overlay: { ...workflowOverlay, scopes: nextScopes },
3454
+ origin: createOrigin("api", clock()),
3455
+ });
3456
+ },
3457
+ getScopeVisibility(scopeId): ScopeVisibility {
3458
+ if (!workflowOverlay) return "visible";
3459
+ const scope = workflowOverlay.scopes.find((s) => s.scopeId === scopeId);
3460
+ return scope?.visibility ?? "visible";
3461
+ },
3462
+ setScopeChromeVisibility(state) {
3463
+ scopeChromeVisibilityState = state;
3464
+ },
3465
+ getScopeChromeVisibility(): ScopeChromeVisibilityState {
3466
+ return scopeChromeVisibilityState;
3467
+ },
3253
3468
  acceptChange(changeId) {
3254
3469
  this.dispatch({
3255
3470
  type: "change.accept",
@@ -3382,7 +3597,11 @@ export function createDocumentRuntime(
3382
3597
  getFootnoteResolver(): FootnoteResolver | undefined {
3383
3598
  const collection = state.document.subParts?.footnoteCollection;
3384
3599
  if (!collection) return undefined;
3385
- return createFootnoteResolver(collection, collectSectionPropertiesInOrder(state.document));
3600
+ return createFootnoteResolver(
3601
+ collection,
3602
+ collectSectionPropertiesInOrder(state.document),
3603
+ state.document,
3604
+ );
3386
3605
  },
3387
3606
  layout: layoutFacet,
3388
3607
  getCurrentLocation() {
@@ -3529,6 +3748,9 @@ export function createDocumentRuntime(
3529
3748
  options,
3530
3749
  );
3531
3750
  if (refreshed.changed) {
3751
+ // Field display text changed — clear field snapshot cache so the
3752
+ // next getCachedFieldSnapshot call rebuilds with fresh displayText.
3753
+ cachedFieldSnapshotEntry = null;
3532
3754
  this.dispatch({
3533
3755
  type: "document.replace",
3534
3756
  document: refreshed.document,
@@ -3733,6 +3955,68 @@ export function createDocumentRuntime(
3733
3955
  filter,
3734
3956
  );
3735
3957
  },
3958
+ subscribeToScopeQuery(filter, callback) {
3959
+ const buildKey = (results: ScopeQueryResult[]) =>
3960
+ results
3961
+ .map(
3962
+ (r) =>
3963
+ `${r.scope.scopeId}:${r.scope.version ?? 0}:${r.scope.visibility ?? "visible"}`,
3964
+ )
3965
+ .join(",");
3966
+
3967
+ let lastKey = "";
3968
+ const fire = () => {
3969
+ const results = this.queryScopes(filter);
3970
+ const key = buildKey(results);
3971
+ if (key !== lastKey) {
3972
+ lastKey = key;
3973
+ callback(results);
3974
+ }
3975
+ };
3976
+
3977
+ // Immediate initial fire
3978
+ fire();
3979
+
3980
+ let pendingTimer: ReturnType<typeof setTimeout> | null = null;
3981
+ const unsub = this.subscribe(() => {
3982
+ if (pendingTimer !== null) return;
3983
+ pendingTimer = setTimeout(() => {
3984
+ pendingTimer = null;
3985
+ fire();
3986
+ }, 0);
3987
+ });
3988
+
3989
+ return () => {
3990
+ unsub();
3991
+ if (pendingTimer !== null) {
3992
+ clearTimeout(pendingTimer);
3993
+ pendingTimer = null;
3994
+ }
3995
+ };
3996
+ },
3997
+ findAllText(query, options) {
3998
+ const sel = makePublicSelectionSnapshot(state.selection.anchor, state.selection.head);
3999
+ return findTextMatches(state.document, sel, query, options ?? {});
4000
+ },
4001
+ findTextWithStyle(query, filter, options) {
4002
+ const sel = makePublicSelectionSnapshot(state.selection.anchor, state.selection.head);
4003
+ return findTextWithStyleMatches(state.document, sel, query, filter, options ?? {});
4004
+ },
4005
+ selectTextWithStyle(query, filter, options) {
4006
+ const sel = makePublicSelectionSnapshot(state.selection.anchor, state.selection.head);
4007
+ const hits = findTextWithStyleMatches(state.document, sel, query, filter, options ?? {});
4008
+ if (hits.length > 0 && hits[0]) {
4009
+ const first = hits[0];
4010
+ if (first.kind === "range") {
4011
+ this.dispatch({
4012
+ type: "selection.set",
4013
+ selection: createSelectionSnapshot(first.from, first.to),
4014
+ origin: createOrigin("api", clock()),
4015
+ });
4016
+ }
4017
+ }
4018
+ return hits.length;
4019
+ },
3736
4020
  findScopesAt(position, options) {
3737
4021
  const pos =
3738
4022
  position.kind === "range"
@@ -3925,6 +4209,38 @@ export function createDocumentRuntime(
3925
4209
  }
3926
4210
 
3927
4211
  function applyTransactionToState(transaction: EditorTransaction): void {
4212
+ // Pure-no-op short-circuit: when a reducer skipped without emitting any
4213
+ // observable effect AND the selection is identical, skip finalizeState
4214
+ // / invalidate / refresh / notify entirely. This keeps hot paths (e.g.
4215
+ // host loops that query invalid ids) cheap — no clock allocation, no
4216
+ // listener loop, no snapshot rebuild for nothing. Transient-warning
4217
+ // commits (the `review_target_not_found` path) carry
4218
+ // `transientWarnings.length > 0` and MUST fall through so `notify`
4219
+ // can emit them. Selection-only dispatches (e.g. `selection.set`) also
4220
+ // fall through because `nextState.selection !== state.selection`.
4221
+ const effects = transaction.effects;
4222
+ const selectionUnchanged = transaction.nextState.selection === state.selection;
4223
+ const isPureNoop =
4224
+ !transaction.markDirty &&
4225
+ transaction.historyBoundary === "skip" &&
4226
+ transaction.mapping.steps.length === 0 &&
4227
+ selectionUnchanged &&
4228
+ effects.warningsAdded.length === 0 &&
4229
+ effects.warningsCleared.length === 0 &&
4230
+ (effects.transientWarnings?.length ?? 0) === 0 &&
4231
+ !effects.commentAdded &&
4232
+ !effects.commentResolved &&
4233
+ !effects.commentReopened &&
4234
+ !effects.commentReplyAdded &&
4235
+ !effects.commentBodyEdited &&
4236
+ !effects.changeAccepted &&
4237
+ !effects.changeRejected &&
4238
+ !effects.revisionAuthored &&
4239
+ !effects.commandBlocked;
4240
+ if (isPureNoop) {
4241
+ return;
4242
+ }
4243
+
3928
4244
  const previous = state;
3929
4245
 
3930
4246
  const tApply0 = performance.now();
@@ -3948,8 +4264,11 @@ export function createDocumentRuntime(
3948
4264
  }
3949
4265
  }
3950
4266
  if (previous.document.subParts !== state.document.subParts) {
3951
- fontLoader.refresh(collectFontLoaderInput(state.document));
3952
- layoutEngine.invalidateMeasurementCache();
4267
+ const fontInput = collectFontLoaderInput(state.document);
4268
+ if (!fontFamiliesEqual(fontInput.families, collectFontLoaderInput(previous.document).families)) {
4269
+ fontLoader.refresh(fontInput);
4270
+ layoutEngine.invalidateMeasurementCache();
4271
+ }
3953
4272
  }
3954
4273
  perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
3955
4274
 
@@ -3979,10 +4298,16 @@ export function createDocumentRuntime(
3979
4298
  // which is optional in the public API for shape-only reasons — the helper
3980
4299
  // itself always returns a defined snapshot.
3981
4300
  const surfaceForValidation = getCachedSurface(state.document, activeStory)!;
4301
+ const validationOptions = state.selection.activeRange.kind === "node"
4302
+ ? {
4303
+ isValidNodeTarget: createSurfaceNodeSelectionProbe(surfaceForValidation),
4304
+ }
4305
+ : undefined;
3982
4306
  const validatedSelection = validateSelectionAgainstDocument(
3983
4307
  state.document,
3984
4308
  state.selection,
3985
4309
  surfaceForValidation.storySize,
4310
+ validationOptions,
3986
4311
  );
3987
4312
  if (validatedSelection !== state.selection) {
3988
4313
  state = { ...state, selection: validatedSelection };
@@ -4184,6 +4509,23 @@ export function createDocumentRuntime(
4184
4509
  options.onWarning?.(publicWarning);
4185
4510
  }
4186
4511
 
4512
+ if (transaction.effects.transientWarnings) {
4513
+ // Fire-and-forget diagnostics (e.g. `review_target_not_found` on a
4514
+ // silent-skip reducer). Surfaces on `warning_added` + `onWarning`
4515
+ // like any other warning, but never touches `state.warnings` or the
4516
+ // compatibility report, so repeated no-op calls (e.g. a host
4517
+ // looping `resolveComment` over stale ids) do not accumulate entries.
4518
+ for (const warning of transaction.effects.transientWarnings) {
4519
+ const publicWarning = toPublicWarning(warning);
4520
+ emit({
4521
+ type: "warning_added",
4522
+ documentId: next.documentId,
4523
+ warning: publicWarning,
4524
+ });
4525
+ options.onWarning?.(publicWarning);
4526
+ }
4527
+ }
4528
+
4187
4529
  for (const cleared of transaction.effects.warningsCleared) {
4188
4530
  emit({
4189
4531
  type: "warning_cleared",
@@ -4433,9 +4775,16 @@ export function createDocumentRuntime(
4433
4775
  base: EditorTransaction["effects"],
4434
4776
  local: EditorTransaction["effects"],
4435
4777
  ): EditorTransaction["effects"] {
4778
+ const baseTransient = base.transientWarnings ?? [];
4779
+ const localTransient = local.transientWarnings ?? [];
4780
+ const mergedTransient =
4781
+ baseTransient.length + localTransient.length === 0
4782
+ ? undefined
4783
+ : [...baseTransient, ...localTransient];
4436
4784
  return {
4437
4785
  warningsAdded: [...base.warningsAdded, ...local.warningsAdded],
4438
4786
  warningsCleared: [...base.warningsCleared, ...local.warningsCleared],
4787
+ ...(mergedTransient ? { transientWarnings: mergedTransient } : {}),
4439
4788
  commentAdded: base.commentAdded ?? local.commentAdded,
4440
4789
  commentResolved: base.commentResolved ?? local.commentResolved,
4441
4790
  commentReopened: base.commentReopened ?? local.commentReopened,
@@ -4979,7 +5328,13 @@ function toRuntimeError(error: unknown): InternalEditorError {
4979
5328
  function toStructuredRuntimeException<T extends InternalEditorError>(
4980
5329
  error: T,
4981
5330
  ): Error & T {
4982
- return Object.assign(new Error(error.message), error);
5331
+ const exception = Object.assign(new Error(error.message), error);
5332
+ // Set `name` so host wrappers that must branch across process / bundle
5333
+ // boundaries (e.g. the gRPC handlers in `vendor/beyondwork/src/ts/docx-api`)
5334
+ // can discriminate structured editor errors from generic `Error` objects
5335
+ // via `error.name === "EditorError"` — `instanceof` is unreliable there.
5336
+ exception.name = "EditorError";
5337
+ return exception;
4983
5338
  }
4984
5339
 
4985
5340
  function toPublicDocumentStats(state: Pick<EditorState, "document">) {
@@ -5077,11 +5432,14 @@ function extractSelectionFragment(
5077
5432
  }
5078
5433
 
5079
5434
  /**
5080
- * Collect the stable ids of comment threads whose entry differs
5081
- * (present in one side but not the other, OR present in both but
5082
- * referencing a different object which indicates any mutation to
5083
- * the thread, since the runtime treats `review.comments` as
5084
- * immutable per commit). Used by the `comments_changed` event.
5435
+ * Collect the stable ids of comment threads whose host-observable state
5436
+ * differs between two commits. A reference-identity diff was too noisy
5437
+ * any `remapCommentStore` pass (fired on every text edit that touches a
5438
+ * thread's anchor region) rebuilt the thread objects and would have marked
5439
+ * every thread as "changed" even when only their anchor numerics shifted
5440
+ * in a way that doesn't affect what the host renders. The semantic diff
5441
+ * below compares the fields hosts actually consume: status, anchor shape,
5442
+ * entry count + ordering, resolution metadata, and warning ids.
5085
5443
  */
5086
5444
  function diffCommentMapKeys(
5087
5445
  previous: CanonicalDocumentEnvelope["review"]["comments"],
@@ -5089,14 +5447,65 @@ function diffCommentMapKeys(
5089
5447
  ): string[] {
5090
5448
  const changed = new Set<string>();
5091
5449
  for (const id of Object.keys(previous)) {
5092
- if (previous[id] !== next[id]) changed.add(id);
5450
+ if (!next[id]) changed.add(id);
5093
5451
  }
5094
5452
  for (const id of Object.keys(next)) {
5095
- if (previous[id] !== next[id]) changed.add(id);
5453
+ const prev = previous[id];
5454
+ if (!prev) {
5455
+ changed.add(id);
5456
+ continue;
5457
+ }
5458
+ if (prev === next[id]) continue;
5459
+ if (!semanticallyEqualCommentThreads(prev, next[id]!)) changed.add(id);
5096
5460
  }
5097
5461
  return Array.from(changed);
5098
5462
  }
5099
5463
 
5464
+ function semanticallyEqualCommentThreads(
5465
+ prev: CanonicalDocumentEnvelope["review"]["comments"][string],
5466
+ next: CanonicalDocumentEnvelope["review"]["comments"][string],
5467
+ ): boolean {
5468
+ if (prev.status !== next.status) return false;
5469
+ if (prev.isResolved !== next.isResolved) return false;
5470
+ if (prev.resolvedAt !== next.resolvedAt) return false;
5471
+ if (prev.resolution?.resolvedAt !== next.resolution?.resolvedAt) return false;
5472
+ if (prev.resolution?.resolvedBy !== next.resolution?.resolvedBy) return false;
5473
+ if (prev.body !== next.body) return false;
5474
+ if ((prev.entries?.length ?? 0) !== (next.entries?.length ?? 0)) return false;
5475
+ const prevEntries = prev.entries ?? [];
5476
+ const nextEntries = next.entries ?? [];
5477
+ for (let i = 0; i < prevEntries.length; i++) {
5478
+ const pe = prevEntries[i]!;
5479
+ const ne = nextEntries[i]!;
5480
+ if (pe.entryId !== ne.entryId) return false;
5481
+ if (pe.body !== ne.body) return false;
5482
+ if (pe.authorId !== ne.authorId) return false;
5483
+ }
5484
+ const prevWarnings = prev.warningIds ?? [];
5485
+ const nextWarnings = next.warningIds ?? [];
5486
+ if (prevWarnings.length !== nextWarnings.length) return false;
5487
+ for (let i = 0; i < prevWarnings.length; i++) {
5488
+ if (prevWarnings[i] !== nextWarnings[i]) return false;
5489
+ }
5490
+ const prevAnchor = prev.anchor;
5491
+ const nextAnchor = next.anchor;
5492
+ if (prevAnchor === nextAnchor) return true;
5493
+ if (prevAnchor.kind !== nextAnchor.kind) return false;
5494
+ if (prevAnchor.kind === "range" && nextAnchor.kind === "range") {
5495
+ return (
5496
+ prevAnchor.range.from === nextAnchor.range.from &&
5497
+ prevAnchor.range.to === nextAnchor.range.to
5498
+ );
5499
+ }
5500
+ if (prevAnchor.kind === "node" && nextAnchor.kind === "node") {
5501
+ return prevAnchor.at === nextAnchor.at;
5502
+ }
5503
+ if (prevAnchor.kind === "detached" && nextAnchor.kind === "detached") {
5504
+ return prevAnchor.reason === nextAnchor.reason;
5505
+ }
5506
+ return false;
5507
+ }
5508
+
5100
5509
  function toPublicCompatibilityReport(
5101
5510
  report: InternalCompatibilityReport,
5102
5511
  ): CompatibilityReport {
@@ -5510,6 +5919,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
5510
5919
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
5511
5920
  }
5512
5921
 
5922
+ function makePublicSelectionSnapshot(anchor: number, head: number): SelectionSnapshot {
5923
+ const from = Math.min(anchor, head);
5924
+ const to = Math.max(anchor, head);
5925
+ return {
5926
+ anchor,
5927
+ head,
5928
+ isCollapsed: anchor === head,
5929
+ activeRange: {
5930
+ kind: "range",
5931
+ from,
5932
+ to,
5933
+ assoc: { start: -1, end: 1 },
5934
+ },
5935
+ };
5936
+ }
5937
+
5513
5938
  /** Commands that are safe in viewing mode (no document mutation). */
5514
5939
  const NON_MUTATION_COMMANDS = new Set([
5515
5940
  "selection.set",
@@ -5526,6 +5951,9 @@ const NON_MUTATION_COMMANDS = new Set([
5526
5951
  "workflow.clear-metadata-entries",
5527
5952
  "host-annotation.set-overlay",
5528
5953
  "host-annotation.clear-overlay",
5954
+ // API-level full-document replacement (scope markers, protection refresh,
5955
+ // collab replay). Not user-initiated — must bypass the workflow guard.
5956
+ "document.replace",
5529
5957
  ]);
5530
5958
 
5531
5959
  /** Mutation commands that are not yet supported in suggesting mode. */
@@ -6626,6 +7054,17 @@ const fontLoaderInputCache = new WeakMap<
6626
7054
  WeakMap<object, { families: readonly string[] }>
6627
7055
  >();
6628
7056
 
7057
+ function fontFamiliesEqual(
7058
+ a: readonly string[],
7059
+ b: readonly string[],
7060
+ ): boolean {
7061
+ if (a.length !== b.length) return false;
7062
+ // Both arrays are Set-derived (no duplicates); sort for order-independence.
7063
+ const sortedA = [...a].sort();
7064
+ const sortedB = [...b].sort();
7065
+ return sortedA.every((v, i) => v === sortedB[i]);
7066
+ }
7067
+
6629
7068
  function collectFontLoaderInput(
6630
7069
  document: CanonicalDocumentEnvelope,
6631
7070
  ): { families: readonly string[] } {
@@ -6678,6 +7117,9 @@ function collectFontLoaderInputUncached(
6678
7117
  /** Test-only export of the uncached walk so memoization tests can spy on it. */
6679
7118
  export const __collectFontLoaderInputUncached = collectFontLoaderInputUncached;
6680
7119
 
7120
+ /** Test-only export of the font-family set equality helper. */
7121
+ export const __fontFamiliesEqual = fontFamiliesEqual;
7122
+
6681
7123
  /**
6682
7124
  * Asynchronously upgrade the engine's measurement backend to canvas once
6683
7125
  * the platform supports it and fonts have resolved. Errors are swallowed