@beyondwork/docx-react-component 1.0.42 → 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 (51) hide show
  1. package/package.json +30 -41
  2. package/src/api/editor-state-types.ts +110 -0
  3. package/src/api/public-types.ts +194 -1
  4. package/src/core/commands/index.ts +33 -8
  5. package/src/core/search/search-text.ts +15 -2
  6. package/src/index.ts +13 -0
  7. package/src/io/docx-session.ts +672 -2
  8. package/src/io/load-scheduler.ts +230 -0
  9. package/src/io/normalize/normalize-text.ts +83 -0
  10. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  11. package/src/io/ooxml/workflow-payload.ts +172 -1
  12. package/src/runtime/collab-session.ts +1 -1
  13. package/src/runtime/document-runtime.ts +364 -36
  14. package/src/runtime/editor-state-channel.ts +544 -0
  15. package/src/runtime/editor-state-integration.ts +217 -0
  16. package/src/runtime/layout/index.ts +2 -0
  17. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  18. package/src/runtime/layout/layout-engine-instance.ts +17 -2
  19. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  20. package/src/runtime/layout/public-facet.ts +400 -1
  21. package/src/runtime/perf-counters.ts +28 -0
  22. package/src/runtime/render/render-frame-types.ts +17 -0
  23. package/src/runtime/render/render-kernel.ts +172 -29
  24. package/src/runtime/surface-projection.ts +10 -5
  25. package/src/runtime/workflow-markup.ts +71 -16
  26. package/src/ui/WordReviewEditor.tsx +67 -45
  27. package/src/ui/editor-command-bag.ts +14 -0
  28. package/src/ui/editor-runtime-boundary.ts +110 -11
  29. package/src/ui/editor-shell-view.tsx +10 -0
  30. package/src/ui/editor-surface-controller.tsx +5 -0
  31. package/src/ui/headless/selection-helpers.ts +10 -0
  32. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  33. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  34. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  35. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  36. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  37. package/src/ui-tailwind/editor-surface/pm-schema.ts +152 -4
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  39. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  40. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  42. package/src/ui-tailwind/index.ts +5 -1
  43. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  44. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  45. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  46. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  47. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  48. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  49. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  50. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  51. package/src/ui-tailwind/tw-review-workspace.tsx +172 -94
@@ -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
 
