@beyondwork/docx-react-component 1.0.41 → 1.0.43

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 (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -204,6 +204,30 @@ import {
204
204
  incrementInvalidationCounter,
205
205
  recordPerfSample,
206
206
  } from "../ui-tailwind/editor-surface/perf-probe.ts";
207
+ import {
208
+ createLoadScheduler,
209
+ type LoadScheduler,
210
+ } from "../io/load-scheduler.ts";
211
+ import {
212
+ createEditorStateChannel,
213
+ type EditorStateChannel,
214
+ } from "./editor-state-channel.ts";
215
+ import { PerfCounters } from "./perf-counters.ts";
216
+ import type {
217
+ EditorStateNamespace,
218
+ EditorStatePolicy,
219
+ EditorStateResolver,
220
+ EditorStatePersister,
221
+ } from "../api/editor-state-types.ts";
222
+ import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
223
+ import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
224
+
225
+ /** Internal extension of ExportDocxOptions that threads the collected
226
+ * editorState payload from the runtime to the docx serializer. */
227
+ interface InternalExportDocxOptions extends ExportDocxOptions {
228
+ /** @internal Schema 1.2 — collected by runtime before serialize. */
229
+ _editorState?: EditorStatePayload;
230
+ }
207
231
 
208
232
  export type Unsubscribe = () => void;
209
233
 
@@ -319,6 +343,7 @@ export interface DocumentRuntime {
319
343
  getSuggestionsSnapshot(): SuggestionsSnapshot;
320
344
  setWorkflowOverlay(overlay: WorkflowOverlay): void;
321
345
  clearWorkflowOverlay(): void;
346
+ getWorkflowOverlay(): WorkflowOverlay | null;
322
347
  getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
323
348
  getInteractionGuardSnapshot(): InteractionGuardSnapshot;
324
349
  getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
@@ -340,6 +365,23 @@ export interface DocumentRuntime {
340
365
  getRuntimeContextAnalytics(
341
366
  query?: RuntimeContextAnalyticsQuery,
342
367
  ): RuntimeContextAnalyticsSnapshot | null;
368
+ // Schema 1.2 — EditorStateChannel delegation
369
+ configureEditorStatePolicy(policy: EditorStatePolicy): void;
370
+ registerEditorStateResolver(resolver: EditorStateResolver | null): void;
371
+ registerEditorStatePersister(persister: EditorStatePersister | null): void;
372
+ getEditorStateKey(namespace: EditorStateNamespace): string | undefined;
373
+ retryPendingPersist(namespace?: EditorStateNamespace): Promise<void>;
374
+ /** Internal: exposes the channel for load-path hydration in editor-runtime-boundary. */
375
+ readonly editorStateChannel: EditorStateChannel;
376
+ /**
377
+ * L7 render-perf instrumentation. Returns a string→number map of internal
378
+ * perf counters incremented during runtime activity. Used by perf benches
379
+ * and tests to assert that a given facet rebuild ran (or did not run) for
380
+ * a given operation. Counter names are not part of the public API contract
381
+ * — they may be added or renamed across L7 phases.
382
+ */
383
+ getPerfCountersSnapshot(): Record<string, number>;
384
+ resetPerfCounters(): void;
343
385
  }
344
386
 
345
387
  export interface CommandAppliedMeta {
@@ -385,6 +427,16 @@ export interface CreateDocumentRuntimeOptions {
385
427
  ) => void;
386
428
  initialViewState?: Partial<ViewState>;
387
429
  protectionSnapshot?: ProtectionSnapshot;
430
+ /**
431
+ * Optional main-thread time-slicing scheduler. When provided, the runtime
432
+ * schedules post-`ready` idle work (e.g., sub-part hydration, compatibility
433
+ * report — reserved for future fastload passes) through it. When omitted,
434
+ * the runtime falls back to the sync scheduler — behavior is byte-identical
435
+ * to the pre-fastload runtime.
436
+ *
437
+ * See `docs/plans/fastload.md` and `src/io/load-scheduler.ts`.
438
+ */
439
+ loadScheduler?: LoadScheduler;
388
440
  }
389
441
 
390
442
  interface HistoryState {
@@ -401,6 +453,29 @@ export function createDocumentRuntime(
401
453
  const sessionId = createSessionId(options.documentId, clock());
402
454
  const listeners = new Set<() => void>();
403
455
  const eventListeners = new Set<(event: DocumentRuntimeEvent) => void>();
456
+ // Fastload P2: optional time-slicing scheduler. When caller does not pass
457
+ // one, use the sync backend so the runtime keeps its pre-fastload behavior
458
+ // end-to-end (no deferred work, idle tasks run inline). Future fastload
459
+ // passes (P6 async loader) will consume `scheduler.scheduleIdle` for
460
+ // post-ready work.
461
+ const loadScheduler: LoadScheduler =
462
+ options.loadScheduler ?? createLoadScheduler({ backendOverride: "sync" });
463
+
464
+ // L7 Phase 0 — perf counters. Each `refreshRenderSnapshot` call increments
465
+ // `refresh.all`. Phase 1 will add per-facet `facet.<name>.build` counters
466
+ // wired to the per-facet cached builders. Cost is one Map.set per call.
467
+ const perfCounters = new PerfCounters();
468
+
469
+ // L7 Phase 1 — context-analytics emit coalescing flag. Multiple events that
470
+ // each trigger context-analytics within one synchronous call stack collapse
471
+ // to a single microtask-deferred emit. Declared early because emit() (which
472
+ // checks this flag) runs during construction.
473
+ let analyticsEmitScheduled = false;
474
+
475
+ // Schema 1.2 — editor-state channel (policy, resolver/persister, debounce queues).
476
+ // Instantiated once per runtime; forwarded to the public interface.
477
+ const editorStateChannel = createEditorStateChannel();
478
+
404
479
  const history: HistoryState = {
405
480
  past: [],
406
481
  future: [],
@@ -479,6 +554,7 @@ export function createDocumentRuntime(
479
554
  zoomLevel: viewState.zoomLevel,
480
555
  },
481
556
  }),
557
+ canonicalDocument: () => state.document,
482
558
  renderKernel: () => renderKernelRef,
483
559
  getWorkflowRailInput: () => {
484
560
  if (!workflowOverlay) return null;
@@ -543,6 +619,21 @@ export function createDocumentRuntime(
543
619
  snapshot: SuggestionsSnapshot;
544
620
  }
545
621
  | undefined;
622
+ // L7 Phase 1 — review-work snapshot cache. Independent of selection.
623
+ // Inputs that bust it: workflowMarkup, comments, trackedChanges,
624
+ // document, navigation references. Per-keystroke profiling on the
625
+ // 40-page CCEP fixture showed createReviewWorkSnapshot at 211ms/call
626
+ // with no cache, dominated by per-item createDocumentLocationSnapshot.
627
+ let cachedReviewWork:
628
+ | {
629
+ comments: CommentSidebarSnapshot;
630
+ trackedChanges: TrackedChangesSnapshot;
631
+ workflowMarkup: WorkflowMarkupSnapshot;
632
+ document: CanonicalDocumentEnvelope;
633
+ navigation: DocumentNavigationSnapshot;
634
+ snapshot: ReviewWorkSnapshot;
635
+ }
636
+ | undefined;
546
637
  let cachedPageLayout:
547
638
  | {
548
639
  revisionToken: string;
@@ -605,7 +696,9 @@ export function createDocumentRuntime(
605
696
  {
606
697
  revisionToken: string;
607
698
  activeStoryKey: string;
608
- selection: EditorState["selection"];
699
+ // L7 Phase 1.6: null for non-selection queries (doc/scope/work_item)
700
+ // so selection movement doesn't invalidate these scope-stable analytics.
701
+ selection: EditorState["selection"] | null;
609
702
  readOnly: boolean;
610
703
  documentMode: DocumentMode;
611
704
  workflowOverlay: WorkflowOverlay | null;
@@ -1365,6 +1458,10 @@ export function createDocumentRuntime(
1365
1458
  protectionSnapshot,
1366
1459
  preservation: state.document.preservation,
1367
1460
  workflowMetadataSnapshot: deriveWorkflowMetadataSnapshot(),
1461
+ perfStage: (name, durationMs) => {
1462
+ perfCounters.increment(`wfMarkup.${name}.us`, Math.round(durationMs * 1000));
1463
+ perfCounters.increment(`wfMarkup.${name}.calls`);
1464
+ },
1368
1465
  });
1369
1466
  cachedWorkflowMarkupSnapshot = {
1370
1467
  revisionToken: state.revisionToken,
@@ -1385,11 +1482,21 @@ export function createDocumentRuntime(
1385
1482
  const activeStoryKey = storyTargetKey(activeStory);
1386
1483
  const queryKey = getRuntimeContextAnalyticsQueryKey(query);
1387
1484
  const cachedEntry = cachedContextAnalyticsSnapshots.get(queryKey);
1485
+ // L7 Phase 1.6 — only `scopeKind: "selection"` queries actually read
1486
+ // selection-anchored data (buildSelectionAnalytics filters markup items
1487
+ // that intersect the selection anchor). For document / workflow_scope /
1488
+ // work_item queries the selection is not an input, so changing it
1489
+ // shouldn't invalidate the cache. When selection moves within the same
1490
+ // workflow scope without text editing, document/scope/work_item analytics
1491
+ // hit; cursor-navigation bench drops accordingly.
1492
+ const effectiveScopeKind = query?.scopeKind ?? "selection";
1493
+ const selectionCacheKey =
1494
+ effectiveScopeKind === "selection" ? state.selection : null;
1388
1495
  if (
1389
1496
  cachedEntry &&
1390
1497
  cachedEntry.revisionToken === state.revisionToken &&
1391
1498
  cachedEntry.activeStoryKey === activeStoryKey &&
1392
- cachedEntry.selection === state.selection &&
1499
+ cachedEntry.selection === selectionCacheKey &&
1393
1500
  cachedEntry.readOnly === state.readOnly &&
1394
1501
  cachedEntry.documentMode === viewState.documentMode &&
1395
1502
  cachedEntry.workflowOverlay === workflowOverlay &&
@@ -1400,28 +1507,76 @@ export function createDocumentRuntime(
1400
1507
  return cachedEntry.snapshot;
1401
1508
  }
1402
1509
 
1510
+ const tWf = performance.now();
1511
+ const wfScope = getCachedWorkflowScopeSnapshot();
1512
+ const wfGuard = getCachedInteractionGuardSnapshot();
1513
+ const wfMarkup = getCachedWorkflowMarkupSnapshot();
1514
+ perfCounters.increment("ctxa.workflow.us", Math.round((performance.now() - tWf) * 1000));
1515
+
1516
+ const tSugg = performance.now();
1517
+ const suggestions = getCachedSuggestionsSnapshot(state);
1518
+ perfCounters.increment("ctxa.suggestions.us", Math.round((performance.now() - tSugg) * 1000));
1519
+
1520
+ const tNav = performance.now();
1521
+ const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
1522
+ perfCounters.increment("ctxa.navigation.us", Math.round((performance.now() - tNav) * 1000));
1523
+
1524
+ const tReview = performance.now();
1525
+ let reviewWork: ReviewWorkSnapshot;
1526
+ if (
1527
+ cachedReviewWork &&
1528
+ cachedReviewWork.comments === cachedRenderSnapshot.comments &&
1529
+ cachedReviewWork.trackedChanges === cachedRenderSnapshot.trackedChanges &&
1530
+ cachedReviewWork.workflowMarkup === wfMarkup &&
1531
+ cachedReviewWork.document === state.document &&
1532
+ cachedReviewWork.navigation === navigation
1533
+ ) {
1534
+ reviewWork = cachedReviewWork.snapshot;
1535
+ perfCounters.increment("ctxa.reviewWork.cacheHit");
1536
+ } else {
1537
+ reviewWork = createReviewWorkSnapshot({
1538
+ comments: cachedRenderSnapshot.comments,
1539
+ trackedChanges: cachedRenderSnapshot.trackedChanges,
1540
+ workflowMarkup: wfMarkup,
1541
+ document: state.document,
1542
+ navigation,
1543
+ });
1544
+ cachedReviewWork = {
1545
+ comments: cachedRenderSnapshot.comments,
1546
+ trackedChanges: cachedRenderSnapshot.trackedChanges,
1547
+ workflowMarkup: wfMarkup,
1548
+ document: state.document,
1549
+ navigation,
1550
+ snapshot: reviewWork,
1551
+ };
1552
+ perfCounters.increment("ctxa.reviewWork.cacheMiss");
1553
+ }
1554
+ perfCounters.increment("ctxa.reviewWork.us", Math.round((performance.now() - tReview) * 1000));
1555
+
1556
+ const tCompat = performance.now();
1557
+ const compat = toPublicCompatibilityReport(createDerivedCompatibility(state));
1558
+ perfCounters.increment("ctxa.compat.us", Math.round((performance.now() - tCompat) * 1000));
1559
+
1560
+ const tBuild = performance.now();
1403
1561
  const snapshot = createRuntimeContextAnalyticsSnapshot({
1404
1562
  query,
1405
1563
  renderSnapshot: cachedRenderSnapshot,
1406
1564
  workflowOverlay,
1407
- workflowScopeSnapshot: getCachedWorkflowScopeSnapshot(),
1408
- interactionGuardSnapshot: getCachedInteractionGuardSnapshot(),
1409
- workflowMarkupSnapshot: getCachedWorkflowMarkupSnapshot(),
1410
- suggestionsSnapshot: getCachedSuggestionsSnapshot(state),
1411
- reviewWorkSnapshot: createReviewWorkSnapshot({
1412
- comments: cachedRenderSnapshot.comments,
1413
- trackedChanges: cachedRenderSnapshot.trackedChanges,
1414
- workflowMarkup: getCachedWorkflowMarkupSnapshot(),
1415
- document: state.document,
1416
- navigation: getCachedDocumentNavigationSnapshot(state, activeStory),
1417
- }),
1565
+ workflowScopeSnapshot: wfScope,
1566
+ interactionGuardSnapshot: wfGuard,
1567
+ workflowMarkupSnapshot: wfMarkup,
1568
+ suggestionsSnapshot: suggestions,
1569
+ reviewWorkSnapshot: reviewWork,
1418
1570
  warnings: state.warnings.map(toPublicWarning),
1419
- compatibility: toPublicCompatibilityReport(createDerivedCompatibility(state)),
1571
+ compatibility: compat,
1420
1572
  });
1573
+ perfCounters.increment("ctxa.build.us", Math.round((performance.now() - tBuild) * 1000));
1421
1574
  cachedContextAnalyticsSnapshots.set(queryKey, {
1422
1575
  revisionToken: state.revisionToken,
1423
1576
  activeStoryKey,
1424
- selection: state.selection,
1577
+ // L7 Phase 1.6: store the selectionCacheKey we tested against, so the
1578
+ // same scopeKind invariant applies to future reads.
1579
+ selection: selectionCacheKey,
1425
1580
  readOnly: state.readOnly,
1426
1581
  documentMode: viewState.documentMode,
1427
1582
  workflowOverlay,
@@ -1508,8 +1663,17 @@ export function createDocumentRuntime(
1508
1663
  return true;
1509
1664
  }
1510
1665
 
1666
+ function timeFacet<T>(name: string, fn: () => T): T {
1667
+ const t0 = performance.now();
1668
+ const result = fn();
1669
+ perfCounters.increment(`facet.${name}.us`, Math.round((performance.now() - t0) * 1000));
1670
+ perfCounters.increment(`facet.${name}.calls`);
1671
+ return result;
1672
+ }
1673
+
1511
1674
  function refreshRenderSnapshot(): RuntimeRenderSnapshot {
1512
- const surface = getCachedSurface(state.document, activeStory);
1675
+ perfCounters.increment("refresh.all");
1676
+ const surface = timeFacet("surface", () => getCachedSurface(state.document, activeStory));
1513
1677
  return {
1514
1678
  documentId: state.documentId,
1515
1679
  sessionId: state.sessionId,
@@ -1521,11 +1685,11 @@ export function createDocumentRuntime(
1521
1685
  documentMode: viewState.documentMode,
1522
1686
  selection: toPublicSelectionSnapshot(state.selection, activeStory),
1523
1687
  activeStory,
1524
- pageLayout: getCachedPageLayoutSnapshot(state, activeStory) ?? undefined,
1688
+ pageLayout: timeFacet("layout", () => getCachedPageLayoutSnapshot(state, activeStory)) ?? undefined,
1525
1689
  documentStats: toPublicDocumentStats(state),
1526
- comments: getCachedCommentSidebarSnapshot(state),
1527
- trackedChanges: getCachedTrackedChangesSnapshot(state, surface),
1528
- compatibility: getCachedCompatibilityReport(state),
1690
+ comments: timeFacet("comments", () => getCachedCommentSidebarSnapshot(state)),
1691
+ trackedChanges: timeFacet("trackedChanges", () => getCachedTrackedChangesSnapshot(state, surface)),
1692
+ compatibility: timeFacet("compatibility", () => getCachedCompatibilityReport(state)),
1529
1693
  warnings: state.warnings.map((warning) => toPublicWarning(warning)),
1530
1694
  fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
1531
1695
  commandState: {
@@ -1603,6 +1767,40 @@ export function createDocumentRuntime(
1603
1767
  });
1604
1768
  }
1605
1769
 
1770
+ // Schema 1.2: forward channel events to the runtime event bus.
1771
+ editorStateChannel.addListener((event) => {
1772
+ switch (event.kind) {
1773
+ case "load_failed":
1774
+ emit({
1775
+ type: "editor_state_part_load_failed",
1776
+ documentId: state.documentId,
1777
+ failure: event.failure,
1778
+ });
1779
+ break;
1780
+ case "persist_failed":
1781
+ emit({
1782
+ type: "editor_state_part_persist_failed",
1783
+ documentId: state.documentId,
1784
+ failure: event.failure,
1785
+ });
1786
+ break;
1787
+ case "policy_migrated":
1788
+ emit({
1789
+ type: "editor_state_policy_migrated",
1790
+ documentId: state.documentId,
1791
+ migration: event.migration,
1792
+ });
1793
+ break;
1794
+ case "unknown_namespace":
1795
+ emit({
1796
+ type: "editor_state_unknown_namespace",
1797
+ documentId: state.documentId,
1798
+ namespace: event.namespace,
1799
+ });
1800
+ break;
1801
+ }
1802
+ });
1803
+
1606
1804
  return {
1607
1805
  subscribe(listener) {
1608
1806
  listeners.add(listener);
@@ -1662,11 +1860,34 @@ export function createDocumentRuntime(
1662
1860
  applyHistory("redo");
1663
1861
  return;
1664
1862
  }
1863
+
1864
+ if (isRuntimeStateOverlayCommand(command)) {
1865
+ applyRuntimeStateOverlayCommand(command);
1866
+ const context = {
1867
+ timestamp: normalizeCommandTimestamp(command.origin?.timestamp) ?? clock(),
1868
+ documentMode: getEffectiveDocumentMode(commandSelection),
1869
+ defaultAuthorId: defaultAuthorId ?? undefined,
1870
+ } as const;
1871
+ const noopTransaction: EditorTransaction = {
1872
+ nextState: state,
1873
+ mapping: createEmptyMapping(),
1874
+ effects: { warningsAdded: [], warningsCleared: [] },
1875
+ historyBoundary: "skip",
1876
+ markDirty: false,
1877
+ };
1878
+ options.onCommandApplied?.(command, noopTransaction, context, {
1879
+ preSelection: state.selection,
1880
+ activeStory,
1881
+ });
1882
+ return;
1883
+ }
1884
+
1665
1885
  try {
1666
1886
  const context = {
1667
1887
  timestamp: normalizeCommandTimestamp(command.origin?.timestamp) ?? clock(),
1668
1888
  documentMode: getEffectiveDocumentMode(commandSelection),
1669
1889
  defaultAuthorId: defaultAuthorId ?? undefined,
1890
+ renderSnapshot: cachedRenderSnapshot,
1670
1891
  } as const;
1671
1892
  const preSelection = commandSelection;
1672
1893
  const preActiveStory = activeStory;
@@ -1685,6 +1906,10 @@ export function createDocumentRuntime(
1685
1906
  if (command.type === "history.undo" || command.type === "history.redo") {
1686
1907
  return;
1687
1908
  }
1909
+ if (isRuntimeStateOverlayCommand(command)) {
1910
+ applyRuntimeStateOverlayCommand(command);
1911
+ return;
1912
+ }
1688
1913
  if (meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory)) {
1689
1914
  activeStory = meta.activeStory;
1690
1915
  storySelections.set(
@@ -1695,7 +1920,17 @@ export function createDocumentRuntime(
1695
1920
  const replayState = meta?.preSelection
1696
1921
  ? { ...state, selection: meta.preSelection }
1697
1922
  : state;
1698
- const transaction = executeEditorCommand(replayState, command, context);
1923
+ const replaySnapshot = meta?.preSelection
1924
+ ? {
1925
+ ...cachedRenderSnapshot,
1926
+ selection: toPublicSelectionSnapshot(meta.preSelection, activeStory),
1927
+ }
1928
+ : cachedRenderSnapshot;
1929
+ const replayContext = {
1930
+ ...context,
1931
+ renderSnapshot: replaySnapshot,
1932
+ };
1933
+ const transaction = executeEditorCommand(replayState, command, replayContext);
1699
1934
  commitRemote(transaction);
1700
1935
  } catch (error) {
1701
1936
  emitError(toRuntimeError(error));
@@ -2156,6 +2391,9 @@ export function createDocumentRuntime(
2156
2391
  state.selection.head,
2157
2392
  activeStory,
2158
2393
  options,
2394
+ // P5 — TOC entries print Word's display number (honors page-
2395
+ // number restarts), not the raw 0-based pageIndex+1.
2396
+ (pageIndex: number) => layoutFacet.getDisplayPageNumber(pageIndex),
2159
2397
  );
2160
2398
  if (refreshed.changed) {
2161
2399
  this.dispatch({
@@ -2213,7 +2451,37 @@ export function createDocumentRuntime(
2213
2451
  throw toStructuredRuntimeException(error);
2214
2452
  }
2215
2453
 
2216
- const result = await options.exportDocx(this.getSessionState(), exportOptions);
2454
+ // Schema 1.2: collect editor-state payload (flushes pending persists).
2455
+ const collectedEditorState = await collectEditorStateForSerialize({
2456
+ channel: editorStateChannel,
2457
+ getNamespaceData: (ns) => {
2458
+ switch (ns) {
2459
+ case "hostAnnotations": {
2460
+ const snap = deriveHostAnnotationSnapshot();
2461
+ if (!snap || snap.annotations.length === 0) return null;
2462
+ return { schemaVersion: "host-annotation-overlay/1", data: snap };
2463
+ }
2464
+ case "workflowOverlay": {
2465
+ const ov = workflowOverlay;
2466
+ if (!ov) return null;
2467
+ return { schemaVersion: "workflow-overlay/1", data: ov };
2468
+ }
2469
+ case "workflowMetadata": {
2470
+ const meta = deriveWorkflowMetadataSnapshot();
2471
+ if (!meta || (meta.definitions.length === 0 && meta.entries.length === 0)) return null;
2472
+ return { schemaVersion: "workflow-metadata/1", data: meta };
2473
+ }
2474
+ case "workItems":
2475
+ return null;
2476
+ }
2477
+ },
2478
+ });
2479
+ const internalOptions: InternalExportDocxOptions = {
2480
+ ...exportOptions,
2481
+ _editorState: collectedEditorState,
2482
+ };
2483
+
2484
+ const result = await options.exportDocx(this.getSessionState(), internalOptions);
2217
2485
 
2218
2486
  emit({
2219
2487
  type: "export_completed",
@@ -2224,47 +2492,25 @@ export function createDocumentRuntime(
2224
2492
  return result;
2225
2493
  },
2226
2494
  setWorkflowOverlay(overlay) {
2227
- workflowOverlay = structuredClone(overlay);
2228
- cachedRenderSnapshot = refreshRenderSnapshot();
2229
- const snapshot = deriveWorkflowScopeSnapshot()!;
2230
- emit({
2231
- type: "workflow_overlay_changed",
2232
- documentId: state.documentId,
2233
- snapshot,
2495
+ this.dispatch({
2496
+ type: "workflow.set-overlay",
2497
+ overlay,
2498
+ origin: createOrigin("api", clock()),
2499
+ });
2500
+ editorStateChannel.recordMutation("workflowOverlay", {
2501
+ namespace: "workflowOverlay",
2502
+ schemaVersion: "workflow-overlay/1",
2503
+ data: overlay,
2234
2504
  });
2235
- if (workflowOverlay.activeWorkItemId !== undefined) {
2236
- emit({
2237
- type: "workflow_active_work_item_changed",
2238
- documentId: state.documentId,
2239
- activeWorkItemId: workflowOverlay.activeWorkItemId ?? null,
2240
- });
2241
- }
2242
- for (const listener of listeners) {
2243
- listener();
2244
- }
2245
2505
  },
2246
2506
  clearWorkflowOverlay() {
2247
- workflowOverlay = null;
2248
- cachedRenderSnapshot = refreshRenderSnapshot();
2249
- emit({
2250
- type: "workflow_active_work_item_changed",
2251
- documentId: state.documentId,
2252
- activeWorkItemId: null,
2253
- });
2254
- emit({
2255
- type: "workflow_overlay_changed",
2256
- documentId: state.documentId,
2257
- snapshot: {
2258
- overlayPresent: false,
2259
- activeWorkItemId: null,
2260
- scopes: [],
2261
- candidates: [],
2262
- blockedReasons: [],
2263
- },
2507
+ this.dispatch({
2508
+ type: "workflow.clear-overlay",
2509
+ origin: createOrigin("api", clock()),
2264
2510
  });
2265
- for (const listener of listeners) {
2266
- listener();
2267
- }
2511
+ },
2512
+ getWorkflowOverlay() {
2513
+ return workflowOverlay;
2268
2514
  },
2269
2515
  getWorkflowScopeSnapshot() {
2270
2516
  return getCachedWorkflowScopeSnapshot();
@@ -2276,77 +2522,56 @@ export function createDocumentRuntime(
2276
2522
  return getCachedWorkflowMarkupSnapshot();
2277
2523
  },
2278
2524
  setWorkflowMetadataDefinitions(definitions) {
2279
- workflowMetadataDefinitions = structuredClone(definitions);
2280
- const snapshot = deriveWorkflowMetadataSnapshot();
2281
- emit({
2282
- type: "workflow_metadata_changed",
2283
- documentId: state.documentId,
2284
- snapshot,
2525
+ this.dispatch({
2526
+ type: "workflow.set-metadata-definitions",
2527
+ definitions,
2528
+ origin: createOrigin("api", clock()),
2285
2529
  });
2286
- for (const listener of listeners) {
2287
- listener();
2288
- }
2289
2530
  },
2290
2531
  clearWorkflowMetadataDefinitions() {
2291
- workflowMetadataDefinitions = [];
2292
- const snapshot = deriveWorkflowMetadataSnapshot();
2293
- emit({
2294
- type: "workflow_metadata_changed",
2295
- documentId: state.documentId,
2296
- snapshot,
2532
+ this.dispatch({
2533
+ type: "workflow.clear-metadata-definitions",
2534
+ origin: createOrigin("api", clock()),
2297
2535
  });
2298
- for (const listener of listeners) {
2299
- listener();
2300
- }
2301
2536
  },
2302
2537
  setWorkflowMetadataEntries(entries) {
2303
- workflowMetadataEntries = structuredClone(entries);
2304
- const snapshot = deriveWorkflowMetadataSnapshot();
2305
- emit({
2306
- type: "workflow_metadata_changed",
2307
- documentId: state.documentId,
2308
- snapshot,
2538
+ this.dispatch({
2539
+ type: "workflow.set-metadata-entries",
2540
+ entries,
2541
+ origin: createOrigin("api", clock()),
2542
+ });
2543
+ editorStateChannel.recordMutation("workflowMetadata", {
2544
+ namespace: "workflowMetadata",
2545
+ schemaVersion: "workflow-metadata/1",
2546
+ data: entries,
2309
2547
  });
2310
- for (const listener of listeners) {
2311
- listener();
2312
- }
2313
2548
  },
2314
2549
  clearWorkflowMetadataEntries() {
2315
- workflowMetadataEntries = [];
2316
- const snapshot = deriveWorkflowMetadataSnapshot();
2317
- emit({
2318
- type: "workflow_metadata_changed",
2319
- documentId: state.documentId,
2320
- snapshot,
2550
+ this.dispatch({
2551
+ type: "workflow.clear-metadata-entries",
2552
+ origin: createOrigin("api", clock()),
2321
2553
  });
2322
- for (const listener of listeners) {
2323
- listener();
2324
- }
2325
2554
  },
2326
2555
  getWorkflowMetadataSnapshot() {
2327
2556
  return deriveWorkflowMetadataSnapshot();
2328
2557
  },
2329
2558
  setHostAnnotationOverlay(overlay) {
2330
- hostAnnotationOverlay = structuredClone(overlay);
2331
- emit({
2332
- type: "host_annotation_overlay_changed",
2333
- documentId: state.documentId,
2334
- snapshot: deriveHostAnnotationSnapshot(),
2559
+ this.dispatch({
2560
+ type: "host-annotation.set-overlay",
2561
+ overlay,
2562
+ origin: createOrigin("api", clock()),
2563
+ });
2564
+ editorStateChannel.recordMutation("hostAnnotations", {
2565
+ namespace: "hostAnnotations",
2566
+ schemaVersion: "host-annotation-overlay/1",
2567
+ data: overlay,
2335
2568
  });
2336
- for (const listener of listeners) {
2337
- listener();
2338
- }
2339
2569
  },
2340
2570
  clearHostAnnotationOverlay() {
2341
- hostAnnotationOverlay = null;
2342
- emit({
2343
- type: "host_annotation_overlay_changed",
2344
- documentId: state.documentId,
2345
- snapshot: deriveHostAnnotationSnapshot(),
2571
+ this.dispatch({
2572
+ type: "host-annotation.clear-overlay",
2573
+ origin: createOrigin("api", clock()),
2346
2574
  });
2347
- for (const listener of listeners) {
2348
- listener();
2349
- }
2350
2575
  },
2351
2576
  getHostAnnotationSnapshot() {
2352
2577
  return deriveHostAnnotationSnapshot();
@@ -2408,6 +2633,31 @@ export function createDocumentRuntime(
2408
2633
  getRuntimeContextAnalytics(query) {
2409
2634
  return getCachedRuntimeContextAnalytics(query);
2410
2635
  },
2636
+ // Schema 1.2 — EditorStateChannel delegation
2637
+ configureEditorStatePolicy(policy) {
2638
+ editorStateChannel.setPolicy(policy);
2639
+ },
2640
+ registerEditorStateResolver(resolver) {
2641
+ editorStateChannel.setResolver(resolver);
2642
+ },
2643
+ registerEditorStatePersister(persister) {
2644
+ editorStateChannel.setPersister(persister);
2645
+ },
2646
+ getEditorStateKey(namespace) {
2647
+ return editorStateChannel.getKey(namespace);
2648
+ },
2649
+ async retryPendingPersist(namespace) {
2650
+ await editorStateChannel.retry(namespace);
2651
+ },
2652
+ get editorStateChannel() {
2653
+ return editorStateChannel;
2654
+ },
2655
+ getPerfCountersSnapshot() {
2656
+ return perfCounters.snapshot();
2657
+ },
2658
+ resetPerfCounters() {
2659
+ perfCounters.reset();
2660
+ },
2411
2661
  };
2412
2662
 
2413
2663
  function applyHistory(direction: "undo" | "redo"): void {
@@ -2455,15 +2705,14 @@ export function createDocumentRuntime(
2455
2705
  function applyTransactionToState(transaction: EditorTransaction): void {
2456
2706
  const previous = state;
2457
2707
 
2708
+ const tApply0 = performance.now();
2458
2709
  protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
2710
+ const tFinalize0 = performance.now();
2459
2711
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
2712
+ perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
2460
2713
  storySelections.set(storyTargetKey(activeStory), state.selection);
2461
2714
 
2462
- // Signal a bounded content-edit invalidation to the layout engine so the
2463
- // next layout query can splice rather than rebuild the full graph. The
2464
- // engine analyzes the reason against its cached graph and falls back to
2465
- // a full rebuild when the edit crosses section boundaries or reaches a
2466
- // page the engine cannot safely resume from.
2715
+ const tInvalidate0 = performance.now();
2467
2716
  if (transaction.markDirty && transaction.mapping.steps.length > 0) {
2468
2717
  let minFrom = Infinity;
2469
2718
  let maxTo = -Infinity;
@@ -2476,17 +2725,20 @@ export function createDocumentRuntime(
2476
2725
  layoutEngine.invalidate({ kind: "content-edit", from: minFrom, to: maxTo });
2477
2726
  }
2478
2727
  }
2479
-
2480
- // Font-loader refresh on subParts identity change — this is the
2481
- // lightweight proxy for "a change that could affect which fonts the
2482
- // canvas backend measures against". Typing edits don't rebuild
2483
- // subParts; style + font + numbering imports do.
2484
2728
  if (previous.document.subParts !== state.document.subParts) {
2485
2729
  fontLoader.refresh(collectFontLoaderInput(state.document));
2730
+ layoutEngine.invalidateMeasurementCache();
2486
2731
  }
2732
+ perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
2487
2733
 
2734
+ const tRefresh0 = performance.now();
2488
2735
  cachedRenderSnapshot = refreshRenderSnapshot();
2736
+ perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
2737
+
2738
+ const tNotify0 = performance.now();
2489
2739
  notify(previous, state, transaction);
2740
+ perfCounters.increment("commit.notify.us", Math.round((performance.now() - tNotify0) * 1000));
2741
+ perfCounters.increment("commit.total.us", Math.round((performance.now() - tApply0) * 1000));
2490
2742
  }
2491
2743
 
2492
2744
  function notify(
@@ -2715,6 +2967,7 @@ export function createDocumentRuntime(
2715
2967
  timestamp,
2716
2968
  documentMode: getEffectiveDocumentMode(selection),
2717
2969
  defaultAuthorId: defaultAuthorId ?? undefined,
2970
+ renderSnapshot: cachedRenderSnapshot,
2718
2971
  } as const;
2719
2972
  const baseState = selection === state.selection
2720
2973
  ? state
@@ -2946,10 +3199,142 @@ export function createDocumentRuntime(
2946
3199
  return nextReview;
2947
3200
  }
2948
3201
 
3202
+ // L7 Phase 1 — context-analytics emissions are coalesced per microtask.
3203
+ // A single keystroke commit fires multiple events that each independently
3204
+ // trigger `emitContextAnalyticsChanged` (selection_changed, change_authored,
3205
+ // …). Each emission re-walks every workflow markup item (~211 ms on a
3206
+ // 40-page CCEP fixture) so unbatched, an N-event commit costs N × 211 ms.
3207
+ // Coalescing collapses the burst to a single emit at the tail of the
3208
+ // synchronous call stack — one per commit. (Flag declared above near
3209
+ // perfCounters so it is initialized before construction-time emits run.)
3210
+ function scheduleContextAnalyticsEmit(): void {
3211
+ if (analyticsEmitScheduled) {
3212
+ perfCounters.increment("emit.contextAnalytics.coalesced");
3213
+ return;
3214
+ }
3215
+ analyticsEmitScheduled = true;
3216
+ queueMicrotask(() => {
3217
+ // Reset BEFORE the emit so any synchronous re-entrant emits triggered
3218
+ // by listener callbacks schedule a fresh second microtask (no lost
3219
+ // updates). Reversing this order would drop bursts originating in
3220
+ // listeners.
3221
+ analyticsEmitScheduled = false;
3222
+ const t = performance.now();
3223
+ emitContextAnalyticsChanged();
3224
+ perfCounters.increment("emit.contextAnalytics.us", Math.round((performance.now() - t) * 1000));
3225
+ });
3226
+ }
3227
+
2949
3228
  function emit(event: DocumentRuntimeEvent): void {
3229
+ perfCounters.increment(`emit.${event.type}.calls`);
3230
+ const t0 = performance.now();
2950
3231
  emitInternal(event);
3232
+ perfCounters.increment(`emit.${event.type}.internal.us`, Math.round((performance.now() - t0) * 1000));
2951
3233
  if (shouldEmitContextAnalyticsChanged(event)) {
2952
- emitContextAnalyticsChanged();
3234
+ scheduleContextAnalyticsEmit();
3235
+ }
3236
+ }
3237
+
3238
+ function applyRuntimeStateOverlayCommand(
3239
+ command: RuntimeStateOverlayCommand,
3240
+ ): void {
3241
+ switch (command.type) {
3242
+ case "workflow.set-overlay": {
3243
+ workflowOverlay = structuredClone(command.overlay);
3244
+ cachedRenderSnapshot = refreshRenderSnapshot();
3245
+ const snapshot = deriveWorkflowScopeSnapshot()!;
3246
+ emit({
3247
+ type: "workflow_overlay_changed",
3248
+ documentId: state.documentId,
3249
+ snapshot,
3250
+ });
3251
+ if (workflowOverlay.activeWorkItemId !== undefined) {
3252
+ emit({
3253
+ type: "workflow_active_work_item_changed",
3254
+ documentId: state.documentId,
3255
+ activeWorkItemId: workflowOverlay.activeWorkItemId ?? null,
3256
+ });
3257
+ }
3258
+ break;
3259
+ }
3260
+ case "workflow.clear-overlay": {
3261
+ workflowOverlay = null;
3262
+ cachedRenderSnapshot = refreshRenderSnapshot();
3263
+ emit({
3264
+ type: "workflow_active_work_item_changed",
3265
+ documentId: state.documentId,
3266
+ activeWorkItemId: null,
3267
+ });
3268
+ emit({
3269
+ type: "workflow_overlay_changed",
3270
+ documentId: state.documentId,
3271
+ snapshot: {
3272
+ overlayPresent: false,
3273
+ activeWorkItemId: null,
3274
+ scopes: [],
3275
+ candidates: [],
3276
+ blockedReasons: [],
3277
+ },
3278
+ });
3279
+ break;
3280
+ }
3281
+ case "workflow.set-metadata-definitions": {
3282
+ workflowMetadataDefinitions = structuredClone(command.definitions);
3283
+ emit({
3284
+ type: "workflow_metadata_changed",
3285
+ documentId: state.documentId,
3286
+ snapshot: deriveWorkflowMetadataSnapshot(),
3287
+ });
3288
+ break;
3289
+ }
3290
+ case "workflow.clear-metadata-definitions": {
3291
+ workflowMetadataDefinitions = [];
3292
+ emit({
3293
+ type: "workflow_metadata_changed",
3294
+ documentId: state.documentId,
3295
+ snapshot: deriveWorkflowMetadataSnapshot(),
3296
+ });
3297
+ break;
3298
+ }
3299
+ case "workflow.set-metadata-entries": {
3300
+ workflowMetadataEntries = structuredClone(command.entries);
3301
+ emit({
3302
+ type: "workflow_metadata_changed",
3303
+ documentId: state.documentId,
3304
+ snapshot: deriveWorkflowMetadataSnapshot(),
3305
+ });
3306
+ break;
3307
+ }
3308
+ case "workflow.clear-metadata-entries": {
3309
+ workflowMetadataEntries = [];
3310
+ emit({
3311
+ type: "workflow_metadata_changed",
3312
+ documentId: state.documentId,
3313
+ snapshot: deriveWorkflowMetadataSnapshot(),
3314
+ });
3315
+ break;
3316
+ }
3317
+ case "host-annotation.set-overlay": {
3318
+ hostAnnotationOverlay = structuredClone(command.overlay);
3319
+ emit({
3320
+ type: "host_annotation_overlay_changed",
3321
+ documentId: state.documentId,
3322
+ snapshot: deriveHostAnnotationSnapshot(),
3323
+ });
3324
+ break;
3325
+ }
3326
+ case "host-annotation.clear-overlay": {
3327
+ hostAnnotationOverlay = null;
3328
+ emit({
3329
+ type: "host_annotation_overlay_changed",
3330
+ documentId: state.documentId,
3331
+ snapshot: deriveHostAnnotationSnapshot(),
3332
+ });
3333
+ break;
3334
+ }
3335
+ }
3336
+ for (const listener of listeners) {
3337
+ listener();
2953
3338
  }
2954
3339
  }
2955
3340
 
@@ -2993,7 +3378,23 @@ export function createDocumentRuntime(
2993
3378
  }
2994
3379
 
2995
3380
  function emitContextAnalyticsChanged(): void {
3381
+ // L7 Phase 1 — short-circuit when no consumer can observe the event.
3382
+ // The downstream cost is dominated by createRuntimeContextAnalyticsSnapshot,
3383
+ // which assembles ~8 facets (suggestions, navigation, review-work,
3384
+ // workflow-markup, etc.) and runs > 300 ms on a 40-page CCEP fixture
3385
+ // because its cache key includes `selection` and selection moves on
3386
+ // every keystroke. Skipping here when nobody is subscribed eliminates
3387
+ // the cost in headless/SSR/test usage. Production keeps its `onEvent`
3388
+ // handler, so the production typing-latency win lives in Phase 1.5
3389
+ // (microtask coalescing) and Phase 1.6 (per-input-axis caching of the
3390
+ // analytics inputs).
3391
+ if (options.onEvent === undefined && eventListeners.size === 0) {
3392
+ perfCounters.increment("emit.contextAnalytics.skippedNoListeners");
3393
+ return;
3394
+ }
3395
+ const t0 = performance.now();
2996
3396
  const trackedSnapshots = collectTrackedContextAnalyticsSnapshots();
3397
+ perfCounters.increment("emit.contextAnalytics.collect.us", Math.round((performance.now() - t0) * 1000));
2997
3398
  if (
2998
3399
  lastEmittedContextAnalyticsSnapshots !== undefined &&
2999
3400
  trackedContextAnalyticsSnapshotsEqual(lastEmittedContextAnalyticsSnapshots, trackedSnapshots)
@@ -3111,6 +3512,39 @@ function normalizeCommandTimestamp(value: unknown): string | undefined {
3111
3512
  return undefined;
3112
3513
  }
3113
3514
 
3515
+ type RuntimeStateOverlayCommand = Extract<
3516
+ EditorCommand,
3517
+ {
3518
+ type:
3519
+ | "workflow.set-overlay"
3520
+ | "workflow.clear-overlay"
3521
+ | "workflow.set-metadata-definitions"
3522
+ | "workflow.clear-metadata-definitions"
3523
+ | "workflow.set-metadata-entries"
3524
+ | "workflow.clear-metadata-entries"
3525
+ | "host-annotation.set-overlay"
3526
+ | "host-annotation.clear-overlay";
3527
+ }
3528
+ >;
3529
+
3530
+ function isRuntimeStateOverlayCommand(
3531
+ command: EditorCommand,
3532
+ ): command is RuntimeStateOverlayCommand {
3533
+ switch (command.type) {
3534
+ case "workflow.set-overlay":
3535
+ case "workflow.clear-overlay":
3536
+ case "workflow.set-metadata-definitions":
3537
+ case "workflow.clear-metadata-definitions":
3538
+ case "workflow.set-metadata-entries":
3539
+ case "workflow.clear-metadata-entries":
3540
+ case "host-annotation.set-overlay":
3541
+ case "host-annotation.clear-overlay":
3542
+ return true;
3543
+ default:
3544
+ return false;
3545
+ }
3546
+ }
3547
+
3114
3548
  function finalizeState(
3115
3549
  state: EditorState,
3116
3550
  markDirty: boolean,
@@ -3684,6 +4118,14 @@ const NON_MUTATION_COMMANDS = new Set([
3684
4118
  "warning.add",
3685
4119
  "warning.clear",
3686
4120
  "comment.open",
4121
+ "workflow.set-overlay",
4122
+ "workflow.clear-overlay",
4123
+ "workflow.set-metadata-definitions",
4124
+ "workflow.clear-metadata-definitions",
4125
+ "workflow.set-metadata-entries",
4126
+ "workflow.clear-metadata-entries",
4127
+ "host-annotation.set-overlay",
4128
+ "host-annotation.clear-overlay",
3687
4129
  ]);
3688
4130
 
3689
4131
  /** Mutation commands that are not yet supported in suggesting mode. */
@@ -4008,6 +4450,7 @@ function refreshDocumentTableOfContents(
4008
4450
  selectionHead: number,
4009
4451
  activeStory: EditorStoryTarget,
4010
4452
  options?: TocRefreshOptions,
4453
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
4011
4454
  ): {
4012
4455
  document: CanonicalDocumentEnvelope;
4013
4456
  result: TocRefreshResult;
@@ -4038,7 +4481,7 @@ function refreshDocumentTableOfContents(
4038
4481
  }
4039
4482
  const nextField: FieldNode = {
4040
4483
  ...field,
4041
- children: buildTocInlineNodes(entries),
4484
+ children: buildTocInlineNodes(entries, resolveDisplayPageNumber),
4042
4485
  refreshStatus: "current",
4043
4486
  };
4044
4487
  if (flattenInlineDisplayText(nextField.children) !== flattenInlineDisplayText(field.children)) {
@@ -4221,14 +4664,26 @@ function buildInlineNodesFromDisplayText(text: string): InlineNode[] {
4221
4664
  return children;
4222
4665
  }
4223
4666
 
4667
+ /**
4668
+ * P5 — TOC entry rendering with display-number resolution. When
4669
+ * `resolveDisplayPageNumber` is supplied, TOC entries print the number
4670
+ * Word would print on the page (honors `w:pgNumType/@w:start` restarts
4671
+ * for front-matter roman numerals → body arabic restart). Without the
4672
+ * resolver, falls back to the raw `pageIndex + 1` (pre-P5 behavior).
4673
+ */
4224
4674
  function buildTocInlineNodes(
4225
4675
  entries: ReadonlyArray<{ level: number; text: string; pageIndex: number }>,
4676
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
4226
4677
  ): InlineNode[] {
4227
4678
  const children: InlineNode[] = [];
4228
4679
  entries.forEach((entry, index) => {
4229
4680
  children.push({ type: "text", text: entry.text });
4230
4681
  children.push({ type: "tab" });
4231
- children.push({ type: "text", text: String(entry.pageIndex + 1) });
4682
+ const displayed = resolveDisplayPageNumber?.(entry.pageIndex);
4683
+ children.push({
4684
+ type: "text",
4685
+ text: String(displayed ?? entry.pageIndex + 1),
4686
+ });
4232
4687
  if (index < entries.length - 1) {
4233
4688
  children.push({ type: "hard_break" });
4234
4689
  }
@@ -4236,6 +4691,14 @@ function buildTocInlineNodes(
4236
4691
  return children;
4237
4692
  }
4238
4693
 
4694
+ /** Test-only export of `buildTocInlineNodes` (P5 unit tests). */
4695
+ export function __buildTocInlineNodes(
4696
+ entries: ReadonlyArray<{ level: number; text: string; pageIndex: number }>,
4697
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
4698
+ ): InlineNode[] {
4699
+ return buildTocInlineNodes(entries, resolveDisplayPageNumber);
4700
+ }
4701
+
4239
4702
  function collectFieldsFromSubParts(
4240
4703
  subParts: SubPartsCatalog | undefined,
4241
4704
  entries: FieldEntrySnapshot[],
@@ -4284,6 +4747,25 @@ function resolveSupportedFieldDisplay(
4284
4747
  if (field.fieldFamily === "TOC") {
4285
4748
  return undefined;
4286
4749
  }
4750
+ if (field.fieldFamily === "PAGE") {
4751
+ const page = resolveRepresentativePageForStory(navigation, storyTarget);
4752
+ if (!page) {
4753
+ return { displayText: "", refreshStatus: "unresolvable" };
4754
+ }
4755
+ return {
4756
+ displayText: String(resolveDisplayedPageNumber(page)),
4757
+ refreshStatus: "current",
4758
+ };
4759
+ }
4760
+ if (field.fieldFamily === "NUMPAGES") {
4761
+ if (navigation.pageCount === 0) {
4762
+ return { displayText: "", refreshStatus: "unresolvable" };
4763
+ }
4764
+ return {
4765
+ displayText: String(navigation.pageCount),
4766
+ refreshStatus: "current",
4767
+ };
4768
+ }
4287
4769
  if (!field.fieldTarget) {
4288
4770
  return { displayText: "", refreshStatus: "unresolvable" };
4289
4771
  }
@@ -4575,8 +5057,38 @@ function remapProtectionSnapshot(
4575
5057
  * let the loader register system fonts it finds via
4576
5058
  * `document.fonts.check(...)`.
4577
5059
  */
5060
+ // P14.d — memoize the font-family walk by `(content, styles)` reference
5061
+ // identity. Both shapes change identity only on a real edit / import /
5062
+ // style mutation; per-keystroke edits keep the same references because
5063
+ // `finalizeState` shallow-spreads `state.document`. WeakMap two-level
5064
+ // cache so the entries free with the documents that own them.
5065
+ const fontLoaderInputCache = new WeakMap<
5066
+ object,
5067
+ WeakMap<object, { families: readonly string[] }>
5068
+ >();
5069
+
4578
5070
  function collectFontLoaderInput(
4579
5071
  document: CanonicalDocumentEnvelope,
5072
+ ): { families: readonly string[] } {
5073
+ const contentKey = document.content as unknown as object;
5074
+ const stylesKey = (document.styles ?? FONT_LOADER_EMPTY_STYLES_KEY) as unknown as object;
5075
+ let stylesCache = fontLoaderInputCache.get(contentKey);
5076
+ if (stylesCache) {
5077
+ const cached = stylesCache.get(stylesKey);
5078
+ if (cached) return cached;
5079
+ } else {
5080
+ stylesCache = new WeakMap();
5081
+ fontLoaderInputCache.set(contentKey, stylesCache);
5082
+ }
5083
+ const result = collectFontLoaderInputUncached(document);
5084
+ stylesCache.set(stylesKey, result);
5085
+ return result;
5086
+ }
5087
+
5088
+ const FONT_LOADER_EMPTY_STYLES_KEY = Object.freeze({});
5089
+
5090
+ function collectFontLoaderInputUncached(
5091
+ document: CanonicalDocumentEnvelope,
4580
5092
  ): { families: readonly string[] } {
4581
5093
  try {
4582
5094
  const families = new Set<string>();
@@ -4604,6 +5116,9 @@ function collectFontLoaderInput(
4604
5116
  }
4605
5117
  }
4606
5118
 
5119
+ /** Test-only export of the uncached walk so memoization tests can spy on it. */
5120
+ export const __collectFontLoaderInputUncached = collectFontLoaderInputUncached;
5121
+
4607
5122
  /**
4608
5123
  * Asynchronously upgrade the engine's measurement backend to canvas once
4609
5124
  * the platform supports it and fonts have resolved. Errors are swallowed