@beyondwork/docx-react-component 1.0.105 → 1.0.108

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 (193) hide show
  1. package/package.json +19 -5
  2. package/src/api/geometry-overlay-rects.ts +5 -0
  3. package/src/api/package-version.ts +1 -1
  4. package/src/api/page-anchor-id.ts +5 -0
  5. package/src/api/public-types.ts +16 -9
  6. package/src/api/table-node-specs.ts +6 -0
  7. package/src/api/v3/_create.ts +10 -2
  8. package/src/api/v3/_page-anchor-id.ts +52 -0
  9. package/src/api/v3/_runtime-handle.ts +92 -1
  10. package/src/api/v3/ai/_audit-reference.ts +28 -0
  11. package/src/api/v3/ai/_audit-time.ts +5 -0
  12. package/src/api/v3/ai/_pe2-evidence.ts +310 -6
  13. package/src/api/v3/ai/attach.ts +29 -4
  14. package/src/api/v3/ai/bundle.ts +6 -2
  15. package/src/api/v3/ai/inspect.ts +6 -2
  16. package/src/api/v3/ai/replacement.ts +112 -18
  17. package/src/api/v3/ai/resolve.ts +2 -2
  18. package/src/api/v3/ai/review.ts +177 -3
  19. package/src/api/v3/index.ts +8 -0
  20. package/src/api/v3/runtime/collab.ts +462 -0
  21. package/src/api/v3/runtime/document.ts +503 -20
  22. package/src/api/v3/runtime/geometry.ts +97 -0
  23. package/src/api/v3/runtime/layout.ts +744 -0
  24. package/src/api/v3/runtime/perf-probe.ts +14 -0
  25. package/src/api/v3/runtime/viewport.ts +9 -8
  26. package/src/api/v3/ui/_types.ts +202 -55
  27. package/src/api/v3/ui/chrome-preset-model.ts +5 -5
  28. package/src/api/v3/ui/debug.ts +115 -2
  29. package/src/api/v3/ui/index.ts +17 -0
  30. package/src/api/v3/ui/overlays.ts +0 -8
  31. package/src/api/v3/ui/surface.ts +56 -0
  32. package/src/api/v3/ui/viewport.ts +119 -9
  33. package/src/core/commands/image-commands.ts +1 -0
  34. package/src/core/commands/index.ts +6 -0
  35. package/src/core/schema/text-schema.ts +43 -5
  36. package/src/core/selection/mapping.ts +8 -1
  37. package/src/core/selection/review-anchors.ts +5 -1
  38. package/src/core/state/text-transaction.ts +8 -2
  39. package/src/io/export/serialize-revisions.ts +149 -1
  40. package/src/io/normalize/normalize-text.ts +6 -0
  41. package/src/io/ooxml/parse-bookmark-references.ts +55 -0
  42. package/src/io/ooxml/parse-fields.ts +24 -2
  43. package/src/io/ooxml/parse-headers-footers.ts +38 -5
  44. package/src/io/ooxml/parse-main-document.ts +153 -9
  45. package/src/io/ooxml/parse-numbering.ts +20 -0
  46. package/src/io/ooxml/parse-revisions.ts +19 -8
  47. package/src/io/opc/package-reader.ts +98 -8
  48. package/src/model/anchor.ts +4 -3
  49. package/src/model/canonical-document.ts +220 -2
  50. package/src/model/canonical-hash.ts +221 -0
  51. package/src/model/canonical-layout-inputs.ts +245 -6
  52. package/src/model/layout/index.ts +1 -0
  53. package/src/model/layout/page-graph-types.ts +147 -1
  54. package/src/model/review/revision-types.ts +14 -3
  55. package/src/preservation/store.ts +20 -4
  56. package/src/review/README.md +1 -1
  57. package/src/review/store/revision-actions.ts +14 -2
  58. package/src/runtime/collab/event-types.ts +67 -1
  59. package/src/runtime/collab/runtime-collab-sync.ts +177 -5
  60. package/src/runtime/diagnostics/layout-guard-warning.ts +18 -0
  61. package/src/runtime/document-heading-outline.ts +147 -0
  62. package/src/runtime/document-navigation.ts +8 -243
  63. package/src/runtime/document-runtime.ts +279 -115
  64. package/src/runtime/edit-dispatch/dispatch-text-command.ts +11 -0
  65. package/src/runtime/formatting/layout-inputs.ts +38 -5
  66. package/src/runtime/formatting/numbering/geometry.ts +28 -2
  67. package/src/runtime/geometry/adjacent-geometry-intake.ts +835 -0
  68. package/src/runtime/geometry/caret-geometry.ts +5 -6
  69. package/src/runtime/geometry/geometry-facet.ts +60 -10
  70. package/src/runtime/geometry/geometry-index.ts +661 -16
  71. package/src/runtime/geometry/geometry-types.ts +59 -0
  72. package/src/runtime/geometry/hit-test.ts +11 -1
  73. package/src/runtime/geometry/overlay-rects.ts +5 -3
  74. package/src/runtime/geometry/project-anchors.ts +1 -1
  75. package/src/runtime/geometry/word-layout-v2-line-intake.ts +323 -0
  76. package/src/runtime/layout/index.ts +6 -0
  77. package/src/runtime/layout/layout-engine-instance.ts +6 -1
  78. package/src/runtime/layout/layout-engine-version.ts +188 -16
  79. package/src/runtime/layout/layout-facet-types.ts +6 -0
  80. package/src/runtime/layout/page-graph.ts +23 -4
  81. package/src/runtime/layout/paginated-layout-engine.ts +149 -15
  82. package/src/runtime/layout/project-block-fragments.ts +351 -14
  83. package/src/runtime/layout/public-facet.ts +162 -24
  84. package/src/runtime/layout/table-row-continuation-contract.ts +107 -0
  85. package/src/runtime/layout/table-row-split.ts +92 -35
  86. package/src/runtime/prerender/cache-envelope.ts +2 -2
  87. package/src/runtime/prerender/cache-key.ts +5 -4
  88. package/src/runtime/prerender/customxml-cache.ts +0 -1
  89. package/src/runtime/render/render-kernel.ts +1 -1
  90. package/src/runtime/revision-runtime.ts +112 -10
  91. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  92. package/src/runtime/scopes/action-validation.ts +22 -2
  93. package/src/runtime/scopes/capabilities.ts +316 -0
  94. package/src/runtime/scopes/compile-scope-bundle.ts +14 -0
  95. package/src/runtime/scopes/compiler-service.ts +108 -4
  96. package/src/runtime/scopes/content-control-evidence.ts +79 -0
  97. package/src/runtime/scopes/create-issue.ts +5 -5
  98. package/src/runtime/scopes/evidence.ts +91 -0
  99. package/src/runtime/scopes/formatting/apply.ts +2 -0
  100. package/src/runtime/scopes/geometry-evidence.ts +130 -0
  101. package/src/runtime/scopes/index.ts +54 -0
  102. package/src/runtime/scopes/issue-lifecycle.ts +224 -0
  103. package/src/runtime/scopes/layout-evidence.ts +374 -0
  104. package/src/runtime/scopes/multi-paragraph-refusal.ts +37 -0
  105. package/src/runtime/scopes/preservation-boundary.ts +7 -1
  106. package/src/runtime/scopes/replacement/apply.ts +97 -34
  107. package/src/runtime/scopes/scope-kinds/paragraph.ts +108 -12
  108. package/src/runtime/scopes/semantic-scope-types.ts +242 -3
  109. package/src/runtime/scopes/visualization.ts +28 -0
  110. package/src/runtime/surface-projection.ts +44 -5
  111. package/src/runtime/telemetry/perf-probe.ts +216 -0
  112. package/src/runtime/virtualized-rendering.ts +36 -1
  113. package/src/runtime/workflow/ai-issue-lifecycle.ts +253 -0
  114. package/src/runtime/workflow/coordinator.ts +39 -11
  115. package/src/runtime/workflow/derived-scope-resolver.ts +63 -9
  116. package/src/runtime/workflow/index.ts +4 -0
  117. package/src/runtime/workflow/overlay-lane-types.ts +58 -0
  118. package/src/runtime/workflow/overlay-lanes.ts +386 -0
  119. package/src/runtime/workflow/overlay-store.ts +2 -2
  120. package/src/runtime/workflow/redline-posture-calibration.ts +257 -0
  121. package/src/runtime/workflow/word-field-matrix-calibration.ts +231 -0
  122. package/src/session/_sync-legacy.ts +17 -27
  123. package/src/session/import/loader.ts +6 -4
  124. package/src/session/import/source-package-evidence.ts +186 -2
  125. package/src/session/index.ts +5 -6
  126. package/src/session/session.ts +30 -56
  127. package/src/session/types.ts +8 -13
  128. package/src/shell/session-bootstrap.ts +155 -81
  129. package/src/ui/WordReviewEditor.tsx +520 -12
  130. package/src/ui/editor-shell-view.tsx +14 -4
  131. package/src/ui/editor-surface-controller.tsx +5 -3
  132. package/src/ui/headless/selection-tool-resolver.ts +1 -2
  133. package/src/ui/presence-overlay-lane.ts +130 -0
  134. package/src/ui/ui-controller-factory.ts +17 -0
  135. package/src/ui-tailwind/chrome/build-context-menu-entries.ts +5 -1
  136. package/src/ui-tailwind/chrome/editor-action-registry.ts +105 -5
  137. package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +7 -0
  138. package/src/ui-tailwind/chrome/layer-debug-contracts.ts +208 -0
  139. package/src/ui-tailwind/chrome/resolve-target-kind.ts +13 -0
  140. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +11 -3
  141. package/src/ui-tailwind/chrome/tw-command-palette.tsx +36 -6
  142. package/src/ui-tailwind/chrome/tw-context-menu.tsx +6 -1
  143. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +42 -109
  144. package/src/ui-tailwind/chrome/tw-inline-find-bar.tsx +26 -6
  145. package/src/ui-tailwind/chrome/tw-navigation-command-bar.tsx +328 -0
  146. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +8 -4
  147. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +129 -1
  148. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +19 -5
  149. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +5 -1
  150. package/src/ui-tailwind/chrome/tw-workspace-chrome-host.tsx +28 -12
  151. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +30 -3
  152. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +116 -10
  153. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +223 -94
  154. package/src/ui-tailwind/chrome-overlay/tw-presence-overlay-lane.tsx +157 -0
  155. package/src/ui-tailwind/chrome-overlay/tw-review-overlay-lane-markers.tsx +259 -0
  156. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +5 -2
  157. package/src/ui-tailwind/chrome-overlay/tw-substrate-overlay-lanes.tsx +314 -0
  158. package/src/ui-tailwind/debug/README.md +4 -1
  159. package/src/ui-tailwind/debug/layer11-consumer-readiness.ts +272 -0
  160. package/src/ui-tailwind/debug/layer11-word-field-matrix-evidence.ts +160 -0
  161. package/src/ui-tailwind/editor-surface/perf-probe.ts +14 -215
  162. package/src/ui-tailwind/editor-surface/pm-decorations.ts +42 -0
  163. package/src/ui-tailwind/editor-surface/pm-position-map.ts +38 -2
  164. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -4
  165. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +34 -5
  166. package/src/ui-tailwind/editor-surface/runtime-decoration-plugin.ts +9 -19
  167. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -2
  168. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +145 -0
  169. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +16 -11
  170. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +8 -10
  171. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +3 -0
  172. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +4 -2
  173. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +60 -20
  174. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +16 -11
  175. package/src/ui-tailwind/review/tw-health-panel.tsx +36 -17
  176. package/src/ui-tailwind/review/tw-review-rail.tsx +7 -4
  177. package/src/ui-tailwind/review-workspace/diagnostics-visibility.ts +44 -0
  178. package/src/ui-tailwind/review-workspace/page-shell-metrics.ts +11 -0
  179. package/src/ui-tailwind/review-workspace/tw-review-workspace-rail.tsx +16 -1
  180. package/src/ui-tailwind/review-workspace/types.ts +26 -12
  181. package/src/ui-tailwind/review-workspace/use-diagnostics-signal.ts +40 -11
  182. package/src/ui-tailwind/review-workspace/use-layout-facet-render-signal.ts +2 -1
  183. package/src/ui-tailwind/review-workspace/use-page-markers.ts +15 -26
  184. package/src/ui-tailwind/review-workspace/use-scope-card-state.ts +35 -18
  185. package/src/ui-tailwind/review-workspace/use-selection-toolbar-placement.ts +41 -32
  186. package/src/ui-tailwind/review-workspace/use-status-bar-page-facts.ts +2 -1
  187. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +2 -1
  188. package/src/ui-tailwind/status/tw-status-bar.tsx +6 -5
  189. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +52 -80
  190. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +12 -48
  191. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +9 -4
  192. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +328 -361
  193. package/src/ui-tailwind/tw-review-workspace.tsx +152 -286
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared KI-014 refusal facts for marker-backed multi-paragraph scopes.
3
+ *
4
+ * These blockers are evidence only: they describe why broad replacement is
5
+ * still disabled and which facts must exist before L08 can safely lower it.
6
+ */
7
+
8
+ export type MultiParagraphReplacementShape = "text" | "fragment" | "unknown";
9
+
10
+ export const MULTI_PARAGRAPH_REPLACEMENT_REFUSAL =
11
+ "compile-refused:scope:multi-paragraph-replace-not-implemented";
12
+
13
+ function shapeBlocker(shape: MultiParagraphReplacementShape): string {
14
+ switch (shape) {
15
+ case "text":
16
+ return "compile-refused:scope:multi-paragraph-text-replace-not-implemented";
17
+ case "fragment":
18
+ return "compile-refused:scope:multi-paragraph-fragment-replace-not-implemented";
19
+ default:
20
+ return "compile-refused:scope:multi-paragraph-replace-shape-not-implemented";
21
+ }
22
+ }
23
+
24
+ export function multiParagraphReplacementBlockers(
25
+ shape: MultiParagraphReplacementShape = "unknown",
26
+ ): readonly string[] {
27
+ return Object.freeze([
28
+ MULTI_PARAGRAPH_REPLACEMENT_REFUSAL,
29
+ shapeBlocker(shape),
30
+ "capability:scope:block-granular-replacement-lowering-required",
31
+ "capability:scope:provenance:marker-backed-required",
32
+ "capability:scope:layout-completeness-required",
33
+ "capability:scope:geometry-completeness-required",
34
+ "capability:scope:continuation-state-required",
35
+ "capability:scope:preservation-verdict-required",
36
+ ]);
37
+ }
@@ -2,7 +2,7 @@
2
2
  * Layer 08 — preservation-boundary helper (Slice 4 interim).
