@beyondwork/docx-react-component 1.0.79 → 1.0.80

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.
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.79",
4
+ "version": "1.0.80",
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": [
@@ -1211,6 +1211,18 @@ export type SurfaceInlineSegment =
1211
1211
  * is enforced by `test/runtime/formatting/production-boundary.test.ts`.
1212
1212
  */
1213
1213
  revisionDisplay?: {
1214
+ /**
1215
+ * Identity of the attached revision. Render consumers emit
1216
+ * `data-revision-id` and route sidebar scroll-to-revision off
1217
+ * this field rather than re-deriving from the review store.
1218
+ */
1219
+ revisionId: string;
1220
+ /**
1221
+ * The revision's kind. Mirrors `RevisionRecord.kind` on the
1222
+ * canonical document. Render consumers branch on this (e.g.
1223
+ * widget-bracket placement in suggestions mode).
1224
+ */
1225
+ kind: "insertion" | "deletion" | "formatting" | "move" | "property-change";
1214
1226
  markupMode: "clean" | "simple" | "all";
1215
1227
  hidden?: boolean;
1216
1228
  strikethrough?: boolean;
@@ -2517,23 +2529,20 @@ export interface AddScopeResult {
2517
2529
  /**
2518
2530
  * Structured diagnostic for the marker-plant attempt. Omitted on the
2519
2531
  * happy path for back-compat; populated with `planted: false` + a
2520
- * typed reason when the plant was refused (cross-paragraph range,
2521
- * non-paragraph target, out-of-bounds). Callers that previously
2522
- * assumed every returned `scopeId` was live should migrate to
2523
- * checking this field.
2532
+ * typed reason when the plant was refused (non-paragraph target,
2533
+ * out-of-bounds, empty-document). Cross-paragraph ranges plant
2534
+ * successfully as of 2026-04-24 see the multi-paragraph-scopes
2535
+ * slice. Callers that previously assumed every returned `scopeId`
2536
+ * was live should migrate to checking this field.
2524
2537
  */
2525
2538
  plantStatus?: {
2526
2539
  readonly planted: false;
2527
2540
  readonly reason:
2528
- | "cross-paragraph-range"
2529
2541
  | "non-paragraph-target"
2530
2542
  | "range-out-of-bounds"
2531
2543
  | "empty-document";
2532
2544
  readonly requestedFrom: number;
2533
2545
  readonly requestedTo: number;
2534
- /** Present on `cross-paragraph-range`. */
2535
- readonly fromBlockIndex?: number;
2536
- readonly toBlockIndex?: number;
2537
2546
  /** Present on `non-paragraph-target`. */
2538
2547
  readonly blockIndex?: number;
2539
2548
  readonly blockKind?: string;
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * @endStateApi v3 — `ai.resolve` family.
3
3
  *
4
- * Slice 3 of refactor/08 graduates `resolveReference` to
5
- * `live-with-adapter` for the FOUR DETERMINISTIC hint kinds: `scope-id`,
6
- * `semantic-path`, `offset`, and `range`. Offset / range resolution now
7
- * honors precise nested-kind ranges (fields, table rows, table cells)
8
- * via the scope-compiler's specificity tie-breaker — see
9
- * `docs/architecture/08-semantic-scope-compiler.md` §"Resolve-reference
10
- * precision" for the specificity table.
4
+ * Slice 3 of refactor/08 graduated `resolveReference` to
5
+ * `live-with-adapter`. Post-KI-P9 (2026-04-24) the reference union
6
+ * carries **three durable reference kinds**: `scope-id`, `semantic-path`,
7
+ * and `natural-language`. Positional queries (`offset` / `range`) were
8
+ * split into `queryScopeAtPosition(at)` / `queryScopeInRange(from, to)`
9
+ * one-shot APIs that return `ScopeHandle | null` directly — the
10
+ * type-system split prevents a positional hint from being cached or
11
+ * round-tripped as a reference across a mutation.
12
+ *
13
+ * Offset / range lookups still honor precise nested-kind ranges (fields,
14
+ * table rows, table cells) via the scope-compiler's specificity
15
+ * tie-breaker — see `docs/architecture/08-semantic-scope-compiler.md`
16
+ * §"Resolve-reference precision" for the specificity table.
11
17
  *
12
18
  * The `natural-language` hint remains **partial by design**. The live
13
19
  * path is a deterministic substring matcher over:
@@ -161,14 +161,11 @@ export type CreateScopeFromAnchorResult =
161
161
  | "from-negative"
162
162
  | "to-less-than-from"
163
163
  | "range-exceeds-story-length"
164
- | "cross-paragraph-range"
165
164
  | "non-paragraph-target"
166
165
  | "empty-document";
167
166
  readonly from: number;
168
167
  readonly to: number;
169
168
  readonly storyLength: number;
170
- readonly fromBlockIndex?: number;
171
- readonly toBlockIndex?: number;
172
169
  readonly blockIndex?: number;
173
170
  readonly blockKind?: string;
174
171
  /** Agent-actionable single-sentence explanation. Safe to surface to LLM tool replies as-is. */
@@ -540,12 +537,6 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
540
537
  from: adapterResult.from,
541
538
  to: adapterResult.to,
542
539
  storyLength: adapterResult.storyLength,
543
- ...(adapterResult.fromBlockIndex !== undefined
544
- ? { fromBlockIndex: adapterResult.fromBlockIndex }
545
- : {}),
546
- ...(adapterResult.toBlockIndex !== undefined
547
- ? { toBlockIndex: adapterResult.toBlockIndex }
548
- : {}),
549
540
  ...(adapterResult.blockIndex !== undefined
550
541
  ? { blockIndex: adapterResult.blockIndex }
551
542
  : {}),
@@ -16,6 +16,10 @@ import type { CanonicalDocumentEnvelope } from "../state/editor-state.ts";
16
16
  * knowing whether the scope was ever live. Every non-`"planted"`
17
17
  * status is now typed so callers can discriminate + surface
18
18
  * agent-actionable recovery hints.
19
+ *
20
+ * Cross-paragraph ranges are first-class (planted) as of the
21
+ * 2026-04-24 multi-paragraph-scopes slice. The `cross-paragraph-range`
22
+ * failure variant no longer exists — markers now span paragraphs.
19
23
  */
20
24
  export type InsertScopeMarkersResult =
21
25
  | {
@@ -25,14 +29,6 @@ export type InsertScopeMarkersResult =
25
29
  /** Absolute span that got planted (after from/to normalization). */
26
30
  readonly plantedRange: { readonly from: number; readonly to: number };
27
31
  }
28
- | {
29
- readonly status: "cross-paragraph-range";
30
- readonly scopeId: string;
31
- readonly from: number;
32
- readonly to: number;
33
- readonly fromBlockIndex: number;
34
- readonly toBlockIndex: number;
35
- }
36
32
  | {
37
33
  readonly status: "non-paragraph-target";
38
34
  readonly scopeId: string;
@@ -60,13 +56,17 @@ export type InsertScopeMarkersResult =
60
56
  * pair of `scope_marker_start` / `scope_marker_end` inline nodes got
61
57
  * inserted at `[from, to]`.
62
58
  *
59
+ * Same-paragraph ranges insert both markers into the single owning
60
+ * paragraph. Cross-paragraph ranges place the start marker inside the
61
+ * paragraph containing `from` and the end marker inside the paragraph
62
+ * containing `to`; intermediate top-level blocks (paragraphs, tables,
63
+ * section breaks) are passed through unmodified and fall inside the
64
+ * scope by document order.
65
+ *
63
66
  * Failure modes (all previously silent — pre-2026-04-24):
64
- * - `cross-paragraph-range` — `from` and `to` resolve to different
65
- * top-level paragraph blocks. Multi-block marker scopes are not
66
- * yet wired; narrow the range to land inside one paragraph.
67
- * - `non-paragraph-target` — the target range lands inside a
67
+ * - `non-paragraph-target` — either `from` or `to` lands inside a
68
68
  * non-paragraph block (table / SDT / section_break). Marker
69
- * scopes only plant inside paragraphs today.
69
+ * scopes only plant inside paragraphs.
70
70
  * - `range-out-of-bounds` — `to` exceeds the main story length.
71
71
  * - `empty-document` — the document has no root / no children.
72
72
  *
@@ -82,10 +82,10 @@ export function insertScopeMarkers(
82
82
  to: number;
83
83
  },
84
84
  ): InsertScopeMarkersResult {
85
- const { scopeId, from, to } = params;
85
+ const { scopeId } = params;
86
86
  const root = document.content as DocumentRootNode;
87
- const normalizedFrom = Math.min(from, to);
88
- const normalizedTo = Math.max(from, to);
87
+ const normalizedFrom = Math.min(params.from, params.to);
88
+ const normalizedTo = Math.max(params.from, params.to);
89
89
 
90
90
  if (!root || root.type !== "doc" || root.children.length === 0) {
91
91
  return {
@@ -96,9 +96,15 @@ export function insertScopeMarkers(
96
96
  };
97
97
  }
98
98
 
99
- // First pass — locate which block each of `from` and `to` resolves to,
100
- // so we can distinguish cross-paragraph / non-paragraph / out-of-bounds
101
- // failures before attempting the plant.
99
+ // Pre-pass — locate which block each of `from` and `to` resolves to,
100
+ // compute story length, and build per-paragraph slot envelopes.
101
+ // **Invariant:** the cursor arithmetic here MUST match
102
+ // `src/runtime/scopes/position-map.ts::computeBlockPositions`
103
+ // (same `inlineLength`, same `+1` per-block boundary). Layer 04
104
+ // cannot change one walker without the other or cross-paragraph
105
+ // marker insertion silently drifts vs. enumerated scope ranges.
106
+ type ParaSlot = { index: number; from: number; to: number };
107
+ const paraSlots: ParaSlot[] = [];
102
108
  let cursor = 0;
103
109
  let fromBlockIndex = -1;
104
110
  let fromBlockKind: string | null = null;
@@ -115,15 +121,24 @@ export function insertScopeMarkers(
115
121
  (total, child) => total + inlineLength(child as InlineNode),
116
122
  0,
117
123
  );
124
+ paraSlots.push({ index: i, from: blockFrom, to: blockFrom + blockLength });
118
125
  } else {
119
126
  blockLength = 1;
120
127
  }
121
128
  const blockTo = blockFrom + blockLength;
122
- if (fromBlockIndex === -1 && normalizedFrom >= blockFrom && normalizedFrom <= blockTo) {
129
+ if (
130
+ fromBlockIndex === -1 &&
131
+ normalizedFrom >= blockFrom &&
132
+ normalizedFrom <= blockTo
133
+ ) {
123
134
  fromBlockIndex = i;
124
135
  fromBlockKind = block.type;
125
136
  }
126
- if (toBlockIndex === -1 && normalizedTo >= blockFrom && normalizedTo <= blockTo) {
137
+ if (
138
+ toBlockIndex === -1 &&
139
+ normalizedTo >= blockFrom &&
140
+ normalizedTo <= blockTo
141
+ ) {
127
142
  toBlockIndex = i;
128
143
  toBlockKind = block.type;
129
144
  }
@@ -150,62 +165,81 @@ export function insertScopeMarkers(
150
165
  storyLength,
151
166
  };
152
167
  }
153
- if (fromBlockIndex !== toBlockIndex) {
168
+ if (fromBlockKind !== "paragraph" || toBlockKind !== "paragraph") {
169
+ // Either endpoint lands on a non-paragraph top-level block
170
+ // (table / section_break / sdt). Report the first non-paragraph
171
+ // offender for diagnostic clarity.
172
+ const nonParaIndex =
173
+ fromBlockKind !== "paragraph" ? fromBlockIndex : toBlockIndex;
174
+ const nonParaKind =
175
+ fromBlockKind !== "paragraph" ? fromBlockKind : toBlockKind;
154
176
  return {
155
- status: "cross-paragraph-range",
177
+ status: "non-paragraph-target",
156
178
  scopeId,
157
179
  from: normalizedFrom,
158
180
  to: normalizedTo,
159
- fromBlockIndex,
160
- toBlockIndex,
181
+ blockIndex: nonParaIndex,
182
+ blockKind: nonParaKind ?? "unknown",
161
183
  };
162
184
  }
163
- if (fromBlockKind !== "paragraph") {
185
+
186
+ const startSlot = paraSlots.find((s) => s.index === fromBlockIndex)!;
187
+ const endSlot = paraSlots.find((s) => s.index === toBlockIndex)!;
188
+
189
+ // Same-paragraph fast path: inject both markers into one paragraph.
190
+ // Preserves all existing single-paragraph behaviour byte-for-byte
191
+ // (regression pin [C9]).
192
+ if (startSlot.index === endSlot.index) {
193
+ const newChildren = injectMarkersIntoInlineList(
194
+ (root.children[startSlot.index] as ParagraphNode).children as InlineNode[],
195
+ scopeId,
196
+ normalizedFrom - startSlot.from,
197
+ normalizedTo - startSlot.from,
198
+ "both",
199
+ );
200
+ const children = root.children.map((block, i) =>
201
+ i === startSlot.index
202
+ ? ({ ...block, children: newChildren } as ParagraphNode)
203
+ : block,
204
+ );
164
205
  return {
165
- status: "non-paragraph-target",
206
+ status: "planted",
207
+ document: { ...document, content: { ...root, children } },
166
208
  scopeId,
167
- from: normalizedFrom,
168
- to: normalizedTo,
169
- blockIndex: fromBlockIndex,
170
- blockKind: fromBlockKind ?? "unknown",
209
+ plantedRange: { from: normalizedFrom, to: normalizedTo },
171
210
  };
172
211
  }
173
212
 
174
- // Plantwe've validated the range lands inside a single paragraph.
175
- cursor = 0;
176
- const children = root.children.map((block, blockIndex) => {
177
- if (blockIndex !== fromBlockIndex) {
178
- if (block.type === "paragraph") {
179
- const len = block.children.reduce(
180
- (total, child) => total + inlineLength(child as InlineNode),
181
- 0,
182
- );
183
- cursor += len;
184
- } else {
185
- cursor += 1;
186
- }
187
- if (blockIndex < root.children.length - 1) cursor += 1;
188
- return block;
213
+ // Cross-paragraph path start marker goes in the start-bearing
214
+ // paragraph; end marker goes in the end-bearing paragraph;
215
+ // intermediate blocks pass through unchanged.
216
+ const children = root.children.map((block, i) => {
217
+ if (i === startSlot.index) {
218
+ const newChildren = injectMarkersIntoInlineList(
219
+ (block as ParagraphNode).children as InlineNode[],
220
+ scopeId,
221
+ normalizedFrom - startSlot.from,
222
+ Number.POSITIVE_INFINITY,
223
+ "start-only",
224
+ );
225
+ return { ...block, children: newChildren } as ParagraphNode;
189
226
  }
190
- const paragraphFrom = cursor;
191
- const paragraph = block as ParagraphNode;
192
- const startOffset = normalizedFrom - paragraphFrom;
193
- const endOffset = normalizedTo - paragraphFrom;
194
- const newChildren = injectMarkersIntoInlineList(
195
- paragraph.children as InlineNode[],
196
- scopeId,
197
- startOffset,
198
- endOffset,
199
- );
200
- return { ...paragraph, children: newChildren };
227
+ if (i === endSlot.index) {
228
+ const newChildren = injectMarkersIntoInlineList(
229
+ (block as ParagraphNode).children as InlineNode[],
230
+ scopeId,
231
+ Number.NEGATIVE_INFINITY,
232
+ normalizedTo - endSlot.from,
233
+ "end-only",
234
+ );
235
+ return { ...block, children: newChildren } as ParagraphNode;
236
+ }
237
+ return block;
201
238
  });
202
239
 
203
240
  return {
204
241
  status: "planted",
205
- document: {
206
- ...document,
207
- content: { ...root, children },
208
- },
242
+ document: { ...document, content: { ...root, children } },
209
243
  scopeId,
210
244
  plantedRange: { from: normalizedFrom, to: normalizedTo },
211
245
  };
@@ -273,28 +307,28 @@ function injectMarkersIntoInlineList(
273
307
  scopeId: string,
274
308
  startOffset: number,
275
309
  endOffset: number,
310
+ mode: "both" | "start-only" | "end-only",
276
311
  ): InlineNode[] {
277
- const start: ScopeMarkerStartNode = {
278
- type: "scope_marker_start",
279
- scopeId,
280
- };
281
- const end: ScopeMarkerEndNode = {
282
- type: "scope_marker_end",
283
- scopeId,
284
- };
312
+ const start: ScopeMarkerStartNode = { type: "scope_marker_start", scopeId };
313
+ const end: ScopeMarkerEndNode = { type: "scope_marker_end", scopeId };
314
+
315
+ const needStart = mode === "both" || mode === "start-only";
316
+ const needEnd = mode === "both" || mode === "end-only";
285
317
 
286
318
  const output: InlineNode[] = [];
287
319
  let cursor = 0;
288
- let startEmitted = false;
289
- let endEmitted = false;
320
+ let startEmitted = !needStart;
321
+ let endEmitted = !needEnd;
290
322
 
291
323
  for (const node of inlines) {
292
324
  const length = inlineLength(node);
293
325
  const nodeStart = cursor;
294
326
  const nodeEnd = cursor + length;
295
327
 
296
- const startInside = !startEmitted && startOffset >= nodeStart && startOffset <= nodeEnd;
297
- const endInside = !endEmitted && endOffset >= nodeStart && endOffset <= nodeEnd;
328
+ const startInside =
329
+ needStart && !startEmitted && startOffset >= nodeStart && startOffset <= nodeEnd;
330
+ const endInside =
331
+ needEnd && !endEmitted && endOffset >= nodeStart && endOffset <= nodeEnd;
298
332
 
299
333
  if (!startInside && !endInside) {
300
334
  output.push(node);
@@ -302,10 +336,7 @@ function injectMarkersIntoInlineList(
302
336
  continue;
303
337
  }
304
338
 
305
- // Currently only text nodes support splitting for an internal cut.
306
339
  if (node.type !== "text") {
307
- // For non-text nodes, markers land at the node boundary closest to the
308
- // target offset — avoids mid-atom splits which would corrupt the node.
309
340
  if (startInside && !startEmitted && startOffset <= nodeStart) {
310
341
  output.push(start);
311
342
  startEmitted = true;
@@ -330,11 +361,7 @@ function injectMarkersIntoInlineList(
330
361
  const text = node.text;
331
362
  const chars = Array.from(text);
332
363
  const marks = node.marks;
333
- const pieces: {
334
- cut: number;
335
- emit: "start" | "end";
336
- }[] = [];
337
-
364
+ const pieces: { cut: number; emit: "start" | "end" }[] = [];
338
365
  if (startInside) pieces.push({ cut: startOffset - nodeStart, emit: "start" });
339
366
  if (endInside) pieces.push({ cut: endOffset - nodeStart, emit: "end" });
340
367
  pieces.sort((a, b) => a.cut - b.cut || (a.emit === "start" ? -1 : 1));
@@ -370,12 +397,11 @@ function injectMarkersIntoInlineList(
370
397
  cursor = nodeEnd;
371
398
  }
372
399
 
373
- // Append markers that were at the very end of the paragraph.
374
- if (!startEmitted) {
400
+ if (needStart && !startEmitted) {
375
401
  output.push(start);
376
402
  startEmitted = true;
377
403
  }
378
- if (!endEmitted) {
404
+ if (needEnd && !endEmitted) {
379
405
  output.push(end);
380
406
  endEmitted = true;
381
407
  }
@@ -110,6 +110,22 @@ export interface EffectiveFieldDisplay {
110
110
  * visual vocabulary (CSS class, inline style, decoration).
111
111
  */
112
112
  export interface RevisionDisplayFlags {
113
+ /**
114
+ * The attached revision's id. Enables render consumers to emit
115
+ * `data-revision-id` + route sidebar scroll-to-revision off the
116
+ * authoritative L03 flags rather than re-deriving from the review-store
117
+ * side channel. Present whenever `input.revision !== undefined` drove
118
+ * flag emission.
119
+ */
120
+ readonly revisionId: string;
121
+ /**
122
+ * The revision's kind. Mirrors `RevisionRecord.kind` from the canonical
123
+ * document (`src/model/canonical-document.ts`). Render consumers use
124
+ * this to branch on insertion/deletion/move/property-change variants
125
+ * (e.g. widget-bracket placement in suggestions mode) without reading
126
+ * the review store.
127
+ */
128
+ readonly kind: "insertion" | "deletion" | "formatting" | "move" | "property-change";
113
129
  readonly markupMode: "clean" | "simple" | "all";
114
130
  /** Hide the run entirely (e.g. open deletion in "clean" mode). */
115
131
  readonly hidden?: boolean;
@@ -44,16 +44,16 @@ export function applyRevisionDisplay(
44
44
  switch (markupMode) {
45
45
  case "clean": {
46
46
  if (revision.kind === "deletion" && revision.status === "open") {
47
- return buildFlags(markupMode, authorColor, { hidden: true });
47
+ return buildFlags(revision, markupMode, authorColor, { hidden: true });
48
48
  }
49
- return buildFlags(markupMode, authorColor);
49
+ return buildFlags(revision, markupMode, authorColor);
50
50
  }
51
51
 
52
52
  case "simple": {
53
53
  // Content visible, de-emphasized. No strikethrough / underline
54
54
  // markers — the consumer renders a muted style (opacity /
55
55
  // secondary color) uniformly.
56
- return buildFlags(markupMode, authorColor, { deemphasize: true });
56
+ return buildFlags(revision, markupMode, authorColor, { deemphasize: true });
57
57
  }
58
58
 
59
59
  case "all": {
@@ -65,18 +65,18 @@ export function applyRevisionDisplay(
65
65
  // add the revision strikethrough flag — avoids double-struck
66
66
  // glyphs in render.
67
67
  if (run.strikethrough === true) {
68
- return buildFlags(markupMode, authorColor);
68
+ return buildFlags(revision, markupMode, authorColor);
69
69
  }
70
- return buildFlags(markupMode, authorColor, { strikethrough: true });
70
+ return buildFlags(revision, markupMode, authorColor, { strikethrough: true });
71
71
  }
72
72
  case "insertion": {
73
73
  // Mirror short-circuit for insertions. If the run already carries
74
74
  // a single-underline via direct formatting, skip the insertion
75
75
  // underline so the render doesn't over-paint.
76
76
  if (run.underline === "single") {
77
- return buildFlags(markupMode, authorColor);
77
+ return buildFlags(revision, markupMode, authorColor);
78
78
  }
79
- return buildFlags(markupMode, authorColor, { insertionUnderline: true });
79
+ return buildFlags(revision, markupMode, authorColor, { insertionUnderline: true });
80
80
  }
81
81
  case "formatting":
82
82
  case "property-change":
@@ -84,20 +84,26 @@ export function applyRevisionDisplay(
84
84
  // Paragraph-level markers (change-bar, move-from/to arrows) are
85
85
  // owned by the paragraph projection. For runs that carry these
86
86
  // revision kinds, fall through to the author-color-only posture.
87
- return buildFlags(markupMode, authorColor);
87
+ return buildFlags(revision, markupMode, authorColor);
88
88
  }
89
89
  }
90
- return buildFlags(markupMode, authorColor);
90
+ return buildFlags(revision, markupMode, authorColor);
91
91
  }
92
92
  }
93
93
  }
94
94
 
95
95
  function buildFlags(
96
+ revision: RevisionRecord,
96
97
  markupMode: RevisionMarkupMode,
97
98
  authorColor: string | undefined,
98
- extras: Omit<RevisionDisplayFlags, "markupMode" | "authorColor"> = {},
99
+ extras: Omit<
100
+ RevisionDisplayFlags,
101
+ "revisionId" | "kind" | "markupMode" | "authorColor"
102
+ > = {},
99
103
  ): RevisionDisplayFlags {
100
104
  return {
105
+ revisionId: revision.changeId,
106
+ kind: revision.kind,
101
107
  markupMode,
102
108
  ...(authorColor !== undefined ? { authorColor } : {}),
103
109
  ...extras,
@@ -18,7 +18,11 @@ import type {
18
18
  WorkflowOverlay,
19
19
  } from "./_scope-dependencies.ts";
20
20
 
21
- import { buildParagraphIndexMap, compileScope } from "./compile-scope.ts";
21
+ import {
22
+ buildParagraphIndexMap,
23
+ buildSectionIndexByBlockIndex,
24
+ compileScope,
25
+ } from "./compile-scope.ts";
22
26
  import type { EnumeratedScope } from "./enumerate-scopes.ts";
23
27
  import { enumerateScopes } from "./enumerate-scopes.ts";
24
28
  import { composeEvidence } from "./evidence.ts";
@@ -157,6 +161,10 @@ export function compileScopeBundleById(
157
161
  ...(fullDoc ? { document: fullDoc } : {}),
158
162
  ...(inputs.overlay !== undefined ? { overlay: inputs.overlay } : {}),
159
163
  paragraphIndexByBlockIndex: buildParagraphIndexMap(inputs.document),
164
+ // Thread the section-index map up-front so the `kind: "scope"` compile
165
+ // arm does not re-walk the document. Shared between dispatch + the
166
+ // `paragraph`/`heading`/`list-item` + `scope` kinds that read it.
167
+ sectionIndexByBlockIndex: buildSectionIndexByBlockIndex(inputs.document),
160
168
  });
161
169
  if (!compiled) return null;
162
170
  return compileScopeBundle(compiled, { ...inputs, scopes });
@@ -61,6 +61,7 @@ import { compileHeadingScope } from "./scope-kinds/heading.ts";
61
61
  import { compileListItemScope } from "./scope-kinds/list-item.ts";
62
62
  import { compileParagraphScope } from "./scope-kinds/paragraph.ts";
63
63
  import { compileRevisionScope } from "./scope-kinds/revision.ts";
64
+ import { compileScopeKind } from "./scope-kinds/scope.ts";
64
65
  import { compileTableScope } from "./scope-kinds/table.ts";
65
66
  import { compileTableCellScope } from "./scope-kinds/table-cell.ts";
66
67
  import { compileTableRowScope } from "./scope-kinds/table-row.ts";
@@ -256,6 +257,21 @@ export function compileScope(
256
257
  return compileCommentThreadScope(entry);
257
258
  case "revision":
258
259
  return compileRevisionScope(entry);
260
+ case "scope": {
261
+ if (!options.document) return null;
262
+ let scopeSectionMap = options.sectionIndexByBlockIndex;
263
+ if (!scopeSectionMap) {
264
+ scopeSectionMap = buildSectionIndexByBlockIndex(options.document);
265
+ }
266
+ const scopeSectionIndex = scopeSectionMap.get(entry.startBlockIndex);
267
+ return compileScopeKind(entry, {
268
+ document: options.document,
269
+ ...(workflow ? { workflow } : {}),
270
+ ...(typeof scopeSectionIndex === "number"
271
+ ? { sectionIndex: scopeSectionIndex }
272
+ : {}),
273
+ });
274
+ }
259
275
  default:
260
276
  return null;
261
277
  }
@@ -121,11 +121,29 @@ export interface RevisionEnumeratedScope {
121
121
  readonly classifications: readonly string[];
122
122
  }
123
123
 
124
+ /**
125
+ * Marker-backed scope whose start + end markers live in different
126
+ * top-level paragraphs. The reserved `"scope"` kind slot in the 13-kind
127
+ * taxonomy (`SemanticScopeKind`) exists to carry these cross-paragraph
128
+ * pairs without forcing them through the paragraph arm — the
129
+ * start-bearing paragraph continues to enumerate as `kind: "paragraph"`
130
+ * + `provenance: "derived"`, and this entry represents the pair as a
131
+ * whole.
132
+ */
133
+ export interface ScopeEnumeratedScope {
134
+ readonly kind: "scope";
135
+ readonly handle: ScopeHandle;
136
+ readonly startBlockIndex: number;
137
+ readonly endBlockIndex: number;
138
+ readonly classifications: readonly string[];
139
+ }
140
+
124
141
  /**
125
142
  * Discriminated by `kind`. Paragraph-bearing entries carry `paragraph`;
126
143
  * table-bearing entries carry the matching canonical node; field entries
127
144
  * carry the inline `FieldNode` + containing paragraph; review-store
128
- * entries carry the thread / revision record directly.
145
+ * entries carry the thread / revision record directly; multi-paragraph
146
+ * marker pairs carry the pair of block indices.
129
147
  */
130
148
  export type EnumeratedScope =
131
149
  | ParagraphLikeEnumeratedScope
@@ -134,7 +152,8 @@ export type EnumeratedScope =
134
152
  | TableCellEnumeratedScope
135
153
  | FieldEnumeratedScope
136
154
  | CommentThreadEnumeratedScope
137
- | RevisionEnumeratedScope;
155
+ | RevisionEnumeratedScope
156
+ | ScopeEnumeratedScope;
138
157
 
139
158
  export interface EnumerateScopesInputs {
140
159
  readonly overlay?: WorkflowOverlay | null;
@@ -424,6 +443,57 @@ function enumerateRevisions(
424
443
  });
425
444
  }
426
445
 
446
+ /**
447
+ * Pre-pass: for each paired marker across multiple paragraphs, return
448
+ * { scopeId, startBlockIndex, endBlockIndex }. Same-paragraph pairs are
449
+ * NOT returned here — they continue to enumerate through the paragraph
450
+ * arm as `kind: "paragraph"` + `provenance: "marker-backed"`. An
451
+ * unmatched marker (only start or only end in the doc) is skipped here;
452
+ * detachment reporting lives in `resolveScope`, not in enumeration.
453
+ */
454
+ function locateMultiParagraphMarkerPairs(
455
+ root: DocumentRootNode,
456
+ ): Array<{ scopeId: string; startBlockIndex: number; endBlockIndex: number }> {
457
+ type Open = { scopeId: string; blockIndex: number };
458
+ const open = new Map<string, Open>();
459
+ const pairs: Array<{
460
+ scopeId: string;
461
+ startBlockIndex: number;
462
+ endBlockIndex: number;
463
+ }> = [];
464
+ for (let i = 0; i < root.children.length; i += 1) {
465
+ const block = root.children[i];
466
+ if (!block || block.type !== "paragraph") continue;
467
+ for (const child of block.children) {
468
+ if (child.type === "scope_marker_start") {
469
+ open.set(child.scopeId, { scopeId: child.scopeId, blockIndex: i });
470
+ } else if (child.type === "scope_marker_end") {
471
+ const opener = open.get(child.scopeId);
472
+ if (!opener) continue;
473
+ if (opener.blockIndex !== i) {
474
+ pairs.push({
475
+ scopeId: child.scopeId,
476
+ startBlockIndex: opener.blockIndex,
477
+ endBlockIndex: i,
478
+ });
479
+ }
480
+ open.delete(child.scopeId);
481
+ }
482
+ }
483
+ }
484
+ // Pairs are pushed in close-order by the walk above — for nested or
485
+ // partially-overlapping pairs the inner/earlier-closing pair appears
486
+ // first. Sort by startBlockIndex (break ties on scopeId) so downstream
487
+ // consumers see entries in document order, and S3 determinism is
488
+ // explicit in the sort rather than implicit in walk timing.
489
+ pairs.sort(
490
+ (a, b) =>
491
+ a.startBlockIndex - b.startBlockIndex ||
492
+ a.scopeId.localeCompare(b.scopeId),
493
+ );
494
+ return pairs;
495
+ }
496
+
427
497
  export function enumerateScopes(
428
498
  document: Pick<CanonicalDocument, "content" | "docId" | "review"> | CanonicalDocumentEnvelope,
429
499
  inputs: EnumerateScopesInputs = {},
@@ -436,6 +506,10 @@ export function enumerateScopes(
436
506
  const documentId = (envelope.docId as unknown as string) ?? "";
437
507
  const classificationIndex = buildClassificationIndex(inputs.overlay);
438
508
  const knownOverlayScopeIds = new Set(classificationIndex.keys());
509
+ const multiParagraphPairs = locateMultiParagraphMarkerPairs(root);
510
+ const multiParagraphScopeIds = new Set(
511
+ multiParagraphPairs.map((p) => p.scopeId),
512
+ );
439
513
 
440
514
  const results: EnumeratedScope[] = [];
441
515
  for (let index = 0; index < root.children.length; index += 1) {
@@ -443,10 +517,19 @@ export function enumerateScopes(
443
517
  if (!block) continue;
444
518
 
445
519
  if (block.type === "paragraph") {
446
- const markerScopeId = paragraphFirstMarkerStart(
520
+ const rawMarkerScopeId = paragraphFirstMarkerStart(
447
521
  block,
448
522
  knownOverlayScopeIds,
449
523
  );
524
+ // Multi-paragraph pairs are emitted below as kind: "scope" and
525
+ // must NOT also promote their start-bearing paragraph to
526
+ // marker-backed — the paragraph stays derived and the separate
527
+ // `scope` entry represents the pair as a whole.
528
+ const markerScopeId =
529
+ rawMarkerScopeId !== null &&
530
+ !multiParagraphScopeIds.has(rawMarkerScopeId)
531
+ ? rawMarkerScopeId
532
+ : null;
450
533
  const kind = detectParagraphKind(block);
451
534
  const semanticPath = buildParagraphSemanticPath(kind, index, block);
452
535
  const scopeId =
@@ -587,6 +670,36 @@ export function enumerateScopes(
587
670
  }
588
671
  }
589
672
 
673
+ // Cross-paragraph marker pairs — emit one `kind: "scope"` entry per
674
+ // pair, ordered by start-block index (preserving document order and
675
+ // S3 determinism across compiles).
676
+ for (const pair of multiParagraphPairs) {
677
+ const semanticPath = ["body", "scope", pair.scopeId];
678
+ const hint = stableRefHintForScopeId(pair.scopeId, inputs.overlay);
679
+ const stableRef: ScopeHandle["stableRef"] =
680
+ hint === "semantic-path"
681
+ ? { kind: "semantic-path", value: semanticPath.join("/") }
682
+ : { kind: "scope-id", value: pair.scopeId };
683
+ const handle: ScopeHandle = {
684
+ scopeId: pair.scopeId,
685
+ documentId,
686
+ storyTarget: MAIN_STORY,
687
+ semanticPath,
688
+ stableRef,
689
+ provenance: "marker-backed",
690
+ rangePrecision: "marker-backed",
691
+ };
692
+ const classifications =
693
+ classificationIndex.get(pair.scopeId) ?? Object.freeze<string[]>([]);
694
+ results.push({
695
+ kind: "scope",
696
+ handle,
697
+ startBlockIndex: pair.startBlockIndex,
698
+ endBlockIndex: pair.endBlockIndex,
699
+ classifications,
700
+ } satisfies ScopeEnumeratedScope);
701
+ }
702
+
590
703
  // Review-store scopes — threads + revisions — enumerate after document
591
704
  // walk so their block ordering in `results` stays stable (all block scopes
592
705
  // first, then review).
@@ -47,6 +47,22 @@ export function deriveReplaceability(
47
47
  reason: "marker-backed-preserves-anchor",
48
48
  };
49
49
  }
50
+ // Multi-paragraph marker-backed scopes — the `scope` kind slot. Replace
51
+ // semantics across multiple blocks are not yet compiler-backed; callers
52
+ // may read but should not full-replace until Task N of the
53
+ // multi-paragraph plan wires block-granular replacement.
54
+ if (kind === "scope") {
55
+ if (provenance === "marker-backed") {
56
+ return {
57
+ level: "preserve-only",
58
+ reason: "multi-paragraph-replace-not-implemented",
59
+ };
60
+ }
61
+ return {
62
+ level: "blocked",
63
+ reason: "scope-kind-requires-markers",
64
+ };
65
+ }
50
66
  switch (kind) {
51
67
  case "paragraph":
52
68
  return { level: "full", reason: "derived-default" };
@@ -199,10 +199,20 @@ export function applyScopeReplacement(
199
199
  resolvedScope.kind === "paragraph" ||
200
200
  resolvedScope.kind === "heading" ||
201
201
  resolvedScope.kind === "list-item";
202
+ // Multi-paragraph `scope` kind (Task 7 of the 2026-04-24 plan): the
203
+ // compiler has no replacement lowering for cross-paragraph marker
204
+ // spans yet. Replaceability already declares `preserve-only` with
205
+ // `reason: "multi-paragraph-replace-not-implemented"`; apply mirrors
206
+ // that reason into the refusal taxonomy suffix so consumers reading
207
+ // `blockers[0]` / `reason` see the actionable sub-reason directly
208
+ // (rather than the bare `compile-refused:scope`). Grammar matches
209
+ // §10 `compile-refused:<kind>:<sub-reason>` (74a45eaf, 2026-04-23).
202
210
  const blocker =
203
- paragraphLike && proposed.operation !== "replace"
204
- ? `compile-refused:${resolvedScope.kind}:operation-not-implemented:${proposed.operation}`
205
- : `compile-refused:${resolvedScope.kind}`;
211
+ resolvedScope.kind === "scope"
212
+ ? "compile-refused:scope:multi-paragraph-replace-not-implemented"
213
+ : paragraphLike && proposed.operation !== "replace"
214
+ ? `compile-refused:${resolvedScope.kind}:operation-not-implemented:${proposed.operation}`
215
+ : `compile-refused:${resolvedScope.kind}`;
206
216
  const refused: ValidationResult = {
207
217
  safe: false,
208
218
  blockedReasons: Object.freeze([blocker]),
@@ -307,6 +307,11 @@ function extractNLHaystack(entry: EnumeratedScope): string {
307
307
  case "revision":
308
308
  return `${entry.revision.kind} ${entry.revision.authorId ?? ""}`
309
309
  .toLowerCase();
310
+ case "scope":
311
+ // Cross-paragraph marker pair — no inline text of its own;
312
+ // semantic-path matching (`body/scope/<id>`) covers it, and the
313
+ // per-paragraph entries inside the pair carry their own haystacks.
314
+ return "";
310
315
  default: {
311
316
  const _never: never = entry;
312
317
  void _never;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Compile `kind: "scope"` entries — multi-paragraph marker-backed scopes.
3
+ *
4
+ * Aggregates the spanning paragraphs' text into `content.text` (joined by
5
+ * `\n`), projects a bounded formatting summary (no single paragraphStyleId
6
+ * is authoritative across multiple paragraphs; we surface none), and stays
7
+ * `partial: true` because layout + geometry projections are not yet
8
+ * compiler-backed for multi-block scopes.
9
+ *
10
+ * Replaceability is `"preserve-only"` for now — see
11
+ * `replaceability.ts::deriveReplaceability`.
12
+ *
13
+ * Determinism (S3): pure projection of (spanning paragraphs' text,
14
+ * classifications, provenance, sectionIndex). No ambient state.
15
+ */
16
+
17
+ import type {
18
+ CanonicalDocument,
19
+ DocumentRootNode,
20
+ ParagraphNode,
21
+ } from "../../../model/canonical-document.ts";
22
+ import type { CanonicalDocumentEnvelope } from "../../../core/state/editor-state.ts";
23
+ import type { ScopeEnumeratedScope } from "../enumerate-scopes.ts";
24
+ import { deriveReplaceability } from "../replaceability.ts";
25
+ import type {
26
+ SemanticScope,
27
+ SemanticScopeWorkflow,
28
+ } from "../semantic-scope-types.ts";
29
+
30
+ import { extractParagraphText, buildExcerpt } from "./_paragraph-text.ts";
31
+
32
+ export interface CompileScopeKindOptions {
33
+ readonly document: CanonicalDocument | CanonicalDocumentEnvelope;
34
+ readonly workflow?: SemanticScopeWorkflow;
35
+ /**
36
+ * 0-based section index of the scope's **first** spanning block
37
+ * (matches paragraph-kind semantics; agents reading layout.sectionIndex
38
+ * for routing get the scope's home section).
39
+ */
40
+ readonly sectionIndex?: number;
41
+ }
42
+
43
+ export function compileScopeKind(
44
+ entry: ScopeEnumeratedScope,
45
+ options: CompileScopeKindOptions,
46
+ ): SemanticScope {
47
+ const envelope = options.document as CanonicalDocumentEnvelope;
48
+ const root: DocumentRootNode =
49
+ "content" in envelope
50
+ ? (envelope.content as DocumentRootNode)
51
+ : (options.document as unknown as DocumentRootNode);
52
+
53
+ const texts: string[] = [];
54
+ for (let i = entry.startBlockIndex; i <= entry.endBlockIndex; i += 1) {
55
+ const block = root.children[i];
56
+ if (!block || block.type !== "paragraph") continue;
57
+ texts.push(extractParagraphText(block as ParagraphNode));
58
+ }
59
+ const text = texts.join("\n");
60
+
61
+ return {
62
+ handle: entry.handle,
63
+ kind: "scope",
64
+ classifications: entry.classifications,
65
+ content: {
66
+ text,
67
+ excerpt: buildExcerpt(text),
68
+ },
69
+ formatting: {},
70
+ layout:
71
+ typeof options.sectionIndex === "number"
72
+ ? { sectionIndex: options.sectionIndex }
73
+ : {},
74
+ geometry: {},
75
+ workflow: options.workflow ?? { scopeIds: [], effectiveMode: "edit" },
76
+ replaceability: deriveReplaceability("scope", entry.handle.provenance),
77
+ audit: {
78
+ source: "runtime",
79
+ derivedFrom:
80
+ entry.classifications.length > 0
81
+ ? ["canonical", "workflow-overlay"]
82
+ : ["canonical"],
83
+ confidence: "medium",
84
+ },
85
+ partial: true,
86
+ };
87
+ }
@@ -165,6 +165,17 @@ export function resolveScopeRange(
165
165
  return anchorToRange(entry.thread.anchor);
166
166
  case "revision":
167
167
  return anchorToRange(entry.revision.anchor);
168
+ case "scope": {
169
+ // Cross-paragraph marker pair. The marker-range lookup at the top
170
+ // of this function (stableRef.kind === "scope-id") normally wins
171
+ // first. This branch handles the case where the handle's stableRef
172
+ // was overridden to `semantic-path` via the `stableRefHint` seam —
173
+ // we fall back to spanning the start-block low to end-block high.
174
+ const startRange = positionMap.blocks.get(entry.startBlockIndex);
175
+ const endRange = positionMap.blocks.get(entry.endBlockIndex);
176
+ if (!startRange || !endRange) return null;
177
+ return { from: startRange.from, to: endRange.to };
178
+ }
168
179
  default: {
169
180
  const never: never = entry;
170
181
  void never;
@@ -824,12 +824,9 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
824
824
  plantStatus: {
825
825
  planted: false,
826
826
  reason: plantResult.status,
827
- ...(plantResult.status === "cross-paragraph-range"
828
- ? {
829
- fromBlockIndex: plantResult.fromBlockIndex,
830
- toBlockIndex: plantResult.toBlockIndex,
831
- }
832
- : {}),
827
+ // Cross-paragraph ranges now plant successfully (2026-04-24
828
+ // multi-paragraph-scopes slice) — that refusal variant is
829
+ // retired. Remaining failure reasons carry diagnostic fields:
833
830
  ...(plantResult.status === "non-paragraph-target"
834
831
  ? {
835
832
  blockIndex: plantResult.blockIndex,
@@ -277,15 +277,11 @@ export type CreateScopeFromAnchorResult =
277
277
  | "from-negative"
278
278
  | "to-less-than-from"
279
279
  | "range-exceeds-story-length"
280
- | "cross-paragraph-range"
281
280
  | "non-paragraph-target"
282
281
  | "empty-document";
283
282
  readonly from: number;
284
283
  readonly to: number;
285
284
  readonly storyLength: number;
286
- /** Cross-paragraph only — the two block indices the range straddled. */
287
- readonly fromBlockIndex?: number;
288
- readonly toBlockIndex?: number;
289
285
  /** Non-paragraph target only — the offending block's index and kind. */
290
286
  readonly blockIndex?: number;
291
287
  readonly blockKind?: string;
@@ -301,7 +297,6 @@ export type CreateScopeFromAnchorResult =
301
297
  * don't want to pattern-match on `reason`. Examples:
302
298
  * "clamp-from-to-zero", "swap-from-and-to",
303
299
  * "clamp-to-to-storyLength-or-pick-a-different-range",
304
- * "narrow-to-single-paragraph",
305
300
  * "pick-a-paragraph-target".
306
301
  */
307
302
  readonly nextStep: string;
@@ -432,31 +427,15 @@ export function createScopeFromAnchor(
432
427
  });
433
428
 
434
429
  // Pre-2026-04-24 the coordinator silently returned a minted scopeId
435
- // even when insertScopeMarkers refused to plant (cross-paragraph
436
- // range, non-paragraph target, out-of-bounds after the story-length
437
- // check passed). Now the coordinator surfaces `plantStatus.planted:
438
- // false`; translate each reason into the same `range-invalid` shape
430
+ // even when insertScopeMarkers refused to plant (non-paragraph
431
+ // target, out-of-bounds after the story-length check passed).
432
+ // Cross-paragraph ranges now plant successfully (2026-04-24
433
+ // multi-paragraph-scopes slice), so that refusal variant is retired.
434
+ // Remaining reasons translate into the same `range-invalid` shape
439
435
  // used by the bounds checks above so the caller gets one uniform
440
436
  // discriminator to branch on.
441
437
  if (result.plantStatus && result.plantStatus.planted === false) {
442
438
  const ps = result.plantStatus;
443
- if (ps.reason === "cross-paragraph-range") {
444
- return {
445
- status: "range-invalid",
446
- reason: "cross-paragraph-range",
447
- from,
448
- to,
449
- storyLength,
450
- fromBlockIndex: ps.fromBlockIndex ?? -1,
451
- toBlockIndex: ps.toBlockIndex ?? -1,
452
- message:
453
- `createScopeFromAnchor refused: range [${from}, ${to}] straddles ` +
454
- `paragraphs ${ps.fromBlockIndex} and ${ps.toBlockIndex}. Marker-backed ` +
455
- `scopes only plant inside a single paragraph today. Narrow the range to ` +
456
- `land inside one paragraph, or create two separate scopes.`,
457
- nextStep: "narrow-to-single-paragraph",
458
- };
459
- }
460
439
  if (ps.reason === "non-paragraph-target") {
461
440
  return {
462
441
  status: "range-invalid",
@@ -155,6 +155,16 @@ export function getRevisionRangeState(
155
155
  * by `test/runtime/formatting/production-boundary.test.ts`.
156
156
  */
157
157
  export interface RevisionDisplayFlags {
158
+ /**
159
+ * Identity of the attached revision. Mirrors
160
+ * `SurfaceInlineSegment.revisionDisplay.revisionId`.
161
+ */
162
+ revisionId: string;
163
+ /**
164
+ * Mirrors `RevisionRecord.kind`. Consumers branch on insertion /
165
+ * deletion variants without reading the review store.
166
+ */
167
+ kind: "insertion" | "deletion" | "formatting" | "move" | "property-change";
158
168
  markupMode: "clean" | "simple" | "all";
159
169
  hidden?: boolean;
160
170
  strikethrough?: boolean;
@@ -1075,7 +1075,16 @@
1075
1075
  }
1076
1076
 
1077
1077
  .wre-page-band:hover {
1078
- background-color: color-mix(in srgb, var(--color-bg-muted) 80%, var(--color-surface));
1078
+ /*
1079
+ * N3 audit (2026-04-24): the prior `color-mix(bg-muted 80%, surface 20%)`
1080
+ * blended two near-adjacent tokens — in dark mode (#17211C vs #182420)
1081
+ * the result moved ≤2 RGB units from the base, leaving no visible
1082
+ * hover affordance. `--color-bg-hover` is the token the design system
1083
+ * already dedicates to this signal (light #EAF6EF vs muted #F7FAF8;
1084
+ * dark #21342A vs muted #17211C) and resolves the dark-mode legibility
1085
+ * gap without regressing light-mode subtlety.
1086
+ */
1087
+ background-color: var(--color-bg-hover);
1079
1088
  }
1080
1089
 
1081
1090
  .wre-page-band[data-active="true"] {