@beyondwork/docx-react-component 1.0.88 → 1.0.90

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 +51 -0
  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 +30 -5
  37. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
  38. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +5 -1
  39. package/src/ui-tailwind/review/tw-review-rail.tsx +5 -0
  40. package/src/ui-tailwind/review/tw-workflow-tab.tsx +32 -38
  41. package/src/ui-tailwind/review-workspace/types.ts +2 -0
  42. package/src/ui-tailwind/theme/editor-theme.css +25 -12
  43. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +20 -37
  44. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +15 -27
  45. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +24 -15
  46. package/src/ui-tailwind/tw-review-workspace.tsx +32 -18
  47. package/src/ui-tailwind/workflow-scope-layers.ts +70 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.88",
4
+ "version": "1.0.90",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -117,6 +117,10 @@ export type RuntimeApiHandle = Pick<
117
117
  // ReplacementScope` + `runtime.content.replaceText(scopeId, …)`
118
118
  // route compiled plans through this seam.
119
119
  | "applyScopeReplacement"
120
+ // Layer-08 scoped formatting actions. `ai.applyScopeAction` and
121
+ // `runtime.formatting.applyToScope` route exact mark mutations through
122
+ // this seam so formatting edits do not replace text payloads.
123
+ | "applyScopeFormatting"
120
124
  // Nested facets (runtime.layout + runtime.geometry families,
121
125
  // plus debug telemetry bus for UxResponse emission)
122
126
  | "debug"
@@ -187,6 +191,7 @@ export const RUNTIME_API_HANDLE_SHAPE_CHECK: Record<keyof RuntimeApiHandle, true
187
191
  compileScopeCardById: true,
188
192
  compileScopeRailSnapshot: true,
189
193
  applyScopeReplacement: true,
194
+ applyScopeFormatting: true,
190
195
  debug: true,
191
196
  layout: true,
192
197
  geometry: true,