3
3
  *
4
4
  * Computes whether a scope's canonical range crosses any preserve-only
5
- * boundary that would be destroyed by a replace/split/insert operation.
5
+ * boundary that would be destroyed by a replacement operation.
6
6
  * Sources consulted:
7
7
  *
8
8
  * - `document.preservation.opaqueFragments` — `OpaqueFragmentRecord[]`
@@ -29,6 +29,7 @@
29
29
  import type { CanonicalDocument } from "../../model/canonical-document.ts";
30
30
  import { findOpaqueFragmentsIntersectingRange } from "../../preservation/store.ts";
31
31
 
32
+ import { findContentControlsIntersectingRange } from "./content-control-evidence.ts";
32
33
  import type { ScopePositionMap, ScopePositionRange } from "./position-map.ts";
33
34
 
34
35
  export interface PreservationVerdict {
@@ -69,6 +70,11 @@ export function computePreservationVerdict(
69
70
  );
70
71
  }
71
72
 
73
+ const contentControls = findContentControlsIntersectingRange(document, range);
74
+ for (const control of contentControls) {
75
+ reasons.push(`content-control:${control.evidenceId}`);
76
+ }
77
+
72
78
  if (positionMap) {
73
79
  for (const [scopeId, markerRange] of positionMap.markerScopes) {
74
80
  // A marker range strictly inside the target range would be
@@ -33,6 +33,10 @@ import { enumerateScopes } from "../enumerate-scopes.ts";
33
33
  import type { EnumeratedScope } from "../enumerate-scopes.ts";
34
34
  import { emitScopeActionAudit } from "../audit-bundle.ts";
35
35
  import { compileReplacement } from "./compile.ts";
36
+ import {
37
+ MULTI_PARAGRAPH_REPLACEMENT_REFUSAL,
38
+ multiParagraphReplacementBlockers,
39
+ } from "../multi-paragraph-refusal.ts";
36
40
  import type {
37
41
  ReplacementScope,
38
42
  RuntimeOperationPlan,
@@ -47,6 +51,7 @@ export interface ApplyScopeReplacementSink {
47
51
  readonly getInteractionGuardSnapshot: () => InteractionGuardSnapshot;
48
52
  readonly getCompatibilityReport: () => CompatibilityReport;
49
53
  readonly applyScopeReplacement: (plan: RuntimeOperationPlan) => void;
54
+ readonly verifyReadback?: boolean;
50
55
  }
51
56
 
52
57
  export interface ApplyScopeReplacementInputs {
@@ -80,19 +85,28 @@ export interface ApplyScopeReplacementResult {
80
85
  }
81
86
 
82
87
  function documentHash(doc: CanonicalDocumentEnvelope): string {
83
- // Cheap structural hash block-count + total text length. Good enough
84
- // for the audit's "something changed" signal; later slices can swap in
85
- // a canonical serializer-backed content hash.
86
- const root = doc.content;
87
- let textLength = 0;
88
- for (const block of root.children) {
89
- if (block.type === "paragraph") {
90
- for (const child of block.children) {
91
- if (child.type === "text") textLength += child.text.length;
92
- }
93
- }
88
+ // Structural content hash. This is intentionally stronger than length-only
89
+ // so same-length stale/no-op replacements do not report as successful edits.
90
+ return JSON.stringify(doc.content);
91
+ }
92
+
93
+ function compileScopeById(
94
+ document: CanonicalDocumentEnvelope,
95
+ overlay: WorkflowOverlay | undefined,
96
+ scopeId: string,
97
+ ): { readonly scope: SemanticScope; readonly entry: EnumeratedScope } | null {
98
+ const paragraphIndexByBlockIndex = buildParagraphIndexMap(document);
99
+ const enumerateInputs = overlay ? { overlay } : {};
100
+ for (const entry of enumerateScopes(document, enumerateInputs)) {
101
+ if (entry.handle.scopeId !== scopeId) continue;
102
+ const compiled = compileScope(entry, {
103
+ document,
104
+ ...(overlay ? { overlay } : {}),
105
+ paragraphIndexByBlockIndex,
106
+ });
107
+ if (compiled) return { scope: compiled, entry };
94
108
  }
95
- return `blocks:${root.children.length}|text:${textLength}`;
109
+ return null;
96
110
  }
97
111
 
98
112
  export function applyScopeReplacement(
@@ -102,27 +116,18 @@ export function applyScopeReplacement(
102
116
  const docBefore = inputs.sink.getCanonicalDocument();
103
117
  const overlay = inputs.sink.getWorkflowOverlay() ?? undefined;
104
118
 
105
- const paragraphIndexByBlockIndex = buildParagraphIndexMap(docBefore);
106
- let resolvedScope: SemanticScope | null = null;
107
- let resolvedEnumerated: EnumeratedScope | null = null;
108
- const enumerateInputs = overlay ? { overlay } : {};
109
- for (const entry of enumerateScopes(docBefore, enumerateInputs)) {
110
- if (entry.handle.scopeId !== proposed.targetHandle.scopeId) continue;
111
- const compiled = compileScope(entry, {
112
- document: docBefore,
113
- ...(overlay ? { overlay } : {}),
114
- paragraphIndexByBlockIndex,
115
- });
116
- if (compiled) {
117
- resolvedScope = compiled;
118
- resolvedEnumerated = entry;
119
- break;
120
- }
121
- }
119
+ const resolved = compileScopeById(
120
+ docBefore,
121
+ overlay,
122
+ proposed.targetHandle.scopeId,
123
+ );
124
+ const resolvedScope = resolved?.scope ?? null;
125
+ const resolvedEnumerated = resolved?.entry ?? null;
122
126
 
123
127
  if (!resolvedScope || !resolvedEnumerated) {
124
128
  const validation: ValidationResult = {
125
129
  safe: false,
130
+ posture: "hard-refusal",
126
131
  blockedReasons: Object.freeze([
127
132
  `scope-not-resolvable:${proposed.targetHandle.scopeId}`,
128
133
  ]),
@@ -207,15 +212,30 @@ export function applyScopeReplacement(
207
212
  // `blockers[0]` / `reason` see the actionable sub-reason directly
208
213
  // (rather than the bare `compile-refused:scope`). Grammar matches
209
214
  // §10 `compile-refused:<kind>:<sub-reason>` (74a45eaf, 2026-04-23).
210
- const blocker =
215
+ const blockers =
211
216
  resolvedScope.kind === "scope"
212
- ? "compile-refused:scope:multi-paragraph-replace-not-implemented"
217
+ ? multiParagraphReplacementBlockers(
218
+ proposed.proposedContent.kind === "structured" ? "fragment" : "text",
219
+ )
220
+ : paragraphLike &&
221
+ proposed.operation === "replace" &&
222
+ proposed.preserve?.opaqueFragments === true
223
+ ? [
224
+ `compile-refused:${resolvedScope.kind}:opaque-preserving-text-target-unavailable`,
225
+ ]
213
226
  : paragraphLike && proposed.operation !== "replace"
214
- ? `compile-refused:${resolvedScope.kind}:operation-not-implemented:${proposed.operation}`
215
- : `compile-refused:${resolvedScope.kind}`;
227
+ ? [
228
+ `compile-refused:${resolvedScope.kind}:operation-not-implemented:${proposed.operation}`,
229
+ ]
230
+ : [`compile-refused:${resolvedScope.kind}`];
231
+ const blocker =
232
+ resolvedScope.kind === "scope"
233
+ ? MULTI_PARAGRAPH_REPLACEMENT_REFUSAL
234
+ : blockers[0] ?? `compile-refused:${resolvedScope.kind}`;
216
235
  const refused: ValidationResult = {
217
236
  safe: false,
218
- blockedReasons: Object.freeze([blocker]),
237
+ posture: "hard-refusal",
238
+ blockedReasons: Object.freeze([...blockers]),
219
239
  warnings: verdict.warnings,
220
240
  };
221
241
  // Coord-08 U5 — `reason` mirrors `blockers[0]` for symmetry. Agents
@@ -251,6 +271,49 @@ export function applyScopeReplacement(
251
271
 
252
272
  const docAfter = inputs.sink.getCanonicalDocument();
253
273
  const documentHashAfter = documentHash(docAfter);
274
+ const readback = compileScopeById(
275
+ docAfter,
276
+ inputs.sink.getWorkflowOverlay() ?? undefined,
277
+ proposed.targetHandle.scopeId,
278
+ );
279
+ const proposedText =
280
+ proposed.proposedContent.kind === "text"
281
+ ? proposed.proposedContent.text ?? ""
282
+ : null;
283
+ const shouldVerifyReadback =
284
+ inputs.sink.verifyReadback === true &&
285
+ proposed.preserve?.opaqueFragments === true;
286
+ const readbackFailureReason =
287
+ shouldVerifyReadback &&
288
+ documentHashAfter === documentHashBefore &&
289
+ posture === "direct-edit" &&
290
+ !readback
291
+ ? `apply-readback-unresolvable:${proposed.targetHandle.scopeId}`
292
+ : shouldVerifyReadback &&
293
+ documentHashAfter === documentHashBefore &&
294
+ posture === "direct-edit" &&
295
+ proposed.operation === "replace" &&
296
+ proposedText !== null &&
297
+ proposedText !== resolvedScope.content.text &&
298
+ readback?.scope.content.text === resolvedScope.content.text
299
+ ? `apply-readback-unchanged:${proposed.targetHandle.scopeId}`
300
+ : undefined;
301
+
302
+ if (readbackFailureReason) {
303
+ return {
304
+ applied: false,
305
+ reason: readbackFailureReason,
306
+ validation: {
307
+ safe: false,
308
+ posture: "hard-refusal",
309
+ blockedReasons: Object.freeze([readbackFailureReason]),
310
+ warnings: verdict.warnings,
311
+ },
312
+ plan,
313
+ scope: readback?.scope ?? resolvedScope,
314
+ authoredRevisionIds: Object.freeze([]),
315
+ };
316
+ }
254
317
 
255
318
  const authoredRevisionIds: string[] = [];
256
319
  for (const id of Object.keys(docAfter.review.revisions ?? {})) {
@@ -128,6 +128,55 @@ function longestTextOnlyRangeInParagraph(
128
128
  return best;
129
129
  }
130
130
 
131
+ function textInParagraphRange(
132
+ paragraph: ParagraphLikeEnumeratedScope["paragraph"],
133
+ paragraphFrom: number,
134
+ target: { readonly from: number; readonly to: number },
135
+ ): string {
136
+ let cursor = paragraphFrom;
137
+ const parts: string[] = [];
138
+
139
+ const appendText = (text: string, from: number, to: number) => {
140
+ const clippedFrom = Math.max(from, target.from);
141
+ const clippedTo = Math.min(to, target.to);
142
+ if (clippedTo <= clippedFrom) return;
143
+ const localFrom = clippedFrom - from;
144
+ const localTo = clippedTo - from;
145
+ parts.push(Array.from(text).slice(localFrom, localTo).join(""));
146
+ };
147
+
148
+ const walkInline = (node: InlineNode, from: number): number => {
149
+ switch (node.type) {
150
+ case "text": {
151
+ const chars = Array.from(node.text);
152
+ appendText(node.text, from, from + chars.length);
153
+ return chars.length;
154
+ }
155
+ case "tab":
156
+ appendText("\t", from, from + 1);
157
+ return 1;
158
+ case "hard_break":
159
+ appendText("\n", from, from + 1);
160
+ return 1;
161
+ case "hyperlink":
162
+ case "field": {
163
+ let local = from;
164
+ for (const child of node.children as readonly InlineNode[]) {
165
+ local += walkInline(child, local);
166
+ }
167
+ return local - from;
168
+ }
169
+ default:
170
+ return inlineLengthLocal(node);
171
+ }
172
+ };
173
+
174
+ for (const child of paragraph.children) {
175
+ cursor += walkInline(child, cursor);
176
+ }
177
+ return parts.join("");
178
+ }
179
+
131
180
  export interface CompileParagraphOptions {
132
181
  readonly document?: CanonicalDocument;
133
182
  readonly paragraphIndex?: number;
@@ -198,13 +247,17 @@ export interface CompileParagraphReplacementOptions {
198
247
  * Slice 5 — lower a paragraph-scope replacement proposal into a
199
248
  * `RuntimeOperationPlan`. Handles two content kinds:
200
249
  *
201
- * - `kind: "text"` → single `text-replace` step spanning the
202
- * paragraph's canonical block range; passes a flat string through
203
- * the existing `text.insert` runtime command.
250
+ * - `replace` + `kind: "text"` → single `text-replace` step spanning
251
+ * the paragraph's effective scope range; passes a flat string
252
+ * through the existing `text.insert` runtime command.
253
+ * - `insert-before` / `insert-after` + `kind: "text"` → single
254
+ * collapsed `text-replace` (or tracked insert in suggest mode) at
255
+ * the effective scope edge.
204
256
  * - `kind: "structured"` → single `fragment-replace` step carrying
205
257
  * a `CanonicalDocumentFragment` payload; dispatches via the
206
- * runtime's `insertFragment` pipeline with the block range as
207
- * selection. Unblocked 2026-04-22 once L02 shipped
258
+ * runtime's `insertFragment` pipeline with the effective range as
259
+ * selection for replace, or a collapsed edge for insert-before /
260
+ * insert-after. Unblocked 2026-04-22 once L02 shipped
208
261
  * `CanonicalDocumentFragment` (`src/model/canonical-document.ts`).
209
262
  *
210
263
  * Determinism (S3): the plan is a pure projection of (blockIndex,
@@ -215,7 +268,13 @@ export function compileParagraphReplacement(
215
268
  proposed: ReplacementScope,
216
269
  options: CompileParagraphReplacementOptions,
217
270
  ): RuntimeOperationPlan | null {
218
- if (proposed.operation !== "replace") return null;
271
+ if (
272
+ proposed.operation !== "replace" &&
273
+ proposed.operation !== "insert-before" &&
274
+ proposed.operation !== "insert-after"
275
+ ) {
276
+ return null;
277
+ }
219
278
 
220
279
  const blocks = computeBlockPositions(options.document);
221
280
  const blockRange = blocks.find((b) => b.blockIndex === entry.blockIndex);
@@ -259,7 +318,10 @@ export function compileParagraphReplacement(
259
318
  // replace range to the longest contiguous text-only sub-range so
260
319
  // opaque inlines (images, charts, preserve-only fragments) survive
261
320
  // the apply at their original positions.
262
- if (proposed.preserve?.opaqueFragments === true) {
321
+ if (
322
+ proposed.operation === "replace" &&
323
+ proposed.preserve?.opaqueFragments === true
324
+ ) {
263
325
  const textOnly = longestTextOnlyRangeInParagraph(
264
326
  entry.paragraph,
265
327
  blockRange.from,
@@ -276,8 +338,22 @@ export function compileParagraphReplacement(
276
338
  rangeKind = "opaque-preserving-text";
277
339
  }
278
340
 
341
+ const operationRange =
342
+ proposed.operation === "insert-before"
343
+ ? { from: effectiveRange.from, to: effectiveRange.from }
344
+ : proposed.operation === "insert-after"
345
+ ? { from: effectiveRange.to, to: effectiveRange.to }
346
+ : effectiveRange;
347
+
279
348
  if (proposed.proposedContent.kind === "text") {
280
349
  const text = proposed.proposedContent.text ?? "";
350
+ if (
351
+ proposed.operation === "replace" &&
352
+ proposed.preserve?.opaqueFragments === true &&
353
+ textInParagraphRange(entry.paragraph, blockRange.from, operationRange) === text
354
+ ) {
355
+ return null;
356
+ }
281
357
  const stepKind =
282
358
  options.posture === "suggest-mode" ? "text-insert-tracked" : "text-replace";
283
359
  const summaryScope =
@@ -286,6 +362,16 @@ export function compileParagraphReplacement(
286
362
  : rangeKind === "opaque-preserving-text"
287
363
  ? `paragraph #${entry.blockIndex} opaque-preserving text range [${effectiveRange.from}..${effectiveRange.to}]`
288
364
  : `paragraph #${entry.blockIndex}`;
365
+ const actionVerb =
366
+ proposed.operation === "insert-before"
367
+ ? "insert before"
368
+ : proposed.operation === "insert-after"
369
+ ? "insert after"
370
+ : "replace";
371
+ const actionSummary =
372
+ proposed.operation === "replace"
373
+ ? `${actionVerb} ${summaryScope} text (len ${text.length})`
374
+ : `${actionVerb} ${summaryScope} text at ${operationRange.from} (len ${text.length})`;
289
375
  return {
290
376
  scopeId: entry.handle.scopeId,
291
377
  targetKind: "paragraph",
@@ -295,9 +381,9 @@ export function compileParagraphReplacement(
295
381
  kind: stepKind,
296
382
  summary:
297
383
  stepKind === "text-replace"
298
- ? `replace ${summaryScope} text (len ${text.length})`
299
- : `suggest-mode ${summaryScope} text replace (len ${text.length})`,
300
- range: { from: effectiveRange.from, to: effectiveRange.to },
384
+ ? actionSummary
385
+ : `suggest-mode ${actionSummary}`,
386
+ range: { from: operationRange.from, to: operationRange.to },
301
387
  text,
302
388
  ...(proposed.formatting ? { formatting: proposed.formatting } : {}),
303
389
  },
@@ -325,6 +411,16 @@ export function compileParagraphReplacement(
325
411
  : rangeKind === "opaque-preserving-text"
326
412
  ? `paragraph #${entry.blockIndex} opaque-preserving text range [${effectiveRange.from}..${effectiveRange.to}]`
327
413
  : `paragraph #${entry.blockIndex}`;
414
+ const actionVerb =
415
+ proposed.operation === "insert-before"
416
+ ? "insert before"
417
+ : proposed.operation === "insert-after"
418
+ ? "insert after"
419
+ : "replace";
420
+ const actionSummary =
421
+ proposed.operation === "replace"
422
+ ? `${actionVerb} ${summaryScope} with structured fragment (${blockCount} block(s))`
423
+ : `${actionVerb} ${summaryScope} structured fragment at ${operationRange.from} (${blockCount} block(s))`;
328
424
  return {
329
425
  scopeId: entry.handle.scopeId,
330
426
  targetKind: "paragraph",
@@ -332,8 +428,8 @@ export function compileParagraphReplacement(
332
428
  steps: Object.freeze([
333
429
  {
334
430
  kind: "fragment-replace",
335
- summary: `replace ${summaryScope} with structured fragment (${blockCount} block(s))`,
336
- range: { from: effectiveRange.from, to: effectiveRange.to },
431
+ summary: actionSummary,
432
+ range: { from: operationRange.from, to: operationRange.to },
337
433
  fragment,
338
434
  },
339
435
  ]),