@beyondwork/docx-react-component 1.0.87 → 1.0.89

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 (47) hide show
  1. package/package.json +1 -1
  2. package/src/api/v3/_runtime-handle.ts +5 -0
  3. package/src/api/v3/ai/replacement.ts +82 -0
  4. package/src/api/v3/runtime/content.ts +3 -0
  5. package/src/api/v3/runtime/formatting.ts +64 -0
  6. package/src/core/commands/formatting-commands.ts +107 -0
  7. package/src/core/state/text-transaction.ts +11 -4
  8. package/src/runtime/document-runtime.ts +293 -27
  9. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  10. package/src/runtime/scopes/action-validation.ts +12 -3
  11. package/src/runtime/scopes/audit-bundle.ts +2 -2
  12. package/src/runtime/scopes/compiler-service.ts +70 -0
  13. package/src/runtime/scopes/formatting/apply.ts +262 -0
  14. package/src/runtime/scopes/index.ts +12 -0
  15. package/src/runtime/scopes/replacement/propose.ts +2 -0
  16. package/src/runtime/scopes/scope-kinds/paragraph.ts +1 -0
  17. package/src/runtime/scopes/semantic-scope-types.ts +48 -4
  18. package/src/runtime/scopes/workflow-overlap.ts +9 -11
  19. package/src/shell/session-bootstrap.ts +1 -0
  20. package/src/ui/WordReviewEditor.tsx +277 -28
  21. package/src/ui/editor-command-bag.ts +11 -0
  22. package/src/ui/editor-shell-view.tsx +10 -0
  23. package/src/ui/headless/chrome-registry.ts +6 -6
  24. package/src/ui/headless/role-action-sets.ts +4 -10
  25. package/src/ui/headless/selection-tool-resolver.ts +11 -0
  26. package/src/ui-tailwind/chrome/editor-action-registry.ts +1 -1
  27. package/src/ui-tailwind/chrome/tw-context-band.tsx +7 -7
  28. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +13 -18
  29. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +8 -5
  30. package/src/ui-tailwind/chrome/tw-inline-find-bar.tsx +100 -0
  31. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +0 -40
  32. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +9 -7
  33. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +17 -1
  34. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +6 -1
  35. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +17 -7
  36. package/src/ui-tailwind/editor-surface/preserve-position.ts +61 -11
  37. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +20 -5
  38. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +52 -6
  39. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +5 -1
  40. package/src/ui-tailwind/review/tw-review-rail.tsx +5 -0
  41. package/src/ui-tailwind/review/tw-workflow-tab.tsx +32 -38
  42. package/src/ui-tailwind/review-workspace/types.ts +2 -0
  43. package/src/ui-tailwind/theme/editor-theme.css +25 -12
  44. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +13 -4
  45. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +6 -15
  46. package/src/ui-tailwind/tw-review-workspace.tsx +28 -18
  47. package/src/ui-tailwind/workflow-scope-layers.ts +70 -0
@@ -124,6 +124,7 @@ import {
124
124
  MAIN_STORY_TARGET,
125
125
  storyTargetsEqual,
126
126
  type EditorAnchorProjection as InternalEditorAnchorProjection,
127
+ type TransactionMapping,
127
128
  } from "../core/selection/mapping.ts";