@@ -28,14 +28,17 @@ import {
28
28
  import type {
29
29
  ReplacementOperationKind,
30
30
  ReplacementPreservePolicy,
31
+ ScopeFormattingAction,
31
32
  ValidationResult as CompilerValidationResult,
32
33
  } from "../../../runtime/scopes/index.ts";
33
34
  import type { AIAction } from "../../../runtime/workflow/ai-action-policy.ts";
35
+ import type { TextFormattingDirective } from "../../public-types.ts";
34
36
 
35
37
  export interface ReplacementProposalInput {
36
38
  readonly targetScopeId: string;
37
39
  readonly operation: ReplacementOperationKind;
38
40
  readonly proposedText?: string;
41
+ readonly formatting?: TextFormattingDirective;
39
42
  readonly preserve?: ReplacementPreservePolicy;
40
43
  readonly reason?: string;
41
44
  readonly actionId?: AIAction;
@@ -150,6 +153,7 @@ export interface ApplyReplacementScopeInput {
150
153
  readonly text?: string;
151
154
  readonly structured?: unknown;
152
155
  };
156
+ readonly formatting?: TextFormattingDirective;
153
157
  readonly preserve?: ReplacementPreservePolicy;
154
158
  readonly reason?: string;
155
159
  readonly actionId?: AIAction;
@@ -185,6 +189,42 @@ export const applyReplacementScopeMetadata: ApiV3FnMetadata = {
185
189
  "§AI API § ai.applyReplacementScope. Adapter: widened to {targetScopeId, operation, proposedText?, preserve?, actionId?, proposalId?}; routes through applyScopeReplacement (re-validates live state, compiles per-kind plan, dispatches via DocumentRuntime.applyScopeReplacement, emits one ScopeActionAudit on the scope telemetry channel).",
186
190
  };
187
191
 
192
+ export interface ApplyScopeActionInput {
193
+ readonly targetScopeId: string;
194
+ readonly action: ScopeFormattingAction;
195
+ readonly reason?: string;
196
+ readonly actionId?: AIAction;
197
+ readonly actorId?: string;
198
+ readonly origin?: "ui" | "agent" | "host";
199
+ readonly proposalId?: string;
200
+ }
201
+
202
+ export const applyScopeActionMetadata: ApiV3FnMetadata = {
203
+ name: "ai.applyScopeAction",
204
+ status: "live-with-adapter",
205
+ sourceLayer: "semantic-scope-compiler",
206
+ liveEvidence: {
207
+ runnerTest: "test/runtime/scopes/formatting-actions.test.ts",
208
+ commit: "refactor-08-scope-formatting-actions",
209
+ },
210
+ uxIntent: {
211
+ uiVisible: true,
212
+ expectsUxResponse: "inline-change",
213
+ expectedDelta: "formatting inside target scope changes",
214
+ },
215
+ agentMetadata: {
216
+ readOrMutate: "mutate",
217
+ boundedScope: "scope",
218
+ auditCategory: "formatting-apply",
219
+ policyHints: ["requires-approval-for-formatting"],
220
+ },
221
+ stateClass: "A-canonical",
222
+ persistsTo: "canonical",
223
+ broadcastsVia: "crdt",
224
+ rwdReference:
225
+ "§AI API § ai.applyScopeAction. Scope-targeted formatting writer over Layer-08; validates live scope/global guards, dispatches one undoable runtime edit, and emits one ScopeActionAudit. Use for exact mark actions such as clearing only w:highlight.",
226
+ };
227
+
188
228
  /**
189
229
  * Input shape for `validateReplacementScope`. Slice 4 widens the input
190
230
  * to carry enough information for a fresh compile + compose, since the
@@ -314,6 +354,7 @@ export function createReplacementFamily(runtime: RuntimeApiHandle) {
314
354
  : input.proposedText !== undefined
315
355
  ? { proposedText: input.proposedText }
316
356
  : {}),
357
+ ...(input.formatting ? { formatting: input.formatting } : {}),
317
358
  ...(input.preserve ? { preserve: input.preserve } : {}),
318
359
  ...(input.reason ? { reason: input.reason } : {}),
319
360
  ...(input.actionId ? { actionId: input.actionId } : {}),
@@ -341,6 +382,47 @@ export function createReplacementFamily(runtime: RuntimeApiHandle) {
341
382
  authoredRevisionIds: result.authoredRevisionIds,
342
383
  };
343
384
  },
385
+
386
+ applyScopeAction(input: ApplyScopeActionInput): ApplyResult {
387
+ // @endStateApi — live-with-adapter. Routes through the scope
388
+ // compiler's applyFormatting + the same ApplyResult projection
389
+ // used by applyReplacementScope.
390
+ const proposalId =
391
+ input.proposalId ??
392
+ mockId(
393
+ runtime.getSessionState().documentId,
394
+ `scope-action-${input.targetScopeId}-${input.action.kind}`,
395
+ );
396
+
397
+ const result = compiler.applyFormatting({
398
+ targetScopeId: input.targetScopeId,
399
+ action: input.action,
400
+ ...(input.reason ? { reason: input.reason } : {}),
401
+ ...(input.actionId ? { actionId: input.actionId } : {}),
402
+ actorId: input.actorId ?? "agent",
403
+ origin: input.origin ?? "agent",
404
+ emittedAtUtc: new Date(0).toISOString(),
405
+ });
406
+
407
+ emitUxResponse(runtime, {
408
+ apiFn: applyScopeActionMetadata.name,
409
+ intent: applyScopeActionMetadata.uxIntent.expectedDelta ?? "",
410
+ mockOrLive: "live",
411
+ uiVisible: true,
412
+ expectedDelta: applyScopeActionMetadata.uxIntent.expectedDelta,
413
+ });
414
+
415
+ return {
416
+ proposalId,
417
+ applied: result.applied,
418
+ ...(result.reason ? { reason: result.reason } : {}),
419
+ ...(result.validation.blockedReasons.length > 0
420
+ ? { blockers: Object.freeze([...result.validation.blockedReasons]) }
421
+ : {}),
422
+ ...(result.audit ? { auditHint: result.audit.actionId } : {}),
423
+ authoredRevisionIds: result.authoredRevisionIds,
424
+ };
425
+ },
344
426
  };
345
427
  }
346
428
 
@@ -17,6 +17,7 @@ import type {
17
17
  EditorAnchorProjection,
18
18
  SurfaceBlockSnapshot,
19
19
  SurfaceInlineSegment,
20
+ TextFormattingDirective,
20
21
  } from "../../public-types.ts";
21
22
  import { emitUxResponse } from "../_ux-response.ts";
22
23
  import { createScopeCompilerService } from "../../../runtime/scopes/index.ts";
@@ -78,6 +79,7 @@ export const getLocationMetadata: ApiV3FnMetadata = {
78
79
  export interface ReplaceTextInput {
79
80
  readonly scopeId: string;
80
81
  readonly replacement: string;
82
+ readonly formatting?: TextFormattingDirective;
81
83
  }
82
84
 
83
85
  export interface ReplaceTextResult {
@@ -200,6 +202,7 @@ export function createContentFamily(runtime: RuntimeApiHandle) {
200
202
  targetScopeId: input.scopeId,
201
203
  operation: "replace",
202
204
  proposedText: input.replacement,
205
+ ...(input.formatting ? { formatting: input.formatting } : {}),
203
206
  actorId: "user",
204
207
  origin: "ui",
205
208
  emittedAtUtc: new Date(0).toISOString(),
@@ -14,6 +14,10 @@
14
14
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
15
15
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
16
16
  import { emitUxResponse } from "../_ux-response.ts";
17
+ import {
18
+ createScopeCompilerService,
19
+ type ScopeFormattingAction,
20
+ } from "../../../runtime/scopes/index.ts";
17
21
  import {
18
22
  resolveEffectiveFormatting,
19
23
  createFormattingContext,
@@ -159,7 +163,43 @@ export const applyMetadata: ApiV3FnMetadata = {
159
163
  "§Runtime API § runtime.formatting.apply. Live adapter over DocumentRuntime.applyFormattingOperation(); supports direct formatting/paragraph alignment/indentation and authors bounded property-change suggestions when the effective mode is suggesting. Style suggestions are intentionally not accepted by this operation shape.",
160
164
  };
161
165
 
166
+ export interface FormattingApplyToScopeInput {
167
+ readonly scopeId: string;
168
+ readonly action: ScopeFormattingAction;
169
+ }
170
+
171
+ export interface FormattingApplyToScopeResult {
172
+ readonly applied: boolean;
173
+ readonly reason?: string;
174
+ }
175
+
176
+ export const applyToScopeMetadata: ApiV3FnMetadata = {
177
+ name: "runtime.formatting.applyToScope",
178
+ status: "live-with-adapter",
179
+ sourceLayer: "semantic-scope-compiler",
180
+ liveEvidence: {
181
+ runnerTest: "test/runtime/scopes/formatting-actions.test.ts",
182
+ commit: "refactor-08-scope-formatting-actions",
183
+ },
184
+ stateClass: "A-canonical",
185
+ persistsTo: "canonical",
186
+ broadcastsVia: "crdt",
187
+ uxIntent: {
188
+ uiVisible: true,
189
+ expectsUxResponse: "inline-change",
190
+ expectedDelta: "formatting changes inside the target scope",
191
+ },
192
+ agentMetadata: {
193
+ readOrMutate: "mutate",
194
+ boundedScope: "scope",
195
+ auditCategory: "formatting-write",
196
+ },
197
+ rwdReference:
198
+ "§Runtime API § runtime.formatting.applyToScope. Scope-targeted formatting writer over Layer-08; resolves target scope by id, validates scope/global guards, applies exact text-mark action, emits one ScopeActionAudit, and enters the normal undo stack.",
199
+ };
200
+
162
201
  export function createFormattingFamily(runtime: RuntimeApiHandle) {
202
+ const compiler = createScopeCompilerService(runtime);
163
203
  return {
164
204
  apply(operation: FormattingOperation): void {
165
205
  // @endStateApi — live. Delegates to the runtime-owned mutation seam
@@ -175,6 +215,30 @@ export function createFormattingFamily(runtime: RuntimeApiHandle) {
175
215
  });
176
216
  },
177
217
 
218
+ applyToScope(input: FormattingApplyToScopeInput): FormattingApplyToScopeResult {
219
+ // @endStateApi — live-with-adapter. Routes through the scope
220
+ // compiler's applyFormatting with actor=user + origin=ui; returns
221
+ // the authored-revision + audit shape projected for v3 callers.
222
+ const result = compiler.applyFormatting({
223
+ targetScopeId: input.scopeId,
224
+ action: input.action,
225
+ actorId: "user",
226
+ origin: "ui",
227
+ emittedAtUtc: new Date(0).toISOString(),
228
+ });
229
+ emitUxResponse(runtime, {
230
+ apiFn: applyToScopeMetadata.name,
231
+ intent: applyToScopeMetadata.uxIntent.expectedDelta ?? "",
232
+ mockOrLive: "live",
233
+ uiVisible: true,
234
+ expectedDelta: applyToScopeMetadata.uxIntent.expectedDelta,
235
+ });
236
+ return {
237
+ applied: result.applied,
238
+ ...(result.reason ? { reason: result.reason } : {}),
239
+ };
240
+ },
241
+
178
242
  getEffective(
179
243
  nodeRef: FormattingNodeRef,
180
244
  opts?: FormattingGetEffectiveOpts,
@@ -588,6 +588,113 @@ export function applyFormattingOperationToDocument(
588
588
  };
589
589
  }
590
590
 
591
+ export type TextMarkClearTarget = TextMark["type"] | "visualHighlight";
592
+
593
+ export type TextMarkRangeOperation =
594
+ | { type: "clear-mark"; mark: TextMarkClearTarget }
595
+ | { type: "set-mark"; mark: TextMark };
596
+
597
+ /**
598
+ * Apply an exact canonical text-mark mutation over a document-coordinate
599
+ * range without relying on the current selection or a mounted render surface.
600
+ * This is the scope-action path for source-layer edits such as removing only
601
+ * `w:highlight` while preserving shading and every unrelated run mark.
602
+ */
603
+ export function applyTextMarkOperationToDocumentRange(
604
+ document: CanonicalDocumentEnvelope,
605
+ range: { from: number; to: number },
606
+ operation: TextMarkRangeOperation,
607
+ ): FormattingMutationResult {
608
+ const selectionFrom = Math.min(range.from, range.to);
609
+ const selectionTo = Math.max(range.from, range.to);
610
+ const selection: RuntimeRenderSnapshot["selection"] = {
611
+ anchor: selectionFrom,
612
+ head: selectionTo,
613
+ isCollapsed: selectionFrom === selectionTo,
614
+ activeRange: {
615
+ kind: "range",
616
+ from: selectionFrom,
617
+ to: selectionTo,
618
+ assoc: { start: -1, end: 1 },
619
+ },
620
+ };
621
+
622
+ if (selectionFrom === selectionTo) {
623
+ return { document, selection, changed: false };
624
+ }
625
+
626
+ const nextDocument = structuredClone(document);
627
+ const root = nextDocument.content as DocumentRootNode;
628
+ const updateMarks = resolveTextMarkRangeUpdater(operation);
629
+ let changed = false;
630
+ let cursor = 0;
631
+
632
+ for (let blockIndex = 0; blockIndex < root.children.length; blockIndex += 1) {
633
+ const block = root.children[blockIndex];
634
+ const blockFrom = cursor;
635
+ const blockLength =
636
+ block?.type === "paragraph"
637
+ ? block.children.reduce(
638
+ (total, child) => total + inlineNodeLength(child as InlineNode),
639
+ 0,
640
+ )
641
+ : 1;
642
+ const blockTo = blockFrom + blockLength;
643
+
644
+ if (
645
+ block?.type === "paragraph" &&
646
+ rangesOverlap(selectionFrom, selectionTo, blockFrom, blockTo)
647
+ ) {
648
+ const transformed = transformInlineNodes(
649
+ block.children,
650
+ blockFrom,
651
+ selectionFrom,
652
+ selectionTo,
653
+ updateMarks,
654
+ );
655
+ if (transformed.changed) {
656
+ block.children = transformed.nodes;
657
+ changed = true;
658
+ }
659
+ }
660
+
661
+ cursor = blockTo;
662
+ if (blockIndex < root.children.length - 1) {
663
+ cursor += 1;
664
+ }
665
+ }
666
+
667
+ return {
668
+ document: changed ? nextDocument : document,
669
+ selection,
670
+ changed,
671
+ };
672
+ }
673
+
674
+ function resolveTextMarkRangeUpdater(
675
+ operation: TextMarkRangeOperation,
676
+ ): (marks?: TextMark[]) => TextMark[] | undefined {
677
+ if (operation.type === "set-mark") {
678
+ return (marks) => {
679
+ const nextMarks = cloneMarks(marks).filter(
680
+ (candidate) => candidate.type !== operation.mark.type,
681
+ );
682
+ nextMarks.push({ ...operation.mark } as TextMark);
683
+ return nextMarks.length > 0 ? nextMarks : undefined;
684
+ };
685
+ }
686
+
687
+ return (marks) => {
688
+ const nextMarks = cloneMarks(marks).filter((candidate) => {
689
+ if (operation.mark === "visualHighlight") {
690
+ return candidate.type !== "highlight" && candidate.type !== "backgroundColor";
691
+ }
692
+ return candidate.type !== operation.mark;
693
+ });
694
+ return nextMarks.length > 0 ? nextMarks : undefined;
695
+ };
696
+ }
697
+
591
698
  function resolveMarkUpdater(
592
699
  snapshot: RuntimeRenderSnapshot,
593
700
  operation: Exclude<
@@ -829,7 +829,7 @@ function createInsertionUnits(
829
829
  * - If the range is collapsed, use the marks of the text unit immediately left of
830
830
  * the caret (Word-matching behavior for empty-range inserts).
831
831
  * - Otherwise walk text units in `[from, to)`. If every text unit shares the same
832
- * marks (by type), use them. Mixed → fall back to paragraph-default (`undefined`).
832
+ * marks (by type and value), use them. Mixed → fall back to paragraph-default (`undefined`).
833
833
  */
834
834
  function resolveMarksForInsertion(
835
835
  intent: TextTransactionIntent,
@@ -887,9 +887,16 @@ function marksAreEqual(left: TextMark[] | undefined, right: TextMark[] | undefin
887
887
  if (!left && !right) return true;
888
888
  if (!left || !right) return false;
889
889
  if (left.length !== right.length) return false;
890
- const sortedLeft = [...left].map((mark) => mark.type).sort();
891
- const sortedRight = [...right].map((mark) => mark.type).sort();
892
- return sortedLeft.every((type, index) => type === sortedRight[index]);
890
+ const sortedLeft = [...left].map(stableMarkKey).sort();
891
+ const sortedRight = [...right].map(stableMarkKey).sort();
892
+ return sortedLeft.every((key, index) => key === sortedRight[index]);
893
+ }
894
+
895
+ function stableMarkKey(mark: TextMark): string {
896
+ const entries = Object.entries(mark).sort(([left], [right]) =>
897
+ left.localeCompare(right),
898
+ );
899
+ return JSON.stringify(entries);
893
900
  }
894
901
 
895
902
  function resolveParagraphPropertiesAtPosition(
@@ -191,6 +191,7 @@ import {
191
191
  } from "../core/commands/add-scope.ts";
192
192
  import {
193
193
  applyFormattingOperationToDocument,
194
+ applyTextMarkOperationToDocumentRange,
194
195
  type FormattingOperation,
195
196
  } from "../core/commands/formatting-commands.ts";
196
197
  import { resolveActiveParagraphIndex } from "../core/commands/paragraph-layout-commands.ts";
@@ -450,6 +451,12 @@ export interface DocumentRuntime {
450
451
  * UI paths.
451
452
  */
452
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;
453
460
  dispatch(command: EditorCommand): void;
454
461
  /**
455
462
  * Apply a command received from a remote collaborator. The command
@@ -3384,6 +3391,7 @@ export function createDocumentRuntime(
3384
3391
  {
3385
3392
  type: "text.insert",
3386
3393
  text: step.text,
3394
+ ...(step.formatting ? { formatting: step.formatting } : {}),
3387
3395
  origin: createOrigin("api", timestamp),
3388
3396
  },
3389
3397
  {
@@ -3458,6 +3466,7 @@ export function createDocumentRuntime(
3458
3466
  {
3459
3467
  type: "text.insert",
3460
3468
  text: step.text,
3469
+ ...(step.formatting ? { formatting: step.formatting } : {}),
3461
3470
  origin: createOrigin("api", timestamp),
3462
3471
  },
3463
3472
  {
@@ -3506,6 +3515,48 @@ export function createDocumentRuntime(
3506
3515
  }
3507
3516
  }
3508
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
+ },
3509
3560
  insertFragment(fragment, target) {
3510
3561
  // I2 Tier B Slice 1 — dispatch `fragment.insert` against the active story. The
3511
3562
  // runtime command handler routes into `applyFragmentInsert` (structure-ops).
@@ -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;