@beyondwork/docx-react-component 1.0.79 → 1.0.81

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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +28 -21
  3. package/src/api/v3/ai/resolve.ts +13 -7
  4. package/src/api/v3/runtime/workflow.ts +0 -9
  5. package/src/api/v3/ui/chrome-composition.ts +10 -2
  6. package/src/core/commands/add-scope.ts +110 -84
  7. package/src/runtime/formatting/formatting-types.ts +16 -0
  8. package/src/runtime/formatting/revision-display.ts +16 -10
  9. package/src/runtime/scopes/compile-scope-bundle.ts +9 -1
  10. package/src/runtime/scopes/compile-scope.ts +16 -0
  11. package/src/runtime/scopes/enumerate-scopes.ts +116 -3
  12. package/src/runtime/scopes/replaceability.ts +16 -0
  13. package/src/runtime/scopes/replacement/apply.ts +13 -3
  14. package/src/runtime/scopes/resolve-reference.ts +5 -0
  15. package/src/runtime/scopes/scope-kinds/scope.ts +87 -0
  16. package/src/runtime/scopes/scope-range.ts +11 -0
  17. package/src/runtime/workflow/coordinator.ts +3 -6
  18. package/src/runtime/workflow/scope-writer.ts +5 -26
  19. package/src/ui/WordReviewEditor.tsx +62 -3
  20. package/src/ui/editor-shell-view.tsx +1 -0
  21. package/src/ui/headless/revision-decoration-model.ts +10 -0
  22. package/src/ui-tailwind/chrome/editor-action-registry.ts +153 -0
  23. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +2 -0
  24. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +5 -1
  25. package/src/ui-tailwind/chrome/use-context-menu-controller.ts +15 -10
  26. package/src/ui-tailwind/review-workspace/types.ts +1 -0
  27. package/src/ui-tailwind/review-workspace/use-workspace-composition.ts +46 -6
  28. package/src/ui-tailwind/theme/editor-theme.css +10 -1
  29. package/src/ui-tailwind/tw-review-workspace.tsx +114 -14
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.81",
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;
@@ -5602,17 +5611,15 @@ export interface WordReviewEditorProps {
5602
5611
  import("../ui/headless/chrome-registry").SelectionToolRegistryEntry
5603
5612
  >;
5604
5613
  /**
5605
- * Phase C.γ host-callback bag. When supplied, the workspace mounts
5606
- * `TwWorkspaceChromeHost` which in turn mounts the right-click
5607
- * context menu (`TwContextMenuPortal`) and the Ctrl/Cmd+K command
5608
- * palette (`TwCommandPaletteMount`), dispatching through the shared
5609
- * `editorActionRegistry`. Actions without a wired callback are
5610
- * hidden from every surface progressive disclosure per
5611
- * `designsystem.md §2.1 principle 4`.
5612
- *
5613
- * Omit to preserve pre-Phase-C behavior (no right-click menu, no
5614
- * palette). Required for the default editor to expose the
5615
- * Phase C.γ surfaces shipped in `5fce913a`.
5614
+ * Optional host-callback extension bag for workspace command chrome.
5615
+ * The default `<WordReviewEditor />` path now mounts
5616
+ * `TwWorkspaceChromeHost` with product-backed commands for formatting,
5617
+ * paragraph/list actions, comments, and table insertion/structure.
5618
+ * Supplying this bag overrides or extends those defaults for host-owned
5619
+ * actions such as custom table properties, hyperlink handling, or
5620
+ * object metadata. Actions without a wired callback are hidden from
5621
+ * every surface — progressive disclosure per `designsystem.md §2.1
5622
+ * principle 4`.
5616
5623
  */
5617
5624
  editorActionHost?: import("../ui-tailwind/chrome/editor-action-registry").EditorActionHostCallbacks;
5618
5625
  /**
@@ -5628,8 +5635,8 @@ export interface WordReviewEditorProps {
5628
5635
  /**
5629
5636
  * Suppress the global Ctrl/Cmd+K palette listener — e.g. when a
5630
5637
  * higher-priority modal captures keyboard focus. Defaults to
5631
- * `false` (palette listener is active when `editorActionHost` is
5632
- * supplied).
5638
+ * `false` (the default product command palette listener is active
5639
+ * unless this prop suppresses it).
5633
5640
  */
5634
5641
  commandPaletteDisabled?: boolean;
5635
5642
  /**
@@ -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
  : {}),
@@ -131,6 +131,7 @@ export interface ChromeCompositionInput {
131
131
  | "simple"
132
132
  | "all";
133
133
  readonly activeRailTab?: EditorRailTab | null;
134
+ readonly railOpen?: boolean;
134
135
  readonly pinnedRailTabs?: ReadonlySet<EditorRailTab>;
135
136
  readonly density?: ChromeDensity;
136
137
  readonly containerWidth?: number;
@@ -255,13 +256,18 @@ function resolveVisibleRailTabs(
255
256
  railOpen: boolean,
256
257
  diagnosticsSignal: DiagnosticsSignal,
257
258
  pinned: ReadonlySet<EditorRailTab>,
259
+ mode: EditorChromeMode,
258
260
  ): ReadonlySet<EditorRailTab> {
259
261
  const visible = new Set<EditorRailTab>();
260
262
  if (railOpen) {
261
263
  visible.add("comments");
262
264
  visible.add("changes");
263
265
  visible.add("workflow");
264
- if (diagnosticsSignal.severity !== "none" || diagnosticsSignal.count > 0) {
266
+ if (
267
+ diagnosticsSignal.severity !== "none" ||
268
+ diagnosticsSignal.count > 0 ||
269
+ mode === "more"
270
+ ) {
265
271
  visible.add("health");
266
272
  }
267
273
  }
@@ -294,11 +300,13 @@ export function resolveChromeComposition(
294
300
  const density: ChromeDensity = input.density ?? "standard";
295
301
  const pinnedRailTabs: ReadonlySet<EditorRailTab> =
296
302
  input.pinnedRailTabs ?? new Set<EditorRailTab>();
297
- const railOpen = options.showReviewRail && visibility.reviewRail;
303
+ const railOpen =
304
+ input.railOpen ?? (options.showReviewRail && visibility.reviewRail);
298
305
  const visibleTabs = resolveVisibleRailTabs(
299
306
  railOpen,
300
307
  diagnosticsSignal,
301
308
  pinnedRailTabs,
309
+ mode,
302
310
  );
303
311
 
304
312
  // Default active tab: honor caller; otherwise land on the mode-appropriate tab.
@@ -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
  }