128
129
  import {
129
130
  toInternalAnchorProjection,
@@ -190,6 +191,7 @@ import {
190
191
  } from "../core/commands/add-scope.ts";
191
192
  import {
192
193
  applyFormattingOperationToDocument,
194
+ applyTextMarkOperationToDocumentRange,
193
195
  type FormattingOperation,
194
196
  } from "../core/commands/formatting-commands.ts";
195
197
  import { resolveActiveParagraphIndex } from "../core/commands/paragraph-layout-commands.ts";
@@ -449,6 +451,12 @@ export interface DocumentRuntime {
449
451
  * UI paths.
450
452
  */
451
453
  applyScopeReplacement(plan: RuntimeOperationPlan): void;
454
+ /**
455
+ * Layer-08 scoped formatting dispatch. Returns true when at least one
456
+ * document.replace transaction was committed, which lets the compiler
457
+ * suppress no-op audits.
458
+ */
459
+ applyScopeFormatting(plan: RuntimeOperationPlan): boolean;
452
460
  dispatch(command: EditorCommand): void;
453
461
  /**
454
462
  * Apply a command received from a remote collaborator. The command
@@ -1174,6 +1182,8 @@ export function createDocumentRuntime(
1174
1182
  // to a single microtask-deferred emit. Declared early because emit() (which
1175
1183
  // checks this flag) runs during construction.
1176
1184
  let analyticsEmitScheduled = false;
1185
+ let analyticsEmitScheduleMode: "none" | "microtask" | "idle" = "none";
1186
+ let deferNextContextAnalyticsEmit = false;
1177
1187
 
1178
1188
  // V6c — heading fingerprint for TOC auto-invalidation. Compared in notify();
1179
1189
  // a mismatch schedules a microtask refresh of TOC fields.
@@ -1380,6 +1390,8 @@ export function createDocumentRuntime(
1380
1390
  let viewportBlockRanges: readonly { start: number; end: number }[] | null = null;
1381
1391
  /** Serialized fingerprint of the active ranges — used as the idempotency key for `refreshSurfaceOnly`. */
1382
1392
  let viewportRangesKey: string = serializeViewportRanges(null);
1393
+ const EDITING_CORRIDOR_BLOCK_RADIUS = 8;
1394
+ const EDITING_CORRIDOR_MIN_BLOCKS = 24;
1383
1395
 
1384
1396
  function applyViewportRanges(
1385
1397
  incoming: readonly { start: number; end: number }[] | null,
@@ -1567,19 +1579,35 @@ export function createDocumentRuntime(
1567
1579
  function getCachedSurface(
1568
1580
  document: CanonicalDocumentEnvelope,
1569
1581
  nextActiveStory: EditorStoryTarget,
1582
+ options: {
1583
+ viewportBlockRangesOverride?: readonly { start: number; end: number }[] | null;
1584
+ enrichCulledPlaceholders?: boolean;
1585
+ } = {},
1570
1586
  ): RuntimeRenderSnapshot["surface"] {
1571
1587
  const activeStoryKey = storyTargetKey(nextActiveStory);
1588
+ const surfaceViewportRanges =
1589
+ "viewportBlockRangesOverride" in options
1590
+ ? options.viewportBlockRangesOverride ?? null
1591
+ : viewportBlockRanges;
1592
+ const surfaceViewportRangesKey =
1593
+ "viewportBlockRangesOverride" in options
1594
+ ? serializeViewportRanges(surfaceViewportRanges)
1595
+ : viewportRangesKey;
1596
+ const surfaceCacheKey =
1597
+ options.enrichCulledPlaceholders === false
1598
+ ? `${surfaceViewportRangesKey}|raw-placeholders`
1599
+ : surfaceViewportRangesKey;
1572
1600
  if (
1573
1601
  cachedSurface &&
1574
1602
  cachedSurface.revisionToken === state.revisionToken &&
1575
1603
  cachedSurface.activeStoryKey === activeStoryKey &&
1576
- cachedSurface.viewportRangesKey === viewportRangesKey
1604
+ cachedSurface.viewportRangesKey === surfaceCacheKey
1577
1605
  ) {
1578
1606
  return cachedSurface.snapshot;
1579
1607
  }
1580
1608
 
1581
1609
  const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory, {
1582
- viewportBlockRanges,
1610
+ viewportBlockRanges: surfaceViewportRanges,
1583
1611
  ...(effectiveMarkupModeProvider
1584
1612
  ? { getEffectiveMarkupMode: effectiveMarkupModeProvider }
1585
1613
  : {}),
@@ -1605,20 +1633,57 @@ export function createDocumentRuntime(
1605
1633
  //
1606
1634
  // No-op on pre-pagination calls (engine not yet ready → empty map)
1607
1635
  // and on the inert facet.
1608
- const enrichedSnapshot = enrichCulledPlaceholdersWithHeights(snapshot);
1636
+ const enrichedSnapshot =
1637
+ options.enrichCulledPlaceholders === false
1638
+ ? snapshot
1639
+ : enrichCulledPlaceholdersWithHeights(snapshot);
1609
1640
  cachedSurface = {
1610
1641
  revisionToken: state.revisionToken,
1611
1642
  activeStoryKey,
1612
- viewportRangesKey,
1643
+ viewportRangesKey: surfaceCacheKey,
1613
1644
  snapshot: enrichedSnapshot,
1614
1645
  };
1615
1646
  // Keep the scroll-path fingerprint in lockstep so a subsequent
1616
1647
  // `maybeRefreshSurfaceForViewport` sees the freshly-built snapshot
1617
1648
  // and short-circuits instead of paying a redundant projection.
1618
- cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
1649
+ cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${surfaceCacheKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
1619
1650
  return enrichedSnapshot;
1620
1651
  }
1621
1652
 
1653
+ function getLocalTextCommitViewportRanges(
1654
+ previousSurface: RuntimeRenderSnapshot["surface"] | undefined,
1655
+ ): readonly { start: number; end: number }[] | null {
1656
+ if (!previousSurface || previousSurface.blocks.length < EDITING_CORRIDOR_MIN_BLOCKS) {
1657
+ return viewportBlockRanges;
1658
+ }
1659
+ const caretBlockIndex = findSurfaceBlockIndexForSelection(previousSurface, state.selection);
1660
+ if (caretBlockIndex === -1) {
1661
+ return viewportBlockRanges;
1662
+ }
1663
+ const corridor = {
1664
+ start: Math.max(0, caretBlockIndex - EDITING_CORRIDOR_BLOCK_RADIUS),
1665
+ end: Math.min(previousSurface.blocks.length, caretBlockIndex + EDITING_CORRIDOR_BLOCK_RADIUS + 1),
1666
+ };
1667
+ return normalizeViewportRanges([...(viewportBlockRanges ?? []), corridor]);
1668
+ }
1669
+
1670
+ function findSurfaceBlockIndexForSelection(
1671
+ surface: RuntimeRenderSnapshot["surface"],
1672
+ selection: EditorState["selection"],
1673
+ ): number {
1674
+ if (!surface) return -1;
1675
+ const from = Math.min(selection.anchor, selection.head);
1676
+ const to = Math.max(selection.anchor, selection.head);
1677
+ for (let index = 0; index < surface.blocks.length; index += 1) {
1678
+ const block = surface.blocks[index];
1679
+ if (!block) continue;
1680
+ if (to >= block.from && from <= block.to) {
1681
+ return index;
1682
+ }
1683
+ }
1684
+ return -1;
1685
+ }
1686
+
1622
1687
  function enrichCulledPlaceholdersWithHeights(
1623
1688
  snapshot: EditorSurfaceSnapshot,
1624
1689
  ): EditorSurfaceSnapshot {
@@ -2469,6 +2534,57 @@ export function createDocumentRuntime(
2469
2534
  return snapshot;
2470
2535
  }
2471
2536
 
2537
+ function refreshLocalTextCommitSnapshot(
2538
+ surface: RuntimeRenderSnapshot["surface"],
2539
+ mapping: TransactionMapping,
2540
+ ): RuntimeRenderSnapshot {
2541
+ perfCounters.increment("refresh.localTextEquivalent");
2542
+ return {
2543
+ ...cachedRenderSnapshot,
2544
+ documentId: state.documentId,
2545
+ sessionId: state.sessionId,
2546
+ sourceLabel: state.sourceLabel,
2547
+ revisionToken: state.revisionToken,
2548
+ isReady: state.phase === "ready",
2549
+ isDirty: state.isDirty,
2550
+ readOnly: state.readOnly,
2551
+ documentMode: viewState.documentMode,
2552
+ selection: toPublicSelectionSnapshot(state.selection, activeStory),
2553
+ activeStory,
2554
+ documentStats: updateDocumentStatsForLocalTextCommit(
2555
+ cachedRenderSnapshot.documentStats,
2556
+ mapping,
2557
+ ),
2558
+ warnings: cachedRenderSnapshot.warnings,
2559
+ fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
2560
+ commandState: {
2561
+ canUndo: history.past.length > 0,
2562
+ canRedo: history.future.length > 0,
2563
+ readOnly: state.readOnly,
2564
+ },
2565
+ surface,
2566
+ protectionSnapshot,
2567
+ grabbedObjectId: grabState.objectId,
2568
+ };
2569
+ }
2570
+
2571
+ function updateDocumentStatsForLocalTextCommit(
2572
+ previousStats: RuntimeRenderSnapshot["documentStats"],
2573
+ mapping: TransactionMapping,
2574
+ ): RuntimeRenderSnapshot["documentStats"] {
2575
+ if (!previousStats) {
2576
+ return toPublicDocumentStats(state);
2577
+ }
2578
+ let delta = 0;
2579
+ for (const step of mapping.steps) {
2580
+ delta += step.insertSize - Math.max(0, step.to - step.from);
2581
+ }
2582
+ return {
2583
+ ...previousStats,
2584
+ storyLength: Math.max(0, previousStats.storyLength + delta),
2585
+ };
2586
+ }
2587
+
2472
2588
  /**
2473
2589
  * Fingerprint over the inputs that the viewport-refresh fast-path knows
2474
2590
  * about. Used to short-circuit {@link maybeRefreshSurfaceForViewport}
@@ -3275,6 +3391,7 @@ export function createDocumentRuntime(
3275
3391
  {
3276
3392
  type: "text.insert",
3277
3393
  text: step.text,
3394
+ ...(step.formatting ? { formatting: step.formatting } : {}),
3278
3395
  origin: createOrigin("api", timestamp),
3279
3396
  },
3280
3397
  {
@@ -3349,6 +3466,7 @@ export function createDocumentRuntime(
3349
3466
  {
3350
3467
  type: "text.insert",
3351
3468
  text: step.text,
3469
+ ...(step.formatting ? { formatting: step.formatting } : {}),
3352
3470
  origin: createOrigin("api", timestamp),
3353
3471
  },
3354
3472
  {
@@ -3397,6 +3515,48 @@ export function createDocumentRuntime(
3397
3515
  }
3398
3516
  }
3399
3517
  },
3518
+ applyScopeFormatting(plan: RuntimeOperationPlan) {
3519
+ let changed = false;
3520
+ for (const step of plan.steps) {
3521
+ if (
3522
+ step.kind !== "formatting-apply" ||
3523
+ !step.range ||
3524
+ !step.formattingAction
3525
+ ) {
3526
+ continue;
3527
+ }
3528
+
3529
+ const operation =
3530
+ step.formattingAction.kind === "clear-mark"
3531
+ ? {
3532
+ type: "clear-mark" as const,
3533
+ mark: step.formattingAction.mark,
3534
+ }
3535
+ : {
3536
+ type: "set-mark" as const,
3537
+ mark: step.formattingAction.mark,
3538
+ };
3539
+ const result = applyTextMarkOperationToDocumentRange(
3540
+ state.document,
3541
+ step.range,
3542
+ operation,
3543
+ );
3544
+ if (!result.changed) {
3545
+ continue;
3546
+ }
3547
+
3548
+ const timestamp = clock();
3549
+ this.dispatch({
3550
+ type: "document.replace",
3551
+ document: { ...result.document, updatedAt: timestamp },
3552
+ mapping: createEmptyMapping(),
3553
+ selection: toInternalSelectionSnapshot(result.selection),
3554
+ origin: createOrigin("api", timestamp),
3555
+ });
3556
+ changed = true;
3557
+ }
3558
+ return changed;
3559
+ },
3400
3560
  insertFragment(fragment, target) {
3401
3561
  // I2 Tier B Slice 1 — dispatch `fragment.insert` against the active story. The
3402
3562
  // runtime command handler routes into `applyFragmentInsert` (structure-ops).
@@ -4892,14 +5052,52 @@ export function createDocumentRuntime(
4892
5052
  history.future = [];
4893
5053
  }
4894
5054
 
4895
- applyTransactionToState(transaction);
5055
+ applyTransactionToState(transaction, { allowLocalTextFastPath: true });
4896
5056
  }
4897
5057
 
4898
5058
  function commitRemote(transaction: EditorTransaction): void {
4899
5059
  applyTransactionToState(transaction);
4900
5060
  }
4901
5061
 
4902
- function applyTransactionToState(transaction: EditorTransaction): void {
5062
+ function shouldUseLocalTextCommitSnapshot(
5063
+ previous: EditorState,
5064
+ next: EditorState,
5065
+ transaction: EditorTransaction,
5066
+ effects: EditorTransaction["effects"],
5067
+ ): boolean {
5068
+ if (activeStory.kind !== "main") return false;
5069
+ if (!transaction.markDirty || transaction.mapping.steps.length === 0) {
5070
+ return false;
5071
+ }
5072
+ if (transaction.mapping.metadata?.invalidatesStructures) {
5073
+ return false;
5074
+ }
5075
+ if (previous.document.subParts !== next.document.subParts) {
5076
+ return false;
5077
+ }
5078
+ if (effects.warningsAdded.length > 0 || effects.warningsCleared.length > 0) {
5079
+ return false;
5080
+ }
5081
+ if ((effects.transientWarnings?.length ?? 0) > 0) {
5082
+ return false;
5083
+ }
5084
+ return (
5085
+ !effects.commentAdded &&
5086
+ !effects.commentResolved &&
5087
+ !effects.commentReopened &&
5088
+ !effects.commentReplyAdded &&
5089
+ !effects.commentBodyEdited &&
5090
+ !effects.changeAccepted &&
5091
+ !effects.changeRejected &&
5092
+ !effects.revisionAuthored &&
5093
+ !effects.commandBlocked
5094
+ );
5095
+ }
5096
+
5097
+ function applyTransactionToState(
5098
+ transaction: EditorTransaction,
5099
+ options: { allowLocalTextFastPath?: boolean } = {},
5100
+ ): void {
4903
5101
  // Pure-no-op short-circuit: when a reducer skipped without emitting any
4904
5102
  // observable effect AND the selection is identical, skip finalizeState
4905
5103
  // / invalidate / refresh / notify entirely. This keeps hot paths (e.g.
@@ -4941,7 +5139,9 @@ export function createDocumentRuntime(
4941
5139
  const previous = state;
4942
5140
 
4943
5141
  const tApply0 = performance.now();
5142
+ const tProtection0 = performance.now();
4944
5143
  protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
5144
+ perfCounters.increment("commit.protectionRemap.us", Math.round((performance.now() - tProtection0) * 1000));
4945
5145
  const tFinalize0 = performance.now();
4946
5146
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
4947
5147
  perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
@@ -4949,8 +5149,10 @@ export function createDocumentRuntime(
4949
5149
  // Re-sync marker-backed scope IDs against the new document; the
4950
5150
  // store handles set computation + telemetry. `replaceOverlay(current, doc)`
4951
5151
  // is idempotent on state and re-derives the marker-backed set.
5152
+ const tOverlay0 = performance.now();
4952
5153
  overlayStore.replaceOverlay(overlayStore.getOverlay(), state.document);
4953
5154
  const detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
5155
+ perfCounters.increment("commit.overlaySync.us", Math.round((performance.now() - tOverlay0) * 1000));
4954
5156
 
4955
5157
  const tInvalidate0 = performance.now();
4956
5158
  if (transaction.markDirty && transaction.mapping.steps.length > 0) {
@@ -4974,12 +5176,37 @@ export function createDocumentRuntime(
4974
5176
  }
4975
5177
  perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
4976
5178
 
5179
+ const notifyEffects: EditorTransaction["effects"] = {
5180
+ ...transaction.effects,
5181
+ warningsAdded: [
5182
+ ...transaction.effects.warningsAdded,
5183
+ ...detachedWorkflowScopeWarnings.added,
5184
+ ],
5185
+ warningsCleared: [
5186
+ ...transaction.effects.warningsCleared,
5187
+ ...detachedWorkflowScopeWarnings.cleared,
5188
+ ],
5189
+ };
5190
+
5191
+ const tClassify0 = performance.now();
5192
+ const useLocalTextCommitSnapshot =
5193
+ options.allowLocalTextFastPath === true &&
5194
+ shouldUseLocalTextCommitSnapshot(
5195
+ previous,
5196
+ state,
5197
+ transaction,
5198
+ notifyEffects,
5199
+ );
5200
+ perfCounters.increment("commit.refreshClassify.us", Math.round((performance.now() - tClassify0) * 1000));
5201
+
4977
5202
  // I5: validate post-mutation selection against the new document bound.
4978
5203
  // First call to getCachedSurface() here computes the post-mutation surface
4979
5204
  // snapshot (cache miss — revisionToken just changed in finalizeState).
4980
- // refreshRenderSnapshot's internal call below hits the cache and reuses it.
4981
- // Net cost: identical to pre-I5 one surface walk per commit, just shifted
4982
- // a few lines earlier so the validator can read storySize.
5205
+ // For local text commits, this is an editing-corridor surface (current
5206
+ // block plus a small halo) so the immediate typing path keeps visible
5207
+ // truth hot without rebuilding offscreen table/comment markup. Structural,
5208
+ // review, and warning commits still use the full current viewport surface
5209
+ // and fall back to refreshRenderSnapshot().
4983
5210
  //
4984
5211
  // Wired ONLY at this chokepoint. The other 12 `cachedRenderSnapshot =
4985
5212
  // refreshRenderSnapshot()` sites in this file are intentionally skipped:
@@ -4999,7 +5226,13 @@ export function createDocumentRuntime(
4999
5226
  // The return type of getCachedSurface is `RuntimeRenderSnapshot["surface"]`
5000
5227
  // which is optional in the public API for shape-only reasons — the helper
5001
5228
  // itself always returns a defined snapshot.
5002
- const surfaceForValidation = getCachedSurface(state.document, activeStory)!;
5229
+ const tValidation0 = performance.now();
5230
+ const surfaceForValidation = useLocalTextCommitSnapshot
5231
+ ? getCachedSurface(state.document, activeStory, {
5232
+ viewportBlockRangesOverride: getLocalTextCommitViewportRanges(cachedRenderSnapshot.surface),
5233
+ enrichCulledPlaceholders: false,
5234
+ })!
5235
+ : getCachedSurface(state.document, activeStory)!;
5003
5236
  const validationOptions = state.selection.activeRange.kind === "node"
5004
5237
  ? {
5005
5238
  isValidNodeTarget: createSurfaceNodeSelectionProbe(surfaceForValidation),
@@ -5015,26 +5248,24 @@ export function createDocumentRuntime(
5015
5248
  state = { ...state, selection: validatedSelection };
5016
5249
  storySelections.set(storyTargetKey(activeStory), state.selection);
5017
5250
  }
5251
+ perfCounters.increment("commit.selectionValidation.us", Math.round((performance.now() - tValidation0) * 1000));
5018
5252
 
5019
5253
  const tRefresh0 = performance.now();
5020
- cachedRenderSnapshot = refreshRenderSnapshot();
5254
+ cachedRenderSnapshot = useLocalTextCommitSnapshot
5255
+ ? refreshLocalTextCommitSnapshot(surfaceForValidation, transaction.mapping)
5256
+ : refreshRenderSnapshot();
5021
5257
  perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
5022
5258
 
5023
5259
  const tNotify0 = performance.now();
5024
- notify(previous, state, {
5025
- ...transaction,
5026
- effects: {
5027
- ...transaction.effects,
5028
- warningsAdded: [
5029
- ...transaction.effects.warningsAdded,
5030
- ...detachedWorkflowScopeWarnings.added,
5031
- ],
5032
- warningsCleared: [
5033
- ...transaction.effects.warningsCleared,
5034
- ...detachedWorkflowScopeWarnings.cleared,
5035
- ],
5036
- },
5037
- });
5260
+ deferNextContextAnalyticsEmit = useLocalTextCommitSnapshot;
5261
+ try {
5262
+ notify(previous, state, {
5263
+ ...transaction,
5264
+ effects: notifyEffects,
5265
+ });
5266
+ } finally {
5267
+ deferNextContextAnalyticsEmit = false;
5268
+ }
5038
5269
  perfCounters.increment("commit.notify.us", Math.round((performance.now() - tNotify0) * 1000));
5039
5270
  perfCounters.increment("commit.total.us", Math.round((performance.now() - tApply0) * 1000));
5040
5271
  emitStageToken(telemetryBus, "commit", "commit.apply.complete", {
@@ -5608,23 +5839,58 @@ export function createDocumentRuntime(
5608
5839
  // synchronous call stack — one per commit. (Flag declared above near
5609
5840
  // perfCounters so it is initialized before construction-time emits run.)
5610
5841
  function scheduleContextAnalyticsEmit(): void {
5842
+ const shouldDefer = deferNextContextAnalyticsEmit;
5843
+ deferNextContextAnalyticsEmit = false;
5611
5844
  if (analyticsEmitScheduled) {
5845
+ if (shouldDefer && analyticsEmitScheduleMode === "microtask") {
5846
+ analyticsEmitScheduleMode = "idle";
5847
+ perfCounters.increment("emit.contextAnalytics.deferred");
5848
+ return;
5849
+ }
5612
5850
  perfCounters.increment("emit.contextAnalytics.coalesced");
5613
5851
  return;
5614
5852
  }
5615
5853
  analyticsEmitScheduled = true;
5616
- queueMicrotask(() => {
5854
+ analyticsEmitScheduleMode = shouldDefer ? "idle" : "microtask";
5855
+ const run = () => {
5617
5856
  // Reset BEFORE the emit so any synchronous re-entrant emits triggered
5618
5857
  // by listener callbacks schedule a fresh second microtask (no lost
5619
5858
  // updates). Reversing this order would drop bursts originating in
5620
5859
  // listeners.
5621
5860
  analyticsEmitScheduled = false;
5861
+ analyticsEmitScheduleMode = "none";
5622
5862
  const t = performance.now();
5623
5863
  emitContextAnalyticsChanged();
5624
5864
  perfCounters.increment("emit.contextAnalytics.us", Math.round((performance.now() - t) * 1000));
5865
+ };
5866
+ if (shouldDefer) {
5867
+ perfCounters.increment("emit.contextAnalytics.deferred");
5868
+ scheduleIdleContextAnalytics(run);
5869
+ return;
5870
+ }
5871
+ queueMicrotask(() => {
5872
+ if (analyticsEmitScheduleMode === "idle") {
5873
+ scheduleIdleContextAnalytics(run);
5874
+ return;
5875
+ }
5876
+ run();
5625
5877
  });
5626
5878
  }
5627
5879
 
5880
+ function scheduleIdleContextAnalytics(callback: () => void): void {
5881
+ const requestIdle = (globalThis as {
5882
+ requestIdleCallback?: (
5883
+ cb: () => void,
5884
+ options?: { timeout?: number },
5885
+ ) => number;
5886
+ }).requestIdleCallback;
5887
+ if (typeof requestIdle === "function") {
5888
+ requestIdle(callback, { timeout: 250 });
5889
+ return;
5890
+ }
5891
+ setTimeout(callback, 0);
5892
+ }
5893
+
5628
5894
  // V6c — TOC auto-refresh scheduler. Mirrors scheduleContextAnalyticsEmit's
5629
5895
  // microtask-coalesce shape. Bursts of heading edits within one synchronous
5630
5896
  // call stack collapse to a single rebuild + a single toc_auto_refreshed
@@ -32,6 +32,7 @@ export type {
32
32
  EditorStoryTarget,
33
33
  EffectiveSelectionMode,
34
34
  InteractionGuardSnapshot,
35
+ TextFormattingDirective,
35
36
  WorkflowBlockedCommandReason,
36
37
  WorkflowMetadataEntry,
37
38
  WorkflowMetadataSnapshot,
@@ -62,8 +62,8 @@ import {
62
62
  } from "./preservation-boundary.ts";
63
63
  import { resolveScopeRange } from "./scope-range.ts";
64
64
  import type {
65
- ReplacementOperationKind,
66
65
  ReplacementScope,
66
+ ScopeActionOperationKind,
67
67
  SemanticScope,
68
68
  ValidationApproval,
69
69
  ValidationIssue,
@@ -85,7 +85,7 @@ export interface ScopeValidationRuntime {
85
85
 
86
86
  export interface ComposeScopeValidationInputs {
87
87
  readonly scope: SemanticScope;
88
- readonly operation: ReplacementOperationKind;
88
+ readonly operation: ScopeActionOperationKind;
89
89
  readonly proposedContent: ReplacementScope["proposedContent"];
90
90
  readonly runtime: ScopeValidationRuntime;
91
91
  /**
@@ -119,6 +119,11 @@ export interface ComposeScopeValidationInputs {
119
119
  * here; the compile step consumes them to drive per-step behavior.
120
120
  */
121
121
  readonly preservePolicy?: ReplacementScope["preserve"];
122
+ /**
123
+ * Formatting-only actions do not destroy opaque fragments or nested scope
124
+ * markers, so they bypass the replacement preservation-boundary check.
125
+ */
126
+ readonly skipPreservation?: boolean;
122
127
  }
123
128
 
124
129
  /**
@@ -127,9 +132,12 @@ export interface ComposeScopeValidationInputs {
127
132
  * entry in the 37-op policy matrix.
128
133
  */
129
134
  function inferActionId(
130
- operation: ReplacementOperationKind,
135
+ operation: ScopeActionOperationKind,
131
136
  content: ReplacementScope["proposedContent"],
132
137
  ): AIAction {
138
+ if (operation === "formatting") {
139
+ return "fix_formatting";
140
+ }
133
141
  switch (operation) {
134
142
  case "replace":
135
143
  return content.kind === "text" ? "rewrite_paragraph" : "generate_text";
@@ -287,6 +295,7 @@ function collectPreservationVerdict(
287
295
  warnings: ValidationIssue[],
288
296
  ): void {
289
297
  const { document, scope, positionMap } = inputs;
298
+ if (inputs.skipPreservation === true) return;
290
299
  if (!document) return;
291
300
  const pm = positionMap ?? buildScopePositionMap(document);
292
301
  const range = inputs.enumeratedScope
@@ -10,9 +10,9 @@
10
10
 
11
11
  import type { TelemetryBus } from "../debug/telemetry-bus.ts";
12
12
  import type {
13
- ReplacementScope,
14
13
  RuntimeOperationPlan,
15
14
  ScopeActionAudit,
15
+ ScopeActionProposal,
16
16
  SemanticScope,
17
17
  ValidationResult,
18
18
  } from "./semantic-scope-types.ts";
@@ -24,7 +24,7 @@ export interface EmitScopeActionAuditInputs {
24
24
  readonly documentHashBefore: string;
25
25
  readonly documentHashAfter?: string;
26
26
  readonly targetScopeSnapshot: SemanticScope;
27
- readonly proposed: ReplacementScope;
27
+ readonly proposed: ScopeActionProposal;
28
28
  readonly plan: RuntimeOperationPlan;
29
29
  readonly validation: ValidationResult;
30
30
  readonly emittedAtUtc: string;