@@ -341,6 +365,23 @@ export interface DocumentRuntime {
341
365
  getRuntimeContextAnalytics(
342
366
  query?: RuntimeContextAnalyticsQuery,
343
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;
344
385
  }
345
386
 
346
387
  export interface CommandAppliedMeta {
@@ -386,6 +427,16 @@ export interface CreateDocumentRuntimeOptions {
386
427
  ) => void;
387
428
  initialViewState?: Partial<ViewState>;
388
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;
389
440
  }
390
441
 
391
442
  interface HistoryState {
@@ -402,6 +453,29 @@ export function createDocumentRuntime(
402
453
  const sessionId = createSessionId(options.documentId, clock());
403
454
  const listeners = new Set<() => void>();
404
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
+
405
479
  const history: HistoryState = {
406
480
  past: [],
407
481
  future: [],
@@ -480,6 +554,7 @@ export function createDocumentRuntime(
480
554
  zoomLevel: viewState.zoomLevel,
481
555
  },
482
556
  }),
557
+ canonicalDocument: () => state.document,
483
558
  renderKernel: () => renderKernelRef,
484
559
  getWorkflowRailInput: () => {
485
560
  if (!workflowOverlay) return null;
@@ -544,6 +619,21 @@ export function createDocumentRuntime(
544
619
  snapshot: SuggestionsSnapshot;
545
620
  }
546
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;
547
637
  let cachedPageLayout:
548
638
  | {
549
639
  revisionToken: string;
@@ -606,7 +696,9 @@ export function createDocumentRuntime(
606
696
  {
607
697
  revisionToken: string;
608
698
  activeStoryKey: string;
609
- 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;
610
702
  readOnly: boolean;
611
703
  documentMode: DocumentMode;
612
704
  workflowOverlay: WorkflowOverlay | null;
@@ -1366,6 +1458,10 @@ export function createDocumentRuntime(
1366
1458
  protectionSnapshot,
1367
1459
  preservation: state.document.preservation,
1368
1460
  workflowMetadataSnapshot: deriveWorkflowMetadataSnapshot(),
1461
+ perfStage: (name, durationMs) => {
1462
+ perfCounters.increment(`wfMarkup.${name}.us`, Math.round(durationMs * 1000));
1463
+ perfCounters.increment(`wfMarkup.${name}.calls`);
1464
+ },
1369
1465
  });
1370
1466
  cachedWorkflowMarkupSnapshot = {
1371
1467
  revisionToken: state.revisionToken,
@@ -1386,11 +1482,21 @@ export function createDocumentRuntime(
1386
1482
  const activeStoryKey = storyTargetKey(activeStory);
1387
1483
  const queryKey = getRuntimeContextAnalyticsQueryKey(query);
1388
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;
1389
1495
  if (
1390
1496
  cachedEntry &&
1391
1497
  cachedEntry.revisionToken === state.revisionToken &&
1392
1498
  cachedEntry.activeStoryKey === activeStoryKey &&
1393
- cachedEntry.selection === state.selection &&
1499
+ cachedEntry.selection === selectionCacheKey &&
1394
1500
  cachedEntry.readOnly === state.readOnly &&
1395
1501
  cachedEntry.documentMode === viewState.documentMode &&
1396
1502
  cachedEntry.workflowOverlay === workflowOverlay &&
@@ -1401,28 +1507,76 @@ export function createDocumentRuntime(
1401
1507
  return cachedEntry.snapshot;
1402
1508
  }
1403
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();
1404
1561
  const snapshot = createRuntimeContextAnalyticsSnapshot({
1405
1562
  query,
1406
1563
  renderSnapshot: cachedRenderSnapshot,
1407
1564
  workflowOverlay,
1408
- workflowScopeSnapshot: getCachedWorkflowScopeSnapshot(),
1409
- interactionGuardSnapshot: getCachedInteractionGuardSnapshot(),
1410
- workflowMarkupSnapshot: getCachedWorkflowMarkupSnapshot(),
1411
- suggestionsSnapshot: getCachedSuggestionsSnapshot(state),
1412
- reviewWorkSnapshot: createReviewWorkSnapshot({
1413
- comments: cachedRenderSnapshot.comments,
1414
- trackedChanges: cachedRenderSnapshot.trackedChanges,
1415
- workflowMarkup: getCachedWorkflowMarkupSnapshot(),
1416
- document: state.document,
1417
- navigation: getCachedDocumentNavigationSnapshot(state, activeStory),
1418
- }),
1565
+ workflowScopeSnapshot: wfScope,
1566
+ interactionGuardSnapshot: wfGuard,
1567
+ workflowMarkupSnapshot: wfMarkup,
1568
+ suggestionsSnapshot: suggestions,
1569
+ reviewWorkSnapshot: reviewWork,
1419
1570
  warnings: state.warnings.map(toPublicWarning),
1420
- compatibility: toPublicCompatibilityReport(createDerivedCompatibility(state)),
1571
+ compatibility: compat,
1421
1572
  });
1573
+ perfCounters.increment("ctxa.build.us", Math.round((performance.now() - tBuild) * 1000));
1422
1574
  cachedContextAnalyticsSnapshots.set(queryKey, {
1423
1575
  revisionToken: state.revisionToken,
1424
1576
  activeStoryKey,
1425
- 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,
1426
1580
  readOnly: state.readOnly,
1427
1581
  documentMode: viewState.documentMode,
1428
1582
  workflowOverlay,
@@ -1509,8 +1663,17 @@ export function createDocumentRuntime(
1509
1663
  return true;
1510
1664
  }
1511
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
+
1512
1674
  function refreshRenderSnapshot(): RuntimeRenderSnapshot {
1513
- const surface = getCachedSurface(state.document, activeStory);
1675
+ perfCounters.increment("refresh.all");
1676
+ const surface = timeFacet("surface", () => getCachedSurface(state.document, activeStory));
1514
1677
  return {
1515
1678
  documentId: state.documentId,
1516
1679
  sessionId: state.sessionId,
@@ -1522,11 +1685,11 @@ export function createDocumentRuntime(
1522
1685
  documentMode: viewState.documentMode,
1523
1686
  selection: toPublicSelectionSnapshot(state.selection, activeStory),
1524
1687
  activeStory,
1525
- pageLayout: getCachedPageLayoutSnapshot(state, activeStory) ?? undefined,
1688
+ pageLayout: timeFacet("layout", () => getCachedPageLayoutSnapshot(state, activeStory)) ?? undefined,
1526
1689
  documentStats: toPublicDocumentStats(state),
1527
- comments: getCachedCommentSidebarSnapshot(state),
1528
- trackedChanges: getCachedTrackedChangesSnapshot(state, surface),
1529
- compatibility: getCachedCompatibilityReport(state),
1690
+ comments: timeFacet("comments", () => getCachedCommentSidebarSnapshot(state)),
1691
+ trackedChanges: timeFacet("trackedChanges", () => getCachedTrackedChangesSnapshot(state, surface)),
1692
+ compatibility: timeFacet("compatibility", () => getCachedCompatibilityReport(state)),
1530
1693
  warnings: state.warnings.map((warning) => toPublicWarning(warning)),
1531
1694
  fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
1532
1695
  commandState: {
@@ -1604,6 +1767,40 @@ export function createDocumentRuntime(
1604
1767
  });
1605
1768
  }
1606
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
+
1607
1804
  return {
1608
1805
  subscribe(listener) {
1609
1806
  listeners.add(listener);
@@ -2254,7 +2451,37 @@ export function createDocumentRuntime(
2254
2451
  throw toStructuredRuntimeException(error);
2255
2452
  }
2256
2453
 
2257
- 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);
2258
2485
 
2259
2486
  emit({
2260
2487
  type: "export_completed",
@@ -2270,6 +2497,11 @@ export function createDocumentRuntime(
2270
2497
  overlay,
2271
2498
  origin: createOrigin("api", clock()),
2272
2499
  });
2500
+ editorStateChannel.recordMutation("workflowOverlay", {
2501
+ namespace: "workflowOverlay",
2502
+ schemaVersion: "workflow-overlay/1",
2503
+ data: overlay,
2504
+ });
2273
2505
  },
2274
2506
  clearWorkflowOverlay() {
2275
2507
  this.dispatch({
@@ -2308,6 +2540,11 @@ export function createDocumentRuntime(
2308
2540
  entries,
2309
2541
  origin: createOrigin("api", clock()),
2310
2542
  });
2543
+ editorStateChannel.recordMutation("workflowMetadata", {
2544
+ namespace: "workflowMetadata",
2545
+ schemaVersion: "workflow-metadata/1",
2546
+ data: entries,
2547
+ });
2311
2548
  },
2312
2549
  clearWorkflowMetadataEntries() {
2313
2550
  this.dispatch({
@@ -2324,6 +2561,11 @@ export function createDocumentRuntime(
2324
2561
  overlay,
2325
2562
  origin: createOrigin("api", clock()),
2326
2563
  });
2564
+ editorStateChannel.recordMutation("hostAnnotations", {
2565
+ namespace: "hostAnnotations",
2566
+ schemaVersion: "host-annotation-overlay/1",
2567
+ data: overlay,
2568
+ });
2327
2569
  },
2328
2570
  clearHostAnnotationOverlay() {
2329
2571
  this.dispatch({
@@ -2391,6 +2633,31 @@ export function createDocumentRuntime(
2391
2633
  getRuntimeContextAnalytics(query) {
2392
2634
  return getCachedRuntimeContextAnalytics(query);
2393
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
+ },
2394
2661
  };
2395
2662
 
2396
2663
  function applyHistory(direction: "undo" | "redo"): void {
@@ -2438,15 +2705,14 @@ export function createDocumentRuntime(
2438
2705
  function applyTransactionToState(transaction: EditorTransaction): void {
2439
2706
  const previous = state;
2440
2707
 
2708
+ const tApply0 = performance.now();
2441
2709
  protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
2710
+ const tFinalize0 = performance.now();
2442
2711
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
2712
+ perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
2443
2713
  storySelections.set(storyTargetKey(activeStory), state.selection);
2444
2714
 
2445
- // Signal a bounded content-edit invalidation to the layout engine so the
2446
- // next layout query can splice rather than rebuild the full graph. The
2447
- // engine analyzes the reason against its cached graph and falls back to
2448
- // a full rebuild when the edit crosses section boundaries or reaches a
2449
- // page the engine cannot safely resume from.
2715
+ const tInvalidate0 = performance.now();
2450
2716
  if (transaction.markDirty && transaction.mapping.steps.length > 0) {
2451
2717
  let minFrom = Infinity;
2452
2718
  let maxTo = -Infinity;
@@ -2459,22 +2725,20 @@ export function createDocumentRuntime(
2459
2725
  layoutEngine.invalidate({ kind: "content-edit", from: minFrom, to: maxTo });
2460
2726
  }
2461
2727
  }
2462
-
2463
- // Font-loader refresh on subParts identity change — this is the
2464
- // lightweight proxy for "a change that could affect which fonts the
2465
- // canvas backend measures against". Typing edits don't rebuild
2466
- // subParts; style + font + numbering imports do. Hardening: also
2467
- // invalidate the measurement provider's glyph cache AND the engine's
2468
- // cached graph so the next pagination run re-measures with the
2469
- // newly-registered FontFaces (pre-hardening the canvas backend kept
2470
- // returning pre-refresh widths from its cache).
2471
2728
  if (previous.document.subParts !== state.document.subParts) {
2472
2729
  fontLoader.refresh(collectFontLoaderInput(state.document));
2473
2730
  layoutEngine.invalidateMeasurementCache();
2474
2731
  }
2732
+ perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
2475
2733
 
2734
+ const tRefresh0 = performance.now();
2476
2735
  cachedRenderSnapshot = refreshRenderSnapshot();
2736
+ perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
2737
+
2738
+ const tNotify0 = performance.now();
2477
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));
2478
2742
  }
2479
2743
 
2480
2744
  function notify(
@@ -2935,10 +3199,39 @@ export function createDocumentRuntime(
2935
3199
  return nextReview;
2936
3200
  }
2937
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
+
2938
3228
  function emit(event: DocumentRuntimeEvent): void {
3229
+ perfCounters.increment(`emit.${event.type}.calls`);
3230
+ const t0 = performance.now();
2939
3231
  emitInternal(event);
3232
+ perfCounters.increment(`emit.${event.type}.internal.us`, Math.round((performance.now() - t0) * 1000));
2940
3233
  if (shouldEmitContextAnalyticsChanged(event)) {
2941
- emitContextAnalyticsChanged();
3234
+ scheduleContextAnalyticsEmit();
2942
3235
  }
2943
3236
  }
2944
3237
 
@@ -3085,7 +3378,23 @@ export function createDocumentRuntime(
3085
3378
  }
3086
3379
 
3087
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();
3088
3396
  const trackedSnapshots = collectTrackedContextAnalyticsSnapshots();
3397
+ perfCounters.increment("emit.contextAnalytics.collect.us", Math.round((performance.now() - t0) * 1000));
3089
3398
  if (
3090
3399
  lastEmittedContextAnalyticsSnapshots !== undefined &&
3091
3400
  trackedContextAnalyticsSnapshotsEqual(lastEmittedContextAnalyticsSnapshots, trackedSnapshots)
@@ -4438,6 +4747,25 @@ function resolveSupportedFieldDisplay(
4438
4747
  if (field.fieldFamily === "TOC") {
4439
4748
  return undefined;
4440
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
+ }
4441
4769
  if (!field.fieldTarget) {
4442
4770
  return { displayText: "", refreshStatus: "unresolvable" };
4443
4771
  }